- 新增多个业务域的__init__.py模块文件,规范包导出结构 - 调整多个DTO文件的导入路径,统一模块组织方式 - 移除测试文件中多余的空行与导入语句 - 优化部分业务模块的包层级划分
1084 lines
42 KiB
Python
1084 lines
42 KiB
Python
"""控制面序列化器与共享工具函数。
|
||
|
||
从原 ``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),
|
||
}
|