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

477 lines
20 KiB
Python
Raw Normal View History

"""持久化命令 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.uiduser_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
expected_version: int
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: 临时会话标记默认 FalseFR-27
last_message_at: 最近消息时间可选新建会话时设置初始值
避免 FR-27 inactive 清理因 NULL 误判
"""
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
last_message_at: datetime | None = None
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: 会话关闭时间可选
expected_version: 期望的乐观锁版本号可选非空时校验
``orm.version == expected_version``不匹配抛 ConflictError
"""
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
expected_version: int | None = None
def __post_init__(self) -> None:
"""校验必填字段非空与至少一个可更新字段。
``session_id`` 必须非空 ``conversation_id`` /
``unified_identity_id`` / ``owner_peer_id`` / ``is_temporary`` /
``closed_at`` / ``expected_version`` 至少提供一个否则无更新
意义在构造时即抛出 ``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,
self.expected_version,
)
):
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
created_by: 创建人 ID审计用可选
"""
channel_type: ChannelType
account_id: str
peer_id: str
peer_name: str | None = None
expires_in_seconds: int = 604800
created_by: str | None = None
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: 消息 IDBEST_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_idFR-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可选
confidence: 身份置信度等级``low`` / ``medium`` / ``high``默认 ``low``
由身份解析结果映射用于持久化身份记录的可信程度
"""
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
confidence: str = "low"
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 / partial_completed / failed
``partial_completed`` 表示 fan-out 存在可重试失败Task 11
客户端可调用 ``retryFailedTargets`` 续投失败目标
response_body: 首次请求的响应体completed / partial_completed
填充含结果与原始 cmd 供重试重建
in_progress_started_at: ``in_progress`` 状态开始时间UTC
``AdminMessageService`` 判断超时卡死的 in_progress 记录并清理
重建避免服务崩溃后幂等键被永久锁死
"""
record_id: int
idempotency_key: str
operation: str
status: Literal["in_progress", "completed", "partial_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`` /
``partial_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", "partial_completed", "failed"):
raise ValidationError(
"status",
"must be one of: in_progress, completed, partial_completed, failed",
)