"""外部系统限界上下文 - 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 StreamingResponse from pydantic import BaseModel, ConfigDict, Field from sqlalchemy.ext.asyncio import AsyncSession from yuxi.external_systems.infrastructure.container import create_use_cases_from_db 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.utils.auth_middleware import get_admin_user, get_db, get_required_user tool_router = APIRouter(prefix="/tools", tags=["external-systems-tool"]) # ============================================================================= # === 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 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``)。 """ 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 = Field(..., min_length=1) 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) class BatchImportToolsRequest(BaseModel): """批量导入工具请求体。字段对齐 ``BatchImportToolsInput``。""" model_config = ConfigDict(frozen=True) system_id: int | None = None tools: list[ExternalToolItemRequest] = Field(default_factory=list) 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 system_id: int | None = None env_key: str = "default" 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 system_id: int | None = None env_key: str = "default" 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(default_factory=list) class BatchCloneToolsRequest(BaseModel): """批量克隆工具请求体。字段对齐 ``BatchCloneToolsInput``(不含 created_by,由服务端填充)。""" model_config = ConfigDict(frozen=True) source_tool_ids: list[int] slug_suffix: str = Field(..., min_length=1, max_length=64) 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), category: str | None = Query(None), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_required_user), ) -> dict[str, Any]: """获取已启用工具列表(供 agents 模块使用)。""" use_cases = create_use_cases_from_db(db) 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), enabled_only: bool = Query(True), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_required_user), ) -> dict[str, Any]: """列出工具选项(供下拉选择使用)。""" use_cases = create_use_cases_from_db(db) 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), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_required_user), ) -> StreamingResponse: """导出工具列表为 JSON 字节流。""" use_cases = create_use_cases_from_db(db) 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") async def _stream(): yield payload return StreamingResponse( _stream(), 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, description="指定工具 slug,未指定则取系统下首个 SOAP 工具"), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_required_user), ) -> dict[str, Any]: """导出 WSDL 内容。 从指定系统下的 SOAP 工具提取 ``wsdl_content``;若工具仅存储 ``wsdl_url``, 则通过 SOAP 适配器加载后返回。``system_id`` 为必填,``tool_slug`` 可选。 """ use_cases = create_use_cases_from_db(db) 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, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_admin_user), ) -> dict[str, Any]: """批量导入工具。created_by 由服务端以 current_user.uid 填充。""" use_cases = create_use_cases_from_db(db) 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, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_admin_user), ) -> dict[str, Any]: """从 WSDL 导入工具。""" use_cases = create_use_cases_from_db(db) 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, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_required_user), ) -> dict[str, Any]: """执行外部工具。caller 由服务端以 current_user.uid 填充。""" use_cases = create_use_cases_from_db(db) 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_slug}/health-check", response_model=dict) async def health_check_external_tool( tool_slug: str, body: HealthCheckToolRequest | None = None, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_required_user), ) -> dict[str, Any]: """触发单个外部工具健康检查。 仅对 system_id 不为 None 的系统级工具持久化结果到 ``ext_system_health_checks``; 工具级工具仅返回探测结果。 """ use_cases = create_use_cases_from_db(db) input_dto = HealthCheckToolInput( tool_slug=tool_slug, env_key=body.env_key if body is not None else None, ) 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, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_required_user), ) -> dict[str, Any]: """批量执行工具。caller 由服务端以 current_user.uid 填充。 单个执行项失败时不中断批量执行:执行器内部失败返回 ``status="failed"``, 前置校验失败(如工具不存在)抛出的异常由 service 层 try/except 捕获并记录错误。 """ use_cases = create_use_cases_from_db(db) 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, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_admin_user), ) -> dict[str, Any]: """批量克隆工具。created_by 由服务端以 current_user.uid 填充。 单个事务内执行整个循环,单个失败时记录错误并继续 (参考 ``batch_import_tools`` 模式)。 """ use_cases = create_use_cases_from_db(db) 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(100, ge=1, le=500), offset: int = Query(0, ge=0), system_id: int | None = Query(None), category: str | None = Query(None), adapter_type: str | None = Query(None), enabled: bool | None = Query(None), keyword: str | None = Query(None), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_required_user), ) -> dict[str, Any]: """分页列出外部工具。""" use_cases = create_use_cases_from_db(db) input_dto = ListToolsInput( page=offset // limit + 1, page_size=limit, system_id=system_id, category=category, adapter_type=adapter_type, enabled=enabled, keyword=keyword, ) 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, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_admin_user), ) -> dict[str, Any]: """创建外部工具。""" use_cases = create_use_cases_from_db(db) 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), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_required_user), ) -> dict[str, Any]: """获取工具详情。""" use_cases = create_use_cases_from_db(db) 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, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_admin_user), ) -> dict[str, Any]: """更新外部工具。仅透传客户端显式设置的字段,保留部分更新语义。""" use_cases = create_use_cases_from_db(db) 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), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_admin_user), ) -> dict[str, Any]: """删除外部工具。""" use_cases = create_use_cases_from_db(db) 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, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_admin_user), ) -> dict[str, Any]: """克隆外部工具。""" use_cases = create_use_cases_from_db(db) 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(100, ge=1, le=500), offset: int = Query(0, ge=0), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_required_user), ) -> dict[str, Any]: """获取工具版本列表。路径参数为 tool_id,service 层内部解析为 slug 查询。""" use_cases = create_use_cases_from_db(db) input_dto = GetToolVersionsInput( tool_id=tool_id, page=offset // limit + 1, page_size=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, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_required_user), ) -> dict[str, Any]: """对比两个工具版本的参数差异。 snapshot 以明文存储,service 层使用 ``mask_all_string_values`` 脱敏后对比结构差异,不泄露敏感信息。 """ use_cases = create_use_cases_from_db(db) 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, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_admin_user), ) -> dict[str, Any]: """将工具回滚到指定历史版本。updated_by 由服务端以 current_user.uid 填充。 回滚前创建当前配置的新版本快照,记录回滚前的状态。 snapshot 以明文存储,service 层通过 persistence 层的 ``get_decrypted_snapshot`` 方法获取明文后传给 ``ToolRepository.update``。 """ use_cases = create_use_cases_from_db(db) 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, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_required_user), ) -> dict[str, Any]: """获取工具参数模板。 基于 ``AdapterMetadata.to_json_schema()`` + ``example_config`` 生成, 供前端动态表单渲染。 """ use_cases = create_use_cases_from_db(db) 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: datetime | None = Query(None, description="开始时间(ISO 8601)"), end_time: datetime | None = Query(None, description="结束时间(ISO 8601)"), interval: Literal["hour", "day"] = Query("day", description="时间桶粒度(hour/day)"), env_key: str | None = Query(None, description="环境 key 过滤"), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_required_user), ) -> dict[str, Any]: """查询工具执行历史趋势(按天/小时聚合)。""" use_cases = create_use_cases_from_db(db) input_dto = GetToolExecutionTrendInput( tool_id=tool_id, start_time=start_time, end_time=end_time, 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, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_required_user), ) -> dict[str, Any]: """查询工具依赖的适配器资产。 工具与资产的关联关系通过 ``adapter_config`` 中的引用建立, service 层按 checksum → asset_name → adapter_type 顺序解析。 """ use_cases = create_use_cases_from_db(db) 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()}