本次提交包含多维度代码优化与功能增强: 1. 移除报告模块冗余导入与枚举,清理报表相关代码 2. 新增扫码登录支持方法与飞书适配器适配 3. 完善异常日志与健康检查信息 4. 扩展目录、配对管理、能力查询等接口 5. 优化出站管道与事务提交后钩子逻辑 6. 修复飞书消息解析与响应空值问题 7. 重构配置更新与服务账号创建逻辑 8. 统一传输错误分类契约与错误基类扩展
346 lines
11 KiB
Python
346 lines
11 KiB
Python
"""基础类型 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, ...]
|