ForcePilot/backend/server/routers/external_systems/tool_router.py
Kris 6dd16ad3f8 refactor(external-systems-routers): 统一分页参数格式并完善各路由文档与校验
本次提交对多个外部系统路由进行了多维度优化:
1.  统一分页参数:将所有路由的`page = offset//limit +1`、`page_size=limit`替换为标准的`limit`+`offset`分页格式
2.  完善接口文档:补充多个端点的功能说明、参数含义与返回字段解释
3.  增强参数校验:新增字段长度限制、正则校验、枚举类型约束与业务逻辑校验
4.  优化代码复用:提取重复逻辑为辅助函数,减少样板代码
5.  修复接口问题:修正工具健康检查端点路径参数类型,优化导出接口响应格式
6.  补充异常处理:为批量操作添加异常捕获与日志记录,避免流程中断
2026-07-11 06:57:24 +08:00

656 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 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"])
# 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
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 = 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 = Field(..., min_length=1, max_length=128, pattern=_TOOL_SLUG_PATTERN)
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 = Field(..., min_length=1, max_length=128, pattern=_TOOL_SLUG_PATTERN)
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(..., min_length=1)
class BatchCloneToolsRequest(BaseModel):
"""批量克隆工具请求体。字段对齐 ``BatchCloneToolsInput``(不含 created_by由服务端填充"""
model_config = ConfigDict(frozen=True)
source_tool_ids: list[int] = Field(..., min_length=1)
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),
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),
) -> Response:
"""导出工具列表为 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")
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, 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_id}/health-check", response_model=dict)
async def health_check_external_tool(
tool_id: int,
body: HealthCheckToolRequest | None = None,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""触发单个外部工具健康检查。
仅对 system_id 不为 None 的系统级工具持久化结果到 ``ext_system_health_checks``
工具级工具仅返回探测结果。
"""
use_cases = create_use_cases_from_db(db)
input_dto = HealthCheckToolInput(
tool_id=tool_id,
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(20, 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(
offset=offset,
limit=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(20, 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_idservice 层内部解析为 slug 查询版本。"""
use_cases = create_use_cases_from_db(db)
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,
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()}