ForcePilot/backend/server/routers/channels/outbox_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

387 lines
17 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.

"""Outbox 管理路由OBX-01~OBX-08 + P0缺口
统一采用模板 A控制面端口路由鉴权依赖 get_admin_user调用
use_cases.outbox_management.<method>raiseOnControlFailure 转译失败,
serialize_control_data 序列化响应。
路径设计:静态跨渠道查询(/messages / /stats / /dead-letter / /retry-policy
/dead-letter/batch-retry / /dead-letter/batch-delete先于动态路径
/messages/{outbox_id} / /dead-letter/{outbox_id} / /messages/{outbox_id}/retry
声明(规范 §6.5),避免静态路径被动态参数捕获。子 router 自身 prefix 为 ``/outbox``
根前缀 ``/channels`` 由 ``channels_router`` 聚合 router 统一追加。
端点清单对应《07-投递基础设施域设计方案》§2.1 + P0缺口补全
- GET /outbox/messages OBX-01 listOutboxEntries
- GET /outbox/stats OBX-02 getOutboxStats
- GET /outbox/trend OBX-TREND getOutboxTrend
- GET /outbox/dead-letter OBX-04 listDeadLetterOutboxEntries
- GET /outbox/retry-policy OBX-07 getOutboxRetryPolicy
- POST /outbox/dead-letter/batch-retry OBX-DL-BATCH-RETRY 批量重投死信
- POST /outbox/dead-letter/batch-delete OBX-DL-BATCH-DELETE 批量清除死信
- GET /outbox/dead-letter/export OBX-DL-EXPORT export_dead_letter
- PUT /outbox/retry-policy OBX-POLICY-UPDATE 更新重试策略
- GET /outbox/messages/{outbox_id} OBX-03 getOutboxEntry
- DELETE /outbox/dead-letter/{outbox_id} OBX-05 deleteDeadLetterOutboxEntry
- POST /outbox/messages/{outbox_id}/retry OBX-08 retryOutboxEntry
"""
from __future__ import annotations
from typing import Any, Literal
from fastapi import APIRouter, Depends, Query, Request
from pydantic import BaseModel, ConfigDict, Field, field_validator
from yuxi.channels.contract.dtos.channel import ChannelType
from yuxi.channels.contract.dtos.outbox import (
DeadLetterExportCmd,
OutboxQueryFilter,
OutboxStatus,
OutboxTrendQuery,
RetryPolicySnapshot,
)
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
outbox_router = APIRouter(prefix="/outbox", tags=["channels-outbox"])
class UpdateRetryPolicyRequest(BaseModel):
"""更新重试策略请求体。"""
model_config = ConfigDict(frozen=True)
max_retry: int = Field(..., ge=1, le=20, description="最大重试次数")
ttl_seconds: int = Field(..., ge=60, le=2592000, description="条目存活时间(秒)")
retry_backoff_schedule: list[int] = Field(
...,
min_length=1,
description="指数退避序列(秒),非空正整数列表",
)
@field_validator("retry_backoff_schedule")
@classmethod
def _validate_backoff_schedule(cls, v: list[int]) -> list[int]:
"""校验退避序列为正整数,与 handler 层校验保持一致。"""
if any(item <= 0 for item in v):
raise ValueError("retry_backoff_schedule must contain only positive integers")
return v
# ---------------- 静态路径端点(须先于动态路径声明) ----------------
@outbox_router.get("/messages", response_model=dict)
async def list_outbox_messages(
request: Request,
channel_type: ChannelType | None = Query(default=None, description="按渠道类型过滤"),
channel_account_id: str | None = Query(default=None, description="按渠道账户 ID 过滤"),
status: OutboxStatus | None = Query(default=None, description="按状态过滤"),
message_id: str | None = Query(default=None, description="按消息 ID 过滤"),
channel_msg_id: str | None = Query(default=None, description="按渠道侧消息 ID 过滤"),
channel_session_id: str | None = Query(default=None, description="按渠道会话 ID 过滤"),
created_after: str | None = Query(default=None, description="创建时间下界ISO 8601"),
created_before: str | None = Query(default=None, description="创建时间上界ISO 8601"),
retry_count_min: int | None = Query(default=None, ge=0, description="最小重试次数下界(含),用于筛选已发生重试的条目"),
limit: int = Query(default=100, ge=1, le=200, description="分页大小"),
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]:
"""列出 outbox 消息OBX-01。对应控制面操作 outbox/list。"""
operator = build_operator(current_user, request)
created_after_dt = parse_datetime("created_after", created_after)
created_before_dt = parse_datetime("created_before", created_before)
query_filter = OutboxQueryFilter(
channel_type=channel_type,
channel_account_id=channel_account_id,
status=status,
message_id=message_id,
channel_msg_id=channel_msg_id,
channel_session_id=channel_session_id,
created_after=created_after_dt,
created_before=created_before_dt,
retry_count_min=retry_count_min,
)
result = await use_cases.outbox_management.listOutboxEntries(
query_filter,
operator=operator,
limit=limit,
offset=offset,
)
raiseOnControlFailure(result)
return {"success": True, "data": serialize_control_data(result.data)}
@outbox_router.get("/stats", response_model=dict)
async def get_outbox_stats(
request: Request,
channel_type: ChannelType | None = Query(default=None, description="按渠道类型过滤"),
use_cases=Depends(get_channel_use_cases),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""查询 outbox 统计OBX-02。对应控制面操作 outbox/stats。"""
operator = build_operator(current_user, request)
result = await use_cases.outbox_management.getOutboxStats(
channel_type,
operator=operator,
)
raiseOnControlFailure(result)
return {"success": True, "data": serialize_control_data(result.data)}
@outbox_router.get("/trend", response_model=dict)
async def get_outbox_trend(
request: Request,
start_time: str = Query(..., description="起始时间ISO 8601"),
end_time: str = Query(..., description="结束时间ISO 8601"),
channel_type: ChannelType | None = Query(default=None, description="按渠道类型过滤"),
granularity: Literal["minute", "hour", "day"] = Query(default="hour", description="时间粒度"),
metric: Literal["queue_depth", "retry_count", "dead_count"] = Query(default="queue_depth", description="度量指标"),
use_cases=Depends(get_channel_use_cases),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""查询 outbox 投递积压趋势OBX-TREND。对应控制面操作 outbox/trend。
按时间粒度聚合 outbox 条目的时间序列数据点,支持 ``queue_depth`` /
``retry_count`` / ``dead_count`` 三种度量指标。
"""
operator = build_operator(current_user, request)
start_time_dt = parse_datetime("start_time", start_time)
end_time_dt = parse_datetime("end_time", end_time)
query = OutboxTrendQuery(
start_time=start_time_dt,
end_time=end_time_dt,
channel_type=channel_type,
granularity=granularity,
metric=metric,
)
result = await use_cases.outbox_management.getOutboxTrend(query, operator=operator)
raiseOnControlFailure(result)
return {"success": True, "data": serialize_control_data(result.data)}
@outbox_router.get("/dead-letter", response_model=dict)
async def list_dead_letter_outbox(
request: Request,
limit: int = Query(default=100, ge=1, le=200, description="分页大小"),
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]:
"""查询死信队列OBX-04。对应控制面操作 outbox/dead_letter_list。"""
operator = build_operator(current_user, request)
result = await use_cases.outbox_management.listDeadLetterOutboxEntries(
operator=operator,
limit=limit,
offset=offset,
)
raiseOnControlFailure(result)
return {"success": True, "data": serialize_control_data(result.data)}
@outbox_router.get("/retry-policy", response_model=dict)
async def get_outbox_retry_policy(
request: Request,
use_cases=Depends(get_channel_use_cases),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""查询 outbox 重试策略OBX-07。对应控制面操作 outbox/retry_policy。
返回当前生效的 OutboxConfig 快照(通过 holder 读取,反映热更新后的值)。
可通过 ``PUT /outbox/retry-policy`` 热更新策略参数。
"""
operator = build_operator(current_user, request)
result = await use_cases.outbox_management.getOutboxRetryPolicy(operator=operator)
raiseOnControlFailure(result)
return {"success": True, "data": serialize_control_data(result.data)}
class DeadLetterBatchCmd(BaseModel):
"""死信批量操作请求体。
``outbox_ids`` 为 ``None``(未传字段或未传 body时作用于全部死信条目
非空时仅作用于指定条目。显式传空列表 ``[]`` 表示"不操作任何条目"
与 ``None`` 语义不同,避免误触全部操作。
"""
model_config = ConfigDict(frozen=True)
outbox_ids: list[str] | None = Field(default=None, description="指定死信条目 ID 列表;为 None 时作用于全部")
@outbox_router.post("/dead-letter/batch-retry", response_model=dict)
async def batch_retry_dead_letter(
request: Request,
cmd: DeadLetterBatchCmd | None = None,
use_cases=Depends(get_channel_use_cases),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""批量重投死信条目OBX-DL-BATCH-RETRY
``outbox_ids`` 为 ``None`` 时重投全部死信条目,非空时仅重投指定条目。
对应控制面操作 ``outbox/batch_retry``。
"""
operator = build_operator(current_user, request)
outbox_ids = cmd.outbox_ids if cmd else None
result = await use_cases.outbox_management.batchRetryDeadLetter(
operator=operator, outbox_ids=outbox_ids
)
raiseOnControlFailure(result)
return {"success": True, "data": serialize_control_data(result.data)}
@outbox_router.post("/dead-letter/batch-delete", response_model=dict)
async def batch_delete_dead_letter(
request: Request,
cmd: DeadLetterBatchCmd | None = None,
use_cases=Depends(get_channel_use_cases),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""批量清除死信条目OBX-DL-BATCH-DELETE
``outbox_ids`` 为 ``None`` 时删除全部死信条目(逻辑删除,置 is_deleted=1、
deleted_at=now(),保留审计痕迹),非空时仅删除指定条目。
对应控制面操作 ``outbox/batch_delete``。
"""
operator = build_operator(current_user, request)
outbox_ids = cmd.outbox_ids if cmd else None
result = await use_cases.outbox_management.batchDeleteDeadLetter(
operator=operator, outbox_ids=outbox_ids
)
raiseOnControlFailure(result)
return {"success": True, "data": serialize_control_data(result.data)}
@outbox_router.get("/dead-letter/export", response_model=dict)
async def export_dead_letter(
request: Request,
channel_type: ChannelType | None = Query(default=None, description="按渠道类型过滤"),
created_after: str | None = Query(default=None, description="创建时间下界ISO 8601"),
created_before: str | None = Query(default=None, description="创建时间上界ISO 8601"),
export_format: Literal["json", "csv"] = Query(
default="json", alias="format", description="导出格式json / csv"
),
limit: int = Query(default=10000, ge=1, le=100000, description="导出条目上限,防止大规模死信队列 OOM"),
use_cases=Depends(get_channel_use_cases),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""导出死信条目OBX-DL-EXPORT。对应控制面操作 outbox/dead_letter_export。
按过滤条件查询 DEAD 状态 outbox 条目并序列化为 ``json`` / ``csv`` 格式。
``format=json`` 时 ``content`` 为记录数组;``format=csv`` 时 ``content``
为 CSV 字符串(含表头与 CSV injection 防护)。``limit`` 限制单次导出条目数
(默认 10000防止大规模死信队列 OOM。
"""
operator = build_operator(current_user, request)
created_after_dt = parse_datetime("created_after", created_after)
created_before_dt = parse_datetime("created_before", created_before)
cmd = DeadLetterExportCmd(
channel_type=channel_type,
created_after=created_after_dt,
created_before=created_before_dt,
format=export_format,
limit=limit,
)
result = await use_cases.outbox_management.exportDeadLetters(cmd, operator=operator)
raiseOnControlFailure(result)
return {"success": True, "data": serialize_control_data(result.data)}
@outbox_router.put("/retry-policy", response_model=dict)
async def update_outbox_retry_policy(
payload: UpdateRetryPolicyRequest,
request: Request,
use_cases=Depends(get_channel_use_cases),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""更新 outbox 重试策略OBX-POLICY-UPDATE
运行时调整重试参数,用于紧急故障场景下临时调整重试行为。
对应控制面操作 ``outbox/update_retry_policy``。
"""
operator = build_operator(current_user, request)
policy = RetryPolicySnapshot(
max_retry=payload.max_retry,
ttl_seconds=payload.ttl_seconds,
retry_backoff_schedule=tuple(payload.retry_backoff_schedule),
)
result = await use_cases.outbox_management.updateRetryPolicy(
policy=policy,
operator=operator,
)
raiseOnControlFailure(result)
return {"success": True, "data": serialize_control_data(result.data)}
# ---------------- 动态路径端点 ----------------
@outbox_router.get("/messages/{outbox_id}", response_model=dict)
async def get_outbox_message(
outbox_id: str,
request: Request,
use_cases=Depends(get_channel_use_cases),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""查询 outbox 消息详情OBX-03。对应控制面操作 outbox/get。"""
operator = build_operator(current_user, request)
result = await use_cases.outbox_management.getOutboxEntry(
outbox_id,
operator=operator,
)
raiseOnControlFailure(result)
return {"success": True, "data": serialize_control_data(result.data)}
@outbox_router.delete("/dead-letter/{outbox_id}", response_model=dict)
async def delete_dead_letter_outbox(
outbox_id: str,
request: Request,
use_cases=Depends(get_channel_use_cases),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""清除死信条目OBX-05逻辑删除。对应控制面操作 outbox/dead_letter_delete。
采用逻辑删除(置 is_deleted=1、deleted_at=now()),保留审计痕迹。
仅允许对 dead 状态条目执行删除,非 dead 状态抛 RuleViolationError。
"""
operator = build_operator(current_user, request)
result = await use_cases.outbox_management.deleteDeadLetterOutboxEntry(
outbox_id,
operator=operator,
)
raiseOnControlFailure(result)
return {
"success": True,
"data": serialize_control_data(result.data),
}
@outbox_router.post("/messages/{outbox_id}/retry", response_model=dict)
async def retry_outbox_message(
outbox_id: str,
request: Request,
use_cases=Depends(get_channel_use_cases),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""手动重投 outbox 消息OBX-08。对应控制面操作 outbox/retry。
仅允许对 failed/sent_unconfirmed 状态条目重投dead 状态抛
RuleViolationError单条重投不可复活死信INV-3批量重投死信请使用
``POST /outbox/dead-letter/batch-retry``,通过 ``revive()`` 受控复活)。
"""
operator = build_operator(current_user, request)
result = await use_cases.outbox_management.retryOutboxEntry(
outbox_id,
operator=operator,
)
raiseOnControlFailure(result)
return {"success": True, "data": serialize_control_data(result.data)}