ForcePilot/backend/server/routers/channels/message_router.py
Kris 12ec5af0de feat: 新增全链路追踪、优雅关停支持与统一异常处理,优化渠道查询接口
1. 新增TraceId中间件实现全链路请求追踪
2. 新增在途请求计数器与中间件支持优雅关停
3. 重构全局异常处理器,统一三模块异常响应格式
4. 优化渠道查询接口,新增多维度过滤与模糊匹配
5. 重构channels模块数据库会话管理,避免事务冲突
6. 新增目录搜索建议与目录导出功能
2026-07-07 16:25:47 +08:00

502 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
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="目标会话 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 = 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-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(..., 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-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(
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)}