from __future__ import annotations import uuid 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 from app.schemas.common import ErrorResponse from app.schemas.risk_disposition import ( RiskDispositionActionCreate, RiskDispositionMutationRead, RiskDispositionRead, ) from app.schemas.risk_observation import ( RiskObservationDashboardRead, RiskObservationFeedbackCreate, RiskObservationFeedbackRead, RiskObservationListRead, RiskObservationRead, ) from app.services.risk_disposition_read_projection import RiskDispositionReadProjection from app.services.risk_dispositions import ( RiskDispositionConflictError, RiskDispositionPermissionError, RiskDispositionService, RiskDispositionVersionConflictError, RiskWaiverDecisionPermissionError, ) from app.services.risk_observation_access_policy import RiskObservationAccessPolicy from app.services.risk_observations import RiskObservationService router = APIRouter(prefix="/risk-observations") DbSession = Annotated[Session, Depends(get_db)] CurrentUser = Annotated[CurrentUserContext, Depends(get_current_user)] @router.get( "", response_model=RiskObservationListRead, summary="查询风险观察列表", description="按单据、风险等级、风险信号、状态和来源筛选统一风险观察池。", ) def list_risk_observations( db: DbSession, current_user: CurrentUser, claim_id: Annotated[str | None, Query(max_length=80)] = None, run_id: Annotated[str | None, Query(max_length=80)] = None, execution_log_id: Annotated[str | None, Query(max_length=80)] = None, risk_level: Annotated[str | None, Query(max_length=20)] = None, risk_signal: Annotated[str | None, Query(max_length=100)] = None, status_value: Annotated[ str | None, Query(alias="status", max_length=30), ] = None, source: Annotated[str | None, Query(max_length=60)] = None, 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, run_id=run_id, execution_log_id=execution_log_id, risk_level=risk_level, risk_signal=risk_signal, status=status_value, source=source, limit=limit, offset=offset, ) projected_items = RiskDispositionReadProjection(db).observation_list_read( items, current_user, ) return RiskObservationListRead( items=projected_items, total=total, limit=limit, offset=offset, ) @router.get( "/dashboard", response_model=RiskObservationDashboardRead, summary="查询风险看板聚合", description="返回风险观察池的总量、分布、算法效果和近期高风险记录。", ) def summarize_risk_observations( db: DbSession, current_user: CurrentUser, 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, limit=limit, ) @router.get( "/claim/{claim_id}", response_model=list[RiskObservationRead], summary="查询单据风险观察", description="按报销单 ID 返回该单据关联的风险观察,供单据详情证据链使用。", ) def list_claim_risk_observations( claim_id: str, db: DbSession, current_user: CurrentUser, ) -> list[RiskObservationRead]: if not RiskObservationAccessPolicy(db).can_read_claim_risks(claim_id, current_user): raise _not_found() observations = RiskObservationService(db).list_claim_observations( claim_id, tenant_id=current_user.tenant_id, ) return RiskDispositionReadProjection(db).observation_list_read( observations, current_user, ) @router.get( "/execution-log/{execution_log_id}", response_model=list[RiskObservationRead], summary="查询数字员工工作记录风险观察", description="按数字员工执行日志 ID 返回本次任务生成的风险观察。", ) def list_execution_log_risk_observations( execution_log_id: str, db: DbSession, current_user: CurrentUser, ) -> list[RiskObservationRead]: _require_pool_access(db, current_user) observations = RiskObservationService(db).list_execution_log_observations( execution_log_id, tenant_id=current_user.tenant_id, ) return RiskDispositionReadProjection(db).observation_list_read( observations, current_user, ) @router.get( "/{observation_key_or_id}", response_model=RiskObservationRead, summary="读取风险观察详情", description="按观察 key 或 ID 返回风险评分、证据链、图谱节点、制度引用和决策追踪。", responses={ status.HTTP_404_NOT_FOUND: { "model": ErrorResponse, "description": "风险观察不存在。", } }, ) def get_risk_observation( observation_key_or_id: str, 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, ) if observation is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Risk observation not found", ) return RiskDispositionReadProjection(db).observation_read( observation, current_user, ) @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 RiskDispositionReadProjection(db).disposition_read( disposition, observation, current_user, ) @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, ) observation = _get_observation_or_404( db, current_user, observation_key_or_id, ) projected_disposition = RiskDispositionReadProjection(db).disposition_snapshot_read( mutation.response.disposition, observation, current_user, ) return mutation.response.model_copy( update={"disposition": projected_disposition}, ) @router.post( "/{observation_key_or_id}/feedback", response_model=RiskObservationFeedbackRead, summary="写入风险观察反馈", description="记录人工确认、误报、忽略、已处理或备注反馈,并同步更新观察状态。", responses={ status.HTTP_404_NOT_FOUND: { "model": ErrorResponse, "description": "风险观察不存在。", } }, ) def create_risk_observation_feedback( observation_key_or_id: str, payload: RiskObservationFeedbackCreate, 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 RiskDispositionService(db).execute_action( observation_key_or_id, payload, tenant_id=current_user.tenant_id, 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_409_CONFLICT, detail={ "code": "RISK_DISPOSITION_VERSION_CONFLICT", "current_version": error.current_version, "message": "风险处置状态已更新,请刷新证据链后重试。", }, ) from None except RiskWaiverDecisionPermissionError as error: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail={ "code": "RISK_WAIVER_DECISION_FORBIDDEN", "message": str(error), }, ) 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, )