feat(platform): close AI expense value loop

Add tenant-safe value, telemetry, connector, commercial, and production-readiness foundations.
This commit is contained in:
caoxiaozhu
2026-07-17 14:14:08 +08:00
parent 242d68c36f
commit 787bc3a481
507 changed files with 82072 additions and 6344 deletions

View File

@@ -24,7 +24,7 @@ class CurrentUserContext:
name: str
role_codes: list[str]
is_admin: bool
tenant_id: str = "default"
tenant_id: str
department_name: str = ""
department_id: str = ""
cost_center: str = ""

View File

@@ -0,0 +1,308 @@
from __future__ import annotations
from typing import Annotated, NoReturn
from fastapi import APIRouter, Depends, Header, HTTPException, status
from sqlalchemy.orm import Session
from app.api.deps import CurrentUserContext, get_db, require_rule_reviewer_user
from app.schemas.agent_asset_release import (
AgentAssetReleaseMonitorRead,
AgentAssetReleaseMonitorTriggerWrite,
AgentAssetReleaseReviewLabelRead,
AgentAssetReleaseReviewLabelWrite,
AgentAssetReleaseReviewQueueRead,
AgentAssetReleaseRollbackWrite,
AgentAssetReleaseServingPlanRead,
AgentAssetReleaseStartWrite,
AgentAssetReleaseStateRead,
)
from app.services.agent_asset_access import stable_user_principal
from app.services.agent_asset_release_guard import (
AgentAssetReleaseGuardService,
ReleaseGuardPolicy,
)
from app.services.agent_asset_release_monitor import AgentAssetReleaseMonitor
from app.services.agent_asset_release_monitor_auth import (
ReleaseMonitorAuthenticationError,
ReleaseMonitorConfigurationError,
require_release_monitor_signature,
)
from app.services.agent_asset_release_review import AgentAssetReleaseReviewService
router = APIRouter(prefix="/agent-assets")
DbSession = Annotated[Session, Depends(get_db)]
RuleReviewerUser = Annotated[CurrentUserContext, Depends(require_rule_reviewer_user)]
RequestIdHeader = Annotated[
str | None,
Header(description="外部请求 ID用于串联审计日志和上游调用链。"),
]
ReleaseMonitorTimestampHeader = Annotated[
str | None,
Header(alias="X-Release-Monitor-Timestamp"),
]
ReleaseMonitorSignatureHeader = Annotated[
str | None,
Header(alias="X-Release-Monitor-Signature"),
]
def _handle_error(exc: Exception) -> NoReturn:
if isinstance(exc, LookupError):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
if isinstance(exc, (PermissionError, ValueError)):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
raise exc
def _actor(user: CurrentUserContext) -> str:
return stable_user_principal(user)
def _state_payload(value: dict) -> AgentAssetReleaseStateRead:
if not value:
return AgentAssetReleaseStateRead()
allowed = set(AgentAssetReleaseStateRead.model_fields)
return AgentAssetReleaseStateRead.model_validate(
{key: item for key, item in value.items() if key in allowed}
)
@router.get(
"/{asset_id}/release",
response_model=AgentAssetReleaseStateRead,
summary="查询 Agent 资产分阶段发布状态",
)
def get_agent_asset_release(
asset_id: str,
current_user: RuleReviewerUser,
db: DbSession,
) -> AgentAssetReleaseStateRead:
try:
return _state_payload(
AgentAssetReleaseGuardService(db).get_state(
asset_id,
tenant_id=current_user.tenant_id,
allow_global_management=current_user.is_admin,
)
)
except Exception as exc:
_handle_error(exc)
@router.get(
"/{asset_id}/release/serving-plan",
response_model=AgentAssetReleaseServingPlanRead,
summary="查询运行时版本路由计划",
)
def get_agent_asset_release_serving_plan(
asset_id: str,
current_user: RuleReviewerUser,
db: DbSession,
) -> AgentAssetReleaseServingPlanRead:
try:
return AgentAssetReleaseServingPlanRead.model_validate(
AgentAssetReleaseGuardService(db).get_serving_plan(
asset_id,
tenant_id=current_user.tenant_id,
allow_global_management=current_user.is_admin,
)
)
except Exception as exc:
_handle_error(exc)
@router.post(
"/{asset_id}/release/shadow",
response_model=AgentAssetReleaseStateRead,
summary="启动受控影子发布",
)
def start_agent_asset_shadow_release(
asset_id: str,
payload: AgentAssetReleaseStartWrite,
current_user: RuleReviewerUser,
db: DbSession,
x_request_id: RequestIdHeader = None,
) -> AgentAssetReleaseStateRead:
try:
policy = ReleaseGuardPolicy(**payload.policy.model_dump())
value = AgentAssetReleaseGuardService(db).start_shadow(
asset_id,
payload.candidate_version,
actor=_actor(current_user),
policy=policy,
tenant_id=current_user.tenant_id,
allow_global_management=current_user.is_admin,
request_id=x_request_id,
)
return _state_payload(value)
except Exception as exc:
_handle_error(exc)
@router.post(
"/{asset_id}/release/evaluations",
response_model=AgentAssetReleaseMonitorRead,
summary="聚合真实发布遥测并在越界时自动回滚",
)
def evaluate_agent_asset_release(
asset_id: str,
payload: AgentAssetReleaseMonitorTriggerWrite,
current_user: RuleReviewerUser,
db: DbSession,
x_release_monitor_timestamp: ReleaseMonitorTimestampHeader = None,
x_release_monitor_signature: ReleaseMonitorSignatureHeader = None,
) -> AgentAssetReleaseMonitorRead:
try:
service = AgentAssetReleaseGuardService(db)
state = service.get_state(
asset_id,
tenant_id=current_user.tenant_id,
allow_global_management=current_user.is_admin,
)
require_release_monitor_signature(
timestamp=x_release_monitor_timestamp,
signature=x_release_monitor_signature,
tenant_id=current_user.tenant_id,
asset_id=asset_id,
release_id=str(state.get("release_id") or ""),
stage=str(state.get("stage") or ""),
payload=payload.model_dump(mode="json", exclude_unset=True),
)
value = AgentAssetReleaseMonitor(db).evaluate_current(
asset_id=asset_id,
actor=_actor(current_user),
tenant_id=current_user.tenant_id,
allow_global_management=current_user.is_admin,
)
return AgentAssetReleaseMonitorRead.model_validate(value)
except ReleaseMonitorConfigurationError as exc:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=str(exc),
) from exc
except ReleaseMonitorAuthenticationError as exc:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=str(exc),
) from exc
except Exception as exc:
_handle_error(exc)
@router.get(
"/{asset_id}/release/review-queue",
response_model=AgentAssetReleaseReviewQueueRead,
summary="查询当前发布的去敏人工复核队列",
)
def list_agent_asset_release_review_queue(
asset_id: str,
current_user: RuleReviewerUser,
db: DbSession,
limit: int = 50,
) -> AgentAssetReleaseReviewQueueRead:
try:
value = AgentAssetReleaseReviewService(db).list_pending(
tenant_id=current_user.tenant_id,
asset_id=asset_id,
limit=limit,
)
return AgentAssetReleaseReviewQueueRead.model_validate(value)
except Exception as exc:
_handle_error(exc)
@router.post(
"/{asset_id}/release/review-queue/{observation_id}/labels",
response_model=AgentAssetReleaseReviewLabelRead,
summary="提交类型化发布复核结论并重新聚合",
)
def label_agent_asset_release_observation(
asset_id: str,
observation_id: str,
payload: AgentAssetReleaseReviewLabelWrite,
current_user: RuleReviewerUser,
db: DbSession,
x_request_id: RequestIdHeader = None,
) -> AgentAssetReleaseReviewLabelRead:
try:
request_id = str(x_request_id or "").strip()
if not request_id:
raise ValueError("X-Request-Id is required for release review labels.")
label = AgentAssetReleaseReviewService(db).record_label(
tenant_id=current_user.tenant_id,
asset_id=asset_id,
observation_id=observation_id,
label=payload.label,
actor_id=_actor(current_user),
request_id=request_id,
)
db.commit()
monitor = AgentAssetReleaseMonitor(db).evaluate_current(
asset_id=asset_id,
actor=_actor(current_user),
tenant_id=current_user.tenant_id,
)
return AgentAssetReleaseReviewLabelRead.model_validate(
{
"label_id": label.id,
"observation_id": label.observation_id,
"label": label.label,
"monitor": monitor,
}
)
except Exception as exc:
db.rollback()
_handle_error(exc)
@router.post(
"/{asset_id}/release/promote",
response_model=AgentAssetReleaseStateRead,
summary="将通过评测的版本晋级到下一阶段",
)
def promote_agent_asset_release(
asset_id: str,
current_user: RuleReviewerUser,
db: DbSession,
x_request_id: RequestIdHeader = None,
) -> AgentAssetReleaseStateRead:
try:
return _state_payload(
AgentAssetReleaseGuardService(db).promote(
asset_id,
actor=_actor(current_user),
tenant_id=current_user.tenant_id,
allow_global_management=current_user.is_admin,
request_id=x_request_id,
)
)
except Exception as exc:
_handle_error(exc)
@router.post(
"/{asset_id}/release/rollback",
response_model=AgentAssetReleaseStateRead,
summary="显式回滚到发布前版本",
)
def rollback_agent_asset_release(
asset_id: str,
payload: AgentAssetReleaseRollbackWrite,
current_user: RuleReviewerUser,
db: DbSession,
x_request_id: RequestIdHeader = None,
) -> AgentAssetReleaseStateRead:
try:
return _state_payload(
AgentAssetReleaseGuardService(db).rollback(
asset_id,
actor=_actor(current_user),
reason=payload.reason,
tenant_id=current_user.tenant_id,
allow_global_management=current_user.is_admin,
request_id=x_request_id,
)
)
except Exception as exc:
_handle_error(exc)

View File

@@ -2,35 +2,52 @@ from __future__ import annotations
from typing import Annotated, NoReturn
from fastapi import APIRouter, Depends, Header, HTTPException, Query, status
from fastapi import APIRouter, BackgroundTasks, Depends, Header, HTTPException, Query, status
from sqlalchemy.orm import Session
from app.api.deps import (
CurrentUserContext,
get_current_user,
get_db,
require_platform_admin_user,
require_rule_editor_user,
require_rule_reviewer_user,
)
from app.db.session import get_session_factory
from app.schemas.agent_asset import (
AgentAssetRead,
AgentAssetRiskRuleDraftUpdate,
AgentAssetRiskRuleEnabledUpdate,
AgentAssetRiskRuleFeedbackCreate,
AgentAssetRiskRuleFeedbackRead,
AgentAssetRiskRuleGenerateRequest,
AgentAssetRiskRuleLatestTestSummary,
AgentAssetRiskRuleLevelUpdate,
AgentAssetRiskRuleRegenerateRequest,
AgentAssetRiskRuleReportRequest,
AgentAssetRiskRuleReturnRequest,
AgentAssetRiskRuleRevisionCreate,
AgentAssetRiskRuleSampleTestRequest,
AgentAssetRiskRuleScenarioTestRequest,
AgentAssetRiskRuleSimulationRead,
AgentAssetRiskRuleSimulationRequest,
AgentAssetRiskRuleTemplateGroupRead,
AgentAssetRiskRuleTestRunRead,
AgentAssetRuleJsonRead,
AgentAssetRuleJsonWrite,
)
from app.services.agent_asset_access import stable_user_principal
from app.services.agent_asset_risk_rule_regeneration import AgentAssetRiskRuleRegenerationService
from app.services.agent_asset_risk_rule_revision import AgentAssetRiskRuleRevisionService
from app.services.agent_assets import AgentAssetService
from app.services.risk_rule_generation_jobs import RiskRuleGenerationJobService
from app.services.risk_rule_template_catalog import list_risk_rule_template_groups
router = APIRouter(prefix="/agent-assets")
DbSession = Annotated[Session, Depends(get_db)]
ActorHeader = Annotated[
str | None,
Header(description="审计操作人。未传时使用当前登录用户名称"),
Header(description="兼容旧客户端;审计主体始终以登录会话中的稳定身份为准"),
]
RequestIdHeader = Annotated[
str | None,
@@ -38,6 +55,7 @@ RequestIdHeader = Annotated[
]
RuleEditorUser = Annotated[CurrentUserContext, Depends(require_rule_editor_user)]
RuleReviewerUser = Annotated[CurrentUserContext, Depends(require_rule_reviewer_user)]
PlatformAdminUser = Annotated[CurrentUserContext, Depends(require_platform_admin_user)]
CurrentUser = Annotated[CurrentUserContext, Depends(get_current_user)]
@@ -50,21 +68,49 @@ def _handle_asset_error(exc: Exception) -> NoReturn:
def _actor_name(current_user: CurrentUserContext, x_actor: str | None) -> str:
return (x_actor or current_user.name or current_user.username or "system").strip() or "system"
del x_actor
return stable_user_principal(current_user)
def _read_asset(db: Session, asset_id: str) -> AgentAssetRead:
asset = AgentAssetService(db).get_asset(asset_id)
def _read_asset(
db: Session,
asset_id: str,
current_user: CurrentUserContext,
) -> AgentAssetRead:
asset = AgentAssetService(db, current_user=current_user).get_asset(asset_id)
if asset is None:
raise LookupError("Asset not found")
return asset
def _complete_risk_rule_generation_task(
asset_id: str,
payload: dict,
actor: str,
request_id: str | None,
tenant_id: str,
) -> None:
db = get_session_factory()()
try:
body = AgentAssetRiskRuleGenerateRequest.model_validate(payload)
RiskRuleGenerationJobService(db).complete_rule_asset_generation(
asset_id,
body,
tenant_id=tenant_id,
actor=actor,
request_id=request_id,
)
finally:
db.close()
@router.get(
"/risk-rules/templates",
response_model=list[AgentAssetRiskRuleTemplateGroupRead],
summary="查询常见费控风险规则模板",
description="返回模板分组、默认自然语言、字段清单和 DSL 样例;模板只用于预填,不绕过通用生成链路。",
description=(
"返回模板分组、默认自然语言、字段清单和 DSL 样例;模板只用于预填,不绕过通用生成链路。"
),
)
def list_risk_rule_templates(_: CurrentUser) -> list[AgentAssetRiskRuleTemplateGroupRead]:
return list_risk_rule_template_groups()
@@ -85,13 +131,13 @@ def update_risk_rule_draft(
x_request_id: RequestIdHeader = None,
) -> AgentAssetRead:
try:
AgentAssetRiskRuleRevisionService(db).update_unpublished_draft(
AgentAssetRiskRuleRevisionService(db, current_user=current_user).update_unpublished_draft(
asset_id,
payload,
actor=_actor_name(current_user, x_actor),
request_id=x_request_id,
)
return _read_asset(db, asset_id)
return _read_asset(db, asset_id, current_user)
except Exception as exc:
_handle_asset_error(exc)
@@ -112,13 +158,13 @@ def create_risk_rule_revision(
x_request_id: RequestIdHeader = None,
) -> AgentAssetRead:
try:
AgentAssetRiskRuleRevisionService(db).create_revision_draft(
AgentAssetRiskRuleRevisionService(db, current_user=current_user).create_revision_draft(
asset_id,
payload,
actor=_actor_name(current_user, x_actor),
request_id=x_request_id,
)
return _read_asset(db, asset_id)
return _read_asset(db, asset_id, current_user)
except Exception as exc:
_handle_asset_error(exc)
@@ -138,14 +184,16 @@ def regenerate_risk_rule(
x_request_id: RequestIdHeader = None,
) -> AgentAssetRead:
try:
AgentAssetRiskRuleRegenerationService(db).regenerate(
AgentAssetRiskRuleRegenerationService(
db, current_user=current_user
).regenerate(
asset_id,
payload,
tenant_id=current_user.tenant_id,
actor=_actor_name(current_user, x_actor),
request_id=x_request_id,
)
return _read_asset(db, asset_id)
return _read_asset(db, asset_id, current_user)
except Exception as exc:
_handle_asset_error(exc)
@@ -166,7 +214,7 @@ def create_risk_rule_feedback(
x_request_id: RequestIdHeader = None,
) -> AgentAssetRiskRuleFeedbackRead:
try:
return AgentAssetService(db).create_risk_rule_feedback(
return AgentAssetService(db, current_user=current_user).create_risk_rule_feedback(
asset_id,
payload,
actor=_actor_name(current_user, x_actor),
@@ -184,14 +232,14 @@ def create_risk_rule_feedback(
)
def list_risk_rule_feedback(
asset_id: str,
_: RuleReviewerUser,
current_user: RuleReviewerUser,
db: DbSession,
version: Annotated[str | None, Query(max_length=30)] = None,
status_value: Annotated[str | None, Query(alias="status", max_length=30)] = None,
limit: Annotated[int, Query(ge=1, le=200)] = 50,
) -> list[AgentAssetRiskRuleFeedbackRead]:
try:
return AgentAssetService(db).list_risk_rule_feedback(
return AgentAssetService(db, current_user=current_user).list_risk_rule_feedback(
asset_id,
version=version,
status=status_value,
@@ -199,3 +247,304 @@ def list_risk_rule_feedback(
)
except Exception as exc:
_handle_asset_error(exc)
@router.get(
"/{asset_id}/rule-json",
response_model=AgentAssetRuleJsonRead,
summary="读取风险规则 JSON",
description="读取 JSON 风险规则资产绑定的规则文件内容。",
)
def get_agent_asset_rule_json(
asset_id: str,
current_user: CurrentUser,
db: DbSession,
) -> AgentAssetRuleJsonRead:
try:
return AgentAssetService(db, current_user=current_user).read_rule_json(asset_id)
except Exception as exc:
_handle_asset_error(exc)
@router.put(
"/{asset_id}/rule-json",
response_model=AgentAssetRuleJsonRead,
summary="保存风险规则 JSON",
description="保存 JSON 风险规则资产绑定的规则文件内容,并写入审计日志。",
)
def save_agent_asset_rule_json(
asset_id: str,
payload: AgentAssetRuleJsonWrite,
current_user: RuleEditorUser,
db: DbSession,
x_actor: ActorHeader = None,
x_request_id: RequestIdHeader = None,
) -> AgentAssetRuleJsonRead:
try:
return AgentAssetService(db, current_user=current_user).write_rule_json(
asset_id,
body=payload,
actor=_actor_name(current_user, x_actor),
request_id=x_request_id,
)
except Exception as exc:
_handle_asset_error(exc)
@router.post(
"/risk-rules/generate",
response_model=AgentAssetRead,
status_code=status.HTTP_201_CREATED,
summary="根据自然语言新建风险规则草稿",
description=(
"根据业务域、自然语言描述和风险评分模型生成 JSON 风险规则,并保存为待上线草稿资产。"
),
)
def generate_agent_asset_risk_rule(
payload: AgentAssetRiskRuleGenerateRequest,
background_tasks: BackgroundTasks,
current_user: RuleReviewerUser,
db: DbSession,
x_actor: ActorHeader = None,
x_request_id: RequestIdHeader = None,
) -> AgentAssetRead:
try:
actor = _actor_name(current_user, x_actor)
asset_id = RiskRuleGenerationJobService(db).enqueue_rule_asset_generation(
payload,
tenant_id=current_user.tenant_id,
actor=actor,
request_id=x_request_id,
)
background_tasks.add_task(
_complete_risk_rule_generation_task,
asset_id,
payload.model_dump(mode="json"),
actor,
x_request_id,
current_user.tenant_id,
)
return _read_asset(db, asset_id, current_user)
except Exception as exc:
_handle_asset_error(exc)
@router.get(
"/{asset_id}/risk-rule-tests/latest",
response_model=AgentAssetRiskRuleLatestTestSummary,
summary="读取风险规则最近测试摘要",
description="返回当前风险规则工作版本最近一次样例测试、场景试运行和测试报告。",
)
def get_agent_asset_risk_rule_latest_test(
asset_id: str,
current_user: CurrentUser,
db: DbSession,
) -> AgentAssetRiskRuleLatestTestSummary:
try:
return AgentAssetService(
db, current_user=current_user
).get_latest_risk_rule_test_summary(asset_id)
except Exception as exc:
_handle_asset_error(exc)
@router.post(
"/{asset_id}/risk-rule-tests/simulate",
response_model=AgentAssetRiskRuleSimulationRead,
summary="执行风险规则对话仿真",
description="基于临时对话输入和附件元信息执行风险识别,不创建业务单据,不写入测试记录。",
)
def simulate_agent_asset_risk_rule_test(
asset_id: str,
payload: AgentAssetRiskRuleSimulationRequest,
current_user: PlatformAdminUser,
db: DbSession,
) -> AgentAssetRiskRuleSimulationRead:
try:
return AgentAssetService(
db, current_user=current_user
).simulate_risk_rule_message(asset_id, payload)
except Exception as exc:
_handle_asset_error(exc)
@router.post(
"/{asset_id}/risk-rule-tests/sample",
response_model=AgentAssetRiskRuleTestRunRead,
summary="执行风险规则快速样例测试",
description="使用人工样例或系统默认样例执行当前 JSON 风险规则,不依赖大模型判断结果。",
)
def run_agent_asset_risk_rule_sample_test(
asset_id: str,
payload: AgentAssetRiskRuleSampleTestRequest,
current_user: PlatformAdminUser,
db: DbSession,
x_actor: ActorHeader = None,
x_request_id: RequestIdHeader = None,
) -> AgentAssetRiskRuleTestRunRead:
try:
return AgentAssetService(db, current_user=current_user).run_risk_rule_sample_test(
asset_id,
payload,
actor=_actor_name(current_user, x_actor),
request_id=x_request_id,
)
except Exception as exc:
_handle_asset_error(exc)
@router.post(
"/{asset_id}/risk-rule-tests/scenario",
response_model=AgentAssetRiskRuleTestRunRead,
summary="执行风险规则真实场景试运行",
description="按测试意图读取真实业务样本并沙盒执行风险规则,不写回业务单据。",
)
def run_agent_asset_risk_rule_scenario_test(
asset_id: str,
payload: AgentAssetRiskRuleScenarioTestRequest,
current_user: PlatformAdminUser,
db: DbSession,
x_actor: ActorHeader = None,
x_request_id: RequestIdHeader = None,
) -> AgentAssetRiskRuleTestRunRead:
try:
return AgentAssetService(
db, current_user=current_user
).run_risk_rule_scenario_test(
asset_id,
payload,
actor=_actor_name(current_user, x_actor),
request_id=x_request_id,
)
except Exception as exc:
_handle_asset_error(exc)
@router.post(
"/{asset_id}/risk-rule-tests/report",
response_model=AgentAssetRiskRuleTestRunRead,
summary="确认风险规则测试报告",
description="在样例测试和真实场景试运行通过后,保存当前版本测试通过记录。",
)
def confirm_agent_asset_risk_rule_test_report(
asset_id: str,
payload: AgentAssetRiskRuleReportRequest,
current_user: PlatformAdminUser,
db: DbSession,
x_actor: ActorHeader = None,
x_request_id: RequestIdHeader = None,
) -> AgentAssetRiskRuleTestRunRead:
try:
return AgentAssetService(
db, current_user=current_user
).confirm_risk_rule_test_report(
asset_id,
payload,
actor=_actor_name(current_user, x_actor),
request_id=x_request_id,
)
except Exception as exc:
_handle_asset_error(exc)
@router.post(
"/{asset_id}/risk-rule-enabled",
response_model=AgentAssetRead,
summary="设置风险规则启用状态",
description=(
"高级财务人员可独立启用或停用 JSON 风险规则;停用后即使已上线也不会进入真实业务扫描。"
),
)
def set_agent_asset_risk_rule_enabled(
asset_id: str,
payload: AgentAssetRiskRuleEnabledUpdate,
current_user: RuleReviewerUser,
db: DbSession,
x_actor: ActorHeader = None,
x_request_id: RequestIdHeader = None,
) -> AgentAssetRead:
try:
asset = AgentAssetService(db, current_user=current_user).set_risk_rule_enabled(
asset_id,
enabled=payload.enabled,
actor=_actor_name(current_user, x_actor),
request_id=x_request_id,
)
return _read_asset(db, asset.id, current_user)
except Exception as exc:
_handle_asset_error(exc)
@router.post(
"/{asset_id}/risk-rule-level",
response_model=AgentAssetRead,
summary="风险规则风险等级已由评分模型接管",
description="风险规则等级和分数由自然语言规则评分模型生成,不再允许人工调整。",
)
def set_agent_asset_risk_rule_level(
asset_id: str,
payload: AgentAssetRiskRuleLevelUpdate,
current_user: RuleEditorUser,
db: DbSession,
x_actor: ActorHeader = None,
x_request_id: RequestIdHeader = None,
) -> AgentAssetRead:
try:
del asset_id, payload, current_user, db, x_actor, x_request_id
raise ValueError("风险等级和分数由评分模型自动计算,不能手动修改。")
except Exception as exc:
_handle_asset_error(exc)
@router.post(
"/{asset_id}/return",
response_model=AgentAssetRiskRuleLatestTestSummary,
summary="回退待审核风险规则",
description="高级财务人员将待审核风险规则回退到草稿,并记录回退原因。",
)
def return_agent_asset_risk_rule(
asset_id: str,
payload: AgentAssetRiskRuleReturnRequest,
current_user: RuleReviewerUser,
db: DbSession,
x_actor: ActorHeader = None,
x_request_id: RequestIdHeader = None,
) -> AgentAssetRiskRuleLatestTestSummary:
try:
return AgentAssetService(db, current_user=current_user).return_risk_rule(
asset_id,
note=payload.note,
actor=_actor_name(current_user, x_actor),
request_id=x_request_id,
)
except Exception as exc:
_handle_asset_error(exc)
@router.post(
"/{asset_id}/publish",
response_model=AgentAssetRead,
summary="审核并启动风险规则影子发布",
description=(
"高级财务人员确认测试与 Golden 门禁通过后,将候选规则送入 shadow"
"该入口不会直接 active后续必须通过 Canary 质量门禁。"
),
)
def publish_agent_asset_risk_rule(
asset_id: str,
current_user: RuleReviewerUser,
db: DbSession,
x_actor: ActorHeader = None,
x_request_id: RequestIdHeader = None,
) -> AgentAssetRead:
try:
asset = AgentAssetService(db, current_user=current_user).publish_risk_rule(
asset_id,
actor=_actor_name(current_user, x_actor),
tenant_id=current_user.tenant_id,
allow_global_management=current_user.is_admin,
request_id=x_request_id,
)
return _read_asset(db, asset.id, current_user)
except Exception as exc:
_handle_asset_error(exc)

View File

@@ -2,7 +2,7 @@ from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, BackgroundTasks, Body, Depends, Header, HTTPException, Query, status
from fastapi import APIRouter, Body, Depends, Header, HTTPException, Query, status
from fastapi.responses import FileResponse
from sqlalchemy.orm import Session
@@ -15,7 +15,6 @@ from app.api.deps import (
require_rule_reviewer_user,
)
from app.api.pagination import PageNumber, PageSize, page_payload, wants_page
from app.db.session import get_session_factory
from app.schemas.agent_asset import (
AgentAssetCreate,
AgentAssetListItem,
@@ -25,19 +24,6 @@ from app.schemas.agent_asset import (
AgentAssetRead,
AgentAssetReviewCreate,
AgentAssetReviewRead,
AgentAssetRiskRuleEnabledUpdate,
AgentAssetRiskRuleGenerateRequest,
AgentAssetRiskRuleLatestTestSummary,
AgentAssetRiskRuleLevelUpdate,
AgentAssetRiskRuleReportRequest,
AgentAssetRiskRuleReturnRequest,
AgentAssetRiskRuleSampleTestRequest,
AgentAssetRiskRuleScenarioTestRequest,
AgentAssetRiskRuleSimulationRead,
AgentAssetRiskRuleSimulationRequest,
AgentAssetRiskRuleTestRunRead,
AgentAssetRuleJsonRead,
AgentAssetRuleJsonWrite,
AgentAssetSpreadsheetChangeRecordRead,
AgentAssetUpdate,
AgentAssetVersionCreate,
@@ -49,14 +35,20 @@ from app.schemas.agent_asset import (
GoldenEvalRequest,
)
from app.schemas.common import ErrorResponse, PaginatedResponse
from app.services.agent_asset_access import stable_user_principal
from app.services.agent_asset_onlyoffice_security import (
AGENT_ASSET_ONLYOFFICE_CALLBACK_SCOPE,
AgentAssetOnlyOfficeReplayError,
AgentAssetOnlyOfficeSecurityError,
AgentAssetOnlyOfficeValidatedSession,
)
from app.services.agent_assets import AgentAssetService
from app.services.risk_rule_generation_jobs import RiskRuleGenerationJobService
router = APIRouter(prefix="/agent-assets")
DbSession = Annotated[Session, Depends(get_db)]
ActorHeader = Annotated[
str | None,
Header(description="审计操作者。未传时回退到请求体中的 owner / reviewer 或 `system`"),
Header(description="兼容旧客户端;审计主体始终以登录会话中的稳定身份为准"),
]
RequestIdHeader = Annotated[
str | None,
@@ -68,6 +60,24 @@ RuleEditorUser = Annotated[CurrentUserContext, Depends(require_rule_editor_user)
RuleReviewerUser = Annotated[CurrentUserContext, Depends(require_rule_reviewer_user)]
def _actor(current_user: CurrentUserContext) -> str:
return stable_user_principal(current_user)
def _onlyoffice_service_for_session(
db: Session,
session: AgentAssetOnlyOfficeValidatedSession,
) -> AgentAssetService:
machine_user = CurrentUserContext(
username=session.actor,
name="ONLYOFFICE",
role_codes=["manager"],
is_admin=session.resource_scope == "platform",
tenant_id=session.tenant_id,
)
return AgentAssetService(db, current_user=machine_user)
def _handle_asset_error(exc: Exception) -> None:
if isinstance(exc, (LookupError, FileNotFoundError)):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
@@ -78,27 +88,6 @@ def _handle_asset_error(exc: Exception) -> None:
raise exc
def _complete_risk_rule_generation_task(
asset_id: str,
payload: dict,
actor: str,
request_id: str | None,
tenant_id: str,
) -> None:
db = get_session_factory()()
try:
body = AgentAssetRiskRuleGenerateRequest.model_validate(payload)
RiskRuleGenerationJobService(db).complete_rule_asset_generation(
asset_id,
body,
tenant_id=tenant_id,
actor=actor,
request_id=request_id,
)
finally:
db.close()
@router.get(
"",
response_model=list[AgentAssetListItem] | PaginatedResponse[AgentAssetListItem],
@@ -106,6 +95,7 @@ def _complete_risk_rule_generation_task(
description="按资产类型、状态、领域和关键字筛选规则、技能、MCP 与任务资产。",
)
def list_agent_assets(
current_user: CurrentUser,
db: DbSession,
asset_type: Annotated[
str | None,
@@ -126,7 +116,7 @@ def list_agent_assets(
page: PageNumber = None,
page_size: PageSize = None,
) -> list[AgentAssetListItem] | PaginatedResponse[AgentAssetListItem]:
service = AgentAssetService(db)
service = AgentAssetService(db, current_user=current_user)
if wants_page(page, page_size):
return page_payload(
service.list_assets_page(
@@ -158,204 +148,17 @@ def list_agent_assets(
}
},
)
def get_agent_asset(asset_id: str, db: DbSession) -> AgentAssetRead:
asset = AgentAssetService(db).get_asset(asset_id)
def get_agent_asset(
asset_id: str,
current_user: CurrentUser,
db: DbSession,
) -> AgentAssetRead:
asset = AgentAssetService(db, current_user=current_user).get_asset(asset_id)
if asset is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Asset not found")
return asset
@router.get(
"/{asset_id}/rule-json",
response_model=AgentAssetRuleJsonRead,
summary="读取风险规则 JSON",
description="读取 JSON 风险规则资产绑定的规则文件内容。",
)
def get_agent_asset_rule_json(
asset_id: str,
_: CurrentUser,
db: DbSession,
) -> AgentAssetRuleJsonRead:
try:
return AgentAssetService(db).read_rule_json(asset_id)
except Exception as exc:
_handle_asset_error(exc)
@router.get(
"/{asset_id}/risk-rule-tests/latest",
response_model=AgentAssetRiskRuleLatestTestSummary,
summary="读取风险规则最近测试摘要",
description="返回当前风险规则工作版本最近一次样例测试、场景试运行和测试报告。",
)
def get_agent_asset_risk_rule_latest_test(
asset_id: str,
_: CurrentUser,
db: DbSession,
) -> AgentAssetRiskRuleLatestTestSummary:
try:
return AgentAssetService(db).get_latest_risk_rule_test_summary(asset_id)
except Exception as exc:
_handle_asset_error(exc)
@router.post(
"/{asset_id}/risk-rule-tests/simulate",
response_model=AgentAssetRiskRuleSimulationRead,
summary="执行风险规则对话仿真",
description="基于临时对话输入和附件元信息执行风险识别,不创建业务单据,不写入测试记录。",
)
def simulate_agent_asset_risk_rule_test(
asset_id: str,
payload: AgentAssetRiskRuleSimulationRequest,
_: PlatformAdminUser,
db: DbSession,
) -> AgentAssetRiskRuleSimulationRead:
try:
return AgentAssetService(db).simulate_risk_rule_message(asset_id, payload)
except Exception as exc:
_handle_asset_error(exc)
@router.post(
"/{asset_id}/risk-rule-tests/sample",
response_model=AgentAssetRiskRuleTestRunRead,
summary="执行风险规则快速样例测试",
description="使用人工样例或系统默认样例执行当前 JSON 风险规则,不依赖大模型判断结果。",
)
def run_agent_asset_risk_rule_sample_test(
asset_id: str,
payload: AgentAssetRiskRuleSampleTestRequest,
current_user: PlatformAdminUser,
db: DbSession,
x_actor: ActorHeader = None,
x_request_id: RequestIdHeader = None,
) -> AgentAssetRiskRuleTestRunRead:
try:
return AgentAssetService(db).run_risk_rule_sample_test(
asset_id,
payload,
actor=(x_actor or current_user.name or "system").strip() or "system",
request_id=x_request_id,
)
except Exception as exc:
_handle_asset_error(exc)
@router.post(
"/{asset_id}/risk-rule-tests/scenario",
response_model=AgentAssetRiskRuleTestRunRead,
summary="执行风险规则真实场景试运行",
description="按测试意图读取真实业务样本并沙盒执行风险规则,不写回业务单据。",
)
def run_agent_asset_risk_rule_scenario_test(
asset_id: str,
payload: AgentAssetRiskRuleScenarioTestRequest,
current_user: PlatformAdminUser,
db: DbSession,
x_actor: ActorHeader = None,
x_request_id: RequestIdHeader = None,
) -> AgentAssetRiskRuleTestRunRead:
try:
return AgentAssetService(db).run_risk_rule_scenario_test(
asset_id,
payload,
actor=(x_actor or current_user.name or "system").strip() or "system",
request_id=x_request_id,
)
except Exception as exc:
_handle_asset_error(exc)
@router.post(
"/{asset_id}/risk-rule-tests/report",
response_model=AgentAssetRiskRuleTestRunRead,
summary="确认风险规则测试报告",
description="在样例测试和真实场景试运行通过后,保存当前版本测试通过记录。",
)
def confirm_agent_asset_risk_rule_test_report(
asset_id: str,
payload: AgentAssetRiskRuleReportRequest,
current_user: PlatformAdminUser,
db: DbSession,
x_actor: ActorHeader = None,
x_request_id: RequestIdHeader = None,
) -> AgentAssetRiskRuleTestRunRead:
try:
return AgentAssetService(db).confirm_risk_rule_test_report(
asset_id,
payload,
actor=(x_actor or current_user.name or "system").strip() or "system",
request_id=x_request_id,
)
except Exception as exc:
_handle_asset_error(exc)
@router.put(
"/{asset_id}/rule-json",
response_model=AgentAssetRuleJsonRead,
summary="保存风险规则 JSON",
description="保存 JSON 风险规则资产绑定的规则文件内容,并写入审计日志。",
)
def save_agent_asset_rule_json(
asset_id: str,
payload: AgentAssetRuleJsonWrite,
current_user: RuleEditorUser,
db: DbSession,
x_actor: ActorHeader = None,
x_request_id: RequestIdHeader = None,
) -> AgentAssetRuleJsonRead:
try:
return AgentAssetService(db).write_rule_json(
asset_id,
body=payload,
actor=(x_actor or current_user.name or "system").strip() or "system",
request_id=x_request_id,
)
except Exception as exc:
_handle_asset_error(exc)
@router.post(
"/risk-rules/generate",
response_model=AgentAssetRead,
status_code=status.HTTP_201_CREATED,
summary="根据自然语言新建风险规则草稿",
description="根据业务域、自然语言描述和风险评分模型生成 JSON 风险规则,并保存为待上线草稿资产。",
)
def generate_agent_asset_risk_rule(
payload: AgentAssetRiskRuleGenerateRequest,
background_tasks: BackgroundTasks,
current_user: RuleReviewerUser,
db: DbSession,
x_actor: ActorHeader = None,
x_request_id: RequestIdHeader = None,
) -> AgentAssetRead:
try:
actor = (x_actor or current_user.name or "system").strip() or "system"
asset_id = RiskRuleGenerationJobService(db).enqueue_rule_asset_generation(
payload,
tenant_id=current_user.tenant_id,
actor=actor,
request_id=x_request_id,
)
background_tasks.add_task(
_complete_risk_rule_generation_task,
asset_id,
payload.model_dump(mode="json"),
actor,
x_request_id,
current_user.tenant_id,
)
asset = AgentAssetService(db).get_asset(asset_id)
if asset is None:
raise LookupError("Asset not found")
return asset
except Exception as exc:
_handle_asset_error(exc)
@router.get(
"/{asset_id}/spreadsheet/onlyoffice-config",
response_model=AgentAssetOnlyOfficeConfigRead,
@@ -372,7 +175,9 @@ def get_agent_asset_spreadsheet_onlyoffice_config(
] = None,
) -> AgentAssetOnlyOfficeConfigRead:
try:
return AgentAssetService(db).build_rule_spreadsheet_onlyoffice_config(
return AgentAssetService(
db, current_user=current_user
).build_rule_spreadsheet_onlyoffice_config(
asset_id,
current_user,
version=version,
@@ -389,7 +194,7 @@ def get_agent_asset_spreadsheet_onlyoffice_config(
)
def get_agent_asset_spreadsheet_content(
asset_id: str,
_: CurrentUser,
current_user: CurrentUser,
db: DbSession,
version: Annotated[
str | None,
@@ -397,10 +202,9 @@ def get_agent_asset_spreadsheet_content(
] = None,
) -> FileResponse:
try:
file_path, media_type, filename = AgentAssetService(db).get_rule_spreadsheet_content(
asset_id,
version=version,
)
file_path, media_type, filename = AgentAssetService(
db, current_user=current_user
).get_rule_spreadsheet_content(asset_id, version=version)
except Exception as exc:
_handle_asset_error(exc)
@@ -426,15 +230,19 @@ def get_agent_asset_spreadsheet_onlyoffice_content(
] = None,
) -> FileResponse:
try:
service = AgentAssetService(db)
service.validate_rule_spreadsheet_access_token(asset_id, access_token)
bootstrap_service = AgentAssetService(db)
validated_session = bootstrap_service.validate_rule_spreadsheet_access_token(
asset_id, access_token
)
service = _onlyoffice_service_for_session(db, validated_session)
file_path, media_type, filename = service.get_rule_spreadsheet_content(
asset_id,
version=version,
validated_session=validated_session,
)
except FileNotFoundError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
except ValueError as exc:
except AgentAssetOnlyOfficeSecurityError as exc:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(exc)) from exc
except Exception as exc:
_handle_asset_error(exc)
@@ -464,11 +272,11 @@ def upload_agent_asset_spreadsheet(
x_request_id: RequestIdHeader = None,
) -> AgentAssetRead:
try:
return AgentAssetService(db).upload_rule_spreadsheet(
return AgentAssetService(db, current_user=current_user).upload_rule_spreadsheet(
asset_id,
filename=filename,
content=content,
actor=current_user.name,
actor=_actor(current_user),
request_id=x_request_id,
)
except Exception as exc:
@@ -497,11 +305,13 @@ def import_agent_asset_spreadsheet_content(
x_request_id: RequestIdHeader = None,
) -> AgentAssetRead:
try:
return AgentAssetService(db).import_rule_spreadsheet_content(
return AgentAssetService(
db, current_user=current_user
).import_rule_spreadsheet_content(
asset_id,
filename=filename,
content=content,
actor=current_user.name,
actor=_actor(current_user),
request_id=x_request_id,
)
except Exception as exc:
@@ -518,22 +328,33 @@ def handle_agent_asset_spreadsheet_onlyoffice_callback(
asset_id: str,
payload: AgentAssetOnlyOfficeCallbackWrite,
db: DbSession,
access_token: Annotated[
str,
Query(min_length=1, description="ONLYOFFICE 回调专用短时令牌。"),
],
version: Annotated[
str | None,
Query(description="兼容旧 ONLYOFFICE 回调;当前表格模式不再使用。"),
] = None,
actor_name: Annotated[
str | None,
Query(description="发起编辑的用户显示名。"),
] = None,
) -> AgentAssetOnlyOfficeCallbackRead:
try:
AgentAssetService(db).handle_rule_spreadsheet_onlyoffice_callback(
bootstrap_service = AgentAssetService(db)
validated_session = bootstrap_service.validate_rule_spreadsheet_access_token(
asset_id,
access_token,
expected_scope=AGENT_ASSET_ONLYOFFICE_CALLBACK_SCOPE,
)
service = _onlyoffice_service_for_session(db, validated_session)
service.handle_rule_spreadsheet_onlyoffice_callback(
asset_id,
version=version,
payload=payload.model_dump(),
actor_name=actor_name,
callback_token=access_token,
)
except AgentAssetOnlyOfficeReplayError as exc:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
except AgentAssetOnlyOfficeSecurityError as exc:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(exc)) from exc
except Exception as exc:
_handle_asset_error(exc)
@@ -548,12 +369,14 @@ def handle_agent_asset_spreadsheet_onlyoffice_callback(
)
def list_agent_asset_spreadsheet_change_records(
asset_id: str,
_: CurrentUser,
current_user: CurrentUser,
db: DbSession,
limit: Annotated[int, Query(ge=1, le=30, description="返回条数,最多 30 条。")] = 30,
) -> list[AgentAssetSpreadsheetChangeRecordRead]:
try:
return AgentAssetService(db).list_spreadsheet_change_records(asset_id, limit=limit)
return AgentAssetService(
db, current_user=current_user
).list_spreadsheet_change_records(asset_id, limit=limit)
except Exception as exc:
_handle_asset_error(exc)
@@ -579,9 +402,9 @@ def create_agent_asset(
x_request_id: RequestIdHeader = None,
) -> AgentAssetRead:
try:
return AgentAssetService(db).create_asset(
return AgentAssetService(db, current_user=current_user).create_asset(
payload,
actor=(x_actor or current_user.name or payload.owner).strip() or "system",
actor=_actor(current_user),
request_id=x_request_id,
)
except Exception as exc:
@@ -607,7 +430,7 @@ def create_agent_asset(
def update_agent_asset(
asset_id: str,
payload: AgentAssetUpdate,
current_user: CurrentUser,
current_user: RuleEditorUser,
db: DbSession,
x_actor: ActorHeader = None,
x_request_id: RequestIdHeader = None,
@@ -618,10 +441,10 @@ def update_agent_asset(
current_user.is_admin or "manager" in role_codes
):
raise PermissionError("只有高级管理员或 admin 管理员可以更改规则上线状态。")
return AgentAssetService(db).update_asset(
return AgentAssetService(db, current_user=current_user).update_asset(
asset_id,
payload,
actor=(x_actor or current_user.name or "system").strip() or "system",
actor=_actor(current_user),
request_id=x_request_id,
)
except Exception as exc:
@@ -642,6 +465,7 @@ def update_agent_asset(
)
def list_agent_asset_versions(
asset_id: str,
current_user: CurrentUser,
db: DbSession,
limit: Annotated[
int,
@@ -649,7 +473,9 @@ def list_agent_asset_versions(
] = 20,
) -> list[AgentAssetVersionRead]:
try:
return AgentAssetService(db).list_versions(asset_id, limit=limit)
return AgentAssetService(db, current_user=current_user).list_versions(
asset_id, limit=limit
)
except Exception as exc:
_handle_asset_error(exc)
@@ -659,7 +485,9 @@ def list_agent_asset_versions(
response_model=AgentAssetVersionRead,
status_code=status.HTTP_201_CREATED,
summary="创建资产版本",
description="为指定资产创建新版本;规则和任务源文件可使用 Markdown技能与 MCP 使用 JSON 快照。",
description=(
"为指定资产创建新版本;规则和任务源文件可使用 Markdown技能与 MCP 使用 JSON 快照。"
),
responses={
status.HTTP_400_BAD_REQUEST: {
"model": ErrorResponse,
@@ -674,15 +502,17 @@ def list_agent_asset_versions(
def create_agent_asset_version(
asset_id: str,
payload: AgentAssetVersionCreate,
current_user: RuleEditorUser,
db: DbSession,
x_actor: ActorHeader = None,
x_request_id: RequestIdHeader = None,
) -> AgentAssetVersionRead:
try:
return AgentAssetService(db).create_version(
payload = payload.model_copy(update={"created_by": _actor(current_user)})
return AgentAssetService(db, current_user=current_user).create_version(
asset_id,
payload,
actor=(x_actor or payload.created_by).strip() or "system",
actor=_actor(current_user),
request_id=x_request_id,
)
except Exception as exc:
@@ -721,10 +551,11 @@ def create_agent_asset_review(
raise PermissionError("只有财务人员或高级财务人员可以提交审核。")
elif not (current_user.is_admin or "manager" in role_codes):
raise PermissionError("只有高级财务人员可以审核规则。")
return AgentAssetService(db).create_review(
payload = payload.model_copy(update={"reviewer": _actor(current_user)})
return AgentAssetService(db, current_user=current_user).create_review(
asset_id,
payload,
actor=(x_actor or payload.reviewer).strip() or "system",
actor=_actor(current_user),
request_id=x_request_id,
)
except Exception as exc:
@@ -749,121 +580,17 @@ def create_agent_asset_review(
)
def activate_agent_asset(
asset_id: str,
_: RuleReviewerUser,
db: DbSession,
x_actor: ActorHeader = None,
x_request_id: RequestIdHeader = None,
) -> AgentAssetRead:
try:
return AgentAssetService(db).activate_asset(
asset_id,
actor=(x_actor or "system").strip() or "system",
request_id=x_request_id,
)
except Exception as exc:
_handle_asset_error(exc)
@router.post(
"/{asset_id}/risk-rule-enabled",
response_model=AgentAssetRead,
summary="设置风险规则启用状态",
description=(
"高级财务人员可独立启用或停用 JSON 风险规则;停用后即使已上线也不会进入真实业务扫描。"
),
)
def set_agent_asset_risk_rule_enabled(
asset_id: str,
payload: AgentAssetRiskRuleEnabledUpdate,
current_user: RuleReviewerUser,
db: DbSession,
x_actor: ActorHeader = None,
x_request_id: RequestIdHeader = None,
) -> AgentAssetRead:
try:
asset = AgentAssetService(db).set_risk_rule_enabled(
return AgentAssetService(db, current_user=current_user).activate_asset(
asset_id,
enabled=payload.enabled,
actor=(x_actor or current_user.name or "system").strip() or "system",
actor=_actor(current_user),
request_id=x_request_id,
)
detail = AgentAssetService(db).get_asset(asset.id)
if detail is None:
raise LookupError("Asset not found")
return detail
except Exception as exc:
_handle_asset_error(exc)
@router.post(
"/{asset_id}/risk-rule-level",
response_model=AgentAssetRead,
summary="风险规则风险等级已由评分模型接管",
description="风险规则等级和分数由自然语言规则评分模型生成,不再允许人工调整。",
)
def set_agent_asset_risk_rule_level(
asset_id: str,
payload: AgentAssetRiskRuleLevelUpdate,
current_user: RuleEditorUser,
db: DbSession,
x_actor: ActorHeader = None,
x_request_id: RequestIdHeader = None,
) -> AgentAssetRead:
try:
del asset_id, payload, current_user, db, x_actor, x_request_id
raise ValueError("风险等级和分数由评分模型自动计算,不能手动修改。")
except Exception as exc:
_handle_asset_error(exc)
@router.post(
"/{asset_id}/return",
response_model=AgentAssetRiskRuleLatestTestSummary,
summary="回退待审核风险规则",
description="高级财务人员将待审核风险规则回退到草稿,并记录回退原因。",
)
def return_agent_asset_risk_rule(
asset_id: str,
payload: AgentAssetRiskRuleReturnRequest,
current_user: RuleReviewerUser,
db: DbSession,
x_actor: ActorHeader = None,
x_request_id: RequestIdHeader = None,
) -> AgentAssetRiskRuleLatestTestSummary:
try:
return AgentAssetService(db).return_risk_rule(
asset_id,
note=payload.note,
actor=(x_actor or current_user.name or "system").strip() or "system",
request_id=x_request_id,
)
except Exception as exc:
_handle_asset_error(exc)
@router.post(
"/{asset_id}/publish",
response_model=AgentAssetRead,
summary="审核并发布风险规则",
description="高级财务人员确认测试通过后,将待审核风险规则一次性审核通过并发布上线。",
)
def publish_agent_asset_risk_rule(
asset_id: str,
current_user: RuleReviewerUser,
db: DbSession,
x_actor: ActorHeader = None,
x_request_id: RequestIdHeader = None,
) -> AgentAssetRead:
try:
asset = AgentAssetService(db).publish_risk_rule(
asset_id,
actor=(x_actor or current_user.name or "system").strip() or "system",
request_id=x_request_id,
)
detail = AgentAssetService(db).get_asset(asset.id)
if detail is None:
raise LookupError("Asset not found")
return detail
except Exception as exc:
_handle_asset_error(exc)
@@ -882,9 +609,9 @@ def delete_agent_asset(
x_request_id: RequestIdHeader = None,
) -> None:
try:
AgentAssetService(db).delete_unpublished_asset(
AgentAssetService(db, current_user=current_user).delete_unpublished_asset(
asset_id,
actor=(x_actor or current_user.name or "system").strip() or "system",
actor=_actor(current_user),
request_id=x_request_id,
)
except Exception as exc:
@@ -906,10 +633,12 @@ def restore_agent_asset_version(
x_request_id: RequestIdHeader = None,
) -> AgentAssetRead:
try:
return AgentAssetService(db).restore_version_as_working_copy(
return AgentAssetService(
db, current_user=current_user
).restore_version_as_working_copy(
asset_id,
version,
actor=(x_actor or current_user.name or "system").strip() or "system",
actor=_actor(current_user),
request_id=x_request_id,
)
except Exception as exc:
@@ -924,11 +653,11 @@ def restore_agent_asset_version(
)
def get_agent_asset_version_timeline(
asset_id: str,
_: CurrentUser,
current_user: CurrentUser,
db: DbSession,
) -> list[AgentAssetVersionTimelineItemRead]:
try:
return AgentAssetService(db).list_version_timeline(asset_id)
return AgentAssetService(db, current_user=current_user).list_version_timeline(asset_id)
except Exception as exc:
_handle_asset_error(exc)
@@ -999,22 +728,25 @@ def list_golden_cases(
def run_golden_eval(
asset_id: str,
body: GoldenEvalRequest,
_: RuleReviewerUser,
current_user: RuleReviewerUser,
db: DbSession,
) -> GoldenEvalRead:
from app.services.agent_asset_spreadsheet import RISK_RULES_LIBRARY
from app.services.risk_rule_golden_evaluator import RiskRuleGoldenEvaluator
try:
asset = AgentAssetService(db).get_asset(asset_id)
service = AgentAssetService(db, current_user=current_user)
asset = service.get_asset(asset_id)
if asset is None:
raise LookupError("Asset not found")
config = asset.config_json if isinstance(asset.config_json, dict) else {}
rule_document = config.get("rule_document") if isinstance(config.get("rule_document"), dict) else {}
rule_document = (
config.get("rule_document") if isinstance(config.get("rule_document"), dict) else {}
)
file_name = str(rule_document.get("file_name") or "").strip()
if not file_name:
raise ValueError("该规则没有可执行的 manifest 文件。")
manager = AgentAssetService(db).rule_library_manager
manager = service.rule_library_manager
manifest = manager.read_rule_library_json(library=RISK_RULES_LIBRARY, file_name=file_name)
rule_code = str(manifest.get("rule_code") or "").strip()
if not rule_code:

View File

@@ -5,13 +5,15 @@ from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from app.api.deps import get_current_user, get_db
from app.api.deps import CurrentUserContext, get_current_user, get_db
from app.schemas.agent_run import AgentRunRead, AgentRunStatsRead
from app.schemas.common import ErrorResponse
from app.services.agent_run_access_policy import AgentRunAccessPolicy
from app.services.agent_runs import AgentRunService
router = APIRouter(prefix="/agent-runs", dependencies=[Depends(get_current_user)])
DbSession = Annotated[Session, Depends(get_db)]
CurrentUser = Annotated[CurrentUserContext, Depends(get_current_user)]
@router.get(
@@ -22,6 +24,7 @@ DbSession = Annotated[Session, Depends(get_db)]
)
def list_agent_runs(
db: DbSession,
current_user: CurrentUser,
agent: Annotated[
str | None,
Query(description="Agent 名称筛选。"),
@@ -39,9 +42,17 @@ def list_agent_runs(
Query(ge=1, le=100, description="返回记录上限。"),
] = 20,
) -> list[AgentRunRead]:
return AgentRunService(db).list_runs(
agent=agent, status=status_value, source=source, limit=limit
tenant_id = AgentRunAccessPolicy.require_current_tenant_id(current_user)
scope_clause = AgentRunAccessPolicy.build_query_scope(current_user)
runs = AgentRunService(db).list_runs_for_tenant(
tenant_id=tenant_id,
agent=agent,
status=status_value,
source=source,
limit=limit,
scope_clause=scope_clause,
)
return AgentRunAccessPolicy.filter_list_items(runs, current_user, db)
@router.get(
@@ -52,6 +63,7 @@ def list_agent_runs(
)
def summarize_agent_runs(
db: DbSession,
current_user: CurrentUser,
agent: Annotated[
str | None,
Query(description="Agent 名称筛选。"),
@@ -69,11 +81,15 @@ def summarize_agent_runs(
Query(ge=1, le=500, description="统计最近记录数。"),
] = 200,
) -> AgentRunStatsRead:
return AgentRunService(db).summarize_runs(
tenant_id = AgentRunAccessPolicy.require_current_tenant_id(current_user)
scope_clause = AgentRunAccessPolicy.build_query_scope(current_user)
return AgentRunService(db).summarize_runs_for_tenant(
tenant_id=tenant_id,
agent=agent,
status=status_value,
source=source,
limit=limit,
scope_clause=scope_clause,
)
@@ -89,8 +105,17 @@ def summarize_agent_runs(
}
},
)
def get_agent_run(run_id: str, db: DbSession) -> AgentRunRead:
run = AgentRunService(db).get_run(run_id)
def get_agent_run(
run_id: str,
db: DbSession,
current_user: CurrentUser,
) -> AgentRunRead:
tenant_id = AgentRunAccessPolicy.require_current_tenant_id(current_user)
run = AgentRunService(db).get_run_for_tenant(
run_id,
tenant_id=tenant_id,
)
if run is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Run not found")
AgentRunAccessPolicy.require_detail_read(run, current_user)
return run

View File

@@ -6,16 +6,18 @@ from typing import Annotated
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from app.api.deps import get_current_user, get_db
from app.api.deps import CurrentUserContext, get_current_user, get_db
from app.schemas.digital_employee_dashboard import DigitalEmployeeDashboardRead
from app.schemas.finance_dashboard import FinanceDashboardRead
from app.schemas.system_dashboard import SystemDashboardRead
from app.services.digital_employee_dashboard import DigitalEmployeeDashboardService
from app.services.finance_dashboard_access_policy import FinanceDashboardAccessPolicy
from app.services.finance_dashboard_snapshot import FinanceDashboardSnapshotService
from app.services.system_dashboard import SystemDashboardService
router = APIRouter(prefix="/analytics", dependencies=[Depends(get_current_user)])
DbSession = Annotated[Session, Depends(get_db)]
CurrentUser = Annotated[CurrentUserContext, Depends(get_current_user)]
@router.get(
@@ -42,6 +44,7 @@ def get_system_dashboard(
)
def get_digital_employee_dashboard(
db: DbSession,
current_user: CurrentUser,
days: Annotated[
int,
Query(ge=1, le=30, description="统计窗口天数。"),
@@ -51,7 +54,10 @@ def get_digital_employee_dashboard(
Query(ge=1, le=1000, description="窗口内最多读取的运行记录数。"),
] = 300,
) -> DigitalEmployeeDashboardRead:
return DigitalEmployeeDashboardService(db).build_dashboard(days=days, limit=limit)
return DigitalEmployeeDashboardService(
db,
tenant_id=current_user.tenant_id,
).build_dashboard(days=days, limit=limit)
@router.get(
@@ -62,17 +68,20 @@ def get_digital_employee_dashboard(
)
def get_finance_dashboard(
db: DbSession,
current_user: CurrentUser,
range_key: Annotated[str, Query(max_length=30, description="顶部时间范围。")] = "近10日",
start_date: Annotated[date | None, Query(description="自定义开始日期。")] = None,
end_date: Annotated[date | None, Query(description="自定义结束日期。")] = None,
trend_range: Annotated[str, Query(max_length=30, description="趋势图时间范围。")] = (
"近12天"
),
trend_range: Annotated[str, Query(max_length=30, description="趋势图时间范围。")] = ("近12天"),
department_range: Annotated[str, Query(max_length=30, description="排行分析时间范围。")] = (
"本月"
),
) -> FinanceDashboardRead:
return FinanceDashboardSnapshotService(db).build_dashboard(
FinanceDashboardAccessPolicy.require_read(current_user)
return FinanceDashboardSnapshotService(
db,
tenant_id=current_user.tenant_id,
).build_dashboard(
range_key=range_key,
start_date=start_date,
end_date=end_date,

View File

@@ -53,7 +53,10 @@ def get_current_auth_user(
current_user: Annotated[CurrentUserContext, Depends(get_current_user)],
db: DbSession,
) -> AuthUserRead:
user = AuthService(db).get_user_snapshot(current_user.username)
user = AuthService(db).get_user_snapshot(
current_user.username,
tenant_id=current_user.tenant_id,
)
if user is not None:
return user
@@ -78,6 +81,7 @@ def get_current_auth_user(
),
avatar=name[:1].upper(),
isAdmin=True,
tenantId=current_user.tenant_id,
)
raise HTTPException(

View File

@@ -0,0 +1,73 @@
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from typing import Annotated, Literal
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from app.api.deps import CurrentUserContext, get_current_user, get_db
from app.schemas.cfo_value import CfoValueDashboardRead, CfoValueFiltersRead
from app.services.cfo_value_analytics import CfoValueAnalyticsService
from app.services.savings_access_policy import SavingsPermissionError
router = APIRouter(prefix="/analytics")
DbSession = Annotated[Session, Depends(get_db)]
CurrentUser = Annotated[CurrentUserContext, Depends(get_current_user)]
@router.get(
"/cfo-value",
response_model=CfoValueDashboardRead,
summary="查询 CFO 经营价值看板",
description=(
"仅汇总 Savings Ledger 中有基线、证据、去重和独立财务确认的价值事实;"
"风险暴露、预计机会和待确认结果不会混入已确认现金节省。"
),
)
def get_cfo_value_dashboard(
db: DbSession,
current_user: CurrentUser,
start: datetime | None = None,
end: datetime | None = None,
as_of: datetime | None = None,
department_id: Annotated[str | None, Query(max_length=160)] = None,
project_code: Annotated[str | None, Query(max_length=160)] = None,
expense_type: Annotated[str | None, Query(max_length=80)] = None,
supplier_id: Annotated[str | None, Query(max_length=160)] = None,
city: Annotated[str | None, Query(max_length=160)] = None,
owner_id: Annotated[str | None, Query(max_length=120)] = None,
source_type: Annotated[str | None, Query(max_length=50)] = None,
value_kind: Annotated[Literal["cash", "labor"] | None, Query()] = None,
) -> CfoValueDashboardRead:
now = datetime.now(UTC)
normalized_end = end or now
normalized_start = start or (normalized_end - timedelta(days=90))
normalized_as_of = as_of or now
try:
return CfoValueAnalyticsService(db).build_dashboard(
current_user,
start=normalized_start,
end=normalized_end,
as_of=normalized_as_of,
filters=CfoValueFiltersRead(
department_id=department_id,
project_code=project_code,
expense_type=expense_type,
supplier_id=supplier_id,
city=city,
owner_id=owner_id,
source_type=source_type,
value_kind=value_kind,
),
)
except SavingsPermissionError as error:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=str(error),
) from error
except (ValueError, PermissionError) as error:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(error),
) from error

View File

@@ -0,0 +1,587 @@
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from typing import Annotated
from fastapi import APIRouter, Depends, Header, HTTPException, Query, status
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.api.deps import (
CurrentUserContext,
get_current_user,
get_db,
require_platform_admin_user,
)
from app.schemas.commercial import (
CommercialAccountRead,
CommercialAnalyticsRead,
CommercialCostEventCreate,
CommercialCostEventRead,
CommercialEntitlementRead,
CommercialEntitlementUpsert,
CommercialMutationRead,
CommercialPlanActivationRead,
CommercialPlanCreate,
CommercialPlanRead,
CommercialPricingScenarioRead,
CommercialPricingScenarioWrite,
CommercialSubscriptionCreate,
CommercialSubscriptionRead,
CommercialSubscriptionTransition,
CommercialVersionAction,
UsageMeterEventCreate,
UsageMeterEventRead,
)
from app.services.commercial_access_policy import (
CommercialAccessPolicy,
CommercialConfigurationError,
CommercialConflictError,
CommercialPermissionError,
)
from app.services.commercial_admin import CommercialAdminService
from app.services.commercial_analytics import CommercialAnalyticsService
from app.services.commercial_entitlements import CommercialEntitlementService
from app.services.commercial_metering import CommercialMeteringService
from app.services.commercial_pricing import CommercialPricingService
from app.services.commercial_queries import CommercialQueryService
router = APIRouter(prefix="/commercial")
DbSession = Annotated[Session, Depends(get_db)]
CurrentUser = Annotated[CurrentUserContext, Depends(get_current_user)]
PlatformAdmin = Annotated[CurrentUserContext, Depends(require_platform_admin_user)]
RequestId = Annotated[
str,
Header(alias="X-Request-Id", min_length=1, max_length=120),
]
@router.get("/account", response_model=CommercialAccountRead, summary="读取当前租户商业账户")
def get_current_commercial_account(
db: DbSession,
current_user: CurrentUser,
as_of: datetime | None = None,
) -> CommercialAccountRead:
try:
return CommercialEntitlementService(db).get_account(current_user, as_of=as_of)
except Exception as error:
raise _http_error(error) from error
@router.get(
"/admin/tenants/{tenant_id}/account",
response_model=CommercialAccountRead,
summary="平台管理员读取租户商业账户",
)
def get_tenant_commercial_account(
tenant_id: str,
db: DbSession,
current_user: PlatformAdmin,
as_of: datetime | None = None,
) -> CommercialAccountRead:
target = CommercialAccessPolicy.require_platform_admin(
current_user,
target_tenant_id=tenant_id,
)
return CommercialEntitlementService(db).get_account_for_tenant(target, as_of=as_of)
@router.post(
"/admin/tenants/{tenant_id}/plans",
response_model=CommercialPlanRead,
status_code=status.HTTP_201_CREATED,
summary="创建租户套餐新版本",
)
def create_commercial_plan(
tenant_id: str,
payload: CommercialPlanCreate,
db: DbSession,
current_user: PlatformAdmin,
request_id: RequestId,
) -> CommercialPlanRead:
target = CommercialAccessPolicy.require_platform_admin(
current_user,
target_tenant_id=tenant_id,
)
try:
row = CommercialAdminService(db).create_plan(
target,
payload,
actor_id=current_user.username,
request_id=request_id,
reason=payload.reason,
)
db.commit()
db.refresh(row)
return row
except Exception as error:
db.rollback()
raise _http_error(error) from error
@router.post(
"/admin/tenants/{tenant_id}/plans/{plan_id}/activate",
response_model=CommercialPlanActivationRead,
summary="激活套餐版本并退役同编码旧版本",
)
def activate_commercial_plan(
tenant_id: str,
plan_id: str,
payload: CommercialVersionAction,
db: DbSession,
current_user: PlatformAdmin,
request_id: RequestId,
) -> CommercialPlanActivationRead:
target = CommercialAccessPolicy.require_platform_admin(
current_user,
target_tenant_id=tenant_id,
)
try:
result = CommercialAdminService(db).activate_plan(
target,
plan_id,
expected_version=payload.expected_version,
actor_id=current_user.username,
request_id=request_id,
reason=payload.reason,
)
db.commit()
return result
except Exception as error:
db.rollback()
raise _http_error(error) from error
@router.post(
"/admin/tenants/{tenant_id}/subscriptions",
response_model=CommercialSubscriptionRead,
status_code=status.HTTP_201_CREATED,
summary="创建并激活租户订阅快照",
)
def create_commercial_subscription(
tenant_id: str,
payload: CommercialSubscriptionCreate,
db: DbSession,
current_user: PlatformAdmin,
request_id: RequestId,
) -> CommercialSubscriptionRead:
target = CommercialAccessPolicy.require_platform_admin(
current_user,
target_tenant_id=tenant_id,
)
try:
row = CommercialAdminService(db).create_subscription(
target,
payload,
actor_id=current_user.username,
request_id=request_id,
reason=payload.reason,
)
db.commit()
db.refresh(row)
return row
except Exception as error:
db.rollback()
raise _http_error(error) from error
@router.post(
"/admin/tenants/{tenant_id}/subscriptions/{subscription_id}/activate",
response_model=CommercialSubscriptionRead,
summary="重新激活可恢复的租户订阅",
)
def activate_commercial_subscription(
tenant_id: str,
subscription_id: str,
payload: CommercialVersionAction,
db: DbSession,
current_user: PlatformAdmin,
request_id: RequestId,
) -> CommercialSubscriptionRead:
target = CommercialAccessPolicy.require_platform_admin(
current_user,
target_tenant_id=tenant_id,
)
try:
row = CommercialAdminService(db).activate_subscription(
target,
subscription_id,
expected_version=payload.expected_version,
actor_id=current_user.username,
request_id=request_id,
reason=payload.reason,
)
db.commit()
db.refresh(row)
return row
except Exception as error:
db.rollback()
raise _http_error(error) from error
@router.post(
"/admin/tenants/{tenant_id}/subscriptions/{subscription_id}/transition",
response_model=CommercialSubscriptionRead,
summary="暂停、标记逾期或终止租户订阅",
)
def transition_commercial_subscription(
tenant_id: str,
subscription_id: str,
payload: CommercialSubscriptionTransition,
db: DbSession,
current_user: PlatformAdmin,
request_id: RequestId,
) -> CommercialSubscriptionRead:
target = CommercialAccessPolicy.require_platform_admin(
current_user,
target_tenant_id=tenant_id,
)
try:
row = CommercialAdminService(db).transition_subscription(
target,
subscription_id,
payload,
actor_id=current_user.username,
request_id=request_id,
)
db.commit()
db.refresh(row)
return row
except Exception as error:
db.rollback()
raise _http_error(error) from error
@router.get(
"/admin/tenants/{tenant_id}/plans",
response_model=list[CommercialPlanRead],
summary="查询租户套餐版本历史",
)
def list_commercial_plans(
tenant_id: str,
db: DbSession,
current_user: PlatformAdmin,
plan_status: str | None = None,
limit: int = Query(default=100, ge=1, le=200),
offset: int = Query(default=0, ge=0),
) -> list[CommercialPlanRead]:
target = CommercialAccessPolicy.require_platform_admin(
current_user,
target_tenant_id=tenant_id,
)
return CommercialQueryService(db).list_plans(
target,
status=plan_status,
limit=limit,
offset=offset,
)
@router.get(
"/admin/tenants/{tenant_id}/subscriptions",
response_model=list[CommercialSubscriptionRead],
summary="查询租户订阅历史",
)
def list_commercial_subscriptions(
tenant_id: str,
db: DbSession,
current_user: PlatformAdmin,
subscription_status: str | None = None,
limit: int = Query(default=100, ge=1, le=200),
offset: int = Query(default=0, ge=0),
) -> list[CommercialSubscriptionRead]:
target = CommercialAccessPolicy.require_platform_admin(
current_user,
target_tenant_id=tenant_id,
)
return CommercialQueryService(db).list_subscriptions(
target,
status=subscription_status,
limit=limit,
offset=offset,
)
@router.get(
"/admin/tenants/{tenant_id}/entitlements",
response_model=list[CommercialEntitlementRead],
summary="查询租户商业权益历史",
)
def list_commercial_entitlements(
tenant_id: str,
db: DbSession,
current_user: PlatformAdmin,
subscription_id: str | None = None,
billing_period_id: str | None = None,
entitlement_status: str | None = None,
limit: int = Query(default=200, ge=1, le=500),
offset: int = Query(default=0, ge=0),
) -> list[CommercialEntitlementRead]:
target = CommercialAccessPolicy.require_platform_admin(
current_user,
target_tenant_id=tenant_id,
)
return CommercialQueryService(db).list_entitlements(
target,
subscription_id=subscription_id,
billing_period_id=billing_period_id,
status=entitlement_status,
limit=limit,
offset=offset,
)
@router.get(
"/admin/tenants/{tenant_id}/usage-events",
response_model=list[UsageMeterEventRead],
summary="查询租户追加式用量事实",
)
def list_commercial_usage_events(
tenant_id: str,
db: DbSession,
current_user: PlatformAdmin,
subscription_id: str | None = None,
billing_period_id: str | None = None,
start: datetime | None = None,
end: datetime | None = None,
limit: int = Query(default=200, ge=1, le=500),
offset: int = Query(default=0, ge=0),
) -> list[UsageMeterEventRead]:
target = CommercialAccessPolicy.require_platform_admin(
current_user,
target_tenant_id=tenant_id,
)
try:
return CommercialQueryService(db).list_usage_events(
target,
subscription_id=subscription_id,
billing_period_id=billing_period_id,
start=start,
end=end,
limit=limit,
offset=offset,
)
except Exception as error:
raise _http_error(error) from error
@router.get(
"/admin/tenants/{tenant_id}/cost-events",
response_model=list[CommercialCostEventRead],
summary="查询租户追加式内部成本事实",
)
def list_commercial_cost_events(
tenant_id: str,
db: DbSession,
current_user: PlatformAdmin,
subscription_id: str | None = None,
billing_period_id: str | None = None,
start: datetime | None = None,
end: datetime | None = None,
limit: int = Query(default=200, ge=1, le=500),
offset: int = Query(default=0, ge=0),
) -> list[CommercialCostEventRead]:
target = CommercialAccessPolicy.require_platform_admin(
current_user,
target_tenant_id=tenant_id,
)
try:
return CommercialQueryService(db).list_cost_events(
target,
subscription_id=subscription_id,
billing_period_id=billing_period_id,
start=start,
end=end,
limit=limit,
offset=offset,
)
except Exception as error:
raise _http_error(error) from error
@router.put(
"/admin/tenants/{tenant_id}/entitlements",
response_model=CommercialEntitlementRead,
summary="创建或版本化更新租户商业权益",
)
def upsert_commercial_entitlement(
tenant_id: str,
payload: CommercialEntitlementUpsert,
db: DbSession,
current_user: PlatformAdmin,
request_id: RequestId,
) -> CommercialEntitlementRead:
target = CommercialAccessPolicy.require_platform_admin(
current_user,
target_tenant_id=tenant_id,
)
try:
row = CommercialAdminService(db).upsert_entitlement(
target,
payload,
actor_id=current_user.username,
request_id=request_id,
reason=payload.reason,
)
db.commit()
db.refresh(row)
return row
except Exception as error:
db.rollback()
raise _http_error(error) from error
@router.post(
"/admin/tenants/{tenant_id}/entitlements/{entitlement_id}/activate",
response_model=CommercialEntitlementRead,
summary="激活租户商业权益",
)
def activate_commercial_entitlement(
tenant_id: str,
entitlement_id: str,
payload: CommercialVersionAction,
db: DbSession,
current_user: PlatformAdmin,
request_id: RequestId,
) -> CommercialEntitlementRead:
target = CommercialAccessPolicy.require_platform_admin(
current_user,
target_tenant_id=tenant_id,
)
try:
row = CommercialAdminService(db).activate_entitlement(
target,
entitlement_id,
expected_version=payload.expected_version,
actor_id=current_user.username,
request_id=request_id,
reason=payload.reason,
)
db.commit()
db.refresh(row)
return row
except Exception as error:
db.rollback()
raise _http_error(error) from error
@router.post(
"/admin/tenants/{tenant_id}/usage-events",
response_model=CommercialMutationRead,
summary="幂等写入租户用量事件",
)
def record_commercial_usage(
tenant_id: str,
payload: UsageMeterEventCreate,
db: DbSession,
current_user: PlatformAdmin,
) -> CommercialMutationRead:
target = CommercialAccessPolicy.require_platform_admin(
current_user,
target_tenant_id=tenant_id,
)
try:
row, created = CommercialMeteringService(db).record_usage(
target,
payload,
actor_type="admin",
actor_id=current_user.username,
)
db.commit()
return CommercialMutationRead(created=created, usage_event=row)
except Exception as error:
db.rollback()
raise _http_error(error) from error
@router.post(
"/admin/tenants/{tenant_id}/cost-events",
response_model=CommercialMutationRead,
summary="幂等写入平台内部成本事件",
)
def record_commercial_cost(
tenant_id: str,
payload: CommercialCostEventCreate,
db: DbSession,
current_user: PlatformAdmin,
) -> CommercialMutationRead:
target = CommercialAccessPolicy.require_platform_admin(
current_user,
target_tenant_id=tenant_id,
)
try:
row, created = CommercialMeteringService(db).record_cost(target, payload)
db.commit()
return CommercialMutationRead(created=created, cost_event=row)
except Exception as error:
db.rollback()
raise _http_error(error) from error
@router.get(
"/admin/tenants/{tenant_id}/analytics",
response_model=CommercialAnalyticsRead,
summary="平台商业与客户价值分账分析",
)
def get_commercial_analytics(
tenant_id: str,
db: DbSession,
current_user: PlatformAdmin,
start: datetime | None = None,
end: datetime | None = None,
as_of: datetime | None = None,
) -> CommercialAnalyticsRead:
target = CommercialAccessPolicy.require_platform_admin(
current_user,
target_tenant_id=tenant_id,
)
now = datetime.now(UTC)
normalized_end = end or now
normalized_start = start or (normalized_end - timedelta(days=90))
try:
return CommercialAnalyticsService(db).build(
target,
start=normalized_start,
end=normalized_end,
as_of=as_of or now,
)
except Exception as error:
raise _http_error(error) from error
@router.post(
"/admin/tenants/{tenant_id}/pricing-scenarios",
response_model=CommercialPricingScenarioRead,
summary="按真实成本与确认价值计算可持续定价走廊",
)
def build_commercial_pricing_scenario(
tenant_id: str,
payload: CommercialPricingScenarioWrite,
db: DbSession,
current_user: PlatformAdmin,
) -> CommercialPricingScenarioRead:
target = CommercialAccessPolicy.require_platform_admin(
current_user,
target_tenant_id=tenant_id,
)
try:
return CommercialPricingService(db).build(target, payload)
except Exception as error:
raise _http_error(error) from error
def _http_error(error: Exception) -> HTTPException:
if isinstance(error, LookupError):
return HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(error))
if isinstance(error, CommercialPermissionError):
return HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(error))
if isinstance(error, CommercialConflictError):
return HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(error))
if isinstance(error, IntegrityError):
return HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="商业配置被并发修改或违反数据库唯一性约束,请刷新后重试。",
)
if isinstance(error, (CommercialConfigurationError, ValueError)):
return HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error))
raise error

View File

@@ -0,0 +1,142 @@
"""商业不可变账期和管理审计历史。"""
from __future__ import annotations
from datetime import datetime
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from app.api.deps import (
CurrentUserContext,
get_current_user,
get_db,
require_platform_admin_user,
)
from app.schemas.commercial_billing import (
CommercialAdminEventRead,
CommercialBillingPeriodRead,
CommercialBillingPeriodTenantRead,
)
from app.services.commercial_access_policy import (
CommercialAccessPolicy,
CommercialPermissionError,
)
from app.services.commercial_billing_periods import CommercialBillingPeriodService
from app.services.commercial_queries import CommercialQueryService
router = APIRouter(prefix="/commercial")
DbSession = Annotated[Session, Depends(get_db)]
CurrentUser = Annotated[CurrentUserContext, Depends(get_current_user)]
PlatformAdmin = Annotated[CurrentUserContext, Depends(require_platform_admin_user)]
@router.get(
"/billing-periods",
response_model=list[CommercialBillingPeriodTenantRead],
summary="读取当前租户不可变账期历史",
)
def list_current_tenant_billing_periods(
db: DbSession,
current_user: CurrentUser,
subscription_id: str | None = None,
as_of: datetime | None = None,
limit: int = Query(default=200, ge=1, le=500),
offset: int = Query(default=0, ge=0),
) -> list[CommercialBillingPeriodTenantRead]:
try:
tenant_id = CommercialAccessPolicy.require_account_read(current_user)
rows = _period_reads(
db,
tenant_id,
subscription_id=subscription_id,
as_of=as_of,
limit=limit,
offset=offset,
)
return [CommercialBillingPeriodTenantRead.model_validate(row.model_dump()) for row in rows]
except CommercialPermissionError as error:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(error)) from error
@router.get(
"/admin/tenants/{tenant_id}/billing-periods",
response_model=list[CommercialBillingPeriodRead],
summary="平台管理员读取租户不可变账期历史",
)
def list_tenant_billing_periods(
tenant_id: str,
db: DbSession,
current_user: PlatformAdmin,
subscription_id: str | None = None,
as_of: datetime | None = None,
limit: int = Query(default=200, ge=1, le=500),
offset: int = Query(default=0, ge=0),
) -> list[CommercialBillingPeriodRead]:
target = CommercialAccessPolicy.require_platform_admin(
current_user,
target_tenant_id=tenant_id,
)
return _period_reads(
db,
target,
subscription_id=subscription_id,
as_of=as_of,
limit=limit,
offset=offset,
)
@router.get(
"/admin/tenants/{tenant_id}/admin-events",
response_model=list[CommercialAdminEventRead],
summary="平台管理员读取商业配置与续期审计",
)
def list_tenant_commercial_admin_events(
tenant_id: str,
db: DbSession,
current_user: PlatformAdmin,
action: str | None = None,
resource_type: str | None = None,
resource_id: str | None = None,
start: datetime | None = None,
end: datetime | None = None,
limit: int = Query(default=200, ge=1, le=500),
offset: int = Query(default=0, ge=0),
) -> list[CommercialAdminEventRead]:
target = CommercialAccessPolicy.require_platform_admin(
current_user,
target_tenant_id=tenant_id,
)
try:
return CommercialQueryService(db).list_admin_events(
target,
action=action,
resource_type=resource_type,
resource_id=resource_id,
start=start,
end=end,
limit=limit,
offset=offset,
)
except ValueError as error:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error
def _period_reads(
db: Session,
tenant_id: str,
*,
subscription_id: str | None,
as_of: datetime | None,
limit: int,
offset: int,
) -> list[CommercialBillingPeriodRead]:
rows = CommercialQueryService(db).list_billing_periods(
tenant_id,
subscription_id=subscription_id,
limit=limit,
offset=offset,
)
return [CommercialBillingPeriodService.to_read(row, as_of=as_of) for row in rows]

View File

@@ -2,12 +2,13 @@ from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Depends, Query
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import func, or_, select
from sqlalchemy.orm import Session
from app.api.deps import CurrentUserContext, get_current_user, get_db
from app.models.employee import Employee
from app.models.financial_record import ExpenseClaim
from app.schemas.employee_profile import EmployeeProfileLatestRead
from app.services.account_behavior_profile import AccountBehaviorProfileService
from app.services.employee_behavior_profile_service import EmployeeBehaviorProfileService
@@ -32,7 +33,10 @@ def get_current_employee_latest_profile(
) -> EmployeeProfileLatestRead:
employee = _resolve_current_employee(db, current_user)
if employee is None:
return AccountBehaviorProfileService(db).get_latest_account_profile(
return AccountBehaviorProfileService(
db,
tenant_id=current_user.tenant_id,
).get_latest_account_profile(
account_id=current_user.username,
account_name=current_user.name,
identifiers=_current_account_identifiers(current_user),
@@ -41,7 +45,7 @@ def get_current_employee_latest_profile(
expense_type_scope=expense_type_scope,
)
service = EmployeeBehaviorProfileService(db)
service = EmployeeBehaviorProfileService(db, tenant_id=current_user.tenant_id)
latest = service.get_latest_profile(
employee_id=employee.id,
scene=scene,
@@ -65,13 +69,13 @@ def get_current_employee_latest_profile(
@router.get(
"/{employee_id}/latest",
"/{target_employee_id}/latest",
response_model=EmployeeProfileLatestRead,
summary="读取员工最新业务行为画像",
description="返回员工在指定场景下的最新画像快照,审批场景默认只展示费用支出和流程质量画像。",
)
def get_employee_latest_profile(
employee_id: str,
target_employee_id: str,
db: DbSession,
current_user: CurrentUser,
scene: Annotated[str, Query(max_length=50)] = "approval",
@@ -79,9 +83,25 @@ def get_employee_latest_profile(
window_days: Annotated[int, Query(ge=1, le=365)] = 90,
expense_type_scope: Annotated[str, Query(max_length=50)] = "overall",
) -> EmployeeProfileLatestRead:
del current_user
return EmployeeBehaviorProfileService(db).get_latest_profile(
employee_id=employee_id,
target = _resolve_tenant_employee(
db,
current_user.tenant_id,
target_employee_id,
)
if target is None or not _can_read_target(db, current_user, target):
_raise_not_found()
if claim_id and not _claim_matches_target(
db,
tenant_id=current_user.tenant_id,
claim_id=claim_id,
employee_id=target.id,
):
_raise_not_found()
return EmployeeBehaviorProfileService(
db,
tenant_id=current_user.tenant_id,
).get_latest_profile(
employee_id=target.id,
scene=scene,
claim_id=claim_id,
window_days=window_days,
@@ -93,7 +113,11 @@ def _resolve_current_employee(
db: Session,
current_user: CurrentUserContext,
) -> Employee | None:
tenant_id = str(current_user.tenant_id or "").strip()
if not tenant_id:
return None
identities = [
str(current_user.employee_id or "").strip(),
str(current_user.username or "").strip(),
str(current_user.name or "").strip(),
]
@@ -108,16 +132,95 @@ def _resolve_current_employee(
if email_values:
conditions.append(func.lower(Employee.email).in_(email_values))
if exact_values:
conditions.append(Employee.id.in_(exact_values))
conditions.append(Employee.name.in_(exact_values))
conditions.append(Employee.employee_no.in_(exact_values))
if not conditions:
return None
stmt = select(Employee).where(or_(*conditions)).order_by(Employee.created_at.asc()).limit(1)
stmt = (
select(Employee)
.where(
Employee.tenant_id == tenant_id,
or_(*conditions),
)
.order_by(Employee.created_at.asc())
.limit(1)
)
return db.scalars(stmt).first()
def _resolve_tenant_employee(
db: Session,
tenant_id: str,
identifier: str,
) -> Employee | None:
normalized_tenant = str(tenant_id or "").strip()
normalized = str(identifier or "").strip()
if not normalized_tenant or not normalized:
return None
conditions = [
Employee.id == normalized,
Employee.employee_no == normalized,
Employee.name == normalized,
]
if "@" in normalized:
conditions.append(func.lower(Employee.email) == normalized.lower())
return db.scalars(
select(Employee)
.where(
Employee.tenant_id == normalized_tenant,
or_(*conditions),
)
.order_by(Employee.created_at.asc())
.limit(1)
).first()
def _can_read_target(
db: Session,
current_user: CurrentUserContext,
target: Employee,
) -> bool:
current = _resolve_current_employee(db, current_user)
if current is not None and current.id == target.id:
return True
role_codes = {str(item or "").strip().lower() for item in current_user.role_codes or []}
if current_user.is_admin or role_codes & {"finance", "executive"}:
return True
return bool(
current is not None
and role_codes & {"manager", "approver"}
and target.manager_id == current.id
)
def _claim_matches_target(
db: Session,
*,
tenant_id: str,
claim_id: str,
employee_id: str,
) -> bool:
return bool(
db.scalar(
select(ExpenseClaim.id).where(
ExpenseClaim.tenant_id == str(tenant_id or "").strip(),
ExpenseClaim.id == str(claim_id or "").strip(),
ExpenseClaim.employee_id == employee_id,
)
)
)
def _raise_not_found() -> None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="员工画像不存在。",
)
def _missing_usage_duration_metric(latest: EmployeeProfileLatestRead) -> bool:
if latest.scene != "operations":
return False

View File

@@ -6,7 +6,7 @@ from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile,
from fastapi.responses import Response
from sqlalchemy.orm import Session
from app.api.deps import get_db, require_admin_user
from app.api.deps import CurrentUserContext, get_db, require_admin_user
from app.api.pagination import PageNumber, PageSize, page_payload, wants_page
from app.schemas.common import ErrorResponse, PaginatedResponse
from app.schemas.employee import (
@@ -21,6 +21,11 @@ from app.services.employee_pagination import EmployeePaginationService
router = APIRouter(dependencies=[Depends(require_admin_user)])
DbSession = Annotated[Session, Depends(get_db)]
AdminUser = Annotated[CurrentUserContext, Depends(require_admin_user)]
def _employee_service(db: Session, current_user: CurrentUserContext) -> EmployeeService:
return EmployeeService(db, tenant_id=current_user.tenant_id)
@router.get(
@@ -29,8 +34,8 @@ DbSession = Annotated[Session, Depends(get_db)]
summary="读取员工目录元数据",
description="返回员工总数、状态汇总和可选角色列表,供员工管理页面初始化使用。",
)
def get_employee_meta(db: DbSession) -> EmployeeMetaRead:
return EmployeeService(db).get_employee_meta()
def get_employee_meta(db: DbSession, current_user: AdminUser) -> EmployeeMetaRead:
return _employee_service(db, current_user).get_employee_meta()
@router.get(
@@ -41,6 +46,7 @@ def get_employee_meta(db: DbSession) -> EmployeeMetaRead:
)
def list_employees(
db: DbSession,
current_user: AdminUser,
status_filter: Annotated[
str | None,
Query(alias="status", description="员工状态筛选值。"),
@@ -54,14 +60,20 @@ def list_employees(
) -> list[EmployeeRead] | PaginatedResponse[EmployeeRead]:
if wants_page(page, page_size):
return page_payload(
EmployeePaginationService(db).list_employees_page(
EmployeePaginationService(
db,
tenant_id=current_user.tenant_id,
).list_employees_page(
status=status_filter,
keyword=keyword,
page=page,
page_size=page_size,
)
)
return EmployeeService(db).list_employees(status=status_filter, keyword=keyword)
return _employee_service(db, current_user).list_employees(
status=status_filter,
keyword=keyword,
)
@router.get(
@@ -69,8 +81,8 @@ def list_employees(
summary="下载员工导入模板",
description="下载固定格式的员工 Excel 导入模板。",
)
def download_employee_import_template(db: DbSession) -> Response:
content = EmployeeService(db).build_import_template()
def download_employee_import_template(db: DbSession, current_user: AdminUser) -> Response:
content = _employee_service(db, current_user).build_import_template()
return Response(
content=content,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
@@ -87,6 +99,7 @@ def download_employee_import_template(db: DbSession) -> Response:
)
def export_employees(
db: DbSession,
current_user: AdminUser,
status_filter: Annotated[
str | None,
Query(alias="status", description="员工状态筛选值。"),
@@ -96,7 +109,10 @@ def export_employees(
Query(description="姓名、工号、邮箱等关键字模糊查询。"),
] = None,
) -> Response:
content = EmployeeService(db).export_employees(status=status_filter, keyword=keyword)
content = _employee_service(db, current_user).export_employees(
status=status_filter,
keyword=keyword,
)
return Response(
content=content,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
@@ -112,6 +128,7 @@ def export_employees(
)
async def import_employees(
db: DbSession,
current_user: AdminUser,
file: Annotated[UploadFile, File(description="待导入的员工 Excel 文件。")],
) -> EmployeeImportResultRead:
filename = (file.filename or "").lower()
@@ -122,7 +139,10 @@ async def import_employees(
)
content = await file.read()
return EmployeeService(db).import_employees(content)
return _employee_service(db, current_user).import_employees(
content,
actor=current_user.username,
)
@router.post(
@@ -138,9 +158,13 @@ async def import_employees(
}
},
)
def create_employee(payload: EmployeeCreate, db: DbSession) -> EmployeeRead:
def create_employee(
payload: EmployeeCreate,
db: DbSession,
current_user: AdminUser,
) -> EmployeeRead:
try:
return EmployeeService(db).create_employee(payload)
return _employee_service(db, current_user).create_employee(payload)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
@@ -157,8 +181,12 @@ def create_employee(payload: EmployeeCreate, db: DbSession) -> EmployeeRead:
}
},
)
def get_employee(employee_id: str, db: DbSession) -> EmployeeRead:
employee = EmployeeService(db).get_employee(employee_id)
def get_employee(
employee_id: str,
db: DbSession,
current_user: AdminUser,
) -> EmployeeRead:
employee = _employee_service(db, current_user).get_employee(employee_id)
if employee is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Employee not found")
return employee
@@ -180,9 +208,14 @@ def get_employee(employee_id: str, db: DbSession) -> EmployeeRead:
},
},
)
def update_employee(employee_id: str, payload: EmployeeUpdate, db: DbSession) -> EmployeeRead:
def update_employee(
employee_id: str,
payload: EmployeeUpdate,
db: DbSession,
current_user: AdminUser,
) -> EmployeeRead:
try:
return EmployeeService(db).update_employee(employee_id, payload)
return _employee_service(db, current_user).update_employee(employee_id, payload)
except LookupError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
except ValueError as exc:
@@ -201,9 +234,13 @@ def update_employee(employee_id: str, payload: EmployeeUpdate, db: DbSession) ->
}
},
)
def disable_employee(employee_id: str, db: DbSession) -> EmployeeRead:
def disable_employee(
employee_id: str,
db: DbSession,
current_user: AdminUser,
) -> EmployeeRead:
try:
return EmployeeService(db).disable_employee(employee_id)
return _employee_service(db, current_user).disable_employee(employee_id)
except LookupError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
@@ -220,8 +257,12 @@ def disable_employee(employee_id: str, db: DbSession) -> EmployeeRead:
}
},
)
def enable_employee(employee_id: str, db: DbSession) -> EmployeeRead:
def enable_employee(
employee_id: str,
db: DbSession,
current_user: AdminUser,
) -> EmployeeRead:
try:
return EmployeeService(db).enable_employee(employee_id)
return _employee_service(db, current_user).enable_employee(employee_id)
except LookupError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc

View File

@@ -0,0 +1,76 @@
from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.api.deps import CurrentUserContext, get_current_user, get_db
from app.schemas.finance_report_config import (
FinanceReportConfigRead,
FinanceReportConfigUpdate,
)
from app.services.expense_claim_access_policy import ExpenseClaimAccessPolicy
from app.services.finance_report_tenant import TenantFinanceReportConfigService
router = APIRouter(prefix="/finance-report-config")
DbSession = Annotated[Session, Depends(get_db)]
CurrentUser = Annotated[CurrentUserContext, Depends(get_current_user)]
MANAGE_ROLES = frozenset({"finance", "executive"})
@router.get("", response_model=FinanceReportConfigRead)
def get_finance_report_config(
db: DbSession,
current_user: CurrentUser,
) -> FinanceReportConfigRead:
_require_manage(current_user)
row = TenantFinanceReportConfigService(db).get(tenant_id=current_user.tenant_id)
if row is None:
return FinanceReportConfigRead(
tenant_id=current_user.tenant_id,
status="disabled",
delivery_enabled=False,
)
return _serialize(row)
@router.put("", response_model=FinanceReportConfigRead)
def update_finance_report_config(
payload: FinanceReportConfigUpdate,
db: DbSession,
current_user: CurrentUser,
) -> FinanceReportConfigRead:
_require_manage(current_user)
try:
row = TenantFinanceReportConfigService(db).upsert(
tenant_id=current_user.tenant_id,
recipients=payload.recipients,
delivery_enabled=payload.delivery_enabled,
updated_by=current_user.username,
)
except (LookupError, ValueError) as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
return _serialize(row)
def _require_manage(current_user: CurrentUserContext) -> None:
tenant_id = str(current_user.tenant_id or "").strip()
roles = ExpenseClaimAccessPolicy.normalize_role_codes(current_user)
if tenant_id and (current_user.is_admin or roles & MANAGE_ROLES):
return
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="只有本租户财务人员或管理员可以维护报告收件配置。",
)
def _serialize(row) -> FinanceReportConfigRead:
return FinanceReportConfigRead(
tenant_id=row.tenant_id,
status=row.status,
delivery_enabled=row.delivery_enabled,
recipients=[str(item) for item in list(row.recipients_json or [])],
updated_by=row.updated_by,
updated_at=row.updated_at,
)

View File

@@ -0,0 +1,544 @@
from __future__ import annotations
import logging
from typing import Annotated
from fastapi import APIRouter, Depends, Header, HTTPException, Query, status
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.api.deps import (
CurrentUserContext,
get_current_user,
get_db,
require_platform_admin_user,
)
from app.schemas.financial_connector import (
FinancialConnectorConfigCreate,
FinancialConnectorConfigEventRead,
FinancialConnectorConfigLifecycleAction,
FinancialConnectorConfigRead,
FinancialConnectorConfigRotateAction,
FinancialConnectorConfigRotationRead,
FinancialConnectorObservabilityRead,
FinancialConnectorSimulationCreate,
FinancialConnectorSimulationRead,
FinancialEventEnvelope,
FinancialEventIngestionRead,
FinancialPaymentEvidenceRead,
PaymentReconciliationActionCreate,
PaymentReconciliationCaseDetailRead,
PaymentReconciliationListRead,
)
from app.services.expense_claims import ExpenseClaimService
from app.services.financial_connector_auth import FinancialConnectorAuthError
from app.services.financial_connector_commercial import (
FinancialConnectorCommercialAccessDenied,
)
from app.services.financial_connector_config_lifecycle import (
FinancialConnectorConfigConflictError,
FinancialConnectorConfigLifecycleService,
)
from app.services.financial_connector_configs import (
FinancialConnectorConfigError,
FinancialConnectorConfigService,
)
from app.services.financial_connector_ingestion import (
FinancialConnectorConflictError,
FinancialConnectorIngestionService,
)
from app.services.financial_connector_mock_adapter import (
FinancialConnectorMockAdapter,
FinancialConnectorMockAdapterError,
)
from app.services.financial_connector_observability import (
FinancialConnectorObservabilityPermissionError,
FinancialConnectorObservabilityService,
)
from app.services.financial_connector_operational_events import (
FinancialConnectorOperationalEventCandidate,
FinancialConnectorOperationalEventService,
)
from app.services.financial_connector_payment_evidence import (
FinancialConnectorPaymentEvidenceService,
)
from app.services.financial_connector_projection import (
FinancialConnectorProjectionService,
FinancialReconciliationConflictError,
FinancialReconciliationPermissionError,
)
router = APIRouter()
logger = logging.getLogger(__name__)
DbSession = Annotated[Session, Depends(get_db)]
CurrentUser = Annotated[CurrentUserContext, Depends(get_current_user)]
PlatformAdmin = Annotated[CurrentUserContext, Depends(require_platform_admin_user)]
@router.post(
"/integrations/financial-events",
response_model=FinancialEventIngestionRead,
summary="接收租户绑定且签名可验证的财务事件",
)
def ingest_financial_event(
payload: FinancialEventEnvelope,
db: DbSession,
tenant_id: Annotated[str, Header(alias="X-Financial-Tenant")],
provider: Annotated[str, Header(alias="X-Financial-Provider")],
key_version: Annotated[str, Header(alias="X-Financial-Key-Version")],
timestamp_value: Annotated[str, Header(alias="X-Financial-Timestamp")],
signature: Annotated[str, Header(alias="X-Financial-Signature")],
) -> FinancialEventIngestionRead:
try:
result = FinancialConnectorIngestionService(db).ingest(
payload,
tenant_header=tenant_id,
provider_header=provider,
key_version_header=key_version,
timestamp_header=timestamp_value,
signature_header=signature,
)
db.commit()
return result
except FinancialConnectorAuthError as error:
db.rollback()
if error.operational_event is not None:
_persist_operational_event(db, error.operational_event)
else:
logger.warning(
"financial_connector_auth_failure_unattributed code=%s "
"path=/api/v1/integrations/financial-events",
error.code,
)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail={"code": error.code, "message": str(error)},
) from error
except FinancialConnectorConflictError as error:
db.rollback()
_persist_operational_event(db, error.operational_event)
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(error)) from error
except IntegrityError as error:
db.rollback()
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="事件正由另一请求处理,请使用相同 external_event_id 安全重试。",
) from error
except FinancialConnectorCommercialAccessDenied as error:
db.rollback()
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=str(error),
) from error
except (ValueError, PermissionError) as error:
db.rollback()
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error
except Exception:
db.rollback()
raise
@router.post(
"/financial-connectors/admin/tenants/{tenant_id}/configs",
response_model=FinancialConnectorConfigRead,
status_code=status.HTTP_201_CREATED,
summary="平台管理员创建不含密钥明文的连接器配置",
)
def create_financial_connector_config(
tenant_id: str,
payload: FinancialConnectorConfigCreate,
db: DbSession,
current_user: PlatformAdmin,
) -> FinancialConnectorConfigRead:
try:
row = FinancialConnectorConfigService(db).create(
tenant_id=tenant_id,
payload=payload,
actor_id=current_user.username,
)
db.commit()
db.refresh(row)
return FinancialConnectorConfigRead.model_validate(row)
except (FinancialConnectorConfigError, IntegrityError) as error:
db.rollback()
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(error)) from error
@router.get(
"/financial-connectors/admin/tenants/{tenant_id}/configs",
response_model=list[FinancialConnectorConfigRead],
summary="平台管理员读取脱敏连接器配置",
)
def list_financial_connector_configs(
tenant_id: str,
db: DbSession,
current_user: PlatformAdmin,
) -> list[FinancialConnectorConfigRead]:
del current_user
return [
FinancialConnectorConfigRead.model_validate(item)
for item in FinancialConnectorConfigService(db).list_for_tenant(tenant_id)
]
@router.post(
"/financial-connectors/admin/tenants/{tenant_id}/configs/{config_id}/activate",
response_model=FinancialConnectorConfigRead,
summary="校验服务端密钥后激活指定版本连接器",
)
def activate_financial_connector_config(
tenant_id: str,
config_id: str,
payload: FinancialConnectorConfigLifecycleAction,
db: DbSession,
current_user: PlatformAdmin,
) -> FinancialConnectorConfigRead:
return _change_config_status(
tenant_id,
config_id,
payload,
db,
current_user,
action="activate",
)
@router.post(
"/financial-connectors/admin/tenants/{tenant_id}/configs/{config_id}/disable",
response_model=FinancialConnectorConfigRead,
summary="按乐观版本停用连接器",
)
def disable_financial_connector_config(
tenant_id: str,
config_id: str,
payload: FinancialConnectorConfigLifecycleAction,
db: DbSession,
current_user: PlatformAdmin,
) -> FinancialConnectorConfigRead:
return _change_config_status(
tenant_id,
config_id,
payload,
db,
current_user,
action="disable",
)
@router.post(
"/financial-connectors/admin/tenants/{tenant_id}/configs/{config_id}/rotate",
response_model=FinancialConnectorConfigRotationRead,
summary="原子轮换连接器密钥版本",
)
def rotate_financial_connector_config(
tenant_id: str,
config_id: str,
payload: FinancialConnectorConfigRotateAction,
db: DbSession,
current_user: PlatformAdmin,
) -> FinancialConnectorConfigRotationRead:
try:
result = FinancialConnectorConfigLifecycleService(db).rotate(
tenant_id=tenant_id,
config_id=config_id,
payload=payload,
actor_id=current_user.username,
)
db.commit()
db.refresh(result.previous)
db.refresh(result.replacement)
return FinancialConnectorConfigRotationRead(
previous=FinancialConnectorConfigRead.model_validate(result.previous),
replacement=FinancialConnectorConfigRead.model_validate(result.replacement),
)
except LookupError as error:
db.rollback()
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(error)) from error
except FinancialConnectorAuthError as error:
db.rollback()
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={"code": error.code, "message": str(error)},
) from error
except (FinancialConnectorConfigConflictError, IntegrityError) as error:
db.rollback()
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(error)) from error
@router.get(
"/financial-connectors/admin/tenants/{tenant_id}/config-events",
response_model=list[FinancialConnectorConfigEventRead],
summary="读取不含密钥材料的连接器配置审计时间线",
)
def list_financial_connector_config_events(
tenant_id: str,
db: DbSession,
current_user: PlatformAdmin,
config_id: str | None = Query(default=None),
) -> list[FinancialConnectorConfigEventRead]:
del current_user
return [
FinancialConnectorConfigEventRead.model_validate(item)
for item in FinancialConnectorConfigLifecycleService(db).list_events(
tenant_id=tenant_id,
config_id=config_id,
)
]
@router.post(
"/financial-connectors/admin/tenants/{tenant_id}/configs/{config_id}/simulate",
response_model=FinancialConnectorSimulationRead,
summary="平台管理员运行确定性的非生产连接器场景",
)
def simulate_financial_connector_event(
tenant_id: str,
config_id: str,
payload: FinancialConnectorSimulationCreate,
db: DbSession,
current_user: PlatformAdmin,
) -> FinancialConnectorSimulationRead:
del current_user
try:
result = FinancialConnectorMockAdapter(db).run(
tenant_id=tenant_id,
config_id=config_id,
payload=payload,
)
db.commit()
return result
except LookupError as error:
db.rollback()
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(error)) from error
except FinancialConnectorAuthError as error:
db.rollback()
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={"code": error.code, "message": str(error)},
) from error
except FinancialConnectorMockAdapterError as error:
db.rollback()
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error
except FinancialConnectorCommercialAccessDenied as error:
db.rollback()
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=str(error),
) from error
except IntegrityError as error:
db.rollback()
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="模拟场景正由另一请求处理,请使用相同 request_id 安全重试。",
) from error
except Exception:
db.rollback()
raise
@router.get(
"/financial-connectors/observability",
response_model=FinancialConnectorObservabilityRead,
summary="读取当前租户脱敏连接器运行指标",
)
def get_current_tenant_connector_observability(
db: DbSession,
current_user: CurrentUser,
window_hours: int = Query(default=24, ge=1, le=720),
) -> FinancialConnectorObservabilityRead:
try:
return FinancialConnectorObservabilityService(db).read_for_current_user(
current_user,
window_hours=window_hours,
)
except FinancialConnectorObservabilityPermissionError as error:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(error)) from error
@router.get(
"/financial-connectors/admin/tenants/{tenant_id}/observability",
response_model=FinancialConnectorObservabilityRead,
summary="平台管理员读取目标租户脱敏连接器运行指标",
)
def get_tenant_connector_observability(
tenant_id: str,
db: DbSession,
current_user: PlatformAdmin,
window_hours: int = Query(default=24, ge=1, le=720),
) -> FinancialConnectorObservabilityRead:
del current_user
return FinancialConnectorObservabilityService(db).read_for_tenant(
tenant_id,
window_hours=window_hours,
)
@router.get(
"/financial-connectors/payment-evidence/{claim_id}",
response_model=FinancialPaymentEvidenceRead,
summary="读取当前租户单据的付款证据等级",
)
def get_financial_payment_evidence(
claim_id: str,
db: DbSession,
current_user: CurrentUser,
) -> FinancialPaymentEvidenceRead:
claim = ExpenseClaimService(db).get_claim(claim_id, current_user)
if claim is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="报销单不存在。")
return FinancialConnectorPaymentEvidenceService.read(claim)
@router.get(
"/financial-reconciliation/cases",
response_model=PaymentReconciliationListRead,
summary="分页读取当前租户对账记录",
)
def list_payment_reconciliation_cases(
db: DbSession,
current_user: CurrentUser,
reconciliation_status: str | None = Query(default=None, alias="status"),
page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=100),
) -> PaymentReconciliationListRead:
try:
return FinancialConnectorProjectionService(db).list_cases(
current_user,
status_filter=reconciliation_status,
page=page,
page_size=page_size,
)
except FinancialReconciliationPermissionError as error:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(error)) from error
@router.get(
"/financial-reconciliation/cases/{case_id}",
response_model=PaymentReconciliationCaseDetailRead,
summary="读取当前租户脱敏对账详情和追加式时间线",
)
def get_payment_reconciliation_case(
case_id: str,
db: DbSession,
current_user: CurrentUser,
) -> PaymentReconciliationCaseDetailRead:
try:
result = FinancialConnectorProjectionService(db).detail(case_id, current_user)
except FinancialReconciliationPermissionError as error:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(error)) from error
if result is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="对账记录不存在。")
return result
@router.post(
"/financial-reconciliation/cases/{case_id}/confirm",
response_model=PaymentReconciliationCaseDetailRead,
summary="财务人员确认异常处置,不伪造付款成功",
)
def confirm_payment_reconciliation_case(
case_id: str,
payload: PaymentReconciliationActionCreate,
db: DbSession,
current_user: CurrentUser,
) -> PaymentReconciliationCaseDetailRead:
return _resolve_case(case_id, payload, db, current_user, action="confirmed")
@router.post(
"/financial-reconciliation/cases/{case_id}/reject",
response_model=PaymentReconciliationCaseDetailRead,
summary="财务人员拒绝错误回执",
)
def reject_payment_reconciliation_case(
case_id: str,
payload: PaymentReconciliationActionCreate,
db: DbSession,
current_user: CurrentUser,
) -> PaymentReconciliationCaseDetailRead:
return _resolve_case(case_id, payload, db, current_user, action="rejected")
def _resolve_case(
case_id: str,
payload: PaymentReconciliationActionCreate,
db: Session,
current_user: CurrentUserContext,
*,
action: str,
) -> PaymentReconciliationCaseDetailRead:
try:
result = FinancialConnectorProjectionService(db).resolve(
case_id,
payload,
current_user,
action=action,
)
if result is None:
db.rollback()
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="对账记录不存在。")
db.commit()
return result
except HTTPException:
raise
except FinancialReconciliationPermissionError as error:
db.rollback()
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(error)) from error
except FinancialReconciliationConflictError as error:
db.rollback()
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(error)) from error
def _change_config_status(
tenant_id: str,
config_id: str,
payload: FinancialConnectorConfigLifecycleAction,
db: Session,
current_user: CurrentUserContext,
*,
action: str,
) -> FinancialConnectorConfigRead:
service = FinancialConnectorConfigLifecycleService(db)
try:
operation = service.activate if action == "activate" else service.disable
row = operation(
tenant_id=tenant_id,
config_id=config_id,
payload=payload,
actor_id=current_user.username,
)
db.commit()
db.refresh(row)
return FinancialConnectorConfigRead.model_validate(row)
except LookupError as error:
db.rollback()
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(error)) from error
except FinancialConnectorAuthError as error:
db.rollback()
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={"code": error.code, "message": str(error)},
) from error
except (FinancialConnectorConfigConflictError, IntegrityError) as error:
db.rollback()
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(error)) from error
def _persist_operational_event(
db: Session,
candidate: FinancialConnectorOperationalEventCandidate,
) -> None:
"""失败响应回滚后独立提交运营事实,写入失败不得改变原始业务结果。"""
try:
FinancialConnectorOperationalEventService(db).record(candidate)
db.commit()
except Exception: # noqa: BLE001 - 审计降级不能覆盖原始 401/409 契约
db.rollback()
logger.exception(
"financial_connector_operational_event_persist_failed "
"event_type=%s reason_code=%s config_id=%s",
candidate.event_type,
candidate.reason_code,
candidate.context.config_id,
)

View File

@@ -4,8 +4,7 @@ from typing import Annotated
from fastapi import APIRouter, Body, Depends, HTTPException, Query, status
from fastapi.responses import FileResponse
from sqlalchemy import select
from sqlalchemy.orm import Session
from sqlalchemy.orm import Session
from app.api.deps import CurrentUserContext, get_current_user, get_db, require_admin_user
from app.core.agent_enums import AgentName, AgentRunSource
@@ -24,9 +23,17 @@ from app.schemas.knowledge import (
LlmWikiSyncWrite,
)
from app.services.agent_runs import AgentRunService
from app.services.knowledge import (
KnowledgeService,
)
from app.services.knowledge import (
KnowledgeService,
)
from app.services.knowledge_onlyoffice_callback import (
handle_onlyoffice_callback,
resolve_onlyoffice_content,
)
from app.services.knowledge_onlyoffice_security import (
OnlyOfficeReplayError,
OnlyOfficeSecurityError,
)
from app.services.knowledge_sync import KnowledgeSyncDispatchService
router = APIRouter(prefix="/knowledge")
@@ -44,11 +51,11 @@ router = APIRouter(prefix="/knowledge")
}
},
)
def get_knowledge_library(
_: Annotated[CurrentUserContext, Depends(get_current_user)],
db: Annotated[Session, Depends(get_db)],
) -> KnowledgeLibraryRead:
return KnowledgeService(db=db).list_library()
def get_knowledge_library(
current_user: Annotated[CurrentUserContext, Depends(get_current_user)],
db: Annotated[Session, Depends(get_db)],
) -> KnowledgeLibraryRead:
return KnowledgeService(db=db, tenant_id=current_user.tenant_id).list_library()
@router.get(
@@ -67,14 +74,18 @@ def get_knowledge_library(
},
},
)
def get_llm_wiki_index(
_: Annotated[CurrentUserContext, Depends(require_admin_user)],
def get_llm_wiki_index(
current_user: Annotated[CurrentUserContext, Depends(require_admin_user)],
db: Annotated[Session, Depends(get_db)],
) -> LlmWikiIndexRead:
run_service = AgentRunService(db)
sync_runs = [
item
for item in run_service.list_runs(agent=AgentName.HERMES.value, limit=200)
for item in run_service.list_runs_for_tenant(
tenant_id=current_user.tenant_id,
agent=AgentName.HERMES.value,
limit=200,
)
if str(item.route_json.get("job_type") or "").strip() == "knowledge_index_sync"
]
return LlmWikiIndexRead(documents=[], sync_run_count=len(sync_runs))
@@ -198,7 +209,10 @@ def sync_knowledge_library(
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
if isinstance(exc, FileNotFoundError):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc)) from exc
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=str(exc),
) from exc
@router.get(
"/documents/{document_id}",
@@ -216,13 +230,16 @@ def sync_knowledge_library(
},
},
)
def get_knowledge_document(
document_id: str,
_: Annotated[CurrentUserContext, Depends(get_current_user)],
def get_knowledge_document(
document_id: str,
current_user: Annotated[CurrentUserContext, Depends(get_current_user)],
db: Annotated[Session, Depends(get_db)],
) -> KnowledgeDocumentDetailRead:
try:
return KnowledgeService(db=db).get_document_detail(document_id)
return KnowledgeService(
db=db,
tenant_id=current_user.tenant_id,
).get_document_detail(document_id)
except FileNotFoundError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
@@ -250,12 +267,24 @@ def get_knowledge_document(
},
},
)
def get_knowledge_document_onlyoffice_config(
document_id: str,
current_user: Annotated[CurrentUserContext, Depends(get_current_user)],
) -> KnowledgeOnlyOfficeConfigRead:
try:
return KnowledgeService().build_onlyoffice_config(document_id, current_user)
def get_knowledge_document_onlyoffice_config(
document_id: str,
current_user: Annotated[CurrentUserContext, Depends(get_current_user)],
db: Annotated[Session, Depends(get_db)],
editable: Annotated[
bool,
Query(description="是否申请租户知识文档编辑会话;默认仅预览。"),
] = False,
) -> KnowledgeOnlyOfficeConfigRead:
try:
return KnowledgeService(
db=db,
tenant_id=current_user.tenant_id,
).build_onlyoffice_config(
document_id,
current_user,
editable=editable,
)
except FileNotFoundError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
@@ -286,7 +315,7 @@ def get_knowledge_document_onlyoffice_config(
},
},
)
def upload_knowledge_document(
def upload_knowledge_document(
content: Annotated[
bytes,
Body(
@@ -296,10 +325,14 @@ def upload_knowledge_document(
],
folder: Annotated[str, Query(min_length=1, description="目标知识库目录名称。")],
filename: Annotated[str, Query(min_length=1, description="原始文件名。")],
current_user: Annotated[CurrentUserContext, Depends(require_admin_user)],
) -> KnowledgeDocumentDetailRead:
try:
return KnowledgeService().upload_document(folder, filename, content, current_user)
current_user: Annotated[CurrentUserContext, Depends(require_admin_user)],
db: Annotated[Session, Depends(get_db)],
) -> KnowledgeDocumentDetailRead:
try:
return KnowledgeService(
db=db,
tenant_id=current_user.tenant_id,
).upload_document(folder, filename, content, current_user)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
@@ -324,12 +357,16 @@ def upload_knowledge_document(
},
},
)
def delete_knowledge_document(
document_id: str,
_: Annotated[CurrentUserContext, Depends(require_admin_user)],
) -> KnowledgeActionResponse:
try:
KnowledgeService().delete_document(document_id)
def delete_knowledge_document(
document_id: str,
current_user: Annotated[CurrentUserContext, Depends(require_admin_user)],
db: Annotated[Session, Depends(get_db)],
) -> KnowledgeActionResponse:
try:
KnowledgeService(
db=db,
tenant_id=current_user.tenant_id,
).delete_document(document_id)
except FileNotFoundError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
@@ -359,19 +396,23 @@ def delete_knowledge_document(
},
},
)
def get_knowledge_document_content(
document_id: str,
disposition: Annotated[
str,
Query(
def get_knowledge_document_content(
document_id: str,
current_user: Annotated[CurrentUserContext, Depends(get_current_user)],
db: Annotated[Session, Depends(get_db)],
disposition: Annotated[
str,
Query(
pattern="^(inline|attachment)$",
description="内容展示方式,支持 `inline` 或 `attachment`。",
),
] = "inline",
_: Annotated[CurrentUserContext, Depends(get_current_user)] = None,
) -> FileResponse:
try:
file_path, media_type, filename = KnowledgeService().get_document_content(document_id)
description="内容展示方式,支持 `inline` 或 `attachment`。",
),
] = "inline",
) -> FileResponse:
try:
file_path, media_type, filename = KnowledgeService(
db=db,
tenant_id=current_user.tenant_id,
).get_document_content(document_id)
except FileNotFoundError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
@@ -402,24 +443,28 @@ def get_knowledge_document_content(
},
},
)
def get_knowledge_document_onlyoffice_content(
def get_knowledge_document_onlyoffice_content(
document_id: str,
access_token: Annotated[
access_token: Annotated[
str,
Query(min_length=1, description="ONLYOFFICE 临时访问令牌。"),
],
) -> FileResponse:
try:
service = KnowledgeService()
service.validate_onlyoffice_access_token(document_id, access_token)
file_path, media_type, filename = service.get_document_content(document_id)
],
db: Annotated[Session, Depends(get_db)],
) -> FileResponse:
try:
file_path, media_type, filename = resolve_onlyoffice_content(
db=db,
storage_root=None,
document_id=document_id,
access_token=access_token,
)
except FileNotFoundError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="知识库文件不存在。",
) from exc
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(exc)) from exc
except (OnlyOfficeSecurityError, OnlyOfficeReplayError) as exc:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(exc)) from exc
return FileResponse(file_path, media_type=media_type, filename=filename)
@@ -440,18 +485,33 @@ def get_knowledge_document_onlyoffice_content(
},
},
)
def handle_knowledge_document_onlyoffice_callback(
document_id: str,
payload: KnowledgeOnlyOfficeCallbackWrite,
) -> KnowledgeOnlyOfficeCallbackRead:
try:
KnowledgeService().handle_onlyoffice_callback(document_id, payload.model_dump())
def handle_knowledge_document_onlyoffice_callback(
document_id: str,
payload: KnowledgeOnlyOfficeCallbackWrite,
callback_token: Annotated[
str,
Query(min_length=1, description="绑定租户与文档的一次性回调会话令牌。"),
],
db: Annotated[Session, Depends(get_db)],
) -> KnowledgeOnlyOfficeCallbackRead:
try:
handle_onlyoffice_callback(
db=db,
storage_root=None,
document_id=document_id,
callback_token=callback_token,
payload=payload.model_dump(),
)
except FileNotFoundError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="知识库文件不存在。",
) from exc
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
except OnlyOfficeReplayError as exc:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
except OnlyOfficeSecurityError as exc:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
return KnowledgeOnlyOfficeCallbackRead()

View File

@@ -2,7 +2,7 @@ from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
from fastapi import APIRouter, Depends, File, Form, Header, HTTPException, UploadFile, status
from sqlalchemy.orm import Session
from starlette.concurrency import run_in_threadpool
@@ -10,6 +10,11 @@ from app.api.deps import CurrentUserContext, get_current_user, get_db
from app.schemas.common import ErrorResponse
from app.schemas.ocr import OcrRecognizeBatchRead
from app.services.ocr import OcrService
from app.services.ocr_commercial import (
OcrCommercialAccessDenied,
content_digest,
trusted_ocr_operation_context,
)
from app.services.receipt_folder import ReceiptFolderService
router = APIRouter(prefix="/ocr")
@@ -29,6 +34,10 @@ router = APIRouter(prefix="/ocr")
"model": ErrorResponse,
"description": "未提供当前登录用户。",
},
status.HTTP_429_TOO_MANY_REQUESTS: {
"model": ErrorResponse,
"description": "OCR 商业额度不足或计量配置不允许本次真实执行。",
},
status.HTTP_503_SERVICE_UNAVAILABLE: {
"model": ErrorResponse,
"description": "OCR 运行时不可用或执行失败。",
@@ -39,7 +48,10 @@ async def recognize_ocr_documents(
files: Annotated[list[UploadFile], File(description="待识别的票据图片或 PDF。")],
current_user: Annotated[CurrentUserContext, Depends(get_current_user)],
db: Annotated[Session, Depends(get_db)],
receipt_ids: Annotated[list[str] | None, Form(description="可选,来源于票据夹的持久化票据 ID。")] = None,
receipt_ids: Annotated[
list[str] | None, Form(description="可选,来源于票据夹的持久化票据 ID。")
] = None,
x_request_id: Annotated[str | None, Header(alias="X-Request-ID")] = None,
) -> OcrRecognizeBatchRead:
try:
payload = []
@@ -51,7 +63,15 @@ async def recognize_ocr_documents(
upload.content_type,
)
)
result = await run_in_threadpool(lambda: OcrService(db).recognize_files(payload))
operation_context = trusted_ocr_operation_context(
current_user,
operation_scope="ocr-endpoint",
content_digests=[content_digest(content) for _, content, _ in payload],
request_id=x_request_id or "",
)
result = await run_in_threadpool(
lambda: OcrService(db, operation_context=operation_context).recognize_files(payload)
)
return ReceiptFolderService().persist_ocr_batch(
files=payload,
result=result,
@@ -60,6 +80,11 @@ async def recognize_ocr_documents(
)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
except OcrCommercialAccessDenied as exc:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=str(exc),
) from exc
except RuntimeError as exc:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,

View File

@@ -5,13 +5,14 @@ from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.api.deps import get_db
from app.api.deps import CurrentUserContext, get_current_user, get_db
from app.schemas.common import ErrorResponse
from app.schemas.ontology import OntologyParseRequest, OntologyParseResult
from app.services.ontology import SemanticOntologyService
router = APIRouter(prefix="/ontology")
DbSession = Annotated[Session, Depends(get_db)]
CurrentUser = Annotated[CurrentUserContext, Depends(get_current_user)]
@router.post(
@@ -19,8 +20,7 @@ DbSession = Annotated[Session, Depends(get_db)]
response_model=OntologyParseResult,
summary="解析自然语言为语义本体",
description=(
"把自然语言问题解析成 Day 3 约定的 8 个核心字段,"
"并写入 AgentRun 与 SemanticParseLog。"
"把自然语言问题解析成 Day 3 约定的 8 个核心字段,并写入 AgentRun 与 SemanticParseLog。"
),
responses={
status.HTTP_400_BAD_REQUEST: {
@@ -29,8 +29,15 @@ DbSession = Annotated[Session, Depends(get_db)]
}
},
)
def parse_ontology(payload: OntologyParseRequest, db: DbSession) -> OntologyParseResult:
def parse_ontology(
payload: OntologyParseRequest,
db: DbSession,
current_user: CurrentUser,
) -> OntologyParseResult:
try:
return SemanticOntologyService(db).parse(payload)
return SemanticOntologyService(db).parse(
payload.model_copy(update={"user_id": current_user.username}),
tenant_id=current_user.tenant_id,
)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc

View File

@@ -24,7 +24,12 @@ DbSession = Annotated[Session, Depends(get_db)]
CurrentUser = Annotated[CurrentUserContext, Depends(get_current_user)]
def _raise_action_error(error: ValueError) -> NoReturn:
def _raise_action_error(error: Exception) -> NoReturn:
if isinstance(error, LookupError):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(error),
) from error
if isinstance(error, ExpenseClaimRiskBlockedError):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
@@ -87,7 +92,7 @@ def return_expense_claim(
task_id=payload.task_id,
expected_task_version=payload.expected_task_version,
)
except ValueError as error:
except (LookupError, ValueError) as error:
_raise_action_error(error)
if claim is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Claim not found")
@@ -128,7 +133,7 @@ def approve_expense_claim(
task_id=payload.task_id,
expected_task_version=payload.expected_task_version,
)
except ValueError as error:
except (LookupError, ValueError) as error:
_raise_action_error(error)
if claim is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Claim not found")
@@ -166,7 +171,7 @@ def pay_expense_claim(
expected_status=payload.expected_status,
expected_approval_stage=payload.expected_approval_stage,
)
except ValueError as error:
except (LookupError, ValueError) as error:
_raise_action_error(error)
if claim is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Claim not found")

View File

@@ -27,6 +27,9 @@ from app.schemas.reimbursement import (
TravelReimbursementCalculatorResponse,
)
from app.services.budget import BudgetService
from app.services.expense_claim_attachment_commercial import (
ExpenseClaimAttachmentCommercialAccessDenied,
)
from app.services.expense_claims import ExpenseClaimService
from app.services.reimbursement import ReimbursementService
from app.services.travel_reimbursement_calculator import TravelReimbursementCalculatorService
@@ -393,9 +396,14 @@ def delete_expense_claim_item(
current_user=current_user,
)
except LookupError as error:
db.rollback()
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(error)) from error
except ValueError as error:
db.rollback()
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error
except Exception:
db.rollback()
raise
if payload is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Claim not found")
@@ -427,6 +435,7 @@ async def upload_expense_claim_item_attachment(
receipt_id: Annotated[
str | None, Form(description="可选,来源于票据夹的持久化票据 ID。")
] = None,
request_id: RequestIdHeader = None,
) -> ExpenseClaimAttachmentActionResponse:
service = ExpenseClaimService(db)
try:
@@ -438,11 +447,23 @@ async def upload_expense_claim_item_attachment(
media_type=file.content_type,
current_user=current_user,
source_receipt_id=receipt_id or "",
request_id=request_id or "",
)
except LookupError as error:
db.rollback()
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(error)) from error
except ExpenseClaimAttachmentCommercialAccessDenied as error:
db.rollback()
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=str(error),
) from error
except ValueError as error:
db.rollback()
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error
except Exception:
db.rollback()
raise
if payload is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Claim not found")
@@ -588,11 +609,17 @@ def delete_expense_claim_item_attachment(
current_user=current_user,
)
except LookupError as error:
db.rollback()
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(error)) from error
except FileNotFoundError as error:
db.rollback()
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(error)) from error
except ValueError as error:
db.rollback()
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error
except Exception:
db.rollback()
raise
if payload is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Claim not found")
@@ -682,7 +709,11 @@ def delete_expense_claim(
try:
claim = service.delete_claim(claim_id, current_user)
except ValueError as error:
db.rollback()
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error
except Exception:
db.rollback()
raise
if claim is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Claim not found")

View File

@@ -0,0 +1,235 @@
from __future__ import annotations
from datetime import datetime
from typing import Annotated, Literal
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from app.api.deps import CurrentUserContext, get_current_user, get_db
from app.schemas.savings import (
SavingsOpportunityActionCreate,
SavingsOpportunityListRead,
SavingsOpportunityMutationRead,
SavingsOpportunityRead,
SavingsRealizationActionCreate,
SavingsRealizationCreate,
SavingsRealizationMutationRead,
)
from app.schemas.savings_insights import (
SavingsBaselineGenerateRequest,
SavingsBaselineGenerationRead,
SavingsInsightAnalysisRead,
SavingsInsightAnalyzeRequest,
)
from app.services.savings_access_policy import SavingsPermissionError
from app.services.savings_actions import SavingsActionService, SavingsTransitionError
from app.services.savings_baseline_generation import SavingsBaselineGenerationService
from app.services.savings_insight_analysis import SavingsInsightAnalysisService
from app.services.savings_protocol import (
SavingsIdempotencyConflictError,
SavingsVersionConflictError,
)
from app.services.savings_query import SavingsQueryService
from app.services.savings_realization import (
SavingsRealizationError,
SavingsRealizationService,
)
router = APIRouter(prefix="/savings")
DbSession = Annotated[Session, Depends(get_db)]
CurrentUser = Annotated[CurrentUserContext, Depends(get_current_user)]
@router.post(
"/baselines/generate",
response_model=SavingsBaselineGenerationRead,
summary="冻结租户费用历史基线",
)
def generate_savings_baselines(
payload: SavingsBaselineGenerateRequest,
db: DbSession,
current_user: CurrentUser,
) -> SavingsBaselineGenerationRead:
try:
return SavingsBaselineGenerationService(db).generate(payload, current_user)
except Exception as error:
raise _mutation_http_error(error) from error
@router.post(
"/insights/analyze",
response_model=SavingsInsightAnalysisRead,
summary="分析费用节省候选信号",
)
def analyze_savings_insights(
payload: SavingsInsightAnalyzeRequest,
db: DbSession,
current_user: CurrentUser,
) -> SavingsInsightAnalysisRead:
try:
return SavingsInsightAnalysisService(db).analyze(payload, current_user)
except Exception as error:
raise _mutation_http_error(error) from error
@router.get(
"/opportunities",
response_model=SavingsOpportunityListRead,
summary="查询节省机会台账",
)
def list_savings_opportunities(
db: DbSession,
current_user: CurrentUser,
page: Annotated[int, Query(ge=1)] = 1,
page_size: Annotated[int, Query(ge=1, le=100)] = 20,
status_value: Annotated[str | None, Query(alias="status", max_length=24)] = None,
source_type: Annotated[str | None, Query(max_length=50)] = None,
value_kind: Annotated[Literal["cash", "labor"] | None, Query()] = None,
department_id: Annotated[str | None, Query(max_length=160)] = None,
project_code: Annotated[str | None, Query(max_length=160)] = None,
expense_type: Annotated[str | None, Query(max_length=80)] = None,
supplier_id: Annotated[str | None, Query(max_length=160)] = None,
city: Annotated[str | None, Query(max_length=160)] = None,
owner_id: Annotated[str | None, Query(max_length=120)] = None,
claim_id: Annotated[str | None, Query(max_length=36)] = None,
created_from: datetime | None = None,
created_to: datetime | None = None,
sort: Annotated[
Literal["created_desc", "created_asc", "due_asc", "estimated_desc"],
Query(),
] = "created_desc",
) -> SavingsOpportunityListRead:
return SavingsQueryService(db).list_opportunities(
current_user,
page=page,
page_size=page_size,
status=status_value,
source_type=source_type,
value_kind=value_kind,
department_id=department_id,
project_code=project_code,
expense_type=expense_type,
supplier_id=supplier_id,
city=city,
owner_id=owner_id,
claim_id=claim_id,
created_from=created_from,
created_to=created_to,
sort=sort,
)
@router.get(
"/opportunities/{opportunity_id}",
response_model=SavingsOpportunityRead,
summary="读取节省机会证据链",
)
def get_savings_opportunity(
opportunity_id: str,
db: DbSession,
current_user: CurrentUser,
) -> SavingsOpportunityRead:
opportunity = SavingsQueryService(db).get_opportunity(opportunity_id, current_user)
if opportunity is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="节省机会不存在。")
return opportunity
@router.post(
"/opportunities/{opportunity_id}/actions",
response_model=SavingsOpportunityMutationRead,
summary="执行节省机会状态动作",
)
def execute_savings_opportunity_action(
opportunity_id: str,
payload: SavingsOpportunityActionCreate,
db: DbSession,
current_user: CurrentUser,
) -> SavingsOpportunityMutationRead:
try:
return (
SavingsActionService(db)
.execute(
opportunity_id,
payload,
current_user,
)
.response
)
except Exception as error:
raise _mutation_http_error(error) from error
@router.post(
"/opportunities/{opportunity_id}/realizations",
response_model=SavingsRealizationMutationRead,
summary="记录节省机会实际结果",
)
def record_savings_realization(
opportunity_id: str,
payload: SavingsRealizationCreate,
db: DbSession,
current_user: CurrentUser,
) -> SavingsRealizationMutationRead:
try:
return (
SavingsRealizationService(db)
.record(
opportunity_id,
payload,
current_user,
)
.response
)
except Exception as error:
raise _mutation_http_error(error) from error
@router.post(
"/realizations/{realization_id}/actions",
response_model=SavingsRealizationMutationRead,
summary="确认、拒绝或冲回实际节省",
)
def execute_savings_realization_action(
realization_id: str,
payload: SavingsRealizationActionCreate,
db: DbSession,
current_user: CurrentUser,
) -> SavingsRealizationMutationRead:
try:
return (
SavingsRealizationService(db)
.execute_action(
realization_id,
payload,
current_user,
)
.response
)
except Exception as error:
raise _mutation_http_error(error) from error
def _mutation_http_error(error: Exception) -> HTTPException:
if isinstance(error, LookupError):
return HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(error))
if isinstance(error, SavingsPermissionError):
return HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(error))
if isinstance(error, SavingsVersionConflictError):
return HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail={"message": str(error), "current_version": error.current_version},
)
if isinstance(
error,
(
SavingsIdempotencyConflictError,
SavingsTransitionError,
SavingsRealizationError,
),
):
return HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(error))
if isinstance(error, (ValueError, PermissionError)):
return HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error))
raise error

View File

@@ -27,6 +27,7 @@ from app.schemas.steward import (
)
from app.services.agent_conversations import AgentConversationService
from app.services.expense_claim_draft_flow import APPROVED_APPLICATION_LINK_STATUSES
from app.services.expense_claim_tenant_scope import ExpenseClaimTenantScopeMixin
from app.services.expense_claims import ExpenseClaimService
from app.services.runtime_chat import RuntimeChatService
from app.services.steward_context_resume import (
@@ -61,15 +62,30 @@ StewardPlannerLike = StewardPlannerService | StewardGraphPlannerService
}
},
)
def create_steward_plan(payload: StewardPlanRequest, db: DbSession) -> StewardPlanResponse:
def create_steward_plan(
payload: StewardPlanRequest,
db: DbSession,
current_user: CurrentUser,
) -> StewardPlanResponse:
try:
payload = _bind_authenticated_plan_request(payload, current_user)
planner = _build_steward_planner(db)
hydrated_payload = _hydrate_required_application_gate(db, payload, planner)
hydrated_payload = _hydrate_required_application_gate(
db,
payload,
planner,
tenant_id=_require_steward_tenant_id(current_user),
)
if isinstance(planner, StewardGraphPlannerService):
plan = planner.build_plan(hydrated_payload, db=db)
else:
plan = planner.build_plan(hydrated_payload)
return _attach_conversation_state(db, hydrated_payload, plan)
return _attach_conversation_state(
db,
hydrated_payload,
plan,
current_user=current_user,
)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
@@ -83,8 +99,10 @@ def create_steward_plan(payload: StewardPlanRequest, db: DbSession) -> StewardPl
def create_steward_slot_decision(
payload: StewardSlotDecisionRequest,
db: DbSession,
current_user: CurrentUser,
) -> StewardSlotDecisionResponse:
return _decide_steward_slot(payload, RuntimeChatService(db))
authenticated_payload = _bind_authenticated_slot_request(payload, current_user)
return _decide_steward_slot(authenticated_payload, RuntimeChatService(db))
@router.post(
@@ -96,10 +114,21 @@ def create_steward_slot_decision(
def create_steward_runtime_decision(
payload: StewardRuntimeDecisionRequest,
db: DbSession,
current_user: CurrentUser,
) -> StewardRuntimeDecisionResponse:
hydrated_payload = _hydrate_runtime_decision_payload(db, payload)
authenticated_payload = _bind_authenticated_runtime_request(payload, current_user)
hydrated_payload = _hydrate_runtime_decision_payload(
db,
authenticated_payload,
current_user=current_user,
)
decision = _decide_steward_runtime(hydrated_payload, RuntimeChatService(db))
return _attach_runtime_conversation_state(db, hydrated_payload, decision)
return _attach_runtime_conversation_state(
db,
hydrated_payload,
decision,
current_user=current_user,
)
@router.post(
@@ -124,9 +153,19 @@ def execute_steward_action(
summary="流式生成小财管家任务计划",
description="以 NDJSON 逐条返回小财管家的过程摘要事件,最后返回完整任务计划。",
)
async def stream_steward_plan(payload: StewardPlanRequest, db: DbSession) -> StreamingResponse:
async def stream_steward_plan(
payload: StewardPlanRequest,
db: DbSession,
current_user: CurrentUser,
) -> StreamingResponse:
authenticated_payload = _bind_authenticated_plan_request(payload, current_user)
return StreamingResponse(
_iter_steward_plan_events(payload, _build_steward_planner(db), db),
_iter_steward_plan_events(
authenticated_payload,
_build_steward_planner(db),
db,
current_user=current_user,
),
media_type="application/x-ndjson",
)
@@ -135,6 +174,8 @@ async def _iter_steward_plan_events(
payload: StewardPlanRequest,
planner: StewardPlannerLike,
db: Session,
*,
current_user: CurrentUserContext,
) -> AsyncIterator[str]:
yield _encode_stream_event(
"thinking",
@@ -149,12 +190,22 @@ async def _iter_steward_plan_events(
await asyncio.sleep(0)
try:
hydrated_payload = _hydrate_required_application_gate(db, payload, planner)
hydrated_payload = _hydrate_required_application_gate(
db,
payload,
planner,
tenant_id=_require_steward_tenant_id(current_user),
)
if isinstance(planner, StewardGraphPlannerService):
plan = planner.build_plan(hydrated_payload, db=db)
else:
plan = planner.build_plan(hydrated_payload)
plan = _attach_conversation_state(db, hydrated_payload, plan)
plan = _attach_conversation_state(
db,
hydrated_payload,
plan,
current_user=current_user,
)
except ValueError as exc:
yield _encode_stream_event("error", {"message": str(exc)})
return
@@ -170,6 +221,114 @@ def _encode_stream_event(event: str, data: dict[str, Any]) -> str:
return json.dumps({"event": event, "data": data}, ensure_ascii=False) + "\n"
def _bind_authenticated_plan_request(
payload: StewardPlanRequest,
current_user: CurrentUserContext,
) -> StewardPlanRequest:
user_id = _require_steward_user_id(current_user)
return payload.model_copy(
update={
"user_id": user_id,
"context_json": _build_trusted_steward_context(
payload.context_json,
current_user,
),
}
)
def _bind_authenticated_slot_request(
payload: StewardSlotDecisionRequest,
current_user: CurrentUserContext,
) -> StewardSlotDecisionRequest:
return payload.model_copy(
update={
"task_context": _build_trusted_steward_context(
payload.task_context,
current_user,
)
}
)
def _bind_authenticated_runtime_request(
payload: StewardRuntimeDecisionRequest,
current_user: CurrentUserContext,
) -> StewardRuntimeDecisionRequest:
return payload.model_copy(
update={
"context_json": _build_trusted_steward_context(
payload.context_json,
current_user,
),
"runtime_state": _build_trusted_steward_context(
payload.runtime_state,
current_user,
),
}
)
def _build_trusted_steward_context(
context_json: dict[str, Any] | None,
current_user: CurrentUserContext,
) -> dict[str, Any]:
tenant_id = _require_steward_tenant_id(current_user)
user_id = _require_steward_user_id(current_user)
trusted_context = dict(context_json or {})
for untrusted_alias in (
"tenantId",
"userId",
"auth_session_id",
):
trusted_context.pop(untrusted_alias, None)
trusted_context.update(
{
"tenant_id": tenant_id,
"user_id": user_id,
"username": current_user.username,
"name": current_user.name,
"role_codes": list(current_user.role_codes),
"is_admin": current_user.is_admin,
"department": current_user.department_name,
"department_name": current_user.department_name,
"department_id": current_user.department_id,
"cost_center": current_user.cost_center,
"position": current_user.position,
"grade": current_user.grade,
"employee_grade": current_user.grade,
"employee_no": current_user.employee_no,
"employee_id": current_user.employee_id,
"manager_name": current_user.manager_name,
"requested_by_username": current_user.username,
"requested_by_name": current_user.name,
"actor": user_id,
"actor_id": user_id,
}
)
return trusted_context
def _require_steward_tenant_id(current_user: CurrentUserContext) -> str:
tenant_id = str(current_user.tenant_id or "").strip()
if tenant_id:
return tenant_id
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="当前登录用户缺少租户归属,无法使用小财管家。",
)
def _require_steward_user_id(current_user: CurrentUserContext) -> str:
user_id = str(current_user.username or current_user.employee_id or "").strip()
if user_id:
return user_id
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="当前登录用户缺少可信用户标识,无法使用小财管家。",
)
def _build_steward_planner(db: Session) -> StewardPlannerLike:
runtime_chat = RuntimeChatService(db)
if get_settings().steward_agent_runtime.strip().lower() == "langgraph":
@@ -217,6 +376,8 @@ def _hydrate_required_application_gate(
db: Session,
payload: StewardPlanRequest,
planner: StewardPlannerLike,
*,
tenant_id: str,
) -> StewardPlanRequest:
context_json = dict(payload.context_json or {})
required_gate = context_json.get("required_application_gate")
@@ -230,7 +391,12 @@ def _hydrate_required_application_gate(
if not planner._looks_like_ambiguous_travel_flow(message, base_date, payload):
return payload
candidates = _query_required_application_gate_candidates(db, payload, context_json)
candidates = _query_required_application_gate_candidates(
db,
payload,
context_json,
tenant_id=tenant_id,
)
next_required_gate = dict(required_gate) if isinstance(required_gate, dict) else {}
next_required_gate["travel"] = {
"checked": True,
@@ -251,10 +417,15 @@ def _query_required_application_gate_candidates(
db: Session,
payload: StewardPlanRequest,
context_json: dict[str, Any],
*,
tenant_id: str,
) -> list[dict[str, Any]]:
identities = _resolve_required_application_gate_identities(payload, context_json)
stmt = (
select(ExpenseClaim)
.where(
ExpenseClaimTenantScopeMixin.build_claim_tenant_condition(tenant_id)
)
.order_by(ExpenseClaim.submitted_at.desc(), ExpenseClaim.updated_at.desc())
.limit(200)
)
@@ -383,16 +554,19 @@ def _attach_conversation_state(
db: Session,
payload: StewardPlanRequest,
plan: StewardPlanResponse,
*,
current_user: CurrentUserContext,
) -> StewardPlanResponse:
context_json = dict(payload.context_json or {})
context_json["session_type"] = str(context_json.get("session_type") or "steward").strip() or "steward"
conversation_service = AgentConversationService(db)
conversation = conversation_service.get_or_create_conversation(
conversation_id=_resolve_conversation_id(context_json),
user_id=payload.user_id,
user_id=_require_steward_user_id(current_user),
source="user_message",
context_json=context_json,
)
_require_steward_conversation_access(conversation, current_user)
current_state = _resolve_current_steward_state(conversation.state_json, context_json)
steward_state = StewardFlowStateService().merge_plan(current_state, plan)
conversation = conversation_service.update_state(
@@ -433,6 +607,8 @@ def _attach_runtime_conversation_state(
db: Session,
payload: StewardRuntimeDecisionRequest,
decision: StewardRuntimeDecisionResponse,
*,
current_user: CurrentUserContext,
) -> StewardRuntimeDecisionResponse:
steward_state = decision.steward_state
if not isinstance(steward_state, dict) or not steward_state:
@@ -443,6 +619,10 @@ def _attach_runtime_conversation_state(
return decision
conversation_service = AgentConversationService(db)
conversation = conversation_service.get_conversation(conversation_id)
if conversation is None:
return decision
_require_steward_conversation_access(conversation, current_user)
conversation_service.update_state(
conversation_id=conversation_id,
run_id=None,
@@ -459,18 +639,26 @@ def _attach_runtime_conversation_state(
def _hydrate_runtime_decision_payload(
db: Session,
payload: StewardRuntimeDecisionRequest,
*,
current_user: CurrentUserContext,
) -> StewardRuntimeDecisionRequest:
context_json = dict(payload.context_json or {})
runtime_state = dict(payload.runtime_state or {})
conversation_id = _resolve_conversation_id(context_json)
conversation = (
AgentConversationService(db).get_conversation(conversation_id)
if conversation_id
else None
)
if conversation is not None:
_require_steward_conversation_access(conversation, current_user)
if isinstance(runtime_state.get("steward_state"), dict) and runtime_state["steward_state"]:
return payload
if isinstance(context_json.get("steward_state"), dict) and context_json["steward_state"]:
return payload
conversation_id = _resolve_conversation_id(context_json)
if not conversation_id:
return payload
conversation = AgentConversationService(db).get_conversation(conversation_id)
stored_state = conversation.state_json.get("steward_state") if conversation and isinstance(conversation.state_json, dict) else None
if not isinstance(stored_state, dict) or not stored_state:
return payload
@@ -487,6 +675,30 @@ def _hydrate_runtime_decision_payload(
)
def _require_steward_conversation_access(
conversation: Any,
current_user: CurrentUserContext,
) -> None:
expected_tenant_id = _require_steward_tenant_id(current_user)
expected_user_id = _require_steward_user_id(current_user)
state_json = (
dict(conversation.state_json)
if isinstance(conversation.state_json, dict)
else {}
)
conversation_tenant_id = str(state_json.get("tenant_id") or "").strip()
conversation_user_id = str(conversation.user_id or "").strip()
if (
conversation_tenant_id == expected_tenant_id
and conversation_user_id == expected_user_id
):
return
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="当前会话不属于登录用户或租户,已拒绝访问。",
)
def _resolve_conversation_id(context_json: dict[str, Any]) -> str | None:
return str(
context_json.get("conversation_id")

View File

@@ -1,5 +1,6 @@
from fastapi import APIRouter
from app.api.v1.endpoints.agent_asset_releases import router as agent_asset_releases_router
from app.api.v1.endpoints.agent_asset_risk_rules import router as agent_asset_risk_rules_router
from app.api.v1.endpoints.agent_assets import router as agent_assets_router
from app.api.v1.endpoints.agent_feedback import router as agent_feedback_router
@@ -15,6 +16,9 @@ from app.api.v1.endpoints.audit_logs import router as audit_logs_router
from app.api.v1.endpoints.auth import router as auth_router
from app.api.v1.endpoints.bootstrap import router as bootstrap_router
from app.api.v1.endpoints.budgets import router as budgets_router
from app.api.v1.endpoints.cfo_value import router as cfo_value_router
from app.api.v1.endpoints.commercial import router as commercial_router
from app.api.v1.endpoints.commercial_billing import router as commercial_billing_router
from app.api.v1.endpoints.employee_profiles import router as employee_profiles_router
from app.api.v1.endpoints.employees import router as employees_router
from app.api.v1.endpoints.expense_application_memories import (
@@ -24,6 +28,8 @@ from app.api.v1.endpoints.expense_application_previews import (
router as expense_application_previews_router,
)
from app.api.v1.endpoints.expense_cases import router as expense_cases_router
from app.api.v1.endpoints.financial_connectors import router as financial_connectors_router
from app.api.v1.endpoints.finance_report_configs import router as finance_report_configs_router
from app.api.v1.endpoints.health import router as health_router
from app.api.v1.endpoints.knowledge import router as knowledge_router
from app.api.v1.endpoints.linked_reimbursement_draft_jobs import (
@@ -36,6 +42,7 @@ from app.api.v1.endpoints.orchestrator import router as orchestrator_router
from app.api.v1.endpoints.receipt_folder import router as receipt_folder_router
from app.api.v1.endpoints.reimbursements import router as reimbursements_router
from app.api.v1.endpoints.risk_observations import router as risk_observations_router
from app.api.v1.endpoints.savings import router as savings_router
from app.api.v1.endpoints.settings import router as settings_router
from app.api.v1.endpoints.steward import router as steward_router
from app.api.v1.endpoints.system_logs import router as system_logs_router
@@ -45,8 +52,12 @@ router.include_router(health_router, tags=["health"])
router.include_router(bootstrap_router, tags=["bootstrap"])
router.include_router(auth_router, tags=["auth"])
router.include_router(budgets_router, tags=["budgets"])
router.include_router(cfo_value_router, tags=["analytics"])
router.include_router(commercial_router, tags=["commercial"])
router.include_router(commercial_billing_router, tags=["commercial"])
router.include_router(agent_assets_router, tags=["agent-assets"])
router.include_router(agent_asset_risk_rules_router, tags=["agent-assets"])
router.include_router(agent_asset_releases_router, tags=["agent-assets"])
router.include_router(agent_feedback_router, tags=["agent-feedback"])
router.include_router(agent_runs_router, tags=["agent-runs"])
router.include_router(agent_traces_router, tags=["agent-traces"])
@@ -67,11 +78,14 @@ router.include_router(orchestrator_router, tags=["orchestrator"])
router.include_router(receipt_folder_router, tags=["receipt-folder"])
router.include_router(employees_router, prefix="/employees", tags=["employees"])
router.include_router(expense_cases_router, tags=["expense-cases"])
router.include_router(financial_connectors_router, tags=["financial-connectors"])
router.include_router(finance_report_configs_router, tags=["finance-report-config"])
router.include_router(expense_application_memories_router, tags=["expense-application-memories"])
router.include_router(expense_application_previews_router, tags=["reimbursements"])
router.include_router(employee_profiles_router, tags=["employee-profiles"])
router.include_router(reimbursements_router, prefix="/reimbursements", tags=["reimbursements"])
router.include_router(risk_observations_router, tags=["risk-observations"])
router.include_router(savings_router, tags=["savings"])
router.include_router(settings_router, tags=["settings"])
router.include_router(steward_router, tags=["steward"])
router.include_router(system_logs_router, tags=["system-logs"])

View File

@@ -0,0 +1 @@
"""受控运维命令的可测试业务编排。"""

View File

@@ -0,0 +1,700 @@
from __future__ import annotations
import hashlib
import hmac
import json
import re
from collections import Counter
from collections.abc import Sequence
from dataclasses import dataclass, replace
from datetime import UTC, datetime
from decimal import Decimal, InvalidOperation
from enum import StrEnum
from typing import Any
from sqlalchemy import and_, or_, select
from sqlalchemy.orm import Session, selectinload
from app.api.deps import CurrentUserContext
from app.models.expense_case import ExpenseCase, ExpenseCaseLink
from app.models.financial_record import ExpenseClaim
from app.models.savings import SavingsOpportunity
from app.services.expense_claim_constants import STANDARD_ADJUSTMENT_RISK_SOURCE
from app.services.savings_discovery import SavingsDiscoveryService
DEFAULT_BATCH_SIZE = 100
MAX_BATCH_SIZE = 1000
_FINGERPRINT_PATTERN = re.compile(r"^sha256:[0-9a-f]{64}$")
class StandardAdjustmentBackfillDisposition(StrEnum):
ELIGIBLE = "eligible"
CREATED = "created"
REPLAYED = "replayed"
MISSING_CASE_LINK = "missing_case_link"
TENANT_CONFLICT = "tenant_conflict"
INVALID_CASE_LINK = "invalid_case_link"
MISSING_ITEM = "missing_item"
AMBIGUOUS_ITEM_HISTORY = "ambiguous_item_history"
DUPLICATE_FLAG = "duplicate_flag"
MISSING_SERVER_POLICY = "missing_server_policy"
MISSING_POLICY_VERSION = "missing_policy_version"
INVALID_POLICY_SNAPSHOT = "invalid_policy_snapshot"
INVALID_CALCULATION_FINGERPRINT = "invalid_calculation_fingerprint"
CALCULATION_FINGERPRINT_MISMATCH = "calculation_fingerprint_mismatch"
INVALID_ORIGINAL_AMOUNT = "invalid_original_amount"
ORIGINAL_AMOUNT_MISMATCH = "original_amount_mismatch"
INVALID_TARGET_AMOUNT = "invalid_target_amount"
INVALID_SAVING_DIFFERENCE = "invalid_saving_difference"
INVALID_CURRENCY = "invalid_currency"
@dataclass(frozen=True, slots=True)
class StandardAdjustmentBackfillCursor:
created_at: datetime
claim_id: str
@dataclass(frozen=True, slots=True)
class StandardAdjustmentBackfillItem:
claim_id: str
claim_no: str
item_id: str
disposition: StandardAdjustmentBackfillDisposition
reason: str
calculation_fingerprint: str = ""
policy_version: str = ""
currency: str = ""
original_amount: Decimal | None = None
target_amount: Decimal | None = None
saving_amount: Decimal | None = None
opportunity_id: str | None = None
@dataclass(frozen=True, slots=True)
class StandardAdjustmentBackfillPage:
tenant_id: str
claims_inspected: int
flags_inspected: int
eligible: int
replayed: int
skipped: int
reasons: dict[str, int]
has_more: bool
next_cursor: StandardAdjustmentBackfillCursor | None
items: tuple[StandardAdjustmentBackfillItem, ...]
@dataclass(frozen=True, slots=True)
class StandardAdjustmentBackfillResult:
tenant_id: str
run_id: str
claims_inspected: int
flags_inspected: int
created: int
replayed: int
skipped: int
reasons: dict[str, int]
has_more: bool
next_cursor: StandardAdjustmentBackfillCursor | None
items: tuple[StandardAdjustmentBackfillItem, ...]
@dataclass(slots=True)
class _BatchContext:
links_by_claim_id: dict[str, ExpenseCaseLink]
cases_by_id: dict[str, ExpenseCase]
opportunities_by_key: dict[str, SavingsOpportunity]
class StandardAdjustmentSavingsBackfillService:
"""严格按历史服务端证据回填节省机会;从不自行提交事务。"""
def __init__(
self,
db: Session,
*,
tenant_id: str,
created_before: datetime | None = None,
) -> None:
self.db = db
self.tenant_id = self._required_text(tenant_id, field_name="tenant_id", max_length=64)
self.created_before = (
self._aware(created_before, field_name="created_before")
if created_before is not None
else None
)
def preview(
self,
*,
batch_size: int = DEFAULT_BATCH_SIZE,
after: StandardAdjustmentBackfillCursor | None = None,
) -> StandardAdjustmentBackfillPage:
"""只读预览;不 add、不 flush、不 commit。"""
with self.db.no_autoflush:
claims, has_more = self._load_claims(
batch_size=self._batch_size(batch_size),
after=after,
lock_rows=False,
)
context = self._load_context(claims)
items = self._classify(claims, context=context)
return StandardAdjustmentBackfillPage(
tenant_id=self.tenant_id,
claims_inspected=len(claims),
flags_inspected=len(items),
eligible=self._count(items, StandardAdjustmentBackfillDisposition.ELIGIBLE),
replayed=self._count(items, StandardAdjustmentBackfillDisposition.REPLAYED),
skipped=self._skipped(items),
reasons=self._reason_counts(items),
has_more=has_more,
next_cursor=self._next_cursor(claims),
items=items,
)
def apply_batch(
self,
*,
run_id: str,
batch_size: int = DEFAULT_BATCH_SIZE,
after: StandardAdjustmentBackfillCursor | None = None,
) -> StandardAdjustmentBackfillResult:
"""锁定并应用一批;调用方负责按批 commit/rollback。"""
normalized_run_id = self._required_text(run_id, field_name="run_id", max_length=64)
claims, has_more = self._load_claims(
batch_size=self._batch_size(batch_size),
after=after,
lock_rows=True,
)
context = self._load_context(claims)
classified = self._classify(claims, context=context)
claims_by_id = {str(claim.id): claim for claim in claims}
items_by_claim = {
str(claim.id): {str(item.id): item for item in claim.items} for claim in claims
}
actor = self._system_actor(run_id=normalized_run_id)
result_items: list[StandardAdjustmentBackfillItem] = []
for item in classified:
if item.disposition is not StandardAdjustmentBackfillDisposition.ELIGIBLE:
result_items.append(item)
continue
claim = claims_by_id[item.claim_id]
claim_items = items_by_claim[item.claim_id]
source_flag = self._find_flag(claim, item=item)
discovered = SavingsDiscoveryService(self.db).discover_standard_adjustments(
claim=claim,
items_by_id=claim_items,
adjustment_flags=[source_flag],
current_user=actor,
request_id=f"savings-backfill:{normalized_run_id}",
)
if len(discovered) != 1:
raise RuntimeError("历史标准调整回填未返回唯一节省机会,当前批次必须回滚。")
opportunity = discovered[0]
context.opportunities_by_key[opportunity.opportunity_key] = opportunity
result_items.append(
replace(
item,
disposition=StandardAdjustmentBackfillDisposition.CREATED,
reason="证据完整,已通过 SavingsDiscoveryService 创建机会。",
opportunity_id=opportunity.id,
)
)
result = tuple(result_items)
return StandardAdjustmentBackfillResult(
tenant_id=self.tenant_id,
run_id=normalized_run_id,
claims_inspected=len(claims),
flags_inspected=len(result),
created=self._count(result, StandardAdjustmentBackfillDisposition.CREATED),
replayed=self._count(result, StandardAdjustmentBackfillDisposition.REPLAYED),
skipped=self._skipped(result),
reasons=self._reason_counts(result),
has_more=has_more,
next_cursor=self._next_cursor(claims),
items=result,
)
def _load_claims(
self,
*,
batch_size: int,
after: StandardAdjustmentBackfillCursor | None,
lock_rows: bool,
) -> tuple[list[ExpenseClaim], bool]:
stmt = select(ExpenseClaim).options(selectinload(ExpenseClaim.items))
if self.created_before is not None:
stmt = stmt.where(ExpenseClaim.created_at < self.created_before)
if after is not None:
cursor_time = self._aware(after.created_at, field_name="after.created_at")
cursor_id = self._required_text(
after.claim_id,
field_name="after.claim_id",
max_length=36,
)
stmt = stmt.where(
or_(
ExpenseClaim.created_at > cursor_time,
and_(ExpenseClaim.created_at == cursor_time, ExpenseClaim.id > cursor_id),
)
)
stmt = stmt.order_by(ExpenseClaim.created_at.asc(), ExpenseClaim.id.asc()).limit(
batch_size + 1
)
if lock_rows:
stmt = stmt.with_for_update()
# JSON 文本过滤在不同数据库方言上语义不完全一致,因此在 Python 中确认来源;
# 这里按 Claim 分页,空标志页仍可通过 cursor 继续推进。
rows = list(self.db.scalars(stmt).unique().all())
return rows[:batch_size], len(rows) > batch_size
def _load_context(self, claims: Sequence[ExpenseClaim]) -> _BatchContext:
claim_ids = [str(claim.id) for claim in claims]
if not claim_ids:
return _BatchContext({}, {}, {})
links = list(
self.db.scalars(
select(ExpenseCaseLink).where(
ExpenseCaseLink.resource_type == "expense_claim",
ExpenseCaseLink.resource_id.in_(claim_ids),
)
).all()
)
links_by_claim_id = {str(link.resource_id): link for link in links}
case_ids = {str(link.expense_case_id) for link in links}
cases_by_id = (
{
str(expense_case.id): expense_case
for expense_case in self.db.scalars(
select(ExpenseCase).where(ExpenseCase.id.in_(case_ids))
).all()
}
if case_ids
else {}
)
opportunities = list(
self.db.scalars(
select(SavingsOpportunity).where(
SavingsOpportunity.tenant_id == self.tenant_id,
SavingsOpportunity.claim_id.in_(claim_ids),
SavingsOpportunity.source_type == "standard_adjustment",
)
).all()
)
return _BatchContext(
links_by_claim_id=links_by_claim_id,
cases_by_id=cases_by_id,
opportunities_by_key={item.opportunity_key: item for item in opportunities},
)
def _classify(
self,
claims: Sequence[ExpenseClaim],
*,
context: _BatchContext,
) -> tuple[StandardAdjustmentBackfillItem, ...]:
results: list[StandardAdjustmentBackfillItem] = []
for claim in claims:
flags = self._standard_adjustment_flags(claim)
if not flags:
continue
grouped: dict[str, list[dict[str, Any]]] = {}
for flag in flags:
grouped.setdefault(str(flag.get("item_id") or "").strip(), []).append(flag)
for _item_id, item_flags in grouped.items():
fingerprints = {
str(flag.get("calculation_fingerprint") or "").strip()
for flag in item_flags
}
if len(fingerprints) > 1:
results.extend(
self._item(
claim,
flag,
StandardAdjustmentBackfillDisposition.AMBIGUOUS_ITEM_HISTORY,
"同一明细存在多个不同政策计算快照,无法安全判断应货币化哪一版。",
)
for flag in item_flags
)
continue
first, *duplicates = item_flags
results.append(self._classify_flag(claim, first, context=context))
results.extend(
self._item(
claim,
flag,
StandardAdjustmentBackfillDisposition.DUPLICATE_FLAG,
"同一明细存在重复标准调整标志;仅首条进入证据校验。",
)
for flag in duplicates
)
return tuple(results)
def _classify_flag(
self,
claim: ExpenseClaim,
flag: dict[str, Any],
*,
context: _BatchContext,
) -> StandardAdjustmentBackfillItem:
item_id = str(flag.get("item_id") or "").strip()
link = context.links_by_claim_id.get(str(claim.id))
if link is None:
return self._item(
claim,
flag,
StandardAdjustmentBackfillDisposition.MISSING_CASE_LINK,
"Claim 没有 ExpenseCaseLink租户归属无法证明。",
)
if link.tenant_id != self.tenant_id:
return self._item(
claim,
flag,
StandardAdjustmentBackfillDisposition.TENANT_CONFLICT,
"Claim 的 ExpenseCaseLink 属于其他租户。",
)
expense_case = context.cases_by_id.get(str(link.expense_case_id))
if (
expense_case is None
or expense_case.tenant_id != self.tenant_id
or not str(link.relation_type or "").strip()
):
return self._item(
claim,
flag,
StandardAdjustmentBackfillDisposition.INVALID_CASE_LINK,
"ExpenseCaseLink 关联目标缺失、租户不一致或关系类型为空。",
)
item = next((entry for entry in claim.items if str(entry.id) == item_id), None)
if item is None:
return self._item(
claim,
flag,
StandardAdjustmentBackfillDisposition.MISSING_ITEM,
"标准调整引用的费用明细不存在。",
)
if str(flag.get("calculation_source") or "").strip() != "server_policy":
return self._item(
claim,
flag,
StandardAdjustmentBackfillDisposition.MISSING_SERVER_POLICY,
"缺少 calculation_source=server_policy不能证明由服务端政策计算。",
)
policy_version = str(flag.get("policy_rule_version") or "").strip()
if not policy_version:
return self._item(
claim,
flag,
StandardAdjustmentBackfillDisposition.MISSING_POLICY_VERSION,
"缺少服务端政策版本。",
)
calculation_fingerprint = str(flag.get("calculation_fingerprint") or "").strip()
if not _FINGERPRINT_PATTERN.fullmatch(calculation_fingerprint):
return self._item(
claim,
flag,
StandardAdjustmentBackfillDisposition.INVALID_CALCULATION_FINGERPRINT,
"政策计算指纹不是合法 sha256 快照。",
)
original = self._money(flag.get("original_amount"))
if original is None or original <= Decimal("0"):
return self._item(
claim,
flag,
StandardAdjustmentBackfillDisposition.INVALID_ORIGINAL_AMOUNT,
"原金额缺失、格式错误或不为正数。",
)
item_amount = self._money(item.item_amount)
if item_amount is None or item_amount != original:
return self._item(
claim,
flag,
StandardAdjustmentBackfillDisposition.ORIGINAL_AMOUNT_MISMATCH,
"历史原金额与当前 ExpenseClaimItem.item_amount 不一致。",
original=original,
)
target = self._money(flag.get("reimbursable_amount"))
if target is None or target < Decimal("0"):
return self._item(
claim,
flag,
StandardAdjustmentBackfillDisposition.INVALID_TARGET_AMOUNT,
"目标报销金额缺失、格式错误或为负数。",
original=original,
)
saving = (original - target).quantize(Decimal("0.01"))
absorbed = self._money(flag.get("employee_absorbed_amount"))
if saving <= Decimal("0") or absorbed is None or absorbed != saving:
return self._item(
claim,
flag,
StandardAdjustmentBackfillDisposition.INVALID_SAVING_DIFFERENCE,
"差额不为正数,或员工承担金额与原金额减目标金额不一致。",
original=original,
target=target,
saving=saving,
)
currency = str(claim.currency or "").strip().upper()
if len(currency) != 3 or not currency.isalpha():
return self._item(
claim,
flag,
StandardAdjustmentBackfillDisposition.INVALID_CURRENCY,
"Claim 缺少合法三位币种,禁止默认猜测币种。",
original=original,
target=target,
saving=saving,
)
expected_fingerprint = self._calculation_fingerprint(flag, original, target)
if expected_fingerprint is None:
return self._item(
claim,
flag,
StandardAdjustmentBackfillDisposition.INVALID_POLICY_SNAPSHOT,
"政策天数、地点、职级、酒店标准或规则名称快照不完整。",
original=original,
target=target,
saving=saving,
)
if not hmac.compare_digest(expected_fingerprint, calculation_fingerprint):
return self._item(
claim,
flag,
StandardAdjustmentBackfillDisposition.CALCULATION_FINGERPRINT_MISMATCH,
"按历史政策快照重算的 SHA-256 与存储指纹不一致。",
original=original,
target=target,
saving=saving,
)
opportunity_key = self._opportunity_key(
claim_id=str(claim.id),
item_id=item_id,
policy_version=policy_version,
calculation_fingerprint=calculation_fingerprint,
)
existing = context.opportunities_by_key.get(opportunity_key)
if existing is not None:
return self._item(
claim,
flag,
StandardAdjustmentBackfillDisposition.REPLAYED,
"相同稳定键的节省机会已存在。",
original=original,
target=target,
saving=saving,
opportunity_id=existing.id,
)
return self._item(
claim,
flag,
StandardAdjustmentBackfillDisposition.ELIGIBLE,
"Case、租户、金额及服务端政策指纹均验证通过。",
original=original,
target=target,
saving=saving,
)
@staticmethod
def _standard_adjustment_flags(claim: ExpenseClaim) -> list[dict[str, Any]]:
return [
flag
for flag in list(claim.risk_flags_json or [])
if isinstance(flag, dict)
and str(flag.get("source") or "").strip() == STANDARD_ADJUSTMENT_RISK_SOURCE
]
@classmethod
def _find_flag(
cls,
claim: ExpenseClaim,
*,
item: StandardAdjustmentBackfillItem,
) -> dict[str, Any]:
matches = [
flag
for flag in cls._standard_adjustment_flags(claim)
if str(flag.get("item_id") or "").strip() == item.item_id
and str(flag.get("calculation_fingerprint") or "").strip()
== item.calculation_fingerprint
]
if len(matches) != 1:
raise RuntimeError("历史标准调整证据在锁定后发生歧义,当前批次必须回滚。")
return matches[0]
@classmethod
def _calculation_fingerprint(
cls,
flag: dict[str, Any],
original: Decimal,
target: Decimal,
) -> str | None:
try:
days = int(flag["policy_days"])
location = cls._snapshot_text(flag, "policy_location")
matched_city = cls._snapshot_text(flag, "policy_matched_city")
grade = cls._snapshot_text(flag, "policy_grade")
grade_band = cls._snapshot_text(flag, "policy_grade_band")
hotel_rate = cls._snapshot_money(flag, "policy_hotel_rate")
hotel_amount = cls._snapshot_money(flag, "policy_hotel_amount")
rule_name = cls._snapshot_text(flag, "policy_rule_name")
rule_version = cls._snapshot_text(flag, "policy_rule_version")
except (KeyError, TypeError, ValueError, InvalidOperation):
return None
if days <= 0 or hotel_rate < Decimal("0") or hotel_amount < Decimal("0"):
return None
material = {
"item_id": str(flag.get("item_id") or "").strip(),
"original_amount": f"{original:.2f}",
"reimbursable_amount": f"{target:.2f}",
"policy_days": days,
"policy_location": location,
"policy_matched_city": matched_city,
"policy_grade": grade,
"policy_grade_band": grade_band,
"policy_hotel_rate": f"{hotel_rate:.2f}",
"policy_hotel_amount": f"{hotel_amount:.2f}",
"policy_rule_name": rule_name,
"policy_rule_version": rule_version,
}
canonical = json.dumps(
material,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
)
return f"sha256:{hashlib.sha256(canonical.encode('utf-8')).hexdigest()}"
def _opportunity_key(
self,
*,
claim_id: str,
item_id: str,
policy_version: str,
calculation_fingerprint: str,
) -> str:
material = {
"tenant_id": self.tenant_id,
"claim_id": claim_id,
"item_id": item_id,
"policy_version": policy_version,
"calculation_fingerprint": calculation_fingerprint,
}
canonical = json.dumps(material, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
return f"standard-adjustment:{hashlib.sha256(canonical.encode('utf-8')).hexdigest()}"
def _item(
self,
claim: ExpenseClaim,
flag: dict[str, Any],
disposition: StandardAdjustmentBackfillDisposition,
reason: str,
*,
original: Decimal | None = None,
target: Decimal | None = None,
saving: Decimal | None = None,
opportunity_id: str | None = None,
) -> StandardAdjustmentBackfillItem:
return StandardAdjustmentBackfillItem(
claim_id=str(claim.id),
claim_no=str(claim.claim_no or claim.id),
item_id=str(flag.get("item_id") or "").strip(),
disposition=disposition,
reason=reason,
calculation_fingerprint=str(flag.get("calculation_fingerprint") or "").strip(),
policy_version=str(flag.get("policy_rule_version") or "").strip(),
currency=str(claim.currency or "").strip().upper(),
original_amount=original,
target_amount=target,
saving_amount=saving,
opportunity_id=opportunity_id,
)
def _system_actor(self, *, run_id: str) -> CurrentUserContext:
return CurrentUserContext(
username=f"savings-backfill:{run_id}"[:120],
name="Savings 历史回填",
role_codes=["finance"],
is_admin=False,
tenant_id=self.tenant_id,
)
@staticmethod
def _snapshot_text(flag: dict[str, Any], key: str) -> str:
value = str(flag[key] or "").strip()
if not value:
raise ValueError(f"{key} is blank")
return value
@classmethod
def _snapshot_money(cls, flag: dict[str, Any], key: str) -> Decimal:
value = cls._money(flag[key])
if value is None:
raise ValueError(f"{key} is not money")
return value
@staticmethod
def _money(value: Any) -> Decimal | None:
if isinstance(value, bool) or value is None or str(value).strip() == "":
return None
try:
parsed = Decimal(str(value))
except (InvalidOperation, ValueError):
return None
if not parsed.is_finite():
return None
return parsed.quantize(Decimal("0.01"))
@staticmethod
def _required_text(value: str, *, field_name: str, max_length: int) -> str:
normalized = str(value or "").strip()
if not normalized:
raise ValueError(f"{field_name} must not be empty")
if len(normalized) > max_length:
raise ValueError(f"{field_name} must be at most {max_length} characters")
return normalized
@staticmethod
def _aware(value: datetime, *, field_name: str) -> datetime:
if value.tzinfo is None or value.utcoffset() is None:
raise ValueError(f"{field_name} must include timezone")
return value.astimezone(UTC)
@staticmethod
def _batch_size(value: int) -> int:
normalized = int(value)
if normalized < 1 or normalized > MAX_BATCH_SIZE:
raise ValueError(f"batch_size must be between 1 and {MAX_BATCH_SIZE}")
return normalized
@staticmethod
def _next_cursor(claims: Sequence[ExpenseClaim]) -> StandardAdjustmentBackfillCursor | None:
if not claims:
return None
claim = claims[-1]
return StandardAdjustmentBackfillCursor(created_at=claim.created_at, claim_id=str(claim.id))
@staticmethod
def _count(
items: Sequence[StandardAdjustmentBackfillItem],
disposition: StandardAdjustmentBackfillDisposition,
) -> int:
return sum(item.disposition is disposition for item in items)
@staticmethod
def _reason_counts(items: Sequence[StandardAdjustmentBackfillItem]) -> dict[str, int]:
counts = Counter(item.disposition.value for item in items)
return dict(sorted(counts.items()))
@staticmethod
def _skipped(items: Sequence[StandardAdjustmentBackfillItem]) -> int:
accepted = {
StandardAdjustmentBackfillDisposition.ELIGIBLE,
StandardAdjustmentBackfillDisposition.CREATED,
StandardAdjustmentBackfillDisposition.REPLAYED,
}
return sum(item.disposition not in accepted for item in items)

View File

@@ -0,0 +1,3 @@
AGENT_ASSET_PLATFORM_SCOPE = "platform"
AGENT_ASSET_TENANT_SCOPE = "tenant"
AGENT_ASSET_PLATFORM_TENANT_ID = "platform"

View File

@@ -0,0 +1,70 @@
"""Agent 发布遥测伪名密钥的本地版本化存储。"""
from __future__ import annotations
import base64
import binascii
import os
import re
import secrets
from pathlib import Path
from app.core.config import SERVER_DIR
FINGERPRINT_KEY_DIRECTORY = SERVER_DIR / ".secrets" / "agent-release-telemetry"
ACTIVE_KEY_VERSION_ENV = "AGENT_RELEASE_TELEMETRY_KEY_VERSION"
DEFAULT_ACTIVE_KEY_VERSION = "v1"
KEY_BYTES = 32
_VERSION_PATTERN = re.compile(r"^[a-zA-Z0-9._-]{1,32}$")
def active_agent_release_telemetry_key_version() -> str:
version = str(os.environ.get(ACTIVE_KEY_VERSION_ENV) or DEFAULT_ACTIVE_KEY_VERSION).strip()
if not _VERSION_PATTERN.fullmatch(version):
raise ValueError("Agent 发布遥测指纹密钥版本无效。")
return version
def available_agent_release_telemetry_key_versions() -> list[str]:
active = active_agent_release_telemetry_key_version()
versions = {active}
if FINGERPRINT_KEY_DIRECTORY.exists():
for path in FINGERPRINT_KEY_DIRECTORY.glob("*.key"):
if path.is_file() and not path.is_symlink() and _VERSION_PATTERN.fullmatch(path.stem):
versions.add(path.stem)
return [active, *sorted(versions - {active})]
def get_agent_release_telemetry_key(version: str, *, create: bool) -> bytes:
normalized = str(version or "").strip()
if not _VERSION_PATTERN.fullmatch(normalized):
raise ValueError("Agent 发布遥测指纹密钥版本无效。")
key_path = FINGERPRINT_KEY_DIRECTORY / f"{normalized}.key"
if not key_path.exists():
if not create:
raise ValueError("Agent 发布遥测指纹密钥版本不可用。")
_create_key_atomically(key_path)
if key_path.is_symlink() or not key_path.is_file():
raise ValueError("Agent 发布遥测指纹密钥文件无效。")
os.chmod(key_path, 0o600)
try:
key = base64.urlsafe_b64decode(key_path.read_text(encoding="utf-8").strip())
except (binascii.Error, ValueError, UnicodeError) as error:
raise ValueError("Agent 发布遥测指纹密钥内容无效。") from error
if len(key) != KEY_BYTES:
raise ValueError("Agent 发布遥测指纹密钥长度无效。")
return key
def _create_key_atomically(key_path: Path) -> None:
key_path.parent.mkdir(parents=True, exist_ok=True)
os.chmod(key_path.parent, 0o700)
encoded = base64.urlsafe_b64encode(secrets.token_bytes(KEY_BYTES))
try:
descriptor = os.open(key_path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
except FileExistsError:
return
with os.fdopen(descriptor, "wb") as stream:
stream.write(encoded)
stream.flush()
os.fsync(stream.fileno())

View File

@@ -73,6 +73,10 @@ class Settings(BaseSettings):
onlyoffice_backend_url: str = Field(default="", alias="ONLYOFFICE_BACKEND_URL")
onlyoffice_jwt_secret: str = Field(default="", alias="ONLYOFFICE_JWT_SECRET")
hermes_agent_shared_token: str = Field(default="", alias="HERMES_AGENT_SHARED_TOKEN")
financial_connector_hmac_keys_json: str = Field(
default="",
alias="FINANCIAL_CONNECTOR_HMAC_KEYS_JSON",
)
steward_agent_runtime: str = Field(default="langgraph", alias="STEWARD_AGENT_RUNTIME")
log_level: str = Field(default="INFO", alias="LOG_LEVEL")

View File

@@ -6,6 +6,11 @@ from app.models.agent_asset import (
AgentAssetTestRun,
AgentAssetVersion,
)
from app.models.agent_asset_release_telemetry import (
AgentAssetReleaseAuditSample,
AgentAssetReleaseLabel,
AgentAssetReleaseObservation,
)
from app.models.agent_conversation import AgentConversation, AgentConversationMessage
from app.models.agent_feedback import AgentOperationFeedback
from app.models.agent_run import AgentRun, AgentToolCall, AgentTraceEvent, SemanticParseLog
@@ -19,11 +24,28 @@ from app.models.attachment_association_job import AttachmentAssociationJob
from app.models.audit_log import AuditLog
from app.models.auth_session import AuthSession
from app.models.budget import BudgetAllocation, BudgetReservation, BudgetTransaction
from app.models.commercial import (
CommercialCostEvent,
CommercialEntitlement,
TenantCommercialPlan,
TenantSubscription,
UsageMeterEvent,
)
from app.models.commercial_billing import CommercialAdminEvent, CommercialBillingPeriod
from app.models.commercial_runtime import CommercialRuntimeReservation
from app.models.employee import Employee
from app.models.employee_behavior_profile import EmployeeBehaviorProfileSnapshot
from app.models.employee_change_log import EmployeeChangeLog
from app.models.expense_case import BusinessEvent, ExpenseCase, ExpenseCaseLink
from app.models.few_shot_sample import FewShotSample
from app.models.financial_connector import (
FinancialConnectorConfig,
FinancialConnectorConfigEvent,
FinancialConnectorEvent,
FinancialConnectorOperationalEvent,
PaymentReconciliationCase,
PaymentReconciliationEvent,
)
from app.models.financial_record import (
AccountsPayableRecord,
AccountsReceivableRecord,
@@ -33,6 +55,7 @@ from app.models.financial_record import (
from app.models.golden_case import GoldenCase
from app.models.hermes_config import HermesTaskConfig, HermesTaskExecutionLog
from app.models.hermes_report import HermesRiskReport
from app.models.knowledge_security import KnowledgeOnlyOfficeSession
from app.models.notification_state import NotificationState
from app.models.organization import OrganizationUnit
from app.models.reimbursement import ReimbursementRequest
@@ -42,6 +65,11 @@ from app.models.role import Role
from app.models.system_model_setting import SystemModelSetting
from app.models.system_setting import SystemSetting
from app.models.system_setting_secret import SystemSettingSecret
from app.models.tenant import Tenant, TenantMembership
from app.models.tenant_finance_report import (
TenantFinanceReportConfig,
TenantFinanceReportRun,
)
from app.models.user_session_metric import UserSessionMetric
__all__ = [
@@ -55,6 +83,9 @@ __all__ = [
"AgentAssetRuleFeedback",
"AgentAssetTestRun",
"AgentAssetVersion",
"AgentAssetReleaseAuditSample",
"AgentAssetReleaseLabel",
"AgentAssetReleaseObservation",
"AgentOperationFeedback",
"AgentRun",
"AgentToolCall",
@@ -72,6 +103,11 @@ __all__ = [
"BudgetAllocation",
"BudgetReservation",
"BudgetTransaction",
"CommercialCostEvent",
"CommercialAdminEvent",
"CommercialBillingPeriod",
"CommercialEntitlement",
"CommercialRuntimeReservation",
"Employee",
"ExpenseCase",
"ExpenseCaseLink",
@@ -81,13 +117,20 @@ __all__ = [
"ExpenseClaim",
"FewShotSample",
"ExpenseClaimItem",
"FinancialConnectorConfig",
"FinancialConnectorConfigEvent",
"FinancialConnectorEvent",
"FinancialConnectorOperationalEvent",
"GoldenCase",
"HermesTaskConfig",
"HermesTaskExecutionLog",
"HermesRiskReport",
"KnowledgeOnlyOfficeSession",
"MemoryEntry",
"MemoryEvidenceLink",
"NotificationState",
"PaymentReconciliationCase",
"PaymentReconciliationEvent",
"OrganizationUnit",
"ReimbursementRequest",
"RiskDisposition",
@@ -99,6 +142,13 @@ __all__ = [
"SystemModelSetting",
"SystemSetting",
"SystemSettingSecret",
"Tenant",
"TenantCommercialPlan",
"TenantMembership",
"TenantFinanceReportConfig",
"TenantFinanceReportRun",
"TenantSubscription",
"UsageMeterEvent",
"UserSessionMetric",
"WorkflowOutcome",
]

View File

@@ -238,7 +238,89 @@ MIGRATION_OWNED_TABLES_BY_REVISION: dict[str, frozenset[str]] = {
}
),
}
if MIGRATION_OWNED_TABLES_BY_REVISION["20260716_0014"] != MIGRATION_OWNED_TABLES:
MIGRATION_OWNED_TABLES_BY_REVISION["20260716_0015"] = (
MIGRATION_OWNED_TABLES_BY_REVISION["20260716_0014"]
| frozenset(
{
"profile_baseline_snapshots",
"savings_opportunities",
"savings_realizations",
"savings_evidence_links",
"savings_events",
}
)
)
MIGRATION_OWNED_TABLES_BY_REVISION["20260716_0016"] = (
MIGRATION_OWNED_TABLES_BY_REVISION["20260716_0015"]
| frozenset(
{
"tenant_commercial_plans",
"tenant_subscriptions",
"commercial_entitlements",
"usage_meter_events",
"commercial_cost_events",
}
)
)
MIGRATION_OWNED_TABLES_BY_REVISION["20260716_0017"] = (
MIGRATION_OWNED_TABLES_BY_REVISION["20260716_0016"]
| frozenset(
{
"financial_connector_configs",
"financial_connector_events",
"payment_reconciliation_cases",
"payment_reconciliation_events",
}
)
)
MIGRATION_OWNED_TABLES_BY_REVISION["20260716_0018"] = (
MIGRATION_OWNED_TABLES_BY_REVISION["20260716_0017"]
| frozenset(
{
"agent_asset_release_observations",
"agent_asset_release_labels",
}
)
)
MIGRATION_OWNED_TABLES_BY_REVISION["20260716_0019"] = (
MIGRATION_OWNED_TABLES_BY_REVISION["20260716_0018"]
| frozenset({"commercial_runtime_reservations"})
)
MIGRATION_OWNED_TABLES_BY_REVISION["20260716_0020"] = (
MIGRATION_OWNED_TABLES_BY_REVISION["20260716_0019"]
| frozenset({"financial_connector_config_events"})
)
MIGRATION_OWNED_TABLES_BY_REVISION["20260716_0021"] = (
MIGRATION_OWNED_TABLES_BY_REVISION["20260716_0020"]
| frozenset({"commercial_admin_events", "commercial_billing_periods"})
)
MIGRATION_OWNED_TABLES_BY_REVISION["20260716_0022"] = (
MIGRATION_OWNED_TABLES_BY_REVISION["20260716_0021"]
| frozenset({"financial_connector_operational_events"})
)
MIGRATION_OWNED_TABLES_BY_REVISION["20260716_0023"] = (
MIGRATION_OWNED_TABLES_BY_REVISION["20260716_0022"]
| frozenset({"agent_asset_release_audit_samples"})
)
MIGRATION_OWNED_TABLES_BY_REVISION["20260717_0024"] = (
MIGRATION_OWNED_TABLES_BY_REVISION["20260716_0023"]
)
MIGRATION_OWNED_TABLES_BY_REVISION["20260717_0025"] = (
MIGRATION_OWNED_TABLES_BY_REVISION["20260717_0024"]
| frozenset({"tenants"})
)
MIGRATION_OWNED_TABLES_BY_REVISION["20260717_0026"] = (
MIGRATION_OWNED_TABLES_BY_REVISION["20260717_0025"]
)
MIGRATION_OWNED_TABLES_BY_REVISION["20260717_0027"] = (
MIGRATION_OWNED_TABLES_BY_REVISION["20260717_0026"]
| frozenset({"knowledge_onlyoffice_sessions"})
)
MIGRATION_OWNED_TABLES_BY_REVISION["20260717_0028"] = (
MIGRATION_OWNED_TABLES_BY_REVISION["20260717_0027"]
| frozenset({"tenant_finance_report_configs", "tenant_finance_report_runs"})
)
if MIGRATION_OWNED_TABLES_BY_REVISION["20260717_0028"] != MIGRATION_OWNED_TABLES:
raise RuntimeError("latest Alembic revision must own the centralized migration table set")
# 0008 之前这三张表由旧 bootstrap / 风险服务按需创建。迁移前置检查允许
@@ -314,6 +396,20 @@ def _validate_connection(connection: Connection) -> MigrationPreflightState:
"20260716_0012",
"20260716_0013",
"20260716_0014",
"20260716_0015",
"20260716_0016",
"20260716_0017",
"20260716_0018",
"20260716_0019",
"20260716_0020",
"20260716_0021",
"20260716_0022",
"20260716_0023",
"20260717_0024",
"20260717_0025",
"20260717_0026",
"20260717_0027",
"20260717_0028",
}
else frozenset()
)

View File

@@ -10,6 +10,9 @@ MIGRATION_OWNED_TABLES: frozenset[str] = frozenset(
"approval_action_ledgers",
"approval_task_events",
"approval_tasks",
"agent_asset_release_audit_samples",
"agent_asset_release_labels",
"agent_asset_release_observations",
"attachment_association_jobs",
"ai_application_preview_decisions",
"ai_decisions",
@@ -17,12 +20,35 @@ MIGRATION_OWNED_TABLES: frozenset[str] = frozenset(
"expense_cases",
"expense_case_links",
"business_events",
"financial_connector_configs",
"financial_connector_config_events",
"financial_connector_events",
"financial_connector_operational_events",
"commercial_cost_events",
"commercial_admin_events",
"commercial_billing_periods",
"commercial_entitlements",
"commercial_runtime_reservations",
"knowledge_onlyoffice_sessions",
"memory_entries",
"memory_evidence_links",
"risk_observations",
"risk_observation_feedback",
"risk_dispositions",
"risk_disposition_events",
"profile_baseline_snapshots",
"payment_reconciliation_cases",
"payment_reconciliation_events",
"savings_opportunities",
"savings_realizations",
"savings_evidence_links",
"savings_events",
"tenant_commercial_plans",
"tenant_finance_report_configs",
"tenant_finance_report_runs",
"tenant_subscriptions",
"tenants",
"usage_meter_events",
"few_shot_samples",
"workflow_outcomes",
}

View File

@@ -15,8 +15,10 @@ from app.core.openapi import API_DESCRIPTION, OPENAPI_TAGS
from app.db.session import get_session_factory
from app.middleware.logging import AccessLogMiddleware
from app.schemas.common import RootStatusRead
from app.services.agent_asset_release_scheduler import agent_asset_release_scheduler
from app.services.agent_foundation import prepare_agent_foundation
from app.services.approval_task_scheduler import approval_task_scheduler
from app.services.commercial_rollover_scheduler import commercial_rollover_scheduler
from app.services.digital_employee_reminder_scheduler import digital_employee_reminder_scheduler
from app.services.employee import EmployeeService, prepare_employee_directory
from app.services.employee_profile_scheduler import employee_profile_scheduler
@@ -28,6 +30,7 @@ from app.services.knowledge_index_tasks import knowledge_index_task_manager
from app.services.knowledge_rag import shutdown_knowledge_rag_runtime
from app.services.knowledge_scheduler import knowledge_index_scheduler
from app.services.settings import SettingsService
from app.services.tenant_registry import DEFAULT_TENANT_ID
from app.services.user_session_metrics import UserSessionMetricService
@@ -71,7 +74,10 @@ def _warm_startup_caches(logger: Logger) -> None:
session_factory = get_session_factory()
with session_factory() as db:
SettingsService(db).ensure_settings_ready()
EmployeeService(db).ensure_directory_ready()
EmployeeService(
db,
tenant_id=DEFAULT_TENANT_ID,
).ensure_directory_ready()
UserSessionMetricService(db).ensure_storage_ready()
logger.info("Startup cache warmup complete")
except Exception:
@@ -104,7 +110,9 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]:
schedulers_started = _should_start_background_schedulers(settings)
if schedulers_started:
knowledge_index_scheduler.start()
agent_asset_release_scheduler.start()
approval_task_scheduler.start()
commercial_rollover_scheduler.start()
finance_dashboard_scheduler.start()
employee_profile_scheduler.start()
digital_employee_reminder_scheduler.start()
@@ -123,7 +131,9 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]:
)
yield
if schedulers_started:
agent_asset_release_scheduler.shutdown()
approval_task_scheduler.shutdown()
commercial_rollover_scheduler.shutdown()
finance_report_scheduler.shutdown()
digital_employee_reminder_scheduler.shutdown()
employee_profile_scheduler.shutdown()

View File

@@ -4,6 +4,11 @@ from app.models.agent_asset import (
AgentAssetRuleFeedback,
AgentAssetVersion,
)
from app.models.agent_asset_release_telemetry import (
AgentAssetReleaseAuditSample,
AgentAssetReleaseLabel,
AgentAssetReleaseObservation,
)
from app.models.agent_conversation import AgentConversation, AgentConversationMessage
from app.models.agent_feedback import AgentOperationFeedback
from app.models.agent_run import AgentRun, AgentToolCall, AgentTraceEvent, SemanticParseLog
@@ -17,11 +22,28 @@ from app.models.attachment_association_job import AttachmentAssociationJob
from app.models.audit_log import AuditLog
from app.models.auth_session import AuthSession
from app.models.budget import BudgetAllocation, BudgetReservation, BudgetTransaction
from app.models.commercial import (
CommercialCostEvent,
CommercialEntitlement,
TenantCommercialPlan,
TenantSubscription,
UsageMeterEvent,
)
from app.models.commercial_billing import CommercialAdminEvent, CommercialBillingPeriod
from app.models.commercial_runtime import CommercialRuntimeReservation
from app.models.employee import Employee
from app.models.employee_behavior_profile import EmployeeBehaviorProfileSnapshot
from app.models.employee_change_log import EmployeeChangeLog
from app.models.expense_case import BusinessEvent, ExpenseCase, ExpenseCaseLink
from app.models.few_shot_sample import FewShotSample
from app.models.financial_connector import (
FinancialConnectorConfig,
FinancialConnectorConfigEvent,
FinancialConnectorEvent,
FinancialConnectorOperationalEvent,
PaymentReconciliationCase,
PaymentReconciliationEvent,
)
from app.models.financial_record import (
AccountsPayableRecord,
AccountsReceivableRecord,
@@ -31,15 +53,28 @@ from app.models.financial_record import (
from app.models.golden_case import GoldenCase
from app.models.hermes_config import HermesTaskConfig, HermesTaskExecutionLog
from app.models.hermes_report import HermesRiskReport
from app.models.knowledge_security import KnowledgeOnlyOfficeSession
from app.models.notification_state import NotificationState
from app.models.organization import OrganizationUnit
from app.models.reimbursement import ReimbursementRequest
from app.models.risk_disposition import RiskDisposition, RiskDispositionEvent
from app.models.risk_observation import RiskObservation, RiskObservationFeedback
from app.models.role import Role
from app.models.savings import (
ProfileBaselineSnapshot,
SavingsEvent,
SavingsEvidenceLink,
SavingsOpportunity,
SavingsRealization,
)
from app.models.system_model_setting import SystemModelSetting
from app.models.system_setting import SystemSetting
from app.models.system_setting_secret import SystemSettingSecret
from app.models.tenant import Tenant, TenantMembership
from app.models.tenant_finance_report import (
TenantFinanceReportConfig,
TenantFinanceReportRun,
)
from app.models.user_session_metric import UserSessionMetric
__all__ = [
@@ -51,6 +86,9 @@ __all__ = [
"AgentAssetReview",
"AgentAssetRuleFeedback",
"AgentAssetVersion",
"AgentAssetReleaseAuditSample",
"AgentAssetReleaseLabel",
"AgentAssetReleaseObservation",
"AgentOperationFeedback",
"AgentRun",
"AgentToolCall",
@@ -68,6 +106,11 @@ __all__ = [
"BudgetAllocation",
"BudgetReservation",
"BudgetTransaction",
"CommercialCostEvent",
"CommercialAdminEvent",
"CommercialBillingPeriod",
"CommercialEntitlement",
"CommercialRuntimeReservation",
"Employee",
"ExpenseCase",
"ExpenseCaseLink",
@@ -76,14 +119,21 @@ __all__ = [
"EmployeeChangeLog",
"ExpenseClaim",
"ExpenseClaimItem",
"FinancialConnectorConfig",
"FinancialConnectorConfigEvent",
"FinancialConnectorEvent",
"FinancialConnectorOperationalEvent",
"FewShotSample",
"GoldenCase",
"HermesTaskConfig",
"HermesTaskExecutionLog",
"HermesRiskReport",
"KnowledgeOnlyOfficeSession",
"MemoryEntry",
"MemoryEvidenceLink",
"NotificationState",
"PaymentReconciliationCase",
"PaymentReconciliationEvent",
"OrganizationUnit",
"ReimbursementRequest",
"RiskDisposition",
@@ -91,10 +141,22 @@ __all__ = [
"RiskObservation",
"RiskObservationFeedback",
"Role",
"ProfileBaselineSnapshot",
"SavingsEvent",
"SavingsEvidenceLink",
"SavingsOpportunity",
"SavingsRealization",
"SemanticParseLog",
"SystemModelSetting",
"SystemSetting",
"SystemSettingSecret",
"Tenant",
"TenantCommercialPlan",
"TenantMembership",
"TenantFinanceReportConfig",
"TenantFinanceReportRun",
"TenantSubscription",
"UsageMeterEvent",
"UserSessionMetric",
"WorkflowOutcome",
]

View File

@@ -4,19 +4,64 @@ import uuid
from datetime import datetime
from typing import Any
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, String, Text, UniqueConstraint, func
from sqlalchemy import (
Boolean,
CheckConstraint,
DateTime,
ForeignKey,
ForeignKeyConstraint,
Index,
String,
Text,
UniqueConstraint,
func,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.types import JSON
from app.core.agent_asset_scope import AGENT_ASSET_PLATFORM_TENANT_ID
from app.db.base_class import Base
class AgentAsset(Base):
__tablename__ = "agent_assets"
__table_args__ = (
UniqueConstraint(
"tenant_id",
"scope",
"id",
name="uq_agent_assets_tenant_scope_id",
),
UniqueConstraint(
"tenant_id",
"scope",
"code",
name="uq_agent_assets_tenant_scope_code",
),
CheckConstraint(
"(scope = 'platform' AND tenant_id = 'platform') OR "
"(scope = 'tenant' AND tenant_id <> 'platform')",
name="ck_agent_assets_scope_tenant",
),
Index("ix_agent_assets_scope_tenant", "scope", "tenant_id"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
tenant_id: Mapped[str] = mapped_column(
String(64),
ForeignKey("tenants.tenant_id", ondelete="RESTRICT"),
nullable=False,
default=AGENT_ASSET_PLATFORM_TENANT_ID,
server_default=AGENT_ASSET_PLATFORM_TENANT_ID,
)
scope: Mapped[str] = mapped_column(
String(16),
nullable=False,
default="platform",
server_default="platform",
)
asset_type: Mapped[str] = mapped_column(String(20), index=True)
code: Mapped[str] = mapped_column(String(100), unique=True, index=True)
code: Mapped[str] = mapped_column(String(100), index=True)
name: Mapped[str] = mapped_column(String(200))
description: Mapped[str] = mapped_column(Text(), default="")
domain: Mapped[str] = mapped_column(String(50), index=True)
@@ -60,14 +105,94 @@ class AgentAsset(Base):
)
class AgentAssetOnlyOfficeSession(Base):
__tablename__ = "agent_asset_onlyoffice_sessions"
__table_args__ = (
CheckConstraint(
"(resource_scope = 'platform' AND tenant_id = 'platform') OR "
"(resource_scope = 'tenant' AND tenant_id <> 'platform')",
name="ck_agent_asset_onlyoffice_sessions_scope_tenant",
),
CheckConstraint(
"status IN ('active', 'processing', 'consumed', 'failed', 'revoked')",
name="ck_agent_asset_onlyoffice_sessions_status",
),
CheckConstraint(
"(status = 'active' AND claimed_at IS NULL AND consumed_at IS NULL) OR "
"(status IN ('processing', 'failed') AND claimed_at IS NOT NULL "
"AND consumed_at IS NULL) OR "
"(status = 'consumed' AND claimed_at IS NOT NULL AND consumed_at IS NOT NULL) OR "
"(status = 'revoked' AND consumed_at IS NULL)",
name="ck_agent_asset_onlyoffice_sessions_lifecycle",
),
Index(
"ix_agent_asset_onlyoffice_sessions_tenant_asset",
"tenant_id",
"resource_scope",
"asset_id",
"created_at",
),
Index(
"ix_agent_asset_onlyoffice_sessions_status_expiry",
"status",
"expires_at",
),
)
jti: Mapped[str] = mapped_column(String(36), primary_key=True)
tenant_id: Mapped[str] = mapped_column(
String(64),
ForeignKey("tenants.tenant_id", ondelete="CASCADE"),
nullable=False,
)
resource_scope: Mapped[str] = mapped_column(String(16), nullable=False)
asset_id: Mapped[str] = mapped_column(String(100), nullable=False)
document_key: Mapped[str] = mapped_column(String(200), nullable=False)
document_version: Mapped[str] = mapped_column(String(30), nullable=False)
document_fingerprint: Mapped[str] = mapped_column(String(160), nullable=False)
audience: Mapped[str] = mapped_column(String(80), nullable=False)
writable: Mapped[bool] = mapped_column(Boolean, nullable=False)
status: Mapped[str] = mapped_column(String(16), nullable=False)
actor: Mapped[str] = mapped_column(String(160), nullable=False)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
claimed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
consumed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
failure_reason: Mapped[str] = mapped_column(Text(), nullable=False, default="")
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
class AgentAssetVersion(Base):
__tablename__ = "agent_asset_versions"
__table_args__ = (
UniqueConstraint("asset_id", "version", name="uq_agent_asset_versions_asset_version"),
CheckConstraint(
"(scope = 'platform' AND tenant_id = 'platform') OR "
"(scope = 'tenant' AND tenant_id <> 'platform')",
name="ck_agent_asset_versions_scope_tenant",
),
ForeignKeyConstraint(
["tenant_id", "scope", "asset_id"],
["agent_assets.tenant_id", "agent_assets.scope", "agent_assets.id"],
ondelete="CASCADE",
name="fk_agent_asset_versions_tenant_asset",
),
Index("ix_agent_asset_versions_tenant_asset", "tenant_id", "scope", "asset_id"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
asset_id: Mapped[str] = mapped_column(ForeignKey("agent_assets.id"), index=True)
tenant_id: Mapped[str] = mapped_column(
String(64),
ForeignKey("tenants.tenant_id", ondelete="RESTRICT"),
nullable=False,
default=AGENT_ASSET_PLATFORM_TENANT_ID,
server_default=AGENT_ASSET_PLATFORM_TENANT_ID,
)
scope: Mapped[str] = mapped_column(
String(16), nullable=False, default="platform", server_default="platform"
)
asset_id: Mapped[str] = mapped_column(String(36), index=True)
version: Mapped[str] = mapped_column(String(30))
content: Mapped[str] = mapped_column(Text())
content_type: Mapped[str] = mapped_column(String(20))
@@ -80,9 +205,33 @@ class AgentAssetVersion(Base):
class AgentAssetReview(Base):
__tablename__ = "agent_asset_reviews"
__table_args__ = (
CheckConstraint(
"(scope = 'platform' AND tenant_id = 'platform') OR "
"(scope = 'tenant' AND tenant_id <> 'platform')",
name="ck_agent_asset_reviews_scope_tenant",
),
ForeignKeyConstraint(
["tenant_id", "scope", "asset_id"],
["agent_assets.tenant_id", "agent_assets.scope", "agent_assets.id"],
ondelete="CASCADE",
name="fk_agent_asset_reviews_tenant_asset",
),
Index("ix_agent_asset_reviews_tenant_asset", "tenant_id", "scope", "asset_id"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
asset_id: Mapped[str] = mapped_column(ForeignKey("agent_assets.id"), index=True)
tenant_id: Mapped[str] = mapped_column(
String(64),
ForeignKey("tenants.tenant_id", ondelete="RESTRICT"),
nullable=False,
default=AGENT_ASSET_PLATFORM_TENANT_ID,
server_default=AGENT_ASSET_PLATFORM_TENANT_ID,
)
scope: Mapped[str] = mapped_column(
String(16), nullable=False, default="platform", server_default="platform"
)
asset_id: Mapped[str] = mapped_column(String(36), index=True)
version: Mapped[str] = mapped_column(String(30))
reviewer: Mapped[str] = mapped_column(String(100))
review_status: Mapped[str] = mapped_column(String(20), index=True)
@@ -95,9 +244,29 @@ class AgentAssetReview(Base):
class AgentAssetTestRun(Base):
__tablename__ = "agent_asset_test_runs"
__table_args__ = (
CheckConstraint(
"(scope = 'platform' AND tenant_id = 'platform') OR "
"(scope = 'tenant' AND tenant_id <> 'platform')",
name="ck_agent_asset_test_runs_scope_tenant",
),
Index("ix_agent_asset_test_runs_tenant_asset", "tenant_id", "scope", "asset_id"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
asset_id: Mapped[str] = mapped_column(ForeignKey("agent_assets.id"), index=True)
tenant_id: Mapped[str] = mapped_column(
String(64),
ForeignKey("tenants.tenant_id", ondelete="RESTRICT"),
nullable=False,
default=AGENT_ASSET_PLATFORM_TENANT_ID,
server_default=AGENT_ASSET_PLATFORM_TENANT_ID,
)
scope: Mapped[str] = mapped_column(
String(16), nullable=False, default="platform", server_default="platform"
)
asset_id: Mapped[str] = mapped_column(
ForeignKey("agent_assets.id", ondelete="CASCADE"), index=True
)
version: Mapped[str] = mapped_column(String(30), index=True)
test_type: Mapped[str] = mapped_column(String(30), index=True)
status: Mapped[str] = mapped_column(String(20), index=True)
@@ -116,6 +285,17 @@ class AgentAssetRuleFeedback(Base):
__table_args__ = (
Index("ix_agent_asset_rule_feedback_asset_version", "asset_id", "version"),
Index("ix_agent_asset_rule_feedback_type_status", "feedback_type", "status"),
Index(
"ix_agent_asset_rule_feedback_tenant_asset",
"tenant_id",
"scope",
"asset_id",
),
CheckConstraint(
"(scope = 'platform' AND tenant_id = 'platform') OR "
"(scope = 'tenant' AND tenant_id <> 'platform')",
name="ck_agent_asset_rule_feedback_scope_tenant",
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
@@ -125,7 +305,19 @@ class AgentAssetRuleFeedback(Base):
index=True,
default=lambda: f"arf_{uuid.uuid4().hex[:16]}",
)
asset_id: Mapped[str] = mapped_column(ForeignKey("agent_assets.id"), index=True)
tenant_id: Mapped[str] = mapped_column(
String(64),
ForeignKey("tenants.tenant_id", ondelete="RESTRICT"),
nullable=False,
default=AGENT_ASSET_PLATFORM_TENANT_ID,
server_default=AGENT_ASSET_PLATFORM_TENANT_ID,
)
scope: Mapped[str] = mapped_column(
String(16), nullable=False, default="platform", server_default="platform"
)
asset_id: Mapped[str] = mapped_column(
ForeignKey("agent_assets.id", ondelete="CASCADE"), index=True
)
version: Mapped[str] = mapped_column(String(30), index=True)
feedback_type: Mapped[str] = mapped_column(String(30), index=True)
status: Mapped[str] = mapped_column(String(30), default="open", index=True)
@@ -137,6 +329,8 @@ class AgentAssetRuleFeedback(Base):
comment: Mapped[str | None] = mapped_column(Text(), nullable=True)
payload_json: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
created_by: Mapped[str] = mapped_column(String(100), default="", index=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), index=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), index=True
)
asset = relationship("AgentAsset", back_populates="rule_feedback_items")

View File

@@ -0,0 +1,298 @@
"""Agent 资产分阶段发布的只追加运行观察与人工标签。"""
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import (
Boolean,
CheckConstraint,
DateTime,
ForeignKeyConstraint,
Index,
Integer,
String,
Text,
UniqueConstraint,
event,
func,
)
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base_class import Base
def _new_id() -> str:
return str(uuid.uuid4())
class AgentAssetReleaseObservation(Base):
"""一次真实规则执行的去敏事实;同一来源重放只能得到同一条记录。"""
__tablename__ = "agent_asset_release_observations"
__table_args__ = (
UniqueConstraint(
"tenant_id",
"id",
name="uq_agent_asset_release_observations_tenant_id",
),
UniqueConstraint(
"tenant_id",
"idempotency_key",
name="uq_agent_asset_release_observations_tenant_idempotency",
),
UniqueConstraint(
"tenant_id",
"id",
"asset_id",
"release_id",
"stage",
"version",
name="uq_agent_asset_release_observations_release_identity",
),
CheckConstraint(
"stage IN ('shadow', 'canary', 'active')",
name="ck_agent_asset_release_observations_stage",
),
CheckConstraint(
"runtime_status IN ('completed', 'failed')",
name="ck_agent_asset_release_observations_runtime_status",
),
CheckConstraint(
"source_kind IN ('expense_claim_risk')",
name="ck_agent_asset_release_observations_source_kind",
),
Index(
"ix_agent_asset_release_observations_release",
"tenant_id",
"asset_id",
"release_id",
"stage",
"version",
"created_at",
),
Index(
"ix_agent_asset_release_observations_source",
"tenant_id",
"source_fingerprint",
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_new_id)
tenant_id: Mapped[str] = mapped_column(String(64), nullable=False)
asset_id: Mapped[str] = mapped_column(String(36), nullable=False)
release_id: Mapped[str] = mapped_column(String(64), nullable=False)
stage: Mapped[str] = mapped_column(String(16), nullable=False)
version: Mapped[str] = mapped_column(String(30), nullable=False)
rule_code: Mapped[str] = mapped_column(String(100), nullable=False)
business_stage: Mapped[str] = mapped_column(String(40), nullable=False)
source_kind: Mapped[str] = mapped_column(
String(32),
nullable=False,
default="expense_claim_risk",
server_default="expense_claim_risk",
)
# 只保存租户、单据和规则的不可逆指纹,不保存单号、事由或票据内容。
source_fingerprint: Mapped[str] = mapped_column(String(64), nullable=False)
candidate_hit: Mapped[bool] = mapped_column(Boolean, nullable=False)
baseline_hit: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
runtime_status: Mapped[str] = mapped_column(String(16), nullable=False)
failure_code: Mapped[str] = mapped_column(
String(40),
nullable=False,
default="none",
server_default="none",
)
idempotency_key: Mapped[str] = mapped_column(String(80), nullable=False)
payload_fingerprint: Mapped[str] = mapped_column(String(64), nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
server_default=func.now(),
)
class AgentAssetReleaseLabel(Base):
"""对运行观察的人工真值标签;纠正通过追加新标签完成。"""
__tablename__ = "agent_asset_release_labels"
__table_args__ = (
UniqueConstraint(
"tenant_id",
"id",
name="uq_agent_asset_release_labels_tenant_id",
),
UniqueConstraint(
"tenant_id",
"idempotency_key",
name="uq_agent_asset_release_labels_tenant_idempotency",
),
CheckConstraint(
"label IN ('confirmed', 'false_positive', 'risk_present', 'risk_absent')",
name="ck_agent_asset_release_labels_label",
),
CheckConstraint(
"verification_source IN ('typed_risk_disposition', 'release_review', "
"'blind_release_review')",
name="ck_agent_asset_release_labels_source",
),
CheckConstraint(
"(verification_source = 'blind_release_review' "
"AND label IN ('risk_present', 'risk_absent')) OR "
"(verification_source IN ('typed_risk_disposition', 'release_review') "
"AND label IN ('confirmed', 'false_positive'))",
name="ck_agent_asset_release_labels_semantics",
),
ForeignKeyConstraint(
[
"tenant_id",
"observation_id",
"asset_id",
"release_id",
"stage",
"version",
],
[
"agent_asset_release_observations.tenant_id",
"agent_asset_release_observations.id",
"agent_asset_release_observations.asset_id",
"agent_asset_release_observations.release_id",
"agent_asset_release_observations.stage",
"agent_asset_release_observations.version",
],
ondelete="RESTRICT",
name="fk_agent_asset_release_labels_release_observation",
),
Index(
"ix_agent_asset_release_labels_observation_time",
"tenant_id",
"observation_id",
"created_at",
),
Index(
"ix_agent_asset_release_labels_release",
"tenant_id",
"asset_id",
"release_id",
"stage",
"version",
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_new_id)
tenant_id: Mapped[str] = mapped_column(String(64), nullable=False)
observation_id: Mapped[str] = mapped_column(String(36), nullable=False)
asset_id: Mapped[str] = mapped_column(String(36), nullable=False)
release_id: Mapped[str] = mapped_column(String(64), nullable=False)
stage: Mapped[str] = mapped_column(String(16), nullable=False)
version: Mapped[str] = mapped_column(String(30), nullable=False)
label: Mapped[str] = mapped_column(String(24), nullable=False)
verification_source: Mapped[str] = mapped_column(String(32), nullable=False)
# 事件和操作者只保留租户内稳定指纹,避免把账号标识带入评测数据集。
source_event_fingerprint: Mapped[str] = mapped_column(String(64), nullable=False)
actor_fingerprint: Mapped[str] = mapped_column(String(64), nullable=False)
idempotency_key: Mapped[str] = mapped_column(String(80), nullable=False)
payload_fingerprint: Mapped[str] = mapped_column(String(64), nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
server_default=func.now(),
)
class AgentAssetReleaseAuditSample(Base):
"""发布盲审样本;模型结论和业务来源只在服务端受控解析。"""
__tablename__ = "agent_asset_release_audit_samples"
__table_args__ = (
UniqueConstraint(
"tenant_id",
"id",
name="uq_agent_asset_release_audit_samples_tenant_id",
),
UniqueConstraint(
"tenant_id",
"observation_id",
name="uq_agent_asset_release_audit_samples_observation",
),
UniqueConstraint(
"tenant_id",
"idempotency_key",
name="uq_agent_asset_release_audit_samples_idempotency",
),
CheckConstraint(
"stratum IN ('candidate_positive_census', "
"'candidate_disagreement_census', 'candidate_negative_random')",
name="ck_agent_asset_release_audit_samples_stratum",
),
CheckConstraint(
"sampling_probability_ppm BETWEEN 1 AND 1000000",
name="ck_agent_asset_release_audit_samples_probability",
),
CheckConstraint(
"selection_score_ppm BETWEEN 0 AND 999999",
name="ck_agent_asset_release_audit_samples_score",
),
ForeignKeyConstraint(
[
"tenant_id",
"observation_id",
"asset_id",
"release_id",
"stage",
"version",
],
[
"agent_asset_release_observations.tenant_id",
"agent_asset_release_observations.id",
"agent_asset_release_observations.asset_id",
"agent_asset_release_observations.release_id",
"agent_asset_release_observations.stage",
"agent_asset_release_observations.version",
],
ondelete="RESTRICT",
name="fk_agent_asset_release_audit_samples_observation",
),
Index(
"ix_agent_asset_release_audit_samples_release",
"tenant_id",
"asset_id",
"release_id",
"stage",
"version",
"created_at",
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_new_id)
tenant_id: Mapped[str] = mapped_column(String(64), nullable=False)
observation_id: Mapped[str] = mapped_column(String(36), nullable=False)
asset_id: Mapped[str] = mapped_column(String(36), nullable=False)
release_id: Mapped[str] = mapped_column(String(64), nullable=False)
stage: Mapped[str] = mapped_column(String(16), nullable=False)
version: Mapped[str] = mapped_column(String(30), nullable=False)
stratum: Mapped[str] = mapped_column(String(40), nullable=False)
sampling_probability_ppm: Mapped[int] = mapped_column(Integer, nullable=False)
selection_score_ppm: Mapped[int] = mapped_column(Integer, nullable=False)
source_reference_encrypted: Mapped[str] = mapped_column(Text, nullable=False)
idempotency_key: Mapped[str] = mapped_column(String(80), nullable=False)
payload_fingerprint: Mapped[str] = mapped_column(String(64), nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
server_default=func.now(),
)
def _reject_mutation(_mapper: object, _connection: object, target: object) -> None:
raise ValueError(f"{type(target).__name__} is append-only and cannot be mutated.")
for _model in (
AgentAssetReleaseObservation,
AgentAssetReleaseLabel,
AgentAssetReleaseAuditSample,
):
event.listen(_model, "before_update", _reject_mutation)
event.listen(_model, "before_delete", _reject_mutation)

View File

@@ -18,7 +18,7 @@ class AuthSession(Base):
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
token_hash: Mapped[str] = mapped_column(String(64), unique=True)
tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True)
tenant_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
principal_type: Mapped[str] = mapped_column(String(20), index=True)
employee_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
username: Mapped[str] = mapped_column(String(255), index=True)

View File

@@ -0,0 +1,607 @@
from __future__ import annotations
import uuid
from datetime import datetime
from decimal import Decimal
from typing import Any
from sqlalchemy import (
Boolean,
CheckConstraint,
DateTime,
ForeignKeyConstraint,
Index,
Integer,
Numeric,
String,
UniqueConstraint,
func,
text,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.types import JSON
from app.db.base_class import Base
def _new_id() -> str:
return str(uuid.uuid4())
class TenantCommercialPlan(Base):
"""租户已协商的商业套餐版本,不与平台内部成本或客户节省事实混用。"""
__tablename__ = "tenant_commercial_plans"
__table_args__ = (
UniqueConstraint("tenant_id", "id", name="uq_tenant_commercial_plans_tenant_id"),
UniqueConstraint(
"tenant_id",
"plan_code",
"version",
name="uq_tenant_commercial_plans_tenant_code_version",
),
CheckConstraint(
"pricing_model IN ('subscription', 'usage', 'hybrid', 'pilot', 'custom')",
name="ck_tenant_commercial_plans_pricing_model",
),
CheckConstraint(
"billing_interval IN ('monthly', 'quarterly', 'annual', 'contract')",
name="ck_tenant_commercial_plans_billing_interval",
),
CheckConstraint(
"status IN ('draft', 'active', 'retired')",
name="ck_tenant_commercial_plans_status",
),
CheckConstraint(
"base_fee >= 0 AND included_seats >= 0 AND version >= 1",
name="ck_tenant_commercial_plans_values",
),
CheckConstraint(
"length(trim(plan_code)) > 0 AND length(trim(name)) > 0",
name="ck_tenant_commercial_plans_keys",
),
CheckConstraint(
"length(trim(currency)) = 3",
name="ck_tenant_commercial_plans_currency",
),
CheckConstraint(
"effective_to IS NULL OR effective_to > effective_from",
name="ck_tenant_commercial_plans_effective_window",
),
Index(
"uq_tenant_commercial_plans_active_code",
"tenant_id",
"plan_code",
unique=True,
postgresql_where=text("status = 'active'"),
),
Index(
"ix_tenant_commercial_plans_tenant_status",
"tenant_id",
"status",
"effective_from",
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_new_id)
tenant_id: Mapped[str] = mapped_column(String(64), nullable=False)
plan_code: Mapped[str] = mapped_column(String(80), nullable=False)
name: Mapped[str] = mapped_column(String(160), nullable=False)
pricing_model: Mapped[str] = mapped_column(String(24), nullable=False)
billing_interval: Mapped[str] = mapped_column(String(20), nullable=False)
currency: Mapped[str] = mapped_column(String(3), nullable=False)
base_fee: Mapped[Decimal] = mapped_column(Numeric(20, 4), nullable=False)
included_seats: Mapped[int] = mapped_column(
Integer, nullable=False, default=0, server_default="0"
)
overage_enabled: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False, server_default="false"
)
status: Mapped[str] = mapped_column(
String(16), nullable=False, default="draft", server_default="draft"
)
effective_from: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
effective_to: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
version: Mapped[int] = mapped_column(Integer, nullable=False, default=1, server_default="1")
contract_terms_json: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False, default=dict)
created_by: Mapped[str] = mapped_column(String(120), nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
)
subscriptions = relationship("TenantSubscription", back_populates="plan", passive_deletes=True)
class TenantSubscription(Base):
"""租户订阅及当期计费快照,避免套餐后续变化污染历史账期。"""
__tablename__ = "tenant_subscriptions"
__table_args__ = (
UniqueConstraint("tenant_id", "id", name="uq_tenant_subscriptions_tenant_id"),
UniqueConstraint(
"tenant_id", "subscription_key", name="uq_tenant_subscriptions_tenant_key"
),
UniqueConstraint(
"tenant_id",
"external_provider",
"external_subscription_id",
name="uq_tenant_subscriptions_external_ref",
),
ForeignKeyConstraint(
["tenant_id", "plan_id"],
["tenant_commercial_plans.tenant_id", "tenant_commercial_plans.id"],
name="fk_tenant_subscriptions_tenant_plan",
ondelete="RESTRICT",
),
CheckConstraint(
"status IN ('trialing', 'active', 'past_due', 'suspended', 'canceled', 'expired')",
name="ck_tenant_subscriptions_status",
),
CheckConstraint(
"billing_interval IN ('monthly', 'quarterly', 'annual', 'contract')",
name="ck_tenant_subscriptions_billing_interval",
),
CheckConstraint(
"seats > 0 AND base_fee_snapshot >= 0 AND version >= 1",
name="ck_tenant_subscriptions_values",
),
CheckConstraint(
"length(trim(subscription_key)) > 0 AND length(trim(currency)) = 3",
name="ck_tenant_subscriptions_keys",
),
CheckConstraint(
"current_period_end > current_period_start",
name="ck_tenant_subscriptions_period",
),
CheckConstraint(
"ends_at IS NULL OR ends_at > starts_at",
name="ck_tenant_subscriptions_contract_window",
),
CheckConstraint(
"(external_provider IS NULL AND external_subscription_id IS NULL) OR "
"(external_provider IS NOT NULL AND external_subscription_id IS NOT NULL)",
name="ck_tenant_subscriptions_external_pair",
),
CheckConstraint(
"status != 'canceled' OR canceled_at IS NOT NULL",
name="ck_tenant_subscriptions_cancellation",
),
Index(
"uq_tenant_subscriptions_current",
"tenant_id",
unique=True,
postgresql_where=text("status IN ('trialing', 'active', 'past_due', 'suspended')"),
),
Index(
"ix_tenant_subscriptions_tenant_status_period",
"tenant_id",
"status",
"current_period_end",
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_new_id)
tenant_id: Mapped[str] = mapped_column(String(64), nullable=False)
subscription_key: Mapped[str] = mapped_column(String(120), nullable=False)
plan_id: Mapped[str] = mapped_column(String(36), nullable=False)
status: Mapped[str] = mapped_column(String(20), nullable=False)
starts_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
ends_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
current_period_start: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
current_period_end: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
seats: Mapped[int] = mapped_column(Integer, nullable=False)
base_fee_snapshot: Mapped[Decimal] = mapped_column(Numeric(20, 4), nullable=False)
currency: Mapped[str] = mapped_column(String(3), nullable=False)
billing_interval: Mapped[str] = mapped_column(String(20), nullable=False)
auto_renew: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False, server_default="false"
)
external_provider: Mapped[str | None] = mapped_column(String(60))
external_subscription_id: Mapped[str | None] = mapped_column(String(160))
canceled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
version: Mapped[int] = mapped_column(Integer, nullable=False, default=1, server_default="1")
metadata_json: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False, default=dict)
created_by: Mapped[str] = mapped_column(String(120), nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
)
plan = relationship("TenantCommercialPlan", back_populates="subscriptions")
entitlements = relationship(
"CommercialEntitlement", back_populates="subscription", passive_deletes=True
)
class CommercialEntitlement(Base):
"""订阅的功能权益与可计量配额定义。"""
__tablename__ = "commercial_entitlements"
__table_args__ = (
UniqueConstraint("tenant_id", "id", name="uq_commercial_entitlements_tenant_id"),
UniqueConstraint(
"tenant_id",
"subscription_id",
"id",
name="uq_commercial_entitlements_tenant_subscription_id",
),
UniqueConstraint(
"tenant_id",
"subscription_id",
"entitlement_key",
name="uq_commercial_entitlements_subscription_key",
),
ForeignKeyConstraint(
["tenant_id", "subscription_id"],
["tenant_subscriptions.tenant_id", "tenant_subscriptions.id"],
name="fk_commercial_entitlements_tenant_subscription",
ondelete="RESTRICT",
),
CheckConstraint(
"entitlement_type IN ('feature', 'metered', 'unlimited')",
name="ck_commercial_entitlements_type",
),
CheckConstraint(
"reset_interval IN ('none', 'monthly', 'quarterly', 'annual', 'contract')",
name="ck_commercial_entitlements_reset_interval",
),
CheckConstraint(
"overage_policy IN ('block', 'allow', 'alert')",
name="ck_commercial_entitlements_overage_policy",
),
CheckConstraint(
"status IN ('active', 'suspended', 'expired')",
name="ck_commercial_entitlements_status",
),
CheckConstraint(
"version >= 1 AND (included_quantity IS NULL OR included_quantity >= 0) "
"AND (hard_limit_quantity IS NULL OR hard_limit_quantity >= 0)",
name="ck_commercial_entitlements_values",
),
CheckConstraint(
"(entitlement_type = 'unlimited' AND included_quantity IS NULL "
"AND hard_limit_quantity IS NULL) OR "
"(entitlement_type = 'feature' AND included_quantity IN (0, 1) "
"AND (hard_limit_quantity IS NULL OR hard_limit_quantity IN (0, 1))) OR "
"(entitlement_type = 'metered' AND included_quantity IS NOT NULL "
"AND (hard_limit_quantity IS NULL OR hard_limit_quantity >= included_quantity))",
name="ck_commercial_entitlements_quota_shape",
),
CheckConstraint(
"length(trim(entitlement_key)) > 0 AND length(trim(metric_key)) > 0 "
"AND length(trim(unit)) > 0",
name="ck_commercial_entitlements_keys",
),
CheckConstraint(
"effective_to IS NULL OR effective_to > effective_from",
name="ck_commercial_entitlements_effective_window",
),
Index(
"ix_commercial_entitlements_subscription_status",
"tenant_id",
"subscription_id",
"status",
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_new_id)
tenant_id: Mapped[str] = mapped_column(String(64), nullable=False)
subscription_id: Mapped[str] = mapped_column(String(36), nullable=False)
entitlement_key: Mapped[str] = mapped_column(String(120), nullable=False)
metric_key: Mapped[str] = mapped_column(String(120), nullable=False)
entitlement_type: Mapped[str] = mapped_column(String(20), nullable=False)
unit: Mapped[str] = mapped_column(String(40), nullable=False)
included_quantity: Mapped[Decimal | None] = mapped_column(Numeric(20, 6))
hard_limit_quantity: Mapped[Decimal | None] = mapped_column(Numeric(20, 6))
reset_interval: Mapped[str] = mapped_column(String(20), nullable=False)
overage_policy: Mapped[str] = mapped_column(String(16), nullable=False)
status: Mapped[str] = mapped_column(String(16), nullable=False)
effective_from: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
effective_to: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
version: Mapped[int] = mapped_column(Integer, nullable=False, default=1, server_default="1")
config_json: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False, default=dict)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
)
subscription = relationship("TenantSubscription", back_populates="entitlements")
class UsageMeterEvent(Base):
"""追加只读的客户用量事实;同一来源幂等键只能产生一条事件。"""
__tablename__ = "usage_meter_events"
__table_args__ = (
UniqueConstraint("tenant_id", "id", name="uq_usage_meter_events_tenant_id"),
UniqueConstraint(
"tenant_id",
"subscription_id",
"id",
name="uq_usage_meter_events_tenant_subscription_id",
),
UniqueConstraint(
"tenant_id",
"subscription_id",
"entitlement_id",
"id",
name="uq_usage_meter_events_entitlement_id",
),
UniqueConstraint(
"tenant_id",
"source_system",
"idempotency_key",
name="uq_usage_meter_events_source_request",
),
ForeignKeyConstraint(
["tenant_id", "subscription_id"],
["tenant_subscriptions.tenant_id", "tenant_subscriptions.id"],
name="fk_usage_meter_events_tenant_subscription",
ondelete="RESTRICT",
),
ForeignKeyConstraint(
["tenant_id", "subscription_id", "entitlement_id"],
[
"commercial_entitlements.tenant_id",
"commercial_entitlements.subscription_id",
"commercial_entitlements.id",
],
name="fk_usage_meter_events_tenant_entitlement",
ondelete="RESTRICT",
),
ForeignKeyConstraint(
["tenant_id", "subscription_id", "billing_period_id"],
[
"commercial_billing_periods.tenant_id",
"commercial_billing_periods.subscription_id",
"commercial_billing_periods.id",
],
name="fk_usage_meter_events_tenant_billing_period",
ondelete="RESTRICT",
),
ForeignKeyConstraint(
["tenant_id", "subscription_id", "entitlement_id", "reversal_of_event_id"],
[
"usage_meter_events.tenant_id",
"usage_meter_events.subscription_id",
"usage_meter_events.entitlement_id",
"usage_meter_events.id",
],
name="fk_usage_meter_events_tenant_reversal",
ondelete="RESTRICT",
),
CheckConstraint(
"event_type IN ('usage', 'credit', 'adjustment', 'reversal')",
name="ck_usage_meter_events_type",
),
CheckConstraint(
"(event_type = 'usage' AND quantity > 0) OR "
"(event_type = 'credit' AND quantity < 0) OR "
"(event_type IN ('adjustment', 'reversal') AND quantity <> 0)",
name="ck_usage_meter_events_quantity",
),
CheckConstraint(
"(event_type = 'reversal' AND reversal_of_event_id IS NOT NULL) OR "
"(event_type != 'reversal' AND reversal_of_event_id IS NULL)",
name="ck_usage_meter_events_reversal",
),
CheckConstraint(
"(subject_type IS NULL AND subject_id IS NULL) OR "
"(subject_type IS NOT NULL AND subject_id IS NOT NULL)",
name="ck_usage_meter_events_subject_pair",
),
CheckConstraint(
"actor_type IN ('system', 'user', 'integration', 'admin')",
name="ck_usage_meter_events_actor_type",
),
CheckConstraint(
"length(trim(metric_key)) > 0 AND length(trim(unit)) > 0 "
"AND length(trim(period_key)) > 0 AND length(trim(source_system)) > 0 "
"AND length(trim(quota_period_key)) > 0 "
"AND length(trim(idempotency_key)) > 0 "
"AND length(trim(request_fingerprint)) > 0",
name="ck_usage_meter_events_keys",
),
Index(
"ix_usage_meter_events_quota_window",
"tenant_id",
"subscription_id",
"metric_key",
"quota_period_key",
"occurred_at",
),
Index(
"ix_usage_meter_events_billing_period",
"tenant_id",
"billing_period_id",
"occurred_at",
),
Index(
"ix_usage_meter_events_correlation",
"tenant_id",
"correlation_id",
"occurred_at",
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_new_id)
tenant_id: Mapped[str] = mapped_column(String(64), nullable=False)
subscription_id: Mapped[str] = mapped_column(String(36), nullable=False)
entitlement_id: Mapped[str] = mapped_column(String(36), nullable=False)
billing_period_id: Mapped[str] = mapped_column(String(36), nullable=False)
event_type: Mapped[str] = mapped_column(String(16), nullable=False)
metric_key: Mapped[str] = mapped_column(String(120), nullable=False)
quantity: Mapped[Decimal] = mapped_column(Numeric(20, 6), nullable=False)
unit: Mapped[str] = mapped_column(String(40), nullable=False)
period_key: Mapped[str] = mapped_column(String(64), nullable=False)
quota_period_key: Mapped[str] = mapped_column(String(64), nullable=False)
occurred_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
source_system: Mapped[str] = mapped_column(String(80), nullable=False)
idempotency_key: Mapped[str] = mapped_column(String(160), nullable=False)
request_fingerprint: Mapped[str] = mapped_column(String(80), nullable=False)
reversal_of_event_id: Mapped[str | None] = mapped_column(String(36))
subject_type: Mapped[str | None] = mapped_column(String(60))
subject_id: Mapped[str | None] = mapped_column(String(160))
actor_type: Mapped[str] = mapped_column(String(20), nullable=False)
actor_id: Mapped[str] = mapped_column(String(120), nullable=False)
correlation_id: Mapped[str | None] = mapped_column(String(120))
trace_id: Mapped[str | None] = mapped_column(String(120))
metadata_json: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False, default=dict)
recorded_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
class CommercialCostEvent(Base):
"""平台内部成本事实;物理上独立于客户节省、价值机会和价值实现。"""
__tablename__ = "commercial_cost_events"
__table_args__ = (
UniqueConstraint("tenant_id", "id", name="uq_commercial_cost_events_tenant_id"),
UniqueConstraint(
"tenant_id",
"source_system",
"idempotency_key",
name="uq_commercial_cost_events_source_request",
),
ForeignKeyConstraint(
["tenant_id", "subscription_id"],
["tenant_subscriptions.tenant_id", "tenant_subscriptions.id"],
name="fk_commercial_cost_events_tenant_subscription",
ondelete="RESTRICT",
),
ForeignKeyConstraint(
["tenant_id", "subscription_id", "usage_event_id"],
[
"usage_meter_events.tenant_id",
"usage_meter_events.subscription_id",
"usage_meter_events.id",
],
name="fk_commercial_cost_events_tenant_usage",
ondelete="RESTRICT",
),
ForeignKeyConstraint(
["tenant_id", "subscription_id", "billing_period_id"],
[
"commercial_billing_periods.tenant_id",
"commercial_billing_periods.subscription_id",
"commercial_billing_periods.id",
],
name="fk_commercial_cost_events_tenant_billing_period",
ondelete="RESTRICT",
),
ForeignKeyConstraint(
["tenant_id", "reversal_of_cost_event_id"],
["commercial_cost_events.tenant_id", "commercial_cost_events.id"],
name="fk_commercial_cost_events_tenant_reversal",
ondelete="RESTRICT",
),
CheckConstraint(
"event_type IN ('incurred', 'credit', 'adjustment', 'reversal')",
name="ck_commercial_cost_events_type",
),
CheckConstraint(
"cost_category IN ('ai_inference', 'ocr', 'storage', 'connector', "
"'support', 'implementation', 'infrastructure', 'payment', 'other')",
name="ck_commercial_cost_events_category",
),
CheckConstraint(
"quantity > 0 AND unit_cost >= 0 AND fx_rate > 0",
name="ck_commercial_cost_events_values",
),
CheckConstraint(
"(event_type = 'incurred' AND cost_amount >= 0 AND reporting_amount >= 0) OR "
"(event_type = 'credit' AND cost_amount <= 0 AND reporting_amount <= 0) OR "
"(event_type IN ('adjustment', 'reversal') AND cost_amount <> 0 "
"AND reporting_amount <> 0)",
name="ck_commercial_cost_events_amount_direction",
),
CheckConstraint(
"(event_type = 'reversal' AND reversal_of_cost_event_id IS NOT NULL) OR "
"(event_type != 'reversal' AND reversal_of_cost_event_id IS NULL)",
name="ck_commercial_cost_events_reversal",
),
CheckConstraint(
"usage_event_id IS NULL OR subscription_id IS NOT NULL",
name="ck_commercial_cost_events_usage_pair",
),
CheckConstraint(
"(subscription_id IS NULL AND billing_period_id IS NULL) OR "
"(subscription_id IS NOT NULL AND billing_period_id IS NOT NULL)",
name="ck_commercial_cost_events_billing_period_pair",
),
CheckConstraint(
"length(trim(unit)) > 0 AND length(trim(source_system)) > 0 "
"AND length(trim(idempotency_key)) > 0 "
"AND length(trim(request_fingerprint)) > 0 "
"AND length(trim(original_currency)) = 3 "
"AND length(trim(reporting_currency)) = 3",
name="ck_commercial_cost_events_keys",
),
Index(
"ix_commercial_cost_events_tenant_period",
"tenant_id",
"occurred_at",
"cost_category",
),
Index(
"ix_commercial_cost_events_subscription_period",
"tenant_id",
"subscription_id",
"billing_period_id",
"occurred_at",
),
Index(
"ix_commercial_cost_events_allocation",
"tenant_id",
"allocation_key",
"occurred_at",
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_new_id)
tenant_id: Mapped[str] = mapped_column(String(64), nullable=False)
subscription_id: Mapped[str | None] = mapped_column(String(36))
billing_period_id: Mapped[str | None] = mapped_column(String(36))
usage_event_id: Mapped[str | None] = mapped_column(String(36))
event_type: Mapped[str] = mapped_column(String(16), nullable=False)
cost_category: Mapped[str] = mapped_column(String(32), nullable=False)
quantity: Mapped[Decimal] = mapped_column(Numeric(20, 6), nullable=False)
unit: Mapped[str] = mapped_column(String(40), nullable=False)
unit_cost: Mapped[Decimal] = mapped_column(Numeric(20, 8), nullable=False)
cost_amount: Mapped[Decimal] = mapped_column(Numeric(20, 4), nullable=False)
original_currency: Mapped[str] = mapped_column(String(3), nullable=False)
reporting_amount: Mapped[Decimal] = mapped_column(Numeric(20, 4), nullable=False)
reporting_currency: Mapped[str] = mapped_column(String(3), nullable=False)
fx_rate: Mapped[Decimal] = mapped_column(Numeric(20, 8), nullable=False)
provider: Mapped[str | None] = mapped_column(String(120))
sku: Mapped[str | None] = mapped_column(String(120))
model_name: Mapped[str | None] = mapped_column(String(120))
allocation_key: Mapped[str] = mapped_column(String(160), nullable=False)
occurred_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
source_system: Mapped[str] = mapped_column(String(80), nullable=False)
idempotency_key: Mapped[str] = mapped_column(String(160), nullable=False)
request_fingerprint: Mapped[str] = mapped_column(String(80), nullable=False)
reversal_of_cost_event_id: Mapped[str | None] = mapped_column(String(36))
correlation_id: Mapped[str | None] = mapped_column(String(120))
trace_id: Mapped[str | None] = mapped_column(String(120))
metadata_json: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False, default=dict)
recorded_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
__all__ = [
"CommercialCostEvent",
"CommercialEntitlement",
"TenantCommercialPlan",
"TenantSubscription",
"UsageMeterEvent",
]

View File

@@ -0,0 +1,238 @@
from __future__ import annotations
import uuid
from datetime import datetime
from decimal import Decimal
from typing import Any
from sqlalchemy import (
CheckConstraint,
DateTime,
ForeignKeyConstraint,
Index,
Integer,
Numeric,
String,
Text,
UniqueConstraint,
func,
)
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.types import JSON
from app.db.base_class import Base
def _new_id() -> str:
return str(uuid.uuid4())
class CommercialBillingPeriod(Base):
"""不可变的订阅账期签发事实;时间态由窗口计算,不回写状态。"""
__tablename__ = "commercial_billing_periods"
__table_args__ = (
UniqueConstraint(
"tenant_id",
"id",
name="uq_commercial_billing_periods_tenant_id",
),
UniqueConstraint(
"tenant_id",
"subscription_id",
"id",
name="uq_commercial_billing_periods_tenant_subscription_id",
),
UniqueConstraint(
"tenant_id",
"subscription_id",
"period_sequence",
name="uq_commercial_billing_periods_subscription_sequence",
),
UniqueConstraint(
"tenant_id",
"subscription_id",
"period_key",
name="uq_commercial_billing_periods_subscription_key",
),
UniqueConstraint(
"tenant_id",
"subscription_id",
"period_start",
name="uq_commercial_billing_periods_subscription_start",
),
UniqueConstraint(
"tenant_id",
"subscription_id",
"idempotency_key",
name="uq_commercial_billing_periods_subscription_request",
),
ForeignKeyConstraint(
["tenant_id", "subscription_id"],
["tenant_subscriptions.tenant_id", "tenant_subscriptions.id"],
name="fk_commercial_billing_periods_tenant_subscription",
ondelete="RESTRICT",
),
ForeignKeyConstraint(
["tenant_id", "plan_id"],
["tenant_commercial_plans.tenant_id", "tenant_commercial_plans.id"],
name="fk_commercial_billing_periods_tenant_plan",
ondelete="RESTRICT",
),
CheckConstraint(
"status = 'issued'",
name="ck_commercial_billing_periods_status",
),
CheckConstraint(
"subscription_status_snapshot IN ('trialing', 'active', 'past_due', "
"'suspended', 'canceled', 'expired')",
name="ck_commercial_billing_periods_subscription_status",
),
CheckConstraint(
"pricing_model_snapshot IN ('subscription', 'usage', 'hybrid', 'pilot', 'custom')",
name="ck_commercial_billing_periods_pricing_model",
),
CheckConstraint(
"billing_interval IN ('monthly', 'quarterly', 'annual', 'contract')",
name="ck_commercial_billing_periods_interval",
),
CheckConstraint(
"source IN ('subscription_created', 'auto_renew', 'migration_backfill')",
name="ck_commercial_billing_periods_source",
),
CheckConstraint(
"period_sequence >= 1 AND period_end > period_start "
"AND plan_version_snapshot >= 1 AND base_fee_snapshot >= 0 "
"AND seats_snapshot > 0",
name="ck_commercial_billing_periods_values",
),
CheckConstraint(
"length(trim(period_key)) > 0 AND length(trim(plan_code_snapshot)) > 0 "
"AND length(trim(currency)) = 3 AND length(trim(idempotency_key)) > 0 "
"AND length(trim(created_by)) > 0",
name="ck_commercial_billing_periods_keys",
),
Index(
"ix_commercial_billing_periods_tenant_window",
"tenant_id",
"period_start",
"period_end",
),
Index(
"ix_commercial_billing_periods_subscription_window",
"tenant_id",
"subscription_id",
"period_start",
"period_end",
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_new_id)
tenant_id: Mapped[str] = mapped_column(String(64), nullable=False)
subscription_id: Mapped[str] = mapped_column(String(36), nullable=False)
plan_id: Mapped[str] = mapped_column(String(36), nullable=False)
period_sequence: Mapped[int] = mapped_column(Integer, nullable=False)
period_key: Mapped[str] = mapped_column(String(64), nullable=False)
status: Mapped[str] = mapped_column(
String(16), nullable=False, default="issued", server_default="issued"
)
period_start: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
period_end: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
subscription_status_snapshot: Mapped[str] = mapped_column(String(20), nullable=False)
plan_code_snapshot: Mapped[str] = mapped_column(String(80), nullable=False)
plan_version_snapshot: Mapped[int] = mapped_column(Integer, nullable=False)
pricing_model_snapshot: Mapped[str] = mapped_column(String(24), nullable=False)
billing_interval: Mapped[str] = mapped_column(String(20), nullable=False)
currency: Mapped[str] = mapped_column(String(3), nullable=False)
base_fee_snapshot: Mapped[Decimal] = mapped_column(Numeric(20, 4), nullable=False)
seats_snapshot: Mapped[int] = mapped_column(Integer, nullable=False)
source: Mapped[str] = mapped_column(String(32), nullable=False)
idempotency_key: Mapped[str] = mapped_column(String(160), nullable=False)
created_by: Mapped[str] = mapped_column(String(120), nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
class CommercialAdminEvent(Base):
"""商业配置与续期动作的脱敏追加式审计事实。"""
__tablename__ = "commercial_admin_events"
__table_args__ = (
UniqueConstraint(
"tenant_id",
"id",
name="uq_commercial_admin_events_tenant_id",
),
UniqueConstraint(
"tenant_id",
"request_id",
"action",
"resource_type",
"resource_id",
name="uq_commercial_admin_events_request_resource",
),
CheckConstraint(
"actor_type IN ('user', 'system', 'migration')",
name="ck_commercial_admin_events_actor_type",
),
CheckConstraint(
"action IN ('plan_created', 'plan_activated', 'plan_retired', "
"'subscription_created', 'subscription_activated', "
"'subscription_transitioned', 'entitlement_created', "
"'entitlement_updated', 'entitlement_activated', "
"'billing_period_created', 'subscription_rolled_over', "
"'legacy_state_imported')",
name="ck_commercial_admin_events_action",
),
CheckConstraint(
"resource_type IN ('plan', 'subscription', 'entitlement', 'billing_period')",
name="ck_commercial_admin_events_resource_type",
),
CheckConstraint(
"resource_version >= 1",
name="ck_commercial_admin_events_resource_version",
),
CheckConstraint(
"length(trim(actor_id)) > 0 AND length(trim(request_id)) > 0 "
"AND length(trim(reason)) > 0 AND length(trim(resource_id)) > 0",
name="ck_commercial_admin_events_required_text",
),
Index(
"ix_commercial_admin_events_tenant_time",
"tenant_id",
"occurred_at",
"id",
),
Index(
"ix_commercial_admin_events_tenant_resource",
"tenant_id",
"resource_type",
"resource_id",
"occurred_at",
),
Index(
"ix_commercial_admin_events_tenant_request",
"tenant_id",
"request_id",
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_new_id)
tenant_id: Mapped[str] = mapped_column(String(64), nullable=False)
actor_type: Mapped[str] = mapped_column(String(16), nullable=False)
actor_id: Mapped[str] = mapped_column(String(120), nullable=False)
request_id: Mapped[str] = mapped_column(String(120), nullable=False)
reason: Mapped[str] = mapped_column(Text, nullable=False)
action: Mapped[str] = mapped_column(String(48), nullable=False)
resource_type: Mapped[str] = mapped_column(String(24), nullable=False)
resource_id: Mapped[str] = mapped_column(String(36), nullable=False)
resource_version: Mapped[int] = mapped_column(Integer, nullable=False)
before_json: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False, default=dict)
after_json: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False, default=dict)
occurred_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
__all__ = ["CommercialAdminEvent", "CommercialBillingPeriod"]

View File

@@ -0,0 +1,167 @@
from __future__ import annotations
import uuid
from datetime import datetime
from decimal import Decimal
from typing import Any
from sqlalchemy import (
CheckConstraint,
DateTime,
ForeignKeyConstraint,
Index,
Numeric,
String,
UniqueConstraint,
func,
)
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.types import JSON
from app.db.base_class import Base
class CommercialRuntimeReservation(Base):
"""工具执行前的额度占位;它是可结算状态,不是客户用量事实。"""
__tablename__ = "commercial_runtime_reservations"
__table_args__ = (
UniqueConstraint(
"tenant_id",
"id",
name="uq_commercial_runtime_reservations_tenant_id",
),
UniqueConstraint(
"tool_call_id",
name="uq_commercial_runtime_reservations_tool_call",
),
ForeignKeyConstraint(
["tenant_id", "subscription_id"],
["tenant_subscriptions.tenant_id", "tenant_subscriptions.id"],
name="fk_commercial_runtime_reservations_tenant_subscription",
ondelete="RESTRICT",
),
ForeignKeyConstraint(
["tenant_id", "subscription_id", "entitlement_id"],
[
"commercial_entitlements.tenant_id",
"commercial_entitlements.subscription_id",
"commercial_entitlements.id",
],
name="fk_commercial_runtime_reservations_tenant_entitlement",
ondelete="RESTRICT",
),
ForeignKeyConstraint(
["tenant_id", "subscription_id", "billing_period_id"],
[
"commercial_billing_periods.tenant_id",
"commercial_billing_periods.subscription_id",
"commercial_billing_periods.id",
],
name="fk_commercial_runtime_reservations_tenant_billing_period",
ondelete="RESTRICT",
),
CheckConstraint(
"status IN ('reserved', 'committed', 'released', 'expired', "
"'reconciliation_required', 'committed_reconciliation_required')",
name="ck_commercial_runtime_reservations_status",
),
CheckConstraint(
"quantity_basis IN ('call', 'input_tokens', 'output_tokens', "
"'total_tokens', 'duration_ms', 'bytes', 'pages', 'objects', 'events')",
name="ck_commercial_runtime_reservations_basis",
),
CheckConstraint(
"reserved_quantity > 0 AND (actual_quantity IS NULL OR actual_quantity > 0)",
name="ck_commercial_runtime_reservations_quantity",
),
CheckConstraint(
"expires_at > created_at",
name="ck_commercial_runtime_reservations_expiry",
),
CheckConstraint(
"(status = 'reserved' AND actual_quantity IS NULL AND settled_at IS NULL "
"AND resolution_code IS NULL) OR "
"(status = 'committed' AND actual_quantity IS NOT NULL "
"AND actual_quantity <= reserved_quantity AND settled_at IS NOT NULL "
"AND resolution_code IS NULL) OR "
"(status IN ('released', 'expired') AND actual_quantity IS NULL "
"AND settled_at IS NOT NULL AND resolution_code IS NOT NULL) OR "
"(status = 'reconciliation_required' AND settled_at IS NULL "
"AND resolution_code IS NOT NULL) OR "
"(status = 'committed_reconciliation_required' "
"AND actual_quantity IS NOT NULL "
"AND actual_quantity <= reserved_quantity "
"AND settled_at IS NOT NULL AND resolution_code IS NOT NULL)",
name="ck_commercial_runtime_reservations_state",
),
CheckConstraint(
"length(trim(run_id)) > 0 AND length(trim(tool_call_id)) > 0 "
"AND length(trim(tool_type)) > 0 AND length(trim(tool_name)) > 0 "
"AND length(trim(period_key)) > 0 "
"AND length(trim(quota_period_key)) > 0 "
"AND length(trim(request_fingerprint)) > 0",
name="ck_commercial_runtime_reservations_keys",
),
Index(
"ix_commercial_runtime_reservations_quota",
"tenant_id",
"subscription_id",
"entitlement_id",
"quota_period_key",
"status",
),
Index(
"ix_commercial_runtime_reservations_billing_period",
"tenant_id",
"billing_period_id",
"status",
),
Index(
"ix_commercial_runtime_reservations_expiry",
"status",
"expires_at",
),
Index(
"ix_commercial_runtime_reservations_run",
"tenant_id",
"run_id",
"created_at",
),
)
id: Mapped[str] = mapped_column(
String(36),
primary_key=True,
default=lambda: str(uuid.uuid4()),
)
tenant_id: Mapped[str] = mapped_column(String(64), nullable=False)
subscription_id: Mapped[str] = mapped_column(String(36), nullable=False)
entitlement_id: Mapped[str] = mapped_column(String(36), nullable=False)
billing_period_id: Mapped[str] = mapped_column(String(36), nullable=False)
run_id: Mapped[str] = mapped_column(String(50), nullable=False)
tool_call_id: Mapped[str] = mapped_column(String(36), nullable=False)
tool_type: Mapped[str] = mapped_column(String(30), nullable=False)
tool_name: Mapped[str] = mapped_column(String(100), nullable=False)
quantity_basis: Mapped[str] = mapped_column(String(20), nullable=False)
reserved_quantity: Mapped[Decimal] = mapped_column(Numeric(20, 6), nullable=False)
actual_quantity: Mapped[Decimal | None] = mapped_column(Numeric(20, 6))
period_key: Mapped[str] = mapped_column(String(64), nullable=False)
quota_period_key: Mapped[str] = mapped_column(String(64), nullable=False)
status: Mapped[str] = mapped_column(String(32), nullable=False)
request_fingerprint: Mapped[str] = mapped_column(String(64), nullable=False)
meter_config_json: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False, default=dict)
resolution_code: Mapped[str | None] = mapped_column(String(64))
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
settled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
server_default=func.now(),
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
server_default=func.now(),
onupdate=func.now(),
)

View File

@@ -3,8 +3,21 @@ from __future__ import annotations
import uuid
from datetime import date, datetime
from sqlalchemy import Boolean, Column, Date, DateTime, ForeignKey, Integer, String, Table, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy import (
Boolean,
Column,
Date,
DateTime,
ForeignKey,
ForeignKeyConstraint,
Index,
Integer,
String,
Table,
UniqueConstraint,
func,
)
from sqlalchemy.orm import Mapped, foreign, mapped_column, relationship, remote
from app.db.base_class import Base
@@ -18,11 +31,43 @@ employee_role_links = Table(
class Employee(Base):
__tablename__ = "employees"
__table_args__ = (
UniqueConstraint("tenant_id", "id", name="uq_employees_tenant_id"),
UniqueConstraint(
"tenant_id",
"employee_no",
name="uq_employees_tenant_employee_no",
),
UniqueConstraint(
"tenant_id",
"email",
name="uq_employees_tenant_email",
),
ForeignKeyConstraint(
["tenant_id", "organization_unit_id"],
["organization_units.tenant_id", "organization_units.id"],
name="fk_employees_tenant_organization_unit",
ondelete="RESTRICT",
),
ForeignKeyConstraint(
["tenant_id", "manager_id"],
["employees.tenant_id", "employees.id"],
name="fk_employees_tenant_manager",
ondelete="RESTRICT",
),
Index("ix_employees_tenant_status", "tenant_id", "employment_status"),
Index("ix_employees_tenant_name", "tenant_id", "name"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
employee_no: Mapped[str] = mapped_column(String(50), unique=True, index=True)
tenant_id: Mapped[str] = mapped_column(
ForeignKey("tenants.tenant_id", ondelete="RESTRICT"),
nullable=False,
server_default="default",
)
employee_no: Mapped[str] = mapped_column(String(50), index=True)
name: Mapped[str] = mapped_column(String(100), index=True)
email: Mapped[str] = mapped_column(String(255), unique=True, index=True)
email: Mapped[str] = mapped_column(String(255), index=True)
gender: Mapped[str | None] = mapped_column(String(20), nullable=True)
birth_date: Mapped[date | None] = mapped_column(Date(), nullable=True)
phone: Mapped[str | None] = mapped_column(String(30), nullable=True)
@@ -41,19 +86,51 @@ class Employee(Base):
compliance_score: Mapped[int] = mapped_column(Integer, default=100)
spotlight: Mapped[bool] = mapped_column(Boolean, default=False)
last_sync_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
organization_unit_id: Mapped[str | None] = mapped_column(
ForeignKey("organization_units.id"), nullable=True, index=True
)
manager_id: Mapped[str | None] = mapped_column(ForeignKey("employees.id"), nullable=True, index=True)
organization_unit_id: Mapped[str | None] = mapped_column(nullable=True, index=True)
manager_id: Mapped[str | None] = mapped_column(nullable=True, index=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
organization_unit = relationship("OrganizationUnit", back_populates="employees")
manager = relationship("Employee", remote_side=[id], back_populates="reports")
reports = relationship("Employee", back_populates="manager")
organization_unit = relationship(
"OrganizationUnit",
back_populates="employees",
primaryjoin=(
"and_(Employee.tenant_id == OrganizationUnit.tenant_id, "
"foreign(Employee.organization_unit_id) == OrganizationUnit.id)"
),
foreign_keys=[organization_unit_id],
overlaps="manager,reports",
)
manager = relationship(
"Employee",
primaryjoin=lambda: (
(Employee.tenant_id == remote(Employee.tenant_id))
& (foreign(Employee.manager_id) == remote(Employee.id))
),
remote_side=[tenant_id, id],
foreign_keys=[manager_id],
back_populates="reports",
overlaps="organization_unit",
)
reports = relationship(
"Employee",
primaryjoin=lambda: (
(Employee.tenant_id == remote(Employee.tenant_id))
& (Employee.id == foreign(remote(Employee.manager_id)))
),
foreign_keys=[manager_id],
back_populates="manager",
overlaps="organization_unit",
)
roles = relationship("Role", secondary=employee_role_links, back_populates="employees")
tenant_memberships = relationship(
"TenantMembership",
back_populates="employee",
cascade="all, delete-orphan",
overlaps="memberships,tenant",
)
change_logs = relationship(
"EmployeeChangeLog",
back_populates="employee",

View File

@@ -4,7 +4,16 @@ import uuid
from datetime import datetime
from typing import Any
from sqlalchemy import DateTime, ForeignKey, Index, Integer, String, func
from sqlalchemy import (
DateTime,
ForeignKey,
ForeignKeyConstraint,
Index,
Integer,
String,
UniqueConstraint,
func,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.types import JSON
@@ -14,8 +23,20 @@ from app.db.base_class import Base
class EmployeeBehaviorProfileSnapshot(Base):
__tablename__ = "employee_behavior_profile_snapshots"
__table_args__ = (
UniqueConstraint(
"tenant_id",
"id",
name="uq_employee_behavior_profiles_tenant_id",
),
ForeignKeyConstraint(
["tenant_id", "subject_id"],
["employees.tenant_id", "employees.id"],
name="fk_employee_behavior_profiles_tenant_employee",
ondelete="CASCADE",
),
Index(
"ix_employee_behavior_profile_latest",
"tenant_id",
"subject_id",
"profile_type",
"window_days",
@@ -25,6 +46,12 @@ class EmployeeBehaviorProfileSnapshot(Base):
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
tenant_id: Mapped[str] = mapped_column(
ForeignKey("tenants.tenant_id", ondelete="RESTRICT"),
nullable=False,
server_default="default",
index=True,
)
subject_type: Mapped[str] = mapped_column(String(30), default="employee", index=True)
subject_id: Mapped[str] = mapped_column(String(100), index=True)
subject_name: Mapped[str] = mapped_column(String(100), index=True)

View File

@@ -0,0 +1,476 @@
from __future__ import annotations
import uuid
from datetime import datetime
from decimal import Decimal
from typing import Any
from sqlalchemy import (
CheckConstraint,
DateTime,
ForeignKeyConstraint,
Index,
Integer,
Numeric,
String,
Text,
UniqueConstraint,
func,
)
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.types import JSON
from app.db.base_class import Base
def _new_id() -> str:
return str(uuid.uuid4())
class FinancialConnectorConfig(Base):
"""租户绑定的连接器契约;只保存服务端密钥引用,不保存密钥。"""
__tablename__ = "financial_connector_configs"
__table_args__ = (
UniqueConstraint("tenant_id", "id", name="uq_financial_connector_configs_tenant_id"),
UniqueConstraint(
"tenant_id",
"provider",
"key_version",
name="uq_financial_connector_configs_tenant_provider_key",
),
CheckConstraint(
"environment IN ('test', 'mock', 'staging', 'production')",
name="ck_financial_connector_configs_environment",
),
CheckConstraint(
"status IN ('active', 'disabled', 'rotating')",
name="ck_financial_connector_configs_status",
),
CheckConstraint(
"clock_skew_seconds BETWEEN 30 AND 900",
name="ck_financial_connector_configs_clock_skew",
),
CheckConstraint(
"version >= 1",
name="ck_financial_connector_configs_version",
),
CheckConstraint(
"length(trim(provider)) > 0 AND length(trim(key_version)) > 0 "
"AND length(trim(secret_ref)) > 0",
name="ck_financial_connector_configs_keys",
),
Index(
"ix_financial_connector_configs_tenant_status",
"tenant_id",
"status",
"provider",
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_new_id)
tenant_id: Mapped[str] = mapped_column(String(64), nullable=False)
provider: Mapped[str] = mapped_column(String(80), nullable=False)
environment: Mapped[str] = mapped_column(String(16), nullable=False)
key_version: Mapped[str] = mapped_column(String(40), nullable=False)
secret_ref: Mapped[str] = mapped_column(String(180), nullable=False)
allowed_event_types_json: Mapped[list[str]] = mapped_column(JSON, nullable=False, default=list)
clock_skew_seconds: Mapped[int] = mapped_column(Integer, nullable=False, default=300)
status: Mapped[str] = mapped_column(String(16), nullable=False, default="disabled")
version: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
last_success_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
last_error_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
last_error_code: Mapped[str | None] = mapped_column(String(80))
created_by: Mapped[str] = mapped_column(String(120), nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
)
class FinancialConnectorConfigEvent(Base):
"""连接器配置生命周期审计事实;不得保存密钥引用或密钥明文。"""
__tablename__ = "financial_connector_config_events"
__table_args__ = (
UniqueConstraint(
"tenant_id",
"id",
name="uq_financial_connector_config_events_tenant_id",
),
UniqueConstraint(
"tenant_id",
"request_id",
"action",
name="uq_financial_connector_config_events_tenant_request_action",
),
ForeignKeyConstraint(
["tenant_id", "config_id"],
["financial_connector_configs.tenant_id", "financial_connector_configs.id"],
name="fk_financial_connector_config_events_tenant_config",
ondelete="RESTRICT",
),
CheckConstraint(
"action IN ('created', 'activated', 'disabled', "
"'rotation_started', 'rotation_replacement_created')",
name="ck_financial_connector_config_events_action",
),
CheckConstraint(
"expected_version IS NULL OR expected_version >= 1",
name="ck_financial_connector_config_events_expected_version",
),
CheckConstraint(
"length(trim(actor_id)) > 0 AND length(trim(request_id)) > 0 "
"AND length(trim(reason)) > 0",
name="ck_financial_connector_config_events_required_text",
),
Index(
"ix_financial_connector_config_events_tenant_config_time",
"tenant_id",
"config_id",
"occurred_at",
),
Index(
"ix_financial_connector_config_events_tenant_request",
"tenant_id",
"request_id",
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_new_id)
tenant_id: Mapped[str] = mapped_column(String(64), nullable=False)
config_id: Mapped[str] = mapped_column(String(36), nullable=False)
action: Mapped[str] = mapped_column(String(40), nullable=False)
actor_id: Mapped[str] = mapped_column(String(120), nullable=False)
request_id: Mapped[str] = mapped_column(String(120), nullable=False)
reason: Mapped[str] = mapped_column(Text(), nullable=False)
expected_version: Mapped[int | None] = mapped_column(Integer)
before_json: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False, default=dict)
after_json: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False, default=dict)
occurred_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
class FinancialConnectorOperationalEvent(Base):
"""连接器重放、认证失败和载荷冲突的最小化追加式运营事实。"""
__tablename__ = "financial_connector_operational_events"
__table_args__ = (
UniqueConstraint(
"tenant_id",
"id",
name="uq_financial_connector_operational_events_tenant_id",
),
UniqueConstraint(
"tenant_id",
"idempotency_key",
name="uq_financial_connector_operational_events_tenant_request",
),
ForeignKeyConstraint(
["tenant_id", "config_id"],
["financial_connector_configs.tenant_id", "financial_connector_configs.id"],
name="fk_financial_connector_operational_events_tenant_config",
ondelete="RESTRICT",
),
CheckConstraint(
"event_type IN ('replay', 'auth_failure', 'payload_conflict')",
name="ck_financial_connector_operational_events_type",
),
CheckConstraint(
"environment IN ('test', 'mock', 'staging', 'production')",
name="ck_financial_connector_operational_events_environment",
),
CheckConstraint(
"length(trim(provider)) > 0 AND length(trim(reason_code)) > 0",
name="ck_financial_connector_operational_events_required_text",
),
CheckConstraint(
"length(request_fingerprint) = 76 "
"AND request_fingerprint LIKE 'hmac-sha256:%' "
"AND length(external_event_fingerprint) = 76 "
"AND external_event_fingerprint LIKE 'hmac-sha256:%' "
"AND length(idempotency_key) = 71 "
"AND idempotency_key LIKE 'sha256:%'",
name="ck_financial_connector_operational_events_fingerprints",
),
Index(
"ix_financial_connector_operational_events_tenant_config_time",
"tenant_id",
"config_id",
"occurred_at",
),
Index(
"ix_financial_connector_operational_events_tenant_type_time",
"tenant_id",
"event_type",
"occurred_at",
),
Index(
"ix_financial_connector_operational_events_tenant_provider_time",
"tenant_id",
"provider",
"occurred_at",
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_new_id)
tenant_id: Mapped[str] = mapped_column(String(64), nullable=False)
config_id: Mapped[str] = mapped_column(String(36), nullable=False)
provider: Mapped[str] = mapped_column(String(80), nullable=False)
environment: Mapped[str] = mapped_column(String(16), nullable=False)
event_type: Mapped[str] = mapped_column(String(32), nullable=False)
reason_code: Mapped[str] = mapped_column(String(80), nullable=False)
request_fingerprint: Mapped[str] = mapped_column(String(76), nullable=False)
external_event_fingerprint: Mapped[str] = mapped_column(String(76), nullable=False)
idempotency_key: Mapped[str] = mapped_column(String(71), nullable=False)
occurred_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
class FinancialConnectorEvent(Base):
"""经签名验证的最小化外部事实。表由 PostgreSQL 触发器强制只追加。"""
__tablename__ = "financial_connector_events"
__table_args__ = (
UniqueConstraint("tenant_id", "id", name="uq_financial_connector_events_tenant_id"),
UniqueConstraint(
"tenant_id",
"provider",
"external_event_id",
name="uq_financial_connector_events_external_id",
),
ForeignKeyConstraint(
["tenant_id", "config_id"],
["financial_connector_configs.tenant_id", "financial_connector_configs.id"],
name="fk_financial_connector_events_tenant_config",
ondelete="RESTRICT",
),
ForeignKeyConstraint(
["tenant_id", "origin_event_id"],
["financial_connector_events.tenant_id", "financial_connector_events.id"],
name="fk_financial_connector_events_tenant_origin",
ondelete="RESTRICT",
),
ForeignKeyConstraint(
["tenant_id", "expense_case_id"],
["expense_cases.tenant_id", "expense_cases.id"],
name="fk_financial_connector_events_tenant_expense_case",
ondelete="RESTRICT",
),
CheckConstraint(
"direction = 'inbound'",
name="ck_financial_connector_events_direction",
),
CheckConstraint(
"event_type IN ('payment_settled', 'payment_failed', 'erp_posted', "
"'erp_posting_failed', 'payment_refunded', 'payment_reversed')",
name="ck_financial_connector_events_type",
),
CheckConstraint(
"environment IN ('test', 'mock', 'staging', 'production')",
name="ck_financial_connector_events_environment",
),
CheckConstraint(
"verification_level IN ('simulated', 'staging_verified', 'production_verified')",
name="ck_financial_connector_events_verification",
),
CheckConstraint(
"processing_status IN ('processed', 'exception', 'pending')",
name="ck_financial_connector_events_processing_status",
),
CheckConstraint(
"length(trim(external_event_id)) > 0 "
"AND length(trim(request_fingerprint)) >= 16 "
"AND length(trim(content_hash)) >= 16",
name="ck_financial_connector_events_fingerprints",
),
CheckConstraint(
"(event_type IN ('payment_refunded', 'payment_reversed', "
"'erp_posted', 'erp_posting_failed') "
"AND (origin_event_id IS NOT NULL OR processing_status = 'exception')) "
"OR (event_type IN ('payment_settled', 'payment_failed') "
"AND origin_event_id IS NULL)",
name="ck_financial_connector_events_origin",
),
Index(
"ix_financial_connector_events_tenant_received",
"tenant_id",
"received_at",
),
Index(
"ix_financial_connector_events_tenant_claim",
"tenant_id",
"claim_id",
"occurred_at",
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_new_id)
tenant_id: Mapped[str] = mapped_column(String(64), nullable=False)
config_id: Mapped[str] = mapped_column(String(36), nullable=False)
provider: Mapped[str] = mapped_column(String(80), nullable=False)
environment: Mapped[str] = mapped_column(String(16), nullable=False)
direction: Mapped[str] = mapped_column(String(12), nullable=False, default="inbound")
external_event_id: Mapped[str] = mapped_column(String(160), nullable=False)
event_type: Mapped[str] = mapped_column(String(40), nullable=False)
occurred_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
received_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
key_version: Mapped[str] = mapped_column(String(40), nullable=False)
verification_level: Mapped[str] = mapped_column(String(32), nullable=False)
request_fingerprint: Mapped[str] = mapped_column(String(80), nullable=False)
content_hash: Mapped[str] = mapped_column(String(80), nullable=False)
processing_status: Mapped[str] = mapped_column(String(20), nullable=False)
error_code: Mapped[str | None] = mapped_column(String(80))
# expense_claims 由 legacy bootstrap 创建,迁移表只保存经过租户 Case 校验的软引用。
claim_id: Mapped[str | None] = mapped_column(String(36))
expense_case_id: Mapped[str | None] = mapped_column(String(36))
origin_event_id: Mapped[str | None] = mapped_column(String(36))
correlation_id: Mapped[str] = mapped_column(String(64), nullable=False)
external_reference_tail: Mapped[str | None] = mapped_column(String(8))
normalized_payload_json: Mapped[dict[str, Any]] = mapped_column(
JSON, nullable=False, default=dict
)
response_json: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False, default=dict)
class PaymentReconciliationCase(Base):
"""对账当前投影;所有历史变化由 PaymentReconciliationEvent 保存。"""
__tablename__ = "payment_reconciliation_cases"
__table_args__ = (
UniqueConstraint("tenant_id", "id", name="uq_payment_reconciliation_cases_tenant_id"),
UniqueConstraint(
"tenant_id",
"provider",
"claim_id",
name="uq_payment_reconciliation_cases_tenant_provider_claim",
),
ForeignKeyConstraint(
["tenant_id", "last_connector_event_id"],
["financial_connector_events.tenant_id", "financial_connector_events.id"],
name="fk_payment_reconciliation_cases_tenant_last_event",
ondelete="RESTRICT",
),
ForeignKeyConstraint(
["tenant_id", "expense_case_id"],
["expense_cases.tenant_id", "expense_cases.id"],
name="fk_payment_reconciliation_cases_tenant_expense_case",
ondelete="RESTRICT",
),
CheckConstraint(
"status IN ('pending', 'matched', 'exception', 'confirmed', "
"'rejected', 'reopened', 'closed')",
name="ck_payment_reconciliation_cases_status",
),
CheckConstraint(
"erp_status IN ('pending_posting', 'posted', 'posting_failed')",
name="ck_payment_reconciliation_cases_erp_status",
),
CheckConstraint(
"expected_amount >= 0 AND actual_amount >= 0",
name="ck_payment_reconciliation_cases_amounts",
),
CheckConstraint(
"length(trim(expected_currency)) = 3 AND length(trim(actual_currency)) = 3",
name="ck_payment_reconciliation_cases_currencies",
),
Index(
"ix_payment_reconciliation_cases_tenant_status",
"tenant_id",
"status",
"updated_at",
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_new_id)
tenant_id: Mapped[str] = mapped_column(String(64), nullable=False)
provider: Mapped[str] = mapped_column(String(80), nullable=False)
# 租户边界由 expense_case_id 复合外键与服务查询共同保证。
claim_id: Mapped[str] = mapped_column(String(36), nullable=False)
expense_case_id: Mapped[str | None] = mapped_column(String(36))
expected_amount: Mapped[Decimal] = mapped_column(Numeric(20, 4), nullable=False)
actual_amount: Mapped[Decimal] = mapped_column(Numeric(20, 4), nullable=False)
amount_difference: Mapped[Decimal] = mapped_column(Numeric(20, 4), nullable=False)
expected_currency: Mapped[str] = mapped_column(String(3), nullable=False)
actual_currency: Mapped[str] = mapped_column(String(3), nullable=False)
expected_reference: Mapped[str] = mapped_column(String(160), nullable=False)
external_reference_tail: Mapped[str | None] = mapped_column(String(8))
status: Mapped[str] = mapped_column(String(20), nullable=False)
exception_code: Mapped[str | None] = mapped_column(String(80))
erp_status: Mapped[str] = mapped_column(String(20), nullable=False, default="pending_posting")
erp_document_tail: Mapped[str | None] = mapped_column(String(8))
erp_document_hash: Mapped[str | None] = mapped_column(String(80))
assigned_to: Mapped[str | None] = mapped_column(String(120))
last_connector_event_id: Mapped[str] = mapped_column(String(36), nullable=False)
version: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
)
class PaymentReconciliationEvent(Base):
"""对账动作审计事实;数据库级禁止更新和删除。"""
__tablename__ = "payment_reconciliation_events"
__table_args__ = (
UniqueConstraint("tenant_id", "id", name="uq_payment_reconciliation_events_tenant_id"),
UniqueConstraint(
"tenant_id",
"connector_event_id",
"action",
name="uq_payment_reconciliation_events_connector_action",
),
ForeignKeyConstraint(
["tenant_id", "reconciliation_case_id"],
["payment_reconciliation_cases.tenant_id", "payment_reconciliation_cases.id"],
name="fk_payment_reconciliation_events_tenant_case",
ondelete="RESTRICT",
),
ForeignKeyConstraint(
["tenant_id", "connector_event_id"],
["financial_connector_events.tenant_id", "financial_connector_events.id"],
name="fk_payment_reconciliation_events_tenant_connector_event",
ondelete="RESTRICT",
),
CheckConstraint(
"action IN ('auto_matched', 'exception_created', 'erp_posted', "
"'erp_posting_failed', 'reopened', 'confirmed', 'rejected', 'closed')",
name="ck_payment_reconciliation_events_action",
),
CheckConstraint(
"length(trim(request_fingerprint)) >= 16",
name="ck_payment_reconciliation_events_fingerprint",
),
Index(
"ix_payment_reconciliation_events_tenant_case_time",
"tenant_id",
"reconciliation_case_id",
"occurred_at",
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_new_id)
tenant_id: Mapped[str] = mapped_column(String(64), nullable=False)
reconciliation_case_id: Mapped[str] = mapped_column(String(36), nullable=False)
connector_event_id: Mapped[str] = mapped_column(String(36), nullable=False)
action: Mapped[str] = mapped_column(String(32), nullable=False)
actor_type: Mapped[str] = mapped_column(String(20), nullable=False)
actor_id: Mapped[str] = mapped_column(String(120), nullable=False)
request_fingerprint: Mapped[str] = mapped_column(String(80), nullable=False)
before_json: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False, default=dict)
after_json: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False, default=dict)
response_json: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False, default=dict)
reason: Mapped[str | None] = mapped_column(Text())
correlation_id: Mapped[str] = mapped_column(String(64), nullable=False)
occurred_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)

View File

@@ -5,7 +5,20 @@ from datetime import date, datetime
from decimal import Decimal
from typing import Any
from sqlalchemy import Boolean, Date, DateTime, ForeignKey, Integer, Numeric, String, Text, func
from sqlalchemy import (
Boolean,
Date,
DateTime,
ForeignKey,
ForeignKeyConstraint,
Index,
Integer,
Numeric,
String,
Text,
UniqueConstraint,
func,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.types import JSON
@@ -14,16 +27,39 @@ from app.db.base_class import Base
class ExpenseClaim(Base):
__tablename__ = "expense_claims"
__table_args__ = (
UniqueConstraint("tenant_id", "id", name="uq_expense_claims_tenant_id"),
UniqueConstraint(
"tenant_id",
"claim_no",
name="uq_expense_claims_tenant_claim_no",
),
ForeignKeyConstraint(
["tenant_id", "employee_id"],
["employees.tenant_id", "employees.id"],
name="fk_expense_claims_tenant_employee",
ondelete="RESTRICT",
),
ForeignKeyConstraint(
["tenant_id", "department_id"],
["organization_units.tenant_id", "organization_units.id"],
name="fk_expense_claims_tenant_department",
ondelete="RESTRICT",
),
Index("ix_expense_claims_tenant_status", "tenant_id", "status"),
Index("ix_expense_claims_tenant_occurred", "tenant_id", "occurred_at"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
claim_no: Mapped[str] = mapped_column(String(50), unique=True, index=True)
employee_id: Mapped[str | None] = mapped_column(
ForeignKey("employees.id"), nullable=True, index=True
tenant_id: Mapped[str] = mapped_column(
ForeignKey("tenants.tenant_id", ondelete="RESTRICT"),
nullable=False,
server_default="default",
)
claim_no: Mapped[str] = mapped_column(String(50), index=True)
employee_id: Mapped[str | None] = mapped_column(nullable=True, index=True)
employee_name: Mapped[str] = mapped_column(String(100), index=True)
department_id: Mapped[str | None] = mapped_column(
ForeignKey("organization_units.id"), nullable=True, index=True
)
department_id: Mapped[str | None] = mapped_column(nullable=True, index=True)
department_name: Mapped[str] = mapped_column(String(100), index=True)
project_code: Mapped[str | None] = mapped_column(String(50), nullable=True)
expense_type: Mapped[str] = mapped_column(String(50), index=True)
@@ -46,7 +82,10 @@ class ExpenseClaim(Base):
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
employee = relationship("Employee", foreign_keys=[employee_id])
employee = relationship(
"Employee",
foreign_keys=[tenant_id, employee_id],
)
items = relationship(
"ExpenseClaimItem",
back_populates="claim",
@@ -119,9 +158,22 @@ class ExpenseClaimItem(Base):
class AccountsReceivableRecord(Base):
__tablename__ = "accounts_receivable"
__table_args__ = (
UniqueConstraint(
"tenant_id",
"receivable_no",
name="uq_accounts_receivable_tenant_no",
),
Index("ix_accounts_receivable_tenant_customer", "tenant_id", "customer_id"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
receivable_no: Mapped[str] = mapped_column(String(50), unique=True, index=True)
tenant_id: Mapped[str] = mapped_column(
ForeignKey("tenants.tenant_id", ondelete="RESTRICT"),
nullable=False,
server_default="default",
)
receivable_no: Mapped[str] = mapped_column(String(50), index=True)
customer_id: Mapped[str] = mapped_column(String(64), index=True)
customer_name: Mapped[str] = mapped_column(String(120), index=True)
contract_no: Mapped[str | None] = mapped_column(String(100), nullable=True)
@@ -143,9 +195,22 @@ class AccountsReceivableRecord(Base):
class AccountsPayableRecord(Base):
__tablename__ = "accounts_payable"
__table_args__ = (
UniqueConstraint(
"tenant_id",
"payable_no",
name="uq_accounts_payable_tenant_no",
),
Index("ix_accounts_payable_tenant_vendor", "tenant_id", "vendor_id"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
payable_no: Mapped[str] = mapped_column(String(50), unique=True, index=True)
tenant_id: Mapped[str] = mapped_column(
ForeignKey("tenants.tenant_id", ondelete="RESTRICT"),
nullable=False,
server_default="default",
)
payable_no: Mapped[str] = mapped_column(String(50), index=True)
vendor_id: Mapped[str] = mapped_column(String(64), index=True)
vendor_name: Mapped[str] = mapped_column(String(120), index=True)
invoice_no: Mapped[str | None] = mapped_column(String(100), nullable=True)

View File

@@ -4,7 +4,17 @@ import uuid
from datetime import datetime
from typing import Any
from sqlalchemy import Boolean, DateTime, ForeignKey, String, Text, func
from sqlalchemy import (
Boolean,
DateTime,
ForeignKey,
ForeignKeyConstraint,
Index,
String,
Text,
UniqueConstraint,
func,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.types import JSON
@@ -13,13 +23,22 @@ from app.db.base_class import Base
class HermesTaskConfig(Base):
__tablename__ = "hermes_task_configs"
__table_args__ = (
UniqueConstraint("tenant_id", "id", name="uq_hermes_task_configs_tenant_id"),
Index("ix_hermes_task_configs_tenant_enabled", "tenant_id", "is_enabled"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
tenant_id: Mapped[str] = mapped_column(
ForeignKey("tenants.tenant_id", ondelete="RESTRICT"),
nullable=False,
server_default="default",
)
task_type: Mapped[str] = mapped_column(String(50), index=True)
cron_expression: Mapped[str] = mapped_column(String(100))
is_enabled: Mapped[bool] = mapped_column(Boolean, default=True)
payload_template: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
@@ -35,13 +54,32 @@ class HermesTaskConfig(Base):
class HermesTaskExecutionLog(Base):
__tablename__ = "hermes_task_execution_logs"
__table_args__ = (
UniqueConstraint(
"tenant_id",
"id",
name="uq_hermes_task_execution_logs_tenant_id",
),
ForeignKeyConstraint(
["tenant_id", "config_id"],
["hermes_task_configs.tenant_id", "hermes_task_configs.id"],
name="fk_hermes_task_logs_tenant_config",
ondelete="CASCADE",
),
Index("ix_hermes_task_logs_tenant_started", "tenant_id", "started_at"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
config_id: Mapped[str] = mapped_column(String(36), ForeignKey("hermes_task_configs.id"), index=True)
tenant_id: Mapped[str] = mapped_column(
ForeignKey("tenants.tenant_id", ondelete="RESTRICT"),
nullable=False,
server_default="default",
)
config_id: Mapped[str] = mapped_column(String(36), index=True)
status: Mapped[str] = mapped_column(String(30), index=True)
result_summary: Mapped[str | None] = mapped_column(String(255), nullable=True)
error_trace: Mapped[str | None] = mapped_column(Text(), nullable=True)
started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)

View File

@@ -2,9 +2,17 @@ from __future__ import annotations
import uuid
from datetime import datetime
from typing import Any
from sqlalchemy import DateTime, ForeignKey, String, Text, func
from sqlalchemy import (
DateTime,
ForeignKey,
ForeignKeyConstraint,
Index,
String,
Text,
UniqueConstraint,
func,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.types import JSON
@@ -13,22 +21,51 @@ from app.db.base_class import Base
class HermesRiskReport(Base):
__tablename__ = "hermes_risk_reports"
__table_args__ = (
UniqueConstraint("tenant_id", "id", name="uq_hermes_risk_reports_tenant_id"),
ForeignKeyConstraint(
["tenant_id", "claim_id"],
["expense_claims.tenant_id", "expense_claims.id"],
name="fk_hermes_risk_reports_tenant_claim",
ondelete="CASCADE",
),
ForeignKeyConstraint(
["tenant_id", "execution_log_id"],
["hermes_task_execution_logs.tenant_id", "hermes_task_execution_logs.id"],
name="fk_hermes_risk_reports_tenant_log",
ondelete="CASCADE",
),
Index("ix_hermes_risk_reports_tenant_status", "tenant_id", "status"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
claim_id: Mapped[str] = mapped_column(ForeignKey("expense_claims.id"), index=True)
execution_log_id: Mapped[str] = mapped_column(ForeignKey("hermes_task_execution_logs.id"), index=True)
tenant_id: Mapped[str] = mapped_column(
ForeignKey("tenants.tenant_id", ondelete="RESTRICT"),
nullable=False,
server_default="default",
)
claim_id: Mapped[str] = mapped_column(index=True)
execution_log_id: Mapped[str] = mapped_column(index=True)
risk_level: Mapped[str] = mapped_column(String(20), index=True)
risk_type: Mapped[str] = mapped_column(String(50), index=True)
risk_description: Mapped[str] = mapped_column(Text())
related_claim_ids: Mapped[list[str]] = mapped_column(JSON, default=list)
status: Mapped[str] = mapped_column(String(30), default="pending_review", index=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
claim = relationship("ExpenseClaim", foreign_keys=[claim_id])
execution_log = relationship("HermesTaskExecutionLog", foreign_keys=[execution_log_id])
claim = relationship(
"ExpenseClaim",
foreign_keys=[tenant_id, claim_id],
overlaps="execution_log",
)
execution_log = relationship(
"HermesTaskExecutionLog",
foreign_keys=[tenant_id, execution_log_id],
overlaps="claim",
)

View File

@@ -0,0 +1,86 @@
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import (
Boolean,
CheckConstraint,
DateTime,
ForeignKey,
Index,
Integer,
String,
Text,
func,
)
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base_class import Base
class KnowledgeOnlyOfficeSession(Base):
__tablename__ = "knowledge_onlyoffice_sessions"
__table_args__ = (
CheckConstraint(
"resource_scope IN ('tenant', 'platform')",
name="ck_knowledge_onlyoffice_sessions_scope",
),
CheckConstraint(
"status IN ('active', 'processing', 'consumed', 'failed', 'revoked')",
name="ck_knowledge_onlyoffice_sessions_status",
),
CheckConstraint(
"tenant_id IS NOT NULL AND "
"(resource_scope = 'tenant' OR "
"(resource_scope = 'platform' AND editable = false))",
name="ck_knowledge_onlyoffice_sessions_scope_tenant",
),
CheckConstraint(
"(status = 'active' AND claimed_at IS NULL AND consumed_at IS NULL) OR "
"(status IN ('processing', 'failed') AND claimed_at IS NOT NULL "
"AND consumed_at IS NULL) OR "
"(status = 'consumed' AND claimed_at IS NOT NULL AND consumed_at IS NOT NULL) OR "
"(status = 'revoked' AND consumed_at IS NULL)",
name="ck_knowledge_onlyoffice_sessions_lifecycle",
),
Index(
"ix_knowledge_onlyoffice_sessions_tenant_document",
"tenant_id",
"document_id",
"created_at",
),
Index(
"ix_knowledge_onlyoffice_sessions_status_expiry",
"status",
"expires_at",
),
)
jti: Mapped[str] = mapped_column(
String(36),
primary_key=True,
default=lambda: str(uuid.uuid4()),
)
tenant_id: Mapped[str] = mapped_column(
String(64),
ForeignKey("tenants.tenant_id", ondelete="CASCADE"),
nullable=False,
)
resource_scope: Mapped[str] = mapped_column(String(16), nullable=False)
document_id: Mapped[str] = mapped_column(String(64), nullable=False)
document_key: Mapped[str] = mapped_column(String(160), nullable=False)
document_version: Mapped[int] = mapped_column(Integer, nullable=False)
audience: Mapped[str] = mapped_column(String(80), nullable=False)
editable: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
status: Mapped[str] = mapped_column(String(16), nullable=False, default="active")
created_by: Mapped[str] = mapped_column(String(100), nullable=False)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
claimed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
consumed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
failure_reason: Mapped[str] = mapped_column(Text, nullable=False, default="")
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
server_default=func.now(),
)

View File

@@ -3,7 +3,15 @@ from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, String, func
from sqlalchemy import (
DateTime,
ForeignKey,
ForeignKeyConstraint,
Index,
String,
UniqueConstraint,
func,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base_class import Base
@@ -11,14 +19,36 @@ from app.db.base_class import Base
class OrganizationUnit(Base):
__tablename__ = "organization_units"
__table_args__ = (
UniqueConstraint(
"tenant_id",
"id",
name="uq_organization_units_tenant_id",
),
UniqueConstraint(
"tenant_id",
"unit_code",
name="uq_organization_units_tenant_code",
),
ForeignKeyConstraint(
["tenant_id", "parent_id"],
["organization_units.tenant_id", "organization_units.id"],
name="fk_organization_units_tenant_parent",
ondelete="RESTRICT",
),
Index("ix_organization_units_tenant_name", "tenant_id", "name"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
unit_code: Mapped[str] = mapped_column(String(50), unique=True, index=True)
tenant_id: Mapped[str] = mapped_column(
ForeignKey("tenants.tenant_id", ondelete="RESTRICT"),
nullable=False,
server_default="default",
)
unit_code: Mapped[str] = mapped_column(String(50), index=True)
name: Mapped[str] = mapped_column(String(100), index=True)
unit_type: Mapped[str] = mapped_column(String(30), default="department", index=True)
parent_id: Mapped[str | None] = mapped_column(
ForeignKey("organization_units.id"), nullable=True, index=True
)
parent_id: Mapped[str | None] = mapped_column(nullable=True, index=True)
cost_center: Mapped[str | None] = mapped_column(String(50), nullable=True)
location: Mapped[str | None] = mapped_column(String(100), nullable=True)
manager_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
@@ -27,6 +57,24 @@ class OrganizationUnit(Base):
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
parent = relationship("OrganizationUnit", remote_side=[id], back_populates="children")
children = relationship("OrganizationUnit", back_populates="parent")
employees = relationship("Employee", back_populates="organization_unit")
parent = relationship(
"OrganizationUnit",
remote_side=[tenant_id, id],
foreign_keys=[tenant_id, parent_id],
back_populates="children",
)
children = relationship(
"OrganizationUnit",
foreign_keys=[tenant_id, parent_id],
back_populates="parent",
)
employees = relationship(
"Employee",
primaryjoin=(
"and_(OrganizationUnit.tenant_id == Employee.tenant_id, "
"OrganizationUnit.id == foreign(Employee.organization_unit_id))"
),
foreign_keys="[Employee.organization_unit_id]",
back_populates="organization_unit",
overlaps="manager,reports",
)

View File

@@ -0,0 +1,800 @@
from __future__ import annotations
import uuid
from datetime import date, datetime
from decimal import Decimal
from typing import Any
from sqlalchemy import (
CheckConstraint,
Date,
DateTime,
ForeignKeyConstraint,
Index,
Integer,
Numeric,
String,
Text,
UniqueConstraint,
func,
text,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.types import JSON
from app.db.base_class import Base
def _new_id() -> str:
return str(uuid.uuid4())
class ProfileBaselineSnapshot(Base):
"""冻结的费用基线事实;机会始终引用快照而不是实时重算结果。"""
__tablename__ = "profile_baseline_snapshots"
__table_args__ = (
UniqueConstraint("tenant_id", "id", name="uq_profile_baseline_snapshots_tenant_id"),
UniqueConstraint(
"tenant_id", "baseline_key", name="uq_profile_baseline_snapshots_tenant_key"
),
CheckConstraint(
"baseline_type IN ('historical_cohort', 'policy_counterfactual', 'manual')",
name="ck_profile_baseline_snapshots_type",
),
CheckConstraint(
"data_quality_status IN ('complete', 'partial', 'insufficient', 'invalid')",
name="ck_profile_baseline_snapshots_quality_status",
),
CheckConstraint(
"baseline_value >= 0 AND sample_count >= 0",
name="ck_profile_baseline_snapshots_values",
),
CheckConstraint(
"data_quality_score >= 0 AND data_quality_score <= 1",
name="ck_profile_baseline_snapshots_quality_score",
),
CheckConstraint(
"window_end IS NULL OR window_start IS NOT NULL",
name="ck_profile_baseline_snapshots_window_pair",
),
CheckConstraint(
"window_start IS NULL OR window_end IS NULL OR window_end >= window_start",
name="ck_profile_baseline_snapshots_window_order",
),
CheckConstraint(
"baseline_type != 'historical_cohort' OR "
"(window_start IS NOT NULL AND window_end IS NOT NULL AND sample_count > 0)",
name="ck_profile_baseline_snapshots_historical_shape",
),
CheckConstraint(
"baseline_type != 'policy_counterfactual' OR "
"(policy_version IS NOT NULL AND length(trim(policy_version)) > 0 "
"AND policy_effective_from IS NOT NULL AND target_resource_type IS NOT NULL "
"AND target_resource_id IS NOT NULL)",
name="ck_profile_baseline_snapshots_policy_shape",
),
CheckConstraint(
"policy_effective_to IS NULL OR policy_effective_from IS NOT NULL",
name="ck_profile_baseline_snapshots_policy_pair",
),
CheckConstraint(
"policy_effective_from IS NULL OR policy_effective_to IS NULL "
"OR policy_effective_to >= policy_effective_from",
name="ck_profile_baseline_snapshots_policy_order",
),
CheckConstraint(
"valid_until IS NULL OR valid_until >= frozen_at",
name="ck_profile_baseline_snapshots_validity",
),
CheckConstraint(
"length(trim(baseline_key)) > 0 AND length(trim(query_fingerprint)) > 0",
name="ck_profile_baseline_snapshots_keys",
),
CheckConstraint("version >= 1", name="ck_profile_baseline_snapshots_version"),
Index(
"ix_profile_baseline_snapshots_lookup", "tenant_id", "baseline_type",
"dimension_type", "dimension_id", "metric_key", "frozen_at",
),
Index(
"ix_profile_baseline_snapshots_quality", "tenant_id",
"data_quality_status", "frozen_at",
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_new_id)
tenant_id: Mapped[str] = mapped_column(String(64), nullable=False)
baseline_key: Mapped[str] = mapped_column(String(160), nullable=False)
baseline_type: Mapped[str] = mapped_column(String(32), nullable=False)
dimension_type: Mapped[str] = mapped_column(String(50), nullable=False)
dimension_id: Mapped[str] = mapped_column(String(160), nullable=False)
metric_key: Mapped[str] = mapped_column(String(100), nullable=False)
unit: Mapped[str] = mapped_column(String(30), nullable=False)
original_currency: Mapped[str | None] = mapped_column(String(3), nullable=True)
baseline_value: Mapped[Decimal] = mapped_column(Numeric(20, 4), nullable=False)
window_start: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
window_end: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
sample_count: Mapped[int] = mapped_column(
Integer, nullable=False, default=0, server_default="0"
)
method: Mapped[str] = mapped_column(String(80), nullable=False)
query_fingerprint: Mapped[str] = mapped_column(String(80), nullable=False)
data_quality_status: Mapped[str] = mapped_column(String(20), nullable=False)
data_quality_score: Mapped[Decimal] = mapped_column(Numeric(5, 4), nullable=False)
quality_issues_json: Mapped[list[dict[str, Any]]] = mapped_column(JSON, default=list)
algorithm_version: Mapped[str] = mapped_column(String(80), nullable=False)
policy_version: Mapped[str | None] = mapped_column(String(120), nullable=True)
policy_effective_from: Mapped[date | None] = mapped_column(Date(), nullable=True)
policy_effective_to: Mapped[date | None] = mapped_column(Date(), nullable=True)
target_resource_type: Mapped[str | None] = mapped_column(String(50), nullable=True)
target_resource_id: Mapped[str | None] = mapped_column(String(160), nullable=True)
frozen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
frozen_by: Mapped[str] = mapped_column(String(120), nullable=False)
valid_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
version: Mapped[int] = mapped_column(Integer, nullable=False, default=1, server_default="1")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
opportunities = relationship(
"SavingsOpportunity", back_populates="baseline_snapshot", passive_deletes=True
)
evidence_links = relationship(
"SavingsEvidenceLink",
foreign_keys="SavingsEvidenceLink.baseline_snapshot_id",
back_populates="baseline_snapshot",
passive_deletes=True,
)
events = relationship(
"SavingsEvent",
foreign_keys="SavingsEvent.baseline_snapshot_id",
back_populates="baseline_snapshot",
passive_deletes=True,
)
class SavingsOpportunity(Base):
"""从风险暴露中分离出的可执行价值机会投影。"""
__tablename__ = "savings_opportunities"
__table_args__ = (
UniqueConstraint("tenant_id", "id", name="uq_savings_opportunities_tenant_id"),
UniqueConstraint(
"tenant_id", "opportunity_key", name="uq_savings_opportunities_tenant_key"
),
ForeignKeyConstraint(
["tenant_id", "expense_case_id"],
["expense_cases.tenant_id", "expense_cases.id"],
ondelete="RESTRICT",
name="fk_savings_opportunities_tenant_case",
),
ForeignKeyConstraint(
["tenant_id", "expense_case_id", "discovery_business_event_id"],
[
"business_events.tenant_id",
"business_events.expense_case_id",
"business_events.id",
],
ondelete="RESTRICT",
name="fk_savings_opportunities_tenant_event",
),
ForeignKeyConstraint(
["tenant_id", "baseline_snapshot_id"],
["profile_baseline_snapshots.tenant_id", "profile_baseline_snapshots.id"],
ondelete="RESTRICT",
name="fk_savings_opportunities_tenant_baseline",
),
ForeignKeyConstraint(
["tenant_id", "expense_case_id", "ai_decision_id"],
["ai_decisions.tenant_id", "ai_decisions.expense_case_id", "ai_decisions.id"],
ondelete="RESTRICT",
name="fk_savings_opportunities_tenant_ai_decision",
),
CheckConstraint(
"value_kind IN ('cash', 'labor')",
name="ck_savings_opportunities_value_kind",
),
CheckConstraint(
"status IN ('identified', 'accepted', 'in_progress', 'realized', "
"'verified', 'reversed', 'rejected', 'expired')",
name="ck_savings_opportunities_status",
),
CheckConstraint(
"exposure_amount >= 0 AND baseline_amount >= 0 AND target_amount >= 0 "
"AND estimated_gross >= 0 AND estimated_cost >= 0 AND estimated_net >= 0 "
"AND estimated_low >= 0 AND estimated_high >= 0",
name="ck_savings_opportunities_amounts",
),
CheckConstraint(
"estimated_net = estimated_gross - estimated_cost",
name="ck_savings_opportunities_net_math",
),
CheckConstraint(
"estimated_low <= estimated_net AND estimated_net <= estimated_high",
name="ck_savings_opportunities_interval",
),
CheckConstraint(
"confidence >= 0 AND confidence <= 1",
name="ck_savings_opportunities_confidence",
),
CheckConstraint("version >= 1", name="ck_savings_opportunities_version"),
CheckConstraint(
"length(trim(benefit_key)) > 0 AND length(trim(opportunity_key)) > 0",
name="ck_savings_opportunities_keys",
),
CheckConstraint(
"length(trim(currency)) = 3 AND length(trim(reporting_currency)) = 3",
name="ck_savings_opportunities_currencies",
),
CheckConstraint(
"status NOT IN ('accepted', 'in_progress', 'realized', 'verified', 'reversed') "
"OR accepted_at IS NOT NULL",
name="ck_savings_opportunities_acceptance",
),
CheckConstraint(
"status NOT IN ('in_progress', 'realized', 'verified', 'reversed') "
"OR started_at IS NOT NULL",
name="ck_savings_opportunities_started",
),
CheckConstraint(
"status NOT IN ('realized', 'verified', 'reversed') OR realized_at IS NOT NULL",
name="ck_savings_opportunities_realized",
),
CheckConstraint(
"status NOT IN ('verified', 'reversed') OR verified_at IS NOT NULL",
name="ck_savings_opportunities_verified",
),
CheckConstraint(
"status NOT IN ('verified', 'reversed', 'rejected', 'expired') "
"OR closed_at IS NOT NULL",
name="ck_savings_opportunities_closed",
),
Index(
"ix_savings_opportunities_tenant_status_due",
"tenant_id",
"status",
"due_at",
),
Index(
"ix_savings_opportunities_tenant_case",
"tenant_id",
"expense_case_id",
"created_at",
),
Index(
"ix_savings_opportunities_tenant_benefit",
"tenant_id",
"benefit_key",
),
Index(
"ix_savings_opportunities_tenant_owner",
"tenant_id",
"owner_id",
"status",
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_new_id)
tenant_id: Mapped[str] = mapped_column(String(64), nullable=False)
opportunity_key: Mapped[str] = mapped_column(String(180), nullable=False)
benefit_key: Mapped[str] = mapped_column(String(180), nullable=False)
expense_case_id: Mapped[str] = mapped_column(String(36), nullable=False)
claim_id: Mapped[str] = mapped_column(String(36), nullable=False)
claim_no_snapshot: Mapped[str] = mapped_column(String(80), nullable=False)
claim_item_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
discovery_business_event_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
source_type: Mapped[str] = mapped_column(String(50), nullable=False)
source_id: Mapped[str] = mapped_column(String(160), nullable=False)
category: Mapped[str] = mapped_column(String(60), nullable=False)
value_kind: Mapped[str] = mapped_column(String(20), nullable=False)
title: Mapped[str] = mapped_column(String(200), nullable=False)
description: Mapped[str] = mapped_column(Text(), nullable=False)
exposure_amount: Mapped[Decimal] = mapped_column(Numeric(20, 4), nullable=False)
baseline_snapshot_id: Mapped[str] = mapped_column(String(36), nullable=False)
baseline_amount: Mapped[Decimal] = mapped_column(Numeric(20, 4), nullable=False)
target_amount: Mapped[Decimal] = mapped_column(Numeric(20, 4), nullable=False)
estimated_gross: Mapped[Decimal] = mapped_column(Numeric(20, 4), nullable=False)
estimated_cost: Mapped[Decimal] = mapped_column(Numeric(20, 4), nullable=False)
estimated_net: Mapped[Decimal] = mapped_column(Numeric(20, 4), nullable=False)
estimated_low: Mapped[Decimal] = mapped_column(Numeric(20, 4), nullable=False)
estimated_high: Mapped[Decimal] = mapped_column(Numeric(20, 4), nullable=False)
confidence: Mapped[Decimal] = mapped_column(Numeric(5, 4), nullable=False)
currency: Mapped[str] = mapped_column(String(3), nullable=False)
reporting_currency: Mapped[str] = mapped_column(String(3), nullable=False)
attribution_method: Mapped[str] = mapped_column(String(60), nullable=False)
ai_decision_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
suggested_action: Mapped[str] = mapped_column(Text(), nullable=False)
owner_id: Mapped[str] = mapped_column(String(120), nullable=False)
owner_name: Mapped[str] = mapped_column(String(120), nullable=False)
owner_role: Mapped[str] = mapped_column(String(60), nullable=False)
due_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
status: Mapped[str] = mapped_column(
String(24), nullable=False, default="identified", server_default="identified"
)
version: Mapped[int] = mapped_column(Integer, nullable=False, default=1, server_default="1")
dimension_json: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False, default=dict)
baseline_snapshot_json: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
evidence_json: Mapped[list[dict[str, Any]]] = mapped_column(JSON, default=list)
accepted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
realized_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
verified_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
closed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
)
baseline_snapshot = relationship("ProfileBaselineSnapshot", back_populates="opportunities")
realizations = relationship(
"SavingsRealization",
back_populates="opportunity",
order_by="asc(SavingsRealization.realized_at)",
passive_deletes=True,
)
evidence_links = relationship(
"SavingsEvidenceLink",
foreign_keys="SavingsEvidenceLink.opportunity_id",
back_populates="opportunity",
passive_deletes=True,
)
events = relationship(
"SavingsEvent",
foreign_keys="SavingsEvent.opportunity_id",
back_populates="opportunity",
passive_deletes=True,
)
class SavingsRealization(Base):
"""实际金额事实及其财务确认投影;冲回以新行表达。"""
__tablename__ = "savings_realizations"
__table_args__ = (
UniqueConstraint("tenant_id", "id", name="uq_savings_realizations_tenant_id"),
UniqueConstraint(
"tenant_id", "realization_key", name="uq_savings_realizations_tenant_key"
),
UniqueConstraint(
"tenant_id", "opportunity_id", "benefit_key", "id",
name="uq_savings_realizations_tenant_opportunity_benefit_id",
),
UniqueConstraint(
"tenant_id", "benefit_key", "id",
name="uq_savings_realizations_tenant_benefit_id",
),
ForeignKeyConstraint(
["tenant_id", "opportunity_id"],
["savings_opportunities.tenant_id", "savings_opportunities.id"],
ondelete="RESTRICT",
name="fk_savings_realizations_tenant_opportunity",
),
ForeignKeyConstraint(
["tenant_id", "expense_case_id"],
["expense_cases.tenant_id", "expense_cases.id"],
ondelete="RESTRICT",
name="fk_savings_realizations_tenant_case",
),
ForeignKeyConstraint(
["tenant_id", "expense_case_id", "business_event_id"],
[
"business_events.tenant_id",
"business_events.expense_case_id",
"business_events.id",
],
ondelete="RESTRICT",
name="fk_savings_realizations_tenant_event",
),
ForeignKeyConstraint(
[
"tenant_id", "opportunity_id", "benefit_key", "reversal_of_realization_id"
],
[
"savings_realizations.tenant_id", "savings_realizations.opportunity_id",
"savings_realizations.benefit_key", "savings_realizations.id",
],
ondelete="RESTRICT",
name="fk_savings_realizations_tenant_reversal",
),
ForeignKeyConstraint(
["tenant_id", "benefit_key", "canonical_realization_id"],
[
"savings_realizations.tenant_id", "savings_realizations.benefit_key",
"savings_realizations.id",
],
ondelete="RESTRICT",
name="fk_savings_realizations_tenant_canonical",
),
CheckConstraint(
"realization_type IN ('actual', 'reversal')",
name="ck_savings_realizations_type",
),
CheckConstraint(
"dedupe_status IN ('pending_review', 'canonical', 'duplicate', 'excluded')",
name="ck_savings_realizations_dedupe_status",
),
CheckConstraint(
"status IN ('pending_confirmation', 'finance_confirmed', 'rejected', 'reversed')",
name="ck_savings_realizations_status",
),
CheckConstraint(
"attribution_ratio > 0 AND attribution_ratio <= 1",
name="ck_savings_realizations_attribution",
),
CheckConstraint(
"incremental_cost >= 0 AND fx_rate > 0",
name="ck_savings_realizations_cost_fx",
),
CheckConstraint(
"actual_net = actual_gross - incremental_cost",
name="ck_savings_realizations_net_math",
),
CheckConstraint(
"(realization_type = 'actual' AND reversal_of_realization_id IS NULL "
"AND actual_gross >= 0 AND actual_net >= 0 AND reporting_amount >= 0) OR "
"(realization_type = 'reversal' AND reversal_of_realization_id IS NOT NULL "
"AND actual_gross <= 0 AND actual_net <= 0 AND reporting_amount <= 0)",
name="ck_savings_realizations_amount_direction",
),
CheckConstraint(
"(dedupe_status = 'duplicate' AND canonical_realization_id IS NOT NULL "
"AND canonical_realization_id <> id) OR "
"(dedupe_status != 'duplicate' AND canonical_realization_id IS NULL)",
name="ck_savings_realizations_duplicate_target",
),
CheckConstraint(
"status != 'finance_confirmed' OR "
"(finance_confirmer_id IS NOT NULL AND finance_confirmer_name IS NOT NULL "
"AND confirmed_at IS NOT NULL AND confirmation_note IS NOT NULL "
"AND (realization_type = 'reversal' OR finance_confirmer_id <> recorded_by_id) "
"AND dedupe_status = 'canonical')",
name="ck_savings_realizations_confirmation",
),
CheckConstraint(
"status != 'rejected' OR (rejected_by_id IS NOT NULL "
"AND rejected_by_name IS NOT NULL AND rejected_at IS NOT NULL "
"AND rejection_reason IS NOT NULL)",
name="ck_savings_realizations_rejection",
),
CheckConstraint(
"status != 'reversed' OR (reversed_by_id IS NOT NULL "
"AND reversed_by_name IS NOT NULL AND reversed_at IS NOT NULL "
"AND reversal_reason IS NOT NULL)",
name="ck_savings_realizations_reversal",
),
CheckConstraint("version >= 1", name="ck_savings_realizations_version"),
CheckConstraint(
"length(trim(realization_key)) > 0 AND length(trim(benefit_key)) > 0",
name="ck_savings_realizations_keys",
),
CheckConstraint(
"length(trim(original_currency)) = 3 "
"AND length(trim(reporting_currency)) = 3",
name="ck_savings_realizations_currencies",
),
Index(
"uq_savings_realizations_actual_canonical_benefit",
"tenant_id",
"benefit_key",
unique=True,
postgresql_where=text(
"realization_type = 'actual' AND dedupe_status = 'canonical'"
),
).ddl_if(dialect="postgresql"),
Index(
"ix_savings_realizations_tenant_status_time", "tenant_id", "status", "realized_at"
),
Index(
"ix_savings_realizations_tenant_opportunity", "tenant_id",
"opportunity_id", "realized_at",
),
Index(
"ix_savings_realizations_tenant_benefit", "tenant_id",
"benefit_key", "dedupe_status",
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_new_id)
tenant_id: Mapped[str] = mapped_column(String(64), nullable=False)
realization_key: Mapped[str] = mapped_column(String(180), nullable=False)
opportunity_id: Mapped[str] = mapped_column(String(36), nullable=False)
expense_case_id: Mapped[str] = mapped_column(String(36), nullable=False)
claim_id: Mapped[str] = mapped_column(String(36), nullable=False)
claim_item_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
business_event_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
realization_type: Mapped[str] = mapped_column(String(20), nullable=False)
reversal_of_realization_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
realized_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
recorded_by_id: Mapped[str] = mapped_column(String(120), nullable=False)
recorded_by_name: Mapped[str] = mapped_column(String(120), nullable=False)
actual_gross: Mapped[Decimal] = mapped_column(Numeric(20, 4), nullable=False)
incremental_cost: Mapped[Decimal] = mapped_column(Numeric(20, 4), nullable=False)
actual_net: Mapped[Decimal] = mapped_column(Numeric(20, 4), nullable=False)
original_currency: Mapped[str] = mapped_column(String(3), nullable=False)
reporting_amount: Mapped[Decimal] = mapped_column(Numeric(20, 4), nullable=False)
reporting_currency: Mapped[str] = mapped_column(String(3), nullable=False)
fx_rate: Mapped[Decimal] = mapped_column(Numeric(20, 8), nullable=False)
fx_source: Mapped[str] = mapped_column(String(80), nullable=False)
fx_date: Mapped[date] = mapped_column(Date(), nullable=False)
fx_version: Mapped[str] = mapped_column(String(80), nullable=False)
attribution_method: Mapped[str] = mapped_column(String(60), nullable=False)
attribution_ratio: Mapped[Decimal] = mapped_column(Numeric(7, 6), nullable=False)
benefit_key: Mapped[str] = mapped_column(String(180), nullable=False)
dedupe_status: Mapped[str] = mapped_column(
String(24), nullable=False, default="pending_review", server_default="pending_review"
)
canonical_realization_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
status: Mapped[str] = mapped_column(
String(24),
nullable=False,
default="pending_confirmation",
server_default="pending_confirmation",
)
finance_confirmer_id: Mapped[str | None] = mapped_column(String(120), nullable=True)
finance_confirmer_name: Mapped[str | None] = mapped_column(String(120), nullable=True)
confirmed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
confirmation_note: Mapped[str | None] = mapped_column(Text(), nullable=True)
rejected_by_id: Mapped[str | None] = mapped_column(String(120), nullable=True)
rejected_by_name: Mapped[str | None] = mapped_column(String(120), nullable=True)
rejected_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
rejection_reason: Mapped[str | None] = mapped_column(Text(), nullable=True)
reversed_by_id: Mapped[str | None] = mapped_column(String(120), nullable=True)
reversed_by_name: Mapped[str | None] = mapped_column(String(120), nullable=True)
reversed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
reversal_reason: Mapped[str | None] = mapped_column(Text(), nullable=True)
baseline_snapshot_json: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
final_snapshot_json: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False, default=dict)
evidence_json: Mapped[list[dict[str, Any]]] = mapped_column(JSON, default=list)
version: Mapped[int] = mapped_column(Integer, nullable=False, default=1, server_default="1")
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
)
opportunity = relationship("SavingsOpportunity", back_populates="realizations")
evidence_links = relationship(
"SavingsEvidenceLink",
foreign_keys="SavingsEvidenceLink.realization_id",
back_populates="realization",
passive_deletes=True,
)
events = relationship(
"SavingsEvent",
foreign_keys="SavingsEvent.realization_id",
back_populates="realization",
passive_deletes=True,
)
class SavingsEvidenceLink(Base):
"""基线、机会或实际结果与服务端可验证资源之间的租户安全链接。"""
__tablename__ = "savings_evidence_links"
__table_args__ = (
UniqueConstraint("tenant_id", "id", name="uq_savings_evidence_links_tenant_id"),
UniqueConstraint(
"tenant_id",
"evidence_key",
name="uq_savings_evidence_links_tenant_key",
),
ForeignKeyConstraint(
["tenant_id", "baseline_snapshot_id"],
["profile_baseline_snapshots.tenant_id", "profile_baseline_snapshots.id"],
ondelete="RESTRICT",
name="fk_savings_evidence_links_tenant_baseline",
),
ForeignKeyConstraint(
["tenant_id", "opportunity_id"],
["savings_opportunities.tenant_id", "savings_opportunities.id"],
ondelete="RESTRICT",
name="fk_savings_evidence_links_tenant_opportunity",
),
ForeignKeyConstraint(
["tenant_id", "realization_id"],
["savings_realizations.tenant_id", "savings_realizations.id"],
ondelete="RESTRICT",
name="fk_savings_evidence_links_tenant_realization",
),
CheckConstraint(
"entity_type IN ('baseline', 'opportunity', 'realization')",
name="ck_savings_evidence_links_entity_type",
),
CheckConstraint(
"(entity_type = 'baseline' AND baseline_snapshot_id = entity_id "
"AND opportunity_id IS NULL AND realization_id IS NULL) OR "
"(entity_type = 'opportunity' AND opportunity_id = entity_id "
"AND baseline_snapshot_id IS NULL AND realization_id IS NULL) OR "
"(entity_type = 'realization' AND realization_id = entity_id "
"AND baseline_snapshot_id IS NULL AND opportunity_id IS NULL)",
name="ck_savings_evidence_links_entity_shape",
),
CheckConstraint(
"verification_status IN ('unverified', 'verified', 'rejected', 'unavailable')",
name="ck_savings_evidence_links_verification",
),
CheckConstraint(
"verification_status != 'verified' OR "
"(verified_by IS NOT NULL AND verified_at IS NOT NULL)",
name="ck_savings_evidence_links_verifier",
),
CheckConstraint(
"length(trim(evidence_key)) > 0 AND length(trim(content_hash)) > 0",
name="ck_savings_evidence_links_keys",
),
Index(
"ix_savings_evidence_links_entity",
"tenant_id",
"entity_type",
"entity_id",
"collected_at",
),
Index(
"ix_savings_evidence_links_resource",
"tenant_id",
"resource_type",
"resource_id",
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_new_id)
tenant_id: Mapped[str] = mapped_column(String(64), nullable=False)
evidence_key: Mapped[str] = mapped_column(String(180), nullable=False)
entity_type: Mapped[str] = mapped_column(String(20), nullable=False)
entity_id: Mapped[str] = mapped_column(String(36), nullable=False)
baseline_snapshot_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
opportunity_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
realization_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
evidence_role: Mapped[str] = mapped_column(String(50), nullable=False)
resource_type: Mapped[str] = mapped_column(String(50), nullable=False)
resource_id: Mapped[str] = mapped_column(String(160), nullable=False)
source_system: Mapped[str] = mapped_column(String(80), nullable=False)
external_event_id: Mapped[str | None] = mapped_column(String(160), nullable=True)
content_hash: Mapped[str] = mapped_column(String(80), nullable=False)
occurred_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
collected_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
verification_status: Mapped[str] = mapped_column(
String(20), nullable=False, default="unverified", server_default="unverified"
)
verified_by: Mapped[str | None] = mapped_column(String(120), nullable=True)
verified_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
metadata_json: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False, default=dict)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
baseline_snapshot = relationship(
"ProfileBaselineSnapshot",
foreign_keys=[baseline_snapshot_id],
back_populates="evidence_links",
)
opportunity = relationship(
"SavingsOpportunity",
foreign_keys=[opportunity_id],
back_populates="evidence_links",
)
realization = relationship(
"SavingsRealization",
foreign_keys=[realization_id],
back_populates="evidence_links",
)
class SavingsEvent(Base):
"""Savings Ledger 的不可变动作记录和幂等首次响应。"""
__tablename__ = "savings_events"
__table_args__ = (
UniqueConstraint("tenant_id", "id", name="uq_savings_events_tenant_id"),
UniqueConstraint(
"tenant_id",
"actor_id",
"request_id",
name="uq_savings_events_actor_request",
),
UniqueConstraint(
"tenant_id",
"aggregate_type",
"aggregate_id",
"result_version",
name="uq_savings_events_aggregate_version",
),
ForeignKeyConstraint(
["tenant_id", "baseline_snapshot_id"],
["profile_baseline_snapshots.tenant_id", "profile_baseline_snapshots.id"],
ondelete="RESTRICT",
name="fk_savings_events_tenant_baseline",
),
ForeignKeyConstraint(
["tenant_id", "opportunity_id"],
["savings_opportunities.tenant_id", "savings_opportunities.id"],
ondelete="RESTRICT",
name="fk_savings_events_tenant_opportunity",
),
ForeignKeyConstraint(
["tenant_id", "realization_id"],
["savings_realizations.tenant_id", "savings_realizations.id"],
ondelete="RESTRICT",
name="fk_savings_events_tenant_realization",
),
CheckConstraint(
"aggregate_type IN ('baseline', 'opportunity', 'realization')",
name="ck_savings_events_aggregate_type",
),
CheckConstraint(
"(aggregate_type = 'baseline' AND baseline_snapshot_id = aggregate_id "
"AND opportunity_id IS NULL AND realization_id IS NULL) OR "
"(aggregate_type = 'opportunity' AND opportunity_id = aggregate_id "
"AND baseline_snapshot_id IS NULL AND realization_id IS NULL) OR "
"(aggregate_type = 'realization' AND realization_id = aggregate_id "
"AND baseline_snapshot_id IS NULL AND opportunity_id IS NULL)",
name="ck_savings_events_aggregate_shape",
),
CheckConstraint(
"length(trim(action)) > 0",
name="ck_savings_events_action",
),
CheckConstraint(
"actor_type IN ('user', 'system', 'agent', 'service')",
name="ck_savings_events_actor_type",
),
CheckConstraint(
"expected_version >= 0 AND result_version >= 1 "
"AND result_version >= expected_version",
name="ck_savings_events_version",
),
CheckConstraint(
"length(trim(request_id)) > 0 AND length(trim(payload_fingerprint)) > 0",
name="ck_savings_events_request",
),
Index(
"ix_savings_events_tenant_aggregate_time",
"tenant_id",
"aggregate_type",
"aggregate_id",
"occurred_at",
),
Index(
"ix_savings_events_tenant_correlation",
"tenant_id",
"correlation_id",
"occurred_at",
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_new_id)
tenant_id: Mapped[str] = mapped_column(String(64), nullable=False)
aggregate_type: Mapped[str] = mapped_column(String(20), nullable=False)
aggregate_id: Mapped[str] = mapped_column(String(36), nullable=False)
baseline_snapshot_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
opportunity_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
realization_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
action: Mapped[str] = mapped_column(String(60), nullable=False)
actor_id: Mapped[str] = mapped_column(String(120), nullable=False)
actor_name: Mapped[str] = mapped_column(String(120), nullable=False)
actor_type: Mapped[str] = mapped_column(String(20), nullable=False)
request_id: Mapped[str] = mapped_column(String(120), nullable=False)
expected_version: Mapped[int] = mapped_column(Integer, nullable=False)
result_version: Mapped[int] = mapped_column(Integer, nullable=False)
payload_fingerprint: Mapped[str] = mapped_column(String(80), nullable=False)
payload_json: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False, default=dict)
before_json: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False, default=dict)
after_json: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False, default=dict)
response_json: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False, default=dict)
correlation_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
causation_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
occurred_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
baseline_snapshot = relationship(
"ProfileBaselineSnapshot",
foreign_keys=[baseline_snapshot_id],
back_populates="events",
)
opportunity = relationship(
"SavingsOpportunity",
foreign_keys=[opportunity_id],
back_populates="events",
)
realization = relationship(
"SavingsRealization",
foreign_keys=[realization_id],
back_populates="events",
)

View File

@@ -0,0 +1,128 @@
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import (
Boolean,
CheckConstraint,
DateTime,
ForeignKey,
ForeignKeyConstraint,
Index,
String,
UniqueConstraint,
func,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base_class import Base
class Tenant(Base):
"""可信租户注册表;业务代码不得用任意字符串替代这里的记录。"""
__tablename__ = "tenants"
__table_args__ = (
CheckConstraint(
"status IN ('active', 'suspended', 'disabled')",
name="ck_tenants_status",
),
CheckConstraint(
"length(trim(tenant_id)) > 0 AND length(trim(tenant_code)) > 0 "
"AND length(trim(name)) > 0",
name="ck_tenants_identity",
),
Index("ix_tenants_status", "status"),
)
tenant_id: Mapped[str] = mapped_column(String(64), primary_key=True)
tenant_code: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
name: Mapped[str] = mapped_column(String(160), nullable=False)
status: Mapped[str] = mapped_column(String(20), nullable=False, default="active")
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
server_default=func.now(),
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
server_default=func.now(),
onupdate=func.now(),
)
memberships = relationship(
"TenantMembership",
back_populates="tenant",
cascade="all, delete-orphan",
overlaps="employee,tenant_memberships",
)
class TenantMembership(Base):
"""员工在租户中的可认证成员资格。"""
__tablename__ = "tenant_memberships"
__table_args__ = (
UniqueConstraint(
"tenant_id",
"employee_id",
name="uq_tenant_memberships_tenant_employee",
),
ForeignKeyConstraint(
["tenant_id", "employee_id"],
["employees.tenant_id", "employees.id"],
name="fk_tenant_memberships_tenant_employee",
ondelete="CASCADE",
),
CheckConstraint(
"status IN ('active', 'inactive')",
name="ck_tenant_memberships_status",
),
Index(
"ix_tenant_memberships_employee_active",
"employee_id",
"status",
),
Index(
"ix_tenant_memberships_tenant_active",
"tenant_id",
"status",
),
)
id: Mapped[str] = mapped_column(
String(36),
primary_key=True,
default=lambda: str(uuid.uuid4()),
)
tenant_id: Mapped[str] = mapped_column(
ForeignKey("tenants.tenant_id", ondelete="RESTRICT"),
nullable=False,
)
employee_id: Mapped[str] = mapped_column(String(36), nullable=False)
status: Mapped[str] = mapped_column(String(20), nullable=False, default="active")
is_primary: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
server_default=func.now(),
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
server_default=func.now(),
onupdate=func.now(),
)
tenant = relationship(
"Tenant",
back_populates="memberships",
overlaps="employee,tenant_memberships",
)
employee = relationship(
"Employee",
back_populates="tenant_memberships",
overlaps="memberships,tenant",
)

View File

@@ -0,0 +1,98 @@
from __future__ import annotations
import uuid
from datetime import date, datetime
from typing import Any
from sqlalchemy import (
Boolean,
CheckConstraint,
Date,
DateTime,
ForeignKey,
Index,
String,
Text,
UniqueConstraint,
func,
)
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.types import JSON
from app.db.base_class import Base
class TenantFinanceReportConfig(Base):
"""租户自有的报告收件配置;绝不回落到平台级管理员邮箱。"""
__tablename__ = "tenant_finance_report_configs"
__table_args__ = (
CheckConstraint(
"status IN ('active', 'disabled')",
name="ck_tenant_finance_report_configs_status",
),
)
tenant_id: Mapped[str] = mapped_column(
ForeignKey("tenants.tenant_id", ondelete="CASCADE"),
primary_key=True,
)
status: Mapped[str] = mapped_column(String(20), nullable=False, default="disabled")
delivery_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
recipients_json: Mapped[list[Any]] = mapped_column(JSON, nullable=False, default=list)
updated_by: Mapped[str] = mapped_column(String(100), nullable=False, default="")
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
server_default=func.now(),
onupdate=func.now(),
)
class TenantFinanceReportRun(Base):
"""按租户和报告周期占位,提供并发安全的定时幂等边界。"""
__tablename__ = "tenant_finance_report_runs"
__table_args__ = (
UniqueConstraint(
"tenant_id",
"idempotency_key",
name="uq_tenant_finance_report_runs_idempotency",
),
CheckConstraint(
"report_type IN ('weekly', 'quarterly', 'annual')",
name="ck_tenant_finance_report_runs_type",
),
CheckConstraint(
"status IN ('running', 'succeeded', 'failed')",
name="ck_tenant_finance_report_runs_status",
),
Index(
"ix_tenant_finance_report_runs_period",
"tenant_id",
"report_type",
"period_start",
"period_end",
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
tenant_id: Mapped[str] = mapped_column(
ForeignKey("tenants.tenant_id", ondelete="CASCADE"), nullable=False
)
report_type: Mapped[str] = mapped_column(String(20), nullable=False)
period_start: Mapped[date] = mapped_column(Date, nullable=False)
period_end: Mapped[date] = mapped_column(Date, nullable=False)
idempotency_key: Mapped[str] = mapped_column(String(180), nullable=False)
status: Mapped[str] = mapped_column(String(20), nullable=False, default="running")
agent_run_id: Mapped[str | None] = mapped_column(String(50), nullable=True, index=True)
storage_key: Mapped[str] = mapped_column(String(512), nullable=False, default="")
result_json: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False, default=dict)
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
started_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)

View File

@@ -10,12 +10,65 @@ from app.models.agent_asset import (
AgentAssetTestRun,
AgentAssetVersion,
)
from app.services.agent_asset_access import AgentAssetAccessScope
from app.services.pagination import PageResult, paginate_select
class AgentAssetRepository:
def __init__(self, db: Session) -> None:
def __init__(
self,
db: Session,
*,
access_scope: AgentAssetAccessScope | None = None,
) -> None:
self.db = db
# 没有可信用户上下文时只允许系统维护平台资产,绝不能退回全表查询。
self.access_scope = access_scope or AgentAssetAccessScope(
tenant_id="__internal_no_tenant__",
is_platform_admin=True,
)
def _visible(self, stmt, model):
return stmt.where(self.access_scope.visibility_clause(model))
def _require_write(self, asset: AgentAsset) -> None:
self.access_scope.require_write(asset)
def _prepare_asset_child(self, child):
asset = self.get(str(child.asset_id or ""))
if asset is None:
raise LookupError("Asset not found")
self._require_write(asset)
tenant_id = str(getattr(child, "tenant_id", "") or "").strip()
scope = str(getattr(child, "scope", "") or "").strip()
if tenant_id and tenant_id != asset.tenant_id:
raise LookupError("Asset not found")
if scope and scope != asset.scope:
raise LookupError("Asset not found")
child.tenant_id = asset.tenant_id
child.scope = asset.scope
return child
def _prepare_tenant_evidence(self, child):
asset = self.get(str(child.asset_id or ""))
if asset is None:
raise LookupError("Asset not found")
tenant_id = str(getattr(child, "tenant_id", "") or "").strip()
scope = str(getattr(child, "scope", "") or "").strip()
expected_tenant = self.access_scope.tenant_id
if expected_tenant != "__internal_no_tenant__":
if tenant_id and tenant_id != expected_tenant:
raise LookupError("Asset not found")
if scope and scope != "tenant":
raise LookupError("Asset not found")
child.tenant_id = expected_tenant
child.scope = "tenant"
return child
if asset.scope != "platform" or asset.tenant_id != "platform":
raise LookupError("Asset not found")
child.tenant_id = "platform"
child.scope = "platform"
return child
def _list_stmt(
self,
@@ -25,7 +78,7 @@ class AgentAssetRepository:
domain: str | None = None,
keyword: str | None = None,
):
stmt = select(AgentAsset)
stmt = self._visible(select(AgentAsset), AgentAsset)
if asset_type:
stmt = stmt.where(AgentAsset.asset_type == asset_type)
@@ -81,18 +134,25 @@ class AgentAssetRepository:
return paginate_select(self.db, stmt, page=page, page_size=page_size)
def get(self, asset_id: str) -> AgentAsset | None:
return self.db.get(AgentAsset, asset_id)
stmt = self._visible(
select(AgentAsset).where(AgentAsset.id == asset_id),
AgentAsset,
)
return self.db.scalar(stmt)
def get_by_code(self, code: str) -> AgentAsset | None:
stmt = select(AgentAsset).where(AgentAsset.code == code)
stmt = self._visible(
select(AgentAsset).where(AgentAsset.code == code),
AgentAsset,
).order_by(AgentAsset.scope.desc())
return self.db.scalar(stmt)
def list_versions(self, asset_id: str, *, limit: int | None = None) -> list[AgentAssetVersion]:
stmt = (
stmt = self._visible(
select(AgentAssetVersion)
.where(AgentAssetVersion.asset_id == asset_id)
.order_by(AgentAssetVersion.created_at.desc())
)
.where(AgentAssetVersion.asset_id == asset_id),
AgentAssetVersion,
).order_by(AgentAssetVersion.created_at.desc())
if limit is not None:
stmt = stmt.limit(limit)
return list(self.db.scalars(stmt).all())
@@ -101,26 +161,29 @@ class AgentAssetRepository:
if not asset_ids:
return []
stmt = (
stmt = self._visible(
select(AgentAssetVersion)
.where(AgentAssetVersion.asset_id.in_(asset_ids))
.order_by(AgentAssetVersion.asset_id, AgentAssetVersion.created_at.desc())
)
.where(AgentAssetVersion.asset_id.in_(asset_ids)),
AgentAssetVersion,
).order_by(AgentAssetVersion.asset_id, AgentAssetVersion.created_at.desc())
return list(self.db.scalars(stmt).all())
def get_version(self, asset_id: str, version: str) -> AgentAssetVersion | None:
stmt = select(AgentAssetVersion).where(
AgentAssetVersion.asset_id == asset_id,
AgentAssetVersion.version == version,
stmt = self._visible(
select(AgentAssetVersion).where(
AgentAssetVersion.asset_id == asset_id,
AgentAssetVersion.version == version,
),
AgentAssetVersion,
)
return self.db.scalar(stmt)
def list_reviews(self, asset_id: str, *, limit: int | None = None) -> list[AgentAssetReview]:
stmt = (
stmt = self._visible(
select(AgentAssetReview)
.where(AgentAssetReview.asset_id == asset_id)
.order_by(AgentAssetReview.created_at.desc())
)
.where(AgentAssetReview.asset_id == asset_id),
AgentAssetReview,
).order_by(AgentAssetReview.created_at.desc())
if limit is not None:
stmt = stmt.limit(limit)
return list(self.db.scalars(stmt).all())
@@ -129,19 +192,22 @@ class AgentAssetRepository:
if not asset_ids:
return []
stmt = (
stmt = self._visible(
select(AgentAssetReview)
.where(AgentAssetReview.asset_id.in_(asset_ids))
.order_by(AgentAssetReview.asset_id, AgentAssetReview.created_at.desc())
)
.where(AgentAssetReview.asset_id.in_(asset_ids)),
AgentAssetReview,
).order_by(AgentAssetReview.asset_id, AgentAssetReview.created_at.desc())
return list(self.db.scalars(stmt).all())
def get_review(
self, asset_id: str, version: str, review_status: str | None = None
) -> AgentAssetReview | None:
stmt = select(AgentAssetReview).where(
AgentAssetReview.asset_id == asset_id,
AgentAssetReview.version == version,
stmt = self._visible(
select(AgentAssetReview).where(
AgentAssetReview.asset_id == asset_id,
AgentAssetReview.version == version,
),
AgentAssetReview,
)
if review_status:
stmt = stmt.where(AgentAssetReview.review_status == review_status)
@@ -149,24 +215,28 @@ class AgentAssetRepository:
return self.db.scalar(stmt)
def create_asset(self, asset: AgentAsset) -> AgentAsset:
self._require_write(asset)
self.db.add(asset)
self.db.commit()
self.db.refresh(asset)
return asset
def save_asset(self, asset: AgentAsset) -> AgentAsset:
self._require_write(asset)
self.db.add(asset)
self.db.commit()
self.db.refresh(asset)
return asset
def create_version(self, version: AgentAssetVersion) -> AgentAssetVersion:
self._prepare_asset_child(version)
self.db.add(version)
self.db.commit()
self.db.refresh(version)
return version
def create_review(self, review: AgentAssetReview) -> AgentAssetReview:
self._prepare_asset_child(review)
self.db.add(review)
self.db.commit()
self.db.refresh(review)
@@ -181,11 +251,11 @@ class AgentAssetRepository:
status: str | None = None,
limit: int | None = None,
) -> list[AgentAssetTestRun]:
stmt = (
stmt = self._visible(
select(AgentAssetTestRun)
.where(AgentAssetTestRun.asset_id == asset_id)
.order_by(AgentAssetTestRun.created_at.desc())
)
.where(AgentAssetTestRun.asset_id == asset_id),
AgentAssetTestRun,
).order_by(AgentAssetTestRun.created_at.desc())
if version:
stmt = stmt.where(AgentAssetTestRun.version == version)
if test_type:
@@ -214,6 +284,7 @@ class AgentAssetRepository:
return items[0] if items else None
def create_test_run(self, test_run: AgentAssetTestRun) -> AgentAssetTestRun:
self._prepare_tenant_evidence(test_run)
self.db.add(test_run)
self.db.commit()
self.db.refresh(test_run)
@@ -227,11 +298,11 @@ class AgentAssetRepository:
status: str | None = None,
limit: int | None = None,
) -> list[AgentAssetRuleFeedback]:
stmt = (
stmt = self._visible(
select(AgentAssetRuleFeedback)
.where(AgentAssetRuleFeedback.asset_id == asset_id)
.order_by(AgentAssetRuleFeedback.created_at.desc())
)
.where(AgentAssetRuleFeedback.asset_id == asset_id),
AgentAssetRuleFeedback,
).order_by(AgentAssetRuleFeedback.created_at.desc())
if version:
stmt = stmt.where(AgentAssetRuleFeedback.version == version)
if status:
@@ -244,11 +315,13 @@ class AgentAssetRepository:
self,
feedback: AgentAssetRuleFeedback,
) -> AgentAssetRuleFeedback:
self._prepare_tenant_evidence(feedback)
self.db.add(feedback)
self.db.commit()
self.db.refresh(feedback)
return feedback
def delete_asset(self, asset: AgentAsset) -> None:
self._require_write(asset)
self.db.delete(asset)
self.db.commit()

View File

@@ -2,7 +2,7 @@ from __future__ import annotations
from typing import Any
from sqlalchemy import select
from sqlalchemy import Select, select
from sqlalchemy.orm import Session
from app.models.agent_run import AgentRun, AgentToolCall, SemanticParseLog
@@ -19,8 +19,13 @@ class AgentRunRepository:
status: str | None = None,
source: str | None = None,
limit: int = 20,
tenant_id: str | None = None,
scope_clause: Any | None = None,
) -> list[AgentRun]:
stmt = select(AgentRun)
stmt = self._apply_tenant_scope(stmt, tenant_id)
if scope_clause is not None:
stmt = stmt.where(scope_clause)
if agent:
stmt = stmt.where(AgentRun.agent == agent)
if status:
@@ -37,6 +42,8 @@ class AgentRunRepository:
status: str | None = None,
source: str | None = None,
limit: int = 20,
tenant_id: str | None = None,
scope_clause: Any | None = None,
) -> list[dict[str, Any]]:
stmt = select(
AgentRun.id.label("id"),
@@ -69,6 +76,9 @@ class AgentRunRepository:
AgentRun.ontology_json["intent"].as_string().label("ontology_intent"),
AgentRun.ontology_json["parse_strategy"].as_string().label("ontology_parse_strategy"),
)
stmt = self._apply_tenant_scope(stmt, tenant_id)
if scope_clause is not None:
stmt = stmt.where(scope_clause)
if agent:
stmt = stmt.where(AgentRun.agent == agent)
if status:
@@ -98,10 +108,63 @@ class AgentRunRepository:
)
return [dict(item) for item in self.db.execute(stmt).mappings().all()]
def get_by_run_id(self, run_id: str) -> AgentRun | None:
def list_light_semantic_parses(self, run_ids: list[str]) -> dict[str, dict[str, Any]]:
if not run_ids:
return {}
stmt = (
select(
SemanticParseLog.id,
SemanticParseLog.run_id,
SemanticParseLog.user_id,
SemanticParseLog.raw_query,
SemanticParseLog.scenario,
SemanticParseLog.intent,
SemanticParseLog.entities_json,
SemanticParseLog.time_range_json,
SemanticParseLog.metrics_json,
SemanticParseLog.constraints_json,
SemanticParseLog.risk_flags_json,
SemanticParseLog.permission_json,
SemanticParseLog.confidence,
SemanticParseLog.created_at,
)
.where(SemanticParseLog.run_id.in_(run_ids))
.order_by(SemanticParseLog.created_at.asc())
)
first_by_run_id: dict[str, dict[str, Any]] = {}
for row in self.db.execute(stmt).mappings():
payload = dict(row)
first_by_run_id.setdefault(str(payload["run_id"]), payload)
return first_by_run_id
def get_by_run_id(
self,
run_id: str,
*,
tenant_id: str | None = None,
) -> AgentRun | None:
stmt = select(AgentRun).where(AgentRun.run_id == run_id)
stmt = self._apply_tenant_scope(stmt, tenant_id)
return self.db.scalar(stmt)
@staticmethod
def _apply_tenant_scope(
stmt: Select[Any],
tenant_id: str | None,
) -> Select[Any]:
"""在排序和 limit 前收窄租户None 仅供受信任的内部调用。"""
if tenant_id is None:
return stmt
route_tenant = AgentRun.route_json["tenant_id"].as_string()
ontology_tenant = AgentRun.ontology_json["tenant_id"].as_string()
# 两份独立载荷都必须携带同一可信租户,缺一、空值或冲突均不进入窗口。
return stmt.where(
route_tenant == tenant_id,
ontology_tenant == tenant_id,
)
def create_run(self, run: AgentRun) -> AgentRun:
self.db.add(run)
self.db.commit()

View File

@@ -7,11 +7,13 @@ from app.models.employee import Employee
from app.models.organization import OrganizationUnit
from app.models.role import Role
from app.services.pagination import PageResult, paginate_select
from app.services.tenant_registry import required_tenant_id
class EmployeeRepository:
def __init__(self, db: Session) -> None:
def __init__(self, db: Session, *, tenant_id: str) -> None:
self.db = db
self.tenant_id = required_tenant_id(tenant_id)
def _list_stmt(self, status: str | None = None, keyword: str | None = None):
stmt = (
@@ -22,6 +24,7 @@ class EmployeeRepository:
selectinload(Employee.roles),
selectinload(Employee.change_logs),
)
.where(Employee.tenant_id == self.tenant_id)
.order_by(Employee.updated_at.desc(), Employee.name.asc())
)
@@ -65,16 +68,25 @@ class EmployeeRepository:
selectinload(Employee.roles),
selectinload(Employee.change_logs),
)
.where(Employee.id == employee_id)
.where(
Employee.tenant_id == self.tenant_id,
Employee.id == employee_id,
)
)
return self.db.execute(stmt).scalars().unique().first()
def get_by_employee_no(self, employee_no: str) -> Employee | None:
stmt = select(Employee).where(Employee.employee_no == employee_no)
stmt = select(Employee).where(
Employee.tenant_id == self.tenant_id,
Employee.employee_no == employee_no,
)
return self.db.execute(stmt).scalars().first()
def get_by_email(self, email: str) -> Employee | None:
stmt = select(Employee).where(Employee.email == email)
stmt = select(Employee).where(
Employee.tenant_id == self.tenant_id,
Employee.email == email,
)
return self.db.execute(stmt).scalars().first()
def list_roles(self) -> list[Role]:
@@ -86,15 +98,24 @@ class EmployeeRepository:
return self.db.execute(stmt).scalars().first()
def list_organization_units(self) -> list[OrganizationUnit]:
stmt = select(OrganizationUnit)
stmt = select(OrganizationUnit).where(
OrganizationUnit.tenant_id == self.tenant_id
)
return list(self.db.execute(stmt).scalars().all())
def get_organization_by_code(self, unit_code: str) -> OrganizationUnit | None:
stmt = select(OrganizationUnit).where(OrganizationUnit.unit_code == unit_code)
stmt = select(OrganizationUnit).where(
OrganizationUnit.tenant_id == self.tenant_id,
OrganizationUnit.unit_code == unit_code,
)
return self.db.execute(stmt).scalars().first()
def count_employees(self) -> int:
stmt = select(func.count()).select_from(Employee)
stmt = (
select(func.count())
.select_from(Employee)
.where(Employee.tenant_id == self.tenant_id)
)
return int(self.db.execute(stmt).scalar_one())
def count_roles(self) -> int:
@@ -102,17 +123,27 @@ class EmployeeRepository:
return int(self.db.execute(stmt).scalar_one())
def count_organization_units(self) -> int:
stmt = select(func.count()).select_from(OrganizationUnit)
stmt = (
select(func.count())
.select_from(OrganizationUnit)
.where(OrganizationUnit.tenant_id == self.tenant_id)
)
return int(self.db.execute(stmt).scalar_one())
def create(self, employee: Employee) -> Employee:
self._require_employee_tenant(employee)
self.db.add(employee)
self.db.commit()
self.db.refresh(employee)
return employee
def save(self, employee: Employee) -> Employee:
self._require_employee_tenant(employee)
self.db.add(employee)
self.db.commit()
self.db.refresh(employee)
return employee
def _require_employee_tenant(self, employee: Employee) -> None:
if required_tenant_id(employee.tenant_id) != self.tenant_id:
raise ValueError("员工记录不属于当前可信租户。")

View File

@@ -1,7 +1,7 @@
from __future__ import annotations
from datetime import datetime
from typing import Any
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field
@@ -15,6 +15,7 @@ from app.core.agent_enums import (
class AgentAssetCreate(BaseModel):
scope: Literal["tenant", "platform"] = "tenant"
asset_type: AgentAssetType
code: str = Field(min_length=1, max_length=100)
name: str = Field(min_length=1, max_length=200)
@@ -67,6 +68,8 @@ class AgentAssetReviewRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
tenant_id: str
scope: str
asset_id: str
version: str
reviewer: str
@@ -90,6 +93,7 @@ class AgentAssetOnlyOfficeCallbackWrite(BaseModel):
status: int = Field(description="ONLYOFFICE 回调状态码。")
url: str | None = Field(default=None, description="文档下载地址,状态为 2 或 6 时使用。")
key: str | None = Field(default=None, description="ONLYOFFICE 当前文档会话 key。")
users: list[str] = Field(default_factory=list, description="当前编辑用户列表。")
@@ -193,6 +197,7 @@ class AgentAssetRiskRuleSampleTestRequest(BaseModel):
class AgentAssetRiskRuleScenarioTestRequest(BaseModel):
target_tenant_id: str = Field(min_length=1, max_length=64)
version: str | None = Field(default=None, max_length=30)
intent: str = Field(default="", max_length=1000)
filters: dict[str, Any] = Field(default_factory=dict)
@@ -320,6 +325,8 @@ class AgentAssetRiskRuleFeedbackRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
tenant_id: str
scope: str
feedback_id: str
asset_id: str
version: str
@@ -340,6 +347,8 @@ class AgentAssetRiskRuleTestRunRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
tenant_id: str
scope: str
asset_id: str
version: str
test_type: str
@@ -399,6 +408,8 @@ class AgentAssetVersionRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
tenant_id: str
scope: str
asset_id: str
version: str
content: Any
@@ -416,6 +427,8 @@ class AgentAssetListItem(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
tenant_id: str
scope: str
asset_type: str
code: str
name: str

View File

@@ -0,0 +1,117 @@
from __future__ import annotations
from datetime import datetime
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field
class AgentAssetReleasePolicyWrite(BaseModel):
shadow_min_samples: int = Field(default=20, ge=1, le=1_000_000)
canary_min_samples: int = Field(default=100, ge=1, le=1_000_000)
max_error_rate: float = Field(default=0.02, ge=0, le=1)
min_precision: float = Field(default=0.98, ge=0, le=1)
max_precision_drop: float = Field(default=0.02, ge=0, le=1)
canary_traffic_percent: int = Field(default=5, ge=1, le=50)
reviewer_quorum: int = Field(default=1, ge=1, le=2)
recall_gate_enabled: bool = True
negative_sample_percent: int = Field(default=20, ge=1, le=100)
negative_min_reviewed: int = Field(default=5, ge=1, le=10_000)
min_recall: float = Field(default=0.95, ge=0, le=1)
recall_confidence_level: Literal[0.9, 0.95, 0.99] = 0.95
class AgentAssetReleaseStartWrite(BaseModel):
candidate_version: str = Field(min_length=1, max_length=30)
policy: AgentAssetReleasePolicyWrite = Field(default_factory=AgentAssetReleasePolicyWrite)
class AgentAssetReleaseMonitorTriggerWrite(BaseModel):
"""可信监控触发器不接受调用方提供的样本数或质量指标。"""
model_config = ConfigDict(extra="forbid")
class AgentAssetReleaseRollbackWrite(BaseModel):
reason: str = Field(min_length=2, max_length=1000)
class AgentAssetReleaseStateRead(BaseModel):
stage: Literal["unmanaged", "shadow", "canary", "active", "rolled_back"] = "unmanaged"
release_id: str = ""
candidate_version: str = ""
previous_version: str = ""
policy: dict[str, Any] = Field(default_factory=dict)
started_at: str = ""
started_by: str = ""
updated_at: str = ""
history: list[dict[str, Any]] = Field(default_factory=list)
rollback: dict[str, Any] | None = None
class AgentAssetReleaseServingPlanRead(BaseModel):
stage: str
primary_version: str
candidate_version: str
candidate_traffic_percent: int
shadow_evaluation: bool
class AgentAssetReleaseMonitorRead(BaseModel):
asset_id: str
release_id: str
stage: str
version: str
telemetry_status: Literal["collecting", "ready"]
status: Literal["collecting", "passed", "failed"]
evaluation_submitted: bool
release_stage: str
rolled_back: bool
reasons: list[str] = Field(default_factory=list)
test_run_id: str | None = None
metrics: dict[str, Any] = Field(default_factory=dict)
alerts: list[dict[str, str]] = Field(default_factory=list)
class AgentAssetReleaseReviewItemRead(BaseModel):
sample_id: str
observation_id: str
source_document_id: str
rule_code: str
business_stage: Literal["expense_application", "reimbursement"]
prediction_blinded: Literal[True] = True
reviewer_count: int = 0
required_reviewers: int = 1
conflicted: bool = False
created_at: datetime
class AgentAssetReleaseReviewQueueRead(BaseModel):
asset_id: str
release_id: str
stage: Literal["shadow", "canary", "active"]
version: str
pending_total: int
telemetry_status: Literal["collecting", "ready"]
reasons: list[str] = Field(default_factory=list)
metrics: dict[str, Any] = Field(default_factory=dict)
alerts: list[dict[str, str]] = Field(default_factory=list)
items: list[AgentAssetReleaseReviewItemRead] = Field(default_factory=list)
class AgentAssetReleaseReviewLabelWrite(BaseModel):
model_config = ConfigDict(extra="forbid")
label: Literal[
"risk_present",
"risk_absent",
"confirmed",
"false_positive",
]
class AgentAssetReleaseReviewLabelRead(BaseModel):
label_id: str
observation_id: str
label: Literal["risk_present", "risk_absent", "confirmed", "false_positive"]
monitor: AgentAssetReleaseMonitorRead

View File

@@ -3,12 +3,20 @@ from __future__ import annotations
from datetime import datetime
from typing import Any
from pydantic import BaseModel, EmailStr, Field
from pydantic import BaseModel, ConfigDict, EmailStr, Field
class LoginRequest(BaseModel):
model_config = ConfigDict(populate_by_name=True)
username: str = Field(min_length=1, max_length=255)
password: str = Field(min_length=1, max_length=128)
tenant_id: str | None = Field(
default=None,
alias="tenantId",
min_length=1,
max_length=64,
)
class AuthUserRead(BaseModel):
@@ -29,6 +37,7 @@ class AuthUserRead(BaseModel):
email: EmailStr | str
avatar: str
isAdmin: bool = False
tenantId: str
class LoginResponse(BaseModel):

View File

@@ -0,0 +1,142 @@
from __future__ import annotations
from datetime import datetime
from decimal import Decimal
from typing import Literal
from pydantic import BaseModel, Field
class CfoValueMoneyRead(BaseModel):
currency: str
amount: Decimal
class CfoValueWindowRead(BaseModel):
start: datetime
end: datetime
as_of: datetime
timezone: str = "Asia/Shanghai"
class CfoValueSourceRead(BaseModel):
system: Literal["savings_ledger"] = "savings_ledger"
ledger_revision: str = "20260716_0015"
generated_at: datetime
freshness_at: datetime | None = None
data_status: Literal["complete", "empty", "partial"]
opportunity_count: int = 0
realization_count: int = 0
coverage_notes: list[str] = Field(default_factory=list)
class CfoValueCashKpiRead(BaseModel):
key: Literal["verified_net_cash_savings"] = "verified_net_cash_savings"
label: str = "财务确认净现金节省"
status: Literal["available", "empty"]
values: list[CfoValueMoneyRead] = Field(default_factory=list)
confirmed_realization_count: int = 0
definition: str
class CfoValueUnavailableKpiRead(BaseModel):
key: Literal[
"verified_releasable_labor_value",
"safe_straight_through_rate",
]
label: str
status: Literal["unavailable", "collecting"]
reason: str
required_inputs: list[str] = Field(default_factory=list)
class CfoValueKpisRead(BaseModel):
verified_cash: CfoValueCashKpiRead
releasable_labor: CfoValueUnavailableKpiRead
safe_straight_through: CfoValueUnavailableKpiRead
class CfoValueFunnelStageRead(BaseModel):
key: Literal[
"estimated",
"in_progress",
"actual_pending",
"verified",
"reversed",
"rejected_or_expired",
]
label: str
count: int
values: list[CfoValueMoneyRead] = Field(default_factory=list)
class CfoValueFunnelRead(BaseModel):
stages: list[CfoValueFunnelStageRead] = Field(default_factory=list)
mature_estimated_values: list[CfoValueMoneyRead] = Field(default_factory=list)
verified_values: list[CfoValueMoneyRead] = Field(default_factory=list)
realization_rate_by_currency: dict[str, Decimal | None] = Field(default_factory=dict)
class CfoValueTrendPointRead(BaseModel):
period: str
currency: str
verified_net: Decimal = Decimal("0")
actual_pending: Decimal = Decimal("0")
reversal: Decimal = Decimal("0")
class CfoValueBreakdownItemRead(BaseModel):
dimension: str
dimension_id: str
dimension_name: str
opportunity_count: int
verified_values: list[CfoValueMoneyRead] = Field(default_factory=list)
estimated_values: list[CfoValueMoneyRead] = Field(default_factory=list)
class CfoValueBreakdownGroupRead(BaseModel):
dimension: str
items: list[CfoValueBreakdownItemRead] = Field(default_factory=list)
class CfoValueGuardrailRead(BaseModel):
key: str
label: str
status: Literal["ok", "attention", "unavailable"]
count: int | None = None
values: list[CfoValueMoneyRead] = Field(default_factory=list)
rate: Decimal | None = None
reason: str = ""
class CfoValueDataQualityRead(BaseModel):
pending_confirmation_count: int = 0
business_state_only_count: int = 0
pending_dedupe_count: int = 0
missing_fx_count: int = 0
missing_evidence_count: int = 0
actual_over_estimate_count: int = 0
notes: list[str] = Field(default_factory=list)
class CfoValueFiltersRead(BaseModel):
department_id: str | None = None
project_code: str | None = None
expense_type: str | None = None
supplier_id: str | None = None
city: str | None = None
owner_id: str | None = None
source_type: str | None = None
value_kind: Literal["cash", "labor"] | None = None
class CfoValueDashboardRead(BaseModel):
window: CfoValueWindowRead
source: CfoValueSourceRead
filters: CfoValueFiltersRead
kpis: CfoValueKpisRead
funnel: CfoValueFunnelRead
trend: list[CfoValueTrendPointRead] = Field(default_factory=list)
breakdowns: list[CfoValueBreakdownGroupRead] = Field(default_factory=list)
guardrails: list[CfoValueGuardrailRead] = Field(default_factory=list)
data_quality: CfoValueDataQualityRead

View File

@@ -0,0 +1,516 @@
from __future__ import annotations
from datetime import datetime
from decimal import Decimal
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
PricingModel = Literal["subscription", "usage", "hybrid", "pilot", "custom"]
BillingInterval = Literal["monthly", "quarterly", "annual", "contract"]
SubscriptionStatus = Literal["trialing", "active", "past_due", "suspended", "canceled", "expired"]
EntitlementType = Literal["feature", "metered", "unlimited"]
EntitlementStatus = Literal["active", "suspended", "expired"]
ResetInterval = Literal["none", "monthly", "quarterly", "annual", "contract"]
OveragePolicy = Literal["block", "allow", "alert"]
class CommercialPlanCreate(BaseModel):
plan_code: str = Field(min_length=1, max_length=80)
name: str = Field(min_length=1, max_length=160)
pricing_model: PricingModel
billing_interval: BillingInterval
currency: str = Field(pattern=r"^[A-Za-z]{3}$")
base_fee: Decimal = Field(ge=0, max_digits=20, decimal_places=4)
included_seats: int = Field(default=0, ge=0)
overage_enabled: bool = False
effective_from: datetime
effective_to: datetime | None = None
contract_terms_json: dict[str, Any] = Field(default_factory=dict)
reason: str = Field(
default="平台管理员创建套餐版本",
min_length=2,
max_length=500,
)
@field_validator("effective_from", "effective_to")
@classmethod
def require_timezone(cls, value: datetime | None) -> datetime | None:
return _require_timezone(value)
@model_validator(mode="after")
def validate_window(self) -> CommercialPlanCreate:
if self.effective_to is not None and self.effective_to <= self.effective_from:
raise ValueError("套餐失效时间必须晚于生效时间。")
return self
class CommercialPlanRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
tenant_id: str
plan_code: str
name: str
pricing_model: PricingModel
billing_interval: BillingInterval
currency: str
base_fee: Decimal
included_seats: int
overage_enabled: bool
status: Literal["draft", "active", "retired"]
effective_from: datetime
effective_to: datetime | None = None
version: int
contract_terms_json: dict[str, Any] = Field(default_factory=dict)
created_by: str
created_at: datetime
updated_at: datetime
class CommercialPlanActivationRead(BaseModel):
plan: CommercialPlanRead
retired_plan_ids: list[str] = Field(default_factory=list)
class CommercialVersionAction(BaseModel):
expected_version: int = Field(ge=1)
reason: str = Field(min_length=2, max_length=500)
class CommercialSubscriptionTransition(BaseModel):
expected_version: int = Field(ge=1)
target_status: Literal["past_due", "suspended", "canceled", "expired"]
reason: str = Field(min_length=2, max_length=500)
class CommercialSubscriptionCreate(BaseModel):
subscription_key: str = Field(min_length=1, max_length=120)
plan_id: str = Field(min_length=1, max_length=36)
status: Literal["trialing", "active"] = "active"
starts_at: datetime
ends_at: datetime | None = None
current_period_start: datetime
current_period_end: datetime
seats: int = Field(ge=1)
auto_renew: bool = False
external_provider: str | None = Field(default=None, max_length=60)
external_subscription_id: str | None = Field(default=None, max_length=160)
metadata_json: dict[str, Any] = Field(default_factory=dict)
reason: str = Field(
default="平台管理员创建订阅",
min_length=2,
max_length=500,
)
@field_validator(
"starts_at",
"ends_at",
"current_period_start",
"current_period_end",
)
@classmethod
def require_timezone(cls, value: datetime | None) -> datetime | None:
return _require_timezone(value)
@model_validator(mode="after")
def validate_windows_and_provider(self) -> CommercialSubscriptionCreate:
if self.current_period_end <= self.current_period_start:
raise ValueError("当前订阅周期结束时间必须晚于开始时间。")
if self.ends_at is not None and self.ends_at <= self.starts_at:
raise ValueError("订阅结束时间必须晚于开始时间。")
if bool(self.external_provider) != bool(self.external_subscription_id):
raise ValueError("外部订阅提供商和订阅编号必须同时填写或同时留空。")
return self
class CommercialSubscriptionRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
tenant_id: str
subscription_key: str
plan_id: str
status: SubscriptionStatus
starts_at: datetime
ends_at: datetime | None = None
current_period_start: datetime
current_period_end: datetime
seats: int
base_fee_snapshot: Decimal
currency: str
billing_interval: BillingInterval
auto_renew: bool
external_provider: str | None = None
external_subscription_id: str | None = None
canceled_at: datetime | None = None
version: int
metadata_json: dict[str, Any] = Field(default_factory=dict)
created_by: str
created_at: datetime
updated_at: datetime
class CommercialEntitlementUpsert(BaseModel):
subscription_id: str = Field(min_length=1, max_length=36)
entitlement_key: str = Field(min_length=1, max_length=120)
metric_key: str = Field(min_length=1, max_length=120)
entitlement_type: EntitlementType
unit: str = Field(min_length=1, max_length=40)
included_quantity: Decimal | None = Field(default=None, ge=0)
hard_limit_quantity: Decimal | None = Field(default=None, ge=0)
reset_interval: ResetInterval
overage_policy: OveragePolicy
status: EntitlementStatus = "active"
effective_from: datetime
effective_to: datetime | None = None
config_json: dict[str, Any] = Field(default_factory=dict)
reason: str = Field(
default="平台管理员维护商业权益",
min_length=2,
max_length=500,
)
@field_validator("effective_from", "effective_to")
@classmethod
def require_timezone(cls, value: datetime | None) -> datetime | None:
return _require_timezone(value)
@model_validator(mode="after")
def validate_shape(self) -> CommercialEntitlementUpsert:
if self.effective_to is not None and self.effective_to <= self.effective_from:
raise ValueError("权益失效时间必须晚于生效时间。")
if self.entitlement_type == "unlimited":
if self.included_quantity is not None or self.hard_limit_quantity is not None:
raise ValueError("无限权益不能配置包含量或硬配额。")
elif self.entitlement_type == "feature":
if self.included_quantity not in {Decimal("0"), Decimal("1")}:
raise ValueError("功能权益包含量只能是 0 或 1。")
if self.hard_limit_quantity not in {None, Decimal("0"), Decimal("1")}:
raise ValueError("功能权益硬配额只能是 0 或 1。")
else:
if self.included_quantity is None:
raise ValueError("计量权益必须配置包含量。")
if (
self.hard_limit_quantity is not None
and self.hard_limit_quantity < self.included_quantity
):
raise ValueError("硬配额不能小于包含量。")
return self
class CommercialEntitlementRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
tenant_id: str
subscription_id: str
entitlement_key: str
metric_key: str
entitlement_type: EntitlementType
unit: str
included_quantity: Decimal | None = None
hard_limit_quantity: Decimal | None = None
reset_interval: ResetInterval
overage_policy: OveragePolicy
status: EntitlementStatus
effective_from: datetime
effective_to: datetime | None = None
version: int
config_json: dict[str, Any] = Field(default_factory=dict)
created_at: datetime
updated_at: datetime
class UsageMeterEventCreate(BaseModel):
subscription_id: str = Field(min_length=1, max_length=36)
entitlement_id: str = Field(min_length=1, max_length=36)
event_type: Literal["usage", "credit", "adjustment", "reversal"] = "usage"
quantity: Decimal = Field(max_digits=20, decimal_places=6)
occurred_at: datetime
source_system: str = Field(min_length=1, max_length=80)
idempotency_key: str = Field(min_length=1, max_length=160)
reversal_of_event_id: str | None = Field(default=None, max_length=36)
subject_type: str | None = Field(default=None, max_length=60)
subject_id: str | None = Field(default=None, max_length=160)
correlation_id: str | None = Field(default=None, max_length=120)
trace_id: str | None = Field(default=None, max_length=120)
metadata_json: dict[str, Any] = Field(default_factory=dict)
@field_validator("occurred_at")
@classmethod
def require_timezone(cls, value: datetime) -> datetime:
return _require_timezone(value)
@model_validator(mode="after")
def validate_usage_event(self) -> UsageMeterEventCreate:
if self.event_type == "usage" and self.quantity <= 0:
raise ValueError("usage 事件数量必须大于 0。")
if self.event_type == "credit" and self.quantity >= 0:
raise ValueError("credit 事件数量必须小于 0。")
if self.event_type in {"adjustment", "reversal"} and self.quantity == 0:
raise ValueError("adjustment/reversal 事件数量不能为 0。")
if (self.event_type == "reversal") != bool(self.reversal_of_event_id):
raise ValueError("只有 reversal 事件必须且只能填写被冲回事件。")
if bool(self.subject_type) != bool(self.subject_id):
raise ValueError("计量主体类型和编号必须同时填写或同时留空。")
return self
class UsageMeterEventRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
tenant_id: str
subscription_id: str
entitlement_id: str
billing_period_id: str
event_type: Literal["usage", "credit", "adjustment", "reversal"]
metric_key: str
quantity: Decimal
unit: str
period_key: str
quota_period_key: str
occurred_at: datetime
source_system: str
idempotency_key: str
request_fingerprint: str
reversal_of_event_id: str | None = None
subject_type: str | None = None
subject_id: str | None = None
actor_type: Literal["system", "user", "integration", "admin"]
actor_id: str
correlation_id: str | None = None
trace_id: str | None = None
metadata_json: dict[str, Any] = Field(default_factory=dict)
recorded_at: datetime
class CommercialCostEventCreate(BaseModel):
subscription_id: str | None = Field(default=None, max_length=36)
usage_event_id: str | None = Field(default=None, max_length=36)
event_type: Literal["incurred", "credit", "adjustment", "reversal"] = "incurred"
cost_category: Literal[
"ai_inference",
"ocr",
"storage",
"connector",
"support",
"implementation",
"infrastructure",
"payment",
"other",
]
quantity: Decimal = Field(gt=0, max_digits=20, decimal_places=6)
unit: str = Field(min_length=1, max_length=40)
unit_cost: Decimal = Field(ge=0, max_digits=20, decimal_places=8)
original_currency: str = Field(pattern=r"^[A-Za-z]{3}$")
reporting_currency: str = Field(pattern=r"^[A-Za-z]{3}$")
fx_rate: Decimal = Field(gt=0, max_digits=20, decimal_places=8)
provider: str | None = Field(default=None, max_length=120)
sku: str | None = Field(default=None, max_length=120)
model_name: str | None = Field(default=None, max_length=120)
allocation_key: str = Field(min_length=1, max_length=160)
occurred_at: datetime
source_system: str = Field(min_length=1, max_length=80)
idempotency_key: str = Field(min_length=1, max_length=160)
reversal_of_cost_event_id: str | None = Field(default=None, max_length=36)
correlation_id: str | None = Field(default=None, max_length=120)
trace_id: str | None = Field(default=None, max_length=120)
metadata_json: dict[str, Any] = Field(default_factory=dict)
@field_validator("occurred_at")
@classmethod
def require_timezone(cls, value: datetime) -> datetime:
return _require_timezone(value)
@model_validator(mode="after")
def validate_cost_event(self) -> CommercialCostEventCreate:
if (self.event_type == "reversal") != bool(self.reversal_of_cost_event_id):
raise ValueError("只有 reversal 成本事件必须且只能填写被冲回事件。")
if self.usage_event_id and not self.subscription_id:
raise ValueError("关联用量事件时必须填写订阅编号。")
return self
class CommercialCostEventRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
tenant_id: str
subscription_id: str | None = None
billing_period_id: str | None = None
usage_event_id: str | None = None
event_type: Literal["incurred", "credit", "adjustment", "reversal"]
cost_category: str
quantity: Decimal
unit: str
unit_cost: Decimal
cost_amount: Decimal
original_currency: str
reporting_amount: Decimal
reporting_currency: str
fx_rate: Decimal
provider: str | None = None
sku: str | None = None
model_name: str | None = None
allocation_key: str
occurred_at: datetime
source_system: str
idempotency_key: str
request_fingerprint: str
reversal_of_cost_event_id: str | None = None
correlation_id: str | None = None
trace_id: str | None = None
metadata_json: dict[str, Any] = Field(default_factory=dict)
recorded_at: datetime
class CommercialMutationRead(BaseModel):
created: bool
usage_event: UsageMeterEventRead | None = None
cost_event: CommercialCostEventRead | None = None
class CommercialQuotaRead(BaseModel):
entitlement: CommercialEntitlementRead
billing_period_id: str
period_key: str
quota_period_key: str
used_quantity: Decimal
reserved_quantity: Decimal = Decimal("0")
included_remaining: Decimal | None = None
hard_limit_remaining: Decimal | None = None
overage_quantity: Decimal
status: Literal["available", "approaching", "exhausted", "unlimited", "inactive"]
commercially_allowed: bool
reason: str
class CommercialAccountRead(BaseModel):
tenant_id: str
as_of: datetime
data_status: Literal["available", "partial", "unavailable"]
plan: CommercialPlanRead | None = None
subscription: CommercialSubscriptionRead | None = None
quotas: list[CommercialQuotaRead] = Field(default_factory=list)
notes: list[str] = Field(default_factory=list)
class EntitlementGateRead(BaseModel):
tenant_id: str
entitlement_key: str
requested_quantity: Decimal
commercial_allowed: bool
security_decision: Literal["allow", "deny", "human_review"]
final_allowed: bool
reason: str
quota: CommercialQuotaRead | None = None
class CommercialMoneyRead(BaseModel):
currency: str
amount: Decimal
basis: str
class CommercialRatioRead(BaseModel):
currency: str
ratio: Decimal
numerator: Decimal
denominator: Decimal
class CommercialMetricRead(BaseModel):
key: str
label: str
status: Literal["available", "partial", "unavailable"]
values: list[CommercialMoneyRead] = Field(default_factory=list)
ratios: list[CommercialRatioRead] = Field(default_factory=list)
reason: str
required_inputs: list[str] = Field(default_factory=list)
notes: list[str] = Field(default_factory=list)
class CommercialAnalyticsRead(BaseModel):
tenant_id: str
start: datetime
end: datetime
as_of: datetime
generated_at: datetime
customer_charges: CommercialMetricRead
internal_costs: CommercialMetricRead
contribution_margin: CommercialMetricRead
verified_cash_savings: CommercialMetricRead
customer_roi: CommercialMetricRead
customer_labor_value: CommercialMetricRead
data_quality_status: Literal["complete", "partial", "unavailable"]
data_quality_issues: list[str] = Field(default_factory=list)
class CommercialPricingScenarioWrite(BaseModel):
start: datetime
end: datetime
as_of: datetime
target_contribution_margin_rate: Decimal = Field(
default=Decimal("0.65"),
ge=0,
lt=Decimal("0.95"),
decimal_places=6,
)
max_verified_savings_share: Decimal = Field(
default=Decimal("0.25"),
gt=0,
le=1,
decimal_places=6,
)
@field_validator("start", "end", "as_of")
@classmethod
def require_timezone(cls, value: datetime) -> datetime:
return _require_timezone(value)
@model_validator(mode="after")
def validate_window(self) -> CommercialPricingScenarioWrite:
if self.end <= self.start:
raise ValueError("定价分析结束时间必须晚于开始时间。")
if self.as_of < self.start:
raise ValueError("定价分析 as_of 不能早于分析开始时间。")
return self
class CommercialPricingCurrencyScenarioRead(BaseModel):
currency: str
status: Literal["feasible", "insufficient_value", "cost_only", "unavailable"]
internal_cost: Decimal | None = None
verified_cash_savings: Decimal | None = None
minimum_sustainable_charge: Decimal | None = None
maximum_value_aligned_charge: Decimal | None = None
maximum_success_fee: Decimal | None = None
customer_roi_at_minimum_charge: Decimal | None = None
contribution_margin_at_value_ceiling: Decimal | None = None
reason: str
class CommercialPricingScenarioRead(BaseModel):
tenant_id: str
start: datetime
end: datetime
as_of: datetime
target_contribution_margin_rate: Decimal
max_verified_savings_share: Decimal
recommended_model: Literal[
"hybrid",
"subscription",
"pilot_collecting",
"optimize_unit_economics",
]
scenarios: list[CommercialPricingCurrencyScenarioRead] = Field(default_factory=list)
evidence_status: Literal["complete", "partial", "unavailable"]
notes: list[str] = Field(default_factory=list)
def _require_timezone(value: datetime | None) -> datetime | None:
if value is not None and (value.tzinfo is None or value.utcoffset() is None):
raise ValueError("商业事实时间必须显式包含时区。")
return value

View File

@@ -0,0 +1,117 @@
from __future__ import annotations
from datetime import datetime
from decimal import Decimal
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field
class CommercialBillingPeriodRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
tenant_id: str
subscription_id: str
plan_id: str
period_sequence: int
period_key: str
status: Literal["issued"]
temporal_state: Literal["upcoming", "current", "elapsed"]
period_start: datetime
period_end: datetime
subscription_status_snapshot: Literal[
"trialing",
"active",
"past_due",
"suspended",
"canceled",
"expired",
]
plan_code_snapshot: str
plan_version_snapshot: int
pricing_model_snapshot: Literal[
"subscription",
"usage",
"hybrid",
"pilot",
"custom",
]
billing_interval: Literal["monthly", "quarterly", "annual", "contract"]
currency: str
base_fee_snapshot: Decimal
seats_snapshot: int
source: Literal["subscription_created", "auto_renew", "migration_backfill"]
idempotency_key: str
created_by: str
created_at: datetime
class CommercialBillingPeriodTenantRead(BaseModel):
"""租户可见账期,不暴露平台操作人和内部幂等键。"""
model_config = ConfigDict(from_attributes=True)
id: str
tenant_id: str
subscription_id: str
plan_id: str
period_sequence: int
period_key: str
status: Literal["issued"]
temporal_state: Literal["upcoming", "current", "elapsed"]
period_start: datetime
period_end: datetime
subscription_status_snapshot: str
plan_code_snapshot: str
plan_version_snapshot: int
pricing_model_snapshot: str
billing_interval: str
currency: str
base_fee_snapshot: Decimal
seats_snapshot: int
source: str
created_at: datetime
class CommercialAdminEventRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
tenant_id: str
actor_type: Literal["user", "system", "migration"]
actor_id: str
request_id: str
reason: str
action: Literal[
"plan_created",
"plan_activated",
"plan_retired",
"subscription_created",
"subscription_activated",
"subscription_transitioned",
"entitlement_created",
"entitlement_updated",
"entitlement_activated",
"billing_period_created",
"subscription_rolled_over",
"legacy_state_imported",
]
resource_type: Literal["plan", "subscription", "entitlement", "billing_period"]
resource_id: str
resource_version: int
before_json: dict[str, Any] = Field(default_factory=dict)
after_json: dict[str, Any] = Field(default_factory=dict)
occurred_at: datetime
class CommercialRolloverRead(BaseModel):
tenant_id: str
subscription_id: str
status: Literal["rolled_over", "not_due", "ineligible", "replayed"]
reason_code: str
reason: str
created_period_ids: list[str] = Field(default_factory=list)
current_period_start: datetime
current_period_end: datetime
subscription_version: int

View File

@@ -0,0 +1,19 @@
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, Field
class FinanceReportConfigUpdate(BaseModel):
recipients: list[str] = Field(default_factory=list, max_length=50)
delivery_enabled: bool = False
class FinanceReportConfigRead(BaseModel):
tenant_id: str
status: str
delivery_enabled: bool
recipients: list[str] = Field(default_factory=list)
updated_by: str = ""
updated_at: datetime | None = None

View File

@@ -0,0 +1,381 @@
from __future__ import annotations
from datetime import datetime
from decimal import Decimal, InvalidOperation
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
FinancialEventType = Literal[
"payment_settled",
"payment_failed",
"erp_posted",
"erp_posting_failed",
"payment_refunded",
"payment_reversed",
]
FinancialConnectorSimulationScenario = Literal[
"success",
"failure",
"out_of_order",
"duplicate",
"conflict",
"refund",
"erp_receipt",
]
class FinancialConnectorConfigCreate(BaseModel):
provider: str = Field(min_length=1, max_length=80)
environment: Literal["test", "mock", "staging", "production"]
key_version: str = Field(min_length=1, max_length=40)
secret_ref: str = Field(min_length=1, max_length=180)
allowed_event_types: list[FinancialEventType] = Field(min_length=1)
clock_skew_seconds: int = Field(default=300, ge=30, le=900)
status: Literal["disabled"] = "disabled"
request_id: str = Field(min_length=8, max_length=120)
reason: str = Field(min_length=4, max_length=1000)
@field_validator(
"provider",
"key_version",
"secret_ref",
"request_id",
"reason",
mode="before",
)
@classmethod
def normalize_text(cls, value: Any) -> str:
return str(value or "").strip()
class FinancialConnectorConfigRead(BaseModel):
"""刻意不包含 secret_ref 或任何 HMAC 材料。"""
model_config = ConfigDict(from_attributes=True)
id: str
tenant_id: str
provider: str
environment: Literal["test", "mock", "staging", "production"]
key_version: str
allowed_event_types_json: list[str] = Field(default_factory=list)
clock_skew_seconds: int
status: Literal["active", "disabled", "rotating"]
version: int
last_success_at: datetime | None = None
last_error_at: datetime | None = None
last_error_code: str | None = None
created_by: str
created_at: datetime
updated_at: datetime
class FinancialConnectorConfigLifecycleAction(BaseModel):
expected_version: int = Field(ge=1)
request_id: str = Field(min_length=8, max_length=120)
reason: str = Field(min_length=4, max_length=1000)
@field_validator("request_id", "reason", mode="before")
@classmethod
def normalize_lifecycle_text(cls, value: Any) -> str:
return str(value or "").strip()
class FinancialConnectorConfigRotateAction(FinancialConnectorConfigLifecycleAction):
new_key_version: str = Field(min_length=1, max_length=40)
new_secret_ref: str = Field(min_length=1, max_length=180)
@field_validator("new_key_version", "new_secret_ref", mode="before")
@classmethod
def normalize_rotation_text(cls, value: Any) -> str:
return str(value or "").strip()
class FinancialConnectorConfigRotationRead(BaseModel):
previous: FinancialConnectorConfigRead
replacement: FinancialConnectorConfigRead
class FinancialConnectorConfigEventRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
tenant_id: str
config_id: str
action: Literal[
"created",
"activated",
"disabled",
"rotation_started",
"rotation_replacement_created",
]
actor_id: str
request_id: str
reason: str
expected_version: int | None = None
before_json: dict[str, Any] = Field(default_factory=dict)
after_json: dict[str, Any] = Field(default_factory=dict)
occurred_at: datetime
class FinancialEventEnvelope(BaseModel):
model_config = ConfigDict(extra="forbid")
tenant_id: str = Field(min_length=1, max_length=64)
external_event_id: str = Field(min_length=1, max_length=160)
event_type: FinancialEventType
occurred_at: datetime
correlation_id: str = Field(min_length=1, max_length=64)
payload: dict[str, Any]
@field_validator("tenant_id", "external_event_id", "correlation_id", mode="before")
@classmethod
def normalize_text(cls, value: Any) -> str:
return str(value or "").strip()
@model_validator(mode="after")
def validate_payload_shape(self) -> FinancialEventEnvelope:
allowed = {
"claim_id",
"claim_reference",
"amount",
"currency",
"external_payment_reference",
"origin_external_event_id",
"erp_document_number",
"accounting_period",
"failure_code",
}
unknown = sorted(str(key) for key in self.payload if key not in allowed)
if unknown:
raise ValueError(f"财务事件 payload 包含未允许字段:{', '.join(unknown)}")
required = {"claim_id", "claim_reference", "amount", "currency"}
if self.event_type in {"payment_settled", "payment_failed"}:
required.add("external_payment_reference")
if self.event_type in {"payment_refunded", "payment_reversed"}:
required.update({"external_payment_reference", "origin_external_event_id"})
if self.event_type in {"erp_posted", "erp_posting_failed"}:
required.add("origin_external_event_id")
if self.event_type == "erp_posted":
required.add("erp_document_number")
missing = sorted(key for key in required if not str(self.payload.get(key) or "").strip())
if missing:
raise ValueError(f"财务事件 payload 缺少字段:{', '.join(missing)}")
currency = str(self.payload.get("currency") or "").strip().upper()
if len(currency) != 3 or not currency.isalpha():
raise ValueError("财务事件币种必须是三位字母代码。")
try:
amount = Decimal(str(self.payload.get("amount")))
except (InvalidOperation, TypeError, ValueError) as error:
raise ValueError("财务事件金额无效。") from error
if not amount.is_finite() or amount < 0 or amount.as_tuple().exponent < -4:
raise ValueError("财务事件金额必须是非负且最多四位小数。")
if self.occurred_at.tzinfo is None:
raise ValueError("财务事件发生时间必须包含时区。")
return self
class FinancialEventIngestionRead(BaseModel):
accepted: bool = True
replayed: bool = False
event_id: str
external_event_id: str
processing_status: Literal["processed", "exception", "pending"]
reconciliation_case_id: str | None = None
reconciliation_status: str | None = None
claim_status: str | None = None
verification_level: Literal["simulated", "staging_verified", "production_verified"]
evidence_classification: Literal["simulated_connector", "staging_connector", "external_cash"]
projection_scope: Literal[
"simulation_only",
"canonical",
"legacy_nonproduction_effect_unknown",
]
error_code: str | None = None
class FinancialConnectorSimulationCreate(BaseModel):
claim_id: str = Field(min_length=1, max_length=36)
scenario: FinancialConnectorSimulationScenario
request_id: str = Field(min_length=8, max_length=120)
@field_validator("claim_id", "request_id", mode="before")
@classmethod
def normalize_simulation_text(cls, value: Any) -> str:
return str(value or "").strip()
class FinancialConnectorSimulationStepRead(BaseModel):
name: str
event_type: FinancialEventType
outcome: Literal["accepted", "replayed", "expected_exception", "conflict"]
event_id: str | None = None
processing_status: Literal["processed", "exception", "pending"] | None = None
error_code: str | None = None
class FinancialConnectorSimulationRead(BaseModel):
tenant_id: str
config_id: str
provider: str
environment: Literal["test", "mock", "staging"]
scenario: FinancialConnectorSimulationScenario
request_fingerprint: str
evidence_classification: Literal["simulated_connector", "staging_connector"]
projection_scope: Literal["simulation_only"] = "simulation_only"
core_side_effects_allowed: Literal[False] = False
steps: list[FinancialConnectorSimulationStepRead] = Field(default_factory=list)
class FinancialConnectorMetricAvailabilityRead(BaseModel):
status: Literal["available", "unavailable"]
source: str
reason: str | None = None
class FinancialConnectorObservabilityItemRead(BaseModel):
config_id: str
provider: str
environment: Literal["test", "mock", "staging", "production"]
key_version: str
status: Literal["active", "disabled", "rotating"]
evidence_classification: Literal[
"simulated_connector",
"staging_connector",
"external_cash",
]
evidence_label: str
last_success_at: datetime | None = None
last_error_at: datetime | None = None
last_error_code: str | None = None
event_count: int = 0
processed_event_count: int = 0
failed_event_count: int = 0
failure_rate: float = Field(default=0.0, ge=0.0, le=1.0)
backlog_count: int = 0
reconciliation_anomaly_count: int = 0
retry_count: int | None = 0
auth_failure_count: int = 0
signature_failure_count: int | None = 0
payload_conflict_count: int = 0
latest_replay_at: datetime | None = None
latest_auth_failure_at: datetime | None = None
latest_signature_failure_at: datetime | None = None
latest_payload_conflict_at: datetime | None = None
class FinancialConnectorObservabilitySummaryRead(BaseModel):
connector_count: int = 0
active_connector_count: int = 0
event_count: int = 0
processed_event_count: int = 0
failed_event_count: int = 0
failure_rate: float = Field(default=0.0, ge=0.0, le=1.0)
backlog_count: int = 0
reconciliation_anomaly_count: int = 0
retry_count: int | None = 0
auth_failure_count: int = 0
signature_failure_count: int | None = 0
payload_conflict_count: int = 0
latest_replay_at: datetime | None = None
latest_auth_failure_at: datetime | None = None
latest_signature_failure_at: datetime | None = None
latest_payload_conflict_at: datetime | None = None
class FinancialConnectorObservabilityRead(BaseModel):
tenant_id: str
window_hours: int
window_started_at: datetime
as_of: datetime
generated_at: datetime
source_revision: str
summary: FinancialConnectorObservabilitySummaryRead
retry_metric: FinancialConnectorMetricAvailabilityRead
auth_failure_metric: FinancialConnectorMetricAvailabilityRead
signature_failure_metric: FinancialConnectorMetricAvailabilityRead
payload_conflict_metric: FinancialConnectorMetricAvailabilityRead
items: list[FinancialConnectorObservabilityItemRead] = Field(default_factory=list)
class FinancialPaymentEvidenceRead(BaseModel):
claim_id: str
claim_status: str
payment_state: Literal["not_paid", "paid"]
evidence_classification: Literal[
"none",
"internal_manual_payment",
"external_cash",
"simulated_connector",
"staging_connector",
]
evidence_label: str
trust_level: Literal["none", "low", "high"]
source_type: Literal["none", "manual_confirmation", "external_connector"]
provider: str | None = None
verification_level: str | None = None
external_reference_tail: str | None = None
recorded_at: datetime | None = None
disclaimer: str
class PaymentReconciliationEventRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
connector_event_id: str
action: str
actor_type: str
actor_id: str
reason: str | None = None
correlation_id: str
occurred_at: datetime
class PaymentReconciliationCaseRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
tenant_id: str
provider: str
claim_id: str
expense_case_id: str | None = None
expected_amount: Decimal
actual_amount: Decimal
amount_difference: Decimal
expected_currency: str
actual_currency: str
expected_reference: str
external_reference_tail: str | None = None
status: str
exception_code: str | None = None
erp_status: str
erp_document_tail: str | None = None
assigned_to: str | None = None
last_connector_event_id: str
version: int
created_at: datetime
updated_at: datetime
class PaymentReconciliationCaseDetailRead(PaymentReconciliationCaseRead):
timeline: list[PaymentReconciliationEventRead] = Field(default_factory=list)
class PaymentReconciliationListRead(BaseModel):
items: list[PaymentReconciliationCaseRead] = Field(default_factory=list)
total: int
page: int
page_size: int
class PaymentReconciliationActionCreate(BaseModel):
expected_version: int = Field(ge=1)
reason: str = Field(min_length=4, max_length=1000)
@field_validator("reason", mode="before")
@classmethod
def normalize_reason(cls, value: Any) -> str:
return str(value or "").strip()

View File

@@ -31,6 +31,8 @@ class KnowledgePreviewPageRead(BaseModel):
class KnowledgeDocumentRead(BaseModel):
id: str
scope: str = "tenant"
readOnly: bool = False
name: str
folder: str
tag: str

View File

@@ -128,14 +128,41 @@ class ExpenseClaimStandardAdjustmentRisk(BaseModel):
item_id: str | None = Field(default=None, max_length=120)
title: str | None = Field(default=None, max_length=120)
risk: str | None = Field(default=None, max_length=500)
application_days: int | None = Field(default=None, ge=1, le=365)
original_amount: Decimal | None = None
reimbursable_amount: Decimal | None = None
application_days: int | None = Field(
default=None,
ge=1,
le=365,
description="旧客户端展示提示;服务端按单据、明细及关联申请重新确定政策天数。",
)
original_amount: Decimal | None = Field(
default=None,
description="旧客户端展示提示;服务端仅使用数据库中的明细原金额。",
)
reimbursable_amount: Decimal | None = Field(
default=None,
description="旧客户端展示提示;服务端仅使用规则中心计算的可报销金额。",
)
class ExpenseClaimStandardAdjustmentPayload(BaseModel):
request_id: str | None = Field(
default=None,
min_length=1,
max_length=120,
description="客户端生成的幂等请求号;相同请求号只能用于同一组明细。",
)
expected_updated_at: datetime | None = Field(
default=None,
description="客户端最后读取到的单据更新时间,用于拒绝过期页面提交。",
)
risks: list[ExpenseClaimStandardAdjustmentRisk] = Field(default_factory=list, max_length=20)
@field_validator("request_id")
@classmethod
def normalize_standard_adjustment_request_id(cls, value: str | None) -> str | None:
normalized = str(value or "").strip()
return normalized or None
class ExpenseClaimPreReviewRemediationRead(BaseModel):
action: str

View File

@@ -0,0 +1,335 @@
from __future__ import annotations
from datetime import date, datetime
from decimal import Decimal
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
SavingsValueType = Literal["cash", "labor"]
SavingsOpportunityStatus = Literal[
"identified",
"accepted",
"in_progress",
"realized",
"verified",
"reversed",
"rejected",
"expired",
]
SavingsOpportunityAction = Literal["accept", "start", "reject", "expire"]
SavingsOpportunityAvailableAction = Literal[
"accept",
"start",
"reject",
"expire",
"record_realization",
]
SavingsRealizationAction = Literal["confirm", "reject", "reverse"]
class ProfileBaselineSnapshotRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
tenant_id: str
baseline_key: str
baseline_type: Literal["historical_cohort", "policy_counterfactual", "manual"]
dimension_type: str
dimension_id: str
metric_key: str
unit: str
original_currency: str | None = None
baseline_value: Decimal
window_start: datetime | None = None
window_end: datetime | None = None
sample_count: int
method: str
query_fingerprint: str
data_quality_status: Literal["complete", "partial", "insufficient", "invalid"]
data_quality_score: Decimal
quality_issues_json: list[dict[str, Any]] = Field(default_factory=list)
algorithm_version: str
policy_version: str | None = None
policy_effective_from: date | None = None
policy_effective_to: date | None = None
target_resource_type: str | None = None
target_resource_id: str | None = None
frozen_at: datetime
frozen_by: str
valid_until: datetime | None = None
version: int
created_at: datetime
class SavingsEvidenceLinkRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
tenant_id: str
evidence_key: str
entity_type: Literal["baseline", "opportunity", "realization"]
entity_id: str
evidence_role: str
resource_type: str
resource_id: str
source_system: str
external_event_id: str | None = None
content_hash: str
occurred_at: datetime
collected_at: datetime
verification_status: Literal["unverified", "verified", "rejected", "unavailable"]
verified_by: str | None = None
verified_at: datetime | None = None
metadata_json: dict[str, Any] = Field(default_factory=dict)
created_at: datetime
class SavingsEventRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
tenant_id: str
aggregate_type: Literal["baseline", "opportunity", "realization"]
aggregate_id: str
action: str
actor_id: str
actor_name: str
actor_type: Literal["user", "system", "agent", "service"]
request_id: str
expected_version: int
result_version: int
payload_fingerprint: str
payload_json: dict[str, Any] = Field(default_factory=dict)
before_json: dict[str, Any] = Field(default_factory=dict)
after_json: dict[str, Any] = Field(default_factory=dict)
response_json: dict[str, Any] = Field(default_factory=dict)
correlation_id: str | None = None
causation_id: str | None = None
occurred_at: datetime
class SavingsRealizationRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
tenant_id: str
realization_key: str
opportunity_id: str
expense_case_id: str
claim_id: str
claim_item_id: str | None = None
business_event_id: str | None = None
realization_type: Literal["actual", "reversal"]
reversal_of_realization_id: str | None = None
realized_at: datetime
recorded_by_id: str
recorded_by_name: str
actual_gross: Decimal
incremental_cost: Decimal
actual_net: Decimal
original_currency: str
reporting_amount: Decimal
reporting_currency: str
fx_rate: Decimal
fx_source: str
fx_date: date
fx_version: str
attribution_method: str
attribution_ratio: Decimal
benefit_key: str
dedupe_status: Literal["pending_review", "canonical", "duplicate", "excluded"]
canonical_realization_id: str | None = None
status: Literal["pending_confirmation", "finance_confirmed", "rejected", "reversed"]
finance_confirmer_id: str | None = None
finance_confirmer_name: str | None = None
confirmed_at: datetime | None = None
confirmation_note: str | None = None
rejected_by_id: str | None = None
rejected_by_name: str | None = None
rejected_at: datetime | None = None
rejection_reason: str | None = None
reversed_by_id: str | None = None
reversed_by_name: str | None = None
reversed_at: datetime | None = None
reversal_reason: str | None = None
baseline_snapshot_json: dict[str, Any] = Field(default_factory=dict)
final_snapshot_json: dict[str, Any] = Field(default_factory=dict)
evidence_json: list[dict[str, Any]] = Field(default_factory=list)
version: int
created_at: datetime
updated_at: datetime
available_actions: list[SavingsRealizationAction] = Field(default_factory=list)
read_only_reason: str = ""
class SavingsOpportunityRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
tenant_id: str
opportunity_key: str
expense_case_id: str
claim_id: str
claim_item_id: str | None = None
discovery_business_event_id: str | None = None
source_type: str
source_id: str
category: str
claim_no_snapshot: str
title: str
description: str
value_kind: SavingsValueType
exposure_amount: Decimal
baseline_snapshot_id: str
baseline_amount: Decimal
target_amount: Decimal
estimated_gross: Decimal
estimated_cost: Decimal
estimated_net: Decimal
estimated_low: Decimal
estimated_high: Decimal
confidence: Decimal
currency: str
reporting_currency: str
attribution_method: str
ai_decision_id: str | None = None
owner_id: str
owner_name: str
owner_role: str
due_at: datetime | None = None
status: SavingsOpportunityStatus
version: int
benefit_key: str
suggested_action: str
dimension_json: dict[str, Any] = Field(default_factory=dict)
baseline_snapshot_json: dict[str, Any] = Field(default_factory=dict)
evidence_json: list[dict[str, Any]] = Field(default_factory=list)
accepted_at: datetime | None = None
started_at: datetime | None = None
realized_at: datetime | None = None
verified_at: datetime | None = None
closed_at: datetime | None = None
created_at: datetime
updated_at: datetime
available_actions: list[SavingsOpportunityAvailableAction] = Field(default_factory=list)
read_only_reason: str = ""
baseline: ProfileBaselineSnapshotRead | None = None
realizations: list[SavingsRealizationRead] = Field(default_factory=list)
evidence: list[SavingsEvidenceLinkRead] = Field(default_factory=list)
events: list[SavingsEventRead] = Field(default_factory=list)
class SavingsOpportunityListRead(BaseModel):
items: list[SavingsOpportunityRead] = Field(default_factory=list)
total: int = 0
page: int = 1
page_size: int = 20
total_pages: int = 0
generated_at: datetime
class SavingsActionBase(BaseModel):
request_id: str = Field(min_length=8, max_length=120)
expected_version: int = Field(ge=1)
comment: str = Field(min_length=2, max_length=1000)
@field_validator("request_id", "comment", mode="before")
@classmethod
def normalize_text(cls, value: Any) -> str:
return str(value or "").strip()
class SavingsOpportunityActionCreate(SavingsActionBase):
action: SavingsOpportunityAction
class SavingsEvidenceCreate(BaseModel):
evidence_key: str = Field(min_length=1, max_length=160)
evidence_role: str = Field(min_length=1, max_length=50)
resource_type: str = Field(min_length=1, max_length=50)
resource_id: str = Field(min_length=1, max_length=160)
source_system: str = Field(min_length=1, max_length=60)
external_event_id: str | None = Field(default=None, max_length=160)
content_hash: str = Field(min_length=16, max_length=80)
occurred_at: datetime
verification_status: Literal["unverified", "unavailable"] = "unverified"
metadata_json: dict[str, Any] = Field(default_factory=dict)
@field_validator(
"evidence_key",
"evidence_role",
"resource_type",
"resource_id",
"source_system",
"external_event_id",
"content_hash",
mode="before",
)
@classmethod
def normalize_optional_text(cls, value: Any) -> Any:
if value is None:
return None
normalized = str(value).strip()
return normalized or None
class SavingsRealizationCreate(SavingsActionBase):
actual_gross: Decimal = Field(gt=0, max_digits=16, decimal_places=2)
incremental_cost: Decimal = Field(
default=Decimal("0.00"),
ge=0,
max_digits=16,
decimal_places=2,
)
currency: str = Field(default="CNY", min_length=3, max_length=10)
realized_at: datetime
attribution_method: str = Field(min_length=2, max_length=80)
attribution_ratio: Decimal = Field(default=Decimal("1.0000"), gt=0, le=1)
evidence_level: Literal["business_state", "external_document", "external_verified"]
evidence: list[SavingsEvidenceCreate] = Field(default_factory=list, max_length=30)
@field_validator("currency", mode="before")
@classmethod
def normalize_currency(cls, value: Any) -> str:
return str(value or "CNY").strip().upper()
@field_validator("attribution_method", mode="before")
@classmethod
def normalize_method(cls, value: Any) -> str:
return str(value or "").strip()
@model_validator(mode="after")
def require_result_evidence(self) -> SavingsRealizationCreate:
if not self.evidence:
raise ValueError("实际节省结果必须至少提供一条可追溯证据。")
if self.incremental_cost > self.actual_gross:
raise ValueError("执行成本不能高于实际毛节省。")
return self
class SavingsRealizationActionCreate(SavingsActionBase):
action: SavingsRealizationAction
reversal_amount: Decimal | None = Field(default=None, gt=0, max_digits=16, decimal_places=2)
evidence: list[SavingsEvidenceCreate] = Field(default_factory=list, max_length=30)
@model_validator(mode="after")
def validate_action_payload(self) -> SavingsRealizationActionCreate:
if self.action == "reverse" and self.reversal_amount is None:
raise ValueError("冲回动作必须提供冲回金额。")
if self.action != "reverse" and self.reversal_amount is not None:
raise ValueError("只有冲回动作可以提供冲回金额。")
return self
class SavingsOpportunityMutationRead(BaseModel):
opportunity: SavingsOpportunityRead
event: SavingsEventRead
replayed: bool = False
class SavingsRealizationMutationRead(BaseModel):
realization: SavingsRealizationRead
opportunity: SavingsOpportunityRead
event: SavingsEventRead
replayed: bool = False

View File

@@ -0,0 +1,163 @@
from __future__ import annotations
from datetime import UTC, datetime
from decimal import Decimal
from typing import Any, Literal
from pydantic import BaseModel, Field, field_validator, model_validator
from app.schemas.savings import ProfileBaselineSnapshotRead
SavingsBaselineDimension = Literal[
"employee",
"department",
"expense_type",
"city",
"project",
"workflow",
"supplier",
]
SavingsInsightType = Literal[
"budget_forecast_variance",
"repeated_small_expense_pattern",
"historical_price_deviation",
"anomaly_driver_attribution",
"policy_simulation_candidate",
]
class SavingsAnalysisQualityIssue(BaseModel):
code: str
message: str
severity: Literal["info", "warning", "error"] = "warning"
dimension_type: str | None = None
dimension_id: str | None = None
metadata: dict[str, Any] = Field(default_factory=dict)
class SavingsWindowRequest(BaseModel):
request_id: str = Field(min_length=8, max_length=120)
window_start: datetime
window_end: datetime
as_of: datetime
@field_validator("request_id", mode="before")
@classmethod
def normalize_request_id(cls, value: Any) -> str:
return str(value or "").strip()
@field_validator("window_start", "window_end", "as_of")
@classmethod
def require_timezone(cls, value: datetime) -> datetime:
if value.tzinfo is None or value.utcoffset() is None:
raise ValueError("分析窗口时间必须包含时区。")
return value.astimezone(UTC)
@model_validator(mode="after")
def validate_window(self) -> SavingsWindowRequest:
if self.window_end < self.window_start:
raise ValueError("分析窗口结束时间不能早于开始时间。")
if self.as_of < self.window_end:
raise ValueError("分析截止时间不能早于窗口结束时间。")
return self
class SavingsBaselineGenerateRequest(SavingsWindowRequest):
dimensions: list[SavingsBaselineDimension] = Field(
default_factory=lambda: [
"employee",
"department",
"expense_type",
"city",
"project",
"workflow",
"supplier",
],
min_length=1,
max_length=7,
)
minimum_complete_samples: int = Field(default=5, ge=2, le=100)
@field_validator("dimensions")
@classmethod
def deduplicate_dimensions(
cls,
value: list[SavingsBaselineDimension],
) -> list[SavingsBaselineDimension]:
return list(dict.fromkeys(value))
class SavingsBaselineGenerationRead(BaseModel):
request_id: str
request_fingerprint: str
tenant_id: str
data_scope: str
snapshots: list[ProfileBaselineSnapshotRead] = Field(default_factory=list)
quality_issues: list[SavingsAnalysisQualityIssue] = Field(default_factory=list)
source_claim_count: int = 0
source_item_count: int = 0
source_workflow_cycle_count: int = 0
replayed: bool = False
generated_at: datetime
class SavingsInsightAnalyzeRequest(SavingsWindowRequest):
small_amount_threshold: Decimal = Field(
default=Decimal("200.00"),
gt=0,
max_digits=16,
decimal_places=2,
)
minimum_repeat_count: int = Field(default=3, ge=3, le=20)
price_deviation_ratio: Decimal = Field(
default=Decimal("1.2500"),
gt=1,
le=10,
max_digits=8,
decimal_places=4,
)
class SavingsInsightEvidenceRead(BaseModel):
evidence_role: str
resource_type: str
resource_id: str
content_hash: str
occurred_at: datetime
metadata: dict[str, Any] = Field(default_factory=dict)
class SavingsInsightCandidateRead(BaseModel):
candidate_key: str
insight_type: SavingsInsightType
title: str
description: str
dimension_json: dict[str, Any] = Field(default_factory=dict)
evidence: list[SavingsInsightEvidenceRead] = Field(default_factory=list)
evidence_sufficient_for_signal: bool
data_quality_status: Literal["complete", "partial", "insufficient"]
quality_issues: list[SavingsAnalysisQualityIssue] = Field(default_factory=list)
exposure_amount: Decimal | None = None
exposure_meaning: str
currency: str | None = None
estimated_savings: Decimal | None = None
monetization_status: Literal[
"eligible",
"withheld_no_counterfactual",
"unavailable",
]
baseline_snapshot_id: str | None = None
class SavingsInsightAnalysisRead(BaseModel):
request_id: str
request_fingerprint: str
tenant_id: str
data_scope: str
candidates: list[SavingsInsightCandidateRead] = Field(default_factory=list)
quality_issues: list[SavingsAnalysisQualityIssue] = Field(default_factory=list)
source_claim_count: int = 0
source_item_count: int = 0
created_opportunity_ids: list[str] = Field(default_factory=list)
monetized_opportunity_count: int = 0
generated_at: datetime

View File

@@ -18,11 +18,13 @@ from app.algorithem.employee_behavior_profile_tags import build_profile_radar, b
from app.models.agent_run import AgentRun
from app.schemas.employee_profile import EmployeeProfileLatestRead, EmployeeProfileRead
from app.services.employee_behavior_profile_helpers import EmployeeBehaviorProfileMetricHelpers
from app.services.finance_report_tenant import require_report_tenant_id
class AccountBehaviorProfileService(EmployeeBehaviorProfileMetricHelpers):
def __init__(self, db: Session) -> None:
def __init__(self, db: Session, *, tenant_id: str = "default") -> None:
self.db = db
self.tenant_id = require_report_tenant_id(tenant_id)
def get_latest_account_profile(
self,
@@ -87,7 +89,10 @@ class AccountBehaviorProfileService(EmployeeBehaviorProfileMetricHelpers):
profiles=[
EmployeeProfileRead(
profile_type=payload["profile_type"],
profile_label=PROFILE_LABELS.get(payload["profile_type"], payload["profile_type"]),
profile_label=PROFILE_LABELS.get(
payload["profile_type"],
payload["profile_type"],
),
score=payload["score"],
level=payload["level"],
level_label=LEVEL_LABELS.get(payload["level"], payload["level"]),
@@ -173,6 +178,11 @@ class AccountBehaviorProfileService(EmployeeBehaviorProfileMetricHelpers):
stmt = (
select(AgentRun)
.options(selectinload(AgentRun.tool_calls))
.where(AgentRun.started_at >= cutoff, AgentRun.user_id.in_(normalized))
.where(
AgentRun.route_json["tenant_id"].as_string() == self.tenant_id,
AgentRun.ontology_json["tenant_id"].as_string() == self.tenant_id,
AgentRun.started_at >= cutoff,
AgentRun.user_id.in_(normalized),
)
)
return list(self.db.scalars(stmt).all())

View File

@@ -0,0 +1,93 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from sqlalchemy import and_, or_, select
from app.api.deps import CurrentUserContext
from app.core.agent_asset_scope import (
AGENT_ASSET_PLATFORM_SCOPE,
AGENT_ASSET_PLATFORM_TENANT_ID,
AGENT_ASSET_TENANT_SCOPE,
)
def stable_user_principal(current_user: CurrentUserContext) -> str:
"""返回不可由显示名变化影响的审计主体。"""
employee_id = str(current_user.employee_id or "").strip()
if employee_id:
return f"employee:{employee_id}"
username = str(current_user.username or "").strip().casefold()
if username:
return f"username:{username}"
raise PermissionError("当前登录用户缺少稳定身份标识。")
@dataclass(frozen=True, slots=True)
class AgentAssetAccessScope:
tenant_id: str
is_platform_admin: bool = False
@classmethod
def from_user(cls, current_user: CurrentUserContext) -> AgentAssetAccessScope:
tenant_id = str(current_user.tenant_id or "").strip()
if not tenant_id or tenant_id == AGENT_ASSET_PLATFORM_TENANT_ID:
raise PermissionError("当前登录会话缺少有效租户。")
return cls(tenant_id=tenant_id, is_platform_admin=bool(current_user.is_admin))
def visibility_clause(self, model: Any) -> Any:
return or_(
and_(
model.scope == AGENT_ASSET_PLATFORM_SCOPE,
model.tenant_id == AGENT_ASSET_PLATFORM_TENANT_ID,
),
and_(
model.scope == AGENT_ASSET_TENANT_SCOPE,
model.tenant_id == self.tenant_id,
),
)
def can_write(self, resource: Any) -> bool:
scope = str(getattr(resource, "scope", "") or "").strip()
tenant_id = str(getattr(resource, "tenant_id", "") or "").strip()
if scope == AGENT_ASSET_PLATFORM_SCOPE:
return bool(
self.is_platform_admin and tenant_id == AGENT_ASSET_PLATFORM_TENANT_ID
)
return scope == AGENT_ASSET_TENANT_SCOPE and tenant_id == self.tenant_id
def require_write(self, resource: Any) -> None:
scope = str(getattr(resource, "scope", "") or "").strip()
tenant_id = str(getattr(resource, "tenant_id", "") or "").strip()
if scope == AGENT_ASSET_PLATFORM_SCOPE:
if tenant_id != AGENT_ASSET_PLATFORM_TENANT_ID:
raise LookupError("Asset not found")
if not self.is_platform_admin:
raise PermissionError("只有平台管理员可以修改平台资产。")
return
if scope != AGENT_ASSET_TENANT_SCOPE or tenant_id != self.tenant_id:
raise LookupError("Asset not found")
def tenant_resource_identity(tenant_id: str) -> tuple[str, str]:
normalized = str(tenant_id or "").strip()
if not normalized or normalized == AGENT_ASSET_PLATFORM_TENANT_ID:
raise ValueError("tenant_id 必须是有效的企业租户。")
return normalized, AGENT_ASSET_TENANT_SCOPE
def platform_resource_identity() -> tuple[str, str]:
return AGENT_ASSET_PLATFORM_TENANT_ID, AGENT_ASSET_PLATFORM_SCOPE
def platform_asset_statement():
"""平台初始化任务只能查询平台资产,不能按 code 命中租户覆盖项。"""
from app.models.agent_asset import AgentAsset
return select(AgentAsset).where(
AgentAsset.scope == AGENT_ASSET_PLATFORM_SCOPE,
AgentAsset.tenant_id == AGENT_ASSET_PLATFORM_TENANT_ID,
)

View File

@@ -4,8 +4,6 @@ from dataclasses import dataclass
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from urllib.parse import quote
from urllib.request import Request, urlopen
import jwt
@@ -13,6 +11,14 @@ from app.api.deps import CurrentUserContext
from app.core.config import get_settings
from app.models.agent_asset import AgentAsset
from app.schemas.agent_asset import AgentAssetOnlyOfficeConfigRead, AgentAssetRead
from app.services.agent_asset_access import stable_user_principal
from app.services.agent_asset_onlyoffice_security import (
AGENT_ASSET_ONLYOFFICE_CALLBACK_SCOPE,
AGENT_ASSET_ONLYOFFICE_CONTENT_SCOPE,
AgentAssetOnlyOfficeSecurityError,
AgentAssetOnlyOfficeSessionService,
AgentAssetOnlyOfficeValidatedSession,
)
from app.services.agent_asset_spreadsheet import (
COMPANY_TRAVEL_EXPENSE_RULE_FILENAME,
FINANCE_RULES_LIBRARY,
@@ -20,6 +26,7 @@ from app.services.agent_asset_spreadsheet import (
AgentAssetSpreadsheetManager,
RuleSpreadsheetMeta,
)
from app.services.knowledge_onlyoffice_security import download_onlyoffice_document
from app.services.settings import resolve_onlyoffice_settings
PREVIEW_RULE_ASSET_ID = "preview-rule-expense-company-travel-expense"
@@ -35,7 +42,7 @@ PREVIEW_RULE_VERSION_FILENAMES = {
class OnlyOfficeCallbackPayload:
status: int
download_url: str
users: list[str]
document_key: str
class AgentAssetOnlyOfficeMixin:
@@ -55,16 +62,27 @@ class AgentAssetOnlyOfficeMixin:
resolved_version, metadata = self._ensure_preview_rule_spreadsheet(version=version)
return self._build_onlyoffice_spreadsheet_config(
asset_id=asset_id,
tenant_id="platform",
resource_scope="platform",
document_version=resolved_version,
current_user=current_user,
metadata=metadata,
editable=resolved_version == PREVIEW_RULE_CURRENT_VERSION,
editable=(
resolved_version == PREVIEW_RULE_CURRENT_VERSION
and current_user.is_admin
),
)
asset = self._require_spreadsheet_rule(asset_id)
_, metadata = self._resolve_current_spreadsheet_meta(asset)
editable = self._can_edit_current_spreadsheet(current_user)
resolved_version, metadata = self._resolve_current_spreadsheet_meta(asset)
editable = self._can_edit_current_spreadsheet(current_user) and (
asset.scope == "tenant" or current_user.is_admin
)
return self._build_onlyoffice_spreadsheet_config(
asset_id=asset.id,
tenant_id=asset.tenant_id,
resource_scope=asset.scope,
document_version=resolved_version,
current_user=current_user,
metadata=metadata,
editable=editable,
@@ -75,10 +93,21 @@ class AgentAssetOnlyOfficeMixin:
asset_id: str,
*,
version: str | None = None,
validated_session: AgentAssetOnlyOfficeValidatedSession | None = None,
) -> tuple[Path, str, str]:
self._ensure_ready()
if asset_id == PREVIEW_RULE_ASSET_ID:
_, metadata = self._ensure_preview_rule_spreadsheet(version=version)
resolved_version, metadata = self._ensure_preview_rule_spreadsheet(
version=(validated_session.document_version if validated_session else version)
)
if validated_session is not None:
self._require_matching_onlyoffice_document(
validated_session,
tenant_id="platform",
resource_scope="platform",
document_version=resolved_version,
metadata=metadata,
)
file_path = self.spreadsheet_manager.resolve_storage_path(metadata.storage_key)
if not file_path.exists():
raise FileNotFoundError(metadata.file_name)
@@ -87,9 +116,19 @@ class AgentAssetOnlyOfficeMixin:
asset = self._require_spreadsheet_rule(asset_id)
requested_version = str(version or "").strip()
if requested_version and requested_version != "current":
_, metadata = self._resolve_spreadsheet_version_meta(asset, version=requested_version)
resolved_version, metadata = self._resolve_spreadsheet_version_meta(
asset, version=requested_version
)
else:
_, metadata = self._resolve_current_spreadsheet_meta(asset)
resolved_version, metadata = self._resolve_current_spreadsheet_meta(asset)
if validated_session is not None:
self._require_matching_onlyoffice_document(
validated_session,
tenant_id=asset.tenant_id,
resource_scope=asset.scope,
document_version=resolved_version,
metadata=metadata,
)
file_path = self.spreadsheet_manager.resolve_storage_path(metadata.storage_key)
if not file_path.exists():
raise FileNotFoundError(metadata.file_name)
@@ -99,22 +138,14 @@ class AgentAssetOnlyOfficeMixin:
self,
asset_id: str,
access_token: str,
) -> None:
onlyoffice_settings = self._resolve_onlyoffice_settings()
try:
payload = jwt.decode(
access_token,
onlyoffice_settings.jwt_secret,
algorithms=["HS256"],
)
except jwt.PyJWTError as exc:
raise ValueError("ONLYOFFICE 文件访问令牌无效。") from exc
if (
payload.get("scope") != "agent-asset-spreadsheet"
or payload.get("asset_id") != asset_id
):
raise ValueError("ONLYOFFICE 文件访问令牌无效。")
*,
expected_scope: str = AGENT_ASSET_ONLYOFFICE_CONTENT_SCOPE,
) -> AgentAssetOnlyOfficeValidatedSession:
return self._onlyoffice_session_service().validate(
asset_id=asset_id,
token=access_token,
expected_scope=expected_scope,
)
def upload_rule_spreadsheet(
self,
@@ -210,42 +241,54 @@ class AgentAssetOnlyOfficeMixin:
*,
version: str | None = None,
payload: dict[str, Any],
actor_name: str | None = None,
callback_token: str,
) -> None:
self._ensure_ready()
if asset_id == PREVIEW_RULE_ASSET_ID:
self._handle_preview_rule_spreadsheet_onlyoffice_callback(
version=version,
payload=payload,
callback = self._parse_onlyoffice_callback(payload)
session_service = self._onlyoffice_session_service()
if callback.status not in {2, 6}:
session_service.validate(
asset_id=asset_id,
token=callback_token,
expected_scope=AGENT_ASSET_ONLYOFFICE_CALLBACK_SCOPE,
)
return
if not callback.download_url:
raise ValueError("ONLYOFFICE 回写回调缺少下载 URL。")
asset = self._require_spreadsheet_rule(asset_id)
callback = self._parse_onlyoffice_callback(payload)
if callback.status not in {2, 6} or not callback.download_url:
return
_, current_metadata = self._resolve_current_spreadsheet_meta(asset)
request = Request(
callback.download_url,
headers={"User-Agent": "x-financial-onlyoffice-agent-asset"},
)
with urlopen(request, timeout=30) as response: # noqa: S310
content = response.read()
if current_metadata.checksum and current_metadata.checksum == self._hash_bytes(content):
return
resolved_actor_name = str(actor_name or "").strip() or (
callback.users[0] if callback.users else "ONLYOFFICE"
)
self.upload_rule_spreadsheet(
asset.id,
filename=current_metadata.file_name,
content=content,
actor=resolved_actor_name,
source="onlyoffice",
claimed = session_service.claim_callback(
asset_id=asset_id,
token=callback_token,
payload_document_key=callback.document_key,
)
if version and str(version).strip() not in {"current", claimed.document_version}:
session_service.finish_callback(
claimed.jti,
succeeded=False,
failure_reason="legacy_version_mismatch",
)
raise AgentAssetOnlyOfficeSecurityError(
"ONLYOFFICE 回调版本与编辑会话不一致。"
)
try:
if asset_id == PREVIEW_RULE_ASSET_ID:
self._save_preview_rule_spreadsheet_callback(
claimed=claimed,
download_url=callback.download_url,
)
else:
self._save_current_rule_spreadsheet_callback(
claimed=claimed,
download_url=callback.download_url,
)
except Exception as exc:
session_service.finish_callback(
claimed.jti,
succeeded=False,
failure_reason=type(exc).__name__,
)
raise
session_service.finish_callback(claimed.jti, succeeded=True)
@staticmethod
@@ -265,28 +308,21 @@ class AgentAssetOnlyOfficeMixin:
for character in raw_key
)
def _build_onlyoffice_access_token(self, asset_id: str) -> str:
onlyoffice_settings = self._resolve_onlyoffice_settings()
payload = {
"scope": "agent-asset-spreadsheet",
"asset_id": asset_id,
}
return jwt.encode(payload, onlyoffice_settings.jwt_secret, algorithm="HS256")
@staticmethod
def _parse_onlyoffice_callback(payload: dict[str, Any]) -> OnlyOfficeCallbackPayload:
return OnlyOfficeCallbackPayload(
status=int(payload.get("status") or 0),
download_url=str(payload.get("url") or "").strip(),
users=[str(item).strip() for item in payload.get("users") or [] if str(item).strip()],
document_key=str(payload.get("key") or "").strip(),
)
def _build_onlyoffice_spreadsheet_config(
self,
*,
asset_id: str,
tenant_id: str,
resource_scope: str,
document_version: str,
current_user: CurrentUserContext,
metadata: RuleSpreadsheetMeta,
editable: bool,
@@ -302,21 +338,32 @@ class AgentAssetOnlyOfficeMixin:
backend_base_url = onlyoffice_settings.backend_url.rstrip("/")
public_url = onlyoffice_settings.public_url.rstrip("/")
access_token = self._build_onlyoffice_access_token(asset_id)
actor = stable_user_principal(current_user)
document_key = self._build_onlyoffice_document_key(asset_id, metadata)
tokens = self._onlyoffice_session_service().issue(
tenant_id=tenant_id,
resource_scope=resource_scope,
asset_id=asset_id,
document_key=document_key,
document_version=document_version,
document_fingerprint=self._onlyoffice_document_fingerprint(metadata),
actor=actor,
writable=editable,
)
document_url = (
f"{backend_base_url}{settings.api_v1_prefix}/agent-assets/{asset_id}/spreadsheet/onlyoffice/content"
f"?access_token={access_token}"
f"?access_token={tokens.content_token}"
)
callback_url = (
f"{backend_base_url}{settings.api_v1_prefix}/agent-assets/{asset_id}/spreadsheet/onlyoffice/callback"
f"?actor_name={quote(current_user.name)}"
f"?access_token={tokens.callback_token}"
)
config: dict[str, Any] = {
"documentType": "cell",
"document": {
"fileType": Path(metadata.file_name).suffix.lstrip(".").lower() or "xlsx",
"key": self._build_onlyoffice_document_key(asset_id, metadata),
"key": document_key,
"title": metadata.file_name,
"url": document_url,
"permissions": {
@@ -396,38 +443,101 @@ class AgentAssetOnlyOfficeMixin:
)
return resolved_version, metadata
def _handle_preview_rule_spreadsheet_onlyoffice_callback(
def _save_current_rule_spreadsheet_callback(
self,
*,
version: str,
payload: dict[str, Any],
claimed: AgentAssetOnlyOfficeValidatedSession,
download_url: str,
) -> None:
callback = self._parse_onlyoffice_callback(payload)
if callback.status not in {2, 6} or not callback.download_url:
return
resolved_version, metadata = self._ensure_preview_rule_spreadsheet(version=version)
request = Request(
callback.download_url,
headers={"User-Agent": "x-financial-onlyoffice-agent-asset-preview"},
asset = self._require_spreadsheet_rule(claimed.asset_id)
resolved_version, metadata = self._resolve_current_spreadsheet_meta(asset)
self._require_matching_onlyoffice_document(
claimed,
tenant_id=asset.tenant_id,
resource_scope=asset.scope,
document_version=resolved_version,
metadata=metadata,
)
content = download_onlyoffice_document(
download_url,
expected_filename=metadata.file_name,
)
with urlopen(request, timeout=30) as response: # noqa: S310
content = response.read()
if metadata.checksum and metadata.checksum == self._hash_bytes(content):
return
self.upload_rule_spreadsheet(
asset.id,
filename=metadata.file_name,
content=content,
actor=claimed.actor,
source="onlyoffice",
)
actor_name = callback.users[0] if callback.users else "ONLYOFFICE"
def _save_preview_rule_spreadsheet_callback(
self,
*,
claimed: AgentAssetOnlyOfficeValidatedSession,
download_url: str,
) -> None:
resolved_version, metadata = self._ensure_preview_rule_spreadsheet(
version=claimed.document_version
)
self._require_matching_onlyoffice_document(
claimed,
tenant_id="platform",
resource_scope="platform",
document_version=resolved_version,
metadata=metadata,
)
content = download_onlyoffice_document(
download_url,
expected_filename=metadata.file_name,
)
if metadata.checksum and metadata.checksum == self._hash_bytes(content):
return
self.spreadsheet_manager.store_rule_library_spreadsheet_snapshot(
library=FINANCE_RULES_LIBRARY,
asset_id=PREVIEW_RULE_ASSET_ID,
version=resolved_version,
file_name=metadata.file_name,
content=content,
actor_name=actor_name,
actor_name=claimed.actor,
source="onlyoffice-preview",
)
def _onlyoffice_session_service(self) -> AgentAssetOnlyOfficeSessionService:
settings = self._resolve_onlyoffice_settings()
return AgentAssetOnlyOfficeSessionService(
self.db,
jwt_secret=settings.jwt_secret,
)
@staticmethod
def _onlyoffice_document_fingerprint(metadata: RuleSpreadsheetMeta) -> str:
return str(metadata.checksum or metadata.updated_at or metadata.file_name)
def _require_matching_onlyoffice_document(
self,
session: AgentAssetOnlyOfficeValidatedSession,
*,
tenant_id: str,
resource_scope: str,
document_version: str,
metadata: RuleSpreadsheetMeta,
) -> None:
comparisons = (
session.tenant_id == tenant_id,
session.resource_scope == resource_scope,
session.document_version == document_version,
session.document_key
== self._build_onlyoffice_document_key(session.asset_id, metadata),
session.document_fingerprint
== self._onlyoffice_document_fingerprint(metadata),
)
if not all(comparisons):
raise AgentAssetOnlyOfficeSecurityError(
"ONLYOFFICE 编辑会话绑定的规则表版本已失效。"
)
@staticmethod
def _read_current_rule_document_meta(asset: AgentAsset) -> RuleSpreadsheetMeta | None:
payload = (asset.config_json or {}).get("rule_document")

View File

@@ -0,0 +1,288 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from typing import Any
from uuid import uuid4
import jwt
from sqlalchemy import update
from sqlalchemy.orm import Session
from app.models.agent_asset import AgentAssetOnlyOfficeSession
AGENT_ASSET_ONLYOFFICE_ISSUER = "x-financial"
AGENT_ASSET_ONLYOFFICE_AUDIENCE = "onlyoffice-document-server"
AGENT_ASSET_ONLYOFFICE_CONTENT_SCOPE = "agent-asset-spreadsheet-content"
AGENT_ASSET_ONLYOFFICE_CALLBACK_SCOPE = "agent-asset-spreadsheet-callback"
AGENT_ASSET_ONLYOFFICE_CONTENT_TTL_SECONDS = 900
AGENT_ASSET_ONLYOFFICE_CALLBACK_TTL_SECONDS = 4 * 60 * 60
class AgentAssetOnlyOfficeSecurityError(ValueError):
pass
class AgentAssetOnlyOfficeReplayError(AgentAssetOnlyOfficeSecurityError):
pass
@dataclass(frozen=True, slots=True)
class AgentAssetOnlyOfficeTokens:
content_token: str
callback_token: str
expires_at: datetime
@dataclass(frozen=True, slots=True)
class AgentAssetOnlyOfficeValidatedSession:
jti: str
tenant_id: str
resource_scope: str
asset_id: str
document_key: str
document_version: str
document_fingerprint: str
writable: bool
actor: str
status: str
class AgentAssetOnlyOfficeSessionService:
def __init__(self, db: Session, *, jwt_secret: str) -> None:
self.db = db
self.jwt_secret = str(jwt_secret or "").strip()
if not self.jwt_secret:
raise AgentAssetOnlyOfficeSecurityError("ONLYOFFICE JWT 密钥未配置。")
def issue(
self,
*,
tenant_id: str,
resource_scope: str,
asset_id: str,
document_key: str,
document_version: str,
document_fingerprint: str,
writable: bool,
actor: str,
) -> AgentAssetOnlyOfficeTokens:
normalized_tenant_id = str(tenant_id or "").strip()
normalized_scope = str(resource_scope or "").strip()
if not normalized_tenant_id or normalized_scope not in {"platform", "tenant"}:
raise AgentAssetOnlyOfficeSecurityError("ONLYOFFICE 会话租户范围不合法。")
if (normalized_scope == "platform") != (normalized_tenant_id == "platform"):
raise AgentAssetOnlyOfficeSecurityError("ONLYOFFICE 会话租户范围不一致。")
now = datetime.now(UTC)
content_expires_at = now + timedelta(
seconds=AGENT_ASSET_ONLYOFFICE_CONTENT_TTL_SECONDS
)
expires_at = now + timedelta(
seconds=AGENT_ASSET_ONLYOFFICE_CALLBACK_TTL_SECONDS
)
jti = str(uuid4())
row = AgentAssetOnlyOfficeSession(
jti=jti,
tenant_id=normalized_tenant_id,
resource_scope=normalized_scope,
asset_id=str(asset_id),
document_key=str(document_key),
document_version=str(document_version),
document_fingerprint=str(document_fingerprint),
audience=AGENT_ASSET_ONLYOFFICE_AUDIENCE,
writable=bool(writable),
status="active",
actor=str(actor),
expires_at=expires_at,
failure_reason="",
)
self.db.add(row)
self.db.commit()
common_claims = {
"iss": AGENT_ASSET_ONLYOFFICE_ISSUER,
"aud": AGENT_ASSET_ONLYOFFICE_AUDIENCE,
"sub": str(asset_id),
"jti": jti,
"iat": int(now.timestamp()),
"nbf": int(now.timestamp()),
"tenant_id": normalized_tenant_id,
"resource_scope": normalized_scope,
"asset_id": str(asset_id),
"document_key": str(document_key),
"document_version": str(document_version),
"document_fingerprint": str(document_fingerprint),
"writable": bool(writable),
"actor": str(actor),
}
return AgentAssetOnlyOfficeTokens(
content_token=self._encode(
{
**common_claims,
"scope": AGENT_ASSET_ONLYOFFICE_CONTENT_SCOPE,
"exp": int(content_expires_at.timestamp()),
}
),
callback_token=self._encode(
{
**common_claims,
"scope": AGENT_ASSET_ONLYOFFICE_CALLBACK_SCOPE,
"exp": int(expires_at.timestamp()),
}
),
expires_at=expires_at,
)
def validate(
self,
*,
asset_id: str,
token: str,
expected_scope: str,
) -> AgentAssetOnlyOfficeValidatedSession:
try:
claims = jwt.decode(
token,
self.jwt_secret,
algorithms=["HS256"],
audience=AGENT_ASSET_ONLYOFFICE_AUDIENCE,
issuer=AGENT_ASSET_ONLYOFFICE_ISSUER,
options={
"require": [
"iss",
"aud",
"sub",
"jti",
"iat",
"nbf",
"exp",
"scope",
"tenant_id",
"resource_scope",
"asset_id",
"document_key",
"document_version",
"document_fingerprint",
"writable",
"actor",
]
},
)
except jwt.PyJWTError as exc:
raise AgentAssetOnlyOfficeSecurityError(
"ONLYOFFICE 会话令牌无效或已过期。"
) from exc
jti = str(claims.get("jti") or "").strip()
row = self.db.get(AgentAssetOnlyOfficeSession, jti)
if row is None:
raise AgentAssetOnlyOfficeSecurityError("ONLYOFFICE 会话不存在。")
now = datetime.now(UTC)
comparisons = (
claims.get("scope") == expected_scope,
claims.get("sub") == asset_id,
claims.get("asset_id") == asset_id == row.asset_id,
claims.get("tenant_id") == row.tenant_id,
claims.get("resource_scope") == row.resource_scope,
claims.get("document_key") == row.document_key,
claims.get("document_version") == row.document_version,
claims.get("document_fingerprint") == row.document_fingerprint,
bool(claims.get("writable")) == row.writable,
claims.get("actor") == row.actor,
row.audience == AGENT_ASSET_ONLYOFFICE_AUDIENCE,
row.status == "active",
_as_utc(row.expires_at) > now,
)
if not all(comparisons):
if row.status != "active":
raise AgentAssetOnlyOfficeReplayError(
"ONLYOFFICE 回调会话已被使用或已撤销。"
)
raise AgentAssetOnlyOfficeSecurityError(
"ONLYOFFICE 会话与目标规则表不匹配。"
)
return self._validated(row)
def claim_callback(
self,
*,
asset_id: str,
token: str,
payload_document_key: str,
) -> AgentAssetOnlyOfficeValidatedSession:
validated = self.validate(
asset_id=asset_id,
token=token,
expected_scope=AGENT_ASSET_ONLYOFFICE_CALLBACK_SCOPE,
)
if not validated.writable:
raise AgentAssetOnlyOfficeSecurityError("只读 ONLYOFFICE 会话禁止回写规则表。")
if not payload_document_key or payload_document_key != validated.document_key:
raise AgentAssetOnlyOfficeSecurityError(
"ONLYOFFICE 回调文档 key 与编辑会话不一致。"
)
now = datetime.now(UTC)
result = self.db.execute(
update(AgentAssetOnlyOfficeSession)
.where(
AgentAssetOnlyOfficeSession.jti == validated.jti,
AgentAssetOnlyOfficeSession.status == "active",
AgentAssetOnlyOfficeSession.expires_at > now,
)
.values(status="processing", claimed_at=now)
)
if result.rowcount != 1:
self.db.rollback()
raise AgentAssetOnlyOfficeReplayError(
"ONLYOFFICE 回调会话已被使用或已过期。"
)
self.db.commit()
row = self.db.get(AgentAssetOnlyOfficeSession, validated.jti)
if row is None: # pragma: no cover - 数据库约束保证
raise AgentAssetOnlyOfficeSecurityError("ONLYOFFICE 会话不存在。")
return self._validated(row)
def finish_callback(
self,
jti: str,
*,
succeeded: bool,
failure_reason: str = "",
) -> None:
row = self.db.get(AgentAssetOnlyOfficeSession, jti)
if row is None or row.status != "processing":
raise AgentAssetOnlyOfficeReplayError("ONLYOFFICE 回调会话状态不可更新。")
if succeeded:
row.status = "consumed"
row.consumed_at = datetime.now(UTC)
row.failure_reason = ""
else:
row.status = "failed"
row.failure_reason = str(failure_reason or "callback_failed")[:1000]
self.db.commit()
def _encode(self, claims: dict[str, Any]) -> str:
return jwt.encode(claims, self.jwt_secret, algorithm="HS256")
@staticmethod
def _validated(row: AgentAssetOnlyOfficeSession) -> AgentAssetOnlyOfficeValidatedSession:
return AgentAssetOnlyOfficeValidatedSession(
jti=row.jti,
tenant_id=row.tenant_id,
resource_scope=row.resource_scope,
asset_id=row.asset_id,
document_key=row.document_key,
document_version=row.document_version,
document_fingerprint=row.document_fingerprint,
writable=row.writable,
actor=row.actor,
status=row.status,
)
def _as_utc(value: datetime) -> datetime:
if value.tzinfo is None:
return value.replace(tzinfo=UTC)
return value.astimezone(UTC)

View File

@@ -0,0 +1,451 @@
"""发布遥测的精度、负样本证据与召回率聚合。"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models.agent_asset_release_telemetry import (
AgentAssetReleaseAuditSample,
AgentAssetReleaseLabel,
AgentAssetReleaseObservation,
)
from app.services.agent_asset_release_label_votes import (
release_reviewer_quorum,
resolve_release_label_votes,
)
from app.services.agent_asset_release_recall import (
ReleaseRecallEstimate,
estimate_release_recall,
)
from app.services.agent_asset_release_telemetry_values import precision
@dataclass(frozen=True, slots=True)
class _RecallEvidence:
status: str
estimate: ReleaseRecallEstimate
false_negative_count: int | None
negative_sample_count: int
negative_labeled_count: int
negative_pending_count: int
random_population_count: int
random_sample_count: int
random_labeled_count: int
random_false_negative_count: int
def build_release_telemetry_aggregate(
*,
db: Session,
tenant_id: str,
asset_id: str,
release_id: str,
stage: str,
version: str,
release_state: dict[str, Any],
) -> Any:
from app.services.agent_asset_release_telemetry import ReleaseTelemetryAggregate
observations = _observations(db, tenant_id, asset_id, release_id, stage, version)
labels = _labels(db, tenant_id, asset_id, release_id, stage, version)
samples = _samples(db, tenant_id, asset_id, release_id, stage, version)
vote_states = resolve_release_label_votes(
labels,
required_reviewers=release_reviewer_quorum(release_state),
required_reviewers_by_observation={
item.observation_id: _sample_quorum(item, release_state)
for item in samples
},
)
latest_labels = {
observation_id: state.label
for observation_id, state in vote_states.items()
if state.label is not None
}
completed = [item for item in observations if item.runtime_status == "completed"]
failures = [item for item in observations if item.runtime_status == "failed"]
candidate_hits = [item for item in completed if item.candidate_hit]
baseline_hits = [item for item in completed if item.baseline_hit is True]
candidate_values = _values(candidate_hits, latest_labels)
baseline_values = _values(baseline_hits, latest_labels)
candidate_confirmed = candidate_values.count("confirmed")
candidate_false_positive = candidate_values.count("false_positive")
baseline_confirmed = baseline_values.count("confirmed")
baseline_false_positive = baseline_values.count("false_positive")
candidate_pending = len(candidate_hits) - len(candidate_values)
baseline_pending = len(baseline_hits) - len(baseline_values)
candidate_precision = precision(candidate_confirmed, candidate_false_positive)
baseline_precision = (
precision(baseline_confirmed, baseline_false_positive)
if not baseline_pending
else None
)
recall = _recall_evidence(
completed=completed,
samples=samples,
labels=latest_labels,
true_positive_count=candidate_confirmed,
candidate_pending_count=candidate_pending,
release_state=release_state,
)
reasons = _reasons(
observations=observations,
candidate_hits=candidate_hits,
candidate_values=candidate_values,
candidate_pending=candidate_pending,
baseline_precision=baseline_precision,
recall=recall,
release_state=release_state,
)
precision_ready = bool(candidate_hits and candidate_values and not candidate_pending)
recall_gate = _recall_gate_enabled(release_state)
recall_ready = recall.status.startswith("available")
min_precision = _policy_float(release_state, "min_precision", 0.98)
hard_precision_failure = (
candidate_precision is not None and candidate_precision < min_precision
)
ready = bool(
observations
and (
failures
or (
precision_ready
and (not recall_gate or recall_ready or hard_precision_failure)
)
)
)
estimate = recall.estimate
return ReleaseTelemetryAggregate(
tenant_id=tenant_id,
asset_id=asset_id,
release_id=release_id,
stage=stage,
version=version,
status="ready" if ready else "collecting",
reasons=tuple(reasons),
observed_count=len(observations),
completed_count=len(completed),
runtime_failure_count=len(failures),
candidate_hit_count=len(candidate_hits),
candidate_labeled_count=len(candidate_values),
candidate_pending_label_count=candidate_pending,
candidate_confirmed_count=candidate_confirmed,
candidate_false_positive_count=candidate_false_positive,
precision=candidate_precision,
baseline_hit_count=len(baseline_hits),
baseline_labeled_count=len(baseline_values),
baseline_pending_label_count=baseline_pending,
baseline_confirmed_count=baseline_confirmed,
baseline_false_positive_count=baseline_false_positive,
baseline_precision=baseline_precision,
false_negative_count=recall.false_negative_count,
estimated_false_negative_count=estimate.estimated_false_negative_count,
false_negative_upper_bound=estimate.false_negative_upper_bound,
negative_sample_count=recall.negative_sample_count,
negative_labeled_count=recall.negative_labeled_count,
negative_pending_label_count=recall.negative_pending_count,
random_negative_population_count=recall.random_population_count,
random_negative_sample_count=recall.random_sample_count,
random_negative_labeled_count=recall.random_labeled_count,
random_negative_false_negative_count=recall.random_false_negative_count,
recall=estimate.recall,
recall_lower_bound=estimate.recall_lower_bound,
recall_confidence_level=estimate.confidence_level,
recall_method=estimate.method,
negative_ground_truth_status=recall.status,
)
def _recall_evidence(
*,
completed: list[AgentAssetReleaseObservation],
samples: list[AgentAssetReleaseAuditSample],
labels: dict[str, str],
true_positive_count: int,
candidate_pending_count: int,
release_state: dict[str, Any],
) -> _RecallEvidence:
by_id = {item.id: item for item in completed}
negative_samples = [
item
for item in samples
if item.observation_id in by_id and not by_id[item.observation_id].candidate_hit
]
negative_labeled = [item for item in negative_samples if item.observation_id in labels]
disagreement = [
item
for item in completed
if not item.candidate_hit and item.baseline_hit is True
]
random_population = [
item
for item in completed
if not item.candidate_hit and item.baseline_hit is not True
]
random_samples = [
item for item in negative_samples if item.stratum == "candidate_negative_random"
]
random_labeled = [item for item in random_samples if item.observation_id in labels]
disagreement_false_negatives = sum(
1 for item in disagreement if labels.get(item.id) == "confirmed"
)
random_false_negatives = sum(
1 for item in random_labeled if labels[item.observation_id] == "confirmed"
)
observed_false_negatives = sum(
1 for item in negative_labeled if labels[item.observation_id] == "confirmed"
)
status = _negative_status(
candidate_pending_count=candidate_pending_count,
disagreement=disagreement,
random_population=random_population,
random_samples=random_samples,
random_labeled=random_labeled,
labels=labels,
minimum_random_reviews=_policy_int(
release_state,
"negative_min_reviewed",
5,
low=1,
high=10_000,
),
)
confidence = _policy_confidence(release_state)
estimate = _estimate_if_ready(
status=status,
true_positive_count=true_positive_count,
disagreement_false_negatives=disagreement_false_negatives,
random_population_count=len(random_population),
random_reviewed_count=len(random_labeled),
random_false_negative_count=random_false_negatives,
confidence=confidence,
)
return _RecallEvidence(
status=status,
estimate=estimate,
false_negative_count=(
observed_false_negatives if status.startswith("available") else None
),
negative_sample_count=len(negative_samples),
negative_labeled_count=len(negative_labeled),
negative_pending_count=len(negative_samples) - len(negative_labeled),
random_population_count=len(random_population),
random_sample_count=len(random_samples),
random_labeled_count=len(random_labeled),
random_false_negative_count=random_false_negatives,
)
def _negative_status(
*,
candidate_pending_count: int,
disagreement: list[AgentAssetReleaseObservation],
random_population: list[AgentAssetReleaseObservation],
random_samples: list[AgentAssetReleaseAuditSample],
random_labeled: list[AgentAssetReleaseAuditSample],
labels: dict[str, str],
minimum_random_reviews: int,
) -> str:
if candidate_pending_count:
return "collecting_candidate_labels"
if any(item.id not in labels for item in disagreement):
return "collecting_disagreement_labels"
if not random_population:
return "available_census"
if not random_samples:
return "unavailable_no_random_negative_samples"
if len(random_labeled) < len(random_samples):
return "collecting_random_negative_labels"
if len(random_labeled) < minimum_random_reviews:
return "insufficient_random_negative_reviews"
return "available_stratified_random_audit"
def _estimate_if_ready(
*,
status: str,
true_positive_count: int,
disagreement_false_negatives: int,
random_population_count: int,
random_reviewed_count: int,
random_false_negative_count: int,
confidence: float,
) -> ReleaseRecallEstimate:
if not status.startswith("available"):
return estimate_release_recall(
true_positive_count=true_positive_count,
disagreement_false_negative_count=disagreement_false_negatives,
random_negative_population_count=max(1, random_population_count),
random_reviewed_count=0,
random_false_negative_count=0,
confidence_level=confidence,
)
return estimate_release_recall(
true_positive_count=true_positive_count,
disagreement_false_negative_count=disagreement_false_negatives,
random_negative_population_count=random_population_count,
random_reviewed_count=random_reviewed_count,
random_false_negative_count=random_false_negative_count,
confidence_level=confidence,
)
def _reasons(
*,
observations: list[AgentAssetReleaseObservation],
candidate_hits: list[AgentAssetReleaseObservation],
candidate_values: list[str],
candidate_pending: int,
baseline_precision: float | None,
recall: _RecallEvidence,
release_state: dict[str, Any],
) -> list[str]:
reasons: list[str] = []
if not observations:
reasons.append("no_runtime_observations")
if not candidate_hits:
reasons.append("no_candidate_positive_samples")
if not candidate_values:
reasons.append("no_candidate_labels")
if candidate_pending:
reasons.append("candidate_labels_pending")
if baseline_precision is None:
reasons.append("baseline_precision_unavailable")
if _recall_gate_enabled(release_state) and not recall.status.startswith("available"):
reasons.append(recall.status)
return reasons
def _observations(
db: Session,
tenant_id: str,
asset_id: str,
release_id: str,
stage: str,
version: str,
) -> list[AgentAssetReleaseObservation]:
return list(
db.scalars(
select(AgentAssetReleaseObservation)
.where(
AgentAssetReleaseObservation.tenant_id == tenant_id,
AgentAssetReleaseObservation.asset_id == asset_id,
AgentAssetReleaseObservation.release_id == release_id,
AgentAssetReleaseObservation.stage == stage,
AgentAssetReleaseObservation.version == version,
)
.order_by(
AgentAssetReleaseObservation.created_at.asc(),
AgentAssetReleaseObservation.id.asc(),
)
).all()
)
def _labels(
db: Session,
tenant_id: str,
asset_id: str,
release_id: str,
stage: str,
version: str,
) -> list[AgentAssetReleaseLabel]:
return list(
db.scalars(
select(AgentAssetReleaseLabel)
.where(
AgentAssetReleaseLabel.tenant_id == tenant_id,
AgentAssetReleaseLabel.asset_id == asset_id,
AgentAssetReleaseLabel.release_id == release_id,
AgentAssetReleaseLabel.stage == stage,
AgentAssetReleaseLabel.version == version,
)
.order_by(
AgentAssetReleaseLabel.created_at.asc(),
AgentAssetReleaseLabel.id.asc(),
)
).all()
)
def _samples(
db: Session,
tenant_id: str,
asset_id: str,
release_id: str,
stage: str,
version: str,
) -> list[AgentAssetReleaseAuditSample]:
return list(
db.scalars(
select(AgentAssetReleaseAuditSample)
.where(
AgentAssetReleaseAuditSample.tenant_id == tenant_id,
AgentAssetReleaseAuditSample.asset_id == asset_id,
AgentAssetReleaseAuditSample.release_id == release_id,
AgentAssetReleaseAuditSample.stage == stage,
AgentAssetReleaseAuditSample.version == version,
)
.order_by(
AgentAssetReleaseAuditSample.created_at.asc(),
AgentAssetReleaseAuditSample.id.asc(),
)
).all()
)
def _values(
observations: list[AgentAssetReleaseObservation],
labels: dict[str, str],
) -> list[str]:
return [labels[item.id] for item in observations if item.id in labels]
def _recall_gate_enabled(release_state: dict[str, Any]) -> bool:
policy = release_state.get("policy")
return isinstance(policy, dict) and policy.get("recall_gate_enabled") is True
def _sample_quorum(
sample: AgentAssetReleaseAuditSample,
release_state: dict[str, Any],
) -> int:
if sample.stratum != "candidate_positive_census":
return 2
return release_reviewer_quorum(release_state)
def _policy_int(
release_state: dict[str, Any],
key: str,
default: int,
*,
low: int,
high: int,
) -> int:
policy = release_state.get("policy")
source = policy if isinstance(policy, dict) else {}
try:
parsed = int(source.get(key, default))
except (TypeError, ValueError, OverflowError):
return default
return max(low, min(high, parsed))
def _policy_float(release_state: dict[str, Any], key: str, default: float) -> float:
policy = release_state.get("policy")
source = policy if isinstance(policy, dict) else {}
try:
parsed = float(source.get(key, default))
except (TypeError, ValueError, OverflowError):
return default
return max(0.0, min(1.0, parsed))
def _policy_confidence(release_state: dict[str, Any]) -> float:
value = _policy_float(release_state, "recall_confidence_level", 0.95)
return value if value in {0.9, 0.95, 0.99} else 0.95

View File

@@ -0,0 +1,129 @@
"""把发布遥测状态转换为不含业务正文的运营告警。"""
from __future__ import annotations
from typing import Any
def build_release_alerts(
*,
status: str,
rolled_back: bool,
reasons: list[str] | tuple[str, ...],
metrics: dict[str, Any],
) -> list[dict[str, str]]:
alerts: list[dict[str, str]] = []
reason_set = {str(item) for item in reasons}
if bool(metrics.get("aggregation_failed")):
alerts.append(
_alert(
"release_aggregation_failed",
"error",
"发布遥测聚合失败,候选版本已停止晋级。",
"检查遥测存储和聚合作业;稳定版本会继续保护业务。",
)
)
if rolled_back:
alerts.append(
_alert(
"release_auto_rolled_back",
"critical",
"候选版本已因真实运行指标越界自动回滚。",
"检查失败样本和版本差异后重新发布。",
)
)
if int(metrics.get("runtime_failure_count") or 0) > 0:
alerts.append(
_alert(
"runtime_failures_detected",
"error",
"候选规则出现结构化运行失败。",
"优先修复 evaluator 或发布快照完整性。",
)
)
if int(metrics.get("candidate_pending_label_count") or 0) > 0:
alerts.append(
_alert(
"release_labels_pending",
"warning",
"候选命中仍有待人工复核样本,发布不会晋级。",
"由非发布发起人完成确认或误报标注。",
)
)
if int(metrics.get("candidate_oldest_pending_age_seconds") or 0) >= 86_400:
alerts.append(
_alert(
"release_labels_overdue",
"error",
"最早待复核样本已超过 24 小时。",
"安排独立复核人处理积压;完成前保持候选版本不晋级。",
)
)
if int(metrics.get("negative_pending_label_count") or 0) > 0:
alerts.append(
_alert(
"release_negative_audit_pending",
"warning",
"独立负样本盲审仍有积压,召回率证据尚未闭合。",
"完成抽样单据核验;复核界面不会展示候选规则的原始结论。",
)
)
negative_status = str(metrics.get("negative_ground_truth_status") or "")
if negative_status in {
"unavailable_no_random_negative_samples",
"insufficient_random_negative_reviews",
}:
alerts.append(
_alert(
"release_negative_audit_insufficient",
"info",
"随机负样本数量尚不足以形成可信召回率下界。",
"继续采集真实流量并完成系统选中的盲审样本。",
)
)
if "precision_below_threshold" in reason_set or "precision_regression_exceeded" in reason_set:
alerts.append(
_alert(
"release_precision_degraded",
"error",
"候选规则精度低于门禁或相对基线明显下降。",
"检查误报样本、规则条件和基线版本。",
)
)
if "recall_lower_bound_below_threshold" in reason_set:
alerts.append(
_alert(
"release_recall_degraded",
"critical",
"候选规则的召回率保守下界低于发布门禁。",
"检查已确认漏检样本,修正规则后重新进入影子发布。",
)
)
if metrics.get("baseline_precision") is None:
alerts.append(
_alert(
"baseline_precision_unavailable",
"info",
"当前没有完整的基线精度证据。",
"继续采集基线命中并完成可信标注。",
)
)
if status == "collecting" and int(metrics.get("observed_count") or 0) == 0:
alerts.append(
_alert(
"release_observations_pending",
"info",
"当前发布阶段尚未采集到真实运行样本。",
"保持稳定版本并等待真实流量覆盖。",
)
)
return alerts
def _alert(code: str, severity: str, message: str, action: str) -> dict[str, str]:
return {
"code": code,
"severity": severity,
"message": message,
"recommended_action": action,
}

View File

@@ -0,0 +1,278 @@
"""风险规则分阶段发布使用的不可变运行快照。"""
from __future__ import annotations
import hashlib
import json
from dataclasses import dataclass
from datetime import UTC, datetime
from typing import Any
from app.core.agent_enums import AgentAssetType
from app.models.agent_asset import AgentAsset
from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager
from app.services.agent_asset_spreadsheet import RISK_RULES_LIBRARY
from app.services.risk_rule_manifest_normalizer import normalize_risk_rule_manifest
@dataclass(frozen=True)
class ReleaseArtifactBundle:
artifacts: dict[str, dict[str, Any]]
previous_config: dict[str, Any]
class AgentAssetReleaseArtifactService:
"""读取并冻结候选与基线规则,避免发布期间文件被原地改写。"""
_PROHIBITED_AUTOMATION_ACTIONS = {
"approve",
"auto_approve",
"auto_reject",
"execute_payment",
"pay",
"reject",
"transfer",
}
def __init__(
self,
rule_library_manager: AgentAssetRuleLibraryManager | None = None,
) -> None:
self.rule_library_manager = rule_library_manager or AgentAssetRuleLibraryManager()
def capture(
self,
asset: AgentAsset,
candidate_version: str,
*,
previous_state: dict[str, Any] | None = None,
) -> ReleaseArtifactBundle:
config = dict(asset.config_json or {})
config.pop("release_guard", None)
if asset.asset_type != AgentAssetType.RULE.value:
return ReleaseArtifactBundle(artifacts={}, previous_config=config)
if str(config.get("detail_mode") or "").strip().lower() != "json_risk":
raise ValueError("分阶段发布当前只支持 JSON 风险规则资产。")
library = str(config.get("rule_library") or RISK_RULES_LIBRARY).strip()
previous_version = str(asset.published_version or "").strip()
artifacts: dict[str, dict[str, Any]] = {}
if previous_version:
previous_document = config.get("rule_document")
artifact = self._capture_document(
asset,
version=previous_version,
library=library,
document=previous_document,
allow_disabled=False,
)
artifacts[previous_version] = artifact
candidate_document = self._candidate_document(
config,
candidate_version,
has_published_version=bool(previous_version),
)
if candidate_document is None:
prior_artifacts = (
previous_state.get("artifacts")
if isinstance(previous_state, dict)
and isinstance(previous_state.get("artifacts"), dict)
else {}
)
prior_candidate = prior_artifacts.get(candidate_version)
if isinstance(prior_candidate, dict):
artifacts[candidate_version] = dict(prior_candidate)
else:
raise ValueError("候选版本缺少可执行规则快照,不能进入影子发布。")
else:
artifacts[candidate_version] = self._capture_document(
asset,
version=candidate_version,
library=library,
document=candidate_document,
allow_disabled=True,
)
return ReleaseArtifactBundle(artifacts=artifacts, previous_config=config)
def apply_candidate(
self,
asset: AgentAsset,
state: dict[str, Any],
*,
actor: str,
) -> None:
candidate = str(state.get("candidate_version") or "").strip()
artifact = self.artifact(state, candidate)
manifest = dict(artifact["manifest"])
config = dict(asset.config_json or {})
config.update(self._runtime_config(manifest, artifact["rule_document"]))
revision = config.get("revision_draft")
if isinstance(revision, dict):
previous_config = state.get("previous_config")
previous_document = (
previous_config.get("rule_document")
if isinstance(previous_config, dict)
and isinstance(previous_config.get("rule_document"), dict)
else {}
)
history = list(
config.get("revision_history")
if isinstance(config.get("revision_history"), list)
else []
)
history.insert(
0,
{
"version": candidate,
"base_version": revision.get("base_version"),
"change_reason": revision.get("change_reason"),
"published_by": actor,
"published_at": datetime.now(UTC).isoformat(),
"previous_rule_document": previous_document,
"rule_document": dict(artifact["rule_document"]),
},
)
config["revision_history"] = history[:20]
config.pop("revision_draft", None)
config["last_operation"] = {
"action": "activate_staged_release",
"actor": actor,
"at": datetime.now(UTC).isoformat(),
"target_version": candidate,
}
asset.name = str(manifest.get("name") or asset.name)
asset.description = str(manifest.get("description") or asset.description)
risk_category = str(manifest.get("risk_category") or "").strip()
if risk_category:
asset.scenario_json = [risk_category]
asset.config_json = config
@staticmethod
def restore_previous(asset: AgentAsset, state: dict[str, Any]) -> None:
previous_config = state.get("previous_config")
if not isinstance(previous_config, dict):
raise ValueError("发布状态缺少基线配置快照,不能安全回滚。")
release_state = dict(state)
restored = dict(previous_config)
restored["release_guard"] = release_state
asset.config_json = restored
@staticmethod
def artifact(state: dict[str, Any], version: str) -> dict[str, Any]:
artifacts = state.get("artifacts")
artifact = artifacts.get(version) if isinstance(artifacts, dict) else None
if not isinstance(artifact, dict) or not isinstance(artifact.get("manifest"), dict):
raise ValueError(f"发布版本 {version or '<empty>'} 缺少可信运行快照。")
manifest = artifact["manifest"]
expected = str(artifact.get("sha256") or "").strip()
if not expected or expected != _manifest_hash(manifest):
raise ValueError(f"发布版本 {version} 的运行快照完整性校验失败。")
return dict(artifact)
def _capture_document(
self,
asset: AgentAsset,
*,
version: str,
library: str,
document: Any,
allow_disabled: bool,
) -> dict[str, Any]:
if not isinstance(document, dict):
raise ValueError(f"规则版本 {version} 缺少 rule_document。")
file_name = str(document.get("file_name") or "").strip()
if not file_name:
raise ValueError(f"规则版本 {version} 缺少 JSON 文件名。")
manifest = normalize_risk_rule_manifest(
self.rule_library_manager.read_rule_library_json(
library=library,
file_name=file_name,
)
)
rule_code = str(manifest.get("rule_code") or "").strip()
if not rule_code or rule_code != str(asset.code or "").strip():
raise ValueError("规则快照的 rule_code 与资产编码不一致。")
if manifest.get("enabled") is False and not allow_disabled:
raise PermissionError("当前基线规则已停用,不能作为分阶段发布的主版本。")
self._assert_no_prohibited_automation(manifest)
frozen = json.loads(json.dumps(manifest, ensure_ascii=False, sort_keys=True, default=str))
return {
"version": version,
"rule_library": library,
"rule_document": dict(document),
"manifest": frozen,
"sha256": _manifest_hash(frozen),
}
@staticmethod
def _candidate_document(
config: dict[str, Any],
version: str,
*,
has_published_version: bool,
) -> dict[str, Any] | None:
revision = config.get("revision_draft")
if isinstance(revision, dict) and str(revision.get("version") or "").strip() == version:
document = revision.get("rule_document")
if isinstance(document, dict):
return dict(document)
document = config.get("rule_document")
if isinstance(document, dict) and not has_published_version:
return dict(document)
return None
def _assert_no_prohibited_automation(self, manifest: dict[str, Any]) -> None:
outcomes = manifest.get("outcomes")
if not isinstance(outcomes, dict):
return
actions = {
str(outcome.get("action") or "").strip().lower()
for outcome in outcomes.values()
if isinstance(outcome, dict)
}
prohibited = sorted(actions & self._PROHIBITED_AUTOMATION_ACTIONS)
if prohibited:
raise PermissionError(
"风险规则包含禁止无人值守执行的高风险动作:" + ", ".join(prohibited)
)
@staticmethod
def _runtime_config(
manifest: dict[str, Any],
rule_document: dict[str, Any],
) -> dict[str, Any]:
metadata = manifest.get("metadata") if isinstance(manifest.get("metadata"), dict) else {}
outcomes = manifest.get("outcomes") if isinstance(manifest.get("outcomes"), dict) else {}
fail = outcomes.get("fail") if isinstance(outcomes.get("fail"), dict) else {}
risk_level = str(metadata.get("risk_level") or fail.get("severity") or "medium")
risk_score = metadata.get("risk_score") or fail.get("risk_score") or 0
try:
parsed_score = int(risk_score)
except (TypeError, ValueError):
parsed_score = 0
return {
"severity": risk_level,
"risk_score": parsed_score,
"risk_level": risk_level,
"risk_level_label": metadata.get("risk_level_label"),
"risk_score_detail": metadata.get("risk_score_detail") or {},
"enabled": True,
"detail_mode": "json_risk",
"rule_document": dict(rule_document),
"ontology_signal": manifest.get("ontology_signal"),
"evaluator": manifest.get("evaluator"),
"risk_category": manifest.get("risk_category"),
"flow_diagram_svg": manifest.get("flow_diagram_svg"),
}
def _manifest_hash(manifest: dict[str, Any]) -> str:
canonical = json.dumps(
manifest,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
default=str,
)
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()

View File

@@ -0,0 +1,147 @@
"""把类型化风险处置解析为当前 Agent 发布样本的可信标签。"""
from __future__ import annotations
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models.agent_asset import AgentAsset
from app.models.agent_asset_release_telemetry import (
AgentAssetReleaseLabel,
AgentAssetReleaseObservation,
)
from app.models.risk_disposition import RiskDispositionEvent
from app.models.risk_observation import RiskObservation
from app.services.agent_asset_release_telemetry import (
AgentAssetReleaseTelemetryService,
)
from app.services.agent_asset_release_telemetry_crypto import release_source_fingerprints
_RELEASE_STAGES = {"shadow", "canary", "active"}
_BUSINESS_STAGES = {"expense_application", "reimbursement"}
class AgentAssetReleaseDispositionLabelService:
"""在当前 release/stage/version 内定位同租户、同单据、同规则样本。"""
def __init__(self, db: Session) -> None:
self.db = db
def record_current_labels(
self,
*,
tenant_id: str,
disposition_event_id: str,
) -> list[AgentAssetReleaseLabel]:
normalized_tenant = _required(tenant_id, "tenant_id", 64)
event = self.db.scalar(
select(RiskDispositionEvent).where(
RiskDispositionEvent.id == disposition_event_id,
RiskDispositionEvent.tenant_id == normalized_tenant,
)
)
if event is None:
raise LookupError("Risk disposition event not found.")
if event.action not in {"confirm", "false_positive"}:
return []
risk_observation = self.db.scalar(
select(RiskObservation).where(
RiskObservation.id == event.observation_id,
RiskObservation.tenant_id == normalized_tenant,
)
)
if risk_observation is None:
raise LookupError("Risk observation not found.")
rule_code = _rule_code(risk_observation)
if not rule_code:
return []
asset = self._asset(normalized_tenant, rule_code)
state = _release_state(asset)
release_id = str(state.get("release_id") or "").strip()
stage = str(state.get("stage") or "").strip().lower()
version = str(state.get("candidate_version") or "").strip()
if not release_id or stage not in _RELEASE_STAGES or not version:
return []
source_fingerprints = release_source_fingerprints(
tenant_id=normalized_tenant,
source_key=_required(risk_observation.claim_id, "claim_id", 100),
rule_code=rule_code,
)
statement = select(AgentAssetReleaseObservation).where(
AgentAssetReleaseObservation.tenant_id == normalized_tenant,
AgentAssetReleaseObservation.asset_id == asset.id,
AgentAssetReleaseObservation.release_id == release_id,
AgentAssetReleaseObservation.stage == stage,
AgentAssetReleaseObservation.version == version,
AgentAssetReleaseObservation.rule_code == rule_code,
AgentAssetReleaseObservation.source_fingerprint.in_(source_fingerprints),
AgentAssetReleaseObservation.runtime_status == "completed",
(
AgentAssetReleaseObservation.candidate_hit.is_(True)
| AgentAssetReleaseObservation.baseline_hit.is_(True)
),
)
business_stage = str(risk_observation.control_stage or "").strip().lower()
if business_stage in _BUSINESS_STAGES:
statement = statement.where(
AgentAssetReleaseObservation.business_stage == business_stage
)
observations = list(
self.db.scalars(
statement.order_by(
AgentAssetReleaseObservation.created_at.asc(),
AgentAssetReleaseObservation.id.asc(),
)
).all()
)
telemetry = AgentAssetReleaseTelemetryService(self.db)
return [
telemetry.record_risk_disposition_label(
tenant_id=normalized_tenant,
observation_id=observation.id,
disposition_event_id=event.id,
)
for observation in observations
]
def _asset(self, tenant_id: str, rule_code: str) -> AgentAsset:
asset = self.db.scalar(
select(AgentAsset)
.where(
AgentAsset.code == rule_code,
(
((AgentAsset.scope == "tenant") & (AgentAsset.tenant_id == tenant_id))
| ((AgentAsset.scope == "platform") & (AgentAsset.tenant_id == "platform"))
),
)
.order_by(AgentAsset.scope.desc())
)
if asset is None:
raise LookupError("Agent asset not found.")
configured_tenant = str((asset.config_json or {}).get("tenant_id") or "").strip()
if asset.scope == "tenant" and configured_tenant not in {"", tenant_id}:
raise LookupError("Agent asset not found.")
if asset.scope == "platform" and configured_tenant != tenant_id:
raise LookupError("Agent asset not found.")
return asset
def _rule_code(observation: RiskObservation) -> str:
value = str((observation.decision_trace_json or {}).get("rule_code") or "").strip()
if not value and observation.policy_refs_json:
value = str(observation.policy_refs_json[0] or "").strip()
return value
def _release_state(asset: AgentAsset) -> dict:
config = asset.config_json if isinstance(asset.config_json, dict) else {}
state = config.get("release_guard")
return dict(state) if isinstance(state, dict) else {}
def _required(value: object, field: str, maximum: int) -> str:
normalized = str(value or "").strip()
if not normalized or len(normalized) > maximum:
raise ValueError(f"{field} is required and must be at most {maximum} characters.")
return normalized

View File

@@ -0,0 +1,658 @@
"""基于 AgentAsset 配置与测试记录的最小安全发布状态机。"""
from __future__ import annotations
import uuid
from datetime import UTC, datetime
from typing import Any
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.core.agent_enums import AgentAssetStatus, AgentAssetType, AgentReviewStatus
from app.models.agent_asset import (
AgentAsset,
AgentAssetReview,
AgentAssetTestRun,
AgentAssetVersion,
)
from app.services.agent_asset_release_artifacts import AgentAssetReleaseArtifactService
from app.services.agent_asset_release_policy import (
ReleaseEvaluationInput,
ReleaseGuardPolicy,
ReleaseStage,
evaluate_release,
normalize_release_policy,
)
from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager
from app.services.audit import AuditLogService
class AgentAssetReleaseGuardService:
"""管理 shadow→canary→active并在质量越界时自动回滚。"""
CONFIG_KEY = "release_guard"
def __init__(
self,
db: Session,
*,
rule_library_manager: AgentAssetRuleLibraryManager | None = None,
) -> None:
self.db = db
self.artifacts = AgentAssetReleaseArtifactService(rule_library_manager)
self.audit_service = AuditLogService(db)
def start_shadow(
self,
asset_id: str,
candidate_version: str,
*,
actor: str,
policy: ReleaseGuardPolicy | None = None,
tenant_id: str | None = None,
allow_global_management: bool = False,
request_id: str | None = None,
) -> dict[str, Any]:
asset = self._asset(
asset_id,
tenant_id=tenant_id,
allow_global_management=allow_global_management,
lock=True,
)
version = str(candidate_version or "").strip()
if not version or self._version(asset.id, version) is None:
raise ValueError("候选版本不存在,不能进入影子发布。")
if version == str(asset.published_version or "").strip():
raise ValueError("候选版本已是当前发布版本,无需重复发布。")
current = self._state(asset)
if current.get("stage") in {"shadow", "canary"}:
if current.get("candidate_version") == version:
return current
raise ValueError("当前已有进行中的发布,请先完成或回滚。")
if current.get("stage") == "active" and current.get("candidate_version") == version:
return current
if asset.asset_type == AgentAssetType.RULE.value:
self._require_rule_start_lifecycle(asset, version)
bundle = self.artifacts.capture(asset, version, previous_state=current)
if asset.asset_type == AgentAssetType.RULE.value:
self._require_rule_release_preconditions(asset, version, bundle.artifacts, actor=actor)
asset = self._asset(
asset_id,
tenant_id=tenant_id,
allow_global_management=allow_global_management,
lock=True,
)
latest_state = self._state(asset)
if latest_state != current:
if (
latest_state.get("stage") == "shadow"
and latest_state.get("candidate_version") == version
):
return latest_state
raise ValueError("发布状态已被其他操作更新,请刷新后重试。")
now = _now()
release_id = uuid.uuid4().hex
history = list(current.get("history") if isinstance(current.get("history"), list) else [])
history.append(
{
"from": current.get("stage") or "none",
"to": "shadow",
"actor": actor,
"at": now,
"reason": "release_started",
}
)
state = {
"stage": "shadow",
"release_id": release_id,
"candidate_version": version,
"previous_version": str(asset.published_version or ""),
"policy": (policy or ReleaseGuardPolicy()).to_dict(),
"artifacts": bundle.artifacts,
"previous_config": bundle.previous_config,
"started_at": now,
"started_by": actor,
"updated_at": now,
"history": history[-20:],
}
self._save_state(asset, state, commit=False)
self._audit(
asset,
actor=actor,
action="start_agent_asset_shadow_release",
after={
"stage": "shadow",
"release_id": release_id,
"candidate_version": version,
},
request_id=request_id,
)
self.db.commit()
return state
def record_evaluation(
self,
asset_id: str,
evaluation: ReleaseEvaluationInput,
*,
actor: str,
tenant_id: str | None = None,
allow_global_management: bool = False,
request_id: str | None = None,
) -> dict[str, Any]:
asset = self._asset(
asset_id,
tenant_id=tenant_id,
allow_global_management=allow_global_management,
lock=True,
)
state = self._state(asset)
stage = str(state.get("stage") or "")
if asset.status == AgentAssetStatus.DISABLED.value:
raise ValueError("当前资产已停用,不能继续写入发布评测。")
if stage not in {"shadow", "canary", "active"}:
raise ValueError("当前资产不在可评测的发布阶段。")
candidate_version = str(state.get("candidate_version") or "")
release_id = str(state.get("release_id") or "")
policy = self._normalized_policy(state.get("policy"))
result = self._evaluate(stage, evaluation, policy)
requested_tenant = str(tenant_id or "").strip()
run = AgentAssetTestRun(
id=str(uuid.uuid4()),
tenant_id=requested_tenant or asset.tenant_id,
scope="tenant" if requested_tenant else asset.scope,
asset_id=asset.id,
version=candidate_version,
test_type=f"release_{stage}",
status=result["status"],
passed=result["status"] == "passed",
summary=result["summary"],
input_json={
"total": evaluation.total,
"failure_count": evaluation.failure_count,
"precision": evaluation.precision,
"baseline_precision": evaluation.baseline_precision,
"release_id": release_id,
},
result_json={**result, "details": dict(evaluation.details or {})},
created_by=actor,
created_at=datetime.now(UTC),
)
self.db.add(run)
if result["status"] == "failed":
self._apply_rollback(
asset,
state,
actor=actor,
reason=";".join(result["reasons"]),
automatic=True,
)
self._audit(
asset,
actor=actor,
action="evaluate_agent_asset_release",
after={
"stage": stage,
"status": result["status"],
"reasons": result["reasons"],
"automatic_rollback": result["status"] == "failed",
},
request_id=request_id,
)
self.db.commit()
self.db.refresh(run)
return {
"test_run_id": run.id,
"stage": stage,
**result,
"release_stage": self._state(asset).get("stage"),
}
def promote(
self,
asset_id: str,
*,
actor: str,
tenant_id: str | None = None,
allow_global_management: bool = False,
request_id: str | None = None,
) -> dict[str, Any]:
asset = self._asset(
asset_id,
tenant_id=tenant_id,
allow_global_management=allow_global_management,
lock=True,
)
state = self._state(asset)
stage = str(state.get("stage") or "")
if stage not in {"shadow", "canary"}:
raise ValueError("只有 shadow 或 canary 阶段可以晋级。")
candidate_version = str(state.get("candidate_version") or "")
if asset.asset_type == AgentAssetType.RULE.value:
self._require_release_state_integrity(asset, state)
latest = self._latest_stage_run(
asset.id,
candidate_version,
stage,
release_id=str(state.get("release_id") or ""),
)
if latest is None or latest.status != "passed" or not latest.passed:
raise PermissionError(f"{stage} 阶段尚无通过的质量评测,不能晋级。")
target = "canary" if stage == "shadow" else "active"
if asset.asset_type == AgentAssetType.RULE.value:
self._require_approved_rule_version(asset, candidate_version)
if target == "active":
if asset.asset_type == AgentAssetType.RULE.value:
self.artifacts.apply_candidate(asset, state, actor=actor)
asset.published_version = candidate_version
asset.current_version = candidate_version
asset.working_version = candidate_version
asset.status = AgentAssetStatus.ACTIVE.value
self._transition(state, stage, target, actor=actor, reason="quality_gate_passed")
self._save_state(asset, state, commit=False)
self._audit(
asset,
actor=actor,
action="promote_agent_asset_release",
after={"from": stage, "to": target, "candidate_version": candidate_version},
request_id=request_id,
)
self.db.commit()
return state
def rollback(
self,
asset_id: str,
*,
actor: str,
reason: str,
tenant_id: str | None = None,
allow_global_management: bool = False,
request_id: str | None = None,
) -> dict[str, Any]:
asset = self._asset(
asset_id,
tenant_id=tenant_id,
allow_global_management=allow_global_management,
lock=True,
)
state = self._state(asset)
if state.get("stage") not in {"shadow", "canary", "active"}:
raise ValueError("当前发布状态不能回滚。")
self._apply_rollback(asset, state, actor=actor, reason=reason, automatic=False)
self._audit(
asset,
actor=actor,
action="rollback_agent_asset_release",
after={"stage": "rolled_back", "reason": reason, "automatic": False},
request_id=request_id,
)
self.db.commit()
return state
def get_state(
self,
asset_id: str,
*,
tenant_id: str | None = None,
allow_global_management: bool = False,
) -> dict[str, Any]:
return self._state(
self._asset(
asset_id,
tenant_id=tenant_id,
allow_global_management=allow_global_management,
)
)
def get_serving_plan(
self,
asset_id: str,
*,
tenant_id: str | None = None,
allow_global_management: bool = False,
) -> dict[str, Any]:
"""返回运行时可直接消费的候选/主版本路由计划。"""
asset = self._asset(
asset_id,
tenant_id=tenant_id,
allow_global_management=allow_global_management,
)
state = self._state(asset)
stage = str(state.get("stage") or "")
candidate = str(state.get("candidate_version") or "")
previous = str(state.get("previous_version") or asset.published_version or "")
policy = self._normalized_policy(state.get("policy"))
if stage == "shadow":
return {
"stage": stage,
"primary_version": previous,
"candidate_version": candidate,
"candidate_traffic_percent": 0,
"shadow_evaluation": True,
}
if stage == "canary":
return {
"stage": stage,
"primary_version": previous,
"candidate_version": candidate,
"candidate_traffic_percent": policy["canary_traffic_percent"],
"shadow_evaluation": False,
}
if stage == "active":
return {
"stage": stage,
"primary_version": candidate,
"candidate_version": candidate,
"candidate_traffic_percent": 100,
"shadow_evaluation": False,
}
return {
"stage": stage or "unmanaged",
"primary_version": previous or str(asset.published_version or ""),
"candidate_version": candidate,
"candidate_traffic_percent": 0,
"shadow_evaluation": False,
}
def _evaluate(
self,
stage: str,
evaluation: ReleaseEvaluationInput,
policy: dict[str, Any],
) -> dict[str, Any]:
return evaluate_release(stage, evaluation, policy)
def _apply_rollback(
self,
asset: AgentAsset,
state: dict[str, Any],
*,
actor: str,
reason: str,
automatic: bool,
) -> None:
previous = str(state.get("previous_version") or "")
candidate = str(state.get("candidate_version") or "")
asset.published_version = previous or None
if previous:
asset.current_version = previous
asset.working_version = previous
asset.status = AgentAssetStatus.ACTIVE.value
else:
asset.current_version = candidate or asset.current_version
asset.working_version = candidate or asset.working_version
asset.status = AgentAssetStatus.REVIEW.value
state["rollback"] = {
"automatic": automatic,
"reason": str(reason or "release_guard_triggered"),
"actor": actor,
"at": _now(),
"restored_version": previous,
}
self._transition(
state,
str(state.get("stage") or "unknown"),
"rolled_back",
actor=actor,
reason=str(reason or "release_guard_triggered"),
)
if asset.asset_type == AgentAssetType.RULE.value:
self.artifacts.restore_previous(asset, state)
self._save_state(asset, state, commit=False)
@staticmethod
def _transition(
state: dict[str, Any],
source: str,
target: ReleaseStage,
*,
actor: str,
reason: str,
) -> None:
now = _now()
history = list(state.get("history") if isinstance(state.get("history"), list) else [])
history.append({"from": source, "to": target, "actor": actor, "at": now, "reason": reason})
state["history"] = history[-20:]
state["stage"] = target
state["updated_at"] = now
def _save_state(
self,
asset: AgentAsset,
state: dict[str, Any],
*,
commit: bool = True,
) -> None:
config = dict(asset.config_json or {})
config[self.CONFIG_KEY] = state
asset.config_json = config
self.db.add(asset)
if commit:
self.db.commit()
@staticmethod
def _state(asset: AgentAsset) -> dict[str, Any]:
config = asset.config_json if isinstance(asset.config_json, dict) else {}
value = config.get(AgentAssetReleaseGuardService.CONFIG_KEY)
return dict(value) if isinstance(value, dict) else {}
def _asset(
self,
asset_id: str,
*,
tenant_id: str | None = None,
allow_global_management: bool = False,
lock: bool = False,
) -> AgentAsset:
requested_tenant = str(tenant_id or "").strip()
stmt = select(AgentAsset).where(AgentAsset.id == asset_id)
if requested_tenant:
stmt = stmt.where(
(
(AgentAsset.scope == "tenant")
& (AgentAsset.tenant_id == requested_tenant)
)
| (
(AgentAsset.scope == "platform")
& (AgentAsset.tenant_id == "platform")
)
)
else:
stmt = stmt.where(
AgentAsset.scope == "platform",
AgentAsset.tenant_id == "platform",
)
if lock:
stmt = stmt.with_for_update()
asset = self.db.scalar(stmt)
if asset is None:
raise LookupError("Agent asset not found")
configured_tenant = str((asset.config_json or {}).get("tenant_id") or "").strip()
if requested_tenant:
if asset.scope == "tenant" and configured_tenant not in {
"",
requested_tenant,
}:
raise LookupError("Agent asset not found")
if asset.scope == "platform" and not allow_global_management:
# 未绑定租户的规则是平台共享资产,租户管理员不得修改其发布状态。
raise LookupError("Agent asset not found")
return asset
def _version(self, asset_id: str, version: str) -> AgentAssetVersion | None:
return self.db.scalar(
select(AgentAssetVersion).where(
AgentAssetVersion.asset_id == asset_id,
AgentAssetVersion.version == version,
)
)
def _latest_stage_run(
self,
asset_id: str,
version: str,
stage: str,
*,
release_id: str,
) -> AgentAssetTestRun | None:
runs = list(
self.db.scalars(
select(AgentAssetTestRun)
.where(
AgentAssetTestRun.asset_id == asset_id,
AgentAssetTestRun.version == version,
AgentAssetTestRun.test_type == f"release_{stage}",
)
.order_by(AgentAssetTestRun.created_at.desc(), AgentAssetTestRun.id.desc())
.limit(50)
).all()
)
return next(
(
run
for run in runs
if str((run.input_json or {}).get("release_id") or "") == release_id
),
None,
)
def _require_approved_rule_version(self, asset: AgentAsset, version: str) -> None:
if asset.asset_type != AgentAssetType.RULE.value:
return
review = self.db.scalar(
select(AgentAssetReview)
.where(
AgentAssetReview.asset_id == asset.id,
AgentAssetReview.version == version,
)
.order_by(AgentAssetReview.created_at.desc(), AgentAssetReview.id.desc())
)
if review is None or review.review_status != AgentReviewStatus.APPROVED.value:
raise PermissionError("风险规则候选版本未经审核批准,不能进入 Canary 或 active。")
def _require_release_state_integrity(
self,
asset: AgentAsset,
state: dict[str, Any],
) -> None:
candidate = str(state.get("candidate_version") or "").strip()
previous = str(state.get("previous_version") or "").strip()
AgentAssetReleaseArtifactService.artifact(state, candidate)
if previous:
AgentAssetReleaseArtifactService.artifact(state, previous)
if str(asset.published_version or "").strip() != previous:
raise PermissionError("发布期间基线版本发生变化,已停止晋级。")
if asset.status != AgentAssetStatus.ACTIVE.value:
raise PermissionError("发布期间基线规则已不再生效,已停止晋级。")
else:
if str(asset.published_version or "").strip():
raise PermissionError("发布期间资产发布版本发生变化,已停止晋级。")
if asset.status != AgentAssetStatus.REVIEW.value:
raise PermissionError("候选规则已不在待审核状态,已停止晋级。")
config = asset.config_json if isinstance(asset.config_json, dict) else {}
if config.get("enabled") is False:
raise PermissionError("发布期间风险规则已被停用,已停止晋级。")
def _require_rule_release_preconditions(
self,
asset: AgentAsset,
version: str,
artifacts: dict[str, dict[str, Any]],
*,
actor: str,
) -> None:
report = self.db.scalar(
select(AgentAssetTestRun)
.where(
AgentAssetTestRun.asset_id == asset.id,
AgentAssetTestRun.version == version,
AgentAssetTestRun.test_type == "report",
)
.order_by(AgentAssetTestRun.created_at.desc(), AgentAssetTestRun.id.desc())
)
if report is None or not report.passed or report.status != "passed":
raise PermissionError("候选风险规则尚无通过的测试报告,不能进入影子发布。")
artifact = artifacts.get(version)
manifest = artifact.get("manifest") if isinstance(artifact, dict) else None
if not isinstance(manifest, dict):
raise PermissionError("候选风险规则缺少可信运行快照。")
rule_code = str(manifest.get("rule_code") or "").strip()
from app.services.risk_rule_golden_evaluator import RiskRuleGoldenEvaluator
RiskRuleGoldenEvaluator().require_pass(
self.db,
asset,
version,
manifest,
rule_code,
actor=actor,
)
review = self.db.scalar(
select(AgentAssetReview)
.where(
AgentAssetReview.asset_id == asset.id,
AgentAssetReview.version == version,
)
.order_by(AgentAssetReview.created_at.desc(), AgentAssetReview.id.desc())
)
if review is None or review.review_status != AgentReviewStatus.APPROVED.value:
self.db.add(
AgentAssetReview(
asset_id=asset.id,
version=version,
reviewer=actor,
review_status=AgentReviewStatus.APPROVED.value,
review_note="批准候选版本进入受控分阶段发布。",
reviewed_at=datetime.now(UTC),
created_at=datetime.now(UTC),
)
)
asset.reviewer = actor
@staticmethod
def _require_rule_start_lifecycle(asset: AgentAsset, version: str) -> None:
working = str(asset.working_version or asset.current_version or "").strip()
published = str(asset.published_version or "").strip()
if version != working:
raise PermissionError("只能发布当前工作版本,历史版本不能直接进入分阶段发布。")
if published:
if asset.status != AgentAssetStatus.ACTIVE.value:
raise PermissionError("只有当前基线仍在线的规则才能发布修订候选。")
if version == published:
raise ValueError("候选版本已是当前发布版本,无需重复发布。")
return
if asset.status != AgentAssetStatus.REVIEW.value:
raise PermissionError("首次发布前必须先完成测试并提交审核。")
def _audit(
self,
asset: AgentAsset,
*,
actor: str,
action: str,
after: dict[str, Any],
request_id: str | None,
) -> None:
self.audit_service.log_action(
actor=actor,
action=action,
resource_type=asset.asset_type,
resource_id=asset.id,
after_json=after,
request_id=request_id,
commit=False,
)
@staticmethod
def _normalized_policy(value: Any) -> dict[str, Any]:
return normalize_release_policy(value)
def _now() -> str:
return datetime.now(UTC).isoformat()

View File

@@ -0,0 +1,69 @@
"""发布复核标签的独立人员票数归并。"""
from __future__ import annotations
from collections.abc import Iterable
from dataclasses import dataclass
from typing import Any
_CANONICAL_LABELS = {
"confirmed": "confirmed",
"risk_present": "confirmed",
"false_positive": "false_positive",
"risk_absent": "false_positive",
}
@dataclass(frozen=True, slots=True)
class ReleaseLabelVoteState:
label: str | None
reviewer_count: int
required_reviewers: int
conflicted: bool
def resolve_release_label_votes(
labels: Iterable[Any],
*,
required_reviewers: int,
required_reviewers_by_observation: dict[str, int] | None = None,
) -> dict[str, ReleaseLabelVoteState]:
default_quorum = max(1, min(2, int(required_reviewers)))
quorum_by_observation = required_reviewers_by_observation or {}
latest_by_actor: dict[tuple[str, str], str] = {}
for item in labels:
observation_id = str(item.observation_id)
actor = str(item.actor_fingerprint)
label = _CANONICAL_LABELS.get(str(item.label))
if label is not None:
latest_by_actor[(observation_id, actor)] = label
votes: dict[str, dict[str, int]] = {}
reviewers: dict[str, int] = {}
for (observation_id, _actor), label in latest_by_actor.items():
reviewers[observation_id] = reviewers.get(observation_id, 0) + 1
bucket = votes.setdefault(observation_id, {})
bucket[label] = bucket.get(label, 0) + 1
states: dict[str, ReleaseLabelVoteState] = {}
for observation_id, counts in votes.items():
quorum = max(
1,
min(2, int(quorum_by_observation.get(observation_id, default_quorum))),
)
eligible = [label for label, count in counts.items() if count >= quorum]
states[observation_id] = ReleaseLabelVoteState(
label=eligible[0] if len(eligible) == 1 else None,
reviewer_count=reviewers[observation_id],
required_reviewers=quorum,
conflicted=len(counts) > 1,
)
return states
def release_reviewer_quorum(state: dict[str, Any]) -> int:
policy = state.get("policy") if isinstance(state.get("policy"), dict) else {}
try:
return max(1, min(2, int(policy.get("reviewer_quorum") or 1)))
except (TypeError, ValueError, OverflowError):
return 1

View File

@@ -0,0 +1,457 @@
"""用数据库真实发布遥测驱动 Release Guard 的协调器。"""
from __future__ import annotations
import hashlib
from typing import Any
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.core.agent_enums import AgentAssetDomain, AgentAssetStatus, AgentAssetType
from app.models.agent_asset import AgentAsset, AgentAssetTestRun
from app.services.agent_asset_release_alerts import build_release_alerts
from app.services.agent_asset_release_guard import AgentAssetReleaseGuardService
from app.services.agent_asset_release_telemetry import (
AgentAssetReleaseTelemetryService,
ReleaseTelemetryAggregate,
ReleaseTelemetryStaleRelease,
)
_MONITORED_STAGES = {"shadow", "canary", "active"}
_MAX_BATCH_SIZE = 500
class AgentAssetReleaseMonitor:
"""只接受 release 身份,绝不接受调用方提供的质量汇总数字。"""
def __init__(
self,
db: Session,
*,
telemetry_service: AgentAssetReleaseTelemetryService | None = None,
guard_service: AgentAssetReleaseGuardService | None = None,
) -> None:
self.db = db
self.telemetry = telemetry_service or AgentAssetReleaseTelemetryService(db)
self.guard = guard_service or AgentAssetReleaseGuardService(db)
def evaluate_current(
self,
*,
tenant_id: str,
asset_id: str,
actor: str,
allow_global_management: bool = False,
) -> dict[str, Any]:
"""聚合当前 release只有真实标签完备后才调用 Release Guard。"""
tenant = _required(tenant_id, "tenant_id", 64)
target_asset = _required(asset_id, "asset_id", 36)
normalized_actor = _required(actor, "actor", 100)
_require_bool(allow_global_management, "allow_global_management")
asset, state = self._load_target(
tenant_id=tenant,
asset_id=target_asset,
allow_global_management=allow_global_management,
lock=False,
)
identity = self._release_identity(asset, state)
aggregate = self.telemetry.aggregate(
tenant_id=tenant,
asset_id=asset.id,
release_id=identity["release_id"],
stage=identity["stage"],
version=identity["version"],
)
if aggregate.status == "collecting":
return self._collecting_result(aggregate)
# 在把指标交给 Guard 前锁定资产并重验 release 身份,避免把旧阶段
# 的真实指标写入刚刚晋级或重启的新 release。
locked_asset, locked_state = self._load_target(
tenant_id=tenant,
asset_id=target_asset,
allow_global_management=allow_global_management,
lock=True,
)
if self._release_identity(locked_asset, locked_state) != identity:
raise ReleaseTelemetryStaleRelease(
"Release changed after telemetry aggregation; evaluation was not submitted."
)
evaluation = aggregate.to_release_evaluation_input()
existing_run = self._existing_evaluation(
aggregate=aggregate,
evaluation_input={
"total": evaluation.total,
"failure_count": evaluation.failure_count,
"precision": evaluation.precision,
"baseline_precision": evaluation.baseline_precision,
"release_id": identity["release_id"],
},
)
if existing_run is not None:
result = existing_run.result_json if isinstance(existing_run.result_json, dict) else {}
release_stage = str(locked_state.get("stage") or identity["stage"])
payload = {
"asset_id": locked_asset.id,
"release_id": identity["release_id"],
"stage": identity["stage"],
"version": identity["version"],
"telemetry_status": "ready",
"status": str(existing_run.status or "unknown"),
"evaluation_submitted": True,
"release_stage": release_stage,
"rolled_back": release_stage == "rolled_back",
"reasons": list(result.get("reasons") or []),
"test_run_id": existing_run.id,
"metrics": self._metrics(aggregate),
}
payload["alerts"] = build_release_alerts(
status=payload["status"],
rolled_back=payload["rolled_back"],
reasons=payload["reasons"],
metrics=payload["metrics"],
)
return payload
guard_result = self.guard.record_evaluation(
locked_asset.id,
evaluation,
actor=normalized_actor,
tenant_id=tenant,
allow_global_management=allow_global_management,
request_id=self._request_id(aggregate),
)
release_stage = str(guard_result.get("release_stage") or identity["stage"])
payload = {
"asset_id": locked_asset.id,
"release_id": identity["release_id"],
"stage": identity["stage"],
"version": identity["version"],
"telemetry_status": "ready",
"status": str(guard_result.get("status") or "unknown"),
"evaluation_submitted": True,
"release_stage": release_stage,
"rolled_back": release_stage == "rolled_back",
"reasons": list(guard_result.get("reasons") or []),
"test_run_id": guard_result.get("test_run_id"),
"metrics": self._metrics(aggregate),
}
payload["alerts"] = build_release_alerts(
status=payload["status"],
rolled_back=payload["rolled_back"],
reasons=payload["reasons"],
metrics=payload["metrics"],
)
return payload
def batch_evaluate(
self,
*,
tenant_id: str,
actor: str,
allow_global_management: bool = False,
limit: int = 100,
after_asset_id: str | None = None,
) -> dict[str, Any]:
"""有界扫描当前受控风险规则,并将每个资产的失败隔离。"""
tenant = _required(tenant_id, "tenant_id", 64)
normalized_actor = _required(actor, "actor", 100)
_require_bool(allow_global_management, "allow_global_management")
normalized_limit = _batch_limit(limit)
normalized_cursor = str(after_asset_id or "").strip() or None
assets = self._batch_targets(
tenant_id=tenant,
limit=normalized_limit,
after_asset_id=normalized_cursor,
)
results: list[dict[str, Any]] = []
errors: list[dict[str, Any]] = []
evaluated = 0
collecting = 0
rolled_back = 0
for asset in assets:
try:
result = self.evaluate_current(
tenant_id=tenant,
asset_id=asset.id,
actor=normalized_actor,
allow_global_management=allow_global_management,
)
except Exception as error:
# Guard 在成功路径自行提交;失败路径必须回滚当前 Session
# 防止一个资产留下的失败事务污染后续资产。
self.db.rollback()
errors.append(
{
"asset_id": asset.id,
"error_type": type(error).__name__,
"message": _safe_error(error),
"alerts": build_release_alerts(
status="collecting",
rolled_back=False,
reasons=("telemetry_aggregation_failed",),
metrics={"aggregation_failed": True},
),
}
)
continue
results.append(result)
if result["evaluation_submitted"]:
evaluated += 1
if result["status"] == "collecting":
collecting += 1
if result["rolled_back"]:
rolled_back += 1
return {
"tenant_id": tenant,
"limit": normalized_limit,
"scanned": len(assets),
"evaluated": evaluated,
"collecting": collecting,
"rolled_back": rolled_back,
"next_cursor": assets[-1].id if assets else normalized_cursor,
"errors": errors,
"results": results,
}
def _existing_evaluation(
self,
*,
aggregate: ReleaseTelemetryAggregate,
evaluation_input: dict[str, Any],
) -> AgentAssetTestRun | None:
"""相同 release 与真实聚合快照只产生一次测试运行。"""
runs = self.db.scalars(
select(AgentAssetTestRun)
.where(
AgentAssetTestRun.tenant_id == aggregate.tenant_id,
AgentAssetTestRun.scope == "tenant",
AgentAssetTestRun.asset_id == aggregate.asset_id,
AgentAssetTestRun.version == aggregate.version,
AgentAssetTestRun.test_type == f"release_{aggregate.stage}",
)
.order_by(AgentAssetTestRun.created_at.desc(), AgentAssetTestRun.id.desc())
)
for run in runs:
if run.input_json == evaluation_input:
return run
return None
def _load_target(
self,
*,
tenant_id: str,
asset_id: str,
allow_global_management: bool,
lock: bool,
) -> tuple[AgentAsset, dict[str, Any]]:
statement = select(AgentAsset).where(
AgentAsset.id == asset_id,
(
((AgentAsset.scope == "tenant") & (AgentAsset.tenant_id == tenant_id))
| ((AgentAsset.scope == "platform") & (AgentAsset.tenant_id == "platform"))
),
)
if lock:
statement = statement.with_for_update().execution_options(populate_existing=True)
asset = self.db.scalar(statement)
if asset is None:
raise LookupError("Agent asset not found.")
config = asset.config_json if isinstance(asset.config_json, dict) else {}
configured_tenant = str(config.get("tenant_id") or "").strip()
if asset.scope == "tenant":
if configured_tenant not in {"", tenant_id}:
raise LookupError("Agent asset not found.")
else:
# 全局资产会命中多个租户;用某一个当前用户的 tenant_id 聚合会把
# 局部样本误当成全局事实。跨租户聚合协议完成前,即使平台管理员
# 也只能显式回滚,不能触发自动评测。
if allow_global_management:
raise ValueError(
"Global release telemetry requires a cross-tenant aggregate and "
"cannot be evaluated by the tenant monitor."
)
raise LookupError("Agent asset not found.")
if (
asset.asset_type != AgentAssetType.RULE.value
or asset.domain != AgentAssetDomain.EXPENSE.value
or str(config.get("detail_mode") or "").strip().lower() != "json_risk"
):
raise ValueError("Asset is not an expense JSON risk rule.")
if asset.status == AgentAssetStatus.DISABLED.value or config.get("enabled") is False:
raise ValueError("Disabled risk rules cannot be evaluated by the release monitor.")
state = config.get("release_guard")
if not isinstance(state, dict):
raise ValueError("Asset has no active release guard state.")
return asset, dict(state)
@staticmethod
def _release_identity(asset: AgentAsset, state: dict[str, Any]) -> dict[str, str]:
stage = str(state.get("stage") or "").strip().lower()
if stage not in _MONITORED_STAGES:
raise ValueError("Asset is not in a monitored release stage.")
return {
"asset_id": asset.id,
"release_id": _required(state.get("release_id"), "release_id", 64),
"stage": stage,
"version": _required(state.get("candidate_version"), "candidate_version", 30),
}
def _batch_targets(
self,
*,
tenant_id: str,
limit: int,
after_asset_id: str | None,
) -> list[AgentAsset]:
config = AgentAsset.config_json
tenant_value = config["tenant_id"].as_string()
base = (
select(AgentAsset)
.where(
AgentAsset.asset_type == AgentAssetType.RULE.value,
AgentAsset.domain == AgentAssetDomain.EXPENSE.value,
AgentAsset.scope == "tenant",
AgentAsset.tenant_id == tenant_id,
config["detail_mode"].as_string() == "json_risk",
config["release_guard"]["stage"].as_string().in_(sorted(_MONITORED_STAGES)),
tenant_value == tenant_id,
)
.order_by(AgentAsset.id.asc())
)
if not after_asset_id:
return list(self.db.scalars(base.limit(limit)).all())
# 游标扫描到尾部后从头补足本轮,避免固定 limit 永远只评测 ID 最小的资产。
after = list(
self.db.scalars(
base.where(AgentAsset.id > after_asset_id).limit(limit)
).all()
)
remaining = limit - len(after)
if remaining <= 0:
return after
wrapped = list(
self.db.scalars(
base.where(AgentAsset.id <= after_asset_id).limit(remaining)
).all()
)
return [*after, *wrapped]
@staticmethod
def _collecting_result(aggregate: ReleaseTelemetryAggregate) -> dict[str, Any]:
payload = {
"asset_id": aggregate.asset_id,
"release_id": aggregate.release_id,
"stage": aggregate.stage,
"version": aggregate.version,
"telemetry_status": "collecting",
"status": "collecting",
"evaluation_submitted": False,
"release_stage": aggregate.stage,
"rolled_back": False,
"reasons": list(aggregate.reasons),
"test_run_id": None,
"metrics": AgentAssetReleaseMonitor._metrics(aggregate),
}
payload["alerts"] = build_release_alerts(
status=payload["status"],
rolled_back=False,
reasons=payload["reasons"],
metrics=payload["metrics"],
)
return payload
@staticmethod
def _metrics(aggregate: ReleaseTelemetryAggregate) -> dict[str, Any]:
observed_count = aggregate.observed_count
return {
"observed_count": observed_count,
"completed_count": aggregate.completed_count,
"runtime_failure_count": aggregate.runtime_failure_count,
"runtime_failure_rate": (
aggregate.runtime_failure_count / observed_count if observed_count else None
),
"candidate_hit_count": aggregate.candidate_hit_count,
"candidate_labeled_count": aggregate.candidate_labeled_count,
"candidate_pending_label_count": aggregate.candidate_pending_label_count,
"precision": aggregate.precision,
"baseline_hit_count": aggregate.baseline_hit_count,
"baseline_labeled_count": aggregate.baseline_labeled_count,
"baseline_pending_label_count": aggregate.baseline_pending_label_count,
"baseline_precision": aggregate.baseline_precision,
"false_negative_count": aggregate.false_negative_count,
"estimated_false_negative_count": aggregate.estimated_false_negative_count,
"false_negative_upper_bound": aggregate.false_negative_upper_bound,
"negative_sample_count": aggregate.negative_sample_count,
"negative_labeled_count": aggregate.negative_labeled_count,
"negative_pending_label_count": aggregate.negative_pending_label_count,
"random_negative_population_count": aggregate.random_negative_population_count,
"random_negative_sample_count": aggregate.random_negative_sample_count,
"random_negative_labeled_count": aggregate.random_negative_labeled_count,
"recall": aggregate.recall,
"recall_lower_bound": aggregate.recall_lower_bound,
"recall_confidence_level": aggregate.recall_confidence_level,
"recall_method": aggregate.recall_method,
"negative_ground_truth_status": aggregate.negative_ground_truth_status,
}
@staticmethod
def _request_id(aggregate: ReleaseTelemetryAggregate) -> str:
source = "\x1f".join(
str(value)
for value in (
aggregate.tenant_id,
aggregate.asset_id,
aggregate.release_id,
aggregate.stage,
aggregate.version,
aggregate.observed_count,
aggregate.runtime_failure_count,
aggregate.candidate_labeled_count,
aggregate.candidate_confirmed_count,
aggregate.candidate_false_positive_count,
aggregate.baseline_labeled_count,
aggregate.baseline_confirmed_count,
aggregate.baseline_false_positive_count,
aggregate.negative_sample_count,
aggregate.negative_labeled_count,
aggregate.false_negative_count,
aggregate.recall,
aggregate.recall_lower_bound,
)
)
return f"release-monitor:{hashlib.sha256(source.encode('utf-8')).hexdigest()[:32]}"
def _required(value: Any, field: str, maximum: int) -> str:
normalized = str(value or "").strip()
if not normalized or len(normalized) > maximum:
raise ValueError(f"{field} is required and must be at most {maximum} characters.")
return normalized
def _require_bool(value: Any, field: str) -> None:
if not isinstance(value, bool):
raise ValueError(f"{field} must be a boolean.")
def _batch_limit(value: Any) -> int:
if isinstance(value, bool):
raise ValueError("limit must be an integer.")
try:
parsed = int(value)
except (TypeError, ValueError, OverflowError) as error:
raise ValueError("limit must be an integer.") from error
if parsed < 1 or parsed > _MAX_BATCH_SIZE:
raise ValueError(f"limit must be between 1 and {_MAX_BATCH_SIZE}.")
return parsed
def _safe_error(error: Exception) -> str:
message = " ".join(str(error).split())
return message[:240] or type(error).__name__

View File

@@ -0,0 +1,119 @@
"""受控发布监控指标的 HMAC 认证。"""
from __future__ import annotations
import hashlib
import hmac
import json
import os
import time
from typing import Any
RELEASE_MONITOR_SECRET_ENV = "AGENT_RELEASE_MONITOR_SECRET"
MAX_CLOCK_SKEW_SECONDS = 300
class ReleaseMonitorConfigurationError(RuntimeError):
"""发布监控认证尚未完成安全配置。"""
class ReleaseMonitorAuthenticationError(PermissionError):
"""发布监控签名不可信。"""
def build_release_monitor_signature(
*,
timestamp: str,
tenant_id: str,
asset_id: str,
release_id: str,
stage: str,
payload: dict[str, Any],
secret: str | None = None,
) -> str:
signing_secret = _resolve_secret(secret)
message = _signature_message(
timestamp=timestamp,
tenant_id=tenant_id,
asset_id=asset_id,
release_id=release_id,
stage=stage,
payload=payload,
)
return hmac.new(
signing_secret.encode("utf-8"),
message.encode("utf-8"),
hashlib.sha256,
).hexdigest()
def require_release_monitor_signature(
*,
timestamp: str | None,
signature: str | None,
tenant_id: str,
asset_id: str,
release_id: str,
stage: str,
payload: dict[str, Any],
now_epoch_seconds: int | None = None,
) -> None:
normalized_timestamp = str(timestamp or "").strip()
normalized_signature = str(signature or "").strip().lower()
if not normalized_timestamp or not normalized_signature:
raise ReleaseMonitorAuthenticationError("发布评测缺少监控签名或时间戳。")
try:
parsed_timestamp = int(normalized_timestamp)
except (TypeError, ValueError, OverflowError) as exc:
raise ReleaseMonitorAuthenticationError("发布评测时间戳格式无效。") from exc
now = int(time.time()) if now_epoch_seconds is None else int(now_epoch_seconds)
if abs(now - parsed_timestamp) > MAX_CLOCK_SKEW_SECONDS:
raise ReleaseMonitorAuthenticationError("发布评测签名已过期或服务器时钟偏差过大。")
expected = build_release_monitor_signature(
timestamp=normalized_timestamp,
tenant_id=tenant_id,
asset_id=asset_id,
release_id=release_id,
stage=stage,
payload=payload,
)
if not hmac.compare_digest(normalized_signature, expected):
raise ReleaseMonitorAuthenticationError("发布评测监控签名无效。")
def _signature_message(
*,
timestamp: str,
tenant_id: str,
asset_id: str,
release_id: str,
stage: str,
payload: dict[str, Any],
) -> str:
canonical_payload = json.dumps(
payload,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
default=str,
)
payload_hash = hashlib.sha256(canonical_payload.encode("utf-8")).hexdigest()
return "\n".join(
(
str(timestamp).strip(),
str(tenant_id).strip(),
str(asset_id).strip(),
str(release_id).strip(),
str(stage).strip(),
payload_hash,
)
)
def _resolve_secret(value: str | None) -> str:
secret = str(value if value is not None else os.environ.get(RELEASE_MONITOR_SECRET_ENV, ""))
if len(secret.encode("utf-8")) < 32:
raise ReleaseMonitorConfigurationError(
f"{RELEASE_MONITOR_SECRET_ENV} 必须配置为至少 32 字节的独立密钥。"
)
return secret

View File

@@ -0,0 +1,257 @@
"""Agent 资产分阶段发布的纯策略归一化与质量门禁计算。"""
from __future__ import annotations
from dataclasses import dataclass
from math import isfinite
from typing import Any, Literal
ReleaseStage = Literal["shadow", "canary", "active", "rolled_back"]
@dataclass(frozen=True)
class ReleaseGuardPolicy:
shadow_min_samples: int = 20
canary_min_samples: int = 100
max_error_rate: float = 0.02
min_precision: float = 0.98
max_precision_drop: float = 0.02
canary_traffic_percent: int = 5
reviewer_quorum: int = 1
recall_gate_enabled: bool = True
negative_sample_percent: int = 20
negative_min_reviewed: int = 5
min_recall: float = 0.95
recall_confidence_level: float = 0.95
def to_dict(self) -> dict[str, Any]:
return {
"shadow_min_samples": max(1, _safe_int(self.shadow_min_samples, 20)),
"canary_min_samples": max(1, _safe_int(self.canary_min_samples, 100)),
"max_error_rate": _clamp01(_safe_float(self.max_error_rate, 0.02)),
"min_precision": _clamp01(_safe_float(self.min_precision, 0.98)),
"max_precision_drop": _clamp01(_safe_float(self.max_precision_drop, 0.02)),
"canary_traffic_percent": max(
1,
min(50, _safe_int(self.canary_traffic_percent, 5)),
),
"reviewer_quorum": max(1, min(2, _safe_int(self.reviewer_quorum, 1))),
"recall_gate_enabled": _safe_bool(self.recall_gate_enabled, True),
"negative_sample_percent": max(
1,
min(100, _safe_int(self.negative_sample_percent, 20)),
),
"negative_min_reviewed": max(
1,
min(10_000, _safe_int(self.negative_min_reviewed, 5)),
),
"min_recall": _clamp01(_safe_float(self.min_recall, 0.95)),
"recall_confidence_level": _confidence_level(
self.recall_confidence_level
),
}
@dataclass(frozen=True)
class ReleaseEvaluationInput:
total: int
failure_count: int
precision: float | None
baseline_precision: float | None = None
details: dict[str, Any] | None = None
def normalize_release_policy(value: Any) -> dict[str, Any]:
source = value if isinstance(value, dict) else {}
return ReleaseGuardPolicy(
shadow_min_samples=_safe_int(source.get("shadow_min_samples"), 20),
canary_min_samples=_safe_int(source.get("canary_min_samples"), 100),
max_error_rate=_safe_float(source.get("max_error_rate"), 0.02),
min_precision=_safe_float(source.get("min_precision"), 0.98),
max_precision_drop=_safe_float(source.get("max_precision_drop"), 0.02),
canary_traffic_percent=_safe_int(source.get("canary_traffic_percent"), 5),
reviewer_quorum=_safe_int(source.get("reviewer_quorum"), 1),
recall_gate_enabled=_safe_bool(source.get("recall_gate_enabled"), True),
negative_sample_percent=_safe_int(source.get("negative_sample_percent"), 20),
negative_min_reviewed=_safe_int(source.get("negative_min_reviewed"), 5),
min_recall=_safe_float(source.get("min_recall"), 0.95),
recall_confidence_level=_safe_float(
source.get("recall_confidence_level"),
0.95,
),
).to_dict()
def evaluate_release(
stage: str,
evaluation: ReleaseEvaluationInput,
policy: dict[str, Any],
) -> dict[str, Any]:
reasons: list[str] = []
total = _optional_int(evaluation.total)
failures = _optional_int(evaluation.failure_count)
precision = _optional_probability(evaluation.precision)
baseline = _optional_probability(evaluation.baseline_precision)
invalid = total is None or failures is None
normalized_total = total if total is not None else 0
normalized_failures = failures if failures is not None else 0
if not invalid:
invalid = (
normalized_total < 0
or normalized_failures < 0
or normalized_failures > normalized_total
)
if evaluation.precision is not None and precision is None:
invalid = True
if evaluation.baseline_precision is not None and baseline is None:
invalid = True
if invalid:
reasons.append("invalid_evaluation_metrics")
return _evaluation_result("failed", normalized_total, 1.0, precision, reasons)
if normalized_total == 0:
return _evaluation_result(
"collecting",
normalized_total,
0.0,
precision,
["no_evaluation_samples"],
)
error_rate = normalized_failures / normalized_total
if error_rate > float(policy["max_error_rate"]):
reasons.append("error_rate_exceeded")
if precision is not None and precision < float(policy["min_precision"]):
reasons.append("precision_below_threshold")
if baseline is not None and precision is not None:
if baseline - precision > float(policy["max_precision_drop"]):
reasons.append("precision_regression_exceeded")
details = evaluation.details if isinstance(evaluation.details, dict) else {}
pending_release_labels = (
details.get("metric_source") == "release_runtime_telemetry"
and _safe_int(details.get("candidate_pending_label_count"), 0) > 0
)
if precision is None and not (pending_release_labels and not reasons):
reasons.append("precision_metric_missing")
recall_collecting_reason = ""
if policy.get("recall_gate_enabled") is True:
recall_status = str(details.get("negative_ground_truth_status") or "").strip()
recall_lower_raw = details.get("recall_lower_bound")
recall_lower = _optional_probability(recall_lower_raw)
if details.get("metric_source") != "release_runtime_telemetry":
recall_collecting_reason = "negative_ground_truth_evidence_missing"
elif recall_lower_raw is not None and recall_lower is None:
reasons.append("invalid_recall_metric")
elif not recall_status.startswith("available") or recall_lower is None:
recall_collecting_reason = recall_status or "recall_metric_missing"
elif recall_lower < float(policy["min_recall"]):
reasons.append("recall_lower_bound_below_threshold")
if reasons:
return _evaluation_result("failed", normalized_total, error_rate, precision, reasons)
if precision is None:
return _evaluation_result(
"collecting",
normalized_total,
error_rate,
precision,
["precision_metric_missing"],
)
if recall_collecting_reason:
return _evaluation_result(
"collecting",
normalized_total,
error_rate,
precision,
[recall_collecting_reason],
)
minimum = 1
if stage == "shadow":
minimum = int(policy["shadow_min_samples"])
elif stage == "canary":
minimum = int(policy["canary_min_samples"])
if normalized_total < minimum:
return _evaluation_result(
"collecting",
normalized_total,
error_rate,
precision,
["minimum_sample_not_reached"],
)
return _evaluation_result(
"passed",
normalized_total,
error_rate,
precision,
["release_quality_gate_passed"],
)
def _evaluation_result(
status: str,
total: int,
error_rate: float,
precision: float | None,
reasons: list[str],
) -> dict[str, Any]:
return {
"status": status,
"summary": f"release evaluation {status}: {', '.join(reasons)}",
"sample_count": max(0, total),
"error_rate": round(error_rate, 6),
"precision": precision,
"reasons": reasons,
}
def _clamp01(value: float) -> float:
try:
parsed = float(value)
except (TypeError, ValueError):
return 0.0
if not isfinite(parsed):
return 0.0
return max(0.0, min(1.0, parsed))
def _optional_probability(value: float | None) -> float | None:
if value is None:
return None
try:
parsed = float(value)
except (TypeError, ValueError):
return None
if not isfinite(parsed) or parsed < 0 or parsed > 1:
return None
return parsed
def _optional_int(value: Any) -> int | None:
if isinstance(value, bool):
return None
try:
return int(value)
except (TypeError, ValueError, OverflowError):
return None
def _safe_int(value: Any, default: int) -> int:
try:
return int(value)
except (TypeError, ValueError, OverflowError):
return default
def _safe_bool(value: Any, default: bool) -> bool:
return value if isinstance(value, bool) else default
def _confidence_level(value: Any) -> float:
parsed = _safe_float(value, 0.95)
return parsed if parsed in {0.9, 0.95, 0.99} else 0.95
def _safe_float(value: Any, default: float) -> float:
try:
parsed = float(value)
except (TypeError, ValueError, OverflowError):
return default
return parsed if isfinite(parsed) else default

View File

@@ -0,0 +1,161 @@
"""发布负样本抽检的保守召回率估计。"""
from __future__ import annotations
from dataclasses import dataclass
from math import isfinite, sqrt
@dataclass(frozen=True, slots=True)
class ReleaseRecallEstimate:
"""候选规则召回率及其保守下界。
``disagreement_false_negative_count`` 是基线已命中、候选未命中的全量复核结果;
``random_*`` 是候选和基线均未命中人群中的独立随机抽检。两层证据不能直接
混成普通样本比例,因此先估算漏检总量,再计算召回率。
"""
recall: float | None
recall_lower_bound: float | None
estimated_false_negative_count: float | None
false_negative_upper_bound: float | None
random_false_negative_rate: float | None
random_false_negative_rate_upper_bound: float | None
confidence_level: float
method: str
def estimate_release_recall(
*,
true_positive_count: int,
disagreement_false_negative_count: int,
random_negative_population_count: int,
random_reviewed_count: int,
random_false_negative_count: int,
confidence_level: float = 0.95,
) -> ReleaseRecallEstimate:
"""使用分层抽检和 Wilson 上界生成保守召回率。
随机层的漏检率用 Wilson 单侧保守上界近似;该上界投影到完整候选负例人群
后,再反推召回率下界。调用方必须只传已经达到独立复核法定票数的样本。
"""
true_positives = _count(true_positive_count, "true_positive_count")
disagreement_false_negatives = _count(
disagreement_false_negative_count,
"disagreement_false_negative_count",
)
random_population = _count(
random_negative_population_count,
"random_negative_population_count",
)
random_reviewed = _count(random_reviewed_count, "random_reviewed_count")
random_false_negatives = _count(
random_false_negative_count,
"random_false_negative_count",
)
if random_reviewed > random_population:
raise ValueError("random_reviewed_count cannot exceed its population.")
if random_false_negatives > random_reviewed:
raise ValueError("random_false_negative_count cannot exceed reviewed samples.")
confidence = _confidence(confidence_level)
if random_population and not random_reviewed:
return _unavailable(confidence)
random_rate = (
random_false_negatives / random_reviewed if random_reviewed else 0.0
)
random_upper = (
_wilson_upper_bound(
successes=random_false_negatives,
total=random_reviewed,
z=_z_score(confidence),
)
if random_reviewed
else 0.0
)
estimated_false_negatives = (
float(disagreement_false_negatives) + random_rate * random_population
)
false_negative_upper = (
float(disagreement_false_negatives) + random_upper * random_population
)
recall = _recall(true_positives, estimated_false_negatives)
recall_lower_bound = _recall(true_positives, false_negative_upper)
return ReleaseRecallEstimate(
recall=_rounded(recall),
recall_lower_bound=_rounded(recall_lower_bound),
estimated_false_negative_count=_rounded(estimated_false_negatives),
false_negative_upper_bound=_rounded(false_negative_upper),
random_false_negative_rate=_rounded(random_rate),
random_false_negative_rate_upper_bound=_rounded(random_upper),
confidence_level=confidence,
method="stratified_random_audit_wilson_upper_bound",
)
def _unavailable(confidence: float) -> ReleaseRecallEstimate:
return ReleaseRecallEstimate(
recall=None,
recall_lower_bound=None,
estimated_false_negative_count=None,
false_negative_upper_bound=None,
random_false_negative_rate=None,
random_false_negative_rate_upper_bound=None,
confidence_level=confidence,
method="stratified_random_audit_wilson_upper_bound",
)
def _wilson_upper_bound(*, successes: int, total: int, z: float) -> float:
if total <= 0:
raise ValueError("Wilson interval requires at least one reviewed sample.")
probability = successes / total
z_squared = z * z
denominator = 1.0 + z_squared / total
centre = probability + z_squared / (2.0 * total)
margin = z * sqrt(
probability * (1.0 - probability) / total
+ z_squared / (4.0 * total * total)
)
return min(1.0, max(0.0, (centre + margin) / denominator))
def _recall(true_positives: int, false_negatives: float) -> float | None:
denominator = float(true_positives) + false_negatives
if denominator <= 0:
return None
return true_positives / denominator
def _count(value: int, field: str) -> int:
if isinstance(value, bool):
raise ValueError(f"{field} must be a non-negative integer.")
try:
parsed = int(value)
except (TypeError, ValueError, OverflowError) as error:
raise ValueError(f"{field} must be a non-negative integer.") from error
if parsed != value or parsed < 0:
raise ValueError(f"{field} must be a non-negative integer.")
return parsed
def _confidence(value: float) -> float:
try:
parsed = float(value)
except (TypeError, ValueError, OverflowError) as error:
raise ValueError("confidence_level must be 0.90, 0.95 or 0.99.") from error
if not isfinite(parsed) or parsed not in {0.9, 0.95, 0.99}:
raise ValueError("confidence_level must be 0.90, 0.95 or 0.99.")
return parsed
def _z_score(confidence: float) -> float:
return {0.9: 1.6448536269514722, 0.95: 1.959963984540054, 0.99: 2.5758293035489004}[
confidence
]
def _rounded(value: float | None) -> float | None:
return None if value is None else round(value, 6)

View File

@@ -0,0 +1,316 @@
"""Agent 分阶段发布的人工复核队列。"""
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models.agent_asset import AgentAsset
from app.models.agent_asset_release_telemetry import (
AgentAssetReleaseAuditSample,
AgentAssetReleaseLabel,
AgentAssetReleaseObservation,
)
from app.services.agent_asset_release_alerts import build_release_alerts
from app.services.agent_asset_release_label_votes import (
release_reviewer_quorum,
resolve_release_label_votes,
)
from app.services.agent_asset_release_sampling import AgentAssetReleaseSamplingService
from app.services.agent_asset_release_telemetry import AgentAssetReleaseTelemetryService
_REVIEWABLE_STAGES = {"shadow", "canary", "active"}
class AgentAssetReleaseReviewService:
"""混排正负样本且隐藏模型结论,禁止发布发起人自审。"""
def __init__(self, db: Session) -> None:
self.db = db
def list_pending(
self,
*,
tenant_id: str,
asset_id: str,
limit: int = 50,
) -> dict[str, Any]:
tenant, asset, state = self._current_release(tenant_id, asset_id)
normalized_limit = _limit(limit)
samples = list(
self.db.scalars(
select(AgentAssetReleaseAuditSample)
.where(
AgentAssetReleaseAuditSample.tenant_id == tenant,
AgentAssetReleaseAuditSample.asset_id == asset.id,
AgentAssetReleaseAuditSample.release_id == state["release_id"],
AgentAssetReleaseAuditSample.stage == state["stage"],
AgentAssetReleaseAuditSample.version == state["candidate_version"],
)
.order_by(
AgentAssetReleaseAuditSample.selection_score_ppm.asc(),
AgentAssetReleaseAuditSample.created_at.asc(),
AgentAssetReleaseAuditSample.id.asc(),
)
).all()
)
observation_ids = [item.observation_id for item in samples]
observations = {
item.id: item
for item in self.db.scalars(
select(AgentAssetReleaseObservation).where(
AgentAssetReleaseObservation.tenant_id == tenant,
AgentAssetReleaseObservation.id.in_(observation_ids),
)
).all()
} if observation_ids else {}
labels: list[AgentAssetReleaseLabel] = []
if observation_ids:
labels = list(
self.db.scalars(
select(AgentAssetReleaseLabel).where(
AgentAssetReleaseLabel.tenant_id == tenant,
AgentAssetReleaseLabel.observation_id.in_(observation_ids),
)
.order_by(
AgentAssetReleaseLabel.created_at.asc(),
AgentAssetReleaseLabel.id.asc(),
)
).all()
)
quorum = release_reviewer_quorum(state)
vote_states = resolve_release_label_votes(
labels,
required_reviewers=quorum,
required_reviewers_by_observation={
item.observation_id: _sample_quorum(item, quorum) for item in samples
},
)
pending = [
item
for item in samples
if vote_states.get(item.observation_id) is None
or vote_states[item.observation_id].label is None
]
items = [
self._item(
item,
observations[item.observation_id],
vote_states.get(item.observation_id),
_sample_quorum(item, quorum),
tenant,
)
for item in pending[:normalized_limit]
if item.observation_id in observations
]
aggregate = AgentAssetReleaseTelemetryService(self.db).aggregate(
tenant_id=tenant,
asset_id=asset.id,
release_id=state["release_id"],
stage=state["stage"],
version=state["candidate_version"],
)
pending_observations = [
observations[item.observation_id]
for item in pending
if item.observation_id in observations
]
metrics = _metrics(aggregate, pending_observations)
alerts = build_release_alerts(
status=aggregate.status,
rolled_back=False,
reasons=aggregate.reasons,
metrics=metrics,
)
return {
"asset_id": asset.id,
"release_id": state["release_id"],
"stage": state["stage"],
"version": state["candidate_version"],
"pending_total": len(pending),
"telemetry_status": aggregate.status,
"reasons": list(aggregate.reasons),
"metrics": metrics,
"alerts": alerts,
"items": items,
}
def record_label(
self,
*,
tenant_id: str,
asset_id: str,
observation_id: str,
label: str,
actor_id: str,
request_id: str,
) -> AgentAssetReleaseLabel:
tenant, asset, state = self._current_release(tenant_id, asset_id)
actor = _required(actor_id, "actor_id", 160)
if actor == str(state.get("started_by") or "").strip():
raise PermissionError("发布发起人不能复核自己的候选版本。")
sample = self.db.scalar(
select(AgentAssetReleaseAuditSample).where(
AgentAssetReleaseAuditSample.tenant_id == tenant,
AgentAssetReleaseAuditSample.observation_id == _required(
observation_id,
"observation_id",
36,
),
AgentAssetReleaseAuditSample.asset_id == asset.id,
AgentAssetReleaseAuditSample.release_id == state["release_id"],
AgentAssetReleaseAuditSample.stage == state["stage"],
AgentAssetReleaseAuditSample.version == state["candidate_version"],
)
)
if sample is None:
raise LookupError("Release audit sample not found.")
ground_truth = {
"confirmed": "risk_present",
"false_positive": "risk_absent",
"risk_present": "risk_present",
"risk_absent": "risk_absent",
}.get(str(label or "").strip().lower())
if ground_truth is None:
raise ValueError("Blind review requires risk_present or risk_absent.")
return AgentAssetReleaseTelemetryService(self.db).record_blind_review_label(
tenant_id=tenant,
observation_id=sample.observation_id,
ground_truth=ground_truth, # type: ignore[arg-type]
request_id=_required(request_id, "request_id", 160),
actor_id=actor,
)
def _current_release(
self,
tenant_id: str,
asset_id: str,
) -> tuple[str, AgentAsset, dict[str, Any]]:
tenant = _required(tenant_id, "tenant_id", 64)
asset = self.db.scalar(
select(AgentAsset).where(
AgentAsset.id == _required(asset_id, "asset_id", 36),
(
((AgentAsset.scope == "tenant") & (AgentAsset.tenant_id == tenant))
| ((AgentAsset.scope == "platform") & (AgentAsset.tenant_id == "platform"))
),
)
)
if asset is None:
raise LookupError("Agent asset not found.")
config = asset.config_json if isinstance(asset.config_json, dict) else {}
configured_tenant = str(config.get("tenant_id") or "").strip()
if asset.scope == "tenant" and configured_tenant not in {"", tenant}:
raise LookupError("Agent asset not found.")
if asset.scope == "platform" and configured_tenant != tenant:
raise LookupError("Agent asset not found.")
state = config.get("release_guard")
if not isinstance(state, dict):
raise ValueError("Asset has no active release guard state.")
normalized = {
**state,
"release_id": _required(state.get("release_id"), "release_id", 64),
"stage": str(state.get("stage") or "").strip().lower(),
"candidate_version": _required(
state.get("candidate_version"),
"candidate_version",
30,
),
}
if normalized["stage"] not in _REVIEWABLE_STAGES:
raise ValueError("Asset is not in a reviewable release stage.")
return tenant, asset, normalized
def _item(
self,
sample: AgentAssetReleaseAuditSample,
observation: AgentAssetReleaseObservation,
votes: Any,
quorum: int,
tenant_id: str,
) -> dict[str, Any]:
return {
"sample_id": sample.id,
"observation_id": observation.id,
"source_document_id": AgentAssetReleaseSamplingService(
self.db
).source_reference(sample=sample, tenant_id=tenant_id),
"rule_code": observation.rule_code,
"business_stage": observation.business_stage,
"prediction_blinded": True,
"reviewer_count": int(votes.reviewer_count if votes is not None else 0),
"required_reviewers": quorum,
"conflicted": bool(votes.conflicted if votes is not None else False),
"created_at": observation.created_at,
}
def _required(value: Any, field: str, maximum: int) -> str:
normalized = str(value or "").strip()
if not normalized or len(normalized) > maximum:
raise ValueError(f"{field} is required and must be at most {maximum} characters.")
return normalized
def _limit(value: Any) -> int:
if isinstance(value, bool):
raise ValueError("limit must be an integer.")
try:
parsed = int(value)
except (TypeError, ValueError, OverflowError) as error:
raise ValueError("limit must be an integer.") from error
if parsed < 1 or parsed > 100:
raise ValueError("limit must be between 1 and 100.")
return parsed
def _sample_quorum(sample: AgentAssetReleaseAuditSample, default: int) -> int:
return 2 if sample.stratum != "candidate_positive_census" else default
def _metrics(aggregate: Any, pending: list[AgentAssetReleaseObservation]) -> dict[str, Any]:
observed_count = aggregate.observed_count
oldest_pending = min((item.created_at for item in pending), default=None)
return {
"observed_count": observed_count,
"runtime_failure_count": aggregate.runtime_failure_count,
"runtime_failure_rate": (
aggregate.runtime_failure_count / observed_count if observed_count else None
),
"candidate_hit_count": aggregate.candidate_hit_count,
"candidate_labeled_count": aggregate.candidate_labeled_count,
"candidate_pending_label_count": aggregate.candidate_pending_label_count,
"candidate_oldest_pending_at": (
oldest_pending.isoformat() if oldest_pending is not None else None
),
"candidate_oldest_pending_age_seconds": _age_seconds(oldest_pending),
"precision": aggregate.precision,
"baseline_hit_count": aggregate.baseline_hit_count,
"baseline_labeled_count": aggregate.baseline_labeled_count,
"baseline_pending_label_count": aggregate.baseline_pending_label_count,
"baseline_precision": aggregate.baseline_precision,
"false_negative_count": aggregate.false_negative_count,
"estimated_false_negative_count": aggregate.estimated_false_negative_count,
"false_negative_upper_bound": aggregate.false_negative_upper_bound,
"negative_sample_count": aggregate.negative_sample_count,
"negative_labeled_count": aggregate.negative_labeled_count,
"negative_pending_label_count": aggregate.negative_pending_label_count,
"random_negative_population_count": aggregate.random_negative_population_count,
"random_negative_sample_count": aggregate.random_negative_sample_count,
"random_negative_labeled_count": aggregate.random_negative_labeled_count,
"recall": aggregate.recall,
"recall_lower_bound": aggregate.recall_lower_bound,
"recall_confidence_level": aggregate.recall_confidence_level,
"recall_method": aggregate.recall_method,
"negative_ground_truth_status": aggregate.negative_ground_truth_status,
}
def _age_seconds(value: datetime | None) -> int | None:
if value is None:
return None
normalized = value.replace(tzinfo=UTC) if value.tzinfo is None else value.astimezone(UTC)
return max(0, int((datetime.now(UTC) - normalized).total_seconds()))

View File

@@ -0,0 +1,159 @@
"""发布运行观察的确定性分层抽样与加密来源绑定。"""
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.core.secret_box import decrypt_secret, encrypt_secret
from app.models.agent_asset_release_telemetry import (
AgentAssetReleaseAuditSample,
AgentAssetReleaseObservation,
)
from app.services.agent_asset_release_telemetry_crypto import (
deterministic_fingerprint,
json_fingerprint,
stable_uuid,
)
_PPM = 1_000_000
class AgentAssetReleaseSamplingService:
"""正例全量、分歧全量、其余负例按不可预测稳定分数抽检。"""
def __init__(self, db: Session) -> None:
self.db = db
def ensure_sample(
self,
*,
observation: AgentAssetReleaseObservation,
source_reference: str,
release_state: dict[str, Any],
) -> AgentAssetReleaseAuditSample | None:
if observation.runtime_status != "completed":
return None
existing = self._sample_for_observation(observation)
if existing is not None:
return existing
stratum, probability = _sampling_plan(observation, release_state)
score = _selection_score(observation)
if stratum == "candidate_negative_random" and score >= probability:
return None
identity = {
"tenant_id": observation.tenant_id,
"observation_id": observation.id,
"asset_id": observation.asset_id,
"release_id": observation.release_id,
"stage": observation.stage,
"version": observation.version,
"stratum": stratum,
"sampling_probability_ppm": probability,
"selection_score_ppm": score,
}
payload_fingerprint = json_fingerprint(
{**identity, "source_fingerprint": observation.source_fingerprint}
)
idempotency_key = (
"aras:"
+ deterministic_fingerprint(
observation.tenant_id,
observation.id,
observation.release_id,
)[:59]
)
item = AgentAssetReleaseAuditSample(
id=stable_uuid(idempotency_key),
**identity,
source_reference_encrypted=encrypt_secret(
_required(source_reference, "source_reference", 500)
),
idempotency_key=idempotency_key,
payload_fingerprint=payload_fingerprint,
created_at=datetime.now(UTC),
)
try:
with self.db.begin_nested():
self.db.add(item)
self.db.flush()
except IntegrityError:
replay = self._sample_for_observation(observation)
if replay is None or replay.payload_fingerprint != payload_fingerprint:
raise
return replay
return item
def source_reference(
self,
*,
sample: AgentAssetReleaseAuditSample,
tenant_id: str,
) -> str:
if sample.tenant_id != _required(tenant_id, "tenant_id", 64):
raise LookupError("Release audit sample not found.")
return _required(
decrypt_secret(sample.source_reference_encrypted),
"source_reference",
500,
)
def _sample_for_observation(
self,
observation: AgentAssetReleaseObservation,
) -> AgentAssetReleaseAuditSample | None:
return self.db.scalar(
select(AgentAssetReleaseAuditSample).where(
AgentAssetReleaseAuditSample.tenant_id == observation.tenant_id,
AgentAssetReleaseAuditSample.observation_id == observation.id,
)
)
def _sampling_plan(
observation: AgentAssetReleaseObservation,
release_state: dict[str, Any],
) -> tuple[str, int]:
if observation.candidate_hit:
return "candidate_positive_census", _PPM
if observation.baseline_hit is True:
return "candidate_disagreement_census", _PPM
policy = release_state.get("policy")
source = policy if isinstance(policy, dict) else {}
percent = _bounded_int(source.get("negative_sample_percent"), default=20, low=1, high=100)
return "candidate_negative_random", percent * 10_000
def _selection_score(observation: AgentAssetReleaseObservation) -> int:
digest = deterministic_fingerprint(
"release-audit-sample:v1",
observation.tenant_id,
observation.asset_id,
observation.release_id,
observation.stage,
observation.version,
observation.source_fingerprint,
)
return int(digest[:16], 16) % _PPM
def _bounded_int(value: Any, *, default: int, low: int, high: int) -> int:
if isinstance(value, bool):
return default
try:
parsed = int(value)
except (TypeError, ValueError, OverflowError):
return default
return max(low, min(high, parsed))
def _required(value: Any, field: str, maximum: int) -> str:
normalized = str(value or "").strip()
if not normalized or len(normalized) > maximum:
raise ValueError(f"{field} is required and must be at most {maximum} characters.")
return normalized

View File

@@ -0,0 +1,213 @@
"""租户级 Agent 资产真实发布遥测周期监控。"""
from __future__ import annotations
import os
import threading
from collections.abc import Callable
from typing import Any
from sqlalchemy import select, text
from sqlalchemy.orm import Session
from app.core.agent_enums import AgentAssetDomain, AgentAssetStatus, AgentAssetType
from app.core.logging import get_logger
from app.db.session import get_session_factory
from app.models.agent_asset import AgentAsset
from app.services.agent_asset_release_monitor import AgentAssetReleaseMonitor
logger = get_logger("app.services.agent_asset_release_scheduler")
_MONITORED_STAGES = {"shadow", "canary", "active"}
_SCHEDULER_LEASE_KEY = "x-financial:agent-asset-release-scheduler:v1"
class AgentAssetReleaseScheduler:
def __init__(
self,
*,
session_factory: Callable[[], Session] | None = None,
) -> None:
self._interval_seconds = max(
30,
_env_int("X_FINANCIAL_RELEASE_MONITOR_INTERVAL_SECONDS", 60),
)
self._initial_delay_seconds = max(
1,
_env_int("X_FINANCIAL_RELEASE_MONITOR_INITIAL_DELAY_SECONDS", 15),
)
self._batch_size = min(
500,
max(1, _env_int("X_FINANCIAL_RELEASE_MONITOR_BATCH_SIZE", 100)),
)
self._session_factory = session_factory
self._tenant_cursors: dict[str, str] = {}
self._stop_event = threading.Event()
self._thread: threading.Thread | None = None
self._lock = threading.Lock()
def start(self) -> None:
with self._lock:
if self._thread is not None and self._thread.is_alive():
return
self._stop_event.clear()
self._thread = threading.Thread(
target=self._run_loop,
name="agent-asset-release-scheduler",
daemon=True,
)
self._thread.start()
logger.info(
"Agent asset release scheduler started interval=%ss batch=%s",
self._interval_seconds,
self._batch_size,
)
def shutdown(self) -> None:
with self._lock:
thread = self._thread
self._thread = None
self._stop_event.set()
if thread is not None and thread.is_alive():
thread.join(timeout=3)
logger.info("Agent asset release scheduler stopped")
def _run_loop(self) -> None:
if self._stop_event.wait(self._initial_delay_seconds):
return
while not self._stop_event.is_set():
try:
self._run_once()
except Exception: # pragma: no cover - 调度器保底日志
logger.exception("Scheduled Agent asset release monitoring failed")
if self._stop_event.wait(self._interval_seconds):
break
def _run_once(self) -> dict[str, Any]:
factory = self._session_factory or get_session_factory()
db = factory()
lease_acquired = False
try:
lease_acquired = self._try_acquire_lease(db)
if not lease_acquired:
logger.info("Agent asset release monitor cycle skipped: lease held by peer")
return {
"tenants": 0,
"scanned": 0,
"evaluated": 0,
"collecting": 0,
"rolled_back": 0,
"errors": 0,
"global_skipped": 0,
"leader_skipped": 1,
}
tenants, global_count = self._tenant_targets(db)
summary: dict[str, Any] = {
"tenants": len(tenants),
"scanned": 0,
"evaluated": 0,
"collecting": 0,
"rolled_back": 0,
"errors": 0,
"global_skipped": global_count,
}
monitor = AgentAssetReleaseMonitor(db)
for tenant_id in tenants:
result = monitor.batch_evaluate(
tenant_id=tenant_id,
actor="release-telemetry-scheduler",
limit=self._batch_size,
after_asset_id=self._tenant_cursors.get(tenant_id),
)
next_cursor = str(result.get("next_cursor") or "").strip()
if next_cursor:
self._tenant_cursors[tenant_id] = next_cursor
for key in ("scanned", "evaluated", "collecting", "rolled_back"):
summary[key] += int(result[key])
summary["errors"] += len(result["errors"])
if any(summary[key] for key in ("evaluated", "rolled_back", "errors")):
logger.info("Agent asset release monitor cycle summary=%s", summary)
if global_count:
logger.warning(
"Global release assets skipped by tenant scheduler count=%s",
global_count,
)
return summary
except Exception:
db.rollback()
raise
finally:
if lease_acquired:
try:
self._release_lease(db)
except Exception: # pragma: no cover - 数据库连接故障兜底
db.rollback()
logger.exception("Failed to release Agent asset scheduler lease")
db.close()
@staticmethod
def _try_acquire_lease(db: Session) -> bool:
bind = db.get_bind()
if bind is None or bind.dialect.name != "postgresql":
return True
return bool(
db.scalar(
text("SELECT pg_try_advisory_lock(hashtextextended(:lease_key, 0))"),
{"lease_key": _SCHEDULER_LEASE_KEY},
)
)
@staticmethod
def _release_lease(db: Session) -> None:
bind = db.get_bind()
if bind is None or bind.dialect.name != "postgresql":
return
released = db.scalar(
text("SELECT pg_advisory_unlock(hashtextextended(:lease_key, 0))"),
{"lease_key": _SCHEDULER_LEASE_KEY},
)
if not released:
logger.warning("Agent asset release scheduler lease was not owned at release time")
def _tenant_targets(self, db: Session) -> tuple[list[str], int]:
rows = list(
db.execute(
select(AgentAsset.tenant_id, AgentAsset.scope, AgentAsset.config_json)
.where(
AgentAsset.asset_type == AgentAssetType.RULE.value,
AgentAsset.domain == AgentAssetDomain.EXPENSE.value,
AgentAsset.status != AgentAssetStatus.DISABLED.value,
AgentAsset.config_json["detail_mode"].as_string() == "json_risk",
AgentAsset.config_json["release_guard"]["stage"]
.as_string()
.in_(sorted(_MONITORED_STAGES)),
)
).all()
)
tenants: set[str] = set()
global_count = 0
for asset_tenant_id, asset_scope, value in rows:
config = value if isinstance(value, dict) else {}
state = config.get("release_guard")
if (
str(config.get("detail_mode") or "").strip().lower() != "json_risk"
or config.get("enabled") is False
or not isinstance(state, dict)
or str(state.get("stage") or "").strip().lower() not in _MONITORED_STAGES
):
continue
if asset_scope == "tenant" and asset_tenant_id != "platform":
tenants.add(str(asset_tenant_id))
elif asset_scope == "platform" and asset_tenant_id == "platform":
global_count += 1
return sorted(tenants), global_count
def _env_int(name: str, default: int) -> int:
try:
return int(str(os.environ.get(name) or default).strip())
except (TypeError, ValueError, OverflowError):
return default
agent_asset_release_scheduler = AgentAssetReleaseScheduler()

View File

@@ -0,0 +1,799 @@
"""从真实风险规则运行与人工处置构建分阶段发布质量指标。"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import UTC, datetime
from typing import Any, Literal
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.models.agent_asset import AgentAsset
from app.models.agent_asset_release_telemetry import (
AgentAssetReleaseAuditSample,
AgentAssetReleaseLabel,
AgentAssetReleaseObservation,
)
from app.models.risk_disposition import RiskDispositionEvent
from app.models.risk_observation import RiskObservation
from app.services.agent_asset_release_guard import ReleaseEvaluationInput
from app.services.agent_asset_release_sampling import AgentAssetReleaseSamplingService
from app.services.agent_asset_release_telemetry_crypto import (
deterministic_fingerprint as _fingerprint,
)
from app.services.agent_asset_release_telemetry_crypto import (
json_fingerprint as _json_fingerprint,
)
from app.services.agent_asset_release_telemetry_crypto import (
release_pseudonym_fingerprint,
release_source_fingerprint,
release_source_fingerprints,
)
from app.services.agent_asset_release_telemetry_crypto import (
stable_uuid as _stable_uuid,
)
from app.services.agent_asset_release_telemetry_values import (
release_stage as _stage,
)
from app.services.agent_asset_release_telemetry_values import (
required_value as _required,
)
from app.services.agent_asset_release_telemetry_values import (
safe_code as _safe_code,
)
from app.services.agent_asset_release_telemetry_values import (
strict_bool as _strict_bool,
)
ReleaseTelemetryStage = Literal["shadow", "canary", "active"]
ReleaseTelemetryLabelValue = Literal[
"confirmed",
"false_positive",
"risk_present",
"risk_absent",
]
_BUSINESS_STAGES = {"expense_application", "reimbursement"}
_FAILURE_CODES = {
"none",
"evaluator_error",
"artifact_integrity_error",
"unsupported_evaluator",
"timeout",
}
class ReleaseTelemetryError(RuntimeError):
"""发布遥测输入不可信或与当前发布状态冲突。"""
class ReleaseTelemetryIdempotencyConflict(ReleaseTelemetryError):
"""幂等键已被不同事实使用。"""
class ReleaseTelemetryStaleRelease(ReleaseTelemetryError):
"""运行观察不再属于资产当前的 release/stage/version。"""
class ReleaseTelemetryCollecting(ReleaseTelemetryError):
"""真实标签证据尚未满足发布评测输入要求。"""
@dataclass(frozen=True, slots=True)
class ReleaseObservationInput:
tenant_id: str
asset_id: str
release_id: str
stage: ReleaseTelemetryStage
version: str
rule_code: str
source_key: str
candidate_hit: bool
baseline_hit: bool | None = None
runtime_status: Literal["completed", "failed"] = "completed"
failure_code: str = "none"
business_stage: str = "reimbursement"
@dataclass(frozen=True, slots=True)
class ReleaseTelemetryAggregate:
tenant_id: str
asset_id: str
release_id: str
stage: str
version: str
status: Literal["collecting", "ready"]
reasons: tuple[str, ...]
observed_count: int
completed_count: int
runtime_failure_count: int
candidate_hit_count: int
candidate_labeled_count: int
candidate_pending_label_count: int
candidate_confirmed_count: int
candidate_false_positive_count: int
precision: float | None
baseline_hit_count: int
baseline_labeled_count: int
baseline_pending_label_count: int
baseline_confirmed_count: int
baseline_false_positive_count: int
baseline_precision: float | None
false_negative_count: int | None = None
estimated_false_negative_count: float | None = None
false_negative_upper_bound: float | None = None
negative_sample_count: int = 0
negative_labeled_count: int = 0
negative_pending_label_count: int = 0
random_negative_population_count: int = 0
random_negative_sample_count: int = 0
random_negative_labeled_count: int = 0
random_negative_false_negative_count: int = 0
recall: float | None = None
recall_lower_bound: float | None = None
recall_confidence_level: float = 0.95
recall_method: str = "unavailable"
negative_ground_truth_status: str = "unavailable"
def to_release_evaluation_input(self) -> ReleaseEvaluationInput:
"""只在所有候选正例已有可信标签时交给 Release Guard。"""
if self.status != "ready":
raise ReleaseTelemetryCollecting(
"发布遥测仍在采集真实标签,不能把未标注样本当作成功。"
)
guarded_precision = (
self.precision if self.candidate_pending_label_count == 0 else None
)
if guarded_precision is None and self.runtime_failure_count == 0:
raise ReleaseTelemetryCollecting(
"发布遥测缺少完整可信标签,不能把部分精度当作成功。"
)
return ReleaseEvaluationInput(
total=self.observed_count,
failure_count=self.runtime_failure_count,
precision=guarded_precision,
baseline_precision=self.baseline_precision,
details={
"metric_source": "release_runtime_telemetry",
"release_id": self.release_id,
"stage": self.stage,
"version": self.version,
"completed_count": self.completed_count,
"candidate_hit_count": self.candidate_hit_count,
"candidate_labeled_count": self.candidate_labeled_count,
"candidate_pending_label_count": self.candidate_pending_label_count,
"baseline_hit_count": self.baseline_hit_count,
"baseline_labeled_count": self.baseline_labeled_count,
"baseline_precision_status": (
"available" if self.baseline_precision is not None else "unavailable"
),
"false_negative_count": self.false_negative_count,
"estimated_false_negative_count": self.estimated_false_negative_count,
"false_negative_upper_bound": self.false_negative_upper_bound,
"negative_sample_count": self.negative_sample_count,
"negative_labeled_count": self.negative_labeled_count,
"negative_pending_label_count": self.negative_pending_label_count,
"random_negative_population_count": self.random_negative_population_count,
"random_negative_sample_count": self.random_negative_sample_count,
"random_negative_labeled_count": self.random_negative_labeled_count,
"recall": self.recall,
"recall_lower_bound": self.recall_lower_bound,
"recall_confidence_level": self.recall_confidence_level,
"recall_method": self.recall_method,
"negative_ground_truth_status": self.negative_ground_truth_status,
},
)
class AgentAssetReleaseTelemetryService:
"""生产真实运行样本,接收可信人工结论并生成保守发布指标。"""
def __init__(self, db: Session) -> None:
self.db = db
def record_observation(
self,
payload: ReleaseObservationInput,
) -> AgentAssetReleaseObservation:
tenant_id = _required(payload.tenant_id, "tenant_id", 64)
asset_id = _required(payload.asset_id, "asset_id", 36)
release_id = _safe_code(payload.release_id, "release_id", 64)
stage = _stage(payload.stage)
version = _safe_code(payload.version, "version", 30)
rule_code = _safe_code(payload.rule_code, "rule_code", 100)
source_key = _required(payload.source_key, "source_key", 500)
business_stage = str(payload.business_stage or "").strip().lower()
if business_stage not in _BUSINESS_STAGES:
raise ValueError("business_stage must be expense_application or reimbursement.")
runtime_status = str(payload.runtime_status or "").strip().lower()
if runtime_status not in {"completed", "failed"}:
raise ValueError("runtime_status must be completed or failed.")
failure_code = str(payload.failure_code or "none").strip().lower()
if failure_code not in _FAILURE_CODES:
raise ValueError("failure_code is not an approved structured code.")
if runtime_status == "completed" and failure_code != "none":
raise ValueError("completed observation cannot contain a failure code.")
if runtime_status == "failed" and failure_code == "none":
raise ValueError("failed observation requires a structured failure code.")
candidate_hit = _strict_bool(payload.candidate_hit, "candidate_hit")
baseline_hit = (
None
if payload.baseline_hit is None
else _strict_bool(payload.baseline_hit, "baseline_hit")
)
_asset, release_state = self._require_current_release(
tenant_id=tenant_id,
asset_id=asset_id,
release_id=release_id,
stage=stage,
version=version,
rule_code=rule_code,
)
source_fingerprint = release_source_fingerprint(
tenant_id=tenant_id,
source_key=source_key,
rule_code=rule_code,
)
identity = {
"tenant_id": tenant_id,
"asset_id": asset_id,
"release_id": release_id,
"stage": stage,
"version": version,
"rule_code": rule_code,
"business_stage": business_stage,
"source_fingerprint": source_fingerprint,
"candidate_hit": candidate_hit,
"baseline_hit": baseline_hit,
"runtime_status": runtime_status,
"failure_code": failure_code,
}
payload_fingerprint = _json_fingerprint(identity)
observation_key_hash = _fingerprint(
"observation",
tenant_id,
asset_id,
release_id,
stage,
version,
rule_code,
business_stage,
source_fingerprint,
)
idempotency_key = f"aro:{observation_key_hash}"
replay = self._observation_replay(tenant_id, idempotency_key, payload_fingerprint)
if replay is not None:
with self.db.begin_nested():
AgentAssetReleaseSamplingService(self.db).ensure_sample(
observation=replay,
source_reference=source_key,
release_state=release_state,
)
return replay
item = AgentAssetReleaseObservation(
id=_stable_uuid(idempotency_key),
**identity,
source_kind="expense_claim_risk",
idempotency_key=idempotency_key,
payload_fingerprint=payload_fingerprint,
created_at=datetime.now(UTC),
)
try:
with self.db.begin_nested():
self.db.add(item)
self.db.flush()
AgentAssetReleaseSamplingService(self.db).ensure_sample(
observation=item,
source_reference=source_key,
release_state=release_state,
)
except IntegrityError:
replay = self._observation_replay(
tenant_id,
idempotency_key,
payload_fingerprint,
)
if replay is None:
raise
with self.db.begin_nested():
AgentAssetReleaseSamplingService(self.db).ensure_sample(
observation=replay,
source_reference=source_key,
release_state=release_state,
)
return replay
return item
def record_expense_risk_result(
self,
*,
tenant_id: str,
claim_id: str,
result: dict[str, Any],
business_stage: str = "reimbursement",
) -> list[AgentAssetReleaseObservation]:
"""把现有 evaluate_platform_risk_rules 返回值转换成真实遥测样本。"""
normalized_tenant = _required(tenant_id, "tenant_id", 64)
normalized_claim = _required(claim_id, "claim_id", 100)
flags = [item for item in result.get("flags", []) if isinstance(item, dict)]
recorded: list[AgentAssetReleaseObservation] = []
for raw in result.get("shadow_evaluations", []):
if not isinstance(raw, dict):
raise ValueError("shadow_evaluations must contain structured objects.")
if str(raw.get("release_stage") or "").strip().lower() != "shadow":
raise ValueError("shadow_evaluations contains a non-shadow release sample.")
candidate_hit = _strict_bool(raw.get("hit"), "hit")
asset_id = _required(raw.get("asset_id"), "asset_id", 36)
rule_code = _safe_code(raw.get("rule_code"), "rule_code", 100)
version = _safe_code(raw.get("rule_version"), "rule_version", 30)
state = self._release_state_for_asset(normalized_tenant, asset_id)
baseline_version = str(state.get("previous_version") or "").strip()
baseline_hit = any(
str(flag.get("rule_code") or "").strip() == rule_code
and str(flag.get("rule_version") or "").strip() == baseline_version
and str(flag.get("release_mode") or "enforced").strip() == "enforced"
for flag in flags
)
recorded.append(
self.record_observation(
ReleaseObservationInput(
tenant_id=normalized_tenant,
asset_id=asset_id,
release_id=_required(state.get("release_id"), "release_id", 64),
stage="shadow",
version=version,
rule_code=rule_code,
source_key=normalized_claim,
candidate_hit=candidate_hit,
baseline_hit=baseline_hit,
business_stage=business_stage,
)
)
)
for flag in flags:
stage = str(flag.get("release_stage") or "").strip().lower()
if stage not in {"canary", "active"}:
continue
rule_code = _safe_code(flag.get("rule_code"), "rule_code", 100)
asset = self._asset_for_rule_code(normalized_tenant, rule_code)
state = self._release_state(asset)
version = _safe_code(flag.get("rule_version"), "rule_version", 30)
recorded.append(
self.record_observation(
ReleaseObservationInput(
tenant_id=normalized_tenant,
asset_id=asset.id,
release_id=_required(state.get("release_id"), "release_id", 64),
stage=stage, # type: ignore[arg-type]
version=version,
rule_code=rule_code,
source_key=normalized_claim,
candidate_hit=True,
baseline_hit=None,
business_stage=business_stage,
)
)
)
return recorded
def record_manifest_evaluation(
self,
*,
tenant_id: str,
claim_id: str,
manifest: dict[str, Any],
hit: bool,
baseline_hit: bool | None = None,
runtime_status: Literal["completed", "failed"] = "completed",
failure_code: str = "none",
business_stage: str = "reimbursement",
) -> AgentAssetReleaseObservation:
"""在规则执行循环内记录候选命中或未命中,覆盖 Canary 的负样本。"""
normalized_tenant = _required(tenant_id, "tenant_id", 64)
asset_id = _required(manifest.get("_rule_asset_id"), "asset_id", 36)
stage = _stage(manifest.get("_release_stage"))
mode = str(manifest.get("_release_mode") or "").strip().lower()
if (stage == "shadow" and mode != "shadow") or (
stage in {"canary", "active"} and mode != "enforced"
):
raise ValueError("Only the candidate route can emit release telemetry.")
version = _safe_code(manifest.get("_rule_version"), "rule_version", 30)
rule_code = _safe_code(manifest.get("rule_code"), "rule_code", 100)
state = self._release_state_for_asset(normalized_tenant, asset_id)
return self.record_observation(
ReleaseObservationInput(
tenant_id=normalized_tenant,
asset_id=asset_id,
release_id=_required(state.get("release_id"), "release_id", 64),
stage=stage, # type: ignore[arg-type]
version=version,
rule_code=rule_code,
source_key=_required(claim_id, "claim_id", 100),
candidate_hit=_strict_bool(hit, "hit") if runtime_status == "completed" else False,
baseline_hit=baseline_hit,
runtime_status=runtime_status,
failure_code=failure_code,
business_stage=business_stage,
)
)
def record_review_label(
self,
*,
tenant_id: str,
observation_id: str,
label: Literal["confirmed", "false_positive"],
request_id: str,
actor_id: str,
) -> AgentAssetReleaseLabel:
"""记录专用发布复核队列给出的类型化结论,不接收评论或业务正文。"""
return self._record_label(
tenant_id=tenant_id,
observation_id=observation_id,
label=label,
verification_source="release_review",
source_event_key=_required(request_id, "request_id", 160),
actor_key=_required(actor_id, "actor_id", 160),
)
def record_blind_review_label(
self,
*,
tenant_id: str,
observation_id: str,
ground_truth: Literal["risk_present", "risk_absent"],
request_id: str,
actor_id: str,
) -> AgentAssetReleaseLabel:
"""记录不披露候选结论的独立业务真值。"""
return self._record_label(
tenant_id=tenant_id,
observation_id=observation_id,
label=ground_truth,
verification_source="blind_release_review",
source_event_key=_required(request_id, "request_id", 160),
actor_key=_required(actor_id, "actor_id", 160),
)
def record_risk_disposition_label(
self,
*,
tenant_id: str,
observation_id: str,
disposition_event_id: str,
) -> AgentAssetReleaseLabel:
"""只接受数据库中真实存在的 confirm/false_positive 处置事件。"""
normalized_tenant = _required(tenant_id, "tenant_id", 64)
telemetry = self._observation(normalized_tenant, observation_id)
event = self.db.scalar(
select(RiskDispositionEvent).where(
RiskDispositionEvent.id == disposition_event_id,
RiskDispositionEvent.tenant_id == normalized_tenant,
)
)
if event is None:
raise LookupError("Risk disposition event not found.")
if event.action not in {"confirm", "false_positive"}:
raise ValueError("Only typed confirm/false_positive events can label release samples.")
risk_observation = self.db.scalar(
select(RiskObservation).where(
RiskObservation.id == event.observation_id,
RiskObservation.tenant_id == normalized_tenant,
)
)
if risk_observation is None:
raise LookupError("Risk observation not found.")
rule_code = str((risk_observation.decision_trace_json or {}).get("rule_code") or "").strip()
if not rule_code and risk_observation.policy_refs_json:
rule_code = str(risk_observation.policy_refs_json[0] or "").strip()
expected_sources = release_source_fingerprints(
tenant_id=normalized_tenant,
source_key=_required(risk_observation.claim_id, "claim_id", 100),
rule_code=rule_code,
)
if rule_code != telemetry.rule_code or telemetry.source_fingerprint not in expected_sources:
raise PermissionError("Risk disposition does not belong to this release observation.")
state = self._release_state_for_asset(normalized_tenant, telemetry.asset_id)
trusted_versions = {
telemetry.version,
str(state.get("previous_version") or "").strip(),
}
if str(risk_observation.algorithm_version or "").strip() not in trusted_versions:
raise PermissionError("Risk disposition version does not match the release sample.")
return self._record_label(
tenant_id=normalized_tenant,
observation_id=telemetry.id,
label="confirmed" if event.action == "confirm" else "false_positive",
verification_source="typed_risk_disposition",
source_event_key=event.id,
actor_key=event.actor_id,
)
def aggregate(
self,
*,
tenant_id: str,
asset_id: str,
release_id: str,
stage: ReleaseTelemetryStage,
version: str,
) -> ReleaseTelemetryAggregate:
from app.services.agent_asset_release_aggregation import (
build_release_telemetry_aggregate,
)
normalized_tenant = _required(tenant_id, "tenant_id", 64)
normalized_asset = _required(asset_id, "asset_id", 36)
normalized_release = _safe_code(release_id, "release_id", 64)
normalized_stage = _stage(stage)
normalized_version = _safe_code(version, "version", 30)
_asset, release_state = self._require_current_release(
tenant_id=normalized_tenant,
asset_id=normalized_asset,
release_id=normalized_release,
stage=normalized_stage,
version=normalized_version,
)
return build_release_telemetry_aggregate(
db=self.db,
tenant_id=normalized_tenant,
asset_id=normalized_asset,
release_id=normalized_release,
stage=normalized_stage,
version=normalized_version,
release_state=release_state,
)
def _record_label(
self,
*,
tenant_id: str,
observation_id: str,
label: str,
verification_source: str,
source_event_key: str,
actor_key: str,
) -> AgentAssetReleaseLabel:
normalized_tenant = _required(tenant_id, "tenant_id", 64)
normalized_label = str(label or "").strip().lower()
if normalized_label not in {
"confirmed",
"false_positive",
"risk_present",
"risk_absent",
}:
raise ValueError("label is not an approved typed release ground truth.")
if verification_source == "blind_release_review":
if normalized_label not in {"risk_present", "risk_absent"}:
raise ValueError(
"Blind release review requires risk_present or risk_absent."
)
elif normalized_label not in {"confirmed", "false_positive"}:
raise ValueError(
"Typed dispositions and release reviews require confirmed or "
"false_positive."
)
observation = self._observation(normalized_tenant, observation_id)
self._require_current_release(
tenant_id=normalized_tenant,
asset_id=observation.asset_id,
release_id=observation.release_id,
stage=observation.stage,
version=observation.version,
rule_code=observation.rule_code,
lock=True,
)
sample = self.db.scalar(
select(AgentAssetReleaseAuditSample).where(
AgentAssetReleaseAuditSample.tenant_id == normalized_tenant,
AgentAssetReleaseAuditSample.observation_id == observation.id,
)
)
if verification_source == "blind_release_review" and sample is None:
raise ValueError("Blind review requires a selected release audit sample.")
if not observation.candidate_hit and observation.baseline_hit is not True:
if verification_source != "blind_release_review":
raise ValueError(
"Negative executions require independent ground truth from a selected "
"blind-review sample."
)
source_event_fingerprint = release_pseudonym_fingerprint(
"label-source",
normalized_tenant,
source_event_key,
)
actor_fingerprint = release_pseudonym_fingerprint(
"label-actor",
normalized_tenant,
actor_key,
)
identity = {
"tenant_id": normalized_tenant,
"observation_id": observation.id,
"asset_id": observation.asset_id,
"release_id": observation.release_id,
"stage": observation.stage,
"version": observation.version,
"label": normalized_label,
"verification_source": verification_source,
"source_event_fingerprint": source_event_fingerprint,
"actor_fingerprint": actor_fingerprint,
}
payload_fingerprint = _json_fingerprint(identity)
label_key_hash = _fingerprint(
"label",
normalized_tenant,
observation.id,
source_event_fingerprint,
)
idempotency_key = f"arl:{label_key_hash}"
replay = self._label_replay(normalized_tenant, idempotency_key, payload_fingerprint)
if replay is not None:
return replay
item = AgentAssetReleaseLabel(
id=_stable_uuid(idempotency_key),
**identity,
idempotency_key=idempotency_key,
payload_fingerprint=payload_fingerprint,
created_at=datetime.now(UTC),
)
try:
with self.db.begin_nested():
self.db.add(item)
self.db.flush()
except IntegrityError:
replay = self._label_replay(
normalized_tenant,
idempotency_key,
payload_fingerprint,
)
if replay is None:
raise
return replay
return item
def _observation(self, tenant_id: str, observation_id: str) -> AgentAssetReleaseObservation:
item = self.db.scalar(
select(AgentAssetReleaseObservation).where(
AgentAssetReleaseObservation.tenant_id == tenant_id,
AgentAssetReleaseObservation.id == observation_id,
)
)
if item is None:
raise LookupError("Release observation not found.")
return item
def _observation_replay(
self,
tenant_id: str,
idempotency_key: str,
payload_fingerprint: str,
) -> AgentAssetReleaseObservation | None:
item = self.db.scalar(
select(AgentAssetReleaseObservation).where(
AgentAssetReleaseObservation.tenant_id == tenant_id,
AgentAssetReleaseObservation.idempotency_key == idempotency_key,
)
)
if item is not None and item.payload_fingerprint != payload_fingerprint:
raise ReleaseTelemetryIdempotencyConflict(
"Observation idempotency key was reused with different facts."
)
return item
def _label_replay(
self,
tenant_id: str,
idempotency_key: str,
payload_fingerprint: str,
) -> AgentAssetReleaseLabel | None:
item = self.db.scalar(
select(AgentAssetReleaseLabel).where(
AgentAssetReleaseLabel.tenant_id == tenant_id,
AgentAssetReleaseLabel.idempotency_key == idempotency_key,
)
)
if item is not None and item.payload_fingerprint != payload_fingerprint:
raise ReleaseTelemetryIdempotencyConflict(
"Label idempotency key was reused with different facts."
)
return item
def _require_current_release(
self,
*,
tenant_id: str,
asset_id: str,
release_id: str,
stage: str,
version: str,
rule_code: str | None = None,
lock: bool = False,
) -> tuple[AgentAsset, dict[str, Any]]:
statement = select(AgentAsset).where(
AgentAsset.id == asset_id,
(
((AgentAsset.scope == "tenant") & (AgentAsset.tenant_id == tenant_id))
| ((AgentAsset.scope == "platform") & (AgentAsset.tenant_id == "platform"))
),
)
bind = self.db.get_bind()
if lock and bind is not None and bind.dialect.name == "postgresql":
statement = statement.with_for_update().execution_options(populate_existing=True)
asset = self.db.scalar(statement)
if asset is None:
raise LookupError("Agent asset not found.")
configured_tenant = str((asset.config_json or {}).get("tenant_id") or "").strip()
if asset.scope == "tenant" and configured_tenant not in {"", tenant_id}:
raise LookupError("Agent asset not found.")
if asset.scope == "platform" and configured_tenant != tenant_id:
raise LookupError("Agent asset not found.")
if rule_code is not None and str(asset.code or "").strip() != rule_code:
raise ReleaseTelemetryStaleRelease("Rule code no longer matches the release asset.")
state = self._release_state(asset)
if (
str(state.get("release_id") or "").strip() != release_id
or str(state.get("stage") or "").strip() != stage
or str(state.get("candidate_version") or "").strip() != version
):
raise ReleaseTelemetryStaleRelease(
"Observation or label targets a stale release/stage/version."
)
return asset, state
def _release_state_for_asset(self, tenant_id: str, asset_id: str) -> dict[str, Any]:
asset = self.db.scalar(
select(AgentAsset).where(
AgentAsset.id == asset_id,
(
((AgentAsset.scope == "tenant") & (AgentAsset.tenant_id == tenant_id))
| ((AgentAsset.scope == "platform") & (AgentAsset.tenant_id == "platform"))
),
)
)
if asset is None:
raise LookupError("Agent asset not found.")
configured_tenant = str((asset.config_json or {}).get("tenant_id") or "").strip()
if asset.scope == "tenant" and configured_tenant not in {"", tenant_id}:
raise LookupError("Agent asset not found.")
if asset.scope == "platform" and configured_tenant != tenant_id:
raise LookupError("Agent asset not found.")
return self._release_state(asset)
def _asset_for_rule_code(self, tenant_id: str, rule_code: str) -> AgentAsset:
asset = self.db.scalar(
select(AgentAsset)
.where(
AgentAsset.code == rule_code,
(
((AgentAsset.scope == "tenant") & (AgentAsset.tenant_id == tenant_id))
| ((AgentAsset.scope == "platform") & (AgentAsset.tenant_id == "platform"))
),
)
.order_by(AgentAsset.scope.desc())
)
if asset is None:
raise LookupError("Agent asset not found.")
configured_tenant = str((asset.config_json or {}).get("tenant_id") or "").strip()
if asset.scope == "tenant" and configured_tenant not in {"", tenant_id}:
raise LookupError("Agent asset not found.")
if asset.scope == "platform" and configured_tenant != tenant_id:
raise LookupError("Agent asset not found.")
return asset
@staticmethod
def _release_state(asset: AgentAsset) -> dict[str, Any]:
config = asset.config_json if isinstance(asset.config_json, dict) else {}
state = config.get("release_guard")
return dict(state) if isinstance(state, dict) else {}

View File

@@ -0,0 +1,67 @@
"""发布遥测的确定性标识与可轮换 HMAC 伪名。"""
from __future__ import annotations
import hashlib
import hmac
import json
import uuid
from typing import Any
from app.core.agent_release_telemetry_keys import (
active_agent_release_telemetry_key_version,
available_agent_release_telemetry_key_versions,
get_agent_release_telemetry_key,
)
def deterministic_fingerprint(*parts: Any) -> str:
payload = "\x1f".join(str(item) for item in parts)
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
def release_pseudonym_fingerprint(domain: str, *parts: Any, version: str | None = None) -> str:
key_version = version or active_agent_release_telemetry_key_version()
key = get_agent_release_telemetry_key(key_version, create=version is None)
payload = "\x1f".join(("agent-release-telemetry:v1", domain, *(str(item) for item in parts)))
return hmac.new(key, payload.encode("utf-8"), hashlib.sha256).hexdigest()
def release_source_fingerprint(*, tenant_id: str, source_key: str, rule_code: str) -> str:
"""使用当前版本密钥生成不可字典反推的租户内业务来源伪名。"""
return release_pseudonym_fingerprint(
"expense-claim-risk-source",
tenant_id,
source_key,
rule_code,
)
def release_source_fingerprints(*, tenant_id: str, source_key: str, rule_code: str) -> set[str]:
"""轮换期间同时核验仍保留的历史密钥,避免在途样本失联。"""
return {
release_pseudonym_fingerprint(
"expense-claim-risk-source",
tenant_id,
source_key,
rule_code,
version=version,
)
for version in available_agent_release_telemetry_key_versions()
}
def json_fingerprint(payload: dict[str, Any]) -> str:
serialized = json.dumps(
payload,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
)
return hashlib.sha256(serialized.encode("utf-8")).hexdigest()
def stable_uuid(key: str) -> str:
return str(uuid.uuid5(uuid.NAMESPACE_URL, f"x-financial:{key}"))

View File

@@ -0,0 +1,43 @@
"""发布遥测输入的纯值校验。"""
from __future__ import annotations
import re
from typing import Any
_SAFE_CODE = re.compile(r"^[A-Za-z0-9_.:-]+$")
_STAGES = {"shadow", "canary", "active"}
def required_value(value: Any, field: str, maximum: int) -> str:
normalized = str(value or "").strip()
if not normalized or len(normalized) > maximum:
raise ValueError(f"{field} is required and must be at most {maximum} characters.")
return normalized
def safe_code(value: Any, field: str, maximum: int) -> str:
normalized = required_value(value, field, maximum)
if not _SAFE_CODE.fullmatch(normalized):
raise ValueError(f"{field} contains unsupported characters.")
return normalized
def release_stage(value: Any) -> str:
normalized = str(value or "").strip().lower()
if normalized not in _STAGES:
raise ValueError("stage must be shadow, canary or active.")
return normalized
def strict_bool(value: Any, field: str) -> bool:
if not isinstance(value, bool):
raise ValueError(f"{field} must be a boolean.")
return value
def precision(confirmed: int, false_positive: int) -> float | None:
denominator = confirmed + false_positive
if denominator <= 0:
return None
return round(confirmed / denominator, 6)

View File

@@ -1,261 +1,44 @@
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
from app.core.agent_enums import AgentAssetStatus, AgentAssetType, AgentReviewStatus
from app.models.agent_asset import AgentAsset, AgentAssetReview
from app.services.agent_asset_spreadsheet import RISK_RULES_LIBRARY
from app.services.risk_rule_manifest_normalizer import normalize_risk_rule_manifest
from app.models.agent_asset import AgentAsset
from app.services.agent_asset_release_guard import AgentAssetReleaseGuardService
class AgentAssetRiskRulePublishMixin:
"""风险规则发布逻辑,支持普通待审核版本和已上线规则修订版本"""
"""把既有“发布”动作收口为强制受控的 shadow 发布入口"""
def publish_risk_rule(
self,
asset_id: str,
*,
actor: str,
tenant_id: str | None = None,
allow_global_management: bool = False,
request_id: str | None = None,
) -> AgentAsset:
asset = self._resolve_asset(asset_id)
self._require_json_risk_asset(asset)
revision = self._resolve_publishable_revision(asset)
if revision is not None:
return self._publish_revision(asset, revision, actor=actor, request_id=request_id)
return self._publish_reviewed_working_version(asset, actor=actor, request_id=request_id)
def _publish_reviewed_working_version(
self,
asset: AgentAsset,
*,
actor: str,
request_id: str | None,
) -> AgentAsset:
version = self._resolve_target_version(asset, None)
if asset.status != AgentAssetStatus.REVIEW.value:
raise ValueError("只有待审核风险规则可以发布上线。")
if not self.get_latest_risk_rule_test_summary(asset, version=version).test_passed:
raise PermissionError("当前规则版本尚未完成测试通过确认,不能发布。")
# golden set 回归门禁:在 golden 用例集上跑规则,未 100% 通过则拦截发布。
self._require_golden_set_passed(asset, version, actor=actor)
before = self._asset_snapshot(asset)
self._ensure_approved_review(asset, version=version, actor=actor, note="发布上线前审核通过。")
asset.reviewer = actor
asset.published_version = version
asset.status = AgentAssetStatus.ACTIVE.value
self.db.add(asset)
self.db.commit()
self.audit_service.log_action(
actor=actor,
action="publish_agent_asset",
resource_type=AgentAssetType.RULE.value,
resource_id=asset.id,
before_json=before,
after_json=self._asset_snapshot(asset),
request_id=request_id,
)
return self._refresh_asset(asset.id)
def _publish_revision(
self,
asset: AgentAsset,
revision: dict[str, Any],
*,
actor: str,
request_id: str | None,
) -> AgentAsset:
version = str(revision.get("version") or "").strip()
if not self.get_latest_risk_rule_test_summary(asset, version=version).test_passed:
raise PermissionError("当前修订版本尚未完成测试通过确认,不能发布。")
rule_document = revision.get("rule_document") if isinstance(revision.get("rule_document"), dict) else {}
file_name = str(rule_document.get("file_name") or "").strip()
if not file_name:
raise ValueError("修订版本尚未生成可发布的 JSON 规则文件。")
before = self._asset_snapshot(asset)
manifest = self.rule_library_manager.read_rule_library_json(
library=RISK_RULES_LIBRARY,
file_name=file_name,
)
manifest = normalize_risk_rule_manifest(manifest)
manifest["enabled"] = True
self.rule_library_manager.write_rule_library_json(
library=RISK_RULES_LIBRARY,
file_name=file_name,
payload=manifest,
)
config = dict(asset.config_json or {})
previous_rule_document = config.get("rule_document") if isinstance(config.get("rule_document"), dict) else {}
published_at = datetime.now(UTC).isoformat()
history = list(config.get("revision_history") if isinstance(config.get("revision_history"), list) else [])
history.insert(
0,
{
"version": version,
"base_version": revision.get("base_version"),
"change_reason": revision.get("change_reason"),
"published_by": actor,
"published_at": published_at,
"previous_rule_document": previous_rule_document,
"rule_document": rule_document,
},
)
config.update(self._config_from_published_manifest(manifest, rule_document))
config["revision_history"] = history[:20]
config.pop("revision_draft", None)
config["last_operation"] = {
"action": "publish_revision",
"actor": actor,
"at": published_at,
"target_version": version,
}
asset.name = str(manifest.get("name") or asset.name)
asset.description = str(manifest.get("description") or asset.description)
risk_category = str(manifest.get("risk_category") or "").strip()
if risk_category:
asset.scenario_json = [risk_category]
asset.config_json = config
asset.current_version = version
asset.working_version = version
asset.published_version = version
asset.reviewer = actor
asset.status = AgentAssetStatus.ACTIVE.value
self._ensure_approved_review(asset, version=version, actor=actor, note="修订版本发布上线。")
self.db.add(asset)
self.db.commit()
self.audit_service.log_action(
actor=actor,
action="publish_risk_rule_revision",
resource_type=AgentAssetType.RULE.value,
resource_id=asset.id,
before_json=before,
after_json=self._asset_snapshot(asset),
request_id=request_id,
)
return self._refresh_asset(asset.id)
def _resolve_publishable_revision(self, asset: AgentAsset) -> dict[str, Any] | None:
config = dict(asset.config_json or {})
revision = config.get("revision_draft")
if not isinstance(revision, dict):
return None
version = str(revision.get("version") or "").strip()
if not version or version != str(asset.working_version or "").strip():
return None
if version == str(asset.published_version or "").strip():
return None
if revision.get("generation_status") != "completed":
raise ValueError("修订版本尚未重新生成,不能发布上线。")
return dict(revision)
def _ensure_approved_review(
self,
asset: AgentAsset,
*,
version: str,
actor: str,
note: str,
) -> None:
approved_review = self.repository.get_review(
asset.id, version, AgentReviewStatus.APPROVED.value
)
if approved_review is not None:
return
self.db.add(
AgentAssetReview(
asset_id=asset.id,
version=version,
reviewer=actor,
review_status=AgentReviewStatus.APPROVED.value,
review_note=note,
reviewed_at=datetime.now(UTC),
)
)
def _require_golden_set_passed(
self,
asset: AgentAsset,
version: str,
*,
actor: str,
) -> None:
"""在 golden set 上跑当前规则 manifest未 100% 通过则拦截发布。
降级策略feature flag 关闭 / 无 rule_document / 无 golden case /
evaluator 异常 → 一律放行,不阻塞发布主链路。
"""
import os
if os.environ.get("GOLDEN_SET_GATE_ENABLED", "true").strip().lower() in {"0", "false", "no"}:
return
config = asset.config_json if isinstance(asset.config_json, dict) else {}
rule_document = config.get("rule_document") if isinstance(config.get("rule_document"), dict) else {}
file_name = str(rule_document.get("file_name") or "").strip()
if not file_name:
return
try:
manifest = self.rule_library_manager.read_rule_library_json(
library=RISK_RULES_LIBRARY,
file_name=file_name,
)
except Exception:
return
rule_code = str(manifest.get("rule_code") or "").strip()
if not rule_code:
return
from app.services.risk_rule_golden_evaluator import RiskRuleGoldenEvaluator
revision = config.get("revision_draft")
if isinstance(revision, dict) and str(revision.get("version") or "").strip() == version:
if revision.get("generation_status") != "completed":
raise ValueError("修订版本尚未重新生成,不能进入影子发布。")
RiskRuleGoldenEvaluator().require_pass(
AgentAssetReleaseGuardService(
self.db,
asset,
rule_library_manager=self.rule_library_manager,
).start_shadow(
asset.id,
version,
manifest,
rule_code,
actor=actor,
tenant_id=(
str(tenant_id or "").strip() or str(config.get("tenant_id") or "").strip() or None
),
allow_global_management=allow_global_management,
request_id=request_id,
)
@staticmethod
def _config_from_published_manifest(
manifest: dict[str, Any],
rule_document: dict[str, Any],
) -> dict[str, Any]:
metadata = manifest.get("metadata") if isinstance(manifest.get("metadata"), dict) else {}
risk_score_detail = metadata.get("risk_score_detail") if isinstance(metadata.get("risk_score_detail"), dict) else {}
risk_level = str(metadata.get("risk_level") or manifest.get("outcomes", {}).get("fail", {}).get("severity") or "medium")
risk_score = int(metadata.get("risk_score") or manifest.get("outcomes", {}).get("fail", {}).get("risk_score") or 0)
return {
"severity": risk_level,
"risk_score": risk_score,
"risk_level": risk_level,
"risk_level_label": metadata.get("risk_level_label"),
"risk_score_detail": risk_score_detail,
"enabled": True,
"requires_attachment": bool(metadata.get("requires_attachment") or manifest.get("requires_attachment")),
"detail_mode": "json_risk",
"business_stage": metadata.get("business_stage"),
"business_stage_label": metadata.get("business_stage_label"),
"expense_category": metadata.get("expense_category"),
"expense_category_label": metadata.get("expense_category_label"),
"risk_category": manifest.get("risk_category"),
"rule_library": RISK_RULES_LIBRARY,
"rule_document": rule_document,
"ontology_signal": manifest.get("ontology_signal"),
"evaluator": manifest.get("evaluator"),
"generated_by": "natural_language",
"source_ref": "自然语言风险规则",
"flow_diagram_svg": manifest.get("flow_diagram_svg"),
}
def _refresh_asset(self, asset_id: str) -> AgentAsset:
refreshed = self.repository.get(asset_id)
refreshed = self.repository.get(asset.id)
if refreshed is None:
raise LookupError("Asset not found")
return refreshed

View File

@@ -5,6 +5,7 @@ from typing import Any
from sqlalchemy.orm import Session
from app.api.deps import CurrentUserContext
from app.core.agent_enums import AgentAssetDomain, AgentAssetStatus, AgentAssetType
from app.models.agent_asset import AgentAsset, AgentAssetVersion
from app.repositories.agent_asset import AgentAssetRepository
@@ -12,6 +13,7 @@ from app.schemas.agent_asset import (
AgentAssetRiskRuleGenerateRequest,
AgentAssetRiskRuleRegenerateRequest,
)
from app.services.agent_asset_access import AgentAssetAccessScope
from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager
from app.services.agent_asset_spreadsheet import RISK_RULES_LIBRARY
from app.services.audit import AuditLogService
@@ -36,9 +38,13 @@ class AgentAssetRiskRuleRegenerationService:
*,
rule_library_manager: AgentAssetRuleLibraryManager | None = None,
runtime_chat_service: RuntimeChatService | None = None,
current_user: CurrentUserContext | None = None,
) -> None:
self.db = db
self.repository = AgentAssetRepository(db)
self.access_scope = (
AgentAssetAccessScope.from_user(current_user) if current_user is not None else None
)
self.repository = AgentAssetRepository(db, access_scope=self.access_scope)
self.rule_library_manager = rule_library_manager or AgentAssetRuleLibraryManager()
self.generator = RiskRuleGenerationService(
db,
@@ -125,7 +131,9 @@ class AgentAssetRiskRuleRegenerationService:
asset.name = str(payload["name"])
asset.description = str(payload["description"])
asset.domain = str(request.get("business_domain") or AgentAssetDomain.EXPENSE.value)
asset.scenario_json = [str(payload.get("risk_category") or BUSINESS_DOMAIN_LABELS[asset.domain])]
asset.scenario_json = [
str(payload.get("risk_category") or BUSINESS_DOMAIN_LABELS[asset.domain])
]
asset.status = AgentAssetStatus.DRAFT.value
asset.current_version = version
asset.working_version = version
@@ -160,7 +168,11 @@ class AgentAssetRiskRuleRegenerationService:
asset,
config,
body.model_dump(exclude_unset=True),
base=revision.get("generation_request") if isinstance(revision.get("generation_request"), dict) else {},
base=(
revision.get("generation_request")
if isinstance(revision.get("generation_request"), dict)
else {}
),
)
payload, risk_score = self._compile_payload(
request,
@@ -303,8 +315,13 @@ class AgentAssetRiskRuleRegenerationService:
asset = self.repository.get(asset_id)
if asset is None:
raise FileNotFoundError("风险规则不存在。")
if self.access_scope is not None:
self.access_scope.require_write(asset)
config = asset.config_json or {}
if asset.asset_type != AgentAssetType.RULE.value or config.get("detail_mode") != "json_risk":
if (
asset.asset_type != AgentAssetType.RULE.value
or config.get("detail_mode") != "json_risk"
):
raise ValueError("当前资产不是自然语言风险规则。")
return asset
@@ -394,6 +411,8 @@ class AgentAssetRiskRuleRegenerationService:
if existing is None:
self.db.add(
AgentAssetVersion(
tenant_id=asset.tenant_id,
scope=asset.scope,
asset_id=asset.id,
version=version,
content=content,

View File

@@ -5,6 +5,7 @@ from typing import Any
from sqlalchemy.orm import Session
from app.api.deps import CurrentUserContext
from app.core.agent_enums import AgentAssetStatus, AgentAssetType
from app.models.agent_asset import AgentAsset, AgentAssetVersion
from app.repositories.agent_asset import AgentAssetRepository
@@ -12,6 +13,7 @@ from app.schemas.agent_asset import (
AgentAssetRiskRuleDraftUpdate,
AgentAssetRiskRuleRevisionCreate,
)
from app.services.agent_asset_access import AgentAssetAccessScope
from app.services.audit import AuditLogService
from app.services.risk_rule_generation_ontology import EXPENSE_RISK_CATEGORY_LABELS
@@ -19,9 +21,17 @@ from app.services.risk_rule_generation_ontology import EXPENSE_RISK_CATEGORY_LAB
class AgentAssetRiskRuleRevisionService:
"""风险规则草稿编辑与已发布规则修订草稿服务。"""
def __init__(self, db: Session) -> None:
def __init__(
self,
db: Session,
*,
current_user: CurrentUserContext | None = None,
) -> None:
self.db = db
self.repository = AgentAssetRepository(db)
self.access_scope = (
AgentAssetAccessScope.from_user(current_user) if current_user is not None else None
)
self.repository = AgentAssetRepository(db, access_scope=self.access_scope)
self.audit_service = AuditLogService(db)
def update_unpublished_draft(
@@ -95,6 +105,8 @@ class AgentAssetRiskRuleRevisionService:
self.db.add(asset)
self.db.add(
AgentAssetVersion(
tenant_id=asset.tenant_id,
scope=asset.scope,
asset_id=asset.id,
version=revision_version,
content=self._build_revision_content(asset, config),
@@ -119,8 +131,13 @@ class AgentAssetRiskRuleRevisionService:
asset = self.repository.get(asset_id)
if asset is None:
raise FileNotFoundError("风险规则不存在。")
if self.access_scope is not None:
self.access_scope.require_write(asset)
config = asset.config_json or {}
if asset.asset_type != AgentAssetType.RULE.value or config.get("detail_mode") != "json_risk":
if (
asset.asset_type != AgentAssetType.RULE.value
or config.get("detail_mode") != "json_risk"
):
raise ValueError("当前资产不是自然语言风险规则。")
return asset
@@ -136,8 +153,12 @@ class AgentAssetRiskRuleRevisionService:
now = datetime.now(UTC).isoformat()
rule_title = str(request.get("rule_title") or asset.name or "").strip()
natural_language = str(request.get("natural_language") or asset.description or "").strip()
expense_category = str(request.get("expense_category") or config.get("expense_category") or "").strip()
category_label = EXPENSE_RISK_CATEGORY_LABELS.get(expense_category, config.get("risk_category") or "")
expense_category = str(
request.get("expense_category") or config.get("expense_category") or ""
).strip()
category_label = EXPENSE_RISK_CATEGORY_LABELS.get(
expense_category, config.get("risk_category") or ""
)
asset.name = rule_title or asset.name
asset.description = natural_language or asset.description
if category_label:
@@ -156,8 +177,14 @@ class AgentAssetRiskRuleRevisionService:
asset.config_json = config
@staticmethod
def _merged_generation_request(config: dict[str, Any], updates: dict[str, Any]) -> dict[str, Any]:
base = config.get("generation_request") if isinstance(config.get("generation_request"), dict) else {}
def _merged_generation_request(
config: dict[str, Any], updates: dict[str, Any]
) -> dict[str, Any]:
base = (
config.get("generation_request")
if isinstance(config.get("generation_request"), dict)
else {}
)
merged = dict(base)
for key, value in updates.items():
if key == "change_reason":
@@ -172,7 +199,12 @@ class AgentAssetRiskRuleRevisionService:
return merged
def _next_revision_version(self, asset: AgentAsset) -> str:
base = str(asset.working_version or asset.current_version or asset.published_version or "v0.1.0")
base = str(
asset.working_version
or asset.current_version
or asset.published_version
or "v0.1.0"
)
major, minor, patch = self._parse_version(base)
existing = {version.version for version in self.repository.list_versions(asset.id)}
while True:
@@ -190,8 +222,16 @@ class AgentAssetRiskRuleRevisionService:
@staticmethod
def _build_revision_content(asset: AgentAsset, config: dict[str, Any]) -> str:
revision = config.get("revision_draft") if isinstance(config.get("revision_draft"), dict) else {}
request = revision.get("generation_request") if isinstance(revision.get("generation_request"), dict) else {}
revision = (
config.get("revision_draft")
if isinstance(config.get("revision_draft"), dict)
else {}
)
request = (
revision.get("generation_request")
if isinstance(revision.get("generation_request"), dict)
else {}
)
return "\n".join(
[
f"# {asset.name} 修订草稿",

View File

@@ -24,9 +24,10 @@ from app.schemas.agent_asset import (
AgentAssetRiskRuleScenarioTestRequest,
AgentAssetRiskRuleTestRunRead,
)
from app.services.agent_asset_spreadsheet import RISK_RULES_LIBRARY
from app.services.expense_claims import ExpenseClaimService
from app.services.risk_rule_template_executor import RiskRuleTemplateExecutor
from app.services.risk_rule_manifest_normalizer import normalize_risk_rule_manifest
from app.services.risk_rule_template_executor import RiskRuleTemplateExecutor
class AgentAssetRiskRuleTestingMixin:
@@ -92,8 +93,12 @@ class AgentAssetRiskRuleTestingMixin:
if asset.domain != AgentAssetDomain.EXPENSE.value:
raise ValueError("一期真实场景试运行仅支持报销业务域。")
target_tenant_id = self._require_scenario_target_tenant(
asset,
body.target_tenant_id,
)
parsed_scope = self._parse_scenario_scope(body.intent, body.filters)
claims = self._query_expense_claim_samples(parsed_scope)
claims = self._query_expense_claim_samples(target_tenant_id, parsed_scope)
claim_results = [self._run_claim_scenario(manifest, claim) for claim in claims]
hit_items = [item for item in claim_results if item["hit"]]
severity_counts: dict[str, int] = {}
@@ -114,6 +119,7 @@ class AgentAssetRiskRuleTestingMixin:
passed=passed,
summary=summary,
input_json={
"target_tenant_id": target_tenant_id,
"intent": body.intent,
"filters": body.filters,
"parsed_scope": parsed_scope,
@@ -126,6 +132,7 @@ class AgentAssetRiskRuleTestingMixin:
},
actor=actor,
request_id=request_id,
evidence_tenant_id=target_tenant_id,
)
def confirm_risk_rule_test_report(
@@ -209,9 +216,13 @@ class AgentAssetRiskRuleTestingMixin:
version = self._resolve_target_version(asset, None)
if asset.status != AgentAssetStatus.REVIEW.value:
raise ValueError("只有待审核风险规则可以回退。")
if self.access_scope is not None:
self.access_scope.require_write(asset)
before = self._asset_snapshot(asset)
review = AgentAssetReview(
tenant_id=asset.tenant_id,
scope=asset.scope,
asset_id=asset.id,
version=version,
reviewer=actor,
@@ -235,55 +246,6 @@ class AgentAssetRiskRuleTestingMixin:
)
return self.get_latest_risk_rule_test_summary(asset)
def publish_risk_rule(
self,
asset_id: str,
*,
actor: str,
request_id: str | None = None,
) -> AgentAsset:
asset = self._resolve_asset(asset_id)
self._require_json_risk_asset(asset)
version = self._resolve_target_version(asset, None)
if asset.status != AgentAssetStatus.REVIEW.value:
raise ValueError("只有待审核风险规则可以发布上线。")
if not self.get_latest_risk_rule_test_summary(asset, version=version).test_passed:
raise PermissionError("当前规则版本尚未完成测试通过确认,不能发布。")
before = self._asset_snapshot(asset)
approved_review = self.repository.get_review(
asset.id, version, AgentReviewStatus.APPROVED.value
)
if approved_review is None:
self.db.add(
AgentAssetReview(
asset_id=asset.id,
version=version,
reviewer=actor,
review_status=AgentReviewStatus.APPROVED.value,
review_note="发布上线前审核通过。",
reviewed_at=datetime.now(UTC),
)
)
asset.reviewer = actor
asset.published_version = version
asset.status = AgentAssetStatus.ACTIVE.value
self.db.add(asset)
self.db.commit()
self.audit_service.log_action(
actor=actor,
action="publish_agent_asset",
resource_type=AgentAssetType.RULE.value,
resource_id=asset.id,
before_json=before,
after_json=self._asset_snapshot(asset),
request_id=request_id,
)
refreshed = self.repository.get(asset.id)
if refreshed is None:
raise LookupError("Asset not found")
return refreshed
def set_risk_rule_enabled(
self,
asset_id: str,
@@ -294,8 +256,26 @@ class AgentAssetRiskRuleTestingMixin:
) -> AgentAsset:
asset = self._resolve_asset(asset_id)
self._require_json_risk_asset(asset)
published_version = str(asset.published_version or "").strip()
if not published_version:
raise PermissionError("未发布风险规则不能直接启用,请先完成 shadow/Canary 发布。")
config_json = dict(asset.config_json or {})
release_state = config_json.get("release_guard")
release_stage = (
str(release_state.get("stage") or "").strip() if isinstance(release_state, dict) else ""
)
if release_stage in {"shadow", "canary"}:
raise PermissionError("分阶段发布进行中,请先完成或回滚后再切换启用状态。")
before = self._asset_snapshot(asset)
rule_library, file_name = self._resolve_json_risk_rule_document(asset)
rule_library = str(config_json.get("rule_library") or RISK_RULES_LIBRARY).strip()
rule_document = config_json.get("rule_document")
file_name = (
str(rule_document.get("file_name") or "").strip()
if isinstance(rule_document, dict)
else ""
)
if not file_name:
raise ValueError("已发布风险规则缺少运行文件,不能切换启用状态。")
manifest = self.rule_library_manager.read_rule_library_json(
library=rule_library,
file_name=file_name,
@@ -307,9 +287,9 @@ class AgentAssetRiskRuleTestingMixin:
payload=manifest,
)
config_json = dict(asset.config_json or {})
config_json["enabled"] = bool(enabled)
self._set_risk_rule_status_for_online_toggle(asset, enabled=enabled, actor=actor)
asset.status = AgentAssetStatus.ACTIVE.value if enabled else AgentAssetStatus.DISABLED.value
asset.reviewer = actor
config_json["last_operation"] = self._build_last_operation(
action="online" if enabled else "offline",
actor=actor,
@@ -327,36 +307,6 @@ class AgentAssetRiskRuleTestingMixin:
)
return updated
def _set_risk_rule_status_for_online_toggle(
self,
asset: AgentAsset,
*,
enabled: bool,
actor: str,
) -> None:
if enabled:
version = self._resolve_target_version(asset, None)
approved_review = self.repository.get_review(
asset.id, version, AgentReviewStatus.APPROVED.value
)
if approved_review is None:
self.db.add(
AgentAssetReview(
asset_id=asset.id,
version=version,
reviewer=actor,
review_status=AgentReviewStatus.APPROVED.value,
review_note="直接上线风险规则。",
reviewed_at=datetime.now(UTC),
)
)
asset.published_version = version
asset.reviewer = actor
asset.status = AgentAssetStatus.ACTIVE.value
return
asset.status = AgentAssetStatus.DISABLED.value
def _mark_risk_rule_operation(self, asset: AgentAsset, *, action: str, actor: str) -> None:
config_json = dict(asset.config_json or {})
config_json["last_operation"] = self._build_last_operation(action=action, actor=actor)
@@ -400,10 +350,16 @@ class AgentAssetRiskRuleTestingMixin:
result_json: dict[str, Any],
actor: str,
request_id: str | None,
evidence_tenant_id: str | None = None,
) -> AgentAssetRiskRuleTestRunRead:
status = "passed" if passed else "failed"
scoped_tenant_id = str(evidence_tenant_id or "").strip()
if not scoped_tenant_id and self.access_scope is not None:
scoped_tenant_id = self.access_scope.tenant_id
created = self.repository.create_test_run(
AgentAssetTestRun(
tenant_id=scoped_tenant_id or asset.tenant_id,
scope="tenant" if scoped_tenant_id else asset.scope,
asset_id=asset.id,
version=version,
test_type=test_type,
@@ -432,7 +388,9 @@ class AgentAssetRiskRuleTestingMixin:
case: AgentAssetRiskRuleSampleCase,
) -> dict[str, Any]:
claim, contexts = self._build_synthetic_claim(case.values, manifest)
execution = RiskRuleTemplateExecutor().evaluate_with_trace(manifest, claim=claim, contexts=contexts)
execution = RiskRuleTemplateExecutor().evaluate_with_trace(
manifest, claim=claim, contexts=contexts
)
result = execution["result"]
actual_hit = result is not None
actual_severity = (
@@ -461,7 +419,9 @@ class AgentAssetRiskRuleTestingMixin:
def _run_claim_scenario(self, manifest: dict[str, Any], claim: ExpenseClaim) -> dict[str, Any]:
contexts = ExpenseClaimService(self.db)._build_claim_attachment_contexts(claim)
execution = RiskRuleTemplateExecutor().evaluate_with_trace(manifest, claim=claim, contexts=contexts)
execution = RiskRuleTemplateExecutor().evaluate_with_trace(
manifest, claim=claim, contexts=contexts
)
result = execution["result"]
hit = result is not None
return {
@@ -621,8 +581,18 @@ class AgentAssetRiskRuleTestingMixin:
template_key = str(manifest.get("template_key") or "").strip()
params = manifest.get("params") if isinstance(manifest.get("params"), dict) else {}
if template_key == "field_compare_v1":
if str(params.get("semantic_type") or "").strip() in {"travel_city_consistency", "travel_route_city_consistency"}:
values.update({"attachment.hotel_city": "上海" if hit else "北京", "attachment.route_cities": ["上海"] if hit else ["北京"], "claim.location": "北京", "item.item_location": "北京"})
if str(params.get("semantic_type") or "").strip() in {
"travel_city_consistency",
"travel_route_city_consistency",
}:
values.update(
{
"attachment.hotel_city": "上海" if hit else "北京",
"attachment.route_cities": ["上海"] if hit else ["北京"],
"claim.location": "北京",
"item.item_location": "北京",
}
)
return values
condition = next(
(item for item in params.get("conditions", []) if isinstance(item, dict)),
@@ -671,11 +641,19 @@ class AgentAssetRiskRuleTestingMixin:
return "住宿费"
return "测试值"
def _query_expense_claim_samples(self, parsed_scope: dict[str, Any]) -> list[ExpenseClaim]:
def _query_expense_claim_samples(
self,
target_tenant_id: str,
parsed_scope: dict[str, Any],
) -> list[ExpenseClaim]:
days = int(parsed_scope.get("days") or 30)
limit = min(max(int(parsed_scope.get("limit") or 50), 1), 200)
since = datetime.now(UTC) - timedelta(days=days)
stmt = select(ExpenseClaim).where(ExpenseClaim.created_at >= since)
# 租户谓词是查询构造的第一项,任何业务过滤与 limit 都只能在租户内生效。
stmt = select(ExpenseClaim).where(
ExpenseClaim.tenant_id == target_tenant_id,
ExpenseClaim.created_at >= since,
)
expense_keyword = str(parsed_scope.get("expense_keyword") or "").strip()
if expense_keyword:
@@ -703,6 +681,22 @@ class AgentAssetRiskRuleTestingMixin:
stmt = stmt.order_by(ExpenseClaim.created_at.desc()).limit(limit)
return list(self.db.scalars(stmt).all())
def _require_scenario_target_tenant(
self,
asset: AgentAsset,
target_tenant_id: str,
) -> str:
target = str(target_tenant_id or "").strip()
if not target or target == "platform":
raise ValueError("真实场景试运行必须显式指定企业租户。")
if self.access_scope is None:
raise PermissionError("真实场景试运行需要可信登录租户上下文。")
if target != self.access_scope.tenant_id:
raise LookupError("Asset not found")
if asset.scope == "tenant" and asset.tenant_id != target:
raise LookupError("Asset not found")
return target
@staticmethod
def _parse_scenario_scope(intent: str, filters: dict[str, Any]) -> dict[str, Any]:
text = str(intent or "")

View File

@@ -0,0 +1,221 @@
"""Agent 资产列表与版本响应的序列化辅助职责。"""
from __future__ import annotations
import json
from collections import defaultdict
from datetime import datetime
from typing import Any
from app.core.agent_enums import AgentAssetContentType, AgentAssetType, AgentReviewStatus
from app.models.agent_asset import AgentAsset, AgentAssetVersion
from app.schemas.agent_asset import AgentAssetListItem, AgentAssetVersionRead
class AgentAssetSerializationMixin:
"""集中处理资产/版本只读投影,不负责业务状态迁移。"""
def _serialize_version(
self, version: AgentAssetVersion, asset: AgentAsset
) -> AgentAssetVersionRead:
latest_review = self.repository.get_review(asset.id, version.version)
working_version = self._resolve_working_version(asset)
published_version = self._resolve_published_version(asset)
return AgentAssetVersionRead(
id=version.id,
tenant_id=version.tenant_id,
scope=version.scope,
asset_id=version.asset_id,
version=version.version,
content=self._deserialize_content(version),
content_type=version.content_type,
change_note=version.change_note,
created_by=version.created_by,
created_at=version.created_at,
is_current=version.version == working_version,
is_published=version.version == published_version,
is_working=version.version == working_version,
lifecycle_state=self._resolve_version_lifecycle_state(
version.version,
working_version=working_version,
published_version=published_version,
latest_review_status=latest_review.review_status if latest_review else "",
),
)
def _collect_version_stats(self, assets: list[AgentAsset]) -> dict[str, dict[str, Any]]:
asset_ids = [item.id for item in assets]
versions = self.repository.list_versions_for_assets(asset_ids)
reviews = self.repository.list_reviews_for_assets(asset_ids)
spreadsheet_logs = self.audit_service.repository.list_for_resources(
resource_type=AgentAssetType.RULE.value,
resource_ids=[
item.id
for item in assets
if item.asset_type == AgentAssetType.RULE.value
and str((item.config_json or {}).get("detail_mode") or "").strip().lower()
== "spreadsheet"
],
action="edit_rule_spreadsheet",
)
working_versions = {item.id: self._resolve_working_version(item) for item in assets}
version_counts: dict[str, int] = defaultdict(int)
modified_by: dict[str, str | None] = {item.id: None for item in assets}
published_versions = {item.id: self._resolve_published_version(item) for item in assets}
published_by: dict[str, str | None] = {}
published_at: dict[str, datetime | None] = {}
spreadsheet_edit_counts: dict[str, int] = defaultdict(int)
spreadsheet_last_actor: dict[str, str | None] = {}
spreadsheet_last_changed_at: dict[str, datetime] = {}
for version in versions:
version_counts[version.asset_id] += 1
if modified_by.get(
version.asset_id
) is None and version.version == working_versions.get(version.asset_id):
modified_by[version.asset_id] = version.created_by
for review in reviews:
if review.asset_id in published_at:
continue
if review.version != published_versions.get(review.asset_id):
continue
if review.review_status != AgentReviewStatus.APPROVED.value:
continue
published_by[review.asset_id] = review.reviewer
published_at[review.asset_id] = review.reviewed_at or review.created_at
for log in spreadsheet_logs:
spreadsheet_edit_counts[log.resource_id] += 1
last_changed_at = spreadsheet_last_changed_at.get(log.resource_id)
if last_changed_at is None or log.created_at >= last_changed_at:
spreadsheet_last_changed_at[log.resource_id] = log.created_at
spreadsheet_last_actor[log.resource_id] = log.actor
return {
item.id: {
"change_count": (
spreadsheet_edit_counts.get(item.id, 0)
if item.asset_type == AgentAssetType.RULE.value
and str((item.config_json or {}).get("detail_mode") or "").strip().lower()
== "spreadsheet"
and spreadsheet_edit_counts.get(item.id, 0) > 0
else max(version_counts.get(item.id, 0) - 1, 0)
),
"modified_by": (
spreadsheet_last_actor.get(item.id)
if item.asset_type == AgentAssetType.RULE.value
and str((item.config_json or {}).get("detail_mode") or "").strip().lower()
== "spreadsheet"
and spreadsheet_last_actor.get(item.id)
else modified_by.get(item.id)
),
"published_by": published_by.get(item.id),
"published_at": published_at.get(item.id),
}
for item in assets
}
@staticmethod
def _serialize_list_item(
asset: AgentAsset,
version_stats: dict[str, int | str | None] | None = None,
) -> AgentAssetListItem:
payload = AgentAssetListItem.model_validate(asset).model_dump()
payload["change_count"] = int((version_stats or {}).get("change_count") or 0)
payload["modified_by"] = str((version_stats or {}).get("modified_by") or "").strip() or None
payload["published_by"] = (
str((version_stats or {}).get("published_by") or "").strip() or None
)
payload["published_at"] = (version_stats or {}).get("published_at")
return AgentAssetListItem.model_validate(payload)
@staticmethod
def _sort_versions(
versions: list[AgentAssetVersion], current_version: str | None
) -> list[AgentAssetVersion]:
return sorted(
versions,
key=lambda item: (item.version == current_version, item.created_at),
reverse=True,
)
@staticmethod
def _serialize_content(content: Any, content_type: str) -> str:
if content_type == AgentAssetContentType.MARKDOWN.value:
return str(content)
return json.dumps(content, ensure_ascii=False, sort_keys=True, indent=2)
@staticmethod
def _deserialize_content(version: AgentAssetVersion | None) -> Any:
if version is None:
return None
if version.content_type == AgentAssetContentType.MARKDOWN.value:
return version.content
return json.loads(version.content)
@staticmethod
def _increment_version(version: str | None) -> str:
normalized = str(version or "").strip().removeprefix("v")
parts = normalized.split(".")
if len(parts) != 3 or not all(item.isdigit() for item in parts):
return "v1.0.0"
major, minor, patch = [int(item) for item in parts]
return f"v{major}.{minor}.{patch + 1}"
@staticmethod
def _hash_bytes(content: bytes) -> str:
import hashlib
return hashlib.sha256(content).hexdigest()
@staticmethod
def _asset_snapshot(asset: AgentAsset) -> dict[str, Any]:
return {
"tenant_id": asset.tenant_id,
"scope": asset.scope,
"asset_type": asset.asset_type,
"code": asset.code,
"name": asset.name,
"status": asset.status,
"current_version": asset.current_version,
"published_version": asset.published_version,
"working_version": asset.working_version,
"domain": asset.domain,
"owner": asset.owner,
"reviewer": asset.reviewer,
}
@staticmethod
def _resolve_working_version(asset: AgentAsset) -> str:
return str(asset.working_version or asset.current_version or "").strip()
@staticmethod
def _resolve_published_version(asset: AgentAsset) -> str:
return str(asset.published_version or "").strip()
@staticmethod
def _resolve_version_lifecycle_state(
version: str,
*,
working_version: str,
published_version: str,
latest_review_status: str,
) -> str:
if version == published_version:
return "published"
if version != working_version:
return "history"
if latest_review_status == AgentReviewStatus.PENDING.value:
return "pending_review"
if latest_review_status == AgentReviewStatus.APPROVED.value:
return "approved"
if latest_review_status == AgentReviewStatus.REJECTED.value:
return "rejected"
return "draft"
def _next_available_version(self, asset: AgentAsset) -> str:
candidate = self._increment_version(self._resolve_working_version(asset))
while self.repository.get_version(asset.id, candidate) is not None:
candidate = self._increment_version(candidate)
return candidate

View File

@@ -1,12 +1,10 @@
from __future__ import annotations
import json
from collections import defaultdict
from datetime import UTC, datetime
from typing import Any
from sqlalchemy.orm import Session
from app.api.deps import CurrentUserContext
from app.core.agent_enums import (
AgentAssetContentType,
AgentAssetStatus,
@@ -26,6 +24,10 @@ from app.schemas.agent_asset import (
AgentAssetVersionCreate,
AgentAssetVersionRead,
)
from app.services.agent_asset_access import (
AgentAssetAccessScope,
platform_resource_identity,
)
from app.services.agent_asset_json_rules import AgentAssetJsonRuleMixin
from app.services.agent_asset_onlyoffice import AgentAssetOnlyOfficeMixin
from app.services.agent_asset_risk_rule_feedback import AgentAssetRiskRuleFeedbackMixin
@@ -34,6 +36,7 @@ from app.services.agent_asset_risk_rule_publish import AgentAssetRiskRulePublish
from app.services.agent_asset_risk_rule_simulation import AgentAssetRiskRuleSimulationMixin
from app.services.agent_asset_risk_rule_testing import AgentAssetRiskRuleTestingMixin
from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager
from app.services.agent_asset_serialization import AgentAssetSerializationMixin
from app.services.agent_asset_spreadsheet import AgentAssetSpreadsheetManager
from app.services.agent_asset_spreadsheet_helpers import AgentAssetSpreadsheetHelperMixin
from app.services.agent_asset_timeline import AgentAssetTimelineMixin
@@ -142,212 +145,31 @@ class AgentAssetVersionMixin:
)
return restored # type: ignore[return-value]
def _serialize_version(
self, version: AgentAssetVersion, asset: AgentAsset
) -> AgentAssetVersionRead:
latest_review = self.repository.get_review(asset.id, version.version)
working_version = self._resolve_working_version(asset)
published_version = self._resolve_published_version(asset)
return AgentAssetVersionRead(
id=version.id,
asset_id=version.asset_id,
version=version.version,
content=self._deserialize_content(version),
content_type=version.content_type,
change_note=version.change_note,
created_by=version.created_by,
created_at=version.created_at,
is_current=version.version == working_version,
is_published=version.version == published_version,
is_working=version.version == working_version,
lifecycle_state=self._resolve_version_lifecycle_state(
version.version,
working_version=working_version,
published_version=published_version,
latest_review_status=latest_review.review_status if latest_review else "",
),
)
def _collect_version_stats(self, assets: list[AgentAsset]) -> dict[str, dict[str, Any]]:
asset_ids = [item.id for item in assets]
versions = self.repository.list_versions_for_assets(asset_ids)
reviews = self.repository.list_reviews_for_assets(asset_ids)
spreadsheet_logs = self.audit_service.repository.list_for_resources(
resource_type=AgentAssetType.RULE.value,
resource_ids=[
item.id
for item in assets
if item.asset_type == AgentAssetType.RULE.value
and str((item.config_json or {}).get("detail_mode") or "").strip().lower()
== "spreadsheet"
],
action="edit_rule_spreadsheet",
)
working_versions = {item.id: self._resolve_working_version(item) for item in assets}
version_counts: dict[str, int] = defaultdict(int)
modified_by: dict[str, str | None] = {item.id: None for item in assets}
published_versions = {item.id: self._resolve_published_version(item) for item in assets}
published_by: dict[str, str | None] = {}
published_at: dict[str, datetime | None] = {}
spreadsheet_edit_counts: dict[str, int] = defaultdict(int)
spreadsheet_last_actor: dict[str, str | None] = {}
spreadsheet_last_changed_at: dict[str, datetime] = {}
for version in versions:
version_counts[version.asset_id] += 1
if modified_by.get(
version.asset_id
) is None and version.version == working_versions.get(version.asset_id):
modified_by[version.asset_id] = version.created_by
for review in reviews:
if review.asset_id in published_at:
continue
if review.version != published_versions.get(review.asset_id):
continue
if review.review_status != AgentReviewStatus.APPROVED.value:
continue
published_by[review.asset_id] = review.reviewer
published_at[review.asset_id] = review.reviewed_at or review.created_at
for log in spreadsheet_logs:
spreadsheet_edit_counts[log.resource_id] += 1
last_changed_at = spreadsheet_last_changed_at.get(log.resource_id)
if last_changed_at is None or log.created_at >= last_changed_at:
spreadsheet_last_changed_at[log.resource_id] = log.created_at
spreadsheet_last_actor[log.resource_id] = log.actor
return {
item.id: {
"change_count": (
spreadsheet_edit_counts.get(item.id, 0)
if item.asset_type == AgentAssetType.RULE.value
and str((item.config_json or {}).get("detail_mode") or "").strip().lower()
== "spreadsheet"
and spreadsheet_edit_counts.get(item.id, 0) > 0
else max(version_counts.get(item.id, 0) - 1, 0)
),
"modified_by": (
spreadsheet_last_actor.get(item.id)
if item.asset_type == AgentAssetType.RULE.value
and str((item.config_json or {}).get("detail_mode") or "").strip().lower()
== "spreadsheet"
and spreadsheet_last_actor.get(item.id)
else modified_by.get(item.id)
),
"published_by": published_by.get(item.id),
"published_at": published_at.get(item.id),
}
for item in assets
}
@staticmethod
def _serialize_list_item(
asset: AgentAsset,
version_stats: dict[str, int | str | None] | None = None,
) -> AgentAssetListItem:
payload = AgentAssetListItem.model_validate(asset).model_dump()
payload["change_count"] = int((version_stats or {}).get("change_count") or 0)
payload["modified_by"] = str((version_stats or {}).get("modified_by") or "").strip() or None
payload["published_by"] = (
str((version_stats or {}).get("published_by") or "").strip() or None
)
payload["published_at"] = (version_stats or {}).get("published_at")
return AgentAssetListItem.model_validate(payload)
@staticmethod
def _sort_versions(
versions: list[AgentAssetVersion], current_version: str | None
) -> list[AgentAssetVersion]:
return sorted(
versions,
key=lambda item: (item.version == current_version, item.created_at),
reverse=True,
)
@staticmethod
def _serialize_content(content: Any, content_type: str) -> str:
if content_type == AgentAssetContentType.MARKDOWN.value:
return str(content)
return json.dumps(content, ensure_ascii=False, sort_keys=True, indent=2)
@staticmethod
def _deserialize_content(version: AgentAssetVersion | None) -> Any:
if version is None:
return None
if version.content_type == AgentAssetContentType.MARKDOWN.value:
return version.content
return json.loads(version.content)
@staticmethod
def _increment_version(version: str | None) -> str:
normalized = str(version or "").strip().removeprefix("v")
parts = normalized.split(".")
if len(parts) != 3 or not all(item.isdigit() for item in parts):
return "v1.0.0"
major, minor, patch = [int(item) for item in parts]
return f"v{major}.{minor}.{patch + 1}"
@staticmethod
def _hash_bytes(content: bytes) -> str:
import hashlib
return hashlib.sha256(content).hexdigest()
@staticmethod
def _asset_snapshot(asset: AgentAsset) -> dict[str, Any]:
return {
"asset_type": asset.asset_type,
"code": asset.code,
"name": asset.name,
"status": asset.status,
"current_version": asset.current_version,
"published_version": asset.published_version,
"working_version": asset.working_version,
"domain": asset.domain,
"owner": asset.owner,
"reviewer": asset.reviewer,
}
@staticmethod
def _resolve_working_version(asset: AgentAsset) -> str:
return str(asset.working_version or asset.current_version or "").strip()
@staticmethod
def _resolve_published_version(asset: AgentAsset) -> str:
return str(asset.published_version or "").strip()
@staticmethod
def _resolve_version_lifecycle_state(
version: str,
class AgentAssetService(
AgentAssetSerializationMixin,
AgentAssetVersionMixin,
AgentAssetOnlyOfficeMixin,
AgentAssetSpreadsheetHelperMixin,
AgentAssetRiskRuleLevelMixin,
AgentAssetRiskRulePublishMixin,
AgentAssetRiskRuleFeedbackMixin,
AgentAssetRiskRuleTestingMixin,
AgentAssetRiskRuleSimulationMixin,
AgentAssetTimelineMixin,
AgentAssetJsonRuleMixin,
):
def __init__(
self,
db: Session,
*,
working_version: str,
published_version: str,
latest_review_status: str,
) -> str:
if version == published_version:
return "published"
if version != working_version:
return "history"
if latest_review_status == AgentReviewStatus.PENDING.value:
return "pending_review"
if latest_review_status == AgentReviewStatus.APPROVED.value:
return "approved"
if latest_review_status == AgentReviewStatus.REJECTED.value:
return "rejected"
return "draft"
def _next_available_version(self, asset: AgentAsset) -> str:
candidate = self._increment_version(self._resolve_working_version(asset))
while self.repository.get_version(asset.id, candidate) is not None:
candidate = self._increment_version(candidate)
return candidate
class AgentAssetService(AgentAssetVersionMixin, AgentAssetOnlyOfficeMixin, AgentAssetSpreadsheetHelperMixin, AgentAssetRiskRuleLevelMixin, AgentAssetRiskRulePublishMixin, AgentAssetRiskRuleFeedbackMixin, AgentAssetRiskRuleTestingMixin, AgentAssetRiskRuleSimulationMixin, AgentAssetTimelineMixin, AgentAssetJsonRuleMixin):
def __init__(self, db: Session) -> None:
current_user: CurrentUserContext | None = None,
) -> None:
self.db = db
self.repository = AgentAssetRepository(db)
self.access_scope = (
AgentAssetAccessScope.from_user(current_user) if current_user is not None else None
)
self.repository = AgentAssetRepository(db, access_scope=self.access_scope)
self.audit_service = AuditLogService(db)
self.spreadsheet_manager = AgentAssetSpreadsheetManager()
self.rule_library_manager = AgentAssetRuleLibraryManager()
@@ -409,10 +231,13 @@ class AgentAssetService(AgentAssetVersionMixin, AgentAssetOnlyOfficeMixin, Agent
if asset is None:
return None
try:
if backfill_missing_risk_rule_score(asset):
can_persist_backfill = self.access_scope is None or self.access_scope.can_write(asset)
if can_persist_backfill and backfill_missing_risk_rule_score(asset):
asset = self.repository.save_asset(asset)
except Exception:
logger.warning("Failed to backfill risk rule score asset_id=%s", asset_id, exc_info=True)
logger.warning(
"Failed to backfill risk rule score asset_id=%s", asset_id, exc_info=True
)
working_version = self._resolve_working_version(asset)
recent_versions = self._sort_versions(
@@ -450,7 +275,9 @@ class AgentAssetService(AgentAssetVersionMixin, AgentAssetOnlyOfficeMixin, Agent
@staticmethod
def _filter_excluded_risk_assets(assets: list[AgentAsset]) -> list[AgentAsset]:
return [asset for asset in assets if not AgentAssetService._is_excluded_budget_risk_asset(asset)]
return [
asset for asset in assets if not AgentAssetService._is_excluded_budget_risk_asset(asset)
]
@staticmethod
def _is_excluded_budget_risk_asset(asset: AgentAsset) -> bool:
@@ -481,7 +308,19 @@ class AgentAssetService(AgentAssetVersionMixin, AgentAssetOnlyOfficeMixin, Agent
if payload.status == AgentAssetStatus.ACTIVE:
raise ValueError("请先创建资产并完成审核,再通过上线接口激活。")
if self.access_scope is None:
tenant_id, resource_scope = platform_resource_identity()
elif payload.scope == "platform":
if not self.access_scope.is_platform_admin:
raise PermissionError("只有平台管理员可以创建平台资产。")
tenant_id, resource_scope = platform_resource_identity()
else:
tenant_id = self.access_scope.tenant_id
resource_scope = "tenant"
asset = AgentAsset(
tenant_id=tenant_id,
scope=resource_scope,
asset_type=payload.asset_type.value,
code=payload.code,
name=payload.name,
@@ -521,6 +360,16 @@ class AgentAssetService(AgentAssetVersionMixin, AgentAssetOnlyOfficeMixin, Agent
before = self._asset_snapshot(asset)
config_json = asset.config_json if isinstance(asset.config_json, dict) else {}
is_json_risk = (
asset.asset_type == AgentAssetType.RULE.value
and str(config_json.get("detail_mode") or "").strip().lower() == "json_risk"
)
if is_json_risk and payload.published_version is not None:
raise ValueError("JSON 风险规则发布版本只能由 shadow/Canary 发布流程变更。")
if is_json_risk and payload.config_json is not None:
raise ValueError("JSON 风险规则运行配置只能由专用规则接口变更。")
if payload.status == AgentAssetStatus.ACTIVE:
raise ValueError("请使用上线接口激活资产。")
@@ -607,7 +456,7 @@ class AgentAssetService(AgentAssetVersionMixin, AgentAssetOnlyOfficeMixin, Agent
content=serialized_content,
content_type=payload.content_type.value,
change_note=payload.change_note,
created_by=payload.created_by,
created_by=actor,
)
created = self.repository.create_version(version)
@@ -675,17 +524,18 @@ class AgentAssetService(AgentAssetVersionMixin, AgentAssetOnlyOfficeMixin, Agent
review = AgentAssetReview(
asset_id=asset_id,
version=payload.version,
reviewer=payload.reviewer,
reviewer=actor,
review_status=payload.review_status.value,
review_note=payload.review_note,
reviewed_at=None
if payload.review_status == AgentReviewStatus.PENDING
else datetime.now(UTC),
created_at=datetime.now(UTC),
)
created = self.repository.create_review(review)
before = self._asset_snapshot(asset)
asset.reviewer = payload.reviewer
asset.reviewer = actor
if payload.review_status == AgentReviewStatus.PENDING:
if not asset.published_version:
asset.status = AgentAssetStatus.REVIEW.value
@@ -810,6 +660,9 @@ class AgentAssetService(AgentAssetVersionMixin, AgentAssetOnlyOfficeMixin, Agent
raise ValueError("资产尚未设置工作版本,无法上线。")
if asset.asset_type == AgentAssetType.RULE.value:
config_json = asset.config_json if isinstance(asset.config_json, dict) else {}
if str(config_json.get("detail_mode") or "").strip().lower() == "json_risk":
raise ValueError("JSON 风险规则必须通过 shadow/Canary 发布流程上线。")
review = self.repository.get_review(
asset.id, candidate_version, AgentReviewStatus.APPROVED.value
)
@@ -846,4 +699,3 @@ class AgentAssetService(AgentAssetVersionMixin, AgentAssetOnlyOfficeMixin, Agent
synced_count += foundation.sync_platform_risk_rules_from_library()
self.db.commit()
return synced_count

View File

@@ -19,6 +19,7 @@ from app.services.agent_foundation_financial_seed import AgentFoundationFinancia
from app.services.agent_foundation_markdown import AgentFoundationMarkdownMixin
from app.services.agent_foundation_risk_rules import AgentFoundationRiskRuleMixin
from app.services.agent_foundation_spreadsheets import AgentFoundationSpreadsheetMixin
from app.services.tenant_registry import TenantRegistryService
logger = get_logger("app.services.agent_foundation")
_foundation_ready_lock = threading.RLock()
@@ -63,6 +64,7 @@ class AgentFoundationService(
def _prepare_foundation(self) -> None:
try:
create_legacy_schema(self.db.get_bind())
TenantRegistryService(self.db).ensure_builtin()
self._ensure_agent_asset_schema()
self._ensure_financial_record_schema()
self._seed_agent_assets()

View File

@@ -1,66 +1,27 @@
from __future__ import annotations
import hashlib
import json
from datetime import UTC, date, datetime
from decimal import Decimal
from pathlib import Path
from datetime import datetime
from sqlalchemy import inspect, select, text
from app.core.agent_enums import (
AgentAssetContentType,
AgentAssetDomain,
AgentAssetStatus,
AgentAssetType,
AgentName,
AgentPermissionLevel,
AgentReviewStatus,
AgentRunSource,
AgentRunStatus,
AgentToolType,
)
from app.models.agent_asset import AgentAsset, AgentAssetReview, AgentAssetVersion
from app.models.agent_run import AgentRun, AgentToolCall, SemanticParseLog
from app.models.audit_log import AuditLog
from app.models.financial_record import (
AccountsPayableRecord,
AccountsReceivableRecord,
ExpenseClaim,
ExpenseClaimItem,
)
from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager
from app.services.agent_asset_spreadsheet import (
AgentAssetSpreadsheetManager,
COMPANY_COMMUNICATION_EXPENSE_RULE_CODE,
COMPANY_COMMUNICATION_EXPENSE_RULE_FILENAME,
COMPANY_TRAVEL_EXPENSE_RULE_CODE,
COMPANY_TRAVEL_EXPENSE_RULE_FILENAME,
FINANCE_RULES_LIBRARY,
RISK_RULES_LIBRARY,
)
from app.services.expense_rule_runtime import (
build_scene_submission_standard_markdown,
build_travel_risk_control_standard_markdown,
)
from app.services.agent_foundation_constants import (
ATTACHMENT_RULE_ASSET_CODE,
ATTACHMENT_RULE_RUNTIME_CONFIG,
COMPANY_COMMUNICATION_RULE_SCENARIO_JSON,
COMPANY_COMMUNICATION_RULE_VERSION,
COMPANY_TRAVEL_RULE_SCENARIO_JSON,
COMPANY_TRAVEL_RULE_VERSION,
DEMO_EXPENSE_CLAIM_SIGNATURES,
DEMO_PAYABLE_SIGNATURES,
DEMO_RECEIVABLE_SIGNATURES,
LEGACY_RULE_CODES,
PLATFORM_DESTINATION_LOCATION_RULE_FILENAME,
)
from app.core.logging import get_logger
from app.models.agent_asset import AgentAsset, AgentAssetReview, AgentAssetVersion
from app.models.audit_log import AuditLog
from app.services.agent_asset_access import platform_asset_statement
from app.services.agent_foundation_constants import (
LEGACY_RULE_CODES,
)
logger = get_logger("app.services.agent_foundation")
class AgentFoundationAssetHelperMixin:
@staticmethod
def _platform_asset_stmt():
return platform_asset_statement()
def _create_seed_asset(
self,
@@ -247,7 +208,7 @@ class AgentFoundationAssetHelperMixin:
self.db.scalars(
select(AgentAsset).where(AgentAsset.code.in_(LEGACY_RULE_CODES))
self._platform_asset_stmt().where(AgentAsset.code.in_(LEGACY_RULE_CODES))
).all()

View File

@@ -34,7 +34,6 @@ from app.services.agent_foundation_constants import (
DIGITAL_EMPLOYEE_FINANCE_POLICY_TASK_CODE,
DIGITAL_EMPLOYEE_PROFILE_SCAN_TASK_CODE,
DIGITAL_EMPLOYEE_RISK_GRAPH_SCAN_TASK_CODE,
DIGITAL_EMPLOYEE_RULE_DISCOVERY_TASK_CODE,
DIGITAL_EMPLOYEE_SKILL_CATEGORIES,
DIGITAL_EMPLOYEE_TASK_CATEGORY_MAP,
)
@@ -177,7 +176,14 @@ class AgentFoundationAssetSeedMixin:
def _seed_agent_assets(self) -> None:
existing_codes = set(self.db.scalars(select(AgentAsset.code)).all())
existing_codes = set(
self.db.scalars(
select(AgentAsset.code).where(
AgentAsset.scope == "platform",
AgentAsset.tenant_id == "platform",
)
).all()
)
if existing_codes:
@@ -513,7 +519,14 @@ class AgentFoundationAssetSeedMixin:
self.db.flush()
self._upsert_runtime_digital_employee_tasks(
set(self.db.scalars(select(AgentAsset.code)).all())
set(
self.db.scalars(
select(AgentAsset.code).where(
AgentAsset.scope == "platform",
AgentAsset.tenant_id == "platform",
)
).all()
)
)
self.db.flush()

View File

@@ -14,6 +14,7 @@ from app.core.agent_enums import (
from app.core.logging import get_logger
from app.models.agent_asset import AgentAsset
from app.models.agent_run import AgentRun
from app.services.agent_asset_access import platform_asset_statement
from app.services.agent_asset_spreadsheet import (
COMPANY_COMMUNICATION_EXPENSE_RULE_CODE,
COMPANY_PREAPPROVAL_RULE_CODE,
@@ -43,7 +44,9 @@ class AgentFoundationAssetTopUpMixin:
def _remove_legacy_digital_employee_assets(self) -> None:
assets = list(
self.db.scalars(
select(AgentAsset).where(AgentAsset.code.in_(DIGITAL_EMPLOYEE_LEGACY_TASK_CODES))
platform_asset_statement().where(
AgentAsset.code.in_(DIGITAL_EMPLOYEE_LEGACY_TASK_CODES)
)
).all()
)
if not assets:
@@ -65,7 +68,7 @@ class AgentFoundationAssetTopUpMixin:
has_changes = False
for code, category in DIGITAL_EMPLOYEE_TASK_CATEGORY_MAP.items():
asset = self.db.scalar(select(AgentAsset).where(AgentAsset.code == code))
asset = self.db.scalar(platform_asset_statement().where(AgentAsset.code == code))
if asset is None:
continue
@@ -95,31 +98,50 @@ class AgentFoundationAssetTopUpMixin:
self._remove_legacy_rule_assets()
self._remove_legacy_digital_employee_assets()
existing_codes = set(self.db.scalars(select(AgentAsset.code)).all())
existing_codes = set(
self.db.scalars(
select(AgentAsset.code).where(
AgentAsset.scope == "platform",
AgentAsset.tenant_id == "platform",
)
).all()
)
self._sync_digital_employee_skill_categories()
attachment_rule = self.db.scalar(
select(AgentAsset).where(AgentAsset.code == ATTACHMENT_RULE_ASSET_CODE)
platform_asset_statement().where(
AgentAsset.code == ATTACHMENT_RULE_ASSET_CODE
)
)
scene_submission_rule = self.db.scalar(
select(AgentAsset).where(AgentAsset.code == "rule.expense.scene_submission_standard")
platform_asset_statement().where(
AgentAsset.code == "rule.expense.scene_submission_standard"
)
)
travel_policy_rule = self.db.scalar(
select(AgentAsset).where(AgentAsset.code == "rule.expense.travel_risk_control_standard")
platform_asset_statement().where(
AgentAsset.code == "rule.expense.travel_risk_control_standard"
)
)
company_travel_rule = self.db.scalar(
select(AgentAsset).where(AgentAsset.code == COMPANY_TRAVEL_EXPENSE_RULE_CODE)
platform_asset_statement().where(
AgentAsset.code == COMPANY_TRAVEL_EXPENSE_RULE_CODE
)
)
company_communication_rule = self.db.scalar(
select(AgentAsset).where(AgentAsset.code == COMPANY_COMMUNICATION_EXPENSE_RULE_CODE)
platform_asset_statement().where(
AgentAsset.code == COMPANY_COMMUNICATION_EXPENSE_RULE_CODE
)
)
company_preapproval_rule = self.db.scalar(
select(AgentAsset).where(AgentAsset.code == COMPANY_PREAPPROVAL_RULE_CODE)
platform_asset_statement().where(
AgentAsset.code == COMPANY_PREAPPROVAL_RULE_CODE
)
)
if ATTACHMENT_RULE_ASSET_CODE not in existing_codes:
@@ -752,7 +774,9 @@ class AgentFoundationAssetTopUpMixin:
else:
asset = self.db.scalar(
select(AgentAsset).where(AgentAsset.code == DIGITAL_EMPLOYEE_FINANCE_POLICY_TASK_CODE)
platform_asset_statement().where(
AgentAsset.code == DIGITAL_EMPLOYEE_FINANCE_POLICY_TASK_CODE
)
)
if asset is None:
return

View File

@@ -1,7 +1,5 @@
from __future__ import annotations
from sqlalchemy import select
from app.core.agent_enums import (
AgentAssetContentType,
AgentAssetDomain,
@@ -10,6 +8,7 @@ from app.core.agent_enums import (
AgentName,
)
from app.models.agent_asset import AgentAsset
from app.services.agent_asset_access import platform_asset_statement
from app.services.agent_foundation_constants import (
DIGITAL_EMPLOYEE_ALGORITHM_REPLAY_TASK_CODE,
DIGITAL_EMPLOYEE_BUDGET_PRECONTROL_TASK_CODE,
@@ -501,7 +500,7 @@ class AgentFoundationDigitalEmployeeTaskMixin:
config_json=config,
)
else:
asset = self.db.scalar(select(AgentAsset).where(AgentAsset.code == code))
asset = self.db.scalar(platform_asset_statement().where(AgentAsset.code == code))
if asset is None:
return
self._refresh_runtime_digital_employee_asset(asset, spec)

View File

@@ -1,26 +1,19 @@
from __future__ import annotations
import hashlib
import json
from datetime import UTC, date, datetime
from decimal import Decimal
from pathlib import Path
from sqlalchemy import inspect, select, text
from sqlalchemy import select
from app.core.agent_enums import (
AgentAssetContentType,
AgentAssetDomain,
AgentAssetStatus,
AgentAssetType,
AgentName,
AgentPermissionLevel,
AgentReviewStatus,
AgentRunSource,
AgentRunStatus,
AgentToolType,
)
from app.models.agent_asset import AgentAsset, AgentAssetReview, AgentAssetVersion
from app.core.logging import get_logger
from app.models.agent_asset import AgentAsset
from app.models.agent_run import AgentRun, AgentToolCall, SemanticParseLog
from app.models.audit_log import AuditLog
from app.models.financial_record import (
@@ -29,47 +22,33 @@ from app.models.financial_record import (
ExpenseClaim,
ExpenseClaimItem,
)
from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager
from app.services.agent_asset_spreadsheet import (
AgentAssetSpreadsheetManager,
COMPANY_COMMUNICATION_EXPENSE_RULE_CODE,
COMPANY_COMMUNICATION_EXPENSE_RULE_FILENAME,
COMPANY_TRAVEL_EXPENSE_RULE_CODE,
COMPANY_TRAVEL_EXPENSE_RULE_FILENAME,
FINANCE_RULES_LIBRARY,
RISK_RULES_LIBRARY,
)
from app.services.expense_rule_runtime import (
build_scene_submission_standard_markdown,
build_travel_risk_control_standard_markdown,
)
from app.services.agent_asset_access import platform_asset_statement
from app.services.agent_foundation_constants import (
ATTACHMENT_RULE_ASSET_CODE,
ATTACHMENT_RULE_RUNTIME_CONFIG,
COMPANY_COMMUNICATION_RULE_SCENARIO_JSON,
COMPANY_COMMUNICATION_RULE_VERSION,
COMPANY_TRAVEL_RULE_SCENARIO_JSON,
COMPANY_TRAVEL_RULE_VERSION,
DEMO_EXPENSE_CLAIM_SIGNATURES,
DEMO_PAYABLE_SIGNATURES,
DEMO_RECEIVABLE_SIGNATURES,
DIGITAL_EMPLOYEE_FINANCE_POLICY_TASK_CODE,
LEGACY_RULE_CODES,
PLATFORM_DESTINATION_LOCATION_RULE_FILENAME,
)
from app.core.logging import get_logger
from app.services.tenant_registry import DEFAULT_TENANT_ID
logger = get_logger("app.services.agent_foundation")
class AgentFoundationFinancialSeedMixin:
def _seed_financial_records(self) -> None:
if self.db.scalar(select(ExpenseClaim.id).limit(1)) is not None:
if self.db.scalar(
select(ExpenseClaim.id)
.where(ExpenseClaim.tenant_id == DEFAULT_TENANT_ID)
.limit(1)
) is not None:
return
claim_1 = ExpenseClaim(
tenant_id=DEFAULT_TENANT_ID,
claim_no="EXP-202605-001",
employee_name="张三",
@@ -140,6 +119,8 @@ class AgentFoundationFinancialSeedMixin:
claim_2 = ExpenseClaim(
tenant_id=DEFAULT_TENANT_ID,
claim_no="EXP-202605-002",
employee_name="李四",
@@ -174,6 +155,8 @@ class AgentFoundationFinancialSeedMixin:
claim_3 = ExpenseClaim(
tenant_id=DEFAULT_TENANT_ID,
claim_no="EXP-202605-003",
employee_name="王五",
@@ -209,6 +192,7 @@ class AgentFoundationFinancialSeedMixin:
ar_records = [
AccountsReceivableRecord(
tenant_id=DEFAULT_TENANT_ID,
receivable_no="AR-202605-001",
@@ -241,6 +225,7 @@ class AgentFoundationFinancialSeedMixin:
),
AccountsReceivableRecord(
tenant_id=DEFAULT_TENANT_ID,
receivable_no="AR-202605-002",
@@ -277,6 +262,7 @@ class AgentFoundationFinancialSeedMixin:
ap_records = [
AccountsPayableRecord(
tenant_id=DEFAULT_TENANT_ID,
payable_no="AP-202605-001",
@@ -307,6 +293,7 @@ class AgentFoundationFinancialSeedMixin:
),
AccountsPayableRecord(
tenant_id=DEFAULT_TENANT_ID,
payable_no="AP-202605-002",
@@ -342,7 +329,13 @@ class AgentFoundationFinancialSeedMixin:
def _purge_demo_financial_records(self) -> None:
demo_claims = list(self.db.scalars(select(ExpenseClaim)).all())
demo_claims = list(
self.db.scalars(
select(ExpenseClaim).where(
ExpenseClaim.tenant_id == DEFAULT_TENANT_ID
)
).all()
)
for claim in demo_claims:
@@ -364,7 +357,13 @@ class AgentFoundationFinancialSeedMixin:
self.db.delete(claim)
demo_receivables = list(self.db.scalars(select(AccountsReceivableRecord)).all())
demo_receivables = list(
self.db.scalars(
select(AccountsReceivableRecord).where(
AccountsReceivableRecord.tenant_id == DEFAULT_TENANT_ID
)
).all()
)
for record in demo_receivables:
@@ -384,7 +383,13 @@ class AgentFoundationFinancialSeedMixin:
self.db.delete(record)
demo_payables = list(self.db.scalars(select(AccountsPayableRecord)).all())
demo_payables = list(
self.db.scalars(
select(AccountsPayableRecord).where(
AccountsPayableRecord.tenant_id == DEFAULT_TENANT_ID
)
).all()
)
for record in demo_payables:
@@ -412,7 +417,9 @@ class AgentFoundationFinancialSeedMixin:
task_asset = self.db.scalar(
select(AgentAsset).where(AgentAsset.code == DIGITAL_EMPLOYEE_FINANCE_POLICY_TASK_CODE)
platform_asset_statement().where(
AgentAsset.code == DIGITAL_EMPLOYEE_FINANCE_POLICY_TASK_CODE
)
)

View File

@@ -13,6 +13,7 @@ from app.core.agent_enums import (
)
from app.core.logging import get_logger
from app.models.agent_asset import AgentAsset
from app.services.agent_asset_access import platform_asset_statement
from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager
from app.services.agent_asset_spreadsheet import (
RISK_RULES_LIBRARY,
@@ -325,7 +326,14 @@ class AgentFoundationRiskRuleMixin:
def sync_platform_risk_rules_from_library(self) -> int:
existing_codes = set(self.db.scalars(select(AgentAsset.code)).all())
existing_codes = set(
self.db.scalars(
select(AgentAsset.code).where(
AgentAsset.scope == "platform",
AgentAsset.tenant_id == "platform",
)
).all()
)
before_count = len(existing_codes)
@@ -339,7 +347,14 @@ class AgentFoundationRiskRuleMixin:
self.db.flush()
after_codes = set(self.db.scalars(select(AgentAsset.code)).all())
after_codes = set(
self.db.scalars(
select(AgentAsset.code).where(
AgentAsset.scope == "platform",
AgentAsset.tenant_id == "platform",
)
).all()
)
synced = max(len(after_codes) - before_count, 0)
@@ -361,7 +376,9 @@ class AgentFoundationRiskRuleMixin:
def _hide_stale_demo_risk_rules(self, manifest_codes: set[str]) -> None:
assets = self.db.scalars(
select(AgentAsset).where(AgentAsset.asset_type == AgentAssetType.RULE.value)
platform_asset_statement().where(
AgentAsset.asset_type == AgentAssetType.RULE.value
)
).all()
for asset in assets:
config = asset.config_json if isinstance(asset.config_json, dict) else {}
@@ -400,7 +417,9 @@ class AgentFoundationRiskRuleMixin:
scenario_json = self._platform_risk_scenario_json(manifest)
asset = self.db.scalar(select(AgentAsset).where(AgentAsset.code == rule_code))
asset = self.db.scalar(
platform_asset_statement().where(AgentAsset.code == rule_code)
)
if asset is None and rule_code not in existing_codes:

View File

@@ -2,24 +2,23 @@ from __future__ import annotations
from pathlib import Path
from sqlalchemy import select
from app.core.agent_enums import (
AgentAssetContentType,
AgentAssetDomain,
AgentAssetStatus,
AgentAssetType,
AgentReviewStatus,
AgentAssetStatus,
)
from app.core.logging import get_logger
from app.models.agent_asset import AgentAsset
from app.services.agent_asset_access import platform_asset_statement
from app.services.agent_asset_spreadsheet import (
COMPANY_COMMUNICATION_EXPENSE_RULE_CODE,
COMPANY_COMMUNICATION_EXPENSE_RULE_FILENAME,
COMPANY_TRAVEL_ALLOWANCE_RULE_CODE,
COMPANY_TRAVEL_ALLOWANCE_RULE_FILENAME,
COMPANY_PREAPPROVAL_RULE_CODE,
COMPANY_PREAPPROVAL_RULE_FILENAME,
COMPANY_TRAVEL_ALLOWANCE_RULE_CODE,
COMPANY_TRAVEL_ALLOWANCE_RULE_FILENAME,
COMPANY_TRAVEL_EXPENSE_RULE_CODE,
COMPANY_TRAVEL_EXPENSE_RULE_FILENAME,
COMPANY_TRAVEL_GRADE_MAPPING_RULE_CODE,
@@ -41,13 +40,13 @@ from app.services.agent_foundation_constants import (
COMPANY_TRAVEL_RULE_SCENARIO_JSON,
COMPANY_TRAVEL_RULE_VERSION,
)
from app.services.agent_foundation_preapproval_spreadsheet import (
build_preapproval_rule_workbook_sheets,
)
from app.services.finance_rule_catalog import (
DEPRECATED_FINANCE_RULE_CODES,
DEPRECATED_FINANCE_RULE_REPLACEMENTS,
)
from app.services.agent_foundation_preapproval_spreadsheet import (
build_preapproval_rule_workbook_sheets,
)
logger = get_logger("app.services.agent_foundation")
@@ -212,7 +211,7 @@ class AgentFoundationSpreadsheetMixin:
tag: str = "基础规则",
refresh_workbook_content: bool = False,
) -> bool:
asset = self.db.scalar(select(AgentAsset).where(AgentAsset.code == code))
asset = self.db.scalar(platform_asset_statement().where(AgentAsset.code == code))
created_asset = asset is None
if asset is None:
asset = self._create_seed_asset(
@@ -376,7 +375,7 @@ class AgentFoundationSpreadsheetMixin:
def _hide_deprecated_finance_rule_assets(self) -> None:
for code in DEPRECATED_FINANCE_RULE_CODES:
asset = self.db.scalar(select(AgentAsset).where(AgentAsset.code == code))
asset = self.db.scalar(platform_asset_statement().where(AgentAsset.code == code))
if asset is None:
continue
asset.status = AgentAssetStatus.DISABLED.value

View File

@@ -0,0 +1,201 @@
from __future__ import annotations
from typing import Any
from fastapi import HTTPException, status
from sqlalchemy import and_, func, not_, or_, select
from sqlalchemy.orm import Session
from app.api.deps import CurrentUserContext
from app.models.agent_run import AgentRun
from app.schemas.agent_run import AgentRunRead
from app.services.finance_dashboard_access_policy import FinanceDashboardAccessPolicy
from app.services.finance_dashboard_scope import (
FINANCE_DASHBOARD_TASK_TYPE,
resolve_finance_dashboard_data_scope,
)
class AgentRunAccessPolicy:
"""对租户边界和敏感领域权限做返回前的第二层校验。"""
@classmethod
def build_query_scope(cls, current_user: CurrentUserContext) -> Any:
"""把财务快照领域门禁下推到 limit 之前,避免不可见记录挤占窗口。"""
tenant_id = cls.require_current_tenant_id(current_user)
route_task_type = func.coalesce(
AgentRun.route_json["task_type"].as_string(),
"",
)
route_job_type = func.coalesce(
AgentRun.route_json["job_type"].as_string(),
"",
)
is_finance_snapshot = or_(
route_task_type == FINANCE_DASHBOARD_TASK_TYPE,
route_job_type == FINANCE_DASHBOARD_TASK_TYPE,
)
if not FinanceDashboardAccessPolicy.can_read(current_user):
return not_(is_finance_snapshot)
expected_data_scope = resolve_finance_dashboard_data_scope(tenant_id)
valid_finance_scope = and_(
AgentRun.route_json["tenant_id"].as_string() == tenant_id,
AgentRun.ontology_json["tenant_id"].as_string() == tenant_id,
AgentRun.route_json["data_scope"].as_string() == expected_data_scope,
AgentRun.ontology_json["data_scope"].as_string() == expected_data_scope,
)
return or_(not_(is_finance_snapshot), valid_finance_scope)
@classmethod
def filter_list_items(
cls,
runs: list[AgentRunRead],
current_user: CurrentUserContext,
db: Session,
) -> list[AgentRunRead]:
payloads_by_run_id = cls._run_scope_payloads(
db,
[run.run_id for run in runs],
)
current_tenant_id = cls.require_current_tenant_id(current_user)
visible: list[AgentRunRead] = []
for run in runs:
payloads = payloads_by_run_id.get(run.run_id)
if payloads is None:
continue
route_json, ontology_json = payloads
if cls._tenant_scope_from_payloads(route_json, ontology_json) != current_tenant_id:
continue
if cls.is_finance_dashboard_snapshot(run) and not cls._can_read_finance_snapshot_scope(
cls._finance_scope_from_payloads(route_json, ontology_json),
current_user,
):
continue
visible.append(run)
return visible
@classmethod
def _run_scope_payloads(
cls,
db: Session,
run_ids: list[str],
) -> dict[str, tuple[object, object]]:
if not run_ids:
return {}
rows = db.execute(
select(AgentRun.run_id, AgentRun.route_json, AgentRun.ontology_json).where(
AgentRun.run_id.in_(run_ids)
)
).all()
return {
str(run_id): (route_json, ontology_json) for run_id, route_json, ontology_json in rows
}
@classmethod
def require_detail_read(
cls,
run: AgentRunRead,
current_user: CurrentUserContext,
) -> None:
current_tenant_id = cls.require_current_tenant_id(current_user)
if cls._tenant_scope_from_payloads(run.route_json, run.ontology_json) != current_tenant_id:
cls._raise_not_found()
if not cls.is_finance_dashboard_snapshot(run):
return
run_scope = cls._finance_scope_from_payloads(run.route_json, run.ontology_json)
expected_scope = (
resolve_finance_dashboard_data_scope(current_tenant_id) if current_tenant_id else None
)
if (
current_tenant_id is None
or run_scope is None
or run_scope != (current_tenant_id, expected_scope)
):
cls._raise_not_found()
FinanceDashboardAccessPolicy.require_read(current_user)
@classmethod
def is_finance_dashboard_snapshot(cls, run: AgentRunRead) -> bool:
route = run.route_json if isinstance(run.route_json, dict) else {}
return any(
str(route.get(key) or "").strip() == FINANCE_DASHBOARD_TASK_TYPE
for key in ("task_type", "job_type")
)
@classmethod
def _can_read_finance_snapshot_scope(
cls,
run_scope: tuple[str, str] | None,
current_user: CurrentUserContext,
) -> bool:
current_tenant_id = cls._normalized_tenant_id(current_user.tenant_id)
expected_scope = (
resolve_finance_dashboard_data_scope(current_tenant_id)
if current_tenant_id is not None
else None
)
return bool(
current_tenant_id
and run_scope
and run_scope == (current_tenant_id, expected_scope)
and FinanceDashboardAccessPolicy.can_read(current_user)
)
@classmethod
def _finance_scope_from_payloads(
cls,
*payloads: object,
) -> tuple[str, str] | None:
tenant_ids: list[str] = []
data_scopes: list[str] = []
for payload in payloads:
if not isinstance(payload, dict):
return None
tenant_id = cls._normalized_tenant_id(payload.get("tenant_id"))
data_scope = str(payload.get("data_scope") or "").strip()
if tenant_id is None or not data_scope:
return None
tenant_ids.append(tenant_id)
data_scopes.append(data_scope)
if not tenant_ids or len(set(tenant_ids)) != 1 or len(set(data_scopes)) != 1:
return None
return tenant_ids[0], data_scopes[0]
@classmethod
def _tenant_scope_from_payloads(
cls,
*payloads: object,
) -> str | None:
tenant_ids: list[str] = []
for payload in payloads:
if not isinstance(payload, dict) or "tenant_id" not in payload:
return None
tenant_id = cls._normalized_tenant_id(payload.get("tenant_id"))
if tenant_id is None:
return None
tenant_ids.append(tenant_id)
if len(tenant_ids) != len(payloads) or len(set(tenant_ids)) != 1:
return None
return tenant_ids[0]
@classmethod
def require_current_tenant_id(cls, current_user: CurrentUserContext) -> str:
tenant_id = cls._normalized_tenant_id(current_user.tenant_id)
if tenant_id is None:
cls._raise_not_found()
return tenant_id
@staticmethod
def _raise_not_found() -> None:
# 统一按不存在处理,避免 run_id 或作用域标记成为租户探针。
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Run not found",
)
@staticmethod
def _normalized_tenant_id(value: object) -> str | None:
normalized = str(value or "").strip()
return normalized or None

View File

@@ -7,8 +7,8 @@ from typing import Any
from sqlalchemy.orm import Session
from app.core.config import get_settings
from app.core.agent_enums import AgentName, AgentPermissionLevel, AgentRunStatus
from app.core.config import get_settings
from app.core.logging import get_logger
from app.models.agent_run import AgentRun, AgentToolCall, SemanticParseLog
from app.repositories.agent_run import AgentRunRepository
@@ -19,6 +19,7 @@ from app.schemas.agent_run import (
SemanticParseRead,
)
from app.services.agent_foundation import AgentFoundationService
from app.services.commercial_runtime_bridge import CommercialRuntimeBridge
from app.services.knowledge_ingest_log import enrich_knowledge_ingest_route_json
logger = get_logger("app.services.agent_runs")
@@ -67,30 +68,94 @@ class AgentRunService:
status: str | None = None,
source: str | None = None,
limit: int = 20,
) -> list[AgentRunRead]:
"""供后台任务等受信任内部流程读取全部作用域。"""
return self._list_runs(
agent=agent,
status=status,
source=source,
limit=limit,
tenant_id=None,
scope_clause=None,
)
def list_runs_for_tenant(
self,
*,
tenant_id: str,
agent: str | None = None,
status: str | None = None,
source: str | None = None,
limit: int = 20,
scope_clause: Any | None = None,
) -> list[AgentRunRead]:
return self._list_runs(
agent=agent,
status=status,
source=source,
limit=limit,
tenant_id=self._require_tenant_id(tenant_id),
scope_clause=scope_clause,
)
def _list_runs(
self,
*,
agent: str | None,
status: str | None,
source: str | None,
limit: int,
tenant_id: str | None,
scope_clause: Any | None,
) -> list[AgentRunRead]:
self._ensure_ready()
self._reconcile_stale_knowledge_index_runs()
self._reconcile_stale_knowledge_index_runs(tenant_id=tenant_id)
rows = self.repository.list_light(
agent=agent,
status=status,
source=source,
limit=limit,
tenant_id=tenant_id,
scope_clause=scope_clause,
)
run_ids = [str(item["run_id"]) for item in rows]
tool_calls_by_run_id = self._group_light_tool_calls(
self.repository.list_light_tool_calls([str(item["run_id"]) for item in rows])
self.repository.list_light_tool_calls(run_ids)
)
semantic_parses_by_run_id = self.repository.list_light_semantic_parses(run_ids)
return [
self._serialize_run_list_item(
item,
tool_calls_by_run_id.get(str(item["run_id"]), []),
semantic_parses_by_run_id.get(str(item["run_id"])),
)
for item in rows
]
def get_run(self, run_id: str) -> AgentRunRead | None:
"""供持有可信 run_id 的内部流程跨作用域读取。"""
return self._get_run(run_id, tenant_id=None)
def get_run_for_tenant(
self,
run_id: str,
*,
tenant_id: str,
) -> AgentRunRead | None:
return self._get_run(run_id, tenant_id=self._require_tenant_id(tenant_id))
def _get_run(
self,
run_id: str,
*,
tenant_id: str | None,
) -> AgentRunRead | None:
self._ensure_ready()
self._reconcile_stale_knowledge_index_runs(target_run_id=run_id)
run = self.repository.get_by_run_id(run_id)
self._reconcile_stale_knowledge_index_runs(
target_run_id=run_id,
tenant_id=tenant_id,
)
run = self.repository.get_by_run_id(run_id, tenant_id=tenant_id)
if run is None:
return None
return self._serialize_run(run, enrich_knowledge_ingest=True)
@@ -102,10 +167,56 @@ class AgentRunService:
status: str | None = None,
source: str | None = None,
limit: int = 200,
) -> AgentRunStatsRead:
"""供后台诊断等受信任内部流程聚合全部作用域。"""
return self._summarize_runs(
agent=agent,
status=status,
source=source,
limit=limit,
tenant_id=None,
scope_clause=None,
)
def summarize_runs_for_tenant(
self,
*,
tenant_id: str,
agent: str | None = None,
status: str | None = None,
source: str | None = None,
limit: int = 200,
scope_clause: Any | None = None,
) -> AgentRunStatsRead:
return self._summarize_runs(
agent=agent,
status=status,
source=source,
limit=limit,
tenant_id=self._require_tenant_id(tenant_id),
scope_clause=scope_clause,
)
def _summarize_runs(
self,
*,
agent: str | None,
status: str | None,
source: str | None,
limit: int,
tenant_id: str | None,
scope_clause: Any | None,
) -> AgentRunStatsRead:
self._ensure_ready()
self._reconcile_stale_knowledge_index_runs()
runs = self.repository.list(agent=agent, status=status, source=source, limit=limit)
self._reconcile_stale_knowledge_index_runs(tenant_id=tenant_id)
runs = self.repository.list(
agent=agent,
status=status,
source=source,
limit=limit,
tenant_id=tenant_id,
scope_clause=scope_clause,
)
agents: dict[str, int] = {}
statuses: dict[str, int] = {}
tool_statuses: dict[str, int] = {}
@@ -180,6 +291,7 @@ class AgentRunService:
*,
agent: str,
source: str,
tenant_id: str | None = None,
user_id: str | None = None,
task_id: str | None = None,
ontology_json: dict[str, Any] | None = None,
@@ -192,14 +304,26 @@ class AgentRunService:
finished_at: datetime | None = None,
) -> AgentRunRead:
self._ensure_ready()
normalized_tenant_id = self._require_tenant_id(tenant_id) if tenant_id is not None else None
scoped_ontology_json = dict(ontology_json or {})
scoped_route_json = dict(route_json or {})
if normalized_tenant_id is not None:
scoped_ontology_json = self._stamp_tenant_scope(
scoped_ontology_json,
normalized_tenant_id,
)
scoped_route_json = self._stamp_tenant_scope(
scoped_route_json,
normalized_tenant_id,
)
run = AgentRun(
run_id=f"run_{uuid.uuid4().hex[:16]}",
agent=agent,
source=source,
user_id=user_id,
task_id=task_id,
ontology_json=ontology_json or {},
route_json=route_json or {},
ontology_json=scoped_ontology_json,
route_json=scoped_route_json,
permission_level=permission_level,
status=status,
result_summary=result_summary,
@@ -228,13 +352,22 @@ class AgentRunService:
run = self.repository.get_by_run_id(run_id)
if run is None:
raise LookupError("Run not found")
existing_tenant_id = self._existing_tenant_id(run)
if agent is not None:
run.agent = agent
if ontology_json is not None:
run.ontology_json = ontology_json
run.ontology_json = (
self._stamp_tenant_scope(ontology_json, existing_tenant_id)
if existing_tenant_id is not None
else ontology_json
)
if route_json is not None:
run.route_json = route_json
run.route_json = (
self._stamp_tenant_scope(route_json, existing_tenant_id)
if existing_tenant_id is not None
else route_json
)
if permission_level is not None:
run.permission_level = permission_level
if status is not None:
@@ -267,6 +400,9 @@ class AgentRunService:
route_json = dict(run.route_json or {})
route_json.update(route_patch or {})
existing_tenant_id = self._existing_tenant_id(run)
if existing_tenant_id is not None:
route_json = self._stamp_tenant_scope(route_json, existing_tenant_id)
run.route_json = route_json
if status is not None:
@@ -279,13 +415,18 @@ class AgentRunService:
run.finished_at = finished_at
updated = self.repository.save_run(run)
logger.info("Merged route_json for agent run run_id=%s status=%s", updated.run_id, updated.status)
logger.info(
"Merged route_json for agent run run_id=%s status=%s",
updated.run_id,
updated.status,
)
return self._serialize_run(updated)
def record_tool_call(
self,
*,
run_id: str,
tool_call_id: str | None = None,
tool_type: str,
tool_name: str,
request_json: dict[str, Any] | None = None,
@@ -296,6 +437,7 @@ class AgentRunService:
) -> AgentToolCallRead:
self._ensure_ready()
tool_call = AgentToolCall(
id=tool_call_id or str(uuid.uuid4()),
run_id=run_id,
tool_type=tool_type,
tool_name=tool_name,
@@ -307,7 +449,9 @@ class AgentRunService:
)
created = self.repository.create_tool_call(tool_call)
logger.info("Recorded tool call run_id=%s tool=%s", run_id, tool_name)
return AgentToolCallRead.model_validate(created)
result = AgentToolCallRead.model_validate(created)
CommercialRuntimeBridge(self.db).sync_tool_call(created.id)
return result
def update_tool_call(
self,
@@ -336,7 +480,9 @@ class AgentRunService:
updated = self.repository.save_tool_call(tool_call)
logger.info("Updated tool call id=%s status=%s", updated.id, updated.status)
return AgentToolCallRead.model_validate(updated)
result = AgentToolCallRead.model_validate(updated)
CommercialRuntimeBridge(self.db).sync_tool_call(updated.id)
return result
def record_semantic_parse(
self,
@@ -378,18 +524,28 @@ class AgentRunService:
def _ensure_ready(self) -> None:
AgentFoundationService(self.db).ensure_foundation_ready()
def _reconcile_stale_knowledge_index_runs(self, *, target_run_id: str | None = None) -> None:
runs = self.repository.list(
agent=AgentName.HERMES.value,
status=AgentRunStatus.RUNNING.value,
limit=200,
)
def _reconcile_stale_knowledge_index_runs(
self,
*,
target_run_id: str | None = None,
tenant_id: str | None = None,
) -> None:
if target_run_id is not None:
target = self.repository.get_by_run_id(
target_run_id,
tenant_id=tenant_id,
)
runs = [target] if target is not None else []
else:
runs = self.repository.list(
agent=AgentName.HERMES.value,
status=AgentRunStatus.RUNNING.value,
limit=200,
tenant_id=tenant_id,
)
now = datetime.now(UTC)
for run in runs:
if target_run_id is not None and run.run_id != target_run_id:
continue
route_json = dict(run.route_json or {})
if str(route_json.get("job_type") or "").strip() not in KNOWLEDGE_SYNC_JOB_TYPES:
continue
@@ -415,11 +571,21 @@ class AgentRunService:
KnowledgeService,
)
KnowledgeService(db=self.db).set_document_ingest_statuses(
stale_document_ids,
KNOWLEDGE_INGEST_STATUS_FAILED,
agent_run_id=run.run_id,
)
run_tenant_id = self._existing_tenant_id(run)
if run_tenant_id is None:
logger.error(
"Refused stale knowledge status reconciliation without tenant run_id=%s",
run.run_id,
)
else:
KnowledgeService(
db=self.db,
tenant_id=run_tenant_id,
).set_document_ingest_statuses(
stale_document_ids,
KNOWLEDGE_INGEST_STATUS_FAILED,
agent_run_id=run.run_id,
)
route_json.update(
{
@@ -445,6 +611,39 @@ class AgentRunService:
except ValueError:
return None
@staticmethod
def _require_tenant_id(value: object) -> str:
normalized = str(value or "").strip()
if not normalized:
raise ValueError("tenant_id 不能为空。")
return normalized
@classmethod
def _stamp_tenant_scope(
cls,
payload: dict[str, Any],
tenant_id: str,
) -> dict[str, Any]:
scoped = dict(payload)
if "tenant_id" in scoped:
existing_tenant_id = cls._require_tenant_id(scoped.get("tenant_id"))
if existing_tenant_id != tenant_id:
raise ValueError("Agent Run tenant_id 与业务上下文冲突。")
scoped["tenant_id"] = tenant_id
return scoped
@classmethod
def _existing_tenant_id(cls, run: AgentRun) -> str | None:
tenant_ids: list[str] = []
for payload in (run.route_json, run.ontology_json):
if not isinstance(payload, dict) or "tenant_id" not in payload:
return None
try:
tenant_ids.append(cls._require_tenant_id(payload.get("tenant_id")))
except ValueError:
return None
return tenant_ids[0] if len(set(tenant_ids)) == 1 else None
def _serialize_run(
self,
run: AgentRun,
@@ -483,6 +682,7 @@ class AgentRunService:
self,
row: dict[str, Any],
tool_calls: list[dict[str, Any]],
semantic_parse: dict[str, Any] | None,
) -> AgentRunRead:
return AgentRunRead(
id=str(row["id"]),
@@ -500,7 +700,9 @@ class AgentRunService:
started_at=row["started_at"],
finished_at=row.get("finished_at"),
tool_calls=[self._serialize_light_tool_call(item) for item in tool_calls],
semantic_parse=None,
semantic_parse=(
SemanticParseRead.model_validate(semantic_parse) if semantic_parse else None
),
)
def _build_list_route_json(self, row: dict[str, Any]) -> dict[str, Any]:

Some files were not shown because too many files have changed in this diff Show More