refactor(architecture): 修复模块依赖违规并强化架构守护

- 字幕编排上移 DownloadChain.download_site_subtitles,SubtitleModule 仅保留站点链接解析
- TransferChain.recommend_name 上移 TV episodes_info 获取,filemanager 模块不再导入 TmdbChain
- endpoint 穿透修复:WXBizMsgCrypt3 迁至 adapters/external/wechat_crypt.py;
  music/tmdb 缓存管理、listenbrainz 常量、TMDbException、WechatClawBot 辅助统一经 chain 包装
- RuleParser 与 builtin_rules 合并为 application/filter_rules.py;
  fsproxy/fsworker 迁至 adapters/system/
- chain/__init__.py 删除 qbittorrentapi/transmission_rpc 导入,消除后端协议类型泄漏
- 架构守护测试新增三项检查:模块间隔离、入口层穿透、下载器 SDK 泄漏
- 文档同步:05-architecture.md 记录 DB/Oper 聚合例外与迁移文件位置,AGENTS.md 更新所有权表
This commit is contained in:
jxxghp
2026-08-16 04:59:17 +08:00
parent f7e39a47c6
commit 02f0dd0b9a
37 changed files with 630 additions and 450 deletions
+62
View File
@@ -428,3 +428,65 @@ def test_resource_adapter_does_not_restart_process():
set(modules),
)
assert "app.runtime.state" not in dependencies
def test_modules_do_not_import_other_modules_or_chain():
"""模块之间以及模块对链层的直接依赖被禁止,跨模块编排归链层。"""
modules = _discover_modules()
known_modules = set(modules)
violations: dict[str, set[str]] = {}
for module_name, path in modules.items():
if not module_name.startswith("app.modules."):
continue
own_package = module_name.split(".")[2]
dependencies = _resolve_imports(module_name, path, known_modules)
forbidden = {
dependency
for dependency in dependencies
if dependency.startswith("app.chain")
or (
dependency.startswith("app.modules.")
and dependency.split(".")[2] != own_package
)
}
if forbidden:
violations[module_name] = forbidden
assert violations == {}
def test_entrypoints_do_not_import_module_internals():
"""入口层不得穿透导入具体模块实现,应经由链层或应用服务。"""
modules = _discover_modules()
known_modules = set(modules)
entrypoint_roots = ("app.api", "app.agent", "app.monitor", "app.workflow", "app.doctor")
violations: dict[str, set[str]] = {}
for module_name, path in modules.items():
if not module_name.startswith(entrypoint_roots):
continue
dependencies = _resolve_imports(module_name, path, known_modules)
forbidden = {
dependency
for dependency in dependencies
if dependency.startswith("app.modules.")
}
if forbidden:
violations[module_name] = forbidden
assert violations == {}
def test_chain_does_not_import_downloader_sdks():
"""链层不得引入下载器后端协议类型,避免后端细节泄漏到编排层。"""
forbidden_sdks = {"qbittorrentapi", "transmission_rpc"}
violations: list[str] = []
for path in (APP_ROOT / "chain").rglob("*.py"):
tree = ast.parse(path.read_text(encoding="utf-8-sig"), filename=str(path))
for node in ast.walk(tree):
names: list[str] = []
if isinstance(node, ast.Import):
names.extend(alias.name for alias in node.names)
elif isinstance(node, ast.ImportFrom) and node.module and node.level == 0:
names.append(node.module)
if any(name.split(".")[0] in forbidden_sdks for name in names):
violations.append(str(path.relative_to(PROJECT_ROOT)))
break
assert violations == []
+7
View File
@@ -148,6 +148,7 @@ def test_download_single_submits_download_added_to_background(monkeypatch):
chain = DownloadChain.__new__(DownloadChain)
chain.download = MagicMock(return_value=("qb", "hash123", "Original", "添加下载成功"))
chain.download_added = MagicMock()
chain.download_site_subtitles = MagicMock()
chain.eventmanager = MagicMock()
chain.eventmanager.send_event.return_value = None
chain.post_message = MagicMock()
@@ -191,6 +192,11 @@ def test_download_single_submits_download_added_to_background(monkeypatch):
download_dir=Path("/downloads"),
torrent_content=b"torrent-content",
)
chain.download_site_subtitles.assert_called_once_with(
context=context,
download_dir=Path("/downloads"),
torrent_content=b"torrent-content",
)
def test_download_single_supplements_category_before_download_event(monkeypatch):
@@ -268,6 +274,7 @@ def test_download_single_persists_custom_words_snapshot(monkeypatch):
chain = DownloadChain.__new__(DownloadChain)
chain.download = MagicMock(return_value=("qb", "hash123", "Original", "添加下载成功"))
chain.download_added = MagicMock()
chain.download_site_subtitles = MagicMock()
chain.eventmanager = MagicMock()
chain.eventmanager.send_event.return_value = None
chain.post_message = MagicMock()
+5 -5
View File
@@ -17,7 +17,7 @@ from pathlib import Path
import pytest
from app.modules.filemanager.fsproxy import FileSystemProxy, FileSystemTimeout
from app.adapters.system.fsproxy import FileSystemProxy, FileSystemTimeout
@pytest.fixture
@@ -119,7 +119,7 @@ def test_timeout_raises_and_kills_worker(tmp_path, monkeypatch):
这是整个代理存在的意义:挂载不返回时,调用方必须在有限时间内拿到异常,
而且冻住的进程要被真正杀掉——不能像线程那样永久悬挂。
"""
import app.modules.filemanager.fsproxy as fsproxy_module
import app.adapters.system.fsproxy as fsproxy_module
# 用一个必定挂死的 worker 替身,模拟 stat 永不返回的挂载
stuck_worker = tmp_path / "stuck_worker.py"
@@ -154,7 +154,7 @@ def test_proxy_recovers_after_timeout(tmp_path, monkeypatch):
超时杀掉代理后,下一次调用要能用新代理正常工作——否则一次挂载抖动
就会让文件操作永久不可用。
"""
import app.modules.filemanager.fsproxy as fsproxy_module
import app.adapters.system.fsproxy as fsproxy_module
stuck_worker = tmp_path / "stuck_worker.py"
stuck_worker.write_text("import time\nwhile True:\n time.sleep(60)\n", encoding="utf-8")
@@ -195,7 +195,7 @@ def test_worker_does_not_import_app_package():
worker 必须只依赖标准库:一旦触发 app/__init__.py 的导入链,启动成本会从
毫秒级涨到秒级,代理被强杀后的重启就不再可行。
"""
worker = Path("app/modules/filemanager/fsworker.py").read_text(encoding="utf-8")
worker = Path("app/adapters/system/fsworker.py").read_text(encoding="utf-8")
assert "from app." not in worker
assert "import app" not in worker
@@ -206,7 +206,7 @@ def test_worker_runs_standalone_without_app_on_path(tmp_path):
直接执行 worker 脚本必须成功,且不依赖项目根在 sys.path 上
——这是「绕开 app 导入链」这一设计前提的实证。
"""
worker_path = Path("app/modules/filemanager/fsworker.py").resolve()
worker_path = Path("app/adapters/system/fsworker.py").resolve()
media = tmp_path / "x.mkv"
media.write_bytes(b"xyz")
+5 -5
View File
@@ -14,7 +14,7 @@ from pathlib import Path
import pytest
from app.modules.filemanager.fsproxy import FileSystemProxy, FileSystemTimeout
from app.adapters.system.fsproxy import FileSystemProxy, FileSystemTimeout
@pytest.fixture
@@ -105,7 +105,7 @@ def test_stalled_transfer_is_detected_and_killed(tmp_path, monkeypatch):
"""
关键区分之二:传输彻底不推进时必须被判定并强杀,而不是永久等待。
"""
import app.modules.filemanager.fsproxy as fsproxy_module
import app.adapters.system.fsproxy as fsproxy_module
# worker 替身:读到请求后完全不响应,模拟卡死在挂载上的传输
stuck = tmp_path / "stuck_worker.py"
@@ -148,7 +148,7 @@ def test_copy_falls_back_to_direct_when_disabled(tmp_path, monkeypatch):
"""
代理关闭时复制退回进程内直接执行,行为与引入代理之前一致。
"""
import app.modules.filemanager.fsproxy as fsproxy_module
import app.adapters.system.fsproxy as fsproxy_module
monkeypatch.setattr(fsproxy_module.settings, "FS_PROXY_ENABLED", False, raising=False)
src = tmp_path / "a.mkv"
@@ -169,7 +169,7 @@ def test_direct_copy_honours_cancel(tmp_path, monkeypatch):
"""
关闭代理时取消同样要生效,否则关掉开关就丢了取消能力。
"""
import app.modules.filemanager.fsproxy as fsproxy_module
import app.adapters.system.fsproxy as fsproxy_module
monkeypatch.setattr(fsproxy_module.settings, "FS_PROXY_ENABLED", False, raising=False)
src = tmp_path / "a.mkv"
@@ -188,7 +188,7 @@ def test_worker_still_standalone_after_streaming_support():
加了流式协议之后 worker 仍须只依赖标准库——一旦引入 app 导入链,
强杀后的重启成本会从毫秒级涨到秒级,整个代理方案就不成立了。
"""
worker = Path("app/modules/filemanager/fsworker.py").read_text(encoding="utf-8")
worker = Path("app/adapters/system/fsworker.py").read_text(encoding="utf-8")
assert "from app." not in worker
assert "import app" not in worker
+1 -1
View File
@@ -253,7 +253,7 @@ def test_watchdog_survives_blocking_exists_in_real_rebuild(tmp_path, monkeypatch
return {"size": 0, "mtime": 0.0, "is_dir": True, "is_file": False}
monkeypatch.setattr(
"app.modules.filemanager.fsproxy.fsproxy.stat", blocking_stat
"app.adapters.system.fsproxy.fsproxy.stat", blocking_stat
)
monkeypatch.setattr("app.monitor.monitor.probe_path", lambda *_a, **_kw: False)
+9 -3
View File
@@ -314,7 +314,9 @@ def test_music_cache_endpoint_returns_management_statistics(monkeypatch):
"recognized": {"media_id": "rec-1", "title": "晴天"},
"unrecognized": {"media_id": "", "title": "未知曲目"},
})
monkeypatch.setattr(music_endpoint, "MusicBrainzCache", lambda: cache)
monkeypatch.setattr(
music_endpoint.MusicBrainzChain, "cache_items", staticmethod(cache.list_items)
)
response = asyncio.run(music_endpoint.music_recognition_cache(None))
@@ -328,7 +330,9 @@ def test_music_cache_endpoint_returns_management_statistics(monkeypatch):
def test_music_cache_delete_endpoint_reports_missing_item(monkeypatch):
"""删除接口应区分成功删除与缓存不存在。"""
cache = _build_music_cache({"existing": {"media_id": "rec-1"}})
monkeypatch.setattr(music_endpoint, "MusicBrainzCache", lambda: cache)
monkeypatch.setattr(
music_endpoint.MusicBrainzChain, "delete_cache", staticmethod(cache.delete)
)
deleted_response = asyncio.run(
music_endpoint.delete_music_recognition_cache("existing", None)
@@ -344,7 +348,9 @@ def test_music_cache_delete_endpoint_reports_missing_item(monkeypatch):
def test_music_cache_clear_endpoint_removes_all_items(monkeypatch):
"""清空接口应删除全部音乐识别缓存。"""
cache = _build_music_cache({"existing": {"media_id": "rec-1"}})
monkeypatch.setattr(music_endpoint, "MusicBrainzCache", lambda: cache)
monkeypatch.setattr(
music_endpoint.MusicBrainzChain, "clear_cache", staticmethod(cache.clear)
)
response = asyncio.run(music_endpoint.clear_music_recognition_cache(None))
+9 -3
View File
@@ -274,7 +274,9 @@ def test_tmdb_cache_endpoint_returns_management_statistics(monkeypatch):
"unrecognized": {"id": 0},
})
get_system_config = Mock(return_value=7)
monkeypatch.setattr(tmdb_endpoint, "TmdbCache", lambda: cache)
monkeypatch.setattr(
tmdb_endpoint.TmdbChain, "cache_items", staticmethod(cache.list_items)
)
monkeypatch.setattr(
tmdb_endpoint,
"SystemConfigOper",
@@ -298,7 +300,9 @@ def test_tmdb_cache_endpoint_returns_management_statistics(monkeypatch):
def test_tmdb_cache_delete_endpoint_reports_missing_item(monkeypatch):
"""删除接口应区分成功删除与缓存不存在。"""
cache = _build_tmdb_cache({"existing": {"id": 1}})
monkeypatch.setattr(tmdb_endpoint, "TmdbCache", lambda: cache)
monkeypatch.setattr(
tmdb_endpoint.TmdbChain, "delete_cache", staticmethod(cache.delete)
)
deleted_response = asyncio.run(
tmdb_endpoint.delete_tmdb_recognition_cache("existing", None)
@@ -314,7 +318,9 @@ def test_tmdb_cache_delete_endpoint_reports_missing_item(monkeypatch):
def test_tmdb_cache_clear_endpoint_removes_all_items(monkeypatch):
"""清空接口应删除全部识别缓存。"""
cache = _build_tmdb_cache({"existing": {"id": 1}})
monkeypatch.setattr(tmdb_endpoint, "TmdbCache", lambda: cache)
monkeypatch.setattr(
tmdb_endpoint.TmdbChain, "clear_cache", staticmethod(cache.clear)
)
response = asyncio.run(tmdb_endpoint.clear_tmdb_recognition_cache(None))
+1 -1
View File
@@ -5,7 +5,7 @@ from unittest.mock import patch
from app.domain.context import MediaInfo, TorrentInfo
from app.application.torrent import TorrentHelper
from app.modules.filter import FilterModule
from app.modules.filter.builtin_rules import BUILTIN_RULE_SET
from app.application.filter_rules import BUILTIN_RULE_SET
from app.adapters.system import rust as rust_accel