ForcePilot/backend/server/routers/external_systems/asset_router.py

445 lines
17 KiB
Python
Raw Normal View History

"""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()}