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