feat(approval): add safe risk disposition workflow

This commit is contained in:
caoxiaozhu
2026-07-16 15:34:58 +08:00
parent ee88a36baf
commit 4940ebc419
64 changed files with 6602 additions and 631 deletions

View File

@@ -1,5 +1,6 @@
from __future__ import annotations
import uuid
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query, status
@@ -7,6 +8,11 @@ from sqlalchemy.orm import Session
from app.api.deps import CurrentUserContext, get_current_user, get_db
from app.schemas.common import ErrorResponse
from app.schemas.risk_disposition import (
RiskDispositionActionCreate,
RiskDispositionMutationRead,
RiskDispositionRead,
)
from app.schemas.risk_observation import (
RiskObservationDashboardRead,
RiskObservationFeedbackCreate,
@@ -14,6 +20,13 @@ from app.schemas.risk_observation import (
RiskObservationListRead,
RiskObservationRead,
)
from app.services.risk_dispositions import (
RiskDispositionConflictError,
RiskDispositionPermissionError,
RiskDispositionService,
RiskDispositionVersionConflictError,
)
from app.services.risk_observation_access_policy import RiskObservationAccessPolicy
from app.services.risk_observations import RiskObservationService
router = APIRouter(prefix="/risk-observations")
@@ -43,6 +56,7 @@ def list_risk_observations(
limit: Annotated[int, Query(ge=1, le=200)] = 50,
offset: Annotated[int, Query(ge=0)] = 0,
) -> RiskObservationListRead:
_require_pool_access(db, current_user)
items, total = RiskObservationService(db).list_observations(
tenant_id=current_user.tenant_id,
claim_id=claim_id,
@@ -70,6 +84,7 @@ def summarize_risk_observations(
window_days: Annotated[int, Query(ge=1, le=365)] = 30,
limit: Annotated[int, Query(ge=1, le=2000)] = 500,
) -> RiskObservationDashboardRead:
_require_pool_access(db, current_user)
return RiskObservationService(db).summarize_dashboard(
tenant_id=current_user.tenant_id,
window_days=window_days,
@@ -88,6 +103,8 @@ def list_claim_risk_observations(
db: DbSession,
current_user: CurrentUser,
) -> list[RiskObservationRead]:
if not RiskObservationAccessPolicy(db).can_read_claim_risks(claim_id, current_user):
raise _not_found()
return RiskObservationService(db).list_claim_observations(
claim_id,
tenant_id=current_user.tenant_id,
@@ -105,6 +122,7 @@ def list_execution_log_risk_observations(
db: DbSession,
current_user: CurrentUser,
) -> list[RiskObservationRead]:
_require_pool_access(db, current_user)
return RiskObservationService(db).list_execution_log_observations(
execution_log_id,
tenant_id=current_user.tenant_id,
@@ -128,6 +146,7 @@ def get_risk_observation(
db: DbSession,
current_user: CurrentUser,
) -> RiskObservationRead:
_require_pool_access(db, current_user)
observation = RiskObservationService(db).get_observation(
observation_key_or_id,
tenant_id=current_user.tenant_id,
@@ -140,6 +159,53 @@ def get_risk_observation(
return observation
@router.get(
"/{observation_key_or_id}/disposition",
response_model=RiskDispositionRead,
summary="读取风险观察处置状态",
description="返回裁决结论、处置生命周期、负责人、截止时间和只追加事件。",
)
def get_risk_observation_disposition(
observation_key_or_id: str,
db: DbSession,
current_user: CurrentUser,
) -> RiskDispositionRead:
_require_pool_access(db, current_user)
observation = _get_observation_or_404(db, current_user, observation_key_or_id)
disposition = RiskDispositionService(db).get_disposition(
observation.id,
tenant_id=current_user.tenant_id,
)
if disposition is None:
raise _not_found("Risk disposition not found")
return disposition
@router.post(
"/{observation_key_or_id}/disposition/actions",
response_model=RiskDispositionMutationRead,
summary="执行类型化风险处置动作",
description="使用乐观锁和请求幂等键追加风险处置事件。",
)
def execute_risk_disposition_action(
observation_key_or_id: str,
payload: RiskDispositionActionCreate,
db: DbSession,
current_user: CurrentUser,
) -> RiskDispositionMutationRead:
mutation = _execute_action(
db,
current_user,
observation_key_or_id,
payload,
)
return RiskDispositionMutationRead(
disposition=mutation.disposition,
event=mutation.event,
replayed=mutation.replayed,
)
@router.post(
"/{observation_key_or_id}/feedback",
response_model=RiskObservationFeedbackRead,
@@ -158,15 +224,103 @@ def create_risk_observation_feedback(
db: DbSession,
current_user: CurrentUser,
) -> RiskObservationFeedbackRead:
observation = _get_observation_or_404(db, current_user, observation_key_or_id)
if (
payload.feedback_type not in {"confirm", "false_positive"}
or payload.action is not None
or bool(payload.payload_json)
):
raise HTTPException(
status_code=status.HTTP_410_GONE,
detail=("自由格式反馈入口已停用;请使用 disposition/actions 类型化处置接口。"),
)
service = RiskDispositionService(db)
mutation = _execute_action(
db,
current_user,
observation_key_or_id,
RiskDispositionActionCreate(
action=payload.feedback_type,
expected_version=service.get_current_version(
observation.id,
tenant_id=current_user.tenant_id,
),
request_id=f"legacy:{uuid.uuid4()}",
comment=payload.comment,
),
)
if mutation.legacy_feedback is None:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Legacy feedback projection is unavailable.",
)
return mutation.legacy_feedback
def _require_pool_access(db: Session, current_user: CurrentUserContext) -> None:
if RiskObservationAccessPolicy(db).can_read_tenant_pool(current_user):
return
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="当前用户无权访问企业风险观察池。",
)
def _get_observation_or_404(
db: Session,
current_user: CurrentUserContext,
observation_key_or_id: str,
):
observation = RiskObservationService(db).get_observation(
observation_key_or_id,
tenant_id=current_user.tenant_id,
)
if observation is None:
raise _not_found()
return observation
def _execute_action(
db: Session,
current_user: CurrentUserContext,
observation_key_or_id: str,
payload: RiskDispositionActionCreate,
):
try:
return RiskObservationService(db).create_feedback(
return RiskDispositionService(db).execute_action(
observation_key_or_id,
payload,
tenant_id=current_user.tenant_id,
actor=current_user.name or current_user.username,
actor_id=current_user.employee_id or current_user.username,
actor_name=current_user.name or current_user.username,
current_user=current_user,
)
except LookupError:
raise _not_found() from None
except RiskDispositionVersionConflictError as error:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Risk observation not found",
status_code=status.HTTP_409_CONFLICT,
detail={
"code": "RISK_DISPOSITION_VERSION_CONFLICT",
"current_version": error.current_version,
"message": "风险处置状态已更新,请刷新证据链后重试。",
},
) from None
except RiskDispositionPermissionError as error:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=str(error),
) from None
except RiskDispositionConflictError as error:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=str(error),
) from None
def _not_found(detail: str = "Risk observation not found") -> HTTPException:
return HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=detail,
)