From ab51b5116ac5684a2c380bb3f5c00baf7b174eba Mon Sep 17 00:00:00 2001 From: jxxghp Date: Sun, 23 Aug 2026 00:27:49 +0800 Subject: [PATCH] refactor: migrate workflow actions to runtime config snapshot --- app/workflow/actions/add_subscribe.py | 5 +- app/workflow/actions/fetch_rss.py | 9 +- app/workflow/actions/scan_file.py | 10 +- .../backend-architecture-next-stage.md | 7 + .../configuration-debt-baseline.json | 5 +- tests/test_workflow_runtime_config.py | 131 ++++++++++++++++++ 6 files changed, 157 insertions(+), 10 deletions(-) create mode 100644 tests/test_workflow_runtime_config.py diff --git a/app/workflow/actions/add_subscribe.py b/app/workflow/actions/add_subscribe.py index b3d928e3d..be77beca3 100644 --- a/app/workflow/actions/add_subscribe.py +++ b/app/workflow/actions/add_subscribe.py @@ -1,6 +1,7 @@ from app.workflow.actions import BaseAction from app.chain.subscribe import SubscribeChain -from app.runtime.config import settings, global_vars +from app.application.configuration import get_chain_runtime_config_snapshot +from app.runtime.config import global_vars from app.domain.context import MediaInfo from app.application.chain.data import SubscribePortProxy as SubscribeOper from app.runtime.log import logger @@ -65,7 +66,7 @@ class AddSubscribeAction(BaseAction): season=mediainfo.season, media_source=mediainfo.media_source, media_id=mediainfo.media_id, - username=settings.SUPERUSER) + username=get_chain_runtime_config_snapshot().superuser) if sid: self._added_subscribes.append(sid) # 保存缓存 diff --git a/app/workflow/actions/fetch_rss.py b/app/workflow/actions/fetch_rss.py index a0c96b0a9..ff2bf8646 100644 --- a/app/workflow/actions/fetch_rss.py +++ b/app/workflow/actions/fetch_rss.py @@ -4,7 +4,8 @@ from pydantic import Field from app.workflow.actions import BaseAction from app.chain.media import MediaChain -from app.runtime.config import settings, global_vars +from app.application.configuration import get_chain_runtime_config_snapshot +from app.runtime.config import global_vars from app.domain.context import Context, TorrentInfo from app.domain.metainfo import MetaInfo from app.application.rss import RssHelper @@ -65,7 +66,11 @@ class FetchRssAction(BaseAction): headers["User-Agent"] = params.ua rss_items = RssHelper().parse(url=params.url, - proxy=settings.PROXY if params.proxy else None, + proxy=( + get_chain_runtime_config_snapshot().proxy + if params.proxy + else None + ), timeout=params.timeout, headers=headers) if rss_items is None or rss_items is False: diff --git a/app/workflow/actions/scan_file.py b/app/workflow/actions/scan_file.py index 2501c8fbb..061bfcc91 100644 --- a/app/workflow/actions/scan_file.py +++ b/app/workflow/actions/scan_file.py @@ -5,7 +5,8 @@ from pydantic import Field from app.workflow.actions import BaseAction from app.chain.storage import StorageChain -from app.runtime.config import global_vars, settings +from app.application.configuration import get_chain_runtime_config_snapshot +from app.runtime.config import global_vars from app.runtime.log import logger from app.schemas.workflow import ActionParams from app.schemas.workflow import ActionContext @@ -56,10 +57,15 @@ class ScanFileAction(BaseAction): self._has_error = True return context files = storagechain.list_files(fileitem, recursion=True) + runtime_config = get_chain_runtime_config_snapshot() + media_exts = ( + runtime_config.media_extensions + + runtime_config.subtitle_extensions + + runtime_config.audio_extensions + ) for file in files: if global_vars.is_workflow_stopped(workflow_id): break - media_exts = settings.RMT_MEDIAEXT + settings.RMT_SUBEXT + settings.RMT_AUDIOEXT if not file.extension or f".{file.extension.lower()}" not in media_exts: continue # 添加文件到队列,而不是目录 diff --git a/docs/refactor/backend-architecture-next-stage.md b/docs/refactor/backend-architecture-next-stage.md index 486ce6aaa..6a9c49632 100644 --- a/docs/refactor/backend-architecture-next-stage.md +++ b/docs/refactor/backend-architecture-next-stage.md @@ -903,6 +903,11 @@ OTel 初始化只能位于 Startup/Adapter;Domain/Application 只依赖 no-op- - Outbox dispatcher 的有限重试和 dead-letter 已分别接入 `scheduler.job.retry` 与 `scheduler.job.dead_letter`,只使用固定 `owner=outbox` 低基数标签;观测失败端口由 Startup 注入, Application 不依赖具体 OTel SDK。 +- 2026-08-23 增加 `compat.facade.hit` 低基数指标和 `observe_compat_facade()` 装饰器,覆盖 + `PluginManager`、`PluginHelper`、`MoviePilotServerHelper` 三个正式 V3 ABI 入口。装饰器保留同步/ + 异步 descriptor、原方法签名和对象身份,并同时记录公开方法与旧私有方法的命中,标签仅包含 + facade、稳定方法名、可见性和固定 ABI 来源,不包含插件/用户/媒体实例数据。专项测试验证三类 + Facade 的公开与私有命中,后续可按真实命中量和行为快照逐项内移算法。 - 专项测试覆盖 exporter 缺失、非法标签、全目录高基数审计、成功/失败 outcome、动态 URL 路由模板; 既有 API、Event、Module、Scheduler 与健康探针回归保持通过。 @@ -995,6 +1000,8 @@ MFA/Passkey 专项测试与架构门禁通过,密钥类配置仍保留在安 两个公开入口均降至 150 行预算内,复杂度基线移除对应债务项;订阅识别、音乐订阅、写入事务和搜索来源专项 共 280 项测试通过,架构、复杂度与异步阻塞门禁通过。 +2026-08-23 将工作流动作 `FetchRssAction`、`ScanFileAction` 和 `AddSubscribeAction` 接入 `ChainRuntimeConfig` 快照,分别移除代理、媒体后缀和超级用户的全局 `settings` 读取;保留动作公开入口与工作流上下文行为,新增快照注入测试覆盖。配置债务由 130 个文件降至 127 个文件,宿主依赖与配置基线已更新。 + **收口记录(2026-08-22)**:`reidentify_cache`、`nettest`、`scrape`、OpenAI `chat_completions/responses`、`get_logging` 和 Web Agent SSE 均改为稳定公开入口委托私有编排实现;四个消息交互 Handler 的公开方法也保留 ABI 并委托私有状态机。复杂度基线已清零,API/Application/Chain 入口预算、异步阻塞 ratchet 均通过;复杂度及兼容专项合计 252 项测试通过。 随后将 `TransferChain.do_transfer` 的公开入口收口为稳定兼容 Facade,先提取媒体身份规范化阶段,保留显式 `media_source/media_id` 校验、识别失败文案和所有原有调用参数;整理专项 80 项测试通过,复杂度基线移除该入口, diff --git a/tests/fixtures/architecture/configuration-debt-baseline.json b/tests/fixtures/architecture/configuration-debt-baseline.json index f2bf11038..8d04171f5 100644 --- a/tests/fixtures/architecture/configuration-debt-baseline.json +++ b/tests/fixtures/architecture/configuration-debt-baseline.json @@ -9,7 +9,7 @@ "root": "app" }, "settings_imports": { - "count": 130, + "count": 127, "files": [ "app/adapters/cache/backends.py", "app/adapters/cache/redis.py", @@ -136,10 +136,7 @@ "app/startup/modules_initializer.py", "app/startup/plugins_initializer.py", "app/startup/routers_initializer.py", - "app/workflow/actions/add_subscribe.py", "app/workflow/actions/fetch_medias.py", - "app/workflow/actions/fetch_rss.py", - "app/workflow/actions/scan_file.py", "app/workflow/actions/send_message.py" ] }, diff --git a/tests/test_workflow_runtime_config.py b/tests/test_workflow_runtime_config.py new file mode 100644 index 000000000..5cacaef9e --- /dev/null +++ b/tests/test_workflow_runtime_config.py @@ -0,0 +1,131 @@ +from datetime import datetime +from types import SimpleNamespace + +from app.application.configuration import ChainRuntimeConfig +from app.schemas import ActionContext +from app.schemas.context import MediaInfo +from app.schemas.types import MediaType +from app.workflow.actions.add_subscribe import AddSubscribeAction +from app.workflow.actions.fetch_rss import FetchRssAction +from app.workflow.actions.scan_file import ScanFileAction +from app.workflow.actions import add_subscribe as add_subscribe_module +from app.workflow.actions import fetch_rss as fetch_rss_module +from app.workflow.actions import scan_file as scan_file_module + + +def _runtime_config(**overrides): + """构造仅覆盖测试关注字段的 Chain 配置快照。""" + values = { + "media_extensions": (".mkv",), + "subtitle_extensions": (".srt",), + "audio_extensions": (".flac",), + "superuser": "snapshot-admin", + "proxy": {"https": "http://snapshot-proxy:7890"}, + } + values.update(overrides) + return ChainRuntimeConfig(**values) + + +def test_fetch_rss_reads_proxy_from_chain_snapshot(monkeypatch): + """RSS 动作应使用一次 Chain 快照中的代理,而不是全局 settings。""" + captured = {} + + class FakeRssHelper: + """记录 RSS 请求参数的测试替身。""" + + def parse(self, **kwargs): + captured.update(kwargs) + return [{ + "title": "Example", + "enclosure": "https://example.com/example.torrent", + "link": "https://example.com/details", + "size": 1, + "pubdate": datetime(2026, 1, 1), + }] + + monkeypatch.setattr(fetch_rss_module, "RssHelper", FakeRssHelper) + monkeypatch.setattr( + fetch_rss_module, + "get_chain_runtime_config_snapshot", + lambda: _runtime_config(), + ) + monkeypatch.setattr(fetch_rss_module.global_vars, "is_workflow_stopped", lambda _: False) + + FetchRssAction("rss").execute( + workflow_id=1, + params={"url": "https://example.com/rss.xml", "proxy": True}, + context=ActionContext(), + ) + + assert captured["proxy"] == {"https": "http://snapshot-proxy:7890"} + + +def test_scan_file_filters_extensions_from_chain_snapshot(monkeypatch): + """扫描动作应按快照后缀集合筛选媒体文件。""" + + class FakeStorageChain: + """返回固定文件列表的存储链测试替身。""" + + def get_file_item(self, storage, directory): + return SimpleNamespace(storage=storage, path=str(directory)) + + def list_files(self, fileitem, recursion=True): + return [ + SimpleNamespace(extension="mkv"), + SimpleNamespace(extension="txt"), + SimpleNamespace(extension="srt"), + ] + + monkeypatch.setattr(scan_file_module, "StorageChain", FakeStorageChain) + monkeypatch.setattr( + scan_file_module, + "get_chain_runtime_config_snapshot", + lambda: _runtime_config(), + ) + monkeypatch.setattr(scan_file_module.global_vars, "is_workflow_stopped", lambda _: False) + + context = ScanFileAction("scan").execute( + workflow_id=1, + params={"storage": "local", "directory": "/library"}, + context=ActionContext(), + ) + + assert [item.extension for item in context.fileitems] == ["mkv", "srt"] + + +def test_add_subscribe_uses_superuser_from_chain_snapshot(monkeypatch): + """添加订阅动作应将快照中的超级管理员传给订阅链。""" + captured = {} + + class FakeSubscribeChain: + """记录订阅新增参数的测试替身。""" + + def exists(self, _mediainfo): + return False + + def add(self, **kwargs): + captured.update(kwargs) + return 42, "ok" + + monkeypatch.setattr(add_subscribe_module, "SubscribeChain", FakeSubscribeChain) + monkeypatch.setattr( + add_subscribe_module, + "get_chain_runtime_config_snapshot", + lambda: _runtime_config(superuser="snapshot-owner"), + ) + monkeypatch.setattr(add_subscribe_module.global_vars, "is_workflow_stopped", lambda _: False) + monkeypatch.setattr( + add_subscribe_module, + "SubscribeOper", + lambda: SimpleNamespace(get=lambda sid: sid), + ) + + AddSubscribeAction("subscribe").execute( + workflow_id=1, + params={}, + context=ActionContext( + medias=[MediaInfo(type=MediaType.MOVIE, title="Example", year="2026")] + ), + ) + + assert captured["username"] == "snapshot-owner"