"""Reports 聚合视图域 Router(RPT-05 / RPT-06 / RPT-ONEOFF-01 / RPT-ONEOFF-02)。 统一采用模板 A(控制面端口路由),鉴权依赖 get_admin_user,调用 use_cases.report_management.,raiseOnControlFailure 转译失败, serialize_control_data 序列化响应。 路径设计(静态先于动态,避免 /{report_id} 误捕获 /oneoff): - POST /reports/oneoff RPT-05 createOneoffReport - GET /reports/oneoff RPT-ONEOFF-01 listOneoffReports - GET /reports/oneoff/{task_id}/download RPT-ONEOFF-02 downloadReport - POST /reports/oneoff/{task_id}/retry RPT-ONEOFF-RETRY retryReport - GET /reports/{report_id} RPT-06 getReport 子 router 自身不设置 prefix,根前缀 ``/channels`` 由 ``channels_router`` 聚合 router 统一追加。 """ from __future__ import annotations from datetime import datetime, timezone from typing import Any, Literal from fastapi import APIRouter, Depends, Query, Request from pydantic import BaseModel, ConfigDict, Field from yuxi.channels.contract.dtos.report import ( DEFAULT_LIST_LIMIT, MAX_LIST_LIMIT, MIN_LIST_LIMIT, ) from yuxi.channels.contract.errors import ValidationError from yuxi.storage.postgres.models_business import User from server.routers.channels import ( build_operator, get_channel_use_cases, parse_datetime, raiseOnControlFailure, serialize_control_data, ) from server.utils.auth_middleware import get_admin_user reports_router = APIRouter(tags=["channels-reports"]) class CreateOneoffReportRequest(BaseModel): """创建一次性报告请求体(RPT-05)。 字段: report_type: 报告类型枚举(5 个值)。 params: 报告生成参数(时间范围 / 过滤条件等)。 run_at: 计划执行时间(ISO 8601 字符串),为 None 时立即执行。 """ model_config = ConfigDict(frozen=True) report_type: Literal[ "message_stats", "session_stats", "account_stats", "delivery_stats", "dashboard_overview", ] = Field(..., description="报告类型") params: dict[str, Any] = Field(default_factory=dict, description="报告生成参数") run_at: str | None = Field(default=None, description="计划执行时间(ISO 8601),为空时立即执行") @reports_router.post("/reports/oneoff", response_model=dict) async def create_oneoff_report( request: Request, body: CreateOneoffReportRequest, use_cases=Depends(get_channel_use_cases), current_user: User = Depends(get_admin_user), ) -> dict[str, Any]: """创建一次性报告(RPT-05)。对应控制面操作 reports/create_oneoff。 异步生成报告:创建 pending 状态报告记录 + scheduler 一次性任务, worker 进程通过 ChannelReportHandler 生成内容后状态变 ready。 """ operator = build_operator(current_user, request) # 时间参数统一在 Router 层校验格式与业务约束,与 list_oneoff_reports # 的 start_time / end_time 处理一致(FR-34 / RPT-05 @pre): # - 格式校验:parse_datetime 翻译为 aware UTC datetime,非法格式抛 # ValidationError(400),避免原生异常在控制面管道内被兜底为 500。 # - 过去时间校验:run_at 非空时不得为过去时间(Port 契约 @pre), # 避免创建永远不会触发的 scheduler 任务。 # - datetime 直接透传至 UseCase(Port 契约要求 datetime | None), # 消除 str → datetime → str → datetime 的冗余往返转换。 run_at_dt = parse_datetime("run_at", body.run_at) if run_at_dt is not None and run_at_dt <= datetime.now(timezone.utc): raise ValidationError( field="run_at", message="run_at must be a future time", ) result = await use_cases.report_management.createOneoffReport( report_type=body.report_type, params=body.params, run_at=run_at_dt, operator=operator, ) raiseOnControlFailure(result) return {"success": True, "data": serialize_control_data(result.data)} @reports_router.get("/reports/oneoff", response_model=dict) async def list_oneoff_reports( request: Request, status: Literal["pending", "generating", "ready", "failed"] | None = Query(default=None, description="按状态过滤"), report_type: Literal["message_stats", "session_stats", "account_stats", "delivery_stats", "dashboard_overview"] | None = Query(default=None, description="按报告类型过滤"), start_time: str | None = Query(default=None, description="起始时间(ISO 8601)"), end_time: str | None = Query(default=None, description="截止时间(ISO 8601)"), limit: int = Query(default=DEFAULT_LIST_LIMIT, ge=MIN_LIST_LIMIT, le=MAX_LIST_LIMIT, description="分页大小(1-200)"), offset: int = Query(default=0, ge=0, description="分页偏移"), use_cases=Depends(get_channel_use_cases), current_user: User = Depends(get_admin_user), ) -> dict[str, Any]: """列出一次性报告(RPT-ONEOFF-01)。对应控制面操作 reports/list_oneoff。 分页查询报告列表,支持按状态 / 类型 / 时间范围过滤。 """ operator = build_operator(current_user, request) start_time_dt = parse_datetime("start_time", start_time) end_time_dt = parse_datetime("end_time", end_time) result = await use_cases.report_management.listOneoffReports( operator=operator, status=status, report_type=report_type, start_time=start_time_dt, end_time=end_time_dt, limit=limit, offset=offset, ) raiseOnControlFailure(result) return {"success": True, "data": serialize_control_data(result.data)} @reports_router.get("/reports/oneoff/{task_id}/download", response_model=dict) async def download_oneoff_report( request: Request, task_id: str, use_cases=Depends(get_channel_use_cases), current_user: User = Depends(get_admin_user), ) -> dict[str, Any]: """下载一次性报告(RPT-ONEOFF-02)。对应控制面操作 reports/download。 根据 task_id 下载已就绪报告内容。未就绪(pending/generating)返回 409, 失败(failed)返回 409。 """ operator = build_operator(current_user, request) result = await use_cases.report_management.downloadReport( task_id=task_id, operator=operator, ) raiseOnControlFailure(result) return {"success": True, "data": serialize_control_data(result.data)} @reports_router.post("/reports/oneoff/{task_id}/retry", response_model=dict) async def retry_oneoff_report( request: Request, task_id: str, use_cases=Depends(get_channel_use_cases), current_user: User = Depends(get_admin_user), ) -> dict[str, Any]: """重试失败报告(RPT-ONEOFF-RETRY)。对应控制面操作 reports/oneoff_retry。 校验原报告 status=="failed" 后创建新 pending 报告并入队 scheduler 任务,同时标记原报告 ``retried_at``。返回新旧 task_id。 """ operator = build_operator(current_user, request) result = await use_cases.report_management.retryReport( task_id=task_id, operator=operator, ) raiseOnControlFailure(result) return {"success": True, "data": serialize_control_data(result.data)} @reports_router.get("/reports/{report_id}", response_model=dict) async def get_report( request: Request, report_id: str, use_cases=Depends(get_channel_use_cases), current_user: User = Depends(get_admin_user), ) -> dict[str, Any]: """查询报告详情(RPT-06)。对应控制面操作 reports/get。 返回报告完整信息(含 content / status / download_url)。 """ operator = build_operator(current_user, request) result = await use_cases.report_management.getReport( report_id=report_id, operator=operator, ) raiseOnControlFailure(result) return {"success": True, "data": serialize_control_data(result.data)}