2026-07-02 03:22:12 +08:00
|
|
|
|
"""控制面序列化器与共享工具函数。
|
|
|
|
|
|
|
|
|
|
|
|
从原 ``dispatch_stage.py`` 模块级函数抽取,按域分组。所有函数行为
|
|
|
|
|
|
零变更,仅迁移到独立模块便于维护。包括:
|
|
|
|
|
|
|
|
|
|
|
|
- 通用工具:``dataclass_to_dict`` / ``_requireParam`` / ``_requireAccountId``
|
2026-07-03 19:18:13 +08:00
|
|
|
|
/ ``_coerceQueryDateTime`` / ``_coerceChannelType`` / ``_coerceAccountStatus``
|
|
|
|
|
|
/ ``_coerceSessionStatus``
|
2026-07-02 03:22:12 +08:00
|
|
|
|
- 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``
|
2026-07-02 18:27:06 +08:00
|
|
|
|
- audit 域:``_auditEntryToDict`` / ``_auditLogStatsToDict`` / ``_auditQueryToDict``
|
2026-07-02 03:22:12 +08:00
|
|
|
|
/ ``_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
|
|
|
|
|
|
|
2026-07-18 02:04:03 +08:00
|
|
|
|
from yuxi.channels.application.control_plane.context import (
|
2026-07-02 03:22:12 +08:00
|
|
|
|
ControlPlaneContext,
|
|
|
|
|
|
)
|
2026-07-18 02:04:03 +08:00
|
|
|
|
from yuxi.channels.application.observability.circuit_breaker.channel_circuit_breaker import (
|
|
|
|
|
|
CircuitState,
|
|
|
|
|
|
)
|
|
|
|
|
|
from yuxi.channels.contract.dtos.audit.audit import (
|
2026-07-02 03:22:12 +08:00
|
|
|
|
AuditEntry,
|
|
|
|
|
|
AuditLogStats,
|
|
|
|
|
|
AuditQuery,
|
|
|
|
|
|
RetentionPolicy,
|
|
|
|
|
|
)
|
2026-07-18 02:04:03 +08:00
|
|
|
|
from yuxi.channels.contract.dtos.audit.content_review import ContentReviewStatsResult
|
|
|
|
|
|
from yuxi.channels.contract.dtos.config.config import (
|
2026-07-02 03:22:12 +08:00
|
|
|
|
ConfigField,
|
|
|
|
|
|
ConfigValue,
|
|
|
|
|
|
)
|
2026-07-18 02:04:03 +08:00
|
|
|
|
from yuxi.channels.contract.dtos.identity.directory import (
|
2026-07-02 03:22:12 +08:00
|
|
|
|
ChannelGroup,
|
|
|
|
|
|
ChannelUser,
|
|
|
|
|
|
DirectorySearchResult,
|
|
|
|
|
|
GroupMember,
|
|
|
|
|
|
GroupMemberResult,
|
|
|
|
|
|
)
|
2026-07-18 02:04:03 +08:00
|
|
|
|
from yuxi.channels.contract.dtos.messaging.channel import (
|
|
|
|
|
|
AccountStatus,
|
|
|
|
|
|
ChannelType,
|
|
|
|
|
|
MessageSearchItem,
|
|
|
|
|
|
SessionStatus,
|
|
|
|
|
|
)
|
|
|
|
|
|
from yuxi.channels.contract.dtos.outbox.outbox import (
|
2026-07-02 03:22:12 +08:00
|
|
|
|
OutboxEntry,
|
|
|
|
|
|
OutboxQueryFilter,
|
|
|
|
|
|
OutboxStatus,
|
|
|
|
|
|
)
|
2026-07-18 02:04:03 +08:00
|
|
|
|
from yuxi.channels.contract.dtos.pairing.pairing import (
|
2026-07-02 03:22:12 +08:00
|
|
|
|
PairingRecord,
|
|
|
|
|
|
PairingStatsResult,
|
|
|
|
|
|
)
|
2026-07-18 02:04:03 +08:00
|
|
|
|
from yuxi.channels.contract.dtos.whitelist.whitelist import (
|
2026-07-02 03:22:12 +08:00
|
|
|
|
WhitelistEntry,
|
|
|
|
|
|
WhitelistPolicyType,
|
|
|
|
|
|
whitelistEntryFromDict,
|
|
|
|
|
|
)
|
|
|
|
|
|
from yuxi.channels.contract.errors import ValidationError
|
2026-07-18 02:04:03 +08:00
|
|
|
|
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
|
2026-07-08 03:57:05 +08:00
|
|
|
|
from yuxi.utils.datetime_utils import coerce_any_to_utc_datetime, format_utc_datetime
|
2026-07-02 03:22:12 +08:00
|
|
|
|
|
|
|
|
|
|
# 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:
|
2026-07-04 00:14:56 +08:00
|
|
|
|
"""将查询参数中的渠道类型值统一转换为 ``ChannelType``。
|
2026-07-02 03:22:12 +08:00
|
|
|
|
|
2026-07-04 00:14:56 +08:00
|
|
|
|
支持非空字符串与 ``ChannelType`` 实例;``None`` 原样返回。
|
|
|
|
|
|
非法值(空字符串、非字符串)抛 ``ValidationError``(400)。
|
2026-07-02 03:22:12 +08:00
|
|
|
|
"""
|
|
|
|
|
|
if value is None:
|
|
|
|
|
|
return None
|
|
|
|
|
|
if isinstance(value, ChannelType):
|
|
|
|
|
|
return value
|
2026-07-04 00:14:56 +08:00
|
|
|
|
if isinstance(value, str) and value:
|
2026-07-02 03:22:12 +08:00
|
|
|
|
return ChannelType(value)
|
2026-07-04 00:14:56 +08:00
|
|
|
|
raise ValidationError(
|
|
|
|
|
|
"channel_type",
|
|
|
|
|
|
f"invalid channel_type: {value!r}",
|
|
|
|
|
|
trace_id=trace_id,
|
|
|
|
|
|
)
|
2026-07-02 03:22:12 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-07-03 19:18:13 +08:00
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-02 03:22:12 +08:00
|
|
|
|
# ---- 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`` 原样返回(可能为多种类型)。
|
2026-07-06 20:49:35 +08:00
|
|
|
|
``scope`` 供配置管理页按作用域分组与筛选(CFG-02)。
|
|
|
|
|
|
``title`` / ``description`` / ``category`` 为可选展示元数据,缺失时
|
|
|
|
|
|
返回 ``None``,由前端 fallback 到 key 本身。
|
2026-07-02 03:22:12 +08:00
|
|
|
|
"""
|
|
|
|
|
|
return {
|
|
|
|
|
|
"key": field.key,
|
|
|
|
|
|
"type": field.type,
|
|
|
|
|
|
"required": field.required,
|
|
|
|
|
|
"default": field.default,
|
|
|
|
|
|
"hot_reloadable": field.hot_reloadable,
|
2026-07-06 20:49:35 +08:00
|
|
|
|
"scope": field.scope.value if field.scope else None,
|
2026-07-02 03:22:12 +08:00
|
|
|
|
"constraints": field.constraints or {},
|
2026-07-06 20:49:35 +08:00
|
|
|
|
"title": field.title,
|
|
|
|
|
|
"description": field.description,
|
|
|
|
|
|
"category": field.category,
|
2026-07-02 03:22:12 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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]:
|
2026-07-11 21:37:16 +08:00
|
|
|
|
"""将 ``ImportConfigResult`` 转换为响应字典。
|
|
|
|
|
|
|
|
|
|
|
|
``failed_keys`` 为 ``ConfigImportFailure`` 元组,逐项序列化为 ``dict``
|
|
|
|
|
|
(``key`` / ``reason``),供前端展示具体失败原因。
|
|
|
|
|
|
"""
|
2026-07-02 03:22:12 +08:00
|
|
|
|
return {
|
|
|
|
|
|
"imported_count": result.imported_count,
|
|
|
|
|
|
"skipped_count": result.skipped_count,
|
|
|
|
|
|
"failed_count": result.failed_count,
|
2026-07-11 21:37:16 +08:00
|
|
|
|
"failed_keys": [{"key": f.key, "reason": f.reason} for f in result.failed_keys],
|
2026-07-02 03:22:12 +08:00
|
|
|
|
"imported_at": result.imported_at.isoformat() if result.imported_at else None,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _batchUpdateConfigResultToDict(result: Any) -> dict[str, Any]:
|
2026-07-03 19:18:13 +08:00
|
|
|
|
"""将 ``BatchUpdateConfigResult`` 转换为响应字典。
|
|
|
|
|
|
|
|
|
|
|
|
``failed`` 为 ``BatchOperationFailure`` 元组,逐项序列化为 ``dict``
|
|
|
|
|
|
(``id`` / ``error_code`` / ``message``),与 DTO 类型声明一致。
|
|
|
|
|
|
"""
|
2026-07-02 03:22:12 +08:00
|
|
|
|
return {
|
|
|
|
|
|
"total": result.total,
|
2026-07-03 19:18:13 +08:00
|
|
|
|
"succeeded": [{"key": item.key, "new_version": item.new_version} for item in result.succeeded],
|
2026-07-04 00:14:56 +08:00
|
|
|
|
"failed": [{"id": f.id, "error_code": f.error_code, "message": f.message} for f in result.failed],
|
2026-07-02 03:22:12 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---- session 域 ----
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _channelSessionToDict(session: Any) -> dict[str, Any]:
|
|
|
|
|
|
"""将 ChannelSession 转换为字典(运维排查用例)。
|
|
|
|
|
|
|
|
|
|
|
|
包含会话所有者、统一身份关联、临时会话标记等运维排查必要字段。
|
2026-07-06 20:49:35 +08:00
|
|
|
|
``status`` / ``last_active_at`` 由原始字段推导,便于前端直接展示。
|
2026-07-02 03:22:12 +08:00
|
|
|
|
"""
|
2026-07-06 20:49:35 +08:00
|
|
|
|
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
|
2026-07-02 03:22:12 +08:00
|
|
|
|
return {
|
|
|
|
|
|
"session_id": session.session_id,
|
2026-07-04 00:14:56 +08:00
|
|
|
|
"channel_type": session.channel_type if session.channel_type else None,
|
2026-07-02 03:22:12 +08:00
|
|
|
|
"account_id": session.account_id,
|
|
|
|
|
|
"peer_id": session.peer_id,
|
|
|
|
|
|
"chat_type": session.chat_type,
|
|
|
|
|
|
"conversation_id": session.conversation_id,
|
2026-07-07 16:20:40 +08:00
|
|
|
|
"has_conversation": bool(session.conversation_id),
|
2026-07-02 03:22:12 +08:00
|
|
|
|
"unified_identity_id": session.unified_identity_id,
|
|
|
|
|
|
"owner_peer_id": session.owner_peer_id,
|
|
|
|
|
|
"is_temporary": session.is_temporary,
|
2026-07-06 20:49:35 +08:00
|
|
|
|
"status": "closed" if closed_at is not None else "active",
|
2026-07-02 03:22:12 +08:00
|
|
|
|
"created_at": session.created_at.isoformat() if session.created_at else None,
|
2026-07-06 20:49:35 +08:00
|
|
|
|
"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,
|
2026-07-02 03:22:12 +08:00
|
|
|
|
"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`` 字段可能包含工具调用
|
2026-07-03 19:18:13 +08:00
|
|
|
|
参数、执行结果等敏感内容,通过 ``MaskingPort.maskFull`` 递归脱敏后
|
2026-07-02 03:22:12 +08:00
|
|
|
|
返回(全量遮蔽,避免泄露 token、密钥等敏感字段)。
|
2026-07-07 16:20:40 +08:00
|
|
|
|
|
|
|
|
|
|
会话上下文字段(``channel_session_id`` / ``channel_account_id`` /
|
|
|
|
|
|
``peer_id`` / ``conversation_title``)优先从 ``message`` 自身属性读取
|
|
|
|
|
|
(适配器在查询后注入),缺失时回退到 ``message.conversation`` 关联,
|
|
|
|
|
|
供前端在列表/详情中展示会话上下文。
|
2026-07-02 03:22:12 +08:00
|
|
|
|
"""
|
|
|
|
|
|
operations_history = message.operations_history
|
|
|
|
|
|
if operations_history:
|
|
|
|
|
|
operations_history = [
|
2026-07-03 19:18:13 +08:00
|
|
|
|
masking_port.maskFull(item) if isinstance(item, dict) else item for item in operations_history
|
2026-07-02 03:22:12 +08:00
|
|
|
|
]
|
2026-07-07 16:20:40 +08:00
|
|
|
|
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
|
2026-07-02 03:22:12 +08:00
|
|
|
|
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,
|
2026-07-07 16:20:40 +08:00
|
|
|
|
"channel_session_id": channel_session_id,
|
|
|
|
|
|
"channel_account_id": channel_account_id,
|
|
|
|
|
|
"peer_id": peer_id,
|
|
|
|
|
|
"conversation_title": conversation_title,
|
2026-07-02 03:22:12 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _messageSearchItemToDict(item: MessageSearchItem) -> dict[str, Any]:
|
|
|
|
|
|
"""将 ``MessageSearchItem`` 序列化为 dict(MSG-SEARCH-01)。
|
|
|
|
|
|
|
|
|
|
|
|
``channel_type`` 为 ``StrEnum``,序列化为 ``.value`` 字符串;
|
2026-07-07 16:20:40 +08:00
|
|
|
|
``created_at`` 序列化为 ISO 8601 字符串。同时返回会话上下文字段
|
|
|
|
|
|
``channel_account_id`` / ``peer_id`` / ``conversation_title``,与
|
|
|
|
|
|
``_messageToDict`` 保持一致。
|
2026-07-02 03:22:12 +08:00
|
|
|
|
"""
|
|
|
|
|
|
return {
|
|
|
|
|
|
"message_id": item.message_id,
|
|
|
|
|
|
"conversation_id": item.conversation_id,
|
|
|
|
|
|
"channel_session_id": item.channel_session_id,
|
2026-07-07 16:20:40 +08:00
|
|
|
|
"channel_account_id": item.channel_account_id,
|
2026-07-04 00:14:56 +08:00
|
|
|
|
"channel_type": item.channel_type if item.channel_type else None,
|
2026-07-02 03:22:12 +08:00
|
|
|
|
"role": item.role,
|
2026-07-07 16:20:40 +08:00
|
|
|
|
"peer_id": item.peer_id,
|
|
|
|
|
|
"conversation_title": item.conversation_title,
|
2026-07-02 03:22:12 +08:00
|
|
|
|
"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,
|
2026-07-04 00:14:56 +08:00
|
|
|
|
"channel_type": record.channel_type if record.channel_type else None,
|
2026-07-02 03:22:12 +08:00
|
|
|
|
"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),
|
2026-07-08 03:57:05 +08:00
|
|
|
|
"expired_at": format_utc_datetime(record.expired_at),
|
2026-07-02 03:22:12 +08:00
|
|
|
|
"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:
|
2026-07-08 03:57:05 +08:00
|
|
|
|
"""从 PairingRecord 重构 PairingApproval 聚合根。
|
|
|
|
|
|
|
|
|
|
|
|
委托 ``PairingApproval.fromRecord`` 统一重建逻辑,携带完整字段
|
|
|
|
|
|
(approved_at/rejected_at/revoked_at/reason 等)确保状态机校验与
|
|
|
|
|
|
领域逻辑一致。使用记录本身的 ``version`` 字段保证乐观锁正确性。
|
|
|
|
|
|
"""
|
|
|
|
|
|
return PairingApproval.fromRecord(record)
|
2026-07-02 03:22:12 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---- 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`` 为当前操作人(防止客户端伪造创建者,
|
2026-07-03 19:18:13 +08:00
|
|
|
|
与单条添加 ``_whitelistAdd`` 保持一致)。
|
|
|
|
|
|
|
|
|
|
|
|
构造失败(``peer_id`` 缺失/为空、``expires_at`` 格式非法)由
|
|
|
|
|
|
``whitelistEntryFromDict`` 抛 ``ValidationError``,此处捕获后跳过
|
|
|
|
|
|
并记入 ``failures``,避免单条非法条目让整批导入崩溃(与
|
|
|
|
|
|
``stop_on_error=False`` 的部分成功语义一致;``stop_on_error=True``
|
|
|
|
|
|
时由 handler 解析阶段终止整批)。
|
2026-07-02 03:22:12 +08:00
|
|
|
|
|
|
|
|
|
|
参数:
|
|
|
|
|
|
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)
|
2026-07-03 19:18:13 +08:00
|
|
|
|
except ValidationError as exc:
|
|
|
|
|
|
failures.append({"peer_id": str(item.get("peer_id", "")), "reason": str(exc)})
|
2026-07-02 03:22:12 +08:00
|
|
|
|
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
|
|
|
|
|
|
|
2026-07-04 00:14:56 +08:00
|
|
|
|
channel = channel_type if hasattr(channel_type, "value") else str(channel_type)
|
2026-07-02 03:22:12 +08:00
|
|
|
|
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`` 或携带用户 / 群组元数据的条目。
|
2026-07-07 16:20:40 +08:00
|
|
|
|
新增字段(``avatar_url`` / ``alias`` / ``remark`` / ``tags`` /
|
|
|
|
|
|
``member_count`` / ``owner_id``)优先从顶层字段读取;顶层缺失时
|
|
|
|
|
|
fallback 到 ``entry.metadata`` 中的同名字段。``extra_metadata``
|
|
|
|
|
|
保持嵌套输出。
|
2026-07-02 03:22:12 +08:00
|
|
|
|
"""
|
2026-07-07 16:20:40 +08:00
|
|
|
|
metadata = entry.metadata or {}
|
|
|
|
|
|
result: dict[str, Any] = {
|
2026-07-02 03:22:12 +08:00
|
|
|
|
"type": entry.type.value if hasattr(entry.type, "value") else str(entry.type),
|
|
|
|
|
|
"id": entry.id,
|
|
|
|
|
|
"name": entry.name,
|
2026-07-07 16:20:40 +08:00
|
|
|
|
"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"),
|
2026-07-02 03:22:12 +08:00
|
|
|
|
"metadata": entry.metadata,
|
|
|
|
|
|
}
|
2026-07-07 16:20:40 +08:00
|
|
|
|
if entry.extra_metadata:
|
|
|
|
|
|
result["extra_metadata"] = entry.extra_metadata
|
|
|
|
|
|
return result
|
2026-07-02 03:22:12 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 字符串
|
2026-07-11 21:37:16 +08:00
|
|
|
|
(带 ``Z`` 后缀),与 ORM ``to_dict()`` 格式一致。``id`` 为 ORM 主键,
|
|
|
|
|
|
供 Router 层构造 keyset pagination 游标使用。
|
2026-07-02 03:22:12 +08:00
|
|
|
|
"""
|
|
|
|
|
|
return {
|
2026-07-11 21:37:16 +08:00
|
|
|
|
"id": entry.id,
|
2026-07-02 03:22:12 +08:00
|
|
|
|
"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,
|
2026-07-04 00:14:56 +08:00
|
|
|
|
"target_channel": query.target_channel if query.target_channel else None,
|
2026-07-02 03:22:12 +08:00
|
|
|
|
"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:
|
2026-07-07 16:20:40 +08:00
|
|
|
|
"""从 payload dict 构造 ``OutboxQueryFilter``。``datetime`` 字段接受 ISO 8601 字符串或 ``datetime`` 实例。
|
|
|
|
|
|
|
|
|
|
|
|
``message_id`` / ``channel_msg_id`` / ``channel_account_id`` / ``last_error``
|
|
|
|
|
|
优先映射为对应的 ``*_like`` 模糊搜索字段,保持向后兼容:传入精确值时
|
|
|
|
|
|
``LIKE`` 子串匹配仍可命中;同时支持前端直接传入 ``*_like`` 字段。
|
|
|
|
|
|
"""
|
2026-07-02 03:22:12 +08:00
|
|
|
|
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)
|
2026-07-02 18:27:06 +08:00
|
|
|
|
retry_count_min = payload.get("retry_count_min")
|
2026-07-02 03:22:12 +08:00
|
|
|
|
|
2026-07-07 16:20:40 +08:00
|
|
|
|
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")
|
|
|
|
|
|
|
2026-07-02 03:22:12 +08:00
|
|
|
|
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,
|
2026-07-03 19:18:13 +08:00
|
|
|
|
channel_session_id=payload.get("channel_session_id"),
|
2026-07-02 18:27:06 +08:00
|
|
|
|
retry_count_min=retry_count_min,
|
2026-07-07 16:20:40 +08:00
|
|
|
|
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,
|
2026-07-02 03:22:12 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _outboxEntryToDict(entry: OutboxEntry) -> dict[str, Any]:
|
2026-07-03 19:18:13 +08:00
|
|
|
|
"""将 ``OutboxEntry`` DTO 序列化为 dict。
|
|
|
|
|
|
|
2026-07-08 03:57:05 +08:00
|
|
|
|
``status`` / ``durability_policy`` 为 ``StrEnum``,序列化为 ``.value``
|
|
|
|
|
|
字符串;``channel_type`` 为 ``ChannelType``(``str`` 子类,非 Enum,
|
|
|
|
|
|
无 ``.value``),用 ``str()`` 取字符串值;``datetime`` 字段序列化为
|
|
|
|
|
|
ISO 8601 字符串(``None`` 保留)。
|
2026-07-03 19:18:13 +08:00
|
|
|
|
"""
|
2026-07-02 03:22:12 +08:00
|
|
|
|
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,
|
2026-07-08 03:57:05 +08:00
|
|
|
|
"channel_type": str(entry.channel_type) if entry.channel_type else None,
|
2026-07-02 03:22:12 +08:00
|
|
|
|
"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,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-18 02:04:03 +08:00
|
|
|
|
def _rebuildOutboxAggregate(entry: OutboxEntry) -> outbox_entry_model.OutboxAggregateRoot:
|
2026-07-02 03:22:12 +08:00
|
|
|
|
"""从 ``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)
|
|
|
|
|
|
)
|
2026-07-18 02:04:03 +08:00
|
|
|
|
return outbox_entry_model.OutboxAggregateRoot(
|
2026-07-02 03:22:12 +08:00
|
|
|
|
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,
|
2026-07-08 03:57:05 +08:00
|
|
|
|
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,
|
2026-07-02 03:22:12 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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)
|
2026-07-03 19:18:13 +08:00
|
|
|
|
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",
|
|
|
|
|
|
]
|
|
|
|
|
|
)
|
2026-07-02 03:22:12 +08:00
|
|
|
|
for entry in entries:
|
2026-07-03 19:18:13 +08:00
|
|
|
|
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,
|
|
|
|
|
|
]
|
|
|
|
|
|
)
|
2026-07-02 03:22:12 +08:00
|
|
|
|
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,
|
2026-07-03 19:18:13 +08:00
|
|
|
|
"by_category": [{"category": stat.category, "count": stat.count} for stat in result.by_category],
|
2026-07-02 03:22:12 +08:00
|
|
|
|
"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,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-02 18:27:06 +08:00
|
|
|
|
def _pluginCatalogItemToDict(item: Any) -> dict[str, Any]:
|
|
|
|
|
|
"""将 ``PluginCatalogItem`` 转换为响应字典。
|
|
|
|
|
|
|
|
|
|
|
|
``capabilities`` 字段为 ``ChannelCapabilities`` dataclass,交由 Router 层
|
2026-07-11 15:52:20 +08:00
|
|
|
|
``serialize_control_data`` 递归序列化(dataclass 嵌套)。``icon`` /
|
|
|
|
|
|
``last_error`` 为 ``str | None``,缺失时返回 ``None``,前端回退默认图标。
|
|
|
|
|
|
``requires_restart`` 为 bool,供前端「待重启」角标展示。
|
2026-07-02 18:27:06 +08:00
|
|
|
|
"""
|
|
|
|
|
|
return {
|
|
|
|
|
|
"plugin_id": item.plugin_id,
|
|
|
|
|
|
"name": item.name,
|
|
|
|
|
|
"version": item.version,
|
|
|
|
|
|
"channel_type": item.channel_type,
|
|
|
|
|
|
"state": item.state,
|
|
|
|
|
|
"capabilities": item.capabilities,
|
2026-07-11 15:52:20 +08:00
|
|
|
|
"last_error": item.last_error,
|
|
|
|
|
|
"requires_restart": item.requires_restart,
|
2026-07-06 20:49:35 +08:00
|
|
|
|
"icon": item.icon,
|
2026-07-02 18:27:06 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-02 03:22:12 +08:00
|
|
|
|
def _pluginDetailToDict(detail: Any) -> dict[str, Any]:
|
|
|
|
|
|
"""将 ``PluginDetail`` 转换为响应字典。
|
|
|
|
|
|
|
2026-07-06 20:49:35 +08:00
|
|
|
|
将 PluginManifest 内部嵌套的 ``manifest`` 展开为 ``channel``,避免前端
|
|
|
|
|
|
出现 ``manifest.manifest`` 的歧义结构;同时保留 adapters / stages 等元
|
|
|
|
|
|
数据,并附加 ``last_error`` 供排障展示。
|
2026-07-02 03:22:12 +08:00
|
|
|
|
"""
|
|
|
|
|
|
return {
|
|
|
|
|
|
"plugin_id": detail.plugin_id,
|
|
|
|
|
|
"name": detail.name,
|
|
|
|
|
|
"version": detail.version,
|
|
|
|
|
|
"channel_type": detail.channel_type,
|
|
|
|
|
|
"state": detail.state,
|
2026-07-06 20:49:35 +08:00
|
|
|
|
"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),
|
|
|
|
|
|
},
|
2026-07-02 03:22:12 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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),
|
|
|
|
|
|
}
|