1. 为所有查询参数添加max_length长度限制,规范参数输入范围 2. 使用Literal类型替换普通字符串参数,限定合法取值范围 3. 为路径参数添加Path校验,确保ID参数合法有效 4. 优化请求体参数的声明,补充缺失的Body注解和校验规则 5. 统一分页参数的offset/limit使用方式,替换旧的page/page_size模式
445 lines
17 KiB
Python
445 lines
17 KiB
Python
"""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 checksum(64 位十六进制字符串)",
|
||
),
|
||
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()}
|