From 34131baf0246b336c7d0fc7c8d78f50bfb062e2f Mon Sep 17 00:00:00 2001 From: jxxghp Date: Mon, 24 Aug 2026 11:01:51 +0800 Subject: [PATCH] refactor: unify plugin package version resolution --- app/adapters/external/market.py | 92 +++++++++---------- .../backend-architecture-next-stage.md | 11 ++- .../test_plugin_package_version_resolution.py | 80 ++++++++++++++++ 3 files changed, 135 insertions(+), 48 deletions(-) create mode 100644 tests/test_plugin_package_version_resolution.py diff --git a/app/adapters/external/market.py b/app/adapters/external/market.py index 77fd2befa..b1c9853a8 100644 --- a/app/adapters/external/market.py +++ b/app/adapters/external/market.py @@ -345,6 +345,32 @@ class PluginHelper(metaclass=WeakSingleton): return cls.is_plugin_info_compatible(plugin_info) return False + @staticmethod + def _package_version_candidates( + package_version: Optional[str], + ) -> Tuple[str, ...]: + """返回插件安装唯一的代际候选顺序,并去除重复的基础索引。""" + preferred_version = package_version or settings.VERSION_FLAG + candidates = [preferred_version] + candidates.extend( + VERSION_BACKWARD_COMPATIBLE_FLAGS.get(preferred_version, []) + ) + candidates.append("") + return tuple(dict.fromkeys(candidates)) + + @classmethod + def _select_compatible_package_version( + cls, + pid: str, + package_version: str, + plugins: Optional[Dict[str, dict]], + ) -> Optional[str]: + """从一个索引结果选择目标插件,并复用统一的代际兼容判定。""" + plugin = (plugins or {}).get(pid) + if plugin and cls.is_package_plugin_compatible(plugin, package_version): + return package_version + return None + @classmethod def check_plugin_system_version(cls, plugin_info: Optional[dict]) -> Tuple[bool, str]: """ @@ -775,29 +801,14 @@ class PluginHelper(metaclass=WeakSingleton): :param package_version: 首选插件版本 (如 "v2", "v3"),如不指定则默认使用系统配置的版本 :return: 返回可用的插件版本号 (如 "v2",如果指定版本不可用则返回空字符串表示 v1),如果插件不可用则返回 None """ - # 如果没有指定版本,则使用当前系统配置的版本(如 "v3") - if not package_version: - package_version = settings.VERSION_FLAG - - # 优先检查指定索引;即使显式指定 V2,也必须尊重 v3:false 排除标志。 - plugin = (self.get_plugins(repo_url, package_version) or {}).get(pid) - if plugin and self.is_package_plugin_compatible( - plugin, package_version - ): - return package_version - - # V3 临时默认接纳 V2 专用索引,v3:false 的 V3 专用副本旧条目除外。 - for backward_flag in VERSION_BACKWARD_COMPATIBLE_FLAGS.get(package_version, []): - plugin = (self.get_plugins(repo_url, backward_flag) or {}).get(pid) - if plugin and self.is_package_plugin_compatible( - plugin, backward_flag - ): - return backward_flag - - # 默认索引只接纳声明 v2:true 或当前版本兼容的共享实现。 - plugin = (self.get_plugins(repo_url) or {}).get(pid, None) - if plugin and self.is_package_plugin_compatible(plugin, ""): - return "" + for candidate in self._package_version_candidates(package_version): + selected = self._select_compatible_package_version( + pid=pid, + package_version=candidate, + plugins=self.get_plugins(repo_url, candidate or None), + ) + if selected is not None: + return selected # 如果所有版本都不存在或插件不兼容,返回 None,表示插件不可用 return None @@ -2159,30 +2170,17 @@ class PluginHelper(metaclass=WeakSingleton): """ 异步版本的获取插件版本方法,功能同 get_plugin_package_version """ - if not package_version: - package_version = settings.VERSION_FLAG - - plugin = ( - await self.async_get_plugins(repo_url, package_version) or {} - ).get(pid) - if plugin and self.is_package_plugin_compatible( - plugin, package_version - ): - return package_version - - # 异步安装链路与同步链路使用相同的 V2 默认兼容规则。 - for backward_flag in VERSION_BACKWARD_COMPATIBLE_FLAGS.get(package_version, []): - plugin = ( - await self.async_get_plugins(repo_url, backward_flag) or {} - ).get(pid) - if plugin and self.is_package_plugin_compatible( - plugin, backward_flag - ): - return backward_flag - - plugin = (await self.async_get_plugins(repo_url) or {}).get(pid, None) - if plugin and self.is_package_plugin_compatible(plugin, ""): - return "" + for candidate in self._package_version_candidates(package_version): + selected = self._select_compatible_package_version( + pid=pid, + package_version=candidate, + plugins=await self.async_get_plugins( + repo_url, + candidate or None, + ), + ) + if selected is not None: + return selected return None diff --git a/docs/refactor/backend-architecture-next-stage.md b/docs/refactor/backend-architecture-next-stage.md index 6ed35eeed..1dd7c723b 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 已收口媒体服务器数据端口;阶段 17 已收口下载数据端口;阶段 18 已收口主订阅数据端口;阶段 19 已收口整理数据端口;阶段 20 已收口 Agent 数据端口;阶段 21 已收口监控历史端口;阶段 22 已统一服务配置应用边界;阶段 23 已补齐媒体服务器 API 遗留的类形配置读取路径;阶段 24 已清除 Scheduler 内部无 owner 的协程提交双轨;阶段 25 已补齐 TaskRegistry 跨线程 owner 并迁移整理 AI 接管;阶段 26 已统一 Agent 会话清理提交;阶段 27 已统一历史 AI 进度 owner;阶段 28 已托管旧插件订阅统计线程;阶段 29 已统一 Emby 系条目转换并清零重复代码白名单;阶段 30 已收口插件市场请求级子任务;阶段 31 已托管搜索 AI 推荐任务;阶段 32 已清除事件调度器绕过生命周期 owner 的投递回退;阶段 33 已统一宿主 Agent 运行时的获取路径;阶段 34 已统一 durable-required 事件与 Outbox topic 事实源;阶段 35 已统一 LLM provider 管理 API 的运行时解析路径;阶段 36 已统一 WebAgent 音频能力访问边界;阶段 37 已统一插件输入事件发布路径;阶段 38 已统一 WebAgent 通知事件监听与队列边界;阶段 39 已补齐搜索 SSE 断线时的上游任务清理;阶段 40 已补齐异步防抖取消的终态所有权;阶段 41 已统一优雅重启兜底线程的唯一所有权;阶段 42 已补齐 Telegram typing 的多实例隔离和终态 owner;阶段 43 已统一 Discord typing 的异步 owner 和 shutdown 收尾;阶段 44 已清除 WebAgent 测试临时事件循环提前关闭产生的 CI 红注解;阶段 45 已统一影视与字幕搜索的请求级逐页任务编排;阶段 46 已收口启动性能门禁的托管 runner 假失败与诊断输出;阶段 47 已补齐 Agent 渠道流式刷新任务的重入 owner;阶段 48 已统一工件上传 action 的 Node 24 主版本。 +> 实施进度:阶段 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 已收口下载数据端口;阶段 18 已收口主订阅数据端口;阶段 19 已收口整理数据端口;阶段 20 已收口 Agent 数据端口;阶段 21 已收口监控历史端口;阶段 22 已统一服务配置应用边界;阶段 23 已补齐媒体服务器 API 遗留的类形配置读取路径;阶段 24 已清除 Scheduler 内部无 owner 的协程提交双轨;阶段 25 已补齐 TaskRegistry 跨线程 owner 并迁移整理 AI 接管;阶段 26 已统一 Agent 会话清理提交;阶段 27 已统一历史 AI 进度 owner;阶段 28 已托管旧插件订阅统计线程;阶段 29 已统一 Emby 系条目转换并清零重复代码白名单;阶段 30 已收口插件市场请求级子任务;阶段 31 已托管搜索 AI 推荐任务;阶段 32 已清除事件调度器绕过生命周期 owner 的投递回退;阶段 33 已统一宿主 Agent 运行时的获取路径;阶段 34 已统一 durable-required 事件与 Outbox topic 事实源;阶段 35 已统一 LLM provider 管理 API 的运行时解析路径;阶段 36 已统一 WebAgent 音频能力访问边界;阶段 37 已统一插件输入事件发布路径;阶段 38 已统一 WebAgent 通知事件监听与队列边界;阶段 39 已补齐搜索 SSE 断线时的上游任务清理;阶段 40 已补齐异步防抖取消的终态所有权;阶段 41 已统一优雅重启兜底线程的唯一所有权;阶段 42 已补齐 Telegram typing 的多实例隔离和终态 owner;阶段 43 已统一 Discord typing 的异步 owner 和 shutdown 收尾;阶段 44 已清除 WebAgent 测试临时事件循环提前关闭产生的 CI 红注解;阶段 45 已统一影视与字幕搜索的请求级逐页任务编排;阶段 46 已收口启动性能门禁的托管 runner 假失败与诊断输出;阶段 47 已补齐 Agent 渠道流式刷新任务的重入 owner;阶段 48 已统一工件上传 action 的 Node 24 主版本;阶段 49 已统一插件安装的同步/异步代际解析事实源。 ## 当前复核结论(2026-08-24) @@ -514,6 +514,15 @@ 默认归档上传语义均保持不变。架构 CI 静态合同枚举所有入口并锁定统一主版本,防止后续再次分叉。 - 本阶段只修改主仓工作流与治理合同,不涉及运行时、构建产物格式、SDK/Compat、V1/V2/V3 插件 ABI 或插件仓。 +### 长期整改阶段 49:插件包代际解析双轨统一(2026-08-24) + +- `PluginHelper.get_plugin_package_version()` 与异步版本原先分别手写当前代、向后兼容代和共享索引选择; + V3 临时兼容 V2 的顺序与 `v3:false` 排除规则因此有两处漂移风险,V1 空版本还会重复读取基础索引。 +- 同步和异步入口现在共用 `_package_version_candidates()` 与 `_select_compatible_package_version()`:候选顺序、 + 去重和兼容判定只有一份,网络 I/O 仍分别走原同步/异步适配器并在首个命中处停止。 +- 回归测试覆盖 V3、显式 V2、V1 基础索引去重,以及同步/异步访问顺序和结果一致。公开类路径、方法签名、 + 返回的 `v3`/`v2`/空字符串/`None` 语义、安装流程、SDK/Compat 和 V1/V2/V3 插件合同均未改变;未修改插件仓。 + ### 总体判断 当前架构总体合理,已经从跨层混合的遗留单体收敛为**边界清晰的模块化单体**: diff --git a/tests/test_plugin_package_version_resolution.py b/tests/test_plugin_package_version_resolution.py new file mode 100644 index 000000000..5eda57dad --- /dev/null +++ b/tests/test_plugin_package_version_resolution.py @@ -0,0 +1,80 @@ +"""插件市场同步与异步包代际解析的统一合同测试。""" + +from types import SimpleNamespace + +import pytest + +from app.adapters.external import market +from app.adapters.external.market import PluginHelper + + +@pytest.mark.parametrize( + ("configured_version", "requested_version", "expected"), + [ + ("v3", None, ("v3", "v2", "")), + ("v3", "v2", ("v2", "")), + ("", None, ("",)), + ], +) +def test_package_version_candidates_have_one_canonical_order( + monkeypatch, + configured_version: str, + requested_version: str | None, + expected: tuple[str, ...], +) -> None: + """显式版本、向后兼容版本和基础索引必须由一个有序事实源产生。""" + monkeypatch.setattr( + market, + "settings", + SimpleNamespace(VERSION_FLAG=configured_version), + ) + + assert PluginHelper._package_version_candidates(requested_version) == expected + + +@pytest.mark.asyncio +async def test_sync_and_async_package_resolution_visit_same_candidates( + monkeypatch, +) -> None: + """同步与异步安装必须按相同顺序停止在首个兼容插件索引。""" + monkeypatch.setattr( + market, + "settings", + SimpleNamespace(VERSION_FLAG="v3"), + ) + helper = PluginHelper.__new__(PluginHelper) + indexes = { + "v3": {}, + "v2": {"CompatiblePlugin": {"version": "1.0.0"}}, + None: {"CompatiblePlugin": {"version": "0.9.0", "v2": True}}, + } + sync_candidates: list[str | None] = [] + async_candidates: list[str | None] = [] + + def get_plugins(_repo_url: str, package_version: str | None = None) -> dict: + """记录同步入口访问顺序并返回固定索引。""" + sync_candidates.append(package_version) + return indexes[package_version] + + async def async_get_plugins( + _repo_url: str, + package_version: str | None = None, + ) -> dict: + """记录异步入口访问顺序并返回同一份固定索引。""" + async_candidates.append(package_version) + return indexes[package_version] + + monkeypatch.setattr(helper, "get_plugins", get_plugins) + monkeypatch.setattr(helper, "async_get_plugins", async_get_plugins) + + sync_result = helper.get_plugin_package_version( + "CompatiblePlugin", + "https://github.com/example/plugins", + ) + async_result = await helper.async_get_plugin_package_version( + "CompatiblePlugin", + "https://github.com/example/plugins", + ) + + assert sync_result == async_result == "v2" + assert sync_candidates == async_candidates == ["v3", "v2"]