ForcePilot/backend/server/routers/channels/plugin_router.py
Kris 2f6a4c29bb refactor(channel routers): 统一参数校验与常量定义,新增功能端点
1. 为渠道账户ID查询添加最小长度校验,统一分析模块常量引用
2. 新增扫码登录向导端点,完善文档说明
3. 优化配对统计接口,移除无效参数
4. 为出站箱接口添加批量上限与202状态码
5. 新增测试用例、访问规则、配额等模块的查询与校验参数
6. 新增适配器健康批量查询、健康检查触发接口
7. 统一告警、审计日志的错误处理方式
8. 新增插件配置账户ID支持,优化批量操作响应
9. 新增环境健康批量查询、Webhook限流与参数校验
10. 完善会话管理、审计日志的参数与文档说明
11. 修复导入模块的校验错误处理逻辑
2026-07-11 21:39:05 +08:00

447 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""插件管理路由PLG-QUERY-01/02 + PLG-LIFE-01~07 + PLG-INSTALL + PLG-UNINSTALL-FILE + PLG-CONFIG + PLG-BATCH-LIFE
统一采用模板 A控制面端口路由鉴权依赖 get_admin_user调用
use_cases.plugin_management.<method>raiseOnControlFailure 转译失败,
serialize_control_data 序列化响应。
路径设计:静态路径 /plugins 先于动态路径 /plugins/{plugin_id} 及其子路径
声明(规范 §6.5),避免静态路径被动态参数捕获(如 plugin_id="schema"
被 /plugins 捕获)。子 router 自身 prefix 为 /plugins根前缀 /channels
由 channels_router 聚合 router 统一追加。
端点清单对应《09-插件管理域设计方案》§2.1
- GET /plugins PLG-QUERY-01 listPlugins
- GET /plugins/catalog PLG-CATALOG listPluginCatalog
- GET /plugins/{plugin_id} PLG-QUERY-02 getPlugin
- POST /plugins/{plugin_id}/load PLG-LIFE-01 pluginLoad
- POST /plugins/{plugin_id}/start PLG-LIFE-02 pluginStart
- POST /plugins/{plugin_id}/pause PLG-LIFE-03 pluginPause
- POST /plugins/{plugin_id}/resume PLG-LIFE-04 pluginResume
- POST /plugins/{plugin_id}/stop PLG-LIFE-05 pluginStop
- POST /plugins/{plugin_id}/unload PLG-LIFE-06 pluginUnload
- POST /plugins/{plugin_id}/reload PLG-LIFE-07 pluginReload
- POST /plugins/install PLG-INSTALL installPlugin
- DELETE /plugins/{plugin_id} PLG-UNINSTALL-FILE uninstallPlugin
- GET /plugins/{plugin_id}/config PLG-CONFIG-GET getPluginConfig
- PUT /plugins/{plugin_id}/config PLG-CONFIG-PUT updatePluginConfig
- POST /plugins/batch/start PLG-BATCH-LIFE-START batchStartPlugins
- POST /plugins/batch/stop PLG-BATCH-LIFE-STOP batchStopPlugins
"""
from __future__ import annotations
from typing import Any, Literal
from fastapi import APIRouter, Depends, Query, Request
from pydantic import BaseModel, ConfigDict, Field
from yuxi.channels.contract.dtos.plugin import (
BatchPluginLifecycleCmd,
InstallPluginCmd,
PluginStateLiteral,
UpdatePluginConfigCmd,
)
from yuxi.storage.postgres.models_business import User
from server.routers.channels import (
build_operator,
get_channel_use_cases,
raiseOnControlFailure,
serialize_control_data,
)
from server.utils.auth_middleware import get_admin_user, get_superadmin_user
plugin_router = APIRouter(prefix="/plugins", tags=["channels-plugins"])
class InstallPluginRequest(BaseModel):
"""安装插件请求体PLG-INSTALL
``source_type`` 为 ``path`` 时从本地目录复制插件文件;``url`` / ``registry``
暂未实现(由 ``PluginLoader.installFromSource`` 抛 ``NotImplementedError`` 501
"""
model_config = ConfigDict(frozen=True)
source_type: Literal["path", "url", "registry"] = Field(..., description="来源类型path / url / registry")
source: str = Field(..., description="来源路径或 URL")
version: str | None = Field(default=None, description="指定版本(可选)")
force: bool = Field(default=False, description="是否强制覆盖已存在插件")
class UpdatePluginConfigRequest(BaseModel):
"""更新插件配置请求体PLG-CONFIG-PUT
``config`` 必须为非空字典(由 ``UpdatePluginConfigCmd.__post_init__``
校验),且 key 必须在插件 manifest 的 ``config_schema`` 中声明(由
dispatch handler 校验),否则抛 ``ValidationError``。
``account_id`` 用于 ACCOUNT 作用域配置字段的读写,未提供时 ACCOUNT
作用域字段更新会抛 ``ValidationError``。
"""
model_config = ConfigDict(frozen=True)
config: dict[str, Any] = Field(..., description="配置值字典")
apply_mode: Literal["hot", "restart_required"] = Field(..., description="应用模式")
account_id: str | None = Field(default=None, description="账户 IDACCOUNT 作用域配置字段需要)")
class BatchPluginLifecycleRequest(BaseModel):
"""批量插件生命周期请求体PLG-BATCH-LIFE
``plugin_ids`` 与 ``filter`` 二选一:不可同时为空、不可同时提供,
由 ``BatchPluginLifecycleCmd.__post_init__`` 在构造时校验并抛
``ValidationError``400避免非法输入穿透至控制面管道。
"""
model_config = ConfigDict(frozen=True)
plugin_ids: list[str] = Field(default_factory=list, description="显式插件 ID 列表")
filter: dict[str, Any] | None = Field(default=None, description="筛选条件(含 channel_type / state")
# ---------------- 静态路径端点(须先于动态路径声明) ----------------
@plugin_router.get("", response_model=dict)
async def list_plugins(
request: Request,
state: PluginStateLiteral | None = Query(
default=None,
description="按生命周期状态过滤discovered/resolved/loaded/initialized/started/paused/stopped/unloaded/failed/not_installed/installed",
),
use_cases=Depends(get_channel_use_cases),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""列出全部插件PLG-QUERY-01。对应控制面操作 plugin/list。"""
operator = build_operator(current_user, request)
result = await use_cases.plugin_management.listPlugins(
operator=operator,
state=state,
)
raiseOnControlFailure(result)
return {"success": True, "data": serialize_control_data(result.data)}
@plugin_router.get("/catalog", response_model=dict)
async def list_plugin_catalog(
request: Request,
state: PluginStateLiteral | None = Query(
default=None,
description="按生命周期状态过滤discovered/resolved/loaded/initialized/started/paused/stopped/unloaded/failed/not_installed/installed",
),
use_cases=Depends(get_channel_use_cases),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""列出插件目录PLG-CATALOG。对应控制面操作 plugin/catalog。
返回已注册插件的卡片视图元数据(含 capabilities供前端卡片网格展示。
与 ``GET /plugins`` 的区别在于返回 capabilities / icon / last_error 字段;
支持 ``state`` 查询参数与 ``GET /plugins`` 保持一致,便于前端直接做服务端状态过滤。
"""
operator = build_operator(current_user, request)
result = await use_cases.plugin_management.listPluginCatalog(
operator=operator,
state=state,
)
raiseOnControlFailure(result)
return {"success": True, "data": serialize_control_data(result.data)}
@plugin_router.post("/install", response_model=dict)
async def install_plugin(
payload: InstallPluginRequest,
request: Request,
use_cases=Depends(get_channel_use_cases),
current_user: User = Depends(get_superadmin_user),
) -> dict[str, Any]:
"""安装插件PLG-INSTALL。对应控制面操作 plugin/install。
从来源(``path`` / ``url`` / ``registry``)安装插件文件到插件目录,解析
manifest 并注册到 PluginRegistry。返回 ``InstallPluginResult``,含
plugin_id / version / installed_at / requires_load=true。需超级管理员权限。
"""
operator = build_operator(current_user, request)
cmd = InstallPluginCmd(
operator=operator,
source_type=payload.source_type,
source=payload.source,
version=payload.version,
force=payload.force,
)
result = await use_cases.plugin_management.installPlugin(cmd)
raiseOnControlFailure(result)
return {"success": True, "data": serialize_control_data(result.data)}
@plugin_router.post("/batch/start", response_model=dict)
async def batch_start_plugins(
payload: BatchPluginLifecycleRequest,
request: Request,
use_cases=Depends(get_channel_use_cases),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""批量启动插件PLG-BATCH-LIFE-START。对应控制面操作 plugin/batch_start。
逐条独立事务执行批量启动(模式 D 部分成功),复用单条 ``pluginStart``
逻辑。``plugin_ids`` 与 ``filter`` 二选一。返回
``BatchPluginLifecycleResult``,含 total / succeeded / failed 列表。
"""
operator = build_operator(current_user, request)
cmd = BatchPluginLifecycleCmd(
operator=operator,
plugin_ids=tuple(payload.plugin_ids),
filter=payload.filter,
)
result = await use_cases.plugin_management.batchStartPlugins(cmd)
raiseOnControlFailure(result)
response: dict[str, Any] = {"success": True, "data": serialize_control_data(result.data)}
if result.status == "partial":
response["partial"] = True
return response
@plugin_router.post("/batch/stop", response_model=dict)
async def batch_stop_plugins(
payload: BatchPluginLifecycleRequest,
request: Request,
use_cases=Depends(get_channel_use_cases),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""批量停止插件PLG-BATCH-LIFE-STOP。对应控制面操作 plugin/batch_stop。
逐条独立事务执行批量停止(模式 D 部分成功),复用单条 ``pluginStop``
逻辑。``plugin_ids`` 与 ``filter`` 二选一。返回
``BatchPluginLifecycleResult``,含 total / succeeded / failed 列表。
"""
operator = build_operator(current_user, request)
cmd = BatchPluginLifecycleCmd(
operator=operator,
plugin_ids=tuple(payload.plugin_ids),
filter=payload.filter,
)
result = await use_cases.plugin_management.batchStopPlugins(cmd)
raiseOnControlFailure(result)
response: dict[str, Any] = {"success": True, "data": serialize_control_data(result.data)}
if result.status == "partial":
response["partial"] = True
return response
# ---------------- 动态路径端点 ----------------
@plugin_router.get("/{plugin_id}", response_model=dict)
async def get_plugin(
plugin_id: str,
request: Request,
use_cases=Depends(get_channel_use_cases),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""查询插件详情PLG-QUERY-02。对应控制面操作 plugin/get。
返回插件 manifest、生命周期状态、绑定渠道类型。
"""
operator = build_operator(current_user, request)
result = await use_cases.plugin_management.getPlugin(
plugin_id=plugin_id,
operator=operator,
)
raiseOnControlFailure(result)
return {"success": True, "data": serialize_control_data(result.data)}
@plugin_router.delete("/{plugin_id}", response_model=dict)
async def uninstall_plugin(
plugin_id: str,
request: Request,
use_cases=Depends(get_channel_use_cases),
current_user: User = Depends(get_superadmin_user),
) -> dict[str, Any]:
"""文件级卸载插件PLG-UNINSTALL-FILE。对应控制面操作 plugin/uninstall。
校验插件状态必须为 ``unloaded`` / ``not_installed`` / ``installed``
(运行中状态需先 stop+unload删除插件文件并
注销 manifest。返回 ``UninstallPluginResult``,含 plugin_id /
uninstalled_at / files_removed。需超级管理员权限。
"""
operator = build_operator(current_user, request)
result = await use_cases.plugin_management.uninstallPlugin(
plugin_id=plugin_id,
operator=operator,
)
raiseOnControlFailure(result)
return {"success": True, "data": serialize_control_data(result.data)}
@plugin_router.get("/{plugin_id}/config", response_model=dict)
async def get_plugin_config(
plugin_id: str,
request: Request,
account_id: str | None = Query(default=None, description="账户 IDACCOUNT 作用域配置字段需要)"),
use_cases=Depends(get_channel_use_cases),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""查询插件配置PLG-CONFIG-GET。对应控制面操作 plugin/config_get。
从 PluginManifest 获取配置 schema从 ConfigManager 获取当前配置值,
组装 ``PluginConfigResult`` 返回,含 plugin_id / config / schema /
requires_restart / updated_at。``account_id`` 用于读取 ACCOUNT 作用域
配置值,未提供时该类字段回退 schema 默认值。
"""
operator = build_operator(current_user, request)
result = await use_cases.plugin_management.getPluginConfig(
plugin_id=plugin_id,
operator=operator,
account_id=account_id,
)
raiseOnControlFailure(result)
return {"success": True, "data": serialize_control_data(result.data)}
@plugin_router.put("/{plugin_id}/config", response_model=dict)
async def update_plugin_config(
plugin_id: str,
payload: UpdatePluginConfigRequest,
request: Request,
use_cases=Depends(get_channel_use_cases),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""更新插件配置PLG-CONFIG-PUT。对应控制面操作 plugin/config_update。
按 ``apply_mode`` 判断:``hot`` 模式立即生效(发布 ConfigChanged 事件);
``restart_required`` 模式仅持久化返回 ``requires_restart=true``。返回
``PluginConfigResult``。config 的 key 必须在插件 manifest 的
``config_schema`` 中声明。``account_id`` 用于 ACCOUNT 作用域配置字段,
未提供时更新该类字段会抛 ``ValidationError``。
"""
operator = build_operator(current_user, request)
cmd = UpdatePluginConfigCmd(
plugin_id=plugin_id,
operator=operator,
config=payload.config,
apply_mode=payload.apply_mode,
account_id=payload.account_id,
)
result = await use_cases.plugin_management.updatePluginConfig(cmd)
raiseOnControlFailure(result)
return {"success": True, "data": serialize_control_data(result.data)}
@plugin_router.post("/{plugin_id}/load", response_model=dict)
async def load_plugin(
plugin_id: str,
request: Request,
use_cases=Depends(get_channel_use_cases),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""加载插件PLG-LIFE-01。对应控制面操作 plugin/load。"""
operator = build_operator(current_user, request)
result = await use_cases.plugin_management.pluginLoad(
plugin_id=plugin_id,
operator=operator,
)
raiseOnControlFailure(result)
return {"success": True, "data": serialize_control_data(result.data)}
@plugin_router.post("/{plugin_id}/start", response_model=dict)
async def start_plugin(
plugin_id: str,
request: Request,
use_cases=Depends(get_channel_use_cases),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""启动插件PLG-LIFE-02。对应控制面操作 plugin/start。"""
operator = build_operator(current_user, request)
result = await use_cases.plugin_management.pluginStart(
plugin_id=plugin_id,
operator=operator,
)
raiseOnControlFailure(result)
return {"success": True, "data": serialize_control_data(result.data)}
@plugin_router.post("/{plugin_id}/pause", response_model=dict)
async def pause_plugin(
plugin_id: str,
request: Request,
use_cases=Depends(get_channel_use_cases),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""暂停插件PLG-LIFE-03。对应控制面操作 plugin/pause。"""
operator = build_operator(current_user, request)
result = await use_cases.plugin_management.pluginPause(
plugin_id=plugin_id,
operator=operator,
)
raiseOnControlFailure(result)
return {"success": True, "data": serialize_control_data(result.data)}
@plugin_router.post("/{plugin_id}/resume", response_model=dict)
async def resume_plugin(
plugin_id: str,
request: Request,
use_cases=Depends(get_channel_use_cases),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""恢复插件PLG-LIFE-04。对应控制面操作 plugin/resume。"""
operator = build_operator(current_user, request)
result = await use_cases.plugin_management.pluginResume(
plugin_id=plugin_id,
operator=operator,
)
raiseOnControlFailure(result)
return {"success": True, "data": serialize_control_data(result.data)}
@plugin_router.post("/{plugin_id}/stop", response_model=dict)
async def stop_plugin(
plugin_id: str,
request: Request,
use_cases=Depends(get_channel_use_cases),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""停止插件PLG-LIFE-05。对应控制面操作 plugin/stop。"""
operator = build_operator(current_user, request)
result = await use_cases.plugin_management.pluginStop(
plugin_id=plugin_id,
operator=operator,
)
raiseOnControlFailure(result)
return {"success": True, "data": serialize_control_data(result.data)}
@plugin_router.post("/{plugin_id}/unload", response_model=dict)
async def unload_plugin(
plugin_id: str,
request: Request,
use_cases=Depends(get_channel_use_cases),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""卸载插件PLG-LIFE-06。对应控制面操作 plugin/unload。"""
operator = build_operator(current_user, request)
result = await use_cases.plugin_management.pluginUnload(
plugin_id=plugin_id,
operator=operator,
)
raiseOnControlFailure(result)
return {"success": True, "data": serialize_control_data(result.data)}
@plugin_router.post("/{plugin_id}/reload", response_model=dict)
async def reload_plugin(
plugin_id: str,
request: Request,
use_cases=Depends(get_channel_use_cases),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""重载插件PLG-LIFE-07。对应控制面操作 plugin/reload。"""
operator = build_operator(current_user, request)
result = await use_cases.plugin_management.pluginReload(
plugin_id=plugin_id,
operator=operator,
)
raiseOnControlFailure(result)
return {"success": True, "data": serialize_control_data(result.data)}