"""Outbox 管理路由(OBX-01~OBX-08 + P0缺口)。 统一采用模板 A(控制面端口路由),鉴权依赖 get_admin_user,调用 use_cases.outbox_management.,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)}