"""会话命令 DTO。 定义会话端口方法引用的命令与结果值对象,包括会话 ID、消息 ID、保存消息 命令、解析会话命令、关联会话命令、合并会话命令与合并结果。所有 DTO 均为 ``dataclass(frozen=True)``,仅依赖标准库与契约层内部类型,用于会话端口 的命令传递与结果返回。 """ from __future__ import annotations from dataclasses import dataclass, field from datetime import datetime from typing import Any, Literal from yuxi.channels.contract.dtos.channel import ChannelType from yuxi.channels.contract.dtos.common import Operator from yuxi.channels.contract.dtos.session import ChatType from yuxi.channels.contract.errors import ValidationError @dataclass(frozen=True) class ConversationId: """会话 ID。 标识内部会话的唯一 ID,用于会话定位、消息持久化与路由绑定。 字段: value: 会话 ID 字符串。 """ value: str @dataclass(frozen=True) class MessageId: """消息 ID。 标识一条消息的唯一 ID,用于消息持久化、状态同步与审计关联。 字段: value: 消息 ID 字符串。 """ value: str @dataclass(frozen=True) class SaveMessageCmd: """保存消息命令(FR-09)。 由端口方法 ``ConversationPort.saveMessage`` 引用,用于将一条消息持久化 至指定会话,携带角色、内容、渠道侧消息 ID、追踪 ID 与渠道侧初始状态 字段。渠道侧初始状态字段用于出站消息创建时一次性填充渠道扩展字段 (FR-09),后续状态变更走 ``ConversationPort.updateMessageChannelStatus``。 字段: conversation_id: 目标会话 ID。 role: 角色(user | assistant | admin)。 content: 消息文本内容。 channel_msg_id: 渠道侧消息 ID(可选)。 trace_id: 追踪 ID(可选)。 channel_status: 渠道侧初始状态(可选,如 ``sent``)。 ref_channel_msg_id: 引用的渠道消息 ID(可选,编辑/回复场景)。 channel_status_history: 初始状态历史数组(可选)。 """ conversation_id: str role: Literal["user", "assistant", "admin"] content: str channel_msg_id: str | None = None trace_id: str | None = None channel_status: Literal["sent", "delivered", "read", "recalled", "edited"] | None = None ref_channel_msg_id: str | None = None channel_status_history: tuple[dict, ...] | None = None def __post_init__(self) -> None: """校验必填字段非空与角色取值。 ``conversation_id`` 与 ``content`` 必须非空,``role`` 必须为 ``user`` / ``assistant`` / ``admin`` 之一,在构造时即抛出 ``ValidationError``,adapter 不再做该校验(INV-8)。 """ if not self.conversation_id: raise ValidationError("conversation_id", "must not be empty") if not self.content: raise ValidationError("content", "must not be empty") if self.role not in ("user", "assistant", "admin"): raise ValidationError( "role", "must be one of: user, assistant, admin", ) @dataclass(frozen=True) class ResolveConversationCmd: """解析会话命令(FR-06)。 由端口方法 ``ConversationPort.resolveConversation`` 引用,根据对端 ID、 渠道类型、账户 ID 与会话类型解析或创建会话,可选携带统一身份 ID。 字段: peer_id: 对端 ID。 channel_type: 渠道类型。 account_id: 渠道账户 ID。 chat_type: 会话类型。 unified_identity_id: 统一身份 ID(可选)。 非空且策略为"关联"时,适配器按此 ID 查询已有跨渠道会话, 命中则复用,未命中则创建新会话并写入该 ID(FR-06)。 create_if_not_found: 未找到会话时是否创建新会话(默认 False)。 为 True 时适配器在未找到已绑定会话的情况下创建新 Conversation 并返回其 ID,不再抛出 NotFoundError(FR-06)。 user_id: 真实用户 ID(可选,FR-06 语义变更)。 身份解析到真实用户时传入,创建新会话时写入 Conversation.uid; 为空时由 ``__post_init__`` 回退到 ``peer_id``,构造完成后保证非空(FR-06 §7.1.2)。 new_conversation_status: 新建会话状态(可选,默认 ``"active"``)。 由应用层调 ``Conversation.create()`` 聚合根工厂填充,适配器 直接消费不再 import core.model(§4.6 / INV-8)。 new_conversation_title: 新建会话标题(可选,默认 ``"New Conversation"``)。 new_conversation_agent_id: 新建会话绑定代理 ID(可选,默认空字符串)。 new_conversation_extra_metadata: 新建会话附加元数据(可选,默认 ``{"attachments": []}``)。 """ peer_id: str channel_type: ChannelType account_id: str chat_type: ChatType unified_identity_id: str | None = None create_if_not_found: bool = False user_id: str | None = None new_conversation_status: str = "active" new_conversation_title: str = "New Conversation" new_conversation_agent_id: str = "" new_conversation_extra_metadata: dict[str, Any] = field(default_factory=lambda: {"attachments": []}) def __post_init__(self) -> None: """应用 FR-06 §7.1.2 身份回退规则。 ``user_id`` 为 None 时回退到 ``peer_id``,构造完成后保证非空。 适配器直接消费 ``cmd.user_id``,不再做条件分支(INV-8)。 """ if self.user_id is None: object.__setattr__(self, "user_id", self.peer_id) @dataclass(frozen=True) class AssociateConversationCmd: """关联会话命令(FR-06)。 由端口方法 ``ConversationPort.associateConversation`` 引用,将统一身份 与路由绑定关联至指定渠道会话,支持身份级路由匹配。 字段: unified_identity_id: 统一身份 ID。 route_binding: 路由绑定信息。 channel_session_id: 渠道会话 ID(可选)。 """ unified_identity_id: str route_binding: dict[str, Any] channel_session_id: str | None = None def __post_init__(self) -> None: """校验必填字段非空。 ``unified_identity_id`` 必须非空,在构造时即抛出 ``ValidationError``,adapter 不再做该校验(INV-8)。 """ if not self.unified_identity_id: raise ValidationError("unified_identity_id", "must not be empty") @dataclass(frozen=True) class MergeConversationCmd: """合并会话命令(FR-07)。 由端口方法 ``ConversationPort.mergeConversations`` 引用,将源会话合并 至目标会话,需记录操作人与合并原因以满足审计要求。 字段: source_conversation_id: 源会话 ID。 target_conversation_id: 目标会话 ID。 operator: 操作人(审计用)。 reason: 合并原因(审计用)。 """ source_conversation_id: str target_conversation_id: str operator: Operator reason: str def __post_init__(self) -> None: """校验源/目标会话不相同(FR-07)。 源与目标相同将导致消息迁移至自身、源会话被软删除的逻辑矛盾, 在构造时即抛出 ``ValidationError``,adapter 不再做该校验(INV-8)。 """ if self.source_conversation_id == self.target_conversation_id: raise ValidationError( "target_conversation_id", "source conversation and target conversation must not be the same", ) @dataclass(frozen=True) class MergeResult: """合并结果(FR-07)。 描述会话合并操作的执行结果,包括迁移消息数量、源会话软删除标记与 审计日志 ID,用于结果聚合与审计追溯。 字段: migrated_message_count: 迁移的消息数量。 source_soft_deleted: 源会话是否已软删除。 audit_log_id: 审计日志 ID(可选)。 """ migrated_message_count: int source_soft_deleted: bool audit_log_id: str | None = None @dataclass(frozen=True) class UpdateMessageChannelStatusCmd: """渠道侧消息状态回写命令(FR-09)。 由端口方法 ``ConversationPort.updateMessageChannelStatus`` 引用,用于 渠道回调或状态回写阶段更新消息的渠道侧状态。命令携带目标消息 ID、新 状态、追加事件条目与按状态填充的时间戳。 状态变更语义: - ``channel_status`` 取值:``sent`` / ``delivered`` / ``read`` / ``recalled`` / ``edited``。 - ``event`` 追加到 ``channel_status_history`` 数组,应包含 ``status`` / ``at`` 等字段。 - 时间戳字段按状态填充:``read`` → ``read_at``,``recalled`` → ``recalled_at``,``edited`` → ``edited_at``。 - ``channel_msg_id`` 为可选字段,用于"持久化记录创建消息"场景(FR-09 增强):当通过 OutboxEntry 反查到 Message 但 Message 的 ``channel_msg_id`` 未初始化时,由状态处理器传入以补全字段。 字段: message_id: 目标消息 ID。 channel_status: 新的渠道状态。 event: 追加到 ``channel_status_history`` 的事件条目。 read_at: 已读时间(可选,``channel_status='read'`` 时填充)。 recalled_at: 撤回时间(可选,``channel_status='recalled'`` 时填充)。 edited_at: 编辑时间(可选,``channel_status='edited'`` 时填充)。 channel_msg_id: 渠道侧消息 ID(可选,用于补全 Message 的 channel_msg_id 字段)。 trace_id: 追踪 ID(可选,DTO 层透传,用于状态回写链路追踪,6-P0-03)。 """ message_id: str channel_status: Literal["sent", "delivered", "read", "recalled", "edited"] event: dict[str, Any] read_at: datetime | None = None recalled_at: datetime | None = None edited_at: datetime | None = None channel_msg_id: str | None = None trace_id: str | None = None # 追踪 ID,用于状态回写链路追踪(DTO 层透传) def __post_init__(self) -> None: """校验必填字段非空与渠道状态取值(FR-09)。 ``message_id`` 必须非空,``channel_status`` 必须为 ``sent`` / ``delivered`` / ``read`` / ``recalled`` / ``edited`` 之一,在构造 时即抛出 ``ValidationError``,adapter 不再做该校验(INV-8)。 """ if not self.message_id: raise ValidationError("message_id", "must not be empty") if self.channel_status not in ( "sent", "delivered", "read", "recalled", "edited", ): raise ValidationError( "channel_status", "must be one of: sent, delivered, read, recalled, edited", ) @dataclass(frozen=True) class AppendOperationHistoryCmd: """追加消息操作历史命令(FR-12)。 由端口方法 ``ConversationPort.appendOperationHistory`` 引用,用于消息 操作执行器在操作完成后将操作记录追加到目标消息的 ``operations_history`` JSON 数组,满足 PRD §FR-12 业务规则第 5 条"操作历史记录到消息元数据的 操作列表"。 字段: message_id: 目标消息 ID。 entry: 操作历史条目,应包含 ``operation`` / ``at`` / ``success`` / ``sender_id`` 等字段。 """ message_id: str entry: dict[str, Any] def __post_init__(self) -> None: """校验必填字段非空(FR-12)。 ``message_id`` 必须非空,在构造时即抛出 ``ValidationError``, adapter 不再做该校验(INV-8)。 """ if not self.message_id: raise ValidationError("message_id", "must not be empty")