1. 新增TraceId中间件实现全链路请求追踪 2. 新增在途请求计数器与中间件支持优雅关停 3. 重构全局异常处理器,统一三模块异常响应格式 4. 优化渠道查询接口,新增多维度过滤与模糊匹配 5. 重构channels模块数据库会话管理,避免事务冲突 6. 新增目录搜索建议与目录导出功能
502 lines
22 KiB
Python
502 lines
22 KiB
Python
"""消息资源域 Router。
|
||
|
||
实现消息资源域的 9 个 HTTP 端点,覆盖管理员发送消息、消息历史查询、消息搜索、
|
||
消息详情、投递状态查询、消息撤回、消息重发、附件上传与批量撤回。MSG-SEND-01
|
||
走数据面(模板 B),直接调用 ``AdminMessagePort.sendAdminMessage`` 并以
|
||
``dataclass_to_dict`` 序列化结果;其余 8 个端点走控制面(模板 A),经
|
||
``use_cases.message_management`` 调用类型化方法,由 ``ChannelControlService._executeControl``
|
||
内部走控制面管道,再通过 ``raiseOnControlFailure`` 转译失败、
|
||
``serialize_control_data`` 序列化成功结果。
|
||
|
||
鉴权策略:全部端点使用 ``get_admin_user`` 依赖,要求管理员或超级管理员角色。
|
||
角色校验由依赖函数完成,router 内不做角色判断(规范 §4)。
|
||
|
||
路径设计:静态跨渠道查询 ``GET /messages`` 系列(含 ``/messages/search``、
|
||
``/messages/batch-recall``)先于动态路径 ``/{channel_type}/...`` 和
|
||
``/messages/{message_id}`` 声明(规范 §6.5),避免静态路径被动态参数捕获。
|
||
``POST /{channel_type}/messages/attachments`` 先于
|
||
``/{channel_type}/messages/{message_id}`` 声明。子 router 不自行设置 prefix,
|
||
根前缀 ``/channels`` 由 ``channels_router`` 聚合 router 统一追加。
|
||
|
||
端点清单(对应《03-消息资源域设计方案 v1.1》+ P0/P1缺口补全):
|
||
- GET /messages MSG-QUERY-01 list_admin_messages
|
||
- GET /messages/search MSG-SEARCH-01 search_messages
|
||
- POST /messages/batch-recall MSG-BATCH-RECALL batch_recall_messages
|
||
- GET /messages/{message_id} MSG-01 get_message
|
||
- GET /messages/{message_id}/status MSG-QUERY-02 get_message_status
|
||
- POST /{channel_type}/messages MSG-SEND-01 send_admin_message
|
||
- POST /{channel_type}/messages/attachments MSG-ATTACH-UPLOAD upload_attachment
|
||
- POST /{channel_type}/messages/{message_id}/recall MSG-05 recall_message
|
||
- POST /{channel_type}/messages/{message_id}/resend MSG-RESEND resend_message
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
from typing import Any, Literal
|
||
|
||
from fastapi import APIRouter, Depends, File, Form, Header, Query, Request, UploadFile
|
||
from pydantic import BaseModel, ConfigDict, Field
|
||
from yuxi.channels.contract.dtos.admin import AdminSendCmd
|
||
from yuxi.channels.contract.dtos.channel import ChannelType
|
||
from yuxi.channels.contract.dtos.common import MessageContent
|
||
from yuxi.channels.contract.dtos.message_ops import (
|
||
BatchRecallCmd,
|
||
ResendMessageCmd,
|
||
)
|
||
from yuxi.channels.contract.errors import ValidationError
|
||
from yuxi.storage.postgres.models_business import User
|
||
|
||
from server.routers.channels import (
|
||
build_operator,
|
||
dataclass_to_dict,
|
||
get_channel_use_cases,
|
||
parse_datetime,
|
||
raiseOnControlFailure,
|
||
serialize_control_data,
|
||
)
|
||
from server.utils.auth_middleware import get_admin_user
|
||
|
||
message_router = APIRouter(tags=["channels-message"])
|
||
|
||
# 附件上传大小上限(10 MiB),防止大文件导致 OOM
|
||
MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024
|
||
|
||
|
||
# ---------------- Request Schemas ----------------
|
||
|
||
|
||
class SendAdminMessageRequest(BaseModel):
|
||
"""管理员发送消息请求体(MSG-SEND-01)。
|
||
|
||
字段对齐 ``AdminSendCmd`` 入参,但 ``channel_type`` 由路径参数提供、
|
||
``idempotency_key`` 由请求头 ``X-Idempotency-Key`` 提取,均不在 body 中。
|
||
``content`` 以 dict 形态承载富消息内容,由 ``to_message_content`` 委托
|
||
``MessageContent.from_dict`` 构造为不可变 DTO。
|
||
"""
|
||
|
||
model_config = ConfigDict(frozen=True)
|
||
|
||
target: str = Field(..., min_length=1, description="目标会话或用户标识")
|
||
content: dict[str, Any] = Field(..., description="富消息内容(按 MessageContent 协议)")
|
||
conversation_policy: Literal["reuse", "reuse-or-create", "new"] = Field(
|
||
default="reuse-or-create",
|
||
description="会话策略",
|
||
)
|
||
target_type: Literal["session_id", "peer_id"] = Field(
|
||
default="session_id",
|
||
description="目标类型:session_id(按会话 ID 触达)或 peer_id(按对端 ID 触达)",
|
||
)
|
||
|
||
def to_message_content(self) -> MessageContent:
|
||
"""将 ``content`` dict 委托至 ``MessageContent.from_dict`` 构造不可变 DTO。
|
||
|
||
``MessageContent.from_dict`` 校验 ``text`` 非空并递归构造 ``Attachment``
|
||
元组,校验失败抛 ``ValidationError`` 由全局异常处理器映射为 400 响应。
|
||
"""
|
||
return MessageContent.from_dict(self.content)
|
||
|
||
|
||
class ResendMessageRequest(BaseModel):
|
||
"""消息重发请求体(MSG-RESEND)。
|
||
|
||
``channel_type`` 与 ``message_id``(原消息)由路径参数提供,不在 body 中。
|
||
body 字段均为可选:缺省 ``target`` 时复用原消息的 ``conversation_id``,
|
||
``content_overrides`` 按字段覆盖原消息内容。
|
||
|
||
``target`` 字段语义为 ``conversation_id``(目标会话 ID),与
|
||
``send_admin_message`` 的 ``target``(``channel_type:account_id:session_id``
|
||
复合格式)语义不同,因重发场景下渠道与会话已由原消息确定,``target``
|
||
仅用于跨会话重发(如将失败消息重发到备用会话)。
|
||
"""
|
||
|
||
model_config = ConfigDict(frozen=True)
|
||
|
||
target: str | None = Field(
|
||
default=None,
|
||
min_length=1,
|
||
description="目标会话 ID(conversation_id,缺省时复用原消息归属会话)",
|
||
)
|
||
content_overrides: dict[str, Any] | None = Field(
|
||
default=None,
|
||
description="内容覆盖(仅支持 text 键,按字段覆盖原消息内容)",
|
||
)
|
||
reason: str | None = Field(default=None, max_length=512, description="重发原因(审计用)")
|
||
|
||
|
||
class BatchRecallRequest(BaseModel):
|
||
"""批量撤回请求体(MSG-BATCH-RECALL)。
|
||
|
||
``message_ids`` 长度限制 1-500,逐条独立事务撤回(模式 D,部分成功语义)。
|
||
"""
|
||
|
||
model_config = ConfigDict(frozen=True)
|
||
|
||
message_ids: list[str] = Field(..., min_length=1, max_length=500, description="待撤回消息 ID 列表(1-500)")
|
||
reason: str | None = Field(default=None, max_length=512, description="撤回原因(审计用)")
|
||
|
||
|
||
# ---------------- Endpoints ----------------
|
||
|
||
|
||
@message_router.get("/messages", response_model=dict)
|
||
async def list_admin_messages(
|
||
request: Request,
|
||
channel_type: ChannelType | None = Query(default=None, description="按渠道过滤"),
|
||
start_time: str | None = Query(default=None, description="起始时间 ISO 8601"),
|
||
end_time: str | None = Query(default=None, description="截止时间 ISO 8601"),
|
||
channel_status: str | None = Query(
|
||
default=None,
|
||
description="按渠道侧状态过滤,支持逗号分隔多值(如 sent,delivered)",
|
||
),
|
||
role: str | None = Query(
|
||
default=None,
|
||
description="按角色过滤,支持逗号分隔多值(如 admin,assistant)",
|
||
),
|
||
account_id: str | None = Query(default=None, description="按渠道账户业务 ID 模糊匹配"),
|
||
peer_id: str | None = Query(default=None, description="按对端 ID 模糊匹配"),
|
||
message_id: str | None = Query(default=None, description="按消息业务 ID 模糊匹配"),
|
||
channel_msg_id: str | None = Query(default=None, description="按渠道消息 ID 模糊匹配"),
|
||
channel_session_id: str | None = Query(default=None, description="按渠道会话 ID 模糊匹配"),
|
||
limit: int = Query(default=50, ge=1, le=200, description="分页大小"),
|
||
offset: int = Query(default=0, ge=0, description="分页偏移"),
|
||
use_cases=Depends(get_channel_use_cases),
|
||
current_user: User = Depends(get_admin_user),
|
||
) -> dict[str, Any]:
|
||
"""列出管理员发送历史(MSG-QUERY-01)。
|
||
|
||
对应控制面操作 ``message/list_admin_history``(由
|
||
``ChannelControlService.listAdminSentMessages`` 内部构造 ``ControlCmd`` 并
|
||
委托 ``_executeControl`` 执行控制面管道)。``params`` 内固定携带
|
||
``filter_role="admin"`` 标识运营审计场景的过滤维度。
|
||
"""
|
||
operator = build_operator(current_user, request)
|
||
start_time_dt = parse_datetime("start_time", start_time)
|
||
end_time_dt = parse_datetime("end_time", end_time)
|
||
result = await use_cases.message_management.listAdminSentMessages(
|
||
channel_type=channel_type,
|
||
start_time=start_time_dt,
|
||
end_time=end_time_dt,
|
||
channel_status=channel_status,
|
||
role=role,
|
||
account_id=account_id,
|
||
peer_id=peer_id,
|
||
message_id=message_id,
|
||
channel_msg_id=channel_msg_id,
|
||
channel_session_id=channel_session_id,
|
||
limit=limit,
|
||
offset=offset,
|
||
operator=operator,
|
||
)
|
||
raiseOnControlFailure(result)
|
||
return {"success": True, "data": serialize_control_data(result.data)}
|
||
|
||
|
||
@message_router.get("/messages/search", response_model=dict)
|
||
async def search_messages(
|
||
request: Request,
|
||
keyword: str = Query(..., min_length=2, description="搜索关键词"),
|
||
channel_type: ChannelType | None = Query(default=None, description="按渠道过滤"),
|
||
channel_status: str | None = Query(
|
||
default=None,
|
||
description="按渠道侧状态过滤,支持逗号分隔多值(如 sent,delivered)",
|
||
),
|
||
role: str | None = Query(
|
||
default=None,
|
||
description="按角色过滤,支持逗号分隔多值(如 admin,assistant)",
|
||
),
|
||
channel_session_id: str | None = Query(default=None, description="按会话 ID 过滤"),
|
||
peer_id: str | None = Query(default=None, description="按对端 ID 过滤"),
|
||
message_id: str | None = Query(default=None, description="按消息业务 ID 模糊匹配"),
|
||
channel_msg_id: str | None = Query(default=None, description="按渠道消息 ID 模糊匹配"),
|
||
start_time: str | None = Query(default=None, description="起始时间(ISO 8601)"),
|
||
end_time: str | None = Query(default=None, description="截止时间(ISO 8601)"),
|
||
limit: int = Query(default=20, ge=1, le=100, description="分页大小"),
|
||
offset: int = Query(default=0, ge=0, description="分页偏移"),
|
||
use_cases=Depends(get_channel_use_cases),
|
||
current_user: User = Depends(get_admin_user),
|
||
) -> dict[str, Any]:
|
||
"""全文搜索消息(MSG-SEARCH-01)。
|
||
|
||
按关键词搜索消息内容,支持渠道、状态、角色、会话、对端 ID、消息 ID、
|
||
渠道消息 ID、时间范围过滤。对应控制面操作 ``message/search``。
|
||
"""
|
||
operator = build_operator(current_user, request)
|
||
start_time_dt = parse_datetime("start_time", start_time)
|
||
end_time_dt = parse_datetime("end_time", end_time)
|
||
result = await use_cases.message_management.searchMessages(
|
||
keyword=keyword,
|
||
limit=limit,
|
||
offset=offset,
|
||
operator=operator,
|
||
channel_type=channel_type,
|
||
channel_status=channel_status,
|
||
role=role,
|
||
channel_session_id=channel_session_id,
|
||
peer_id=peer_id,
|
||
message_id=message_id,
|
||
channel_msg_id=channel_msg_id,
|
||
start_time=start_time_dt,
|
||
end_time=end_time_dt,
|
||
)
|
||
raiseOnControlFailure(result)
|
||
return {"success": True, "data": serialize_control_data(result.data)}
|
||
|
||
|
||
@message_router.post("/messages/batch-recall", response_model=dict)
|
||
async def batch_recall_messages(
|
||
payload: BatchRecallRequest,
|
||
request: Request,
|
||
use_cases=Depends(get_channel_use_cases),
|
||
current_user: User = Depends(get_admin_user),
|
||
) -> dict[str, Any]:
|
||
"""批量撤回消息(MSG-BATCH-RECALL)。
|
||
|
||
逐条独立事务复用单条 ``recallMessage`` 逻辑(模式 D,部分成功语义),
|
||
收集成功/失败结果。单条失败不回滚已成功条目,撤回为外部副作用操作不可
|
||
回滚。对应控制面操作 ``message/batch_recall``。
|
||
|
||
部分成功语义:存在失败条目时 ``result.status="partial"``,
|
||
``success`` 标记为 ``True``,客户端需检查 ``data.failed`` 字段获取
|
||
失败详情。``data`` 中仍包含 ``total`` / ``succeeded`` / ``failed``
|
||
三字段供精确判断。
|
||
|
||
静态路径先于 ``/messages/{message_id}`` 声明,避免被动态参数捕获。
|
||
"""
|
||
operator = build_operator(current_user, request)
|
||
cmd = BatchRecallCmd(
|
||
message_ids=tuple(payload.message_ids),
|
||
operator=operator,
|
||
reason=payload.reason,
|
||
)
|
||
result = await use_cases.message_management.batchRecallMessages(cmd)
|
||
raiseOnControlFailure(result)
|
||
# 部分成功语义:result.status="partial" 时 success 标记为 True,
|
||
# 客户端需检查 data.failed 字段获取失败详情。data 中仍包含
|
||
# total/succeeded/failed 三字段供精确判断。
|
||
return {
|
||
"success": result.status in ("success", "partial"),
|
||
"data": serialize_control_data(result.data),
|
||
}
|
||
|
||
|
||
@message_router.get("/messages/{message_id}", response_model=dict)
|
||
async def get_message(
|
||
message_id: str,
|
||
request: Request,
|
||
use_cases=Depends(get_channel_use_cases),
|
||
current_user: User = Depends(get_admin_user),
|
||
) -> dict[str, Any]:
|
||
"""按内部消息 ID 查询消息详情(MSG-01)。
|
||
|
||
对应控制面操作 ``message/get``(由 ``ChannelControlService.getMessageById``
|
||
内部构造 ``ControlCmd`` 并委托 ``_executeControl`` 执行控制面管道)。
|
||
与 ``message/get_by_channel_msg_id`` 区别:本端点按 Yuxi 内部 ``message_id``
|
||
查询,后者按渠道侧 ``channel_msg_id`` 查询。
|
||
"""
|
||
operator = build_operator(current_user, request)
|
||
result = await use_cases.message_management.getMessageById(
|
||
message_id=message_id,
|
||
operator=operator,
|
||
)
|
||
raiseOnControlFailure(result)
|
||
return {"success": True, "data": serialize_control_data(result.data)}
|
||
|
||
|
||
@message_router.get("/messages/{message_id}/status", response_model=dict)
|
||
async def get_message_status(
|
||
message_id: str,
|
||
request: Request,
|
||
use_cases=Depends(get_channel_use_cases),
|
||
current_user: User = Depends(get_admin_user),
|
||
) -> dict[str, Any]:
|
||
"""查询单条消息投递状态(MSG-QUERY-02)。
|
||
|
||
对应控制面操作 ``message/status``(由
|
||
``ChannelControlService.getMessageStatus`` 内部构造 ``ControlCmd`` 并委托
|
||
``_executeControl`` 执行控制面管道)。返回精简状态视图,不含 ``content`` /
|
||
``operations_history`` / ``role``,避免带宽浪费。
|
||
"""
|
||
operator = build_operator(current_user, request)
|
||
result = await use_cases.message_management.getMessageStatus(
|
||
message_id=message_id,
|
||
operator=operator,
|
||
)
|
||
raiseOnControlFailure(result)
|
||
return {"success": True, "data": serialize_control_data(result.data)}
|
||
|
||
|
||
@message_router.post("/{channel_type}/messages", response_model=dict)
|
||
async def send_admin_message(
|
||
channel_type: ChannelType,
|
||
payload: SendAdminMessageRequest,
|
||
request: Request,
|
||
idempotency_key: str = Header(..., alias="X-Idempotency-Key", description="幂等键,防重复发送"),
|
||
use_cases=Depends(get_channel_use_cases),
|
||
current_user: User = Depends(get_admin_user),
|
||
) -> dict[str, Any]:
|
||
"""管理员向目标会话或用户发送消息(FR-19,MSG-SEND-01,数据面)。
|
||
|
||
走模板 B(数据面),直接调用 ``AdminMessagePort.sendAdminMessage``,
|
||
不经控制面管道、不调用 ``raiseOnControlFailure``(数据面无 ``ControlResult``)。
|
||
成功结果以 ``dataclass_to_dict`` 序列化为 ``AdminSendResult`` dict。
|
||
|
||
部分成功语义:fan-out 存在 ``failures`` 或 ``skipped`` 时 ``success``
|
||
标记为 ``False``,客户端需检查 ``data.failures`` / ``data.skipped``
|
||
字段获取详情。``data`` 中仍包含 ``message_ids`` / ``failures`` /
|
||
``skipped`` 三字段供精确判断。
|
||
|
||
协议翻译:
|
||
- 路径参数 ``channel_type`` 不在 body 中
|
||
- body 仅承载 ``target`` / ``content`` / ``conversation_policy``
|
||
- ``idempotency_key`` 从请求头 ``X-Idempotency-Key`` 提取,缺失时由
|
||
FastAPI ``Header(...)`` 自动返回 422 响应
|
||
"""
|
||
operator = build_operator(current_user, request)
|
||
cmd = AdminSendCmd(
|
||
target=payload.target,
|
||
content=payload.to_message_content(),
|
||
conversation_policy=payload.conversation_policy,
|
||
idempotency_key=idempotency_key,
|
||
operator=operator,
|
||
target_type=payload.target_type,
|
||
)
|
||
result = await use_cases.admin_message.sendAdminMessage(cmd)
|
||
# 部分成功语义:fan-out 存在 failures 或 skipped 时 success 标记为 False,
|
||
# 客户端需检查 data.failures / data.skipped 字段获取详情。data 中仍包含
|
||
# message_ids / failures / skipped 三字段供精确判断。
|
||
is_partial = bool(result.failures) or bool(result.skipped)
|
||
return {
|
||
"success": not is_partial,
|
||
"data": dataclass_to_dict(result),
|
||
}
|
||
|
||
|
||
@message_router.post(
|
||
"/{channel_type}/messages/attachments",
|
||
response_model=dict,
|
||
)
|
||
async def upload_attachment(
|
||
channel_type: ChannelType,
|
||
request: Request,
|
||
account_id: str = Form(..., description="渠道账户 ID"),
|
||
file: UploadFile = File(..., description="待上传附件文件"),
|
||
purpose: str | None = Form(default=None, description="上传用途(如 avatar / attachment)"),
|
||
use_cases=Depends(get_channel_use_cases),
|
||
current_user: User = Depends(get_admin_user),
|
||
) -> dict[str, Any]:
|
||
"""上传附件(MSG-ATTACH-UPLOAD)。
|
||
|
||
通过 ``attachment_upload_adapters`` 检查插件 ``AttachmentUploadAdapter``
|
||
能力 → 调用 ``uploadAttachment(account, file_data, filename,
|
||
content_type, purpose)`` → 返回附件元数据。适配器不支持时由 dispatch
|
||
handler 抛 ``NotImplementedError``(501)。对应控制面操作
|
||
``message/attachment_upload``。
|
||
|
||
协议翻译:multipart/form-data 中 ``account_id`` / ``purpose`` 为 Form
|
||
字段,``file`` 为 UploadFile(读取 bytes 后传入)。``filename`` 与
|
||
``content_type`` 从 ``UploadFile`` 元数据提取。
|
||
|
||
静态路径 ``/messages/attachments`` 先于
|
||
``/{channel_type}/messages/{message_id}`` 声明,避免被动态参数捕获。
|
||
"""
|
||
operator = build_operator(current_user, request)
|
||
# 分块流式读取,累计超限即中止,避免大文件全量载入内存导致 OOM
|
||
chunks: list[bytes] = []
|
||
total = 0
|
||
while chunk := await file.read(1024 * 1024):
|
||
total += len(chunk)
|
||
if total > MAX_ATTACHMENT_SIZE:
|
||
raise ValidationError(
|
||
"file",
|
||
f"attachment size exceeds limit {MAX_ATTACHMENT_SIZE} bytes",
|
||
)
|
||
chunks.append(chunk)
|
||
file_data = b"".join(chunks)
|
||
# filename 安全过滤:取 basename 防止路径穿越(如 ``../etc/passwd`` /
|
||
# ``C:\\Windows\\system32``),适配器层无需重复校验。
|
||
raw_filename = file.filename or ""
|
||
filename = os.path.basename(raw_filename)
|
||
if not filename:
|
||
raise ValidationError(
|
||
"filename",
|
||
f"must not be empty (got: {raw_filename!r})",
|
||
)
|
||
content_type = file.content_type or "application/octet-stream"
|
||
result = await use_cases.message_management.uploadAttachment(
|
||
channel_type=channel_type,
|
||
account_id=account_id,
|
||
file_data=file_data,
|
||
filename=filename,
|
||
content_type=content_type,
|
||
operator=operator,
|
||
purpose=purpose,
|
||
)
|
||
raiseOnControlFailure(result)
|
||
return {"success": True, "data": serialize_control_data(result.data)}
|
||
|
||
|
||
@message_router.post(
|
||
"/{channel_type}/messages/{message_id}/recall",
|
||
response_model=dict,
|
||
)
|
||
async def recall_message(
|
||
channel_type: ChannelType,
|
||
message_id: str,
|
||
request: Request,
|
||
use_cases=Depends(get_channel_use_cases),
|
||
current_user: User = Depends(get_admin_user),
|
||
) -> dict[str, Any]:
|
||
"""撤回消息(FR-12,MSG-05)。
|
||
|
||
对应控制面操作 ``message/recall``(由 ``ChannelControlService.recallMessage``
|
||
内部构造 ``ControlCmd`` 并委托 ``_executeControl`` 执行控制面管道)。
|
||
撤回为外部副作用不可回滚,审计使用 ``INDEPENDENT`` 独立事务 + fail-closed。
|
||
|
||
协议翻译:路径参数 ``channel_type`` 与 ``message_id`` 直接传入
|
||
``recallMessage(channel_type=..., message_id=..., operator=...)``。
|
||
"""
|
||
operator = build_operator(current_user, request)
|
||
result = await use_cases.message_management.recallMessage(
|
||
channel_type=channel_type,
|
||
message_id=message_id,
|
||
operator=operator,
|
||
)
|
||
raiseOnControlFailure(result)
|
||
return {"success": True, "data": serialize_control_data(result.data)}
|
||
|
||
|
||
@message_router.post(
|
||
"/{channel_type}/messages/{message_id}/resend",
|
||
response_model=dict,
|
||
)
|
||
async def resend_message(
|
||
channel_type: ChannelType,
|
||
message_id: str,
|
||
payload: ResendMessageRequest,
|
||
request: Request,
|
||
use_cases=Depends(get_channel_use_cases),
|
||
current_user: User = Depends(get_admin_user),
|
||
) -> dict[str, Any]:
|
||
"""消息重发(MSG-RESEND)。
|
||
|
||
查询原消息 → 校验状态可重发(非已撤回/非已删除)→ 构造新消息(应用
|
||
``content_overrides``、``target`` 缺省时复用原消息目标)→ 发送并
|
||
持久化 → 返回新旧消息 ID 映射。对应控制面操作 ``message/resend``。
|
||
|
||
协议翻译:路径参数 ``channel_type`` 与 ``message_id``(原消息)与 body
|
||
字段组合为 ``ResendMessageCmd``。
|
||
"""
|
||
operator = build_operator(current_user, request)
|
||
cmd = ResendMessageCmd(
|
||
channel_type=channel_type,
|
||
message_id=message_id,
|
||
operator=operator,
|
||
target=payload.target,
|
||
content_overrides=payload.content_overrides,
|
||
reason=payload.reason,
|
||
)
|
||
result = await use_cases.message_management.resendMessage(cmd)
|
||
raiseOnControlFailure(result)
|
||
return {"success": True, "data": serialize_control_data(result.data)}
|