本次提交包含多项核心改进: 1. 新增微信公众号插件拉取传输模式配置,完善manifest与manifest加载逻辑 2. 新增运行态状态机与幂等冲突错误体系,补充错误映射与领域错误导出 3. 优化出站与入站上下文,新增幂等键、流式中断标记等字段 4. 完善发件箱仓储与模型,新增失败条目查询、投递原子语义字段 5. 修复签名验证阶段异常捕获逻辑,防御性处理内置NotImplementedError 6. 新增出站预算释放方法,完善机器人循环预算管控 7. 优化出站管道格式阶段,新增消息长度校验逻辑 8. 完善出站打字指示器阶段,新增重复启动防御与状态同步 9. 重构出站标记失败阶段,按源状态分支处理状态转换 10. 新增入站幂等过滤阶段,修复入站路由阶段空指针问题 11. 优化出站恢复扫描器,修复状态机调用与聚合根重建逻辑 12. 完善微信公众号适配器,新增类型校验与异常包装 13. 修复数据库事务回滚逻辑,简化不必要的显式回滚操作
401 lines
16 KiB
Python
401 lines
16 KiB
Python
"""集中式 ORM↔dataclass 转换 + 加解密边界。
|
||
|
||
本模块是 ``channels/adapters/`` 下唯一调用 ``yuxi.utils.crypto`` 的文件,
|
||
负责 ORM Model ↔ 领域 dataclass 的字段映射与敏感字段加解密。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import datetime as dt
|
||
from typing import Any
|
||
|
||
from yuxi.channels.contract.dtos.audit import AuditEntry, AuditOperationType
|
||
from yuxi.channels.contract.dtos.channel import (
|
||
AccountStatus,
|
||
ChannelAccount,
|
||
ChannelSession,
|
||
ChannelType,
|
||
OnboardingStatus,
|
||
UserIdentity,
|
||
)
|
||
from yuxi.channels.contract.dtos.outbox import (
|
||
MessageDurabilityPolicy,
|
||
OutboxEntry,
|
||
OutboxStatus,
|
||
)
|
||
from yuxi.channels.contract.dtos.pairing import PairingRecord, PairingStatus
|
||
from yuxi.channels.contract.dtos.route import RouteBindingRule
|
||
from yuxi.channels.contract.errors import DependencyError
|
||
from yuxi.channels.contract.errors.base import Error
|
||
from yuxi.storage.postgres.models_channels import (
|
||
ChannelAccount as ChannelAccountORM,
|
||
)
|
||
from yuxi.storage.postgres.models_channels import (
|
||
ChannelAuditLog as ChannelAuditLogORM,
|
||
)
|
||
from yuxi.storage.postgres.models_channels import (
|
||
ChannelOutboxEntry as ChannelOutboxEntryORM,
|
||
)
|
||
from yuxi.storage.postgres.models_channels import (
|
||
ChannelPairing as ChannelPairingORM,
|
||
)
|
||
from yuxi.storage.postgres.models_channels import (
|
||
ChannelRouteBinding as ChannelRouteBindingORM,
|
||
)
|
||
from yuxi.storage.postgres.models_channels import (
|
||
ChannelSession as ChannelSessionORM,
|
||
)
|
||
from yuxi.storage.postgres.models_channels import (
|
||
ChannelUserIdentity as ChannelUserIdentityORM,
|
||
)
|
||
from yuxi.utils.crypto import decrypt_sensitive_fields, encrypt_sensitive_fields
|
||
|
||
|
||
def _enum[T, V](value: V, cls: type[T], resource: str) -> T:
|
||
"""安全构造枚举,将非法值翻译为 DependencyError。
|
||
|
||
ORM 字段在数据损坏/迁移残留场景下可能持有非法枚举值,原生
|
||
``Enum(...)`` 构造抛出 ``ValueError`` 会穿透至核心层,违反 INV-7。
|
||
本 helper 捕获 ``ValueError`` 并翻译为契约层 ``DependencyError``,
|
||
保留原始异常链,资源标识用于错误追踪。
|
||
|
||
Args:
|
||
value: ORM 字段原始值。
|
||
cls: 目标枚举类型。
|
||
resource: 资源标识(如 ``"channel_account"``),用于错误信息。
|
||
|
||
Returns:
|
||
构造后的枚举实例。
|
||
|
||
Raises:
|
||
DependencyError: ``value`` 不是 ``cls`` 的合法成员。
|
||
"""
|
||
try:
|
||
return cls(value)
|
||
except ValueError as exc:
|
||
raise DependencyError(
|
||
resource,
|
||
Error(f"invalid enum value for {cls.__name__}: {value!r}"),
|
||
) from exc
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════════
|
||
# 含敏感字段的 dataclass(读取时解密 / 写入前加密)
|
||
# ═══════════════════════════════════════════════════════════════════════════
|
||
|
||
# ─── ChannelAccount(dict 型敏感字段:config) ─────────────────────────────
|
||
|
||
|
||
def orm_to_channel_account(orm: ChannelAccountORM) -> ChannelAccount:
|
||
"""ORM → dataclass,解密 ``config`` 字段。
|
||
|
||
解密失败时翻译为 ``DependencyError`` 抛出,保留原始异常链
|
||
(密钥变更或数据损坏场景),不让原生 ``ValueError`` 穿透至调用方。
|
||
|
||
存量数据兜底:``onboarding_status`` 为空时默认 ``OnboardingStatus.PENDING``。
|
||
"""
|
||
try:
|
||
config = decrypt_sensitive_fields(orm.config) or {}
|
||
except ValueError as exc:
|
||
raise DependencyError(
|
||
"channel_account",
|
||
Error(f"config decryption failed: {exc}"),
|
||
) from exc
|
||
onboarding_status_raw = orm.onboarding_status or OnboardingStatus.PENDING.value
|
||
return ChannelAccount(
|
||
channel_type=ChannelType(orm.channel_type),
|
||
account_id=orm.account_id,
|
||
display_name=orm.display_name,
|
||
config=config,
|
||
enabled=bool(orm.enabled),
|
||
status=_enum(orm.status, AccountStatus, "channel_account"),
|
||
created_at=orm.created_at,
|
||
updated_at=orm.updated_at,
|
||
transport_cursor=orm.transport_cursor or "",
|
||
last_rotated_at=orm.last_rotated_at,
|
||
service_user_uid=orm.service_user_uid,
|
||
onboarding_status=_enum(onboarding_status_raw, OnboardingStatus, "channel_account"),
|
||
credential_ref=orm.credential_ref,
|
||
credential_version=orm.credential_version,
|
||
)
|
||
|
||
|
||
def channel_account_for_write(data: dict[str, Any]) -> dict[str, Any]:
|
||
"""明文 dict → 密文 dict,加密 ``config`` 字段。
|
||
|
||
注意:``encrypt_sensitive_fields`` 不幂等,调用方必须保证传入的是明文(未被加密过)。
|
||
``onboarding_status`` 为 ``OnboardingStatus`` 枚举时转为 ``.value`` 字符串以写入 ORM
|
||
(``credential_ref`` / ``credential_version`` 为原生类型,随 ``dict(data)`` 透传)。
|
||
"""
|
||
data = dict(data)
|
||
if data.get("config") is not None:
|
||
data["config"] = encrypt_sensitive_fields(data["config"])
|
||
onboarding_status = data.get("onboarding_status")
|
||
if isinstance(onboarding_status, OnboardingStatus):
|
||
data["onboarding_status"] = onboarding_status.value
|
||
return data
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════════
|
||
# 无敏感字段的 dataclass(直接字段映射)
|
||
# ═══════════════════════════════════════════════════════════════════════════
|
||
|
||
# ─── ChannelSession(无敏感字段) ───────────────────────────────────────────
|
||
|
||
|
||
def orm_to_channel_session(orm: ChannelSessionORM) -> ChannelSession:
|
||
"""ORM → dataclass,无敏感字段,直接映射。
|
||
|
||
``account_id`` / ``conversation_id`` 为 ORM 外键(int),转为 str 业务 ID。
|
||
"""
|
||
return ChannelSession(
|
||
session_id=orm.session_id,
|
||
channel_type=ChannelType(orm.channel_type),
|
||
account_id=str(orm.account_id),
|
||
peer_id=orm.peer_id,
|
||
chat_type=orm.chat_type,
|
||
conversation_id=str(orm.conversation_id) if orm.conversation_id is not None else None,
|
||
unified_identity_id=orm.unified_identity_id,
|
||
owner_peer_id=orm.owner_peer_id,
|
||
is_temporary=bool(orm.is_temporary),
|
||
created_at=orm.created_at,
|
||
updated_at=orm.updated_at,
|
||
deleted_at=orm.deleted_at,
|
||
closed_at=orm.closed_at,
|
||
last_message_at=orm.last_message_at,
|
||
)
|
||
|
||
|
||
def channel_session_for_write(data: dict[str, Any]) -> dict[str, Any]:
|
||
"""明文 dict → 密文 dict,无敏感字段,直接透传。"""
|
||
return dict(data)
|
||
|
||
|
||
# ─── PairingRecord(无敏感字段) ────────────────────────────────────────────
|
||
|
||
|
||
def orm_to_pairing(
|
||
orm: ChannelPairingORM,
|
||
account_orm: ChannelAccountORM | None = None,
|
||
) -> PairingRecord:
|
||
"""ORM → dataclass,无敏感字段,直接映射。
|
||
|
||
``account_orm`` 非空时从 ``channel_accounts`` 表关联填充业务
|
||
``channel_account_id``(字符串业务标识)与 ``channel_type``;为 ``None``
|
||
时 ``channel_account_id`` 回退为 ORM 外键的字符串形式(向后兼容),
|
||
``channel_type`` 保持 ``None``。
|
||
|
||
参数:
|
||
orm: 配对审批 ORM 实例。
|
||
account_orm: 关联的渠道账户 ORM 实例(可选,由适配器层查询填充)。
|
||
"""
|
||
return PairingRecord(
|
||
pairing_id=orm.pairing_id,
|
||
channel_account_id=(account_orm.account_id if account_orm is not None else str(orm.account_id)),
|
||
peer_id=orm.peer_id,
|
||
status=_enum(orm.status, PairingStatus, "pairing"),
|
||
channel_type=(ChannelType(account_orm.channel_type) if account_orm is not None else None),
|
||
peer_name=orm.peer_name,
|
||
approver_id=orm.approver_id,
|
||
approved_at=orm.approved_at,
|
||
rejected_at=orm.rejected_at,
|
||
revoked_at=orm.revoked_at,
|
||
reason=orm.reason,
|
||
created_at=orm.created_at,
|
||
updated_at=orm.updated_at,
|
||
expires_at=orm.expires_at,
|
||
requested_at=orm.requested_at,
|
||
version=orm.version,
|
||
)
|
||
|
||
|
||
def pairing_for_write(data: dict[str, Any]) -> dict[str, Any]:
|
||
"""明文 dict → 密文 dict,无敏感字段,直接透传。"""
|
||
return dict(data)
|
||
|
||
|
||
# ─── AuditEntry(无敏感字段,脱敏由上层完成) ───────────────────────────────
|
||
|
||
|
||
def orm_to_audit_log(orm: ChannelAuditLogORM) -> AuditEntry:
|
||
"""ORM → dataclass,无敏感字段,直接映射。
|
||
|
||
``params_summary`` 脱敏由上层 ``AuditLog`` 聚合根完成,mapper 不再脱敏。
|
||
ORM ``target_channel`` 同时映射到 dataclass ``target`` 与 ``target_channel``。
|
||
"""
|
||
return AuditEntry(
|
||
operator=orm.operator,
|
||
operation=_enum(orm.operation, AuditOperationType, "audit_log"),
|
||
target=orm.target_channel,
|
||
result=orm.result,
|
||
timestamp=orm.timestamp,
|
||
params_summary=orm.params_summary or None,
|
||
trace_id=orm.trace_id,
|
||
source_ip=orm.source_ip,
|
||
request_id=orm.request_id,
|
||
message_id=getattr(orm, "message_id", None),
|
||
content_summary=getattr(orm, "content_summary", None),
|
||
target_channel=orm.target_channel,
|
||
target_account=orm.target_account,
|
||
)
|
||
|
||
|
||
def _serialize_for_json(value: Any) -> Any:
|
||
"""递归将 datetime/date 对象转为 ISO 字符串,保证 JSONB 可序列化。"""
|
||
if isinstance(value, dt.datetime):
|
||
return value.isoformat()
|
||
if isinstance(value, dt.date):
|
||
return value.isoformat()
|
||
if isinstance(value, dict):
|
||
return {k: _serialize_for_json(v) for k, v in value.items()}
|
||
if isinstance(value, list):
|
||
return [_serialize_for_json(v) for v in value]
|
||
if isinstance(value, tuple):
|
||
return tuple(_serialize_for_json(v) for v in value)
|
||
return value
|
||
|
||
|
||
def audit_log_for_write(data: dict[str, Any]) -> dict[str, Any]:
|
||
"""明文 dict → 密文 dict,无敏感字段。
|
||
|
||
ORM 列名为 ``target_channel``:优先使用 dataclass 的 ``target_channel`` 字段,
|
||
未提供时回退到 ``target`` 字段(向后兼容)。
|
||
|
||
``params_summary`` 中可能混入 datetime/date 对象(如前端传入的时间过滤
|
||
参数或后端生成的字段),在写入 JSONB 前递归转为 ISO 字符串,避免
|
||
``Object of type datetime is not JSON serializable`` 导致事务回滚。
|
||
"""
|
||
data = dict(data)
|
||
if not data.get("target_channel") and "target" in data:
|
||
data["target_channel"] = data.pop("target")
|
||
if "params_summary" in data:
|
||
data["params_summary"] = _serialize_for_json(data["params_summary"])
|
||
return data
|
||
|
||
|
||
# ─── OutboxEntry(无敏感字段) ───────────────────────────────────────────────
|
||
|
||
|
||
def orm_to_outbox_entry(
|
||
orm: ChannelOutboxEntryORM,
|
||
channel_type: ChannelType | None = None,
|
||
) -> OutboxEntry:
|
||
"""ORM → dataclass,无敏感字段,直接映射。
|
||
|
||
``message_id`` / ``account_id`` / ``channel_session_id`` 为 ORM 外键
|
||
(int,``channel_session_id`` 可空),转为 str 业务 ID。``max_retry`` /
|
||
``expires_at`` 从 ORM 列直接映射,消除补偿阶段硬编码(P2-7)。
|
||
``channel_type`` 由列表查询关联 ``channel_accounts`` 表提供,未提供时
|
||
默认 ``None`` 保持向后兼容。
|
||
"""
|
||
return OutboxEntry(
|
||
outbox_id=orm.outbox_id,
|
||
message_id=str(orm.message_id),
|
||
channel_account_id=str(orm.account_id),
|
||
status=_enum(orm.status, OutboxStatus, "outbox_entry"),
|
||
durability_policy=_enum(orm.durability_policy, MessageDurabilityPolicy, "outbox_entry"),
|
||
retry_count=orm.retry_count,
|
||
max_retry=orm.max_retry,
|
||
next_retry_at=orm.next_retry_at,
|
||
last_error=orm.last_error,
|
||
created_at=orm.created_at,
|
||
updated_at=orm.updated_at,
|
||
expires_at=orm.expires_at,
|
||
channel_msg_id=orm.channel_msg_id,
|
||
version=orm.version,
|
||
channel_session_id=str(orm.channel_session_id) if orm.channel_session_id is not None else None,
|
||
# 聚合视图域 analytics 子域字段(延迟分布 / 漏斗聚合查询)
|
||
latency_ms=orm.latency_ms,
|
||
funnel_node=orm.funnel_node,
|
||
channel_type=channel_type,
|
||
# 投递原子语义与降级追踪字段(O-02/O-09/O-10/O-11/O-16)
|
||
idempotency_key=orm.idempotency_key,
|
||
channel_request_id=orm.channel_request_id,
|
||
partial_failure=orm.partial_failure,
|
||
stream_aborted_at_chunk=orm.stream_aborted_at_chunk,
|
||
degraded_reason=orm.degraded_reason,
|
||
)
|
||
|
||
|
||
def outbox_entry_for_write(data: dict[str, Any]) -> dict[str, Any]:
|
||
"""明文 dict → 密文 dict,无敏感字段,直接透传。
|
||
|
||
``latency_ms`` / ``funnel_node`` 为聚合视图域 analytics 子域新增字段,
|
||
由调用方按需写入 dict;本函数透传至 ORM 写入层,未提供时由 ORM
|
||
默认 ``None`` 兜底(向后兼容历史数据)。
|
||
"""
|
||
return dict(data)
|
||
|
||
|
||
# ─── UserIdentity(无敏感字段) ─────────────────────────────────────────────
|
||
|
||
|
||
def orm_to_user_identity(orm: ChannelUserIdentityORM) -> UserIdentity:
|
||
"""ORM → dataclass,无敏感字段,直接映射。
|
||
|
||
``user_id`` 为 ORM 外键(int,可空),转为 str 业务 ID。
|
||
``channel_type`` 为可空字符串,转为 ``ChannelType`` 枚举或 None。
|
||
"""
|
||
return UserIdentity(
|
||
identity_id=orm.identity_id,
|
||
user_id=str(orm.user_id) if orm.user_id is not None else None,
|
||
identity_type=orm.identity_type,
|
||
identity_value=orm.identity_value,
|
||
channel_type=ChannelType(orm.channel_type) if orm.channel_type is not None else None,
|
||
channel_sender_id=orm.channel_sender_id,
|
||
source=orm.source,
|
||
created_at=orm.created_at,
|
||
updated_at=orm.updated_at,
|
||
version=orm.version,
|
||
)
|
||
|
||
|
||
def user_identity_for_write(data: dict[str, Any]) -> dict[str, Any]:
|
||
"""明文 dict → 密文 dict,无敏感字段,直接透传。"""
|
||
return dict(data)
|
||
|
||
|
||
# ─── WhitelistEntry ─────────────────────────────────────────────────────────
|
||
# 注:whitelistEntryToDict / whitelistEntryFromDict 已上移至
|
||
# contract/dtos/whitelist.py(纯 DTO 转换,不依赖 ORM,供应用层共用)
|
||
|
||
|
||
# ─── RouteBindingRule(无敏感字段) ─────────────────────────────────────────
|
||
|
||
|
||
#: 内置匹配层级优先级(与 factory._DEFAULT_MATCH_TIERS 保持一致,仅展示用)
|
||
_TIER_PRIORITY_MAP: dict[str, int] = {
|
||
"session_key": 800,
|
||
"identity_id": 700,
|
||
"peer_id": 600,
|
||
"chat_type": 500,
|
||
"channel_session": 400,
|
||
"channel_type": 300,
|
||
"account": 200,
|
||
"default": 100,
|
||
}
|
||
|
||
|
||
def orm_to_route_binding(orm: ChannelRouteBindingORM) -> RouteBindingRule:
|
||
"""ORM → dataclass,无敏感字段,直接映射。
|
||
|
||
``match_source`` 为 tier 名字符串(非枚举),直接透传;
|
||
``channel_type`` 转为 ``ChannelType`` 枚举;
|
||
``priority`` 根据 ``match_source`` 从内置层级优先级派生,供前端展示。
|
||
"""
|
||
return RouteBindingRule(
|
||
binding_id=orm.binding_id,
|
||
channel_type=ChannelType(orm.channel_type),
|
||
account_id=orm.account_id,
|
||
match_source=orm.match_source,
|
||
match_value=orm.match_value,
|
||
agent_binding=orm.agent_binding,
|
||
enabled=bool(orm.enabled),
|
||
description=orm.description,
|
||
priority=_TIER_PRIORITY_MAP.get(orm.match_source),
|
||
created_by=orm.created_by,
|
||
updated_by=orm.updated_by,
|
||
created_at=orm.created_at,
|
||
updated_at=orm.updated_at,
|
||
)
|