"""基础类型 DTO。 定义跨层共享的不可变值对象,包括消息格式、操作人角色、原始事件、消息内容、 附件、操作人、失败详情、跳过详情等。所有 DTO 均为 ``dataclass(frozen=True)``, 仅依赖标准库,禁止泄露领域实体引用。 """ from __future__ import annotations from dataclasses import dataclass from datetime import datetime from enum import StrEnum from typing import Any, Literal from yuxi.channels.contract.errors import ValidationError class MessageFormat(StrEnum): """消息内容格式。 用于 ``MessageContent.format`` 字段,标识消息文本的渲染格式,便于适配器 按格式转换为渠道侧对应的消息结构。 取值: TEXT: 纯文本。 MARKDOWN: Markdown 文本。 RICH: 富消息(含结构化卡片 / 模板)。 """ TEXT = "text" MARKDOWN = "markdown" RICH = "rich" class OperatorRole(StrEnum): """操作人角色。 用于审计日志与权限校验,区分触发操作的用户类型。 取值: REQUIRED_USER: 普通需求用户。 ADMIN_USER: 管理员用户。 SUPERADMIN_USER: 超级管理员用户。 """ REQUIRED_USER = "required_user" ADMIN_USER = "admin_user" SUPERADMIN_USER = "superadmin_user" @dataclass(frozen=True) class RawEvent: """原始事件。 渠道适配器接收到外部事件(webhook / SSE / polling)后封装的原始数据, 作为管道入口的统一输入,保留原始负载与请求头供后续签名校验与解析。 字段: source: 事件来源(webhook=渠道HTTP推送 / polling=Puller轮询 / stream=Streamer长连接)。 payload: 原始事件负载。 headers: 请求头(含签名、时间戳等)。 received_at: 事件接收时间。 """ source: Literal["webhook", "polling", "stream"] payload: dict[str, Any] headers: dict[str, str] received_at: datetime def __post_init__(self) -> None: """校验 source 取值合法。 ``source`` 必须为 ``webhook`` / ``polling`` / ``stream`` 之一,在 构造时即抛出 ``ValidationError``,避免非法来源传播到管道(INV-8)。 """ if self.source not in ("webhook", "polling", "stream"): raise ValidationError( "source", "must be one of: webhook, polling, stream", ) @dataclass(frozen=True) class Attachment: """消息附件。 描述消息中的非文本内容(图片 / 文件 / 音频 / 视频),由适配器按渠道协议 解析后填充。入站图片经 media-fetch 阶段下载二进制并预处理为 base64 后, 填充 content / base64_content / width / height 字段;视频仅透传 URL 与 元数据,不下载二进制。 字段: type: 附件类型(image | file | audio | video)。 url: 附件资源 URL。 mime_type: MIME 类型。 size: 附件字节数。 content: 二进制内容(入站由 media-fetch 阶段填充)。 base64_content: base64 编码内容(供多模态模型消费)。 filename: 文件名(含扩展名)。 width: 图片 / 视频宽度(像素)。 height: 图片 / 视频高度(像素)。 duration_ms: 音频 / 视频时长(毫秒)。 """ type: Literal["image", "file", "audio", "video"] url: str mime_type: str | None = None size: int | None = None content: bytes | None = None base64_content: str | None = None filename: str | None = None width: int | None = None height: int | None = None duration_ms: int | None = None def __post_init__(self) -> None: """校验 type 取值与 url 非空。 ``type`` 必须为 ``image`` / ``file`` / ``audio`` / ``video`` 之一, ``url`` 必须非空,在构造时即抛出 ``ValidationError``,避免非法附件 类型传播到渲染层(INV-8)。 """ if self.type not in ("image", "file", "audio", "video"): raise ValidationError( "type", "must be one of: image, file, audio, video", ) if not self.url: raise ValidationError("url", "must not be empty") @dataclass(frozen=True) class MessageContent: """消息内容。 统一描述渠道消息的文本与附件,跨层传递时保持不可变;附件字段使用 tuple 以保证 frozen dataclass 的不可变语义。 字段: text: 文本内容。 format: 文本格式(text | markdown | rich)。 attachments: 附件列表(tuple 保证不可变)。 metadata: 渠道侧元数据。 """ text: str format: MessageFormat = MessageFormat.TEXT attachments: tuple[Attachment, ...] = () metadata: dict[str, Any] | None = None def __post_init__(self) -> None: """校验 text 非空。 ``text`` 必须非空,与 ``from_dict`` 的校验保持一致,在构造时即抛出 ``ValidationError``,避免空文本消息传播到出站管道(INV-8)。 """ if not self.text: raise ValidationError("text", "text is required and must not be empty") @classmethod def from_dict(cls, data: dict[str, Any]) -> MessageContent: """从 dict 构造 MessageContent 实例。 将 dict 形态的消息内容(如 HTTP 请求体或外部序列化结构)转换为不可变 ``MessageContent`` DTO,递归构造 ``Attachment`` 元组。供驱动适配器层 (如 MSG-SEND-01 端点)将 Pydantic 字段委托至契约层 DTO 构造, 保证跨层传递的不可变语义。 @pre - ``data`` 含 ``text`` 字段非空(``""`` / ``None`` / 缺失均视为非法) - ``format`` 可选,默认 ``MessageFormat.TEXT``;非缺省值必须为 ``MessageFormat`` 合法成员(``text`` / ``markdown`` / ``rich``) - ``attachments`` 可选,默认空元组 - ``metadata`` 可选,默认 ``None`` @post - 返回不可变 ``MessageContent`` 实例 - ``attachments`` 中 dict 元素已通过 ``Attachment(**a)`` 构造为 ``Attachment`` 实例;已是 ``Attachment`` 实例的元素原样保留 @failure - ``ValidationError(field="text", message="text is required and must not be empty")``: ``text`` 为空或缺失 - ``ValidationError(field="format", message="unsupported format: ...")``: ``format`` 非合法 ``MessageFormat`` 成员 参数: data: dict 形态的消息内容。 返回: 构造完成的 ``MessageContent`` 实例。 """ text = data.get("text", "") if not text: raise ValidationError("text", "text is required and must not be empty") fmt = data.get("format", MessageFormat.TEXT) valid_formats = {f.value for f in MessageFormat} if fmt not in valid_formats: raise ValidationError( "format", f"unsupported format: {fmt}, must be one of: {', '.join(f.value for f in MessageFormat)}", ) raw_attachments = data.get("attachments", []) attachments = tuple(Attachment(**a) if isinstance(a, dict) else a for a in raw_attachments) metadata = data.get("metadata") return cls(text=text, format=fmt, attachments=attachments, metadata=metadata) @dataclass(frozen=True) class Operator: """操作人。 用于审计日志与权限校验,记录触发操作的用户身份与链路追踪信息。 字段: user_id: 操作人用户 ID(或 "system")。 role: 操作人角色。 ip: 来源 IP(审计用)。 request_id: 请求 ID(链路追踪用)。 """ user_id: str role: OperatorRole ip: str | None = None request_id: str | None = None def __post_init__(self) -> None: """校验 user_id 非空。 ``user_id`` 必须非空(或为 ``"system"``),在构造时即抛出 ``ValidationError``,避免空操作人传播到审计日志(INV-8)。 """ if not self.user_id: raise ValidationError("user_id", "must not be empty") @dataclass(frozen=True) class FailureDetail: """失败详情。 描述批量操作中单个目标的失败信息,用于结果聚合与重试策略决策。 字段: target: 失败目标。 error_code: 错误码。 message: 人类可读错误信息。 retryable: 是否可重试。 details: 原始异常的业务字段(从 ``Error.details`` 提取),用于 反向重构异常时保留 ``resource`` / ``id`` / ``field`` / ``rule`` 等字段,避免业务信息丢失。 """ target: str error_code: str message: str retryable: bool = False details: dict[str, Any] | None = None @dataclass(frozen=True) class SkipDetail: """跳过详情。 描述批量操作中单个目标被跳过的原因与触发策略,用于结果聚合与审计。 字段: target: 跳过目标。 reason: 跳过原因(如 "in_denylist")。 policy: 触发的策略。 """ target: str reason: str policy: str @dataclass(frozen=True) class BatchOperationFailure: """批量操作失败条目(通用)。 描述 P1 批量操作(逐条独立事务模式 D)中单条失败条目,统一以 ``id`` 字段承载失败目标标识(session_id / account_id / pairing_id 等), 供各域批量结果 ``failed`` 列表共用。 字段: id: 失败目标标识。 error_code: 错误码。 message: 人类可读错误信息。 """ id: str error_code: str message: str @dataclass(frozen=True) class TrendDataPoint: """通用趋势数据点。 描述按时间粒度切片后的单个时间桶计数,供 outbox / pairing / analytics 等域趋势结果共用。``timestamp`` 序列化为 ISO 8601 字符串由调用方处理。 字段: timestamp: 时间桶起始时间。 value: 计数值。 """ timestamp: datetime value: int @dataclass(frozen=True) class CategoryStat: """分类统计(通用)。 描述按分类分组的计数项,供 content review stats / analytics 等域 ``by_category`` 列表共用。 字段: category: 分类标识。 count: 计数。 """ category: str count: int @dataclass(frozen=True) class CleanExpiredResult: """清理过期结果(通用)。 描述 P1 ``clean-expired`` 操作(白名单 / 配对等)的统一返回结构, 单一事务批量清理模式下不区分逐条失败。 字段: total: 清理条目总数。 cleaned: 已清理的目标 ID 元组。 """ total: int cleaned: tuple[str, ...]