2026-07-02 03:29:06 +08:00
|
|
|
|
"""消息资源域 Router。
|
|
|
|
|
|
|
|
|
|
|
|
实现消息资源域的 9 个 HTTP 端点,覆盖管理员发送消息、消息历史查询、消息搜索、
|
|
|
|
|
|
消息详情、投递状态查询、消息撤回、消息重发、附件上传与批量撤回。MSG-SEND-01
|
|
|
|
|
|
走数据面(模板 B),直接调用 ``AdminMessagePort.sendAdminMessage`` 并以
|
|
|
|
|
|
``dataclass_to_dict`` 序列化结果;其余 8 个端点走控制面(模板 A),经
|
2026-07-04 00:16:00 +08:00
|
|
|
|
``use_cases.message_management`` 调用类型化方法,由 ``ChannelControlService._executeControl``
|
2026-07-02 03:29:06 +08:00
|
|
|
|
内部走控制面管道,再通过 ``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
|
|
|
|
|
|
|
2026-07-04 00:16:00 +08:00
|
|
|
|
import os
|
2026-07-02 03:29:06 +08:00
|
|
|
|
from typing import Any, Literal
|
|
|
|
|
|
|
2026-07-04 00:16:00 +08:00
|
|
|
|
from fastapi import APIRouter, Depends, File, Form, Header, Query, Request, UploadFile
|
2026-07-02 03:29:06 +08:00
|
|
|
|
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="会话策略",
|
|
|
|
|
|
)
|
2026-07-04 00:16:00 +08:00
|
|
|
|
target_type: Literal["session_id", "peer_id"] = Field(
|
|
|
|
|
|
default="session_id",
|
|
|
|
|
|
description="目标类型:session_id(按会话 ID 触达)或 peer_id(按对端 ID 触达)",
|
|
|
|
|
|
)
|
2026-07-02 03:29:06 +08:00
|
|
|
|
|
|
|
|
|
|
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 中。
|
2026-07-04 00:16:00 +08:00
|
|
|
|
body 字段均为可选:缺省 ``target`` 时复用原消息的 ``conversation_id``,
|
|
|
|
|
|
``content_overrides`` 按字段覆盖原消息内容。
|
|
|
|
|
|
|
|
|
|
|
|
``target`` 字段语义为 ``conversation_id``(目标会话 ID),与
|
|
|
|
|
|
``send_admin_message`` 的 ``target``(``channel_type:account_id:session_id``
|
|
|
|
|
|
复合格式)语义不同,因重发场景下渠道与会话已由原消息确定,``target``
|
|
|
|
|
|
仅用于跨会话重发(如将失败消息重发到备用会话)。
|
2026-07-02 03:29:06 +08:00
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
model_config = ConfigDict(frozen=True)
|
|
|
|
|
|
|
2026-07-04 00:16:00 +08:00
|
|
|
|
target: str | None = Field(
|
|
|
|
|
|
default=None,
|
|
|
|
|
|
min_length=1,
|
|
|
|
|
|
description="目标会话 ID(conversation_id,缺省时复用原消息归属会话)",
|
|
|
|
|
|
)
|
2026-07-02 03:29:06 +08:00
|
|
|
|
content_overrides: dict[str, Any] | None = Field(
|
|
|
|
|
|
default=None,
|
2026-07-04 00:16:00 +08:00
|
|
|
|
description="内容覆盖(仅支持 text 键,按字段覆盖原消息内容)",
|
2026-07-02 03:29:06 +08:00
|
|
|
|
)
|
|
|
|
|
|
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"),
|
2026-07-04 00:16:00 +08:00
|
|
|
|
channel_status: Literal["sent", "delivered", "read", "recalled", "edited"] | None = Query(
|
|
|
|
|
|
default=None,
|
|
|
|
|
|
description="按渠道侧状态过滤(sent/delivered/read/recalled/edited)",
|
|
|
|
|
|
),
|
2026-07-02 03:29:06 +08:00
|
|
|
|
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)
|
2026-07-04 00:16:00 +08:00
|
|
|
|
result = await use_cases.message_management.listAdminSentMessages(
|
2026-07-02 03:29:06 +08:00
|
|
|
|
channel_type=channel_type,
|
2026-07-04 00:16:00 +08:00
|
|
|
|
start_time=start_time_dt,
|
|
|
|
|
|
end_time=end_time_dt,
|
|
|
|
|
|
channel_status=channel_status,
|
2026-07-02 03:29:06 +08:00
|
|
|
|
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_session_id: str | None = Query(default=None, description="按会话 ID 过滤"),
|
2026-07-04 00:16:00 +08:00
|
|
|
|
peer_id: str | None = Query(default=None, description="按对端 ID 过滤"),
|
2026-07-02 03:29:06 +08:00
|
|
|
|
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)。
|
|
|
|
|
|
|
2026-07-04 00:16:00 +08:00
|
|
|
|
按关键词搜索消息内容,支持渠道、会话、对端 ID、时间范围过滤。
|
2026-07-02 03:29:06 +08:00
|
|
|
|
对应控制面操作 ``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)
|
2026-07-04 00:16:00 +08:00
|
|
|
|
result = await use_cases.message_management.searchMessages(
|
2026-07-02 03:29:06 +08:00
|
|
|
|
keyword=keyword,
|
|
|
|
|
|
limit=limit,
|
|
|
|
|
|
offset=offset,
|
|
|
|
|
|
operator=operator,
|
|
|
|
|
|
channel_type=channel_type,
|
|
|
|
|
|
channel_session_id=channel_session_id,
|
2026-07-04 00:16:00 +08:00
|
|
|
|
peer_id=peer_id,
|
2026-07-02 03:29:06 +08:00
|
|
|
|
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``。
|
|
|
|
|
|
|
2026-07-04 00:16:00 +08:00
|
|
|
|
部分成功语义:存在失败条目时 ``result.status="partial"``,
|
|
|
|
|
|
``success`` 标记为 ``True``,客户端需检查 ``data.failed`` 字段获取
|
|
|
|
|
|
失败详情。``data`` 中仍包含 ``total`` / ``succeeded`` / ``failed``
|
|
|
|
|
|
三字段供精确判断。
|
|
|
|
|
|
|
2026-07-02 03:29:06 +08:00
|
|
|
|
静态路径先于 ``/messages/{message_id}`` 声明,避免被动态参数捕获。
|
|
|
|
|
|
"""
|
|
|
|
|
|
operator = build_operator(current_user, request)
|
|
|
|
|
|
cmd = BatchRecallCmd(
|
|
|
|
|
|
message_ids=tuple(payload.message_ids),
|
|
|
|
|
|
operator=operator,
|
|
|
|
|
|
reason=payload.reason,
|
|
|
|
|
|
)
|
2026-07-04 00:16:00 +08:00
|
|
|
|
result = await use_cases.message_management.batchRecallMessages(cmd)
|
2026-07-02 03:29:06 +08:00
|
|
|
|
raiseOnControlFailure(result)
|
2026-07-04 00:16:00 +08:00
|
|
|
|
# 部分成功语义:result.status="partial" 时 success 标记为 True,
|
|
|
|
|
|
# 客户端需检查 data.failed 字段获取失败详情。data 中仍包含
|
|
|
|
|
|
# total/succeeded/failed 三字段供精确判断。
|
|
|
|
|
|
return {
|
|
|
|
|
|
"success": result.status in ("success", "partial"),
|
|
|
|
|
|
"data": serialize_control_data(result.data),
|
|
|
|
|
|
}
|
2026-07-02 03:29:06 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@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)
|
2026-07-04 00:16:00 +08:00
|
|
|
|
result = await use_cases.message_management.getMessageById(
|
2026-07-02 03:29:06 +08:00
|
|
|
|
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)
|
2026-07-04 00:16:00 +08:00
|
|
|
|
result = await use_cases.message_management.getMessageStatus(
|
2026-07-02 03:29:06 +08:00
|
|
|
|
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,
|
2026-07-04 00:16:00 +08:00
|
|
|
|
idempotency_key: str = Header(..., alias="X-Idempotency-Key", description="幂等键,防重复发送"),
|
2026-07-02 03:29:06 +08:00
|
|
|
|
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。
|
|
|
|
|
|
|
2026-07-04 00:16:00 +08:00
|
|
|
|
部分成功语义:fan-out 存在 ``failures`` 或 ``skipped`` 时 ``success``
|
|
|
|
|
|
标记为 ``False``,客户端需检查 ``data.failures`` / ``data.skipped``
|
|
|
|
|
|
字段获取详情。``data`` 中仍包含 ``message_ids`` / ``failures`` /
|
|
|
|
|
|
``skipped`` 三字段供精确判断。
|
|
|
|
|
|
|
2026-07-02 03:29:06 +08:00
|
|
|
|
协议翻译:
|
|
|
|
|
|
- 路径参数 ``channel_type`` 不在 body 中
|
|
|
|
|
|
- body 仅承载 ``target`` / ``content`` / ``conversation_policy``
|
2026-07-04 00:16:00 +08:00
|
|
|
|
- ``idempotency_key`` 从请求头 ``X-Idempotency-Key`` 提取,缺失时由
|
|
|
|
|
|
FastAPI ``Header(...)`` 自动返回 422 响应
|
2026-07-02 03:29:06 +08:00
|
|
|
|
"""
|
|
|
|
|
|
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,
|
2026-07-04 00:16:00 +08:00
|
|
|
|
target_type=payload.target_type,
|
2026-07-02 03:29:06 +08:00
|
|
|
|
)
|
|
|
|
|
|
result = await use_cases.admin_message.sendAdminMessage(cmd)
|
2026-07-04 00:16:00 +08:00
|
|
|
|
# 部分成功语义: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),
|
|
|
|
|
|
}
|
2026-07-02 03:29:06 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@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)。
|
|
|
|
|
|
|
2026-07-04 00:16:00 +08:00
|
|
|
|
通过 ``attachment_upload_adapters`` 检查插件 ``AttachmentUploadAdapter``
|
2026-07-02 03:29:06 +08:00
|
|
|
|
能力 → 调用 ``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)
|
2026-07-04 00:16:00 +08:00
|
|
|
|
# 分块流式读取,累计超限即中止,避免大文件全量载入内存导致 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:
|
2026-07-02 03:29:06 +08:00
|
|
|
|
raise ValidationError(
|
2026-07-04 00:16:00 +08:00
|
|
|
|
"filename",
|
|
|
|
|
|
f"must not be empty (got: {raw_filename!r})",
|
2026-07-02 03:29:06 +08:00
|
|
|
|
)
|
|
|
|
|
|
content_type = file.content_type or "application/octet-stream"
|
2026-07-04 00:16:00 +08:00
|
|
|
|
result = await use_cases.message_management.uploadAttachment(
|
2026-07-02 03:29:06 +08:00
|
|
|
|
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)
|
2026-07-04 00:16:00 +08:00
|
|
|
|
result = await use_cases.message_management.recallMessage(
|
2026-07-02 03:29:06 +08:00
|
|
|
|
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,
|
|
|
|
|
|
)
|
2026-07-04 00:16:00 +08:00
|
|
|
|
result = await use_cases.message_management.resendMessage(cmd)
|
2026-07-02 03:29:06 +08:00
|
|
|
|
raiseOnControlFailure(result)
|
|
|
|
|
|
return {"success": True, "data": serialize_control_data(result.data)}
|