ForcePilot/backend/package/yuxi/channels/contract/dtos/common.py

346 lines
11 KiB
Python
Raw Normal View History

"""基础类型 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: 事件接收时间
account_id: 渠道账号 ID由框架层 ``BaseTransportWorker._deliverMessage``
自动填充 ``InboundAdapter.downloadAttachment`` 等下游组件
获取账号上下文避免在 ``payload`` 中注入私有字段
"""
source: Literal["webhook", "polling", "stream"]
payload: dict[str, Any]
headers: dict[str, str]
received_at: datetime
account_id: str | None = None
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, ...]