ForcePilot/backend/server/routers/channels/message_router.py
Kris 2f6a4c29bb refactor(channel routers): 统一参数校验与常量定义,新增功能端点
1. 为渠道账户ID查询添加最小长度校验,统一分析模块常量引用
2. 新增扫码登录向导端点,完善文档说明
3. 优化配对统计接口,移除无效参数
4. 为出站箱接口添加批量上限与202状态码
5. 新增测试用例、访问规则、配额等模块的查询与校验参数
6. 新增适配器健康批量查询、健康检查触发接口
7. 统一告警、审计日志的错误处理方式
8. 新增插件配置账户ID支持,优化批量操作响应
9. 新增环境健康批量查询、Webhook限流与参数校验
10. 完善会话管理、审计日志的参数与文档说明
11. 修复导入模块的校验错误处理逻辑
2026-07-11 21:39:05 +08:00

518 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""消息资源域 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="目标会话 IDconversation_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-19MSG-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
# 部署于 Linuxos.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-12MSG-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)}