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

414 lines
18 KiB
Python
Raw Normal View History

"""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=1deleted_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)}