369 lines
12 KiB
Python
369 lines
12 KiB
Python
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,
|
|
)
|