"""领域事件 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", ]