本次提交涵盖了近百处代码优化与功能补全,包括: 1. 完善配置与数据模型:新增expired_at配对记录字段、路由绑定乐观锁版本控制、会话路由信息追踪字段 2. 优化业务流程:添加幂等记录操作人审计、会话合并领域服务文档更新、媒体处理异步化改造 3. 新增功能能力:健康检查时间更新、会话路由信息更新接口、内容审核/幂等记录清理定时任务 4. 修复与简化:移除废弃的max_message_length属性、修复微信iLink适配器配置读取路径、简化配对过期扫描逻辑 5. 代码规范优化:统一敏感词检测工具导入、完善事务上下文处理注释、调整wechat_woc入站适配器sender回退逻辑
474 lines
19 KiB
Python
474 lines
19 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
|
||
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: 临时会话标记(默认 False,FR-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_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(可选)。
|
||
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 / 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",
|
||
)
|