"""持久化命令 DTO。 定义持久化端口的命令值对象,包括保存 / 更新渠道账户、保存 / 更新渠道 会话、创建配对、保存审计日志、保存发件箱条目与保存用户身份等命令。 所有 DTO 均为 ``dataclass(frozen=True)``,仅依赖标准库与契约层内部类型, 用于持久化端口的命令传递。 """ from __future__ import annotations from dataclasses import dataclass from datetime import datetime from typing import Any, Literal from yuxi.channels.contract.dtos.channel import ( AccountStatus, ChannelType, OnboardingStatus, ) from yuxi.channels.contract.dtos.outbox import MessageDurabilityPolicy from yuxi.channels.contract.errors import ValidationError @dataclass(frozen=True) class SaveChannelAccountCmd: """保存渠道账户命令(AL-01)。 由持久化端口方法引用,描述一次渠道账户保存请求,携带渠道类型、账户 ID、显示名称、配置、启用状态与运行时状态机值,用于账户创建与更新。 字段: channel_type: 渠道类型。 account_id: 渠道账户 ID。 display_name: 显示名称。 config: 渠道配置。 enabled: 是否启用(默认 True)。 status: 运行时状态机值(默认 None)。由 ``account/create`` handler 通过聚合根 ``ChannelAccount.create()`` 计算后显式 传入,确保 DB 初始状态与聚合根不变量一致 (``status = ACTIVE if enabled else DISABLED``)。为 ``None`` 时 adapter 回落到 DB 列默认值 ``"active"``,仅用于向后兼容 非聚合根路径的调用方。 service_user_uid: 关联服务账号 User.uid(user_type='service'), 可选。为 ``None`` 时不在写入数据中包含该字段,由 DB 列默认 ``NULL`` 兜底。 onboarding_status: 接入态状态机值(默认 ``PENDING``)。描述账号 onboarding 生命周期,与运行态 ``status`` 解耦。 credential_ref: 凭证引用(可选),指向凭证存储中的版本化凭证记录。 credential_version: 凭证版本号(默认 0),与 ``credential_ref`` 共同 定位具体凭证版本。 """ channel_type: ChannelType account_id: str display_name: str config: dict[str, Any] enabled: bool = True status: AccountStatus | None = None service_user_uid: str | None = None onboarding_status: OnboardingStatus = OnboardingStatus.PENDING credential_ref: str | None = None credential_version: int = 0 def __post_init__(self) -> None: """校验必填字段非空。 ``account_id`` 与 ``display_name`` 必须非空,在构造时即抛出 ``ValidationError``,adapter 不再做该校验(INV-8)。 """ if not self.account_id: raise ValidationError("account_id", "must not be empty") if not self.display_name: raise ValidationError("display_name", "must not be empty") @dataclass(frozen=True) class UpdateChannelAccountCmd: """更新渠道账户命令(AL-01)。 由持久化端口方法引用,描述一次渠道账户更新请求,仅更新提供的字段, 支持显示名称、配置、启用状态与运行时状态机的局部更新。 字段: channel_type: 渠道类型。 account_id: 渠道账户 ID。 display_name: 显示名称(可选)。 config: 渠道配置(可选)。 enabled: 是否启用(可选)。 status: 运行时状态机值(可选)。由 ``account/enable`` / ``account/disable`` 等状态变更操作传入,用于持久化聚合根 状态机变更结果。为 ``None`` 时表示不更新状态机字段。 transport_cursor: 传输游标(可选,Puller类型使用)。 last_rotated_at: 凭据最近轮换时间(可选)。 onboarding_status: 接入态状态机值(可选)。为 ``None`` 时表示不更新 该字段。 credential_ref: 凭证引用(可选)。为 ``None`` 时表示不更新该字段。 credential_version: 凭证版本号(可选)。为 ``None`` 时表示不更新该 字段。 last_error: 最近错误描述(可选)。用于健康检查协同(Task 22)记录 网络故障或凭证失效原因,为 ``None`` 时表示不更新该字段。 """ channel_type: ChannelType account_id: str display_name: str | None = None config: dict[str, Any] | None = None enabled: bool | None = None status: AccountStatus | None = None transport_cursor: str | None = None last_rotated_at: datetime | None = None onboarding_status: OnboardingStatus | None = None credential_ref: str | None = None credential_version: int | None = None last_error: str | None = None def __post_init__(self) -> None: """校验必填字段非空与至少一个可更新字段(AL-01)。 ``account_id`` 必须非空,且 ``display_name`` / ``config`` / ``enabled`` / ``status`` / ``transport_cursor`` / ``last_rotated_at`` / ``onboarding_status`` / ``credential_ref`` / ``credential_version`` / ``last_error`` 至少提供一个,否则无更新意义,在构造时即抛出 ``ValidationError``,adapter 不再做该校验(INV-8)。 """ if not self.account_id: raise ValidationError("account_id", "must not be empty") if all( f is None for f in ( self.display_name, self.config, self.enabled, self.status, self.transport_cursor, self.last_rotated_at, self.onboarding_status, self.credential_ref, self.credential_version, self.last_error, ) ): raise ValidationError( "update", "at least one of display_name/config/enabled/status/transport_cursor/" "last_rotated_at/onboarding_status/credential_ref/credential_version/" "last_error must be provided", ) @dataclass(frozen=True) class SaveChannelSessionCmd: """保存渠道会话命令(AL-02)。 由持久化端口方法引用,描述一次渠道会话保存请求,携带渠道类型、账户 ID、对端 ID、会话类型与可选的会话关联信息,用于会话创建与更新。 字段: channel_type: 渠道类型。 account_id: 渠道账户 ID。 peer_id: 对端 ID。 chat_type: 会话类型(p2p | group)。 conversation_id: 关联的内部会话 ID(可选)。 unified_identity_id: 统一身份 ID(可选)。 owner_peer_id: 主会话所有者对端 ID(可选,FR-26)。 is_temporary: 临时会话标记(默认 False,FR-27)。 """ channel_type: ChannelType account_id: str peer_id: str chat_type: Literal["p2p", "group"] conversation_id: str | None = None unified_identity_id: str | None = None owner_peer_id: str | None = None is_temporary: bool = False def __post_init__(self) -> None: """校验必填字段非空与会话类型取值。 ``account_id`` 与 ``peer_id`` 必须非空,``chat_type`` 必须为 ``p2p`` / ``group`` 之一,在构造时即抛出 ``ValidationError``, adapter 不再做该校验(INV-8)。 """ if not self.account_id: raise ValidationError("account_id", "must not be empty") if not self.peer_id: raise ValidationError("peer_id", "must not be empty") if self.chat_type not in ("p2p", "group"): raise ValidationError( "chat_type", "must be one of: p2p, group", ) @dataclass(frozen=True) class UpdateChannelSessionCmd: """更新渠道会话命令(AL-02)。 由持久化端口方法引用,描述一次渠道会话更新请求,仅更新提供的字段, 支持会话关联信息与临时标记的局部更新。 字段: session_id: 会话 ID。 conversation_id: 关联的内部会话 ID(可选)。 unified_identity_id: 统一身份 ID(可选)。 owner_peer_id: 主会话所有者对端 ID(可选)。 is_temporary: 临时会话标记(可选)。 closed_at: 会话关闭时间(可选)。 """ session_id: str conversation_id: str | None = None unified_identity_id: str | None = None owner_peer_id: str | None = None is_temporary: bool | None = None closed_at: datetime | None = None def __post_init__(self) -> None: """校验必填字段非空与至少一个可更新字段。 ``session_id`` 必须非空,且 ``conversation_id`` / ``unified_identity_id`` / ``owner_peer_id`` / ``is_temporary`` / ``closed_at`` 至少提供一个,否则无更新意义,在构造时即抛出 ``ValidationError``,adapter 不再做该校验(INV-8)。 """ if not self.session_id: raise ValidationError("session_id", "must not be empty") if all( f is None for f in ( self.conversation_id, self.unified_identity_id, self.owner_peer_id, self.is_temporary, self.closed_at, ) ): raise ValidationError( "update", "at least one updatable field must be provided", ) @dataclass(frozen=True) class CreatePairingCmd: """创建配对命令(FR-33)。 由持久化端口方法引用,描述一次配对创建请求,携带渠道类型、账户 ID、 对端 ID、对端名称与过期时间,用于 DM 安全与配对审批(FR-33)。 字段: channel_type: 渠道类型。 account_id: 渠道账户 ID。 peer_id: 对端 ID。 peer_name: 对端名称(可选)。 expires_in_seconds: 过期时间(秒,默认 604800 即 7 天,FR-33)。 """ channel_type: ChannelType account_id: str peer_id: str peer_name: str | None = None expires_in_seconds: int = 604800 def __post_init__(self) -> None: """校验必填字段非空与过期时间合理性(FR-33)。 ``account_id`` 与 ``peer_id`` 必须非空,``expires_in_seconds`` 必须 为正整数,在构造时即抛出 ``ValidationError``,adapter 不再做该校验 (INV-8)。 """ if not self.account_id: raise ValidationError("account_id", "must not be empty") if not self.peer_id: raise ValidationError("peer_id", "must not be empty") if self.expires_in_seconds <= 0: raise ValidationError("expires_in_seconds", "must be positive") @dataclass(frozen=True) class SaveAuditLogCmd: """保存审计日志命令(FR-34)。 由持久化端口方法引用,描述一次审计日志保存请求,携带操作人、操作类型、 目标、参数摘要、结果与追踪信息,用于审计日志持久化(FR-34)。 字段: operator: 操作人。 operation: 操作类型。 target: 操作目标。 result: 操作结果(success | failed)。 params_summary: 参数摘要(可选)。 trace_id: 追踪 ID(可选)。 source_ip: 来源 IP(可选)。 request_id: 请求 ID(可选)。 message_id: 关联消息 ID(可选,FR-19 管理员消息审计)。 content_summary: 消息内容摘要(可选,FR-19 管理员消息审计)。 target_channel: 目标渠道类型(可选,FR-34 审计日志按渠道作用域)。 target_account: 目标账户 ID(可选,FR-34 按账户作用域查询)。 与 ``target_channel`` 共同确定审计作用域,供 ``AuditQuery.target_account`` 过滤生效。 """ operator: str operation: str target: str result: Literal["success", "failed"] params_summary: dict[str, Any] | None = None trace_id: str | None = None source_ip: str | None = None request_id: str | None = None message_id: str | None = None content_summary: str | None = None target_channel: str | None = None target_account: str | None = None def __post_init__(self) -> None: """校验必填字段非空(FR-34)。 ``operator`` / ``operation`` / ``target`` / ``result`` 必须非空, 在构造时即抛出 ``ValidationError``,adapter 不再做该校验(INV-8)。 ``result`` 取值不限定枚举,实际场景覆盖 ``success`` / ``failed`` / ``degraded`` / ``recovered`` 等多状态。 """ if not self.operator: raise ValidationError("operator", "must not be empty") if not self.operation: raise ValidationError("operation", "must not be empty") if not self.target: raise ValidationError("target", "must not be empty") if not self.result: raise ValidationError("result", "must not be empty") @dataclass(frozen=True) class SaveOutboxEntryCmd: """保存发件箱条目命令(FR-22)。 由持久化端口方法引用,描述一次发件箱条目保存请求,携带消息 ID、渠道 账户 ID、持久化策略与追踪 ID,用于持久化投递(FR-22)。 字段: message_id: 消息 ID。 channel_account_id: 渠道账户 ID。 durability_policy: 持久化策略(required | best_effort | none)。 trace_id: 追踪 ID(可选)。 channel_session_id: 渠道会话 ID(可选,业务标识 session_id UUID)。 由出站管道从 ``OutboundContext.channel_session_id`` 透传, persistence 适配器据此解析 ORM 主键 int 并写入 ``channel_outbox_entries.channel_session_id`` 列,供重试 worker 按 session 精确定位 peer_id(FR-22)。 """ message_id: str channel_account_id: str durability_policy: MessageDurabilityPolicy trace_id: str | None = None channel_session_id: str | None = None def __post_init__(self) -> None: """校验必填字段非空与持久化策略取值(FR-22)。 ``message_id`` 与 ``channel_account_id`` 必须非空, ``durability_policy`` 必须为 ``required`` / ``best_effort`` / ``none`` 之一,在构造时即抛出 ``ValidationError``,adapter 不再做该校验(INV-8)。 """ if not self.message_id: raise ValidationError("message_id", "must not be empty") if not self.channel_account_id: raise ValidationError("channel_account_id", "must not be empty") if self.durability_policy not in ("required", "best_effort", "none"): raise ValidationError( "durability_policy", "must be one of: required, best_effort, none", ) @dataclass(frozen=True) class SaveUserIdentityCmd: """保存用户身份命令(FR-05)。 由持久化端口方法引用,描述一次用户身份保存请求,携带身份类型、身份 值、渠道信息与关联用户 ID,用于身份解析与统一身份关联(FR-05)。 字段: identity_type: 身份类型(邮箱 / 手机 / 组织员工 ID)。 identity_value: 身份值。 channel_type: 渠道类型(可选)。 channel_sender_id: 渠道侧发送者 ID(可选)。 source: 身份来源(可选)。 user_id: 关联用户 ID(可选)。 """ identity_type: str identity_value: str channel_type: ChannelType | None = None channel_sender_id: str | None = None source: str | None = None user_id: str | None = None def __post_init__(self) -> None: """校验必填字段非空(FR-05)。 ``identity_type`` 与 ``identity_value`` 必须非空,在构造时即抛出 ``ValidationError``,adapter 不再做该校验(INV-8)。 """ if not self.identity_type: raise ValidationError("identity_type", "must not be empty") if not self.identity_value: raise ValidationError("identity_value", "must not be empty") @dataclass(frozen=True) class IdempotencyRecord: """幂等记录值对象(FR-19)。 描述一次幂等操作的执行状态,供 ``AdminMessageService`` 判断重复请求 与回放首次响应。 字段: record_id: 幂等记录主键。 idempotency_key: 幂等键。 operation: 操作类型。 status: 状态(in_progress / completed / failed)。 response_body: 首次请求的响应体(仅 completed 时填充)。 in_progress_started_at: ``in_progress`` 状态开始时间(UTC),供 ``AdminMessageService`` 判断超时卡死的 in_progress 记录并清理 重建,避免服务崩溃后幂等键被永久锁死。 """ record_id: int idempotency_key: str operation: str status: Literal["in_progress", "completed", "failed"] response_body: dict[str, Any] | None = None in_progress_started_at: datetime | None = None def __post_init__(self) -> None: """校验必填字段非空与 status 取值(FR-19)。 ``record_id`` 必须为正整数,``idempotency_key`` 与 ``operation`` 必须非空,``status`` 必须为 ``in_progress`` / ``completed`` / ``failed`` 之一,在构造时即抛出 ``ValidationError``,adapter 不再做 该校验(INV-8)。 """ if self.record_id <= 0: raise ValidationError("record_id", "must be a positive integer") if not self.idempotency_key: raise ValidationError("idempotency_key", "must not be empty") if not self.operation: raise ValidationError("operation", "must not be empty") if self.status not in ("in_progress", "completed", "failed"): raise ValidationError( "status", "must be one of: in_progress, completed, failed", )