添加webui直接下载插件的依赖动态导入

pull/1374/head
2829798842 2025-11-20 02:13:33 +08:00
parent 6457df2e61
commit 11ba82d024
3 changed files with 701 additions and 132 deletions

View File

@ -0,0 +1,368 @@
import importlib.metadata
import sys
import asyncio
import shutil
import time
import os
import urllib.parse
from typing import Dict, List, Optional, Tuple
from dataclasses import dataclass
import aiohttp
from packaging.specifiers import SpecifierSet
from packaging.utils import canonicalize_name
from src.common.logger import get_logger
from src.plugin_system.base.component_types import PythonDependency
logger = get_logger("dependency_manager")
@dataclass
class DependencyInfo:
"""依赖信息
Attributes:
name (str): 依赖包名称
required_version (str): 要求的版本范围
installed_version (Optional[str]): 已安装的版本
is_satisfied (bool): 是否满足依赖要求
install_name (str): 用于pip安装的名称
"""
name: str
required_version: str
installed_version: Optional[str] = None
is_satisfied: bool = False
install_name: str = "" # 用于pip安装的名称
class MirrorManager:
"""PyPI镜像源管理器"""
MIRRORS = {
"aliyun": "https://mirrors.aliyun.com/pypi/simple",
"tsinghua": "https://pypi.tuna.tsinghua.edu.cn/simple",
"tencent": "https://mirrors.cloud.tencent.com/pypi/simple",
"huawei": "https://repo.huaweicloud.com/repository/pypi/simple",
"official": "https://pypi.org/simple"
}
def __init__(self):
self._fastest_mirror: Optional[str] = None
async def get_fastest_mirror(self) -> str:
"""获取最快的镜像源
如果已经测试过直接返回缓存的结果
否则并发测试所有镜像源的响应速度
"""
if self._fastest_mirror:
return self._fastest_mirror
logger.info("正在测试PyPI镜像源速度...")
async def test_mirror(url: str) -> Tuple[str, float]:
try:
start = time.time()
# 禁用 SSL 验证以避免证书问题干扰测速
# trust_env=True 让 aiohttp 读取系统代理设置 (HTTP_PROXY/HTTPS_PROXY),解决 pip 能连但测速连不上的问题
async with aiohttp.ClientSession(
connector=aiohttp.TCPConnector(ssl=False),
trust_env=True
) as session:
async with session.head(url, timeout=3, allow_redirects=True) as response:
if response.status == 200:
latency = time.time() - start
logger.debug(f"镜像源 {url} 测速成功,延迟: {latency*1000:.2f}ms")
return url, latency
logger.debug(f"镜像源 {url} 测速失败,状态码: {response.status}")
except Exception as e: # pylint: disable=broad-except
logger.debug(f"镜像源 {url} 连接异常: {repr(e)}")
pass
return url, float('inf')
tasks = [test_mirror(url) for _, url in self.MIRRORS.items()]
results = await asyncio.gather(*tasks)
# 过滤掉超时的inf,按时间排序
valid_results = sorted([r for r in results if r[1] != float('inf')], key=lambda x: x[1])
if valid_results:
best_url, latency = valid_results[0]
logger.info(f"选用最快镜像源: {best_url} (延迟: {latency*1000:.2f}ms)")
self._fastest_mirror = best_url
return best_url
# 默认回退到阿里云
logger.warning("所有镜像源测速失败,默认使用阿里云源")
return self.MIRRORS["aliyun"]
class PluginDependencyManager:
"""插件依赖管理器
负责管理插件的Python依赖包括扫描已安装包检查依赖状态以及自动安装缺失依赖
"""
def __init__(self):
self._installed_packages: Dict[str, str] = {}
self.mirror_manager = MirrorManager()
self.uv_path = shutil.which("uv")
if self.uv_path:
logger.info(f"检测到 uv 工具: {self.uv_path}")
else:
logger.info("未检测到 uv 工具,将使用 pip")
self.scan_installed_packages()
def scan_installed_packages(self) -> Dict[str, str]:
"""扫描已安装的所有Python包。
使用 importlib.metadata.distributions() 获取所有已安装的包
并将包名规范化以便后续匹配
Returns:
Dict[str, str]: 包含 {规范化包名: 版本号} 的字典
"""
self._installed_packages = {}
try:
for dist in importlib.metadata.distributions():
# 使用 packaging.utils.canonicalize_name 规范化包名
name = canonicalize_name(dist.metadata['Name'])
version = dist.version
self._installed_packages[name] = version
except Exception as e: # pylint: disable=broad-except
logger.error(f"扫描已安装包失败: {e}")
return self._installed_packages
def parse_plugin_dependencies(
self,
python_dependencies: List[PythonDependency]
) -> List[DependencyInfo]:
"""解析插件配置中的依赖信息。
Args:
python_dependencies: 插件定义的Python依赖列表
Returns:
List[DependencyInfo]: 解析后的依赖详细信息列表
"""
dependencies = []
for dep in python_dependencies:
# 使用 install_name 进行查找,因为它是 PyPI 包名
# 并使用 canonicalize_name 规范化
target_name = dep.install_name or dep.package_name
pkg_name_canonical = canonicalize_name(target_name)
installed_version = self._installed_packages.get(pkg_name_canonical)
# 构建版本要求字符串
specifier_str = dep.version if dep.version else ""
is_satisfied = False
if installed_version:
if not specifier_str:
is_satisfied = True
else:
try:
spec = SpecifierSet(specifier_str)
is_satisfied = spec.contains(installed_version)
except Exception as e: # pylint: disable=broad-except
logger.warning(f"版本说明符解析失败 {dep.package_name} {specifier_str}: {e}")
# 如果解析失败,保守起见认为不满足,或者根据策略处理
is_satisfied = False
dependencies.append(DependencyInfo(
name=dep.package_name,
required_version=specifier_str,
installed_version=installed_version,
is_satisfied=is_satisfied,
install_name=target_name
))
return dependencies
def check_dependencies(
self,
python_dependencies: List[PythonDependency]
) -> Tuple[List[DependencyInfo], List[DependencyInfo]]:
"""检查插件依赖是否满足。
Args:
python_dependencies: 插件定义的Python依赖列表
Returns:
Tuple[List[DependencyInfo], List[DependencyInfo]]:
返回一个元组 (satisfied, unsatisfied)
分别包含满足和不满足的依赖信息列表
"""
dependencies = self.parse_plugin_dependencies(python_dependencies)
satisfied = []
unsatisfied = []
for dep in dependencies:
if dep.is_satisfied:
satisfied.append(dep)
else:
unsatisfied.append(dep)
return satisfied, unsatisfied
async def _get_install_command(self, args: List[str], upgrade: bool = False) -> List[str]:
"""构建安装命令,自动选择 uv 或 pip并添加镜像源"""
mirror_url = await self.mirror_manager.get_fastest_mirror()
cmd = []
if self.uv_path:
# uv pip install ...
cmd = [self.uv_path, "pip", "install"]
else:
# python -m pip install ...
cmd = [sys.executable, "-m", "pip", "install"]
# 添加 pip 的安全/静默参数
cmd.extend(["--disable-pip-version-check", "--no-input"])
if upgrade:
cmd.append("--upgrade")
cmd.extend(["-i", mirror_url])
# 自动添加 trusted-host 参数,解决部分镜像源 SSL 问题
if mirror_url:
try:
host = urllib.parse.urlparse(mirror_url).hostname
if host:
cmd.extend(["--trusted-host", host])
except Exception: # pylint: disable=broad-except
pass
cmd.extend(args)
return cmd
def _parse_pip_error(self, output: str) -> str:
"""解析并简化 pip 错误信息"""
patterns = {
"No matching distribution": "包不存在或版本不可用",
"Could not find a version": "找不到指定版本",
"SSL: CERTIFICATE_VERIFY_FAILED": "SSL 验证失败,尝试检查网络或镜像源",
"403": "访问被拒绝(镜像源可能需要认证)",
"404": "资源不存在",
"Connection refused": "连接被拒绝,检查镜像源地址",
"Network is unreachable": "网络不可达",
"Permission denied": "权限不足,请尝试使用管理员权限运行",
}
for k, v in patterns.items():
if k in output:
return f"{v} (原始错误: {k})"
# 如果没有匹配到已知模式,返回截断的错误信息
return output[:300] + "..." if len(output) > 300 else output
async def install_from_file(self, file_path: str) -> bool:
"""从 requirements.txt 或 pyproject.toml 安装依赖
Args:
file_path: 依赖文件路径
Returns:
bool: 安装是否成功
"""
if not file_path:
return False
logger.info(f"正在从文件安装依赖: {file_path}")
# 构建参数
args = ["-r", file_path]
# 如果是 pyproject.toml 且使用 pip可能需要不同的处理
# 但 uv pip install -r pyproject.toml 是支持的
# 对于 pyproject.toml通常是 pip install .
# 但这里我们假设用户希望安装依赖,而不是安装包本身
# 如果是 pyproject.toml尝试直接作为 requirements 传入
cmd = await self._get_install_command(args, upgrade=True)
return await self._run_install_command(cmd)
async def install_auto_from_directory(self, plugin_dir: str) -> bool:
"""自动检测并安装插件目录下的依赖文件
优先检查 pyproject.toml其次检查 requirements.txt
Args:
plugin_dir: 插件目录路径
Returns:
bool: 如果找到文件且安装成功返回 True未找到文件返回 True (视为成功)安装失败返回 False
"""
dependency_file = None
pyproject_path = os.path.join(plugin_dir, "pyproject.toml")
requirements_path = os.path.join(plugin_dir, "requirements.txt")
if os.path.exists(pyproject_path):
dependency_file = pyproject_path
elif os.path.exists(requirements_path):
dependency_file = requirements_path
if not dependency_file:
return True
logger.info(f"{plugin_dir} 检测到依赖文件: {dependency_file}")
return await self.install_from_file(dependency_file)
async def install_dependencies(
self,
dependencies: List[DependencyInfo],
*,
upgrade: bool = False
) -> bool:
"""安装缺失或版本不匹配的依赖。
Args:
dependencies: 需要安装的依赖信息列表
upgrade: 是否使用 --upgrade 参数升级包
Returns:
bool: 安装是否成功
"""
if not dependencies:
return True
packages_to_install = []
for dep in dependencies:
pkg_str = dep.install_name
if dep.required_version:
if any(op in dep.required_version for op in ['=', '>', '<', '~', '!']):
pkg_str += dep.required_version
else:
pkg_str += f"=={dep.required_version}"
packages_to_install.append(pkg_str)
cmd = await self._get_install_command(packages_to_install, upgrade=upgrade)
logger.info(f"正在自动安装依赖: {' '.join(packages_to_install)}")
return await self._run_install_command(cmd)
async def _run_install_command(self, cmd: List[str]) -> bool:
"""执行安装命令"""
try:
process = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
_, stderr = await process.communicate()
if process.returncode == 0:
logger.info("依赖安装成功")
# 安装成功后重新扫描已安装包
self.scan_installed_packages()
return True
else:
error_msg = stderr.decode()
friendly_error = self._parse_pip_error(error_msg)
logger.error(f"依赖安装失败: {friendly_error}")
logger.debug(f"完整错误日志: {error_msg}")
return False
except Exception as e: # pylint: disable=broad-except
logger.error(f"执行安装命令失败: {e}")
return False
# 全局依赖管理器实例
plugin_dependency_manager = PluginDependencyManager()

View File

@ -1,5 +1,6 @@
import os import os
import traceback import traceback
import asyncio
from typing import Dict, List, Optional, Tuple, Type, Any from typing import Dict, List, Optional, Tuple, Type, Any
from importlib.util import spec_from_file_location, module_from_spec from importlib.util import spec_from_file_location, module_from_spec
@ -11,6 +12,7 @@ from src.plugin_system.base.plugin_base import PluginBase
from src.plugin_system.base.component_types import ComponentType from src.plugin_system.base.component_types import ComponentType
from src.plugin_system.utils.manifest_utils import VersionComparator from src.plugin_system.utils.manifest_utils import VersionComparator
from .component_registry import component_registry from .component_registry import component_registry
from .dependency_manager import plugin_dependency_manager
logger = get_logger("plugin_manager") logger = get_logger("plugin_manager")
@ -73,7 +75,13 @@ class PluginManager:
total_registered = 0 total_registered = 0
total_failed_registration = 0 total_failed_registration = 0
for plugin_name in self.plugin_classes.keys(): # 获取所有已注册的插件名称
all_plugin_names = list(self.plugin_classes.keys())
# 对插件进行拓扑排序
sorted_plugin_names = self._sort_plugins_by_dependency(all_plugin_names)
for plugin_name in sorted_plugin_names:
load_status, count = self.load_registered_plugin_classes(plugin_name) load_status, count = self.load_registered_plugin_classes(plugin_name)
if load_status: if load_status:
total_registered += 1 total_registered += 1
@ -84,6 +92,60 @@ class PluginManager:
return total_registered, total_failed_registration return total_registered, total_failed_registration
def _sort_plugins_by_dependency(self, plugin_names: List[str]) -> List[str]:
"""对插件进行拓扑排序,确保依赖加载顺序
Args:
plugin_names: 待排序的插件名称列表
Returns:
List[str]: 排序后的插件名称列表依赖项排在前面
"""
# 预实例化所有插件以获取依赖信息
temp_instances = {}
for name in plugin_names:
cls = self.plugin_classes.get(name)
path = self.plugin_paths.get(name)
if cls and path:
try:
# 实例化插件以获取依赖信息,但不注册
instance = cls(plugin_dir=path)
temp_instances[name] = instance
except Exception as e: # pylint: disable=broad-except
logger.warning(f"预加载插件 {name} 以获取依赖信息失败: {e}")
# 构建依赖图
graph = {name: set() for name in plugin_names}
for name, instance in temp_instances.items():
for dep in instance.dependencies:
if dep in plugin_names:
graph[name].add(dep)
else:
logger.warning(f"插件 {name} 依赖未知的插件: {dep}")
# 拓扑排序
result = []
visited = set()
temp_mark = set()
def visit(n):
if n in temp_mark:
logger.error(f"检测到循环依赖: {n}")
return
if n not in visited:
temp_mark.add(n)
for m in graph.get(n, []):
visit(m)
temp_mark.remove(n)
visited.add(n)
result.append(n)
for name in plugin_names:
if name not in visited:
visit(name)
return result
def load_registered_plugin_classes(self, plugin_name: str) -> Tuple[bool, int]: def load_registered_plugin_classes(self, plugin_name: str) -> Tuple[bool, int]:
# sourcery skip: extract-duplicate-method, extract-method # sourcery skip: extract-duplicate-method, extract-method
""" """
@ -110,6 +172,53 @@ class PluginManager:
logger.info(f"插件 {plugin_name} 已禁用,跳过加载") logger.info(f"插件 {plugin_name} 已禁用,跳过加载")
return False, 0 return False, 0
# 优先检查并安装配置文件定义的依赖
try:
# 检查是否有正在运行的事件循环
try:
asyncio.get_running_loop()
pass
except RuntimeError:
# 没有运行的循环,可以使用 asyncio.run
asyncio.run(
plugin_dependency_manager.install_auto_from_directory(plugin_dir)
)
except Exception as e: # pylint: disable=broad-except
logger.error(f"自动安装依赖文件失败: {e}")
if plugin_instance.python_dependencies:
_, unsatisfied = plugin_dependency_manager.check_dependencies(
plugin_instance.python_dependencies)
if unsatisfied:
logger.warning(f"插件 {plugin_name} 缺少Python依赖:"
f" {[d.name for d in unsatisfied]},尝试自动安装...")
# 尝试自动安装
install_success = False
try:
# 检查是否有正在运行的事件循环
try:
asyncio.get_running_loop()
# 如果已经在循环中(例如 WebUI 调用),我们无法在同步方法中阻塞等待异步安装
# 记录警告并跳过自动安装,尝试继续加载(如果真的缺依赖,后续会抛出 ImportError
logger.warning(f"检测到运行中的事件循环,跳过插件 {plugin_name} 的元数据依赖自动安装。如果加载失败,请手动安装: {[d.name for d in unsatisfied]}")
install_success = False # 标记为未执行安装,但允许后续流程尝试加载
except RuntimeError:
# 没有运行的循环,可以使用 asyncio.run
install_success = asyncio.run(
plugin_dependency_manager.install_dependencies(unsatisfied))
except Exception as e: # pylint: disable=broad-except
logger.error(f"自动安装依赖失败: {e}")
install_success = False
if install_success:
logger.info(f"插件 {plugin_name} 依赖安装成功,继续加载")
elif unsatisfied:
logger.warning(f"插件 {plugin_name} 存在未满足的依赖且未能自动安装,将尝试强制加载...")
# 检查版本兼容性 # 检查版本兼容性
is_compatible, compatibility_error = self._check_plugin_version_compatibility( is_compatible, compatibility_error = self._check_plugin_version_compatibility(
plugin_name, plugin_instance.manifest_data plugin_name, plugin_instance.manifest_data

View File

@ -5,6 +5,7 @@ from pathlib import Path
import json import json
from src.common.logger import get_logger from src.common.logger import get_logger
from src.config.config import MMC_VERSION from src.config.config import MMC_VERSION
from src.plugin_system.core.dependency_manager import plugin_dependency_manager
from .git_mirror_service import get_git_mirror_service, set_update_progress_callback from .git_mirror_service import get_git_mirror_service, set_update_progress_callback
from .token_manager import get_token_manager from .token_manager import get_token_manager
from .plugin_progress_ws import update_progress from .plugin_progress_ws import update_progress
@ -21,21 +22,21 @@ set_update_progress_callback(update_progress)
def parse_version(version_str: str) -> tuple[int, int, int]: def parse_version(version_str: str) -> tuple[int, int, int]:
""" """
解析版本号字符串 解析版本号字符串
支持格式: 支持格式:
- 0.11.2 -> (0, 11, 2) - 0.11.2 -> (0, 11, 2)
- 0.11.2.snapshot.2 -> (0, 11, 2) - 0.11.2.snapshot.2 -> (0, 11, 2)
Returns: Returns:
(major, minor, patch) 三元组 (major, minor, patch) 三元组
""" """
# 移除 snapshot 等后缀 # 移除 snapshot 等后缀
base_version = version_str.split(".snapshot")[0].split(".dev")[0].split(".alpha")[0].split(".beta")[0] base_version = version_str.split('.snapshot')[0].split('.dev')[0].split('.alpha')[0].split('.beta')[0]
parts = base_version.split(".") parts = base_version.split('.')
if len(parts) < 3: if len(parts) < 3:
# 补齐到 3 位 # 补齐到 3 位
parts.extend(["0"] * (3 - len(parts))) parts.extend(['0'] * (3 - len(parts)))
try: try:
major = int(parts[0]) major = int(parts[0])
@ -49,10 +50,8 @@ def parse_version(version_str: str) -> tuple[int, int, int]:
# ============ 请求/响应模型 ============ # ============ 请求/响应模型 ============
class FetchRawFileRequest(BaseModel): class FetchRawFileRequest(BaseModel):
"""获取 Raw 文件请求""" """获取 Raw 文件请求"""
owner: str = Field(..., description="仓库所有者", example="MaiM-with-u") owner: str = Field(..., description="仓库所有者", example="MaiM-with-u")
repo: str = Field(..., description="仓库名称", example="plugin-repo") repo: str = Field(..., description="仓库名称", example="plugin-repo")
branch: str = Field(..., description="分支名称", example="main") branch: str = Field(..., description="分支名称", example="main")
@ -63,7 +62,6 @@ class FetchRawFileRequest(BaseModel):
class FetchRawFileResponse(BaseModel): class FetchRawFileResponse(BaseModel):
"""获取 Raw 文件响应""" """获取 Raw 文件响应"""
success: bool = Field(..., description="是否成功") success: bool = Field(..., description="是否成功")
data: Optional[str] = Field(None, description="文件内容") data: Optional[str] = Field(None, description="文件内容")
error: Optional[str] = Field(None, description="错误信息") error: Optional[str] = Field(None, description="错误信息")
@ -74,7 +72,6 @@ class FetchRawFileResponse(BaseModel):
class CloneRepositoryRequest(BaseModel): class CloneRepositoryRequest(BaseModel):
"""克隆仓库请求""" """克隆仓库请求"""
owner: str = Field(..., description="仓库所有者", example="MaiM-with-u") owner: str = Field(..., description="仓库所有者", example="MaiM-with-u")
repo: str = Field(..., description="仓库名称", example="plugin-repo") repo: str = Field(..., description="仓库名称", example="plugin-repo")
target_path: str = Field(..., description="目标路径(相对于插件目录)") target_path: str = Field(..., description="目标路径(相对于插件目录)")
@ -86,7 +83,6 @@ class CloneRepositoryRequest(BaseModel):
class CloneRepositoryResponse(BaseModel): class CloneRepositoryResponse(BaseModel):
"""克隆仓库响应""" """克隆仓库响应"""
success: bool = Field(..., description="是否成功") success: bool = Field(..., description="是否成功")
path: Optional[str] = Field(None, description="克隆路径") path: Optional[str] = Field(None, description="克隆路径")
error: Optional[str] = Field(None, description="错误信息") error: Optional[str] = Field(None, description="错误信息")
@ -98,7 +94,6 @@ class CloneRepositoryResponse(BaseModel):
class MirrorConfigResponse(BaseModel): class MirrorConfigResponse(BaseModel):
"""镜像源配置响应""" """镜像源配置响应"""
id: str = Field(..., description="镜像源 ID") id: str = Field(..., description="镜像源 ID")
name: str = Field(..., description="镜像源名称") name: str = Field(..., description="镜像源名称")
raw_prefix: str = Field(..., description="Raw 文件前缀") raw_prefix: str = Field(..., description="Raw 文件前缀")
@ -109,14 +104,12 @@ class MirrorConfigResponse(BaseModel):
class AvailableMirrorsResponse(BaseModel): class AvailableMirrorsResponse(BaseModel):
"""可用镜像源列表响应""" """可用镜像源列表响应"""
mirrors: List[MirrorConfigResponse] = Field(..., description="镜像源列表") mirrors: List[MirrorConfigResponse] = Field(..., description="镜像源列表")
default_priority: List[str] = Field(..., description="默认优先级顺序ID 列表)") default_priority: List[str] = Field(..., description="默认优先级顺序ID 列表)")
class AddMirrorRequest(BaseModel): class AddMirrorRequest(BaseModel):
"""添加镜像源请求""" """添加镜像源请求"""
id: str = Field(..., description="镜像源 ID", example="custom-mirror") id: str = Field(..., description="镜像源 ID", example="custom-mirror")
name: str = Field(..., description="镜像源名称", example="自定义镜像源") name: str = Field(..., description="镜像源名称", example="自定义镜像源")
raw_prefix: str = Field(..., description="Raw 文件前缀", example="https://example.com/raw") raw_prefix: str = Field(..., description="Raw 文件前缀", example="https://example.com/raw")
@ -127,7 +120,6 @@ class AddMirrorRequest(BaseModel):
class UpdateMirrorRequest(BaseModel): class UpdateMirrorRequest(BaseModel):
"""更新镜像源请求""" """更新镜像源请求"""
name: Optional[str] = Field(None, description="镜像源名称") name: Optional[str] = Field(None, description="镜像源名称")
raw_prefix: Optional[str] = Field(None, description="Raw 文件前缀") raw_prefix: Optional[str] = Field(None, description="Raw 文件前缀")
clone_prefix: Optional[str] = Field(None, description="克隆前缀") clone_prefix: Optional[str] = Field(None, description="克隆前缀")
@ -137,7 +129,6 @@ class UpdateMirrorRequest(BaseModel):
class GitStatusResponse(BaseModel): class GitStatusResponse(BaseModel):
"""Git 安装状态响应""" """Git 安装状态响应"""
installed: bool = Field(..., description="是否已安装 Git") installed: bool = Field(..., description="是否已安装 Git")
version: Optional[str] = Field(None, description="Git 版本号") version: Optional[str] = Field(None, description="Git 版本号")
path: Optional[str] = Field(None, description="Git 可执行文件路径") path: Optional[str] = Field(None, description="Git 可执行文件路径")
@ -146,7 +137,6 @@ class GitStatusResponse(BaseModel):
class InstallPluginRequest(BaseModel): class InstallPluginRequest(BaseModel):
"""安装插件请求""" """安装插件请求"""
plugin_id: str = Field(..., description="插件 ID") plugin_id: str = Field(..., description="插件 ID")
repository_url: str = Field(..., description="插件仓库 URL") repository_url: str = Field(..., description="插件仓库 URL")
branch: Optional[str] = Field("main", description="分支名称") branch: Optional[str] = Field("main", description="分支名称")
@ -155,7 +145,6 @@ class InstallPluginRequest(BaseModel):
class VersionResponse(BaseModel): class VersionResponse(BaseModel):
"""麦麦版本响应""" """麦麦版本响应"""
version: str = Field(..., description="麦麦版本号") version: str = Field(..., description="麦麦版本号")
version_major: int = Field(..., description="主版本号") version_major: int = Field(..., description="主版本号")
version_minor: int = Field(..., description="次版本号") version_minor: int = Field(..., description="次版本号")
@ -164,13 +153,11 @@ class VersionResponse(BaseModel):
class UninstallPluginRequest(BaseModel): class UninstallPluginRequest(BaseModel):
"""卸载插件请求""" """卸载插件请求"""
plugin_id: str = Field(..., description="插件 ID") plugin_id: str = Field(..., description="插件 ID")
class UpdatePluginRequest(BaseModel): class UpdatePluginRequest(BaseModel):
"""更新插件请求""" """更新插件请求"""
plugin_id: str = Field(..., description="插件 ID") plugin_id: str = Field(..., description="插件 ID")
repository_url: str = Field(..., description="插件仓库 URL") repository_url: str = Field(..., description="插件仓库 URL")
branch: Optional[str] = Field("main", description="分支名称") branch: Optional[str] = Field("main", description="分支名称")
@ -179,24 +166,28 @@ class UpdatePluginRequest(BaseModel):
# ============ API 路由 ============ # ============ API 路由 ============
@router.get("/version", response_model=VersionResponse) @router.get("/version", response_model=VersionResponse)
async def get_maimai_version() -> VersionResponse: async def get_maimai_version() -> VersionResponse:
""" """
获取麦麦版本信息 获取麦麦版本信息
此接口无需认证用于前端检查插件兼容性 此接口无需认证用于前端检查插件兼容性
""" """
major, minor, patch = parse_version(MMC_VERSION) major, minor, patch = parse_version(MMC_VERSION)
return VersionResponse(version=MMC_VERSION, version_major=major, version_minor=minor, version_patch=patch) return VersionResponse(
version=MMC_VERSION,
version_major=major,
version_minor=minor,
version_patch=patch
)
@router.get("/git-status", response_model=GitStatusResponse) @router.get("/git-status", response_model=GitStatusResponse)
async def check_git_status() -> GitStatusResponse: async def check_git_status() -> GitStatusResponse:
""" """
检查本机 Git 安装状态 检查本机 Git 安装状态
此接口无需认证用于前端快速检测是否可以使用插件安装功能 此接口无需认证用于前端快速检测是否可以使用插件安装功能
""" """
service = get_git_mirror_service() service = get_git_mirror_service()
@ -206,7 +197,9 @@ async def check_git_status() -> GitStatusResponse:
@router.get("/mirrors", response_model=AvailableMirrorsResponse) @router.get("/mirrors", response_model=AvailableMirrorsResponse)
async def get_available_mirrors(authorization: Optional[str] = Header(None)) -> AvailableMirrorsResponse: async def get_available_mirrors(
authorization: Optional[str] = Header(None)
) -> AvailableMirrorsResponse:
""" """
获取所有可用的镜像源配置 获取所有可用的镜像源配置
""" """
@ -227,16 +220,22 @@ async def get_available_mirrors(authorization: Optional[str] = Header(None)) ->
raw_prefix=m["raw_prefix"], raw_prefix=m["raw_prefix"],
clone_prefix=m["clone_prefix"], clone_prefix=m["clone_prefix"],
enabled=m["enabled"], enabled=m["enabled"],
priority=m["priority"], priority=m["priority"]
) )
for m in all_mirrors for m in all_mirrors
] ]
return AvailableMirrorsResponse(mirrors=mirrors, default_priority=config.get_default_priority_list()) return AvailableMirrorsResponse(
mirrors=mirrors,
default_priority=config.get_default_priority_list()
)
@router.post("/mirrors", response_model=MirrorConfigResponse) @router.post("/mirrors", response_model=MirrorConfigResponse)
async def add_mirror(request: AddMirrorRequest, authorization: Optional[str] = Header(None)) -> MirrorConfigResponse: async def add_mirror(
request: AddMirrorRequest,
authorization: Optional[str] = Header(None)
) -> MirrorConfigResponse:
""" """
添加新的镜像源 添加新的镜像源
""" """
@ -256,7 +255,7 @@ async def add_mirror(request: AddMirrorRequest, authorization: Optional[str] = H
raw_prefix=request.raw_prefix, raw_prefix=request.raw_prefix,
clone_prefix=request.clone_prefix, clone_prefix=request.clone_prefix,
enabled=request.enabled, enabled=request.enabled,
priority=request.priority, priority=request.priority
) )
return MirrorConfigResponse( return MirrorConfigResponse(
@ -265,7 +264,7 @@ async def add_mirror(request: AddMirrorRequest, authorization: Optional[str] = H
raw_prefix=mirror["raw_prefix"], raw_prefix=mirror["raw_prefix"],
clone_prefix=mirror["clone_prefix"], clone_prefix=mirror["clone_prefix"],
enabled=mirror["enabled"], enabled=mirror["enabled"],
priority=mirror["priority"], priority=mirror["priority"]
) )
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e raise HTTPException(status_code=400, detail=str(e)) from e
@ -276,7 +275,9 @@ async def add_mirror(request: AddMirrorRequest, authorization: Optional[str] = H
@router.put("/mirrors/{mirror_id}", response_model=MirrorConfigResponse) @router.put("/mirrors/{mirror_id}", response_model=MirrorConfigResponse)
async def update_mirror( async def update_mirror(
mirror_id: str, request: UpdateMirrorRequest, authorization: Optional[str] = Header(None) mirror_id: str,
request: UpdateMirrorRequest,
authorization: Optional[str] = Header(None)
) -> MirrorConfigResponse: ) -> MirrorConfigResponse:
""" """
更新镜像源配置 更新镜像源配置
@ -297,7 +298,7 @@ async def update_mirror(
raw_prefix=request.raw_prefix, raw_prefix=request.raw_prefix,
clone_prefix=request.clone_prefix, clone_prefix=request.clone_prefix,
enabled=request.enabled, enabled=request.enabled,
priority=request.priority, priority=request.priority
) )
if not mirror: if not mirror:
@ -309,7 +310,7 @@ async def update_mirror(
raw_prefix=mirror["raw_prefix"], raw_prefix=mirror["raw_prefix"],
clone_prefix=mirror["clone_prefix"], clone_prefix=mirror["clone_prefix"],
enabled=mirror["enabled"], enabled=mirror["enabled"],
priority=mirror["priority"], priority=mirror["priority"]
) )
except HTTPException: except HTTPException:
raise raise
@ -319,7 +320,10 @@ async def update_mirror(
@router.delete("/mirrors/{mirror_id}") @router.delete("/mirrors/{mirror_id}")
async def delete_mirror(mirror_id: str, authorization: Optional[str] = Header(None)) -> Dict[str, Any]: async def delete_mirror(
mirror_id: str,
authorization: Optional[str] = Header(None)
) -> Dict[str, Any]:
""" """
删除镜像源 删除镜像源
""" """
@ -337,18 +341,22 @@ async def delete_mirror(mirror_id: str, authorization: Optional[str] = Header(No
if not success: if not success:
raise HTTPException(status_code=404, detail=f"未找到镜像源: {mirror_id}") raise HTTPException(status_code=404, detail=f"未找到镜像源: {mirror_id}")
return {"success": True, "message": f"已删除镜像源: {mirror_id}"} return {
"success": True,
"message": f"已删除镜像源: {mirror_id}"
}
@router.post("/fetch-raw", response_model=FetchRawFileResponse) @router.post("/fetch-raw", response_model=FetchRawFileResponse)
async def fetch_raw_file( async def fetch_raw_file(
request: FetchRawFileRequest, authorization: Optional[str] = Header(None) request: FetchRawFileRequest,
authorization: Optional[str] = Header(None)
) -> FetchRawFileResponse: ) -> FetchRawFileResponse:
""" """
获取 GitHub 仓库的 Raw 文件内容 获取 GitHub 仓库的 Raw 文件内容
支持多镜像源自动切换和错误重试 支持多镜像源自动切换和错误重试
注意此接口可公开访问用于获取插件仓库等公开资源 注意此接口可公开访问用于获取插件仓库等公开资源
""" """
# Token 验证(可选,用于日志记录) # Token 验证(可选,用于日志记录)
@ -369,7 +377,7 @@ async def fetch_raw_file(
progress=10, progress=10,
message=f"正在获取插件列表: {request.file_path}", message=f"正在获取插件列表: {request.file_path}",
total_plugins=0, total_plugins=0,
loaded_plugins=0, loaded_plugins=0
) )
try: try:
@ -382,19 +390,22 @@ async def fetch_raw_file(
branch=request.branch, branch=request.branch,
file_path=request.file_path, file_path=request.file_path,
mirror_id=request.mirror_id, mirror_id=request.mirror_id,
custom_url=request.custom_url, custom_url=request.custom_url
) )
if result.get("success"): if result.get("success"):
# 更新进度:成功获取 # 更新进度:成功获取
await update_progress( await update_progress(
stage="loading", progress=70, message="正在解析插件数据...", total_plugins=0, loaded_plugins=0 stage="loading",
progress=70,
message="正在解析插件数据...",
total_plugins=0,
loaded_plugins=0
) )
# 尝试解析插件数量 # 尝试解析插件数量
try: try:
import json import json
data = json.loads(result.get("data", "[]")) data = json.loads(result.get("data", "[]"))
total = len(data) if isinstance(data, list) else 0 total = len(data) if isinstance(data, list) else 0
@ -404,12 +415,16 @@ async def fetch_raw_file(
progress=100, progress=100,
message=f"成功加载 {total} 个插件", message=f"成功加载 {total} 个插件",
total_plugins=total, total_plugins=total,
loaded_plugins=total, loaded_plugins=total
) )
except Exception: except Exception:
# 如果解析失败,仍然发送成功状态 # 如果解析失败,仍然发送成功状态
await update_progress( await update_progress(
stage="success", progress=100, message="加载完成", total_plugins=0, loaded_plugins=0 stage="success",
progress=100,
message="加载完成",
total_plugins=0,
loaded_plugins=0
) )
return FetchRawFileResponse(**result) return FetchRawFileResponse(**result)
@ -419,7 +434,12 @@ async def fetch_raw_file(
# 发送错误进度 # 发送错误进度
await update_progress( await update_progress(
stage="error", progress=0, message="加载失败", error=str(e), total_plugins=0, loaded_plugins=0 stage="error",
progress=0,
message="加载失败",
error=str(e),
total_plugins=0,
loaded_plugins=0
) )
raise HTTPException(status_code=500, detail=f"服务器错误: {str(e)}") from e raise HTTPException(status_code=500, detail=f"服务器错误: {str(e)}") from e
@ -427,11 +447,12 @@ async def fetch_raw_file(
@router.post("/clone", response_model=CloneRepositoryResponse) @router.post("/clone", response_model=CloneRepositoryResponse)
async def clone_repository( async def clone_repository(
request: CloneRepositoryRequest, authorization: Optional[str] = Header(None) request: CloneRepositoryRequest,
authorization: Optional[str] = Header(None)
) -> CloneRepositoryResponse: ) -> CloneRepositoryResponse:
""" """
克隆 GitHub 仓库到本地 克隆 GitHub 仓库到本地
支持多镜像源自动切换和错误重试 支持多镜像源自动切换和错误重试
""" """
# Token 验证 # Token 验证
@ -440,7 +461,9 @@ async def clone_repository(
if not token or not token_manager.verify_token(token): if not token or not token_manager.verify_token(token):
raise HTTPException(status_code=401, detail="未授权:无效的访问令牌") raise HTTPException(status_code=401, detail="未授权:无效的访问令牌")
logger.info(f"收到克隆仓库请求: {request.owner}/{request.repo} -> {request.target_path}") logger.info(
f"收到克隆仓库请求: {request.owner}/{request.repo} -> {request.target_path}"
)
try: try:
# TODO: 验证 target_path 的安全性,防止路径遍历攻击 # TODO: 验证 target_path 的安全性,防止路径遍历攻击
@ -456,7 +479,7 @@ async def clone_repository(
branch=request.branch, branch=request.branch,
mirror_id=request.mirror_id, mirror_id=request.mirror_id,
custom_url=request.custom_url, custom_url=request.custom_url,
depth=request.depth, depth=request.depth
) )
return CloneRepositoryResponse(**result) return CloneRepositoryResponse(**result)
@ -467,10 +490,13 @@ async def clone_repository(
@router.post("/install") @router.post("/install")
async def install_plugin(request: InstallPluginRequest, authorization: Optional[str] = Header(None)) -> Dict[str, Any]: async def install_plugin(
request: InstallPluginRequest,
authorization: Optional[str] = Header(None)
) -> Dict[str, Any]:
""" """
安装插件 安装插件
Git 仓库克隆插件到本地插件目录 Git 仓库克隆插件到本地插件目录
""" """
# Token 验证 # Token 验证
@ -488,16 +514,16 @@ async def install_plugin(request: InstallPluginRequest, authorization: Optional[
progress=5, progress=5,
message=f"开始安装插件: {request.plugin_id}", message=f"开始安装插件: {request.plugin_id}",
operation="install", operation="install",
plugin_id=request.plugin_id, plugin_id=request.plugin_id
) )
# 1. 解析仓库 URL # 1. 解析仓库 URL
# repository_url 格式: https://github.com/owner/repo # repository_url 格式: https://github.com/owner/repo
repo_url = request.repository_url.rstrip("/") repo_url = request.repository_url.rstrip('/')
if repo_url.endswith(".git"): if repo_url.endswith('.git'):
repo_url = repo_url[:-4] repo_url = repo_url[:-4]
parts = repo_url.split("/") parts = repo_url.split('/')
if len(parts) < 2: if len(parts) < 2:
raise HTTPException(status_code=400, detail="无效的仓库 URL") raise HTTPException(status_code=400, detail="无效的仓库 URL")
@ -509,7 +535,7 @@ async def install_plugin(request: InstallPluginRequest, authorization: Optional[
progress=10, progress=10,
message=f"解析仓库信息: {owner}/{repo}", message=f"解析仓库信息: {owner}/{repo}",
operation="install", operation="install",
plugin_id=request.plugin_id, plugin_id=request.plugin_id
) )
# 2. 确定插件安装路径 # 2. 确定插件安装路径
@ -523,10 +549,10 @@ async def install_plugin(request: InstallPluginRequest, authorization: Optional[
await update_progress( await update_progress(
stage="error", stage="error",
progress=0, progress=0,
message="插件已存在", message=f"插件已存在",
operation="install", operation="install",
plugin_id=request.plugin_id, plugin_id=request.plugin_id,
error="插件已安装,请先卸载", error="插件已安装,请先卸载"
) )
raise HTTPException(status_code=400, detail="插件已安装") raise HTTPException(status_code=400, detail="插件已安装")
@ -535,26 +561,31 @@ async def install_plugin(request: InstallPluginRequest, authorization: Optional[
progress=15, progress=15,
message=f"准备克隆到: {target_path}", message=f"准备克隆到: {target_path}",
operation="install", operation="install",
plugin_id=request.plugin_id, plugin_id=request.plugin_id
) )
# 3. 克隆仓库(这里会自动推送 20%-80% 的进度) # 3. 克隆仓库(这里会自动推送 20%-80% 的进度)
service = get_git_mirror_service() service = get_git_mirror_service()
# 如果是 GitHub 仓库,使用镜像源 # 如果是 GitHub 仓库,使用镜像源
if "github.com" in repo_url: if 'github.com' in repo_url:
result = await service.clone_repository( result = await service.clone_repository(
owner=owner, owner=owner,
repo=repo, repo=repo,
target_path=target_path, target_path=target_path,
branch=request.branch, branch=request.branch,
mirror_id=request.mirror_id, mirror_id=request.mirror_id,
depth=1, # 浅克隆,节省时间和空间 depth=1 # 浅克隆,节省时间和空间
) )
else: else:
# 自定义仓库,直接使用 URL # 自定义仓库,直接使用 URL
result = await service.clone_repository( result = await service.clone_repository(
owner=owner, repo=repo, target_path=target_path, branch=request.branch, custom_url=repo_url, depth=1 owner=owner,
repo=repo,
target_path=target_path,
branch=request.branch,
custom_url=repo_url,
depth=1
) )
if not result.get("success"): if not result.get("success"):
@ -565,20 +596,23 @@ async def install_plugin(request: InstallPluginRequest, authorization: Optional[
message="克隆仓库失败", message="克隆仓库失败",
operation="install", operation="install",
plugin_id=request.plugin_id, plugin_id=request.plugin_id,
error=error_msg, error=error_msg
) )
raise HTTPException(status_code=500, detail=error_msg) raise HTTPException(status_code=500, detail=error_msg)
# 4. 验证插件完整性 # 4. 验证插件完整性
await update_progress( await update_progress(
stage="loading", progress=85, message="验证插件文件...", operation="install", plugin_id=request.plugin_id stage="loading",
progress=85,
message="验证插件文件...",
operation="install",
plugin_id=request.plugin_id
) )
manifest_path = target_path / "_manifest.json" manifest_path = target_path / "_manifest.json"
if not manifest_path.exists(): if not manifest_path.exists():
# 清理失败的安装 # 清理失败的安装
import shutil import shutil
shutil.rmtree(target_path, ignore_errors=True) shutil.rmtree(target_path, ignore_errors=True)
await update_progress( await update_progress(
@ -587,23 +621,26 @@ async def install_plugin(request: InstallPluginRequest, authorization: Optional[
message="插件缺少 _manifest.json", message="插件缺少 _manifest.json",
operation="install", operation="install",
plugin_id=request.plugin_id, plugin_id=request.plugin_id,
error="无效的插件格式", error="无效的插件格式"
) )
raise HTTPException(status_code=400, detail="无效的插件:缺少 _manifest.json") raise HTTPException(status_code=400, detail="无效的插件:缺少 _manifest.json")
# 5. 读取并验证 manifest # 5. 读取并验证 manifest
await update_progress( await update_progress(
stage="loading", progress=90, message="读取插件配置...", operation="install", plugin_id=request.plugin_id stage="loading",
progress=90,
message="读取插件配置...",
operation="install",
plugin_id=request.plugin_id
) )
try: try:
import json as json_module import json as json_module
with open(manifest_path, 'r', encoding='utf-8') as f:
with open(manifest_path, "r", encoding="utf-8") as f:
manifest = json_module.load(f) manifest = json_module.load(f)
# 基本验证 # 基本验证
required_fields = ["manifest_version", "name", "version", "author"] required_fields = ['manifest_version', 'name', 'version', 'author']
for field in required_fields: for field in required_fields:
if field not in manifest: if field not in manifest:
raise ValueError(f"缺少必需字段: {field}") raise ValueError(f"缺少必需字段: {field}")
@ -611,7 +648,6 @@ async def install_plugin(request: InstallPluginRequest, authorization: Optional[
except Exception as e: except Exception as e:
# 清理失败的安装 # 清理失败的安装
import shutil import shutil
shutil.rmtree(target_path, ignore_errors=True) shutil.rmtree(target_path, ignore_errors=True)
await update_progress( await update_progress(
@ -620,26 +656,42 @@ async def install_plugin(request: InstallPluginRequest, authorization: Optional[
message="_manifest.json 无效", message="_manifest.json 无效",
operation="install", operation="install",
plugin_id=request.plugin_id, plugin_id=request.plugin_id,
error=str(e), error=str(e)
) )
raise HTTPException(status_code=400, detail=f"无效的 _manifest.json: {e}") from e raise HTTPException(status_code=400, detail=f"无效的 _manifest.json: {e}") from e
# 5.5 安装依赖
await update_progress(
stage="loading",
progress=95,
message="正在检查并安装依赖...",
operation="install",
plugin_id=request.plugin_id
)
try:
# 自动检测并安装 requirements.txt 或 pyproject.toml
await plugin_dependency_manager.install_auto_from_directory(str(target_path))
except Exception as e:
logger.error(f"依赖安装过程出错: {e}")
# 不中断安装流程,但记录日志
# 6. 安装成功 # 6. 安装成功
await update_progress( await update_progress(
stage="success", stage="success",
progress=100, progress=100,
message=f"成功安装插件: {manifest['name']} v{manifest['version']}", message=f"成功安装插件: {manifest['name']} v{manifest['version']}",
operation="install", operation="install",
plugin_id=request.plugin_id, plugin_id=request.plugin_id
) )
return { return {
"success": True, "success": True,
"message": "插件安装成功", "message": "插件安装成功",
"plugin_id": request.plugin_id, "plugin_id": request.plugin_id,
"plugin_name": manifest["name"], "plugin_name": manifest['name'],
"version": manifest["version"], "version": manifest['version'],
"path": str(target_path), "path": str(target_path)
} }
except HTTPException: except HTTPException:
@ -653,7 +705,7 @@ async def install_plugin(request: InstallPluginRequest, authorization: Optional[
message="安装失败", message="安装失败",
operation="install", operation="install",
plugin_id=request.plugin_id, plugin_id=request.plugin_id,
error=str(e), error=str(e)
) )
raise HTTPException(status_code=500, detail=f"服务器错误: {str(e)}") from e raise HTTPException(status_code=500, detail=f"服务器错误: {str(e)}") from e
@ -661,11 +713,12 @@ async def install_plugin(request: InstallPluginRequest, authorization: Optional[
@router.post("/uninstall") @router.post("/uninstall")
async def uninstall_plugin( async def uninstall_plugin(
request: UninstallPluginRequest, authorization: Optional[str] = Header(None) request: UninstallPluginRequest,
authorization: Optional[str] = Header(None)
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
卸载插件 卸载插件
删除插件目录及其所有文件 删除插件目录及其所有文件
""" """
# Token 验证 # Token 验证
@ -683,7 +736,7 @@ async def uninstall_plugin(
progress=10, progress=10,
message=f"开始卸载插件: {request.plugin_id}", message=f"开始卸载插件: {request.plugin_id}",
operation="uninstall", operation="uninstall",
plugin_id=request.plugin_id, plugin_id=request.plugin_id
) )
# 1. 检查插件是否存在 # 1. 检查插件是否存在
@ -697,7 +750,7 @@ async def uninstall_plugin(
message="插件不存在", message="插件不存在",
operation="uninstall", operation="uninstall",
plugin_id=request.plugin_id, plugin_id=request.plugin_id,
error="插件未安装或已被删除", error="插件未安装或已被删除"
) )
raise HTTPException(status_code=404, detail="插件未安装") raise HTTPException(status_code=404, detail="插件未安装")
@ -706,7 +759,7 @@ async def uninstall_plugin(
progress=30, progress=30,
message=f"正在删除插件文件: {plugin_path}", message=f"正在删除插件文件: {plugin_path}",
operation="uninstall", operation="uninstall",
plugin_id=request.plugin_id, plugin_id=request.plugin_id
) )
# 2. 读取插件信息(用于日志) # 2. 读取插件信息(用于日志)
@ -716,8 +769,7 @@ async def uninstall_plugin(
if manifest_path.exists(): if manifest_path.exists():
try: try:
import json as json_module import json as json_module
with open(manifest_path, 'r', encoding='utf-8') as f:
with open(manifest_path, "r", encoding="utf-8") as f:
manifest = json_module.load(f) manifest = json_module.load(f)
plugin_name = manifest.get("name", request.plugin_id) plugin_name = manifest.get("name", request.plugin_id)
except Exception: except Exception:
@ -728,7 +780,7 @@ async def uninstall_plugin(
progress=50, progress=50,
message=f"正在删除 {plugin_name}...", message=f"正在删除 {plugin_name}...",
operation="uninstall", operation="uninstall",
plugin_id=request.plugin_id, plugin_id=request.plugin_id
) )
# 3. 删除插件目录 # 3. 删除插件目录
@ -738,7 +790,6 @@ async def uninstall_plugin(
def remove_readonly(func, path, _): def remove_readonly(func, path, _):
"""清除只读属性并删除文件""" """清除只读属性并删除文件"""
import os import os
os.chmod(path, stat.S_IWRITE) os.chmod(path, stat.S_IWRITE)
func(path) func(path)
@ -752,10 +803,15 @@ async def uninstall_plugin(
progress=100, progress=100,
message=f"成功卸载插件: {plugin_name}", message=f"成功卸载插件: {plugin_name}",
operation="uninstall", operation="uninstall",
plugin_id=request.plugin_id, plugin_id=request.plugin_id
) )
return {"success": True, "message": "插件卸载成功", "plugin_id": request.plugin_id, "plugin_name": plugin_name} return {
"success": True,
"message": "插件卸载成功",
"plugin_id": request.plugin_id,
"plugin_name": plugin_name
}
except HTTPException: except HTTPException:
raise raise
@ -768,7 +824,7 @@ async def uninstall_plugin(
message="卸载失败", message="卸载失败",
operation="uninstall", operation="uninstall",
plugin_id=request.plugin_id, plugin_id=request.plugin_id,
error="权限不足,无法删除插件文件", error="权限不足,无法删除插件文件"
) )
raise HTTPException(status_code=500, detail="权限不足,无法删除插件文件") from e raise HTTPException(status_code=500, detail="权限不足,无法删除插件文件") from e
@ -781,17 +837,20 @@ async def uninstall_plugin(
message="卸载失败", message="卸载失败",
operation="uninstall", operation="uninstall",
plugin_id=request.plugin_id, plugin_id=request.plugin_id,
error=str(e), error=str(e)
) )
raise HTTPException(status_code=500, detail=f"服务器错误: {str(e)}") from e raise HTTPException(status_code=500, detail=f"服务器错误: {str(e)}") from e
@router.post("/update") @router.post("/update")
async def update_plugin(request: UpdatePluginRequest, authorization: Optional[str] = Header(None)) -> Dict[str, Any]: async def update_plugin(
request: UpdatePluginRequest,
authorization: Optional[str] = Header(None)
) -> Dict[str, Any]:
""" """
更新插件 更新插件
删除旧版本重新克隆新版本 删除旧版本重新克隆新版本
""" """
# Token 验证 # Token 验证
@ -809,7 +868,7 @@ async def update_plugin(request: UpdatePluginRequest, authorization: Optional[st
progress=5, progress=5,
message=f"开始更新插件: {request.plugin_id}", message=f"开始更新插件: {request.plugin_id}",
operation="update", operation="update",
plugin_id=request.plugin_id, plugin_id=request.plugin_id
) )
# 1. 检查插件是否已安装 # 1. 检查插件是否已安装
@ -823,23 +882,21 @@ async def update_plugin(request: UpdatePluginRequest, authorization: Optional[st
message="插件不存在", message="插件不存在",
operation="update", operation="update",
plugin_id=request.plugin_id, plugin_id=request.plugin_id,
error="插件未安装,请先安装", error="插件未安装,请先安装"
) )
raise HTTPException(status_code=404, detail="插件未安装") raise HTTPException(status_code=404, detail="插件未安装")
# 2. 读取旧版本信息 # 2. 读取旧版本信息
manifest_path = plugin_path / "_manifest.json" manifest_path = plugin_path / "_manifest.json"
old_version = "unknown" old_version = "unknown"
plugin_name = request.plugin_id
if manifest_path.exists(): if manifest_path.exists():
try: try:
import json as json_module import json as json_module
with open(manifest_path, 'r', encoding='utf-8') as f:
with open(manifest_path, "r", encoding="utf-8") as f:
manifest = json_module.load(f) manifest = json_module.load(f)
old_version = manifest.get("version", "unknown") old_version = manifest.get("version", "unknown")
_plugin_name = manifest.get("name", request.plugin_id) # plugin_name = manifest.get("name", request.plugin_id)
except Exception: except Exception:
pass pass
@ -848,12 +905,16 @@ async def update_plugin(request: UpdatePluginRequest, authorization: Optional[st
progress=10, progress=10,
message=f"当前版本: {old_version},准备更新...", message=f"当前版本: {old_version},准备更新...",
operation="update", operation="update",
plugin_id=request.plugin_id, plugin_id=request.plugin_id
) )
# 3. 删除旧版本 # 3. 删除旧版本
await update_progress( await update_progress(
stage="loading", progress=20, message="正在删除旧版本...", operation="update", plugin_id=request.plugin_id stage="loading",
progress=20,
message="正在删除旧版本...",
operation="update",
plugin_id=request.plugin_id
) )
import shutil import shutil
@ -862,7 +923,6 @@ async def update_plugin(request: UpdatePluginRequest, authorization: Optional[st
def remove_readonly(func, path, _): def remove_readonly(func, path, _):
"""清除只读属性并删除文件""" """清除只读属性并删除文件"""
import os import os
os.chmod(path, stat.S_IWRITE) os.chmod(path, stat.S_IWRITE)
func(path) func(path)
@ -876,14 +936,14 @@ async def update_plugin(request: UpdatePluginRequest, authorization: Optional[st
progress=30, progress=30,
message="正在准备下载新版本...", message="正在准备下载新版本...",
operation="update", operation="update",
plugin_id=request.plugin_id, plugin_id=request.plugin_id
) )
repo_url = request.repository_url.rstrip("/") repo_url = request.repository_url.rstrip('/')
if repo_url.endswith(".git"): if repo_url.endswith('.git'):
repo_url = repo_url[:-4] repo_url = repo_url[:-4]
parts = repo_url.split("/") parts = repo_url.split('/')
if len(parts) < 2: if len(parts) < 2:
raise HTTPException(status_code=400, detail="无效的仓库 URL") raise HTTPException(status_code=400, detail="无效的仓库 URL")
@ -893,18 +953,23 @@ async def update_plugin(request: UpdatePluginRequest, authorization: Optional[st
# 5. 克隆新版本(这里会推送 35%-85% 的进度) # 5. 克隆新版本(这里会推送 35%-85% 的进度)
service = get_git_mirror_service() service = get_git_mirror_service()
if "github.com" in repo_url: if 'github.com' in repo_url:
result = await service.clone_repository( result = await service.clone_repository(
owner=owner, owner=owner,
repo=repo, repo=repo,
target_path=plugin_path, target_path=plugin_path,
branch=request.branch, branch=request.branch,
mirror_id=request.mirror_id, mirror_id=request.mirror_id,
depth=1, depth=1
) )
else: else:
result = await service.clone_repository( result = await service.clone_repository(
owner=owner, repo=repo, target_path=plugin_path, branch=request.branch, custom_url=repo_url, depth=1 owner=owner,
repo=repo,
target_path=plugin_path,
branch=request.branch,
custom_url=repo_url,
depth=1
) )
if not result.get("success"): if not result.get("success"):
@ -915,13 +980,17 @@ async def update_plugin(request: UpdatePluginRequest, authorization: Optional[st
message="下载新版本失败", message="下载新版本失败",
operation="update", operation="update",
plugin_id=request.plugin_id, plugin_id=request.plugin_id,
error=error_msg, error=error_msg
) )
raise HTTPException(status_code=500, detail=error_msg) raise HTTPException(status_code=500, detail=error_msg)
# 6. 验证新版本 # 6. 验证新版本
await update_progress( await update_progress(
stage="loading", progress=90, message="验证新版本...", operation="update", plugin_id=request.plugin_id stage="loading",
progress=90,
message="验证新版本...",
operation="update",
plugin_id=request.plugin_id
) )
new_manifest_path = plugin_path / "_manifest.json" new_manifest_path = plugin_path / "_manifest.json"
@ -930,7 +999,6 @@ async def update_plugin(request: UpdatePluginRequest, authorization: Optional[st
def remove_readonly(func, path, _): def remove_readonly(func, path, _):
"""清除只读属性并删除文件""" """清除只读属性并删除文件"""
import os import os
os.chmod(path, stat.S_IWRITE) os.chmod(path, stat.S_IWRITE)
func(path) func(path)
@ -942,18 +1010,31 @@ async def update_plugin(request: UpdatePluginRequest, authorization: Optional[st
message="新版本缺少 _manifest.json", message="新版本缺少 _manifest.json",
operation="update", operation="update",
plugin_id=request.plugin_id, plugin_id=request.plugin_id,
error="无效的插件格式", error="无效的插件格式"
) )
raise HTTPException(status_code=400, detail="无效的插件:缺少 _manifest.json") raise HTTPException(status_code=400, detail="无效的插件:缺少 _manifest.json")
# 7. 读取新版本信息 # 7. 读取新版本信息
try: try:
with open(new_manifest_path, "r", encoding="utf-8") as f: with open(new_manifest_path, 'r', encoding='utf-8') as f:
new_manifest = json_module.load(f) new_manifest = json_module.load(f)
new_version = new_manifest.get("version", "unknown") new_version = new_manifest.get("version", "unknown")
new_name = new_manifest.get("name", request.plugin_id) new_name = new_manifest.get("name", request.plugin_id)
# 7.5 安装依赖
await update_progress(
stage="loading",
progress=95,
message="正在更新依赖...",
operation="update",
plugin_id=request.plugin_id
)
try:
await plugin_dependency_manager.install_auto_from_directory(str(plugin_path))
except Exception as e:
logger.error(f"依赖更新过程出错: {e}")
logger.info(f"成功更新插件: {request.plugin_id} {old_version}{new_version}") logger.info(f"成功更新插件: {request.plugin_id} {old_version}{new_version}")
# 8. 推送成功状态 # 8. 推送成功状态
@ -962,7 +1043,7 @@ async def update_plugin(request: UpdatePluginRequest, authorization: Optional[st
progress=100, progress=100,
message=f"成功更新 {new_name}: {old_version}{new_version}", message=f"成功更新 {new_name}: {old_version}{new_version}",
operation="update", operation="update",
plugin_id=request.plugin_id, plugin_id=request.plugin_id
) )
return { return {
@ -971,7 +1052,7 @@ async def update_plugin(request: UpdatePluginRequest, authorization: Optional[st
"plugin_id": request.plugin_id, "plugin_id": request.plugin_id,
"plugin_name": new_name, "plugin_name": new_name,
"old_version": old_version, "old_version": old_version,
"new_version": new_version, "new_version": new_version
} }
except Exception as e: except Exception as e:
@ -984,7 +1065,7 @@ async def update_plugin(request: UpdatePluginRequest, authorization: Optional[st
message="_manifest.json 无效", message="_manifest.json 无效",
operation="update", operation="update",
plugin_id=request.plugin_id, plugin_id=request.plugin_id,
error=str(e), error=str(e)
) )
raise HTTPException(status_code=400, detail=f"无效的 _manifest.json: {e}") from e raise HTTPException(status_code=400, detail=f"无效的 _manifest.json: {e}") from e
@ -994,17 +1075,24 @@ async def update_plugin(request: UpdatePluginRequest, authorization: Optional[st
logger.error(f"更新插件失败: {e}", exc_info=True) logger.error(f"更新插件失败: {e}", exc_info=True)
await update_progress( await update_progress(
stage="error", progress=0, message="更新失败", operation="update", plugin_id=request.plugin_id, error=str(e) stage="error",
progress=0,
message="更新失败",
operation="update",
plugin_id=request.plugin_id,
error=str(e)
) )
raise HTTPException(status_code=500, detail=f"服务器错误: {str(e)}") from e raise HTTPException(status_code=500, detail=f"服务器错误: {str(e)}") from e
@router.get("/installed") @router.get("/installed")
async def get_installed_plugins(authorization: Optional[str] = Header(None)) -> Dict[str, Any]: async def get_installed_plugins(
authorization: Optional[str] = Header(None)
) -> Dict[str, Any]:
""" """
获取已安装的插件列表 获取已安装的插件列表
扫描 plugins 目录返回所有已安装插件的 ID 和基本信息 扫描 plugins 目录返回所有已安装插件的 ID 和基本信息
""" """
# Token 验证 # Token 验证
@ -1022,7 +1110,10 @@ async def get_installed_plugins(authorization: Optional[str] = Header(None)) ->
if not plugins_dir.exists(): if not plugins_dir.exists():
logger.info("插件目录不存在,创建目录") logger.info("插件目录不存在,创建目录")
plugins_dir.mkdir(exist_ok=True) plugins_dir.mkdir(exist_ok=True)
return {"success": True, "plugins": []} return {
"success": True,
"plugins": []
}
installed_plugins = [] installed_plugins = []
@ -1036,7 +1127,7 @@ async def get_installed_plugins(authorization: Optional[str] = Header(None)) ->
plugin_id = plugin_path.name plugin_id = plugin_path.name
# 跳过隐藏目录和特殊目录 # 跳过隐藏目录和特殊目录
if plugin_id.startswith(".") or plugin_id.startswith("__"): if plugin_id.startswith('.') or plugin_id.startswith('__'):
continue continue
# 读取 _manifest.json # 读取 _manifest.json
@ -1048,23 +1139,20 @@ async def get_installed_plugins(authorization: Optional[str] = Header(None)) ->
try: try:
import json as json_module import json as json_module
with open(manifest_path, 'r', encoding='utf-8') as f:
with open(manifest_path, "r", encoding="utf-8") as f:
manifest = json_module.load(f) manifest = json_module.load(f)
# 基本验证 # 基本验证
if "name" not in manifest or "version" not in manifest: if 'name' not in manifest or 'version' not in manifest:
logger.warning(f"插件 {plugin_id} 的 _manifest.json 格式无效,跳过") logger.warning(f"插件 {plugin_id} 的 _manifest.json 格式无效,跳过")
continue continue
# 添加到已安装列表(返回完整的 manifest 信息) # 添加到已安装列表(返回完整的 manifest 信息)
installed_plugins.append( installed_plugins.append({
{ "id": plugin_id,
"id": plugin_id, "manifest": manifest, # 返回完整的 manifest 对象
"manifest": manifest, # 返回完整的 manifest 对象 "path": str(plugin_path.absolute())
"path": str(plugin_path.absolute()), })
}
)
except json.JSONDecodeError as e: except json.JSONDecodeError as e:
logger.warning(f"插件 {plugin_id} 的 _manifest.json 解析失败: {e}") logger.warning(f"插件 {plugin_id} 的 _manifest.json 解析失败: {e}")
@ -1075,7 +1163,11 @@ async def get_installed_plugins(authorization: Optional[str] = Header(None)) ->
logger.info(f"找到 {len(installed_plugins)} 个已安装插件") logger.info(f"找到 {len(installed_plugins)} 个已安装插件")
return {"success": True, "plugins": installed_plugins, "total": len(installed_plugins)} return {
"success": True,
"plugins": installed_plugins,
"total": len(installed_plugins)
}
except Exception as e: except Exception as e:
logger.error(f"获取已安装插件列表失败: {e}", exc_info=True) logger.error(f"获取已安装插件列表失败: {e}", exc_info=True)