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

552 lines
21 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 probe 端点``size`` ``checksum`` service
根据 ``content`` 统一计算不在 Router 侧透传避免与 service 计算结果不一致
"""
from __future__ import annotations
import base64
import binascii
import logging
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, FrameworkError
from yuxi.external_systems.infrastructure.container import create_use_cases_from_db
from yuxi.external_systems.use_cases.dto.asset import (
BatchDeleteFailureItem,
BatchDeleteOutput,
BatchUploadFailureItem,
BatchUploadOutput,
BatchValidateFailureItem,
BatchValidateOutput,
CreateAssetInput,
DeleteAssetInput,
GeneratePreviewInput,
GetAssetByChecksumInput,
GetAssetInput,
GetAssetReferencesInput,
ListAssetsInput,
PreviewAssetInput,
ProbeAssetInput,
UpdateAssetInput,
ValidateAssetInput,
)
from yuxi.external_systems.use_cases.services.asset_service import MAX_CONTENT_BYTES
from yuxi.storage.postgres.models_business import User
from yuxi.utils.trace_context import get_trace_id
from server.utils.auth_middleware import get_admin_user, get_db, get_required_user
logger = logging.getLogger(__name__)
asset_router = APIRouter(prefix="/assets", tags=["external-systems-asset"])
# base64 字符串最大长度≈10MB 二进制,含 33% 编码开销 + padding 余量)。
# 二进制上限引用 ``asset_service.MAX_CONTENT_BYTES``,保证两处一致。
_MAX_BASE64_LENGTH = int(MAX_CONTENT_BYTES * 1.4)
# ---------------- 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, max_length=_MAX_BASE64_LENGTH)
@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, max_length=_MAX_BASE64_LENGTH)
@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, max_length=_MAX_BASE64_LENGTH)
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(20, 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]:
"""分页列出适配器资产。"""
logger.info(
"list_assets: user=%s limit=%s offset=%s adapter_type=%s asset_type=%s status=%s",
current_user.uid,
limit,
offset,
adapter_type,
asset_type,
status,
)
use_cases = create_use_cases_from_db(db)
input_dto = ListAssetsInput(
limit=limit,
offset=offset,
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]:
"""查询资产统计(按适配器类型、资产类型、状态分组)。"""
logger.info("get_asset_stats: user=%s", current_user.uid)
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=BatchUploadOutput)
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),
) -> BatchUploadOutput:
"""批量上传资产文件multipart/form-data允许部分成功。
所有文件共享一个 ``adapter_type`` ``asset_type``适用于批量上传同类型资产场景
单次最多 100 个文件每个文件独立创建失败项记录 filename reason
业务异常``ExternalSystemError``与技术异常``FrameworkError``均不中断批量流程
技术异常额外记录堆栈日志便于排查
"""
use_cases = create_use_cases_from_db(db)
uploaded = []
failed = []
for file in files:
filename = file.filename or "unnamed"
try:
content = await file.read()
input_dto = CreateAssetInput(
adapter_type=adapter_type,
asset_type=asset_type,
name=filename,
content=content,
created_by=current_user.uid,
)
output = await use_cases.asset_service.create_asset(input_dto)
uploaded.append(output)
except FrameworkError as exc:
logger.exception("批量上传文件 %s 时发生框架异常", filename)
failed.append(BatchUploadFailureItem(
filename=filename,
reason=str(exc),
error_code=exc.error_code,
trace_id=exc.trace_id or get_trace_id(),
))
except ExternalSystemError as exc:
failed.append(BatchUploadFailureItem(
filename=filename,
reason=str(exc),
error_code=exc.error_code,
trace_id=exc.trace_id or get_trace_id(),
))
except Exception as exc:
logger.exception("批量上传文件 %s 时发生非预期异常", filename)
wrapped = FrameworkError(f"批量上传文件 {filename} 失败: {exc}")
failed.append(BatchUploadFailureItem(
filename=filename,
reason=str(wrapped),
error_code=wrapped.error_code,
trace_id=get_trace_id(),
))
return BatchUploadOutput(uploaded=uploaded, failed=failed)
@asset_router.post("/batch-delete", response_model=BatchDeleteOutput)
async def batch_delete_assets(
payload: BatchDeleteAssetsRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_admin_user),
) -> BatchDeleteOutput:
"""批量删除资产(软删除),允许部分成功。
循环调用 ``delete_asset`` 保证每个资产的引用检查完整执行
业务异常``ExternalSystemError``与技术异常``FrameworkError``均不中断批量流程
技术异常额外记录堆栈日志便于排查
"""
use_cases = create_use_cases_from_db(db)
deleted_count = 0
failed = []
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 FrameworkError as exc:
logger.exception("批量删除资产 %s 时发生框架异常", asset_id)
failed.append(BatchDeleteFailureItem(
asset_id=asset_id,
reason=str(exc),
error_code=exc.error_code,
trace_id=exc.trace_id or get_trace_id(),
))
except ExternalSystemError as exc:
failed.append(BatchDeleteFailureItem(
asset_id=asset_id,
reason=str(exc),
error_code=exc.error_code,
trace_id=exc.trace_id or get_trace_id(),
))
except Exception as exc:
logger.exception("批量删除资产 %s 时发生非预期异常", asset_id)
wrapped = FrameworkError(f"批量删除资产 {asset_id} 失败: {exc}")
failed.append(BatchDeleteFailureItem(
asset_id=asset_id,
reason=str(wrapped),
error_code=wrapped.error_code,
trace_id=get_trace_id(),
))
return BatchDeleteOutput(deleted_count=deleted_count, failed=failed)
@asset_router.post("/batch-validate", response_model=BatchValidateOutput)
async def batch_validate_assets(
payload: BatchValidateAssetsRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_admin_user),
) -> BatchValidateOutput:
"""批量校验资产并更新状态,允许部分成功。
业务异常``ExternalSystemError``与技术异常``FrameworkError``均不中断批量流程
技术异常额外记录堆栈日志便于排查
"""
use_cases = create_use_cases_from_db(db)
validated = []
failed = []
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)
except FrameworkError as exc:
logger.exception("批量校验资产 %s 时发生框架异常", asset_id)
failed.append(BatchValidateFailureItem(
asset_id=asset_id,
reason=str(exc),
error_code=exc.error_code,
trace_id=exc.trace_id or get_trace_id(),
))
except ExternalSystemError as exc:
failed.append(BatchValidateFailureItem(
asset_id=asset_id,
reason=str(exc),
error_code=exc.error_code,
trace_id=exc.trace_id or get_trace_id(),
))
except Exception as exc:
logger.exception("批量校验资产 %s 时发生非预期异常", asset_id)
wrapped = FrameworkError(f"批量校验资产 {asset_id} 失败: {exc}")
failed.append(BatchValidateFailureItem(
asset_id=asset_id,
reason=str(wrapped),
error_code=wrapped.error_code,
trace_id=get_trace_id(),
))
return BatchValidateOutput(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 内容),返回适配器解析的摘要,不保存资产。
``content`` base64 字符串Router 内解码为 bytes 后传给 service
create/update 端点保持一致
"""
use_cases = create_use_cases_from_db(db)
content_bytes: bytes | None = None
if payload.content is not None:
content_bytes = base64.b64decode(payload.content)
input_dto = ProbeAssetInput(
adapter_type=payload.adapter_type,
source_type=payload.source_type,
content=content_bytes,
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()}