From 814da86028cccf3e4351b2ed73c1ded30092faf2 Mon Sep 17 00:00:00 2001 From: InfinityPacer Date: Sat, 12 Sep 2026 19:02:24 +0800 Subject: [PATCH] fix(plugin): avoid refreshing all repositories on update --- app/application/plugin/gateway.py | 4 +- app/application/plugin/release.py | 26 ++++++--- tests/test_plugin_endpoint.py | 79 ++++++++++++++++++++++++++++ tests/test_plugin_install_gateway.py | 31 +++++++++++ 4 files changed, 131 insertions(+), 9 deletions(-) diff --git a/app/application/plugin/gateway.py b/app/application/plugin/gateway.py index cedd5358b..d7b473624 100644 --- a/app/application/plugin/gateway.py +++ b/app/application/plugin/gateway.py @@ -94,7 +94,9 @@ class PluginInstallGateway: ) -> PluginInstallResult: """读取冻结库存并执行一次不能绕过来源身份的插件写入。""" try: - inventory = await self.__inventory(force) + # 安装 ``force`` 只控制载荷覆盖;远程市场索引由市场手动刷新和 + # 定时缓存任务维护,不能让单个插件更新放大全部仓库请求。 + inventory = await self.__inventory(False) async with plugin_lifecycle.hold(plugin_id, startup_token): identity = await self.__identity(plugin_id) admission = admit_plugin_install( diff --git a/app/application/plugin/release.py b/app/application/plugin/release.py index 5f4ec883a..0bca0c0dd 100644 --- a/app/application/plugin/release.py +++ b/app/application/plugin/release.py @@ -7,7 +7,7 @@ from dataclasses import dataclass from typing import Any, cast from app.application.plugin.catalog import apply_declared_metadata_fallback -from app.application.plugin.identity import PluginIdentity +from app.application.plugin.identity import PluginIdentity, PluginPayloadSourceType from app.schemas.plugin import Plugin MarketPluginLoader = Callable[[str, str | None, bool], Awaitable[list[Plugin] | None]] @@ -77,16 +77,26 @@ class PluginReleaseService: (plugin for plugin in self._local_repo_plugins() if plugin.id == plugin_id), None, ) - if local_plugin is not None: - return _merge_market_metadata(installed_plugin, local_plugin) + if identity is not None and identity.payload_source_type is PluginPayloadSourceType.LOCAL: + return ( + _merge_market_metadata(installed_plugin, local_plugin) + if local_plugin is not None + else installed_plugin + ) repo_url = _trusted_repo_url(identity) - if repo_url is None: - return installed_plugin - market_plugin = await self._market_plugin(plugin_id, repo_url, force) + if repo_url is not None: + market_plugin = await self._market_plugin(plugin_id, repo_url, force) + return ( + _merge_market_metadata(installed_plugin, market_plugin) + if market_plugin is not None + else installed_plugin + ) + + # 尚未建立来源身份的本地插件继续使用本地仓库元数据,兼容首次迁移前的运行态。 return ( - _merge_market_metadata(installed_plugin, market_plugin) - if market_plugin is not None + _merge_market_metadata(installed_plugin, local_plugin) + if local_plugin is not None else installed_plugin ) diff --git a/tests/test_plugin_endpoint.py b/tests/test_plugin_endpoint.py index dc2c19dec..f43b36db1 100644 --- a/tests/test_plugin_endpoint.py +++ b/tests/test_plugin_endpoint.py @@ -1,5 +1,6 @@ import asyncio from contextlib import asynccontextmanager, nullcontext +from dataclasses import replace from datetime import datetime, timezone from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch @@ -396,6 +397,84 @@ def test_plugin_history_merges_remote_metadata(): plugin_manager.async_get_plugins_from_market.assert_awaited_once_with(SOURCE_URL, settings.VERSION_FLAG, True) +def test_plugin_history_uses_payload_source_when_local_candidate_also_exists(): + """在线运行载荷不得被同 ID 的本地仓库历史覆盖。""" + installed_plugin = schemas.Plugin( + id="DemoPlugin", + plugin_name="Demo Plugin", + plugin_version="1.0.0", + installed=True, + history={}, + ) + local_plugin = schemas.Plugin( + id="DemoPlugin", + repo_url="local://DemoPlugin?path=/plugins&version=v3", + history={"v0.9.0": "本地旧版说明"}, + ) + market_plugin = schemas.Plugin( + id="DemoPlugin", + repo_url=SOURCE_URL, + history={"v1.1.0": "在线新版说明"}, + ) + plugin_manager = MagicMock() + plugin_manager.get_installed_plugins.return_value = [installed_plugin] + plugin_manager.get_local_repo_plugins.return_value = [local_plugin] + plugin_manager.async_get_plugins_from_market = AsyncMock(return_value=[market_plugin]) + metadata = PluginDeclaredMetadata.from_package( + {"name": "Demo Plugin", "v3": True}, + declaration_version="1.0.0", + manifest_matches_payload=True, + ) + identity = _plugin_identity(metadata=metadata) + release_service = _release_service(plugin_manager, persistence=_persistence(identity)) + + result = asyncio.run(release_service.history("DemoPlugin", force=True)) + + assert result is not None + assert result.repo_url == SOURCE_URL + assert result.history == {"v1.1.0": "在线新版说明"} + plugin_manager.async_get_plugins_from_market.assert_awaited_once_with(SOURCE_URL, settings.VERSION_FLAG, True) + + +def test_plugin_history_uses_local_metadata_for_local_payload(): + """本地运行载荷继续读取同一本地仓库的历史。""" + installed_plugin = schemas.Plugin( + id="DemoPlugin", + plugin_name="Demo Plugin", + plugin_version="1.0.0", + installed=True, + history={}, + ) + local_plugin = schemas.Plugin( + id="DemoPlugin", + repo_url="local://DemoPlugin?path=/plugins&version=v3", + history={"v1.1.0": "本地新版说明"}, + ) + plugin_manager = MagicMock() + plugin_manager.get_installed_plugins.return_value = [installed_plugin] + plugin_manager.get_local_repo_plugins.return_value = [local_plugin] + plugin_manager.async_get_plugins_from_market = AsyncMock() + metadata = PluginDeclaredMetadata.from_package( + {"name": "Demo Plugin", "v3": True}, + declaration_version="1.0.0", + manifest_matches_payload=True, + ) + identity = _plugin_identity(metadata=metadata) + identity = replace( + identity, + payload_source_type=PluginPayloadSourceType.LOCAL, + payload_source_key=None, + ) + release_service = _release_service(plugin_manager, persistence=_persistence(identity)) + + result = asyncio.run(release_service.history("DemoPlugin", force=True)) + + assert result is not None + assert result.repo_url == local_plugin.repo_url + assert result.history == {"v1.1.0": "本地新版说明"} + plugin_manager.async_get_plugins_from_market.assert_not_awaited() + + def test_plugin_history_falls_back_to_backward_compatible_package(): """已绑定来源的插件在当前索引缺失时仍读取向后兼容代际的历史。""" installed_plugin = schemas.Plugin( diff --git a/tests/test_plugin_install_gateway.py b/tests/test_plugin_install_gateway.py index bcd61cf9c..c55eea0fd 100644 --- a/tests/test_plugin_install_gateway.py +++ b/tests/test_plugin_install_gateway.py @@ -76,6 +76,37 @@ async def test_gateway_freezes_admission_before_executing_transaction() -> None: assert admission.expected_revision is None +@pytest.mark.asyncio +async def test_force_install_reuses_cached_candidate_inventory() -> None: + """强制覆盖载荷时不得连带强刷全部远程插件仓库。""" + inventory = AsyncMock(return_value=_inventory()) + executor = AsyncMock() + executor.execute.return_value = type( + "Result", + (), + {"success": True, "message": ""}, + )() + gateway = PluginInstallGateway( + inventory=inventory, + identity=AsyncMock(return_value=None), + candidate_compatibility=lambda _candidate: (True, ""), + executor=executor, + clock=lambda: NOW, + ) + + result = await gateway.install( + plugin_id="DemoPlugin", + repo_url=REPO_URL, + package_version="v3", + force=True, + explicit_source=True, + ) + + assert result.success is True + inventory.assert_awaited_once_with(False) + assert executor.execute.await_args.kwargs["force"] is True + + @pytest.mark.asyncio async def test_local_only_requires_explicit_online_binding() -> None: """本地专属身份即使发现唯一在线来源,也只能由管理员显式绑定。"""