refactor: unify plugin install mode policy

This commit is contained in:
jxxghp
2026-08-24 11:41:42 +08:00
parent 4fb86f4ecc
commit df9b733d1e
3 changed files with 227 additions and 74 deletions
+121 -73
View File
@@ -1,5 +1,6 @@
import asyncio
from collections import deque
from dataclasses import dataclass
import importlib
import io
import json
@@ -79,6 +80,14 @@ VERSION_BACKWARD_COMPATIBLE_FLAGS: Dict[str, List[str]] = {
InstalledPluginsProvider = Callable[[], List[str]]
@dataclass(frozen=True)
class _RemotePluginInstallPlan:
"""描述远端插件内容准备模式,不持有同步或异步 I/O 实现。"""
release_tag: Optional[str]
fallback_to_filelist: bool
def _empty_installed_plugins() -> List[str]:
"""组合根尚未注入配置读取器时返回空安装清单。"""
return []
@@ -840,12 +849,61 @@ class PluginHelper(metaclass=WeakSingleton):
return self.__parse_plugin_release_response(pid, self._get_plugin_repo_releases(repo_url.rstrip("/")))
@staticmethod
def __has_installable_release_version(release_items: List[dict], release_version: str) -> bool:
def __has_installable_release_version(
release_items: Sequence[dict],
release_version: str,
) -> bool:
"""
指定版本必须来自已解析出的可安装 Release 列表,避免直接拼接任意 tag。
"""
return any(item.get("version") == release_version for item in release_items)
@classmethod
def _build_remote_plugin_install_plan(
cls,
pid: str,
meta: dict,
release_version: Optional[str] = None,
release_items: Sequence[dict] = (),
) -> Tuple[Optional[_RemotePluginInstallPlan], str]:
"""统一选择指定 Release、可回退当前 Release 或文件列表安装模式。"""
is_release = meta.get("release")
plugin_version = meta.get("version")
if release_version:
if not is_release:
return None, f"{pid} 未声明 Release 安装,无法安装指定版本"
if not cls.__has_installable_release_version(
release_items, release_version
):
return None, f"{pid} 未找到可安装的 Release 版本:{release_version}"
if release_version == plugin_version:
compatible, message = cls.check_plugin_system_version(meta)
if not compatible:
logger.debug(f"{pid} 插件系统版本兼容性检查失败:{message}")
return None, message
return _RemotePluginInstallPlan(
release_tag=f"{pid}_v{release_version}",
fallback_to_filelist=False,
), ""
compatible, message = cls.check_plugin_system_version(meta)
if not compatible:
logger.debug(f"{pid} 插件系统版本兼容性检查失败:{message}")
return None, message
if not is_release:
return _RemotePluginInstallPlan(
release_tag=None,
fallback_to_filelist=False,
), ""
if not plugin_version:
return None, f"未在插件清单中找到 {pid} 的版本号,无法进行 Release 安装"
return _RemotePluginInstallPlan(
release_tag=f"{pid}_v{plugin_version}",
fallback_to_filelist=True,
), ""
def get_plugin_package_version(self, pid: str, repo_url: str,
package_version: Optional[str] = None) -> Optional[str]:
"""
@@ -937,44 +995,39 @@ class PluginHelper(metaclass=WeakSingleton):
# 2. 决定安装方式(release 或文件列表)并执行统一安装流程。
meta = self.__get_plugin_meta(pid, repo_url, package_version)
# 是否使用 Release 打包。Release 缺失或资产不可用时仍保留文件列表兜底,
# 避免索引先发布、Actions 打包滞后导致插件短时间无法安装。
is_release = meta.get("release")
# 插件版本号
plugin_version = meta.get("version")
if release_version:
if not is_release:
return False, f"{pid} 未声明 Release 安装,无法安装指定版本"
if not self.__has_installable_release_version(
self.get_plugin_release_versions(pid, repo_url), release_version
):
return False, f"{pid} 未找到可安装的 Release 版本:{release_version}"
if release_version == plugin_version:
compatible, message = self.check_plugin_system_version(meta)
if not compatible:
logger.debug(f"{pid} 插件系统版本兼容性检查失败:{message}")
return False, message
release_tag = f"{pid}_v{release_version}"
release_items = (
self.get_plugin_release_versions(pid, repo_url)
if release_version
else []
)
plan, message = self._build_remote_plugin_install_plan(
pid=pid,
meta=meta,
release_version=release_version,
release_items=release_items,
)
if plan is None:
return False, message
release_tag = plan.release_tag
if release_tag and not plan.fallback_to_filelist:
def prepare_selected_release() -> Tuple[bool, str]:
return self.__install_from_release(pid, user_repo, release_tag)
return self.__install_from_release(
pid,
user_repo,
release_tag,
)
return self.__install_flow_sync(pid, force_install, prepare_selected_release, repo_url)
compatible, message = self.check_plugin_system_version(meta)
if not compatible:
logger.debug(f"{pid} 插件系统版本兼容性检查失败:{message}")
return False, message
if is_release:
# 使用 插件ID_插件版本号 作为 Release tag
if not plugin_version:
return False, f"未在插件清单中找到 {pid} 的版本号,无法进行 Release 安装"
# 拼接 release_tag
release_tag = f"{pid}_v{plugin_version}"
# 使用 release 进行安装
if release_tag:
# 当前索引 Release 失败时回退文件列表,避免发布产物短暂滞后阻断安装。
def prepare_release() -> Tuple[bool, str]:
ok, msg = self.__install_from_release(pid, user_repo, release_tag)
ok, msg = self.__install_from_release(
pid,
user_repo,
release_tag,
)
if ok:
return True, msg
logger.warning(f"{pid} Release 安装失败,回退文件列表安装:{msg}")
@@ -982,12 +1035,11 @@ class PluginHelper(metaclass=WeakSingleton):
return self.__prepare_content_via_filelist_sync(pid.lower(), user_repo, package_version)
return self.__install_flow_sync(pid, force_install, prepare_release, repo_url)
else:
# 未声明 release 打包的插件继续使用文件列表方式安装。
def prepare_filelist() -> Tuple[bool, str]:
return self.__prepare_content_via_filelist_sync(pid.lower(), user_repo, package_version)
# 未声明 release 打包的插件继续使用文件列表方式安装。
def prepare_filelist() -> Tuple[bool, str]:
return self.__prepare_content_via_filelist_sync(pid.lower(), user_repo, package_version)
return self.__install_flow_sync(pid, force_install, prepare_filelist, repo_url)
return self.__install_flow_sync(pid, force_install, prepare_filelist, repo_url)
def install_local(self, pid: str, repo_url: str = "", force_install: bool = False) -> Tuple[bool, str]:
"""
@@ -2959,42 +3011,39 @@ class PluginHelper(metaclass=WeakSingleton):
# 2. 统一异步安装流程(release 或文件列表)。
meta = await self.__async_get_plugin_meta(pid, repo_url, package_version)
# 是否使用 Release 打包;失败时兜底文件列表,保持同步/异步安装语义一致。
is_release = meta.get("release")
# 插件版本号
plugin_version = meta.get("version")
if release_version:
if not is_release:
return False, f"{pid} 未声明 Release 安装,无法安装指定版本"
release_items = await self.async_get_plugin_release_versions(pid, repo_url)
if not self.__has_installable_release_version(release_items, release_version):
return False, f"{pid} 未找到可安装的 Release 版本:{release_version}"
if release_version == plugin_version:
compatible, message = self.check_plugin_system_version(meta)
if not compatible:
logger.debug(f"{pid} 插件系统版本兼容性检查失败:{message}")
return False, message
release_tag = f"{pid}_v{release_version}"
release_items = (
await self.async_get_plugin_release_versions(pid, repo_url)
if release_version
else []
)
plan, message = self._build_remote_plugin_install_plan(
pid=pid,
meta=meta,
release_version=release_version,
release_items=release_items,
)
if plan is None:
return False, message
release_tag = plan.release_tag
if release_tag and not plan.fallback_to_filelist:
async def prepare_selected_release() -> Tuple[bool, str]:
return await self.__async_install_from_release(pid, user_repo, release_tag)
return await self.__async_install_from_release(
pid,
user_repo,
release_tag,
)
return await self.__install_flow_async(pid, force_install, prepare_selected_release, repo_url)
compatible, message = self.check_plugin_system_version(meta)
if not compatible:
logger.debug(f"{pid} 插件系统版本兼容性检查失败:{message}")
return False, message
if is_release:
# 使用 插件ID_插件版本号 作为 Release tag
if not plugin_version:
return False, f"未在插件清单中找到 {pid} 的版本号,无法进行 Release 安装"
# 拼接 release_tag
release_tag = f"{pid}_v{plugin_version}"
# 使用 release 进行安装
if release_tag:
# 当前索引 Release 失败时回退文件列表,保持同步与异步安装一致。
async def prepare_release() -> Tuple[bool, str]:
ok, msg = await self.__async_install_from_release(pid, user_repo, release_tag)
ok, msg = await self.__async_install_from_release(
pid,
user_repo,
release_tag,
)
if ok:
return True, msg
logger.warning(f"{pid} Release 安装失败,回退文件列表安装:{msg}")
@@ -3002,12 +3051,11 @@ class PluginHelper(metaclass=WeakSingleton):
return await self.__prepare_content_via_filelist_async(pid.lower(), user_repo, package_version)
return await self.__install_flow_async(pid, force_install, prepare_release, repo_url)
else:
# 未声明 release 打包的插件继续使用文件列表方式安装。
async def prepare_filelist() -> Tuple[bool, str]:
return await self.__prepare_content_via_filelist_async(pid.lower(), user_repo, package_version)
# 未声明 release 打包的插件继续使用文件列表方式安装。
async def prepare_filelist() -> Tuple[bool, str]:
return await self.__prepare_content_via_filelist_async(pid.lower(), user_repo, package_version)
return await self.__install_flow_async(pid, force_install, prepare_filelist, repo_url)
return await self.__install_flow_async(pid, force_install, prepare_filelist, repo_url)
async def __async_get_plugin_meta(self, pid: str, repo_url: str,
package_version: Optional[str]) -> dict:
@@ -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 主版本;阶段 49 已统一插件安装的同步/异步代际解析事实源;阶段 50 已统一插件市场 GitHub 请求降级策略;阶段 51 已统一插件索引请求与响应三态策略;阶段 52 已统一插件 Release 分页策略。
> 实施进度:阶段 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 已统一插件安装的同步/异步代际解析事实源;阶段 50 已统一插件市场 GitHub 请求降级策略;阶段 51 已统一插件索引请求与响应三态策略;阶段 52 已统一插件 Release 分页策略;阶段 53 已统一远端插件安装模式决策
## 当前复核结论(2026-08-24
@@ -551,6 +551,16 @@
payload 与 JSON 异常。仓库级共享缓存、强刷合并、TaskRegistry owner、取消传播、公开 Release 列表、
SDK/Compat 和 V1/V2/V3 插件 ABI 均未改变;未修改插件仓。
### 长期整改阶段 53:远端插件安装模式决策统一(2026-08-24)
- 同步 `install()` 与异步版本原先分别判定指定 Release、当前 Release 回退、文件列表安装、Release 版本
存在性和主系统版本限制;相同插件元数据可能因后续单侧修改而落入不同安装模式。
- `_build_remote_plugin_install_plan()` 现在唯一产出不可变 `_RemotePluginInstallPlan`:指定历史 Release 不回退,
指定当前 Release 继续校验系统版本,当前索引 Release 失败可回退,未声明 Release 使用文件列表。同步与异步
入口只负责各自索引/Release I/O 和内容准备实现。
- 回归测试锁定四种计划与拒绝文案,既有同步/异步安装测试继续覆盖备份、清理、下载、文件列表回退和依赖
安装顺序。公开安装方法、返回值、缓存、取消、SDK/Compat、V1/V2/V3 插件 ABI 均未改变;未修改插件仓。
### 总体判断
当前架构总体合理,已经从跨层混合的遗留单体收敛为**边界清晰的模块化单体**:
+95
View File
@@ -0,0 +1,95 @@
"""远端插件同步与异步安装模式共享策略测试。"""
from unittest.mock import patch
import pytest
from packaging.version import Version
from app.adapters.external.market import PluginHelper
@pytest.mark.parametrize(
("meta", "release_version", "release_items", "tag", "fallback", "error"),
[
({"release": False, "version": "1.2.3"}, None, [], None, False, ""),
({"release": True, "version": "1.2.3"}, None, [], "DemoPlugin_v1.2.3", True, ""),
(
{"release": True, "version": "1.2.3", "system_version": ">=9"},
"1.2.0",
[{"version": "1.2.0"}],
"DemoPlugin_v1.2.0",
False,
"",
),
(
{"release": True, "version": "1.2.3"},
"1.2.0",
[{"version": "1.2.3"}],
None,
False,
"DemoPlugin 未找到可安装的 Release 版本:1.2.0",
),
(
{"release": False, "version": "1.2.3"},
"1.2.0",
[{"version": "1.2.0"}],
None,
False,
"DemoPlugin 未声明 Release 安装,无法安装指定版本",
),
(
{"release": True},
None,
[],
None,
False,
"未在插件清单中找到 DemoPlugin 的版本号,无法进行 Release 安装",
),
],
)
def test_remote_plugin_install_plan_preserves_mode_contract(
meta: dict,
release_version: str | None,
release_items: list[dict],
tag: str | None,
fallback: bool,
error: str,
) -> None:
"""唯一计划器必须保留文件列表、当前 Release、指定 Release 与拒绝结果。"""
with patch.object(
PluginHelper,
"get_current_system_version",
return_value=Version("3.0.0"),
):
plan, message = PluginHelper._build_remote_plugin_install_plan(
pid="DemoPlugin",
meta=meta,
release_version=release_version,
release_items=release_items,
)
assert message == error
assert (plan.release_tag if plan else None) == tag
assert (plan.fallback_to_filelist if plan else False) is fallback
def test_current_release_keeps_system_version_admission() -> None:
"""指定当前索引版本仍须通过系统版本限制,旧 Release 则沿用历史兼容语义。"""
with patch.object(
PluginHelper,
"get_current_system_version",
return_value=Version("3.0.0"),
):
plan, message = PluginHelper._build_remote_plugin_install_plan(
pid="DemoPlugin",
meta={
"release": True,
"version": "1.2.3",
"system_version": ">=9",
},
release_version="1.2.3",
release_items=[{"version": "1.2.3"}],
)
assert plan is None
assert "MoviePilot 版本 >=9" in message