"""控制面序列化器与共享工具函数。 从原 ``dispatch_stage.py`` 模块级函数抽取,按域分组。所有函数行为 零变更,仅迁移到独立模块便于维护。包括: - 通用工具:``dataclass_to_dict`` / ``_requireParam`` / ``_requireAccountId`` / ``_coerceQueryDateTime`` / ``_coerceChannelType`` / ``_coerceAccountStatus`` / ``_coerceSessionStatus`` - config 域:``_configValueToDict`` / ``_configFieldToDict`` / ``_normalizeConfigEntries`` - session 域:``_channelSessionToDict`` - message 域:``_messageToDict`` / ``_messageSearchItemToDict`` - pairing 域:``_pairingRecordToDict`` / ``_pairingStatsToDict`` / ``_reconstructPairingApproval`` - whitelist 域:``_dictToWhitelistEntries`` / ``_entriesToCsv`` / ``_sanitize_csv_field`` / ``_buildExportFilename`` - directory 域:``_directorySearchResultToDict`` / ``_directoryEntryToDict`` / ``_channelUserToDict`` / ``_channelGroupToDict`` / ``_groupMemberToDict`` / ``_groupMemberResultToDict`` - audit 域:``_auditEntryToDict`` / ``_auditLogStatsToDict`` / ``_auditQueryToDict`` / ``_retentionPolicyToDict`` - outbox 域:``_buildOutboxQueryFilter`` / ``_outboxEntryToDict`` / ``_rebuildOutboxAggregate`` / ``_outboxEntriesToCsv`` - content_review 域:``_contentReviewStatsToDict`` - 熔断器:``_serializeCircuitState`` """ from __future__ import annotations import dataclasses from datetime import UTC, datetime, timedelta from typing import Any from yuxi.channels.application.control_plane.context import ( ControlPlaneContext, ) from yuxi.channels.application.observability.circuit_breaker.channel_circuit_breaker import ( CircuitState, ) from yuxi.channels.contract.dtos.audit.audit import ( AuditEntry, AuditLogStats, AuditQuery, RetentionPolicy, ) from yuxi.channels.contract.dtos.audit.content_review import ContentReviewStatsResult from yuxi.channels.contract.dtos.config.config import ( ConfigField, ConfigValue, ) from yuxi.channels.contract.dtos.identity.directory import ( ChannelGroup, ChannelUser, DirectorySearchResult, GroupMember, GroupMemberResult, ) from yuxi.channels.contract.dtos.messaging.channel import ( AccountStatus, ChannelType, MessageSearchItem, SessionStatus, ) from yuxi.channels.contract.dtos.outbox.outbox import ( OutboxEntry, OutboxQueryFilter, OutboxStatus, ) from yuxi.channels.contract.dtos.pairing.pairing import ( PairingRecord, PairingStatsResult, ) from yuxi.channels.contract.dtos.whitelist.whitelist import ( WhitelistEntry, WhitelistPolicyType, whitelistEntryFromDict, ) from yuxi.channels.contract.errors import ValidationError from yuxi.channels.contract.ports.driven.shared.masking_port import MaskingPort from yuxi.channels.core.outbox import outbox_entry as outbox_entry_model from yuxi.channels.core.pairing.pairing_approval import PairingApproval from yuxi.utils.datetime_utils import coerce_any_to_utc_datetime, format_utc_datetime # FR-14 目录查询缓存 TTL(秒),PRD §FR-14 建议 5 分钟 _DIRECTORY_CACHE_TTL_SECONDS = 300 def _serializeCircuitState(state: CircuitState | None) -> dict[str, Any] | None: """序列化熔断器状态快照为 JSON 可序列化的 dict,``None`` 原样返回。""" if state is None: return None return { "status": state.status, "failure_count": state.failure_count, "opened_at": state.opened_at.isoformat() if state.opened_at else None, "recovery_timeout": state.recovery_timeout, "half_open_permits": state.half_open_permits, } def dataclass_to_dict(obj: Any) -> dict[str, Any]: """将 dataclass 实例序列化为 dict,供控制面 handler 返回 dispatch_result。""" return dataclasses.asdict(obj) def _requireAccountId(ctx: ControlPlaneContext) -> str: """从控制面上下文提取并校验 ``account_id`` 参数。缺失或为空抛 ``ValidationError``(400)。""" account_id = ctx.params.get("account_id") if not account_id or not isinstance(account_id, str): raise ValidationError( "account_id", "account_id is required and must be a non-empty string", trace_id=ctx.trace_id, ) return account_id def _requireParam(ctx: ControlPlaneContext, name: str) -> Any: """从控制面上下文提取并校验必填参数。缺失抛 ``ValidationError``(400)。""" value = ctx.params.get(name) if value is None: raise ValidationError( name, f"{name} is required", trace_id=ctx.trace_id, ) return value def _coerceQueryDateTime( value: Any, field_name: str, trace_id: str | None, ) -> datetime | None: """将查询参数中的时间值统一转换为 UTC datetime(FR-34)。 支持 ``datetime`` / ISO 8601 字符串 / Unix 时间戳;``None`` 原样返回。 非法格式抛 ``ValidationError``(400),避免原生异常被 ``_executeControl`` 兜底翻译为 ``InternalError``(500)。 """ try: return coerce_any_to_utc_datetime(value) except (ValueError, TypeError) as exc: raise ValidationError( field_name, f"invalid {field_name} format: {value!r}", trace_id=trace_id, ) from exc def _coerceChannelType(value: Any, trace_id: str | None) -> ChannelType | None: """将查询参数中的渠道类型值统一转换为 ``ChannelType``。 支持非空字符串与 ``ChannelType`` 实例;``None`` 原样返回。 非法值(空字符串、非字符串)抛 ``ValidationError``(400)。 """ if value is None: return None if isinstance(value, ChannelType): return value if isinstance(value, str) and value: return ChannelType(value) raise ValidationError( "channel_type", f"invalid channel_type: {value!r}", trace_id=trace_id, ) def _coerceAccountStatus(value: Any, trace_id: str | None) -> AccountStatus | None: """将查询参数中的账户状态值统一转换为 ``AccountStatus`` 枚举。 支持 ``AccountStatus`` 实例与合法枚举值字符串(``active`` / ``disabled`` / ``degraded``);``None`` 原样返回。非法值抛 ``ValidationError``(400),避免原生异常被 ``_executeControl`` 兜底 翻译为 ``InternalError``(500)。 """ if value is None: return None if isinstance(value, AccountStatus): return value try: return AccountStatus(value) except ValueError as exc: raise ValidationError( "status", f"invalid account status: {value!r}", trace_id=trace_id, ) from exc def _coerceSessionStatus(value: Any, trace_id: str | None) -> SessionStatus | None: """将查询参数中的会话状态值统一转换为 ``SessionStatus`` 枚举。 支持 ``SessionStatus`` 实例与合法枚举值字符串(``active`` / ``closed``);``None`` 原样返回。非法值抛 ``ValidationError``(400), 避免原生异常被 ``_executeControl`` 兜底翻译为 ``InternalError``(500)。 """ if value is None: return None if isinstance(value, SessionStatus): return value try: return SessionStatus(value) except ValueError as exc: raise ValidationError( "status", f"invalid session status: {value!r}", trace_id=trace_id, ) from exc # ---- config 域 ---- def _configValueToDict(value: ConfigValue) -> dict[str, Any]: """将 ConfigValue 转换为字典。""" return { "key": value.key, "value": value.value, "version": value.version, "scope": value.scope.value, } def _configFieldToDict(field: ConfigField) -> dict[str, Any]: """将 ``ConfigField`` schema 元数据转换为响应字典。 ``constraints`` 为 ``None`` 时返回空字典,避免前端表单渲染 null; ``default`` 原样返回(可能为多种类型)。 ``scope`` 供配置管理页按作用域分组与筛选(CFG-02)。 ``title`` / ``description`` / ``category`` 为可选展示元数据,缺失时 返回 ``None``,由前端 fallback 到 key 本身。 """ return { "key": field.key, "type": field.type, "required": field.required, "default": field.default, "hot_reloadable": field.hot_reloadable, "scope": field.scope.value if field.scope else None, "constraints": field.constraints or {}, "title": field.title, "description": field.description, "category": field.category, } def _normalizeConfigEntries(value: Any) -> list[dict[str, Any]]: """将 ``allow_from`` 配置值归一化为字典列表(FR-18)。 支持三种历史格式:字符串(视为单条 ``peer_id``)、字符串列表、字典列表 (补齐缺失的 ``peer_type`` 为 ``dm``,仅保留含 ``peer_id`` 的合法条目)。 归一化后所有条目均含 ``peer_type``,确保 add/remove 按 ``peer_id + peer_type`` 联合匹配,避免误删其他策略类型的条目。 """ if value is None: return [] if isinstance(value, str): return [{"peer_id": value, "peer_type": WhitelistPolicyType.DM.value}] if isinstance(value, list): result: list[dict[str, Any]] = [] for item in value: if isinstance(item, str): result.append({"peer_id": item, "peer_type": WhitelistPolicyType.DM.value}) elif isinstance(item, dict) and item.get("peer_id"): normalized = dict(item) if "peer_type" not in normalized: normalized["peer_type"] = WhitelistPolicyType.DM.value result.append(normalized) return result return [] def _configExportResultToDict(result: Any) -> dict[str, Any]: """将 ``ConfigExportResult`` 转换为响应字典。""" return { "scope": result.scope.value, "target": result.target, "config_data": result.config_data, "version": result.version, "exported_at": result.exported_at.isoformat() if result.exported_at else None, } def _importConfigResultToDict(result: Any) -> dict[str, Any]: """将 ``ImportConfigResult`` 转换为响应字典。 ``failed_keys`` 为 ``ConfigImportFailure`` 元组,逐项序列化为 ``dict`` (``key`` / ``reason``),供前端展示具体失败原因。 """ return { "imported_count": result.imported_count, "skipped_count": result.skipped_count, "failed_count": result.failed_count, "failed_keys": [{"key": f.key, "reason": f.reason} for f in result.failed_keys], "imported_at": result.imported_at.isoformat() if result.imported_at else None, } def _batchUpdateConfigResultToDict(result: Any) -> dict[str, Any]: """将 ``BatchUpdateConfigResult`` 转换为响应字典。 ``failed`` 为 ``BatchOperationFailure`` 元组,逐项序列化为 ``dict`` (``id`` / ``error_code`` / ``message``),与 DTO 类型声明一致。 """ return { "total": result.total, "succeeded": [{"key": item.key, "new_version": item.new_version} for item in result.succeeded], "failed": [{"id": f.id, "error_code": f.error_code, "message": f.message} for f in result.failed], } # ---- session 域 ---- def _channelSessionToDict(session: Any) -> dict[str, Any]: """将 ChannelSession 转换为字典(运维排查用例)。 包含会话所有者、统一身份关联、临时会话标记等运维排查必要字段。 ``status`` / ``last_active_at`` 由原始字段推导,便于前端直接展示。 """ last_message_at = getattr(session, "last_message_at", None) updated_at = getattr(session, "updated_at", None) closed_at = getattr(session, "closed_at", None) last_active_at = last_message_at if last_message_at is not None else updated_at return { "session_id": session.session_id, "channel_type": session.channel_type if session.channel_type else None, "account_id": session.account_id, "peer_id": session.peer_id, "chat_type": session.chat_type, "conversation_id": session.conversation_id, "has_conversation": bool(session.conversation_id), "unified_identity_id": session.unified_identity_id, "owner_peer_id": session.owner_peer_id, "is_temporary": session.is_temporary, "status": "closed" if closed_at is not None else "active", "created_at": session.created_at.isoformat() if session.created_at else None, "updated_at": updated_at.isoformat() if updated_at else None, "last_message_at": last_message_at.isoformat() if last_message_at else None, "last_active_at": last_active_at.isoformat() if last_active_at else None, "closed_at": closed_at.isoformat() if closed_at else None, "deleted_at": session.deleted_at.isoformat() if session.deleted_at else None, } # ---- message 域 ---- def _messageToDict(message: Any, masking_port: MaskingPort) -> dict[str, Any]: """将 Message 转换为字典(运维排查用例)。 含渠道侧状态字段(``channel_status`` / ``channel_status_history``), 满足投递失败排查需求。``operations_history`` 字段可能包含工具调用 参数、执行结果等敏感内容,通过 ``MaskingPort.maskFull`` 递归脱敏后 返回(全量遮蔽,避免泄露 token、密钥等敏感字段)。 会话上下文字段(``channel_session_id`` / ``channel_account_id`` / ``peer_id`` / ``conversation_title``)优先从 ``message`` 自身属性读取 (适配器在查询后注入),缺失时回退到 ``message.conversation`` 关联, 供前端在列表/详情中展示会话上下文。 """ operations_history = message.operations_history if operations_history: operations_history = [ masking_port.maskFull(item) if isinstance(item, dict) else item for item in operations_history ] conversation = getattr(message, "conversation", None) channel_session_id = getattr(message, "channel_session_id", None) channel_account_id = getattr(message, "channel_account_id", None) peer_id = getattr(message, "peer_id", None) conversation_title = getattr(message, "conversation_title", None) if conversation is not None: if conversation_title is None: conversation_title = getattr(conversation, "title", None) if channel_session_id is None: channel_session_id = getattr(conversation, "channel_session_id", None) if channel_account_id is None: channel_account_id = getattr(conversation, "channel_account_id", None) if peer_id is None: peer_id = getattr(conversation, "peer_id", None) if peer_id is None: extra_metadata = getattr(conversation, "extra_metadata", None) or {} peer_id = extra_metadata.get("peer_id") if isinstance(extra_metadata, dict) else None return { "message_id": message.message_id, "conversation_id": message.conversation_id, "role": message.role, "content": message.content, "channel_status": message.channel_status, "channel_msg_id": message.channel_msg_id, "ref_channel_msg_id": message.ref_channel_msg_id, "channel_status_history": message.channel_status_history, "operations_history": operations_history, "channel_read_at": message.channel_read_at.isoformat() if message.channel_read_at else None, "channel_recalled_at": message.channel_recalled_at.isoformat() if message.channel_recalled_at else None, "channel_edited_at": message.channel_edited_at.isoformat() if message.channel_edited_at else None, "created_at": message.created_at.isoformat() if message.created_at else None, "channel_session_id": channel_session_id, "channel_account_id": channel_account_id, "peer_id": peer_id, "conversation_title": conversation_title, } def _messageSearchItemToDict(item: MessageSearchItem) -> dict[str, Any]: """将 ``MessageSearchItem`` 序列化为 dict(MSG-SEARCH-01)。 ``channel_type`` 为 ``StrEnum``,序列化为 ``.value`` 字符串; ``created_at`` 序列化为 ISO 8601 字符串。同时返回会话上下文字段 ``channel_account_id`` / ``peer_id`` / ``conversation_title``,与 ``_messageToDict`` 保持一致。 """ return { "message_id": item.message_id, "conversation_id": item.conversation_id, "channel_session_id": item.channel_session_id, "channel_account_id": item.channel_account_id, "channel_type": item.channel_type if item.channel_type else None, "role": item.role, "peer_id": item.peer_id, "conversation_title": item.conversation_title, "content": item.content, "snippet": item.snippet, "created_at": item.created_at.isoformat() if item.created_at else None, } # ---- pairing 域 ---- def _pairingRecordToDict(record: PairingRecord) -> dict[str, Any]: """将 PairingRecord 转换为字典(FR-33 审批列表完整字段)。 包含配对 ID、渠道账户业务 ID、渠道类型、对端信息、状态、审批人、 全量时间戳与原因,供管理后台展示与审计追溯。所有时间戳统一使用 ``format_utc_datetime`` 序列化为带 ``Z`` 后缀的 UTC ISO 8601 字符串 (与 ``_pairingStatsToDict`` 一致,避免前端按本地时区解析偏移)。 """ return { "pairing_id": record.pairing_id, "channel_account_id": record.channel_account_id, "channel_type": record.channel_type if record.channel_type else None, "peer_id": record.peer_id, "peer_name": record.peer_name, "status": record.status.value, "approver_id": record.approver_id, "approved_at": format_utc_datetime(record.approved_at), "rejected_at": format_utc_datetime(record.rejected_at), "expired_at": format_utc_datetime(record.expired_at), "revoked_at": format_utc_datetime(record.revoked_at), "reason": record.reason, "created_at": format_utc_datetime(record.created_at), "updated_at": format_utc_datetime(record.updated_at), "expires_at": format_utc_datetime(record.expires_at), "requested_at": format_utc_datetime(record.requested_at), "version": record.version, } def _pairingStatsToDict(result: PairingStatsResult) -> dict[str, Any]: """将 ``PairingStatsResult`` 转换为响应字典(PRG-STATS)。 参数: result: 配对统计结果值对象。 返回: 含 total_requested / approved_count / rejected_count / revoked_count / expired_count / approve_rate / avg_approval_seconds / trend 字段的 字典。``trend`` 中 ``timestamp`` 转为 ISO 8601 字符串。 """ return { "total_requested": result.total_requested, "approved_count": result.approved_count, "rejected_count": result.rejected_count, "revoked_count": result.revoked_count, "expired_count": result.expired_count, "approve_rate": result.approve_rate, "avg_approval_seconds": result.avg_approval_seconds, "trend": [ { "timestamp": format_utc_datetime(point.timestamp) or "", "requested": point.requested, "approved": point.approved, } for point in result.trend ], } def _reconstructPairingApproval(record: PairingRecord) -> PairingApproval: """从 PairingRecord 重构 PairingApproval 聚合根。 委托 ``PairingApproval.fromRecord`` 统一重建逻辑,携带完整字段 (approved_at/rejected_at/revoked_at/reason 等)确保状态机校验与 领域逻辑一致。使用记录本身的 ``version`` 字段保证乐观锁正确性。 """ return PairingApproval.fromRecord(record) # ---- whitelist 域 ---- def _dictToWhitelistEntries( raw_entries: Any, policy_type: WhitelistPolicyType, operator_user_id: str, ) -> tuple[tuple[WhitelistEntry, ...], list[dict[str, str]]]: """将原始条目字典列表转换为 ``WhitelistEntry`` 元组。 遍历 ``raw_entries``,调 ``whitelistEntryFromDict`` 构造条目, 并强制覆盖 ``added_by`` 为当前操作人(防止客户端伪造创建者, 与单条添加 ``_whitelistAdd`` 保持一致)。 构造失败(``peer_id`` 缺失/为空、``expires_at`` 格式非法)由 ``whitelistEntryFromDict`` 抛 ``ValidationError``,此处捕获后跳过 并记入 ``failures``,避免单条非法条目让整批导入崩溃(与 ``stop_on_error=False`` 的部分成功语义一致;``stop_on_error=True`` 时由 handler 解析阶段终止整批)。 参数: raw_entries: 原始条目字典列表。 policy_type: 白名单策略类型(用于 failures 上下文)。 operator_user_id: 当前操作人 ID,强制写入 ``added_by``。 返回: (entries, failures) 元组:entries 为成功构造的条目元组, failures 为失败项列表(含 ``peer_id`` / ``reason`` 字段)。 """ if not isinstance(raw_entries, list): return (), [] entries: list[WhitelistEntry] = [] failures: list[dict[str, str]] = [] for item in raw_entries: if not isinstance(item, dict): failures.append({"peer_id": "", "reason": "invalid entry format"}) continue try: entry = whitelistEntryFromDict(item) except ValidationError as exc: failures.append({"peer_id": str(item.get("peer_id", "")), "reason": str(exc)}) continue # 强制覆盖 added_by 为当前操作人,防止客户端伪造创建者 entry = WhitelistEntry( peer_id=entry.peer_id, peer_name=entry.peer_name, peer_type=entry.peer_type, reason=entry.reason, added_by=operator_user_id, expires_at=entry.expires_at, ) entries.append(entry) return tuple(entries), failures def _sanitize_csv_field(value: str) -> str: """防护 CSV injection:危险前缀字段前缀单引号。 若字段值以 ``=`` / ``+`` / ``-`` / ``@`` / ``\\t`` / ``\\r`` / ``\\n`` 开头,需在字段前缀单引号 ``'`` 防止 Excel 等表格软件误解析为公式。 """ if value and value[0] in ("=", "+", "-", "@", "\t", "\r", "\n"): return f"'{value}" return value def _entriesToCsv(entries: tuple[WhitelistEntry, ...]) -> str: """将白名单条目转换为 CSV 字符串(含 CSV injection 防护)。 列顺序:``peer_id,peer_name,peer_type,reason,added_by,expires_at``。 危险前缀字段(``=`` / ``+`` / ``-`` / ``@`` / ``\\t`` / ``\\r`` / ``\\n``) 前缀单引号防护,防止 Excel 等表格软件误解析为公式。 """ import csv import io output = io.StringIO() writer = csv.writer(output) writer.writerow(["peer_id", "peer_name", "peer_type", "reason", "added_by", "expires_at"]) for entry in entries: writer.writerow( [ _sanitize_csv_field(entry.peer_id), _sanitize_csv_field(entry.peer_name) if entry.peer_name else "", entry.peer_type.value, _sanitize_csv_field(entry.reason) if entry.reason else "", entry.added_by or "", entry.expires_at.isoformat() if entry.expires_at else "", ] ) return output.getvalue() def _buildExportFilename( channel_type: Any, account_id: str, policy_type: WhitelistPolicyType, ) -> str: """构造导出文件名。 格式:``allowlist_{channel_type}_{account_id}_{policy_type}_{YYYYMMDD}.csv``。 """ from datetime import datetime as _dt channel = channel_type if hasattr(channel_type, "value") else str(channel_type) date_str = _dt.now().strftime("%Y%m%d") return f"allowlist_{channel}_{account_id}_{policy_type.value}_{date_str}.csv" # ---- directory 域 ---- def _directorySearchResultToDict(result: DirectorySearchResult) -> dict[str, Any]: """将 DirectorySearchResult 转换为字典(FR-14)。""" return { "entries": [_directoryEntryToDict(e) for e in result.entries], "next_cursor": result.next_cursor, } def _directoryEntryToDict(entry: Any) -> dict[str, Any]: """将 DirectoryEntry 转换为字典(FR-14)。 ``entry`` 可能为 ``DirectoryEntry`` 或携带用户 / 群组元数据的条目。 新增字段(``avatar_url`` / ``alias`` / ``remark`` / ``tags`` / ``member_count`` / ``owner_id``)优先从顶层字段读取;顶层缺失时 fallback 到 ``entry.metadata`` 中的同名字段。``extra_metadata`` 保持嵌套输出。 """ metadata = entry.metadata or {} result: dict[str, Any] = { "type": entry.type.value if hasattr(entry.type, "value") else str(entry.type), "id": entry.id, "name": entry.name, "avatar_url": entry.avatar_url if entry.avatar_url is not None else metadata.get("avatar_url"), "alias": entry.alias if entry.alias is not None else metadata.get("alias"), "remark": entry.remark if entry.remark is not None else metadata.get("remark"), "tags": entry.tags if entry.tags is not None else metadata.get("tags"), "member_count": entry.member_count if entry.member_count is not None else metadata.get("member_count"), "owner_id": entry.owner_id if entry.owner_id is not None else metadata.get("owner_id"), "metadata": entry.metadata, } if entry.extra_metadata: result["extra_metadata"] = entry.extra_metadata return result def _channelUserToDict(user: ChannelUser) -> dict[str, Any]: """将 ChannelUser 转换为字典(FR-14)。""" return { "peer_id": user.peer_id, "name": user.name, "avatar_url": user.avatar_url, "email": user.email, "phone": user.phone, } def _channelGroupToDict(group: ChannelGroup) -> dict[str, Any]: """将 ChannelGroup 转换为字典(FR-14)。""" return { "group_id": group.group_id, "name": group.name, "member_count": group.member_count, } def _groupMemberToDict(member: GroupMember) -> dict[str, Any]: """将 GroupMember 转换为字典(FR-14)。""" return { "user_id": member.user_id, "group_id": member.group_id, "role": member.role, "joined_at": member.joined_at.isoformat() if member.joined_at else None, } def _groupMemberResultToDict(result: GroupMemberResult) -> dict[str, Any]: """将 GroupMemberResult 转换为字典(FR-14)。""" return { "members": [_groupMemberToDict(m) for m in result.members], "next_cursor": result.next_cursor, } # ---- audit 域 ---- def _auditEntryToDict(entry: AuditEntry) -> dict[str, Any]: """将 AuditEntry 转换为字典。 包含目标渠道、目标账户与操作详情(``params_summary``,已脱敏),满足 PRD FR-34 审计日志查询返回 ``操作详情(JSON)`` / ``目标渠道`` / ``目标账户`` 字段要求。``timestamp`` 统一输出 UTC ISO 8601 字符串 (带 ``Z`` 后缀),与 ORM ``to_dict()`` 格式一致。``id`` 为 ORM 主键, 供 Router 层构造 keyset pagination 游标使用。 """ return { "id": entry.id, "operator": entry.operator, "operation": entry.operation.value, "target": entry.target, "target_channel": entry.target_channel, "target_account": entry.target_account, "result": entry.result, "timestamp": format_utc_datetime(entry.timestamp), "params_summary": entry.params_summary, "trace_id": entry.trace_id, "source_ip": entry.source_ip, "request_id": entry.request_id, "message_id": entry.message_id, "content_summary": entry.content_summary, } def _auditLogStatsToDict(stats: AuditLogStats) -> dict[str, Any]: """将 ``AuditLogStats`` 序列化为 dict(AUD-05)。 ``time_range_start`` / ``time_range_end`` 序列化为 ISO 8601 字符串, ``None`` 保留。 """ return { "total": stats.total, "by_operation_type": dict(stats.by_operation_type), "by_result": dict(stats.by_result), "time_range_start": format_utc_datetime(stats.time_range_start) if stats.time_range_start else None, "time_range_end": format_utc_datetime(stats.time_range_end) if stats.time_range_end else None, } def _auditQueryToDict(query: AuditQuery) -> dict[str, Any]: """将 ``AuditQuery`` 序列化为 dict。 枚举字段序列化为 ``.value`` 字符串,datetime 序列化为 ISO 8601 字符串, ``None`` 保留。供 ``query_snapshot`` 嵌套字段序列化使用。 """ return { "operation_type": query.operation_type.value if query.operation_type else None, "operator": query.operator, "target_channel": query.target_channel if query.target_channel else None, "target_account": query.target_account, "start_time": query.start_time.isoformat() if query.start_time else None, "end_time": query.end_time.isoformat() if query.end_time else None, "limit": query.limit, "offset": query.offset, } def _retentionPolicyToDict(policy: RetentionPolicy) -> dict[str, Any]: """将 ``RetentionPolicy`` 转换为响应字典。 参数: policy: 审计保留策略值对象。 返回: 含 default_retention_days / by_operation_type / auto_archive_enabled / auto_archive_before_days / updated_at 字段的字典。 """ return { "default_retention_days": policy.default_retention_days, "by_operation_type": policy.by_operation_type, "auto_archive_enabled": policy.auto_archive_enabled, "auto_archive_before_days": policy.auto_archive_before_days, "updated_at": policy.updated_at.isoformat() if policy.updated_at else None, } # ---- outbox 域 ---- def _buildOutboxQueryFilter(payload: dict[str, Any]) -> OutboxQueryFilter: """从 payload dict 构造 ``OutboxQueryFilter``。``datetime`` 字段接受 ISO 8601 字符串或 ``datetime`` 实例。 ``message_id`` / ``channel_msg_id`` / ``channel_account_id`` / ``last_error`` 优先映射为对应的 ``*_like`` 模糊搜索字段,保持向后兼容:传入精确值时 ``LIKE`` 子串匹配仍可命中;同时支持前端直接传入 ``*_like`` 字段。 """ channel_type_value = payload.get("channel_type") channel_type: ChannelType | None = None if channel_type_value is not None: channel_type = _coerceChannelType(channel_type_value, None) status_value = payload.get("status") status: OutboxStatus | None = None if status_value is not None: status = OutboxStatus(status_value) created_after = _coerceQueryDateTime(payload.get("created_after"), "created_after", None) created_before = _coerceQueryDateTime(payload.get("created_before"), "created_before", None) retry_count_min = payload.get("retry_count_min") def _like_value(key: str) -> str | None: return payload.get(key) if payload.get(key) is not None else None message_id_like = _like_value("message_id_like") or _like_value("message_id") channel_msg_id_like = _like_value("channel_msg_id_like") or _like_value("channel_msg_id") channel_account_id_like = _like_value("channel_account_id_like") or _like_value("channel_account_id") last_error_like = _like_value("last_error_like") or _like_value("last_error") return OutboxQueryFilter( channel_type=channel_type, channel_account_id=payload.get("channel_account_id"), status=status, message_id=payload.get("message_id"), channel_msg_id=payload.get("channel_msg_id"), created_after=created_after, created_before=created_before, channel_session_id=payload.get("channel_session_id"), retry_count_min=retry_count_min, message_id_like=message_id_like, channel_msg_id_like=channel_msg_id_like, channel_account_id_like=channel_account_id_like, last_error_like=last_error_like, ) def _outboxEntryToDict(entry: OutboxEntry) -> dict[str, Any]: """将 ``OutboxEntry`` DTO 序列化为 dict。 ``status`` / ``durability_policy`` 为 ``StrEnum``,序列化为 ``.value`` 字符串;``channel_type`` 为 ``ChannelType``(``str`` 子类,非 Enum, 无 ``.value``),用 ``str()`` 取字符串值;``datetime`` 字段序列化为 ISO 8601 字符串(``None`` 保留)。 """ return { "outbox_id": entry.outbox_id, "message_id": entry.message_id, "channel_account_id": entry.channel_account_id, "channel_session_id": entry.channel_session_id, "channel_type": str(entry.channel_type) if entry.channel_type else None, "status": entry.status.value, "durability_policy": entry.durability_policy.value, "retry_count": entry.retry_count, "max_retry": entry.max_retry, "next_retry_at": entry.next_retry_at.isoformat() if entry.next_retry_at else None, "last_error": entry.last_error, "channel_msg_id": entry.channel_msg_id, "created_at": entry.created_at.isoformat() if entry.created_at else None, "updated_at": entry.updated_at.isoformat() if entry.updated_at else None, "expires_at": entry.expires_at.isoformat() if entry.expires_at else None, "version": entry.version, } def _rebuildOutboxAggregate(entry: OutboxEntry) -> outbox_entry_model.OutboxAggregateRoot: """从 ``OutboxEntry`` DTO 重建发件箱聚合根实例。 聚合根类与 DTO 同名但不同模块,本函数以模块别名引用聚合根类避免命名冲突。 重建后调用 ``canRetry()`` 校验状态机不变量(INV-3)。 """ expires_at = entry.expires_at or ( entry.created_at + timedelta(seconds=86400) if entry.created_at else datetime.now(UTC) ) return outbox_entry_model.OutboxAggregateRoot( outbox_id=entry.outbox_id, message_id=entry.message_id, channel_account_id=entry.channel_account_id, status=entry.status, durability_policy=entry.durability_policy, retry_count=entry.retry_count, max_retry=entry.max_retry, next_retry_at=entry.next_retry_at, last_error=entry.last_error, channel_msg_id=entry.channel_msg_id, created_at=entry.created_at, updated_at=entry.updated_at, expires_at=expires_at, version=entry.version, latency_ms=entry.latency_ms, funnel_node=entry.funnel_node, sent_at=entry.sent_at, last_retry_at=entry.last_retry_at, idempotency_key=entry.idempotency_key, channel_request_id=entry.channel_request_id, partial_failure=entry.partial_failure, stream_aborted_at_chunk=entry.stream_aborted_at_chunk, degraded_reason=entry.degraded_reason, ) def _outboxEntriesToCsv(entries: tuple[OutboxEntry, ...]) -> str: """将死信 outbox 条目元组序列化为 CSV 字符串(含 CSV injection 防护)。 列顺序:``outbox_id,message_id,channel_account_id,channel_session_id,status, durability_policy,retry_count,max_retry,next_retry_at,last_error,channel_msg_id, created_at,updated_at,expires_at,version``。``datetime`` 字段输出 ISO 8601 字符串,``None`` 输出空字符串。危险前缀字段经 ``_sanitize_csv_field`` 防护。 """ import csv import io output = io.StringIO() writer = csv.writer(output) writer.writerow( [ "outbox_id", "message_id", "channel_account_id", "channel_session_id", "status", "durability_policy", "retry_count", "max_retry", "next_retry_at", "last_error", "channel_msg_id", "created_at", "updated_at", "expires_at", "version", ] ) for entry in entries: writer.writerow( [ _sanitize_csv_field(entry.outbox_id), _sanitize_csv_field(entry.message_id), _sanitize_csv_field(entry.channel_account_id), _sanitize_csv_field(entry.channel_session_id) if entry.channel_session_id else "", entry.status.value, entry.durability_policy.value, entry.retry_count, entry.max_retry, entry.next_retry_at.isoformat() if entry.next_retry_at else "", _sanitize_csv_field(entry.last_error) if entry.last_error else "", _sanitize_csv_field(entry.channel_msg_id) if entry.channel_msg_id else "", entry.created_at.isoformat() if entry.created_at else "", entry.updated_at.isoformat() if entry.updated_at else "", entry.expires_at.isoformat() if entry.expires_at else "", entry.version, ] ) return output.getvalue() # ---- content_review 域 ---- def _contentReviewStatsToDict(result: ContentReviewStatsResult) -> dict[str, Any]: """将 ``ContentReviewStatsResult`` 转换为响应字典。 参数: result: 审核统计结果值对象。 返回: 含 total_reviews / pass_count / review_count / block_count / pass_rate / block_rate / manual_intervention_rate / avg_decision_seconds / by_category / trend 字段的字典。 ``by_category`` 与 ``trend`` 中的 ``datetime`` 转为 ISO 8601 字符串。 """ return { "total_reviews": result.total_reviews, "pass_count": result.pass_count, "review_count": result.review_count, "block_count": result.block_count, "pass_rate": result.pass_rate, "block_rate": result.block_rate, "manual_intervention_rate": result.manual_intervention_rate, "avg_decision_seconds": result.avg_decision_seconds, "by_category": [{"category": stat.category, "count": stat.count} for stat in result.by_category], "trend": [ { "timestamp": format_utc_datetime(point.timestamp) or "", "pass_count": point.pass_count, "block_count": point.block_count, } for point in result.trend ], } # ---- plugin 域 ---- def _lifecycleResultToDict(result: Any, plugin_id: str) -> dict[str, Any]: """将 ``LifecycleResult`` 转换为响应字典。""" return { "plugin_id": plugin_id, "state": result.state, "error": result.error, "error_code": result.error_code, "trace_id": result.trace_id, } def _pluginSummaryToDict(summary: Any) -> dict[str, Any]: """将 ``PluginSummary`` 转换为响应字典。""" return { "plugin_id": summary.plugin_id, "name": summary.name, "version": summary.version, "channel_type": summary.channel_type, "state": summary.state, } def _pluginCatalogItemToDict(item: Any) -> dict[str, Any]: """将 ``PluginCatalogItem`` 转换为响应字典。 ``capabilities`` 字段为 ``ChannelCapabilities`` dataclass,交由 Router 层 ``serialize_control_data`` 递归序列化(dataclass 嵌套)。``icon`` / ``last_error`` 为 ``str | None``,缺失时返回 ``None``,前端回退默认图标。 ``requires_restart`` 为 bool,供前端「待重启」角标展示。 """ return { "plugin_id": item.plugin_id, "name": item.name, "version": item.version, "channel_type": item.channel_type, "state": item.state, "capabilities": item.capabilities, "last_error": item.last_error, "requires_restart": item.requires_restart, "icon": item.icon, } def _pluginDetailToDict(detail: Any) -> dict[str, Any]: """将 ``PluginDetail`` 转换为响应字典。 将 PluginManifest 内部嵌套的 ``manifest`` 展开为 ``channel``,避免前端 出现 ``manifest.manifest`` 的歧义结构;同时保留 adapters / stages 等元 数据,并附加 ``last_error`` 供排障展示。 """ return { "plugin_id": detail.plugin_id, "name": detail.name, "version": detail.version, "channel_type": detail.channel_type, "state": detail.state, "last_error": detail.last_error, "manifest": { "channel": dataclass_to_dict(detail.manifest.manifest), "adapters": list(detail.manifest.adapters), "stages": list(detail.manifest.stages), "event_subscriptions": list(detail.manifest.event_subscriptions), "config_sources": list(detail.manifest.config_sources), }, } def _installPluginResultToDict(result: Any) -> dict[str, Any]: """将 ``InstallPluginResult`` 转换为响应字典。""" return { "plugin_id": result.plugin_id, "version": result.version, "installed_at": result.installed_at.isoformat() if result.installed_at else None, "requires_load": result.requires_load, } def _uninstallPluginResultToDict(result: Any) -> dict[str, Any]: """将 ``UninstallPluginResult`` 转换为响应字典。""" return { "plugin_id": result.plugin_id, "uninstalled_at": result.uninstalled_at.isoformat() if result.uninstalled_at else None, "files_removed": list(result.files_removed), } def _pluginConfigResultToDict(result: Any) -> dict[str, Any]: """将 ``PluginConfigResult`` 转换为响应字典。""" return { "plugin_id": result.plugin_id, "config": result.config, "schema": result.schema, "requires_restart": result.requires_restart, "updated_at": result.updated_at.isoformat() if result.updated_at else None, } def _batchPluginLifecycleResultToDict(result: Any) -> dict[str, Any]: """将 ``BatchPluginLifecycleResult`` 转换为响应字典。""" return { "total": result.total, "succeeded": [ { "plugin_id": item.plugin_id, "new_state": item.new_state, } for item in result.succeeded ], "failed": list(result.failed), }