消息收发是自动化的心脏。发一条消息很简单------一个 POST 请求的事。但当消息量上来、类型变多、文件要上传下载、语音要转文字,单一同步调用的模式就会迅速崩溃。这篇文章聊的是如何设计一个经得起流量考验的消息处理系统。
一、从"调接口"到"管道处理"
最简单的消息收发代码是这样的:
python
def send_message(guid, to_id, content):
resp = requests.post(API_URL, json={
"method": "/msg/sendText",
"params": {"guid": guid, "toId": to_id, "content": content}
})
return resp.json()
这段代码在单条消息、低频场景下完全没问题。但你很快会遇到这些情况:
- 同时要发 1000 条消息,for 循环太慢
- 发图片要先上传,上传是异步的,需要等回调
- 语音消息要先下载,再转文字,转文字也是异步的
- 某条消息发送失败,但你不知道是"没发出去"还是"发出去了没收到回复"
答案是把消息处理抽象成一个管道,每一步都可控、可观测、可重试。
二、消息发送管道设计
把发送抽象为三个阶段:准备 → 发送 → 确认。
消息请求 → [准备阶段] → [发送阶段] → [确认阶段] → 完成
│ │ │
│ 媒体上传 │ 实际调用 │ 查询状态
│ Base64编码 │ 错误分类 │ 记录日志
│ 内容校验 │ 重试决策 │ 回调等待
2.1 准备阶段:媒体资源预处理
文本消息可以直接发送,但图片、视频、语音、文件等需要先上传。
上传有两种模式:
- 本地文件上传:同步调用,直接上传本地文件到平台,拿到 fileId
- 异步上传:适用于大文件,提交上传任务后通过 20000 回调获取结果
python
class MediaPreparer:
"""媒体资源预处理器"""
async def prepare(self, msg: Message) -> PreparedMessage:
if msg.type == MessageType.TEXT:
return PreparedMessage(content=msg.content)
if msg.type in (MessageType.IMAGE, MessageType.VIDEO, MessageType.FILE):
# 小文件同步上传
file_id = await self.upload_file(msg.file_path)
return PreparedMessage(media_id=file_id)
if msg.type == MessageType.VOICE:
# 语音需要先下载到本地再上传
local_path = await self.download_voice(msg.voice_url)
file_id = await self.upload_file(local_path)
return PreparedMessage(media_id=file_id)
2.2 发送阶段:错误分类与重试
发送的核心不是"调 API",而是"调完 API 之后怎么办"。错误分类我们在上一篇聊过(A/B/C 三级),这里补充一条重要经验:
发送超时 ≠ 发送失败。
超时只说明你在限定时间内没收到 HTTP 响应,不代表服务端没有执行操作。盲目重试 = 消息重复。
python
class MessageSender:
"""消息发送器"""
async def send(self, msg: PreparedMessage, device_id: str) -> SendResult:
client_msg_id = generate_uuid() # 客户端生成幂等 ID
try:
resp = await self.api.send({
"method": self._get_method(msg.type),
"params": {
"guid": device_id,
"toId": msg.to_id,
"content": msg.content,
# 如果平台支持 client_msg_id,带上做幂等
}
}, timeout=5)
return SendResult(
success=True,
server_msg_id=resp["data"]["msgServerId"],
client_msg_id=client_msg_id
)
except TimeoutError:
# 关键:超时后不要立即重试,先查状态
logger.warning(f"send timeout, client_msg_id={client_msg_id}")
return SendResult(success=False, error="timeout", client_msg_id=client_msg_id)
except RetryableError as e:
return SendResult(success=False, error=str(e), retryable=True)
except FatalError as e:
return SendResult(success=False, error=str(e), retryable=False)
三、消息接收与消费管道
接收端同样需要管道化。一条消息从 Webhook 进来到处理完成,标准流程是:
Webhook 接收 → 签名校验 → 入队 → 消费者拉取
→ 去重判断 → 类型识别 → 内容解析 → 业务分发 → 结果写回
3.1 内容解析层
不同消息类型的内容载体不同:
| 消息类型 | 内容载体 | 解析方式 |
|---|---|---|
| 文本 | content 字段直接就是文本 |
无需额外处理 |
| 图片 | content 是文件标识 |
需要调用下载接口获取实际图片 |
| 语音 | content 是语音文件标识 |
下载后调用语音转文字 |
| 视频 | content 是视频文件标识 |
下载 + 可选缩略图 |
| 文件 | content 是文件标识 + 文件名 |
下载文件到本地 |
| 链接 | content 是 URL + 标题 |
可直接解析 |
| 小程序 | content 是小程序卡片信息 |
提取 appid + 路径 |
python
class MessageParser:
"""消息内容解析器"""
async def parse(self, raw_msg: dict) -> ParsedMessage:
msg_type = raw_msg.get("msgType")
parsers = {
1: self._parse_text, # 文本
2: self._parse_image, # 图片
3: self._parse_voice, # 语音
4: self._parse_video, # 视频
5: self._parse_file, # 文件
6: self._parse_link, # 链接
7: self._parse_miniprogram,# 小程序
}
parser = parsers.get(msg_type, self._parse_unknown)
return await parser(raw_msg)
async def _parse_voice(self, raw_msg):
# 语音消息:下载语音文件 → 提交转文字任务 → 等异步回调
voice_url = self._extract_file_url(raw_msg)
local_path = await self.download_file(voice_url)
# 提交语音转文字任务(异步)
task_id = await self.submit_voice_to_text(local_path)
# 结果通过 20000 异步消息回调返回
return ParsedMessage(
type=MessageType.VOICE,
content=f"[语音消息,转文字任务: {task_id}]",
pending_task_id=task_id
)
3.2 异步任务的回调处理
媒体文件上传、语音转文字、大文件处理等都是异步任务。它们完成后通过 cmd=20000 的回调返回结果。
关键设计:异步任务需要维护一个"任务 → 回调"的映射表,在收到 20000 回调时找到原始任务并完成后续流程。
python
class AsyncTaskManager:
"""异步任务管理器"""
def __init__(self):
self.pending_tasks = {} # task_id → Future
async def submit_task(self, task_type, params) -> str:
task_id = generate_uuid()
future = asyncio.Future()
self.pending_tasks[task_id] = {
"future": future,
"type": task_type,
"created_at": time.time(),
"params": params
}
# 超时自动取消
asyncio.create_task(self._timeout_guard(task_id, timeout=300))
return task_id
def on_callback(self, event: dict):
"""处理 20000 异步消息回调"""
cloud_url = event["msgData"].get("cloudUrl", "")
# 从 URL 或其他标识中解析 task_id
task_id = self._extract_task_id(event)
if task_id in self.pending_tasks:
task_info = self.pending_tasks.pop(task_id)
task_info["future"].set_result(event["msgData"])
async def _timeout_guard(self, task_id, timeout):
await asyncio.sleep(timeout)
if task_id in self.pending_tasks:
task_info = self.pending_tasks.pop(task_id)
task_info["future"].set_exception(TimeoutError("async task timeout"))
四、历史消息同步:增量拉取的时序难题
不是所有消息都走 Webhook。如果你需要同步历史消息(比如设备刚上线时补拉离线期间的消息),就需要用到分页拉取。
4.1 分页拉取的标准模式
python
async def sync_history(guid: str, checkpoint: dict):
"""增量同步历史消息"""
while True:
resp = await api.call("/msg/syncHistory", {
"guid": guid,
"cursor": checkpoint
})
messages = resp["data"]["messages"]
if not messages:
break # 没有更多数据
for msg in messages:
yield msg
# 更新 checkpoint
last_msg = messages[-1]
checkpoint = {
"timestamp": last_msg["timestamp"],
"msg_id": last_msg["msgServerId"]
}
4.2 为什么不只用 timestamp 做 cursor
因为同一条消息可能在不同时间点被拉取到,而且跨设备场景下 timestamp 不严格递增。用 (timestamp, msg_id) 组合作为 cursor 才能避免漏拉或重复。
五、消息撤回的处理
撤回是一种特殊的消息事件。处理撤回的逻辑是:
- 收到撤回事件(携带被撤回消息的 msgServerId)
- 在已存储的消息中查找原始消息
- 更新该消息的状态为"已撤回"
- 如果消息已经被业务处理过(比如已经回复了),需要执行对应的撤回补偿逻辑
python
async def handle_revoke(event: dict):
revoked_msg_id = event["msgData"]["revokedMsgId"]
# 在数据库中标记为已撤回
await db.update_message(revoked_msg_id, status="revoked")
# 查找关联的自动回复并撤回
replies = await db.find_replies(revoked_msg_id)
for reply in replies:
await revoke_message(reply["msgServerId"])
六、群发消息的正确姿势
群发不是"for 循环调单发"。平台提供了群发助手,一次请求指定多个接收者:
python
async def mass_send(guid: str, user_ids: list[str], content: str):
"""群发消息"""
resp = await api.call("/msg/massSend", {
"guid": guid,
"toIds": user_ids, # 一次指定所有接收者
"content": content
})
# 查询群发状态
task_id = resp["data"]["taskId"]
return MassSendTask(task_id=task_id, total=len(user_ids))
群发任务提交后是异步的,需要通过状态查询接口轮询完成进度。这里同样需要处理部分失败的情况:
- 有些用户可能不是好友
- 有些用户可能设置了免打扰
- 频率限制可能导致部分消息延迟发送
七、消息可靠性保障清单
综合以上,一个可靠的消息系统至少需要覆盖这些点:
| 环节 | 保障措施 |
|---|---|
| 接收 | 去重(msgUniqueIdentifier)、签名校验 |
| 解析 | 每种 msgType 独立解析器、Base64 解码 |
| 异步任务 | 任务映射表 + 超时保护 + 20000 回调 |
| 发送 | 客户端幂等 ID、超时不盲重试、错误分级 |
| 撤回 | 消息状态更新 + 回复补偿 |
| 群发 | 使用群发接口而非循环单发、状态轮询 |
| 文件 | 区分同步/异步上传、下载缓存 |
| 同步 | cursor 方案而非 timestamp 方案 |
八、总结
消息系统不是"发出去就行",它是一个从接收到发送的完整生命周期管理:
- 管道化思维:每个环节独立可控,失败可追溯
- 异步任务要有超时:文件上传、语音转文字都不是瞬间完成的
- 撤回要考虑补偿:不是简单标记状态就完事
- 群发用专用接口:不要拿循环单发凑合
- 超时不等于失败:重试前先确认状态
把消息处理当成一个状态机来设计,而不是一个函数调用,系统的稳定性才有保障。
本文参考了 QiweAPI 平台技术文档 中的架构设计思路与接口规范,在此致谢。