"""渠道类型 DTO。 定义渠道账户、会话、身份、消息等跨层共享的不可变值对象。所有 DTO 均为 ``dataclass(frozen=True)``,仅依赖标准库,禁止泄露领域实体引用。 """ from __future__ import annotations from dataclasses import dataclass, field from datetime import datetime from enum import StrEnum from typing import Any, Literal from yuxi.channels.contract.dtos.common import BatchOperationFailure, Operator from yuxi.channels.contract.errors import ValidationError class ChannelType(str): """渠道类型标识符。 由插件 manifest 声明,框架层不做白名单校验。每个插件应使用唯一的 channel_type 值,由 ``PluginRegistry`` 保证唯一性。运行时为 ``str`` 子类,可直接作为字典 key、JSON 序列化、FastAPI 参数。 约束: - 框架层(contract / core / application)**禁止** 在任何决策语句中 分支到具体 channel_type 值,渠道特性决策 **必须** 通过 ``ChannelManifest`` 声明字段驱动。 - 新增渠道仅需在插件 manifest 中声明 channel_type 字符串,无需 修改本类。 - channel_type 值在系统中全局唯一(由 ``PluginRegistry.register`` 校验),两个插件不得共用同一 channel_type。 """ __slots__ = () @classmethod def __get_pydantic_core_schema__(cls, source_type: Any, handler: Any) -> Any: """Pydantic 核心 schema:以 ``str`` 校验后包装为 ``ChannelType``。 ``ChannelType`` 为 ``str`` 子类(非 ``StrEnum``),Pydantic 默认 无法为其生成 schema,导致 ``ChannelType | None`` 作为 FastAPI ``Query`` / ``Field`` 参数或 Pydantic 模型字段时抛 ``PydanticSchemaGenerationError``。本方法以 ``str_schema`` 校验 输入后用 ``cls`` 包装,保留 str 子类的开放值语义(支持 ``ChannelType("wechat_ilink")`` 等动态值,无需预定义枚举成员)。 ``pydantic_core`` 在方法内延迟导入,避免在 contract 层引入硬依赖, 保持 DTO 模块"仅依赖标准库"的约束(无 Pydantic 环境下本类仍可正常 使用,仅失去 Pydantic 字段类型支持)。 """ from pydantic_core import core_schema return core_schema.no_info_after_validator_function( cls, core_schema.str_schema(), ) class AccountStatus(StrEnum): """渠道账户状态枚举。 聚合根 ``ChannelAccount`` 的状态机枚举。状态转换规则参见 ``ChannelAccount`` 聚合根: - ``ACTIVE`` ↔ ``DISABLED``:通过 ``enable()`` / ``disable()`` 切换。 - ``ACTIVE`` → ``DEGRADED``:通过 ``degrade()`` 切换(不可从 ``DISABLED`` 降级)。 - ``DEGRADED`` → ``ACTIVE``:通过 ``recover()`` 切换(不可直接 ``enable``)。 - ``DISABLED`` 不可直接 ``recover``,需先 ``enable`` 回到 ``ACTIVE``。 """ ACTIVE = "active" DISABLED = "disabled" DEGRADED = "degraded" class OnboardingStatus(StrEnum): """渠道账号接入态枚举。 描述渠道账号 onboarding 生命周期状态,独立于运行态 ``AccountStatus`` (``ACTIVE`` / ``DISABLED`` / ``DEGRADED``):onboarding 状态刻画账号 从创建到可用的接入流程进度,运行态刻画已上线账号的实时可用性。两者 解耦后,账号下线(``OFFLINE``)与运行态禁用(``DISABLED``)可独立流转, 避免 onboarding 流程被运行态切换误覆盖。 取值: PENDING: 账号已创建,凭证未配置。 CONFIGURED: 凭证已落库,未验证连通性。 VERIFIED: 连通性验证通过,未上线。 ONLINE: 已上线(运行态 ``ACTIVE``)。 OFFLINE: 已下线(运行态 ``DISABLED``)。 FAILED: 接入失败(凭证失效 / 验证失败)。 继承 ``StrEnum`` 以兼容 JSON 序列化:``str(OnboardingStatus.PENDING)`` 即 ``"pending"``,可直接用于 ``json.dumps`` 与持久化层字符串字段, 无需额外的 ``__str__`` 或 serializer 适配。 """ PENDING = "pending" CONFIGURED = "configured" VERIFIED = "verified" ONLINE = "online" OFFLINE = "offline" FAILED = "failed" class SessionStatus(StrEnum): """渠道会话状态枚举。 标识渠道会话的生命周期状态,用于会话关闭与消息投递决策。继承 ``str, Enum`` 以支持 JSON 序列化与字符串比较。 取值: ACTIVE: 活跃会话,可正常收发消息。 CLOSED: 已关闭会话,停止接收新消息。 """ ACTIVE = "active" CLOSED = "closed" @dataclass(frozen=True) class ChannelAccount: """渠道账户。 描述一个渠道账户的完整配置,用于账户管理与消息路由。config 字段为脱敏后 的渠道配置,不包含原始密钥。 字段: channel_type: 渠道类型。 account_id: 渠道账户 ID(全局唯一)。 display_name: 显示名称。 config: 渠道配置(脱敏后)。 enabled: 是否启用。 status: 账户状态机值(active/disabled/degraded)。 created_at: 创建时间。 updated_at: 更新时间。 transport_cursor: 传输游标,Puller类型使用。 last_rotated_at: 凭据最近轮换时间(可选)。 service_user_uid: 关联服务账号 User.uid(user_type='service'),可选。 用于渠道账户以服务账号身份执行内部操作。 onboarding_status: 接入态状态机值(pending/configured/verified/ online/offline/failed)。描述账号 onboarding 生命周期,与运行态 ``status`` 解耦。必填,无默认值(kw_only)。 credential_ref: 凭证引用(可选),指向凭证存储中的版本化凭证记录。 credential_version: 凭证版本号(默认 0),与 ``credential_ref`` 共同 定位具体凭证版本。 transport_mode: 传输模式(默认 ``both``)。声明账号支持的传输方式: ``pull``(仅拉取)、``stream``(仅流式)、``both``(两者皆可), 供入站/出站链路决策拉取器与流式通道启用策略。 """ channel_type: ChannelType account_id: str display_name: str config: dict[str, Any] enabled: bool = True status: AccountStatus = AccountStatus.ACTIVE created_at: datetime | None = None updated_at: datetime | None = None transport_cursor: str = "" last_rotated_at: datetime | None = None service_user_uid: str | None = None transport_mode: Literal["pull", "stream", "both"] = "both" onboarding_status: OnboardingStatus = field(kw_only=True) credential_ref: str | None = field(kw_only=True, default=None) credential_version: int = field(kw_only=True, default=0) last_error: str | None = field(kw_only=True, default=None) plugin_status: str = field(kw_only=True, default="stopped") version: int = field(kw_only=True, default=1) def __post_init__(self) -> None: """校验必填字段非空。 ``channel_type`` 不可为 ``None``,``account_id`` 与 ``display_name`` 必须非空,在构造时即抛出 ``ValidationError``,adapter 不再做该校验 (INV-8)。 """ if self.channel_type is None: raise ValidationError("channel_type", "must not be None") 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 ChannelAccountSummary: """渠道账户摘要。 用于账户列表查询等场景,仅包含展示必要字段,不泄露配置详情。 字段: channel_type: 渠道类型。 account_id: 渠道账户 ID。 display_name: 显示名称。 enabled: 是否启用。 status: 账户状态机值(active/disabled/degraded)。 """ channel_type: ChannelType account_id: str display_name: str enabled: bool status: AccountStatus = AccountStatus.ACTIVE @dataclass(frozen=True) class ChannelSession: """渠道会话。 描述渠道侧会话状态,关联会话与统一身份、主会话所有者,支持临时会话标记 与软删除。 字段: session_id: 会话 ID。 channel_type: 渠道类型。 account_id: 渠道账户 ID。 peer_id: 对端 ID。 chat_type: 会话类型("p2p" | "group" | "kf")。 conversation_id: 关联的内部会话 ID。 unified_identity_id: 统一身份 ID。 owner_peer_id: 主会话所有者对端 ID(FR-26)。 is_temporary: 临时会话标记(FR-27)。 created_at: 创建时间。 updated_at: 更新时间。 deleted_at: 软删除时间。 closed_at: 会话关闭时间(可选)。 last_message_at: 最近一次消息时间(可选,用于会话列表排序与 inactive 临时会话清理,FR-27)。 last_route_at: 最近路由更新时间(可选,FR-04/FR-26,用于跨渠道 会话排序与路由诊断)。 route_match_source: 最近路由匹配来源(可选,取值见 ``ConfigMatchSource`` 枚举,用于路由审计)。 version: 乐观锁版本号(默认 1,用于并发更新校验)。 """ session_id: str channel_type: ChannelType account_id: str peer_id: str chat_type: Literal["p2p", "group", "kf"] conversation_id: str | None = None unified_identity_id: str | None = None owner_peer_id: str | None = None is_temporary: bool = False created_at: datetime | None = None updated_at: datetime | None = None deleted_at: datetime | None = None closed_at: datetime | None = None last_message_at: datetime | None = None last_route_at: datetime | None = None route_match_source: str | None = None version: int = 1 @dataclass(frozen=True) class UserIdentity: """用户身份。 描述用户在渠道侧或外部身份系统的身份信息,用于身份解析与统一身份关联。 ``user_id`` 为可空字段但需显式传入,以区分"未关联用户"与"使用默认值"。 字段: identity_id: 统一身份 ID。 user_id: 关联用户表 ID(可空)。 identity_type: 身份类型(邮箱 / 手机 / 组织员工 ID)。 identity_value: 身份值。 channel_type: 渠道类型。 channel_sender_id: 渠道侧发送者 ID。 source: 身份来源。 channel_bindings: 渠道绑定映射 ``{channel_type: [peer_id, ...]}``, 记录该身份在各渠道的对端 ID,用于跨渠道身份关联与反查。 merged_from: 合并来源结构化证据列表(H-21),每项含 ``{identity_id, trigger, sibling_confidence, canonical_confidence, merged_at, operator_id}``。 confidence: 身份置信度等级(``low`` / ``medium`` / ``high``), 反映身份解析结果的可信程度,用于路由决策与审计。 created_at: 创建时间。 updated_at: 更新时间。 version: 乐观锁版本号(P3 渐进式绑定,用于 updateUserIdentity 并发控制)。 pending_review: 低置信度合并待审批标记(H-21),``True`` 表示该身份 有待管理员审批放行的合并,不自动合并。 """ identity_id: str user_id: str | None identity_type: str identity_value: str channel_type: ChannelType | None = None channel_sender_id: str | None = None source: str | None = None channel_bindings: dict[str, list[str]] = field(default_factory=dict) merged_from: list[dict] = field(default_factory=list) confidence: str = "low" created_at: datetime | None = None updated_at: datetime | None = None version: int = 1 pending_review: bool = False @dataclass(frozen=True) class SessionInfo: """会话信息。 渠道适配器解析原始事件后产出的会话定位信息,用于核心层解析或创建会话。 字段: channel_type: 渠道类型。 account_id: 渠道账户 ID。 peer_id: 对端 ID。 chat_type: 会话类型("p2p" | "group" | "kf")。 group_id: 群组 ID。 topic_id: 话题 ID。 """ channel_type: ChannelType account_id: str peer_id: str chat_type: Literal["p2p", "group", "kf"] group_id: str | None = None topic_id: str | None = None @dataclass(frozen=True) class Message: """消息。 描述一条消息的完整状态,包括内部字段与渠道侧状态字段(已读、撤回、编辑 等),用于消息持久化与渠道侧状态同步。 字段: message_id: 消息 ID。 conversation_id: 会话 ID。 role: 角色(user | assistant | admin)。 content: 消息文本内容。 channel_status: 渠道侧状态(FR-09)。 channel_msg_id: 渠道侧消息 ID。 ref_channel_msg_id: 引用的渠道消息 ID(编辑/回复场景)。 channel_status_history: 渠道侧状态事件历史数组(FR-09)。 operations_history: 消息操作历史数组(FR-12),每项记录一次消息操作 的执行信息(操作类型、执行者、时间戳、是否成功)。 channel_read_at: 渠道侧已读时间。 channel_recalled_at: 渠道侧撤回时间。 channel_edited_at: 渠道侧编辑时间。 created_at: 创建时间。 channel_type: 渠道类型(本期新增,由持久化层从会话关联填充,供 _messageStatus 返回)。 """ message_id: str conversation_id: str role: Literal["user", "assistant", "admin"] content: str channel_status: str | None = None channel_msg_id: str | None = None ref_channel_msg_id: str | None = None channel_status_history: tuple[dict, ...] | None = None operations_history: tuple[dict, ...] | None = None channel_read_at: datetime | None = None channel_recalled_at: datetime | None = None channel_edited_at: datetime | None = None created_at: datetime | None = None channel_type: ChannelType | None = None def __post_init__(self) -> None: """校验必填字段非空与角色取值。 ``message_id`` 与 ``conversation_id`` 必须非空,``role`` 必须为 ``user`` / ``assistant`` / ``admin`` 之一,在构造时即抛出 ``ValidationError``,adapter 不再做该校验(INV-8)。 """ if not self.message_id: raise ValidationError("message_id", "must not be empty") 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 RotateCredentialsResult: """凭据轮换结果。 描述账户凭据轮换操作的返回结果,包括轮换时间与是否成功撤销旧凭据。 字段: account_id: 渠道账户 ID。 rotated_at: 轮换完成时间。 old_credentials_revoked: 旧凭据是否已成功撤销。 """ account_id: str rotated_at: datetime old_credentials_revoked: bool @dataclass(frozen=True) class ConnectionCheckResult: """连接检查结果。 描述单次渠道账户连接连通性检查的结果。 字段: success: 连接是否成功。 checked_at: 检查时间。 latency_ms: 连接延迟(毫秒,可选)。 error: 错误信息(失败时填充,可选)。 """ success: bool checked_at: datetime latency_ms: int | None = None error: str | None = None @dataclass(frozen=True) class TestConnectionResult: """测试连接结果。 描述渠道账户连接测试的完整结果,包括连通性检查与凭据验证两部分。 字段: account_id: 渠道账户 ID。 connection: 连通性检查结果。 credentials_valid: 凭据是否有效。 tested_at: 测试时间。 """ account_id: str connection: ConnectionCheckResult credentials_valid: bool tested_at: datetime @dataclass(frozen=True) class MessageSearchItem: """消息搜索结果项。 描述消息全文搜索的单条匹配结果,包含消息核心字段与匹配高亮信息。 字段: message_id: 消息 ID。 conversation_id: 会话 ID。 channel_type: 渠道类型。 role: 消息角色(user | assistant | admin)。 content: 消息内容。 created_at: 创建时间。 channel_session_id: 渠道会话 ID(可选)。 channel_account_id: 渠道账户业务 ID(可选)。 peer_id: 对端 ID(可选)。 conversation_title: 会话标题(可选)。 snippet: 匹配片段(高亮后,可选)。 """ message_id: str conversation_id: str channel_type: ChannelType role: Literal["user", "assistant", "admin"] content: str created_at: datetime channel_session_id: str | None = None channel_account_id: str | None = None peer_id: str | None = None conversation_title: str | None = None snippet: str | None = None def __post_init__(self) -> None: """校验必填字段非空与角色取值。 ``message_id`` / ``conversation_id`` / ``content`` 必须非空, ``channel_type`` 不可为 ``None``,``role`` 必须为 ``user`` / ``assistant`` / ``admin`` 之一,在构造时即抛出 ``ValidationError``, adapter 不再做该校验(INV-8)。 """ if not self.message_id: raise ValidationError("message_id", "must not be empty") if not self.conversation_id: raise ValidationError("conversation_id", "must not be empty") if self.channel_type is None: raise ValidationError("channel_type", "must not be None") 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 WebhookTestCmd: """Webhook 测试命令(WHK-TEST)。 由 ``AccountManagementPort.testWebhook`` 引用,触发对指定渠道账户的 webhook 测试事件投递。``account_id`` 缺省时由 dispatch handler 取该 渠道首个账户。 字段: channel_type: 渠道类型。 operator: 操作人(审计用)。 account_id: 渠道账户 ID(可选,缺省取首个账户)。 event_type: 测试事件类型(默认 ``test_event``)。 payload: 测试事件负载(可选,``None`` 归一化为空 dict)。 """ channel_type: ChannelType operator: Operator account_id: str | None = None event_type: str = "test_event" payload: dict[str, Any] | None = None def __post_init__(self) -> None: """校验必填字段非空并归一化 payload(WHK-TEST)。 ``channel_type`` 不可为 ``None``,``event_type`` 必须为非空字符串; 违规抛 ``ValidationError``,在构造时即拦截,避免空值传播到控制面 管道后才暴露(INV-8)。``payload`` 为 ``None`` 时归一化为空 dict, 与 ``WebhookTestAdapter.testWebhook`` 协议签名(非 Optional)对齐, 避免适配器解引用 ``None`` 触发 ``TypeError``。 """ if self.channel_type is None: raise ValidationError("channel_type", "must not be None") if not self.event_type: raise ValidationError("event_type", "must not be empty") if self.payload is None: object.__setattr__(self, "payload", {}) @dataclass(frozen=True) class WebhookTestResult: """Webhook 测试结果(WHK-TEST)。 描述测试事件投递的结果,由插件 ``WebhookTestAdapter.testWebhook`` 返回。 字段: test_id: 测试事件 ID。 delivered: 是否投递成功。 http_status: 渠道侧返回的 HTTP 状态码(投递失败时可能为 None)。 response_time_ms: 响应延迟(毫秒,投递失败时可能为 None)。 error: 错误信息(投递失败时填充,可选)。 """ test_id: str delivered: bool http_status: int | None = None response_time_ms: int | None = None error: str | None = None @dataclass(frozen=True) class BatchStateChangeCmd: """批量启停账户命令(ACC-BATCH-STATE)。 由 ``AccountManagementPort.batchEnableAccounts`` / ``batchDisableAccounts`` 引用,支持显式 ID 列表或筛选条件两种模式。 ``action`` 取值 ``enable`` / ``disable``,由 dispatch handler 决定。 字段: action: 操作类型(``enable`` / ``disable``)。 operator: 操作人(审计用)。 account_ids: 显式账户 ID 元组(默认空元组)。 filter: 筛选条件(含 channel_type / status,可选)。 reason: 操作原因(审计用,可选)。 """ action: Literal["enable", "disable"] operator: Operator account_ids: tuple[str, ...] = () filter: dict[str, Any] | None = None reason: str | None = None def __post_init__(self) -> None: """校验 action 取值与至少一个目标模式(ACC-BATCH-STATE)。 ``action`` 必须为 ``enable`` / ``disable`` 之一,``account_ids`` 与 ``filter`` 至少提供一个,避免无目标的批量操作,在构造时即抛出 ``ValidationError``,adapter 不再做该校验(INV-8)。 """ if self.action not in ("enable", "disable"): raise ValidationError( "action", "must be one of: enable, disable", ) if not self.account_ids and self.filter is None: raise ValidationError( "filter", "at least one of account_ids or filter must be provided", ) @dataclass(frozen=True) class BatchStateChangeResult: """批量启停账户结果(ACC-BATCH-STATE)。 描述逐条独立事务启停账户的执行结果,``failed`` 使用通用 ``BatchOperationFailure``(``id`` 字段承载 account_id)。 字段: total: 待操作账户总数。 succeeded: 成功操作的账户 ID 元组。 failed: 失败条目元组。 """ total: int succeeded: tuple[str, ...] failed: tuple[BatchOperationFailure, ...] @dataclass(frozen=True) class AccountExportResult: """账户配置导出结果(ACC-EXPORT)。 描述账户配置导出的返回内容,``secrets_included=false`` 时 ``raw_config`` 中敏感字段已通过 MaskingPort 脱敏。 字段: account_id: 渠道账户 ID。 channel_type: 渠道类型。 display_name: 账户显示名。 raw_config: 原始配置(脱敏后或含凭据)。 exported_at: 导出时间。 secrets_included: 是否包含敏感凭据。 """ account_id: str channel_type: ChannelType display_name: str raw_config: dict[str, Any] exported_at: datetime secrets_included: bool @dataclass(frozen=True) class CloneAccountCmd: """克隆账户命令(ACC-CLONE)。 由 ``AccountManagementPort.cloneAccount`` 引用,基于源账户配置创建 新账户。``include_credentials=false`` 时清除凭据字段。 字段: channel_type: 渠道类型。 account_id: 源账户 ID。 new_display_name: 新账户显示名(必填)。 operator: 操作人(审计用)。 new_raw_config_overrides: 配置覆盖项(可选,默认空 dict)。 include_credentials: 是否克隆凭据(默认 False)。 """ channel_type: ChannelType account_id: str new_display_name: str operator: Operator new_raw_config_overrides: dict[str, Any] | None = None include_credentials: bool = False def __post_init__(self) -> None: """校验必填字段非空(ACC-CLONE)。 ``new_display_name`` 必须非空字符串,在构造时即抛出 ``ValidationError``,避免空值传播到聚合根 ``clone()`` 后才暴露 (INV-8)。 """ if not self.new_display_name: raise ValidationError("new_display_name", "new_display_name must not be empty") @dataclass(frozen=True) class CloneAccountResult: """克隆账户结果(ACC-CLONE)。 描述克隆操作返回的新账户信息,新账户默认为 DISABLED 状态,需手动 enable。 字段: new_account_id: 新账户 ID。 cloned_from: 源账户 ID。 status: 新账户状态(默认 DISABLED)。 created_at: 创建时间。 """ new_account_id: str cloned_from: str status: AccountStatus created_at: datetime @dataclass(frozen=True) class AccountFilter: """渠道账户筛选条件值对象(ACC-BATCH-STATE)。 供 ``ChannelAccountRepositoryPort.findAccountsByFilter`` 使用,替换原 ``dict[str, Any]`` 弱类型参数。所有字段均可选,但 ``__post_init__`` 强制至少提供一个筛选条件,避免全表扫描。 字段: channel_type: 渠道类型筛选(可选)。 status: 账户状态筛选(可选)。 """ channel_type: ChannelType | None = None status: AccountStatus | None = None def __post_init__(self) -> None: if self.channel_type is None and self.status is None: raise ValidationError( "filter", "at least one of channel_type or status must be provided", ) @dataclass(frozen=True) class SessionFilter: """渠道会话筛选条件值对象(SES-BATCH-CLOSE-01)。 供 ``ChannelSessionRepositoryPort.findSessionsByFilter`` 使用,替换原 ``dict[str, Any]`` 弱类型参数。所有字段均可选,但 ``__post_init__`` 强制至少提供一个筛选条件,避免全表扫描。 字段: channel_type: 渠道类型筛选(可选)。 unified_identity_id: 统一身份 ID 精确匹配(P3 渐进式绑定, 跨渠道会话合并场景,可选)。 inactive_before: 非活跃截止时间;``last_message_at`` 为 NULL 或早于该时间均视为非活跃(可选)。 status: 会话状态筛选(可选)。 """ channel_type: ChannelType | None = None unified_identity_id: str | None = None inactive_before: datetime | None = None status: SessionStatus | None = None def __post_init__(self) -> None: has_filter = any( field is not None for field in ( self.channel_type, self.unified_identity_id, self.inactive_before, self.status, ) ) if not has_filter: raise ValidationError( "filter", "at least one filter field must be provided", )