mirror of
https://github.com/DrizzleTime/Foxel.git
synced 2026-07-05 14:22:03 +08:00
Initial commit
This commit is contained in:
17
domain/plugins/__init__.py
Normal file
17
domain/plugins/__init__.py
Normal file
@@ -0,0 +1,17 @@
|
||||
"""
|
||||
Foxel 插件系统
|
||||
|
||||
提供 .foxpkg 插件包的安装、管理和运行时加载功能。
|
||||
"""
|
||||
|
||||
from .loader import PluginLoadError, PluginLoader
|
||||
from .service import PluginService
|
||||
from .startup import init_plugins, load_installed_plugins
|
||||
|
||||
__all__ = [
|
||||
"PluginLoader",
|
||||
"PluginLoadError",
|
||||
"PluginService",
|
||||
"init_plugins",
|
||||
"load_installed_plugins",
|
||||
]
|
||||
131
domain/plugins/api.py
Normal file
131
domain/plugins/api.py
Normal file
@@ -0,0 +1,131 @@
|
||||
"""
|
||||
插件管理 API 路由
|
||||
"""
|
||||
|
||||
from typing import Annotated, List
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Request, UploadFile
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from domain.audit import AuditAction, audit
|
||||
from domain.auth import User, get_current_active_user
|
||||
from domain.permission import require_system_permission
|
||||
from domain.permission.types import SystemPermission
|
||||
from .service import PluginService
|
||||
from .types import (
|
||||
PluginInstallResult,
|
||||
PluginOut,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/plugins", tags=["plugins"])
|
||||
|
||||
|
||||
# ========== 安装 ==========
|
||||
|
||||
|
||||
@router.post("/install", response_model=PluginInstallResult)
|
||||
@audit(action=AuditAction.CREATE, description="安装插件包")
|
||||
@require_system_permission(SystemPermission.ROLE_MANAGE)
|
||||
async def install_plugin(
|
||||
request: Request,
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
file: UploadFile = File(...),
|
||||
):
|
||||
"""
|
||||
安装 .foxpkg 插件包
|
||||
|
||||
上传 .foxpkg 文件进行安装。
|
||||
"""
|
||||
content = await file.read()
|
||||
return await PluginService.install_package(content, file.filename or "plugin.foxpkg")
|
||||
|
||||
|
||||
# ========== 插件列表和详情 ==========
|
||||
|
||||
|
||||
@router.get("", response_model=List[PluginOut])
|
||||
@audit(action=AuditAction.READ, description="获取插件列表")
|
||||
async def list_plugins(
|
||||
request: Request,
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
):
|
||||
"""获取已安装的插件列表"""
|
||||
return await PluginService.list_plugins()
|
||||
|
||||
|
||||
@router.get("/{key_or_id}", response_model=PluginOut)
|
||||
@audit(action=AuditAction.READ, description="获取插件详情")
|
||||
async def get_plugin(
|
||||
request: Request,
|
||||
key_or_id: str,
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
):
|
||||
"""获取单个插件详情"""
|
||||
return await PluginService.get_plugin(key_or_id)
|
||||
|
||||
|
||||
# ========== 插件管理 ==========
|
||||
|
||||
|
||||
@router.delete("/{key_or_id}")
|
||||
@audit(action=AuditAction.DELETE, description="卸载插件")
|
||||
@require_system_permission(SystemPermission.ROLE_MANAGE)
|
||||
async def delete_plugin(
|
||||
request: Request,
|
||||
key_or_id: str,
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
):
|
||||
"""卸载插件"""
|
||||
await PluginService.delete(key_or_id)
|
||||
return {"code": 0, "msg": "ok"}
|
||||
|
||||
|
||||
# ========== 插件资源 ==========
|
||||
|
||||
|
||||
@router.get("/{key_or_id}/bundle.js")
|
||||
async def get_bundle(request: Request, key_or_id: str):
|
||||
"""获取插件前端 bundle"""
|
||||
path = await PluginService.get_bundle_path(key_or_id)
|
||||
v = (request.query_params.get("v") or "").strip()
|
||||
cache_control = "public, max-age=31536000, immutable" if v else "no-cache"
|
||||
return FileResponse(
|
||||
path,
|
||||
media_type="application/javascript",
|
||||
headers={"Cache-Control": cache_control},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{key}/assets/{asset_path:path}")
|
||||
async def get_asset(request: Request, key: str, asset_path: str):
|
||||
"""获取插件静态资源"""
|
||||
path = await PluginService.get_asset_path(key, asset_path)
|
||||
|
||||
# 根据扩展名确定 MIME 类型
|
||||
ext = path.suffix.lower()
|
||||
media_types = {
|
||||
".js": "application/javascript",
|
||||
".css": "text/css",
|
||||
".json": "application/json",
|
||||
".svg": "image/svg+xml",
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".gif": "image/gif",
|
||||
".webp": "image/webp",
|
||||
".ico": "image/x-icon",
|
||||
".woff": "font/woff",
|
||||
".woff2": "font/woff2",
|
||||
".ttf": "font/ttf",
|
||||
".eot": "application/vnd.ms-fontobject",
|
||||
".html": "text/html",
|
||||
".txt": "text/plain",
|
||||
".md": "text/markdown",
|
||||
}
|
||||
media_type = media_types.get(ext, "application/octet-stream")
|
||||
|
||||
return FileResponse(
|
||||
path,
|
||||
media_type=media_type,
|
||||
headers={"Cache-Control": "public, max-age=3600"},
|
||||
)
|
||||
449
domain/plugins/loader.py
Normal file
449
domain/plugins/loader.py
Normal file
@@ -0,0 +1,449 @@
|
||||
"""
|
||||
插件加载器模块
|
||||
|
||||
负责:
|
||||
1. .foxpkg 解包和验证
|
||||
2. 插件文件部署
|
||||
3. 后端路由动态加载
|
||||
4. 处理器动态注册
|
||||
"""
|
||||
|
||||
import io
|
||||
import json
|
||||
import shutil
|
||||
import sys
|
||||
import zipfile
|
||||
from importlib.util import module_from_spec, spec_from_file_location
|
||||
from pathlib import Path
|
||||
from types import ModuleType
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from .types import (
|
||||
ManifestProcessorConfig,
|
||||
ManifestRouteConfig,
|
||||
PluginManifest,
|
||||
)
|
||||
|
||||
|
||||
class PluginLoadError(Exception):
|
||||
"""插件加载错误"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class PluginLoader:
|
||||
"""插件加载器"""
|
||||
|
||||
PLUGINS_ROOT = Path("data/plugins")
|
||||
|
||||
# 已加载的插件模块缓存
|
||||
_loaded_modules: Dict[str, ModuleType] = {}
|
||||
# 已挂载的路由追踪
|
||||
_mounted_routers: Dict[str, List[APIRouter]] = {}
|
||||
|
||||
@classmethod
|
||||
def get_plugin_dir(cls, plugin_key: str) -> Path:
|
||||
"""获取插件目录"""
|
||||
return cls.PLUGINS_ROOT / plugin_key
|
||||
|
||||
@classmethod
|
||||
def get_manifest_path(cls, plugin_key: str) -> Path:
|
||||
"""获取插件 manifest.json 路径"""
|
||||
return cls.get_plugin_dir(plugin_key) / "manifest.json"
|
||||
|
||||
@classmethod
|
||||
def get_frontend_bundle_path(cls, plugin_key: str, entry: Optional[str] = None) -> Path:
|
||||
"""获取前端 bundle 路径"""
|
||||
plugin_dir = cls.get_plugin_dir(plugin_key)
|
||||
if entry:
|
||||
return plugin_dir / entry
|
||||
# 默认位置
|
||||
return plugin_dir / "frontend" / "index.js"
|
||||
|
||||
@classmethod
|
||||
def get_asset_path(cls, plugin_key: str, asset_path: str) -> Path:
|
||||
"""获取静态资源路径"""
|
||||
return cls.get_plugin_dir(plugin_key) / asset_path
|
||||
|
||||
# ========== 解包和验证 ==========
|
||||
|
||||
@classmethod
|
||||
def validate_manifest(cls, manifest_data: Dict[str, Any]) -> Tuple[bool, List[str]]:
|
||||
"""验证 manifest 数据"""
|
||||
errors: List[str] = []
|
||||
|
||||
# 必需字段检查
|
||||
if not manifest_data.get("key"):
|
||||
errors.append("manifest 缺少必需字段: key")
|
||||
if not manifest_data.get("name"):
|
||||
errors.append("manifest 缺少必需字段: name")
|
||||
|
||||
# key 格式检查(Java 命名空间格式)
|
||||
key = manifest_data.get("key", "")
|
||||
if key:
|
||||
import re
|
||||
|
||||
# 格式: com.example.plugin (至少两级,每级以小写字母开头,可包含小写字母和数字)
|
||||
if not re.match(r"^[a-z][a-z0-9]*(\.[a-z][a-z0-9]*)+$", key):
|
||||
errors.append(
|
||||
"key 格式无效:必须使用命名空间格式(如 com.example.plugin),"
|
||||
"每个部分以小写字母开头,只能包含小写字母和数字,至少两级"
|
||||
)
|
||||
|
||||
# 版本格式检查(简单检查)
|
||||
version = manifest_data.get("version", "")
|
||||
if version and not isinstance(version, str):
|
||||
errors.append("version 必须是字符串")
|
||||
|
||||
# 验证 frontend 配置
|
||||
frontend = manifest_data.get("frontend")
|
||||
if frontend and isinstance(frontend, dict):
|
||||
if frontend.get("entry") and not isinstance(frontend["entry"], str):
|
||||
errors.append("frontend.entry 必须是字符串")
|
||||
if frontend.get("styles") is not None:
|
||||
if not isinstance(frontend["styles"], list) or not all(
|
||||
isinstance(x, str) for x in frontend["styles"]
|
||||
):
|
||||
errors.append("frontend.styles 必须是字符串数组")
|
||||
supported_exts = frontend.get("supportedExts") or frontend.get("supported_exts")
|
||||
if supported_exts and not isinstance(supported_exts, list):
|
||||
errors.append("frontend.supportedExts 必须是数组")
|
||||
use_system_window = frontend.get("useSystemWindow") or frontend.get("use_system_window")
|
||||
if use_system_window is not None and not isinstance(use_system_window, bool):
|
||||
errors.append("frontend.useSystemWindow 必须是布尔值")
|
||||
|
||||
# 验证 backend 配置
|
||||
backend = manifest_data.get("backend")
|
||||
if backend and isinstance(backend, dict):
|
||||
routes = backend.get("routes", [])
|
||||
if routes:
|
||||
for i, route in enumerate(routes):
|
||||
if not route.get("module"):
|
||||
errors.append(f"backend.routes[{i}] 缺少 module")
|
||||
if not route.get("prefix"):
|
||||
errors.append(f"backend.routes[{i}] 缺少 prefix")
|
||||
|
||||
processors = backend.get("processors", [])
|
||||
if processors:
|
||||
for i, proc in enumerate(processors):
|
||||
if not proc.get("module"):
|
||||
errors.append(f"backend.processors[{i}] 缺少 module")
|
||||
if not proc.get("type"):
|
||||
errors.append(f"backend.processors[{i}] 缺少 type")
|
||||
|
||||
return len(errors) == 0, errors
|
||||
|
||||
@classmethod
|
||||
def unpack_foxpkg(
|
||||
cls, file_content: bytes, target_key: Optional[str] = None
|
||||
) -> Tuple[PluginManifest, Path]:
|
||||
"""
|
||||
解包 .foxpkg 文件
|
||||
|
||||
Args:
|
||||
file_content: .foxpkg 文件内容
|
||||
target_key: 可选,指定安装的插件 key(覆盖 manifest 中的 key)
|
||||
|
||||
Returns:
|
||||
(manifest, plugin_dir) 元组
|
||||
|
||||
Raises:
|
||||
PluginLoadError: 解包或验证失败
|
||||
"""
|
||||
try:
|
||||
with zipfile.ZipFile(io.BytesIO(file_content)) as zf:
|
||||
# 读取 manifest.json
|
||||
try:
|
||||
manifest_bytes = zf.read("manifest.json")
|
||||
except KeyError:
|
||||
raise PluginLoadError("插件包缺少 manifest.json")
|
||||
|
||||
try:
|
||||
manifest_data = json.loads(manifest_bytes.decode("utf-8"))
|
||||
except json.JSONDecodeError as e:
|
||||
raise PluginLoadError(f"manifest.json 解析失败: {e}")
|
||||
|
||||
# 验证 manifest
|
||||
valid, errors = cls.validate_manifest(manifest_data)
|
||||
if not valid:
|
||||
raise PluginLoadError(f"manifest 验证失败: {'; '.join(errors)}")
|
||||
|
||||
# 解析 manifest
|
||||
try:
|
||||
manifest = PluginManifest.model_validate(manifest_data)
|
||||
except Exception as e:
|
||||
raise PluginLoadError(f"manifest 解析失败: {e}")
|
||||
|
||||
# 确定插件 key
|
||||
plugin_key = target_key or manifest.key
|
||||
|
||||
# 验证包内文件
|
||||
cls._validate_package_files(zf, manifest)
|
||||
|
||||
# 部署文件
|
||||
target_dir = cls.PLUGINS_ROOT / plugin_key
|
||||
if target_dir.exists():
|
||||
# 备份旧版本
|
||||
backup_dir = cls.PLUGINS_ROOT / f"{plugin_key}.backup"
|
||||
if backup_dir.exists():
|
||||
shutil.rmtree(backup_dir)
|
||||
shutil.move(str(target_dir), str(backup_dir))
|
||||
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
try:
|
||||
zf.extractall(target_dir)
|
||||
except Exception as e:
|
||||
# 恢复备份
|
||||
if (cls.PLUGINS_ROOT / f"{plugin_key}.backup").exists():
|
||||
shutil.rmtree(target_dir, ignore_errors=True)
|
||||
shutil.move(str(cls.PLUGINS_ROOT / f"{plugin_key}.backup"), str(target_dir))
|
||||
raise PluginLoadError(f"文件解压失败: {e}")
|
||||
|
||||
# 清理备份
|
||||
backup_dir = cls.PLUGINS_ROOT / f"{plugin_key}.backup"
|
||||
if backup_dir.exists():
|
||||
shutil.rmtree(backup_dir, ignore_errors=True)
|
||||
|
||||
return manifest, target_dir
|
||||
|
||||
except zipfile.BadZipFile:
|
||||
raise PluginLoadError("无效的插件包格式(非 ZIP 文件)")
|
||||
|
||||
@classmethod
|
||||
def _validate_package_files(cls, zf: zipfile.ZipFile, manifest: PluginManifest) -> None:
|
||||
"""验证包内文件是否完整"""
|
||||
file_list = zf.namelist()
|
||||
|
||||
# 检查前端入口
|
||||
if manifest.frontend and manifest.frontend.entry:
|
||||
if manifest.frontend.entry not in file_list:
|
||||
raise PluginLoadError(f"前端入口文件不存在: {manifest.frontend.entry}")
|
||||
|
||||
# 检查后端模块
|
||||
if manifest.backend:
|
||||
if manifest.backend.routes:
|
||||
for route in manifest.backend.routes:
|
||||
if route.module not in file_list:
|
||||
raise PluginLoadError(f"路由模块不存在: {route.module}")
|
||||
|
||||
if manifest.backend.processors:
|
||||
for proc in manifest.backend.processors:
|
||||
if proc.module not in file_list:
|
||||
raise PluginLoadError(f"处理器模块不存在: {proc.module}")
|
||||
|
||||
# ========== 路由动态加载 ==========
|
||||
|
||||
@classmethod
|
||||
def load_route_module(cls, plugin_key: str, route_config: ManifestRouteConfig) -> APIRouter:
|
||||
"""
|
||||
动态加载插件路由模块
|
||||
|
||||
Args:
|
||||
plugin_key: 插件标识
|
||||
route_config: 路由配置
|
||||
|
||||
Returns:
|
||||
加载的 APIRouter
|
||||
"""
|
||||
module_path = cls.get_plugin_dir(plugin_key) / route_config.module
|
||||
|
||||
if not module_path.exists():
|
||||
raise PluginLoadError(f"路由模块不存在: {module_path}")
|
||||
|
||||
module_name = f"foxel_plugin_{plugin_key}_route_{module_path.stem}"
|
||||
|
||||
try:
|
||||
spec = spec_from_file_location(module_name, module_path)
|
||||
if spec is None or spec.loader is None:
|
||||
raise PluginLoadError(f"无法加载路由模块: {module_path}")
|
||||
|
||||
module = module_from_spec(spec)
|
||||
sys.modules[module_name] = module
|
||||
spec.loader.exec_module(module)
|
||||
|
||||
# 缓存模块
|
||||
cls._loaded_modules[f"{plugin_key}:route:{route_config.module}"] = module
|
||||
|
||||
# 获取 router
|
||||
router = getattr(module, "router", None)
|
||||
if router is None:
|
||||
raise PluginLoadError(f"路由模块缺少 'router' 对象: {module_path}")
|
||||
|
||||
if not isinstance(router, APIRouter):
|
||||
raise PluginLoadError(f"'router' 不是有效的 APIRouter 实例: {module_path}")
|
||||
|
||||
# 创建包装路由器添加前缀
|
||||
wrapper = APIRouter(prefix=route_config.prefix, tags=route_config.tags or [])
|
||||
wrapper.include_router(router)
|
||||
|
||||
return wrapper
|
||||
|
||||
except PluginLoadError:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise PluginLoadError(f"加载路由模块失败 [{module_path}]: {e}")
|
||||
|
||||
@classmethod
|
||||
def load_all_routes(cls, plugin_key: str, manifest: PluginManifest) -> List[APIRouter]:
|
||||
"""加载插件的所有路由"""
|
||||
routers: List[APIRouter] = []
|
||||
|
||||
if not manifest.backend or not manifest.backend.routes:
|
||||
return routers
|
||||
|
||||
for route_config in manifest.backend.routes:
|
||||
router = cls.load_route_module(plugin_key, route_config)
|
||||
routers.append(router)
|
||||
|
||||
cls._mounted_routers[plugin_key] = routers
|
||||
return routers
|
||||
|
||||
# ========== 处理器动态注册 ==========
|
||||
|
||||
@classmethod
|
||||
def load_processor_module(
|
||||
cls, plugin_key: str, processor_config: ManifestProcessorConfig
|
||||
) -> None:
|
||||
"""
|
||||
动态加载并注册处理器模块
|
||||
|
||||
Args:
|
||||
plugin_key: 插件标识
|
||||
processor_config: 处理器配置
|
||||
"""
|
||||
module_path = cls.get_plugin_dir(plugin_key) / processor_config.module
|
||||
|
||||
if not module_path.exists():
|
||||
raise PluginLoadError(f"处理器模块不存在: {module_path}")
|
||||
|
||||
module_name = f"foxel_plugin_{plugin_key}_processor_{module_path.stem}"
|
||||
|
||||
try:
|
||||
spec = spec_from_file_location(module_name, module_path)
|
||||
if spec is None or spec.loader is None:
|
||||
raise PluginLoadError(f"无法加载处理器模块: {module_path}")
|
||||
|
||||
module = module_from_spec(spec)
|
||||
sys.modules[module_name] = module
|
||||
spec.loader.exec_module(module)
|
||||
|
||||
# 缓存模块
|
||||
cls._loaded_modules[f"{plugin_key}:processor:{processor_config.module}"] = module
|
||||
|
||||
# 获取处理器工厂
|
||||
factory = getattr(module, "PROCESSOR_FACTORY", None)
|
||||
if factory is None:
|
||||
raise PluginLoadError(f"处理器模块缺少 'PROCESSOR_FACTORY': {module_path}")
|
||||
|
||||
# 获取配置 schema
|
||||
config_schema = getattr(module, "CONFIG_SCHEMA", [])
|
||||
processor_name = getattr(module, "PROCESSOR_NAME", processor_config.name or processor_config.type)
|
||||
supported_exts = getattr(module, "SUPPORTED_EXTS", [])
|
||||
|
||||
# 注册到处理器注册表
|
||||
from domain.processors import CONFIG_SCHEMAS, TYPE_MAP
|
||||
|
||||
processor_type = processor_config.type
|
||||
TYPE_MAP[processor_type] = factory
|
||||
|
||||
# 获取实例以读取属性
|
||||
try:
|
||||
sample = factory()
|
||||
produces_file = getattr(sample, "produces_file", False)
|
||||
supports_directory = getattr(sample, "supports_directory", False)
|
||||
except Exception:
|
||||
produces_file = False
|
||||
supports_directory = False
|
||||
|
||||
CONFIG_SCHEMAS[processor_type] = {
|
||||
"type": processor_type,
|
||||
"name": processor_name,
|
||||
"supported_exts": supported_exts,
|
||||
"config_schema": config_schema,
|
||||
"produces_file": produces_file,
|
||||
"supports_directory": supports_directory,
|
||||
"plugin": plugin_key, # 标记来源插件
|
||||
"module_path": str(module_path),
|
||||
}
|
||||
|
||||
except PluginLoadError:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise PluginLoadError(f"加载处理器模块失败 [{module_path}]: {e}")
|
||||
|
||||
@classmethod
|
||||
def load_all_processors(cls, plugin_key: str, manifest: PluginManifest) -> List[str]:
|
||||
"""加载插件的所有处理器,返回处理器类型列表"""
|
||||
processor_types: List[str] = []
|
||||
|
||||
if not manifest.backend or not manifest.backend.processors:
|
||||
return processor_types
|
||||
|
||||
for proc_config in manifest.backend.processors:
|
||||
cls.load_processor_module(plugin_key, proc_config)
|
||||
processor_types.append(proc_config.type)
|
||||
|
||||
return processor_types
|
||||
|
||||
# ========== 卸载 ==========
|
||||
|
||||
@classmethod
|
||||
def unload_plugin(cls, plugin_key: str, manifest: Optional[PluginManifest] = None) -> None:
|
||||
"""
|
||||
卸载插件的后端组件
|
||||
|
||||
Args:
|
||||
plugin_key: 插件标识
|
||||
manifest: 可选的 manifest,用于确定要卸载的组件
|
||||
"""
|
||||
# 卸载处理器
|
||||
if manifest and manifest.backend and manifest.backend.processors:
|
||||
from domain.processors import CONFIG_SCHEMAS, TYPE_MAP
|
||||
|
||||
for proc_config in manifest.backend.processors:
|
||||
proc_type = proc_config.type
|
||||
if proc_type in TYPE_MAP:
|
||||
del TYPE_MAP[proc_type]
|
||||
if proc_type in CONFIG_SCHEMAS:
|
||||
del CONFIG_SCHEMAS[proc_type]
|
||||
|
||||
# 清理缓存的模块
|
||||
keys_to_remove = [k for k in cls._loaded_modules if k.startswith(f"{plugin_key}:")]
|
||||
for key in keys_to_remove:
|
||||
module = cls._loaded_modules.pop(key, None)
|
||||
if module and module.__name__ in sys.modules:
|
||||
del sys.modules[module.__name__]
|
||||
|
||||
# 清理路由追踪(注意:FastAPI 不支持动态移除路由,需要重启应用)
|
||||
cls._mounted_routers.pop(plugin_key, None)
|
||||
|
||||
@classmethod
|
||||
def delete_plugin_files(cls, plugin_key: str) -> None:
|
||||
"""删除插件文件"""
|
||||
plugin_dir = cls.get_plugin_dir(plugin_key)
|
||||
if plugin_dir.exists():
|
||||
shutil.rmtree(plugin_dir)
|
||||
|
||||
# 同时删除备份
|
||||
backup_dir = cls.PLUGINS_ROOT / f"{plugin_key}.backup"
|
||||
if backup_dir.exists():
|
||||
shutil.rmtree(backup_dir)
|
||||
|
||||
# ========== 读取 manifest ==========
|
||||
|
||||
@classmethod
|
||||
def read_manifest(cls, plugin_key: str) -> Optional[PluginManifest]:
|
||||
"""从文件系统读取插件 manifest"""
|
||||
manifest_path = cls.get_manifest_path(plugin_key)
|
||||
if not manifest_path.exists():
|
||||
return None
|
||||
|
||||
try:
|
||||
with open(manifest_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
return PluginManifest.model_validate(data)
|
||||
except Exception:
|
||||
return None
|
||||
273
domain/plugins/service.py
Normal file
273
domain/plugins/service.py
Normal file
@@ -0,0 +1,273 @@
|
||||
"""
|
||||
插件服务模块
|
||||
|
||||
负责插件的安装、卸载等管理操作
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
import logging
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, Union
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from .loader import PluginLoadError, PluginLoader
|
||||
from .types import (
|
||||
PluginInstallResult,
|
||||
PluginManifest,
|
||||
PluginOut,
|
||||
)
|
||||
from models.database import Plugin
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PluginService:
|
||||
"""插件服务"""
|
||||
|
||||
_plugins_root = Path("data/plugins")
|
||||
|
||||
# ========== 工具方法 ==========
|
||||
|
||||
@classmethod
|
||||
def _get_plugin_dir(cls, plugin_key: str) -> Path:
|
||||
"""获取插件目录"""
|
||||
return cls._plugins_root / plugin_key
|
||||
|
||||
@classmethod
|
||||
def _get_bundle_path(cls, rec: Plugin) -> Path:
|
||||
"""获取前端 bundle 路径"""
|
||||
plugin_dir = cls._get_plugin_dir(rec.key)
|
||||
# 从 manifest 读取
|
||||
if rec.manifest:
|
||||
frontend = rec.manifest.get("frontend", {})
|
||||
entry = frontend.get("entry")
|
||||
if entry:
|
||||
return plugin_dir / entry
|
||||
# 默认位置
|
||||
return plugin_dir / "frontend" / "index.js"
|
||||
|
||||
@classmethod
|
||||
async def _get_by_key_or_404(cls, key: str) -> Plugin:
|
||||
"""通过 key 获取插件,不存在则返回 404"""
|
||||
rec = await Plugin.get_or_none(key=key)
|
||||
if not rec:
|
||||
raise HTTPException(status_code=404, detail="Plugin not found")
|
||||
return rec
|
||||
|
||||
@classmethod
|
||||
async def _get_by_key_or_id(cls, key_or_id: Union[str, int]) -> Plugin:
|
||||
"""通过 key 或 ID 获取插件"""
|
||||
# 尝试作为 ID
|
||||
if isinstance(key_or_id, int) or (isinstance(key_or_id, str) and key_or_id.isdigit()):
|
||||
plugin_id = int(key_or_id)
|
||||
rec = await Plugin.get_or_none(id=plugin_id)
|
||||
if rec:
|
||||
return rec
|
||||
# 尝试作为 key
|
||||
if isinstance(key_or_id, str):
|
||||
rec = await Plugin.get_or_none(key=key_or_id)
|
||||
if rec:
|
||||
return rec
|
||||
raise HTTPException(status_code=404, detail="Plugin not found")
|
||||
|
||||
# ========== 安装 ==========
|
||||
|
||||
@classmethod
|
||||
async def install_package(cls, file_content: bytes, filename: str) -> PluginInstallResult:
|
||||
"""
|
||||
安装 .foxpkg 插件包
|
||||
|
||||
Args:
|
||||
file_content: 插件包内容
|
||||
filename: 文件名
|
||||
|
||||
Returns:
|
||||
安装结果
|
||||
"""
|
||||
errors: List[str] = []
|
||||
|
||||
try:
|
||||
# 解包
|
||||
manifest, plugin_dir = PluginLoader.unpack_foxpkg(file_content)
|
||||
plugin_key = manifest.key
|
||||
|
||||
# 检查是否已存在
|
||||
existing = await Plugin.get_or_none(key=plugin_key)
|
||||
if existing:
|
||||
# 更新现有插件
|
||||
logger.info(f"更新插件: {plugin_key}")
|
||||
rec = existing
|
||||
else:
|
||||
# 创建新插件
|
||||
logger.info(f"安装新插件: {plugin_key}")
|
||||
rec = Plugin(key=plugin_key)
|
||||
|
||||
# 更新字段
|
||||
rec.name = manifest.name
|
||||
rec.version = manifest.version
|
||||
rec.description = manifest.description
|
||||
rec.author = manifest.author
|
||||
rec.website = manifest.website
|
||||
rec.github = manifest.github
|
||||
rec.license = manifest.license
|
||||
rec.manifest = manifest.model_dump(mode="json")
|
||||
|
||||
# 从 manifest.frontend 提取前端配置
|
||||
if manifest.frontend:
|
||||
rec.open_app = manifest.frontend.open_app or False
|
||||
rec.supported_exts = manifest.frontend.supported_exts
|
||||
rec.default_bounds = manifest.frontend.default_bounds
|
||||
rec.default_maximized = manifest.frontend.default_maximized
|
||||
rec.icon = manifest.frontend.icon
|
||||
|
||||
await rec.save()
|
||||
|
||||
# 加载后端组件(如果有)
|
||||
loaded_routes: List[str] = []
|
||||
loaded_processors: List[str] = []
|
||||
|
||||
if manifest.backend:
|
||||
# 加载路由
|
||||
if manifest.backend.routes:
|
||||
try:
|
||||
from main import app
|
||||
routers = PluginLoader.load_all_routes(plugin_key, manifest)
|
||||
for router in routers:
|
||||
app.include_router(router)
|
||||
loaded_routes.append(router.prefix)
|
||||
except PluginLoadError as e:
|
||||
errors.append(f"路由加载失败: {e}")
|
||||
logger.error(f"插件 {plugin_key} 路由加载失败: {e}")
|
||||
except Exception as e:
|
||||
errors.append(f"路由加载失败: {e}")
|
||||
logger.exception(f"插件 {plugin_key} 路由加载异常")
|
||||
|
||||
# 加载处理器
|
||||
if manifest.backend.processors:
|
||||
try:
|
||||
processor_types = PluginLoader.load_all_processors(plugin_key, manifest)
|
||||
loaded_processors = processor_types
|
||||
except PluginLoadError as e:
|
||||
errors.append(f"处理器加载失败: {e}")
|
||||
logger.error(f"插件 {plugin_key} 处理器加载失败: {e}")
|
||||
except Exception as e:
|
||||
errors.append(f"处理器加载失败: {e}")
|
||||
logger.exception(f"插件 {plugin_key} 处理器加载异常")
|
||||
|
||||
# 更新加载状态
|
||||
rec.loaded_routes = loaded_routes if loaded_routes else None
|
||||
rec.loaded_processors = loaded_processors if loaded_processors else None
|
||||
await rec.save()
|
||||
|
||||
return PluginInstallResult(
|
||||
success=True,
|
||||
plugin=PluginOut.model_validate(rec),
|
||||
message="安装成功" if not errors else "安装完成,但有部分组件加载失败",
|
||||
errors=errors if errors else None,
|
||||
)
|
||||
|
||||
except PluginLoadError as e:
|
||||
logger.error(f"插件安装失败: {e}")
|
||||
return PluginInstallResult(
|
||||
success=False,
|
||||
message=str(e),
|
||||
errors=[str(e)],
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("插件安装异常")
|
||||
return PluginInstallResult(
|
||||
success=False,
|
||||
message=f"安装失败: {e}",
|
||||
errors=[str(e)],
|
||||
)
|
||||
|
||||
# ========== 查询 ==========
|
||||
|
||||
@classmethod
|
||||
async def list_plugins(cls) -> List[PluginOut]:
|
||||
"""获取所有插件列表"""
|
||||
rows = await Plugin.all().order_by("-id")
|
||||
for rec in rows:
|
||||
try:
|
||||
manifest = PluginLoader.read_manifest(rec.key)
|
||||
if manifest:
|
||||
rec.manifest = manifest.model_dump(mode="json")
|
||||
except Exception:
|
||||
continue
|
||||
return [PluginOut.model_validate(r) for r in rows]
|
||||
|
||||
@classmethod
|
||||
async def get_plugin(cls, key_or_id: Union[str, int]) -> PluginOut:
|
||||
"""获取单个插件详情"""
|
||||
rec = await cls._get_by_key_or_id(key_or_id)
|
||||
try:
|
||||
manifest = PluginLoader.read_manifest(rec.key)
|
||||
if manifest:
|
||||
rec.manifest = manifest.model_dump(mode="json")
|
||||
except Exception:
|
||||
pass
|
||||
return PluginOut.model_validate(rec)
|
||||
|
||||
@classmethod
|
||||
async def get_bundle_path(cls, key_or_id: Union[str, int]) -> Path:
|
||||
"""获取插件前端 bundle 路径"""
|
||||
rec = await cls._get_by_key_or_id(key_or_id)
|
||||
bundle_path = cls._get_bundle_path(rec)
|
||||
if not bundle_path.exists():
|
||||
raise HTTPException(status_code=404, detail="Plugin bundle not found")
|
||||
return bundle_path
|
||||
|
||||
@classmethod
|
||||
async def get_asset_path(cls, key: str, asset_path: str) -> Path:
|
||||
"""获取插件静态资源路径"""
|
||||
rec = await cls._get_by_key_or_404(key)
|
||||
plugin_dir = cls._get_plugin_dir(rec.key)
|
||||
|
||||
# 安全检查:防止路径遍历
|
||||
asset_path = asset_path.lstrip("/")
|
||||
if ".." in asset_path:
|
||||
raise HTTPException(status_code=400, detail="Invalid asset path")
|
||||
|
||||
full_path = plugin_dir / asset_path
|
||||
if not full_path.exists():
|
||||
raise HTTPException(status_code=404, detail="Asset not found")
|
||||
|
||||
# 确保路径在插件目录内
|
||||
try:
|
||||
full_path.resolve().relative_to(plugin_dir.resolve())
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid asset path")
|
||||
|
||||
return full_path
|
||||
|
||||
# ========== 管理操作 ==========
|
||||
|
||||
@classmethod
|
||||
async def delete(cls, key_or_id: Union[str, int]) -> None:
|
||||
"""删除/卸载插件"""
|
||||
rec = await cls._get_by_key_or_id(key_or_id)
|
||||
|
||||
# 获取 manifest 用于卸载组件
|
||||
manifest: Optional[PluginManifest] = None
|
||||
if rec.manifest:
|
||||
try:
|
||||
manifest = PluginManifest.model_validate(rec.manifest)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 卸载后端组件
|
||||
if manifest:
|
||||
PluginLoader.unload_plugin(rec.key, manifest)
|
||||
|
||||
# 删除数据库记录
|
||||
await rec.delete()
|
||||
|
||||
# 删除文件
|
||||
with contextlib.suppress(Exception):
|
||||
plugin_dir = cls._get_plugin_dir(rec.key)
|
||||
if plugin_dir.exists():
|
||||
shutil.rmtree(plugin_dir)
|
||||
|
||||
logger.info(f"插件 {rec.key} 已卸载")
|
||||
115
domain/plugins/startup.py
Normal file
115
domain/plugins/startup.py
Normal file
@@ -0,0 +1,115 @@
|
||||
"""
|
||||
插件启动加载模块
|
||||
|
||||
负责在应用启动时加载所有已安装的插件
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, List, Tuple
|
||||
|
||||
from .loader import PluginLoadError, PluginLoader
|
||||
from .types import PluginManifest
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from fastapi import FastAPI
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def load_installed_plugins(app: "FastAPI") -> Tuple[int, List[str]]:
|
||||
"""
|
||||
加载所有已安装的插件
|
||||
|
||||
Args:
|
||||
app: FastAPI 应用实例
|
||||
|
||||
Returns:
|
||||
(成功加载数量, 错误列表)
|
||||
"""
|
||||
from models.database import Plugin
|
||||
|
||||
errors: List[str] = []
|
||||
loaded_count = 0
|
||||
|
||||
try:
|
||||
plugins = await Plugin.all()
|
||||
except Exception as e:
|
||||
logger.error(f"查询插件列表失败: {e}")
|
||||
return 0, [f"查询插件列表失败: {e}"]
|
||||
|
||||
for plugin in plugins:
|
||||
if not plugin.key:
|
||||
continue
|
||||
|
||||
try:
|
||||
# 获取 manifest
|
||||
manifest = None
|
||||
if plugin.manifest:
|
||||
try:
|
||||
manifest = PluginManifest.model_validate(plugin.manifest)
|
||||
except Exception:
|
||||
# 尝试从文件系统读取
|
||||
manifest = PluginLoader.read_manifest(plugin.key)
|
||||
else:
|
||||
manifest = PluginLoader.read_manifest(plugin.key)
|
||||
|
||||
if not manifest:
|
||||
logger.warning(f"插件 {plugin.key} 缺少 manifest,跳过加载")
|
||||
continue
|
||||
|
||||
# 加载后端路由
|
||||
loaded_routes: List[str] = []
|
||||
if manifest.backend and manifest.backend.routes:
|
||||
try:
|
||||
routers = PluginLoader.load_all_routes(plugin.key, manifest)
|
||||
for router in routers:
|
||||
app.include_router(router)
|
||||
loaded_routes.append(router.prefix)
|
||||
logger.info(f"插件 {plugin.key} 加载了 {len(routers)} 个路由")
|
||||
except PluginLoadError as e:
|
||||
errors.append(f"插件 {plugin.key} 路由加载失败: {e}")
|
||||
logger.error(f"插件 {plugin.key} 路由加载失败: {e}")
|
||||
|
||||
# 加载处理器
|
||||
loaded_processors: List[str] = []
|
||||
if manifest.backend and manifest.backend.processors:
|
||||
try:
|
||||
processor_types = PluginLoader.load_all_processors(plugin.key, manifest)
|
||||
loaded_processors = processor_types
|
||||
logger.info(f"插件 {plugin.key} 注册了 {len(processor_types)} 个处理器")
|
||||
except PluginLoadError as e:
|
||||
errors.append(f"插件 {plugin.key} 处理器加载失败: {e}")
|
||||
logger.error(f"插件 {plugin.key} 处理器加载失败: {e}")
|
||||
|
||||
# 更新数据库记录
|
||||
plugin.loaded_routes = loaded_routes if loaded_routes else None
|
||||
plugin.loaded_processors = loaded_processors if loaded_processors else None
|
||||
await plugin.save()
|
||||
|
||||
loaded_count += 1
|
||||
logger.info(f"插件 {plugin.key} 加载完成")
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"插件 {plugin.key} 加载异常: {e}"
|
||||
errors.append(error_msg)
|
||||
logger.exception(error_msg)
|
||||
|
||||
return loaded_count, errors
|
||||
|
||||
|
||||
async def init_plugins(app: "FastAPI") -> None:
|
||||
"""
|
||||
初始化插件系统
|
||||
|
||||
在应用启动时调用
|
||||
"""
|
||||
logger.info("开始加载已安装插件...")
|
||||
|
||||
loaded_count, errors = await load_installed_plugins(app)
|
||||
|
||||
if errors:
|
||||
logger.warning(f"插件加载完成,共 {loaded_count} 个成功,{len(errors)} 个错误")
|
||||
for error in errors:
|
||||
logger.warning(f" - {error}")
|
||||
else:
|
||||
logger.info(f"插件加载完成,共 {loaded_count} 个插件")
|
||||
143
domain/plugins/types.py
Normal file
143
domain/plugins/types.py
Normal file
@@ -0,0 +1,143 @@
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
# ========== Manifest 相关类型 ==========
|
||||
|
||||
|
||||
class ManifestFrontend(BaseModel):
|
||||
"""manifest.json 中的 frontend 配置"""
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True, extra="ignore")
|
||||
|
||||
entry: Optional[str] = Field(default=None, description="前端入口文件路径")
|
||||
styles: Optional[List[str]] = Field(default=None, description="前端样式文件路径列表(相对插件根目录)")
|
||||
open_app: Optional[bool] = Field(
|
||||
default=None,
|
||||
alias="openApp",
|
||||
description="是否支持独立打开",
|
||||
)
|
||||
supported_exts: Optional[List[str]] = Field(
|
||||
default=None,
|
||||
alias="supportedExts",
|
||||
description="支持的文件扩展名列表",
|
||||
)
|
||||
default_bounds: Optional[Dict[str, Any]] = Field(
|
||||
default=None,
|
||||
alias="defaultBounds",
|
||||
description="默认窗口尺寸",
|
||||
)
|
||||
default_maximized: Optional[bool] = Field(
|
||||
default=None,
|
||||
alias="defaultMaximized",
|
||||
description="是否默认最大化",
|
||||
)
|
||||
icon: Optional[str] = Field(default=None, description="图标路径")
|
||||
use_system_window: Optional[bool] = Field(
|
||||
default=None,
|
||||
alias="useSystemWindow",
|
||||
description="是否使用系统窗口",
|
||||
)
|
||||
|
||||
|
||||
class ManifestRouteConfig(BaseModel):
|
||||
"""manifest.json 中的路由配置"""
|
||||
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
module: str = Field(..., description="路由模块路径")
|
||||
prefix: str = Field(..., description="路由前缀")
|
||||
tags: Optional[List[str]] = Field(default=None, description="API 标签")
|
||||
|
||||
|
||||
class ManifestProcessorConfig(BaseModel):
|
||||
"""manifest.json 中的处理器配置"""
|
||||
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
module: str = Field(..., description="处理器模块路径")
|
||||
type: str = Field(..., description="处理器类型标识")
|
||||
name: Optional[str] = Field(default=None, description="处理器显示名称")
|
||||
|
||||
|
||||
class ManifestBackend(BaseModel):
|
||||
"""manifest.json 中的 backend 配置"""
|
||||
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
routes: Optional[List[ManifestRouteConfig]] = Field(default=None, description="路由列表")
|
||||
processors: Optional[List[ManifestProcessorConfig]] = Field(
|
||||
default=None, description="处理器列表"
|
||||
)
|
||||
|
||||
|
||||
class ManifestDependencies(BaseModel):
|
||||
"""manifest.json 中的依赖配置"""
|
||||
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
python: Optional[str] = Field(default=None, description="Python 版本要求")
|
||||
packages: Optional[List[str]] = Field(default=None, description="Python 包依赖列表")
|
||||
|
||||
|
||||
class PluginManifest(BaseModel):
|
||||
"""完整的 manifest.json 结构"""
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True, extra="ignore")
|
||||
|
||||
foxpkg: str = Field(default="1.0", description="foxpkg 格式版本")
|
||||
key: str = Field(..., min_length=1, description="插件唯一标识")
|
||||
name: str = Field(..., min_length=1, description="插件名称")
|
||||
version: str = Field(default="1.0.0", description="插件版本")
|
||||
description: Optional[str] = Field(default=None, description="插件描述")
|
||||
i18n: Optional[Dict[str, Dict[str, str]]] = Field(
|
||||
default=None,
|
||||
description="多语言信息(name/description),例如:{'en': {'name': '...', 'description': '...'}}",
|
||||
)
|
||||
author: Optional[str] = Field(default=None, description="作者")
|
||||
website: Optional[str] = Field(default=None, description="网站")
|
||||
github: Optional[str] = Field(default=None, description="GitHub 地址")
|
||||
license: Optional[str] = Field(default=None, description="许可证")
|
||||
|
||||
frontend: Optional[ManifestFrontend] = Field(default=None, description="前端配置")
|
||||
backend: Optional[ManifestBackend] = Field(default=None, description="后端配置")
|
||||
dependencies: Optional[ManifestDependencies] = Field(default=None, description="依赖配置")
|
||||
|
||||
|
||||
# ========== API 请求/响应类型 ==========
|
||||
|
||||
|
||||
class PluginOut(BaseModel):
|
||||
"""插件输出模型"""
|
||||
|
||||
id: int
|
||||
key: str
|
||||
open_app: bool = False
|
||||
name: Optional[str] = None
|
||||
version: Optional[str] = None
|
||||
supported_exts: Optional[List[str]] = None
|
||||
default_bounds: Optional[Dict[str, Any]] = None
|
||||
default_maximized: Optional[bool] = None
|
||||
icon: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
author: Optional[str] = None
|
||||
website: Optional[str] = None
|
||||
github: Optional[str] = None
|
||||
license: Optional[str] = None
|
||||
|
||||
# 新增字段
|
||||
manifest: Optional[Dict[str, Any]] = None
|
||||
loaded_routes: Optional[List[str]] = None
|
||||
loaded_processors: Optional[List[str]] = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class PluginInstallResult(BaseModel):
|
||||
"""安装结果"""
|
||||
|
||||
success: bool
|
||||
plugin: Optional[PluginOut] = None
|
||||
message: Optional[str] = None
|
||||
errors: Optional[List[str]] = None
|
||||
Reference in New Issue
Block a user