feat(plugin): add virtual plugin instances

This commit is contained in:
jxxghp
2026-08-22 09:40:38 +08:00
parent 4061868d25
commit 7b9499fc5b
21 changed files with 891 additions and 114 deletions
+26 -10
View File
@@ -336,20 +336,26 @@ async def install_plugin_runtime(
return None
async def reload_runtime(target_id: str) -> object:
"""通过 Agent 阻塞任务适配器重建插件实例。"""
"""通过 Agent 阻塞任务适配器重载源插件及其虚拟实例。"""
return await run_agent_blocking(
"plugin",
plugin_manager.reload_plugin,
plugin_manager.reload_plugin_tree,
target_id,
)
async def refresh_registrations(target_id: str) -> object:
"""通过 Agent 阻塞任务适配器刷新服务、命令和动态路由"""
return await run_agent_blocking(
"plugin",
refresh_plugin_registrations,
target_id,
)
"""通过 Agent 阻塞任务适配器刷新源插件及其虚拟实例注册"""
result = None
reload_targets = list(
plugin_manager.get_plugin_reload_targets(target_id)
) or [target_id]
for reload_target in reload_targets:
result = await run_agent_blocking(
"plugin",
refresh_plugin_registrations,
reload_target,
)
return result
with plugin_manager.suppress_plugin_monitor(plugin_id):
result = await PluginInstallCommand(
@@ -387,6 +393,13 @@ async def uninstall_plugin_runtime(plugin_id: str) -> dict[str, Any]:
from app.application.plugin.routes import remove_plugin_api
from app.application.scheduling import remove_plugin_job
plugin_manager = get_plugin_manager()
virtual_instance = plugin_manager.get_plugin_instance(plugin_id)
source_instances = plugin_manager.get_plugin_source_instances(plugin_id)
if not virtual_instance and source_instances:
instance_ids = "".join(item.instance_id for item in source_instances)
raise ValueError(f"请先卸载该插件的分身:{instance_ids}")
config_oper = SystemConfigOper()
install_plugins = config_oper.get(SystemConfigKey.UserInstalledPlugins) or []
if plugin_id in install_plugins:
@@ -396,12 +409,15 @@ async def uninstall_plugin_runtime(plugin_id: str) -> dict[str, Any]:
remove_plugin_api(plugin_id)
remove_plugin_job(plugin_id)
plugin_manager = get_plugin_manager()
plugin_class = plugin_manager.plugins.get(plugin_id)
was_clone = bool(getattr(plugin_class, "is_clone", False))
clone_files_removed = False
if was_clone:
if virtual_instance:
plugin_manager.delete_plugin_config(plugin_id, force=True)
plugin_manager.delete_plugin_data(plugin_id, force=True)
plugin_manager.delete_plugin_instance(plugin_id)
elif was_clone:
plugin_manager.delete_plugin_config(plugin_id)
plugin_manager.delete_plugin_data(plugin_id)
plugin_base_dir = settings.ROOT_PATH / "app" / "plugins" / plugin_id.lower()
+35 -15
View File
@@ -13,6 +13,7 @@ from starlette.responses import StreamingResponse
from app.schemas.common import JsonObject as _SchemaJsonObject
from app.schemas.plugin import Plugin as _SchemaPlugin
from app.schemas.plugin import PluginDashboard as _SchemaPluginDashboard
from app.schemas.plugin import PluginCloneRequest as _SchemaPluginCloneRequest
from app.schemas.plugin import PluginDashboardMetaItem as _SchemaPluginDashboardMetaItem
from app.schemas.plugin import PluginFoldersData as _SchemaPluginFoldersData
from app.schemas.plugin import PluginRating as _SchemaPluginRating
@@ -558,12 +559,13 @@ async def install(
)
async def reload_runtime(target_id: str) -> object:
"""在线程池中重建插件实例并广播重载事件"""
return await run_in_threadpool(PluginManager().reload_plugin, target_id)
"""在线程池中重建插件及其全部虚拟实例。"""
return await run_in_threadpool(PluginManager().reload_plugin_tree, target_id)
async def refresh_registrations(target_id: str) -> object:
"""在线程池中刷新插件服务、命令和动态路由"""
return await run_in_threadpool(register_plugin, target_id)
"""在线程池中刷新插件及其虚拟实例的全部宿主注册"""
for reload_target in plugin_manager.get_plugin_reload_targets(target_id):
await run_in_threadpool(register_plugin, reload_target)
command = PluginInstallCommand(
installed_plugins_reader=lambda: get_configured_system_config().get(
@@ -779,8 +781,12 @@ async def plugin_static_file(
)
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden")
source_plugin_id = PluginManager().get_plugin_source_id(plugin_id)
plugin_base_dir = (
AsyncPath(settings.ROOT_PATH) / "app" / "plugins" / plugin_id.lower()
AsyncPath(settings.ROOT_PATH)
/ "app"
/ "plugins"
/ source_plugin_id.lower()
)
plugin_file_path = plugin_base_dir / filepath.lstrip("/")
@@ -940,7 +946,9 @@ async def update_folder_plugins(
"/clone/{plugin_id}", summary="创建插件分身", response_model=_SchemaResponse[None]
)
def clone_plugin(
plugin_id: str, clone_data: dict, _: ApiPrincipal = Depends(get_current_active_superuser)
plugin_id: str,
clone_data: _SchemaPluginCloneRequest,
_: ApiPrincipal = Depends(get_current_active_superuser),
) -> Any:
"""
创建插件分身
@@ -948,16 +956,16 @@ def clone_plugin(
try:
success, message = PluginManager().clone_plugin(
plugin_id=plugin_id,
suffix=clone_data.get("suffix", ""),
name=clone_data.get("name", ""),
description=clone_data.get("description", ""),
version=clone_data.get("version", ""),
icon=clone_data.get("icon", ""),
suffix=clone_data.suffix,
name=clone_data.name,
description=clone_data.description,
version=clone_data.version,
icon=clone_data.icon,
)
if success:
# 注册插件服务
reload_plugin(message)
# 分身服务已完成运行态加载,此处只补齐宿主注册。
register_plugin(message)
# 将分身插件添加到原插件所在的文件夹中
_add_clone_to_plugin_folder(plugin_id, message)
return _SchemaResponse(success=True, message="插件分身创建成功")
@@ -1003,6 +1011,15 @@ def uninstall_plugin(
"""
卸载插件
"""
plugin_manager = PluginManager()
virtual_instance = plugin_manager.get_plugin_instance(plugin_id)
source_instances = plugin_manager.get_plugin_source_instances(plugin_id)
if not virtual_instance and source_instances:
instance_ids = "".join(item.instance_id for item in source_instances)
return _SchemaResponse(
success=False,
message=f"请先卸载该插件的分身:{instance_ids}",
)
config_oper = get_configured_system_config()
# 删除已安装信息
install_plugins = config_oper.get(SystemConfigKey.UserInstalledPlugins) or []
@@ -1016,9 +1033,12 @@ def uninstall_plugin(
# 移除插件服务
remove_plugin_job(plugin_id)
# 判断是否为分身
plugin_manager = PluginManager()
plugin_class = plugin_manager.plugins.get(plugin_id)
if getattr(plugin_class, "is_clone", False):
if virtual_instance:
plugin_manager.delete_plugin_config(plugin_id, force=True)
plugin_manager.delete_plugin_data(plugin_id, force=True)
plugin_manager.delete_plugin_instance(plugin_id)
elif getattr(plugin_class, "is_clone", False):
# 如果是分身插件,则删除分身数据和配置
plugin_manager.delete_plugin_config(plugin_id)
plugin_manager.delete_plugin_data(plugin_id)
+4
View File
@@ -839,6 +839,10 @@
"source": "创建插件分身失败:{reason}",
"target": "Failed to create plugin clone: {reason}"
},
{
"source": "请先卸载该插件的分身:{instances}",
"target": "Uninstall this plugin's instances first: {instances}"
},
{
"source": "{domain} 站点己存在",
"target": "Site {domain} already exists"
+4
View File
@@ -835,6 +835,10 @@
"source": "创建插件分身失败:{reason}",
"target": "建立插件分身失敗:{reason}"
},
{
"source": "请先卸载该插件的分身:{instances}",
"target": "請先解除安裝該插件的分身:{instances}"
},
{
"source": "{domain} 站点己存在",
"target": "{domain} 站點已存在"
+3 -2
View File
@@ -45,9 +45,10 @@ class PluginAccessPolicy:
and hasattr(plugin, "plugin_public_key")
):
plugin_id = (
getattr(plugin, "id", None)
getattr(plugin, "plugin_source_id", None)
or getattr(plugin, "id", None)
if not isinstance(plugin, type)
else plugin.__name__
else getattr(plugin, "plugin_source_id", None) or plugin.__name__
)
public_key = plugin.plugin_public_key
if public_key and plugin_id:
+38 -5
View File
@@ -11,7 +11,7 @@ from app.runtime.config import settings
from app.runtime.extensions.plugin.contracts import supports_plugin_hook
from app.runtime.extensions.plugin.storage import PluginStorage
from app.runtime.extensions.plugin.system import PluginSystemServices
from app.schemas.plugin import Plugin, PluginRuntimeStatus
from app.schemas.plugin import Plugin, PluginInstance, PluginRuntimeStatus
from app.schemas.types import SystemConfigKey
@@ -31,6 +31,8 @@ class PluginCatalogFacade:
map_plugin: Callable[..., Optional[Plugin]],
auth_checker: Callable[..., bool],
plugin_attr: Callable[[str, str], Any],
plugin_instance: Callable[[str], Optional[PluginInstance]],
plugin_instances: Callable[[], dict[str, PluginInstance]],
runtime_status: Callable[[str], Optional[PluginRuntimeStatus]],
log: Any,
) -> None:
@@ -45,6 +47,8 @@ class PluginCatalogFacade:
self._map_plugin = map_plugin
self._auth_checker = auth_checker
self._plugin_attr = plugin_attr
self._plugin_instance = plugin_instance
self._plugin_instances = plugin_instances
self._runtime_status = runtime_status
self._logger = log
@@ -64,10 +68,11 @@ class PluginCatalogFacade:
def local(self) -> list[Plugin]:
"""把已加载插件投影为本地插件目录 DTO。"""
installed = self._storage().read(SystemConfigKey.UserInstalledPlugins) or []
installed = self._installed_ids()
plugins: list[Plugin] = []
for plugin_id, plugin_class in self._classes().items():
plugin_instance = self._running().get(plugin_id)
instance = self._plugin_instance(plugin_id)
plugin = Plugin(
id=plugin_id,
installed=plugin_id in installed,
@@ -84,6 +89,9 @@ class PluginCatalogFacade:
plugin_order=getattr(plugin_class, "plugin_order", 0),
has_update=False,
is_local=True,
source_plugin_id=getattr(plugin_class, "plugin_source_id", None),
is_instance=instance is not None,
instance_mode=instance.mode if instance else None,
)
if not self._auth_checker(plugin=plugin, source=plugin_class):
continue
@@ -93,7 +101,7 @@ class PluginCatalogFacade:
def installed(self) -> list[Plugin]:
"""按安装清单投影插件,未加载项目仍返回可观察占位卡片。"""
installed_ids = self._storage().read(SystemConfigKey.UserInstalledPlugins) or []
installed_ids = self._installed_ids()
local_by_id = {
plugin.id: plugin
for plugin in self.local()
@@ -105,6 +113,7 @@ class PluginCatalogFacade:
if plugin:
result.append(plugin)
continue
instance = self._plugin_instance(plugin_id)
result.append(Plugin(
id=plugin_id,
plugin_name=plugin_id,
@@ -112,6 +121,11 @@ class PluginCatalogFacade:
state=False,
runtime_status=self._runtime_status(plugin_id),
is_local=True,
source_plugin_id=(
instance.source_plugin_id if instance else None
),
is_instance=instance is not None,
instance_mode=instance.mode if instance else None,
))
# 展示顺序由持久化安装清单保留,避免后台恢复或占位卡片出现后改变用户看到的位置。
# 前端可用用户级 PluginOrder 覆盖,plugin_order 只用于运行期插件发现顺序。
@@ -119,7 +133,7 @@ class PluginCatalogFacade:
def local_version(self, plugin_id: str) -> Optional[str]:
"""读取指定已安装插件版本,不触发全量目录投影。"""
installed = self._storage().read(SystemConfigKey.UserInstalledPlugins) or []
installed = self._installed_ids()
if plugin_id not in installed:
return None
plugin_class = self._classes().get(plugin_id)
@@ -156,11 +170,20 @@ class PluginCatalogFacade:
if not plugin_id:
return False
try:
package_name = f"app.plugins.{plugin_id.lower()}"
instance = self._plugin_instance(plugin_id)
source_plugin_id = (
instance.source_plugin_id if instance else plugin_id
)
package_name = f"app.plugins.{source_plugin_id.lower()}"
spec = importlib.util.find_spec(package_name)
if spec is None or spec.origin is None:
return False
local_version = self._plugin_attr(plugin_id, "plugin_version")
if not local_version and instance:
local_version = self._plugin_attr(
instance.source_plugin_id,
"plugin_version",
)
if not local_version:
return False
if version and not compare_version(local_version, ">=", version):
@@ -174,6 +197,16 @@ class PluginCatalogFacade:
self._logger.debug(f"获取插件是否在本地包中存在失败,{error}")
return False
def _installed_ids(self) -> list[str]:
"""合并物理安装清单和虚拟实例清单并保持各自持久化顺序。"""
installed = list(
self._storage().read(SystemConfigKey.UserInstalledPlugins) or []
)
for instance_id in self._plugin_instances():
if instance_id not in installed:
installed.append(instance_id)
return installed
def get_from_market(
self,
market: str,
+44 -37
View File
@@ -5,36 +5,38 @@ from __future__ import annotations
from collections.abc import Callable
from typing import Any, Optional
from app.schemas.plugin import PluginInstance, PluginRuntimeStatus
class PluginCloneService:
"""协调插件包复制、安装清单、配置复制和运行态刷新"""
"""创建共享源码的虚拟插件实例并协调失败回滚"""
def __init__(
self,
*,
plugin_class: Callable[[str], Optional[Any]],
plugin_exists: Callable[[str], bool],
package_clone: Callable[..., tuple[bool, str]],
installed_plugins: Callable[[], list[str]],
save_installed_plugins: Callable[[list[str]], Any],
source_plugin_id: Callable[[str], str],
save_instance: Callable[[PluginInstance], Any],
delete_instance: Callable[[str], bool],
read_config: Callable[[str], dict],
save_config: Callable[[str, dict], bool],
delete_config: Callable[[str], bool],
reload_plugin: Callable[[str], Any],
running_plugin: Callable[[str], Optional[Any]],
initialize_plugin: Callable[[str, dict], Any],
remove_plugin: Callable[[str], Any],
log: Any,
) -> None:
"""保存、持久化和运行态端口。"""
"""保存实例描述、持久化和运行态端口。"""
self._plugin_class = plugin_class
self._plugin_exists = plugin_exists
self._package_clone = package_clone
self._installed_plugins = installed_plugins
self._save_installed_plugins = save_installed_plugins
self._source_plugin_id = source_plugin_id
self._save_instance = save_instance
self._delete_instance = delete_instance
self._read_config = read_config
self._save_config = save_config
self._delete_config = delete_config
self._reload_plugin = reload_plugin
self._running_plugin = running_plugin
self._initialize_plugin = initialize_plugin
self._remove_plugin = remove_plugin
self._logger = log
def clone(
@@ -47,11 +49,10 @@ class PluginCloneService:
version: Optional[str] = None,
icon: Optional[str] = None,
) -> tuple[bool, str]:
"""创建插件分身并保持原有默认禁用配置语义。"""
"""创建虚拟分身,复制隔离配置并保持默认禁用语义。"""
if not plugin_id or not suffix:
return False, "插件ID和分身后缀不能为空"
original_class = self._plugin_class(plugin_id)
if original_class is None:
if self._plugin_class(plugin_id) is None:
return False, f"原插件 {plugin_id} 不存在"
clone_id = f"{plugin_id}{suffix.lower()}"
@@ -59,38 +60,44 @@ class PluginCloneService:
return False, f"分身插件 {clone_id} 已存在"
try:
success, message = self._package_clone(
plugin_id=plugin_id,
clone_id=clone_id,
original_class_name=original_class.__name__,
suffix=suffix.lower(),
name=name,
description=description,
version=version,
icon=icon,
instance = PluginInstance(
instance_id=clone_id,
source_plugin_id=self._source_plugin_id(plugin_id),
plugin_name=name or None,
plugin_desc=description or None,
plugin_icon=icon or None,
)
if not success:
return False, message
installed = list(self._installed_plugins())
if clone_id not in installed:
installed.append(clone_id)
self._save_installed_plugins(installed)
self._save_instance(instance)
original_config = self._read_config(plugin_id)
if original_config:
clone_config = dict(original_config)
clone_config["enable"] = False
clone_config["enabled"] = False
self._save_config(clone_id, clone_config)
if not self._save_config(clone_id, clone_config):
raise RuntimeError("虚拟实例配置保存失败")
self._reload_plugin(clone_id)
clone_instance = self._running_plugin(clone_id)
clone_config = self._read_config(clone_id)
if clone_instance and clone_config:
self._initialize_plugin(clone_id, clone_config)
status = self._reload_plugin(clone_id)
if status is PluginRuntimeStatus.LOAD_FAILED:
raise RuntimeError("虚拟实例加载失败")
self._logger.info(f"插件分身 {clone_id} 创建成功")
return True, clone_id
except Exception as error: # noqa: BLE001
self._rollback(clone_id)
self._logger.error(f"创建插件分身失败:{error}")
return False, f"创建插件分身失败:{error}"
def _rollback(self, clone_id: str) -> None:
"""逐项清理失败实例,单个清理错误不得阻断其余回滚。"""
rollback_steps = (
("运行态", self._remove_plugin),
("实例描述", self._delete_instance),
("配置", self._delete_config),
)
for label, rollback in rollback_steps:
try:
rollback(clone_id)
except Exception as rollback_error: # noqa: BLE001
self._logger.warning(
f"回滚插件分身 {clone_id}{label}失败:{rollback_error}"
)
+165
View File
@@ -3,12 +3,16 @@
from __future__ import annotations
import importlib
import importlib.util
import sys
import threading
import traceback
from collections.abc import Callable
from pathlib import Path
from typing import Any, Optional
from app.schemas.plugin import PluginInstance
PluginImportPreparer = Callable[..., None]
PluginImportScanner = Callable[..., None]
@@ -18,6 +22,8 @@ PluginValidator = Callable[[Any], bool]
class PluginLoader:
"""只负责从运行目录发现插件类,并维护对应模块缓存。"""
_instance_import_lock = threading.RLock()
def __init__(
self,
*,
@@ -96,6 +102,165 @@ class PluginLoader:
)
return plugins
def load_instance(
self,
instance: PluginInstance,
validator: PluginValidator,
) -> list[Any]:
"""在实例专属模块命名空间中重新执行源插件代码并返回适配类。"""
source_dir = self._plugins_root / instance.source_plugin_id.lower()
source_file = source_dir / "__init__.py"
if not source_file.exists():
self._logger.warning(
f"虚拟插件实例 {instance.instance_id} 的源码不存在:{source_dir}"
)
return []
module_name = f"app.plugins.{instance.instance_id.lower()}"
self.clear_modules(instance.instance_id)
try:
self._import_preparer(
plugin_id=instance.source_plugin_id.lower(),
plugin_dir=source_dir,
)
self._import_scanner(
plugin_id=instance.source_plugin_id.lower(),
plugin_dir=source_dir,
)
spec = importlib.util.spec_from_file_location(
module_name,
source_file,
submodule_search_locations=[str(source_dir)],
)
if spec is None or spec.loader is None:
raise ImportError(f"无法创建模块规格:{module_name}")
module = importlib.util.module_from_spec(spec)
self._execute_instance_module(
module=module,
module_name=module_name,
source_module_name=(
f"app.plugins.{instance.source_plugin_id.lower()}"
),
loader=spec.loader,
)
for name, candidate in module.__dict__.items():
if name.startswith("_") or not isinstance(candidate, type):
continue
if not validator(candidate):
continue
self._adapt_instance_class(candidate, instance)
self._logger.debug(
f"{instance.source_plugin_id} 加载虚拟插件实例:{instance.instance_id}"
)
return [candidate]
except Exception as error: # noqa: BLE001
self.clear_modules(instance.instance_id)
self._logger.error(
f"加载虚拟插件实例 {instance.instance_id} 失败:{error} - "
f"{traceback.format_exc()}"
)
return []
def _execute_instance_module(
self,
*,
module: Any,
module_name: str,
source_module_name: str,
loader: Any,
) -> None:
"""执行实例模块,并把旧式自身绝对导入迁移到实例命名空间。"""
source_prefix = f"{source_module_name}."
parent_module = sys.modules.get("app.plugins")
source_attribute = source_module_name.rsplit(".", 1)[-1]
missing = object()
with self._instance_import_lock:
source_snapshot = {
name: loaded_module
for name, loaded_module in list(sys.modules.items())
if name == source_module_name or name.startswith(source_prefix)
}
parent_snapshot = (
getattr(parent_module, source_attribute, missing)
if parent_module
else missing
)
for name in source_snapshot:
sys.modules.pop(name, None)
sys.modules[module_name] = module
# 兼容旧插件在包内仍写 app.plugins.<source> 的绝对导入。
sys.modules[source_module_name] = module
if parent_module:
setattr(parent_module, source_attribute, module)
captured: dict[str, Any] = {}
try:
loader.exec_module(module)
captured = {
name: loaded_module
for name, loaded_module in list(sys.modules.items())
if name == source_module_name or name.startswith(source_prefix)
}
finally:
for name in list(sys.modules):
if name == source_module_name or name.startswith(source_prefix):
sys.modules.pop(name, None)
sys.modules.update(source_snapshot)
if parent_module:
if parent_snapshot is missing:
try:
delattr(parent_module, source_attribute)
except AttributeError:
pass
else:
setattr(parent_module, source_attribute, parent_snapshot)
for source_name, loaded_module in captured.items():
suffix = source_name[len(source_module_name):]
instance_name = f"{module_name}{suffix}"
sys.modules[instance_name] = loaded_module
self._retarget_module_identity(
loaded_module,
source_name,
instance_name,
)
@staticmethod
def _retarget_module_identity(
module: Any,
source_name: str,
instance_name: str,
) -> None:
"""修正被旧绝对路径加载对象的模块身份,避免事件与诊断键冲突。"""
if getattr(module, "__name__", None) == source_name:
module.__name__ = instance_name
package_name = getattr(module, "__package__", None)
if isinstance(package_name, str) and package_name.startswith(source_name):
module.__package__ = instance_name + package_name[len(source_name):]
spec = getattr(module, "__spec__", None)
if spec and getattr(spec, "name", None) == source_name:
spec.name = instance_name
for value in vars(module).values():
if getattr(value, "__module__", None) == source_name:
try:
value.__module__ = instance_name
except (AttributeError, TypeError):
continue
@staticmethod
def _adapt_instance_class(candidate: Any, instance: PluginInstance) -> None:
"""只改运行身份与展示元数据,不改源码、限定名和联邦产物。"""
candidate.__name__ = instance.instance_id
candidate.plugin_instance_id = instance.instance_id
candidate.plugin_source_id = instance.source_plugin_id
candidate.is_clone = True
candidate.plugin_config_prefix = f"{instance.instance_id.lower()}_"
if instance.plugin_name:
candidate.plugin_name = instance.plugin_name
if instance.plugin_desc:
candidate.plugin_desc = instance.plugin_desc
if instance.plugin_icon:
candidate.plugin_icon = instance.plugin_icon
def clear_modules(self, plugin_id: Optional[str] = None) -> list[str]:
"""清除指定插件或全部插件的 Python 模块缓存。"""
prefix = (
+16 -3
View File
@@ -150,11 +150,15 @@ class PluginProjection:
continue
if not self._remote_entry_factory:
raise RuntimeError("插件联邦入口生成器尚未配置")
remotes.append({
remote = {
"id": plugin_id,
"url": self._remote_entry_factory(plugin_id, dist_path),
"name": plugin.plugin_name,
})
}
source_plugin_id = getattr(plugin, "plugin_source_id", None)
if source_plugin_id:
remote["source_plugin_id"] = source_plugin_id
remotes.append(remote)
return remotes
def auth_providers(self) -> List[Dict[str, Any]]:
@@ -189,11 +193,15 @@ class PluginProjection:
if not self._remote_entry_factory:
raise RuntimeError("插件联邦入口生成器尚未配置")
provider.setdefault("component", "AuthPage")
provider["remote"] = {
remote = {
"id": plugin_id,
"url": self._remote_entry_factory(plugin_id, dist_path),
"name": plugin.plugin_name,
}
source_plugin_id = getattr(plugin, "plugin_source_id", None)
if source_plugin_id:
remote["source_plugin_id"] = source_plugin_id
provider["remote"] = remote
providers.append(provider)
return providers
@@ -335,4 +343,9 @@ class PluginProjection:
cols=cols or {},
attrs=attrs or {},
elements=elements,
source_plugin_id=getattr(plugin, "plugin_source_id", None),
is_instance=bool(getattr(plugin, "plugin_source_id", None)),
instance_mode=(
"virtual" if getattr(plugin, "plugin_source_id", None) else None
),
)
+73
View File
@@ -5,6 +5,11 @@ from __future__ import annotations
from collections.abc import Awaitable, Callable
from typing import Any
from pydantic import ValidationError
from app.schemas.plugin import PluginInstance
from app.schemas.types import SystemConfigKey
ConfigReader = Callable[[Any], Any]
ConfigWriter = Callable[[Any, Any], Any]
@@ -139,6 +144,74 @@ class PluginConfigStore:
return True
class PluginInstanceStore:
"""管理虚拟插件实例描述,并隔离兼容清单与新实例清单。"""
def __init__(self, *, storage: Callable[[], "PluginStorage"]) -> None:
"""保存延迟解析的持久化端口,便于启动组合根后装配。"""
self._storage = storage
def all(self) -> dict[str, PluginInstance]:
"""读取全部有效实例,忽略损坏项以免阻断存量插件启动。"""
raw_instances = self._storage().read(SystemConfigKey.PluginInstances) or {}
if isinstance(raw_instances, list):
entries = {
item.get("instance_id"): item
for item in raw_instances
if isinstance(item, dict) and item.get("instance_id")
}
elif isinstance(raw_instances, dict):
entries = raw_instances
else:
return {}
instances: dict[str, PluginInstance] = {}
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
except (TypeError, ValidationError):
continue
return instances
def get(self, instance_id: str) -> PluginInstance | None:
"""读取指定实例描述。"""
return self.all().get(instance_id)
def save(self, instance: PluginInstance) -> None:
"""新增或更新实例描述,并以实例 ID 作为稳定持久化键。"""
instances = self.all()
instances[instance.instance_id] = instance
self._write(instances)
def delete(self, instance_id: str) -> bool:
"""删除指定实例描述,返回删除前是否存在。"""
instances = self.all()
removed = instances.pop(instance_id, None)
if removed is None:
return False
self._write(instances)
return True
def for_source(self, source_plugin_id: str) -> list[PluginInstance]:
"""按持久化顺序返回引用同一源码插件的全部实例。"""
return [
instance
for instance in self.all().values()
if instance.source_plugin_id == source_plugin_id
]
def _write(self, instances: dict[str, PluginInstance]) -> None:
"""把模型映射序列化为普通字典,避免存储层依赖 Pydantic。"""
payload = {
instance_id: instance.model_dump(mode="json")
for instance_id, instance in instances.items()
}
self._storage().write(SystemConfigKey.PluginInstances, payload)
_plugin_storage = PluginStorage()
+103 -18
View File
@@ -9,7 +9,7 @@ from watchfiles import watch
from app.schemas.plugin import Plugin as _SchemaPlugin
from app.schemas.plugin import PluginDashboard as _SchemaPluginDashboard
from app.schemas.plugin import PluginRuntimeStatus
from app.schemas.plugin import PluginInstance, PluginRuntimeStatus
from app.foundation.crypto import RSAUtils
from app.foundation.singleton import Singleton
from app.foundation.version import compare_version
@@ -42,7 +42,7 @@ from app.runtime.extensions.plugin.dependency import (
PluginDependencyInstallResult,
PluginDependencyService,
)
from app.runtime.extensions.plugin.storage import PluginConfigStore
from app.runtime.extensions.plugin.storage import PluginConfigStore, PluginInstanceStore
from app.schemas.types import EventType, SystemConfigKey
LegacyDiagnosticsConfigurator = Callable[..., None]
@@ -131,6 +131,9 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton):
# 旧属性继续引用注册表拥有的可变字典,保持插件和测试的访问身份。
self._plugins = self._plugin_registry.classes
self._running_plugins = self._plugin_registry.running
self._plugin_instance_store = PluginInstanceStore(
storage=lambda: get_plugin_storage(),
)
# 配置Key
self._config_key: str = "plugin.%s"
self._plugin_config_store = PluginConfigStore(
@@ -158,6 +161,8 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton):
map_plugin=lambda **kwargs: self._process_plugin_info(**kwargs),
auth_checker=lambda **kwargs: self.__set_and_check_auth_level(**kwargs),
plugin_attr=lambda pid, attr: self.get_plugin_attr(pid, attr),
plugin_instance=self.get_plugin_instance,
plugin_instances=self.get_plugin_instances,
runtime_status=self._plugin_registry.runtime_status,
log=logger,
)
@@ -201,7 +206,7 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton):
self._plugin_lifecycle = PluginLifecycle(
classes=self._plugins,
running=self._running_plugins,
load_plugins=lambda pid, installed, check: self._load_selective_plugins(
load_plugins=lambda pid, installed, check: self._load_runtime_plugins(
pid,
installed,
check,
@@ -262,23 +267,21 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton):
self._plugin_clone = PluginCloneService(
plugin_class=self._plugin_registry.plugin_class,
plugin_exists=lambda plugin_id: self.is_plugin_exists(plugin_id),
package_clone=lambda **kwargs: get_plugin_system().package.clone(**kwargs),
installed_plugins=lambda: get_plugin_storage().read(
SystemConfigKey.UserInstalledPlugins
) or [],
save_installed_plugins=lambda plugins: get_plugin_storage().write(
SystemConfigKey.UserInstalledPlugins,
plugins,
),
source_plugin_id=self.get_plugin_source_id,
save_instance=self._plugin_instance_store.save,
delete_instance=self._plugin_instance_store.delete,
read_config=self.get_plugin_config,
save_config=lambda plugin_id, config: self.save_plugin_config(
plugin_id,
config,
force=True,
),
delete_config=lambda plugin_id: self.delete_plugin_config(
plugin_id,
force=True,
),
reload_plugin=self.reload_plugin,
running_plugin=self._plugin_registry.instance,
initialize_plugin=self.init_plugin,
remove_plugin=self.remove_plugin,
log=logger,
)
# 事件总线只通过通用解析器访问运行中的插件实例。
@@ -372,6 +375,30 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton):
log=logger,
).load(pid, installed_plugins, check_module_func)
def _load_runtime_plugins(
self,
pid: Optional[str],
installed_plugins: List[str],
check_module_func: Callable[[Any], bool],
) -> List[Any]:
"""加载物理插件或虚拟实例,并保留旧选择性加载方法的合同。"""
if pid:
instance = self._plugin_instance_store.get(pid)
if instance:
return self._plugin_loader.load_instance(instance, check_module_func)
return self._plugin_loader.load(pid, installed_plugins, check_module_func)
plugins = self._plugin_loader.load(
None,
installed_plugins,
check_module_func,
)
for instance in self._plugin_instance_store.all().values():
plugins.extend(
self._plugin_loader.load_instance(instance, check_module_func)
)
return plugins
@property
def running_plugins(self) -> Dict[str, Any]:
"""
@@ -435,7 +462,7 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton):
monitor_suppressed=self.is_plugin_monitor_suppressed,
local_candidate=self._get_local_plugin_candidate_from_path,
sync_local=self._sync_local_plugin_if_installed,
reload_plugin=self.reload_plugin,
reload_plugin=self.reload_plugin_tree,
dependency_manifest_status=(
get_plugin_system().dependency_manifest_status
),
@@ -512,6 +539,27 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton):
"""
return self._plugin_lifecycle.reload(plugin_id, EventType.PluginReload)
def reload_plugin_tree(self, plugin_id: str) -> PluginRuntimeStatus:
"""重载源码插件,并同步刷新所有引用该源码的虚拟实例。"""
source_plugin_id = self.get_plugin_source_id(plugin_id)
status = self.reload_plugin(source_plugin_id)
for instance in self._plugin_instance_store.for_source(source_plugin_id):
self.reload_plugin(instance.instance_id)
return status
def get_plugin_reload_targets(self, plugin_id: str) -> List[str]:
"""返回源码更新后需要刷新注册信息的源插件及其实例 ID。"""
source_plugin_id = self.get_plugin_source_id(plugin_id)
return [
source_plugin_id,
*(
instance.instance_id
for instance in self._plugin_instance_store.for_source(
source_plugin_id
)
),
]
@staticmethod
def _clear_plugin_modules(plugin_id: Optional[str] = None):
"""
@@ -551,13 +599,29 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton):
log=logger,
).install_missing_with_status()
@staticmethod
def classify_plugins() -> PluginDependencyClassification:
"""按源码和依赖是否就绪划分已安装插件。"""
return PluginDependencyService(
def classify_plugins(self) -> PluginDependencyClassification:
"""按源码依赖状态分类物理插件,并把结果映射到虚拟实例。"""
source_classification = PluginDependencyService(
system=get_plugin_system,
log=logger,
).classify_plugins()
ready = list(source_classification.ready)
missing_dependencies = list(source_classification.missing_dependencies)
missing_source = list(source_classification.missing_source)
source_ready = set(source_classification.ready)
source_pending = set(source_classification.missing_dependencies)
for instance in self._plugin_instance_store.all().values():
if instance.source_plugin_id in source_ready:
ready.append(instance.instance_id)
elif instance.source_plugin_id in source_pending:
missing_dependencies.append(instance.instance_id)
else:
missing_source.append(instance.instance_id)
return PluginDependencyClassification(
ready=tuple(ready),
missing_dependencies=tuple(missing_dependencies),
missing_source=tuple(missing_source),
)
def apply_plugin_dependency_classification(
self,
@@ -610,6 +674,27 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton):
"""
return self._plugin_config_store.read(pid)
def get_plugin_instances(self) -> dict[str, PluginInstance]:
"""返回全部有效虚拟插件实例描述。"""
return self._plugin_instance_store.all()
def get_plugin_instance(self, plugin_id: str) -> Optional[PluginInstance]:
"""返回指定虚拟插件实例描述,物理插件返回空。"""
return self._plugin_instance_store.get(plugin_id)
def get_plugin_source_id(self, plugin_id: str) -> str:
"""解析插件运行身份对应的源码身份,普通插件保持原值。"""
instance = self._plugin_instance_store.get(plugin_id)
return instance.source_plugin_id if instance else plugin_id
def get_plugin_source_instances(self, plugin_id: str) -> List[PluginInstance]:
"""返回直接引用指定源码插件的虚拟实例。"""
return self._plugin_instance_store.for_source(plugin_id)
def delete_plugin_instance(self, plugin_id: str) -> bool:
"""删除虚拟实例描述;调用方仍负责停止实例和清理业务数据。"""
return self._plugin_instance_store.delete(plugin_id)
def save_plugin_config(self, pid: str, conf: dict, force: bool = False) -> bool:
"""
保存插件配置
+4 -2
View File
@@ -246,11 +246,13 @@ SCHEMA_EXPORTS = {
'Path': ('app.schemas.transfer', 'Path'),
'PerformanceSnapshot': ('app.schemas.monitoring', 'PerformanceSnapshot'),
'Plugin': ('app.schemas.plugin', 'Plugin'),
'PluginCloneRequest': ('app.schemas.plugin', 'PluginCloneRequest'),
'PluginDashboard': ('app.schemas.plugin', 'PluginDashboard'),
'PluginDashboardMetaItem': ('app.schemas.plugin', 'PluginDashboardMetaItem'),
'PluginDataResetEventData': ('app.schemas.event', 'PluginDataResetEventData'),
'PluginFolderConfigData': ('app.schemas.plugin', 'PluginFolderConfigData'),
'PluginFoldersData': ('app.schemas.plugin', 'PluginFoldersData'),
'PluginInstance': ('app.schemas.plugin', 'PluginInstance'),
'PluginMarketSyncData': ('app.schemas.system', 'PluginMarketSyncData'),
'PluginMarketSyncRequest': ('app.schemas.system', 'PluginMarketSyncRequest'),
'PluginMemoryInfo': ('app.schemas.plugin', 'PluginMemoryInfo'),
@@ -423,7 +425,7 @@ SCHEMA_CONFLICTS = {
'FilterRuleGroup': ['app.schemas.rule', 'app.schemas.system'],
'JsonData': ['app.schemas.agent', 'app.schemas.common', 'app.schemas.context', 'app.schemas.dashboard', 'app.schemas.event', 'app.schemas.history', 'app.schemas.mediaserver', 'app.schemas.message', 'app.schemas.mfa', 'app.schemas.music', 'app.schemas.plugin', 'app.schemas.search', 'app.schemas.openai', 'app.schemas.servcookie', 'app.schemas.site', 'app.schemas.user', 'app.schemas.workflow', 'app.schemas.mcp'],
'List': ['app.schemas.agent', 'app.schemas.context', 'app.schemas.event', 'app.schemas.history', 'app.schemas.mediaserver', 'app.schemas.message', 'app.schemas.monitoring', 'app.schemas.plugin', 'app.schemas.openai', 'app.schemas.subscribe', 'app.schemas.transfer', 'app.schemas.workflow'],
'Literal': ['app.schemas.agent', 'app.schemas.music', 'app.schemas.search', 'app.schemas.servcookie', 'app.schemas.mcp'],
'Literal': ['app.schemas.agent', 'app.schemas.music', 'app.schemas.plugin', 'app.schemas.search', 'app.schemas.servcookie', 'app.schemas.mcp'],
'LocaleHelper': ['app.schemas.dashboard', 'app.schemas.response'],
'MediaInfo': ['app.schemas.context', 'app.schemas.system', 'app.schemas.transfer', 'app.schemas.workflow'],
'MediaSource': ['app.schemas.cache', 'app.schemas.context', 'app.schemas.event', 'app.schemas.history', 'app.schemas.mediaserver', 'app.schemas.music', 'app.schemas.subscribe', 'app.schemas.transfer'],
@@ -448,6 +450,6 @@ SCHEMA_CONFLICTS = {
'UserPermissions': ['app.schemas.token', 'app.schemas.user'],
'dataclass': ['app.schemas.notification', 'app.schemas.system'],
'datetime': ['app.schemas.agent', 'app.schemas.monitoring'],
'field_validator': ['app.schemas.event', 'app.schemas.message', 'app.schemas.music', 'app.schemas.response', 'app.schemas.subscribe', 'app.schemas.system'],
'field_validator': ['app.schemas.event', 'app.schemas.message', 'app.schemas.music', 'app.schemas.plugin', 'app.schemas.response', 'app.schemas.subscribe', 'app.schemas.system'],
'model_validator': ['app.schemas.dashboard', 'app.schemas.event', 'app.schemas.mediaserver', 'app.schemas.subscribe'],
}
+48 -2
View File
@@ -1,7 +1,7 @@
from enum import Enum as _Enum
from typing import Optional, List, Dict, Union
from typing import Literal, Optional, List, Dict, Union
from pydantic import BaseModel, Field, RootModel
from pydantic import BaseModel, Field, RootModel, field_validator
from app.schemas.common import JsonData
@@ -17,6 +17,27 @@ class PluginRuntimeStatus(str, _Enum):
LOAD_FAILED = "load_failed"
class PluginInstance(BaseModel):
"""持久化一个共享源码插件的独立运行实例。"""
instance_id: str = Field(description="运行实例 ID,也是配置、数据和路由命名空间")
source_plugin_id: str = Field(description="提供代码与前端资源的源插件 ID")
plugin_name: Optional[str] = Field(default=None, description="实例展示名称")
plugin_desc: Optional[str] = Field(default=None, description="实例展示描述")
plugin_icon: Optional[str] = Field(default=None, description="实例展示图标")
mode: Literal["virtual"] = Field(default="virtual", description="实例实现模式")
@field_validator("instance_id", "source_plugin_id")
@classmethod
def validate_plugin_id(cls, value: str) -> str:
"""限制实例标识为可安全用作 Python 类名和路由段的格式。"""
if not value or not value[0].isalpha() or not value.isalnum():
raise ValueError("插件 ID 必须以字母开头且只能包含字母和数字")
if len(value) > 128:
raise ValueError("插件 ID 长度不能超过 128 个字符")
return value
class Plugin(BaseModel):
"""
插件信息
@@ -72,6 +93,12 @@ class Plugin(BaseModel):
add_time: Optional[int] = 0
# 插件公钥
plugin_public_key: Optional[str] = None
# 共享代码与前端资源的源插件 ID;普通插件为空
source_plugin_id: Optional[str] = None
# 是否为共享源码的虚拟实例
is_instance: Optional[bool] = False
# 实例实现模式;存量物理分身为空
instance_mode: Optional[str] = None
class PluginRuntimeSummary(BaseModel):
@@ -83,6 +110,24 @@ class PluginRuntimeSummary(BaseModel):
failed_count: int = Field(description="加载失败或被策略阻止的插件数量")
class PluginCloneRequest(BaseModel):
"""创建虚拟插件分身的请求参数。"""
suffix: str = Field(
min_length=1,
max_length=20,
pattern=r"^[A-Za-z0-9]+$",
description="追加到当前插件 ID 后的 ASCII 字母或数字后缀",
)
name: str = Field(default="", description="分身展示名称")
description: str = Field(default="", description="分身展示描述")
icon: Optional[str] = Field(default=None, description="分身展示图标")
version: Optional[str] = Field(
default=None,
description="兼容旧客户端保留,虚拟分身始终跟随源插件版本",
)
class PluginDashboard(Plugin):
"""
插件仪表盘
@@ -164,6 +209,7 @@ class PluginRemoteInfo(BaseModel):
id: str
url: str
name: str
source_plugin_id: Optional[str] = None
class PluginReleaseItem(BaseModel):
+2
View File
@@ -392,6 +392,8 @@ class SystemConfigKey(Enum):
UserCustomCSS = "UserCustomCSS"
# 用户已安装的插件
UserInstalledPlugins = "UserInstalledPlugins"
# 共享源码插件的虚拟运行实例
PluginInstances = "PluginInstances"
# 插件文件夹分组配置
PluginFolders = "PluginFolders"
# 默认电影订阅规则
+9 -2
View File
@@ -13,8 +13,8 @@
"runtime_to_db": [],
"workflow_to_db": []
},
"edge_count": 6306,
"edge_sha256": "7ca2da728599965ef604010fdf8e8e03d2939c68f3f45eba5b93aff63c2838a4",
"edge_count": 6313,
"edge_sha256": "25292ce04e33206f713eb24cff2397931124fcb22f55ab0d78f94314581161d4",
"edges": [
"app -> app.runtime",
"app -> app.runtime.compat",
@@ -5482,6 +5482,8 @@
"app.runtime.extensions.plugin.catalog -> app.schemas",
"app.runtime.extensions.plugin.catalog -> app.schemas.plugin",
"app.runtime.extensions.plugin.catalog -> app.schemas.types",
"app.runtime.extensions.plugin.clone -> app.schemas",
"app.runtime.extensions.plugin.clone -> app.schemas.plugin",
"app.runtime.extensions.plugin.contracts -> app.foundation",
"app.runtime.extensions.plugin.contracts -> app.foundation.reflection",
"app.runtime.extensions.plugin.dependency -> app.runtime",
@@ -5490,6 +5492,8 @@
"app.runtime.extensions.plugin.dependency -> app.runtime.extensions.plugin.system",
"app.runtime.extensions.plugin.lifecycle -> app.schemas",
"app.runtime.extensions.plugin.lifecycle -> app.schemas.plugin",
"app.runtime.extensions.plugin.loader -> app.schemas",
"app.runtime.extensions.plugin.loader -> app.schemas.plugin",
"app.runtime.extensions.plugin.metadata -> app.runtime",
"app.runtime.extensions.plugin.metadata -> app.runtime.extensions",
"app.runtime.extensions.plugin.metadata -> app.runtime.extensions.plugin",
@@ -5509,6 +5513,9 @@
"app.runtime.extensions.plugin.projection -> app.schemas.plugin",
"app.runtime.extensions.plugin.registry -> app.schemas",
"app.runtime.extensions.plugin.registry -> app.schemas.plugin",
"app.runtime.extensions.plugin.storage -> app.schemas",
"app.runtime.extensions.plugin.storage -> app.schemas.plugin",
"app.runtime.extensions.plugin.storage -> app.schemas.types",
"app.runtime.extensions.plugin.sync -> app.runtime",
"app.runtime.extensions.plugin.sync -> app.runtime.extensions",
"app.runtime.extensions.plugin.sync -> app.runtime.extensions.plugin",
+25 -14
View File
@@ -2695,7 +2695,7 @@
]
},
"get_api": {
"file_count": 83,
"file_count": 84,
"files": [
"plugins.v2/agentresourceofficer/__init__.py",
"plugins.v2/agenttokens/__init__.py",
@@ -2775,6 +2775,7 @@
"plugins.v3/imdbsource/__init__.py",
"plugins.v3/libraryscraper/__init__.py",
"plugins.v3/maoyanrank/__init__.py",
"plugins.v3/mediainforenameenhancer/__init__.py",
"plugins.v3/mediaservermsg/__init__.py",
"plugins.v3/neodbsync/__init__.py",
"plugins.v3/personmeta/__init__.py",
@@ -2787,7 +2788,7 @@
"files": []
},
"get_command": {
"file_count": 81,
"file_count": 82,
"files": [
"plugins.v2/agentresourceofficer/__init__.py",
"plugins.v2/agenttokens/__init__.py",
@@ -2865,6 +2866,7 @@
"plugins.v3/imdbsource/__init__.py",
"plugins.v3/libraryscraper/__init__.py",
"plugins.v3/maoyanrank/__init__.py",
"plugins.v3/mediainforenameenhancer/__init__.py",
"plugins.v3/mediaservermsg/__init__.py",
"plugins.v3/neodbsync/__init__.py",
"plugins.v3/personmeta/__init__.py",
@@ -2888,7 +2890,7 @@
]
},
"get_form": {
"file_count": 83,
"file_count": 84,
"files": [
"plugins.v2/agentresourceofficer/__init__.py",
"plugins.v2/agenttokens/__init__.py",
@@ -2968,6 +2970,7 @@
"plugins.v3/imdbsource/__init__.py",
"plugins.v3/libraryscraper/__init__.py",
"plugins.v3/maoyanrank/__init__.py",
"plugins.v3/mediainforenameenhancer/__init__.py",
"plugins.v3/mediaservermsg/__init__.py",
"plugins.v3/neodbsync/__init__.py",
"plugins.v3/personmeta/__init__.py",
@@ -2986,7 +2989,7 @@
]
},
"get_page": {
"file_count": 83,
"file_count": 84,
"files": [
"plugins.v2/agentresourceofficer/__init__.py",
"plugins.v2/agenttokens/__init__.py",
@@ -3066,6 +3069,7 @@
"plugins.v3/imdbsource/__init__.py",
"plugins.v3/libraryscraper/__init__.py",
"plugins.v3/maoyanrank/__init__.py",
"plugins.v3/mediainforenameenhancer/__init__.py",
"plugins.v3/mediaservermsg/__init__.py",
"plugins.v3/neodbsync/__init__.py",
"plugins.v3/personmeta/__init__.py",
@@ -3148,7 +3152,7 @@
"files": []
},
"get_state": {
"file_count": 85,
"file_count": 86,
"files": [
"plugins.v2/agentresourceofficer/__init__.py",
"plugins.v2/agenttokens/__init__.py",
@@ -3230,6 +3234,7 @@
"plugins.v3/imdbsource/__init__.py",
"plugins.v3/libraryscraper/__init__.py",
"plugins.v3/maoyanrank/__init__.py",
"plugins.v3/mediainforenameenhancer/__init__.py",
"plugins.v3/mediaservermsg/__init__.py",
"plugins.v3/neodbsync/__init__.py",
"plugins.v3/personmeta/__init__.py",
@@ -3238,7 +3243,7 @@
]
},
"init_plugin": {
"file_count": 83,
"file_count": 84,
"files": [
"plugins.v2/agentresourceofficer/__init__.py",
"plugins.v2/agenttokens/__init__.py",
@@ -3318,6 +3323,7 @@
"plugins.v3/imdbsource/__init__.py",
"plugins.v3/libraryscraper/__init__.py",
"plugins.v3/maoyanrank/__init__.py",
"plugins.v3/mediainforenameenhancer/__init__.py",
"plugins.v3/mediaservermsg/__init__.py",
"plugins.v3/neodbsync/__init__.py",
"plugins.v3/personmeta/__init__.py",
@@ -3326,7 +3332,7 @@
]
},
"stop_service": {
"file_count": 83,
"file_count": 84,
"files": [
"plugins.v2/agentresourceofficer/__init__.py",
"plugins.v2/agenttokens/__init__.py",
@@ -3406,6 +3412,7 @@
"plugins.v3/imdbsource/__init__.py",
"plugins.v3/libraryscraper/__init__.py",
"plugins.v3/maoyanrank/__init__.py",
"plugins.v3/mediainforenameenhancer/__init__.py",
"plugins.v3/mediaservermsg/__init__.py",
"plugins.v3/neodbsync/__init__.py",
"plugins.v3/personmeta/__init__.py",
@@ -4293,7 +4300,7 @@
]
},
"app.plugins": {
"file_count": 83,
"file_count": 84,
"files": [
"plugins.v2/agentresourceofficer/__init__.py",
"plugins.v2/agenttokens/__init__.py",
@@ -4373,6 +4380,7 @@
"plugins.v3/imdbsource/__init__.py",
"plugins.v3/libraryscraper/__init__.py",
"plugins.v3/maoyanrank/__init__.py",
"plugins.v3/mediainforenameenhancer/__init__.py",
"plugins.v3/mediaservermsg/__init__.py",
"plugins.v3/neodbsync/__init__.py",
"plugins.v3/personmeta/__init__.py",
@@ -4541,7 +4549,7 @@
]
},
"app.schemas.types": {
"file_count": 66,
"file_count": 67,
"files": [
"plugins.v2/agentresourceofficer/__init__.py",
"plugins.v2/agenttokens/__init__.py",
@@ -4604,6 +4612,7 @@
"plugins.v3/historytov2/__init__.py",
"plugins.v3/imdbsource/__init__.py",
"plugins.v3/imdbsource/imdbhelper.py",
"plugins.v3/mediainforenameenhancer/__init__.py",
"plugins.v3/mediaservermsg/__init__.py",
"plugins.v3/neodbsync/__init__.py",
"plugins.v3/personmeta/__init__.py",
@@ -4655,7 +4664,7 @@
]
},
"app.sdk.events": {
"file_count": 11,
"file_count": 12,
"files": [
"plugins.v3/autoclean/__init__.py",
"plugins.v3/bangumicoll/__init__.py",
@@ -4664,6 +4673,7 @@
"plugins.v3/downloadsitetag/__init__.py",
"plugins.v3/episodegroupmeta/__init__.py",
"plugins.v3/imdbsource/__init__.py",
"plugins.v3/mediainforenameenhancer/__init__.py",
"plugins.v3/mediaservermsg/__init__.py",
"plugins.v3/neodbsync/__init__.py",
"plugins.v3/personmeta/__init__.py",
@@ -4671,7 +4681,7 @@
]
},
"app.sdk.logging": {
"file_count": 23,
"file_count": 24,
"files": [
"plugins.v3/agentresourceofficer/__init__.py",
"plugins.v3/agentresourceofficer/feishu_channel.py",
@@ -4691,6 +4701,7 @@
"plugins.v3/imdbsource/officialapi.py",
"plugins.v3/libraryscraper/__init__.py",
"plugins.v3/maoyanrank/__init__.py",
"plugins.v3/mediainforenameenhancer/__init__.py",
"plugins.v3/mediaservermsg/__init__.py",
"plugins.v3/neodbsync/__init__.py",
"plugins.v3/personmeta/__init__.py",
@@ -4936,9 +4947,9 @@
}
},
"provenance": {
"head": "217c8d25ffe6ff0b3f6352c4278fd6896def442e",
"python_file_count": 237,
"source_sha256": "c3796565ff3fd49a8ff3f1e1373d9afab44fd7b98371e44f662c32739fe53d15"
"head": "fd0363711dbba96e31a27ad56b2e87c3524096a4",
"python_file_count": 238,
"source_sha256": "f07ff2e8c95080cbbf6d229376464e61dc74061c1a8d371b5ea2cf00148413de"
},
"schema_version": 3,
"scope": {
+1 -1
View File
@@ -334,7 +334,7 @@ def test_install_plugin_runtime_reloads_in_threadpool() -> None:
)
assert len(calls) == 2
assert calls[0][0] == "plugin"
assert calls[0][1] == plugin_manager.reload_plugin
assert calls[0][1] == plugin_manager.reload_plugin_tree
assert calls[0][2] == ("DemoPlugin",)
assert calls[0][3] == {}
assert calls[1][0] == "plugin"
+2
View File
@@ -37,6 +37,8 @@ def test_installed_catalog_keeps_plugins_that_are_not_loaded():
map_plugin=lambda **_kwargs: None,
auth_checker=lambda **_kwargs: True,
plugin_attr=lambda _plugin_id, _attr: None,
plugin_instance=lambda _plugin_id: None,
plugin_instances=lambda: {},
runtime_status=statuses.get,
log=SimpleNamespace(error=lambda *_args: None, info=lambda *_args: None),
)
+66 -3
View File
@@ -8,13 +8,15 @@ from app.api.endpoints.plugin import plugin_releases
from app.api.endpoints.plugin import reset_plugin
from app.api.endpoints.plugin import reload_plugin
from app.api.endpoints.plugin import runtime_status
from app.api.endpoints.plugin import plugin_static_file
from app.api.endpoints.plugin import uninstall_plugin
from app.api.endpoints.system import sync_plugin_market_from_wiki
from app.application.plugin.config import PluginConfigCommand
from app.runtime.config import settings
from app.runtime.extensions.plugin_manager import PluginManager
from app.schemas.event import PluginDataResetEventData
from app.schemas.plugin import PluginRuntimeStatus
from app.schemas.types import ChainEventType
from app.schemas.plugin import PluginInstance, PluginRuntimeStatus
from app.schemas.types import ChainEventType, SystemConfigKey
from app.foundation.singleton import Singleton
@@ -518,11 +520,72 @@ def test_delete_plugin_config_can_force_delete_after_plugin_is_stopped():
storage.delete.return_value = True
with patch("app.runtime.extensions.plugin_manager.get_plugin_storage", return_value=storage):
assert manager.delete_plugin_config("DemoPlugin", force=True) is True
storage.delete.assert_called_once_with("plugin.DemoPlugin")
Singleton._instances.pop((PluginManager, (), frozenset()), None)
def test_virtual_instance_static_file_reads_from_source_directory(tmp_path, monkeypatch):
"""实例 URL 保持独立,但静态内容直接读取共享的源插件目录。"""
source_file = tmp_path / "app/plugins/demoplugin/dist/remoteEntry.js"
source_file.parent.mkdir(parents=True)
source_file.write_text("export default 'shared'", encoding="utf-8")
plugin_manager = MagicMock()
plugin_manager.get_plugin_source_id.return_value = "DemoPlugin"
monkeypatch.setattr(plugin_endpoint, "PluginManager", lambda: plugin_manager)
monkeypatch.setattr(
plugin_endpoint,
"settings",
MagicMock(ROOT_PATH=tmp_path),
)
response = asyncio.run(
plugin_static_file("DemoPluginwork", "dist/remoteEntry.js", None)
)
async def read_body() -> bytes:
"""读取流式响应的全部测试内容。"""
return b"".join([chunk async for chunk in response.body_iterator])
assert asyncio.run(read_body()) == b"export default 'shared'"
assert response.media_type == "application/javascript"
plugin_manager.get_plugin_source_id.assert_called_once_with("DemoPluginwork")
def test_uninstall_virtual_instance_never_removes_source_package(monkeypatch):
"""卸载虚拟实例只清理实例状态,不触碰源插件安装清单或目录。"""
plugin_manager = MagicMock()
plugin_manager.get_plugin_instance.return_value = PluginInstance(
instance_id="DemoPluginwork",
source_plugin_id="DemoPlugin",
)
plugin_manager.get_plugin_source_instances.return_value = []
config = MagicMock()
config.get.return_value = ["DemoPlugin"]
monkeypatch.setattr(plugin_endpoint, "PluginManager", lambda: plugin_manager)
monkeypatch.setattr(plugin_endpoint, "get_configured_system_config", lambda: config)
monkeypatch.setattr(plugin_endpoint, "remove_plugin_api", MagicMock())
monkeypatch.setattr(plugin_endpoint, "remove_plugin_job", MagicMock())
monkeypatch.setattr(plugin_endpoint, "remove_plugin_from_folders", MagicMock())
result = uninstall_plugin("DemoPluginwork", None)
assert result.success is True
config.set.assert_called_once_with(
SystemConfigKey.UserInstalledPlugins,
["DemoPlugin"],
)
plugin_manager.delete_plugin_config.assert_called_once_with(
"DemoPluginwork",
force=True,
)
plugin_manager.delete_plugin_data.assert_called_once_with(
"DemoPluginwork",
force=True,
)
plugin_manager.delete_plugin_instance.assert_called_once_with("DemoPluginwork")
plugin_manager.remove_plugin.assert_called_once_with("DemoPluginwork")
def test_delete_plugin_data_can_force_delete_after_plugin_is_stopped():
"""
重置入口会先停止插件;插件数据删除不能依赖运行态注册仍存在。
+21
View File
@@ -172,6 +172,27 @@ def test_projection_builds_federation_and_auth_provider_entries():
}]
def test_projection_exposes_source_identity_for_virtual_frontend_instance():
"""虚拟实例的联邦入口保留实例 URL,并补充共享源码身份。"""
plugin = _Plugin(
plugin_source_id="Demo",
get_render_mode=lambda: ("vue", "dist/assets"),
get_auth_providers=lambda: [{"id": "demo-login"}],
)
projection = PluginProjection(
{"DemoWork": plugin},
remote_entry_factory=lambda plugin_id, path: f"/{plugin_id}/{path}",
)
assert projection.remotes()[0] == {
"id": "DemoWork",
"url": "/DemoWork/dist/assets",
"name": "测试插件",
"source_plugin_id": "Demo",
}
assert projection.auth_providers()[0]["remote"]["source_plugin_id"] == "Demo"
def test_projection_normalizes_sidebar_and_dashboard_metadata():
"""侧栏和仪表板元数据在投影层完成校验、排序与兼容默认值。"""
plugin = _Plugin(
+202
View File
@@ -0,0 +1,202 @@
"""虚拟插件实例的持久化、加载和创建行为测试。"""
import sys
from types import ModuleType, SimpleNamespace
from app.runtime.extensions.plugin.clone import PluginCloneService
from app.runtime.extensions.plugin.loader import PluginLoader
from app.runtime.extensions.plugin.storage import PluginInstanceStore, PluginStorage
from app.schemas.plugin import PluginInstance, PluginRuntimeStatus
from app.schemas.types import SystemConfigKey
def _logger() -> SimpleNamespace:
"""提供加载器测试所需的最小日志对象。"""
return SimpleNamespace(
debug=lambda *_args: None,
info=lambda *_args: None,
warning=lambda *_args: None,
error=lambda *_args: None,
)
def test_instance_store_keeps_virtual_instances_out_of_installed_list():
"""虚拟实例使用独立配置键,不改写存量物理插件安装清单。"""
values = {SystemConfigKey.UserInstalledPlugins: ["DemoPlugin"]}
storage = PluginStorage(
read=values.get,
write=lambda key, value: values.__setitem__(key, value),
)
store = PluginInstanceStore(storage=lambda: storage)
instance = PluginInstance(
instance_id="DemoPluginWork",
source_plugin_id="DemoPlugin",
plugin_name="工作实例",
)
store.save(instance)
assert store.get("DemoPluginWork") == instance
assert store.for_source("DemoPlugin") == [instance]
assert values[SystemConfigKey.UserInstalledPlugins] == ["DemoPlugin"]
assert store.delete("DemoPluginWork") is True
assert store.all() == {}
def test_loader_executes_each_instance_in_an_isolated_module_namespace(
tmp_path,
monkeypatch,
):
"""两个实例共享磁盘源码,但模块全局状态、类身份和相对导入互相隔离。"""
source_dir = tmp_path / "demoplugin"
source_dir.mkdir()
(source_dir / "state.py").write_text("state = []\n", encoding="utf-8")
(source_dir / "__init__.py").write_text(
"from app.plugins.demoplugin.state import state\n"
"class DemoPlugin:\n"
" plugin_name = 'Demo'\n"
" plugin_desc = 'Source'\n"
" plugin_icon = 'source.svg'\n"
" plugin_config_prefix = 'demo_'\n"
" def init_plugin(self, _config):\n"
" state.append(self.__class__.__name__)\n",
encoding="utf-8",
)
loader = PluginLoader(
plugins_root=tmp_path,
import_preparer=lambda **_kwargs: None,
import_scanner=lambda **_kwargs: None,
log=_logger(),
)
validator = lambda candidate: hasattr(candidate, "init_plugin")
import app.plugins as plugin_package
source_module = ModuleType("app.plugins.demoplugin")
source_module.__path__ = [str(source_dir)]
monkeypatch.setitem(sys.modules, "app.plugins.demoplugin", source_module)
monkeypatch.setattr(plugin_package, "demoplugin", source_module, raising=False)
work_class = loader.load_instance(
PluginInstance(
instance_id="DemoPluginWork",
source_plugin_id="DemoPlugin",
plugin_name="工作实例",
),
validator,
)[0]
home_class = loader.load_instance(
PluginInstance(
instance_id="DemoPluginHome",
source_plugin_id="DemoPlugin",
plugin_desc="家庭实例",
),
validator,
)[0]
work_class().init_plugin({})
home_class().init_plugin({})
assert work_class.__name__ == "DemoPluginWork"
assert home_class.__name__ == "DemoPluginHome"
assert work_class.__qualname__ == "DemoPlugin"
assert work_class.__module__ == "app.plugins.demopluginwork"
assert home_class.__module__ == "app.plugins.demopluginhome"
assert work_class.plugin_source_id == "DemoPlugin"
assert work_class.plugin_name == "工作实例"
assert work_class.plugin_config_prefix == "demopluginwork_"
assert work_class.__dict__["init_plugin"].__globals__["state"] == [
"DemoPluginWork"
]
assert home_class.__dict__["init_plugin"].__globals__["state"] == [
"DemoPluginHome"
]
assert sys.modules["app.plugins.demoplugin"] is source_module
assert plugin_package.demoplugin is source_module
def test_clone_service_persists_descriptor_without_copying_source_package():
"""创建分身只写实例描述和隔离配置,并始终跟随源插件版本。"""
instances: dict[str, PluginInstance] = {}
configs = {"DemoPlugin": {"enable": True, "token": "secret"}}
reloaded: list[str] = []
removed: list[str] = []
class DemoPlugin:
"""提供创建用例需要的最小源插件类。"""
service = PluginCloneService(
plugin_class=lambda plugin_id: DemoPlugin if plugin_id == "DemoPlugin" else None,
plugin_exists=lambda plugin_id: plugin_id in instances,
source_plugin_id=lambda plugin_id: plugin_id,
save_instance=lambda instance: instances.__setitem__(
instance.instance_id,
instance,
),
delete_instance=lambda plugin_id: instances.pop(plugin_id, None) is not None,
read_config=lambda plugin_id: configs.get(plugin_id, {}),
save_config=lambda plugin_id, config: not configs.__setitem__(plugin_id, config),
delete_config=lambda plugin_id: configs.pop(plugin_id, None) is not None,
reload_plugin=lambda plugin_id: (
reloaded.append(plugin_id) or PluginRuntimeStatus.ACTIVE
),
remove_plugin=removed.append,
log=_logger(),
)
success, clone_id = service.clone(
plugin_id="DemoPlugin",
suffix="Work",
name="工作实例",
description="独立配置",
version="9.9.9",
)
assert success is True
assert clone_id == "DemoPluginwork"
assert instances[clone_id].source_plugin_id == "DemoPlugin"
assert configs[clone_id] == {
"enable": False,
"enabled": False,
"token": "secret",
}
assert reloaded == [clone_id]
assert removed == []
def test_clone_service_rolls_back_descriptor_and_config_after_load_failure():
"""实例首次加载失败时不留下不可见描述或孤立配置。"""
instances: dict[str, PluginInstance] = {}
configs = {"DemoPlugin": {"enabled": True}}
removed: list[str] = []
class DemoPlugin:
"""提供失败回滚用例需要的最小源插件类。"""
service = PluginCloneService(
plugin_class=lambda _plugin_id: DemoPlugin,
plugin_exists=lambda plugin_id: plugin_id in instances,
source_plugin_id=lambda plugin_id: plugin_id,
save_instance=lambda instance: instances.__setitem__(
instance.instance_id,
instance,
),
delete_instance=lambda plugin_id: instances.pop(plugin_id, None) is not None,
read_config=lambda plugin_id: configs.get(plugin_id, {}),
save_config=lambda plugin_id, config: not configs.__setitem__(plugin_id, config),
delete_config=lambda plugin_id: configs.pop(plugin_id, None) is not None,
reload_plugin=lambda _plugin_id: PluginRuntimeStatus.LOAD_FAILED,
remove_plugin=removed.append,
log=_logger(),
)
success, message = service.clone(
plugin_id="DemoPlugin",
suffix="Broken",
name="失败实例",
description="",
)
assert success is False
assert "加载失败" in message
assert instances == {}
assert configs == {"DemoPlugin": {"enabled": True}}
assert removed == ["DemoPluginbroken"]