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