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

431 lines
15 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 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
from app.services.expense_claim_tenant_scope import ExpenseClaimTenantScopeMixin
from app.services.risk_observation_access_policy import RiskObservationAccessPolicy
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:
disposition: RiskDisposition
event: RiskDispositionEvent
replayed: bool
legacy_feedback: RiskObservationFeedback | None = None
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."
)
if 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)
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,
)
self.db.add(disposition)
_validate_transition(disposition, payload)
before = _state(disposition)
_apply_action(disposition, observation, payload)
disposition.version = current_version + 1
disposition.updated_at = datetime.now(UTC)
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=normalized_actor_id,
actor_name=_text(actor_name) or _text(actor_id) or "anonymous",
request_id=payload.request_id,
payload_fingerprint=fingerprint,
comment=payload.comment,
before_json=before,
after_json=_state(disposition),
)
self.db.add(event)
legacy_feedback = self._append_safe_feedback(
observation,
event,
payload,
actor_name=actor_name,
)
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(
disposition=disposition,
event=event,
replayed=False,
legacy_feedback=legacy_feedback,
)
except (LookupError, RiskDispositionConflictError, RiskDispositionPermissionError):
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 _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 已被不同的风险处置内容使用。")
disposition = self.db.get(RiskDisposition, event.disposition_id)
if disposition is None:
raise RiskDispositionConflictError("Risk disposition replay target is missing.")
feedback = self.db.scalar(
select(RiskObservationFeedback).where(
RiskObservationFeedback.observation_id == event.observation_id,
RiskObservationFeedback.action == f"disposition:{event.id}",
)
)
return RiskDispositionMutation(
disposition=disposition,
event=event,
replayed=True,
legacy_feedback=feedback,
)
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,
) -> 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
lifecycle_by_action = {
"request_supplement": "supplement_requested",
"start_remediation": "remediation_in_progress",
"request_waiver": "waiver_requested",
"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,
) -> None:
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:
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,
"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()