fix(approval): replay immutable action responses
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user