ForcePilot/backend/server/routers/channels/reports_router.py

195 lines
7.9 KiB
Python
Raw Normal View History

"""Reports 聚合视图域 RouterRPT-05 / RPT-06 / RPT-ONEOFF-01 / RPT-ONEOFF-02
统一采用模板 A控制面端口路由鉴权依赖 get_admin_user调用
use_cases.report_management.<method>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非法格式抛
# ValidationError400避免原生异常在控制面管道内被兜底为 500。
# - 过去时间校验run_at 非空时不得为过去时间Port 契约 @pre
# 避免创建永远不会触发的 scheduler 任务。
# - datetime 直接透传至 UseCasePort 契约要求 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)}