1. 为渠道账户ID查询添加最小长度校验,统一分析模块常量引用 2. 新增扫码登录向导端点,完善文档说明 3. 优化配对统计接口,移除无效参数 4. 为出站箱接口添加批量上限与202状态码 5. 新增测试用例、访问规则、配额等模块的查询与校验参数 6. 新增适配器健康批量查询、健康检查触发接口 7. 统一告警、审计日志的错误处理方式 8. 新增插件配置账户ID支持,优化批量操作响应 9. 新增环境健康批量查询、Webhook限流与参数校验 10. 完善会话管理、审计日志的参数与文档说明 11. 修复导入模块的校验错误处理逻辑
422 lines
18 KiB
Python
422 lines
18 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 (
|
||
EXPORT_LIMIT,
|
||
LARGE_LIMIT,
|
||
OFFSET,
|
||
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,
|
||
max_length=20,
|
||
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 = LARGE_LIMIT,
|
||
offset: int = OFFSET,
|
||
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 = LARGE_LIMIT,
|
||
offset: int = OFFSET,
|
||
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,
|
||
max_length=500,
|
||
description="指定死信条目 ID 列表;为 None 时作用于全部(上限 500)",
|
||
)
|
||
|
||
|
||
@outbox_router.post("/dead-letter/batch-retry", response_model=dict, status_code=202)
|
||
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)"),
|
||
export_limit: int = EXPORT_LIMIT,
|
||
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 防护)。``export_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=export_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, status_code=202)
|
||
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)}
|