diff --git a/app/api/dependencies/history.py b/app/api/dependencies/history.py index 44d956024..d6941fe72 100644 --- a/app/api/dependencies/history.py +++ b/app/api/dependencies/history.py @@ -93,6 +93,7 @@ def get_transfer_history_mutation_command( download_repository=runtime.history.download_repository(db), unit_of_work=runtime.persistence.sync_transaction(db), file_item_factory=lambda payload: _SchemaFileItem(**payload), + file_exists=storage_chain.exists, delete_media_file=storage_chain.delete_media_file, publish_download_file_deleted=lambda payload: eventmanager.send_event( EventType.DownloadFileDeleted, diff --git a/app/api/endpoints/history.py b/app/api/endpoints/history.py index 7b2c9e21e..ce3fdbdac 100644 --- a/app/api/endpoints/history.py +++ b/app/api/endpoints/history.py @@ -49,6 +49,7 @@ from app.schemas.common import ProgressKeyData as _SchemaProgressKeyData from app.schemas.history import BatchTransferHistoryRedoRequest as _SchemaBatchTransferHistoryRedoRequest from app.schemas.history import DownloadHistory as _SchemaDownloadHistory from app.schemas.history import TransferHistory as _SchemaTransferHistory +from app.schemas.history import TransferHistoryDeleteResult as _SchemaTransferHistoryDeleteResult from app.schemas.history import TransferHistoryPage as _SchemaTransferHistoryPage from app.schemas.response import Response as _SchemaResponse from app.schemas.token import TokenPayload as _SchemaTokenPayload @@ -399,7 +400,11 @@ async def transfer_history( return _SchemaResponse(success=True, data=result) -@router.delete("/transfer", summary="删除整理记录", response_model=_SchemaResponse[None]) +@router.delete( + "/transfer", + summary="删除整理记录", + response_model=_SchemaResponse[_SchemaTransferHistoryDeleteResult], +) def delete_transfer_history( history_in: _SchemaTransferHistory, deletesrc: Optional[bool] = False, @@ -417,7 +422,11 @@ def delete_transfer_history( delete_source=bool(deletesrc), delete_destination=bool(deletedest), ) - return _SchemaResponse(success=result.success, message=result.message) + return _SchemaResponse( + success=result.success, + message=result.message, + data=result, + ) @router.post( diff --git a/app/application/history.py b/app/application/history.py index 222fa0bf4..51ea97798 100644 --- a/app/application/history.py +++ b/app/application/history.py @@ -1,10 +1,30 @@ from __future__ import annotations from dataclasses import asdict, dataclass -from pathlib import Path from typing import Any, Callable, Dict, NoReturn, Optional, Protocol, Union from app.application.configuration import TransferRetryConfig, get_transfer_retry_config +from app.application.historymutation import ( + DownloadFileMutationRepository as DownloadFileMutationRepository, +) +from app.application.historymutation import ( + DownloadHistoryMutationCommand as DownloadHistoryMutationCommand, +) +from app.application.historymutation import ( + DownloadHistoryMutationRepository as DownloadHistoryMutationRepository, +) +from app.application.historymutation import ( + HistoryMutationResult as HistoryMutationResult, +) +from app.application.historymutation import ( + HistoryUnitOfWork as HistoryUnitOfWork, +) +from app.application.historymutation import ( + TransferHistoryMutationCommand as TransferHistoryMutationCommand, +) +from app.application.historymutation import ( + TransferHistoryMutationRepository as TransferHistoryMutationRepository, +) from app.domain.context import MediaInfo, MusicInfo from app.domain.meta.metabase import MetaBase from app.domain.meta.metamusic import MetaMusic @@ -35,14 +55,6 @@ FAILED_RETRY_TTL = 24 * 3600 _failed_retry_counts = TTLCache(region="transfer_failed_retry", maxsize=5000, ttl=FAILED_RETRY_TTL) -@dataclass(frozen=True, slots=True) -class HistoryMutationResult: - """描述历史记录维护操作是否成功及兼容提示。""" - - success: bool - message: str = "" - - class _FrozenJsonDict(dict[str, JsonData]): """保留 JSON 字典读取与序列化行为,并拒绝常规原地修改。""" @@ -697,13 +709,6 @@ class HistoryQueryService: status: Optional[bool] = None, ) -> TransferHistoryPage: """应用历史筛选规则并返回整理历史分页 DTO。""" - if title == "失败": - title = None - status = False - elif title == "成功": - title = None - status = True - if title: wildcard = "*" in title or "?" in title if wildcard: @@ -764,168 +769,6 @@ class HistoryQueryService: return result.replace("*", "%").replace("?", "_") -class DownloadHistoryMutationRepository(Protocol): - """下载历史删除用例需要的最小持久化端口。""" - - def stage_delete_history(self, history_id: int) -> None: - """暂存下载历史删除。""" - ... - - -class TransferHistoryMutationRepository(Protocol): - """整理历史删除与清理用例需要的最小持久化端口。""" - - def get(self, history_id: int) -> Optional[TransferHistorySnapshot]: - """读取整理历史。""" - ... - - def stage_delete(self, history_id: int) -> None: - """暂存整理历史删除。""" - ... - - def stage_truncate(self) -> None: - """暂存全部整理历史删除。""" - ... - - -class DownloadFileMutationRepository(Protocol): - """整理历史删除时关联下载文件状态更新端口。""" - - def stage_delete_file_by_fullpath(self, fullpath: str) -> None: - """暂存下载文件删除状态。""" - ... - - -class HistoryUnitOfWork(Protocol): - """同步历史维护用例使用的事务端口。""" - - def commit(self) -> None: - """提交当前事务。""" - ... - - def rollback(self) -> None: - """回滚当前事务。""" - ... - - -class DownloadHistoryMutationCommand: - """统一提交下载历史删除,避免 API 直接持有数据库事务。""" - - def __init__( - self, - *, - repository: DownloadHistoryMutationRepository, - unit_of_work: HistoryUnitOfWork, - ) -> None: - """保存下载历史持久化和事务端口。""" - self._repository = repository - self._unit_of_work = unit_of_work - - def delete(self, history_id: int) -> HistoryMutationResult: - """暂存并提交单条下载历史删除。""" - self._repository.stage_delete_history(history_id) - self._commit() - return HistoryMutationResult(True) - - def _commit(self) -> None: - """提交事务,失败时回滚。""" - try: - self._unit_of_work.commit() - except Exception: - self._unit_of_work.rollback() - raise - - -class TransferHistoryMutationCommand: - """协调整理历史、关联文件状态和外部存储删除。""" - - def __init__( - self, - *, - repository: TransferHistoryMutationRepository, - download_repository: DownloadFileMutationRepository, - unit_of_work: HistoryUnitOfWork, - file_item_factory: Callable[[dict[str, JsonData]], Any], - delete_media_file: Callable[[Any], bool], - publish_download_file_deleted: Callable[[dict[str, Any]], None], - clear_failures: Callable[[Optional[str], Optional[str]], None], - ) -> None: - """保存历史事务、存储删除、事件和失败状态清理端口。""" - self._repository = repository - self._download_repository = download_repository - self._unit_of_work = unit_of_work - self._file_item_factory = file_item_factory - self._delete_media_file = delete_media_file - self._publish_download_file_deleted = publish_download_file_deleted - self._clear_failures = clear_failures - - def delete( - self, - history_id: int, - *, - delete_source: bool = False, - delete_destination: bool = False, - ) -> HistoryMutationResult: - """删除整理记录,并保持源文件失败时不提交数据库变更。""" - history = self._repository.get(history_id) - if not history: - return HistoryMutationResult(False, "记录不存在") - if getattr(history, "transfer_task_id", None): - return HistoryMutationResult( - False, - "持久整理失败记录不可删除,请使用重试或人工复核入口", - ) - - if delete_destination and history.dest_fileitem: - destination_payload = self._file_item_payload(history.dest_fileitem) - if destination_payload is None: - return HistoryMutationResult(False, "目标文件历史数据无效") - destination = self._file_item_factory(destination_payload) - self._delete_media_file(destination) - - source_deleted = False - if delete_source and history.src_fileitem: - source_payload = self._file_item_payload(history.src_fileitem) - if source_payload is None: - return HistoryMutationResult(False, "源文件历史数据无效") - source = self._file_item_factory(source_payload) - if not self._delete_media_file(source): - return HistoryMutationResult(False, f"{source.path} 删除失败") - self._download_repository.stage_delete_file_by_fullpath( - Path(source.path).as_posix() - ) - source_deleted = True - - self._repository.stage_delete(history_id) - self._commit() - if source_deleted: - self._publish_download_file_deleted({ - "src": history.src, - "hash": history.download_hash, - }) - self._clear_failures(history.src, history.src_storage) - return HistoryMutationResult(True) - - @staticmethod - def _file_item_payload(value: JsonData) -> Optional[dict[str, JsonData]]: - """仅接受可安全构造文件项的历史 JSON 对象。""" - return value if isinstance(value, dict) else None - - def truncate(self) -> HistoryMutationResult: - """在单一事务中清空旧历史,并保留当前失败任务记录。""" - self._repository.stage_truncate() - self._commit() - return HistoryMutationResult(True, "已清空旧整理记录,失败任务记录已保留") - - def _commit(self) -> None: - """提交历史事务,失败时回滚且不发布事件或清缓存。""" - try: - self._unit_of_work.commit() - except Exception: - self._unit_of_work.rollback() - raise - - class HistoryGateAction: """ 整理历史查重闸的判定结果。 diff --git a/app/application/historymutation.py b/app/application/historymutation.py new file mode 100644 index 000000000..7c80adecf --- /dev/null +++ b/app/application/historymutation.py @@ -0,0 +1,277 @@ +"""历史删除命令及其事务端口。""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Callable, Optional, Protocol + +from app.schemas.common import JsonData +from app.schemas.history import TransferHistoryDeleteResult, TransferHistoryDeleteStep + + +@dataclass(frozen=True, slots=True) +class HistoryMutationResult: + """描述历史记录维护操作是否成功及兼容提示。""" + + success: bool + message: str = "" + + +class DownloadHistoryMutationRepository(Protocol): + """下载历史删除用例需要的最小持久化端口。""" + + def stage_delete_history(self, history_id: int) -> None: + """暂存下载历史删除。""" + ... + + +class TransferHistoryMutationRecord(Protocol): + """整理历史删除用例读取的稳定字段投影。""" + + @property + def transfer_task_id(self) -> Optional[str]: + """返回持久整理任务标识。""" + ... + + @property + def src(self) -> Optional[str]: + """返回源路径。""" + ... + + @property + def src_storage(self) -> Optional[str]: + """返回源存储标识。""" + ... + + @property + def src_fileitem(self) -> Optional[JsonData]: + """返回源文件项快照。""" + ... + + @property + def dest_fileitem(self) -> Optional[JsonData]: + """返回目标文件项快照。""" + ... + + @property + def download_hash(self) -> Optional[str]: + """返回下载任务 Hash。""" + ... + + +class TransferHistoryMutationRepository(Protocol): + """整理历史删除与清理用例需要的最小持久化端口。""" + + def get(self, history_id: int) -> Optional[TransferHistoryMutationRecord]: + """读取整理历史。""" + ... + + def stage_delete(self, history_id: int) -> None: + """暂存整理历史删除。""" + ... + + def stage_truncate(self) -> None: + """暂存全部整理历史删除。""" + ... + + +class DownloadFileMutationRepository(Protocol): + """整理历史删除时关联下载文件状态更新端口。""" + + def stage_delete_file_by_fullpath(self, fullpath: str) -> None: + """暂存下载文件删除状态。""" + ... + + +class HistoryUnitOfWork(Protocol): + """同步历史维护用例使用的事务端口。""" + + def commit(self) -> None: + """提交当前事务。""" + ... + + def rollback(self) -> None: + """回滚当前事务。""" + ... + + +class DownloadHistoryMutationCommand: + """统一提交下载历史删除,避免 API 直接持有数据库事务。""" + + def __init__( + self, + *, + repository: DownloadHistoryMutationRepository, + unit_of_work: HistoryUnitOfWork, + ) -> None: + """保存下载历史持久化和事务端口。""" + self._repository = repository + self._unit_of_work = unit_of_work + + def delete(self, history_id: int) -> HistoryMutationResult: + """暂存并提交单条下载历史删除。""" + self._repository.stage_delete_history(history_id) + self._commit() + return HistoryMutationResult(True) + + def _commit(self) -> None: + """提交事务,失败时回滚。""" + try: + self._unit_of_work.commit() + except Exception: + self._unit_of_work.rollback() + raise + + +class TransferHistoryMutationCommand: + """协调整理历史、关联文件状态和外部存储删除。""" + + def __init__( + self, + *, + repository: TransferHistoryMutationRepository, + download_repository: DownloadFileMutationRepository, + unit_of_work: HistoryUnitOfWork, + file_item_factory: Callable[[dict[str, JsonData]], Any], + delete_media_file: Callable[[Any], bool], + publish_download_file_deleted: Callable[[dict[str, Any]], None], + clear_failures: Callable[[Optional[str], Optional[str]], None], + file_exists: Optional[Callable[[Any], Optional[bool]]] = None, + ) -> None: + """保存历史事务、存储删除、事件和失败状态清理端口。""" + self._repository = repository + self._download_repository = download_repository + self._unit_of_work = unit_of_work + self._file_item_factory = file_item_factory + self._file_exists = file_exists or (lambda _fileitem: True) + self._delete_media_file = delete_media_file + self._publish_download_file_deleted = publish_download_file_deleted + self._clear_failures = clear_failures + + def delete( + self, + history_id: int, + *, + delete_source: bool = False, + delete_destination: bool = False, + ) -> TransferHistoryDeleteResult: + """删除整理记录并返回源、目标文件及历史记录的分项结果。""" + history = self._repository.get(history_id) + if not history: + return TransferHistoryDeleteResult( + source=TransferHistoryDeleteStep(status="not_requested"), + destination=TransferHistoryDeleteStep(status="not_requested"), + history="not_found", + message="记录不存在", + ) + if history.transfer_task_id: + return TransferHistoryDeleteResult( + source=TransferHistoryDeleteStep(status="not_requested"), + destination=TransferHistoryDeleteStep(status="not_requested"), + history="retained", + message="持久整理失败记录不可删除,请使用重试或人工复核入口", + ) + + destination_result = self._delete_file( + history.dest_fileitem if delete_destination else None, + requested=delete_destination, + label="目标文件", + ) + source_result = self._delete_file( + history.src_fileitem if delete_source else None, + requested=delete_source, + label="源文件", + ) + source_completed = source_result.status in {"deleted", "already_missing"} + all_requested_completed = all( + step.status in {"not_requested", "deleted", "already_missing"} + for step in (source_result, destination_result) + ) + + if source_completed: + source_payload = self._file_item_payload(history.src_fileitem) + if source_payload is not None: + source = self._file_item_factory(source_payload) + self._download_repository.stage_delete_file_by_fullpath( + Path(source.path).as_posix() + ) + + if all_requested_completed: + self._repository.stage_delete(history_id) + + if source_completed or all_requested_completed: + self._commit() + if source_completed: + self._publish_download_file_deleted({ + "src": history.src, + "hash": history.download_hash, + }) + if all_requested_completed: + self._clear_failures(history.src, history.src_storage) + + return TransferHistoryDeleteResult( + source=source_result, + destination=destination_result, + history="deleted" if all_requested_completed else "retained", + message=( + "已删除整理记录" + if all_requested_completed + else next( + ( + step.message + for step in (source_result, destination_result) + if step.status == "failed" and step.message + ), + "整理记录已保留,部分文件处理失败", + ) + ), + ) + + def _delete_file( + self, + value: Optional[JsonData], + *, + requested: bool, + label: str, + ) -> TransferHistoryDeleteStep: + """删除一个文件目标;原目标已不存在时按完成处理,失败不抛出以便继续汇总其他步骤。""" + if not requested: + return TransferHistoryDeleteStep(status="not_requested") + payload = self._file_item_payload(value) + if payload is None: + return TransferHistoryDeleteStep( + status="failed", + message=f"{label}历史数据无效", + ) + try: + fileitem = self._file_item_factory(payload) + if self._file_exists(fileitem) is False: + return TransferHistoryDeleteStep(status="already_missing") + if not self._delete_media_file(fileitem): + return TransferHistoryDeleteStep( + status="failed", + message=f"{fileitem.path} 删除失败", + ) + except Exception: + return TransferHistoryDeleteStep(status="failed", message=f"{label}删除失败") + return TransferHistoryDeleteStep(status="deleted") + + @staticmethod + def _file_item_payload(value: JsonData) -> Optional[dict[str, JsonData]]: + """仅接受可安全构造文件项的历史 JSON 对象。""" + return value if isinstance(value, dict) else None + + def truncate(self) -> HistoryMutationResult: + """在单一事务中清空旧历史,并保留当前失败任务记录。""" + self._repository.stage_truncate() + self._commit() + return HistoryMutationResult(True, "已清空旧整理记录,失败任务记录已保留") + + def _commit(self) -> None: + """提交历史事务,失败时回滚且不发布事件或清缓存。""" + try: + self._unit_of_work.commit() + except Exception: + self._unit_of_work.rollback() + raise diff --git a/app/schemas/exports.py b/app/schemas/exports.py index 4d8ea0699..2110a2a05 100644 --- a/app/schemas/exports.py +++ b/app/schemas/exports.py @@ -413,6 +413,8 @@ SCHEMA_EXPORTS = { 'TorrentReidentifyData': ('app.schemas.cache', 'TorrentReidentifyData'), 'TransferDirectoryConf': ('app.schemas.system', 'TransferDirectoryConf'), 'TransferHistory': ('app.schemas.history', 'TransferHistory'), + 'TransferHistoryDeleteResult': ('app.schemas.history', 'TransferHistoryDeleteResult'), + 'TransferHistoryDeleteStep': ('app.schemas.history', 'TransferHistoryDeleteStep'), 'TransferHistoryPage': ('app.schemas.history', 'TransferHistoryPage'), 'TransferInfo': ('app.schemas.transfer', 'TransferInfo'), 'TransferInterceptEventData': ('app.schemas.event', 'TransferInterceptEventData'), @@ -483,7 +485,7 @@ SCHEMA_CONFLICTS = { 'FilterRuleGroup': ['app.schemas.rule', 'app.schemas.system'], 'JsonData': ['app.schemas.agent', 'app.schemas.common', 'app.schemas.context', 'app.schemas.dashboard', 'app.schemas.event', 'app.schemas.history', 'app.schemas.mediaserver', 'app.schemas.message', 'app.schemas.mfa', 'app.schemas.music', 'app.schemas.plugin', 'app.schemas.search', 'app.schemas.openai', 'app.schemas.servcookie', 'app.schemas.site', 'app.schemas.transfer', 'app.schemas.user', 'app.schemas.workflow', 'app.schemas.mcp'], 'List': ['app.schemas.agent', 'app.schemas.context', 'app.schemas.event', 'app.schemas.history', 'app.schemas.mediaserver', 'app.schemas.message', 'app.schemas.monitoring', 'app.schemas.plugin', 'app.schemas.openai', 'app.schemas.subscribe', 'app.schemas.transfer', 'app.schemas.workflow'], - 'Literal': ['app.schemas.agent', 'app.schemas.music', 'app.schemas.plugin', 'app.schemas.search', 'app.schemas.servcookie', 'app.schemas.system', 'app.schemas.transfer', 'app.schemas.mcp'], + 'Literal': ['app.schemas.agent', 'app.schemas.history', 'app.schemas.music', 'app.schemas.plugin', 'app.schemas.search', 'app.schemas.servcookie', 'app.schemas.system', 'app.schemas.transfer', 'app.schemas.mcp'], 'LocaleHelper': ['app.schemas.dashboard', 'app.schemas.response'], 'MediaInfo': ['app.schemas.context', 'app.schemas.system', 'app.schemas.transfer', 'app.schemas.workflow'], 'MediaSource': ['app.schemas.cache', 'app.schemas.context', 'app.schemas.event', 'app.schemas.history', 'app.schemas.mediaserver', 'app.schemas.music', 'app.schemas.subscribe', 'app.schemas.transfer'], diff --git a/app/schemas/history.py b/app/schemas/history.py index 1f8c44ae2..7dd0392a8 100644 --- a/app/schemas/history.py +++ b/app/schemas/history.py @@ -1,4 +1,4 @@ -from typing import List, Optional +from typing import List, Literal, Optional from pydantic import BaseModel, ConfigDict, Field @@ -144,3 +144,32 @@ class TransferHistoryPage(BaseModel): list: List[TransferHistory] = Field(default_factory=list, description="整理历史列表") total: int = Field(default=0, description="记录总数") + + +class TransferHistoryDeleteStep(BaseModel): # type: ignore[misc] + """整理历史删除流程中单个文件目标的执行结果。""" + + # not_requested 未请求;deleted 已删除;already_missing 原目标已不存在;failed 删除或校验失败 + status: Literal["not_requested", "deleted", "already_missing", "failed"] + # 仅用于无法继续处理时给出可读原因,正常状态为空 + message: str = "" + + +class TransferHistoryDeleteResult(BaseModel): # type: ignore[misc] + """整理历史删除及源/目标文件清理的分项结果。""" + + # 源文件步骤 + source: TransferHistoryDeleteStep + # 目标文件步骤 + destination: TransferHistoryDeleteStep + # 历史记录步骤:deleted 已删除、retained 保留待重试、not_found 不存在 + history: Literal["deleted", "retained", "not_found"] + # 面向请求方的概要消息;详细状态由三个步骤字段表达 + message: str = "" + + @property + def success(self) -> bool: + """兼容应用层命令调用方,以历史记录是否删除作为整体成功条件。""" + return self.history == "deleted" + + model_config = ConfigDict(from_attributes=True) diff --git a/docs/architecture-overview.md b/docs/architecture-overview.md index 74527dfb2..c620e70cf 100644 --- a/docs/architecture-overview.md +++ b/docs/architecture-overview.md @@ -755,7 +755,7 @@ flowchart LR | 指标 | 当前值 | |---|---:| | Python 模块 | 973 | -| 内部导入边 | 8,422 | +| 内部导入边 | 8,426 | | 非平凡 SCC | 1(精确 containment 的 TMDB 移植包环) | | Application / Chain 具体 Adapter 直连 | 0 / 0 | | Direct egress | 53(债务已清零,53 条精确 containment) | diff --git a/docs/architecture/optimization-checklist.md b/docs/architecture/optimization-checklist.md index 844345d16..26f357bc5 100644 --- a/docs/architecture/optimization-checklist.md +++ b/docs/architecture/optimization-checklist.md @@ -19,7 +19,7 @@ | RES-002 | `DELIVERED`(R2) | 复杂度门禁漏扫私有方法、类/文件和 Scheduler;原生并发清单不完整 | `f7ca7e517`、`5cd5780d3`:完整宿主 AST、canonical owner/count、低水位与零增长门禁已落地 | | RES-003 | `DELIVERED`(R3) | Outbox after_commit 失败只有内存 pending 标记,重启不可恢复 | `9d06f91bb`:持久 intent、唯一 handler、claim/fencing、重启回放、幂等和失败观测完整闭环 | | RES-004 | `DELIVERED`(R4) | Startup initializer 与插件市场存在多套 Transport/Adapter/Manager 构造 | `7f5b8b469` 至 `046b0b305`:构造回收到 composition,兼容门面消费同一 owner,canonical 无重复正式实现 | -| RES-005 | `DELIVERED`(R5) | 审计声明、机器门禁、CI 与远端交付状态需要重新校准 | 当前文档、生成 fixture 与规则一致;锁定全量 `7684 passed, 9 skipped`,Application/Domain 覆盖率为 `81.79%` / `81.03%`,Pylint `10.00/10`、架构/兼容、真实启动和最终 exact-head GitHub CI 闭环,远端 `0/0` | +| RES-005 | `DELIVERED`(R5) | 审计声明、机器门禁、CI 与远端交付状态需要重新校准 | 当前文档、生成 fixture 与规则一致;锁定全量 `7684 passed, 9 skipped`,Application/Domain 覆盖率为 `81.80%` / `81.03%`,Pylint `10.00/10`、架构/兼容、真实启动和最终 exact-head GitHub CI 闭环,远端 `0/0` | R2 门禁现已完整覆盖私有、dunder、任意控制流嵌套方法、类、文件与 `app/scheduler/`;`concurrency.py` 扫描完整宿主源码,按 canonical import/alias、 TaskGroup、可证明的 loop/executor 来源和词法 owner 聚合数量。新增 owner、数量增长以及 @@ -93,7 +93,7 @@ ARCH-201 至 ARCH-204 均达到实现、验证、提交、推送和远端门禁 | 指标 | 当前值 | 解释 | |---|---:|---| -| 宿主 Python 模块 / 内部依赖边 | 981 / 8,422 | `dependency-baseline.json` 当前快照 | +| 宿主 Python 模块 / 内部依赖边 | 982 / 8,426 | `dependency-baseline.json` 当前快照 | | 非平凡 SCC | 1 | 仅保留精确 containment 的 29 模块 TMDB 移植包环 | | 跨层 DB 边界债务 | 0 | Application、Chain、API、Agent、Runtime、Workflow 到 DB 的受控债务均为零 | | Model/Oper 事务债务 | 0 | 自建 Session、自动事务装饰器、直接 commit/rollback 等基线均为零 | @@ -103,7 +103,7 @@ ARCH-201 至 ARCH-204 均达到实现、验证、提交、推送和远端门禁 | 长方法 | 290 个超过 80 行 | AST 统计排除 `app/plugins/**`;65 个超过 150 行,21 个超过 250 行 | | 全量 mypy 历史债务 | 9,982 / 591 文件 | canonical `SearchChain` Facade 已补齐显式类型转发;strict frontier 当前覆盖 41 个文件,低水位只允许继续下降 | | Ruff 历史诊断 | 631 | 低水位门禁通过,但规则集只覆盖 `E4/E7/E9/F/I` | -| 覆盖率低水位 | Application 81.79%,Domain 81.03% | Chain、Runtime、Agent、Adapter、Startup 未进入包级覆盖率门禁 | +| 覆盖率低水位 | Application 81.80%,Domain 81.03% | Chain、Runtime、Agent、Adapter、Startup 未进入包级覆盖率门禁 | ### 3.3 热点文件 diff --git a/tests/fixtures/architecture/coverage-baseline.json b/tests/fixtures/architecture/coverage-baseline.json index f6be67e77..d8de22da8 100644 --- a/tests/fixtures/architecture/coverage-baseline.json +++ b/tests/fixtures/architecture/coverage-baseline.json @@ -1,8 +1,8 @@ { "application": { - "covered_lines": 12935, - "percent": 81.79, - "statements": 15814 + "covered_lines": 12966, + "percent": 81.8, + "statements": 15851 }, "domain": { "covered_lines": 3686, diff --git a/tests/fixtures/architecture/dependency-baseline.json b/tests/fixtures/architecture/dependency-baseline.json index 86cfc9b08..6c8a62881 100644 --- a/tests/fixtures/architecture/dependency-baseline.json +++ b/tests/fixtures/architecture/dependency-baseline.json @@ -1089,8 +1089,8 @@ "runtime_only": true } }, - "edge_count": 8422, - "edge_sha256": "3971beb13490710d6054b8cedbc66da9c0920bb30107332440f8c788d27feebd", + "edge_count": 8426, + "edge_sha256": "b100c70544e268aba59153e0725133903487e4dcd2c6b28ca9130ea8ef6a982c", "edges": [ "app -> app.foundation", "app -> app.foundation.environment", @@ -3796,6 +3796,7 @@ "app.application.formatting -> app.schemas.workflow", "app.application.history -> app.application", "app.application.history -> app.application.configuration", + "app.application.history -> app.application.historymutation", "app.application.history -> app.domain", "app.application.history -> app.domain.context", "app.application.history -> app.domain.meta", @@ -3813,6 +3814,9 @@ "app.application.history -> app.schemas.media", "app.application.history -> app.schemas.transfer", "app.application.history -> app.schemas.types", + "app.application.historymutation -> app.schemas", + "app.application.historymutation -> app.schemas.common", + "app.application.historymutation -> app.schemas.history", "app.application.image -> app.application", "app.application.image -> app.application.configuration", "app.application.image -> app.application.security", @@ -9515,7 +9519,7 @@ "app.workflow.actions.transfer_file -> app.workflow", "app.workflow.actions.transfer_file -> app.workflow.actions" ], - "module_count": 981, + "module_count": 982, "modules": [ "app", "app.adapters", @@ -9794,6 +9798,7 @@ "app.application.downloader", "app.application.formatting", "app.application.history", + "app.application.historymutation", "app.application.image", "app.application.maintenance", "app.application.mediaserver", diff --git a/tests/fixtures/architecture/startup-performance-baseline.json b/tests/fixtures/architecture/startup-performance-baseline.json index 9842026cc..d9cb92f8b 100644 --- a/tests/fixtures/architecture/startup-performance-baseline.json +++ b/tests/fixtures/architecture/startup-performance-baseline.json @@ -6,7 +6,7 @@ "repeat": 3, "targets": { "app.startup.lifecycle": { - "loaded_app_module_count": 498, + "loaded_app_module_count": 499, "max_ms": 1138.629, "median_ms": 1108.684, "min_ms": 1097.855, @@ -17,7 +17,7 @@ ] }, "app.factory": { - "loaded_app_module_count": 510, + "loaded_app_module_count": 511, "max_ms": 1118.665, "median_ms": 1117.279, "min_ms": 1111.113, @@ -28,7 +28,7 @@ ] }, "app.main": { - "loaded_app_module_count": 512, + "loaded_app_module_count": 513, "max_ms": 1172.187, "median_ms": 1161.652, "min_ms": 1155.282, diff --git a/tests/test_history_mutation_command.py b/tests/test_history_mutation_command.py index 7fc0b511e..3e78385a3 100644 --- a/tests/test_history_mutation_command.py +++ b/tests/test_history_mutation_command.py @@ -69,6 +69,8 @@ def test_transfer_source_delete_failure_keeps_database_unchanged(): assert result.success is False assert result.message == "/downloads/demo.mkv 删除失败" + assert result.source.status == "failed" + assert result.destination.status == "not_requested" dependencies["repository"].stage_delete.assert_not_called() dependencies["download_repository"].stage_delete_file_by_fullpath.assert_not_called() dependencies["unit_of_work"].commit.assert_not_called() @@ -91,6 +93,8 @@ def test_transfer_delete_commits_before_event_and_retry_cleanup(): ) assert result.success is True + assert result.source.status == "deleted" + assert result.destination.status == "deleted" dependencies["download_repository"].stage_delete_file_by_fullpath.assert_called_once_with( "/downloads/demo.mkv" ) @@ -135,6 +139,79 @@ def test_transfer_delete_rejects_durable_receipt_before_file_side_effects(): assert result.success is False assert result.message == "持久整理失败记录不可删除,请使用重试或人工复核入口" + assert result.history == "retained" dependencies["delete_media_file"].assert_not_called() dependencies["repository"].stage_delete.assert_not_called() dependencies["unit_of_work"].commit.assert_not_called() + + +def test_transfer_destination_delete_failure_keeps_history_and_reports_step(): + """目标文件删除失败时必须保留历史,并向调用方报告失败步骤。""" + command, dependencies = _transfer_command( + history=_history(), + delete_result=False, + ) + + result = command.delete(7, delete_destination=True) + + assert result.success is False + assert result.destination.status == "failed" + assert result.history == "retained" + assert result.source.status == "not_requested" + dependencies["repository"].stage_delete.assert_not_called() + dependencies["unit_of_work"].commit.assert_not_called() + + +def test_transfer_delete_commits_completed_source_when_destination_fails(): + """一项文件已完成而另一项失败时,已完成源文件状态仍应提交并保留历史。""" + repository = Mock() + repository.get.return_value = _history() + dependencies = { + "repository": repository, + "download_repository": Mock(), + "unit_of_work": Mock(), + "file_item_factory": lambda payload: SimpleNamespace(**payload), + "file_exists": Mock(return_value=True), + "delete_media_file": Mock(side_effect=[False, True]), + "publish_download_file_deleted": Mock(), + "clear_failures": Mock(), + } + command = TransferHistoryMutationCommand(**dependencies) + + result = command.delete(7, delete_source=True, delete_destination=True) + + assert result.success is False + assert result.destination.status == "failed" + assert result.source.status == "deleted" + assert result.history == "retained" + repository.stage_delete.assert_not_called() + dependencies["download_repository"].stage_delete_file_by_fullpath.assert_called_once_with( + "/downloads/demo.mkv" + ) + dependencies["unit_of_work"].commit.assert_called_once_with() + dependencies["publish_download_file_deleted"].assert_called_once() + dependencies["clear_failures"].assert_not_called() + + +def test_transfer_delete_treats_missing_requested_file_as_completed(): + """重试时已被前一次尝试删除的文件应跳过外部删除并允许清理历史。""" + command, dependencies = _transfer_command(history=_history()) + dependencies["delete_media_file"].reset_mock() + dependencies["file_exists"] = Mock(return_value=False) + command = TransferHistoryMutationCommand( + repository=dependencies["repository"], + download_repository=dependencies["download_repository"], + unit_of_work=dependencies["unit_of_work"], + file_item_factory=dependencies["file_item_factory"], + file_exists=dependencies["file_exists"], + delete_media_file=dependencies["delete_media_file"], + publish_download_file_deleted=dependencies["publish_download_file_deleted"], + clear_failures=dependencies["clear_failures"], + ) + + result = command.delete(7, delete_source=True, delete_destination=True) + + assert result.success is True + assert result.source.status == "already_missing" + assert result.destination.status == "already_missing" + dependencies["delete_media_file"].assert_not_called() diff --git a/tests/test_history_query.py b/tests/test_history_query.py index 94c271218..67745382d 100644 --- a/tests/test_history_query.py +++ b/tests/test_history_query.py @@ -1,7 +1,7 @@ """历史查询应用服务的分页、筛选和 DTO 边界测试。""" from types import SimpleNamespace -from unittest.mock import AsyncMock +from unittest.mock import ANY, AsyncMock import pytest @@ -42,24 +42,26 @@ async def test_list_download_returns_schema_dtos() -> None: @pytest.mark.asyncio -async def test_list_transfer_maps_failed_alias_to_status_filter() -> None: - """“失败”快捷条件应继续映射为无标题的失败状态查询。""" +async def test_list_transfer_uses_explicit_status_without_reinterpreting_title() -> None: + """状态筛选必须使用显式参数,中文标题仍保持标题查询语义。""" service, _, transfer_repository = _make_service() - transfer_repository.async_list_by_page.return_value = [ + transfer_repository.async_list_by_title.return_value = [ SimpleNamespace(id=3, status=False) ] - transfer_repository.async_count.return_value = 1 + transfer_repository.async_count_by_title.return_value = 1 - page = await service.list_transfer(title="失败", page=3, count=5) + page = await service.list_transfer(title="失败", page=3, count=5, status=False) assert page.list == [TransferHistory(id=3, status=False)] assert page.total == 1 - transfer_repository.async_list_by_page.assert_awaited_once_with( + transfer_repository.async_count_by_title.assert_awaited_once() + transfer_repository.async_list_by_title.assert_awaited_once_with( + ANY, page=3, count=5, status=False, + wildcard=False, ) - transfer_repository.async_count.assert_awaited_once_with(status=False) @pytest.mark.asyncio diff --git a/tests/test_transfer_failed_retry_budget.py b/tests/test_transfer_failed_retry_budget.py index 900fb6731..da80ebaf6 100644 --- a/tests/test_transfer_failed_retry_budget.py +++ b/tests/test_transfer_failed_retry_budget.py @@ -105,10 +105,11 @@ def test_delete_transfer_history_endpoint_clears_retry_count(monkeypatch): id=101, src=src_path, src_storage=storage, - dest_fileitem=None, - src_fileitem=None, - download_hash=None, - ) + dest_fileitem=None, + src_fileitem=None, + download_hash=None, + transfer_task_id=None, + ) repository = Mock() repository.get.return_value = history command = TransferHistoryMutationCommand( @@ -136,6 +137,10 @@ def test_delete_transfer_history_endpoint_clears_retry_count(monkeypatch): ) assert response.success is True + assert response.data is not None + assert response.data.history == "deleted" + assert response.data.source.status == "not_requested" + assert response.data.destination.status == "not_requested" assert failed_retry_count(src_path, storage) == 0 finally: _reset_failed_retries(src_path, storage)