""" 整理步骤执行与终态结算端口的 SQLAlchemy 短事务适配器。 """ from __future__ import annotations from collections.abc import Callable from contextlib import AbstractAsyncContextManager from dataclasses import asdict, replace from datetime import datetime, timezone from pathlib import Path from typing import Any, Optional from uuid import uuid4 from sqlalchemy import and_, func, or_, select from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import Session, load_only from app.application.history.retry import max_failed_retries from app.application.transfer.execution import ( TransferExecutionCheckpoint, TransferExecutionConflictError, TransferExecutionLeaseLostError, TransferExecutionSnapshot, TransferExecutionState, TransferExecutionStep, TransferFailureDiscardResult, TransferManualReviewDecision, TransferManualReviewDiscardResult, TransferManualReviewPage, TransferManualReviewResult, TransferManualReviewSource, TransferManualReviewStepView, TransferManualReviewTaskView, TransferRetryRequestResult, TransferStepIntent, TransferStepResult, TransferStepState, build_transfer_operation_id, build_transfer_overwrite_paths, ) from app.application.transfer.feedback import TransferFailureStage, classify_transfer_failure from app.application.transfer.models import JSONValue from app.application.transfer.recovery import music_replanning_input from app.application.transfer.workflow import ( TRANSFER_ADMISSION_ACCEPTED, TRANSFER_ADMISSION_PLANNED, TRANSFER_ADMISSION_PROVIDER_PENDING, TRANSFER_PLAN_CHECKPOINT_LEGACY_VERSION, TRANSFER_PLAN_CHECKPOINT_VERSION, TransferPlanCheckpoint, TransferPlanItem, TransferPlanningInput, TransferProviderInvocationSnapshot, TransferProviderReference, ) from app.db.models.transferexecutionstep import ( TransferExecutionStep as TransferExecutionStepModel, ) from app.db.models.transferhistory import TransferHistory from app.db.models.transferpending import TransferPending from app.db.oper.transferexecutionstep import TransferExecutionStepOper from app.db.oper.transferhistory import TransferHistoryOper from app.db.oper.transferpending import TransferPendingOper from app.db.uow import SqlAlchemyUnitOfWork _LEGACY_REVIEW_STEP_KIND = "legacy_execution_review" _UNHANDLED_REVIEW_STEP_KIND = "unhandled_execution_review" _REVIEW_STEP_KINDS = {_LEGACY_REVIEW_STEP_KIND, _UNHANDLED_REVIEW_STEP_KIND} # 与迁移保留的最大序号相邻,复核凭据不占用从零连续递增的真实执行序号。 _UNHANDLED_REVIEW_STEP_ORDINAL = 2_147_483_646 def _local_now() -> datetime: """ 返回业务审计字段使用的宿主本地时间。 """ return datetime.now() def _utc_now() -> datetime: """ 返回租约 fencing 使用的 UTC 时间。 """ return datetime.now(timezone.utc) class TransactionalTransferExecutionRepository: """ 以操作级短 Session 实现整理执行持久化端口。 """ def __init__( self, session_factory: Callable[[], Session], *, async_session: Optional[ Callable[[], AbstractAsyncContextManager[AsyncSession]] ] = None, local_clock: Callable[[], datetime] = _local_now, lease_clock: Callable[[], datetime] = _utc_now, ) -> None: """ 保存 Session 工厂及可测试时钟,不持有跨外部 I/O 事务。 """ self._session_factory = session_factory self._async_session = async_session self._local_clock = local_clock self._lease_clock = lease_clock @staticmethod def _format_local(value: datetime) -> str: """ 编码与既有业务审计列一致的本地时间。 """ return value.strftime("%Y-%m-%d %H:%M:%S") @staticmethod def _format_utc(value: datetime) -> str: """ 编码可按字典序比较的固定宽度 UTC 租约时间。 """ return value.astimezone(timezone.utc).strftime("%Y-%m-%d %H:%M:%S.%f") @staticmethod def _execution_steps( steps: list[TransferExecutionStepModel], ) -> list[TransferExecutionStepModel]: """ 排除只用于人工审计、不得参与真实执行的 synthetic 步骤。 """ return [step for step in steps if step.kind not in _REVIEW_STEP_KINDS] def isolate_unhandled_failure( self, *, task_id: str, lease_token: str, error: str, ) -> bool: """ 原子隔离未记账异常并保存可发现的复核凭据,保留所有真实步骤证据。 """ now_utc, updated_at = self._times() with self._session_factory() as session: transaction = SqlAlchemyUnitOfWork(session) try: pending_oper = TransferPendingOper(session) updated = pending_oper.stage_isolate_unhandled_failure( task_id=task_id, lease_token=lease_token, error=error, now_utc=now_utc, updated_at=updated_at, ) if updated != 1: transaction.commit() return False session.expire_all() pending = pending_oper.get_by_task_id(task_id=task_id) if pending is None: raise TransferExecutionConflictError("异常隔离后未找到整理任务") self._stage_unhandled_review(session, pending=pending, error=error, updated_at=updated_at) transaction.commit() return True except Exception: self._rollback(transaction) raise @staticmethod def _stage_unhandled_review( session: Session, *, pending: TransferPending, error: str, updated_at: str, ) -> None: """ 创建或更新独立复核凭据;同一任务再次异常时复用凭据并保留复核审计。 """ oper = TransferExecutionStepOper(session) step = next((item for item in oper.list_by_task_id(task_id=pending.task_id) if item.kind == _UNHANDLED_REVIEW_STEP_KIND), None) if step is None: intent = TransferStepIntent.create( task_id=pending.task_id, checkpoint_fingerprint=pending.input_fingerprint, ordinal=_UNHANDLED_REVIEW_STEP_ORDINAL, phase="review", kind=_UNHANDLED_REVIEW_STEP_KIND, payload={"source": {"storage": pending.storage, "path": pending.src_path}}, ) step = oper.stage_prepare( task_id=pending.task_id, operation_id=intent.operation_id, checkpoint_fingerprint=intent.checkpoint_fingerprint, ordinal=intent.ordinal, phase=intent.phase, kind=intent.kind, intent_version=intent.version, intent_payload=dict(intent.payload), now_time=updated_at, ) step.state = TransferStepState.MANUAL_REVIEW.value step.last_error = error step.updated_at = updated_at @staticmethod def _project_step(step: TransferExecutionStepModel) -> TransferExecutionStep: """ 在 Session 有效期内冻结 ORM 步骤为 Application DTO。 """ if (step.result_version is None) != (step.result_payload is None): raise TransferExecutionConflictError("整理步骤记录不完整,请重新识别文件后再整理") result = ( TransferStepResult( version=step.result_version, payload=dict(step.result_payload), ) if step.result_version is not None and step.result_payload is not None else None ) intent = TransferStepIntent( operation_id=step.operation_id, checkpoint_fingerprint=step.checkpoint_fingerprint, ordinal=step.ordinal, phase=step.phase, kind=step.kind, payload=dict(step.intent_payload), version=step.intent_version, ) return TransferExecutionStep( task_id=step.task_id, operation_id=step.operation_id, checkpoint_fingerprint=step.checkpoint_fingerprint, ordinal=step.ordinal, phase=step.phase, kind=step.kind, state=TransferStepState(step.state), attempt_token=step.attempt_token, attempt_count=step.attempt_count, intent=intent, result=result, last_error=step.last_error, prepared_at=step.prepared_at, started_at=step.started_at, completed_at=step.completed_at, updated_at=step.updated_at, ) @classmethod def _project_snapshot( cls, pending: TransferPending, steps: list[TransferExecutionStepModel], ) -> TransferExecutionSnapshot: """ 在 Session 有效期内冻结 pending 与步骤聚合状态。 """ has_checkpoint_column = pending.execution_payload is not None has_checkpoint_identity = ( pending.execution_version is not None and pending.execution_fingerprint is not None ) if has_checkpoint_column != has_checkpoint_identity: raise TransferExecutionConflictError("整理任务执行检查点字段不一致,请重新识别文件后再整理") checkpoint = None if pending.execution_payload is not None: checkpoint = TransferExecutionCheckpoint.from_payload( pending.execution_payload, fingerprint=pending.execution_fingerprint or "", ) if checkpoint.version != pending.execution_version: raise TransferExecutionConflictError("整理任务记录版本不一致,请重新识别文件后再整理") return TransferExecutionSnapshot( task_id=pending.task_id, state=TransferExecutionState(pending.execution_state), checkpoint=checkpoint, retry_generation=pending.retry_generation, retry_count=pending.retry_count, retry_due_at=pending.retry_due_at, settlement_revision=pending.settlement_revision, terminal_history_id=pending.terminal_history_id, last_error=pending.last_error, steps=tuple( cls._project_step(step) for step in cls._execution_steps(steps) ), ) @classmethod def _project_manual_review( cls, pending: TransferPending, steps: list[TransferExecutionStepModel], ) -> TransferManualReviewTaskView: """ 冻结人工复核详情,并只选择当前或最近一次复核步骤。 """ state = TransferExecutionState(pending.execution_state) if state is TransferExecutionState.MANUAL_REVIEW: candidates = [ step for step in steps if step.state == TransferStepState.MANUAL_REVIEW.value ] elif ( state is TransferExecutionState.RETRY_WAIT and pending.manual_review_revision > 0 ): candidates = [ step for step in steps if step.state in { TransferStepState.FAILED.value, TransferStepState.SUCCEEDED.value, } and step.updated_at == pending.reviewed_at ] else: raise TransferExecutionConflictError("该整理任务当前不需要人工复核") if not candidates: raise TransferExecutionConflictError("人工复核信息不完整,请重新打开整理记录") step = max(candidates, key=lambda item: (item.ordinal, item.id)) evidence = dict(step.result_payload) if step.result_payload is not None else None return TransferManualReviewTaskView( task_id=pending.task_id, source=TransferManualReviewSource( storage=pending.storage, path=pending.src_path, ), state=state, step=TransferManualReviewStepView( operation_id=step.operation_id, kind=step.kind, intent=dict(step.intent_payload), evidence=evidence, error=step.last_error, ), review_revision=pending.manual_review_revision, ) @staticmethod def _intent_matches( step: TransferExecutionStepModel, *, task_id: str, intent: TransferStepIntent, ) -> bool: """ 判断同 operation ID 的既有步骤是否为完全相同的冻结意图。 """ return all(( step.task_id == task_id, step.checkpoint_fingerprint == intent.checkpoint_fingerprint, step.ordinal == intent.ordinal, step.phase == intent.phase, step.kind == intent.kind, step.intent_version == intent.version, step.intent_payload == intent.payload, )) @staticmethod def _plan_identity( pending: TransferPending, ) -> tuple[TransferPlanCheckpoint, str]: """ 恢复完整冻结计划,并返回其规范 payload 的稳定指纹。 """ if pending.state not in { TRANSFER_ADMISSION_PLANNED, TRANSFER_ADMISSION_PROVIDER_PENDING, }: raise TransferExecutionConflictError("整理任务尚未准备完成,请稍后再试") if ( pending.checkpoint_version is None or pending.checkpoint_payload is None or pending.planned_at is None ): raise TransferExecutionConflictError("整理任务计划检查点字段不完整,请重新识别文件后再整理") try: checkpoint = TransferPlanCheckpoint.from_payload( pending.checkpoint_payload ) except (TypeError, ValueError) as error: raise TransferExecutionConflictError( "整理任务记录已损坏,请重新识别文件后再整理" ) from error if pending.checkpoint_version != checkpoint.schema_version: raise TransferExecutionConflictError("整理任务记录版本不一致,请重新识别文件后再整理") if checkpoint.planning_input.fingerprint != pending.input_fingerprint: raise TransferExecutionConflictError("整理任务状态已发生变化,请刷新整理历史后再试") expected_state = ( TRANSFER_ADMISSION_PROVIDER_PENDING if checkpoint.is_provider_pending else TRANSFER_ADMISSION_PLANNED ) if pending.state != expected_state: raise TransferExecutionConflictError("整理任务状态已发生变化,请刷新整理历史后再试") return checkpoint, checkpoint.fingerprint @staticmethod def _provider_predecessor_checkpoint( checkpoint: TransferPlanCheckpoint, step: TransferExecutionStepModel, ) -> Optional[TransferPlanCheckpoint]: """ 重建 provider 回退前的冻结计划,证明序号零步骤的历史归属。 """ if ( not checkpoint.pre_execution_cleanup_completed or step.ordinal != 0 or step.phase != "provider" or step.kind != "legacy_transfer_provider_sequence" ): return None providers_payload = step.intent_payload.get("providers") invocation_payload = step.intent_payload.get("invocation") if ( not isinstance(providers_payload, list) or not providers_payload or not all(isinstance(item, dict) for item in providers_payload) or not isinstance(invocation_payload, dict) ): return None try: providers = tuple( TransferProviderReference.from_payload(item) for item in providers_payload ) invocation = TransferProviderInvocationSnapshot.from_payload( invocation_payload ) if invocation.fileitem != checkpoint.planning_input.source_fileitem: return None for schema_version in ( TRANSFER_PLAN_CHECKPOINT_VERSION, TRANSFER_PLAN_CHECKPOINT_LEGACY_VERSION, ): predecessor = TransferPlanCheckpoint( planning_input=checkpoint.planning_input, target_storage="", root_target_path="", final_target_path="", resolved_transfer_type="", items=(), classification_snapshot=checkpoint.classification_snapshot, resolved_meta=invocation.meta, resolved_meta_kind=invocation.meta_kind, resolved_mediainfo=invocation.mediainfo, resolved_mediainfo_kind=invocation.mediainfo_kind, resolved_episodes_info=invocation.episodes_info, legacy_transfer_providers=providers, provider_invocation=invocation, preview=invocation.preview, schema_version=schema_version, ) if predecessor.fingerprint == step.checkpoint_fingerprint: return predecessor except (TypeError, ValueError): return None return None @staticmethod def _safe_overwrite_intents( checkpoint: TransferPlanCheckpoint, item: TransferPlanItem, ) -> tuple[tuple[str, str, dict[str, Any]], ...]: """ 仅从冻结叶计划推导安全覆盖的完整步骤及确定路径。 """ stage, backup = build_transfer_overwrite_paths( source=item.source_fileitem, target_storage=item.target_storage, target_path=item.target_path, transfer_type=checkpoint.resolved_transfer_type, ) intents: list[tuple[str, str, dict[str, Any]]] = [ ("transfer", "stage_overwrite_target", { "source": item.source_fileitem, "target_storage": item.target_storage, "target_path": stage, "transfer_type": ( "copy" if checkpoint.resolved_transfer_type == "move" else checkpoint.resolved_transfer_type ), }), ("transfer", "backup_overwrite_target", { "storage": item.target_storage, "source_path": item.target_path, "target_path": backup, }), ("transfer", "promote_overwrite_target", { "storage": item.target_storage, "source_path": stage, "target_path": item.target_path, }), ] if checkpoint.resolved_transfer_type == "move": intents.append(("cleanup", "delete_overwrite_source", { "storage": item.source_fileitem["storage"], "path": item.source_fileitem["path"], })) intents.append(("cleanup", "delete_overwrite_backup", { "storage": item.target_storage, "path": backup, })) return tuple(intents) @staticmethod def _overwrite_decision_succeeded( item: TransferPlanItem, previous_steps: list[TransferExecutionStepModel], ) -> bool: """ 暂存只能在同一源和最终目标的覆盖裁决明确成功后开始。 """ return any( step.phase == "decision" and step.kind in {"resolve_overwrite", "resolve_leaf_overwrite"} and step.state == TransferStepState.SUCCEEDED.value and step.intent_payload.get("source") == item.source_fileitem and step.intent_payload.get("target_storage") == item.target_storage and step.intent_payload.get("target_path") == item.target_path and isinstance(step.result_payload, dict) and step.result_payload.get("over_flag") is True for step in previous_steps ) @classmethod def _safe_overwrite_intent_belongs_to_checkpoint( cls, checkpoint: TransferPlanCheckpoint, *, phase: str, kind: str, payload: dict[str, Any], previous_steps: list[TransferExecutionStepModel], ) -> bool: """ 安全覆盖步骤必须精确匹配冻结计划且其前驱已经持久成功。 """ if phase == "decision" and kind == "resolve_leaf_overwrite": return checkpoint.planning_input.source_fileitem.get("type") == "dir" and any( payload == {"source": item.source_fileitem, "target_storage": item.target_storage, "target_path": item.target_path, "transfer_type": checkpoint.resolved_transfer_type} for item in checkpoint.items ) requested = (phase, kind, payload) for item in checkpoint.items: intents = cls._safe_overwrite_intents(checkpoint, item) if requested not in intents: continue index = intents.index(requested) if index == 0: return cls._overwrite_decision_succeeded(item, previous_steps) previous_phase, previous_kind, previous_payload = intents[index - 1] return any( step.phase == previous_phase and step.kind == previous_kind and step.intent_payload == previous_payload and step.state == TransferStepState.SUCCEEDED.value for step in previous_steps ) return False @staticmethod def _version_delete_intent_belongs_to_checkpoint( checkpoint: TransferPlanCheckpoint, payload: dict[str, Any], previous_steps: list[TransferExecutionStepModel], ) -> bool: """ 版本删除仅消费已持久成功的发现清单,不能自由指定删除路径。 """ candidate = payload.get("item") if (set(payload) != {"storage", "item"} or payload.get("storage") != checkpoint.target_storage or not isinstance(candidate, dict)): return False return any( step.phase == "prepare" and step.kind == "discover_version_targets" and step.state == TransferStepState.SUCCEEDED.value and isinstance(step.result_payload, dict) and candidate in step.result_payload.get("items", []) for step in previous_steps ) @classmethod def _intent_belongs_to_checkpoint( cls, checkpoint: TransferPlanCheckpoint, *, phase: str, kind: str, payload: dict[str, Any], previous_steps: list[TransferExecutionStepModel], ) -> bool: """ 校验步骤类型及稳定参数由冻结计划或其已提交发现证据导出。 """ if kind in { "stage_overwrite_target", "backup_overwrite_target", "promote_overwrite_target", "delete_overwrite_source", "delete_overwrite_backup", "resolve_leaf_overwrite", }: return cls._safe_overwrite_intent_belongs_to_checkpoint( checkpoint, phase=phase, kind=kind, payload=payload, previous_steps=previous_steps, ) if checkpoint.rejection_error: return ( phase == "planning" and kind == "reject" and payload == {"error": checkpoint.rejection_error} ) if checkpoint.is_provider_pending: invocation = checkpoint.provider_invocation if invocation is None: return False return ( phase == "provider" and kind == "legacy_transfer_provider_sequence" and payload == { "providers": [ provider.to_payload() for provider in checkpoint.legacy_transfer_providers ], "invocation": invocation.to_payload(), } ) plan_items = tuple(checkpoint.items) if phase == "transfer" and kind == checkpoint.resolved_transfer_type: return any(payload == { "source": item.source_fileitem.get("path"), "target": item.target_path, } for item in plan_items) if phase == "transfer" and kind == "materialize_target": return any(payload == { "source": item.source_fileitem, "target_storage": item.target_storage, "target_path": item.target_path, "transfer_type": ( "copy" if ( checkpoint.resolved_transfer_type == "move" and item.source_fileitem.get("storage") != item.target_storage ) else checkpoint.resolved_transfer_type ), } for item in plan_items) if phase == "transfer" and kind == "delete_move_source": return ( checkpoint.resolved_transfer_type == "move" and any( item.source_fileitem.get("storage") != item.target_storage and payload == { "source": item.source_fileitem, "target_storage": item.target_storage, "target_path": item.target_path, } for item in plan_items ) ) source = checkpoint.planning_input.source_fileitem if phase == "prepare" and kind == "cleanup_previous_destination": return payload == {"source_path": source.get("path")} if phase == "prepare" and kind == "ensure_target_directory": target_path = Path(checkpoint.final_target_path) directory_path = ( target_path if source.get("type") == "dir" else target_path.parent ) return payload == { "storage": checkpoint.target_storage, "path": directory_path.as_posix(), } if phase == "prepare" and kind == "delete_overwrite_target": return payload == { "storage": checkpoint.target_storage, "path": checkpoint.final_target_path, } if phase == "decision" and kind == "resolve_overwrite": return payload == { "source": source, "target_storage": checkpoint.target_storage, "target_path": checkpoint.final_target_path, "transfer_type": checkpoint.resolved_transfer_type, "overwrite_mode": checkpoint.overwrite_mode, "need_notify": checkpoint.need_notify, } if phase == "decision" and kind == "plugin_transfer_intercept": stable_payload = { "source": source, "target_storage": checkpoint.target_storage, "target_path": checkpoint.final_target_path, "transfer_type": checkpoint.resolved_transfer_type, } return payload == stable_payload or ( set(payload) == {*stable_payload, "over_flag"} and all(payload[key] == value for key, value in stable_payload.items()) and isinstance(payload.get("over_flag"), bool) ) if phase == "prepare" and kind == "discover_version_targets": return payload == { "storage": checkpoint.target_storage, "path": checkpoint.final_target_path, } if phase == "prepare" and kind == "delete_version_target": return cls._version_delete_intent_belongs_to_checkpoint( checkpoint, payload, previous_steps, ) return False @classmethod def _validate_plan_steps( cls, *, task_id: str, checkpoint: TransferPlanCheckpoint, checkpoint_fingerprint: str, steps: list[TransferExecutionStepModel], ) -> None: """ 验证全部持久步骤属于冻结计划演进且身份与全局顺序未被篡改。 """ if tuple(step.ordinal for step in steps) != tuple(range(len(steps))): raise TransferExecutionConflictError("整理任务正在被其他操作处理,请稍后再试") for index, step in enumerate(steps): if step.task_id != task_id: raise TransferExecutionConflictError("整理任务状态已发生变化,请刷新整理历史后再试") step_checkpoint = checkpoint if step.checkpoint_fingerprint != checkpoint_fingerprint: predecessor = cls._provider_predecessor_checkpoint( checkpoint, step, ) if ( predecessor is None or step.checkpoint_fingerprint != predecessor.fingerprint ): raise TransferExecutionConflictError( "整理任务记录已失效,请重新识别文件后再整理" ) step_checkpoint = predecessor if not cls._intent_belongs_to_checkpoint( step_checkpoint, phase=step.phase, kind=step.kind, payload=step.intent_payload, previous_steps=steps[:index], ): raise TransferExecutionConflictError( "整理任务记录已失效,请重新识别文件后再整理" ) expected_operation_id = build_transfer_operation_id( task_id=task_id, checkpoint_fingerprint=step.checkpoint_fingerprint, ordinal=step.ordinal, phase=step.phase, kind=step.kind, intent_payload=step.intent_payload, ) if step.operation_id != expected_operation_id: raise TransferExecutionConflictError("整理任务记录已失效,请重新识别文件后再整理") @classmethod def _validate_new_intent( cls, *, task_id: str, checkpoint: TransferPlanCheckpoint, checkpoint_fingerprint: str, steps: list[TransferExecutionStepModel], intent: TransferStepIntent, ) -> None: """ 验证待准备意图属于当前冻结计划,并且只追加或幂等重放既有序号。 """ cls._validate_plan_steps( task_id=task_id, checkpoint=checkpoint, checkpoint_fingerprint=checkpoint_fingerprint, steps=steps, ) if intent.checkpoint_fingerprint != checkpoint_fingerprint: raise TransferExecutionConflictError("整理任务记录已失效,请重新识别文件后再整理") if not cls._intent_belongs_to_checkpoint( checkpoint, phase=intent.phase, kind=intent.kind, payload=intent.payload, previous_steps=steps, ): raise TransferExecutionConflictError( "整理任务记录已失效,请重新识别文件后再整理" ) expected_operation_id = build_transfer_operation_id( task_id=task_id, checkpoint_fingerprint=intent.checkpoint_fingerprint, ordinal=intent.ordinal, phase=intent.phase, kind=intent.kind, intent_payload=intent.payload, ) if intent.operation_id != expected_operation_id: raise TransferExecutionConflictError("整理任务记录已失效,请重新识别文件后再整理") existing = next( (step for step in steps if step.operation_id == intent.operation_id), None, ) if existing is not None: if not cls._intent_matches(existing, task_id=task_id, intent=intent): raise TransferExecutionConflictError( "整理任务正在被其他操作处理,请稍后再试" ) return if intent.ordinal != len(steps): raise TransferExecutionConflictError("整理任务记录已失效,请重新识别文件后再整理") @staticmethod def _require_active_lease( pending: Optional[TransferPending], *, lease_token: str, now_utc: str, ) -> TransferPending: """ 返回仍由调用方持有的任务,并优先报告 lease fencing 失败。 """ if ( pending is None or pending.lease_token != lease_token or pending.lease_expires_at is None or pending.lease_expires_at <= now_utc ): raise TransferExecutionLeaseLostError("整理任务租约已失效或被接管") return pending @classmethod def _raise_fenced_failure( cls, pending: Optional[TransferPending], *, lease_token: str, now_utc: str, detail: str, ) -> None: """ 区分租约丢失与同租约内的状态或 attempt 冲突。 """ cls._require_active_lease( pending, lease_token=lease_token, now_utc=now_utc, ) raise TransferExecutionConflictError(detail) def _times(self) -> tuple[str, str]: """ 生成一次事务内共享的 UTC fencing 与本地审计时间。 """ return ( self._format_utc(self._lease_clock()), self._format_local(self._local_clock()), ) @staticmethod def _rollback(transaction: SqlAlchemyUnitOfWork) -> None: """ 统一回滚失败的操作级事务。 """ transaction.rollback() def get_snapshot(self, *, task_id: str) -> Optional[TransferExecutionSnapshot]: """ 使用一次只读短 Session 获取任务执行快照。 """ with self._session_factory() as session: pending = TransferPendingOper(session).get_by_task_id(task_id=task_id) if pending is None: return None steps = TransferExecutionStepOper(session).list_by_task_id(task_id=task_id) return self._project_snapshot(pending, steps) async def async_get_snapshot( self, *, task_id: str, ) -> Optional[TransferExecutionSnapshot]: """ 使用异步只读短 Session 获取任务执行快照。 """ if self._async_session is None: raise RuntimeError("整理执行仓储未配置异步 Session 工厂") async with self._async_session() as session: pending = ( await session.scalars( select(TransferPending).where(TransferPending.task_id == task_id) ) ).first() if pending is None: return None steps = list( ( await session.scalars( select(TransferExecutionStepModel) .where(TransferExecutionStepModel.task_id == task_id) .order_by(TransferExecutionStepModel.ordinal.asc()) ) ).all() ) return self._project_snapshot(pending, steps) def list_manual_reviews( self, *, state: TransferExecutionState, page: int, page_size: int, ) -> TransferManualReviewPage: """ 在数据库内过滤和分页人工复核任务,再冻结步骤证据。 """ if state not in { TransferExecutionState.MANUAL_REVIEW, TransferExecutionState.RETRY_WAIT, }: raise ValueError(f"人工复核查询不支持状态:{state.value}") state_predicate = TransferPending.execution_state == state.value if state is TransferExecutionState.RETRY_WAIT: state_predicate = and_( state_predicate, TransferPending.manual_review_revision > 0, ) with self._session_factory() as session: total = int(session.scalar( select(func.count()).select_from(TransferPending).where(state_predicate) ) or 0) pendings = list(session.scalars( select(TransferPending) .where(state_predicate) .order_by(TransferPending.updated_at.desc(), TransferPending.id.desc()) .offset((page - 1) * page_size) .limit(page_size) ).all()) step_oper = TransferExecutionStepOper(session) items = tuple( self._project_manual_review( pending, step_oper.list_by_task_id(task_id=pending.task_id), ) for pending in pendings ) return TransferManualReviewPage( items=items, total=total, page=page, page_size=page_size, ) def get_manual_review( self, *, task_id: str, ) -> Optional[TransferManualReviewTaskView]: """ 读取待人工复核或已判定等待恢复的单个任务。 """ with self._session_factory() as session: pending = session.scalar( select(TransferPending).where( TransferPending.task_id == task_id, or_( TransferPending.execution_state == TransferExecutionState.MANUAL_REVIEW.value, and_( TransferPending.execution_state == TransferExecutionState.RETRY_WAIT.value, TransferPending.manual_review_revision > 0, ), ), ) ) if pending is None: return None return self._project_manual_review( pending, TransferExecutionStepOper(session).list_by_task_id(task_id=task_id), ) def prepare_step( self, *, task_id: str, lease_token: str, intent: TransferStepIntent, ) -> TransferExecutionStep: """ 在外部副作用前以有效 lease 幂等提交稳定步骤意图。 """ now_utc, updated_at = self._times() with self._session_factory() as session: transaction = SqlAlchemyUnitOfWork(session) try: pending_oper = TransferPendingOper(session) pending = pending_oper.get_by_task_id(task_id=task_id) pending = self._require_active_lease( pending, lease_token=lease_token, now_utc=now_utc, ) checkpoint, checkpoint_fingerprint = self._plan_identity(pending) oper = TransferExecutionStepOper(session) steps = self._execution_steps( oper.list_by_task_id(task_id=task_id) ) self._validate_new_intent( task_id=task_id, checkpoint=checkpoint, checkpoint_fingerprint=checkpoint_fingerprint, steps=steps, intent=intent, ) updated = pending_oper.stage_execution_running( task_id=task_id, lease_token=lease_token, admission_state=pending.state, checkpoint_version=checkpoint.schema_version, checkpoint_payload=checkpoint.to_payload(), now_utc=now_utc, updated_at=updated_at, ) if updated != 1: self._raise_fenced_failure( pending, lease_token=lease_token, now_utc=now_utc, detail="整理任务当前状态不能准备外部步骤", ) step = oper.get_by_operation_id(operation_id=intent.operation_id) if step is None: step = oper.stage_prepare( task_id=task_id, operation_id=intent.operation_id, checkpoint_fingerprint=intent.checkpoint_fingerprint, ordinal=intent.ordinal, phase=intent.phase, kind=intent.kind, intent_version=intent.version, intent_payload=dict(intent.payload), now_time=updated_at, ) session.flush() elif not self._intent_matches(step, task_id=task_id, intent=intent): raise TransferExecutionConflictError( "整理任务正在被其他操作处理,请稍后再试" ) projected = self._project_step(step) transaction.commit() return projected except IntegrityError as error: self._rollback(transaction) raise TransferExecutionConflictError( "整理任务正在被其他操作处理,请稍后再试" ) from error except Exception: self._rollback(transaction) raise def start_step( self, *, task_id: str, lease_token: str, operation_id: str, attempt_token: str, ) -> TransferExecutionStep: """ 以当前 lease 把 PREPARED 步骤推进为 STARTED。 """ now_utc, updated_at = self._times() with self._session_factory() as session: transaction = SqlAlchemyUnitOfWork(session) try: oper = TransferExecutionStepOper(session) updated = oper.stage_start_attempt( task_id=task_id, lease_token=lease_token, operation_id=operation_id, attempt_token=attempt_token, now_utc=now_utc, updated_at=updated_at, ) if updated != 1: self._raise_fenced_failure( TransferPendingOper(session).get_by_task_id(task_id=task_id), lease_token=lease_token, now_utc=now_utc, detail="整理步骤不是可开始的 PREPARED 状态", ) session.expire_all() step = oper.get_by_operation_id(operation_id=operation_id) if step is None: raise TransferExecutionConflictError("整理任务执行状态暂时无法确认,请刷新整理历史后再试") projected = self._project_step(step) transaction.commit() return projected except Exception: self._rollback(transaction) raise def restart_after_not_applied( self, *, task_id: str, lease_token: str, operation_id: str, previous_attempt_token: str, attempt_token: str, evidence: TransferStepResult, ) -> TransferExecutionStep: """ 以严格 NOT_APPLIED 证据和旧 attempt CAS 重启遗留步骤。 """ now_utc, updated_at = self._times() with self._session_factory() as session: transaction = SqlAlchemyUnitOfWork(session) try: oper = TransferExecutionStepOper(session) updated = oper.stage_restart_after_not_applied( task_id=task_id, lease_token=lease_token, operation_id=operation_id, previous_attempt_token=previous_attempt_token, attempt_token=attempt_token, result_version=evidence.version, result_payload=dict(evidence.payload), now_utc=now_utc, updated_at=updated_at, ) if updated != 1: self._raise_fenced_failure( TransferPendingOper(session).get_by_task_id(task_id=task_id), lease_token=lease_token, now_utc=now_utc, detail="遗留步骤状态或旧 attempt token 已变化", ) session.expire_all() step = oper.get_by_operation_id(operation_id=operation_id) if step is None: raise TransferExecutionConflictError("整理任务执行状态暂时无法确认,请刷新整理历史后再试") projected = self._project_step(step) transaction.commit() return projected except Exception: self._rollback(transaction) raise def resume_failed_step( self, *, task_id: str, lease_token: str, operation_id: str, attempt_token: str, ) -> TransferExecutionStep: """ 在 retry_wait 到期并重新 claim 后恢复同一 FAILED 操作。 """ now_utc, updated_at = self._times() with self._session_factory() as session: transaction = SqlAlchemyUnitOfWork(session) try: pending_oper = TransferPendingOper(session) pending = pending_oper.get_by_task_id(task_id=task_id) pending = self._require_active_lease( pending, lease_token=lease_token, now_utc=now_utc, ) checkpoint, checkpoint_fingerprint = self._plan_identity(pending) oper = TransferExecutionStepOper(session) steps = self._execution_steps( oper.list_by_task_id(task_id=task_id) ) self._validate_plan_steps( task_id=task_id, checkpoint=checkpoint, checkpoint_fingerprint=checkpoint_fingerprint, steps=steps, ) if operation_id not in {step.operation_id for step in steps}: raise TransferExecutionConflictError( "整理任务记录已失效,请重新识别文件后再整理" ) pending_updated = pending_oper.stage_execution_running( task_id=task_id, lease_token=lease_token, admission_state=pending.state, checkpoint_version=checkpoint.schema_version, checkpoint_payload=checkpoint.to_payload(), now_utc=now_utc, updated_at=updated_at, ) if pending_updated != 1: self._raise_fenced_failure( pending, lease_token=lease_token, now_utc=now_utc, detail="重试任务未到期或当前状态不能恢复", ) updated = oper.stage_resume_failed_attempt( task_id=task_id, lease_token=lease_token, operation_id=operation_id, attempt_token=attempt_token, now_utc=now_utc, updated_at=updated_at, ) if updated != 1: self._raise_fenced_failure( pending, lease_token=lease_token, now_utc=now_utc, detail="整理步骤不是可恢复的 FAILED 状态", ) session.expire_all() step = oper.get_by_operation_id(operation_id=operation_id) if step is None: raise TransferExecutionConflictError("整理任务执行状态暂时无法确认,请刷新整理历史后再试") projected = self._project_step(step) transaction.commit() return projected except Exception: self._rollback(transaction) raise def complete_step( self, *, task_id: str, lease_token: str, operation_id: str, attempt_token: str, result: TransferStepResult, ) -> TransferExecutionStep: """ 以 lease 与 attempt 双 CAS 提交外部操作成功证据。 """ now_utc, updated_at = self._times() with self._session_factory() as session: transaction = SqlAlchemyUnitOfWork(session) try: oper = TransferExecutionStepOper(session) updated = oper.stage_complete_attempt( task_id=task_id, lease_token=lease_token, operation_id=operation_id, attempt_token=attempt_token, result_version=result.version, result_payload=dict(result.payload), now_utc=now_utc, updated_at=updated_at, ) if updated != 1: self._raise_fenced_failure( TransferPendingOper(session).get_by_task_id(task_id=task_id), lease_token=lease_token, now_utc=now_utc, detail="整理步骤 attempt token 已变化或状态不是 STARTED", ) session.expire_all() step = oper.get_by_operation_id(operation_id=operation_id) if step is None: raise TransferExecutionConflictError("整理任务执行状态暂时无法确认,请刷新整理历史后再试") projected = self._project_step(step) transaction.commit() return projected except Exception: self._rollback(transaction) raise def defer_step( self, *, task_id: str, lease_token: str, operation_id: str, attempt_token: str, error: str, retry_due_at: str, evidence: Optional[TransferStepResult] = None, ) -> TransferExecutionSnapshot: """ 原子提交已知失败证据、重试世代和到期时间并释放 lease。 """ now_utc, updated_at = self._times() with self._session_factory() as session: transaction = SqlAlchemyUnitOfWork(session) try: step_oper = TransferExecutionStepOper(session) updated = step_oper.stage_fail_attempt( task_id=task_id, lease_token=lease_token, operation_id=operation_id, attempt_token=attempt_token, error=error, result_version=evidence.version if evidence else None, result_payload=dict(evidence.payload) if evidence else None, now_utc=now_utc, updated_at=updated_at, ) pending_oper = TransferPendingOper(session) pending = pending_oper.get_by_task_id(task_id=task_id) if updated != 1: self._raise_fenced_failure( pending, lease_token=lease_token, now_utc=now_utc, detail="整理步骤已知失败证据与当前 attempt 冲突", ) pending_updated = pending_oper.stage_defer_execution( task_id=task_id, lease_token=lease_token, error=error, retry_due_at=retry_due_at, now_utc=now_utc, updated_at=updated_at, ) if pending_updated != 1: self._raise_fenced_failure( pending, lease_token=lease_token, now_utc=now_utc, detail="整理任务不能进入重试等待", ) session.flush() session.expire_all() pending = pending_oper.get_by_task_id(task_id=task_id) if pending is None: raise TransferExecutionConflictError("整理任务执行状态暂时无法确认,请刷新整理历史后再试") snapshot = self._project_snapshot( pending, step_oper.list_by_task_id(task_id=task_id), ) transaction.commit() return snapshot except Exception: self._rollback(transaction) raise def exhaust_step( self, *, task_id: str, lease_token: str, operation_id: str, attempt_token: str, error: str, evidence: Optional[TransferStepResult] = None, ) -> TransferExecutionSnapshot: """ 原子提交预算耗尽步骤失败,并保留 lease 建立失败结算检查点。 """ now_utc, updated_at = self._times() with self._session_factory() as session: transaction = SqlAlchemyUnitOfWork(session) try: step_oper = TransferExecutionStepOper(session) updated = step_oper.stage_fail_attempt( task_id=task_id, lease_token=lease_token, operation_id=operation_id, attempt_token=attempt_token, error=error, result_version=evidence.version if evidence else None, result_payload=dict(evidence.payload) if evidence else None, now_utc=now_utc, updated_at=updated_at, ) pending_oper = TransferPendingOper(session) pending = pending_oper.get_by_task_id(task_id=task_id) if updated != 1: self._raise_fenced_failure( pending, lease_token=lease_token, now_utc=now_utc, detail="预算耗尽失败证据与当前 attempt 冲突", ) session.flush() steps = self._execution_steps( step_oper.list_by_task_id(task_id=task_id) ) checkpoint = TransferExecutionCheckpoint.create( payload={ "outcome": "failed", "failed_operation_id": operation_id, "error": error, "evidence": ( { "schema_version": evidence.version, "payload": dict(evidence.payload), } if evidence else None ), }, operation_ids=tuple(step.operation_id for step in steps), ) pending_updated = pending_oper.stage_checkpoint_exhausted_failure( task_id=task_id, lease_token=lease_token, execution_version=checkpoint.version, execution_payload=checkpoint.to_payload(), execution_fingerprint=checkpoint.fingerprint, error=error, now_utc=now_utc, updated_at=updated_at, ) if pending_updated != 1: self._raise_fenced_failure( pending, lease_token=lease_token, now_utc=now_utc, detail="整理任务不能进入失败结算状态", ) session.flush() session.expire_all() pending = pending_oper.get_by_task_id(task_id=task_id) if pending is None: raise TransferExecutionConflictError("整理失败状态暂时无法确认,请刷新整理历史后再试") snapshot = self._project_snapshot(pending, steps) transaction.commit() return snapshot except Exception: self._rollback(transaction) raise def request_retry( self, *, task_id: str, reason: str, requested_by: str, ) -> TransferRetryRequestResult: """ 以短事务登记用户重试意图,不 claim、执行或删除既有证据。 """ retry_due_at, updated_at = self._times() with self._session_factory() as session: transaction = SqlAlchemyUnitOfWork(session) try: oper = TransferPendingOper(session) pending = oper.get_by_task_id(task_id=task_id) if pending is None: raise TransferExecutionConflictError("这条整理任务已不存在或已经处理完成") state = TransferExecutionState(pending.execution_state) if state is TransferExecutionState.RETRY_WAIT: return TransferRetryRequestResult( accepted=True, state=state, retry_generation=pending.retry_generation, message="整理任务已在等待重新处理", ) if state is not TransferExecutionState.FAILED: message = ( "这条整理任务需要先完成人工确认,再重试" if state is TransferExecutionState.MANUAL_REVIEW else "这条整理任务当前无法重试,请刷新后再试" ) return TransferRetryRequestResult( accepted=False, state=state, retry_generation=pending.retry_generation, message=message, ) if not self._clear_inactive_claim(session, pending=pending, now_utc=retry_due_at): return TransferRetryRequestResult( accepted=False, state=state, retry_generation=pending.retry_generation, message="整理任务正在处理中或状态已变化,请刷新后重试", ) replanned = self._replan_music_rejection(session, pending=pending, updated_at=updated_at) if replanned is not None: transaction.commit() return replanned updated = oper.stage_request_execution_retry( task_id=task_id, reason=reason, requested_by=requested_by, retry_due_at=retry_due_at, updated_at=updated_at, ) if updated != 1: session.expire_all() pending = oper.get_by_task_id(task_id=task_id) if pending is None: raise TransferExecutionConflictError("这条整理任务已被其他操作处理,请刷新整理历史确认最新状态") state = TransferExecutionState(pending.execution_state) if state is TransferExecutionState.RETRY_WAIT: return TransferRetryRequestResult( accepted=True, state=state, retry_generation=pending.retry_generation, message="整理任务已提交重试,请勿重复操作", ) return TransferRetryRequestResult( accepted=False, state=state, retry_generation=pending.retry_generation, message="整理任务状态已变化,请刷新后重试", ) session.flush() session.expire_all() pending = oper.get_by_task_id(task_id=task_id) if pending is None: raise TransferExecutionConflictError("整理重试状态暂时无法确认,请刷新整理历史后再试") result = TransferRetryRequestResult( accepted=True, state=TransferExecutionState.RETRY_WAIT, retry_generation=pending.retry_generation, message="已提交重新整理,后台将自动处理", ) transaction.commit() return result except Exception: self._rollback(transaction) raise def request_replan( self, *, history_id: int, task_id: str, settlement_revision: int, media_source: str, media_id: str, media_type: str, season: Optional[int], episode: Optional[int], requested_by: str, ) -> TransferRetryRequestResult: """ 原子修正零副作用的识别拒绝;归档原检查点和步骤,继续使用同一任务及重试预算。 :param history_id: 当前失败回执的历史标识 :param task_id: 分析开始时历史关联的持久任务标识 :param settlement_revision: 分析开始时的结算版本,用于拒绝陈旧修正 :param media_source: 已核实的媒体来源 :param media_id: 已核实的来源原生标识 :param media_type: 已核实的电影或电视剧类型 :param season: 修正后的季号;None 保留原值 :param episode: 修正后的单集集号;None 保留原值 :param requested_by: 提交修正的身份 :return: 调度受理结果;不安全或过期请求抛出状态冲突 """ now_utc, updated_at = self._times() with self._session_factory() as session: transaction = SqlAlchemyUnitOfWork(session) try: history = TransferHistoryOper(session).get(history_id) if (history is None or history.transfer_task_id != task_id or history.transfer_settlement_revision != settlement_revision): raise TransferExecutionConflictError("分析期间整理历史或结算版本已变化,请重新分析") pending = TransferPendingOper(session).get_by_task_id(task_id=task_id) if pending is None: raise TransferExecutionConflictError("没有找到对应的失败整理任务,请刷新后重试") if not self._clear_inactive_claim(session, pending=pending, now_utc=now_utc): raise TransferExecutionConflictError("整理任务正在处理中,暂时无法重新规划") checkpoint, fingerprint = self._plan_identity(pending) steps = TransferExecutionStepOper(session).list_by_task_id(task_id=pending.task_id) self._validate_recognition_replan(pending, history, checkpoint, steps) self._validate_plan_steps(task_id=pending.task_id, checkpoint=checkpoint, checkpoint_fingerprint=fingerprint, steps=steps) snapshot = self._project_snapshot(pending, steps) planning = checkpoint.planning_input meta = {**(planning.meta or {}), "type": media_type} if season is not None: meta.update(begin_season=season, end_season=None, total_season=1) if episode is not None: meta.update(begin_episode=episode, end_episode=None, total_episode=1) evidence: dict[str, JSONValue] = { "plan": checkpoint.to_payload(), "execution": asdict(snapshot), "requested_by": requested_by, "requested_at": updated_at, } planning = replace( planning, media_source=media_source, media_id=media_id, media_type=media_type, meta=meta, mediainfo=None, episodes_info=(), options={**planning.options, "_mediainfo_kind": None, "ai_replan_evidence": evidence}, ) # 唯一步骤是内部 reject;原内容已随新输入持久归档,外部操作步骤始终禁止替换。 TransferExecutionStepOper(session).stage_delete_task(task_id=pending.task_id) pending.state = TRANSFER_ADMISSION_ACCEPTED pending.planning_input = planning.to_payload() pending.input_fingerprint = planning.fingerprint pending.checkpoint_version = pending.checkpoint_payload = pending.planned_at = None pending.execution_version = pending.execution_payload = pending.execution_fingerprint = None pending.execution_state = TransferExecutionState.RETRY_WAIT.value pending.retry_generation += 1 pending.retry_count += 1 pending.retry_due_at = now_utc pending.retry_requested_by = requested_by pending.retry_reason = "智能助手核实媒体身份及季集后重新规划" pending.updated_at = updated_at transaction.commit() return TransferRetryRequestResult(True, TransferExecutionState.RETRY_WAIT, pending.retry_generation, "已提交识别修正,等待后台重新规划整理") except Exception: transaction.rollback() raise @staticmethod def _validate_recognition_replan( pending: TransferPending, history: TransferHistory, checkpoint: TransferPlanCheckpoint, steps: list[TransferExecutionStepModel], ) -> None: """ 拒绝复核、过期回执、预算耗尽或任何可能含文件副作用的重新规划。 :param pending: 事务内已取得恢复权的任务 :param history: 当前读取的失败历史 :param checkpoint: 原始冻结计划 :param steps: 原始持久执行步骤 """ if pending.execution_state != TransferExecutionState.FAILED.value: raise TransferExecutionConflictError("任务需要人工确认或正在处理,不能自动重新规划") if (history.status or pending.terminal_history_id != history.id or pending.settlement_revision != history.transfer_settlement_revision): raise TransferExecutionConflictError("整理历史与任务结算版本已变化,请刷新后重试") if history.auto_paused or max(history.retry_count or 0, pending.retry_count) >= max_failed_retries(): raise TransferExecutionConflictError("整理任务自动重试次数已耗尽,请人工处理") stage = history.failure_stage or classify_transfer_failure(checkpoint.rejection_error).stage if stage not in {TransferFailureStage.RECOGNITION, TransferFailureStage.PLANNING}: raise TransferExecutionConflictError("当前失败需要先修复文件或存储配置,不能自动修正识别") planning = TransferPlanningInput.from_payload(pending.planning_input) if (planning.fingerprint != pending.input_fingerprint or pending.input_version != planning.schema_version or not planning.meta): raise TransferExecutionConflictError("原始规划输入不完整或已变化,不能自动重新规划") if (not checkpoint.rejection_error or checkpoint.items or checkpoint.provider_invocation or checkpoint.legacy_transfer_providers or len(steps) != 1): raise TransferExecutionConflictError("已有文件操作计划或证据,不能自动替换,请人工处理") step = steps[0] if step.phase != "planning" or step.kind != "reject" or step.state != TransferStepState.SUCCEEDED.value: raise TransferExecutionConflictError("存在文件操作或不确定执行证据,不能自动重新规划") def _replan_music_rejection( self, session: Session, *, pending: TransferPending, updated_at: str, ) -> Optional[TransferRetryRequestResult]: """ 只替换已结算且没有外部步骤的音乐拒绝;保留旧历史,不碰任何文件操作记录。 """ if not pending.checkpoint_payload or not pending.terminal_history_id or pending.settlement_revision < 1: return None checkpoint, fingerprint = self._plan_identity(pending) planning = music_replanning_input(checkpoint) if planning is None: return None step_oper = TransferExecutionStepOper(session) steps = step_oper.list_by_task_id(task_id=pending.task_id) if len(steps) != 1 or any(step.phase != "planning" or step.kind != "reject" or step.state != TransferStepState.SUCCEEDED.value for step in steps): return None self._validate_plan_steps(task_id=pending.task_id, checkpoint=checkpoint, checkpoint_fingerprint=fingerprint, steps=steps) # 唯一步骤是无外部副作用的 reject。删除内部拒绝步骤不会清除文件操作证据。 step_oper.stage_delete_task(task_id=pending.task_id) oper = TransferPendingOper(session) removed = oper.stage_delete_terminal_failure(task_id=pending.task_id, history_id=pending.terminal_history_id, settlement_revision=pending.settlement_revision) if removed != 1: raise TransferExecutionConflictError("音乐重新识别的原任务状态已改变") detached = TransferHistoryOper(session).stage_detach_failed_transfer_task( history_id=pending.terminal_history_id, task_id=pending.task_id, settlement_revision=pending.settlement_revision, ) if detached != 1: raise TransferExecutionConflictError("音乐重新识别的历史绑定已改变") session.expunge(pending) options = {**planning.options, "music_replanned_from": pending.task_id} planning = type(planning).from_payload({**planning.to_payload(), "options": options}) created = oper.stage_admit(task_id=uuid4().hex, storage=pending.storage, src_path=pending.src_path, state=TRANSFER_ADMISSION_ACCEPTED, now_time=updated_at, input_version=planning.schema_version, planning_input=planning.to_payload(), input_fingerprint=planning.fingerprint) if created is None: raise TransferExecutionConflictError("音乐重新识别任务未能登记") return TransferRetryRequestResult(accepted=True, state=TransferExecutionState.NOT_STARTED, retry_generation=0, message="已重新生成音乐识别任务,后台将重新识别后整理") def discard_corrupt_task( self, *, task_id: str, lease_token: str, error: str, ) -> bool: """ 在当前有效租约下清理损坏 pending、步骤及历史任务映射。 """ if not task_id or not lease_token or not error: return False now_utc, updated_at = self._times() with self._session_factory() as session: transaction = SqlAlchemyUnitOfWork(session) try: pending = TransferPendingOper(session).get_by_task_id(task_id=task_id) self._require_active_lease(pending, lease_token=lease_token, now_utc=now_utc) history = TransferHistoryOper(session).get_by_transfer_task_id(task_id=task_id) if history is not None: history.transfer_task_id = None history.transfer_settlement_revision = None TransferExecutionStepOper(session).stage_delete_task(task_id=task_id) deleted = session.query(TransferPending).filter( TransferPending.task_id == task_id, TransferPending.lease_token == lease_token, ).delete(synchronize_session=False) if deleted != 1: raise TransferExecutionConflictError("损坏整理任务清理未完成,请刷新后重试") transaction.commit() return True except Exception: self._rollback(transaction) raise def discard_corrupt_by_history( self, *, task_id: str, history_id: int, ) -> TransferFailureDiscardResult: """ 在无活动租约时删除损坏任务、步骤并解除历史绑定。 """ now_utc, _updated_at = self._times() with self._session_factory() as session: transaction = SqlAlchemyUnitOfWork(session) try: history_oper = TransferHistoryOper(session) # 放弃旧任务只读取身份与状态,损坏 JSON 不能挡住显式重新规划。 pending = session.scalar(select(TransferPending).where( TransferPending.task_id == task_id, ).options(load_only(TransferPending.task_id, TransferPending.execution_state))) history = history_oper.get(history_id) if pending is None: return TransferFailureDiscardResult(True, None, "整理任务已被清理") # 显式放弃必须能处理损坏的状态字段,仍由原始状态和租约 CAS 保护。 try: state: Optional[TransferExecutionState] = TransferExecutionState(pending.execution_state) except ValueError: state = None if history is None or history.transfer_task_id != task_id: return TransferFailureDiscardResult(False, state, "整理历史与任务绑定已变化,请刷新后重试") if not self._clear_inactive_claim(session, pending=pending, now_utc=now_utc): return TransferFailureDiscardResult(False, state, "整理任务正在处理中或状态已变化,暂时无法放弃") history.transfer_task_id = None history.transfer_settlement_revision = None TransferExecutionStepOper(session).stage_delete_task(task_id=task_id) session.delete(pending) transaction.commit() return TransferFailureDiscardResult(True, state, "已放弃损坏整理任务") except Exception: self._rollback(transaction) raise def discard_failed( self, *, task_id: str, history_id: int, settlement_revision: int, ) -> TransferFailureDiscardResult: """ 原子删除指定 FAILED pending、步骤证据并解除历史回执映射。 """ now_utc, _updated_at = self._times() with self._session_factory() as session: transaction = SqlAlchemyUnitOfWork(session) try: pending_oper = TransferPendingOper(session) history_oper = TransferHistoryOper(session) pending = pending_oper.get_by_task_id(task_id=task_id) if pending is None: history = history_oper.get(history_id) if history is None or history.transfer_task_id is None: return TransferFailureDiscardResult( discarded=True, state=None, message="整理任务已被其他操作放弃", ) return TransferFailureDiscardResult( discarded=False, state=None, message="没有找到对应的整理任务,请刷新后重试", ) state = TransferExecutionState(pending.execution_state) if state is not TransferExecutionState.FAILED: message = ( "这条整理任务需要先完成人工确认,再重试" if state is TransferExecutionState.MANUAL_REVIEW else "这条整理任务当前无法放弃,请刷新后重试" ) return TransferFailureDiscardResult( discarded=False, state=state, message=message, ) if ( pending.terminal_history_id != history_id or pending.settlement_revision != settlement_revision ): return TransferFailureDiscardResult( discarded=False, state=state, message="整理任务状态已变化,请刷新后重试", ) if not self._clear_inactive_claim(session, pending=pending, now_utc=now_utc): return TransferFailureDiscardResult( discarded=False, state=state, message="整理任务正在处理中或状态已变化,暂时无法放弃,请稍后重试", ) deleted = pending_oper.stage_delete_terminal_failure( task_id=task_id, history_id=history_id, settlement_revision=settlement_revision, ) if deleted != 1: session.expire_all() pending = pending_oper.get_by_task_id(task_id=task_id) history = history_oper.get(history_id) if pending is None and ( history is None or history.transfer_task_id is None ): return TransferFailureDiscardResult( discarded=True, state=None, message="整理任务已被其他操作放弃", ) return TransferFailureDiscardResult( discarded=False, state=( TransferExecutionState(pending.execution_state) if pending is not None else None ), message="整理任务状态已变化,请刷新后重试", ) # PostgreSQL/启用外键的 SQLite 会级联删除;显式清理兼容独立测试库。 TransferExecutionStepOper(session).stage_delete_task(task_id=task_id) detached = history_oper.stage_detach_failed_transfer_task( history_id=history_id, task_id=task_id, settlement_revision=settlement_revision, ) if detached != 1: raise TransferExecutionConflictError( "整理失败记录清理未完成,请刷新整理历史后再试" ) transaction.commit() return TransferFailureDiscardResult( discarded=True, state=TransferExecutionState.FAILED, message="已放弃这条失败的整理任务", ) except Exception: self._rollback(transaction) raise @staticmethod def _clear_inactive_claim( session: Session, *, pending: TransferPending, now_utc: str, ) -> bool: """ 以状态和期限 CAS 取得用户恢复权,持有行写锁直到重试或清理提交。 """ updated = session.query(TransferPending).filter( TransferPending.task_id == pending.task_id, TransferPending.execution_state == pending.execution_state, or_( TransferPending.lease_token.is_(None), TransferPending.lease_expires_at.is_(None), TransferPending.lease_expires_at <= now_utc, ), ).update( { "lease_owner": None, "lease_token": None, "lease_expires_at": None, "heartbeat_at": None, }, synchronize_session=False, ) return bool(updated == 1) def resolve_manual_review( self, *, task_id: str, operation_id: str, decision: TransferManualReviewDecision, actor: str, reason: str, result: Optional[TransferStepResult] = None, ) -> TransferManualReviewResult: """ 在无 lease 短事务中原子提交步骤与 pending 的人工判定。 """ if decision is TransferManualReviewDecision.FAILED: raise TransferExecutionConflictError( "该整理任务需要通过正常流程完成结算,请重新整理" ) if decision is TransferManualReviewDecision.APPLIED and result is None: raise ValueError("人工判定已发生时必须提供结果证据") retry_due_at, updated_at = self._times() target_state = ( TransferStepState.SUCCEEDED.value if decision is TransferManualReviewDecision.APPLIED else TransferStepState.FAILED.value ) with self._session_factory() as session: transaction = SqlAlchemyUnitOfWork(session) try: step_oper = TransferExecutionStepOper(session) step = step_oper.get_by_operation_id(operation_id=operation_id) if ( decision is TransferManualReviewDecision.APPLIED and step is not None and step.task_id == task_id and step.kind in _REVIEW_STEP_KINDS ): raise TransferExecutionConflictError( "这条整理步骤无法确认是否已经执行,请人工确认文件状态后再继续;" "请先人工回滚,或确认未发生后选择 NOT_APPLIED" ) step_updated = step_oper.stage_resolve_manual_review( task_id=task_id, operation_id=operation_id, target_state=target_state, reason=reason, result_version=result.version if result else None, result_payload=dict(result.payload) if result else None, updated_at=updated_at, ) if step_updated != 1: raise TransferExecutionConflictError( "该步骤当前无法进行人工确认,请刷新整理历史后再试" ) pending_oper = TransferPendingOper(session) pending_updated = pending_oper.stage_resolve_manual_review( task_id=task_id, decision=decision.value, actor=actor, reason=reason, retry_due_at=retry_due_at, updated_at=updated_at, ) if pending_updated != 1: raise TransferExecutionConflictError( "该整理任务当前无法进行人工确认,请刷新整理历史后再试" ) session.flush() session.expire_all() pending = pending_oper.get_by_task_id(task_id=task_id) step = step_oper.get_by_operation_id(operation_id=operation_id) if pending is None or step is None: raise TransferExecutionConflictError("人工确认结果暂时无法保存,请刷新整理历史后再试") resolved = TransferManualReviewResult( task_id=task_id, operation_id=operation_id, decision=decision, state=TransferExecutionState(pending.execution_state), review_revision=pending.manual_review_revision, step=self._project_step(step), ) transaction.commit() return resolved except Exception: self._rollback(transaction) raise def discard_manual_review( self, *, task_id: str, operation_id: str, actor: str, reason: str, ) -> TransferManualReviewDiscardResult: """原子解除人工复核占用;不会执行任何文件系统或 provider 操作。""" now_utc, _updated_at = self._times() with self._session_factory() as session: transaction = SqlAlchemyUnitOfWork(session) try: pending_oper = TransferPendingOper(session) pending = pending_oper.get_by_task_id(task_id=task_id) if pending is None: return TransferManualReviewDiscardResult(task_id, operation_id, True, "整理任务已被清理") if TransferExecutionState(pending.execution_state) is not TransferExecutionState.MANUAL_REVIEW: return TransferManualReviewDiscardResult(task_id, operation_id, False, "整理任务状态已变化,请刷新后重试") if not self._clear_inactive_claim(session, pending=pending, now_utc=now_utc): return TransferManualReviewDiscardResult(task_id, operation_id, False, "整理任务正在处理中,暂时无法放弃") step = TransferExecutionStepOper(session).get_by_operation_id(operation_id=operation_id) if ( step is None or step.task_id != task_id or TransferStepState(step.state) is not TransferStepState.MANUAL_REVIEW ): return TransferManualReviewDiscardResult(task_id, operation_id, False, "复核步骤已变化,请刷新后重试") TransferExecutionStepOper(session).stage_delete_task(task_id=task_id) session.delete(pending) transaction.commit() return TransferManualReviewDiscardResult(task_id, operation_id, True, "已放弃整理任务;未修改任何文件") except Exception: self._rollback(transaction) raise def mark_manual_review( self, *, task_id: str, lease_token: str, operation_id: str, error: str, evidence: Optional[TransferStepResult] = None, attempt_token: Optional[str] = None, ) -> TransferExecutionSnapshot: """ 原子隔离外部结果未知的步骤与任务,并释放自动调度 lease。 """ now_utc, updated_at = self._times() with self._session_factory() as session: transaction = SqlAlchemyUnitOfWork(session) try: step_oper = TransferExecutionStepOper(session) updated = step_oper.stage_manual_review( task_id=task_id, lease_token=lease_token, operation_id=operation_id, attempt_token=attempt_token, error=error, result_version=evidence.version if evidence else None, result_payload=dict(evidence.payload) if evidence else None, now_utc=now_utc, updated_at=updated_at, ) pending_oper = TransferPendingOper(session) pending = pending_oper.get_by_task_id(task_id=task_id) if updated != 1: self._raise_fenced_failure( pending, lease_token=lease_token, now_utc=now_utc, detail="人工复核证据与当前步骤状态冲突", ) pending_updated = pending_oper.stage_mark_execution_manual_review( task_id=task_id, lease_token=lease_token, error=error, now_utc=now_utc, updated_at=updated_at, ) if pending_updated != 1: self._raise_fenced_failure( pending, lease_token=lease_token, now_utc=now_utc, detail="整理任务不能进入人工复核", ) session.flush() session.expire_all() pending = pending_oper.get_by_task_id(task_id=task_id) if pending is None: raise TransferExecutionConflictError("人工复核状态暂时无法确认,请刷新整理历史后再试") snapshot = self._project_snapshot( pending, step_oper.list_by_task_id(task_id=task_id), ) transaction.commit() return snapshot except Exception: self._rollback(transaction) raise def checkpoint_execution( self, *, task_id: str, lease_token: str, checkpoint: TransferExecutionCheckpoint, ) -> TransferExecutionSnapshot: """ 仅在完整步骤集合均成功时提交可重放终态执行检查点。 """ now_utc, updated_at = self._times() with self._session_factory() as session: transaction = SqlAlchemyUnitOfWork(session) try: pending_oper = TransferPendingOper(session) pending = pending_oper.get_by_task_id(task_id=task_id) pending = self._require_active_lease( pending, lease_token=lease_token, now_utc=now_utc, ) plan_checkpoint, plan_fingerprint = self._plan_identity(pending) step_oper = TransferExecutionStepOper(session) steps = self._execution_steps( step_oper.list_by_task_id(task_id=task_id) ) self._validate_plan_steps( task_id=task_id, checkpoint=plan_checkpoint, checkpoint_fingerprint=plan_fingerprint, steps=steps, ) step_ids = tuple(step.operation_id for step in steps) if step_ids != checkpoint.operation_ids: raise TransferExecutionConflictError( "整理任务状态已发生变化,请刷新整理历史后再试" ) if any(step.state != TransferStepState.SUCCEEDED.value for step in steps): raise TransferExecutionConflictError( "整理任务仍有步骤未完成,请先完成前置步骤" ) running = pending_oper.stage_execution_running( task_id=task_id, lease_token=lease_token, admission_state=pending.state, checkpoint_version=plan_checkpoint.schema_version, checkpoint_payload=plan_checkpoint.to_payload(), now_utc=now_utc, updated_at=updated_at, ) if running != 1: self._raise_fenced_failure( pending, lease_token=lease_token, now_utc=now_utc, detail="整理任务当前状态不能建立执行检查点", ) updated = pending_oper.stage_checkpoint_execution( task_id=task_id, lease_token=lease_token, execution_version=checkpoint.version, execution_payload=checkpoint.to_payload(), execution_fingerprint=checkpoint.fingerprint, now_utc=now_utc, updated_at=updated_at, ) if updated != 1: self._raise_fenced_failure( pending, lease_token=lease_token, now_utc=now_utc, detail="整理任务当前状态不能提交执行检查点", ) session.flush() session.expire_all() pending = pending_oper.get_by_task_id(task_id=task_id) if pending is None: raise TransferExecutionConflictError("整理任务执行状态暂时无法确认,请刷新整理历史后再试") snapshot = self._project_snapshot(pending, steps) transaction.commit() return snapshot except Exception: self._rollback(transaction) raise