fix(approval): replay immutable action responses

This commit is contained in:
caoxiaozhu
2026-07-16 15:49:43 +08:00
parent 4940ebc419
commit 28b834edd3
20 changed files with 552 additions and 60 deletions

View File

@@ -6,7 +6,9 @@ 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
@@ -16,7 +18,12 @@ 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.schemas.risk_disposition import (
RiskDispositionActionCreate,
RiskDispositionEventRead,
RiskDispositionMutationRead,
RiskDispositionRead,
)
from app.services.expense_claim_tenant_scope import ExpenseClaimTenantScopeMixin
from app.services.risk_observation_access_policy import RiskObservationAccessPolicy
@@ -45,11 +52,21 @@ class RiskDispositionPermissionError(RuntimeError):
@dataclass(slots=True)
class RiskDispositionMutation:
disposition: RiskDisposition
event: RiskDispositionEvent
replayed: bool
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:
@@ -149,6 +166,7 @@ class RiskDispositionService:
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()),
@@ -157,6 +175,8 @@ class RiskDispositionService:
adjudication=_initial_adjudication(observation),
lifecycle_status=_initial_lifecycle_status(observation),
version=0,
created_at=mutation_at,
updated_at=mutation_at,
)
self.db.add(disposition)
@@ -164,7 +184,7 @@ class RiskDispositionService:
before = _state(disposition)
_apply_action(disposition, observation, payload)
disposition.version = current_version + 1
disposition.updated_at = datetime.now(UTC)
disposition.updated_at = mutation_at
event = RiskDispositionEvent(
id=str(uuid.uuid4()),
tenant_id=normalized_tenant,
@@ -179,7 +199,16 @@ class RiskDispositionService:
comment=payload.comment,
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,
@@ -194,9 +223,7 @@ class RiskDispositionService:
self.db.refresh(legacy_feedback)
self._ingest_feedback_sample(observation, legacy_feedback)
return RiskDispositionMutation(
disposition=disposition,
event=event,
replayed=False,
response=response,
legacy_feedback=legacy_feedback,
)
except (LookupError, RiskDispositionConflictError, RiskDispositionPermissionError):
@@ -232,6 +259,49 @@ class RiskDispositionService:
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,
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,
*,
@@ -249,9 +319,7 @@ class RiskDispositionService:
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.")
response = self._event_response(event)
feedback = self.db.scalar(
select(RiskObservationFeedback).where(
RiskObservationFeedback.observation_id == event.observation_id,
@@ -259,12 +327,97 @@ class RiskDispositionService:
)
)
return RiskDispositionMutation(
disposition=disposition,
event=event,
replayed=True,
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"),
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,