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