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
|
2026-07-03 19:18:13 +08:00
|
|
|
|
from typing import Any, Literal
|
|
|
|
|
|
|
|
|
|
|
|
from yuxi.channels.contract.dtos.channel import (
|
|
|
|
|
|
AccountStatus,
|
|
|
|
|
|
ChannelType,
|
|
|
|
|
|
OnboardingStatus,
|
|
|
|
|
|
)
|
|
|
|
|
|
from yuxi.channels.contract.dtos.outbox import MessageDurabilityPolicy
|
2026-07-02 03:22:12 +08:00
|
|
|
|
from yuxi.channels.contract.errors import ValidationError
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
|
|
|
|
class SaveChannelAccountCmd:
|
2026-07-03 19:18:13 +08:00
|
|
|
|
"""保存渠道账户命令(AL-01)。
|
2026-07-02 03:22:12 +08:00
|
|
|
|
|
|
|
|
|
|
由持久化端口方法引用,描述一次渠道账户保存请求,携带渠道类型、账户
|
|
|
|
|
|
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"``,仅用于向后兼容
|
|
|
|
|
|
非聚合根路径的调用方。
|
2026-07-03 19:18:13 +08:00
|
|
|
|
service_user_uid: 关联服务账号 User.uid(user_type='service'),
|
|
|
|
|
|
可选。为 ``None`` 时不在写入数据中包含该字段,由 DB 列默认
|
|
|
|
|
|
``NULL`` 兜底。
|
|
|
|
|
|
onboarding_status: 接入态状态机值(默认 ``PENDING``)。描述账号
|
|
|
|
|
|
onboarding 生命周期,与运行态 ``status`` 解耦。
|
|
|
|
|
|
credential_ref: 凭证引用(可选),指向凭证存储中的版本化凭证记录。
|
|
|
|
|
|
credential_version: 凭证版本号(默认 0),与 ``credential_ref`` 共同
|
|
|
|
|
|
定位具体凭证版本。
|
2026-07-02 03:22:12 +08:00
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
channel_type: ChannelType
|
|
|
|
|
|
account_id: str
|
|
|
|
|
|
display_name: str
|
|
|
|
|
|
config: dict[str, Any]
|
|
|
|
|
|
enabled: bool = True
|
|
|
|
|
|
status: AccountStatus | None = None
|
2026-07-03 19:18:13 +08:00
|
|
|
|
service_user_uid: str | None = None
|
|
|
|
|
|
onboarding_status: OnboardingStatus = OnboardingStatus.PENDING
|
|
|
|
|
|
credential_ref: str | None = None
|
|
|
|
|
|
credential_version: int = 0
|
2026-07-02 03:22:12 +08:00
|
|
|
|
|
|
|
|
|
|
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:
|
2026-07-03 19:18:13 +08:00
|
|
|
|
"""更新渠道账户命令(AL-01)。
|
2026-07-02 03:22:12 +08:00
|
|
|
|
|
|
|
|
|
|
由持久化端口方法引用,描述一次渠道账户更新请求,仅更新提供的字段,
|
|
|
|
|
|
支持显示名称、配置、启用状态与运行时状态机的局部更新。
|
|
|
|
|
|
|
|
|
|
|
|
字段:
|
|
|
|
|
|
channel_type: 渠道类型。
|
|
|
|
|
|
account_id: 渠道账户 ID。
|
|
|
|
|
|
display_name: 显示名称(可选)。
|
|
|
|
|
|
config: 渠道配置(可选)。
|
|
|
|
|
|
enabled: 是否启用(可选)。
|
|
|
|
|
|
status: 运行时状态机值(可选)。由 ``account/enable`` /
|
|
|
|
|
|
``account/disable`` 等状态变更操作传入,用于持久化聚合根
|
|
|
|
|
|
状态机变更结果。为 ``None`` 时表示不更新状态机字段。
|
|
|
|
|
|
transport_cursor: 传输游标(可选,Puller类型使用)。
|
|
|
|
|
|
last_rotated_at: 凭据最近轮换时间(可选)。
|
2026-07-03 19:18:13 +08:00
|
|
|
|
onboarding_status: 接入态状态机值(可选)。为 ``None`` 时表示不更新
|
|
|
|
|
|
该字段。
|
|
|
|
|
|
credential_ref: 凭证引用(可选)。为 ``None`` 时表示不更新该字段。
|
|
|
|
|
|
credential_version: 凭证版本号(可选)。为 ``None`` 时表示不更新该
|
|
|
|
|
|
字段。
|
|
|
|
|
|
last_error: 最近错误描述(可选)。用于健康检查协同(Task 22)记录
|
|
|
|
|
|
网络故障或凭证失效原因,为 ``None`` 时表示不更新该字段。
|
2026-07-02 03:22:12 +08:00
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
channel_type: ChannelType
|
|
|
|
|
|
account_id: str
|
2026-07-08 03:57:05 +08:00
|
|
|
|
expected_version: int
|
2026-07-02 03:22:12 +08:00
|
|
|
|
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
|
2026-07-03 19:18:13 +08:00
|
|
|
|
onboarding_status: OnboardingStatus | None = None
|
|
|
|
|
|
credential_ref: str | None = None
|
|
|
|
|
|
credential_version: int | None = None
|
|
|
|
|
|
last_error: str | None = None
|
2026-07-02 03:22:12 +08:00
|
|
|
|
|
|
|
|
|
|
def __post_init__(self) -> None:
|
|
|
|
|
|
"""校验必填字段非空与至少一个可更新字段(AL-01)。
|
|
|
|
|
|
|
|
|
|
|
|
``account_id`` 必须非空,且 ``display_name`` / ``config`` / ``enabled``
|
2026-07-03 19:18:13 +08:00
|
|
|
|
/ ``status`` / ``transport_cursor`` / ``last_rotated_at``
|
|
|
|
|
|
/ ``onboarding_status`` / ``credential_ref`` / ``credential_version``
|
|
|
|
|
|
/ ``last_error`` 至少提供一个,否则无更新意义,在构造时即抛出
|
2026-07-02 03:22:12 +08:00
|
|
|
|
``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,
|
2026-07-03 19:18:13 +08:00
|
|
|
|
self.onboarding_status,
|
|
|
|
|
|
self.credential_ref,
|
|
|
|
|
|
self.credential_version,
|
|
|
|
|
|
self.last_error,
|
2026-07-02 03:22:12 +08:00
|
|
|
|
)
|
|
|
|
|
|
):
|
|
|
|
|
|
raise ValidationError(
|
|
|
|
|
|
"update",
|
2026-07-03 19:18:13 +08:00
|
|
|
|
"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",
|
2026-07-02 03:22:12 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
|
|
|
|
class SaveChannelSessionCmd:
|
2026-07-03 19:18:13 +08:00
|
|
|
|
"""保存渠道会话命令(AL-02)。
|
2026-07-02 03:22:12 +08:00
|
|
|
|
|
|
|
|
|
|
由持久化端口方法引用,描述一次渠道会话保存请求,携带渠道类型、账户
|
|
|
|
|
|
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)。
|
2026-07-08 03:57:05 +08:00
|
|
|
|
last_message_at: 最近消息时间(可选,新建会话时设置初始值,
|
|
|
|
|
|
避免 FR-27 inactive 清理因 NULL 误判)。
|
2026-07-02 03:22:12 +08:00
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
channel_type: ChannelType
|
|
|
|
|
|
account_id: str
|
|
|
|
|
|
peer_id: str
|
2026-07-03 19:18:13 +08:00
|
|
|
|
chat_type: Literal["p2p", "group"]
|
2026-07-02 03:22:12 +08:00
|
|
|
|
conversation_id: str | None = None
|
|
|
|
|
|
unified_identity_id: str | None = None
|
|
|
|
|
|
owner_peer_id: str | None = None
|
|
|
|
|
|
is_temporary: bool = False
|
2026-07-08 03:57:05 +08:00
|
|
|
|
last_message_at: datetime | None = None
|
2026-07-02 03:22:12 +08:00
|
|
|
|
|
|
|
|
|
|
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:
|
2026-07-03 19:18:13 +08:00
|
|
|
|
"""更新渠道会话命令(AL-02)。
|
2026-07-02 03:22:12 +08:00
|
|
|
|
|
|
|
|
|
|
由持久化端口方法引用,描述一次渠道会话更新请求,仅更新提供的字段,
|
|
|
|
|
|
支持会话关联信息与临时标记的局部更新。
|
|
|
|
|
|
|
|
|
|
|
|
字段:
|
|
|
|
|
|
session_id: 会话 ID。
|
|
|
|
|
|
conversation_id: 关联的内部会话 ID(可选)。
|
|
|
|
|
|
unified_identity_id: 统一身份 ID(可选)。
|
|
|
|
|
|
owner_peer_id: 主会话所有者对端 ID(可选)。
|
|
|
|
|
|
is_temporary: 临时会话标记(可选)。
|
|
|
|
|
|
closed_at: 会话关闭时间(可选)。
|
2026-07-08 03:57:05 +08:00
|
|
|
|
expected_version: 期望的乐观锁版本号(可选,非空时校验
|
|
|
|
|
|
``orm.version == expected_version``,不匹配抛 ConflictError)。
|
2026-07-02 03:22:12 +08:00
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
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
|
2026-07-08 03:57:05 +08:00
|
|
|
|
expected_version: int | None = None
|
2026-07-02 03:22:12 +08:00
|
|
|
|
|
|
|
|
|
|
def __post_init__(self) -> None:
|
|
|
|
|
|
"""校验必填字段非空与至少一个可更新字段。
|
|
|
|
|
|
|
|
|
|
|
|
``session_id`` 必须非空,且 ``conversation_id`` /
|
|
|
|
|
|
``unified_identity_id`` / ``owner_peer_id`` / ``is_temporary`` /
|
2026-07-08 03:57:05 +08:00
|
|
|
|
``closed_at`` / ``expected_version`` 至少提供一个,否则无更新
|
|
|
|
|
|
意义,在构造时即抛出 ``ValidationError``,adapter 不再做该校验
|
|
|
|
|
|
(INV-8)。
|
2026-07-02 03:22:12 +08:00
|
|
|
|
"""
|
|
|
|
|
|
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,
|
2026-07-08 03:57:05 +08:00
|
|
|
|
self.expected_version,
|
2026-07-02 03:22:12 +08:00
|
|
|
|
)
|
|
|
|
|
|
):
|
|
|
|
|
|
raise ValidationError(
|
|
|
|
|
|
"update",
|
|
|
|
|
|
"at least one updatable field must be provided",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
|
|
|
|
class CreatePairingCmd:
|
2026-07-03 19:18:13 +08:00
|
|
|
|
"""创建配对命令(FR-33)。
|
2026-07-02 03:22:12 +08:00
|
|
|
|
|
|
|
|
|
|
由持久化端口方法引用,描述一次配对创建请求,携带渠道类型、账户 ID、
|
|
|
|
|
|
对端 ID、对端名称与过期时间,用于 DM 安全与配对审批(FR-33)。
|
|
|
|
|
|
|
|
|
|
|
|
字段:
|
|
|
|
|
|
channel_type: 渠道类型。
|
|
|
|
|
|
account_id: 渠道账户 ID。
|
|
|
|
|
|
peer_id: 对端 ID。
|
|
|
|
|
|
peer_name: 对端名称(可选)。
|
|
|
|
|
|
expires_in_seconds: 过期时间(秒,默认 604800 即 7 天,FR-33)。
|
2026-07-08 03:57:05 +08:00
|
|
|
|
created_by: 创建人 ID(审计用,可选)。
|
2026-07-02 03:22:12 +08:00
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
channel_type: ChannelType
|
|
|
|
|
|
account_id: str
|
|
|
|
|
|
peer_id: str
|
|
|
|
|
|
peer_name: str | None = None
|
|
|
|
|
|
expires_in_seconds: int = 604800
|
2026-07-08 03:57:05 +08:00
|
|
|
|
created_by: str | None = None
|
2026-07-02 03:22:12 +08:00
|
|
|
|
|
|
|
|
|
|
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:
|
2026-07-03 19:18:13 +08:00
|
|
|
|
"""保存审计日志命令(FR-34)。
|
2026-07-02 03:22:12 +08:00
|
|
|
|
|
|
|
|
|
|
由持久化端口方法引用,描述一次审计日志保存请求,携带操作人、操作类型、
|
|
|
|
|
|
目标、参数摘要、结果与追踪信息,用于审计日志持久化(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
|
2026-07-03 19:18:13 +08:00
|
|
|
|
result: Literal["success", "failed"]
|
2026-07-02 03:22:12 +08:00
|
|
|
|
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:
|
2026-07-03 19:18:13 +08:00
|
|
|
|
"""保存发件箱条目命令(FR-22)。
|
2026-07-02 03:22:12 +08:00
|
|
|
|
|
|
|
|
|
|
由持久化端口方法引用,描述一次发件箱条目保存请求,携带消息 ID、渠道
|
|
|
|
|
|
账户 ID、持久化策略与追踪 ID,用于持久化投递(FR-22)。
|
|
|
|
|
|
|
|
|
|
|
|
字段:
|
2026-07-07 18:54:33 +08:00
|
|
|
|
message_id: 消息 ID。BEST_EFFORT 轻量记录允许 ``None``(消息持久化
|
|
|
|
|
|
失败但仍需追踪投递状态的降级路径,O-01/C-6)。
|
2026-07-02 03:22:12 +08:00
|
|
|
|
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)。
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
2026-07-07 18:54:33 +08:00
|
|
|
|
message_id: str | None
|
2026-07-02 03:22:12 +08:00
|
|
|
|
channel_account_id: str
|
2026-07-03 19:18:13 +08:00
|
|
|
|
durability_policy: MessageDurabilityPolicy
|
2026-07-02 03:22:12 +08:00
|
|
|
|
trace_id: str | None = None
|
|
|
|
|
|
channel_session_id: str | None = None
|
|
|
|
|
|
|
|
|
|
|
|
def __post_init__(self) -> None:
|
|
|
|
|
|
"""校验必填字段非空与持久化策略取值(FR-22)。
|
|
|
|
|
|
|
2026-07-07 18:54:33 +08:00
|
|
|
|
``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)。
|
2026-07-02 03:22:12 +08:00
|
|
|
|
"""
|
2026-07-07 18:54:33 +08:00
|
|
|
|
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")
|
2026-07-02 03:22:12 +08:00
|
|
|
|
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:
|
2026-07-03 19:18:13 +08:00
|
|
|
|
"""保存用户身份命令(FR-05)。
|
2026-07-02 03:22:12 +08:00
|
|
|
|
|
|
|
|
|
|
由持久化端口方法引用,描述一次用户身份保存请求,携带身份类型、身份
|
|
|
|
|
|
值、渠道信息与关联用户 ID,用于身份解析与统一身份关联(FR-05)。
|
|
|
|
|
|
|
|
|
|
|
|
字段:
|
|
|
|
|
|
identity_type: 身份类型(邮箱 / 手机 / 组织员工 ID)。
|
|
|
|
|
|
identity_value: 身份值。
|
|
|
|
|
|
channel_type: 渠道类型(可选)。
|
|
|
|
|
|
channel_sender_id: 渠道侧发送者 ID(可选)。
|
|
|
|
|
|
source: 身份来源(可选)。
|
|
|
|
|
|
user_id: 关联用户 ID(可选)。
|
2026-07-08 03:57:05 +08:00
|
|
|
|
confidence: 身份置信度等级(``low`` / ``medium`` / ``high``,默认 ``low``),
|
|
|
|
|
|
由身份解析结果映射,用于持久化身份记录的可信程度。
|
2026-07-02 03:22:12 +08:00
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
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
|
2026-07-08 03:57:05 +08:00
|
|
|
|
confidence: str = "low"
|
2026-07-02 03:22:12 +08:00
|
|
|
|
|
|
|
|
|
|
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: 操作类型。
|
2026-07-09 04:21:28 +08:00
|
|
|
|
status: 状态(in_progress / completed / partial_completed / failed)。
|
|
|
|
|
|
``partial_completed`` 表示 fan-out 存在可重试失败(Task 11),
|
|
|
|
|
|
客户端可调用 ``retryFailedTargets`` 续投失败目标。
|
|
|
|
|
|
response_body: 首次请求的响应体(completed / partial_completed 时
|
|
|
|
|
|
填充,含结果与原始 cmd 供重试重建)。
|
2026-07-03 19:18:13 +08:00
|
|
|
|
in_progress_started_at: ``in_progress`` 状态开始时间(UTC),供
|
|
|
|
|
|
``AdminMessageService`` 判断超时卡死的 in_progress 记录并清理
|
|
|
|
|
|
重建,避免服务崩溃后幂等键被永久锁死。
|
2026-07-02 03:22:12 +08:00
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
record_id: int
|
|
|
|
|
|
idempotency_key: str
|
|
|
|
|
|
operation: str
|
2026-07-09 04:21:28 +08:00
|
|
|
|
status: Literal["in_progress", "completed", "partial_completed", "failed"]
|
2026-07-02 03:22:12 +08:00
|
|
|
|
response_body: dict[str, Any] | None = None
|
2026-07-03 19:18:13 +08:00
|
|
|
|
in_progress_started_at: datetime | None = None
|
|
|
|
|
|
|
|
|
|
|
|
def __post_init__(self) -> None:
|
|
|
|
|
|
"""校验必填字段非空与 status 取值(FR-19)。
|
|
|
|
|
|
|
|
|
|
|
|
``record_id`` 必须为正整数,``idempotency_key`` 与 ``operation``
|
|
|
|
|
|
必须非空,``status`` 必须为 ``in_progress`` / ``completed`` /
|
2026-07-09 04:21:28 +08:00
|
|
|
|
``partial_completed`` / ``failed`` 之一,在构造时即抛出
|
|
|
|
|
|
``ValidationError``,adapter 不再做该校验(INV-8)。
|
2026-07-03 19:18:13 +08:00
|
|
|
|
"""
|
|
|
|
|
|
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")
|
2026-07-09 04:21:28 +08:00
|
|
|
|
if self.status not in ("in_progress", "completed", "partial_completed", "failed"):
|
2026-07-03 19:18:13 +08:00
|
|
|
|
raise ValidationError(
|
|
|
|
|
|
"status",
|
2026-07-09 04:21:28 +08:00
|
|
|
|
"must be one of: in_progress, completed, partial_completed, failed",
|
2026-07-03 19:18:13 +08:00
|
|
|
|
)
|