"""集中式 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, last_error=orm.last_error, plugin_status=orm.plugin_status, version=orm.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, last_route_at=orm.last_route_at, route_match_source=orm.route_match_source, version=orm.version, ) 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, expired_at=orm.expired_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, sent_at=orm.sent_at, last_retry_at=orm.last_retry_at, 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, delivered_parts=list(orm.delivered_parts) if orm.delivered_parts else [], ) 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。 ``channel_bindings`` / ``merged_from`` 为 JSON 列,默认空容器兜底。 """ 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, channel_bindings=orm.channel_bindings or {}, merged_from=orm.merged_from or [], confidence=orm.confidence or "low", created_at=orm.created_at, updated_at=orm.updated_at, version=orm.version, pending_review=bool(getattr(orm, "pending_review", False)), ) 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, version=orm.version, 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, )