ForcePilot/backend/server/routers/channels/outbox_router.py
Kris 12ec5af0de feat: 新增全链路追踪、优雅关停支持与统一异常处理,优化渠道查询接口
1. 新增TraceId中间件实现全链路请求追踪
2. 新增在途请求计数器与中间件支持优雅关停
3. 重构全局异常处理器,统一三模块异常响应格式
4. 优化渠道查询接口,新增多维度过滤与模糊匹配
5. 重构channels模块数据库会话管理,避免事务冲突
6. 新增目录搜索建议与目录导出功能
2026-07-07 16:25:47 +08:00

414 lines
18 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 过滤"),
last_error: str | 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"),
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,
status=status,
channel_session_id=channel_session_id,
created_after=created_after_dt,
created_before=created_before_dt,
retry_count_min=retry_count_min,
channel_account_id_like=channel_account_id,
message_id_like=message_id,
channel_msg_id_like=channel_msg_id,
last_error_like=last_error,
)
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="按渠道类型过滤"),
channel_account_id: str | None = Query(default=None, description="按渠道账户 ID 过滤"),
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,
channel_account_id=channel_account_id,
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,
channel_type: ChannelType | None = Query(default=None, description="按渠道类型过滤"),
channel_account_id: str | None = Query(default=None, description="按渠道账户 ID 过滤,支持模糊搜索"),
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 过滤"),
last_error: str | 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"),
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]:
"""查询死信队列OBX-04。对应控制面操作 outbox/dead_letter_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_session_id=channel_session_id,
created_after=created_after_dt,
created_before=created_before_dt,
retry_count_min=retry_count_min,
channel_account_id_like=channel_account_id,
message_id_like=message_id,
channel_msg_id_like=channel_msg_id,
last_error_like=last_error,
)
result = await use_cases.outbox_management.listDeadLetterOutboxEntries(
query_filter,
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)}