2026-07-02 03:22:12 +08:00
|
|
|
|
"""基础类型 DTO。
|
|
|
|
|
|
|
|
|
|
|
|
定义跨层共享的不可变值对象,包括消息格式、操作人角色、原始事件、消息内容、
|
|
|
|
|
|
附件、操作人、失败详情、跳过详情等。所有 DTO 均为 ``dataclass(frozen=True)``,
|
|
|
|
|
|
仅依赖标准库,禁止泄露领域实体引用。
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
from dataclasses import dataclass
|
|
|
|
|
|
from datetime import datetime
|
|
|
|
|
|
from enum import StrEnum
|
2026-07-03 19:18:13 +08:00
|
|
|
|
from typing import Any, Literal
|
2026-07-02 03:22:12 +08:00
|
|
|
|
|
|
|
|
|
|
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: 事件接收时间。
|
2026-07-06 20:49:35 +08:00
|
|
|
|
account_id: 渠道账号 ID(由框架层 ``BaseTransportWorker._deliverMessage``
|
|
|
|
|
|
自动填充,供 ``InboundAdapter.downloadAttachment`` 等下游组件
|
|
|
|
|
|
获取账号上下文,避免在 ``payload`` 中注入私有字段)。
|
2026-07-02 03:22:12 +08:00
|
|
|
|
"""
|
|
|
|
|
|
|
2026-07-03 19:18:13 +08:00
|
|
|
|
source: Literal["webhook", "polling", "stream"]
|
2026-07-02 03:22:12 +08:00
|
|
|
|
payload: dict[str, Any]
|
|
|
|
|
|
headers: dict[str, str]
|
|
|
|
|
|
received_at: datetime
|
2026-07-06 20:49:35 +08:00
|
|
|
|
account_id: str | None = None
|
2026-07-02 03:22:12 +08:00
|
|
|
|
|
2026-07-03 19:18:13 +08:00
|
|
|
|
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",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-07-02 03:22:12 +08:00
|
|
|
|
|
|
|
|
|
|
@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: 音频 / 视频时长(毫秒)。
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
2026-07-03 19:18:13 +08:00
|
|
|
|
type: Literal["image", "file", "audio", "video"]
|
2026-07-02 03:22:12 +08:00
|
|
|
|
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
|
|
|
|
|
|
|
2026-07-03 19:18:13 +08:00
|
|
|
|
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")
|
|
|
|
|
|
|
2026-07-02 03:22:12 +08:00
|
|
|
|
|
|
|
|
|
|
@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
|
|
|
|
|
|
|
2026-07-03 19:18:13 +08:00
|
|
|
|
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")
|
|
|
|
|
|
|
2026-07-02 03:22:12 +08:00
|
|
|
|
@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`` / 缺失均视为非法)
|
2026-07-03 19:18:13 +08:00
|
|
|
|
- ``format`` 可选,默认 ``MessageFormat.TEXT``;非缺省值必须为
|
|
|
|
|
|
``MessageFormat`` 合法成员(``text`` / ``markdown`` / ``rich``)
|
2026-07-02 03:22:12 +08:00
|
|
|
|
- ``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`` 为空或缺失
|
2026-07-03 19:18:13 +08:00
|
|
|
|
- ``ValidationError(field="format", message="unsupported format: ...")``:
|
|
|
|
|
|
``format`` 非合法 ``MessageFormat`` 成员
|
2026-07-02 03:22:12 +08:00
|
|
|
|
|
|
|
|
|
|
参数:
|
|
|
|
|
|
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)
|
2026-07-03 19:18:13 +08:00
|
|
|
|
valid_formats = {f.value for f in MessageFormat}
|
|
|
|
|
|
if fmt not in valid_formats:
|
|
|
|
|
|
raise ValidationError(
|
|
|
|
|
|
"format",
|
2026-07-04 00:14:56 +08:00
|
|
|
|
f"unsupported format: {fmt}, must be one of: {', '.join(f.value for f in MessageFormat)}",
|
2026-07-03 19:18:13 +08:00
|
|
|
|
)
|
2026-07-02 03:22:12 +08:00
|
|
|
|
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
|
|
|
|
|
|
|
2026-07-03 19:18:13 +08:00
|
|
|
|
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")
|
|
|
|
|
|
|
2026-07-02 03:22:12 +08:00
|
|
|
|
|
|
|
|
|
|
@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, ...]
|