1. 为渠道账户ID查询添加最小长度校验,统一分析模块常量引用 2. 新增扫码登录向导端点,完善文档说明 3. 优化配对统计接口,移除无效参数 4. 为出站箱接口添加批量上限与202状态码 5. 新增测试用例、访问规则、配额等模块的查询与校验参数 6. 新增适配器健康批量查询、健康检查触发接口 7. 统一告警、审计日志的错误处理方式 8. 新增插件配置账户ID支持,优化批量操作响应 9. 新增环境健康批量查询、Webhook限流与参数校验 10. 完善会话管理、审计日志的参数与文档说明 11. 修复导入模块的校验错误处理逻辑
518 lines
22 KiB
Python
518 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
|
||
|
||
from typing import Any, Literal
|
||
|
||
from fastapi import APIRouter, Depends, File, Form, Header, Path, 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 (
|
||
DEFAULT_LIMIT,
|
||
LARGE_LIMIT,
|
||
OFFSET,
|
||
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 = LARGE_LIMIT,
|
||
offset: int = OFFSET,
|
||
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 过滤"),
|
||
account_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 = DEFAULT_LIMIT,
|
||
offset: int = OFFSET,
|
||
use_cases=Depends(get_channel_use_cases),
|
||
current_user: User = Depends(get_admin_user),
|
||
) -> dict[str, Any]:
|
||
"""全文搜索消息(MSG-SEARCH-01)。
|
||
|
||
按关键词搜索消息内容,支持渠道、状态、角色、会话、对端 ID、账户 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,
|
||
account_id=account_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``。
|
||
|
||
部分成功语义:存在失败条目时 ``success`` 标记为 ``False``(与
|
||
``send_admin_message`` 语义统一),客户端需检查 ``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)
|
||
# 部分成功语义:status=="partial" 时 success=False,与 send_admin_message 语义统一。
|
||
# 客户端需检查 data.failed 字段获取失败详情。data 中仍包含
|
||
# total/succeeded/failed 三字段供精确判断。
|
||
is_partial = result.status == "partial"
|
||
return {
|
||
"success": not is_partial,
|
||
"data": serialize_control_data(result.data),
|
||
}
|
||
|
||
|
||
@message_router.get("/messages/{message_id}", response_model=dict)
|
||
async def get_message(
|
||
request: Request,
|
||
message_id: str = Path(..., min_length=1, description="消息内部 ID"),
|
||
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(
|
||
request: Request,
|
||
message_id: str = Path(..., min_length=1, description="消息内部 ID"),
|
||
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",
|
||
min_length=1,
|
||
max_length=128,
|
||
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(..., min_length=1, 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。
|
||
# try-finally 确保超限异常路径也关闭 UploadFile,及时释放临时文件资源。
|
||
try:
|
||
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)
|
||
finally:
|
||
await file.close()
|
||
file_data = b"".join(chunks)
|
||
# filename 安全过滤:统一替换 \ 为 / 后取末段,兼容跨平台路径(Docker
|
||
# 部署于 Linux,os.path.basename 不识别 \ 分隔符,会导致
|
||
# ``..\\..\\etc\\passwd`` 原样透传给适配器,路径穿越防护失效)。
|
||
raw_filename = file.filename or ""
|
||
filename = raw_filename.replace("\\", "/").rsplit("/", 1)[-1]
|
||
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(
|
||
request: Request,
|
||
channel_type: ChannelType,
|
||
message_id: str = Path(..., min_length=1, description="消息内部 ID"),
|
||
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(
|
||
request: Request,
|
||
channel_type: ChannelType,
|
||
payload: ResendMessageRequest,
|
||
message_id: str = Path(..., min_length=1, description="消息内部 ID"),
|
||
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)}
|