Files
taiji-AI-PAD/services/mcp-server/app/routes/external_tools.py
T
2026-03-12 02:32:23 +00:00

1246 lines
38 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.
"""
外部数据工具 API 接口
用户可以创建外部数据工具,工具配置发送给 Agent Manager 生成 Pydantic 工具文件,
MCP-Server 只存储工具基本信息和关联标识(tool_ref_id)。
v2 简化设计:
- 用户只需提供 name, url, auth, example
- 系统自动推断:HTTP 方法、参数结构、认证头配置
"""
import structlog
from typing import Optional, Dict, Any, Tuple
from uuid import UUID as PyUUID
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, and_
import json
from database import get_db
from models import ExternalDataTool, ExternalToolkit, User
from app.schemas import (
SuccessResponse,
CreateExternalToolRequest,
UpdateExternalToolRequest,
TestExternalToolRequest,
ExternalToolInfo,
ExternalToolDetail,
CreateToolkitRequest,
UpdateToolkitRequest,
ToolkitInfo,
ToolkitDetail,
ExternalToolAuthConfig,
)
from app.auth import require_auth
from app.agent_manager_client import get_agent_manager_client
logger = structlog.get_logger(__name__)
router = APIRouter(prefix="/api/user/external-tools", tags=["External Data Tools"])
# ============= 辅助函数:自动推断配置 =============
def infer_json_schema_from_example(example: Dict[str, Any]) -> Dict[str, Any]:
"""
从请求示例推断 JSON Schema
示例:{"city": "北京", "count": 10}
=> {
"type": "object",
"properties": {
"city": {"type": "string", "description": "city 参数"},
"count": {"type": "integer", "description": "count 参数"}
},
"required": ["city", "count"]
}
"""
if not example:
return None
def infer_type(value: Any) -> Dict[str, Any]:
"""推断单个值的类型"""
if value is None:
return {"type": "string", "nullable": True}
elif isinstance(value, bool):
return {"type": "boolean"}
elif isinstance(value, int):
return {"type": "integer"}
elif isinstance(value, float):
return {"type": "number"}
elif isinstance(value, str):
return {"type": "string"}
elif isinstance(value, list):
if len(value) > 0:
item_schema = infer_type(value[0])
return {"type": "array", "items": item_schema}
return {"type": "array", "items": {"type": "string"}}
elif isinstance(value, dict):
return infer_json_schema_from_example(value)
else:
return {"type": "string"}
properties = {}
required = []
for key, value in example.items():
prop_schema = infer_type(value)
prop_schema["description"] = f"{key} 参数"
properties[key] = prop_schema
# 所有在 example 中出现的字段都标记为必填
required.append(key)
return {
"type": "object",
"properties": properties,
"required": required
}
def build_full_auth_config(auth: Optional[ExternalToolAuthConfig]) -> Optional[Dict[str, Any]]:
"""
从简化的认证配置构建完整的 Agent Manager 认证配置
自动推断规则:
- api_key: header 位置,名称尝试 X-API-Key, Authorization, api_key
- bearer: header 位置,名称 Authorization,值前缀 Bearer
- basic: header 位置,名称 Authorization,Base64 编码
"""
if not auth or auth.type == "none":
return None
secret = auth.get_secret_value()
if auth.type == "api_key":
return {
"type": "api_key",
"key": secret,
"in": auth.in_location or "header", # 默认 header
"name": auth.name or "X-API-Key" # 默认 X-API-Key
}
elif auth.type == "bearer":
return {
"type": "bearer",
"token": secret
}
elif auth.type == "basic":
return {
"type": "basic",
"username": auth.username,
"password": auth.password
}
return None
def resolve_tool_config(req: CreateExternalToolRequest) -> Tuple[str, Dict[str, str], Dict[str, Any], Dict[str, Any], int, Dict[str, Any]]:
"""
从简化请求解析完整的工具配置
返回: (method, headers, request_params, request_body, timeout, retry)
"""
# 1. 解析 HTTP 方法(优先级:advanced > 兼容字段 > 默认 POST)
method = "POST"
if req.advanced and req.advanced.method:
method = req.advanced.method
elif req.method:
method = req.method
# 2. 解析 headers(优先级:advanced > 兼容字段 > 默认)
headers = {"Content-Type": "application/json"}
if req.advanced and req.advanced.headers:
headers.update(req.advanced.headers)
elif req.headers:
headers.update(req.headers)
# 3. 解析请求参数/体
request_params = None
request_body = None
# 优先使用兼容字段(如果用户提供了 JSON Schema)
if req.request_params:
request_params = req.request_params
if req.request_body:
request_body = req.request_body
# 如果用户提供了 example,从中推断
if req.example and not request_params and not request_body:
inferred_schema = infer_json_schema_from_example(req.example)
if method in ("GET", "DELETE"):
request_params = inferred_schema
else:
request_body = inferred_schema
# 4. 解析超时(优先级:advanced > 兼容字段 > 默认 30)
timeout = 30
if req.advanced and req.advanced.timeout:
timeout = req.advanced.timeout
elif req.timeout:
timeout = req.timeout
# 5. 解析重试配置
retry = None
if req.advanced and req.advanced.retry:
retry = req.advanced.retry.dict()
elif req.retry:
retry = req.retry.dict()
return method, headers, request_params, request_body, timeout, retry
# ============= 创建外部数据工具 =============
@router.post("", response_model=SuccessResponse)
async def create_external_tool(
req: CreateExternalToolRequest,
principal: dict = Depends(require_auth),
db: AsyncSession = Depends(get_db)
):
"""
创建外部数据工具(简化版 v2)
用户只需提供最少信息:
- name: 工具名称
- url: API 地址
- auth: 认证配置(type + secret)
- example: 请求参数示例(可选但推荐)
系统自动推断:
- HTTP 方法(默认 POST)
- 请求参数结构(从 example 推断 JSON Schema)
- 认证头名称和位置
"""
user_id = principal.get("user_id")
tenant_id = principal.get("tenant_id")
# 从 JWT claims 中获取 channelId(驼峰命名)
channel_id = principal.get("claims", {}).get("channelId")
# 解析完整配置(从简化输入推断)
method, headers, request_params, request_body, timeout, retry = resolve_tool_config(req)
auth_config = build_full_auth_config(req.auth)
logger.info(
"creating_external_tool",
user_id=user_id,
name=req.name,
url=req.url,
method=method,
has_example=req.example is not None
)
# 检查工具名称是否已存在
existing = await db.execute(
select(ExternalDataTool).where(
and_(
ExternalDataTool.owner_id == PyUUID(user_id),
ExternalDataTool.name == req.name
)
)
)
if existing.scalar_one_or_none():
raise HTTPException(
status_code=409,
detail={"error": "tool_name_exists", "message": f"工具名称 '{req.name}' 已存在"}
)
# 调用 Agent Manager 生成工具
client = get_agent_manager_client()
try:
am_result = await client.generate_external_tool(
name=req.name,
description=req.description or "",
url=req.url,
method=method,
user_id=user_id,
tenant_id=tenant_id,
headers=headers,
auth=auth_config,
request_params=request_params,
request_body=request_body,
response_mapping=req.response_mapping, # 兼容字段
timeout=timeout,
retry=retry
)
# Agent Manager 返回格式: {"success": true, "data": {"tool_ref_id": "xxx", ...}}
am_data = am_result.get("data", {})
tool_ref_id = am_data.get("tool_ref_id")
am_status = "active" if am_result.get("success") else "error"
error_message = am_result.get("message") if not am_result.get("success") else None
except Exception as e:
logger.error("agent_manager_generate_failed", error=str(e), name=req.name)
# Agent Manager 调用失败,仍然创建工具记录,状态为 error
tool_ref_id = None
am_status = "error"
error_message = f"Agent Manager 生成工具失败: {str(e)}"
# 创建工具记录(保存完整配置,用于创建自定义 Agent)
tool = ExternalDataTool(
name=req.name,
description=req.description,
url=req.url,
method=method,
auth_type=req.auth.type if req.auth else "none",
# 保存完整配置
headers=headers,
auth_config=auth_config,
request_params=request_params,
request_body=request_body,
response_mapping=req.response_mapping,
timeout=timeout,
retry_config=retry,
# Agent Manager 关联
tool_ref_id=tool_ref_id,
status=am_status,
error_message=error_message,
owner_id=PyUUID(user_id),
tenant_id=PyUUID(tenant_id) if tenant_id else None,
channel_id=PyUUID(channel_id) if channel_id else None,
is_active=True,
usage_count=0
)
db.add(tool)
await db.commit()
await db.refresh(tool)
logger.info(
"external_tool_created",
tool_id=str(tool.id),
tool_ref_id=tool_ref_id,
status=am_status
)
return SuccessResponse(
data={
"id": str(tool.id),
"name": tool.name,
"tool_ref_id": tool_ref_id,
"status": am_status,
"created_at": tool.created_at.isoformat() if tool.created_at else None
},
message="工具创建成功,可以开始使用了" if am_status == "active" else f"工具创建完成,但生成失败: {error_message}"
)
@router.post("/upload", response_model=SuccessResponse)
async def upload_external_tool(
file: UploadFile = File(...),
principal: dict = Depends(require_auth),
db: AsyncSession = Depends(get_db)
):
"""
上传 JSON 文件创建外部数据工具
JSON 文件格式参考 CreateExternalToolRequest
"""
user_id = principal.get("user_id")
# 验证文件类型
if not file.filename.endswith(".json"):
raise HTTPException(
status_code=400,
detail={"error": "invalid_file_type", "message": "只支持 .json 文件"}
)
# 读取并解析 JSON
try:
content = await file.read()
config = json.loads(content.decode("utf-8"))
except json.JSONDecodeError as e:
raise HTTPException(
status_code=400,
detail={"error": "invalid_json", "message": f"JSON 解析失败: {str(e)}"}
)
except Exception as e:
raise HTTPException(
status_code=400,
detail={"error": "file_read_error", "message": f"文件读取失败: {str(e)}"}
)
# 验证必填字段
required_fields = ["name", "url"]
missing_fields = [f for f in required_fields if f not in config]
if missing_fields:
raise HTTPException(
status_code=400,
detail={"error": "missing_fields", "message": f"缺少必填字段: {', '.join(missing_fields)}"}
)
# 构造请求对象
try:
req = CreateExternalToolRequest(**config)
except Exception as e:
raise HTTPException(
status_code=400,
detail={"error": "invalid_config", "message": f"配置格式无效: {str(e)}"}
)
logger.info("uploading_external_tool", user_id=user_id, filename=file.filename)
# 调用创建接口
return await create_external_tool(req, principal, db)
# ============= 获取工具列表 =============
@router.get("", response_model=SuccessResponse)
async def list_external_tools(
status: Optional[str] = Query(None, description="过滤状态: active/pending/error"),
page: int = Query(1, ge=1, description="页码"),
page_size: int = Query(20, ge=1, le=100, description="每页数量"),
principal: dict = Depends(require_auth),
db: AsyncSession = Depends(get_db)
):
"""
获取用户的外部数据工具列表
"""
user_id = principal.get("user_id")
# 构建查询
query = select(ExternalDataTool).where(
ExternalDataTool.owner_id == PyUUID(user_id)
)
if status:
query = query.where(ExternalDataTool.status == status)
# 获取总数
count_query = select(ExternalDataTool).where(
ExternalDataTool.owner_id == PyUUID(user_id)
)
if status:
count_query = count_query.where(ExternalDataTool.status == status)
result = await db.execute(count_query)
total = len(result.scalars().all())
# 分页查询
query = query.order_by(ExternalDataTool.created_at.desc())
query = query.offset((page - 1) * page_size).limit(page_size)
result = await db.execute(query)
tools = result.scalars().all()
# 构建响应
tools_data = []
for tool in tools:
tools_data.append({
"id": str(tool.id),
"name": tool.name,
"description": tool.description,
"url": tool.url,
"method": tool.method,
"auth_type": tool.auth_type,
"tool_ref_id": tool.tool_ref_id, # Agent Manager 返回的工具标识
"status": tool.status,
"usage_count": tool.usage_count,
"created_at": tool.created_at.isoformat() if tool.created_at else None
})
return SuccessResponse(
data={
"tools": tools_data,
"total": total,
"page": page,
"page_size": page_size
}
)
# ============= 获取工具详情 =============
@router.get("/{tool_id}", response_model=SuccessResponse)
async def get_external_tool(
tool_id: str,
principal: dict = Depends(require_auth),
db: AsyncSession = Depends(get_db)
):
"""
获取外部数据工具详情
"""
user_id = principal.get("user_id")
try:
tool_uuid = PyUUID(tool_id)
except ValueError:
raise HTTPException(
status_code=400,
detail={"error": "invalid_tool_id", "message": "无效的工具 ID 格式"}
)
result = await db.execute(
select(ExternalDataTool).where(
and_(
ExternalDataTool.id == tool_uuid,
ExternalDataTool.owner_id == PyUUID(user_id)
)
)
)
tool = result.scalar_one_or_none()
if not tool:
raise HTTPException(
status_code=404,
detail={"error": "tool_not_found", "message": "工具不存在"}
)
return SuccessResponse(
data={
"id": str(tool.id),
"name": tool.name,
"description": tool.description,
"url": tool.url,
"method": tool.method,
"auth_type": tool.auth_type,
"tool_ref_id": tool.tool_ref_id,
"status": tool.status,
"error_message": tool.error_message,
"usage_count": tool.usage_count,
"created_at": tool.created_at.isoformat() if tool.created_at else None,
"updated_at": tool.updated_at.isoformat() if tool.updated_at else None
}
)
# ============= 更新工具 =============
@router.put("/{tool_id}", response_model=SuccessResponse)
async def update_external_tool(
tool_id: str,
req: UpdateExternalToolRequest,
principal: dict = Depends(require_auth),
db: AsyncSession = Depends(get_db)
):
"""
更新外部数据工具配置
需要传递完整配置,会重新调用 Agent Manager 生成工具文件。
"""
user_id = principal.get("user_id")
tenant_id = principal.get("tenant_id")
try:
tool_uuid = PyUUID(tool_id)
except ValueError:
raise HTTPException(
status_code=400,
detail={"error": "invalid_tool_id", "message": "无效的工具 ID 格式"}
)
result = await db.execute(
select(ExternalDataTool).where(
and_(
ExternalDataTool.id == tool_uuid,
ExternalDataTool.owner_id == PyUUID(user_id)
)
)
)
tool = result.scalar_one_or_none()
if not tool:
raise HTTPException(
status_code=404,
detail={"error": "tool_not_found", "message": "工具不存在"}
)
# 解析更新配置(支持部分更新)
# 使用请求中的值,如果没有则保持原值
new_name = req.name if req.name else tool.name
new_description = req.description if req.description is not None else tool.description
new_url = req.url if req.url else tool.url
# 解析配置(使用新的辅助函数,兼容旧格式)
# 创建一个临时的 CreateExternalToolRequest 来复用 resolve_tool_config
from app.schemas import CreateExternalToolRequest as TempReq
temp_req = TempReq(
name=new_name,
url=new_url,
auth=req.auth,
example=req.example,
advanced=req.advanced,
method=req.method,
headers=req.headers,
request_params=req.request_params,
request_body=req.request_body,
response_mapping=req.response_mapping,
timeout=req.timeout,
retry=req.retry
)
method, headers, request_params, request_body, timeout, retry = resolve_tool_config(temp_req)
auth_config = build_full_auth_config(req.auth)
# 如果没有提供新的 method,使用原来的
if not req.method and not (req.advanced and req.advanced.method):
method = tool.method
logger.info(
"updating_external_tool",
tool_id=tool_id,
user_id=user_id,
name=new_name,
has_example=req.example is not None
)
# 调用 Agent Manager 更新工具
client = get_agent_manager_client()
try:
if tool.tool_ref_id:
# 已有 tool_ref_id,调用更新接口
am_result = await client.update_external_tool(
tool_ref_id=tool.tool_ref_id,
name=new_name,
description=new_description or "",
url=new_url,
method=method,
user_id=user_id,
tenant_id=tenant_id,
headers=headers,
auth=auth_config,
request_params=request_params,
request_body=request_body,
response_mapping=req.response_mapping,
timeout=timeout,
retry=retry
)
else:
# 没有 tool_ref_id,调用生成接口
am_result = await client.generate_external_tool(
name=new_name,
description=new_description or "",
url=new_url,
method=method,
user_id=user_id,
tenant_id=tenant_id,
headers=headers,
auth=auth_config,
request_params=request_params,
request_body=request_body,
response_mapping=req.response_mapping,
timeout=timeout,
retry=retry
)
# Agent Manager 返回格式: {"success": true, "data": {"tool_ref_id": "xxx", ...}}
am_data = am_result.get("data", {})
new_tool_ref_id = am_data.get("tool_ref_id")
am_status = "active" if am_result.get("success") else "error"
error_message = am_result.get("message") if not am_result.get("success") else None
except Exception as e:
logger.error("agent_manager_update_failed", error=str(e), tool_id=tool_id)
new_tool_ref_id = tool.tool_ref_id # 保持原有的
am_status = "error"
error_message = f"Agent Manager 更新工具失败: {str(e)}"
# 更新数据库记录
tool.name = new_name
tool.description = new_description
tool.url = new_url
tool.method = method
tool.auth_type = req.auth.type if req.auth else tool.auth_type
tool.tool_ref_id = new_tool_ref_id
tool.status = am_status
tool.error_message = error_message
await db.commit()
await db.refresh(tool)
logger.info(
"external_tool_updated",
tool_id=str(tool.id),
tool_ref_id=new_tool_ref_id,
status=am_status
)
return SuccessResponse(
data={
"id": str(tool.id),
"name": tool.name,
"tool_ref_id": new_tool_ref_id,
"status": am_status,
"updated_at": tool.updated_at.isoformat() if tool.updated_at else None
},
message="外部数据工具更新成功" if am_status == "active" else f"工具更新完成,但生成失败: {error_message}"
)
# ============= 删除工具 =============
@router.delete("/{tool_id}", response_model=SuccessResponse)
async def delete_external_tool(
tool_id: str,
principal: dict = Depends(require_auth),
db: AsyncSession = Depends(get_db)
):
"""
删除外部数据工具
会同时通知 Agent Manager 删除对应的工具文件。
"""
user_id = principal.get("user_id")
try:
tool_uuid = PyUUID(tool_id)
except ValueError:
raise HTTPException(
status_code=400,
detail={"error": "invalid_tool_id", "message": "无效的工具 ID 格式"}
)
result = await db.execute(
select(ExternalDataTool).where(
and_(
ExternalDataTool.id == tool_uuid,
ExternalDataTool.owner_id == PyUUID(user_id)
)
)
)
tool = result.scalar_one_or_none()
if not tool:
raise HTTPException(
status_code=404,
detail={"error": "tool_not_found", "message": "工具不存在"}
)
logger.info(
"deleting_external_tool",
tool_id=tool_id,
tool_ref_id=tool.tool_ref_id,
user_id=user_id
)
# 通知 Agent Manager 删除工具文件
if tool.tool_ref_id:
client = get_agent_manager_client()
try:
await client.delete_external_tool(tool.tool_ref_id)
except Exception as e:
logger.warning(
"agent_manager_delete_failed",
error=str(e),
tool_ref_id=tool.tool_ref_id
)
# 即使 AM 删除失败,也继续删除本地记录
# 删除数据库记录
await db.delete(tool)
await db.commit()
logger.info("external_tool_deleted", tool_id=tool_id)
return SuccessResponse(
data={"id": tool_id},
message="外部数据工具删除成功"
)
# ============= 测试工具连接 =============
@router.post("/{tool_id}/test", response_model=SuccessResponse)
async def test_external_tool(
tool_id: str,
req: TestExternalToolRequest = None,
principal: dict = Depends(require_auth),
db: AsyncSession = Depends(get_db)
):
"""
测试外部数据工具连接
调用 Agent Manager 的测试接口。
"""
user_id = principal.get("user_id")
try:
tool_uuid = PyUUID(tool_id)
except ValueError:
raise HTTPException(
status_code=400,
detail={"error": "invalid_tool_id", "message": "无效的工具 ID 格式"}
)
result = await db.execute(
select(ExternalDataTool).where(
and_(
ExternalDataTool.id == tool_uuid,
ExternalDataTool.owner_id == PyUUID(user_id)
)
)
)
tool = result.scalar_one_or_none()
if not tool:
raise HTTPException(
status_code=404,
detail={"error": "tool_not_found", "message": "工具不存在"}
)
if not tool.tool_ref_id:
raise HTTPException(
status_code=400,
detail={"error": "tool_not_ready", "message": "工具尚未生成成功,无法测试"}
)
logger.info(
"testing_external_tool",
tool_id=tool_id,
tool_ref_id=tool.tool_ref_id
)
# 调用 Agent Manager 测试接口
client = get_agent_manager_client()
try:
test_params = req.test_params if req else None
am_result = await client.test_external_tool(tool.tool_ref_id, test_params)
return SuccessResponse(
data={
"connected": am_result.get("connected", False),
"response_time_ms": am_result.get("response_time_ms"),
"status_code": am_result.get("status_code"),
"sample_response": am_result.get("sample_response")
},
message="工具连接测试成功" if am_result.get("connected") else "工具连接测试失败"
)
except Exception as e:
logger.error("test_external_tool_failed", error=str(e), tool_id=tool_id)
return SuccessResponse(
data={
"connected": False,
"error_message": str(e)
},
message=f"工具连接测试失败: {str(e)}"
)
# 注意:创建自定义 Agent 请使用 /api/user/custom-agents 接口
# 在请求体中通过 externalTools 字段传入外部数据工具 ID 列表
# 或通过 toolkit 字段传入工具集 ID
# ============= 工具集管理 =============
toolkit_router = APIRouter(prefix="/api/user/toolkits", tags=["External Toolkits"])
@toolkit_router.post("", response_model=SuccessResponse)
async def create_toolkit(
req: CreateToolkitRequest,
principal: dict = Depends(require_auth),
db: AsyncSession = Depends(get_db)
):
"""
创建工具集
将多个外部数据工具组合成一个工具集,最多 8 个工具。
"""
user_id = principal.get("user_id")
tenant_id = principal.get("tenant_id")
# 从 JWT claims 中获取 channelId(驼峰命名)
channel_id = principal.get("claims", {}).get("channelId")
logger.info(
"creating_toolkit",
user_id=user_id,
name=req.name,
tool_count=len(req.tool_ids)
)
# 验证工具数量
if len(req.tool_ids) > 8:
raise HTTPException(
status_code=400,
detail={"error": "too_many_tools", "message": "工具集最多包含 8 个工具"}
)
# 检查工具集名称是否已存在
existing = await db.execute(
select(ExternalToolkit).where(
and_(
ExternalToolkit.owner_id == PyUUID(user_id),
ExternalToolkit.name == req.name
)
)
)
if existing.scalar_one_or_none():
raise HTTPException(
status_code=409,
detail={"error": "toolkit_name_exists", "message": f"工具集名称 '{req.name}' 已存在"}
)
# 验证所有工具是否存在且属于用户
validated_tool_ids = []
for tool_id in req.tool_ids:
try:
tool_uuid = PyUUID(tool_id)
except ValueError:
raise HTTPException(
status_code=400,
detail={"error": "invalid_tool_id", "message": f"无效的工具 ID: {tool_id}"}
)
result = await db.execute(
select(ExternalDataTool).where(
and_(
ExternalDataTool.id == tool_uuid,
ExternalDataTool.owner_id == PyUUID(user_id)
)
)
)
tool = result.scalar_one_or_none()
if not tool:
raise HTTPException(
status_code=404,
detail={"error": "tool_not_found", "message": f"工具不存在或无权限: {tool_id}"}
)
validated_tool_ids.append(str(tool.id))
# 创建工具集
toolkit = ExternalToolkit(
name=req.name,
description=req.description,
tool_ids=validated_tool_ids,
owner_id=PyUUID(user_id),
tenant_id=PyUUID(tenant_id) if tenant_id else None,
channel_id=PyUUID(channel_id) if channel_id else None,
is_active=True,
usage_count=0
)
db.add(toolkit)
await db.commit()
await db.refresh(toolkit)
logger.info(
"toolkit_created",
toolkit_id=str(toolkit.id),
name=toolkit.name,
tool_count=len(validated_tool_ids)
)
return SuccessResponse(
data={
"id": str(toolkit.id),
"name": toolkit.name,
"description": toolkit.description,
"tool_count": len(validated_tool_ids),
"created_at": toolkit.created_at.isoformat() if toolkit.created_at else None
},
message="工具集创建成功"
)
@toolkit_router.get("", response_model=SuccessResponse)
async def list_toolkits(
page: int = Query(1, ge=1, description="页码"),
page_size: int = Query(20, ge=1, le=100, description="每页数量"),
principal: dict = Depends(require_auth),
db: AsyncSession = Depends(get_db)
):
"""
获取用户的工具集列表
"""
user_id = principal.get("user_id")
# 获取总数
count_result = await db.execute(
select(ExternalToolkit).where(
ExternalToolkit.owner_id == PyUUID(user_id)
)
)
total = len(count_result.scalars().all())
# 分页查询
query = select(ExternalToolkit).where(
ExternalToolkit.owner_id == PyUUID(user_id)
).order_by(ExternalToolkit.created_at.desc())
query = query.offset((page - 1) * page_size).limit(page_size)
result = await db.execute(query)
toolkits = result.scalars().all()
# 构建响应
toolkits_data = []
for tk in toolkits:
tool_ids = tk.tool_ids if tk.tool_ids else []
toolkits_data.append({
"id": str(tk.id),
"name": tk.name,
"description": tk.description,
"tool_count": len(tool_ids),
"usage_count": tk.usage_count or 0,
"created_at": tk.created_at.isoformat() if tk.created_at else None
})
return SuccessResponse(
data={
"toolkits": toolkits_data,
"total": total,
"page": page,
"page_size": page_size
}
)
@toolkit_router.get("/{toolkit_id}", response_model=SuccessResponse)
async def get_toolkit(
toolkit_id: str,
principal: dict = Depends(require_auth),
db: AsyncSession = Depends(get_db)
):
"""
获取工具集详情
包含工具集的基本信息和包含的工具详情。
"""
user_id = principal.get("user_id")
try:
toolkit_uuid = PyUUID(toolkit_id)
except ValueError:
raise HTTPException(
status_code=400,
detail={"error": "invalid_toolkit_id", "message": "无效的工具集 ID 格式"}
)
result = await db.execute(
select(ExternalToolkit).where(
and_(
ExternalToolkit.id == toolkit_uuid,
ExternalToolkit.owner_id == PyUUID(user_id)
)
)
)
toolkit = result.scalar_one_or_none()
if not toolkit:
raise HTTPException(
status_code=404,
detail={"error": "toolkit_not_found", "message": "工具集不存在"}
)
# 获取包含的工具详情
tool_ids = toolkit.tool_ids if toolkit.tool_ids else []
tools_data = []
for tid in tool_ids:
try:
tool_result = await db.execute(
select(ExternalDataTool).where(ExternalDataTool.id == PyUUID(tid))
)
tool = tool_result.scalar_one_or_none()
if tool:
tools_data.append({
"id": str(tool.id),
"name": tool.name,
"description": tool.description,
"url": tool.url,
"method": tool.method,
"auth_type": tool.auth_type,
"status": tool.status,
"usage_count": tool.usage_count or 0
})
except Exception:
pass # 跳过无效的工具 ID
return SuccessResponse(
data={
"id": str(toolkit.id),
"name": toolkit.name,
"description": toolkit.description,
"tool_ids": tool_ids,
"tools": tools_data,
"usage_count": toolkit.usage_count or 0,
"created_at": toolkit.created_at.isoformat() if toolkit.created_at else None,
"updated_at": toolkit.updated_at.isoformat() if toolkit.updated_at else None
}
)
@toolkit_router.put("/{toolkit_id}", response_model=SuccessResponse)
async def update_toolkit(
toolkit_id: str,
req: UpdateToolkitRequest,
principal: dict = Depends(require_auth),
db: AsyncSession = Depends(get_db)
):
"""
更新工具集
"""
user_id = principal.get("user_id")
try:
toolkit_uuid = PyUUID(toolkit_id)
except ValueError:
raise HTTPException(
status_code=400,
detail={"error": "invalid_toolkit_id", "message": "无效的工具集 ID 格式"}
)
result = await db.execute(
select(ExternalToolkit).where(
and_(
ExternalToolkit.id == toolkit_uuid,
ExternalToolkit.owner_id == PyUUID(user_id)
)
)
)
toolkit = result.scalar_one_or_none()
if not toolkit:
raise HTTPException(
status_code=404,
detail={"error": "toolkit_not_found", "message": "工具集不存在"}
)
logger.info(
"updating_toolkit",
toolkit_id=toolkit_id,
user_id=user_id
)
# 更新名称(检查是否与其他工具集重名)
if req.name and req.name != toolkit.name:
existing = await db.execute(
select(ExternalToolkit).where(
and_(
ExternalToolkit.owner_id == PyUUID(user_id),
ExternalToolkit.name == req.name,
ExternalToolkit.id != toolkit_uuid
)
)
)
if existing.scalar_one_or_none():
raise HTTPException(
status_code=409,
detail={"error": "toolkit_name_exists", "message": f"工具集名称 '{req.name}' 已存在"}
)
toolkit.name = req.name
if req.description is not None:
toolkit.description = req.description
# 更新工具列表
if req.tool_ids is not None:
if len(req.tool_ids) > 8:
raise HTTPException(
status_code=400,
detail={"error": "too_many_tools", "message": "工具集最多包含 8 个工具"}
)
# 验证所有工具
validated_tool_ids = []
for tool_id in req.tool_ids:
try:
tool_uuid = PyUUID(tool_id)
except ValueError:
raise HTTPException(
status_code=400,
detail={"error": "invalid_tool_id", "message": f"无效的工具 ID: {tool_id}"}
)
tool_result = await db.execute(
select(ExternalDataTool).where(
and_(
ExternalDataTool.id == tool_uuid,
ExternalDataTool.owner_id == PyUUID(user_id)
)
)
)
if not tool_result.scalar_one_or_none():
raise HTTPException(
status_code=404,
detail={"error": "tool_not_found", "message": f"工具不存在或无权限: {tool_id}"}
)
validated_tool_ids.append(str(tool_uuid))
toolkit.tool_ids = validated_tool_ids
await db.commit()
await db.refresh(toolkit)
logger.info(
"toolkit_updated",
toolkit_id=str(toolkit.id),
name=toolkit.name
)
return SuccessResponse(
data={
"id": str(toolkit.id),
"name": toolkit.name,
"description": toolkit.description,
"tool_count": len(toolkit.tool_ids) if toolkit.tool_ids else 0,
"updated_at": toolkit.updated_at.isoformat() if toolkit.updated_at else None
},
message="工具集更新成功"
)
@toolkit_router.delete("/{toolkit_id}", response_model=SuccessResponse)
async def delete_toolkit(
toolkit_id: str,
principal: dict = Depends(require_auth),
db: AsyncSession = Depends(get_db)
):
"""
删除工具集
注意:删除工具集不会删除其中的工具。
"""
user_id = principal.get("user_id")
try:
toolkit_uuid = PyUUID(toolkit_id)
except ValueError:
raise HTTPException(
status_code=400,
detail={"error": "invalid_toolkit_id", "message": "无效的工具集 ID 格式"}
)
result = await db.execute(
select(ExternalToolkit).where(
and_(
ExternalToolkit.id == toolkit_uuid,
ExternalToolkit.owner_id == PyUUID(user_id)
)
)
)
toolkit = result.scalar_one_or_none()
if not toolkit:
raise HTTPException(
status_code=404,
detail={"error": "toolkit_not_found", "message": "工具集不存在"}
)
logger.info(
"deleting_toolkit",
toolkit_id=toolkit_id,
name=toolkit.name,
user_id=user_id
)
await db.delete(toolkit)
await db.commit()
logger.info("toolkit_deleted", toolkit_id=toolkit_id)
return SuccessResponse(
data={"id": toolkit_id},
message="工具集删除成功"
)