"""Outbox DTO。 定义发件箱相关的不可变值对象,包括发件箱 ID、发件箱状态枚举、消息 持久化策略枚举、投递回执、多分片回执与发件箱条目。所有 DTO 均为 ``dataclass(frozen=True)``,仅依赖标准库与契约层内部类型,用于 持久化投递、重试控制与死信管理(FR-22)。 """ from __future__ import annotations from dataclasses import dataclass, field from datetime import datetime from enum import StrEnum from typing import TYPE_CHECKING, Any, Literal from yuxi.channels.contract.dtos.channel import ChannelType from yuxi.channels.contract.dtos.common import TrendDataPoint from yuxi.channels.contract.errors import ValidationError if TYPE_CHECKING: from yuxi.channels.contract.dtos.outbound import FormattedMessage @dataclass(frozen=True) class OutboxId: """发件箱 ID。 标识一条发件箱条目的唯一 ID,用于持久化投递追踪与状态同步。 字段: value: 发件箱 ID 字符串。 """ value: str class OutboxStatus(StrEnum): """发件箱状态。 标识发件箱条目的生命周期状态,用于投递流程控制与重试决策。继承 ``str, Enum`` 以支持 JSON 序列化与字符串比较。 取值: PENDING: 待发送。 SENT: 已发送。 SUPPRESSED: 已抑制(栅栏拦截)。 FAILED: 已失败。 SENT_UNCONFIRMED: 已发未确认。 DEAD: 死信(重试超限)。 """ PENDING = "pending" SENT = "sent" SUPPRESSED = "suppressed" FAILED = "failed" SENT_UNCONFIRMED = "sent_unconfirmed" DEAD = "dead" class MessageDurabilityPolicy(StrEnum): """消息持久化策略。 标识消息投递的持久化等级,用于权衡投递可靠性与性能。继承 ``str, Enum`` 以支持 JSON 序列化与字符串比较。 取值: REQUIRED: 必须持久化(强可靠)。 BEST_EFFORT: 尽力持久化。 NONE: 不持久化。 """ REQUIRED = "required" BEST_EFFORT = "best_effort" NONE = "none" @dataclass(frozen=True) class DeliveryReceipt: """投递回执(FR-22)。 描述一次投递的回执信息,包括发件箱 ID、状态、渠道侧消息 ID、重试 次数与下次重试时间,用于投递状态同步与重试调度。 字段: outbox_id: 发件箱 ID。 status: 发件箱状态。 channel_msg_id: 渠道侧消息 ID(可选)。 retry_count: 重试次数(默认 0)。 next_retry_at: 下次重试时间(可选)。 """ outbox_id: str status: OutboxStatus channel_msg_id: str | None = None retry_count: int = 0 next_retry_at: datetime | None = None @dataclass(frozen=True) class MultiPartReceipt: """多分片回执(FR-22)。 描述一条消息在渠道侧拆分为多个分片投递的回执,包括平台消息 ID、 分片类型、序号、可选的话题 / 回复目标 ID 与分片投递结果,用于 多分片消息的状态同步与部分失败判定(FR-22)。 字段: platform_msg_id: 平台消息 ID。 part_type: 分片类型(text / card / attachment)。 sequence: 序号(默认 0)。 topic_id: 话题 ID(可选)。 reply_target_id: 回复目标 ID(可选)。 success: 分片投递是否成功(默认 True)。``False`` 表示该分片 投递失败,``DeliverStage`` 据此判定部分失败。 """ platform_msg_id: str part_type: Literal["text", "card", "attachment"] sequence: int = 0 topic_id: str | None = None reply_target_id: str | None = None success: bool = True @dataclass(frozen=True) class OutboxConfig: """Outbox 配置。 描述发件箱投递相关的可配置参数,由组合根从配置源加载并注入到应用层 阶段,解耦应用层对全局配置的依赖(§6.1 应用服务层禁止依赖具体技术 适配器)。 字段: ttl_seconds: 发件箱条目存活时间(秒),``expires_at`` 缺失时 用于回退计算过期时间。 retry_backoff_schedule: 指数退避序列(秒),由聚合根 ``markFailed`` 计算 ``next_retry_at`` 时使用。 max_retry: 最大重试次数(默认 5,镜像聚合根)。 failed_retry_enabled: 是否启用 FAILED 状态自动重试扫描(默认 True, O-04/H-7)。禁用时恢复扫描器跳过 FAILED 条目,仅由运维通过 batch-retry 端点手动重投。 failed_retry_interval_seconds: FAILED 条目重试最小间隔(秒,默认 300)。 仅扫描 ``updated_at`` 距今超过此间隔的条目,避免刚失败的条目 被立即重试导致风暴。 sent_unconfirmed_timeout_seconds: SENT_UNCONFIRMED 无回执超时(秒, 默认 300)。Scanner 判定 SENT_UNCONFIRMED 消息无回执的超时秒数, 超过此时间视为可能已发送但无回执。 retry_lock_ttl_seconds: Worker 单条消息重试的分布式锁 TTL(秒,默认 60)。覆盖一次网络调用 + DB 持久化 + 事件发布的完整周期。 """ ttl_seconds: int retry_backoff_schedule: tuple[int, ...] max_retry: int = 5 failed_retry_enabled: bool = True failed_retry_interval_seconds: int = 300 sent_unconfirmed_timeout_seconds: int = 300 retry_lock_ttl_seconds: int = 60 def __post_init__(self) -> None: """校验配置参数合法性。""" if self.ttl_seconds <= 0: raise ValidationError("ttl_seconds", "must be a positive integer") if self.max_retry <= 0: raise ValidationError("max_retry", "must be a positive integer") if not self.retry_backoff_schedule: raise ValidationError("retry_backoff_schedule", "must be a non-empty list") if self.failed_retry_interval_seconds <= 0: raise ValidationError( "failed_retry_interval_seconds", "must be a positive integer", ) if self.sent_unconfirmed_timeout_seconds <= 0: raise ValidationError( "sent_unconfirmed_timeout_seconds", "must be a positive integer", ) if self.retry_lock_ttl_seconds <= 0: raise ValidationError( "retry_lock_ttl_seconds", "must be a positive integer", ) @classmethod def default(cls) -> OutboxConfig: """构造默认 OutboxConfig 实例。 集中声明默认值,供装配期在配置缺失时使用,消除应用层 三处重复硬编码(deliver_stage / outbox_mark_failed / outbox_rollback)。 默认值: ttl_seconds: 86400(24 小时)。 retry_backoff_schedule: (60, 120, 300, 600, 1800)(指数退避序列)。 max_retry: 5。 failed_retry_enabled: True(O-04 FAILED 自动重试默认启用)。 failed_retry_interval_seconds: 300(5 分钟最小重试间隔)。 sent_unconfirmed_timeout_seconds: 300(5 分钟无回执超时)。 retry_lock_ttl_seconds: 60(单条重试锁 TTL)。 """ return cls( ttl_seconds=86400, retry_backoff_schedule=(60, 120, 300, 600, 1800), max_retry=5, failed_retry_enabled=True, failed_retry_interval_seconds=300, sent_unconfirmed_timeout_seconds=300, retry_lock_ttl_seconds=60, ) class OutboxConfigHolder: """OutboxConfig 可变持有器,支持运行时热更新。 ``OutboxConfig`` 本身为 ``frozen=True`` 不可变值对象,无法在运行时 修改字段。本持有器封装可变引用,使 ``updateRetryPolicy`` 操作能够 热更新配置,并被同一请求范围内的 handler / pipeline / adapter 共享 读取。跨请求与跨进程一致性通过 ``ConfigPort``(Redis)持久化保证。 使用方式: - 组合根创建 holder 实例并注入到需要读取配置的组件。 - ``OutboxHandler.updateRetryPolicy`` 调用 ``holder.update()`` 热更新。 - 其他组件通过 ``holder.current`` 读取最新配置。 """ def __init__(self, config: OutboxConfig) -> None: self._config = config @property def current(self) -> OutboxConfig: """返回当前持有的 OutboxConfig 实例。""" return self._config def update(self, config: OutboxConfig) -> None: """热更新持有的 OutboxConfig 实例。 参数: config: 新的 OutboxConfig 实例。 """ self._config = config @dataclass(frozen=True) class OutboxEntry: """发件箱条目。 描述一条发件箱条目的完整状态,包括发件箱 ID、消息 ID、渠道账户 ID、状态、持久化策略、重试信息与时间戳,用于持久化投递的全流程 管理(FR-22)。 字段: outbox_id: 发件箱 ID。 message_id: 消息 ID。BEST_EFFORT 轻量记录允许 ``None``(消息持久化 失败但仍需追踪投递状态的降级路径,O-01/C-6)。 channel_account_id: 渠道账户 ID。 status: 发件箱状态。 durability_policy: 消息持久化策略。 retry_count: 重试次数(默认 0)。 max_retry: 最大重试次数(默认 5,镜像聚合根)。 next_retry_at: 下次重试时间(可选)。 last_error: 最近错误信息(可选)。 created_at: 创建时间(可选)。 updated_at: 更新时间(可选)。 expires_at: 过期时间(可选,默认 created_at + 24h,镜像聚合根)。 channel_msg_id: 渠道侧消息 ID(可选,投递成功时填充)。 version: 乐观锁版本号(默认 1,完整更新时用于并发控制)。 channel_session_id: 渠道会话 ID(可选,业务标识 session_id UUID)。 由 ORM ``channel_session_id``(int FK)经 mapper 转换为 ``str`` 业务标识填充。重试 worker 当前仍通过 ORM 反查 ``channel_session_id`` 列(int PK)定位 peer_id,本字段 供其他消费方按业务标识引用会话(FR-22)。 latency_ms: 投递延迟(毫秒,可选)。仅 ``markSent`` 首次调用时 计算,供聚合视图域 analytics 子域延迟分布查询使用。默认 ``None`` 保证向后兼容历史数据。 funnel_node: 漏斗节点(可选,取值 enter / sent / suppressed / failed / dead)。供聚合视图域 analytics 子域漏斗聚合查询 使用。默认 ``None`` 保证向后兼容历史数据。 sent_at: 首次成功投递时间(可选)。``markSent`` 首次调用时写入, 供诊断与 SLA 审计使用。默认 ``None`` 保证向后兼容历史数据。 last_retry_at: 上次重试时间(可选)。``markFailed`` / ``markDeliveryUnconfirmedFailed`` 调用时写入,用于诊断退避 进度与 SLA。默认 ``None`` 保证向后兼容历史数据。 channel_type: 渠道类型(可选)。由列表查询关联 ``channel_accounts`` 表填充,供管理后台展示与级联筛选使用。 idempotency_key: 幂等键(可选)。出站投递幂等控制用,跨重试稳定, 供适配器侧去重与 ``sendMessageContinuation`` 续发定位。 channel_request_id: 渠道请求 ID(可选)。适配器 ``sendMessage`` 等 调用生成的请求标识,供 ``queryMessageByRequestId`` 反查投递结果。 partial_failure: 是否部分失败(默认 False)。多分片投递时部分分片 失败置 True,供下游阶段判定降级与重试策略。 stream_aborted_at_chunk: 流式中断时分片序号(可选)。流式投递被 中断时记录已发送分片序号,供 ``sendMessageContinuation`` 续发。 degraded_reason: 降级原因(可选)。投递降级时记录原因,供可观测性 与审计使用。 delivered_parts: 已成功投递的分片序号列表(默认空列表)。多分片 投递部分失败时记录已投递分片,重试时据此仅发送未投递分片(H-15)。 """ outbox_id: str message_id: str | None channel_account_id: str status: OutboxStatus durability_policy: MessageDurabilityPolicy retry_count: int = 0 max_retry: int = 5 next_retry_at: datetime | None = None last_error: str | None = None created_at: datetime | None = None updated_at: datetime | None = None expires_at: datetime | None = None channel_msg_id: str | None = None version: int = 1 channel_session_id: str | None = None latency_ms: int | None = None funnel_node: Literal["enter", "sent", "suppressed", "failed", "dead"] | None = None sent_at: datetime | None = None last_retry_at: datetime | None = None channel_type: ChannelType | None = None idempotency_key: str | None = None channel_request_id: str | None = None partial_failure: bool = False stream_aborted_at_chunk: int | None = None degraded_reason: str | None = None delivered_parts: list[int] = field(default_factory=list) @dataclass(frozen=True) class RetryContext: """Outbox 重试上下文 DTO(FR-22)。 封装 ``OutboxRetryWorker`` 重试所需的渠道上下文,由 ``PersistencePort.resolveRetryContext`` 解析后返回,消除应用层 对 SQLAlchemy ORM 的直接依赖(INV-1)。 字段: channel_type: 渠道类型字符串(由 manifest 声明,如 ``"feishu"`` / ``"custom"``)。 account_id: 渠道账户业务 ID(str,非 ORM 主键)。 peer_id: 对端 ID(接收方标识)。 message: 完整的格式化消息对象(``FormattedMessage``),保留 ``format`` / ``rich_message`` / ``attachments``,避免重试时 富媒体 / 卡片消息退化为纯文本。 """ channel_type: str account_id: str peer_id: str message: "FormattedMessage" # noqa: UP037 @dataclass(frozen=True) class OutboxQueryFilter: """Outbox 查询过滤条件(OBX-01 / OBX-04)。 描述 outbox 条目列表查询与统计的过滤条件,所有字段可选,未提供时 表示不按该维度过滤。由驱动端口 ``listOutboxEntries`` / ``countOutboxEntries`` 消费,被驱动端口 ``OutboxRepositoryPort`` 实现解析为持久化查询条件。 字段: channel_type: 渠道类型(可选)。 channel_account_id: 渠道账户 ID(可选)。 status: 发件箱状态(可选)。 message_id: 消息 ID(可选)。 channel_msg_id: 渠道侧消息 ID(可选)。 created_after: 创建时间下界(可选,含)。 created_before: 创建时间上界(可选,含)。 channel_session_id: 渠道会话 ID(可选)。 retry_count_min: 最小重试次数下界(可选,含)。仅返回 ``retry_count >= retry_count_min`` 的条目,用于筛选已发生重试 的条目(如排查反复失败的投递)。 message_id_like: 消息 ID 模糊搜索(可选,SQL ``LIKE`` 子串匹配)。 channel_msg_id_like: 渠道侧消息 ID 模糊搜索(可选,SQL ``LIKE``)。 channel_account_id_like: 渠道账户 ID 模糊搜索(可选,SQL ``LIKE``)。 last_error_like: 最近错误关键词筛选(可选,SQL ``LIKE``)。 """ channel_type: ChannelType | None = None channel_account_id: str | None = None status: OutboxStatus | None = None message_id: str | None = None channel_msg_id: str | None = None created_after: datetime | None = None created_before: datetime | None = None channel_session_id: str | None = None retry_count_min: int | None = None message_id_like: str | None = None channel_msg_id_like: str | None = None channel_account_id_like: str | None = None last_error_like: str | None = None def __post_init__(self) -> None: """校验过滤条件合法性。 - ``retry_count_min`` 非空时必须 >= 0。 - ``created_after`` 与 ``created_before`` 同时提供时, ``created_after`` 必须早于 ``created_before``。 """ if self.retry_count_min is not None and self.retry_count_min < 0: raise ValidationError("retry_count_min", "must be a non-negative integer") if ( self.created_after is not None and self.created_before is not None and self.created_after >= self.created_before ): raise ValidationError("time_range", "created_after must be earlier than created_before") def to_dict(self) -> dict[str, Any]: """序列化为 dict,仅包含非 None 字段。 ``channel_type`` 与 ``status`` 序列化为 ``.value`` 字符串, ``datetime`` 序列化为 ISO 8601 字符串,供日志输出与审计记录使用。 """ result: dict[str, Any] = {} if self.channel_type is not None: result["channel_type"] = self.channel_type if self.channel_account_id is not None: result["channel_account_id"] = self.channel_account_id if self.status is not None: result["status"] = self.status.value if self.message_id is not None: result["message_id"] = self.message_id if self.channel_msg_id is not None: result["channel_msg_id"] = self.channel_msg_id if self.created_after is not None: result["created_after"] = self.created_after.isoformat() if self.created_before is not None: result["created_before"] = self.created_before.isoformat() if self.channel_session_id is not None: result["channel_session_id"] = self.channel_session_id if self.retry_count_min is not None: result["retry_count_min"] = self.retry_count_min if self.message_id_like is not None: result["message_id_like"] = self.message_id_like if self.channel_msg_id_like is not None: result["channel_msg_id_like"] = self.channel_msg_id_like if self.channel_account_id_like is not None: result["channel_account_id_like"] = self.channel_account_id_like if self.last_error_like is not None: result["last_error_like"] = self.last_error_like return result @dataclass(frozen=True) class OutboxStats: """Outbox 统计信息(OBX-02)。 描述 outbox 条目按状态分组的计数快照与诊断指标,由 ``getOutboxStats`` 返回,供管理后台展示投递健康度。所有计数仅含 ``is_deleted=0`` 条目。 字段: total: 条目总数。 pending: 待发送条目数。 sent: 已发送条目数。 suppressed: 已抑制条目数。 failed: 已失败条目数。 sent_unconfirmed: 已发未确认条目数。 dead: 死信条目数。 top_errors: 最近错误 Top5,每项含 ``error`` 与 ``count``。 avg_latency_ms: 平均投递延迟(毫秒),无数据时为 ``None``。 oldest_pending_at: 最早 pending 条目创建时间,无 pending 时为 ``None``。 """ total: int pending: int sent: int suppressed: int failed: int sent_unconfirmed: int dead: int top_errors: tuple[dict[str, Any], ...] = () avg_latency_ms: float | None = None oldest_pending_at: datetime | None = None def to_dict(self) -> dict[str, Any]: """序列化为 dict,供控制面结果返回与日志输出使用。""" return { "total": self.total, "pending": self.pending, "sent": self.sent, "suppressed": self.suppressed, "failed": self.failed, "sent_unconfirmed": self.sent_unconfirmed, "dead": self.dead, "top_errors": list(self.top_errors), "avg_latency_ms": self.avg_latency_ms, "oldest_pending_at": self.oldest_pending_at.isoformat() if self.oldest_pending_at else None, } @dataclass(frozen=True) class BatchRetryResult: """批量重投死信结果(OBX-001)。 描述死信批量重投操作的执行结果,包括成功入队数量与失败条目。 字段: retried_count: 成功入队重投的条目数。 failed_count: 重投失败的条目数。 retried_at: 重投执行时间。 """ retried_count: int failed_count: int retried_at: datetime @dataclass(frozen=True) class BatchDeleteResult: """批量删除死信结果(OBX-002)。 描述死信批量删除操作的执行结果,包括成功删除数量。 字段: deleted_count: 成功删除的条目数。 deleted_at: 删除执行时间。 """ deleted_count: int deleted_at: datetime @dataclass(frozen=True) class RetryPolicySnapshot: """重试策略快照(OBX-003)。 描述 Outbox 重试策略的只读快照,用于查询与更新操作返回。 字段: max_retry: 最大重试次数。 ttl_seconds: 条目存活时间(秒)。 retry_backoff_schedule: 指数退避序列(秒)。 updated_at: 策略更新时间(可选,默认策略时为 None)。 """ max_retry: int ttl_seconds: int retry_backoff_schedule: tuple[int, ...] updated_at: datetime | None = None def __post_init__(self) -> None: """校验策略参数合法性。""" if self.max_retry <= 0: raise ValidationError("max_retry", "must be a positive integer") if self.ttl_seconds <= 0: raise ValidationError("ttl_seconds", "must be a positive integer") if not self.retry_backoff_schedule: raise ValidationError("retry_backoff_schedule", "must be a non-empty list") @dataclass(frozen=True) class DeadLetterExportCmd: """死信导出命令(OBX-DL-EXPORT)。 描述死信导出查询条件,由 ``OutboxRepositoryPort.exportDeadLetters`` 消费。 ``format`` 取值 ``json`` / ``csv``,默认 ``json``。 字段: channel_type: 渠道类型过滤(可选)。 created_after: 创建时间下界(可选,含)。 created_before: 创建时间上界(可选,含)。 format: 导出格式(默认 ``json``,``json`` / ``csv``)。 limit: 导出条目上限(默认 10000),防止大规模死信队列 OOM。 """ channel_type: ChannelType | None = None created_after: datetime | None = None created_before: datetime | None = None format: Literal["json", "csv"] = "json" limit: int = 10000 def __post_init__(self) -> None: """校验导出命令参数合法性。 - ``format`` 必须为 ``json`` 或 ``csv``。 - ``created_after`` 与 ``created_before`` 同时提供时, ``created_after`` 必须早于 ``created_before``。 - ``limit`` 必须为正整数。 """ if self.format not in ("json", "csv"): raise ValidationError("format", f"must be 'json' or 'csv', got '{self.format}'") if ( self.created_after is not None and self.created_before is not None and self.created_after >= self.created_before ): raise ValidationError("time_range", "created_after must be earlier than created_before") if not isinstance(self.limit, int) or self.limit <= 0: raise ValidationError("limit", f"must be a positive integer, got {self.limit!r}") @dataclass(frozen=True) class DeadLetterExportResult: """死信导出结果(OBX-DL-EXPORT)。 描述死信导出的返回内容,``content`` 在 ``format=json`` 时为记录元组, 在 ``format=csv`` 时为 CSV 字符串。 字段: format: 导出格式(``json`` / ``csv``)。 filename: 建议的文件名(``dead-letter-{timestamp}.{format}``)。 total_records: 导出记录总数。 content: 导出内容(json 为记录元组,csv 为字符串)。 """ format: str filename: str total_records: int content: tuple[dict[str, Any], ...] | str @dataclass(frozen=True) class OutboxTrendQuery: """投递积压趋势查询(OBX-TREND)。 描述 outbox 积压趋势的查询条件,由 ``OutboxRepositoryPort.getTrend`` 消费。 ``granularity`` 取值 ``minute`` / ``hour`` / ``day``,默认 ``hour``; ``metric`` 取值 ``queue_depth`` / ``retry_count`` / ``dead_count``, 默认 ``queue_depth``。 字段: start_time: 起始时间(必填,含)。 end_time: 结束时间(必填,含)。 channel_type: 渠道类型过滤(可选)。 channel_account_id: 渠道账户业务 ID 过滤(可选)。 granularity: 时间粒度(默认 ``hour``)。 metric: 度量指标(默认 ``queue_depth``)。 """ start_time: datetime end_time: datetime channel_type: ChannelType | None = None channel_account_id: str | None = None granularity: Literal["minute", "hour", "day"] = "hour" metric: Literal["queue_depth", "retry_count", "dead_count"] = "queue_depth" def __post_init__(self) -> None: """校验趋势查询参数合法性。 - ``start_time`` 必须早于 ``end_time``。 - ``granularity`` 必须为 ``minute`` / ``hour`` / ``day``。 - ``metric`` 必须为 ``queue_depth`` / ``retry_count`` / ``dead_count``。 """ if self.start_time >= self.end_time: raise ValidationError("time_range", "start_time must be earlier than end_time") if self.granularity not in ("minute", "hour", "day"): raise ValidationError( "granularity", f"must be 'minute', 'hour' or 'day', got '{self.granularity}'", ) if self.metric not in ("queue_depth", "retry_count", "dead_count"): raise ValidationError( "metric", f"must be 'queue_depth', 'retry_count' or 'dead_count', got '{self.metric}'", ) @dataclass(frozen=True) class OutboxTrendResult: """投递积压趋势结果(OBX-TREND)。 描述按时间粒度聚合后的趋势序列,``series`` 使用通用 ``TrendDataPoint``。 字段: metric: 度量指标。 granularity: 时间粒度。 series: 趋势数据点元组。 """ metric: str granularity: str series: tuple[TrendDataPoint, ...] __all__ = [ "BatchDeleteResult", "BatchRetryResult", "DeadLetterExportCmd", "DeadLetterExportResult", "DeliveryReceipt", "MessageDurabilityPolicy", "MultiPartReceipt", "OutboxConfig", "OutboxConfigHolder", "OutboxEntry", "OutboxId", "OutboxQueryFilter", "OutboxStats", "OutboxStatus", "OutboxTrendQuery", "OutboxTrendResult", "RetryContext", "RetryPolicySnapshot", ]