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相关端点,重构路由路径与校验逻辑
387 lines
17 KiB
Python
387 lines
17 KiB
Python
"""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)}
|