feat(platform): close AI expense value loop
Add tenant-safe value, telemetry, connector, commercial, and production-readiness foundations.
This commit is contained in:
@@ -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 = ""
|
||||
|
||||
308
server/src/app/api/v1/endpoints/agent_asset_releases.py
Normal file
308
server/src/app/api/v1/endpoints/agent_asset_releases.py
Normal 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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
73
server/src/app/api/v1/endpoints/cfo_value.py
Normal file
73
server/src/app/api/v1/endpoints/cfo_value.py
Normal 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
|
||||
587
server/src/app/api/v1/endpoints/commercial.py
Normal file
587
server/src/app/api/v1/endpoints/commercial.py
Normal 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
|
||||
142
server/src/app/api/v1/endpoints/commercial_billing.py
Normal file
142
server/src/app/api/v1/endpoints/commercial_billing.py
Normal 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]
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
76
server/src/app/api/v1/endpoints/finance_report_configs.py
Normal file
76
server/src/app/api/v1/endpoints/finance_report_configs.py
Normal 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,
|
||||
)
|
||||
544
server/src/app/api/v1/endpoints/financial_connectors.py
Normal file
544
server/src/app/api/v1/endpoints/financial_connectors.py
Normal 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,
|
||||
)
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
235
server/src/app/api/v1/endpoints/savings.py
Normal file
235
server/src/app/api/v1/endpoints/savings.py
Normal 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
|
||||
@@ -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")
|
||||
|
||||
@@ -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"])
|
||||
|
||||
1
server/src/app/cli/__init__.py
Normal file
1
server/src/app/cli/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""受控运维命令的可测试业务编排。"""
|
||||
700
server/src/app/cli/savings_standard_adjustment_backfill.py
Normal file
700
server/src/app/cli/savings_standard_adjustment_backfill.py
Normal 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)
|
||||
3
server/src/app/core/agent_asset_scope.py
Normal file
3
server/src/app/core/agent_asset_scope.py
Normal file
@@ -0,0 +1,3 @@
|
||||
AGENT_ASSET_PLATFORM_SCOPE = "platform"
|
||||
AGENT_ASSET_TENANT_SCOPE = "tenant"
|
||||
AGENT_ASSET_PLATFORM_TENANT_ID = "platform"
|
||||
70
server/src/app/core/agent_release_telemetry_keys.py
Normal file
70
server/src/app/core/agent_release_telemetry_keys.py
Normal 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())
|
||||
@@ -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")
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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")
|
||||
|
||||
298
server/src/app/models/agent_asset_release_telemetry.py
Normal file
298
server/src/app/models/agent_asset_release_telemetry.py
Normal 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)
|
||||
@@ -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)
|
||||
|
||||
607
server/src/app/models/commercial.py
Normal file
607
server/src/app/models/commercial.py
Normal 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",
|
||||
]
|
||||
238
server/src/app/models/commercial_billing.py
Normal file
238
server/src/app/models/commercial_billing.py
Normal 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"]
|
||||
167
server/src/app/models/commercial_runtime.py
Normal file
167
server/src/app/models/commercial_runtime.py
Normal 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(),
|
||||
)
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
476
server/src/app/models/financial_connector.py
Normal file
476
server/src/app/models/financial_connector.py
Normal 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()
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
86
server/src/app/models/knowledge_security.py
Normal file
86
server/src/app/models/knowledge_security.py
Normal 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(),
|
||||
)
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
800
server/src/app/models/savings.py
Normal file
800
server/src/app/models/savings.py
Normal 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",
|
||||
)
|
||||
128
server/src/app/models/tenant.py
Normal file
128
server/src/app/models/tenant.py
Normal 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",
|
||||
)
|
||||
98
server/src/app/models/tenant_finance_report.py
Normal file
98
server/src/app/models/tenant_finance_report.py
Normal 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)
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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("员工记录不属于当前可信租户。")
|
||||
|
||||
@@ -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
|
||||
|
||||
117
server/src/app/schemas/agent_asset_release.py
Normal file
117
server/src/app/schemas/agent_asset_release.py
Normal 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
|
||||
@@ -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):
|
||||
|
||||
142
server/src/app/schemas/cfo_value.py
Normal file
142
server/src/app/schemas/cfo_value.py
Normal 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
|
||||
516
server/src/app/schemas/commercial.py
Normal file
516
server/src/app/schemas/commercial.py
Normal 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
|
||||
117
server/src/app/schemas/commercial_billing.py
Normal file
117
server/src/app/schemas/commercial_billing.py
Normal 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
|
||||
19
server/src/app/schemas/finance_report_config.py
Normal file
19
server/src/app/schemas/finance_report_config.py
Normal 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
|
||||
381
server/src/app/schemas/financial_connector.py
Normal file
381
server/src/app/schemas/financial_connector.py
Normal 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()
|
||||
@@ -31,6 +31,8 @@ class KnowledgePreviewPageRead(BaseModel):
|
||||
|
||||
class KnowledgeDocumentRead(BaseModel):
|
||||
id: str
|
||||
scope: str = "tenant"
|
||||
readOnly: bool = False
|
||||
name: str
|
||||
folder: str
|
||||
tag: str
|
||||
|
||||
@@ -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
|
||||
|
||||
335
server/src/app/schemas/savings.py
Normal file
335
server/src/app/schemas/savings.py
Normal 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
|
||||
163
server/src/app/schemas/savings_insights.py
Normal file
163
server/src/app/schemas/savings_insights.py
Normal 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
|
||||
@@ -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())
|
||||
|
||||
93
server/src/app/services/agent_asset_access.py
Normal file
93
server/src/app/services/agent_asset_access.py
Normal 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,
|
||||
)
|
||||
@@ -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")
|
||||
|
||||
288
server/src/app/services/agent_asset_onlyoffice_security.py
Normal file
288
server/src/app/services/agent_asset_onlyoffice_security.py
Normal 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)
|
||||
451
server/src/app/services/agent_asset_release_aggregation.py
Normal file
451
server/src/app/services/agent_asset_release_aggregation.py
Normal 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
|
||||
129
server/src/app/services/agent_asset_release_alerts.py
Normal file
129
server/src/app/services/agent_asset_release_alerts.py
Normal 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,
|
||||
}
|
||||
278
server/src/app/services/agent_asset_release_artifacts.py
Normal file
278
server/src/app/services/agent_asset_release_artifacts.py
Normal 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()
|
||||
@@ -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
|
||||
658
server/src/app/services/agent_asset_release_guard.py
Normal file
658
server/src/app/services/agent_asset_release_guard.py
Normal 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()
|
||||
69
server/src/app/services/agent_asset_release_label_votes.py
Normal file
69
server/src/app/services/agent_asset_release_label_votes.py
Normal 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
|
||||
457
server/src/app/services/agent_asset_release_monitor.py
Normal file
457
server/src/app/services/agent_asset_release_monitor.py
Normal 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__
|
||||
119
server/src/app/services/agent_asset_release_monitor_auth.py
Normal file
119
server/src/app/services/agent_asset_release_monitor_auth.py
Normal 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
|
||||
257
server/src/app/services/agent_asset_release_policy.py
Normal file
257
server/src/app/services/agent_asset_release_policy.py
Normal 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
|
||||
161
server/src/app/services/agent_asset_release_recall.py
Normal file
161
server/src/app/services/agent_asset_release_recall.py
Normal 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)
|
||||
316
server/src/app/services/agent_asset_release_review.py
Normal file
316
server/src/app/services/agent_asset_release_review.py
Normal 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()))
|
||||
159
server/src/app/services/agent_asset_release_sampling.py
Normal file
159
server/src/app/services/agent_asset_release_sampling.py
Normal 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
|
||||
213
server/src/app/services/agent_asset_release_scheduler.py
Normal file
213
server/src/app/services/agent_asset_release_scheduler.py
Normal 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()
|
||||
799
server/src/app/services/agent_asset_release_telemetry.py
Normal file
799
server/src/app/services/agent_asset_release_telemetry.py
Normal 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 {}
|
||||
@@ -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}"))
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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} 修订草稿",
|
||||
|
||||
@@ -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 "")
|
||||
|
||||
221
server/src/app/services/agent_asset_serialization.py
Normal file
221
server/src/app/services/agent_asset_serialization.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
201
server/src/app/services/agent_run_access_policy.py
Normal file
201
server/src/app/services/agent_run_access_policy.py
Normal 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
|
||||
@@ -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
Reference in New Issue
Block a user