ForcePilot/backend/server/routers/external_systems/asset_router.py
Kris aabde54f2e refactor(routers): 统一完善所有外部系统接口的参数校验和类型约束
1. 为所有查询参数添加max_length长度限制,规范参数输入范围
2. 使用Literal类型替换普通字符串参数,限定合法取值范围
3. 为路径参数添加Path校验,确保ID参数合法有效
4. 优化请求体参数的声明,补充缺失的Body注解和校验规则
5. 统一分页参数的offset/limit使用方式,替换旧的page/page_size模式
2026-07-04 00:16:45 +08:00

445 lines
17 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.

"""Asset 子域 Router。
外部系统限界上下文的适配器资产管理 API覆盖资产 CRUD / 校验 / 预览 /
引用 / 生成预览。所有端点通过 ``create_use_cases_from_db`` 装配 use_cases
经 ``asset_service`` 端口调用用例。
Request Schema 与 Input DTO 不共享类Router 内显式构造 DTO操作人字段由
current_user.uid 填充。``content`` 字段在 Request Schema 中为 base64 字符串,
Router 内解码为 bytes``size`` 与 ``checksum`` 由 service 根据 ``content``
统一计算,不在 Router 侧透传,避免与 service 计算结果不一致。
"""
from __future__ import annotations
import base64
import binascii
from datetime import datetime
from typing import Any
from fastapi import (
APIRouter,
Depends,
File,
Form,
Path,
Query,
UploadFile,
)
from pydantic import (
BaseModel,
ConfigDict,
Field,
field_validator,
model_validator,
)
from sqlalchemy.ext.asyncio import AsyncSession
from yuxi.external_systems.exceptions import ExternalSystemError
from yuxi.external_systems.infrastructure.container import create_use_cases_from_db
from yuxi.external_systems.use_cases.dto.asset import (
CreateAssetInput,
DeleteAssetInput,
GeneratePreviewInput,
GetAssetByChecksumInput,
GetAssetInput,
GetAssetReferencesInput,
ListAssetsInput,
PreviewAssetInput,
ProbeAssetInput,
UpdateAssetInput,
ValidateAssetInput,
)
from yuxi.storage.postgres.models_business import User
from server.utils.auth_middleware import get_admin_user, get_db, get_required_user
asset_router = APIRouter(prefix="/assets", tags=["external-systems-asset"])
# ---------------- Request Schemas ----------------
def _validate_base64_content(value: str) -> str:
"""校验 base64 编码字符串,非法时抛 ValueError由 Pydantic 映射为 422"""
try:
base64.b64decode(value, validate=True)
except (binascii.Error, ValueError) as exc:
raise ValueError("content 必须是合法的 base64 编码") from exc
return value
class CreateAssetRequest(BaseModel):
"""创建资产请求体。字段对齐 ``CreateAssetInput``(不含 size、checksum 与 created_by
``content`` 为 base64 编码的字符串Router 内解码为 bytes
``size`` 与 ``checksum`` 由 service 根据 ``content`` 统一计算。
"""
model_config = ConfigDict(frozen=True)
adapter_type: str = Field(..., max_length=32)
asset_type: str = Field(..., max_length=32)
name: str = Field(..., min_length=1, max_length=128)
content: str = Field(..., min_length=1)
@field_validator("content")
@classmethod
def validate_content(cls, v: str) -> str:
return _validate_base64_content(v)
class UpdateAssetRequest(BaseModel):
"""更新资产请求体。字段对齐 ``UpdateAssetInput``(不含 id、size、checksum 与 updated_by
``content`` 为 base64 编码的字符串Router 内解码为 bytes
``size`` 与 ``checksum`` 由 service 根据 ``content`` 统一计算,
不接受客户端单独传入 ``checksum``,避免与实际内容不一致。
仅透传客户端显式设置的字段。
"""
model_config = ConfigDict(frozen=True)
name: str | None = Field(default=None, min_length=1, max_length=128)
status: str | None = Field(default=None, max_length=32)
status_message: str | None = None
content: str | None = Field(default=None, min_length=1)
@field_validator("content")
@classmethod
def validate_content(cls, v: str | None) -> str | None:
if v is None:
return v
return _validate_base64_content(v)
class BatchDeleteAssetsRequest(BaseModel):
"""批量删除资产请求体。"""
model_config = ConfigDict(frozen=True)
asset_ids: list[int] = Field(..., min_length=1, max_length=100)
class BatchValidateAssetsRequest(BaseModel):
"""批量校验资产请求体。"""
model_config = ConfigDict(frozen=True)
asset_ids: list[int] = Field(..., min_length=1, max_length=100)
class ProbeAssetRequest(BaseModel):
"""资产探测请求体。``content`` 为 base64 编码字符串,与 ``url`` 二选一。"""
model_config = ConfigDict(frozen=True)
adapter_type: str = Field(..., max_length=32)
source_type: str = Field(..., max_length=32)
content: str | None = Field(default=None, min_length=1)
url: str | None = Field(default=None, max_length=2048)
@field_validator("content")
@classmethod
def validate_content(cls, v: str | None) -> str | None:
if v is None:
return v
return _validate_base64_content(v)
@model_validator(mode="after")
def validate_source(self) -> ProbeAssetRequest:
if self.content is None and self.url is None:
raise ValueError("必须提供 url 或 content")
if self.content is not None and self.url is not None:
raise ValueError("url 与 content 不能同时提供")
return self
# ---------------- Endpoints ----------------
@asset_router.get("", response_model=dict)
async def list_assets(
limit: int = Query(100, ge=1, le=500),
offset: int = Query(0, ge=0),
adapter_type: str | None = Query(None, max_length=32),
asset_type: str | None = Query(None, max_length=32),
status: str | None = Query(None, max_length=32),
keyword: str | None = Query(None, max_length=128),
start_time: datetime | None = Query(None, description="创建时间起始UTC ISO 8601"),
end_time: datetime | None = Query(None, description="创建时间结束UTC ISO 8601"),
created_by: str | None = Query(None, max_length=64, description="按创建人过滤"),
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 = ListAssetsInput(
page=offset // limit + 1,
page_size=limit,
adapter_type=adapter_type,
asset_type=asset_type,
status=status,
keyword=keyword,
start_time=start_time,
end_time=end_time,
created_by=created_by,
)
output = await use_cases.asset_service.list_assets(input_dto)
return {"success": True, "data": output.model_dump()}
@asset_router.post("", response_model=dict)
async def create_asset(
payload: CreateAssetRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""创建适配器资产。``content`` 为 base64 字符串,解码后交由 service 计算 size 与 checksum。"""
use_cases = create_use_cases_from_db(db)
content = base64.b64decode(payload.content)
input_dto = CreateAssetInput(
adapter_type=payload.adapter_type,
asset_type=payload.asset_type,
name=payload.name,
content=content,
created_by=current_user.uid,
)
output = await use_cases.asset_service.create_asset(input_dto)
return {"success": True, "data": output.model_dump()}
@asset_router.get("/stats", response_model=dict)
async def get_asset_stats(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_required_user),
) -> dict[str, Any]:
"""查询资产统计(按适配器类型、资产类型、状态分组)。"""
use_cases = create_use_cases_from_db(db)
output = await use_cases.asset_service.get_asset_stats()
return {"success": True, "data": output.model_dump()}
@asset_router.get("/by-checksum/{checksum}", response_model=dict)
async def get_asset_by_checksum(
checksum: str = Path(
...,
min_length=64,
max_length=64,
pattern=r"^[0-9a-f]{64}$",
description="SHA-256 checksum64 位十六进制字符串)",
),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_required_user),
) -> dict[str, Any]:
"""按 SHA-256 checksum 查询资产(上传去重检查)。资产不存在时 data 为 null。"""
use_cases = create_use_cases_from_db(db)
input_dto = GetAssetByChecksumInput(checksum=checksum)
output = await use_cases.asset_service.get_asset_by_checksum(input_dto)
return {"success": True, "data": output.model_dump() if output else None}
@asset_router.post("/batch-upload", response_model=dict)
async def batch_upload_assets(
adapter_type: str = Form(..., max_length=32),
asset_type: str = Form(..., max_length=32),
files: list[UploadFile] = File(..., min_length=1, max_length=100),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""批量上传资产文件multipart/form-data允许部分成功。
所有文件共享一个 ``adapter_type`` 与 ``asset_type``,适用于批量上传同类型资产场景。
单次最多 100 个文件,每个文件独立创建,失败项记录 filename 与 reason。
"""
use_cases = create_use_cases_from_db(db)
uploaded: list[dict[str, Any]] = []
failed: list[dict[str, str]] = []
for file in files:
try:
content = await file.read()
input_dto = CreateAssetInput(
adapter_type=adapter_type,
asset_type=asset_type,
name=file.filename or "unnamed",
content=content,
created_by=current_user.uid,
)
output = await use_cases.asset_service.create_asset(input_dto)
uploaded.append(output.model_dump())
except ExternalSystemError as exc:
failed.append({"filename": file.filename or "unnamed", "reason": str(exc)})
return {"success": True, "data": {"uploaded": uploaded, "failed": failed}}
@asset_router.post("/batch-delete", response_model=dict)
async def batch_delete_assets(
payload: BatchDeleteAssetsRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""批量删除资产(软删除),允许部分成功。
循环调用 ``delete_asset`` 保证每个资产的引用检查完整执行。
"""
use_cases = create_use_cases_from_db(db)
deleted_count = 0
failed: list[dict[str, Any]] = []
for asset_id in payload.asset_ids:
try:
input_dto = DeleteAssetInput(id=asset_id, user=current_user.uid)
await use_cases.asset_service.delete_asset(input_dto)
deleted_count += 1
except ExternalSystemError as exc:
failed.append({"asset_id": asset_id, "reason": str(exc)})
return {"success": True, "data": {"deleted_count": deleted_count, "failed": failed}}
@asset_router.post("/batch-validate", response_model=dict)
async def batch_validate_assets(
payload: BatchValidateAssetsRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""批量校验资产并更新状态,允许部分成功。"""
use_cases = create_use_cases_from_db(db)
validated: list[dict[str, Any]] = []
failed: list[dict[str, Any]] = []
for asset_id in payload.asset_ids:
try:
input_dto = ValidateAssetInput(id=asset_id, user=current_user.uid)
output = await use_cases.asset_service.validate_asset(input_dto)
validated.append(output.model_dump())
except ExternalSystemError as exc:
failed.append({"asset_id": asset_id, "reason": str(exc)})
return {"success": True, "data": {"validated": validated, "failed": failed}}
@asset_router.post("/probe", response_model=dict)
async def probe_asset(
payload: ProbeAssetRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""探测资产内容URL 或 base64 内容),返回适配器解析的摘要,不保存资产。"""
use_cases = create_use_cases_from_db(db)
input_dto = ProbeAssetInput(
adapter_type=payload.adapter_type,
source_type=payload.source_type,
content=payload.content,
url=payload.url,
)
output = await use_cases.asset_service.probe_asset(input_dto)
return {"success": True, "data": output.model_dump()}
@asset_router.get("/{asset_id}", response_model=dict)
async def get_asset(
asset_id: int = Path(ge=1),
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 = GetAssetInput(id=asset_id)
output = await use_cases.asset_service.get_asset(input_dto)
return {"success": True, "data": output.model_dump()}
@asset_router.put("/{asset_id}", response_model=dict)
async def update_asset(
payload: UpdateAssetRequest,
asset_id: int = Path(ge=1),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""更新资产。``content`` 为 base64 字符串,解码后交由 service 计算 size 与 checksum。仅透传显式设置的字段。"""
use_cases = create_use_cases_from_db(db)
data = payload.model_dump(exclude_unset=True)
content_b64 = data.pop("content", None)
if content_b64 is not None:
data["content"] = base64.b64decode(content_b64)
input_dto = UpdateAssetInput(
id=asset_id,
updated_by=current_user.uid,
**data,
)
output = await use_cases.asset_service.update_asset(input_dto)
return {"success": True, "data": output.model_dump()}
@asset_router.delete("/{asset_id}", response_model=dict)
async def delete_asset(
asset_id: int = Path(ge=1),
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 = DeleteAssetInput(id=asset_id, user=current_user.uid)
output = await use_cases.asset_service.delete_asset(input_dto)
return {"success": True, "data": output.model_dump()}
@asset_router.post("/{asset_id}/validate", response_model=dict)
async def validate_asset(
asset_id: int = Path(ge=1),
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 = ValidateAssetInput(id=asset_id, user=current_user.uid)
output = await use_cases.asset_service.validate_asset(input_dto)
return {"success": True, "data": output.model_dump()}
@asset_router.get("/{asset_id}/preview", response_model=dict)
async def preview_asset(
asset_id: int = Path(ge=1),
max_lines: int = Query(200, ge=1, le=10000),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_required_user),
) -> dict[str, Any]:
"""预览资产内容(适配器视角的结构化摘要)。
调用适配器的 ``preview_asset`` 能力,返回适配器解析的摘要(如 OpenAPI 的 endpoint 列表)。
与 ``generate_preview`` 的区别:本端点调用外部适配器获取结构化摘要,
``generate_preview`` 仅本地解码内容并截取前 N 行原始文本。
"""
use_cases = create_use_cases_from_db(db)
input_dto = PreviewAssetInput(id=asset_id, max_lines=max_lines)
output = await use_cases.asset_service.preview_asset(input_dto)
return {"success": True, "data": output.model_dump()}
@asset_router.get("/{asset_id}/generate-preview", response_model=dict)
async def generate_preview(
asset_id: int = Path(ge=1),
max_lines: int = Query(200, ge=1, le=10000),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_required_user),
) -> dict[str, Any]:
"""生成资产文本预览(本地解码内容并截取前 N 行)。
不调用外部适配器,仅本地解码内容并截取前 N 行,适用于查看资产原始文本内容。
与 ``preview_asset`` 的区别:本端点不调用外部适配器,仅返回原始文本前 N 行;
``preview_asset`` 调用适配器获取结构化摘要。
"""
use_cases = create_use_cases_from_db(db)
input_dto = GeneratePreviewInput(id=asset_id, max_lines=max_lines)
output = await use_cases.asset_service.generate_preview(input_dto)
return {"success": True, "data": output.model_dump()}
@asset_router.get("/{asset_id}/references", response_model=dict)
async def get_asset_references(
asset_id: int = Path(ge=1),
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 = GetAssetReferencesInput(id=asset_id)
output = await use_cases.asset_service.get_asset_references(input_dto)
return {"success": True, "data": output.model_dump()}