ForcePilot/backend/package/yuxi/channels/application/control_plane/serializers.py
Kris 08617091dc refactor: 整理项目包结构与导入路径
- 新增多个业务域的__init__.py模块文件,规范包导出结构
- 调整多个DTO文件的导入路径,统一模块组织方式
- 移除测试文件中多余的空行与导入语句
- 优化部分业务模块的包层级划分
2026-07-18 02:04:03 +08:00

1084 lines
42 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""控制面序列化器与共享工具函数。
从原 ``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),
}