ForcePilot/backend/server/routers/channels/outbox_router.py
Kris bba1775220 refactor(channel-router): 清理冗余空行并优化代码结构
本次提交包含多类优化:
1.  移除多个路由文件中多余的空导入行,统一代码格式
2.  重构Query参数定义,将长参数拆分为多行提升可读性
3.  新增多个业务端点:
    - 渠道能力画像矩阵查询CAP-03
    - 配对审批计数接口用于待办角标
    - 批量查询对端目录资料接口
    - 向导扫码登录相关端点
    - 会话实时事件SSE推送端点
    - 工作台待办统计接口
4.  完善异常处理逻辑,补充OperationTimeoutError导入并优化NotImplementedError的细节返回
5.  调整路由导入顺序,修复动态路由路径冲突隐患
6.  更新文档注释与接口清单,修正部分接口描述细节
2026-07-06 20:50:03 +08:00

385 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="按渠道类型过滤"),
channel_account_id: str | None = Query(default=None, description="按渠道账户 ID 过滤"),
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,
channel_account_id=channel_account_id,
)
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)}