ForcePilot/backend/server/routers/external_systems/tool_router.py
Kris f9f08221fc chore: 整理代码风格与优化细节
本次提交包含多类代码优化:
1. 修复多处单行代码换行格式,统一代码排版
2. 为外部系统模块新增快捷菜单配置
3. 优化前端API请求参数命名一致性
4. 补充媒体下载超限错误码与领域异常类
5. 完善仓储层更新逻辑,支持显式清空字段
6. 优化部分测试用例与工具函数代码结构
7. 为微信插件白名单缓存增加过期时间
2026-07-14 15:13:29 +08:00

644 lines
25 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.

"""外部系统限界上下文 - Tool 子域 Router。
挂载到聚合 router 的 /tools 前缀下,覆盖工具 CRUD / 批量导入 / 克隆 / 版本 /
导入导出 / WSDL 导入导出 / 执行入口 / ``get_enabled_tools`` / ``list_tool_options``
以及 tool_router 扩展端点(版本对比 / 版本回滚 / 参数模板 / 批量执行 /
批量克隆 / 执行趋势 / 依赖资产。Request Schema 与 Input DTO 不共享类,
Router 内显式构造 DTO操作人字段由 current_user.uid 填充。
"""
from __future__ import annotations
import json
from datetime import UTC, datetime
from typing import Any, Literal
from fastapi import APIRouter, Depends, Query
from fastapi.responses import Response
from pydantic import BaseModel, ConfigDict, Field
from yuxi.external_systems.infrastructure.container import UseCases
from yuxi.external_systems.use_cases.dto.tool import (
BatchCloneToolsInput,
BatchExecuteToolItem,
BatchExecuteToolsInput,
BatchImportToolsInput,
CloneToolInput,
CompareToolVersionsInput,
CreateToolInput,
DeleteToolInput,
ExecuteToolInput,
ExportToolsInput,
ExportWsdlInput,
ExternalToolCreateInput,
GetEnabledToolsInput,
GetToolAssetsInput,
GetToolExecutionTrendInput,
GetToolInput,
GetToolParameterTemplateInput,
GetToolVersionsInput,
HealthCheckToolInput,
ImportWsdlInput,
ListToolOptionsInput,
ListToolsInput,
RollbackToolVersionInput,
UpdateToolInput,
)
from yuxi.storage.postgres.models_business import User
from server.routers.external_systems import get_use_cases, parse_and_naive, validate_time_range
from server.utils.auth_middleware import get_admin_user, get_required_user
tool_router = APIRouter(prefix="/tools", tags=["external-systems-tool"])
# slug 校验正则:字母/下划线开头,仅含字母数字下划线连字符,最长 128
_TOOL_SLUG_PATTERN = r"^[a-zA-Z_][a-zA-Z0-9_-]{0,127}$"
# slug 后缀校验正则:仅含字母数字下划线连字符(拼接后须满足 slug 规则)
_SLUG_SUFFIX_PATTERN = r"^[a-zA-Z0-9_-]+$"
# =============================================================================
# === Request Schemas与 Input DTO 不共享类) ===
# =============================================================================
class CreateToolRequest(BaseModel):
"""创建工具请求体。字段对齐 ``CreateToolInput``(不含 created_by"""
model_config = ConfigDict(frozen=True)
slug: str = Field(..., min_length=1, max_length=128, pattern=r"^[a-zA-Z_][a-zA-Z0-9_-]{0,127}$")
name: str = Field(..., min_length=1, max_length=128)
description: str = ""
category: str = Field(default="default", max_length=64)
adapter_type: str = Field(default="http", max_length=32)
auth_type: str = Field(default="none", max_length=32)
adapter_config: dict[str, Any] = Field(default_factory=dict)
auth_config: dict[str, Any] | None = None
timeout: int = Field(default=30, ge=1, le=300)
retry_policy: dict[str, Any] = Field(default_factory=dict)
enabled: bool = True
system_id: int | None = None
source_type: str | None = Field(default=None, max_length=64)
class UpdateToolRequest(BaseModel):
"""更新工具请求体。字段对齐 ``UpdateToolInput``(不含 id 与 updated_by
字段长度约束对齐 ``CreateToolRequest``,仅透传客户端显式设置的字段
(通过 ``exclude_unset=True``),未设置字段保持 ``None`` 以保留部分更新语义。
"""
model_config = ConfigDict(frozen=True)
name: str | None = Field(default=None, min_length=1, max_length=128)
description: str | None = None
category: str | None = Field(default=None, max_length=64)
adapter_type: str | None = Field(default=None, max_length=32)
auth_type: str | None = Field(default=None, max_length=32)
adapter_config: dict[str, Any] | None = None
auth_config: dict[str, Any] | None = None
timeout: int | None = Field(default=None, ge=1, le=300)
retry_policy: dict[str, Any] | None = None
enabled: bool | None = None
system_id: int | None = None
class CloneToolRequest(BaseModel):
"""克隆工具请求体。字段对齐 ``CloneToolInput``(不含 id 与 created_by"""
model_config = ConfigDict(frozen=True)
new_slug: str = Field(..., min_length=1, max_length=128, pattern=r"^[a-zA-Z_][a-zA-Z0-9_-]{0,127}$")
new_name: str | None = Field(default=None, min_length=1, max_length=128)
system_id: int | None = None
class ExternalToolItemRequest(BaseModel):
"""外部工具项请求体。字段对齐 ``ExternalToolCreateInput``。
共享于两个批量工具写入端点:
- 批量导入工具(``BatchImportToolsRequest.tools``
- 持久化集成生成工具(``integration_tool_router.PersistIntegrationToolsRequest.tools``
"""
model_config = ConfigDict(frozen=True)
slug: str = Field(..., min_length=1, max_length=128, pattern=r"^[a-zA-Z_][a-zA-Z0-9_-]{0,127}$")
name: str = Field(..., min_length=1, max_length=128)
description: str = ""
category: str = Field(default="default", max_length=64)
adapter_type: str = Field(default="http", max_length=32)
enabled: bool = True
timeout: int = Field(default=30, ge=1, le=300)
retry_policy: dict[str, Any] = Field(default_factory=dict)
auth_type: str = Field(default="none", max_length=32)
auth_config: dict[str, Any] = Field(default_factory=dict)
adapter_config: dict[str, Any] = Field(default_factory=dict)
source_type: str | None = Field(default=None, max_length=64)
class BatchImportToolsRequest(BaseModel):
"""批量导入工具请求体。字段对齐 ``BatchImportToolsInput``。"""
model_config = ConfigDict(frozen=True)
system_id: int | None = None
tools: list[ExternalToolItemRequest] = Field(..., min_length=1, max_length=100)
class ImportWsdlRequest(BaseModel):
"""导入 WSDL 请求体。字段对齐 ``ImportWsdlInput``(不含 created_by"""
model_config = ConfigDict(frozen=True)
wsdl_url: str | None = None
wsdl_content: str | None = None
system_id: int | None = None
class ExecuteToolRequest(BaseModel):
"""执行工具请求体。字段对齐 ``ExecuteToolInput``(不含 caller由服务端填充"""
model_config = ConfigDict(frozen=True)
tool_slug: str = Field(..., min_length=1, max_length=128, pattern=_TOOL_SLUG_PATTERN)
system_id: int | None = None
env_key: str = Field(default="default", max_length=32)
arguments: dict[str, Any] = Field(default_factory=dict)
caller_id: str | None = None
correlation_id: str | None = None
tags: dict[str, Any] = Field(default_factory=dict)
class HealthCheckToolRequest(BaseModel):
"""触发工具健康检查请求体。字段对齐 ``HealthCheckToolInput``。"""
model_config = ConfigDict(frozen=True)
env_key: str | None = Field(default=None, max_length=32)
class BatchExecuteToolItemRequest(BaseModel):
"""批量执行工具项请求体。字段对齐 ``BatchExecuteToolItem``。"""
model_config = ConfigDict(frozen=True)
tool_slug: str = Field(..., min_length=1, max_length=128, pattern=_TOOL_SLUG_PATTERN)
system_id: int | None = None
env_key: str = Field(default="default", max_length=32)
arguments: dict[str, Any] = Field(default_factory=dict)
caller_id: str | None = None
correlation_id: str | None = None
tags: dict[str, Any] = Field(default_factory=dict)
class BatchExecuteToolsRequest(BaseModel):
"""批量执行工具请求体。字段对齐 ``BatchExecuteToolsInput``(不含 caller由服务端填充"""
model_config = ConfigDict(frozen=True)
executions: list[BatchExecuteToolItemRequest] = Field(..., min_length=1, max_length=100)
class BatchCloneToolsRequest(BaseModel):
"""批量克隆工具请求体。字段对齐 ``BatchCloneToolsInput``(不含 created_by由服务端填充"""
model_config = ConfigDict(frozen=True)
source_tool_ids: list[int] = Field(..., min_length=1, max_length=100)
slug_suffix: str = Field(..., min_length=1, max_length=64, pattern=_SLUG_SUFFIX_PATTERN)
override_config: dict[str, Any] = Field(default_factory=dict)
# =============================================================================
# === 静态路径端点(必须在 /{tool_id} 之前声明) ===
# =============================================================================
@tool_router.get("/enabled", response_model=dict)
async def get_enabled_tools(
system_id: int | None = Query(None),
env_key: str | None = Query(None, max_length=32),
category: str | None = Query(None, max_length=64),
use_cases: UseCases = Depends(get_use_cases),
current_user: User = Depends(get_required_user),
) -> dict[str, Any]:
"""获取已启用工具列表(供 agents 模块使用)。"""
input_dto = GetEnabledToolsInput(system_id=system_id, env_key=env_key, category=category)
output = await use_cases.tool_service.get_enabled_tools(input_dto)
return {"success": True, "data": output.model_dump()}
@tool_router.get("/options", response_model=dict)
async def list_tool_options(
system_id: int | None = Query(None),
category: str | None = Query(None, max_length=64),
enabled_only: bool = Query(True),
use_cases: UseCases = Depends(get_use_cases),
current_user: User = Depends(get_required_user),
) -> dict[str, Any]:
"""列出工具选项(供下拉选择使用)。"""
input_dto = ListToolOptionsInput(
system_id=system_id,
category=category,
enabled_only=enabled_only,
)
output = await use_cases.tool_service.list_tool_options(input_dto)
return {"success": True, "data": output.model_dump()}
@tool_router.get("/export")
async def export_tools(
system_id: int | None = Query(None),
ids: list[int] | None = Query(None),
category: str | None = Query(None, max_length=64),
use_cases: UseCases = Depends(get_use_cases),
current_user: User = Depends(get_required_user),
) -> Response:
"""导出工具列表为 JSON 字节流。"""
input_dto = ExportToolsInput(system_id=system_id, ids=ids, category=category)
output = await use_cases.tool_service.export_tools(input_dto)
payload = json.dumps(
[item.model_dump() for item in output.items],
ensure_ascii=False,
default=str,
).encode("utf-8")
timestamp = datetime.now(UTC).strftime("%Y%m%d%H%M%S")
return Response(
content=payload,
media_type="application/octet-stream",
headers={"Content-Disposition": f"attachment; filename=tools_{timestamp}.json"},
)
@tool_router.get("/export-wsdl", response_model=dict)
async def export_wsdl(
system_id: int = Query(..., description="目标外部系统 ID"),
tool_slug: str | None = Query(None, max_length=128, description="指定工具 slug未指定则取系统下首个 SOAP 工具"),
use_cases: UseCases = Depends(get_use_cases),
current_user: User = Depends(get_required_user),
) -> dict[str, Any]:
"""导出 WSDL 内容。
从指定系统下的 SOAP 工具提取 ``wsdl_content``;若工具仅存储 ``wsdl_url``
则通过 SOAP 适配器加载后返回。``system_id`` 为必填,``tool_slug`` 可选。
"""
input_dto = ExportWsdlInput(system_id=system_id, tool_slug=tool_slug)
output = await use_cases.tool_service.export_wsdl(input_dto)
return {"success": True, "data": output.model_dump()}
@tool_router.post("/import", response_model=dict)
async def batch_import_tools(
body: BatchImportToolsRequest,
use_cases: UseCases = Depends(get_use_cases),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""批量导入工具。created_by 由服务端以 current_user.uid 填充。"""
input_dto = BatchImportToolsInput(
system_id=body.system_id,
tools=[ExternalToolCreateInput(**item.model_dump()) for item in body.tools],
created_by=current_user.uid,
)
output = await use_cases.tool_service.batch_import_tools(input_dto)
return {"success": True, "data": output.model_dump()}
@tool_router.post("/import-from-wsdl", response_model=dict)
async def import_wsdl(
body: ImportWsdlRequest,
use_cases: UseCases = Depends(get_use_cases),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""从 WSDL 导入工具。"""
input_dto = ImportWsdlInput(
wsdl_url=body.wsdl_url,
wsdl_content=body.wsdl_content,
system_id=body.system_id,
created_by=current_user.uid,
)
output = await use_cases.tool_service.import_wsdl(input_dto)
return {"success": True, "data": output.model_dump()}
@tool_router.post("/execute", response_model=dict)
async def execute_external_tool(
body: ExecuteToolRequest,
use_cases: UseCases = Depends(get_use_cases),
current_user: User = Depends(get_required_user),
) -> dict[str, Any]:
"""执行外部工具。caller 由服务端以 current_user.uid 填充。"""
input_dto = ExecuteToolInput(
tool_slug=body.tool_slug,
system_id=body.system_id,
env_key=body.env_key,
arguments=body.arguments,
caller=current_user.uid,
caller_id=body.caller_id,
correlation_id=body.correlation_id,
tags=body.tags,
)
output = await use_cases.tool_service.execute_external_tool(input_dto)
return {"success": True, "data": output.model_dump()}
@tool_router.post("/{tool_id}/health-check", response_model=dict)
async def health_check_external_tool(
tool_id: int,
body: HealthCheckToolRequest | None = None,
use_cases: UseCases = Depends(get_use_cases),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""触发单个外部工具健康检查。
仅对 system_id 不为 None 的系统级工具持久化结果到 ``ext_system_health_checks``
工具级工具仅返回探测结果。
"""
input_dto = HealthCheckToolInput(
tool_id=tool_id,
env_key=body.env_key if body is not None else None,
triggered_by=current_user.uid,
)
output = await use_cases.tool_service.health_check_external_tool(input_dto)
return {"success": True, "data": output.model_dump()}
@tool_router.post("/batch-execute", response_model=dict)
async def batch_execute_tools(
body: BatchExecuteToolsRequest,
use_cases: UseCases = Depends(get_use_cases),
current_user: User = Depends(get_required_user),
) -> dict[str, Any]:
"""批量执行工具。caller 由服务端以 current_user.uid 填充。
单个执行项失败时不中断批量执行:执行器内部失败返回 ``status="failed"``
前置校验失败(如工具不存在)抛出的异常由 service 层 try/except 捕获并记录错误。
"""
input_dto = BatchExecuteToolsInput(
executions=[BatchExecuteToolItem(**item.model_dump()) for item in body.executions],
caller=current_user.uid,
)
output = await use_cases.tool_service.batch_execute_tools(input_dto)
return {"success": True, "data": output.model_dump()}
@tool_router.post("/batch-clone", response_model=dict)
async def batch_clone_tools(
body: BatchCloneToolsRequest,
use_cases: UseCases = Depends(get_use_cases),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""批量克隆工具。created_by 由服务端以 current_user.uid 填充。
单个事务内执行整个循环,单个失败时记录错误并继续
(参考 ``batch_import_tools`` 模式)。
"""
input_dto = BatchCloneToolsInput(
source_tool_ids=body.source_tool_ids,
slug_suffix=body.slug_suffix,
override_config=body.override_config,
created_by=current_user.uid,
)
output = await use_cases.tool_service.batch_clone_tools(input_dto)
return {"success": True, "data": output.model_dump()}
# =============================================================================
# === 根路径端点 ===
# =============================================================================
@tool_router.get("", response_model=dict)
async def list_tools(
limit: int = Query(20, ge=1, le=500),
offset: int = Query(0, ge=0),
system_id: int | None = Query(None),
category: str | None = Query(None, max_length=64),
adapter_type: str | None = Query(None, max_length=32),
enabled: bool | None = Query(None),
keyword: str | None = Query(None, max_length=128),
sort_by: str | None = Query(None, description="排序字段created_at/updated_at/name/slug/category/adapter_type"),
sort_order: str = Query("desc", pattern="^(asc|desc)$", description="排序方向"),
use_cases: UseCases = Depends(get_use_cases),
current_user: User = Depends(get_required_user),
) -> dict[str, Any]:
"""分页列出外部工具。``sort_by`` / ``sort_order`` 控制排序,由仓储白名单校验防注入。"""
input_dto = ListToolsInput(
offset=offset,
limit=limit,
system_id=system_id,
category=category,
adapter_type=adapter_type,
enabled=enabled,
keyword=keyword,
sort_by=sort_by,
sort_order=sort_order,
)
output = await use_cases.tool_service.list_tools(input_dto)
return {"success": True, "data": output.model_dump()}
@tool_router.post("", response_model=dict)
async def create_tool(
body: CreateToolRequest,
use_cases: UseCases = Depends(get_use_cases),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""创建外部工具。"""
input_dto = CreateToolInput(**body.model_dump(), created_by=current_user.uid)
output = await use_cases.tool_service.create_tool(input_dto)
return {"success": True, "data": output.model_dump()}
# =============================================================================
# === 动态路径端点(/{tool_id} 与 /{tool_id}/versions ===
# =============================================================================
@tool_router.get("/{tool_id}", response_model=dict)
async def get_tool(
tool_id: int,
system_id: int | None = Query(None),
use_cases: UseCases = Depends(get_use_cases),
current_user: User = Depends(get_required_user),
) -> dict[str, Any]:
"""获取工具详情。"""
input_dto = GetToolInput(id=tool_id, system_id=system_id)
output = await use_cases.tool_service.get_tool(input_dto)
return {"success": True, "data": output.model_dump()}
@tool_router.put("/{tool_id}", response_model=dict)
async def update_tool(
tool_id: int,
body: UpdateToolRequest,
use_cases: UseCases = Depends(get_use_cases),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""更新外部工具。仅透传客户端显式设置的字段,保留部分更新语义。"""
input_dto = UpdateToolInput(
**body.model_dump(exclude_unset=True),
id=tool_id,
updated_by=current_user.uid,
)
output = await use_cases.tool_service.update_tool(input_dto)
return {"success": True, "data": output.model_dump()}
@tool_router.delete("/{tool_id}", response_model=dict)
async def delete_tool(
tool_id: int,
system_id: int | None = Query(None),
use_cases: UseCases = Depends(get_use_cases),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""删除外部工具。"""
input_dto = DeleteToolInput(id=tool_id, system_id=system_id, deleted_by=current_user.uid)
output = await use_cases.tool_service.delete_tool(input_dto)
return {"success": True, "data": output.model_dump()}
@tool_router.post("/{tool_id}/clone", response_model=dict)
async def clone_tool(
tool_id: int,
body: CloneToolRequest,
use_cases: UseCases = Depends(get_use_cases),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""克隆外部工具。"""
input_dto = CloneToolInput(
id=tool_id,
new_slug=body.new_slug,
new_name=body.new_name,
system_id=body.system_id,
created_by=current_user.uid,
)
output = await use_cases.tool_service.clone_tool(input_dto)
return {"success": True, "data": output.model_dump()}
@tool_router.get("/{tool_id}/versions", response_model=dict)
async def get_tool_versions(
tool_id: int,
limit: int = Query(20, ge=1, le=500),
offset: int = Query(0, ge=0),
use_cases: UseCases = Depends(get_use_cases),
current_user: User = Depends(get_required_user),
) -> dict[str, Any]:
"""获取工具版本列表。路径参数为 tool_idservice 层内部解析为 slug 查询版本。"""
input_dto = GetToolVersionsInput(
tool_id=tool_id,
offset=offset,
limit=limit,
)
output = await use_cases.tool_service.get_tool_versions(input_dto)
return {"success": True, "data": output.model_dump()}
# -----------------------------------------------------------------------------
# === tool_router 扩展端点(动态路径) ===
# -----------------------------------------------------------------------------
@tool_router.get("/{tool_id}/versions/{version_id_1}/compare/{version_id_2}", response_model=dict)
async def compare_tool_versions(
tool_id: int,
version_id_1: str,
version_id_2: str,
use_cases: UseCases = Depends(get_use_cases),
current_user: User = Depends(get_required_user),
) -> dict[str, Any]:
"""对比两个工具版本的参数差异。
snapshot 以明文存储service 层使用 ``mask_all_string_values``
脱敏后对比结构差异,不泄露敏感信息。
"""
input_dto = CompareToolVersionsInput(
tool_id=tool_id,
version_id_1=version_id_1,
version_id_2=version_id_2,
)
output = await use_cases.tool_service.compare_tool_versions(input_dto)
return {"success": True, "data": output.model_dump()}
@tool_router.post("/{tool_id}/versions/{version_id}/rollback", response_model=dict)
async def rollback_tool_version(
tool_id: int,
version_id: str,
use_cases: UseCases = Depends(get_use_cases),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""将工具回滚到指定历史版本。updated_by 由服务端以 current_user.uid 填充。
回滚前创建当前配置的新版本快照,记录回滚前的状态。
snapshot 以明文存储service 层通过 persistence 层的
``get_decrypted_snapshot`` 方法获取明文后传给 ``ToolRepository.update``。
"""
input_dto = RollbackToolVersionInput(
tool_id=tool_id,
version_id=version_id,
updated_by=current_user.uid,
)
output = await use_cases.tool_service.rollback_tool_version(input_dto)
return {"success": True, "data": output.model_dump()}
@tool_router.get("/{tool_id}/parameter-template", response_model=dict)
async def get_tool_parameter_template(
tool_id: int,
use_cases: UseCases = Depends(get_use_cases),
current_user: User = Depends(get_required_user),
) -> dict[str, Any]:
"""获取工具参数模板。
基于 ``AdapterMetadata.to_json_schema()`` + ``example_config`` 生成,
供前端动态表单渲染。
"""
input_dto = GetToolParameterTemplateInput(tool_id=tool_id)
output = await use_cases.tool_service.get_tool_parameter_template(input_dto)
return {"success": True, "data": output.model_dump()}
@tool_router.get("/{tool_id}/execution-trend", response_model=dict)
async def get_tool_execution_trend(
tool_id: int,
start_time: str | None = Query(None, description="开始时间ISO 8601"),
end_time: str | None = Query(None, description="结束时间ISO 8601"),
interval: Literal["hour", "day"] = Query("day", description="时间桶粒度hour/day"),
env_key: str | None = Query(None, max_length=32, description="环境 key 过滤"),
use_cases: UseCases = Depends(get_use_cases),
current_user: User = Depends(get_required_user),
) -> dict[str, Any]:
"""查询工具执行历史趋势(按天/小时聚合)。"""
start_dt = parse_and_naive("start_time", start_time)
end_dt = parse_and_naive("end_time", end_time)
validate_time_range(start_dt, end_dt)
input_dto = GetToolExecutionTrendInput(
tool_id=tool_id,
start_time=start_dt,
end_time=end_dt,
interval=interval,
env_key=env_key,
)
output = await use_cases.tool_service.get_tool_execution_trend(input_dto)
return {"success": True, "data": output.model_dump()}
@tool_router.get("/{tool_id}/assets", response_model=dict)
async def get_tool_assets(
tool_id: int,
use_cases: UseCases = Depends(get_use_cases),
current_user: User = Depends(get_required_user),
) -> dict[str, Any]:
"""查询工具依赖的适配器资产。
工具与资产的关联关系通过 ``adapter_config`` 中的引用建立,
service 层按 checksum → asset_name → adapter_type 顺序解析。
"""
input_dto = GetToolAssetsInput(tool_id=tool_id)
output = await use_cases.tool_service.get_tool_assets(input_dto)
return {"success": True, "data": output.model_dump()}