From 4491610adbbfb45ec214b5952f3005786f431745 Mon Sep 17 00:00:00 2001 From: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com> Date: Sat, 12 Sep 2026 21:22:01 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=8F=92=E4=BB=B6=E6=9D=A5?= =?UTF-8?q?=E6=BA=90=E4=BB=B2=E8=A3=81=E4=B8=8E=E6=95=B0=E6=8D=AE=E5=BA=93?= =?UTF-8?q?=E5=A4=87=E4=BB=BD=20review=20=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 合并 PR #6658。业务相关检查已通过;剩余失败为 PR-Agent 外部 API 日限额与上游既有复杂度 ratchet,不属于本次改动。 --- app/adapters/system/backup/files.py | 25 +++++++++------- app/application/plugin/admission.py | 11 ++++++-- app/application/plugin/source.py | 18 ++++++++++-- app/runtime/extensions/plugin/sync.py | 7 +++-- app/startup/composition/database.py | 3 +- tests/test_database_backup_service.py | 20 +++++++++++++ tests/test_plugin_candidate_inventory.py | 9 +++--- tests/test_plugin_install_admission.py | 6 ++-- tests/test_plugin_install_gateway.py | 8 ++++-- tests/test_plugin_source_policy.py | 7 +++-- tests/test_plugin_sync_service.py | 36 ++++++++++++++++++++++++ 11 files changed, 118 insertions(+), 32 deletions(-) diff --git a/app/adapters/system/backup/files.py b/app/adapters/system/backup/files.py index 567d96f8c..cb3c23a45 100644 --- a/app/adapters/system/backup/files.py +++ b/app/adapters/system/backup/files.py @@ -11,8 +11,8 @@ from pathlib import Path from app.runtime.version import get_app_version _BACKUP_NAME = re.compile( - r"^(?:(?P[0-9A-Za-z][0-9A-Za-z_-]*)_" - r"(?Pv\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?)_)?" + r"^(?:(?P[0-9A-Za-z][0-9A-Za-z_-]*)" + r"(?:_(?Pv\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?))?_)?" r"(?Psqlite|postgresql)_" r"(?P\d{8}_\d{6})" r"(?:_(?P\d+))?" @@ -85,16 +85,21 @@ class BackupFiles: target: str = "moviepilot", version: str | None = None, ) -> str: - """生成包含目标、版本、数据库类型和时间的可读文件名。""" + """生成包含目标、可选版本、数据库类型和时间的可读文件名。""" timestamp = created_at.strftime("%Y%m%d_%H%M%S") - version = (version or get_app_version()).strip() - if not version.startswith("v"): - version = f"v{version}" - if _RELEASE_VERSION.fullmatch(version) is None or not re.fullmatch( - r"[0-9A-Za-z][0-9A-Za-z_-]*", target - ): + if not re.fullmatch(r"[0-9A-Za-z][0-9A-Za-z_-]*", target): raise ValueError("数据库备份目标或版本号无法用于文件命名") - base = f"{target}_{version}_{db_type}_{timestamp}" + normalized_version = version.strip() if version else None + if normalized_version is None and target == "moviepilot": + normalized_version = get_app_version().strip() + if normalized_version: + if not normalized_version.startswith("v"): + normalized_version = f"v{normalized_version}" + if _RELEASE_VERSION.fullmatch(normalized_version) is None: + raise ValueError("数据库备份目标或版本号无法用于文件命名") + base = f"{target}_{normalized_version}_{db_type}_{timestamp}" + else: + base = f"{target}_{db_type}_{timestamp}" candidate = f"{base}{suffix}" sequence = 1 while (self.root / candidate).exists(): diff --git a/app/application/plugin/admission.py b/app/application/plugin/admission.py index cbcb09de0..c3b592758 100644 --- a/app/application/plugin/admission.py +++ b/app/application/plugin/admission.py @@ -20,7 +20,10 @@ from app.application.plugin.source import ( PluginSelectionStatus, select_plugin_candidate, ) -from app.domain.plugin import parse_local_plugin_reference +from app.domain.plugin import ( + parse_local_plugin_generation, + parse_local_plugin_reference, +) class PluginSourceAdmissionError(RuntimeError): @@ -147,10 +150,14 @@ def admit_plugin_install( available_local_candidates = inventory.local_candidates_for( request.plugin_id ) + requested_generation = parse_local_plugin_generation( + request.requested_repo_url + ) exact_candidates = tuple( candidate for candidate in available_local_candidates - if candidate.repo_url == request.requested_repo_url + if requested_generation is None + or candidate.package_generation == requested_generation ) local_candidates = exact_candidates or available_local_candidates if not local_candidates: diff --git a/app/application/plugin/source.py b/app/application/plugin/source.py index f33bc92cd..2929521c3 100644 --- a/app/application/plugin/source.py +++ b/app/application/plugin/source.py @@ -6,6 +6,7 @@ from collections.abc import Iterable, Sequence from dataclasses import dataclass, field from enum import StrEnum from typing import Any, TypeAlias +from urllib.parse import quote from app.application.plugin.identity import ( PluginIdentity, @@ -20,6 +21,15 @@ from app.application.plugin.identity import ( from app.foundation.version import compare_version PLUGIN_GENERATIONS = ("v1", "v2", "v3") +LOCAL_PLUGIN_SOURCE_PREFIX = "local://" + + +def _build_public_local_source(plugin_id: str, package_generation: str) -> str: + """生成不携带宿主路径的本地候选标识。""" + source = f"{LOCAL_PLUGIN_SOURCE_PREFIX}{quote(plugin_id, safe='')}" + if package_generation != "v1": + source += f"?version={quote(package_generation, safe='')}" + return source class MarketReadStatus(StrEnum): @@ -126,11 +136,15 @@ class PluginLocalCandidate: return None def public_dict(self) -> dict[str, Any]: - """生成本地候选的公共投影,保留管理员配置的仓库路径。""" + """生成不包含本地仓库路径的公共候选投影。""" + public_repo_url = _build_public_local_source( + self.plugin_id, + self.package_generation, + ) return { "plugin_id": self.plugin_id, "source_type": PluginPayloadSourceType.LOCAL.value, - "repo_url": self.repo_url, + "repo_url": public_repo_url, "package_generation": self.package_generation, "plugin_version": self.plugin_version, } diff --git a/app/runtime/extensions/plugin/sync.py b/app/runtime/extensions/plugin/sync.py index c03f78d8d..3c589a7b6 100644 --- a/app/runtime/extensions/plugin/sync.py +++ b/app/runtime/extensions/plugin/sync.py @@ -82,11 +82,12 @@ class PluginSyncService: if restore_plugin_ids or (missing_ids - local_plugin_ids) else [] ) - # 启动同步只负责恢复缺失载荷;候选来源按本地优先建立索引,避免 - # 市场目录合并结果把本地候选替换成在线候选。 + # 先沿用目录服务对在线候选的版本仲裁,再以本地候选覆盖同 ID 的在线候选。 + # 这样既保持本地载荷优先,也不会因字典遍历顺序把高版本在线候选覆盖掉。 + merged_online = self._merge_plugins(online, [], []) if online else [] candidates_by_id: dict[str, Any] = { plugin.id.lower(): plugin - for plugin in online + for plugin in merged_online if getattr(plugin, "id", None) } candidates_by_id.update( diff --git a/app/startup/composition/database.py b/app/startup/composition/database.py index 038d1599f..badeb912d 100644 --- a/app/startup/composition/database.py +++ b/app/startup/composition/database.py @@ -215,10 +215,9 @@ def build_database_governance() -> DatabaseGovernance: version = manager.get_local_plugin_version(handle.plugin_id) if not version: logger.warning( - "跳过插件 %s 的数据库备份:未找到插件版本", + "插件 %s 未找到版本,将使用无版本文件名继续备份数据库", handle.plugin_id, ) - continue plugin_service = DatabaseBackupService( backend=SQLiteBackupBackend(handle.engine), artifact_store_factory=BackupFiles, diff --git a/tests/test_database_backup_service.py b/tests/test_database_backup_service.py index 4de2e55e4..1b0d3d39e 100644 --- a/tests/test_database_backup_service.py +++ b/tests/test_database_backup_service.py @@ -171,6 +171,26 @@ def test_backup_name_uses_plugin_target_and_version(tmp_path: Path) -> None: assert artifact.target == "DemoPlugin" +def test_plugin_backup_without_version_uses_target_without_version_segment( + tmp_path: Path, +) -> None: + """插件版本元数据缺失时仍应备份,并使用可解析的无版本文件名。""" + service = DatabaseBackupService( + backend=_Backend(), + artifact_store_factory=BackupFiles, + policy_reader=lambda: BackupPolicy(tmp_path), + clock=lambda: datetime(2026, 8, 19, 13, 45, 26), + target="DemoPlugin", + version=None, + ) + + artifact = service.create() + + assert artifact.name == "DemoPlugin_sqlite_20260819_134526.db" + assert artifact.target == "DemoPlugin" + assert BackupFiles.target(artifact.name) == "DemoPlugin" + + def test_retention_applies_after_new_artifact_is_available(tmp_path: Path) -> None: old = _service(tmp_path, now=datetime(2026, 8, 1, 3, 0, 0)).create() current = _service( diff --git a/tests/test_plugin_candidate_inventory.py b/tests/test_plugin_candidate_inventory.py index 8fb95a51c..2c27a7759 100644 --- a/tests/test_plugin_candidate_inventory.py +++ b/tests/test_plugin_candidate_inventory.py @@ -222,15 +222,15 @@ def test_local_scan_preserves_absent_present_and_failed_states() -> None: assert failed.local_read.error == "local repository unavailable" -def test_local_candidates_keep_source_marker_in_inventory_projection() -> None: - """本地候选可参与库存,并保留可供来源选择使用的来源标识。""" +def test_local_candidates_hide_path_in_inventory_projection() -> None: + """本地候选可参与库存,但库存公共投影不包含仓库路径。""" reader = PluginCandidateInventoryReader( market_loader=lambda *_args: {}, local_candidate_loader=lambda: { "LocalPlugin": { "version": "3.0.0", "package_version": "v3", - "repo_url": "local://LocalPlugin?path=/private/local&version=v3", + "repo_url": "local://LocalPlugin?version=v3", "path": "/private/local/plugins/LocalPlugin", "repo_path": "/private/local", }, @@ -244,10 +244,11 @@ def test_local_candidates_keep_source_marker_in_inventory_projection() -> None: assert public["local_candidates"] == [{ "plugin_id": "LocalPlugin", "source_type": "local", - "repo_url": "local://LocalPlugin?path=/private/local&version=v3", + "repo_url": "local://LocalPlugin?version=v3", "package_generation": "v3", "plugin_version": "3.0.0", }] + assert "/private/local" not in str(public) def test_invalid_local_candidate_does_not_abort_online_inventory() -> None: diff --git a/tests/test_plugin_install_admission.py b/tests/test_plugin_install_admission.py index 2da28a9a8..022eb0792 100644 --- a/tests/test_plugin_install_admission.py +++ b/tests/test_plugin_install_admission.py @@ -322,8 +322,8 @@ def test_first_local_payload_creates_local_only_identity() -> None: assert target.trusted_source_type is TrustedPluginSourceType.UNKNOWN -def test_local_reference_selects_configured_candidate_with_source_marker() -> None: - """本地来源标识仍能选择配置内候选,并保留可回传的来源标识。""" +def test_local_reference_selects_configured_candidate_without_path() -> None: + """无路径本地来源标识仍能选择配置内候选。""" local = PluginLocalCandidate( plugin_id="DemoPlugin", repo_url=( @@ -350,7 +350,7 @@ def test_local_reference_selects_configured_candidate_with_source_marker() -> No assert public == { "plugin_id": "DemoPlugin", "source_type": "local", - "repo_url": "local://DemoPlugin?path=/private/secret/plugins&version=v3", + "repo_url": "local://DemoPlugin?version=v3", "package_generation": "v3", "plugin_version": "2.0.0-dev", } diff --git a/tests/test_plugin_install_gateway.py b/tests/test_plugin_install_gateway.py index c55eea0fd..4318a3e04 100644 --- a/tests/test_plugin_install_gateway.py +++ b/tests/test_plugin_install_gateway.py @@ -388,8 +388,8 @@ async def test_gateway_checks_compatibility_on_final_trusted_candidate() -> None @pytest.mark.asyncio -async def test_gateway_source_inspection_preserves_sources_and_local_repo_url() -> None: - """来源查询按在线仓归并版本,并保留本地候选仓库标识。""" +async def test_gateway_source_inspection_preserves_sources_without_local_path() -> None: + """来源查询按在线仓归并版本,但不返回本地仓库路径。""" official_v3 = _inventory().online_candidates[0] official_v2 = PluginMarketCandidate( plugin_id="DemoPlugin", @@ -445,7 +445,9 @@ async def test_gateway_source_inspection_preserves_sources_and_local_repo_url() ] assert inspection.online_candidates[0].package_generation == "v3" assert inspection.local_candidate is local - assert inspection.local_candidate.public_dict()["repo_url"] == local.repo_url + public_repo_url = inspection.local_candidate.public_dict()["repo_url"] + assert public_repo_url == "local://DemoPlugin?version=v3" + assert "/private/plugins" not in public_repo_url @pytest.mark.asyncio diff --git a/tests/test_plugin_source_policy.py b/tests/test_plugin_source_policy.py index 6d435cbb9..eea9adddd 100644 --- a/tests/test_plugin_source_policy.py +++ b/tests/test_plugin_source_policy.py @@ -480,8 +480,8 @@ def test_uninstalled_unique_and_multiple_sources_are_distinct() -> None: assert set(conflict.conflict_source_keys) == {THIRD_PARTY_SOURCE, OTHER_SOURCE} -def test_official_candidate_is_selectable_and_local_projection_keeps_repo_url() -> None: - """官方来源可正常选择,本地公共投影保留可回传的仓库标识。""" +def test_official_candidate_is_selectable_and_local_projection_hides_path() -> None: + """官方来源可正常选择,本地公共投影不包含仓库路径。""" official = select_plugin_candidate( _inventory( MarketRead.present( @@ -518,4 +518,5 @@ def test_official_candidate_is_selectable_and_local_projection_keeps_repo_url() assert local.source_key is None assert local_result.candidate is local public = local_result.public_dict() - assert public["candidate"]["repo_url"] == local.repo_url + assert public["candidate"]["repo_url"] == "local://DemoPlugin?version=v3" + assert "/private/secret/plugins" not in str(public) diff --git a/tests/test_plugin_sync_service.py b/tests/test_plugin_sync_service.py index b38d7ab74..53e5119a8 100644 --- a/tests/test_plugin_sync_service.py +++ b/tests/test_plugin_sync_service.py @@ -76,6 +76,42 @@ def test_market_sync_keeps_install_rollback_enabled() -> None: install.assert_called_once_with(plugin.id, None, False, None) +def test_market_sync_arbitrates_online_versions_before_local_overlay() -> None: + """在线候选先按版本仲裁,不能因输入顺序选择旧版本。""" + old = SimpleNamespace( + id="DemoPlugin", + repo_url="https://example.com/old", + plugin_name="Demo old", + plugin_version="1.0.0", + system_version_compatible=True, + ) + new = SimpleNamespace( + id="DemoPlugin", + repo_url="https://example.com/new", + plugin_name="Demo new", + plugin_version="2.0.0", + system_version_compatible=True, + ) + install = Mock(return_value=(True, "")) + + def merge_online(items, *_args): + return [max(items, key=lambda item: Version(item.plugin_version))] + + service = PluginSyncService( + frozen=lambda: False, + installed_plugins=lambda: [old.id], + online_plugins=lambda: [new, old], + local_plugins=lambda: [], + merge_plugins=merge_online, + plugin_exists=lambda *_args: False, + install=install, + log=Mock(), + ) + + assert service.sync() == [new.id] + install.assert_called_once_with(new.id, None, False, None) + + def test_market_sync_restores_trusted_online_payload_after_local_source_removed() -> None: """本地高版本来源消失后,启动同步仍恢复已绑定的在线载荷。""" plugin = SimpleNamespace(