本次提交包含多项核心改进: 1. 新增微信公众号插件拉取传输模式配置,完善manifest与manifest加载逻辑 2. 新增运行态状态机与幂等冲突错误体系,补充错误映射与领域错误导出 3. 优化出站与入站上下文,新增幂等键、流式中断标记等字段 4. 完善发件箱仓储与模型,新增失败条目查询、投递原子语义字段 5. 修复签名验证阶段异常捕获逻辑,防御性处理内置NotImplementedError 6. 新增出站预算释放方法,完善机器人循环预算管控 7. 优化出站管道格式阶段,新增消息长度校验逻辑 8. 完善出站打字指示器阶段,新增重复启动防御与状态同步 9. 重构出站标记失败阶段,按源状态分支处理状态转换 10. 新增入站幂等过滤阶段,修复入站路由阶段空指针问题 11. 优化出站恢复扫描器,修复状态机调用与聚合根重建逻辑 12. 完善微信公众号适配器,新增类型校验与异常包装 13. 修复数据库事务回滚逻辑,简化不必要的显式回滚操作
460 lines
18 KiB
Python
460 lines
18 KiB
Python
"""持久化命令 DTO。
|
||
|
||
定义持久化端口的命令值对象,包括保存 / 更新渠道账户、保存 / 更新渠道
|
||
会话、创建配对、保存审计日志、保存发件箱条目与保存用户身份等命令。
|
||
所有 DTO 均为 ``dataclass(frozen=True)``,仅依赖标准库与契约层内部类型,
|
||
用于持久化端口的命令传递。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from dataclasses import dataclass
|
||
from datetime import datetime
|
||
from typing import Any, Literal
|
||
|
||
from yuxi.channels.contract.dtos.channel import (
|
||
AccountStatus,
|
||
ChannelType,
|
||
OnboardingStatus,
|
||
)
|
||
from yuxi.channels.contract.dtos.outbox import MessageDurabilityPolicy
|
||
from yuxi.channels.contract.errors import ValidationError
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class SaveChannelAccountCmd:
|
||
"""保存渠道账户命令(AL-01)。
|
||
|
||
由持久化端口方法引用,描述一次渠道账户保存请求,携带渠道类型、账户
|
||
ID、显示名称、配置、启用状态与运行时状态机值,用于账户创建与更新。
|
||
|
||
字段:
|
||
channel_type: 渠道类型。
|
||
account_id: 渠道账户 ID。
|
||
display_name: 显示名称。
|
||
config: 渠道配置。
|
||
enabled: 是否启用(默认 True)。
|
||
status: 运行时状态机值(默认 None)。由 ``account/create``
|
||
handler 通过聚合根 ``ChannelAccount.create()`` 计算后显式
|
||
传入,确保 DB 初始状态与聚合根不变量一致
|
||
(``status = ACTIVE if enabled else DISABLED``)。为 ``None``
|
||
时 adapter 回落到 DB 列默认值 ``"active"``,仅用于向后兼容
|
||
非聚合根路径的调用方。
|
||
service_user_uid: 关联服务账号 User.uid(user_type='service'),
|
||
可选。为 ``None`` 时不在写入数据中包含该字段,由 DB 列默认
|
||
``NULL`` 兜底。
|
||
onboarding_status: 接入态状态机值(默认 ``PENDING``)。描述账号
|
||
onboarding 生命周期,与运行态 ``status`` 解耦。
|
||
credential_ref: 凭证引用(可选),指向凭证存储中的版本化凭证记录。
|
||
credential_version: 凭证版本号(默认 0),与 ``credential_ref`` 共同
|
||
定位具体凭证版本。
|
||
"""
|
||
|
||
channel_type: ChannelType
|
||
account_id: str
|
||
display_name: str
|
||
config: dict[str, Any]
|
||
enabled: bool = True
|
||
status: AccountStatus | None = None
|
||
service_user_uid: str | None = None
|
||
onboarding_status: OnboardingStatus = OnboardingStatus.PENDING
|
||
credential_ref: str | None = None
|
||
credential_version: int = 0
|
||
|
||
def __post_init__(self) -> None:
|
||
"""校验必填字段非空。
|
||
|
||
``account_id`` 与 ``display_name`` 必须非空,在构造时即抛出
|
||
``ValidationError``,adapter 不再做该校验(INV-8)。
|
||
"""
|
||
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 UpdateChannelAccountCmd:
|
||
"""更新渠道账户命令(AL-01)。
|
||
|
||
由持久化端口方法引用,描述一次渠道账户更新请求,仅更新提供的字段,
|
||
支持显示名称、配置、启用状态与运行时状态机的局部更新。
|
||
|
||
字段:
|
||
channel_type: 渠道类型。
|
||
account_id: 渠道账户 ID。
|
||
display_name: 显示名称(可选)。
|
||
config: 渠道配置(可选)。
|
||
enabled: 是否启用(可选)。
|
||
status: 运行时状态机值(可选)。由 ``account/enable`` /
|
||
``account/disable`` 等状态变更操作传入,用于持久化聚合根
|
||
状态机变更结果。为 ``None`` 时表示不更新状态机字段。
|
||
transport_cursor: 传输游标(可选,Puller类型使用)。
|
||
last_rotated_at: 凭据最近轮换时间(可选)。
|
||
onboarding_status: 接入态状态机值(可选)。为 ``None`` 时表示不更新
|
||
该字段。
|
||
credential_ref: 凭证引用(可选)。为 ``None`` 时表示不更新该字段。
|
||
credential_version: 凭证版本号(可选)。为 ``None`` 时表示不更新该
|
||
字段。
|
||
last_error: 最近错误描述(可选)。用于健康检查协同(Task 22)记录
|
||
网络故障或凭证失效原因,为 ``None`` 时表示不更新该字段。
|
||
"""
|
||
|
||
channel_type: ChannelType
|
||
account_id: str
|
||
display_name: str | None = None
|
||
config: dict[str, Any] | None = None
|
||
enabled: bool | None = None
|
||
status: AccountStatus | None = None
|
||
transport_cursor: str | None = None
|
||
last_rotated_at: datetime | None = None
|
||
onboarding_status: OnboardingStatus | None = None
|
||
credential_ref: str | None = None
|
||
credential_version: int | None = None
|
||
last_error: str | None = None
|
||
|
||
def __post_init__(self) -> None:
|
||
"""校验必填字段非空与至少一个可更新字段(AL-01)。
|
||
|
||
``account_id`` 必须非空,且 ``display_name`` / ``config`` / ``enabled``
|
||
/ ``status`` / ``transport_cursor`` / ``last_rotated_at``
|
||
/ ``onboarding_status`` / ``credential_ref`` / ``credential_version``
|
||
/ ``last_error`` 至少提供一个,否则无更新意义,在构造时即抛出
|
||
``ValidationError``,adapter 不再做该校验(INV-8)。
|
||
"""
|
||
if not self.account_id:
|
||
raise ValidationError("account_id", "must not be empty")
|
||
if all(
|
||
f is None
|
||
for f in (
|
||
self.display_name,
|
||
self.config,
|
||
self.enabled,
|
||
self.status,
|
||
self.transport_cursor,
|
||
self.last_rotated_at,
|
||
self.onboarding_status,
|
||
self.credential_ref,
|
||
self.credential_version,
|
||
self.last_error,
|
||
)
|
||
):
|
||
raise ValidationError(
|
||
"update",
|
||
"at least one of display_name/config/enabled/status/transport_cursor/"
|
||
"last_rotated_at/onboarding_status/credential_ref/credential_version/"
|
||
"last_error must be provided",
|
||
)
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class SaveChannelSessionCmd:
|
||
"""保存渠道会话命令(AL-02)。
|
||
|
||
由持久化端口方法引用,描述一次渠道会话保存请求,携带渠道类型、账户
|
||
ID、对端 ID、会话类型与可选的会话关联信息,用于会话创建与更新。
|
||
|
||
字段:
|
||
channel_type: 渠道类型。
|
||
account_id: 渠道账户 ID。
|
||
peer_id: 对端 ID。
|
||
chat_type: 会话类型(p2p | group)。
|
||
conversation_id: 关联的内部会话 ID(可选)。
|
||
unified_identity_id: 统一身份 ID(可选)。
|
||
owner_peer_id: 主会话所有者对端 ID(可选,FR-26)。
|
||
is_temporary: 临时会话标记(默认 False,FR-27)。
|
||
"""
|
||
|
||
channel_type: ChannelType
|
||
account_id: str
|
||
peer_id: str
|
||
chat_type: Literal["p2p", "group"]
|
||
conversation_id: str | None = None
|
||
unified_identity_id: str | None = None
|
||
owner_peer_id: str | None = None
|
||
is_temporary: bool = False
|
||
|
||
def __post_init__(self) -> None:
|
||
"""校验必填字段非空与会话类型取值。
|
||
|
||
``account_id`` 与 ``peer_id`` 必须非空,``chat_type`` 必须为
|
||
``p2p`` / ``group`` 之一,在构造时即抛出 ``ValidationError``,
|
||
adapter 不再做该校验(INV-8)。
|
||
"""
|
||
if not self.account_id:
|
||
raise ValidationError("account_id", "must not be empty")
|
||
if not self.peer_id:
|
||
raise ValidationError("peer_id", "must not be empty")
|
||
if self.chat_type not in ("p2p", "group"):
|
||
raise ValidationError(
|
||
"chat_type",
|
||
"must be one of: p2p, group",
|
||
)
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class UpdateChannelSessionCmd:
|
||
"""更新渠道会话命令(AL-02)。
|
||
|
||
由持久化端口方法引用,描述一次渠道会话更新请求,仅更新提供的字段,
|
||
支持会话关联信息与临时标记的局部更新。
|
||
|
||
字段:
|
||
session_id: 会话 ID。
|
||
conversation_id: 关联的内部会话 ID(可选)。
|
||
unified_identity_id: 统一身份 ID(可选)。
|
||
owner_peer_id: 主会话所有者对端 ID(可选)。
|
||
is_temporary: 临时会话标记(可选)。
|
||
closed_at: 会话关闭时间(可选)。
|
||
"""
|
||
|
||
session_id: str
|
||
conversation_id: str | None = None
|
||
unified_identity_id: str | None = None
|
||
owner_peer_id: str | None = None
|
||
is_temporary: bool | None = None
|
||
closed_at: datetime | None = None
|
||
|
||
def __post_init__(self) -> None:
|
||
"""校验必填字段非空与至少一个可更新字段。
|
||
|
||
``session_id`` 必须非空,且 ``conversation_id`` /
|
||
``unified_identity_id`` / ``owner_peer_id`` / ``is_temporary`` /
|
||
``closed_at`` 至少提供一个,否则无更新意义,在构造时即抛出
|
||
``ValidationError``,adapter 不再做该校验(INV-8)。
|
||
"""
|
||
if not self.session_id:
|
||
raise ValidationError("session_id", "must not be empty")
|
||
if all(
|
||
f is None
|
||
for f in (
|
||
self.conversation_id,
|
||
self.unified_identity_id,
|
||
self.owner_peer_id,
|
||
self.is_temporary,
|
||
self.closed_at,
|
||
)
|
||
):
|
||
raise ValidationError(
|
||
"update",
|
||
"at least one updatable field must be provided",
|
||
)
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class CreatePairingCmd:
|
||
"""创建配对命令(FR-33)。
|
||
|
||
由持久化端口方法引用,描述一次配对创建请求,携带渠道类型、账户 ID、
|
||
对端 ID、对端名称与过期时间,用于 DM 安全与配对审批(FR-33)。
|
||
|
||
字段:
|
||
channel_type: 渠道类型。
|
||
account_id: 渠道账户 ID。
|
||
peer_id: 对端 ID。
|
||
peer_name: 对端名称(可选)。
|
||
expires_in_seconds: 过期时间(秒,默认 604800 即 7 天,FR-33)。
|
||
"""
|
||
|
||
channel_type: ChannelType
|
||
account_id: str
|
||
peer_id: str
|
||
peer_name: str | None = None
|
||
expires_in_seconds: int = 604800
|
||
|
||
def __post_init__(self) -> None:
|
||
"""校验必填字段非空与过期时间合理性(FR-33)。
|
||
|
||
``account_id`` 与 ``peer_id`` 必须非空,``expires_in_seconds`` 必须
|
||
为正整数,在构造时即抛出 ``ValidationError``,adapter 不再做该校验
|
||
(INV-8)。
|
||
"""
|
||
if not self.account_id:
|
||
raise ValidationError("account_id", "must not be empty")
|
||
if not self.peer_id:
|
||
raise ValidationError("peer_id", "must not be empty")
|
||
if self.expires_in_seconds <= 0:
|
||
raise ValidationError("expires_in_seconds", "must be positive")
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class SaveAuditLogCmd:
|
||
"""保存审计日志命令(FR-34)。
|
||
|
||
由持久化端口方法引用,描述一次审计日志保存请求,携带操作人、操作类型、
|
||
目标、参数摘要、结果与追踪信息,用于审计日志持久化(FR-34)。
|
||
|
||
字段:
|
||
operator: 操作人。
|
||
operation: 操作类型。
|
||
target: 操作目标。
|
||
result: 操作结果(success | failed)。
|
||
params_summary: 参数摘要(可选)。
|
||
trace_id: 追踪 ID(可选)。
|
||
source_ip: 来源 IP(可选)。
|
||
request_id: 请求 ID(可选)。
|
||
message_id: 关联消息 ID(可选,FR-19 管理员消息审计)。
|
||
content_summary: 消息内容摘要(可选,FR-19 管理员消息审计)。
|
||
target_channel: 目标渠道类型(可选,FR-34 审计日志按渠道作用域)。
|
||
target_account: 目标账户 ID(可选,FR-34 按账户作用域查询)。
|
||
与 ``target_channel`` 共同确定审计作用域,供
|
||
``AuditQuery.target_account`` 过滤生效。
|
||
"""
|
||
|
||
operator: str
|
||
operation: str
|
||
target: str
|
||
result: Literal["success", "failed"]
|
||
params_summary: dict[str, Any] | None = None
|
||
trace_id: str | None = None
|
||
source_ip: str | None = None
|
||
request_id: str | None = None
|
||
message_id: str | None = None
|
||
content_summary: str | None = None
|
||
target_channel: str | None = None
|
||
target_account: str | None = None
|
||
|
||
def __post_init__(self) -> None:
|
||
"""校验必填字段非空(FR-34)。
|
||
|
||
``operator`` / ``operation`` / ``target`` / ``result`` 必须非空,
|
||
在构造时即抛出 ``ValidationError``,adapter 不再做该校验(INV-8)。
|
||
``result`` 取值不限定枚举,实际场景覆盖 ``success`` / ``failed`` /
|
||
``degraded`` / ``recovered`` 等多状态。
|
||
"""
|
||
if not self.operator:
|
||
raise ValidationError("operator", "must not be empty")
|
||
if not self.operation:
|
||
raise ValidationError("operation", "must not be empty")
|
||
if not self.target:
|
||
raise ValidationError("target", "must not be empty")
|
||
if not self.result:
|
||
raise ValidationError("result", "must not be empty")
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class SaveOutboxEntryCmd:
|
||
"""保存发件箱条目命令(FR-22)。
|
||
|
||
由持久化端口方法引用,描述一次发件箱条目保存请求,携带消息 ID、渠道
|
||
账户 ID、持久化策略与追踪 ID,用于持久化投递(FR-22)。
|
||
|
||
字段:
|
||
message_id: 消息 ID。BEST_EFFORT 轻量记录允许 ``None``(消息持久化
|
||
失败但仍需追踪投递状态的降级路径,O-01/C-6)。
|
||
channel_account_id: 渠道账户 ID。
|
||
durability_policy: 持久化策略(required | best_effort | none)。
|
||
trace_id: 追踪 ID(可选)。
|
||
channel_session_id: 渠道会话 ID(可选,业务标识 session_id UUID)。
|
||
由出站管道从 ``OutboundContext.channel_session_id`` 透传,
|
||
persistence 适配器据此解析 ORM 主键 int 并写入
|
||
``channel_outbox_entries.channel_session_id`` 列,供重试
|
||
worker 按 session 精确定位 peer_id(FR-22)。
|
||
"""
|
||
|
||
message_id: str | None
|
||
channel_account_id: str
|
||
durability_policy: MessageDurabilityPolicy
|
||
trace_id: str | None = None
|
||
channel_session_id: str | None = None
|
||
|
||
def __post_init__(self) -> None:
|
||
"""校验必填字段非空与持久化策略取值(FR-22)。
|
||
|
||
``channel_account_id`` 必须非空,``message_id`` 在非 BEST_EFFORT 策略下
|
||
必须非空(BEST_EFFORT 轻量记录允许 ``message_id=None``,用于消息持久化
|
||
失败但仍需追踪投递状态的降级路径,O-01/C-6),``durability_policy`` 必须
|
||
为 ``required`` / ``best_effort`` / ``none`` 之一,在构造时即抛出
|
||
``ValidationError``,adapter 不再做该校验(INV-8)。
|
||
"""
|
||
if not self.message_id and self.durability_policy != MessageDurabilityPolicy.BEST_EFFORT:
|
||
raise ValidationError("message_id", "must not be empty for non-best_effort policy")
|
||
if not self.channel_account_id:
|
||
raise ValidationError("channel_account_id", "must not be empty")
|
||
if self.durability_policy not in ("required", "best_effort", "none"):
|
||
raise ValidationError(
|
||
"durability_policy",
|
||
"must be one of: required, best_effort, none",
|
||
)
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class SaveUserIdentityCmd:
|
||
"""保存用户身份命令(FR-05)。
|
||
|
||
由持久化端口方法引用,描述一次用户身份保存请求,携带身份类型、身份
|
||
值、渠道信息与关联用户 ID,用于身份解析与统一身份关联(FR-05)。
|
||
|
||
字段:
|
||
identity_type: 身份类型(邮箱 / 手机 / 组织员工 ID)。
|
||
identity_value: 身份值。
|
||
channel_type: 渠道类型(可选)。
|
||
channel_sender_id: 渠道侧发送者 ID(可选)。
|
||
source: 身份来源(可选)。
|
||
user_id: 关联用户 ID(可选)。
|
||
"""
|
||
|
||
identity_type: str
|
||
identity_value: str
|
||
channel_type: ChannelType | None = None
|
||
channel_sender_id: str | None = None
|
||
source: str | None = None
|
||
user_id: str | None = None
|
||
|
||
def __post_init__(self) -> None:
|
||
"""校验必填字段非空(FR-05)。
|
||
|
||
``identity_type`` 与 ``identity_value`` 必须非空,在构造时即抛出
|
||
``ValidationError``,adapter 不再做该校验(INV-8)。
|
||
"""
|
||
if not self.identity_type:
|
||
raise ValidationError("identity_type", "must not be empty")
|
||
if not self.identity_value:
|
||
raise ValidationError("identity_value", "must not be empty")
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class IdempotencyRecord:
|
||
"""幂等记录值对象(FR-19)。
|
||
|
||
描述一次幂等操作的执行状态,供 ``AdminMessageService`` 判断重复请求
|
||
与回放首次响应。
|
||
|
||
字段:
|
||
record_id: 幂等记录主键。
|
||
idempotency_key: 幂等键。
|
||
operation: 操作类型。
|
||
status: 状态(in_progress / completed / failed)。
|
||
response_body: 首次请求的响应体(仅 completed 时填充)。
|
||
in_progress_started_at: ``in_progress`` 状态开始时间(UTC),供
|
||
``AdminMessageService`` 判断超时卡死的 in_progress 记录并清理
|
||
重建,避免服务崩溃后幂等键被永久锁死。
|
||
"""
|
||
|
||
record_id: int
|
||
idempotency_key: str
|
||
operation: str
|
||
status: Literal["in_progress", "completed", "failed"]
|
||
response_body: dict[str, Any] | None = None
|
||
in_progress_started_at: datetime | None = None
|
||
|
||
def __post_init__(self) -> None:
|
||
"""校验必填字段非空与 status 取值(FR-19)。
|
||
|
||
``record_id`` 必须为正整数,``idempotency_key`` 与 ``operation``
|
||
必须非空,``status`` 必须为 ``in_progress`` / ``completed`` /
|
||
``failed`` 之一,在构造时即抛出 ``ValidationError``,adapter 不再做
|
||
该校验(INV-8)。
|
||
"""
|
||
if self.record_id <= 0:
|
||
raise ValidationError("record_id", "must be a positive integer")
|
||
if not self.idempotency_key:
|
||
raise ValidationError("idempotency_key", "must not be empty")
|
||
if not self.operation:
|
||
raise ValidationError("operation", "must not be empty")
|
||
if self.status not in ("in_progress", "completed", "failed"):
|
||
raise ValidationError(
|
||
"status",
|
||
"must be one of: in_progress, completed, failed",
|
||
)
|