ForcePilot/backend/server/routers/external_systems/asset_router.py
Kris 2f6a4c29bb refactor(channel routers): 统一参数校验与常量定义,新增功能端点
1. 为渠道账户ID查询添加最小长度校验,统一分析模块常量引用
2. 新增扫码登录向导端点,完善文档说明
3. 优化配对统计接口,移除无效参数
4. 为出站箱接口添加批量上限与202状态码
5. 新增测试用例、访问规则、配额等模块的查询与校验参数
6. 新增适配器健康批量查询、健康检查触发接口
7. 统一告警、审计日志的错误处理方式
8. 新增插件配置账户ID支持,优化批量操作响应
9. 新增环境健康批量查询、Webhook限流与参数校验
10. 完善会话管理、审计日志的参数与文档说明
11. 修复导入模块的校验错误处理逻辑
2026-07-11 21:39:05 +08:00

552 lines
21 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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