本次提交包含多项核心改进: 1. 新增微信公众号插件拉取传输模式配置,完善manifest与manifest加载逻辑 2. 新增运行态状态机与幂等冲突错误体系,补充错误映射与领域错误导出 3. 优化出站与入站上下文,新增幂等键、流式中断标记等字段 4. 完善发件箱仓储与模型,新增失败条目查询、投递原子语义字段 5. 修复签名验证阶段异常捕获逻辑,防御性处理内置NotImplementedError 6. 新增出站预算释放方法,完善机器人循环预算管控 7. 优化出站管道格式阶段,新增消息长度校验逻辑 8. 完善出站打字指示器阶段,新增重复启动防御与状态同步 9. 重构出站标记失败阶段,按源状态分支处理状态转换 10. 新增入站幂等过滤阶段,修复入站路由阶段空指针问题 11. 优化出站恢复扫描器,修复状态机调用与聚合根重建逻辑 12. 完善微信公众号适配器,新增类型校验与异常包装 13. 修复数据库事务回滚逻辑,简化不必要的显式回滚操作
409 lines
15 KiB
Python
409 lines
15 KiB
Python
"""领域事件 DTO。
|
||
|
||
定义跨层传递的领域事件值对象,供 ``EventPublisherPort`` 发布与订阅者消费。
|
||
事件载荷为不可变值对象(``dataclass(frozen=True)``),不泄露领域实体引用。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import uuid
|
||
from dataclasses import dataclass
|
||
from datetime import datetime
|
||
from typing import Literal
|
||
|
||
from yuxi.channels.contract.dtos.channel import ChannelType
|
||
from yuxi.channels.contract.dtos.outbox import OutboxStatus
|
||
from yuxi.channels.contract.dtos.plugin import DomainEvent
|
||
from yuxi.channels.contract.dtos.session import ChatType
|
||
from yuxi.channels.contract.errors import ValidationError
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class OutboxStateChangedEvent:
|
||
"""发件箱状态变更事件(OBX-004)。
|
||
|
||
描述一条发件箱条目(``OutboxEntry``)的状态迁移,由应用层在 outbox
|
||
状态机推进时(``markSent`` / ``markFailed`` / ``markSuppressed`` /
|
||
``markSentUnconfirmed`` 等)产出,经 ``EventPublisherPort`` 分发给
|
||
订阅者。状态值取自 ``OutboxStatus`` 枚举的字符串形式,便于跨层序列化
|
||
与审计。
|
||
|
||
字段:
|
||
entry_id: 发件箱条目 ID(``OutboxEntry.outbox_id``)。
|
||
old_state: 变更前状态(``OutboxStatus`` 字符串值)。
|
||
new_state: 变更后状态(``OutboxStatus`` 字符串值)。
|
||
reason: 变更原因(如投递成功、重试失败、栅栏抑制等)。
|
||
occurred_at: 事件发生时间。
|
||
"""
|
||
|
||
entry_id: str
|
||
old_state: OutboxStatus
|
||
new_state: OutboxStatus
|
||
reason: str
|
||
occurred_at: datetime
|
||
|
||
def __post_init__(self) -> None:
|
||
"""校验必填字段非空。
|
||
|
||
``entry_id`` / ``old_state`` / ``new_state`` / ``reason`` 必须非空,
|
||
在构造时即抛出 ``ValidationError``,避免空值事件传播到订阅者
|
||
(INV-8)。
|
||
"""
|
||
if not self.entry_id:
|
||
raise ValidationError("entry_id", "must not be empty")
|
||
if not self.old_state:
|
||
raise ValidationError("old_state", "must not be empty")
|
||
if not self.new_state:
|
||
raise ValidationError("new_state", "must not be empty")
|
||
if not self.reason:
|
||
raise ValidationError("reason", "must not be empty")
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class OutboxEntryPurgedEvent:
|
||
"""发件箱条目被物理清理事件(OBX-005)。
|
||
|
||
描述一批发件箱条目(``OutboxEntry``)被物理删除(purge)的事件,由
|
||
调度器定时清理 dead / sent 终态条目时产出,经 ``EventPublisherPort``
|
||
分发给订阅者。``entry_ids`` 为本次被清理条目的业务 ID 列表,供下游
|
||
做审计、统计与缓存失效等处理。
|
||
|
||
字段:
|
||
entry_ids: 被物理清理的发件箱条目业务 ID 列表(``OutboxEntry.outbox_id``)。
|
||
occurred_at: 事件发生时间。
|
||
"""
|
||
|
||
entry_ids: tuple[str, ...]
|
||
occurred_at: datetime
|
||
|
||
def __post_init__(self) -> None:
|
||
"""校验 entry_ids 非空。
|
||
|
||
``entry_ids`` 必须为非空元组,空列表的清理事件无意义,在构造时即
|
||
抛出 ``ValidationError``,避免空事件传播到订阅者(INV-8)。
|
||
"""
|
||
if not self.entry_ids:
|
||
raise ValidationError("entry_ids", "must not be empty")
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class ChannelSessionUpdatedEvent:
|
||
"""渠道会话状态更新事件。
|
||
|
||
描述渠道会话生命周期变更(创建、关闭、合并、转移所有者),由入站流水线
|
||
或控制面处理器在事务提交后发布。
|
||
|
||
字段:
|
||
session_id: 渠道会话 ID。
|
||
channel_type: 渠道类型。
|
||
channel_account_id: 渠道账户 ID。
|
||
update_type: 更新类型(created / closed / merged / transferred)。
|
||
occurred_at: 事件发生时间。
|
||
"""
|
||
|
||
session_id: str
|
||
channel_type: ChannelType
|
||
channel_account_id: str
|
||
update_type: Literal["created", "closed", "merged", "transferred"]
|
||
occurred_at: datetime
|
||
|
||
def __post_init__(self) -> None:
|
||
"""校验必填字段与枚举范围。"""
|
||
if not self.session_id:
|
||
raise ValidationError("session_id", "must not be empty")
|
||
if not self.channel_account_id:
|
||
raise ValidationError("channel_account_id", "must not be empty")
|
||
if self.channel_type is None or not self.channel_type:
|
||
raise ValidationError("channel_type", "must not be empty")
|
||
if self.update_type not in ("created", "closed", "merged", "transferred"):
|
||
raise ValidationError(
|
||
"update_type",
|
||
"must be one of: created, closed, merged, transferred",
|
||
)
|
||
if self.occurred_at is None:
|
||
raise ValidationError("occurred_at", "must not be None")
|
||
if not isinstance(self.occurred_at, datetime):
|
||
raise ValidationError("occurred_at", "must be a datetime")
|
||
|
||
def toDomainEvent(self) -> DomainEvent:
|
||
"""转换为契约层 ``DomainEvent``。"""
|
||
return DomainEvent(
|
||
event_id=str(uuid.uuid4()),
|
||
event_type="ChannelSessionUpdated",
|
||
payload={
|
||
"session_id": self.session_id,
|
||
"channel_type": str(self.channel_type),
|
||
"channel_account_id": self.channel_account_id,
|
||
"update_type": self.update_type,
|
||
"occurred_at": self.occurred_at.isoformat(),
|
||
},
|
||
timestamp=self.occurred_at,
|
||
trace_id=None,
|
||
)
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class ChannelMessageReceivedEvent:
|
||
"""渠道新用户消息到达事件。
|
||
|
||
描述入站流水线解析到用户消息后产生的事件,供 SSE 连接推送最新消息提醒。
|
||
|
||
字段:
|
||
session_id: 渠道会话 ID。
|
||
conversation_id: 内部会话 ID。
|
||
channel_type: 渠道类型。
|
||
chat_type: 会话类型(p2p / group)。
|
||
content_preview: 脱敏后的消息内容预览(前 120 字符)。
|
||
occurred_at: 事件发生时间。
|
||
"""
|
||
|
||
session_id: str
|
||
conversation_id: str
|
||
channel_type: ChannelType
|
||
chat_type: ChatType
|
||
content_preview: str
|
||
occurred_at: datetime
|
||
|
||
def __post_init__(self) -> None:
|
||
"""校验必填字段与枚举范围。"""
|
||
if not self.session_id:
|
||
raise ValidationError("session_id", "must not be empty")
|
||
if not self.conversation_id:
|
||
raise ValidationError("conversation_id", "must not be empty")
|
||
if self.channel_type is None or not self.channel_type:
|
||
raise ValidationError("channel_type", "must not be empty")
|
||
if self.chat_type not in (ChatType.P2P, ChatType.GROUP):
|
||
raise ValidationError(
|
||
"chat_type",
|
||
"must be one of: p2p, group",
|
||
)
|
||
if self.content_preview is None or not self.content_preview:
|
||
raise ValidationError("content_preview", "must not be empty")
|
||
if self.occurred_at is None:
|
||
raise ValidationError("occurred_at", "must not be None")
|
||
if not isinstance(self.occurred_at, datetime):
|
||
raise ValidationError("occurred_at", "must be a datetime")
|
||
|
||
def toDomainEvent(self) -> DomainEvent:
|
||
"""转换为契约层 ``DomainEvent``。"""
|
||
return DomainEvent(
|
||
event_id=str(uuid.uuid4()),
|
||
event_type="ChannelMessageReceived",
|
||
payload={
|
||
"session_id": self.session_id,
|
||
"conversation_id": self.conversation_id,
|
||
"channel_type": str(self.channel_type),
|
||
"chat_type": self.chat_type.value,
|
||
"content_preview": self.content_preview,
|
||
"occurred_at": self.occurred_at.isoformat(),
|
||
},
|
||
timestamp=self.occurred_at,
|
||
trace_id=None,
|
||
)
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class ChannelMessageSentEvent:
|
||
"""渠道回复/管理员消息已发送事件。
|
||
|
||
描述出站流水线将 assistant 或 admin 消息持久化完成后产生的事件。
|
||
|
||
字段:
|
||
session_id: 渠道会话 ID。
|
||
conversation_id: 内部会话 ID。
|
||
message_id: 消息 ID。
|
||
role: 消息角色(assistant / admin)。
|
||
channel_type: 渠道类型。
|
||
occurred_at: 事件发生时间。
|
||
"""
|
||
|
||
session_id: str
|
||
conversation_id: str
|
||
message_id: str
|
||
role: Literal["assistant", "admin"]
|
||
channel_type: ChannelType
|
||
occurred_at: datetime
|
||
|
||
def __post_init__(self) -> None:
|
||
"""校验必填字段与枚举范围。"""
|
||
if not self.session_id:
|
||
raise ValidationError("session_id", "must not be empty")
|
||
if not self.conversation_id:
|
||
raise ValidationError("conversation_id", "must not be empty")
|
||
if not self.message_id:
|
||
raise ValidationError("message_id", "must not be empty")
|
||
if self.role not in ("assistant", "admin"):
|
||
raise ValidationError(
|
||
"role",
|
||
"must be one of: assistant, admin",
|
||
)
|
||
if self.channel_type is None or not self.channel_type:
|
||
raise ValidationError("channel_type", "must not be empty")
|
||
if self.occurred_at is None:
|
||
raise ValidationError("occurred_at", "must not be None")
|
||
if not isinstance(self.occurred_at, datetime):
|
||
raise ValidationError("occurred_at", "must be a datetime")
|
||
|
||
def toDomainEvent(self) -> DomainEvent:
|
||
"""转换为契约层 ``DomainEvent``。"""
|
||
return DomainEvent(
|
||
event_id=str(uuid.uuid4()),
|
||
event_type="ChannelMessageSent",
|
||
payload={
|
||
"session_id": self.session_id,
|
||
"conversation_id": self.conversation_id,
|
||
"message_id": self.message_id,
|
||
"role": self.role,
|
||
"channel_type": str(self.channel_type),
|
||
"occurred_at": self.occurred_at.isoformat(),
|
||
},
|
||
timestamp=self.occurred_at,
|
||
trace_id=None,
|
||
)
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class ChannelMessagePersistedEvent:
|
||
"""渠道回复/管理员消息已持久化事件。
|
||
|
||
在 outbox 持久化成功后发布,替代 ``ChannelMessageSentEvent`` 的语义
|
||
(后者命名暗示"已发送",但实际发布时机为持久化完成,语义不准确)。
|
||
保留原 ``ChannelMessageSentEvent`` 不删除,避免破坏现有订阅者;新订阅者
|
||
应订阅本事件以获得语义准确的事件流。
|
||
|
||
字段:
|
||
session_id: 渠道会话 ID。
|
||
conversation_id: 内部会话 ID。
|
||
message_id: 消息 ID。
|
||
role: 消息角色(assistant / admin)。
|
||
channel_type: 渠道类型。
|
||
occurred_at: 事件发生时间。
|
||
"""
|
||
|
||
session_id: str
|
||
conversation_id: str
|
||
message_id: str
|
||
role: Literal["assistant", "admin"]
|
||
channel_type: ChannelType
|
||
occurred_at: datetime
|
||
|
||
def __post_init__(self) -> None:
|
||
"""校验必填字段与枚举范围。"""
|
||
if not self.session_id:
|
||
raise ValidationError("session_id", "must not be empty")
|
||
if not self.conversation_id:
|
||
raise ValidationError("conversation_id", "must not be empty")
|
||
if not self.message_id:
|
||
raise ValidationError("message_id", "must not be empty")
|
||
if self.role not in ("assistant", "admin"):
|
||
raise ValidationError(
|
||
"role",
|
||
"must be one of: assistant, admin",
|
||
)
|
||
if self.channel_type is None or not self.channel_type:
|
||
raise ValidationError("channel_type", "must not be empty")
|
||
if self.occurred_at is None:
|
||
raise ValidationError("occurred_at", "must not be None")
|
||
if not isinstance(self.occurred_at, datetime):
|
||
raise ValidationError("occurred_at", "must be a datetime")
|
||
|
||
def toDomainEvent(self) -> DomainEvent:
|
||
"""转换为契约层 ``DomainEvent``。"""
|
||
return DomainEvent(
|
||
event_id=str(uuid.uuid4()),
|
||
event_type="ChannelMessagePersisted",
|
||
payload={
|
||
"session_id": self.session_id,
|
||
"conversation_id": self.conversation_id,
|
||
"message_id": self.message_id,
|
||
"role": self.role,
|
||
"channel_type": str(self.channel_type),
|
||
"occurred_at": self.occurred_at.isoformat(),
|
||
},
|
||
timestamp=self.occurred_at,
|
||
trace_id=None,
|
||
)
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class ChannelMessageDeliveredEvent:
|
||
"""渠道消息已投递事件。
|
||
|
||
在 deliver_stage 成功标记 SENT 后发布,表示消息已实际投递至渠道侧。
|
||
字段在 ``ChannelMessagePersistedEvent`` 基础上增加 ``outbox_id`` 与
|
||
``channel_msg_id``,便于下游关联发件箱条目与渠道侧消息。
|
||
|
||
字段:
|
||
session_id: 渠道会话 ID。
|
||
conversation_id: 内部会话 ID。
|
||
message_id: 消息 ID。
|
||
role: 消息角色(assistant / admin)。
|
||
channel_type: 渠道类型。
|
||
outbox_id: 发件箱条目 ID。
|
||
channel_msg_id: 渠道侧消息 ID。
|
||
occurred_at: 事件发生时间。
|
||
"""
|
||
|
||
session_id: str
|
||
conversation_id: str
|
||
message_id: str
|
||
role: Literal["assistant", "admin"]
|
||
channel_type: ChannelType
|
||
outbox_id: str
|
||
channel_msg_id: str
|
||
occurred_at: datetime
|
||
|
||
def __post_init__(self) -> None:
|
||
"""校验必填字段与枚举范围。"""
|
||
if not self.session_id:
|
||
raise ValidationError("session_id", "must not be empty")
|
||
if not self.conversation_id:
|
||
raise ValidationError("conversation_id", "must not be empty")
|
||
if not self.message_id:
|
||
raise ValidationError("message_id", "must not be empty")
|
||
if self.role not in ("assistant", "admin"):
|
||
raise ValidationError(
|
||
"role",
|
||
"must be one of: assistant, admin",
|
||
)
|
||
if self.channel_type is None or not self.channel_type:
|
||
raise ValidationError("channel_type", "must not be empty")
|
||
if not self.outbox_id:
|
||
raise ValidationError("outbox_id", "must not be empty")
|
||
if not self.channel_msg_id:
|
||
raise ValidationError("channel_msg_id", "must not be empty")
|
||
if self.occurred_at is None:
|
||
raise ValidationError("occurred_at", "must not be None")
|
||
if not isinstance(self.occurred_at, datetime):
|
||
raise ValidationError("occurred_at", "must be a datetime")
|
||
|
||
def toDomainEvent(self) -> DomainEvent:
|
||
"""转换为契约层 ``DomainEvent``。"""
|
||
return DomainEvent(
|
||
event_id=str(uuid.uuid4()),
|
||
event_type="ChannelMessageDelivered",
|
||
payload={
|
||
"session_id": self.session_id,
|
||
"conversation_id": self.conversation_id,
|
||
"message_id": self.message_id,
|
||
"role": self.role,
|
||
"channel_type": str(self.channel_type),
|
||
"outbox_id": self.outbox_id,
|
||
"channel_msg_id": self.channel_msg_id,
|
||
"occurred_at": self.occurred_at.isoformat(),
|
||
},
|
||
timestamp=self.occurred_at,
|
||
trace_id=None,
|
||
)
|
||
|
||
|
||
__all__ = [
|
||
"OutboxStateChangedEvent",
|
||
"OutboxEntryPurgedEvent",
|
||
"ChannelSessionUpdatedEvent",
|
||
"ChannelMessageReceivedEvent",
|
||
"ChannelMessageSentEvent",
|
||
"ChannelMessagePersistedEvent",
|
||
"ChannelMessageDeliveredEvent",
|
||
]
|