fix(plugin): avoid refreshing all repositories on update

This commit is contained in:
InfinityPacer
2026-09-12 19:02:24 +08:00
parent 747f9f921f
commit 814da86028
4 changed files with 131 additions and 9 deletions

View File

@@ -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(

View File

@@ -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
)

View File

@@ -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(

View File

@@ -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:
"""本地专属身份即使发现唯一在线来源,也只能由管理员显式绑定。"""