feat(plugin): skip invalid plugin instance records during read operations

This commit is contained in:
jxxghp
2026-09-13 16:56:26 +08:00
parent d44480d739
commit 421d2febac
3 changed files with 99 additions and 21 deletions

View File

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

View File

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

View File

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