ForcePilot/backend/package/yuxi/channels/contract/dtos/outbox.py

693 lines
27 KiB
Python
Raw Normal View History

"""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 无回执超时
默认 300Scanner 判定 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: 8640024 小时
retry_backoff_schedule: (60, 120, 300, 600, 1800)指数退避序列
max_retry: 5
failed_retry_enabled: TrueO-04 FAILED 自动重试默认启用
failed_retry_interval_seconds: 3005 分钟最小重试间隔
sent_unconfirmed_timeout_seconds: 3005 分钟无回执超时
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: 消息 IDBEST_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 重试上下文 DTOFR-22
封装 ``OutboxRetryWorker`` 重试所需的渠道上下文
``PersistencePort.resolveRetryContext`` 解析后返回消除应用层
SQLAlchemy ORM 的直接依赖INV-1
字段
channel_type: 渠道类型字符串 manifest 声明 ``"feishu"`` / ``"custom"``
account_id: 渠道账户业务 IDstr 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",
]