- 新增多个业务域的__init__.py模块文件,规范包导出结构 - 调整多个DTO文件的导入路径,统一模块组织方式 - 移除测试文件中多余的空行与导入语句 - 优化部分业务模块的包层级划分
509 lines
19 KiB
Python
509 lines
19 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.messaging.channel import ChannelType
|
||
from yuxi.channels.contract.dtos.outbox.outbox import OutboxStatus
|
||
from yuxi.channels.contract.dtos.plugin.plugin import DomainEvent
|
||
from yuxi.channels.contract.dtos.session.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,
|
||
)
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class MediaDownloadFailedEvent:
|
||
"""媒体下载永久失败事件。
|
||
|
||
描述入站适配器在有限重试后仍无法下载媒体附件(如 bridge 404 在重试
|
||
窗口内未恢复),由适配器发布以触发告警,避免媒体静默丢失。订阅者可
|
||
据此记录审计、发出告警或触发补偿流程。
|
||
|
||
字段:
|
||
account_id: 渠道账户 ID。
|
||
msg_id: bridge 全局唯一消息 ID。
|
||
reason: 失败原因(如 ``not_found_after_retries``)。
|
||
occurred_at: 事件发生时间。
|
||
"""
|
||
|
||
account_id: str
|
||
msg_id: str
|
||
reason: str
|
||
occurred_at: datetime
|
||
|
||
def __post_init__(self) -> None:
|
||
"""校验必填字段非空。"""
|
||
if not self.account_id:
|
||
raise ValidationError("account_id", "must not be empty")
|
||
if not self.msg_id:
|
||
raise ValidationError("msg_id", "must not be empty")
|
||
if not self.reason:
|
||
raise ValidationError("reason", "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="MediaDownloadFailed",
|
||
payload={
|
||
"account_id": self.account_id,
|
||
"msg_id": self.msg_id,
|
||
"reason": self.reason,
|
||
"occurred_at": self.occurred_at.isoformat(),
|
||
},
|
||
timestamp=self.occurred_at,
|
||
trace_id=None,
|
||
)
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class ChannelTransportFailedEvent:
|
||
"""渠道传输永久失败事件(N-M1)。
|
||
|
||
描述 TransportManager 在降级重试耗尽后仍无法恢复账号传输任务,
|
||
由 ``_degradeToPuller`` 在 3 次重试均失败后发布,触发告警。订阅者
|
||
可据此记录审计、发出告警或触发人工介入流程。
|
||
|
||
字段:
|
||
channel_type: 渠道类型。
|
||
account_id: 渠道账户 ID。
|
||
reason: 失败原因(如 ``degrade_failed_after_3_retries``)。
|
||
occurred_at: 事件发生时间。
|
||
"""
|
||
|
||
channel_type: ChannelType
|
||
account_id: str
|
||
reason: str
|
||
occurred_at: datetime
|
||
|
||
def __post_init__(self) -> None:
|
||
"""校验必填字段非空。"""
|
||
if self.channel_type is None or not self.channel_type:
|
||
raise ValidationError("channel_type", "must not be empty")
|
||
if not self.account_id:
|
||
raise ValidationError("account_id", "must not be empty")
|
||
if not self.reason:
|
||
raise ValidationError("reason", "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="ChannelTransportFailed",
|
||
payload={
|
||
"channel_type": str(self.channel_type),
|
||
"account_id": self.account_id,
|
||
"reason": self.reason,
|
||
"occurred_at": self.occurred_at.isoformat(),
|
||
},
|
||
timestamp=self.occurred_at,
|
||
trace_id=None,
|
||
)
|
||
|
||
|
||
__all__ = [
|
||
"OutboxStateChangedEvent",
|
||
"OutboxEntryPurgedEvent",
|
||
"ChannelSessionUpdatedEvent",
|
||
"ChannelMessageReceivedEvent",
|
||
"ChannelMessageSentEvent",
|
||
"ChannelMessagePersistedEvent",
|
||
"ChannelMessageDeliveredEvent",
|
||
"MediaDownloadFailedEvent",
|
||
"ChannelTransportFailedEvent",
|
||
]
|