ForcePilot/backend/server/routers/channels/reports_router.py
Kris e5e9f45411 refactor(channel-routers): 批量优化各渠道路由代码与契约对齐
1. config_router: 为expected_version添加ge=1校验
2. directory_router: 补充scope校验逻辑与注释
3. login_router: 拆分强制下线权限,添加参数校验与注释更新
4. reports_router: 统一时间参数处理,修复分页限制使用契约常量
5. dashboard_router: 更新文档与响应格式,修正参数传递逻辑
6. health_router: 缩减健康检查响应字段,修复响应结构与参数校验
7. plugin_router: 新增插件目录端点,补充枚举校验与注释
8. pairing_router: 新增时间过滤参数,补充参数校验
9. __init__.py: 修复异常映射,更新trace_id获取逻辑与工具类
10. doctor_router: 重构单项检查端点,修正注释与校验逻辑
11. account_router: 新增恢复降级账户端点,补充批量操作校验
12. webhook_router: 优化webhook处理逻辑,修复流式读取与响应逻辑
13. content_review_router: 补充批量审核端点,完善参数校验与注释
14. analytics_router: 修正管道阶段描述,统一参数传递
15. wizard_router: 新增OAuth相关端点,重构路由路径与校验逻辑
2026-07-04 00:16:00 +08:00

195 lines
7.9 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.

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