ForcePilot/backend/server/routers/channels/message_router.py
Kris ddafd95ff0 refactor(routers): 整理并新增多组渠道相关路由功能
1.  移除多个导出接口的显式response_model声明
2.  调整access_rule和test_case的创建接口位置,修复静态路径冲突
3.  优化适配器配置校验的异常处理逻辑
4.  重构集成路由的查询逻辑,统一使用get_integration_or_raise
5.  新增channels路由组下的capability、reports、dashboard、webhook、wizard、doctor、directory、session共8个子路由模块
6.  注册channels_router到全局路由列表
2026-07-02 03:29:06 +08:00

420 lines
18 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_query`` 调用类型化方法,由 ``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, 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="会话策略",
)
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`` 时复用原消息目标,``content_overrides``
按字段覆盖原消息内容。
"""
model_config = ConfigDict(frozen=True)
target: str | None = Field(default=None, min_length=1, description="重发目标(缺省时复用原消息目标)")
content_overrides: dict[str, Any] | None = Field(
default=None,
description="内容覆盖(按字段覆盖原消息内容)",
)
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"),
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_query.listAdminSentMessages(
channel_type=channel_type,
start_time=start_time_dt.isoformat() if start_time_dt else None,
end_time=end_time_dt.isoformat() if end_time_dt else None,
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 过滤"),
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
按关键词搜索消息内容,支持渠道、会话、时间范围过滤。
对应控制面操作 ``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_query.searchMessages(
keyword=keyword,
limit=limit,
offset=offset,
operator=operator,
channel_type=channel_type,
channel_session_id=channel_session_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``。
静态路径先于 ``/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_query.batchRecallMessages(cmd)
raiseOnControlFailure(result)
return {"success": True, "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_query.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_query.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,
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。
协议翻译:
- 路径参数 ``channel_type`` 不在 body 中
- body 仅承载 ``target`` / ``content`` / ``conversation_policy``
- ``idempotency_key`` 从请求头 ``X-Idempotency-Key`` 提取,缺失时抛
``ValidationError``(由全局异常处理器映射为 400 ``VALIDATION_ERROR``
"""
operator = build_operator(current_user, request)
idempotency_key = request.headers.get("X-Idempotency-Key", "")
if not idempotency_key:
raise ValidationError(
"X-Idempotency-Key",
"X-Idempotency-Key header is required",
)
cmd = AdminSendCmd(
target=payload.target,
content=payload.to_message_content(),
conversation_policy=payload.conversation_policy,
idempotency_key=idempotency_key,
operator=operator,
)
result = await use_cases.admin_message.sendAdminMessage(cmd)
return {"success": True, "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`` 检查插件 ``AttachmentUploadable``
能力 → 调用 ``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)
file_data = await file.read()
if len(file_data) > MAX_ATTACHMENT_SIZE:
raise ValidationError(
"file",
f"attachment size {len(file_data)} exceeds limit {MAX_ATTACHMENT_SIZE} bytes",
)
filename = file.filename or ""
content_type = file.content_type or "application/octet-stream"
result = await use_cases.message_query.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_query.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_query.resendMessage(cmd)
raiseOnControlFailure(result)
return {"success": True, "data": serialize_control_data(result.data)}