"""Trash 子域 Router。 外部系统限界上下文的回收站管理 API,覆盖回收站列表查询 / 恢复软删除资源 / 物理删除 / 清理过期回收站。所有端点通过 ``create_use_cases_from_db`` 装配 use_cases,经 ``trash_service`` 端口调用用例。 鉴权分层(与前端权限保持一致): - 查询(``list_trash``)与恢复(``restore``)使用 ``get_admin_user``(admin + superadmin) - 物理删除(``hard_delete``)与清理(``purge``)使用 ``get_superadmin_user``(仅 superadmin), 因物理删除不可恢复,前端同样仅对 superadmin 开放物理删除按钮 Request Schema 与 Input DTO 不共享类,Router 内显式构造 DTO,操作者字段 (``restored_by`` / ``deleted_by`` / ``purged_by``)由 ``current_user.uid`` 填充。 Router 内不 try/except 领域异常,由全局异常处理器统一处理。 路径顺序(关键,来自 FastAPI 路由匹配规则):静态路径端点(``GET ""`` / ``POST "/purge"``)必须在动态路径端点(``POST "/{resource_type}/{resource_id}/restore"`` / ``DELETE "/{resource_type}/{resource_id}"``)之前声明。 ``ResourceType`` 枚举值与 ``TrashService._RESOURCE_REPO_MAP`` 的 key 对齐, 在边界层拦截非法资源类型(FastAPI 自动返回 422),同时在 OpenAPI 文档中 暴露支持的资源类型列表。 """ from __future__ import annotations from enum import StrEnum from typing import Any from fastapi import APIRouter, Depends, Path, Query from pydantic import BaseModel, ConfigDict, Field from yuxi.external_systems.infrastructure.container import UseCases from yuxi.external_systems.use_cases.dto.trash import ( BatchHardDeleteInput, BatchItemInput, BatchRestoreInput, HardDeleteInput, ListTrashInput, PurgeTrashInput, RestoreInput, ) from yuxi.storage.postgres.models_business import User from server.routers.external_systems import get_use_cases, parse_and_naive, validate_time_range from server.utils.auth_middleware import get_admin_user, get_superadmin_user trash_router = APIRouter( prefix="/trash", tags=["external-systems-trash"], ) # ---------------- 边界层校验枚举(与 TrashService._RESOURCE_REPO_MAP 对齐) ---------------- class ResourceType(StrEnum): """回收站支持的资源类型。 值与 ``TrashService._RESOURCE_REPO_MAP`` 的 key 一一对应, 新增资源类型时需同步更新此处与 service 层映射。 """ SYSTEM = "system" ENVIRONMENT = "environment" TOOL = "tool" TOKEN = "token" ASSET = "asset" WEBHOOK_SUBSCRIPTION = "webhook_subscription" QUOTA = "quota" NOTIFICATION_CHANNEL = "notification_channel" SECRET_ROTATION_POLICY = "secret_rotation_policy" ACCESS_RULE = "access_rule" TEST_CASE = "test_case" # ---------------- Request Schemas ---------------- class PurgeTrashRequest(BaseModel): """清理过期回收站请求体。字段对齐 ``PurgeTrashInput``。""" model_config = ConfigDict(frozen=True) older_than_days: int = Field(default=30, ge=1) class BatchItemRequest(BaseModel): """批量操作的单条资源项请求体。""" model_config = ConfigDict(frozen=True) resource_type: ResourceType resource_id: int = Field(ge=1) class BatchOperationRequest(BaseModel): """批量操作请求体。字段对齐 ``BatchRestoreInput`` / ``BatchHardDeleteInput``。""" model_config = ConfigDict(frozen=True) items: list[BatchItemRequest] = Field(min_length=1, max_length=100) # ---------------- Endpoints ---------------- @trash_router.get("", response_model=dict) async def list_trash( resource_type: ResourceType | None = Query(None, description="资源类型过滤;不传时返回各类型计数摘要"), limit: int = Query(50, ge=1, le=200), offset: int = Query(0, ge=0), start_time: str | None = Query( None, description="仅明细模式生效(需传 resource_type),按 deleted_at 过滤下界(ISO 8601)" ), end_time: str | None = Query( None, description="仅明细模式生效(需传 resource_type),按 deleted_at 过滤上界(ISO 8601)" ), use_cases: UseCases = Depends(get_use_cases), current_user: User = Depends(get_admin_user), ) -> dict[str, Any]: """查询回收站列表。 - 不传 ``resource_type``:返回各类型计数摘要(``by_type``),``items`` 为空 - 传 ``resource_type``:返回指定类型的软删除记录明细,``start_time``/``end_time`` 生效 """ start_dt = parse_and_naive("start_time", start_time) end_dt = parse_and_naive("end_time", end_time) validate_time_range(start_dt, end_dt) input_dto = ListTrashInput( resource_type=resource_type.value if resource_type is not None else None, limit=limit, offset=offset, start_time=start_dt, end_time=end_dt, ) output = await use_cases.trash_service.list_trash(input_dto) return {"success": True, "data": output.model_dump()} @trash_router.post("/purge", response_model=dict) async def purge_trash( payload: PurgeTrashRequest, use_cases: UseCases = Depends(get_use_cases), current_user: User = Depends(get_superadmin_user), ) -> dict[str, Any]: """清理过期回收站(物理删除超过保留期的软删除资源)。 ``purged_by`` 由当前管理员填充。物理删除不可恢复,仅 superadmin 可执行。 """ input_dto = PurgeTrashInput( older_than_days=payload.older_than_days, purged_by=current_user.uid, ) output = await use_cases.trash_service.purge(input_dto) return {"success": True, "data": output.model_dump()} @trash_router.post("/batch/restore", response_model=dict) async def batch_restore_resources( payload: BatchOperationRequest, use_cases: UseCases = Depends(get_use_cases), current_user: User = Depends(get_admin_user), ) -> dict[str, Any]: """批量恢复软删除资源。 每条独立事务,已知领域异常记录为失败项,未预期异常冒泡。 ``restored_by`` 由当前管理员填充。 """ input_dto = BatchRestoreInput( items=[ BatchItemInput(resource_type=item.resource_type.value, resource_id=item.resource_id) for item in payload.items ], restored_by=current_user.uid, ) output = await use_cases.trash_service.batch_restore(input_dto) return {"success": True, "data": output.model_dump()} @trash_router.post("/batch/hard-delete", response_model=dict) async def batch_hard_delete_resources( payload: BatchOperationRequest, use_cases: UseCases = Depends(get_use_cases), current_user: User = Depends(get_superadmin_user), ) -> dict[str, Any]: """批量物理删除资源(不可恢复,仅 superadmin)。 每条独立事务,已知领域异常记录为失败项,未预期异常冒泡。 ``deleted_by`` 由当前管理员填充。 """ input_dto = BatchHardDeleteInput( items=[ BatchItemInput(resource_type=item.resource_type.value, resource_id=item.resource_id) for item in payload.items ], deleted_by=current_user.uid, ) output = await use_cases.trash_service.batch_hard_delete(input_dto) return {"success": True, "data": output.model_dump()} @trash_router.post("/{resource_type}/{resource_id}/restore", response_model=dict) async def restore_resource( resource_type: ResourceType, resource_id: int = Path(..., ge=1), use_cases: UseCases = Depends(get_use_cases), current_user: User = Depends(get_admin_user), ) -> dict[str, Any]: """恢复软删除资源。 ``restored_by`` 由当前管理员填充。 """ input_dto = RestoreInput( resource_type=resource_type.value, resource_id=resource_id, restored_by=current_user.uid, ) output = await use_cases.trash_service.restore(input_dto) return {"success": True, "data": output.model_dump()} @trash_router.delete("/{resource_type}/{resource_id}", response_model=dict) async def hard_delete_resource( resource_type: ResourceType, resource_id: int = Path(..., ge=1), use_cases: UseCases = Depends(get_use_cases), current_user: User = Depends(get_superadmin_user), ) -> dict[str, Any]: """物理删除回收站中的资源(不可恢复)。 ``deleted_by`` 由当前管理员填充。物理删除不可恢复,仅 superadmin 可执行。 """ input_dto = HardDeleteInput( resource_type=resource_type.value, resource_id=resource_id, deleted_by=current_user.uid, ) output = await use_cases.trash_service.hard_delete(input_dto) return {"success": True, "data": output.model_dump()}