From 017fecabd6882d473609dde59b0dd0de882df897 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Fri, 28 Aug 2026 11:55:33 +0800 Subject: [PATCH] refactor(history): close transfer history boundary --- .../tools/impl/delete_transfer_history.py | 2 + app/api/dependencies/history.py | 10 +- app/application/agentdata.py | 14 +- app/application/chain/data.py | 14 +- app/application/chain/events.py | 22 +- app/application/dashboard.py | 26 +- app/application/history.py | 576 +++++++++++++----- app/application/query.py | 14 +- app/chain/_transfer.py | 49 +- app/chain/transfer.py | 18 +- app/db/adapters/chain.py | 58 +- app/db/adapters/history/transfer.py | 401 ++++++++++++ app/db/oper/__init__.py | 3 - app/db/oper/transferhistory.py | 63 +- app/monitor/dispatcher.py | 14 +- app/runtime/compat/manifest.py | 5 + app/sdk/_legacy/history.py | 53 +- app/startup/composition/context.py | 15 +- app/startup/initializers/modules.py | 21 +- app/workflow/actions/transfer_file.py | 6 + docs/architecture-optimization-checklist.md | 23 +- docs/architecture-overview.md | 2 +- docs/architecture-refactor-roadmap.md | 4 +- docs/rules/05-architecture.md | 25 +- docs/rules/10-data-and-persistent.md | 9 +- .../architecture/coverage-baseline.json | 6 +- .../architecture/dependency-baseline.json | 27 +- .../fixtures/architecture/mypy-baseline.json | 16 +- .../fixtures/architecture/ruff-baseline.json | 16 - .../runtime-contract-baseline.json | 5 + tests/test_chain_durable_events.py | 19 +- tests/test_db_oper_layer_extra.py | 10 +- tests/test_host_runtime_context.py | 1 + tests/test_legacy_db_behavior_compat.py | 5 +- tests/test_legacy_import_compat.py | 5 +- tests/test_manual_transfer_history.py | 42 +- tests/test_monitor_dispatcher_history.py | 13 +- tests/test_monitor_watchfiles.py | 29 +- tests/test_transactional_transfer_history.py | 236 +++++++ tests/test_transfer_history_architecture.py | 265 ++++++++ tests/test_transfer_history_legacy_compat.py | 99 +++ tests/test_transfer_history_write_path.py | 39 +- tests/test_transfer_legacy_terminal_compat.py | 18 +- tests/test_transfer_planning_checkpoint.py | 3 +- tests/test_transfer_settling_recovery.py | 9 +- ..._transferhistory_media_source_migration.py | 58 +- tests/test_ugreen_mediaserver.py | 241 ++++---- 47 files changed, 2023 insertions(+), 586 deletions(-) create mode 100644 app/db/adapters/history/transfer.py create mode 100644 tests/test_transactional_transfer_history.py create mode 100644 tests/test_transfer_history_architecture.py create mode 100644 tests/test_transfer_history_legacy_compat.py diff --git a/app/agent/tools/impl/delete_transfer_history.py b/app/agent/tools/impl/delete_transfer_history.py index b28d0213e..09212ebe5 100644 --- a/app/agent/tools/impl/delete_transfer_history.py +++ b/app/agent/tools/impl/delete_transfer_history.py @@ -101,6 +101,8 @@ class DeleteTransferHistoryTool(MoviePilotTool): status = "成功" if history.status else "失败" deleted_dest = False if history.dest_fileitem and not (history.status and history.mode == "move"): + if not isinstance(history.dest_fileitem, dict): + return "删除失败:目标文件历史数据无效,已保留历史记录" dest_fileitem = FileItem(**history.dest_fileitem) try: destination_exists, destination_deleted = await self.run_blocking( diff --git a/app/api/dependencies/history.py b/app/api/dependencies/history.py index 5e86c79f4..c43f00958 100644 --- a/app/api/dependencies/history.py +++ b/app/api/dependencies/history.py @@ -32,14 +32,13 @@ def get_mediaserver_query_service( def get_dashboard_query_service( - db: Session = Depends(get_sync_session), runtime: HostRuntime = Depends(get_host_runtime), ) -> DashboardQueryService: """组装 Dashboard 媒体与整理历史统计查询服务。""" from app.chain.dashboard import DashboardChain return DashboardQueryService( - repository=runtime.history.transfer_repository(db), + repository=runtime.history.transfer_repository, media_statistics=DashboardChain().media_statistic, ) @@ -62,17 +61,16 @@ def get_history_query_service( """组装历史列表和详情异步查询服务。""" return HistoryQueryService( download_repository=runtime.history.download_repository(db), - transfer_repository=runtime.history.transfer_repository(db), + transfer_repository=runtime.history.transfer_repository, ) def get_transfer_history_lookup_service( - db: Session = Depends(get_sync_session), runtime: HostRuntime = Depends(get_host_runtime), ) -> TransferHistoryLookupService: """组装手动整理使用的同步历史投影服务。""" return TransferHistoryLookupService( - runtime.history.transfer_repository(db) + runtime.history.transfer_repository ) @@ -83,7 +81,7 @@ def get_transfer_history_mutation_command( """组装整理历史删除、文件处理和事件发布用例。""" storage_chain = StorageChain() return TransferHistoryMutationCommand( - repository=runtime.history.transfer_repository(db), + repository=runtime.history.transfer_mutation_repository(db), download_repository=runtime.history.download_repository(db), unit_of_work=runtime.persistence.sync_transaction(db), file_item_factory=lambda payload: _SchemaFileItem(**payload), diff --git a/app/application/agentdata.py b/app/application/agentdata.py index 661cf347a..a9c234067 100644 --- a/app/application/agentdata.py +++ b/app/application/agentdata.py @@ -5,7 +5,10 @@ from __future__ import annotations from collections.abc import Callable from typing import Any, cast -from app.application.history import DownloadHistoryRepository +from app.application.history import ( + DownloadHistoryRepository, + TransferHistoryRepository, +) from app.application.security.user import ChainUserRepository AgentDataFactory = Callable[[], Any] @@ -146,9 +149,12 @@ def get_agent_subscribe_history_port() -> Any: return get_agent_data_ports().subscribe_history() -def get_agent_transfer_history_port() -> Any: - """创建 Agent 整理历史数据端口实例。""" - return get_agent_data_ports().transfer_history() +def get_agent_transfer_history_port() -> TransferHistoryRepository: + """创建 Agent 类型化整理历史数据端口实例。""" + return cast( + TransferHistoryRepository, + get_agent_data_ports().transfer_history(), + ) def get_agent_download_history_port() -> DownloadHistoryRepository: diff --git a/app/application/chain/data.py b/app/application/chain/data.py index a960bf814..c73b4c591 100644 --- a/app/application/chain/data.py +++ b/app/application/chain/data.py @@ -11,7 +11,10 @@ from dataclasses import dataclass from typing import Any, Optional from app.application.download.failures import DownloadFailureRepository -from app.application.history import DownloadHistoryRepository +from app.application.history import ( + DownloadHistoryRepository, + TransferHistoryRepository, +) from app.application.mediaserver import MediaServerRepository from app.application.security.user import ChainUserRepository from app.application.transfer.execution import TransferExecutionRepository @@ -20,6 +23,7 @@ from app.application.transfer.workflow import TransferAdmissionRepository OperFactory = Callable[[], Any] DownloadFailureRepositoryFactory = Callable[[], DownloadFailureRepository] DownloadHistoryRepositoryFactory = Callable[[], DownloadHistoryRepository] +TransferHistoryRepositoryFactory = Callable[[], TransferHistoryRepository] MediaServerRepositoryFactory = Callable[[], MediaServerRepository] ChainUserRepositoryFactory = Callable[[], ChainUserRepository] TransferAdmissionRepositoryFactory = Callable[[], TransferAdmissionRepository] @@ -33,7 +37,7 @@ class ChainDataPorts: site: OperFactory subscribe: OperFactory download_history: DownloadHistoryRepositoryFactory - transfer_history: OperFactory + transfer_history: TransferHistoryRepositoryFactory transfer_pending: TransferAdmissionRepositoryFactory transfer_execution: TransferExecutionRepositoryFactory media_server: MediaServerRepositoryFactory @@ -49,7 +53,7 @@ def configure_chain_data_ports( site: OperFactory, subscribe: OperFactory, download_history: DownloadHistoryRepositoryFactory, - transfer_history: OperFactory, + transfer_history: TransferHistoryRepositoryFactory, transfer_pending: TransferAdmissionRepositoryFactory, transfer_execution: TransferExecutionRepositoryFactory, media_server: MediaServerRepositoryFactory, @@ -93,8 +97,8 @@ def get_chain_download_history_port() -> DownloadHistoryRepository: return get_chain_data_ports().download_history() -def get_chain_transfer_history_port() -> Any: - """创建整理历史数据端口实例。""" +def get_chain_transfer_history_port() -> TransferHistoryRepository: + """创建类型化的整理历史查询与变更端口实例。""" return get_chain_data_ports().transfer_history() diff --git a/app/application/chain/events.py b/app/application/chain/events.py index 30a26c25f..ab81ed32c 100644 --- a/app/application/chain/events.py +++ b/app/application/chain/events.py @@ -13,8 +13,8 @@ from uuid import uuid4 from app.application.history import ( DownloadFileWrite, DownloadHistoryWrite, - TransferHistoryRecord, - TransferHistoryWriter, + TransferHistorySnapshot, + TransferHistoryStagingPort, ) from app.application.transfer.execution import TransferSettlementResult from app.domain.context import Context, MediaInfo, MusicInfo, TorrentInfo @@ -44,11 +44,14 @@ class ChainDurableEventWriter(Protocol): self, *, topic: str | None, - stage_history: Callable[[TransferHistoryWriter], TransferHistoryRecord | None], + stage_history: Callable[ + [TransferHistoryStagingPort], + TransferHistorySnapshot | None, + ], event_payload: dict[str, Any], publish: Callable[[dict[str, Any]], None] | None, settlement: "TransferResultSettlement | None" = None, - ) -> TransferHistoryRecord | TransferSettlementResult | None: + ) -> TransferHistorySnapshot | TransferSettlementResult | None: """提交历史、可选任务终态和结果 intent,再按 topic 广播事件。""" @@ -72,17 +75,6 @@ class TransferResultSettlement: raise ValueError("整理失败终态必须包含可诊断原因") -@dataclass(frozen=True, slots=True) -class TransferHistoryRef: - """事务关闭后仍可安全读取的最小整理历史投影。""" - - id: int - status: bool - src: str | None - src_storage: str | None - src_fileitem: dict[str, Any] | None - - def download_added_event_key(history_id: int) -> str: """由下载历史 ID 与本次事实标识构造 DownloadAdded 幂等键。""" return f"download.added:{history_id}:{uuid4().hex}:v1" diff --git a/app/application/dashboard.py b/app/application/dashboard.py index 7ce3c7ce1..0f1e56c1d 100644 --- a/app/application/dashboard.py +++ b/app/application/dashboard.py @@ -1,19 +1,26 @@ """Dashboard 统计查询用例。""" from collections.abc import Callable -from typing import Any, Optional, Protocol +from typing import Optional, Protocol +from app.application.history import ( + TransferHistoryMonthlyStatistics, + TransferHistoryStatisticSnapshot, +) from app.schemas.dashboard import Statistic class TransferHistoryQueryRepository(Protocol): """Dashboard 所需的整理历史统计端口。""" - def monthly_media_statistics(self) -> tuple[int, int, int, int]: + def monthly_media_statistics(self) -> TransferHistoryMonthlyStatistics: """返回本月电影、剧集、单集和音乐数量。""" ... - async def async_statistic(self, days: int = 7) -> list[Any]: + async def async_statistic( + self, + days: int = 7, + ) -> list[TransferHistoryStatisticSnapshot]: """返回最近若干天的整理趋势。""" ... @@ -50,15 +57,14 @@ class DashboardQueryService: else: result = Statistic() - ( - result.movie_count_month, - result.tv_count_month, - result.episode_count_month, - result.music_count_month, - ) = self._repository.monthly_media_statistics() + monthly = self._repository.monthly_media_statistics() + result.movie_count_month = monthly.movies + result.tv_count_month = monthly.tv_shows + result.episode_count_month = monthly.episodes + result.music_count_month = monthly.music return result async def transfer(self, days: int = 7) -> list[int]: """返回最近若干天的整理数量序列。""" rows = await self._repository.async_statistic(days) - return [row[1] for row in rows] + return [row.count for row in rows] diff --git a/app/application/history.py b/app/application/history.py index 098070ed1..5fde68447 100644 --- a/app/application/history.py +++ b/app/application/history.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from dataclasses import asdict, dataclass from pathlib import Path from typing import Any, Callable, Dict, NoReturn, Optional, Protocol, Union @@ -33,65 +35,6 @@ FAILED_RETRY_TTL = 24 * 3600 _failed_retry_counts = TTLCache(region="transfer_failed_retry", maxsize=5000, ttl=FAILED_RETRY_TTL) -class TransferHistoryRecord(Protocol): - """整理历史用例读取的最小记录投影。""" - - id: int - status: bool - src: Optional[str] - src_storage: Optional[str] - src_fileitem: Optional[dict] - transfer_task_id: Optional[str] - - -class TransferHistoryWriter(Protocol): - """整理历史写入和查重端口。""" - - def get_by_src(self, src: str, storage: Optional[str] = None) -> Optional[TransferHistoryRecord]: - """按源路径读取记录。""" - - def get_success_by_src(self, src: str, storage: Optional[str] = None) -> Optional[TransferHistoryRecord]: - """按源路径读取成功记录。""" - - def add_force(self, **payload: Any) -> Optional[TransferHistoryRecord]: - """强制写入整理历史。""" - - -_configured_transfer_history_provider: Callable[[], TransferHistoryWriter] | None = None - - -def configure_transfer_history_provider( - provider: Callable[[], TransferHistoryWriter], -) -> None: - """由启动组合根登记整理历史数据端口提供器。""" - global _configured_transfer_history_provider - _configured_transfer_history_provider = provider - - -def _get_transfer_history_writer( - writer: Optional[TransferHistoryWriter], -) -> TransferHistoryWriter: - """获取显式传入或组合根登记的整理历史数据端口。""" - if writer is not None: - return writer - if _configured_transfer_history_provider is None: - raise RuntimeError("整理历史数据端口尚未配置") - return _configured_transfer_history_provider() - - -class TransferHistoryPort: - """把监控等宿主用例的存量构造形态转发到整理历史端口。""" - - def __getattr__(self, name: str) -> Any: - """转发整理历史读写方法,避免上层直接导入数据库操作器。""" - return getattr(_get_transfer_history_writer(None), name) - - -def get_transfer_history_port() -> TransferHistoryWriter: - """返回启动组合根登记的整理历史数据端口实例。""" - return _get_transfer_history_writer(None) - - @dataclass(frozen=True, slots=True) class HistoryMutationResult: """描述历史记录维护操作是否成功及兼容提示。""" @@ -105,7 +48,7 @@ class _FrozenJsonDict(dict[str, JsonData]): def _reject_mutation(self, *args: Any, **kwargs: Any) -> NoReturn: """拒绝修改已经进入历史快照的嵌套 JSON。""" - raise TypeError("下载历史快照 JSON 不可修改") + raise TypeError("历史快照 JSON 不可修改") __setitem__ = _reject_mutation __delitem__ = _reject_mutation @@ -122,7 +65,7 @@ class _FrozenJsonList(list[JsonData]): def _reject_mutation(self, *args: Any, **kwargs: Any) -> NoReturn: """拒绝修改已经进入历史快照的嵌套 JSON。""" - raise TypeError("下载历史快照 JSON 不可修改") + raise TypeError("历史快照 JSON 不可修改") __setitem__ = _reject_mutation __delitem__ = _reject_mutation @@ -147,6 +90,312 @@ def _freeze_json(value: JsonData) -> JsonData: return value +@dataclass(frozen=True, slots=True) +class TransferHistorySnapshot: + """脱离数据库会话后供整理、Agent 和历史用例读取的完整历史快照。""" + + id: int + transfer_task_id: Optional[str] = None + transfer_settlement_revision: Optional[int] = None + src: Optional[str] = None + src_storage: Optional[str] = None + src_fileitem: Optional[JsonData] = None + dest: Optional[str] = None + dest_storage: Optional[str] = None + dest_fileitem: Optional[JsonData] = None + mode: Optional[str] = None + type: Optional[str] = None + category: Optional[str] = None + title: Optional[str] = None + year: Optional[str] = None + media_source: Optional[MediaSource] = None + media_id: Optional[str] = None + music_type: Optional[str] = None + total_tracks: Optional[int] = None + audio_format: Optional[str] = None + audio_lossless: Optional[bool] = None + bit_depth: Optional[int] = None + sample_rate: Optional[int] = None + bitrate: Optional[int] = None + seasons: Optional[str] = None + episodes: Optional[str] = None + image: Optional[str] = None + downloader: Optional[str] = None + download_hash: Optional[str] = None + status: bool = True + errmsg: Optional[str] = None + date: Optional[str] = None + files: Optional[JsonData] = None + episode_group: Optional[str] = None + + def __post_init__(self) -> None: + """递归冻结历史 JSON 字段,避免跨层共享可变 ORM 列值。""" + object.__setattr__(self, "src_fileitem", _freeze_json(self.src_fileitem)) + object.__setattr__(self, "dest_fileitem", _freeze_json(self.dest_fileitem)) + object.__setattr__(self, "files", _freeze_json(self.files)) + + +@dataclass(frozen=True, slots=True) +class TransferHistoryWrite: + """替换同源整理历史所需的完整稳定写入数据。""" + + src: str + src_storage: Optional[str] = None + src_fileitem: Optional[JsonData] = None + dest: Optional[str] = None + dest_storage: Optional[str] = None + dest_fileitem: Optional[JsonData] = None + mode: Optional[str] = None + type: Optional[str] = None + category: Optional[str] = None + title: Optional[str] = None + year: Optional[str] = None + media_source: Optional[MediaSource] = None + media_id: Optional[str] = None + music_type: Optional[str] = None + total_tracks: Optional[int] = None + audio_format: Optional[str] = None + audio_lossless: Optional[bool] = None + bit_depth: Optional[int] = None + sample_rate: Optional[int] = None + bitrate: Optional[int] = None + seasons: Optional[str] = None + episodes: Optional[str] = None + image: Optional[str] = None + downloader: Optional[str] = None + download_hash: Optional[str] = None + status: bool = True + errmsg: Optional[str] = None + files: Optional[JsonData] = None + episode_group: Optional[str] = None + + def to_payload(self) -> dict[str, object]: + """返回仅含持久化字段的独立副本。""" + payload: dict[str, object] = asdict(self) + if self.media_source is not None: + payload["media_source"] = str(self.media_source) + return payload + + +@dataclass(frozen=True, slots=True) +class TransferHistoryStatisticSnapshot: + """单日整理历史数量统计。""" + + date: str + count: int + + +@dataclass(frozen=True, slots=True) +class TransferHistoryMonthlyStatistics: + """本月按媒体类别聚合的整理历史数量。""" + + movies: int + tv_shows: int + episodes: int + music: int + + +class TransferHistoryQueryPort(Protocol): + """宿主整理、历史、Agent 和工作流所需的类型化查询端口。""" + + def get(self, history_id: int) -> Optional[TransferHistorySnapshot]: + """按主键返回整理历史快照。""" + ... + + def get_by_src( + self, + src: str, + storage: Optional[str] = None, + ) -> Optional[TransferHistorySnapshot]: + """按源路径和可选存储返回最新历史快照。""" + ... + + def get_success_by_src( + self, + src: str, + storage: Optional[str] = None, + ) -> Optional[TransferHistorySnapshot]: + """按源路径和可选存储返回最新成功历史快照。""" + ... + + def get_by_dest( + self, + dest: str, + storage: Optional[str] = None, + ) -> Optional[TransferHistorySnapshot]: + """按目标路径和可选存储返回最新历史快照。""" + ... + + def get_by_transfer_task_id( + self, + *, + task_id: str, + ) -> Optional[TransferHistorySnapshot]: + """按 durable 整理任务标识返回终态历史快照。""" + ... + + def get_by_media_identity( + self, + media_source: MediaSource, + media_id: str, + mtype: Optional[str] = None, + ) -> Optional[TransferHistorySnapshot]: + """按规范媒体身份和可选媒体类型返回历史快照。""" + ... + + def list_success_by_src( + self, + src: str, + storage: Optional[str] = None, + recursive: bool = False, + ) -> list[TransferHistorySnapshot]: + """按源路径返回成功整理历史快照。""" + ... + + def list_success_move_by_dest( + self, + dest: str, + storage: Optional[str] = None, + recursive: bool = False, + ) -> list[TransferHistorySnapshot]: + """按目标路径返回成功移动历史快照。""" + ... + + def list_by_hash(self, download_hash: str) -> list[TransferHistorySnapshot]: + """按下载任务 Hash 返回历史快照。""" + ... + + async def async_get( + self, + history_id: int, + ) -> Optional[TransferHistorySnapshot]: + """异步按主键返回整理历史快照。""" + ... + + async def async_list_by_title( + self, + title: str, + page: int = 1, + count: int = 30, + status: Optional[bool] = None, + wildcard: bool = False, + ) -> list[TransferHistorySnapshot]: + """异步按标题或路径分页返回历史快照。""" + ... + + async def async_list_by_page( + self, + page: int = 1, + count: int = 30, + status: Optional[bool] = None, + ) -> list[TransferHistorySnapshot]: + """异步按时间倒序分页返回历史快照。""" + ... + + async def async_count(self, status: Optional[bool] = None) -> int: + """异步统计指定状态的整理历史数量。""" + ... + + async def async_count_by_title( + self, + title: str, + status: Optional[bool] = None, + wildcard: bool = False, + ) -> int: + """异步统计匹配标题或路径的整理历史数量。""" + ... + + async def async_statistic( + self, + days: int = 7, + ) -> list[TransferHistoryStatisticSnapshot]: + """异步返回最近若干天的每日整理数量。""" + ... + + def monthly_media_statistics(self) -> TransferHistoryMonthlyStatistics: + """返回本月电影、剧集、单集和音乐整理数量。""" + ... + + +class TransferHistoryWritePort(Protocol): + """整理历史替换、删除与维护所需的类型化事务端口。""" + + def replace(self, history: TransferHistoryWrite) -> TransferHistorySnapshot: + """在独立事务中替换同源历史并返回快照。""" + ... + + def delete(self, history_id: int) -> None: + """在独立事务中删除一条旧历史。""" + ... + + async def async_delete(self, history_id: int) -> None: + """在独立异步事务中删除一条旧历史。""" + ... + + def truncate(self) -> None: + """在独立事务中清空没有 durable 任务映射的历史。""" + ... + + def update_download_hash(self, history_id: int, download_hash: str) -> None: + """在独立事务中补充整理历史的下载任务 Hash。""" + ... + + +class TransferHistoryReplacePort(Protocol): + """整理历史业务写入规则需要的最小替换端口。""" + + def replace(self, history: TransferHistoryWrite) -> TransferHistorySnapshot: + """替换同源历史并返回冻结快照。""" + ... + + +class TransferHistoryStagingPort(TransferHistoryReplacePort, Protocol): + """durable 结算事务内查询并替换整理历史的类型化暂存端口。""" + + def get_by_src( + self, + src: str, + storage: Optional[str] = None, + ) -> Optional[TransferHistorySnapshot]: + """在调用方 Session 内按源路径读取历史快照。""" + ... + + def get_success_by_src( + self, + src: str, + storage: Optional[str] = None, + ) -> Optional[TransferHistorySnapshot]: + """在调用方 Session 内按源路径读取成功历史快照。""" + ... + +class TransferHistoryRepository( + TransferHistoryQueryPort, + TransferHistoryWritePort, + Protocol, +): + """组合宿主所需全部整理历史查询和变更能力。""" + + +_configured_transfer_history_repository: ( + Callable[[], TransferHistoryRepository] | None +) = None + + +def configure_transfer_history_repository( + provider: Callable[[], TransferHistoryRepository], +) -> None: + """由启动组合根登记类型化整理历史仓储提供器。""" + global _configured_transfer_history_repository + _configured_transfer_history_repository = provider + + +def get_transfer_history_repository() -> TransferHistoryRepository: + """返回启动组合根登记的类型化整理历史仓储。""" + if _configured_transfer_history_repository is None: + raise RuntimeError("类型化整理历史仓储尚未配置") + return _configured_transfer_history_repository() + + @dataclass(frozen=True, slots=True) class DownloadHistorySnapshot: """脱离数据库会话后供宿主下载、订阅和整理用例读取的历史快照。""" @@ -346,47 +595,6 @@ class AsyncDownloadHistoryQueryRepository(Protocol): ... -class AsyncTransferHistoryQueryRepository(Protocol): - """整理历史列表和详情查询需要的最小异步持久化端口。""" - - async def async_get(self, historyid: int) -> Optional[Any]: - """按主键读取单条整理历史。""" - ... - - async def async_list_by_title( - self, - title: str, - page: int = 1, - count: int = 30, - status: Optional[bool] = None, - wildcard: bool = False, - ) -> list[Any]: - """按标题或路径分页读取整理历史。""" - ... - - async def async_list_by_page( - self, - page: int = 1, - count: int = 30, - status: Optional[bool] = None, - ) -> list[Any]: - """按时间倒序分页读取整理历史。""" - ... - - async def async_count(self, status: Optional[bool] = None) -> Optional[int]: - """统计指定状态的整理历史数量。""" - ... - - async def async_count_by_title( - self, - title: str, - status: Optional[bool] = None, - wildcard: bool = False, - ) -> Optional[int]: - """统计匹配标题或路径的整理历史数量。""" - ... - - @dataclass(frozen=True, slots=True) class ManualTransferHistory: """手动整理准备阶段需要的稳定历史投影。""" @@ -394,8 +602,8 @@ class ManualTransferHistory: id: int status: bool mode: Optional[str] - src_fileitem: Optional[dict] - dest_fileitem: Optional[dict] + src_fileitem: Optional[dict[str, JsonData]] + dest_fileitem: Optional[dict[str, JsonData]] downloader: Optional[str] download_hash: Optional[str] type: Optional[str] @@ -410,7 +618,7 @@ class ManualTransferHistory: class TransferHistoryLookupRepository(Protocol): """手动整理历史投影所需的同步查询端口。""" - def get(self, history_id: int) -> Optional[Any]: + def get(self, history_id: int) -> Optional[TransferHistorySnapshot]: """按主键读取整理历史。""" ... @@ -427,12 +635,18 @@ class TransferHistoryLookupService: record = self._repository.get(history_id) if record is None: return None + src_fileitem = ( + record.src_fileitem if isinstance(record.src_fileitem, dict) else None + ) + dest_fileitem = ( + record.dest_fileitem if isinstance(record.dest_fileitem, dict) else None + ) return ManualTransferHistory( id=record.id, status=bool(record.status), mode=record.mode, - src_fileitem=record.src_fileitem, - dest_fileitem=record.dest_fileitem, + src_fileitem=src_fileitem, + dest_fileitem=dest_fileitem, downloader=record.downloader, download_hash=record.download_hash, type=record.type, @@ -452,7 +666,7 @@ class HistoryQueryService: self, *, download_repository: AsyncDownloadHistoryQueryRepository, - transfer_repository: AsyncTransferHistoryQueryRepository, + transfer_repository: TransferHistoryQueryPort, ) -> None: """保存下载历史和整理历史的只读端口。""" self._download_repository = download_repository @@ -555,7 +769,7 @@ class DownloadHistoryMutationRepository(Protocol): class TransferHistoryMutationRepository(Protocol): """整理历史删除与清理用例需要的最小持久化端口。""" - def get(self, history_id: int) -> Optional[Any]: + def get(self, history_id: int) -> Optional[TransferHistorySnapshot]: """读取整理历史。""" ... @@ -625,9 +839,9 @@ class TransferHistoryMutationCommand: repository: TransferHistoryMutationRepository, download_repository: DownloadFileMutationRepository, unit_of_work: HistoryUnitOfWork, - file_item_factory: Callable[[dict], Any], + file_item_factory: Callable[[dict[str, JsonData]], Any], delete_media_file: Callable[[Any], bool], - publish_download_file_deleted: Callable[[dict], None], + publish_download_file_deleted: Callable[[dict[str, Any]], None], clear_failures: Callable[[Optional[str], Optional[str]], None], ) -> None: """保存历史事务、存储删除、事件和失败状态清理端口。""" @@ -657,12 +871,18 @@ class TransferHistoryMutationCommand: ) if delete_destination and history.dest_fileitem: - destination = self._file_item_factory(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 = self._file_item_factory(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( @@ -680,6 +900,11 @@ class TransferHistoryMutationCommand: 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() @@ -805,7 +1030,7 @@ def file_fingerprint( :param fileid: 存储器文件唯一标识 :return: 非空且可比较的指纹字段 """ - fingerprint = {} + fingerprint: Dict[str, Any] = {} size = coerce_size(file_size) if size is not None: fingerprint["size"] = size @@ -960,7 +1185,7 @@ def coerce_size(size: Any) -> Optional[int]: return None -def history_src_size(history: TransferHistoryRecord) -> Optional[int]: +def history_src_size(history: TransferHistorySnapshot) -> Optional[int]: """ 读取整理记录中的源文件大小。 src_fileitem 是 JSON 列,历史数据可能为空、缺 size 键甚至不是字典, @@ -971,7 +1196,7 @@ def history_src_size(history: TransferHistoryRecord) -> Optional[int]: return history_src_fingerprint(history).get("size") -def history_src_fingerprint(history: TransferHistoryRecord) -> Dict[str, Any]: +def history_src_fingerprint(history: TransferHistorySnapshot) -> Dict[str, Any]: """ 读取整理记录中的源文件版本指纹。 :param history: 整理记录 @@ -987,9 +1212,11 @@ def history_src_fingerprint(history: TransferHistoryRecord) -> Dict[str, Any]: ) -def resolve_history(src_path: str, storage: Optional[str] = None, - transfer_history_oper: Optional[TransferHistoryWriter] = None - ) -> Optional[TransferHistoryRecord]: +def resolve_history( + src_path: str, + storage: Optional[str] = None, + transfer_history_oper: Optional[TransferHistoryQueryPort] = None, +) -> Optional[TransferHistorySnapshot]: """ 查询源路径对应的整理记录。 @@ -998,17 +1225,17 @@ def resolve_history(src_path: str, storage: Optional[str] = None, 此处吞掉,由调用方按各自的重试策略处理。 :param src_path: 整理记录使用的源路径 :param storage: 存储 - :param transfer_history_oper: 复用的历史操作对象,未传时新建 + :param transfer_history_oper: 兼容旧关键字的类型化仓储,未传时使用组合根实现 :return: 命中的整理记录,未命中时为 None """ - oper = _get_transfer_history_writer(transfer_history_oper) - history = oper.get_by_src(src_path, storage=storage) + repository = transfer_history_oper or get_transfer_history_repository() + history = repository.get_by_src(src_path, storage=storage) if history is not None and not history.status: - history = oper.get_success_by_src(src_path, storage=storage) or history + history = repository.get_success_by_src(src_path, storage=storage) or history return history -def evaluate_history_gate(history: Optional[TransferHistoryRecord], +def evaluate_history_gate(history: Optional[TransferHistorySnapshot], file_size: Optional[float] = None, file_modify_time: Optional[float] = None, fileid: Optional[str] = None, @@ -1060,7 +1287,7 @@ def evaluate_history_gate(history: Optional[TransferHistoryRecord], return HistoryGateAction.SKIP -def describe_history_gate(history: Optional[TransferHistoryRecord], +def describe_history_gate(history: Optional[TransferHistorySnapshot], file_size: Optional[float] = None, file_modify_time: Optional[float] = None, fileid: Optional[str] = None) -> str: @@ -1102,7 +1329,7 @@ def describe_history_gate(history: Optional[TransferHistoryRecord], # 整理历史的写入路径 # # 这两个函数把 FileItem / MetaBase / MediaInfo / TransferInfo 四个领域对象翻译成 -# 一行整理历史,是整理历史表的唯一写入口。它们此前长在 TransferHistoryOper 上,但 +# 一行整理历史,是整理历史表的唯一写入口。它们此前由表级适配器承载,但 # 拼标题、拆季集、取海报、判音乐字段都是整理链的业务规则而非数据访问——Oper 只该 # 收敛查询,领域对象不该出现在它的入参里。搬到本模块与查重闸(读侧)作伴:同一张 # 表的读写规则放在一起,字段含义只有一处需要维护。 @@ -1112,18 +1339,34 @@ def _history_title(meta: MetaBase, mediainfo: Optional[Union[MediaInfo, MusicInfo]] = None) -> Optional[str]: """音乐文件优先记录曲目标题,其它媒体保持识别标题。""" if isinstance(meta, MetaMusic) and meta.title: - return meta.title + return str(meta.title) if mediainfo and mediainfo.title: - return mediainfo.title - return meta.name + return str(mediainfo.title) + return str(meta.name) if meta.name else None -def add_transfer_success(fileitem: FileItem, mode: str, meta: MetaBase, - mediainfo: Union[MediaInfo, MusicInfo], transferinfo: TransferInfo, - downloader: Optional[str] = None, - download_hash: Optional[str] = None, - transfer_history_oper: Optional[TransferHistoryWriter] = None - ) -> Optional[TransferHistoryRecord]: +def _history_source_path(fileitem: FileItem) -> str: + """返回整理历史必需的源路径,拒绝持久化无身份记录。""" + if not fileitem.path: + raise ValueError("整理历史缺少源文件路径") + return fileitem.path + + +def _history_year(value: object) -> Optional[str]: + """把媒体年份规范为整理历史稳定字符串。""" + return str(value) if value is not None else None + + +def add_transfer_success( + fileitem: FileItem, + mode: str, + meta: MetaBase, + mediainfo: Union[MediaInfo, MusicInfo], + transferinfo: TransferInfo, + downloader: Optional[str] = None, + download_hash: Optional[str] = None, + transfer_history_oper: Optional[TransferHistoryReplacePort] = None, +) -> TransferHistorySnapshot: """ 新增转移成功历史记录。 :param fileitem: 源文件项 @@ -1133,13 +1376,13 @@ def add_transfer_success(fileitem: FileItem, mode: str, meta: MetaBase, :param transferinfo: 整理结果 :param downloader: 下载器 :param download_hash: 种子 hash - :param transfer_history_oper: 复用的历史操作对象,未传时新建 + :param transfer_history_oper: 兼容旧关键字的暂存端口,未传时使用组合根仓储 :return: 落库后的整理记录 """ - oper = _get_transfer_history_writer(transfer_history_oper) + repository = transfer_history_oper or get_transfer_history_repository() media_source, media_id = resolve_media_identity(media=mediainfo) - return oper.add_force( - src=fileitem.path, + return repository.replace(TransferHistoryWrite( + src=_history_source_path(fileitem), src_storage=fileitem.storage, src_fileitem=fileitem.model_dump(), dest=transferinfo.target_item.path if transferinfo.target_item else None, @@ -1149,7 +1392,7 @@ def add_transfer_success(fileitem: FileItem, mode: str, meta: MetaBase, type=mediainfo.type.value, category=mediainfo.category, title=_history_title(meta, mediainfo), - year=mediainfo.year, + year=_history_year(mediainfo.year), media_source=media_source, media_id=media_id, music_type=getattr(mediainfo, "music_type", None), @@ -1164,18 +1407,21 @@ def add_transfer_success(fileitem: FileItem, mode: str, meta: MetaBase, image=mediainfo.get_poster_image(), downloader=downloader, download_hash=download_hash, - status=1, - files=transferinfo.file_list - ) + status=True, + files=transferinfo.file_list, + )) -def add_transfer_fail(fileitem: FileItem, mode: str, meta: MetaBase, - mediainfo: Optional[Union[MediaInfo, MusicInfo]] = None, - transferinfo: Optional[TransferInfo] = None, - downloader: Optional[str] = None, - download_hash: Optional[str] = None, - transfer_history_oper: Optional[TransferHistoryWriter] = None - ) -> Optional[TransferHistoryRecord]: +def add_transfer_fail( + fileitem: FileItem, + mode: str, + meta: MetaBase, + mediainfo: Optional[Union[MediaInfo, MusicInfo]] = None, + transferinfo: Optional[TransferInfo] = None, + downloader: Optional[str] = None, + download_hash: Optional[str] = None, + transfer_history_oper: Optional[TransferHistoryReplacePort] = None, +) -> TransferHistorySnapshot: """ 新增转移失败历史记录。 @@ -1188,14 +1434,14 @@ def add_transfer_fail(fileitem: FileItem, mode: str, meta: MetaBase, :param transferinfo: 整理结果,未进入整理时为 None :param downloader: 下载器 :param download_hash: 种子 hash - :param transfer_history_oper: 复用的历史操作对象,未传时新建 + :param transfer_history_oper: 兼容旧关键字的暂存端口,未传时使用组合根仓储 :return: 落库后的整理记录 """ - oper = _get_transfer_history_writer(transfer_history_oper) + repository = transfer_history_oper or get_transfer_history_repository() if mediainfo and transferinfo: media_source, media_id = resolve_media_identity(media=mediainfo) - his = oper.add_force( - src=fileitem.path, + history = repository.replace(TransferHistoryWrite( + src=_history_source_path(fileitem), src_storage=fileitem.storage, src_fileitem=fileitem.model_dump(), dest=transferinfo.target_item.path if transferinfo.target_item else None, @@ -1205,7 +1451,7 @@ def add_transfer_fail(fileitem: FileItem, mode: str, meta: MetaBase, type=mediainfo.type.value, category=mediainfo.category, title=_history_title(meta, mediainfo), - year=mediainfo.year or meta.year, + year=_history_year(mediainfo.year or meta.year), media_source=media_source, media_id=media_id, music_type=getattr(mediainfo, "music_type", None), @@ -1221,16 +1467,16 @@ def add_transfer_fail(fileitem: FileItem, mode: str, meta: MetaBase, downloader=downloader, download_hash=download_hash, episode_group=mediainfo.episode_group, - status=0, + status=False, errmsg=transferinfo.message or '未知错误', - files=transferinfo.file_list - ) + files=transferinfo.file_list, + )) else: media_source, media_id = resolve_media_identity(media=meta) - his = oper.add_force( + history = repository.replace(TransferHistoryWrite( type=meta.type.value if meta.type else None, title=_history_title(meta), - year=meta.year, + year=_history_year(meta.year), media_source=media_source, media_id=media_id, music_type=MUSIC_ENTITY_RECORDING if isinstance(meta, MetaMusic) else None, @@ -1239,7 +1485,7 @@ def add_transfer_fail(fileitem: FileItem, mode: str, meta: MetaBase, bit_depth=getattr(meta, "bit_depth", None), sample_rate=getattr(meta, "sample_rate", None), bitrate=getattr(meta, "bitrate", None), - src=fileitem.path, + src=_history_source_path(fileitem), src_storage=fileitem.storage, src_fileitem=fileitem.model_dump(), mode=mode, @@ -1247,7 +1493,7 @@ def add_transfer_fail(fileitem: FileItem, mode: str, meta: MetaBase, episodes=meta.episode, downloader=downloader, download_hash=download_hash, - status=0, - errmsg="未识别到媒体信息" - ) - return his + status=False, + errmsg="未识别到媒体信息", + )) + return history diff --git a/app/application/query.py b/app/application/query.py index 0d4a08bae..07445987a 100644 --- a/app/application/query.py +++ b/app/application/query.py @@ -74,11 +74,14 @@ class HistoryQueryPort(Protocol): *, filters: DownloadHistoryFilter, page: QueryPageRequest, - ) -> QueryRows[object]: + ) -> QueryRows[DownloadHistorySnapshot]: """按组合筛选和稳定分页读取下载历史。""" ... - def get_download_history(self, history_id: int) -> object | None: + def get_download_history( + self, + history_id: int, + ) -> DownloadHistorySnapshot | None: """按 ID 读取一条下载历史。""" ... @@ -87,11 +90,14 @@ class HistoryQueryPort(Protocol): *, filters: TransferHistoryFilter, page: QueryPageRequest, - ) -> QueryRows[object]: + ) -> QueryRows[TransferHistorySnapshot]: """按组合筛选和稳定分页读取整理历史。""" ... - def get_transfer_history(self, history_id: int) -> object | None: + def get_transfer_history( + self, + history_id: int, + ) -> TransferHistorySnapshot | None: """按 ID 读取一条整理历史。""" ... diff --git a/app/chain/_transfer.py b/app/chain/_transfer.py index 2b83059ff..ac28040f3 100644 --- a/app/chain/_transfer.py +++ b/app/chain/_transfer.py @@ -26,6 +26,8 @@ from app.application.history import ( DownloadFileSnapshot, DownloadHistoryQueryPort, DownloadHistorySnapshot, + TransferHistoryRepository, + TransferHistorySnapshot, clear_transfer_failures, resolve_history, ) @@ -58,11 +60,9 @@ from app.schemas.types import ( ) from app.schemas.workflow import FileItem -TransferHistory = Any - def _request_durable_transfer_retry( - history: TransferHistory, + history: TransferHistorySnapshot, *, requested_by: str, ) -> Optional[Tuple[bool, str]]: @@ -357,7 +357,10 @@ class FileFilterMixin: return file_meta, file_info @staticmethod - def _is_music_retry_source(history: TransferHistory, src_path: Path) -> bool: + def _is_music_retry_source( + history: TransferHistorySnapshot, + src_path: Path, + ) -> bool: """ 判断重新整理来源是否应走音乐链路:历史类型为音乐,或源路径为音频文件。 """ @@ -367,7 +370,7 @@ class FileFilterMixin: def _recognize_music_retry_media( self, - history: TransferHistory, + history: TransferHistorySnapshot, src_path: Path, ) -> Optional[Union[MusicInfo, MediaInfo]]: """ @@ -457,7 +460,7 @@ class FileFilterMixin: @staticmethod def _is_overwrite_declined(task: TransferTask, transferinfo: TransferInfo, - transferhis: Any) -> bool: + transferhis: TransferHistoryRepository) -> bool: """ 判断本次未入库是否为「同路径已有成功记录 + 覆盖模式裁定不覆盖」。 @@ -470,7 +473,11 @@ class FileFilterMixin: :param transferhis: 历史操作对象 :return: True 表示应保留原成功记录 """ - if not transferinfo.overwrite_skipped or not task.fileitem: + if ( + not transferinfo.overwrite_skipped + or not task.fileitem + or not task.fileitem.path + ): return False try: history = resolve_history( @@ -1184,7 +1191,9 @@ class ManualHistoryMixin: ) @staticmethod - def _is_successful_move_history(history: Optional[TransferHistory]) -> bool: + def _is_successful_move_history( + history: Optional[TransferHistorySnapshot], + ) -> bool: """判断历史记录是否为已成功完成的移动类整理。""" return bool( history @@ -1196,10 +1205,12 @@ class ManualHistoryMixin: def _get_manual_transfer_history( self, fileitem: FileItem, - transfer_history_oper: Any, + transfer_history_oper: TransferHistoryRepository, include_move_dest: bool = False, - ) -> Optional[TransferHistory]: + ) -> Optional[TransferHistorySnapshot]: """查询文件源路径历史,并兼容从成功移动后的目标现址重新整理。""" + if not fileitem.path: + return None # resolve_history 在命中失败记录时会再确认一次有无成功记录, # 避免 get_by_src 无排序导致同源多行时返回哪条不确定 history = resolve_history( @@ -1219,7 +1230,7 @@ class ManualHistoryMixin: def get_manual_transfer_histories( self, fileitems: List[FileItem], - ) -> List[TransferHistory]: + ) -> List[TransferHistorySnapshot]: """ 查询文件或目录命中的成功整理记录,供手动整理界面显示重整状态。 @@ -1227,7 +1238,7 @@ class ManualHistoryMixin: :return: 去重后的成功整理记录 """ transfer_history_oper = get_chain_transfer_history_port() - histories: Dict[int, TransferHistory] = {} + histories: Dict[int, TransferHistorySnapshot] = {} for fileitem in fileitems or []: if not fileitem or not fileitem.path: continue @@ -1259,7 +1270,7 @@ class ManualHistoryMixin: def _request_durable_transfer_retry( self, - history: TransferHistory, + history: TransferHistorySnapshot, *, requested_by: str, ) -> Optional[Tuple[bool, str]]: @@ -1279,8 +1290,8 @@ class ManualHistoryMixin: def _delete_manual_transfer_history( self, - history: TransferHistory, - transfer_history_oper: Any, + history: TransferHistorySnapshot, + transfer_history_oper: TransferHistoryRepository, ) -> Tuple[bool, str]: """删除手动重整历史;非成功移动记录同时清理可能存在的旧目标。""" durable_retry = self._request_durable_transfer_retry( @@ -1295,6 +1306,8 @@ class ManualHistoryMixin: history.dest_fileitem and not ManualHistoryMixin._is_successful_move_history(history) ): + if not isinstance(history.dest_fileitem, dict): + return False, "目标文件历史数据无效" dest_fileitem = FileItem(**history.dest_fileitem) storage_chain = StorageChain() if ( @@ -1601,7 +1614,7 @@ class FailedRetryMixin: :param media_id: 数据源原生 ID,必须与 media_source 成对提供 """ # 查询历史记录 - history: TransferHistory = get_chain_transfer_history_port().get(logid) + history = get_chain_transfer_history_port().get(logid) if not history: logger.error(f"整理记录不存在,ID:{logid}") return False, "整理记录不存在" @@ -1676,12 +1689,16 @@ class FailedRetryMixin: # 删除旧的已整理文件 if history.dest_fileitem: + if not isinstance(history.dest_fileitem, dict): + return False, "目标文件历史数据无效" # 解析目标文件对象 dest_fileitem = FileItem(**history.dest_fileitem) StorageChain().delete_file(dest_fileitem) # 强制整理 if history.src_fileitem: + if not isinstance(history.src_fileitem, dict): + return False, "源文件历史数据无效" state, errmsg = self.do_transfer( fileitem=FileItem(**history.src_fileitem), mediainfo=mediainfo, diff --git a/app/chain/transfer.py b/app/chain/transfer.py index 25a0ba035..58b2ecc30 100755 --- a/app/chain/transfer.py +++ b/app/chain/transfer.py @@ -27,6 +27,8 @@ from app.application.formatting import FormatParser from app.application.history import ( DownloadHistoryQueryPort, DownloadHistorySnapshot, + TransferHistorySnapshot, + TransferHistoryStagingPort, add_transfer_fail, add_transfer_success, clear_transfer_failures, @@ -524,8 +526,12 @@ class TransferChain(FileFilterMixin, ScrapeBatchMixin, EpisodeFormatMixin, Histo if writer is None: raise RuntimeError("旧整理兼容命令缺少 durable 原子写入端口") - def stage_history(staging: Any) -> Any: + def stage_history( + staging: TransferHistoryStagingPort, + ) -> Optional[TransferHistorySnapshot]: """按兼容结果暂存成功、失败或覆盖跳过的唯一历史投影。""" + if not task.fileitem or not task.fileitem.path: + raise ValueError("整理终态缺少源文件路径") if overwrite_declined: return staging.get_success_by_src( task.fileitem.path, @@ -578,7 +584,11 @@ class TransferChain(FileFilterMixin, ScrapeBatchMixin, EpisodeFormatMixin, Histo self.__forget_owned_lease(task.admission_task_id, task.lease_token) @staticmethod - def __transfer_history_id(history: Any) -> Optional[int]: + def __transfer_history_id( + history: Optional[ + Union[TransferHistorySnapshot, TransferSettlementResult] + ], + ) -> Optional[int]: """统一读取旧历史投影和 task-aware 结算结果的历史标识。""" if isinstance(history, TransferSettlementResult): return history.history_id @@ -943,7 +953,7 @@ class TransferChain(FileFilterMixin, ScrapeBatchMixin, EpisodeFormatMixin, Histo # 转移失败 if not transferinfo.success: # 查重闸放行同路径新版本后由 overwrite_mode 判定不覆盖,是一次正常裁决而非故障: - # 媒体库里原有版本仍然在位,写失败记录会用 add_force 顶掉原成功记录,此后该路径 + # 媒体库里原有版本仍然在位,写失败记录会按同源 replace 替换原成功记录,此后该路径 # 永远处于失败态,每个新事件都会重试并重推失败通知。此时保留原记录、不写历史、 # 不发事件与通知、不触发重试,仅把任务置为未入库 history = None @@ -4559,7 +4569,7 @@ class TransferChain(FileFilterMixin, ScrapeBatchMixin, EpisodeFormatMixin, Histo file_items, matched_episode_format_template = self._collect_transfer_candidates( fileitem=fileitem, batch_mtype=batch_mtype, - min_filesize=min_filesize, + min_filesize=min_filesize or 0, epformat=epformat, season=season, continue_callback=continue_callback, diff --git a/app/db/adapters/chain.py b/app/db/adapters/chain.py index 7ea06b1e1..982f31b86 100644 --- a/app/db/adapters/chain.py +++ b/app/db/adapters/chain.py @@ -12,7 +12,6 @@ from sqlalchemy.orm import Session from app.application.chain.events import ( ChainDurableEventWriter, - TransferHistoryRef, TransferResultSettlement, download_added_event_key, snapshot_download_added, @@ -22,8 +21,9 @@ from app.application.chain.events import ( from app.application.history import ( DownloadFileWrite, DownloadHistoryWrite, - TransferHistoryRecord, - TransferHistoryWriter, + TransferHistorySnapshot, + TransferHistoryStagingPort, + TransferHistoryWrite, ) from app.application.outbox import ( DOWNLOAD_ADDED_TOPIC, @@ -37,6 +37,7 @@ from app.application.transfer.execution import ( TransferExecutionState, TransferSettlementResult, ) +from app.db.adapters.history.transfer import project_transfer_history from app.db.adapters.outbox import ( SqlAlchemyOutboxDispatchStore, SqlAlchemyOutboxStager, @@ -50,7 +51,7 @@ from app.db.oper.transfersettlementreceipt import TransferSettlementReceiptOper from app.db.uow import SqlAlchemyUnitOfWork -class _StagingTransferHistoryWriter: +class _StagingTransferHistoryRepository: """让既有历史字段映射复用无提交的 replace 适配器。""" def __init__( @@ -69,46 +70,56 @@ class _StagingTransferHistoryWriter: self, src: str, storage: str | None = None, - ) -> TransferHistoryRecord | None: + ) -> TransferHistorySnapshot | None: """转发按源路径读取。""" - return self._repository.get_by_src(src, storage) + record = self._repository.get_by_src(src, storage) + return project_transfer_history(record) if record is not None else None def get_success_by_src( self, src: str, storage: str | None = None, - ) -> TransferHistoryRecord | None: + ) -> TransferHistorySnapshot | None: """读取成功记录;任务结算时绑定当前任务投影。""" if self._settlement is not None: if self._settlement_revision is None: raise RuntimeError("整理任务结算缺少事务内修订号") - return self._repository.stage_bind_settlement( + record = self._repository.stage_bind_settlement( task_id=self._settlement.task_id, settlement_revision=self._settlement_revision, src=src, storage=storage, ) - return self._repository.get_success_by_src(src, storage) + return ( + project_transfer_history(record) + if record is not None + else None + ) + record = self._repository.get_success_by_src(src, storage) + return project_transfer_history(record) if record is not None else None - def add_force(self, **payload: Any) -> TransferHistoryRecord: - """保持旧端口名,并按是否存在任务身份选择暂存策略。""" + def replace(self, history: TransferHistoryWrite) -> TransferHistorySnapshot: + """按是否存在任务身份选择事务内类型化替换策略。""" + payload = history.to_payload() if self._settlement is not None: if self._settlement_revision is None: raise RuntimeError("整理任务结算缺少事务内修订号") - return self._repository.stage_upsert_by_transfer_task_id( + record = self._repository.stage_upsert_by_transfer_task_id( task_id=self._settlement.task_id, settlement_revision=self._settlement_revision, retain_task_mapping=self._settlement.outcome == "failed", payload=payload, ) - return self._repository.stage_replace_by_src(**payload) + else: + record = self._repository.stage_replace_by_src(**payload) + return project_transfer_history(record) @dataclass(frozen=True, slots=True) class _StagedTransferResult: """保存构造 outbox 所需历史投影及可选任务结算结果。""" - history: TransferHistoryRef + history: TransferHistorySnapshot settlement: TransferSettlementResult | None = None @@ -170,11 +181,14 @@ class TransactionalChainDurableEventWriter(ChainDurableEventWriter): self, *, topic: str | None, - stage_history: Callable[[TransferHistoryWriter], TransferHistoryRecord | None], + stage_history: Callable[ + [TransferHistoryStagingPort], + TransferHistorySnapshot | None, + ], event_payload: dict[str, Any], publish: Callable[[dict[str, Any]], None] | None, settlement: TransferResultSettlement | None = None, - ) -> TransferHistoryRecord | TransferSettlementResult | None: + ) -> TransferHistorySnapshot | TransferSettlementResult | None: """原子写历史、可选任务终态与 intent,再返回稳定投影。""" if topic is None and settlement is None: raise ValueError("无事件 topic 的整理写入必须绑定 durable 任务结算") @@ -208,7 +222,7 @@ class TransactionalChainDurableEventWriter(ChainDurableEventWriter): if expected_revision is not None else None ) - staging = _StagingTransferHistoryWriter( + staging = _StagingTransferHistoryRepository( history_repository, settlement=settlement, settlement_revision=next_revision, @@ -216,13 +230,7 @@ class TransactionalChainDurableEventWriter(ChainDurableEventWriter): history = stage_history(staging) if history is None: raise RuntimeError("整理历史暂存失败,无法登记 durable 结果事件") - projected = TransferHistoryRef( - id=history.id, - status=bool(history.status), - src=history.src, - src_storage=history.src_storage, - src_fileitem=history.src_fileitem, - ) + projected = history if settlement is None: return _StagedTransferResult(history=projected) assert expected_revision is not None @@ -479,7 +487,7 @@ class TransactionalChainDurableEventWriter(ChainDurableEventWriter): @staticmethod def _validate_history_outcome( - history: TransferHistoryRef, + history: TransferHistorySnapshot, settlement: TransferResultSettlement, ) -> None: """拒绝结算终态与历史状态不一致的调用。""" diff --git a/app/db/adapters/history/transfer.py b/app/db/adapters/history/transfer.py new file mode 100644 index 000000000..44e9a956f --- /dev/null +++ b/app/db/adapters/history/transfer.py @@ -0,0 +1,401 @@ +"""整理历史的类型化查询、写入与短事务适配器。""" + +from __future__ import annotations + +from collections.abc import Callable +from contextlib import AbstractAsyncContextManager +from copy import deepcopy +from typing import Optional, TypeVar + +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import Session + +from app.application.history import ( + TransferHistoryMonthlyStatistics, + TransferHistorySnapshot, + TransferHistoryStatisticSnapshot, + TransferHistoryWrite, +) +from app.db.oper.transferhistory import TransferHistoryOper +from app.db.uow import SqlAlchemyAsyncUnitOfWork, SqlAlchemyUnitOfWork +from app.schemas.media import normalize_media_source +from app.schemas.types import MediaSource + +ResultT = TypeVar("ResultT") + + +def project_transfer_history(record: object) -> TransferHistorySnapshot: + """在 Session 内把整理历史 ORM 记录投影为不可变快照。""" + history_id = getattr(record, "id", None) + if not isinstance(history_id, int): + raise ValueError("整理历史记录缺少稳定身份") + media_source = normalize_media_source(getattr(record, "media_source", None)) + media_id_value = getattr(record, "media_id", None) + media_id = str(media_id_value).strip() if media_id_value is not None else None + if not media_source or not media_id or media_id == "0": + media_source = None + media_id = None + return TransferHistorySnapshot( + id=history_id, + transfer_task_id=getattr(record, "transfer_task_id", None), + transfer_settlement_revision=getattr( + record, + "transfer_settlement_revision", + None, + ), + src=getattr(record, "src", None), + src_storage=getattr(record, "src_storage", None), + src_fileitem=deepcopy(getattr(record, "src_fileitem", None)), + dest=getattr(record, "dest", None), + dest_storage=getattr(record, "dest_storage", None), + dest_fileitem=deepcopy(getattr(record, "dest_fileitem", None)), + mode=getattr(record, "mode", None), + type=getattr(record, "type", None), + category=getattr(record, "category", None), + title=getattr(record, "title", None), + year=getattr(record, "year", None), + media_source=media_source, + media_id=media_id, + music_type=getattr(record, "music_type", None), + total_tracks=getattr(record, "total_tracks", None), + audio_format=getattr(record, "audio_format", None), + audio_lossless=getattr(record, "audio_lossless", None), + bit_depth=getattr(record, "bit_depth", None), + sample_rate=getattr(record, "sample_rate", None), + bitrate=getattr(record, "bitrate", None), + seasons=getattr(record, "seasons", None), + episodes=getattr(record, "episodes", None), + image=getattr(record, "image", None), + downloader=getattr(record, "downloader", None), + download_hash=getattr(record, "download_hash", None), + status=bool(getattr(record, "status", False)), + errmsg=getattr(record, "errmsg", None), + date=getattr(record, "date", None), + files=deepcopy(getattr(record, "files", None)), + episode_group=getattr(record, "episode_group", None), + ) + + +class TransactionalTransferHistoryRepository: + """为整理、Agent、工作流和历史接口创建短生命周期 Session。""" + + def __init__( + self, + *, + sync_session: Callable[[], Session], + async_session: Callable[[], AbstractAsyncContextManager[AsyncSession]], + ) -> None: + """保存由启动组合根提供的同步与异步 Session 工厂。""" + self._sync_session = sync_session + self._async_session = async_session + + def _read(self, operation: Callable[[TransferHistoryOper], ResultT]) -> ResultT: + """在独立同步 Session 中执行一次只读操作。""" + session = self._sync_session() + try: + return operation(TransferHistoryOper(session)) + finally: + session.close() + + def _write(self, operation: Callable[[TransferHistoryOper], ResultT]) -> ResultT: + """在独立同步 Session 和单一 UoW 中执行一次写操作。""" + session = self._sync_session() + unit_of_work = SqlAlchemyUnitOfWork(session) + try: + result = operation(TransferHistoryOper(session)) + unit_of_work.commit() + return result + except Exception: + unit_of_work.rollback() + raise + finally: + session.close() + + def get(self, history_id: int) -> Optional[TransferHistorySnapshot]: + """按主键返回整理历史快照。""" + return self._read( + lambda repository: ( + project_transfer_history(record) + if (record := repository.get(history_id)) is not None + else None + ) + ) + + def get_by_src( + self, + src: str, + storage: Optional[str] = None, + ) -> Optional[TransferHistorySnapshot]: + """按源路径和可选存储返回最新历史快照。""" + return self._read( + lambda repository: ( + project_transfer_history(record) + if (record := repository.get_by_src(src, storage)) is not None + else None + ) + ) + + def get_success_by_src( + self, + src: str, + storage: Optional[str] = None, + ) -> Optional[TransferHistorySnapshot]: + """按源路径和可选存储返回最新成功历史快照。""" + return self._read( + lambda repository: ( + project_transfer_history(record) + if (record := repository.get_success_by_src(src, storage)) is not None + else None + ) + ) + + def get_by_dest( + self, + dest: str, + storage: Optional[str] = None, + ) -> Optional[TransferHistorySnapshot]: + """按目标路径和可选存储返回最新历史快照。""" + return self._read( + lambda repository: ( + project_transfer_history(record) + if (record := repository.get_by_dest(dest, storage)) is not None + else None + ) + ) + + def get_by_transfer_task_id( + self, + *, + task_id: str, + ) -> Optional[TransferHistorySnapshot]: + """按 durable 整理任务标识返回终态历史快照。""" + return self._read( + lambda repository: ( + project_transfer_history(record) + if ( + record := repository.get_by_transfer_task_id(task_id=task_id) + ) is not None + else None + ) + ) + + def get_by_media_identity( + self, + media_source: MediaSource, + media_id: str, + mtype: Optional[str] = None, + ) -> Optional[TransferHistorySnapshot]: + """按规范媒体身份和可选媒体类型返回历史快照。""" + return self._read( + lambda repository: ( + project_transfer_history(record) + if ( + record := repository.get_by_media_identity( + media_source, + media_id, + mtype, + ) + ) is not None + else None + ) + ) + + def list_success_by_src( + self, + src: str, + storage: Optional[str] = None, + recursive: bool = False, + ) -> list[TransferHistorySnapshot]: + """按源路径返回成功整理历史快照。""" + return self._read( + lambda repository: [ + project_transfer_history(record) + for record in repository.list_success_by_src( + src, + storage, + recursive, + ) + ] + ) + + def list_success_move_by_dest( + self, + dest: str, + storage: Optional[str] = None, + recursive: bool = False, + ) -> list[TransferHistorySnapshot]: + """按目标路径返回成功移动历史快照。""" + return self._read( + lambda repository: [ + project_transfer_history(record) + for record in repository.list_success_move_by_dest( + dest, + storage, + recursive, + ) + ] + ) + + def list_by_hash(self, download_hash: str) -> list[TransferHistorySnapshot]: + """按下载任务 Hash 返回历史快照。""" + return self._read( + lambda repository: [ + project_transfer_history(record) + for record in repository.list_by_hash(download_hash) + ] + ) + + async def async_get( + self, + history_id: int, + ) -> Optional[TransferHistorySnapshot]: + """异步按主键返回整理历史快照。""" + async with self._async_session() as session: + record = await TransferHistoryOper(session).async_get(history_id) + return project_transfer_history(record) if record is not None else None + + async def async_list_by_title( + self, + title: str, + page: int = 1, + count: int = 30, + status: Optional[bool] = None, + wildcard: bool = False, + ) -> list[TransferHistorySnapshot]: + """异步按标题或路径分页返回历史快照。""" + async with self._async_session() as session: + records = await TransferHistoryOper(session).async_list_by_title( + title, + page, + count, + status, + wildcard, + ) + return [project_transfer_history(record) for record in records] + + async def async_list_by_page( + self, + page: int = 1, + count: int = 30, + status: Optional[bool] = None, + ) -> list[TransferHistorySnapshot]: + """异步按时间倒序分页返回历史快照。""" + async with self._async_session() as session: + records = await TransferHistoryOper(session).async_list_by_page( + page, + count, + status, + ) + return [project_transfer_history(record) for record in records] + + async def async_count(self, status: Optional[bool] = None) -> int: + """异步统计指定状态的整理历史数量。""" + async with self._async_session() as session: + count = await TransferHistoryOper(session).async_count(status) + return int(count or 0) + + async def async_count_by_title( + self, + title: str, + status: Optional[bool] = None, + wildcard: bool = False, + ) -> int: + """异步统计匹配标题或路径的整理历史数量。""" + async with self._async_session() as session: + count = await TransferHistoryOper(session).async_count_by_title( + title, + status, + wildcard, + ) + return int(count or 0) + + async def async_statistic( + self, + days: int = 7, + ) -> list[TransferHistoryStatisticSnapshot]: + """异步返回最近若干天的每日整理数量。""" + async with self._async_session() as session: + rows = await TransferHistoryOper(session).async_statistic(days) + return [ + TransferHistoryStatisticSnapshot( + date=str(row[0]), + count=int(row[1]), + ) + for row in rows + ] + + def monthly_media_statistics(self) -> TransferHistoryMonthlyStatistics: + """返回本月电影、剧集、单集和音乐整理数量。""" + movies, tv_shows, episodes, music = self._read( + lambda repository: repository.monthly_media_statistics() + ) + return TransferHistoryMonthlyStatistics( + movies=movies, + tv_shows=tv_shows, + episodes=episodes, + music=music, + ) + + def replace(self, history: TransferHistoryWrite) -> TransferHistorySnapshot: + """在独立事务中替换同源历史并返回快照。""" + return self._write( + lambda repository: project_transfer_history( + repository.stage_replace_by_src(**history.to_payload()) + ) + ) + + def delete(self, history_id: int) -> None: + """在独立事务中删除一条没有 durable 任务映射的旧历史。""" + self._write(lambda repository: repository.stage_delete(history_id)) + + async def async_delete(self, history_id: int) -> None: + """在独立异步事务中删除一条没有 durable 任务映射的旧历史。""" + async with self._async_session() as session: + unit_of_work = SqlAlchemyAsyncUnitOfWork(session) + try: + await TransferHistoryOper(session).async_stage_delete(history_id) + await unit_of_work.commit() + except Exception: + await unit_of_work.rollback() + raise + + def truncate(self) -> None: + """在独立事务中清空没有 durable 任务映射的历史。""" + self._write(lambda repository: repository.stage_truncate()) + + def update_download_hash(self, history_id: int, download_hash: str) -> None: + """在独立事务中补充整理历史的下载任务 Hash。""" + self._write( + lambda repository: repository.stage_update_download_hash( + history_id, + download_hash, + ) + ) + + +class SessionTransferHistoryRepository: + """把 API 请求持有的同步 Session 适配为整理历史查询和暂存端口。""" + + def __init__(self, session: Session) -> None: + """保存由请求依赖独占的同步数据库 Session。""" + self._session = session + + def get(self, history_id: int) -> Optional[TransferHistorySnapshot]: + """在请求 Session 内读取并立即投影整理历史。""" + record = TransferHistoryOper(self._session).get(history_id) + return project_transfer_history(record) if record is not None else None + + def replace(self, history: TransferHistoryWrite) -> TransferHistorySnapshot: + """在请求 Session 内暂存同源替换并返回冻结快照。""" + record = TransferHistoryOper(self._session).stage_replace_by_src( + **history.to_payload() + ) + return project_transfer_history(record) + + def stage_delete(self, history_id: int) -> None: + """在请求 Session 内暂存一条旧整理历史删除。""" + TransferHistoryOper(self._session).stage_delete(history_id) + + def stage_truncate(self) -> None: + """在请求 Session 内暂存全部旧整理历史删除。""" + TransferHistoryOper(self._session).stage_truncate() diff --git a/app/db/oper/__init__.py b/app/db/oper/__init__.py index b72cb87ac..1b8c0808a 100644 --- a/app/db/oper/__init__.py +++ b/app/db/oper/__init__.py @@ -31,7 +31,6 @@ if TYPE_CHECKING: from app.db.oper.subscribe import SubscribeOper from app.db.oper.subscribehistory import SubscribeHistoryOper from app.db.oper.systemconfig import SystemConfigOper - from app.db.oper.transferhistory import TransferHistoryOper from app.db.oper.transferpending import TransferPendingOper from app.db.oper.transfersettlementreceipt import TransferSettlementReceiptOper from app.db.oper.user import UserOper @@ -50,7 +49,6 @@ _OPER_MODULES = { "SubscribeHistoryOper": "subscribehistory", "SubscribeOper": "subscribe", "SystemConfigOper": "systemconfig", - "TransferHistoryOper": "transferhistory", "TransferPendingOper": "transferpending", "TransferSettlementReceiptOper": "transfersettlementreceipt", "UserConfigOper": "userconfig", @@ -92,7 +90,6 @@ __all__ = [ "SubscribeHistoryOper", "SubscribeOper", "SystemConfigOper", - "TransferHistoryOper", "TransferPendingOper", "TransferSettlementReceiptOper", "UserConfigOper", diff --git a/app/db/oper/transferhistory.py b/app/db/oper/transferhistory.py index 4c1049c2d..0530d7fc2 100644 --- a/app/db/oper/transferhistory.py +++ b/app/db/oper/transferhistory.py @@ -414,6 +414,17 @@ class TransferHistoryOper(DbOper): ) ) + async def async_stage_delete(self, historyid: int) -> None: + """在调用方异步事务内暂存旧整理记录删除。""" + if not isinstance(self._db, AsyncSession): + raise RuntimeError("整理历史异步删除需要调用方提供异步 Session") + await self._db.execute( + sqlalchemy_delete(TransferHistory).where( + TransferHistory.id == historyid, + TransferHistory.transfer_task_id.is_(None), + ) + ) + def stage_truncate(self) -> None: """暂存旧整理记录删除,只保留当前失败任务历史。""" self._db.execute( @@ -449,49 +460,13 @@ class TransferHistoryOper(DbOper): ) ) - def add_force(self, **kwargs) -> Optional[TransferHistory]: - """ - 新增转移历史,并以同源存储的记录为准替换旧记录。 - """ - # 文件项的默认存储是 local;归一化旧调用传入的 None,确保运行时语义与 - # (src, src_storage) 唯一索引一致。 - kwargs["src_storage"] = kwargs.get("src_storage") or "local" - # 旧记录的清理交给 replace_by_src 按 (src, src_storage) 处理: - # 仅按 src 删除会连带删掉其他存储下同路径的记录。 - kwargs.update({ - "date": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()) - }) - def stage(session: Session) -> Optional[TransferHistory]: - """在同一事务替换记录并返回兼容查询投影。""" - TransferHistory.replace_by_src(session, **kwargs) - return TransferHistory.get_by_src( - session, - kwargs.get("src"), - kwargs["src_storage"], - ) - - # 保持 add_force 的既有返回契约:返回可被调用方安全读取字段的查询结果, - # 而非事务提交后可能已脱离会话的新建实例。 - return self._execute_sync_write(stage) - def stage_replace_by_src(self, **kwargs) -> TransferHistory: """在调用方事务内按源路径替换整理历史并返回已分配 ID 的新记录。""" if not isinstance(self._db, Session): raise RuntimeError("整理历史事务写入需要调用方提供同步 Session") kwargs["src_storage"] = kwargs.get("src_storage") or "local" kwargs["date"] = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()) - self._db.execute( - sqlalchemy_delete(TransferHistory).where( - TransferHistory.src == kwargs.get("src"), - TransferHistory.src_storage == kwargs["src_storage"], - TransferHistory.transfer_task_id.is_(None), - ) - ) - self._db.flush() - history = TransferHistory(**kwargs) - self._db.add(history) - self._db.flush() - return history + return TransferHistory.replace_by_src(self._db, **kwargs) def stage_upsert_by_transfer_task_id( self, @@ -546,6 +521,20 @@ class TransferHistoryOper(DbOper): ) ) + def stage_update_download_hash( + self, + historyid: int, + download_hash: str, + ) -> None: + """在调用方事务内暂存整理历史下载任务 Hash 更新。""" + if not isinstance(self._db, Session): + raise RuntimeError("整理历史同步更新需要调用方提供同步 Session") + TransferHistory.update_download_hash( + self._db, + historyid, + download_hash, + ) + def list_by_date(self, date: str) -> List[TransferHistory]: """ 查询某时间之后的转移历史 diff --git a/app/monitor/dispatcher.py b/app/monitor/dispatcher.py index dd7a2e1a0..375c0d6d6 100644 --- a/app/monitor/dispatcher.py +++ b/app/monitor/dispatcher.py @@ -4,23 +4,23 @@ from pathlib import Path from threading import Lock from typing import Any, Dict, List, Optional, Tuple -from app.chain.transfer import TransferChain -from app.runtime.cache import TTLCache +from app.adapters.system.fsproxy import fsproxy from app.application.directory import DirectoryHelper from app.application.history import ( HistoryGateAction, describe_history_gate, evaluate_history_gate, - get_transfer_history_port, + get_transfer_history_repository, is_skip_action, max_failed_retries, resolve_history, ) +from app.chain.transfer import TransferChain +from app.runtime.cache import TTLCache from app.runtime.log import logger -from app.adapters.system.fsproxy import fsproxy -from app.schemas.workflow import FileItem -from app.schemas.types import MediaType from app.runtime.settings import get_runtime_setting +from app.schemas.types import MediaType +from app.schemas.workflow import FileItem class TransferDispatcher: @@ -111,7 +111,7 @@ class TransferDispatcher: """ try: history = resolve_history(src_path, storage=storage, - transfer_history_oper=get_transfer_history_port()) + transfer_history_oper=get_transfer_history_repository()) except Exception as err: logger.error(f"查询整理历史失败: {src_path} - {err}") return None diff --git a/app/runtime/compat/manifest.py b/app/runtime/compat/manifest.py index 549779d17..451706079 100644 --- a/app/runtime/compat/manifest.py +++ b/app/runtime/compat/manifest.py @@ -775,6 +775,11 @@ _MESSAGE_NOTIFICATION_SYMBOL_ALIASES: Dict[str, SymbolAlias] = { SYMBOL_ALIASES: Dict[str, Dict[str, SymbolAlias]] = { "app.db.oper": { + "TransferHistoryOper": SymbolAlias( + target_module="app.sdk._legacy.history", + target_name="TransferHistoryOper", + replacement="app.application.history.TransferHistoryRepository", + ), "WorkflowOper": SymbolAlias( target_module="app.sdk._legacy.workflow", target_name="WorkflowOper", diff --git a/app/sdk/_legacy/history.py b/app/sdk/_legacy/history.py index ccdaf6d14..fd07e028a 100644 --- a/app/sdk/_legacy/history.py +++ b/app/sdk/_legacy/history.py @@ -1,15 +1,43 @@ """把旧整理历史 Oper 的业务写入方法转交给应用服务。""" -from typing import Any, Optional +from typing import Any, Optional, cast -from app.application.history import add_transfer_fail, add_transfer_success +from sqlalchemy.orm import Session + +from app.application.history import ( + TransferHistoryReplacePort, + TransferHistorySnapshot, + TransferHistoryWrite, + add_transfer_fail, + add_transfer_success, +) +from app.db.adapters.history.transfer import ( + SessionTransferHistoryRepository, + TransactionalTransferHistoryRepository, +) from app.db.oper.transferhistory import TransferHistoryOper as CanonicalTransferHistoryOper +from app.db.session import SessionFactory, async_session_scope from app.domain.context import MediaInfo, MusicInfo from app.domain.meta.metabase import MetaBase from app.schemas.file import FileItem from app.schemas.transfer import TransferInfo +class _LegacyTransferHistoryStager: + """把旧 Oper 写入口适配到 Application 类型化暂存合同。""" + + def __init__(self, owner: "TransferHistoryOper") -> None: + """保存兼容 Oper。""" + self._owner = owner + + def replace(self, history: TransferHistoryWrite) -> TransferHistorySnapshot: + """把类型化写入转回旧 add_force ABI。""" + return cast( + TransferHistorySnapshot, + self._owner.add_force(**history.to_payload()), + ) + + class TransferHistoryOper(CanonicalTransferHistoryOper): """继承新的查询接口,并保留旧的成功/失败历史写入方法。""" @@ -36,7 +64,7 @@ class TransferHistoryOper(CanonicalTransferHistoryOper): transferinfo=transferinfo, downloader=downloader, download_hash=download_hash, - transfer_history_oper=self, + transfer_history_oper=_LegacyTransferHistoryStager(self), ) def add_fail( @@ -62,8 +90,25 @@ class TransferHistoryOper(CanonicalTransferHistoryOper): transferinfo=transferinfo, downloader=downloader, download_hash=download_hash, - transfer_history_oper=self, + transfer_history_oper=_LegacyTransferHistoryStager(self), ) + def add_force( # type: ignore[no-untyped-def] + self, + **kwargs, + ) -> Optional[TransferHistorySnapshot]: + """按旧动态字段签名替换同源历史,并返回脱离 Session 的快照。""" + payload = dict(kwargs) + payload.pop("date", None) + repository: TransferHistoryReplacePort + if isinstance(getattr(self, "_db", None), Session): + repository = SessionTransferHistoryRepository(self._db) + else: + repository = TransactionalTransferHistoryRepository( + sync_session=SessionFactory, + async_session=async_session_scope, + ) + return repository.replace(TransferHistoryWrite(**payload)) + __all__ = ["TransferHistoryOper"] diff --git a/app/startup/composition/context.py b/app/startup/composition/context.py index 4e074359c..5c11e34a9 100644 --- a/app/startup/composition/context.py +++ b/app/startup/composition/context.py @@ -5,6 +5,10 @@ from dataclasses import dataclass, field from typing import Protocol from app.application.configuration import RuntimeConfiguration, RuntimeSettingsService +from app.application.history import ( + TransferHistoryMutationRepository, + TransferHistoryRepository, +) from app.application.messaging.chat import ( AgentChatPersistenceService, AsyncAgentChatRepository, @@ -92,6 +96,14 @@ class RepositoryFactory(Protocol): ... +class TransferHistoryRepositoryFactory(Protocol): + """由请求 Session 构造整理历史事务仓储的工厂。""" + + def __call__(self, session: object) -> TransferHistoryMutationRepository: + """绑定请求会话并返回整理历史暂存仓储。""" + ... + + class StandaloneRepositoryFactory(Protocol): """构造自持有兼容事务边界的领域仓储。""" @@ -150,7 +162,8 @@ class HistoryRuntime: """下载、整理、媒体服务器与 Dashboard 领域的数据工厂。""" download_repository: RepositoryFactory - transfer_repository: RepositoryFactory + transfer_repository: TransferHistoryRepository + transfer_mutation_repository: TransferHistoryRepositoryFactory media_server_repository: RepositoryFactory diff --git a/app/startup/initializers/modules.py b/app/startup/initializers/modules.py index cc065e483..5aecd5995 100644 --- a/app/startup/initializers/modules.py +++ b/app/startup/initializers/modules.py @@ -61,7 +61,7 @@ from app.application.configuration import ( get_configured_system_config, ) from app.application.database import configure_database_governance -from app.application.history import configure_transfer_history_provider +from app.application.history import configure_transfer_history_repository from app.application.image import configure_wallpaper_providers from app.application.messaging.agent import ( dispatch_web_agent_message_event, @@ -125,6 +125,10 @@ from app.db.adapters.history.download import ( SessionDownloadHistoryRepository, TransactionalDownloadHistoryRepository, ) +from app.db.adapters.history.transfer import ( + SessionTransferHistoryRepository, + TransactionalTransferHistoryRepository, +) from app.db.adapters.mediaserver import TransactionalMediaServerRepository from app.db.adapters.outbox import ( SqlAlchemyAsyncOutboxDispatchStore, @@ -157,7 +161,6 @@ from app.db.oper.site import SiteOper from app.db.oper.subscribe import SubscribeOper from app.db.oper.subscribehistory import SubscribeHistoryOper from app.db.oper.systemconfig import SystemConfigOper -from app.db.oper.transferhistory import TransferHistoryOper from app.db.oper.workflow import WorkflowOper from app.db.session import ( SessionFactory, @@ -855,7 +858,6 @@ async def init_modules() -> HostRuntime: "site": SiteOper, "subscribe": SubscribeOper, "subscribe_history": SubscribeHistoryOper, - "transfer_history": TransferHistoryOper, "user": SqlAlchemyUserRepository, "workflow": WorkflowOper, }, @@ -886,6 +888,10 @@ async def init_modules() -> HostRuntime: sync_session=SessionFactory, async_session=async_session_scope, ) + transfer_history_repository = TransactionalTransferHistoryRepository( + sync_session=SessionFactory, + async_session=async_session_scope, + ) agent_chat_persistence = AgentChatPersistenceService( repository=lambda session: AgentChatOper(session), async_executor=database_worker, @@ -914,7 +920,8 @@ async def init_modules() -> HostRuntime: messaging=MessagingRuntime(repository=MessageOper), history=HistoryRuntime( download_repository=SessionDownloadHistoryRepository, - transfer_repository=TransferHistoryOper, + transfer_repository=transfer_history_repository, + transfer_mutation_repository=SessionTransferHistoryRepository, media_server_repository=MediaServerOper, ), site=SiteRuntime(repository=SiteOper), @@ -954,7 +961,7 @@ async def init_modules() -> HostRuntime: ), subscribe=lambda: SubscribeOper(), download_history=lambda: download_history_repository, - transfer_history=lambda: TransferHistoryOper(), + transfer_history=lambda: transfer_history_repository, transfer_pending=lambda: TransactionalTransferAdmissionRepository( SessionFactory ), @@ -998,7 +1005,7 @@ async def init_modules() -> HostRuntime: ) ) configure_passkey_service(PasskeyService(repository=PassKeyOper())) - configure_transfer_history_provider(lambda: TransferHistoryOper()) + configure_transfer_history_repository(lambda: transfer_history_repository) configure_site_query_service(SiteQueryService(repository=TransactionalSiteRepository( sync_session=SessionFactory, async_session=async_session_scope, @@ -1017,7 +1024,7 @@ async def init_modules() -> HostRuntime: ), subscribe=lambda: SubscribeOper(), subscribe_history=lambda: SubscribeHistoryOper(), - transfer_history=lambda: TransferHistoryOper(), + transfer_history=lambda: transfer_history_repository, download_history=lambda: download_history_repository, plugin_data=lambda: PluginDataOper(), ) diff --git a/app/workflow/actions/transfer_file.py b/app/workflow/actions/transfer_file.py index 0adc58ffc..00b7ec2e5 100644 --- a/app/workflow/actions/transfer_file.py +++ b/app/workflow/actions/transfer_file.py @@ -84,6 +84,9 @@ class TransferFileAction(BaseAction): if not fileitem: logger.info(f"文件 {download.path} 不存在") continue + if not fileitem.path: + logger.warn("工作流文件缺少可查询的源路径,跳过") + continue transferd = transferhis.get_by_src(fileitem.path, storage=fileitem.storage) if transferd: # 已经整理过的文件不再整理 @@ -102,6 +105,9 @@ class TransferFileAction(BaseAction): for fileitem in copy.deepcopy(context.fileitems): if not check_continue(): break + if not fileitem.path: + logger.warn("工作流文件缺少可查询的源路径,跳过") + continue # 检查缓存 cache_key = f"{fileitem.path}" if self.check_cache(workflow_id, cache_key): diff --git a/docs/architecture-optimization-checklist.md b/docs/architecture-optimization-checklist.md index 3e1945098..ab679ad7c 100644 --- a/docs/architecture-optimization-checklist.md +++ b/docs/architecture-optimization-checklist.md @@ -69,7 +69,7 @@ MoviePilot V3 已经形成较清晰的模块化单体:`foundation`、`domain` | 指标 | 当前值 | 解释 | |---|---:|---| -| 宿主 Python 模块 / 内部依赖边 | 856 / 7,006 | `dependency-baseline.json` 当前快照 | +| 宿主 Python 模块 / 内部依赖边 | 857 / 7,024 | `dependency-baseline.json` 当前快照 | | 非平凡 SCC | 2 | 新增 Chain 包根环;另一个是隔离的 29 模块 TMDB 移植包环 | | 跨层 DB 边界债务 | 0 | Application、Chain、API、Agent、Runtime、Workflow 到 DB 的受控债务均为零 | | Model/Oper 事务债务 | 0 | 自建 Session、自动事务装饰器、直接 commit/rollback 等基线均为零 | @@ -77,9 +77,9 @@ MoviePilot V3 已经形成较清晰的模块化单体:`foundation`、`domain` | Event Contract | 53 | 均已有 payload model,但当前全部是 diagnostic enforcement | | Python 源码量 | 约 271,400 行 | 60 个文件超过 1,000 行,14 个超过 2,000 行 | | 长方法 | 281 个超过 80 行 | 67 个超过 150 行,23 个超过 250 行;大量是私有方法 | -| 全量 mypy 历史债务 | 11,808 / 596 文件 | strict frontier 当前覆盖 41 个文件,本批迁移路径的类型债务已清零 | -| Ruff 历史诊断 | 840 | 低水位门禁通过,但规则集只覆盖 `E4/E7/E9/F/I` | -| 覆盖率低水位 | Application 79.39%,Domain 79.29% | Chain、Runtime、Agent、Adapter、Startup 未进入包级覆盖率门禁 | +| 全量 mypy 历史债务 | 11,763 / 596 文件 | strict frontier 当前覆盖 41 个文件,本批迁移路径的类型债务已清零 | +| Ruff 历史诊断 | 834 | 低水位门禁通过,但规则集只覆盖 `E4/E7/E9/F/I` | +| 覆盖率低水位 | Application 79.47%,Domain 79.29% | Chain、Runtime、Agent、Adapter、Startup 未进入包级覆盖率门禁 | ### 3.3 热点文件 @@ -268,11 +268,11 @@ MoviePilot V3 已经形成较清晰的模块化单体:`foundation`、`domain` **问题与证据** -- `app/application/chain/data.py` 的 Site/Subscribe/TransferHistory factory 仍是 `Any`; +- `app/application/chain/data.py` 的 Site/Subscribe factory 仍是 `Any`; `app/application/agentdata.py` 仍通过 `__dict__.update()` 动态组装未迁移端口。 - `app/startup/initializers/modules.py` 仍向生产 Chain/Agent 注入部分无 Session Oper。 - 无 Session Oper 会为单次调用独立创建事务;一个业务操作的“查询后更新”可能被拆成多个事务。 -- Workflow、User 和 DownloadHistory 已迁入冻结 DTO 与 adapter-owned Session 投影; +- Workflow、User、DownloadHistory 和 TransferHistory 已迁入冻结 DTO 与 adapter-owned Session 投影; 剩余 Chain/Agent raw data port 仍可返回 `Any`/ORM,Subscription mutation 内部也消费 ORM, 因而尚有 Session 生命周期外 detached/lazy-load 风险。 - S1-L2 由 `b4f873654`、`a01a35bcb` 交付;精确 head SHA 的 Unit Tests `33098869736` 与 @@ -295,8 +295,9 @@ MoviePilot V3 已经形成较清晰的模块化单体:`foundation`、`domain` - [x] User Chain/Agent/认证查询改用冻结 `UserSnapshot`/`UserAuthSnapshot`;创建、更名和 删除由请求级 UoW 原子提交,用户名唯一约束、最后一个启用超级管理员保护与 UserConfig/PassKey 级联约束共同守住身份聚合。 -- [x] DownloadHistory 查询和写入改用冻结 DTO、typed Port 与短 Session adapter,删除在单一 - UoW 中处理;TransferHistory 仍是 raw port,History 整体仍在执行中。 +- [x] DownloadHistory 与 TransferHistory 查询和写入改用深度冻结 DTO、typed Port 与短 + Session adapter;请求级删除和 durable 结算在各自单一 UoW 中处理,canonical 调用方不再 + 接收 raw Oper/ORM,旧动态写入只保留在 SDK Legacy/Compat。 - [ ] `ChainDataPorts`/`AgentDataPorts` 可暂时保留为兼容聚合器,但字段必须显式、可类型检查。 - [ ] 以一个业务纵切面迁移并验证后,再迁移下一组,禁止一次替换所有 Oper。 - [ ] 增加 AST 门禁,禁止向 `ChainDataPorts`、`AgentDataPorts` 和新的 canonical use-case service @@ -304,10 +305,10 @@ MoviePilot V3 已经形成较清晰的模块化单体:`foundation`、`domain` **后续顺序** -1. 完成 TransferHistory,收口 History 剩余半边。 -2. Chain/Agent 的 Subscribe/Site raw port。 +1. Chain/Agent 的 Site raw port。 +2. Chain/Workflow/interaction 的 Subscription raw port。 3. Subscription mutation 与站点、规则组引用更新。 -4. Agent 数据能力。 +4. Agent/Transfer locator gate。 **验收** diff --git a/docs/architecture-overview.md b/docs/architecture-overview.md index e778e62a2..05a37dfba 100644 --- a/docs/architecture-overview.md +++ b/docs/architecture-overview.md @@ -705,7 +705,7 @@ flowchart LR | 指标 | 当前值 | |---|---:| | Python 模块 | 856 | -| 内部导入边 | 7,006 | +| 内部导入边 | 7,024 | | 非平凡 SCC | 2(`ARCH-107` 临时 Chain 包根环;精确 containment 的 TMDB 移植包环) | | Direct egress | 66(12 条待迁移债务,54 条精确 containment) | | Module Contract V2 spec | 217(其中 215 个进入 `run_module` 观察面) | diff --git a/docs/architecture-refactor-roadmap.md b/docs/architecture-refactor-roadmap.md index 09bafa45a..074bf3e5d 100644 --- a/docs/architecture-refactor-roadmap.md +++ b/docs/architecture-refactor-roadmap.md @@ -104,7 +104,7 @@ canonical 主程序;兼容只经统一 Compat/SDK 门面提供。 | S1-L3.2.1 Registry hygiene | `DELIVERED` | S1-L3.1 | `ac7a20132`:删除零消费者 PortProxy/动态转发和 `ChainRuntimeContext.data_ports` 伪注入;Workflow 退出 Chain registry,只保留 Application owner 单一配置入口;Unit Tests `33120205586`、Pylint `33120205581` 全绿 | | S1-L3.3 DownloadFailure/MediaServer | `DELIVERED` | S1-L3.2 | `5fb62108a`:两组 raw factory 已替换为冻结 DTO/typed Port;失败冷却在 Session 内投影,媒体库查询只返回标量且每个 upsert/cleanup 独立短事务,远端枚举不持有 Session;旧 Oper 与插件可见 Chain ABI 保持不变;Unit Tests `33127544925`、Pylint `33127544927` 全绿,Application 覆盖率低水位提升至 `78.95%` | | S1-L3.4 User | `VERIFIED` | S1-L3.3 | User Chain/Agent/认证改用冻结 typed snapshot;创建、更名、删除与最后一个启用超级管理员保护归并到单 UoW;用户名唯一索引及 UserConfig/PassKey 级联迁移落地,UserConfig 在 commit 后持写锁重载数据库事实源并发布内存快照 | -| S1-L3.5 History | `IN_PROGRESS` | S1-L3.4 | DownloadHistory 已迁入冻结 DTO、typed query/write Port 与短 Session adapter,下载历史删除不再拆成 fail-open 双事务;TransferHistory 半边尚未完成,本项不宣称整体交付 | +| S1-L3.5 History | `VERIFIED` | S1-L3.4 | DownloadHistory 与 TransferHistory 均已迁入深度冻结 DTO、typed query/write/staging Port 与短 Session adapter;请求级删除和 durable 结算各自保持单一 UoW,canonical 调用方不再接收 raw Oper/ORM,旧写入 ABI 只由 SDK Legacy/Compat 提供 | | S1-L3.6 Site | `PLANNED` | S1-L3.5 | 复用 Site query/health,补齐同步 typed command,Session 内完成 DTO 投影 | | S1-L3.7 Subscription | `PLANNED` | S1-L3.6 | Chain/Workflow/interaction 全部消费 typed query/command;完成后进入 S1-L4 原子事务收口 | | S1-L3.8 Agent/Transfer locator gate | `PLANNED` | S1-L3.7 | 删除 AgentDataPorts 与 Chain locator 跨层泄漏,AST 门禁确认 canonical 无 raw getter/Oper/Any | @@ -153,7 +153,7 @@ canonical 主程序;兼容只经统一 Compat/SDK 门面提供。 | S4-L2 Event strict contract | `PLANNED` | S0-L2.6,S1-L6 | 宿主事件输入/输出按风险 strict,诊断例外只属于第三方插件兼容 | | S4-L3 Complexity v2 | `PLANNED` | S3 | 私有方法、class/file、圈复杂度进入门禁;所有超限通过职责拆分归零 | | S4-L4 全量 mypy 清零 | `PLANNED` | S3,S4-L1,S4-L2 | `mypy-baseline.json` 归零并删除债务接受路径,全宿主 strict 类型通过 | -| S4-L5 Ruff 治理债务清零 | `PLANNED` | S3 | 当前受控 840 条诊断归零,规则集扩展经过独立审查且新增诊断为零 | +| S4-L5 Ruff 治理债务清零 | `PLANNED` | S3 | 当前受控 834 条诊断归零,规则集扩展经过独立审查且新增诊断为零 | | S4-L6 Coverage/并发/质量证据 | `PLANNED` | S3,S4-L1,S4-L2 | 高风险包纳入 coverage;raw concurrency 分类清零;Module Quality 有真实 evidence test | ### S5:Plugin、Agent、Domain、Startup 与最终收口 diff --git a/docs/rules/05-architecture.md b/docs/rules/05-architecture.md index 87a947770..1d4047534 100644 --- a/docs/rules/05-architecture.md +++ b/docs/rules/05-architecture.md @@ -64,7 +64,7 @@ to make the directory tree look symmetrical. | `app/application/subscription/` | Subscription use cases: `write.py` owns media-to-row translation and the write port; `contract.py` owns shared metadata/media-key projection; query, mutation, deletion, identity and search stay in their single-word modules | | `app/application/search/` | Search state and later search-plan use cases | | `app/application/download/` | Download task querying/control and selection use cases; `failures.py` owns the frozen failure-cooldown write/query DTOs and persistence Port | -| `app/application/history.py` | History use cases and persistence contracts; DownloadHistory owns frozen query/write DTOs and typed ports, while TransferHistory remains a separate unfinished migration surface | +| `app/application/history.py` | History use cases and persistence contracts; DownloadHistory and TransferHistory own deeply frozen DTOs plus typed query/write/staging ports | | `app/application/music/` | Multi-source music catalog orchestration | | `app/application/chain/` | Injectable Chain runtime capabilities: `context.py` owns the runtime dependency aggregate, `data.py` owns named persistence ports, and `events.py` owns durable event write contracts plus replayable payload conversion | | `app/application/agentdata.py` | Named Agent data ports; canonical Agent consumers use `get_agent_*_port()` and do not alias legacy proxies to Oper classes | @@ -151,9 +151,9 @@ Agent orchestration, memory and tool implementations follow the same rule via the named `get_agent_*_port()` functions from `app/application/agentdata.py`. The legacy Agent `*Port` proxy classes remain import-compatible boundaries and must not be reintroduced as Oper aliases in canonical Agent modules. -Monitor history checks use `get_transfer_history_port()` from -`app/application/history.py`; the constructible `TransferHistoryPort` facade is -retained only for compatibility and is not a canonical Oper substitute. +Monitor history checks use `get_transfer_history_repository()` from +`app/application/history.py`; old constructible Oper-style facades are available +only through the exact SDK Legacy/Compat mapping. Durable transfer execution follows one explicit boundary. The Chain freezes each external file operation into the Application-owned contract in @@ -491,12 +491,14 @@ The configured user-configuration repository publishes its in-memory snapshot only after the user transaction commits, and reloads from the database if publication fails. -Download history is the verified half of the History migration: -`app/application/history.py` owns frozen `DownloadHistorySnapshot` / -`DownloadFileSnapshot` values and typed query/write ports; -`app/db/adapters/history/download.py` performs projection and mutations in short -Session scopes. TransferHistory still uses its existing ports, so this must not -be described as completion of the entire History boundary. +History is a verified typed boundary: `app/application/history.py` owns deeply +frozen DownloadHistory and TransferHistory snapshots plus typed query/write and +staging ports. `app/db/adapters/history/download.py` and +`app/db/adapters/history/transfer.py` perform ORM projection and mutations inside +short Session scopes; durable transfer settlement uses the staging port in its +caller-owned transaction. Canonical callers never receive history ORM rows or +raw Oper objects, while old plugin imports resolve only through SDK Legacy and +the exact Compat manifest. Durable post-commit side effects have a separate boundary: @@ -712,8 +714,9 @@ driven workflow registration. | `app/application/download/failures.py` | Frozen download-failure cooldown write/query DTOs and Chain persistence Port | | `app/db/adapters/download.py` | Short-session download-failure snapshot and mutation adapter | | `app/db/adapters/mediaserver.py` | Per-operation media-server cache query/upsert/cleanup transaction adapter | -| `app/application/history.py` | History use cases; frozen DownloadHistory DTOs and typed query/write ports, with TransferHistory migration still pending | +| `app/application/history.py` | History use cases; deeply frozen DownloadHistory/TransferHistory DTOs and typed query/write/staging ports | | `app/db/adapters/history/download.py` | DownloadHistory short-session snapshot, query and mutation adapter | +| `app/db/adapters/history/transfer.py` | TransferHistory short-session snapshot/query/mutation adapter and caller-owned transaction stager | | `app/application/security/user.py` | Frozen user/auth projections and atomic user aggregate service contracts | | `app/db/adapters/user.py` | User projection plus request-UoW mutation adapter | | `app/db/adapters/configuration.py` | Commit-after UserConfig snapshot publication and fact-source reload adapter | diff --git a/docs/rules/10-data-and-persistent.md b/docs/rules/10-data-and-persistent.md index e5a76f820..bc0265ffe 100644 --- a/docs/rules/10-data-and-persistent.md +++ b/docs/rules/10-data-and-persistent.md @@ -140,10 +140,11 @@ adapters; it does not retain reusable repository implementations. cascades on user delete; `(UserConfig.username, UserConfig.key)` is unique and non-null. A schema change to any of these constraints requires a replay-safe migration that repairs legacy duplicates/orphans before creating constraints. -- DownloadHistory is projected into frozen DTOs within the adapter Session. - Its typed query/write port and delete mutation use short Session/UoW scopes. - TransferHistory has not completed the same migration and must be tracked - separately rather than treating the whole History area as typed. +- DownloadHistory and TransferHistory are projected into deeply frozen DTOs + within the adapter Session. Their typed query/write ports use short + Session/UoW scopes; TransferHistory additionally exposes a staging port for + durable settlement inside the caller-owned transaction. Canonical callers + must not receive raw history ORM rows or Oper objects. ### Durable post-commit side effects diff --git a/tests/fixtures/architecture/coverage-baseline.json b/tests/fixtures/architecture/coverage-baseline.json index 84ec25b6f..2b2e64666 100644 --- a/tests/fixtures/architecture/coverage-baseline.json +++ b/tests/fixtures/architecture/coverage-baseline.json @@ -1,8 +1,8 @@ { "application": { - "covered_lines": 10432, - "percent": 79.39, - "statements": 13141 + "covered_lines": 10572, + "percent": 79.47, + "statements": 13303 }, "domain": { "covered_lines": 3392, diff --git a/tests/fixtures/architecture/dependency-baseline.json b/tests/fixtures/architecture/dependency-baseline.json index a4952a78d..717114efa 100644 --- a/tests/fixtures/architecture/dependency-baseline.json +++ b/tests/fixtures/architecture/dependency-baseline.json @@ -1435,8 +1435,8 @@ "runtime_only": true } }, - "edge_count": 7006, - "edge_sha256": "74169f74212541ac2c1f990578f2e1d67f084944c08f1894c5fc1bff1d9016bd", + "edge_count": 7024, + "edge_sha256": "188c70617997f86402b34bf46a4963f6dc2a55f6102737e86528e120c58208c3", "edges": [ "app -> app.runtime", "app -> app.runtime.compat", @@ -4012,6 +4012,8 @@ "app.application.configuration -> app.application.database", "app.application.configuration -> app.schemas", "app.application.configuration -> app.schemas.types", + "app.application.dashboard -> app.application", + "app.application.dashboard -> app.application.history", "app.application.dashboard -> app.schemas", "app.application.dashboard -> app.schemas.dashboard", "app.application.directory -> app.adapters", @@ -5140,6 +5142,8 @@ "app.db.adapters.chain -> app.application.transfer.execution", "app.db.adapters.chain -> app.db", "app.db.adapters.chain -> app.db.adapters", + "app.db.adapters.chain -> app.db.adapters.history", + "app.db.adapters.chain -> app.db.adapters.history.transfer", "app.db.adapters.chain -> app.db.adapters.outbox", "app.db.adapters.chain -> app.db.models", "app.db.adapters.chain -> app.db.models.transfersettlementreceipt", @@ -5173,6 +5177,15 @@ "app.db.adapters.history.download -> app.schemas", "app.db.adapters.history.download -> app.schemas.media", "app.db.adapters.history.download -> app.schemas.types", + "app.db.adapters.history.transfer -> app.application", + "app.db.adapters.history.transfer -> app.application.history", + "app.db.adapters.history.transfer -> app.db", + "app.db.adapters.history.transfer -> app.db.oper", + "app.db.adapters.history.transfer -> app.db.oper.transferhistory", + "app.db.adapters.history.transfer -> app.db.uow", + "app.db.adapters.history.transfer -> app.schemas", + "app.db.adapters.history.transfer -> app.schemas.media", + "app.db.adapters.history.transfer -> app.schemas.types", "app.db.adapters.mediaserver -> app.application", "app.db.adapters.mediaserver -> app.application.mediaserver", "app.db.adapters.mediaserver -> app.db", @@ -7757,8 +7770,12 @@ "app.sdk._legacy.history -> app.application", "app.sdk._legacy.history -> app.application.history", "app.sdk._legacy.history -> app.db", + "app.sdk._legacy.history -> app.db.adapters", + "app.sdk._legacy.history -> app.db.adapters.history", + "app.sdk._legacy.history -> app.db.adapters.history.transfer", "app.sdk._legacy.history -> app.db.oper", "app.sdk._legacy.history -> app.db.oper.transferhistory", + "app.sdk._legacy.history -> app.db.session", "app.sdk._legacy.history -> app.domain", "app.sdk._legacy.history -> app.domain.context", "app.sdk._legacy.history -> app.domain.meta", @@ -7906,6 +7923,7 @@ "app.startup.composition.configuration -> app.schemas.types", "app.startup.composition.context -> app.application", "app.startup.composition.context -> app.application.configuration", + "app.startup.composition.context -> app.application.history", "app.startup.composition.context -> app.application.messaging", "app.startup.composition.context -> app.application.messaging.chat", "app.startup.composition.context -> app.application.outbox", @@ -8077,6 +8095,7 @@ "app.startup.initializers.modules -> app.db.adapters.download", "app.startup.initializers.modules -> app.db.adapters.history", "app.startup.initializers.modules -> app.db.adapters.history.download", + "app.startup.initializers.modules -> app.db.adapters.history.transfer", "app.startup.initializers.modules -> app.db.adapters.mediaserver", "app.startup.initializers.modules -> app.db.adapters.outbox", "app.startup.initializers.modules -> app.db.adapters.pluginidentity", @@ -8101,7 +8120,6 @@ "app.startup.initializers.modules -> app.db.oper.subscribe", "app.startup.initializers.modules -> app.db.oper.subscribehistory", "app.startup.initializers.modules -> app.db.oper.systemconfig", - "app.startup.initializers.modules -> app.db.oper.transferhistory", "app.startup.initializers.modules -> app.db.oper.workflow", "app.startup.initializers.modules -> app.db.session", "app.startup.initializers.modules -> app.db.uow", @@ -8445,7 +8463,7 @@ "app.workflow.actions.transfer_file -> app.workflow", "app.workflow.actions.transfer_file -> app.workflow.actions" ], - "module_count": 856, + "module_count": 857, "modules": [ "app", "app.adapters", @@ -8843,6 +8861,7 @@ "app.db.adapters.download", "app.db.adapters.history", "app.db.adapters.history.download", + "app.db.adapters.history.transfer", "app.db.adapters.mediaserver", "app.db.adapters.outbox", "app.db.adapters.pluginidentity", diff --git a/tests/fixtures/architecture/mypy-baseline.json b/tests/fixtures/architecture/mypy-baseline.json index 9388af840..e34b3a98a 100644 --- a/tests/fixtures/architecture/mypy-baseline.json +++ b/tests/fixtures/architecture/mypy-baseline.json @@ -837,7 +837,7 @@ "redundant-cast": 1 }, "app/api/dependencies/history.py": { - "arg-type": 11, + "arg-type": 7, "attr-defined": 1 }, "app/api/dependencies/site.py": { @@ -1140,10 +1140,8 @@ "type-arg": 2 }, "app/application/history.py": { - "assignment": 2, "attr-defined": 1, - "no-any-return": 1, - "type-arg": 5 + "no-any-return": 1 }, "app/application/image.py": { "arg-type": 1, @@ -1380,7 +1378,7 @@ "type-arg": 4 }, "app/chain/_transfer.py": { - "arg-type": 9, + "arg-type": 8, "assignment": 4, "attr-defined": 55, "no-any-return": 3, @@ -1603,9 +1601,6 @@ "valid-type": 1, "var-annotated": 1 }, - "app/db/adapters/chain.py": { - "return-value": 1 - }, "app/db/adapters/plugininstallation.py": { "no-any-return": 1, "redundant-cast": 2 @@ -1730,9 +1725,8 @@ "no-untyped-def": 4 }, "app/db/oper/transferhistory.py": { - "arg-type": 1, "no-any-return": 2, - "no-untyped-def": 7, + "no-untyped-def": 6, "union-attr": 2 }, "app/db/oper/user.py": { @@ -3380,7 +3374,7 @@ "no-untyped-def": 2 }, "app/startup/initializers/modules.py": { - "arg-type": 13, + "arg-type": 12, "assignment": 1, "attr-defined": 2, "misc": 1, diff --git a/tests/fixtures/architecture/ruff-baseline.json b/tests/fixtures/architecture/ruff-baseline.json index dd1a9ab62..3491680ec 100644 --- a/tests/fixtures/architecture/ruff-baseline.json +++ b/tests/fixtures/architecture/ruff-baseline.json @@ -834,9 +834,6 @@ "app/monitor/__init__.py": { "I001": 1 }, - "app/monitor/dispatcher.py": { - "I001": 1 - }, "app/monitor/monitor.py": { "I001": 1 }, @@ -1181,9 +1178,6 @@ "tests/test_db_lazy_engine.py": { "I001": 1 }, - "tests/test_db_oper_layer_extra.py": { - "I001": 1 - }, "tests/test_db_public_api.py": { "I001": 1 }, @@ -1366,10 +1360,6 @@ "tests/test_module_quality.py": { "I001": 1 }, - "tests/test_monitor_dispatcher_history.py": { - "F401": 1, - "I001": 1 - }, "tests/test_monitor_gap_recovery.py": { "I001": 1 }, @@ -1642,18 +1632,12 @@ "tests/test_transfer_history_retransfer.py": { "I001": 1 }, - "tests/test_transfer_history_write_path.py": { - "I001": 1 - }, "tests/test_transfer_queue_count.py": { "I001": 1 }, "tests/test_transfer_rename_build_event.py": { "I001": 1 }, - "tests/test_transferhistory_media_source_migration.py": { - "I001": 1 - }, "tests/test_uvicorn_entrypoint.py": { "I001": 1 }, diff --git a/tests/fixtures/architecture/runtime-contract-baseline.json b/tests/fixtures/architecture/runtime-contract-baseline.json index 56ea9a638..6ac143c47 100644 --- a/tests/fixtures/architecture/runtime-contract-baseline.json +++ b/tests/fixtures/architecture/runtime-contract-baseline.json @@ -927,6 +927,11 @@ } }, "app.db.oper": { + "TransferHistoryOper": { + "replacement": "app.application.history.TransferHistoryRepository", + "target_module": "app.sdk._legacy.history", + "target_name": "TransferHistoryOper" + }, "WorkflowOper": { "replacement": "app.application.workflow.WorkflowExecutionPort", "target_module": "app.sdk._legacy.workflow", diff --git a/tests/test_chain_durable_events.py b/tests/test_chain_durable_events.py index fdac1cd3e..d890f690c 100644 --- a/tests/test_chain_durable_events.py +++ b/tests/test_chain_durable_events.py @@ -24,6 +24,7 @@ from app.application.history import ( DownloadFileWrite, DownloadHistoryWrite, TransferHistoryMutationCommand, + TransferHistoryWrite, ) from app.application.transfer.execution import ( TransferExecutionCheckpoint, @@ -186,13 +187,13 @@ def _stage_result_history( succeeded: bool, src_path: str | None = None, ): - """通过兼容历史端口暂存一条最小任务结算记录。""" - return repository.add_force( + """通过类型化历史端口暂存一条最小任务结算记录。""" + return repository.replace(TransferHistoryWrite( src=src_path or f"/downloads/{task_id}.mkv", src_storage="local", status=succeeded, errmsg=None if succeeded else "目标文件校验失败", - ) + )) def _assert_event_key( @@ -416,11 +417,11 @@ def test_transfer_succeeds_when_history_id_is_reused_with_retained_outbox(): """写入一条最小成功整理事实并完成即时事件。""" return writer.transfer_result( topic="transfer.completed", - stage_history=lambda repository: repository.add_force( + stage_history=lambda repository: repository.replace(TransferHistoryWrite( src=src, src_storage="local", - status=1, - ), + status=True, + )), event_payload={}, publish=lambda _payload: None, ) @@ -460,15 +461,15 @@ def test_transfer_event_failure_leaves_committed_intent_pending(): def stage_history(repository): """通过应用历史端口名暂存一条成功整理记录。""" - return repository.add_force( + return repository.replace(TransferHistoryWrite( src=fileitem.path, src_storage=fileitem.storage, src_fileitem=fileitem.model_dump(mode="json"), dest=transferinfo.target_item.path, dest_storage=transferinfo.target_item.storage, dest_fileitem=transferinfo.target_item.model_dump(mode="json"), - status=1, - ) + status=True, + )) def fail_publish(_payload): """模拟插件事件总线在业务提交后失败。""" diff --git a/tests/test_db_oper_layer_extra.py b/tests/test_db_oper_layer_extra.py index 556bb3605..34005a79c 100644 --- a/tests/test_db_oper_layer_extra.py +++ b/tests/test_db_oper_layer_extra.py @@ -9,13 +9,13 @@ import asyncio import pytest -from app.db.oper.downloadfailure import DownloadFailureOper -from app.db.oper.message import MessageOper from app.db.models.downloadfailure import DownloadFailure from app.db.models.message import Message from app.db.models.subscribe import Subscribe from app.db.models.subscribehistory import SubscribeHistory from app.db.models.transferhistory import TransferHistory +from app.db.oper.downloadfailure import DownloadFailureOper +from app.db.oper.message import MessageOper from app.db.oper.subscribe import SubscribeOper from app.db.oper.subscribehistory import SubscribeHistoryOper from app.db.oper.transferhistory import TransferHistoryOper @@ -93,14 +93,16 @@ def test_transferhistory_oper_identity_and_hash_lookups(db): assert oper.statistic(days=36500) -def test_transferhistory_oper_add_force_replaces_same_source(db): +def test_transferhistory_oper_stage_replace_replaces_same_source(db): """ 强制新增用同源新记录替换旧的,同一源路径只留一条。 """ oper = TransferHistoryOper(db=db.session) oper.add(**_transfer_kwargs("旧记录", "/data/op-force.mkv")) - created = oper.add_force(**_transfer_kwargs("新记录", "/data/op-force.mkv")) + created = oper.stage_replace_by_src( + **_transfer_kwargs("新记录", "/data/op-force.mkv") + ) assert created.title == "新记录" assert [h.title for h in oper.list_success_by_src("/data/op-force.mkv")] == ["新记录"] diff --git a/tests/test_host_runtime_context.py b/tests/test_host_runtime_context.py index 8457a246c..e4934edde 100644 --- a/tests/test_host_runtime_context.py +++ b/tests/test_host_runtime_context.py @@ -155,6 +155,7 @@ def _runtime() -> HostRuntime: history=HistoryRuntime( download_repository=_Repository, transfer_repository=_Repository, + transfer_mutation_repository=_Repository, media_server_repository=_Repository, ), site=SiteRuntime(repository=_Repository), diff --git a/tests/test_legacy_db_behavior_compat.py b/tests/test_legacy_db_behavior_compat.py index a257d28af..88919e07c 100644 --- a/tests/test_legacy_db_behavior_compat.py +++ b/tests/test_legacy_db_behavior_compat.py @@ -190,7 +190,10 @@ def test_legacy_transfer_history_writes_delegate_to_application_service( monkeypatch.setattr(legacy, service_name, fake_service) assert getattr(oper, method_name)(**arguments) == "history" - assert captured == {**arguments, "transfer_history_oper": oper} + stager = captured.pop("transfer_history_oper") + assert captured == arguments + assert stager._owner is oper + assert callable(stager.replace) def test_legacy_transfer_history_mutations_preserve_durable_receipts(db): diff --git a/tests/test_legacy_import_compat.py b/tests/test_legacy_import_compat.py index 8f588a47e..29414bc6c 100644 --- a/tests/test_legacy_import_compat.py +++ b/tests/test_legacy_import_compat.py @@ -482,7 +482,10 @@ def test_plugin_scan_reports_moved_symbol_import(tmp_path: Path): def test_symbol_alias_manifest_covers_all_moved_public_symbols(): """符号级映射清单应覆盖媒体身份、整理工作项、刮削拆分与消息/通知命名统一的旧入口。""" - assert set(SYMBOL_ALIASES["app.db.oper"]) == {"WorkflowOper"} + assert set(SYMBOL_ALIASES["app.db.oper"]) == { + "TransferHistoryOper", + "WorkflowOper", + } assert set(SYMBOL_ALIASES["app.workflow"]) == {"WorkFlowManager"} assert set(SYMBOL_ALIASES["app.domain.media"]) == { "MEDIA_SOURCE_ALIASES", diff --git a/tests/test_manual_transfer_history.py b/tests/test_manual_transfer_history.py index a30621a72..79fc944dd 100644 --- a/tests/test_manual_transfer_history.py +++ b/tests/test_manual_transfer_history.py @@ -7,13 +7,15 @@ from app.api.endpoints.transfer import ( query_manual_transfer_history, ) from app.application.history import ( + TransferHistoryWrite, clear_transfer_failures, failed_retry_count, max_failed_retries, record_transfer_failure, ) from app.chain.transfer import TransferChain -from app.db.oper.transferhistory import TransferHistoryOper +from app.db.adapters.history.transfer import TransactionalTransferHistoryRepository +from app.db.session import SessionFactory, async_session_scope from app.runtime.config import settings from app.schemas.transfer import ManualTransferItem from tests.test_transfer_sync_extra_files import ( @@ -28,6 +30,14 @@ def _reset_failed_retries(src_path, storage=None): clear_transfer_failures(src_path, storage) +def _history_repository() -> TransactionalTransferHistoryRepository: + """构造类型化整理历史仓储。""" + return TransactionalTransferHistoryRepository( + sync_session=SessionFactory, + async_session=async_session_scope, + ) + + def _patch_transfer_planning(monkeypatch, chain, fileitem, history, planned, deleted): """隔离整理规划依赖,并记录历史及旧目标清理动作。""" monkeypatch.setattr( @@ -207,23 +217,23 @@ def test_history_endpoint_reorganize_uses_chain_cleanup(monkeypatch): def test_success_history_directory_query_excludes_failed_and_siblings(): """目录历史查询应限定路径边界,并且只返回成功记录。""" - transfer_history_oper = TransferHistoryOper() + transfer_history_oper = _history_repository() created_histories = [ - transfer_history_oper.add_force( + transfer_history_oper.replace(TransferHistoryWrite( src="/issue-6191/show/episode-1.mkv", src_storage="local", status=True, - ), - transfer_history_oper.add_force( + )), + transfer_history_oper.replace(TransferHistoryWrite( src="/issue-6191/show/episode-2.mkv", src_storage="local", status=False, - ), - transfer_history_oper.add_force( + )), + transfer_history_oper.replace(TransferHistoryWrite( src="/issue-6191/show-other/episode-3.mkv", src_storage="local", status=True, - ), + )), ] try: histories = transfer_history_oper.list_success_by_src( @@ -242,18 +252,18 @@ def test_success_history_directory_query_excludes_failed_and_siblings(): def test_success_history_directory_query_escapes_sql_wildcards(): """目录名中的 SQL 通配符应按普通字符匹配,不能扩大查询范围。""" - transfer_history_oper = TransferHistoryOper() + transfer_history_oper = _history_repository() created_histories = [ - transfer_history_oper.add_force( + transfer_history_oper.replace(TransferHistoryWrite( src="/issue-6191/show_100%/episode-1.mkv", src_storage="local", status=True, - ), - transfer_history_oper.add_force( + )), + transfer_history_oper.replace(TransferHistoryWrite( src="/issue-6191/showA100B/episode-2.mkv", src_storage="local", status=True, - ), + )), ] try: histories = transfer_history_oper.list_success_by_src( @@ -272,9 +282,9 @@ def test_success_history_directory_query_escapes_sql_wildcards(): def test_successful_move_history_is_found_by_current_destination(): """成功移动后从媒体库现址打开整理界面时,应识别原整理历史。""" - transfer_history_oper = TransferHistoryOper() + transfer_history_oper = _history_repository() destination = make_fileitem("/library/Test Show/Test.Show.S01E01.mkv") - history = transfer_history_oper.add_force( + history = transfer_history_oper.replace(TransferHistoryWrite( src="/downloads/Test.Show.S01E01.mkv", src_storage="local", dest=destination.path, @@ -282,7 +292,7 @@ def test_successful_move_history_is_found_by_current_destination(): dest_fileitem=destination.model_dump(), mode="move", status=True, - ) + )) try: histories = TransferChain.get_manual_transfer_histories( make_transfer_chain(), diff --git a/tests/test_monitor_dispatcher_history.py b/tests/test_monitor_dispatcher_history.py index f6cbdbc1b..74890e19b 100644 --- a/tests/test_monitor_dispatcher_history.py +++ b/tests/test_monitor_dispatcher_history.py @@ -3,13 +3,12 @@ from pathlib import Path from types import SimpleNamespace from unittest.mock import MagicMock -from app.runtime.config import settings from app.application.history import ( clear_transfer_failures, - failed_retry_count, record_transfer_failure, ) from app.monitor.dispatcher import TransferDispatcher +from app.runtime.config import settings def _build_dispatcher() -> TransferDispatcher: @@ -54,7 +53,10 @@ def _patch_history(monkeypatch, record=None, success_record=None) -> MagicMock: oper = MagicMock() oper.get_by_src.return_value = record oper.get_success_by_src.return_value = success_record - monkeypatch.setattr("app.monitor.dispatcher.get_transfer_history_port", MagicMock(return_value=oper)) + monkeypatch.setattr( + "app.monitor.dispatcher.get_transfer_history_repository", + MagicMock(return_value=oper), + ) return oper @@ -229,7 +231,10 @@ def test_history_query_error_registers_pending(monkeypatch): dispatcher = _build_dispatcher() oper = MagicMock() oper.get_by_src.side_effect = RuntimeError("数据库不可用") - monkeypatch.setattr("app.monitor.dispatcher.get_transfer_history_port", MagicMock(return_value=oper)) + monkeypatch.setattr( + "app.monitor.dispatcher.get_transfer_history_repository", + MagicMock(return_value=oper), + ) chain = _patch_chain(monkeypatch) assert dispatcher.handle_file(storage="local", event_path=Path("/downloads/a.mkv"), file_size=100) is False diff --git a/tests/test_monitor_watchfiles.py b/tests/test_monitor_watchfiles.py index 86d3451a7..be3427b53 100644 --- a/tests/test_monitor_watchfiles.py +++ b/tests/test_monitor_watchfiles.py @@ -4,6 +4,7 @@ from unittest.mock import MagicMock from watchfiles import Change +from app.application.history import TransferHistorySnapshot from app.monitor.dispatcher import TransferDispatcher from app.monitor.monitor import Monitor from app.monitor.watcher import DirectoryChangeEvent, LocalDirectoryWatcher @@ -257,12 +258,25 @@ def test_handle_file_skips_transfer_when_history_exists(monkeypatch): 记录查询参数并返回已有记录。 """ lookups.append((src, storage)) - return object() + return TransferHistorySnapshot( + id=1, + src=src, + src_storage=storage, + src_fileitem={"size": 1024}, + status=True, + ) + + def get_success_by_src(self, src: str, storage: str = None): + """成功记录已由首次查询返回,无需二次回退。""" + return None transfer_chain = MagicMock() logger_info = MagicMock() logger_debug = MagicMock() - monkeypatch.setattr("app.monitor.dispatcher.get_transfer_history_port", FakeTransferHistoryOper) + monkeypatch.setattr( + "app.monitor.dispatcher.get_transfer_history_repository", + FakeTransferHistoryOper, + ) monkeypatch.setattr("app.monitor.dispatcher.TransferChain", transfer_chain) monkeypatch.setattr("app.monitor.dispatcher.logger.info", logger_info) monkeypatch.setattr("app.monitor.dispatcher.logger.debug", logger_debug) @@ -277,7 +291,7 @@ def test_handle_file_skips_transfer_when_history_exists(monkeypatch): assert lookups == [(event_path.as_posix(), "local")] transfer_chain.assert_not_called() logger_info.assert_not_called() - logger_debug.assert_not_called() + assert "已整理过且文件未变化" in logger_debug.call_args.args[0] def test_handle_file_invokes_transfer_when_history_missing(monkeypatch): @@ -298,9 +312,16 @@ def test_handle_file_invokes_transfer_when_history_missing(monkeypatch): """ return None + def get_success_by_src(self, src: str, storage: str = None): + """空仓储没有成功历史。""" + return None + transfer_chain_instance = MagicMock() transfer_chain = MagicMock(return_value=transfer_chain_instance) - monkeypatch.setattr("app.monitor.dispatcher.get_transfer_history_port", FakeTransferHistoryOper) + monkeypatch.setattr( + "app.monitor.dispatcher.get_transfer_history_repository", + FakeTransferHistoryOper, + ) monkeypatch.setattr("app.monitor.dispatcher.TransferChain", transfer_chain) handled = dispatcher.handle_file( diff --git a/tests/test_transactional_transfer_history.py b/tests/test_transactional_transfer_history.py new file mode 100644 index 000000000..2a5c4c08e --- /dev/null +++ b/tests/test_transactional_transfer_history.py @@ -0,0 +1,236 @@ +"""整理历史类型化适配器的投影与事务测试。""" + +import asyncio + +import pytest + +from app.application.history import ( + TransferHistoryMonthlyStatistics, + TransferHistorySnapshot, + TransferHistoryStatisticSnapshot, + TransferHistoryWrite, +) +from app.db.adapters.history.transfer import ( + SessionTransferHistoryRepository, + TransactionalTransferHistoryRepository, +) +from app.db.models.transferhistory import TransferHistory +from app.db.session import SessionFactory, async_session_scope +from app.db.uow import SqlAlchemyUnitOfWork +from app.schemas.types import MediaSource, MediaType + + +def _repository() -> TransactionalTransferHistoryRepository: + """构造绑定测试数据库短 Session 的整理历史仓储。""" + return TransactionalTransferHistoryRepository( + sync_session=SessionFactory, + async_session=async_session_scope, + ) + + +def _history_write( + *, + src: str = "/downloads/Typed.Show.S01/Episode01.mkv", + status: bool = True, +) -> TransferHistoryWrite: + """构造覆盖整理、历史和 Agent 消费字段的类型化写入。""" + return TransferHistoryWrite( + src=src, + src_storage="local", + src_fileitem={ + "path": src, + "size": 1024, + "nested": {"parts": [1, 2]}, + }, + dest="/media/Typed Show (2026)/Season 01/Episode01.mkv", + dest_storage="local", + dest_fileitem={"path": "/media/Typed Show (2026)/Season 01/Episode01.mkv"}, + mode="copy", + type=MediaType.TV.value, + category="剧集", + title="Typed Show", + year="2026", + media_source=MediaSource.TMDB, + media_id="7001", + seasons="S01", + episodes="E01", + image="https://example.test/poster.jpg", + downloader="qb", + download_hash="typed-transfer-hash", + status=status, + errmsg=None if status else "transfer failed", + files=["Episode01.mkv"], + episode_group="group-1", + ) + + +def test_transactional_repository_projects_detached_snapshots(db) -> None: + """同步查询应在 Session 内完成投影,并冻结所有嵌套 JSON。""" + repository = _repository() + created = repository.replace(_history_write()) + + by_id = repository.get(created.id) + by_src = repository.get_by_src(created.src or "", "local") + successful = repository.get_success_by_src(created.src or "", "local") + by_dest = repository.get_by_dest(created.dest or "", "local") + by_identity = repository.get_by_media_identity( + MediaSource.TMDB, + "7001", + MediaType.TV.value, + ) + by_hash = repository.list_by_hash("typed-transfer-hash") + + assert isinstance(created, TransferHistorySnapshot) + assert by_id == created + assert by_src == created + assert successful == created + assert by_dest == created + assert by_identity == created + assert by_hash == [created] + assert created.media_source == MediaSource.TMDB + assert not hasattr(created, "_sa_instance_state") + + assert isinstance(created.src_fileitem, dict) + with pytest.raises(TypeError, match="不可修改"): + created.src_fileitem["size"] = 2048 + nested = created.src_fileitem["nested"] + assert isinstance(nested, dict) + parts = nested["parts"] + assert isinstance(parts, list) + with pytest.raises(TypeError, match="不可修改"): + parts.append(3) + + refreshed = repository.get(created.id) + assert refreshed is not None + assert refreshed.src_fileitem == { + "path": created.src, + "size": 1024, + "nested": {"parts": [1, 2]}, + } + + +def test_transactional_repository_rolls_back_replace_on_commit_failure( + db, + monkeypatch, +) -> None: + """替换提交失败时应恢复旧记录,不留下未提交的新投影。""" + repository = _repository() + original = repository.replace(_history_write()) + + def fail_commit(_unit_of_work) -> None: + """模拟数据库提交失败。""" + raise RuntimeError("commit failed") + + monkeypatch.setattr(SqlAlchemyUnitOfWork, "commit", fail_commit) + with pytest.raises(RuntimeError, match="commit failed"): + repository.replace( + _history_write(status=False), + ) + + current = repository.get_by_src(original.src or "", "local") + assert current == original + + +def test_transactional_repository_sync_mutations_use_committed_uow(db) -> None: + """Hash 更新、普通删除和清空应分别由独立事务完整提交。""" + repository = _repository() + first = repository.replace(_history_write(src="/downloads/first.mkv")) + second = repository.replace(_history_write(src="/downloads/second.mkv")) + + repository.update_download_hash(first.id, "updated-hash") + updated = repository.get(first.id) + assert updated is not None + assert updated.download_hash == "updated-hash" + + repository.delete(first.id) + assert repository.get(first.id) is None + assert repository.get(second.id) is not None + + repository.truncate() + assert repository.get(second.id) is None + + +def test_transactional_repository_preserves_durable_history(db) -> None: + """普通删除和清空不得破坏 durable 任务恢复证据。""" + durable = db.add( + TransferHistory( + transfer_task_id="task-typed-history", + transfer_settlement_revision=1, + src="/downloads/durable.mkv", + src_storage="local", + status=False, + ) + ) + repository = _repository() + + repository.delete(durable.id) + repository.truncate() + + preserved = repository.get_by_transfer_task_id(task_id="task-typed-history") + assert preserved is not None + assert preserved.id == durable.id + + +def test_transactional_repository_async_queries_and_delete(db) -> None: + """异步分页与计数返回快照,删除由独立异步事务提交。""" + repository = _repository() + created = repository.replace(_history_write(src="/downloads/async.mkv")) + + async def exercise() -> tuple[ + list[TransferHistorySnapshot], + int, + int, + list[TransferHistoryStatisticSnapshot], + ]: + """在同一事件循环执行全部异步查询和删除。""" + by_id = await repository.async_get(created.id) + assert by_id == created + by_title = await repository.async_list_by_title("Typed", count=10) + by_page = await repository.async_list_by_page(count=10) + total = await repository.async_count() + matching = await repository.async_count_by_title("Typed") + statistics = await repository.async_statistic() + assert by_title == [created] + await repository.async_delete(created.id) + return by_page, total, matching, statistics + + records, total, matching, statistics = asyncio.run(exercise()) + + assert created in records + assert total >= 1 + assert matching >= 1 + assert all(isinstance(item, TransferHistoryStatisticSnapshot) for item in statistics) + assert sum(item.count for item in statistics) >= 1 + assert all(not hasattr(record, "_sa_instance_state") for record in records) + assert repository.get(created.id) is None + + +def test_transactional_repository_returns_typed_monthly_statistics(db) -> None: + """月度聚合应返回命名 DTO,不向 Application 泄漏位置元组。""" + repository = _repository() + repository.replace(_history_write(src="/downloads/monthly.mkv")) + + statistics = repository.monthly_media_statistics() + + assert isinstance(statistics, TransferHistoryMonthlyStatistics) + assert statistics.tv_shows >= 1 + assert statistics.episodes >= 1 + + +def test_session_repository_obeys_request_uow(db) -> None: + """请求级 adapter 只暂存删除,提交或回滚由请求 UoW 决定。""" + repository = _repository() + created = repository.replace(_history_write(src="/downloads/request.mkv")) + + with SessionFactory() as session: + request_repository = SessionTransferHistoryRepository(session) + assert request_repository.get(created.id) == created + request_repository.stage_delete(created.id) + session.rollback() + assert repository.get(created.id) is not None + + with SessionFactory() as session: + request_repository = SessionTransferHistoryRepository(session) + request_repository.stage_truncate() + session.commit() + assert repository.get(created.id) is None diff --git a/tests/test_transfer_history_architecture.py b/tests/test_transfer_history_architecture.py new file mode 100644 index 000000000..436bcfd39 --- /dev/null +++ b/tests/test_transfer_history_architecture.py @@ -0,0 +1,265 @@ +"""整理历史类型化边界与适配器布局门禁。""" + +import ast +from pathlib import Path + +PROJECT_ROOT = Path(__file__).parents[1] +APP_ROOT = PROJECT_ROOT / "app" +HISTORY_APPLICATION_PATH = APP_ROOT / "application" / "history.py" +QUERY_APPLICATION_PATH = APP_ROOT / "application" / "query.py" +HISTORY_ADAPTER_PACKAGE = APP_ROOT / "db" / "adapters" / "history" +CANONICAL_CONSUMER_PATHS = ( + APP_ROOT / "agent", + APP_ROOT / "api", + APP_ROOT / "application", + APP_ROOT / "chain", + APP_ROOT / "modules", + APP_ROOT / "monitor", + APP_ROOT / "startup", + APP_ROOT / "workflow", + APP_ROOT / "scheduler.py", +) + + +def _python_paths(root: Path) -> list[Path]: + """返回 canonical 消费边界内的 Python 源文件。""" + if root.is_file(): + return [root] + return sorted(root.rglob("*.py")) + + +def _annotation_contains_any(annotation: ast.expr | None) -> bool: + """判断类型注解是否仍以 Any 隐藏跨层数据合同。""" + return annotation is not None and any( + isinstance(node, ast.Name) and node.id == "Any" + for node in ast.walk(annotation) + ) + + +def _is_frozen_slotted_dataclass(node: ast.ClassDef) -> bool: + """判断类是否声明为不可变且使用 slots 的数据类。""" + for decorator in node.decorator_list: + if not isinstance(decorator, ast.Call): + continue + if not isinstance(decorator.func, ast.Name) or decorator.func.id != "dataclass": + continue + keywords = { + keyword.arg: keyword.value + for keyword in decorator.keywords + if keyword.arg is not None + } + return all( + isinstance(keywords.get(name), ast.Constant) + and keywords[name].value is True + for name in ("frozen", "slots") + ) + return False + + +def test_transfer_history_contract_is_typed_and_frozen() -> None: + """整理历史必须使用冻结 DTO 和明确 Query/Write/Repository 端口。""" + tree = ast.parse( + HISTORY_APPLICATION_PATH.read_text(encoding="utf-8-sig"), + filename=str(HISTORY_APPLICATION_PATH), + ) + classes = { + node.name: node + for node in tree.body + if isinstance(node, ast.ClassDef) + } + expected = { + "TransferHistorySnapshot", + "TransferHistoryWrite", + "TransferHistoryQueryPort", + "TransferHistoryWritePort", + "TransferHistoryRepository", + } + + assert expected <= classes.keys() + assert _is_frozen_slotted_dataclass(classes["TransferHistorySnapshot"]) + assert _is_frozen_slotted_dataclass(classes["TransferHistoryWrite"]) + + violations: list[str] = [] + for class_name in expected: + class_node = classes[class_name] + for node in ast.walk(class_node): + if isinstance(node, ast.AnnAssign) and _annotation_contains_any(node.annotation): + violations.append(f"{class_name}:{node.lineno}:field") + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + annotations = [ + argument.annotation + for argument in ( + *node.args.posonlyargs, + *node.args.args, + *node.args.kwonlyargs, + ) + if argument.arg not in {"self", "cls"} + ] + if node.args.vararg is not None: + annotations.append(node.args.vararg.annotation) + if node.args.kwarg is not None: + annotations.append(node.args.kwarg.annotation) + annotations.append(node.returns) + if any(_annotation_contains_any(annotation) for annotation in annotations): + violations.append(f"{class_name}:{node.lineno}:{node.name}") + + assert violations == [] + + +def test_history_query_port_returns_explicit_snapshots() -> None: + """插件查询端口也必须声明冻结历史快照,不能以 object 隐藏合同。""" + tree = ast.parse( + QUERY_APPLICATION_PATH.read_text(encoding="utf-8-sig"), + filename=str(QUERY_APPLICATION_PATH), + ) + history_port = next( + node + for node in tree.body + if isinstance(node, ast.ClassDef) and node.name == "HistoryQueryPort" + ) + expected = { + "list_download_history": "QueryRows[DownloadHistorySnapshot]", + "get_download_history": "DownloadHistorySnapshot | None", + "list_transfer_history": "QueryRows[TransferHistorySnapshot]", + "get_transfer_history": "TransferHistorySnapshot | None", + } + + assert { + node.name: ast.unparse(node.returns) + for node in history_port.body + if isinstance(node, ast.FunctionDef) + } == expected + + +def test_transfer_history_retires_dynamic_writer_facade() -> None: + """类型化仓储启用后不得保留动态 getter、Facade 或 add_force 合同。""" + tree = ast.parse( + HISTORY_APPLICATION_PATH.read_text(encoding="utf-8-sig"), + filename=str(HISTORY_APPLICATION_PATH), + ) + retired_classes = { + "TransferHistoryRecord", + "TransferHistoryWriter", + "TransferHistoryPort", + "AsyncTransferHistoryQueryRepository", + } + retired_functions = { + "_get_transfer_history_writer", + "get_transfer_history_port", + } + violations: list[str] = [] + for node in ast.walk(tree): + if isinstance(node, ast.ClassDef) and node.name in retired_classes: + violations.append(f"class:{node.name}:{node.lineno}") + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + if node.name in retired_functions or node.name == "add_force": + violations.append(f"function:{node.name}:{node.lineno}") + if isinstance(node, ast.Attribute) and node.attr == "add_force": + violations.append(f"attribute:add_force:{node.lineno}") + + assert violations == [] + + +def test_canonical_consumers_do_not_import_raw_transfer_history_persistence() -> None: + """宿主消费者不得导入整理历史 ORM、Oper 或旧动态 getter。""" + forbidden_from_imports = { + ("app.db.models", "TransferHistory"), + ("app.db.models.transferhistory", "TransferHistory"), + ("app.db.oper", "TransferHistoryOper"), + ("app.db.oper.transferhistory", "TransferHistoryOper"), + ("app.application.history", "TransferHistoryPort"), + ("app.application.history", "get_transfer_history_port"), + } + forbidden_modules = { + "app.db.models.transferhistory", + "app.db.oper.transferhistory", + } + violations: list[str] = [] + + for root in CANONICAL_CONSUMER_PATHS: + for path in _python_paths(root): + tree = ast.parse(path.read_text(encoding="utf-8-sig"), filename=str(path)) + relative = path.relative_to(PROJECT_ROOT).as_posix() + for node in ast.walk(tree): + if isinstance(node, ast.ImportFrom): + for alias in node.names: + if (node.module, alias.name) in forbidden_from_imports: + violations.append( + f"{relative}:{node.lineno}:{node.module}.{alias.name}" + ) + elif isinstance(node, ast.Import): + for alias in node.names: + if alias.name in forbidden_modules: + violations.append(f"{relative}:{node.lineno}:{alias.name}") + elif isinstance(node, ast.Attribute) and node.attr == "add_force": + violations.append(f"{relative}:{node.lineno}:add_force") + elif isinstance(node, ast.Name) and node.id == "get_transfer_history_port": + violations.append( + f"{relative}:{node.lineno}:get_transfer_history_port" + ) + elif isinstance(node, (ast.Assign, ast.AnnAssign)): + targets = ( + node.targets + if isinstance(node, ast.Assign) + else [node.target] + ) + value = node.value + if ( + any( + isinstance(target, ast.Name) + and target.id.startswith("TransferHistory") + for target in targets + ) + and value is not None + and any( + isinstance(child, ast.Name) and child.id == "Any" + for child in ast.walk(value) + ) + ): + violations.append(f"{relative}:{node.lineno}:Any alias") + + assert violations == [] + + +def test_add_force_is_owned_only_by_legacy_sdk_facade() -> None: + """旧无类型替换入口只能存在于私有 Legacy facade。""" + allowed = "app/sdk/_legacy/history.py" + violations: list[str] = [] + for path in APP_ROOT.rglob("*.py"): + relative = path.relative_to(PROJECT_ROOT).as_posix() + if relative.startswith("app/plugins/") or relative == allowed: + continue + tree = ast.parse(path.read_text(encoding="utf-8-sig"), filename=str(path)) + for node in ast.walk(tree): + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + if node.name == "add_force": + violations.append(f"{relative}:{node.lineno}:definition") + elif isinstance(node, ast.Attribute) and node.attr == "add_force": + violations.append(f"{relative}:{node.lineno}:call") + + assert violations == [] + + +def test_transfer_history_adapter_uses_topic_package_without_root_exports() -> None: + """整理历史适配器必须位于 history/transfer.py,包根不得复制宿主导出面。""" + adapter_path = HISTORY_ADAPTER_PACKAGE / "transfer.py" + package_path = HISTORY_ADAPTER_PACKAGE / "__init__.py" + + assert adapter_path.is_file() + assert not (APP_ROOT / "db" / "adapters" / "transferhistory.py").exists() + assert not (APP_ROOT / "db" / "adapters" / "transfer_history.py").exists() + + tree = ast.parse( + package_path.read_text(encoding="utf-8-sig"), + filename=str(package_path), + ) + statements = [ + node + for node in tree.body + if not ( + isinstance(node, ast.Expr) + and isinstance(node.value, ast.Constant) + and isinstance(node.value.value, str) + ) + ] + assert statements == [] diff --git a/tests/test_transfer_history_legacy_compat.py b/tests/test_transfer_history_legacy_compat.py new file mode 100644 index 000000000..a5ae42cb2 --- /dev/null +++ b/tests/test_transfer_history_legacy_compat.py @@ -0,0 +1,99 @@ +"""旧整理历史 Oper 的精确插件 ABI 门禁。""" + +import importlib +import inspect + +from app.db.oper.transferhistory import TransferHistoryOper as CanonicalTransferHistoryOper +from app.runtime.compat.manifest import MODULE_ALIASES + + +def test_legacy_transfer_history_import_targets_private_sdk_facade() -> None: + """旧 DB 路径必须精确路由到私有 SDK 门面。""" + alias = MODULE_ALIASES["app.db.transferhistory_oper"] + legacy = importlib.import_module("app.db.transferhistory_oper") + + assert alias.target == "app.sdk._legacy.history" + assert alias.owner == "sdk" + assert alias.replacement == "app.application.history" + assert legacy is importlib.import_module(alias.target) + assert legacy.__all__ == ["TransferHistoryOper"] + assert issubclass(legacy.TransferHistoryOper, CanonicalTransferHistoryOper) + assert legacy.TransferHistoryOper is not CanonicalTransferHistoryOper + + oper_package = importlib.import_module("app.db.oper") + assert oper_package.TransferHistoryOper is legacy.TransferHistoryOper + assert "TransferHistoryOper" not in oper_package.__all__ + + +def test_legacy_transfer_history_oper_preserves_exact_write_abi() -> None: + """兼容类冻结旧方法参数,并以脱离 Session 的快照替代 ORM 注解。""" + legacy = importlib.import_module("app.db.transferhistory_oper") + oper_type = legacy.TransferHistoryOper + public_methods = { + name + for name, value in oper_type.__dict__.items() + if not name.startswith("_") and callable(value) + } + + assert public_methods == {"add_success", "add_fail", "add_force"} + assert str(inspect.signature(oper_type.add_force)) == ( + "(self, **kwargs) -> app.application.history.TransferHistorySnapshot | None" + ) + assert str(inspect.signature(oper_type.add_success)) == ( + "(self, fileitem: app.schemas.file.FileItem, mode: str, " + "meta: app.domain.meta.metabase.MetaBase, " + "mediainfo: app.domain.context.MediaInfo | app.domain.context.MusicInfo, " + "transferinfo: app.schemas.transfer.TransferInfo, " + "downloader: str | None = None, download_hash: str | None = None) -> " + "Any | None" + ) + assert str(inspect.signature(oper_type.add_fail)) == ( + "(self, fileitem: app.schemas.file.FileItem, mode: str, " + "meta: app.domain.meta.metabase.MetaBase, " + "mediainfo: app.domain.context.MediaInfo | app.domain.context.MusicInfo | " + "None = None, transferinfo: app.schemas.transfer.TransferInfo | None = None, " + "downloader: str | None = None, download_hash: str | None = None) -> " + "Any | None" + ) + + +def test_legacy_add_force_preserves_replace_behavior(db) -> None: + """旧 add_force 继续按同源存储替换并返回可读记录。""" + legacy = importlib.import_module("app.db.transferhistory_oper") + oper = legacy.TransferHistoryOper(db.session) + + first = oper.add_force( + src="/downloads/legacy-replace.mkv", + src_storage=None, + title="旧标题", + status=False, + ) + second = oper.add_force( + src="/downloads/legacy-replace.mkv", + src_storage="local", + title="新标题", + status=True, + ) + + assert first is not None + assert second is not None + assert second.src == "/downloads/legacy-replace.mkv" + assert second.src_storage == "local" + assert second.title == "新标题" + assert second.status is True + records = oper.list_success_by_src( + "/downloads/legacy-replace.mkv", + "local", + ) + assert [record.id for record in records] == [second.id] + + +def test_legacy_transfer_history_helper_keeps_exact_application_route() -> None: + """旧 Helper 路径继续复用应用层实现,不创建第二份兼容模块。""" + alias = MODULE_ALIASES["app.helper.transferhistory"] + + assert alias.target == "app.application.history" + assert alias.owner == "application" + assert importlib.import_module("app.helper.transferhistory") is importlib.import_module( + alias.target + ) diff --git a/tests/test_transfer_history_write_path.py b/tests/test_transfer_history_write_path.py index 69a3e9e47..9541425ba 100644 --- a/tests/test_transfer_history_write_path.py +++ b/tests/test_transfer_history_write_path.py @@ -16,10 +16,11 @@ import pytest from app import schemas from app.application.history import add_transfer_fail, add_transfer_success +from app.db.adapters.history.transfer import TransactionalTransferHistoryRepository +from app.db.models.transferhistory import TransferHistory +from app.db.session import SessionFactory, async_session_scope from app.domain.context import MediaInfo from app.domain.metainfo import MetaInfo -from app.db.models.transferhistory import TransferHistory -from app.db.oper.transferhistory import TransferHistoryOper from app.schemas.types import MediaSource, MediaType @@ -35,6 +36,14 @@ def _fileitem(path: str, storage: str = "local") -> schemas.FileItem: name=path.rsplit("/", 1)[-1], type="file") +def _repository() -> TransactionalTransferHistoryRepository: + """构造类型化整理历史仓储。""" + return TransactionalTransferHistoryRepository( + sync_session=SessionFactory, + async_session=async_session_scope, + ) + + def _transferinfo(dest: str = "/media/片名/Season 01/片名 - S01E02.mkv", message: str = None, with_target: bool = True) -> schemas.TransferInfo: """构造整理结果。""" @@ -72,7 +81,7 @@ def test_add_success_maps_every_field_onto_the_row(db): 源/目标各自带存储、季集来自识别结果、状态为成功——这些是整理查重与媒体库 溯源的全部依据,错一项就查不回来。 """ - oper = TransferHistoryOper(db=db.session) + oper = _repository() meta = MetaInfo("片名.S01E02.2026.mkv") add_transfer_success(transfer_history_oper=oper, @@ -99,7 +108,7 @@ def test_add_success_persists_both_file_items(db): 整理链回滚、重新整理都要拿原始 FileItem 复原,只存路径字符串不够。 """ - oper = TransferHistoryOper(db=db.session) + oper = _repository() add_transfer_success(transfer_history_oper=oper, fileitem=_fileitem("/downloads/item.mkv"), mode="link", meta=MetaInfo("item.mkv"), mediainfo=_mediainfo(), @@ -116,7 +125,7 @@ def test_add_success_tolerates_missing_target_item(db): 某些存储的整理只回传结果不回传条目,硬取 target_item.path 会直接抛。 """ - oper = TransferHistoryOper(db=db.session) + oper = _repository() add_transfer_success(transfer_history_oper=oper, fileitem=_fileitem("/downloads/no-target.mkv"), mode="copy", meta=MetaInfo("no-target.mkv"), mediainfo=_mediainfo(), @@ -131,9 +140,9 @@ def test_add_success_replaces_the_previous_record_of_the_same_source(db): """ 同一源路径重复整理只保留最新一条。 - 经 add_force 的先删后插实现;留下旧行会让查重命中过期的目标路径。 + 经同源替换的先删后插实现;留下旧行会让查重命中过期的目标路径。 """ - oper = TransferHistoryOper(db=db.session) + oper = _repository() for title in ("第一次", "第二次"): add_transfer_success(transfer_history_oper=oper, fileitem=_fileitem("/downloads/dup.mkv"), mode="move", @@ -151,7 +160,7 @@ def test_add_success_prefers_track_title_for_music(db): 音乐库按曲目组织,记成专辑名会让单曲在历史里全部重名、无法区分。 """ - oper = TransferHistoryOper(db=db.session) + oper = _repository() music_meta = MetaInfo("周杰伦 - 晴天.flac") add_transfer_success(transfer_history_oper=oper, @@ -167,7 +176,7 @@ def test_add_success_falls_back_to_recognized_title(db): """ 非音乐文件用识别标题,识别标题为空时退回文件名解析出的名字。 """ - oper = TransferHistoryOper(db=db.session) + oper = _repository() meta = MetaInfo("片名.S01E02.2026.mkv") add_transfer_success(transfer_history_oper=oper, @@ -188,7 +197,7 @@ def test_add_fail_records_the_transfer_error_message(db): 失败重试与人工排障都只能看这条 errmsg。 """ - oper = TransferHistoryOper(db=db.session) + oper = _repository() add_transfer_fail(transfer_history_oper=oper, fileitem=_fileitem("/downloads/fail.mkv"), mode="move", @@ -207,7 +216,7 @@ def test_add_fail_uses_a_default_message_when_none_given(db): 留空会让失败记录在界面上显示成「无错误」,与成功记录无从区分。 """ - oper = TransferHistoryOper(db=db.session) + oper = _repository() add_transfer_fail(transfer_history_oper=oper, fileitem=_fileitem("/downloads/nomsg.mkv"), mode="move", @@ -221,7 +230,7 @@ def test_add_fail_persists_episode_group(db): """ 失败记录要带上剧集组——重试时靠它还原到同一个剧集组,否则会整理错季。 """ - oper = TransferHistoryOper(db=db.session) + oper = _repository() add_transfer_fail(transfer_history_oper=oper, fileitem=_fileitem("/downloads/eg.mkv"), mode="move", @@ -238,7 +247,7 @@ def test_add_fail_without_recognition_takes_the_unidentified_branch(db): 这条分支是「文件识别不出来」的唯一记录方式,丢了这些文件就彻底无迹可寻。 """ - oper = TransferHistoryOper(db=db.session) + oper = _repository() meta = MetaInfo("无法识别的文件.S02E05.2020.mkv") add_transfer_fail(transfer_history_oper=oper, @@ -260,7 +269,7 @@ def test_add_fail_unidentified_music_is_marked_as_a_recording(db): 音乐订阅按单曲/专辑分别查重,实体类型为空会让这条历史两边都匹配不上。 """ - oper = TransferHistoryOper(db=db.session) + oper = _repository() add_transfer_fail(transfer_history_oper=oper, fileitem=_fileitem("/downloads/unknown.flac"), mode="move", @@ -276,7 +285,7 @@ def test_add_fail_returns_the_persisted_row(db): """ 两条分支都要把落库后的记录返回,调用方据此拿主键做后续关联。 """ - oper = TransferHistoryOper(db=db.session) + oper = _repository() identified = add_transfer_fail(transfer_history_oper=oper, fileitem=_fileitem("/downloads/r1.mkv"), mode="move", diff --git a/tests/test_transfer_legacy_terminal_compat.py b/tests/test_transfer_legacy_terminal_compat.py index 389f64267..a86586ab2 100644 --- a/tests/test_transfer_legacy_terminal_compat.py +++ b/tests/test_transfer_legacy_terminal_compat.py @@ -4,6 +4,7 @@ import threading from types import SimpleNamespace from unittest.mock import Mock, patch +from app.application.history import TransferHistorySnapshot from app.application.transfer.execution import ( TransferExecutionCheckpoint, TransferSettlementResult, @@ -110,19 +111,20 @@ def _settlement_writer(*, status: bool, history_id: int = 41): """暂存历史后返回与当前结算修订一致的投影。""" staging = Mock() - def add_force(**payload): + def replace_history(history): """保存历史 payload 并返回 writer 所需的最小记录。""" + payload = history.to_payload() staged_payloads.append(payload) - return SimpleNamespace( + return TransferHistorySnapshot( id=history_id, - status=bool(payload["status"]), - src=payload["src"], - src_storage=payload["src_storage"], - src_fileitem=payload["src_fileitem"], + status=history.status, + src=history.src, + src_storage=history.src_storage, + src_fileitem=history.src_fileitem, ) - staging.add_force.side_effect = add_force - staging.get_success_by_src.return_value = SimpleNamespace( + staging.replace.side_effect = replace_history + staging.get_success_by_src.return_value = TransferHistorySnapshot( id=history_id, status=True, src="/downloads/Movie.2026.mkv", diff --git a/tests/test_transfer_planning_checkpoint.py b/tests/test_transfer_planning_checkpoint.py index ed95ec5d6..79296548d 100644 --- a/tests/test_transfer_planning_checkpoint.py +++ b/tests/test_transfer_planning_checkpoint.py @@ -12,6 +12,7 @@ import pytest from sqlalchemy import create_engine, select from sqlalchemy.orm import sessionmaker +from app.application.history import TransferHistorySnapshot from app.application.transfer import workflow as transfer_application from app.application.transfer.execution import ( TransferExecutionCheckpoint, @@ -1064,7 +1065,7 @@ def test_legacy_transfer_command_uses_durable_pipeline_and_settles_pending(): def settle_result(**kwargs): """执行历史暂存并模拟 writer 返回 task-aware 结算投影。""" staging = Mock() - staging.add_force.return_value = SimpleNamespace( + staging.replace.return_value = TransferHistorySnapshot( id=31, status=True, src=result.fileitem.path, diff --git a/tests/test_transfer_settling_recovery.py b/tests/test_transfer_settling_recovery.py index f4b79442c..2d7570120 100644 --- a/tests/test_transfer_settling_recovery.py +++ b/tests/test_transfer_settling_recovery.py @@ -10,6 +10,7 @@ from sqlalchemy import create_engine, select from sqlalchemy.orm import sessionmaker from app.application.chain.events import TransferResultSettlement +from app.application.history import TransferHistoryWrite from app.application.transfer.execution import ( TransferExecutionCheckpoint, TransferExecutionCommand, @@ -597,12 +598,12 @@ def test_failed_settlement_retry_replays_step_and_commits_new_receipt( writer = TransactionalChainDurableEventWriter(factory) first_settlement = writer.transfer_result( topic=None, - stage_history=lambda repository: repository.add_force( + stage_history=lambda repository: repository.replace(TransferHistoryWrite( src=path, src_storage="local", status=False, errmsg="copy failed", - ), + )), event_payload={}, publish=None, settlement=TransferResultSettlement( @@ -654,12 +655,12 @@ def test_failed_settlement_retry_replays_step_and_commits_new_receipt( assert new_checkpoint.fingerprint != exhausted.checkpoint.fingerprint second_settlement = writer.transfer_result( topic=None, - stage_history=lambda repository: repository.add_force( + stage_history=lambda repository: repository.replace(TransferHistoryWrite( src=path, src_storage="local", status=True, errmsg=None, - ), + )), event_payload={}, publish=None, settlement=TransferResultSettlement( diff --git a/tests/test_transferhistory_media_source_migration.py b/tests/test_transferhistory_media_source_migration.py index 289341435..302d783ef 100644 --- a/tests/test_transferhistory_media_source_migration.py +++ b/tests/test_transferhistory_media_source_migration.py @@ -1,20 +1,29 @@ import importlib -from types import SimpleNamespace from unittest.mock import Mock import sqlalchemy as sa from alembic.migration import MigrationContext from alembic.operations import Operations -from app.application.history import add_transfer_fail, add_transfer_success +from app.application.history import ( + TransferHistorySnapshot, + add_transfer_fail, + add_transfer_success, +) from app.domain.context import MUSIC_ENTITY_ALBUM, MusicInfo from app.domain.meta.metabase import MetaBase from app.domain.meta.metamusic import MetaMusic -from app.db.oper.transferhistory import TransferHistoryOper from app.schemas.file import FileItem from app.schemas.transfer import TransferInfo +def _history_repository(src: str) -> Mock: + """构造接收类型化写入并返回冻结快照的测试仓储。""" + repository = Mock() + repository.replace.return_value = TransferHistorySnapshot(id=1, src=src) + return repository + + def test_transferhistory_migration_backfills_existing_source_ids(monkeypatch) -> None: """迁移应把存量TMDB和豆瓣字段回填到统一来源字段。""" migration = importlib.import_module( @@ -64,8 +73,7 @@ def test_transferhistory_migration_backfills_existing_source_ids(monkeypatch) -> def test_failed_transfer_history_preserves_explicit_media_source() -> None: """识别失败记录也应保存文件名中显式指定的数据源ID。""" - oper = object.__new__(TransferHistoryOper) - oper.add_force = Mock(return_value=SimpleNamespace(id=1)) + repository = _history_repository("/downloads/Frieren.mkv") meta = MetaBase("Frieren") meta.cn_name = "Frieren" meta.media_source = "anilist" @@ -79,18 +87,17 @@ def test_failed_transfer_history_preserves_explicit_media_source() -> None: ), mode="copy", meta=meta, - transfer_history_oper=oper, + transfer_history_oper=repository, ) - call = oper.add_force.call_args - assert call.kwargs["media_source"] == "anilist" - assert call.kwargs["media_id"] == "154587" + history = repository.replace.call_args.args[0] + assert str(history.media_source) == "anilist" + assert history.media_id == "154587" def test_failed_music_history_preserves_media_type_and_entity_namespace() -> None: """未识别音乐的失败记录也应保留音乐类型和单曲实体命名空间。""" - oper = object.__new__(TransferHistoryOper) - oper.add_force = Mock(return_value=SimpleNamespace(id=1)) + repository = _history_repository("/downloads/周杰伦 - 晴天.flac") meta = MetaMusic( title="晴天", artists=["周杰伦"], @@ -107,12 +114,12 @@ def test_failed_music_history_preserves_media_type_and_entity_namespace() -> Non ), mode="copy", meta=meta, - transfer_history_oper=oper, + transfer_history_oper=repository, ) - call = oper.add_force.call_args - assert call.kwargs["type"] == "音乐" - assert call.kwargs["music_type"] == "recording" + history = repository.replace.call_args.args[0] + assert history.type == "音乐" + assert history.music_type == "recording" def test_transferhistory_music_migration_is_idempotent(monkeypatch) -> None: @@ -224,8 +231,7 @@ def test_music_audio_quality_migration_is_idempotent(monkeypatch) -> None: def test_transfer_history_preserves_album_entity_context() -> None: """整理成功记录应保存整专实体和预期曲目数供 Agent 重试。""" - oper = object.__new__(TransferHistoryOper) - oper.add_force = Mock(return_value=SimpleNamespace(id=1)) + repository = _history_repository("/downloads/叶惠美/01.flac") media = MusicInfo( media_source="musicbrainz", media_id="release-group-1", @@ -254,14 +260,14 @@ def test_transfer_history_preserves_album_entity_context() -> None: type="file", ), ), - transfer_history_oper=oper, + transfer_history_oper=repository, ) - call = oper.add_force.call_args - assert call.kwargs["music_type"] == "album" - assert call.kwargs["total_tracks"] == 11 - assert call.kwargs["audio_format"] == "FLAC" - assert call.kwargs["audio_lossless"] is True - assert call.kwargs["bit_depth"] == 24 - assert call.kwargs["sample_rate"] == 96_000 - assert call.kwargs["bitrate"] == 2_304_000 + history = repository.replace.call_args.args[0] + assert history.music_type == "album" + assert history.total_tracks == 11 + assert history.audio_format == "FLAC" + assert history.audio_lossless is True + assert history.bit_depth == 24 + assert history.sample_rate == 96_000 + assert history.bitrate == 2_304_000 diff --git a/tests/test_ugreen_mediaserver.py b/tests/test_ugreen_mediaserver.py index 664da3040..b4f5a6c05 100644 --- a/tests/test_ugreen_mediaserver.py +++ b/tests/test_ugreen_mediaserver.py @@ -1,7 +1,9 @@ -import unittest from unittest.mock import patch +import pytest + from app import schemas +from app.application.history import TransferHistoryMonthlyStatistics from app.modules.ugreen.ugreen import Ugreen try: @@ -67,143 +69,148 @@ class _PagedFolderApi: } -class UgreenScanModeTest(unittest.TestCase): - def test_resolve_scan_type(self): - resolve = Ugreen._Ugreen__resolve_scan_type +def test_resolve_scan_type(): + resolve = Ugreen._Ugreen__resolve_scan_type - self.assertEqual(resolve(scan_mode="new_and_modified"), 1) - self.assertEqual(resolve(scan_mode="supplement_missing"), 2) - self.assertEqual(resolve(scan_mode="full_override"), 3) - - self.assertEqual(resolve(scan_mode="1"), 1) - self.assertEqual(resolve(scan_mode="2"), 2) - self.assertEqual(resolve(scan_mode="3"), 3) - - self.assertEqual(resolve(scan_type=1), 1) - self.assertEqual(resolve(scan_type=2), 2) - self.assertEqual(resolve(scan_type=3), 3) - - self.assertEqual(resolve(scan_mode="unknown"), 2) - self.assertEqual(resolve(), 2) + assert resolve(scan_mode="new_and_modified") == 1 + assert resolve(scan_mode="supplement_missing") == 2 + assert resolve(scan_mode="full_override") == 3 + assert resolve(scan_mode="1") == 1 + assert resolve(scan_mode="2") == 2 + assert resolve(scan_mode="3") == 3 + assert resolve(scan_type=1) == 1 + assert resolve(scan_type=2) == 2 + assert resolve(scan_type=3) == 3 + assert resolve(scan_mode="unknown") == 2 + assert resolve() == 2 -class UgreenVerifySslTest(unittest.TestCase): - def test_resolve_verify_ssl(self): - resolve = Ugreen._Ugreen__resolve_verify_ssl - self.assertEqual(resolve(True), True) - self.assertEqual(resolve(False), False) - self.assertEqual(resolve("true"), True) - self.assertEqual(resolve("1"), True) - self.assertEqual(resolve("false"), False) - self.assertEqual(resolve("0"), False) - self.assertEqual(resolve(None), True) +def test_resolve_verify_ssl(): + resolve = Ugreen._Ugreen__resolve_verify_ssl + assert resolve(True) is True + assert resolve(False) is False + assert resolve("true") is True + assert resolve("1") is True + assert resolve("false") is False + assert resolve("0") is False + assert resolve(None) is True -class UgreenStatisticTest(unittest.TestCase): - def test_get_medias_count_episode_is_none(self): - ugreen = Ugreen.__new__(Ugreen) - ugreen._host = "http://127.0.0.1:9999" - ugreen._username = "tester" - ugreen._password = "secret" - ugreen._userinfo = {"name": "tester"} - ugreen._api = _FakeUgreenApi() +def test_get_medias_count_episode_is_none(): + ugreen = Ugreen.__new__(Ugreen) + ugreen._host = "http://127.0.0.1:9999" + ugreen._username = "tester" + ugreen._password = "secret" + ugreen._userinfo = {"name": "tester"} + ugreen._api = _FakeUgreenApi() - stat = ugreen.get_medias_count() - self.assertEqual(stat.movie_count, 12) - self.assertEqual(stat.tv_count, 34) - self.assertIsNone(stat.episode_count) + stat = ugreen.get_medias_count() + assert stat.movie_count == 12 + assert stat.tv_count == 34 + assert stat.episode_count is None -class UgreenReconnectTest(unittest.TestCase): - def test_reconnect_does_not_eagerly_load_libraries(self): - ugreen = Ugreen.__new__(Ugreen) - ugreen._host = "http://127.0.0.1:9999" - ugreen._username = "tester" - ugreen._password = "secret" - ugreen._verify_ssl = True - ugreen._libraries = {"old": {"id": "old"}} - ugreen._library_paths = {"old": "/old"} - ugreen._api = None - ugreen._userinfo = None +def test_reconnect_does_not_eagerly_load_libraries(): + ugreen = Ugreen.__new__(Ugreen) + ugreen._host = "http://127.0.0.1:9999" + ugreen._username = "tester" + ugreen._password = "secret" + ugreen._verify_ssl = True + ugreen._libraries = {"old": {"id": "old"}} + ugreen._library_paths = {"old": "/old"} + ugreen._api = None + ugreen._userinfo = None - with patch.object(Ugreen, "_Ugreen__restore_persisted_session", return_value=False), patch( - "app.modules.ugreen.ugreen.Api", return_value=_FakeReconnectApi() - ), patch.object(Ugreen, "_Ugreen__save_persisted_session", return_value=None), patch.object( - Ugreen, "disconnect", wraps=ugreen.disconnect - ), patch.object(Ugreen, "get_librarys") as mocked_get_librarys: - self.assertTrue(ugreen.reconnect()) + with patch.object(Ugreen, "_Ugreen__restore_persisted_session", return_value=False), patch( + "app.modules.ugreen.ugreen.Api", return_value=_FakeReconnectApi() + ), patch.object(Ugreen, "_Ugreen__save_persisted_session", return_value=None), patch.object( + Ugreen, "disconnect", wraps=ugreen.disconnect + ), patch.object(Ugreen, "get_librarys") as mocked_get_librarys: + assert ugreen.reconnect() is True - mocked_get_librarys.assert_not_called() - self.assertEqual(ugreen._libraries, {}) - self.assertEqual(ugreen._library_paths, {}) + mocked_get_librarys.assert_not_called() + assert ugreen._libraries == {} + assert ugreen._library_paths == {} -class UgreenLibraryPathLimitTest(unittest.TestCase): - def test_load_library_paths_stops_at_last_page(self): - ugreen = Ugreen.__new__(Ugreen) - ugreen._username = "tester" - ugreen._api = _PagedFolderApi(stop_after=3) +def test_load_library_paths_stops_at_last_page(): + ugreen = Ugreen.__new__(Ugreen) + ugreen._username = "tester" + ugreen._api = _PagedFolderApi(stop_after=3) - paths = ugreen._Ugreen__load_library_paths() + paths = ugreen._Ugreen__load_library_paths() - self.assertEqual(ugreen._api.pages, [1, 2, 3]) - self.assertEqual(paths["3"], "/library/3") - - def test_load_library_paths_respects_page_limit(self): - ugreen = Ugreen.__new__(Ugreen) - ugreen._username = "tester" - ugreen._api = _PagedFolderApi() - - paths = ugreen._Ugreen__load_library_paths() - - self.assertEqual(ugreen._api.calls, Ugreen.LIBRARY_PATH_PAGE_LIMIT) - self.assertEqual(len(paths), Ugreen.LIBRARY_PATH_PAGE_LIMIT) - self.assertIn(str(Ugreen.LIBRARY_PATH_PAGE_LIMIT), paths) + assert ugreen._api.pages == [1, 2, 3] + assert paths["3"] == "/library/3" -class DashboardStatisticTest(unittest.TestCase): - class _Repository: - """Dashboard 汇总测试使用的零增量历史仓储。""" +def test_load_library_paths_respects_page_limit(): + ugreen = Ugreen.__new__(Ugreen) + ugreen._username = "tester" + ugreen._api = _PagedFolderApi() - @staticmethod - def monthly_media_statistics(): - """返回全零月度统计,隔离媒体服务汇总断言。""" - return 0, 0, 0, 0 + paths = ugreen._Ugreen__load_library_paths() - @unittest.skipIf(dashboard_endpoint is None, "dashboard endpoint dependencies are missing") - def test_statistic_all_episode_missing(self): - mocked_stats = [ - schemas.Statistic(movie_count=10, tv_count=20, episode_count=None, user_count=2), - schemas.Statistic(movie_count=1, tv_count=2, episode_count=None, user_count=1), - ] - from app.application.dashboard import DashboardQueryService + assert ugreen._api.calls == Ugreen.LIBRARY_PATH_PAGE_LIMIT + assert len(paths) == Ugreen.LIBRARY_PATH_PAGE_LIMIT + assert str(Ugreen.LIBRARY_PATH_PAGE_LIMIT) in paths - service = DashboardQueryService( - repository=self._Repository(), - media_statistics=lambda _name: mocked_stats, + +class _DashboardRepository: + """Dashboard 汇总测试使用的零增量历史仓储。""" + + @staticmethod + def monthly_media_statistics() -> TransferHistoryMonthlyStatistics: + """返回全零月度统计,隔离媒体服务汇总断言。""" + return TransferHistoryMonthlyStatistics( + movies=0, + tv_shows=0, + episodes=0, + music=0, ) - ret = dashboard_endpoint.statistic(name="ugreen", service=service, _=None) - self.assertEqual(ret.movie_count, 11) - self.assertEqual(ret.tv_count, 22) - self.assertEqual(ret.user_count, 3) - self.assertIsNone(ret.episode_count) - @unittest.skipIf(dashboard_endpoint is None, "dashboard endpoint dependencies are missing") - def test_statistic_mixed_episode_count(self): - mocked_stats = [ - schemas.Statistic(movie_count=10, tv_count=20, episode_count=None, user_count=2), - schemas.Statistic(movie_count=1, tv_count=2, episode_count=6, user_count=1), - ] - from app.application.dashboard import DashboardQueryService +@pytest.mark.skipif( + dashboard_endpoint is None, + reason="dashboard endpoint dependencies are missing", +) +def test_statistic_all_episode_missing(): + mocked_stats = [ + schemas.Statistic(movie_count=10, tv_count=20, episode_count=None, user_count=2), + schemas.Statistic(movie_count=1, tv_count=2, episode_count=None, user_count=1), + ] + from app.application.dashboard import DashboardQueryService - service = DashboardQueryService( - repository=self._Repository(), - media_statistics=lambda _name: mocked_stats, - ) - ret = dashboard_endpoint.statistic(name="all", service=service, _=None) + service = DashboardQueryService( + repository=_DashboardRepository(), + media_statistics=lambda _name: mocked_stats, + ) + ret = dashboard_endpoint.statistic(name="ugreen", service=service, _=None) - self.assertEqual(ret.movie_count, 11) - self.assertEqual(ret.tv_count, 22) - self.assertEqual(ret.user_count, 3) - self.assertEqual(ret.episode_count, 6) + assert ret.movie_count == 11 + assert ret.tv_count == 22 + assert ret.user_count == 3 + assert ret.episode_count is None + + +@pytest.mark.skipif( + dashboard_endpoint is None, + reason="dashboard endpoint dependencies are missing", +) +def test_statistic_mixed_episode_count(): + mocked_stats = [ + schemas.Statistic(movie_count=10, tv_count=20, episode_count=None, user_count=2), + schemas.Statistic(movie_count=1, tv_count=2, episode_count=6, user_count=1), + ] + from app.application.dashboard import DashboardQueryService + + service = DashboardQueryService( + repository=_DashboardRepository(), + media_statistics=lambda _name: mocked_stats, + ) + ret = dashboard_endpoint.statistic(name="all", service=service, _=None) + + assert ret.movie_count == 11 + assert ret.tv_count == 22 + assert ret.user_count == 3 + assert ret.episode_count == 6