From 3eee49659d504318b79713198cab8bc4d79f3517 Mon Sep 17 00:00:00 2001 From: Aqr-K <1210498076@qq.com> Date: Sun, 13 Sep 2026 00:14:47 -0400 Subject: [PATCH] =?UTF-8?q?fix(plugin):=20=E6=97=A7=E5=AE=9E=E4=BE=8B?= =?UTF-8?q?=E9=94=AE=E5=9B=9E=E6=BB=9A=E5=BE=80=E8=BF=94=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E7=9A=84=E5=88=86=E8=BA=AB=E8=A6=81=E8=83=BD=E5=90=88=E5=B9=B6?= =?UTF-8?q?=E8=BF=9B=E7=8B=AC=E7=AB=8B=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `PluginInstances` 旧键刻意保留作回滚依据、从不清理,旧版本只认它,因此它在 迁移之后仍可能长出新条目或被改写。原判据是「一条一次性的永久标记」加「整表 是否为空」:标记落下且表非空之后,回滚到旧版本新建的分身会被永久跳过,保留 旧键作回滚依据的承诺随之落空。两个条件都表达不出「旧载荷相对已导入内容有没有 新增或变化」。 改为按条目记住导入时的内容指纹(`PluginInstancesImported` 的载荷从布尔改为 `{实例ID: 摘要}`):指纹对得上就跳过,独立表里那行用户怎么改都不会被旧副本盖 回去;指纹对不上说明旧版本改写过,合并;指纹没记过且表里也没有该行说明旧版本 新增过,导入;指纹没记过但表里已有该行(alembic 刚搬过去的那一批)只认领指纹 不覆盖。指纹取自原始载荷而非模型 dump,模型字段将来增减才不会让全部指纹一起 失配、把用户改动整批盖回去。指纹表与旧键内容对齐后无变化的启动不再产生写入, 也不额外查表。 --- app/runtime/extensions/plugin/storage.py | 75 +++++++++--- app/schemas/types.py | 3 +- tests/test_plugin_virtual_instances.py | 145 +++++++++++++++++++++++ 3 files changed, 206 insertions(+), 17 deletions(-) diff --git a/app/runtime/extensions/plugin/storage.py b/app/runtime/extensions/plugin/storage.py index ba3b82f83..3c7baa421 100644 --- a/app/runtime/extensions/plugin/storage.py +++ b/app/runtime/extensions/plugin/storage.py @@ -2,9 +2,11 @@ from __future__ import annotations +import hashlib +import json from collections.abc import Awaitable, Callable from datetime import datetime -from typing import Any, Optional +from typing import Any, NamedTuple, Optional from pydantic import ValidationError @@ -273,6 +275,26 @@ class PluginInstanceDirectory: return self._delete(instance_id) +class _LegacyInstanceEntry(NamedTuple): + """旧 systemconfig 单键里的一条实例描述,连同其原始载荷的内容指纹。""" + + instance: PluginInstance + fingerprint: str + + +def _payload_fingerprint(payload: dict[str, Any]) -> str: + """为旧键里的一条原始载荷算内容指纹,用于判定旧版本此后有没有改动过它。 + + 指纹取自原始载荷而非模型 dump:模型字段将来增减会让全部指纹一起失配,把用户在 + 独立表里的改动当成「旧载荷变化」整批盖回去。键排序后再序列化,旧版本重写整个键 + 造成的字段顺序变化不会被误判成内容变化。 + :param payload: 旧键里的单条原始载荷 + :return: 十六进制摘要 + """ + canonical = json.dumps(payload, sort_keys=True, default=str, ensure_ascii=False) + return hashlib.sha256(canonical.encode("utf-8")).hexdigest() + + class PluginInstanceStore: """管理共享源码的分身实例描述,并把源插件本体自身的那一行隔离在视图之外。 @@ -292,13 +314,18 @@ class PluginInstanceStore: self._bootstrap_checked = False def _ensure_bootstrapped(self) -> None: - """旧 systemconfig 单键的内容向独立表兜底导入一次,且只导入一次。 + """把旧 systemconfig 单键里新增或变化的实例描述合并进独立表,每进程判定一次。 alembic 迁移已经搬过一轮,这里兜的是「库结构升级后又用旧版本写过分身」这类 - 回滚往返。导入完成后落一个持久化标记,判据不能是「表当前为空」:旧键刻意保留 - 作回滚依据、从不清理,用户把分身全部删光后表就会重新变空,下次进程启动会把 - 已删除的分身整批导回来,删一次复活一次。进程内另外维护一个已检查标志,避免 - 每次访问都为此多打一次查询。 + 回滚往返:旧键刻意保留作回滚依据、从不清理,旧版本只认它,因此它在迁移之后 + 仍可能长出新条目或被改写。判据既不能是「表当前为空」——用户删光分身后表会重新 + 变空,下次启动会把删掉的分身整批导回来;也不能是一条一次性的永久标记——标记 + 落下之后旧版本新增的分身就永远看不见。 + + 因此按条目记住导入时的内容指纹:指纹对得上就跳过,表里那行用户怎么改都不会被 + 旧副本盖回去;指纹对不上说明旧版本改写过该条目,合并进来;指纹没记过且表里也 + 没有该行说明旧版本新增过它,导入。指纹没记过但表里已有该行(迁移刚搬过去的那 + 一批),只认领指纹而不覆盖。指纹表始终与旧键当前内容对齐,重复启动不再产生写入。 :raise Exception: 导入失败时向上抛出,不吞掉持久化层错误 """ @@ -306,15 +333,28 @@ class PluginInstanceStore: return self._bootstrap_checked = True storage = self._storage() - if storage.read(SystemConfigKey.PluginInstancesImported): - return - directory = self._directory() - if not directory.list_all(): - for instance in self._legacy_instances().values(): - directory.save(instance) - storage.write(SystemConfigKey.PluginInstancesImported, True) + recorded = storage.read(SystemConfigKey.PluginInstancesImported) + imported: dict[str, str] = dict(recorded) if isinstance(recorded, dict) else {} + legacy = self._legacy_instances() + pending = { + instance_id: entry + for instance_id, entry in legacy.items() + if imported.get(instance_id) != entry.fingerprint + } + if pending: + directory = self._directory() + # 只在确有待定条目时扫一次现有行,让无变化的启动不额外查表 + present = {record.instance_id for record in directory.list_all()} + for instance_id, entry in pending.items(): + if instance_id in imported or instance_id not in present: + directory.save(entry.instance) + fingerprints = { + instance_id: entry.fingerprint for instance_id, entry in legacy.items() + } + if fingerprints != imported: + storage.write(SystemConfigKey.PluginInstancesImported, fingerprints) - def _legacy_instances(self) -> dict[str, PluginInstance]: + def _legacy_instances(self) -> dict[str, _LegacyInstanceEntry]: """解析旧 systemconfig 单键里的实例描述,兼容历史字典与列表两种载荷形态。""" raw_instances = self._storage().read(SystemConfigKey.PluginInstances) or {} if isinstance(raw_instances, list): @@ -328,13 +368,16 @@ class PluginInstanceStore: else: return {} - instances: dict[str, PluginInstance] = {} + instances: dict[str, _LegacyInstanceEntry] = {} for instance_id, raw_instance in entries.items(): try: payload = dict(raw_instance) if isinstance(raw_instance, dict) else {} payload.setdefault("instance_id", instance_id) instance = PluginInstance.model_validate(payload) - instances[instance.instance_id] = instance + instances[instance.instance_id] = _LegacyInstanceEntry( + instance=instance, + fingerprint=_payload_fingerprint(payload), + ) except (TypeError, ValidationError): continue return instances diff --git a/app/schemas/types.py b/app/schemas/types.py index bd8b41b57..5f53b2799 100644 --- a/app/schemas/types.py +++ b/app/schemas/types.py @@ -386,7 +386,8 @@ class SystemConfigKey(Enum): UserInstalledPlugins = "UserInstalledPlugins" # 共享源码插件的虚拟运行实例(已迁移到独立表,本键保留作回滚依据) PluginInstances = "PluginInstances" - # 上面那个旧键是否已完成向独立表的兜底导入,避免用户删光分身后被重新导回 + # 上面那个旧键各条目导入独立表时的内容指纹 {实例ID: 摘要},据此识别回滚到旧版本 + # 后新增或改写过的条目,同时避免把用户删掉的分身重新导回 PluginInstancesImported = "PluginInstancesImported" # 插件文件夹分组配置 PluginFolders = "PluginFolders" diff --git a/tests/test_plugin_virtual_instances.py b/tests/test_plugin_virtual_instances.py index 4a8dbf551..aaae6e8d5 100644 --- a/tests/test_plugin_virtual_instances.py +++ b/tests/test_plugin_virtual_instances.py @@ -37,6 +37,23 @@ def _make_directory() -> tuple[PluginInstanceDirectory, dict[str, PluginInstance return directory, records +def _make_storage(values: dict) -> tuple[PluginStorage, list]: + """构造进程内配置存储,并记录写入过的键,供幂等性断言使用。""" + written: list = [] + + def _write(key, value): + """记录写入键并落进内存配置字典。""" + written.append(key) + values[key] = value + + return PluginStorage(read=values.get, write=_write), written + + +def _legacy_values(**entries) -> dict: + """构造只含旧 systemconfig 单键的配置字典。""" + return {SystemConfigKey.PluginInstances: dict(entries)} + + def _logger() -> SimpleNamespace: """提供加载器测试所需的最小日志对象。""" return SimpleNamespace( @@ -152,6 +169,134 @@ def test_legacy_instances_are_not_reimported_after_being_deleted(): assert values[SystemConfigKey.PluginInstances] is not None +def test_legacy_entries_added_by_an_older_version_are_merged_in(): + """回滚到旧版本新增的分身,切回新版本后要能合并进独立表。 + + 旧版本只认 `PluginInstances` 旧键,在那里新建的分身既不在独立表里,也不在 + 已导入指纹里。若判据只有「一条永久标记」加「整表是否为空」,标记落下且表非空 + 之后这些分身会被永久跳过,保留旧键作回滚依据的承诺就落空了。 + """ + values = _legacy_values( + DemoPluginWork={ + "instance_id": "DemoPluginWork", + "source_plugin_id": "DemoPlugin", + } + ) + storage, _written = _make_storage(values) + directory, records = _make_directory() + + # 新版本首次启动:旧键内容导入独立表,落下已导入指纹 + first = PluginInstanceStore(storage=lambda: storage, directory=lambda: directory) + assert set(first.all()) == {"DemoPluginWork"} + assert values[SystemConfigKey.PluginInstancesImported] + + # 回滚到旧版本:旧版本只往旧键里新建分身,独立表保持不动 + values[SystemConfigKey.PluginInstances]["DemoPluginHome"] = { + "instance_id": "DemoPluginHome", + "source_plugin_id": "DemoPlugin", + "plugin_name": "家庭实例", + } + + # 切回新版本 + second = PluginInstanceStore(storage=lambda: storage, directory=lambda: directory) + + assert set(second.all()) == {"DemoPluginWork", "DemoPluginHome"} + assert records["DemoPluginHome"].plugin_name == "家庭实例" + + +def test_legacy_entries_changed_by_an_older_version_are_merged_in(): + """回滚到旧版本改写过的分身,切回新版本后要按旧键的新内容合并。""" + values = _legacy_values( + DemoPluginWork={ + "instance_id": "DemoPluginWork", + "source_plugin_id": "DemoPlugin", + "plugin_name": "工作实例", + } + ) + storage, _written = _make_storage(values) + directory, records = _make_directory() + + first = PluginInstanceStore(storage=lambda: storage, directory=lambda: directory) + assert first.all()["DemoPluginWork"].plugin_name == "工作实例" + + values[SystemConfigKey.PluginInstances]["DemoPluginWork"]["plugin_name"] = "旧版本改过" + + second = PluginInstanceStore(storage=lambda: storage, directory=lambda: directory) + + assert second.all()["DemoPluginWork"].plugin_name == "旧版本改过" + assert records["DemoPluginWork"].plugin_name == "旧版本改过" + + +def test_rows_edited_in_the_new_table_are_not_overwritten_by_stale_legacy_copies(): + """旧键没变时,独立表里被用户改过的行不得被旧键里的陈旧副本盖回去。""" + values = _legacy_values( + DemoPluginWork={ + "instance_id": "DemoPluginWork", + "source_plugin_id": "DemoPlugin", + "plugin_name": "工作实例", + } + ) + storage, _written = _make_storage(values) + directory, records = _make_directory() + + first = PluginInstanceStore(storage=lambda: storage, directory=lambda: directory) + assert set(first.all()) == {"DemoPluginWork"} + + # 用户在新版本里改名,旧键始终没人动过 + records["DemoPluginWork"] = PluginInstance( + instance_id="DemoPluginWork", + source_plugin_id="DemoPlugin", + plugin_name="用户改过", + ) + + second = PluginInstanceStore(storage=lambda: storage, directory=lambda: directory) + + assert second.all()["DemoPluginWork"].plugin_name == "用户改过" + + +def test_rows_migrated_by_alembic_are_claimed_without_being_overwritten(): + """迁移已搬进独立表、指纹还没记过的那批行,只认领指纹不覆盖内容。""" + values = _legacy_values( + DemoPluginWork={ + "instance_id": "DemoPluginWork", + "source_plugin_id": "DemoPlugin", + "plugin_name": "工作实例", + } + ) + storage, _written = _make_storage(values) + directory, records = _make_directory() + # 模拟 alembic 迁移搬完之后用户又改过名 + records["DemoPluginWork"] = PluginInstance( + instance_id="DemoPluginWork", + source_plugin_id="DemoPlugin", + plugin_name="迁移后改过", + ) + + store = PluginInstanceStore(storage=lambda: storage, directory=lambda: directory) + + assert store.all()["DemoPluginWork"].plugin_name == "迁移后改过" + assert set(values[SystemConfigKey.PluginInstancesImported]) == {"DemoPluginWork"} + + +def test_merging_legacy_entries_is_idempotent_across_restarts(): + """旧键没有新增或变化时,重复启动既不重复建行也不重复落盘指纹。""" + values = _legacy_values( + DemoPluginWork={ + "instance_id": "DemoPluginWork", + "source_plugin_id": "DemoPlugin", + } + ) + storage, written = _make_storage(values) + directory, records = _make_directory() + + for _restart in range(3): + store = PluginInstanceStore(storage=lambda: storage, directory=lambda: directory) + assert set(store.all()) == {"DemoPluginWork"} + + assert set(records) == {"DemoPluginWork"} + assert written == [SystemConfigKey.PluginInstancesImported] + + def test_loader_executes_each_instance_in_an_isolated_module_namespace( tmp_path, monkeypatch,