ForcePilot/backend/package/yuxi/channels/contract/dtos/persistence.py
Kris b8ac375e8e feat: 新增多渠道客服会话、身份合并与重试能力等功能
本次提交包含多项核心功能迭代与优化:
1. 新增KF客服会话类型,完善聊天类型枚举
2. 新增消息撤回操作类型与身份置信度排序方法
3. 新增控制面结果DTO与敏感字段注册表端口
4. 新增身份合并回滚、重试失败投递目标等业务能力
5. 优化Outbox投递逻辑与熔断器状态判断
6. 修复部分代码冗余与类型不匹配问题
7. 新增数据库索引并发创建与路由绑定清理逻辑
8. 优化会话关闭服务与插件重载并发控制
2026-07-09 04:21:28 +08:00

477 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""持久化命令 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: 消息 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_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",
)