修复插件来源仲裁与数据库备份 review 问题

合并 PR #6658。业务相关检查已通过;剩余失败为 PR-Agent 外部 API 日限额与上游既有复杂度 ratchet,不属于本次改动。
This commit is contained in:
InfinityPacer
2026-09-12 21:22:01 +08:00
committed by GitHub
parent ee4dcd9d89
commit 4491610adb
11 changed files with 118 additions and 32 deletions

View File

@@ -11,8 +11,8 @@ from pathlib import Path
from app.runtime.version import get_app_version
_BACKUP_NAME = re.compile(
r"^(?:(?P<target>[0-9A-Za-z][0-9A-Za-z_-]*)_"
r"(?P<version>v\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?)_)?"
r"^(?:(?P<target>[0-9A-Za-z][0-9A-Za-z_-]*)"
r"(?:_(?P<version>v\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?))?_)?"
r"(?P<db_type>sqlite|postgresql)_"
r"(?P<timestamp>\d{8}_\d{6})"
r"(?:_(?P<sequence>\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():

View File

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

View File

@@ -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,
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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",
}

View File

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

View File

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

View File

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