ForcePilot/backend/package/yuxi/channels/application/control_plane/serializers.py

1084 lines
42 KiB
Python
Raw Normal View History

"""控制面序列化器与共享工具函数。
从原 ``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 目录查询缓存 TTLPRD §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 datetimeFR-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`` 序列化为 dictMSG-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`` 序列化为 dictAUD-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),
}