1. 移除多个导出接口的显式response_model声明 2. 调整access_rule和test_case的创建接口位置,修复静态路径冲突 3. 优化适配器配置校验的异常处理逻辑 4. 重构集成路由的查询逻辑,统一使用get_integration_or_raise 5. 新增channels路由组下的capability、reports、dashboard、webhook、wizard、doctor、directory、session共8个子路由模块 6. 注册channels_router到全局路由列表
347 lines
15 KiB
Python
347 lines
15 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
|
||
|
||
from fastapi import APIRouter, Depends, Query, Request
|
||
from pydantic import BaseModel, ConfigDict, Field
|
||
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(
|
||
default_factory=list,
|
||
description="指数退避序列(秒),为空时使用默认退避策略",
|
||
)
|
||
|
||
|
||
# ---------------- 静态路径端点(须先于动态路径声明) ----------------
|
||
|
||
|
||
@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 过滤"),
|
||
created_after: str | None = Query(default=None, description="创建时间下界(ISO 8601)"),
|
||
created_before: str | None = Query(default=None, description="创建时间上界(ISO 8601)"),
|
||
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,
|
||
created_after=created_after_dt,
|
||
created_before=created_before_dt,
|
||
)
|
||
result = await use_cases.outbox_management.listOutboxEntries(
|
||
query_filter,
|
||
limit,
|
||
offset,
|
||
operator=operator,
|
||
)
|
||
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: str = Query(default="hour", description="时间粒度(minute / hour / day)"),
|
||
metric: str = Query(default="queue_depth", description="度量指标(queue_depth / retry_count / dead_count)"),
|
||
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(
|
||
limit,
|
||
offset,
|
||
operator=operator,
|
||
)
|
||
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 快照,不支持运行时修改。
|
||
"""
|
||
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)}
|
||
|
||
|
||
@outbox_router.post("/dead-letter/batch-retry", response_model=dict)
|
||
async def batch_retry_dead_letter(
|
||
request: Request,
|
||
use_cases=Depends(get_channel_use_cases),
|
||
current_user: User = Depends(get_admin_user),
|
||
) -> dict[str, Any]:
|
||
"""批量重投死信条目(OBX-DL-BATCH-RETRY)。
|
||
|
||
对所有死信条目执行批量重投:FAILED/SENT_UNCONFIRMED 原地重投,
|
||
DEAD 条目新建条目重投(INV-3 死信不可复活)。
|
||
对应控制面操作 ``outbox/batch_retry``。
|
||
"""
|
||
operator = build_operator(current_user, request)
|
||
result = await use_cases.outbox_management.batchRetryDeadLetter(operator=operator)
|
||
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,
|
||
use_cases=Depends(get_channel_use_cases),
|
||
current_user: User = Depends(get_admin_user),
|
||
) -> dict[str, Any]:
|
||
"""批量清除死信条目(OBX-DL-BATCH-DELETE)。
|
||
|
||
逻辑删除所有死信条目(置 is_deleted=1、deleted_at=now()),保留审计痕迹。
|
||
对应控制面操作 ``outbox/batch_delete``。
|
||
"""
|
||
operator = build_operator(current_user, request)
|
||
result = await use_cases.outbox_management.batchDeleteDeadLetter(operator=operator)
|
||
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)"),
|
||
format: str = Query(default="json", description="导出格式(json / csv)"),
|
||
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 防护)。
|
||
"""
|
||
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=format,
|
||
)
|
||
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=list(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 聚合根不变量)。
|
||
"""
|
||
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)}
|