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

272 lines
10 KiB
Python
Raw Normal View History

"""领域事件 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,
)
__all__ = [
"OutboxStateChangedEvent",
"OutboxEntryPurgedEvent",
"ChannelSessionUpdatedEvent",
"ChannelMessageReceivedEvent",
"ChannelMessageSentEvent",
]