Files
X-Financial/server/src/app/services/risk_dispositions.py

750 lines
29 KiB
Python
Raw Normal View History

from __future__ import annotations
import hashlib
import json
import os
import uuid
from dataclasses import dataclass
from datetime import UTC, datetime
from typing import Any
from pydantic import ValidationError
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.api.deps import CurrentUserContext
from app.core.logging import get_logger
from app.models.financial_record import ExpenseClaim
from app.models.risk_disposition import RiskDisposition, RiskDispositionEvent
from app.models.risk_observation import RiskObservation, RiskObservationFeedback
from app.schemas.risk_disposition import (
RiskDispositionActionCreate,
RiskDispositionEventRead,
RiskDispositionMutationRead,
RiskDispositionRead,
)
from app.services.approval_task_projection_refresh import (
ApprovalTaskProjectionRefreshService,
)
from app.services.expense_claim_tenant_scope import ExpenseClaimTenantScopeMixin
from app.services.risk_observation_access_policy import RiskObservationAccessPolicy
from app.services.risk_waiver_decision_policy import (
RISK_WAIVER_DECISION_ACTIONS,
RiskWaiverDecisionPermissionError,
RiskWaiverDecisionPolicy,
)
logger = get_logger("app.services.risk_dispositions")
class RiskDispositionConflictError(RuntimeError):
pass
class RiskDispositionVersionConflictError(RiskDispositionConflictError):
def __init__(self, current_version: int) -> None:
self.current_version = current_version
super().__init__(
f"Risk disposition version conflict; current version is {current_version}."
)
class RiskDispositionIdempotencyConflictError(RiskDispositionConflictError):
pass
class RiskDispositionPermissionError(RuntimeError):
pass
@dataclass(slots=True)
class RiskDispositionMutation:
response: RiskDispositionMutationRead
legacy_feedback: RiskObservationFeedback | None = None
@property
def disposition(self) -> RiskDispositionRead:
return self.response.disposition
@property
def event(self) -> RiskDispositionEventRead:
return self.response.event
@property
def replayed(self) -> bool:
return self.response.replayed
class RiskDispositionService:
def __init__(self, db: Session) -> None:
self.db = db
def get_disposition(
self,
observation_id: str,
*,
tenant_id: str,
) -> RiskDisposition | None:
return self.db.scalar(
select(RiskDisposition).where(
RiskDisposition.tenant_id == _tenant(tenant_id),
RiskDisposition.observation_id == observation_id,
)
)
def get_current_version(self, observation_id: str, *, tenant_id: str) -> int:
disposition = self.get_disposition(observation_id, tenant_id=tenant_id)
return disposition.version if disposition is not None else 0
def execute_action(
self,
observation_key_or_id: str,
payload: RiskDispositionActionCreate,
*,
tenant_id: str,
actor_id: str,
actor_name: str,
current_user: CurrentUserContext | None = None,
) -> RiskDispositionMutation:
normalized_tenant = _tenant(tenant_id)
normalized_actor_id = _text(actor_id) or "anonymous"
observation_locator = self.db.execute(
select(RiskObservation.id, RiskObservation.claim_id).where(
RiskObservation.tenant_id == normalized_tenant,
(
(RiskObservation.id == observation_key_or_id)
| (RiskObservation.observation_key == observation_key_or_id)
),
)
).one_or_none()
if observation_locator is None:
raise LookupError("Risk observation not found.")
observation_id, located_claim_id = observation_locator
fingerprint = _payload_fingerprint(
payload,
observation_id=observation_id,
actor_id=normalized_actor_id,
)
replay = self._find_replay(
tenant_id=normalized_tenant,
request_id=payload.request_id,
fingerprint=fingerprint,
)
if replay is not None:
return replay
try:
locked_claim = self._lock_claim(
str(located_claim_id or "").strip(),
tenant_id=normalized_tenant,
)
observation = self.db.scalar(
select(RiskObservation)
.where(
RiskObservation.tenant_id == normalized_tenant,
RiskObservation.id == observation_id,
)
.with_for_update()
)
if observation is None:
raise LookupError("Risk observation not found.")
if str(observation.claim_id or "").strip() != str(located_claim_id or "").strip():
raise RiskDispositionConflictError(
"Risk observation claim changed concurrently; reload and retry."
)
is_waiver_decision = payload.action in RISK_WAIVER_DECISION_ACTIONS
if (
not is_waiver_decision
and current_user is not None
and not RiskObservationAccessPolicy(
self.db
).can_manage_locked_disposition(
observation,
current_user,
locked_claim=locked_claim,
)
):
raise RiskDispositionPermissionError("当前用户已不再是该单据的有效审批人。")
disposition = self.db.scalar(
select(RiskDisposition)
.where(
RiskDisposition.tenant_id == normalized_tenant,
RiskDisposition.observation_id == observation.id,
)
.with_for_update()
)
current_version = disposition.version if disposition is not None else 0
if current_version != payload.expected_version:
raise RiskDispositionVersionConflictError(current_version)
mutation_at = datetime.now(UTC)
if disposition is None:
disposition = RiskDisposition(
id=str(uuid.uuid4()),
tenant_id=normalized_tenant,
observation_id=observation.id,
adjudication=_initial_adjudication(observation),
lifecycle_status=_initial_lifecycle_status(observation),
version=0,
created_at=mutation_at,
updated_at=mutation_at,
)
self.db.add(disposition)
waiver_policy = RiskWaiverDecisionPolicy(self.db)
waiver_actor_id, waiver_actor_name = waiver_policy.actor_identity(
current_user,
fallback_id=normalized_actor_id,
fallback_name=_text(actor_name) or normalized_actor_id,
)
_validate_transition(disposition, payload, now=mutation_at)
if is_waiver_decision:
waiver_actor_id, waiver_actor_name = waiver_policy.require_decider(
disposition,
current_user=current_user,
tenant_id=normalized_tenant,
)
before = _state(disposition)
_apply_action(
disposition,
observation,
payload,
actor_id=waiver_actor_id,
actor_name=waiver_actor_name,
mutation_at=mutation_at,
)
disposition.version = current_version + 1
disposition.updated_at = mutation_at
event = RiskDispositionEvent(
id=str(uuid.uuid4()),
tenant_id=normalized_tenant,
disposition_id=disposition.id,
observation_id=observation.id,
version=disposition.version,
action=payload.action,
actor_id=waiver_actor_id,
actor_name=waiver_actor_name,
request_id=payload.request_id,
payload_fingerprint=fingerprint,
comment=payload.comment or payload.waiver_reason,
before_json=before,
after_json=_state(disposition),
created_at=mutation_at,
)
response = self._build_response_snapshot(
disposition,
event,
prior_version=current_version,
)
# PostgreSQL 会拒绝 append-only 事件的后续 UPDATE因此完整响应
# 必须和事件在同一条 INSERT 中原子写入。
event.response_json = response.model_dump(mode="json")
self.db.add(event)
legacy_feedback = self._append_safe_feedback(
observation,
event,
payload,
actor_name=actor_name,
)
# 风险事实与审批任务队列投影必须原子可见autoflush 关闭时先显式
# 落下 observation/disposition再按最新风险状态重算开放任务。
self.db.flush()
ApprovalTaskProjectionRefreshService(self.db).refresh_claim(
tenant_id=normalized_tenant,
claim_id=str(observation.claim_id or "").strip(),
now=mutation_at,
)
self.db.commit()
self.db.refresh(disposition)
self.db.refresh(event)
if legacy_feedback is not None:
self.db.refresh(legacy_feedback)
self._ingest_feedback_sample(observation, legacy_feedback)
return RiskDispositionMutation(
response=response,
legacy_feedback=legacy_feedback,
)
except (
LookupError,
RiskDispositionConflictError,
RiskDispositionPermissionError,
RiskWaiverDecisionPermissionError,
):
self.db.rollback()
raise
except IntegrityError as error:
self.db.rollback()
replay = self._find_replay(
tenant_id=normalized_tenant,
request_id=payload.request_id,
fingerprint=fingerprint,
)
if replay is not None:
return replay
raise RiskDispositionConflictError(
"Risk disposition was changed concurrently; reload and retry."
) from error
def _lock_claim(
self,
claim_id: str,
*,
tenant_id: str,
) -> ExpenseClaim | None:
if not claim_id:
return None
statement = select(ExpenseClaim).where(
ExpenseClaim.id == claim_id,
ExpenseClaimTenantScopeMixin.build_claim_tenant_condition(tenant_id),
)
bind = self.db.get_bind()
if bind is not None and bind.dialect.name == "postgresql":
statement = statement.with_for_update()
return self.db.scalar(statement.execution_options(populate_existing=True))
def _build_response_snapshot(
self,
disposition: RiskDisposition,
event: RiskDispositionEvent,
*,
prior_version: int,
) -> RiskDispositionMutationRead:
prior_events = list(
self.db.scalars(
select(RiskDispositionEvent)
.where(
RiskDispositionEvent.tenant_id == disposition.tenant_id,
RiskDispositionEvent.disposition_id == disposition.id,
RiskDispositionEvent.version <= prior_version,
)
.order_by(RiskDispositionEvent.version.asc())
).all()
)
event_read = RiskDispositionEventRead.model_validate(event)
event_history = [
*[RiskDispositionEventRead.model_validate(item) for item in prior_events],
event_read,
]
disposition_read = RiskDispositionRead(
id=disposition.id,
tenant_id=disposition.tenant_id,
observation_id=disposition.observation_id,
adjudication=disposition.adjudication,
lifecycle_status=disposition.lifecycle_status,
version=disposition.version,
assignee=disposition.assignee,
due_at=disposition.due_at,
resolution=disposition.resolution,
waiver_requester_id=disposition.waiver_requester_id,
waiver_requester_name=disposition.waiver_requester_name,
waiver_requested_at=disposition.waiver_requested_at,
waiver_reason=disposition.waiver_reason,
waiver_scope=disposition.waiver_scope,
waiver_expires_at=disposition.waiver_expires_at,
waiver_conditions_json=list(disposition.waiver_conditions_json or []),
waiver_decision=disposition.waiver_decision,
waiver_decider_id=disposition.waiver_decider_id,
waiver_decider_name=disposition.waiver_decider_name,
waiver_decided_at=disposition.waiver_decided_at,
waiver_decision_reason=disposition.waiver_decision_reason,
created_at=disposition.created_at,
updated_at=disposition.updated_at,
events=event_history,
)
return RiskDispositionMutationRead(
disposition=disposition_read,
event=event_read,
replayed=False,
)
def _find_replay(
self,
*,
tenant_id: str,
request_id: str,
fingerprint: str,
) -> RiskDispositionMutation | None:
event = self.db.scalar(
select(RiskDispositionEvent).where(
RiskDispositionEvent.tenant_id == tenant_id,
RiskDispositionEvent.request_id == request_id,
)
)
if event is None:
return None
if event.payload_fingerprint != fingerprint:
raise RiskDispositionIdempotencyConflictError("request_id 已被不同的风险处置内容使用。")
response = self._event_response(event)
feedback = self.db.scalar(
select(RiskObservationFeedback).where(
RiskObservationFeedback.observation_id == event.observation_id,
RiskObservationFeedback.action == f"disposition:{event.id}",
)
)
return RiskDispositionMutation(
response=response.model_copy(update={"replayed": True}),
legacy_feedback=feedback,
)
def _event_response(
self,
event: RiskDispositionEvent,
) -> RiskDispositionMutationRead:
if isinstance(event.response_json, dict) and event.response_json:
try:
response = RiskDispositionMutationRead.model_validate(event.response_json)
except ValidationError as error:
raise RiskDispositionConflictError(
"Stored risk disposition response snapshot is invalid."
) from error
self._validate_snapshot_identity(event, response)
return response
return self._rebuild_legacy_event_response(event)
def _rebuild_legacy_event_response(
self,
event: RiskDispositionEvent,
) -> RiskDispositionMutationRead:
# 0012 之前的事件没有响应快照。只读取目标版本及以前的 append-only
# 事件,并使用目标事件 after_json 重建;禁止读取当前 disposition。
history = list(
self.db.scalars(
select(RiskDispositionEvent)
.where(
RiskDispositionEvent.tenant_id == event.tenant_id,
RiskDispositionEvent.disposition_id == event.disposition_id,
RiskDispositionEvent.version <= event.version,
)
.order_by(RiskDispositionEvent.version.asc())
).all()
)
target = next((item for item in history if item.id == event.id), None)
if target is None:
raise RiskDispositionConflictError(
"Legacy risk disposition replay event is missing from its audit chain."
)
state: dict[str, Any] = dict(event.after_json or {})
event_reads = [RiskDispositionEventRead.model_validate(item) for item in history]
target_read = RiskDispositionEventRead.model_validate(target)
try:
disposition = RiskDispositionRead(
id=event.disposition_id,
tenant_id=event.tenant_id,
observation_id=event.observation_id,
adjudication=state.get("adjudication"),
lifecycle_status=state.get("lifecycle_status"),
version=event.version,
assignee=state.get("assignee"),
due_at=state.get("due_at"),
resolution=state.get("resolution"),
waiver_requester_id=state.get("waiver_requester_id"),
waiver_requester_name=state.get("waiver_requester_name"),
waiver_requested_at=state.get("waiver_requested_at"),
waiver_reason=state.get("waiver_reason"),
waiver_scope=state.get("waiver_scope"),
waiver_expires_at=state.get("waiver_expires_at"),
waiver_conditions_json=state.get("waiver_conditions_json") or [],
waiver_decision=state.get("waiver_decision"),
waiver_decider_id=state.get("waiver_decider_id"),
waiver_decider_name=state.get("waiver_decider_name"),
waiver_decided_at=state.get("waiver_decided_at"),
waiver_decision_reason=state.get("waiver_decision_reason"),
created_at=history[0].created_at,
updated_at=event.created_at,
events=event_reads,
)
response = RiskDispositionMutationRead(
disposition=disposition,
event=target_read,
replayed=False,
)
except ValidationError as error:
raise RiskDispositionConflictError(
"Legacy risk disposition response cannot be reconstructed safely."
) from error
self._validate_snapshot_identity(event, response)
return response
@staticmethod
def _validate_snapshot_identity(
event: RiskDispositionEvent,
response: RiskDispositionMutationRead,
) -> None:
if (
response.event.id != event.id
or response.event.request_id != event.request_id
or response.event.version != event.version
or response.disposition.id != event.disposition_id
or response.disposition.observation_id != event.observation_id
or response.disposition.tenant_id != event.tenant_id
or response.disposition.version != event.version
or not any(item.id == event.id for item in response.disposition.events)
or any(item.version > event.version for item in response.disposition.events)
):
raise RiskDispositionConflictError(
"Risk disposition response snapshot does not match its immutable event."
)
def _append_safe_feedback(
self,
observation: RiskObservation,
event: RiskDispositionEvent,
payload: RiskDispositionActionCreate,
*,
actor_name: str,
) -> RiskObservationFeedback | None:
if payload.action not in {"confirm", "false_positive"}:
return None
feedback = RiskObservationFeedback(
observation_id=observation.id,
feedback_type=payload.action,
action=f"disposition:{event.id}",
actor=_text(actor_name) or "anonymous",
comment=payload.comment,
payload_json={
"decision": payload.action,
"source": "typed_risk_disposition",
},
)
self.db.add(feedback)
return feedback
def _ingest_feedback_sample(
self,
observation: RiskObservation,
feedback: RiskObservationFeedback,
) -> None:
enabled = os.environ.get("FEW_SHOT_INJECTION_ENABLED", "true")
if enabled.strip().lower() in {"0", "false", "no"}:
return
try:
from app.services.few_shot_ingestion import FewShotIngestionService
FewShotIngestionService(self.db).ingest_observation_feedback(
observation,
feedback,
)
except Exception:
logger.exception(
"few-shot ingestion failed for disposition event %s",
feedback.action,
)
def _apply_action(
disposition: RiskDisposition,
observation: RiskObservation,
payload: RiskDispositionActionCreate,
*,
actor_id: str,
actor_name: str,
mutation_at: datetime,
) -> None:
if payload.action == "confirm":
disposition.adjudication = "confirmed"
observation.status = "confirmed"
observation.feedback_status = "confirmed"
return
if payload.action == "false_positive":
disposition.adjudication = "false_positive"
observation.status = "false_positive"
observation.feedback_status = "false_positive"
return
if payload.action == "request_waiver":
disposition.lifecycle_status = "waiver_requested"
disposition.waiver_requester_id = actor_id
disposition.waiver_requester_name = actor_name
disposition.waiver_requested_at = mutation_at
disposition.waiver_reason = payload.waiver_reason
disposition.waiver_scope = payload.waiver_scope
disposition.waiver_expires_at = payload.waiver_expires_at
disposition.waiver_conditions_json = list(payload.waiver_conditions)
disposition.waiver_decision = None
disposition.waiver_decider_id = None
disposition.waiver_decider_name = None
disposition.waiver_decided_at = None
disposition.waiver_decision_reason = None
disposition.resolution = None
if payload.assignee is not None:
disposition.assignee = payload.assignee
if payload.due_at is not None:
disposition.due_at = payload.due_at
return
if payload.action == "approve_waiver":
disposition.lifecycle_status = "waived"
disposition.waiver_decision = "approved"
disposition.waiver_decider_id = actor_id
disposition.waiver_decider_name = actor_name
disposition.waiver_decided_at = mutation_at
disposition.waiver_decision_reason = payload.comment
observation.status = "waived"
return
if payload.action == "reject_waiver":
disposition.lifecycle_status = "waiver_rejected"
disposition.waiver_decision = "rejected"
disposition.waiver_decider_id = actor_id
disposition.waiver_decider_name = actor_name
disposition.waiver_decided_at = mutation_at
disposition.waiver_decision_reason = payload.comment
observation.status = "confirmed"
return
lifecycle_by_action = {
"request_supplement": "supplement_requested",
"start_remediation": "remediation_in_progress",
"resolve": "resolved",
}
disposition.lifecycle_status = lifecycle_by_action[payload.action]
if payload.assignee is not None:
disposition.assignee = payload.assignee
if payload.due_at is not None:
disposition.due_at = payload.due_at
if payload.action != "resolve":
disposition.resolution = None
else:
disposition.resolution = payload.resolution
observation.status = "resolved"
def _validate_transition(
disposition: RiskDisposition,
payload: RiskDispositionActionCreate,
*,
now: datetime,
) -> None:
if payload.action in RISK_WAIVER_DECISION_ACTIONS:
if disposition.lifecycle_status != "waiver_requested":
raise RiskDispositionConflictError("只有待决定的豁免申请才能批准或拒绝。")
if not _text(disposition.waiver_requester_id):
raise RiskDispositionConflictError(
"历史豁免申请缺少职责分离所需的申请人身份,请重新提交申请。"
)
if payload.action == "approve_waiver":
expires_at = _as_utc(disposition.waiver_expires_at)
if expires_at is None or expires_at <= _as_utc(now):
raise RiskDispositionConflictError("风险豁免申请已过期,不能批准。")
return
if disposition.lifecycle_status == "resolved":
raise RiskDispositionConflictError("已解决的风险不能再次变更裁决或处置生命周期。")
if payload.action == "confirm":
if disposition.adjudication == "confirmed":
raise RiskDispositionConflictError("该风险已经确认成立,请勿重复提交。")
return
if payload.action == "false_positive":
if disposition.adjudication == "false_positive":
raise RiskDispositionConflictError("该风险已经标记为误报,请勿重复提交。")
return
target_lifecycle = {
"request_supplement": "supplement_requested",
"start_remediation": "remediation_in_progress",
"request_waiver": "waiver_requested",
"resolve": "resolved",
}[payload.action]
if disposition.lifecycle_status == target_lifecycle:
if payload.action == "request_waiver" and not _text(
disposition.waiver_requester_id
):
# 0014 前的旧申请没有申请人和有效期,允许以完整协议重新提交。
pass
else:
raise RiskDispositionConflictError("该风险已处于目标处置状态,请勿重复提交。")
if payload.action == "request_waiver":
expires_at = _as_utc(payload.waiver_expires_at)
if expires_at is None or expires_at <= _as_utc(now):
raise RiskDispositionConflictError("风险豁免有效期必须晚于申请时间。")
if payload.action == "request_supplement":
return
if disposition.adjudication != "confirmed":
raise RiskDispositionConflictError("风险必须先确认成立,才能启动整改、申请豁免或标记解决。")
def _state(disposition: RiskDisposition) -> dict[str, object]:
return {
"adjudication": disposition.adjudication,
"lifecycle_status": disposition.lifecycle_status,
"assignee": disposition.assignee,
"due_at": disposition.due_at.isoformat() if disposition.due_at else None,
"resolution": disposition.resolution,
"waiver_requester_id": disposition.waiver_requester_id,
"waiver_requester_name": disposition.waiver_requester_name,
"waiver_requested_at": (
disposition.waiver_requested_at.isoformat()
if disposition.waiver_requested_at
else None
),
"waiver_reason": disposition.waiver_reason,
"waiver_scope": disposition.waiver_scope,
"waiver_expires_at": (
disposition.waiver_expires_at.isoformat()
if disposition.waiver_expires_at
else None
),
"waiver_conditions_json": list(disposition.waiver_conditions_json or []),
"waiver_decision": disposition.waiver_decision,
"waiver_decider_id": disposition.waiver_decider_id,
"waiver_decider_name": disposition.waiver_decider_name,
"waiver_decided_at": (
disposition.waiver_decided_at.isoformat()
if disposition.waiver_decided_at
else None
),
"waiver_decision_reason": disposition.waiver_decision_reason,
"version": disposition.version,
}
def _initial_adjudication(observation: RiskObservation) -> str:
values = {
_text(observation.feedback_status).lower(),
_text(observation.status).lower(),
}
if "false_positive" in values:
return "false_positive"
if "confirmed" in values:
return "confirmed"
return "unreviewed"
def _initial_lifecycle_status(observation: RiskObservation) -> str:
return "resolved" if _text(observation.status).lower() == "resolved" else "open"
def _payload_fingerprint(
payload: RiskDispositionActionCreate,
*,
observation_id: str,
actor_id: str,
) -> str:
canonical_payload = payload.model_dump(
mode="json",
exclude={"request_id"},
exclude_none=False,
)
canonical_payload["observation_id"] = observation_id
canonical_payload["actor_id"] = actor_id
canonical = json.dumps(
canonical_payload,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
)
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
def _tenant(value: str) -> str:
return _text(value) or "default"
def _text(value: object) -> str:
return str(value or "").strip()
def _as_utc(value: datetime | None) -> datetime | None:
if value is None:
return None
if value.tzinfo is None:
return value.replace(tzinfo=UTC)
return value.astimezone(UTC)