diff --git a/app/runtime/extensions/plugin/storage.py b/app/runtime/extensions/plugin/storage.py index f35e2eefe..eb69ff2cc 100644 --- a/app/runtime/extensions/plugin/storage.py +++ b/app/runtime/extensions/plugin/storage.py @@ -14,7 +14,6 @@ from app.runtime.extensions.plugin.database import PluginDatabase from app.schemas.plugin import PluginInstance from app.schemas.types import SystemConfigKey - ConfigReader = Callable[[Any], Any] ConfigWriter = Callable[[Any, Any], Any] AsyncConfigWriter = Callable[[Any, Any], Awaitable[Any]] @@ -293,6 +292,9 @@ class PluginInstanceDirectory: ``list_all``/``get`` 一律返回全部登记行(含停用的),``list_enabled`` 才是运行期 装载的取数口:是否实例化由 ``is_enabled`` 单独表达,读取口不替调用方把停用的行 藏起来——藏起来会让卡片、卸载守卫和默认目标候选一并丢掉这些行。 + + 历史脏行如果无法投影为运行时 ``PluginInstance``,由启动组合根的读取适配器记录并 + 跳过;本端口仍只向上层暴露可安全使用的实例描述,避免单个旧插件记录阻断整个启动。 """ def __init__( diff --git a/app/startup/initializers/plugins.py b/app/startup/initializers/plugins.py index 0bf4f7ef0..3c192712d 100644 --- a/app/startup/initializers/plugins.py +++ b/app/startup/initializers/plugins.py @@ -6,6 +6,7 @@ from pathlib import Path from typing import Any, cast import sqlalchemy as sa +from pydantic import ValidationError from app.adapters.external.market import ( LOCAL_REPO_PREFIX, @@ -258,21 +259,46 @@ def _prime_plugin_instance_log_levels() -> None: session.close() -def _plugin_instance_from_record(record: PluginInstanceRecord) -> PluginInstance: +def _plugin_instance_from_record(record: PluginInstanceRecord) -> PluginInstance | None: """把插件实例表的 ORM 行投影为运行时端口使用的 Pydantic 描述。 只投影描述符各列:业务参数走插件配置读取端口,运行时端口拿到的应当是一份实例 身份、展示信息与调用目标置位的视图。 + + 历史版本写入的脏实例行不能阻断整个插件目录的读取;严格模型校验仍保留,只把 + 当前行视为不可装载并交给上层跳过。数据库异常不在这里捕获,避免把真正的存储故障 + 伪装成单个插件问题。 """ - return PluginInstance( - instance_id=record.instance_id, - source_plugin_id=record.source_plugin_id, - plugin_name=record.plugin_name, - plugin_desc=record.plugin_desc, - plugin_icon=record.plugin_icon, - is_default_target=record.is_default_target, - is_enabled=record.is_enabled, - ) + try: + return PluginInstance( + instance_id=record.instance_id, + source_plugin_id=record.source_plugin_id, + plugin_name=record.plugin_name, + plugin_desc=record.plugin_desc, + plugin_icon=record.plugin_icon, + is_default_target=record.is_default_target, + is_enabled=record.is_enabled, + ) + except ValidationError as error: + logger.warning( + "跳过无效插件实例记录:instance_id=%r, source_plugin_id=%r, error=%s", + record.instance_id, + record.source_plugin_id, + error, + ) + return None + + +def _plugin_instances_from_records( + records: list[PluginInstanceRecord], +) -> list[PluginInstance]: + """逐行投影实例记录,跳过不能通过运行时模型校验的历史行。""" + instances: list[PluginInstance] = [] + for record in records: + instance = _plugin_instance_from_record(record) + if instance is not None: + instances.append(instance) + return instances def _save_plugin_instance_record(instance: PluginInstance) -> None: @@ -326,20 +352,25 @@ def _build_plugin_instance_directory() -> PluginInstanceDirectory: record = oper.get(instance_id) return _plugin_instance_from_record(record) if record is not None else None + def _list_all() -> list[PluginInstance]: + """列出全部可用实例描述,历史无效行只影响自身。""" + return _plugin_instances_from_records(oper.list_all()) + + def _list_by_source(source_plugin_id: str) -> list[PluginInstance]: + """列出指定源插件的可用实例描述,跳过历史无效行。""" + return _plugin_instances_from_records(oper.list_by_source(source_plugin_id)) + + def _list_enabled() -> list[PluginInstance]: + """列出启用且可投影的实例描述,供启动依赖分类使用。""" + return _plugin_instances_from_records(oper.list_enabled()) + return PluginInstanceDirectory( get=_get, - list_all=lambda: [ - _plugin_instance_from_record(record) for record in oper.list_all() - ], - list_by_source=lambda source_plugin_id: [ - _plugin_instance_from_record(record) - for record in oper.list_by_source(source_plugin_id) - ], + list_all=_list_all, + list_by_source=_list_by_source, save=_save_plugin_instance_record, delete=oper.delete, - list_enabled=lambda: [ - _plugin_instance_from_record(record) for record in oper.list_enabled() - ], + list_enabled=_list_enabled, set_enabled=lambda instance_id, is_enabled: oper.set_enabled( instance_id=instance_id, is_enabled=is_enabled, diff --git a/tests/test_plugin_instance_directory.py b/tests/test_plugin_instance_directory.py new file mode 100644 index 000000000..bb986b185 --- /dev/null +++ b/tests/test_plugin_instance_directory.py @@ -0,0 +1,45 @@ +"""插件实例目录对历史无效持久化行的读取隔离测试。""" + +from types import SimpleNamespace +from unittest.mock import MagicMock + +from app.startup.initializers import plugins as plugins_initializer + + +def _record(instance_id: str, source_plugin_id: str) -> SimpleNamespace: + """构造包含 ORM 所需字段的轻量实例记录替身。""" + return SimpleNamespace( + instance_id=instance_id, + source_plugin_id=source_plugin_id, + plugin_name=None, + plugin_desc=None, + plugin_icon=None, + is_default_target=False, + is_enabled=True, + ) + + +def test_plugin_instance_directory_skips_invalid_records_on_every_read(monkeypatch) -> None: + """单条历史非法实例记录不得阻断其它实例读取或启动分类。""" + invalid = _record("__ConfigCenter__", "__ConfigCenter__") + valid = _record("DemoPlugin", "DemoPlugin") + oper = MagicMock() + oper.get.return_value = invalid + oper.list_all.return_value = [invalid, valid] + oper.list_by_source.return_value = [invalid, valid] + oper.list_enabled.return_value = [invalid, valid] + logger = MagicMock() + + monkeypatch.setattr(plugins_initializer, "PluginInstanceOper", lambda: oper) + monkeypatch.setattr(plugins_initializer, "logger", logger) + + directory = plugins_initializer._build_plugin_instance_directory() + + assert directory.get(invalid.instance_id) is None + assert [item.instance_id for item in directory.list_all()] == [valid.instance_id] + assert [item.instance_id for item in directory.list_by_source("DemoPlugin")] == [ + valid.instance_id + ] + assert [item.instance_id for item in directory.list_enabled()] == [valid.instance_id] + assert logger.warning.call_count == 4 + assert invalid.instance_id in str(logger.warning.call_args)