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

@@ -199,11 +199,7 @@ def execute_risk_disposition_action(
observation_key_or_id,
payload,
)
return RiskDispositionMutationRead(
disposition=mutation.disposition,
event=mutation.event,
replayed=mutation.replayed,
)
return mutation.response
@router.post(

View File

@@ -170,8 +170,29 @@ MIGRATION_OWNED_TABLES_BY_REVISION: dict[str, frozenset[str]] = {
"approval_action_ledgers",
}
),
"20260716_0012": frozenset(
{
"expense_cases",
"expense_case_links",
"business_events",
"auth_sessions",
"attachment_association_jobs",
"ai_application_preview_decisions",
"ai_decisions",
"ai_decision_feedback",
"memory_entries",
"memory_evidence_links",
"risk_observations",
"risk_observation_feedback",
"risk_dispositions",
"risk_disposition_events",
"few_shot_samples",
"workflow_outcomes",
"approval_action_ledgers",
}
),
}
if MIGRATION_OWNED_TABLES_BY_REVISION["20260716_0011"] != MIGRATION_OWNED_TABLES:
if MIGRATION_OWNED_TABLES_BY_REVISION["20260716_0012"] != MIGRATION_OWNED_TABLES:
raise RuntimeError("latest Alembic revision must own the centralized migration table set")
# 0008 之前这三张表由旧 bootstrap / 风险服务按需创建。迁移前置检查允许
@@ -244,6 +265,7 @@ def _validate_connection(connection: Connection) -> MigrationPreflightState:
"20260716_0009",
"20260716_0010",
"20260716_0011",
"20260716_0012",
}
else frozenset()
)

View File

@@ -165,6 +165,7 @@ class RiskDispositionEvent(Base):
comment: Mapped[str | None] = mapped_column(Text(), nullable=True)
before_json: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False, default=dict)
after_json: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False, default=dict)
response_json: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,

View File

@@ -72,7 +72,6 @@ class ApprovalActionProtocol:
expected_approval_stage: str | None,
payload: Mapping[str, Any],
claim_loader: Callable[[], ExpenseClaim | None],
replay_claim_loader: Callable[[], ExpenseClaim | None],
) -> ApprovalActionStart:
tenant_id = ExpenseClaimTenantScopeMixin.normalize_tenant_id(current_user.tenant_id)
actor_id = self._normalize_actor_id(current_user.username)
@@ -101,7 +100,7 @@ class ApprovalActionProtocol:
if existing.completed_at is None:
raise ApprovalActionConflictError("该审批动作仍在处理中,请稍后重试。")
return ApprovalActionStart(
claim=replay_claim_loader(),
claim=None,
ledger=existing,
request_id=request_id,
replayed=True,

View File

@@ -3,12 +3,17 @@ from __future__ import annotations
from collections.abc import Callable, Mapping
from typing import Any
from pydantic import ValidationError
from sqlalchemy import select
from app.api.deps import CurrentUserContext
from app.models.approval_action import ApprovalActionLedger
from app.models.financial_record import ExpenseClaim
from app.services.approval_action_protocol import ApprovalActionProtocol
from app.schemas.reimbursement import ExpenseClaimRead
from app.services.approval_action_protocol import (
ApprovalActionConflictError,
ApprovalActionProtocol,
)
from app.services.expense_claim_tenant_scope import ExpenseClaimTenantScopeMixin
ClaimActionExecutor = Callable[
@@ -29,7 +34,7 @@ class ExpenseClaimActionProtocolMixin:
expected_approval_stage: str | None,
payload: Mapping[str, Any],
executor: ClaimActionExecutor,
) -> ExpenseClaim | None:
) -> ExpenseClaim | ExpenseClaimRead | None:
protocol = ApprovalActionProtocol(self.db)
tenant_id = ExpenseClaimTenantScopeMixin.normalize_tenant_id(current_user.tenant_id)
actor_id = str(current_user.username or "").strip().casefold()
@@ -51,18 +56,16 @@ class ExpenseClaimActionProtocolMixin:
claim_id,
current_user,
),
replay_claim_loader=lambda: self._load_claim_for_replay(
claim_id,
current_user,
),
)
if started.replayed:
if started.ledger is None: # pragma: no cover - defensive invariant
raise RuntimeError("审批动作重放账本缺失。")
response = self._response_from_ledger(started.ledger)
self.db.commit()
return response
if started.claim is None:
self.db.rollback()
return None
if started.replayed:
self.db.commit()
self.db.refresh(started.claim)
return self._access_policy.attach_approval_snapshot(started.claim)
if started.ledger is None: # pragma: no cover - defensive invariant
raise RuntimeError("审批动作账本初始化失败。")
@@ -71,14 +74,16 @@ class ExpenseClaimActionProtocolMixin:
started.ledger,
normalized_request_id,
)
self.db.flush()
self.db.refresh(claim)
response = ExpenseClaimRead.model_validate(
self._access_policy.attach_approval_snapshot(claim)
)
response_json = response.model_dump(mode="json")
protocol.complete(
started.ledger,
claim,
response_json={
"claim_id": claim.id,
"status": str(claim.status or "").strip(),
"approval_stage": str(claim.approval_stage or "").strip(),
},
response_json=response_json,
)
self.db.commit()
self.db.refresh(claim)
@@ -108,13 +113,22 @@ class ExpenseClaimActionProtocolMixin:
self._repair_duplicate_budget_approval_stage(claim)
return claim
def _load_claim_for_replay(
self,
claim_id: str,
current_user: CurrentUserContext,
) -> ExpenseClaim | None:
stmt = select(ExpenseClaim).where(
ExpenseClaim.id == claim_id,
ExpenseClaimTenantScopeMixin.build_claim_tenant_condition(current_user.tenant_id),
)
return self.db.scalar(stmt)
@staticmethod
def _response_from_ledger(ledger: ApprovalActionLedger) -> ExpenseClaimRead:
try:
response = ExpenseClaimRead.model_validate(ledger.response_json)
except (TypeError, ValueError, ValidationError) as error:
raise ApprovalActionConflictError(
"该审批动作缺少可验证的原始响应快照,请刷新单据后使用新的 request_id。"
) from error
if str(response.id) != str(ledger.claim_id):
raise ApprovalActionConflictError("审批动作原始响应快照与单据不一致。")
if str(response.status or "").strip().lower() != str(
ledger.result_status or ""
).strip().lower():
raise ApprovalActionConflictError("审批动作原始响应快照状态校验失败。")
if str(response.approval_stage or "").strip() != str(
ledger.result_approval_stage or ""
).strip():
raise ApprovalActionConflictError("审批动作原始响应快照节点校验失败。")
return response

View File

@@ -6,6 +6,7 @@ from datetime import UTC, datetime
from app.api.deps import CurrentUserContext
from app.models.approval_action import ApprovalActionLedger
from app.models.financial_record import ExpenseClaim
from app.schemas.reimbursement import ExpenseClaimRead
from app.services.expense_claim_constants import RETURN_REASON_OPTIONS
@@ -20,7 +21,7 @@ class ExpenseClaimReturnFlowMixin:
request_id: str | None = None,
expected_status: str | None = None,
expected_approval_stage: str | None = None,
) -> ExpenseClaim | None:
) -> ExpenseClaim | ExpenseClaimRead | None:
normalized_reason = str(reason or "").strip()
reason_code_payload = self._normalize_return_reason_code_payload(reason_codes)
normalized_codes = [

View File

@@ -8,11 +8,16 @@ from sqlalchemy.orm import Session
from app.models.financial_record import ExpenseClaim
from app.models.risk_disposition import RiskDisposition
from app.models.risk_observation import RiskObservation
from app.services.document_numbering import is_application_claim_no
from app.services.expense_claim_risk_flags import (
claim_risk_flag_observation_key,
claim_risk_flag_severity,
is_blocking_claim_risk_flag,
)
from app.services.expense_claim_risk_stage import (
risk_business_stage_for_claim,
risk_flag_business_stage,
)
from app.services.expense_claim_tenant_scope import ExpenseClaimTenantScopeMixin
_BLOCKING_LEVELS = {"high", "critical", "danger"}
@@ -52,6 +57,7 @@ class ExpenseClaimRiskGate:
tenant_id: str | None,
) -> list[BlockingRiskObservation]:
normalized_tenant = ExpenseClaimTenantScopeMixin.normalize_tenant_id(tenant_id)
claim_business_stage = self._claim_business_stage(claim)
rows = self.db.execute(
select(RiskObservation, RiskDisposition)
.outerjoin(
@@ -75,6 +81,12 @@ class ExpenseClaimRiskGate:
materialized_keys: set[str] = set()
for observation, disposition in rows:
materialized_keys.add(str(observation.observation_key or "").strip())
observation_stage = risk_flag_business_stage(
{"control_stage": observation.control_stage},
default="",
)
if observation_stage and observation_stage != claim_business_stage:
continue
level = str(observation.risk_level or "").strip().lower()
if level not in _BLOCKING_LEVELS:
continue
@@ -100,6 +112,9 @@ class ExpenseClaimRiskGate:
for index, flag in enumerate(list(claim.risk_flags_json or [])):
if not isinstance(flag, dict) or not is_blocking_claim_risk_flag(flag):
continue
flag_stage = risk_flag_business_stage(flag, default="")
if flag_stage and flag_stage != claim_business_stage:
continue
observation_key = claim_risk_flag_observation_key(flag, claim_id=claim.id)
if observation_key and observation_key in materialized_keys:
# 完整物化后的处置投影优先;仅对没有 Observation 的高风险兜底。
@@ -124,6 +139,16 @@ class ExpenseClaimRiskGate:
)
return blockers
@staticmethod
def _claim_business_stage(claim: ExpenseClaim) -> str:
expense_type = str(claim.expense_type or "").strip().lower()
is_application = (
is_application_claim_no(str(claim.claim_no or "").strip())
or expense_type == "application"
or expense_type.endswith("_application")
)
return risk_business_stage_for_claim(is_application_claim=is_application)
def ensure_approvable(
self,
claim: ExpenseClaim,

View File

@@ -27,6 +27,7 @@ SUPPORTED_RISK_VISIBILITY_SCOPES = {
SUPPORTED_RISK_ACTIONABILITIES = {
"fixable_by_submitter",
"review_decision",
"route_review",
"budget_governance",
"finance_check",
"system_trace",

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,