From f93ecf65d33caf8306452f2ddd258500276d3700 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Mon, 24 Aug 2026 04:36:56 +0800 Subject: [PATCH] refactor: unify download chain data ports --- app/chain/download.py | 16 ++++++------ .../backend-architecture-next-stage.md | 10 ++++++- docs/rules/05-architecture.md | 2 +- tests/test_architecture_dependencies.py | 21 +++++++++++++++ tests/test_download_chain.py | 26 +++++++++++++++---- tests/test_music_download.py | 5 +++- 6 files changed, 64 insertions(+), 16 deletions(-) diff --git a/app/chain/download.py b/app/chain/download.py index 6bda0f18d..be1eccedd 100644 --- a/app/chain/download.py +++ b/app/chain/download.py @@ -30,9 +30,9 @@ from app.domain.meta.metabase import MetaBase from app.domain.meta.metamusic import MetaMusic from app.domain.metainfo import MetaInfo from app.application.chain.data import ( - DownloadFailurePortProxy as DownloadFailureOper, - DownloadHistoryPortProxy as DownloadHistoryOper, - MediaServerPortProxy as MediaServerOper, + get_chain_download_failure_port, + get_chain_download_history_port, + get_chain_media_server_port, ) from app.application.directory import DirectoryHelper, validate_download_save_path from app.application.download.tasks import DownloadTaskService @@ -872,7 +872,7 @@ class DownloadChain(ChainBase): torrent = context.torrent_info site = getattr(torrent, "site", None) try: - DownloadFailureOper().record_failure( + get_chain_download_failure_port().record_failure( fingerprint=fingerprint, now_time=now_time, next_retry_at=next_retry_at, @@ -918,7 +918,7 @@ class DownloadChain(ChainBase): return {} now_time = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()) try: - return DownloadFailureOper().get_active_by_fingerprints( + return get_chain_download_failure_port().get_active_by_fingerprints( fingerprints=fingerprints, now_time=now_time, ) except Exception as err: @@ -1291,7 +1291,7 @@ class DownloadChain(ChainBase): publish=lambda payload: self.eventmanager.send_event(EventType.DownloadAdded, payload), ) return - downloadhis = DownloadHistoryOper() + downloadhis = get_chain_download_history_port() downloadhis.add(**history_payload) if files_to_add: downloadhis.add_files(files_to_add) @@ -2075,7 +2075,7 @@ class DownloadChain(ChainBase): if not totals: totals = {} - mediaserver = MediaServerOper() + mediaserver = get_chain_media_server_port() if mediainfo.type == MediaType.MOVIE: # 电影 itemid = mediaserver.get_item_id(mtype=mediainfo.type.value, @@ -2263,7 +2263,7 @@ class DownloadChain(ChainBase): """构造绑定当前下载器能力与历史仓储的任务服务。""" return DownloadTaskService( list_torrents=self.list_torrents, - get_history_by_hashes=DownloadHistoryOper().get_by_hashes, + get_history_by_hashes=get_chain_download_history_port().get_by_hashes, start_torrents=self.start_torrents, stop_torrents=self.stop_torrents, remove_torrents=self.remove_torrents, diff --git a/docs/refactor/backend-architecture-next-stage.md b/docs/refactor/backend-architecture-next-stage.md index 1217d9bdb..f9fd19b3a 100644 --- a/docs/refactor/backend-architecture-next-stage.md +++ b/docs/refactor/backend-architecture-next-stage.md @@ -6,7 +6,7 @@ > 审计范围:宿主后端;排除 `app/plugins/**` 运行时插件副本 > 规范优先级:`AGENTS.md` 与 `docs/rules/` 高于本文 > 相关文档:`docs/architecture-overview.md`、`docs/refactor/backend-architecture-governance.md`、`docs/refactor/backend-module-refactor-compatibility.md` -> 实施进度:阶段 0~6 的宿主架构能力已完成收口;API/Application 公共复杂度基线已清零,启动组合根的 SystemConfigOper 构造点已由 14 降至 1;API 进程内后台任务已完成首批统一登记,插件仓适配和 Outbox 外围扩展仍按风险切片推进。Model/Base 查询与写装饰器、legacy 隐式会话外壳均已清零,插件 SDK 也不再导出宿主 Model。2026-08-23 的长期整改阶段 0 已恢复宿主、启动性能、官方插件和 SDK 契约门禁的可信基线;阶段 1a 已补齐 TaskRegistry owner 零债务门禁和诚实的关停超时语义;阶段 1b1 已收口整理 worker、pending 回放、失败通知、进程内 AI 重试、插件监控与事件投递的生命周期所有权;2026-08-24 的阶段 2 已将 212 个已观察宿主模块方法的 legacy aggregation 清零,并补齐可执行 fanout 与下载器文件 DTO 边界;阶段 3 已将消息交互和远程命令的订阅删除统一到 Application/UoW/outbox,宿主不再调用裸线程统计入口;阶段 4 已统一七种消息渠道的宿主回环与后台执行边界;阶段 5 已补齐事件窗口聚合任务的生命周期所有权;阶段 6 已统一插件文件操作的取消完成语义;阶段 7 已统一插件协程补偿的终态等待;阶段 8 已统一宿主同步函数的异步线程池入口;阶段 9 已统一工作流运行时的宿主获取路径;阶段 10 已统一模块、插件与调度运行时的显式 getter 调用;阶段 11 已清除系统配置 getter 的 Oper 形别名;阶段 12 已完成工作流域的显式 Chain 数据端口迁移;阶段 13 已收口用户、交互与消息链的数据端口;阶段 14 已收口音乐订阅数据端口;阶段 15 已收口站点数据端口;阶段 16 已收口媒体服务器数据端口。 +> 实施进度:阶段 0~6 的宿主架构能力已完成收口;API/Application 公共复杂度基线已清零,启动组合根的 SystemConfigOper 构造点已由 14 降至 1;API 进程内后台任务已完成首批统一登记,插件仓适配和 Outbox 外围扩展仍按风险切片推进。Model/Base 查询与写装饰器、legacy 隐式会话外壳均已清零,插件 SDK 也不再导出宿主 Model。2026-08-23 的长期整改阶段 0 已恢复宿主、启动性能、官方插件和 SDK 契约门禁的可信基线;阶段 1a 已补齐 TaskRegistry owner 零债务门禁和诚实的关停超时语义;阶段 1b1 已收口整理 worker、pending 回放、失败通知、进程内 AI 重试、插件监控与事件投递的生命周期所有权;2026-08-24 的阶段 2 已将 212 个已观察宿主模块方法的 legacy aggregation 清零,并补齐可执行 fanout 与下载器文件 DTO 边界;阶段 3 已将消息交互和远程命令的订阅删除统一到 Application/UoW/outbox,宿主不再调用裸线程统计入口;阶段 4 已统一七种消息渠道的宿主回环与后台执行边界;阶段 5 已补齐事件窗口聚合任务的生命周期所有权;阶段 6 已统一插件文件操作的取消完成语义;阶段 7 已统一插件协程补偿的终态等待;阶段 8 已统一宿主同步函数的异步线程池入口;阶段 9 已统一工作流运行时的宿主获取路径;阶段 10 已统一模块、插件与调度运行时的显式 getter 调用;阶段 11 已清除系统配置 getter 的 Oper 形别名;阶段 12 已完成工作流域的显式 Chain 数据端口迁移;阶段 13 已收口用户、交互与消息链的数据端口;阶段 14 已收口音乐订阅数据端口;阶段 15 已收口站点数据端口;阶段 16 已收口媒体服务器数据端口;阶段 17 已收口下载数据端口。 ## 当前复核结论(2026-08-24) @@ -187,6 +187,14 @@ - 兼容边界不变:数据库 `MediaServerOper`、Proxy 类、媒体库同步公开方法、进度合同、停止语义和插件调用 方式均未修改。 +### 长期整改阶段 17:下载 Chain 数据端口收口(2026-08-24) + +- `DownloadChain` 原先把下载失败、下载历史和媒体服务器三个 `*PortProxy` 别名为数据库 Oper,用于失败 + 抑制、重复下载查询、下载结果结算和媒体库判重;现在全部统一调用对应 `get_chain_*_port()`。 +- 五个下载/音乐测试接缝改为替换命名 getter,架构门禁禁止下载 Chain 重新导入这三个 Proxy。 +- 兼容边界不变:三个 DB Oper、Proxy 类、下载公开入口、失败指纹、历史 hash 查询、媒体库判重和插件 + 调用合同均未修改。 + ### 总体判断 当前架构总体合理,已经从跨层混合的遗留单体收敛为**边界清晰的模块化单体**: diff --git a/docs/rules/05-architecture.md b/docs/rules/05-architecture.md index 063df2b21..a3b513c0e 100644 --- a/docs/rules/05-architecture.md +++ b/docs/rules/05-architecture.md @@ -131,7 +131,7 @@ Session. `app/db/adapters/` is the concrete persistence-adapter layer: it may depend on Application-owned Protocols, UoW/Session and Oper implementations. This deliberate dependency inversion is the only `DB implementation -> Application contract` direction; Application must remain free of DB imports. -Migrated workflow, user, interaction, messaging, music, site and media-server +Migrated workflow, user, interaction, messaging, music, site, media-server and download Chain consumers use the named `get_chain_*_port()` functions from `app/application/chain/data.py`; they must not alias migration-time `*PortProxy` classes back to database Oper names. Those proxy classes remain compatibility diff --git a/tests/test_architecture_dependencies.py b/tests/test_architecture_dependencies.py index 6237594fb..80c444757 100644 --- a/tests/test_architecture_dependencies.py +++ b/tests/test_architecture_dependencies.py @@ -501,6 +501,27 @@ def test_mediaserver_chain_uses_explicit_data_port_getter(): assert violations == [] +def test_download_chain_uses_explicit_data_port_getters(): + """下载链不得把三个迁移期 PortProxy 伪装成数据库 Oper。""" + path = APP_ROOT / "chain" / "download.py" + tree = ast.parse(path.read_text(encoding="utf-8-sig"), filename=str(path)) + forbidden = { + "DownloadFailurePortProxy", + "DownloadHistoryPortProxy", + "MediaServerPortProxy", + } + violations = [ + f"{path.relative_to(PROJECT_ROOT).as_posix()}:{node.lineno}:{alias.name}" + for node in ast.walk(tree) + if isinstance(node, ast.ImportFrom) + and node.module == "app.application.chain.data" + for alias in node.names + if alias.name in forbidden + ] + + assert violations == [] + + def test_plugin_components_do_not_reexport_legacy_abi_names(): """新插件组件只提供 canonical 能力,不得复制旧 Helper、Manager 或 Oper 导出。""" violations: list[str] = [] diff --git a/tests/test_download_chain.py b/tests/test_download_chain.py index 523c70f6c..72f426e04 100644 --- a/tests/test_download_chain.py +++ b/tests/test_download_chain.py @@ -142,7 +142,11 @@ def test_download_single_submits_download_added_to_background(monkeypatch): lambda _self: _download_dirs(), ) monkeypatch.setattr(download_module, "ThreadHelper", _FakeThreadHelper) - monkeypatch.setattr(download_module, "DownloadHistoryOper", _FakeDownloadHistoryOper) + monkeypatch.setattr( + download_module, + "get_chain_download_history_port", + _FakeDownloadHistoryOper, + ) monkeypatch.setattr(download_module, "TorrentHelper", _FakeTorrentHelper) chain = DownloadChain.__new__(DownloadChain) @@ -268,7 +272,11 @@ def test_download_single_persists_custom_words_snapshot(monkeypatch): lambda _self: _download_dirs(), ) monkeypatch.setattr(download_module, "ThreadHelper", _FakeThreadHelper) - monkeypatch.setattr(download_module, "DownloadHistoryOper", _CapturingDownloadHistoryOper) + monkeypatch.setattr( + download_module, + "get_chain_download_history_port", + _CapturingDownloadHistoryOper, + ) monkeypatch.setattr(download_module, "TorrentHelper", _FakeTorrentHelper) chain = DownloadChain.__new__(DownloadChain) @@ -791,7 +799,11 @@ def test_download_single_records_failure_cooldown_when_downloader_rejects(monkey lambda _self: _download_dirs(), ) monkeypatch.setattr(download_module, "TorrentHelper", _FakeTorrentHelper) - monkeypatch.setattr(download_module, "DownloadFailureOper", _CapturingDownloadFailureOper) + monkeypatch.setattr( + download_module, + "get_chain_download_failure_port", + _CapturingDownloadFailureOper, + ) monkeypatch.setattr(download_module.eventmanager, "send_event", lambda *args, **kwargs: None) chain = DownloadChain.__new__(DownloadChain) @@ -924,7 +936,11 @@ def test_batch_download_skips_failed_subscription_resource_and_tries_next(monkey next_retry_at="2026-01-02 03:04:05", )} - monkeypatch.setattr(download_module, "DownloadFailureOper", _ActiveDownloadFailureOper) + monkeypatch.setattr( + download_module, + "get_chain_download_failure_port", + _ActiveDownloadFailureOper, + ) chain = DownloadChain.__new__(DownloadChain) chain.download_single = MagicMock(return_value="hash") @@ -1157,7 +1173,7 @@ def test_downloading_includes_media_type_and_source_site(monkeypatch): monkeypatch.setattr(chain, "list_torrents", lambda **_kwargs: [torrent]) monkeypatch.setattr( download_module, - "DownloadHistoryOper", + "get_chain_download_history_port", lambda: SimpleNamespace(get_by_hashes=lambda _hashes: {torrent.hash: history}), ) diff --git a/tests/test_music_download.py b/tests/test_music_download.py index 1763d640c..9afe53708 100644 --- a/tests/test_music_download.py +++ b/tests/test_music_download.py @@ -217,7 +217,10 @@ def test_music_library_exists_uses_atomic_album_lookup(): mediaserver = Mock() mediaserver.get_item_id.return_value = "album-item-1" - with patch("app.chain.download.MediaServerOper", return_value=mediaserver): + with patch( + "app.chain.download.get_chain_media_server_port", + return_value=mediaserver, + ): exists, no_exists = chain.get_no_exists_info( meta=MetaMusic.from_music_info(album), mediainfo=album,