feat(approval): add safe risk disposition workflow
This commit is contained in:
@@ -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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user