diff --git a/app/api/endpoints/plugin.py b/app/api/endpoints/plugin.py index 59e6843e..946b7a0f 100644 --- a/app/api/endpoints/plugin.py +++ b/app/api/endpoints/plugin.py @@ -347,6 +347,8 @@ async def plugin_releases( ) -> dict: """ 查询指定插件可直接安装的 GitHub Release 版本。 + + 市场元数据只读取请求仓库的当前 package,避免版本历史请求触发全部市场缓存读取。 """ if not repo_url: return { @@ -357,35 +359,32 @@ async def plugin_releases( } plugin_manager = PluginManager() - online_plugins = await plugin_manager.async_get_online_plugins(force=force) + market_plugins = await plugin_manager.async_get_plugins_from_market( + repo_url, settings.VERSION_FLAG, force + ) market_plugin = next( ( plugin - for plugin in online_plugins - if plugin.id == plugin_id and plugin.repo_url == repo_url + for plugin in market_plugins or [] + if plugin.id == plugin_id ), None, ) - if not market_plugin: + if not market_plugin and settings.VERSION_FLAG: + compatible_plugins = await plugin_manager.async_get_plugins_from_market( + repo_url, None, force + ) market_plugin = next( ( plugin - for plugin in online_plugins + for plugin in compatible_plugins or [] if plugin.id == plugin_id ), None, ) - installed_plugin = next( - ( - plugin - for plugin in plugin_manager.get_local_plugins() - if plugin.id == plugin_id and plugin.installed - ), - None, - ) latest_version = market_plugin.plugin_version if market_plugin else None - current_version = installed_plugin.plugin_version if installed_plugin else None + current_version = plugin_manager.get_local_plugin_version(plugin_id) if not getattr(market_plugin, "release", False): return { "release_supported": False, diff --git a/app/core/plugin.py b/app/core/plugin.py index 4bd4b71e..2959eb24 100644 --- a/app/core/plugin.py +++ b/app/core/plugin.py @@ -1199,8 +1199,6 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): """ if not settings.PLUGIN_MARKET: return [] - if force: - PluginHelper().get_plugin_release_versions.cache_clear() # 用于存储高于 v1 版本的插件(如 v2, v3 等) higher_version_plugins = [] @@ -1311,6 +1309,20 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): plugins.sort(key=lambda x: x.plugin_order if hasattr(x, "plugin_order") else 0) return plugins + def get_local_plugin_version(self, pid: str) -> Optional[str]: + """ + 获取指定已安装插件的本地版本,不触发全部插件的状态、页面和权限计算。 + + 插件类由运行期动态加载,旧插件可能未声明版本属性,因此缺失时返回 None。 + """ + installed_apps = SystemConfigOper().get(SystemConfigKey.UserInstalledPlugins) or [] + if pid not in installed_apps: + return None + plugin_class = self._plugins.get(pid) + if not plugin_class: + return None + return getattr(plugin_class, "plugin_version", None) + def get_local_repo_plugins(self) -> List[schemas.Plugin]: """ 获取本地插件仓库目录中的插件信息 @@ -1566,8 +1578,6 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): """ if not settings.PLUGIN_MARKET: return [] - if force: - await PluginHelper().async_get_plugin_release_versions.cache_clear() # 用于存储高于 v1 版本的插件(如 v2, v3 等) higher_version_plugins = [] diff --git a/app/helper/plugin.py b/app/helper/plugin.py index 667c0550..5623385e 100644 --- a/app/helper/plugin.py +++ b/app/helper/plugin.py @@ -51,6 +51,9 @@ class PluginHelper(metaclass=WeakSingleton): _base_url = "https://raw.githubusercontent.com/{user}/{repo}/main/" # 串行化运行期依赖安装,避免多个 pip 子进程和导入缓存刷新互相踩踏。 _pip_install_lock = threading.Lock() + # 同仓库的并发 Release 请求共享任务;事件循环参与键控,避免热重载或测试循环切换后复用失效任务。 + _release_task_lock = threading.Lock() + _release_tasks: Dict[Tuple[asyncio.AbstractEventLoop, str, bool], asyncio.Task] = {} # 这些包一旦被插件覆盖,最容易直接拖垮主程序启动,因此冲突提示需要单独高亮。 _protected_runtime_packages = frozenset({ "alembic", @@ -458,6 +461,27 @@ class PluginHelper(metaclass=WeakSingleton): releases.append(item) return releases + @staticmethod + def __normalize_plugin_release_response(payload) -> List[dict]: + """仅保留版本展示和资产匹配所需字段,控制仓库级缓存体积。""" + if not isinstance(payload, list): + return [] + return [ + { + "tag_name": release_info.get("tag_name"), + "name": release_info.get("name"), + "published_at": release_info.get("published_at"), + "body": release_info.get("body"), + "assets": [ + {"name": asset.get("name")} + for asset in release_info.get("assets") or [] + if isinstance(asset, dict) + ], + } + for release_info in payload + if isinstance(release_info, dict) + ] + @cached(maxsize=128, ttl=1800) def get_plugins(self, repo_url: str, package_version: Optional[str] = None) -> Optional[Dict[str, dict]]: @@ -486,12 +510,12 @@ class PluginHelper(metaclass=WeakSingleton): return None return self.__parse_plugin_index_response(res.text) - @cached(maxsize=128, ttl=1800) - def get_plugin_release_versions(self, pid: str, repo_url: str) -> List[dict]: + @cached(maxsize=32, ttl=1800, shared_key="get_plugin_repo_releases") + def _get_plugin_repo_releases(self, repo_url: str) -> Optional[List[dict]]: """ - 获取插件可安装的 GitHub Release 版本列表。 + 按仓库获取 GitHub Release 原始分页数据,供仓库内所有插件共享。 """ - if not pid or not repo_url: + if not repo_url: return [] user, repo = self.get_repo_info(repo_url) @@ -510,20 +534,32 @@ class PluginHelper(metaclass=WeakSingleton): is_api=True, ) if res is None or res.status_code != 200: - break + return None try: payload = res.json() if not payload: break - releases.extend(self.__parse_plugin_release_response(pid, payload)) + if not isinstance(payload, list): + return None + releases.extend(self.__normalize_plugin_release_response(payload)) if len(payload) < 100: break except Exception as e: - logger.error(f"解析插件 {pid} Release 列表失败:{e}") - break + logger.error(f"解析插件仓库 {repo_url} Release 列表失败:{e}") + return None return releases + def get_plugin_release_versions(self, pid: str, repo_url: str) -> List[dict]: + """ + 获取插件可安装的 GitHub Release 版本列表。 + + GitHub 分页结果按仓库缓存,插件 ID 只参与本地过滤,避免同仓库重复分页。 + """ + if not pid or not repo_url: + return [] + 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: """ @@ -2000,12 +2036,12 @@ class PluginHelper(metaclass=WeakSingleton): return None return self.__parse_plugin_index_response(res.text) - @cached(maxsize=128, ttl=1800) - async def async_get_plugin_release_versions(self, pid: str, repo_url: str) -> List[dict]: + @cached(maxsize=32, ttl=1800, shared_key="get_plugin_repo_releases") + async def _async_get_plugin_repo_releases(self, repo_url: str) -> Optional[List[dict]]: """ - 异步获取插件可安装的 GitHub Release 版本列表。 + 异步按仓库获取 GitHub Release 原始分页数据。 """ - if not pid or not repo_url: + if not repo_url: return [] user, repo = self.get_repo_info(repo_url) @@ -2024,20 +2060,85 @@ class PluginHelper(metaclass=WeakSingleton): is_api=True, ) if res is None or res.status_code != 200: - break + return None try: payload = res.json() if not payload: break - releases.extend(self.__parse_plugin_release_response(pid, payload)) + if not isinstance(payload, list): + return None + releases.extend(self.__normalize_plugin_release_response(payload)) if len(payload) < 100: break except Exception as e: - logger.error(f"解析插件 {pid} Release 列表失败:{e}") - break + logger.error(f"解析插件仓库 {repo_url} Release 列表失败:{e}") + return None return releases + async def async_get_plugin_release_versions(self, pid: str, repo_url: str) -> List[dict]: + """ + 异步获取插件可安装的 GitHub Release 版本列表。 + + 同一事件循环内,同仓库的并发读取和强制刷新共享一个请求任务。 + """ + if not pid or not repo_url: + return [] + + loop = asyncio.get_running_loop() + normalized_repo_url = repo_url.rstrip("/") + normal_task_key = (loop, normalized_repo_url, False) + force_task_key = (loop, normalized_repo_url, True) + with self._release_task_lock: + force_task = self._release_tasks.get(force_task_key) + if force_task and not force_task.done(): + task_key = force_task_key + task = force_task + elif is_fresh(): + pending_normal_task = self._release_tasks.get(normal_task_key) + if pending_normal_task and pending_normal_task.done(): + pending_normal_task = None + task_key = force_task_key + task = loop.create_task( + self._async_refresh_plugin_repo_releases(normalized_repo_url, pending_normal_task) + ) + self._release_tasks[task_key] = task + task.add_done_callback( + lambda completed_task: self._remove_release_task(task_key, completed_task) + ) + else: + task_key = normal_task_key + task = self._release_tasks.get(task_key) + if task is None or task.done(): + task = loop.create_task(self._async_get_plugin_repo_releases(normalized_repo_url)) + self._release_tasks[task_key] = task + task.add_done_callback( + lambda completed_task: self._remove_release_task(task_key, completed_task) + ) + + payload = await asyncio.shield(task) + return self.__parse_plugin_release_response(pid, payload) + + async def _async_refresh_plugin_repo_releases( + self, + repo_url: str, + pending_normal_task: Optional[asyncio.Task], + ) -> Optional[List[dict]]: + """等待在途普通读取落盘后执行强刷,确保旧结果不会覆盖强刷缓存。""" + if pending_normal_task: + try: + await asyncio.shield(pending_normal_task) + except (Exception, asyncio.CancelledError): + pass + return await self._async_get_plugin_repo_releases(repo_url) + + @classmethod + def _remove_release_task(cls, task_key: Tuple[asyncio.AbstractEventLoop, str, bool], task: asyncio.Task) -> None: + """请求任务完成后释放事件循环和仓库引用。""" + with cls._release_task_lock: + if cls._release_tasks.get(task_key) is task: + cls._release_tasks.pop(task_key, None) + async def __async_get_file_list(self, pid: str, user_repo: str, package_version: Optional[str] = None) -> \ Tuple[Optional[list], Optional[str]]: """ @@ -2665,3 +2766,10 @@ class PluginHelper(metaclass=WeakSingleton): except Exception as e: logger.error(f"解压 Release 压缩包失败:{e}") return False, f"解压 Release 压缩包失败:{e}" + + +# 公开 Release 查询的缓存管理统一指向仓库级分页缓存。 +PluginHelper.get_plugin_release_versions.cache_clear = PluginHelper._get_plugin_repo_releases.cache_clear +PluginHelper.get_plugin_release_versions.cache_region = PluginHelper._get_plugin_repo_releases.cache_region +PluginHelper.async_get_plugin_release_versions.cache_clear = PluginHelper._async_get_plugin_repo_releases.cache_clear +PluginHelper.async_get_plugin_release_versions.cache_region = PluginHelper._async_get_plugin_repo_releases.cache_region diff --git a/tests/test_plugin_endpoint.py b/tests/test_plugin_endpoint.py index f9082f94..d5883ef6 100644 --- a/tests/test_plugin_endpoint.py +++ b/tests/test_plugin_endpoint.py @@ -6,6 +6,7 @@ from app.api.endpoints.plugin import plugin_history from app.api.endpoints.plugin import plugin_releases from app.api.endpoints.plugin import reset_plugin from app.api.endpoints.system import sync_plugin_market_from_wiki +from app.core.config import settings from app.schemas.event import PluginDataResetEventData from app.schemas.types import ChainEventType @@ -75,14 +76,10 @@ def test_plugin_releases_returns_supported_versions_with_latest_and_current(monk repo_url="https://github.com/demo/plugins", release=True, ) - installed_plugin = schemas.Plugin( - id="DemoPlugin", - plugin_version="1.2.0", - installed=True, - ) plugin_manager = MagicMock() plugin_manager.async_get_online_plugins = AsyncMock(return_value=[market_plugin]) - plugin_manager.get_local_plugins.return_value = [installed_plugin] + plugin_manager.async_get_plugins_from_market = AsyncMock(return_value=[market_plugin]) + plugin_manager.get_local_plugin_version.return_value = "1.2.0" plugin_helper = MagicMock() plugin_helper.async_get_plugin_release_versions = AsyncMock(return_value=[ {"version": "1.2.3", "tag_name": "DemoPlugin_v1.2.3", "asset_name": "demoplugin_v1.2.3.zip"}, @@ -102,7 +99,11 @@ def test_plugin_releases_returns_supported_versions_with_latest_and_current(monk assert result["items"][0]["is_current"] is False assert result["items"][1]["is_latest"] is False assert result["items"][1]["is_current"] is True - plugin_manager.async_get_online_plugins.assert_awaited_once_with(force=False) + plugin_manager.async_get_plugins_from_market.assert_awaited_once_with( + "https://github.com/demo/plugins", settings.VERSION_FLAG, False + ) + plugin_manager.async_get_online_plugins.assert_not_awaited() + plugin_manager.get_local_plugins.assert_not_called() def test_plugin_releases_does_not_mutate_cached_release_items(monkeypatch): @@ -115,17 +116,12 @@ def test_plugin_releases_does_not_mutate_cached_release_items(monkeypatch): repo_url="https://github.com/demo/plugins", release=True, ) - installed_plugin = schemas.Plugin( - id="DemoPlugin", - plugin_version="1.2.0", - installed=True, - ) release_items = [ {"version": "1.2.3", "tag_name": "DemoPlugin_v1.2.3", "asset_name": "demoplugin_v1.2.3.zip"}, ] plugin_manager = MagicMock() - plugin_manager.async_get_online_plugins = AsyncMock(return_value=[market_plugin]) - plugin_manager.get_local_plugins.return_value = [installed_plugin] + plugin_manager.async_get_plugins_from_market = AsyncMock(return_value=[market_plugin]) + plugin_manager.get_local_plugin_version.return_value = "1.2.0" plugin_helper = MagicMock() plugin_helper.async_get_plugin_release_versions = AsyncMock(return_value=release_items) @@ -140,6 +136,39 @@ def test_plugin_releases_does_not_mutate_cached_release_items(monkeypatch): assert "is_current" not in release_items[0] +def test_plugin_releases_falls_back_to_compatible_base_package(monkeypatch): + """ + 当前版本 package 未包含插件时,再读取基础 package 兼容项,不扫描其他市场。 + """ + market_plugin = schemas.Plugin( + id="DemoPlugin", + plugin_version="1.2.3", + repo_url="https://github.com/demo/plugins", + release=True, + ) + plugin_manager = MagicMock() + plugin_manager.async_get_plugins_from_market = AsyncMock( + side_effect=[[], [market_plugin]] + ) + plugin_manager.get_local_plugin_version.return_value = None + plugin_helper = MagicMock() + plugin_helper.async_get_plugin_release_versions = AsyncMock(return_value=[]) + + with ( + patch("app.api.endpoints.plugin.PluginManager", return_value=plugin_manager), + patch("app.api.endpoints.plugin.PluginHelper", return_value=plugin_helper), + ): + result = asyncio.run( + plugin_releases("DemoPlugin", None, "https://github.com/demo/plugins", False) + ) + + assert result["latest_version"] == "1.2.3" + assert plugin_manager.async_get_plugins_from_market.await_args_list == [ + (("https://github.com/demo/plugins", settings.VERSION_FLAG, False), {}), + (("https://github.com/demo/plugins", None, False), {}), + ] + + def test_plugin_releases_uses_force_refresh_for_market_metadata(monkeypatch): """ release 列表接口沿用插件市场的 force 语义,供前端手动刷新时绕过缓存。 @@ -151,8 +180,8 @@ def test_plugin_releases_uses_force_refresh_for_market_metadata(monkeypatch): release=True, ) plugin_manager = MagicMock() - plugin_manager.async_get_online_plugins = AsyncMock(return_value=[market_plugin]) - plugin_manager.get_local_plugins.return_value = [] + plugin_manager.async_get_plugins_from_market = AsyncMock(return_value=[market_plugin]) + plugin_manager.get_local_plugin_version.return_value = None plugin_helper = MagicMock() plugin_helper.async_get_plugin_release_versions = AsyncMock(return_value=[]) @@ -163,8 +192,13 @@ def test_plugin_releases_uses_force_refresh_for_market_metadata(monkeypatch): result = asyncio.run(plugin_releases("DemoPlugin", None, "https://github.com/demo/plugins", True)) assert result["release_supported"] is False - plugin_manager.async_get_online_plugins.assert_awaited_once_with(force=True) - assert plugin_helper.async_get_plugin_release_versions.await_args.args == ("DemoPlugin", "https://github.com/demo/plugins") + plugin_manager.async_get_plugins_from_market.assert_awaited_once_with( + "https://github.com/demo/plugins", settings.VERSION_FLAG, True + ) + assert plugin_helper.async_get_plugin_release_versions.await_args.args == ( + "DemoPlugin", + "https://github.com/demo/plugins", + ) def test_plugin_releases_hides_items_when_market_plugin_does_not_enable_release(monkeypatch): @@ -178,8 +212,8 @@ def test_plugin_releases_hides_items_when_market_plugin_does_not_enable_release( release=False, ) plugin_manager = MagicMock() - plugin_manager.async_get_online_plugins = AsyncMock(return_value=[market_plugin]) - plugin_manager.get_local_plugins.return_value = [] + plugin_manager.async_get_plugins_from_market = AsyncMock(return_value=[market_plugin]) + plugin_manager.get_local_plugin_version.return_value = None plugin_helper = MagicMock() plugin_helper.async_get_plugin_release_versions = AsyncMock(return_value=[ {"version": "1.2.3", "tag_name": "DemoPlugin_v1.2.3", "asset_name": "demoplugin_v1.2.3.zip"}, diff --git a/tests/test_plugin_helper.py b/tests/test_plugin_helper.py index 2904ca62..9c76442a 100644 --- a/tests/test_plugin_helper.py +++ b/tests/test_plugin_helper.py @@ -245,7 +245,11 @@ class TestPluginHelper: }, ] helper = PluginHelper() - monkeypatch.setattr(helper, "_PluginHelper__request_with_fallback", lambda *_args, **_kwargs: _FakeTextResponse(200, payload)) + monkeypatch.setattr( + helper, + "_PluginHelper__request_with_fallback", + lambda *_args, **_kwargs: _FakeTextResponse(200, payload), + ) releases = helper.get_plugin_release_versions(PLUGIN_ID, REPO_URL) @@ -323,9 +327,177 @@ class TestPluginHelper: assert requested_pages == ["1", "2"] assert [item["version"] for item in releases] == ["1.2.0"] - def test_get_online_plugins_force_clears_release_cache(self, monkeypatch): + def test_get_plugin_release_versions_reuses_repository_pages_across_plugins(self, monkeypatch): """ - 插件市场缓存刷新会一并清理 Release 列表缓存,覆盖定时刷新服务入口。 + 同一仓库的不同插件共享 GitHub Release 分页结果,避免按插件 ID 重复请求。 + """ + try: + from app.helper.plugin import PluginHelper + except ModuleNotFoundError as exc: + pytest.skip(f"missing dependency: {exc}") + + payload = [ + { + "tag_name": "DemoPlugin_v1.2.3", + "assets": [{"name": "demoplugin_v1.2.3.zip", "id": 1}], + }, + { + "tag_name": "OtherPlugin_v2.0.0", + "assets": [{"name": "otherplugin_v2.0.0.zip", "id": 2}], + }, + ] + request_count = 0 + + def fake_request(*_args, **_kwargs): + nonlocal request_count + request_count += 1 + return _FakeTextResponse(200, payload) + + helper = PluginHelper() + helper.get_plugin_release_versions.cache_clear() + monkeypatch.setattr(helper, "_PluginHelper__request_with_fallback", fake_request) + + demo_releases = helper.get_plugin_release_versions("DemoPlugin", REPO_URL) + other_releases = helper.get_plugin_release_versions("OtherPlugin", REPO_URL) + + assert request_count == 1 + assert [item["version"] for item in demo_releases] == ["1.2.3"] + assert [item["version"] for item in other_releases] == ["2.0.0"] + + def test_async_get_plugin_release_versions_coalesces_forced_repository_requests(self, monkeypatch): + """ + 同一仓库的并发强制刷新共享一个请求任务,避免缓存失效瞬间放大 GitHub 请求。 + """ + try: + from app.core.cache import async_fresh + from app.helper.plugin import PluginHelper + except ModuleNotFoundError as exc: + pytest.skip(f"missing dependency: {exc}") + + payload = [ + { + "tag_name": "DemoPlugin_v1.2.3", + "assets": [{"name": "demoplugin_v1.2.3.zip", "id": 1}], + }, + { + "tag_name": "OtherPlugin_v2.0.0", + "assets": [{"name": "otherplugin_v2.0.0.zip", "id": 2}], + }, + ] + request_count = 0 + + async def fake_request(*_args, **_kwargs): + nonlocal request_count + request_count += 1 + await asyncio.sleep(0.01) + return _FakeTextResponse(200, payload) + + async def run_test(): + helper = PluginHelper() + await helper.async_get_plugin_release_versions.cache_clear() + monkeypatch.setattr(helper, "_PluginHelper__async_request_with_fallback", fake_request) + async with async_fresh(True): + return await asyncio.gather( + helper.async_get_plugin_release_versions("DemoPlugin", REPO_URL), + helper.async_get_plugin_release_versions("OtherPlugin", REPO_URL), + ) + + demo_releases, other_releases = asyncio.run(run_test()) + + assert request_count == 1 + assert [item["version"] for item in demo_releases] == ["1.2.3"] + assert [item["version"] for item in other_releases] == ["2.0.0"] + + def test_async_forced_release_refresh_does_not_reuse_normal_read_task(self, monkeypatch): + """强刷等待在途普通读取后再请求,最终缓存必须保留强刷结果。""" + try: + from app.core.cache import async_fresh + from app.helper.plugin import PluginHelper + except ModuleNotFoundError as exc: + pytest.skip(f"missing dependency: {exc}") + + old_payload = [{ + "tag_name": "DemoPlugin_v1.2.2", + "assets": [{"name": "demoplugin_v1.2.2.zip", "id": 1}], + }] + fresh_payload = [{ + "tag_name": "DemoPlugin_v1.2.3", + "assets": [{"name": "demoplugin_v1.2.3.zip", "id": 2}], + }] + first_request_started = asyncio.Event() + release_first_request = asyncio.Event() + request_count = 0 + + async def fake_request(*_args, **_kwargs): + nonlocal request_count + request_count += 1 + if request_count == 1: + first_request_started.set() + await release_first_request.wait() + return _FakeTextResponse(200, old_payload) + return _FakeTextResponse(200, fresh_payload) + + async def run_test(): + helper = PluginHelper() + await helper.async_get_plugin_release_versions.cache_clear() + monkeypatch.setattr(helper, "_PluginHelper__async_request_with_fallback", fake_request) + normal_task = asyncio.create_task( + helper.async_get_plugin_release_versions("DemoPlugin", REPO_URL) + ) + await first_request_started.wait() + async with async_fresh(True): + force_task = asyncio.create_task( + helper.async_get_plugin_release_versions("DemoPlugin", REPO_URL) + ) + await asyncio.sleep(0.01) + request_count_before_normal_finished = request_count + release_first_request.set() + normal_result, force_result = await asyncio.gather(normal_task, force_task) + cached_result = await helper.async_get_plugin_release_versions("DemoPlugin", REPO_URL) + return request_count_before_normal_finished, normal_result, force_result, cached_result + + request_count_before_normal_finished, normal_result, force_result, cached_result = asyncio.run(run_test()) + + assert request_count_before_normal_finished == 1 + assert [item["version"] for item in normal_result] == ["1.2.2"] + assert [item["version"] for item in force_result] == ["1.2.3"] + assert [item["version"] for item in cached_result] == ["1.2.3"] + assert request_count == 2 + + def test_failed_forced_release_refresh_preserves_cached_repository_payload(self, monkeypatch): + """GitHub 强刷失败时不以空值覆盖该仓库已有 Release 缓存。""" + try: + from app.core.cache import fresh + from app.helper.plugin import PluginHelper + except ModuleNotFoundError as exc: + pytest.skip(f"missing dependency: {exc}") + + payload = [{ + "tag_name": "DemoPlugin_v1.2.3", + "assets": [{"name": "demoplugin_v1.2.3.zip", "id": 1}], + }] + responses = [_FakeTextResponse(200, payload), None] + + def fake_request(*_args, **_kwargs): + return responses.pop(0) + + helper = PluginHelper() + helper.get_plugin_release_versions.cache_clear() + monkeypatch.setattr(helper, "_PluginHelper__request_with_fallback", fake_request) + + initial = helper.get_plugin_release_versions("DemoPlugin", REPO_URL) + with fresh(True): + failed_refresh = helper.get_plugin_release_versions("DemoPlugin", REPO_URL) + cached = helper.get_plugin_release_versions("DemoPlugin", REPO_URL) + + assert [item["version"] for item in initial] == ["1.2.3"] + assert failed_refresh == [] + assert [item["version"] for item in cached] == ["1.2.3"] + assert responses == [] + + def test_get_online_plugins_force_keeps_release_cache_scoped(self, monkeypatch): + """ + 全市场刷新不清理 Release 缓存,Release 接口按请求仓库协调刷新两类数据。 """ try: from app.core.plugin import PluginManager @@ -342,7 +514,56 @@ class TestPluginHelper: PluginManager().get_online_plugins(force=True) - assert clear_calls == ["clear"] + assert clear_calls == [] + + def test_async_get_online_plugins_force_keeps_release_cache_scoped(self, monkeypatch): + """异步全市场刷新同样不得清理其他仓库的 Release 缓存。""" + try: + from app.core.plugin import PluginManager + except ModuleNotFoundError as exc: + pytest.skip(f"missing dependency: {exc}") + + clear_calls = [] + + async def fake_clear(): + clear_calls.append("clear") + + fake_release_method = SimpleNamespace(cache_clear=fake_clear) + fake_helper = SimpleNamespace(async_get_plugin_release_versions=fake_release_method) + + async def fake_market(*_args, **_kwargs): + return [] + + monkeypatch.setattr("app.core.plugin.settings.PLUGIN_MARKET", "https://github.com/demo/plugins") + monkeypatch.setattr("app.core.plugin.PluginHelper", lambda: fake_helper) + monkeypatch.setattr(PluginManager, "async_get_plugins_from_market", fake_market) + + asyncio.run(PluginManager().async_get_online_plugins(force=True)) + + assert clear_calls == [] + + def test_get_local_plugin_version_reads_only_requested_installed_plugin(self, monkeypatch): + """单插件版本查询不构建全部本地插件信息。""" + try: + from app.core.plugin import PluginManager + from app.db.systemconfig_oper import SystemConfigOper + from app.schemas.types import SystemConfigKey + except ModuleNotFoundError as exc: + pytest.skip(f"missing dependency: {exc}") + + class DemoPlugin: + plugin_version = "1.2.0" + + plugin_manager = PluginManager() + monkeypatch.setattr(plugin_manager, "_plugins", {"DemoPlugin": DemoPlugin}) + monkeypatch.setattr( + SystemConfigOper, + "get", + lambda _self, key: ["DemoPlugin"] if key == SystemConfigKey.UserInstalledPlugins else None, + ) + + assert plugin_manager.get_local_plugin_version("DemoPlugin") == "1.2.0" + assert plugin_manager.get_local_plugin_version("OtherPlugin") is None def test_annotate_plugin_system_version_marks_incompatible(self): """