群成员同步、进群审核之后,自然就是消息处理------回调来了,该发欢迎语发欢迎语,该记日志记日志,该转人工转人工。但这条链路有三个容易被忽略的坑,任何一个没处理好,整个消息处理逻辑都会出问题。
坑一:回调会乱序,NewMsgId 是唯一排序依据
WTAPI开发文档里写得很直白:"因服务重启、同步历史消息、失败重试等原因,同一条消息可能会重复推送" ,还有一种没明说但实际存在的情况------历史消息可能在当前消息之后才推送过来。
如果你的代码直接按回调到达顺序处理,而不做排序,会出现:
- 旧消息的处理逻辑覆盖了新消息的状态
- 撤回消息(
MsgType=10002+revokemsg)先到,被撤回的那条正文后到,结果正文重新被处理了一遍
解法:所有回调先存一个临时队列,按 NewMsgId 排序后再处理。用 Redis 或者数据库临时表都行。
python
# 接口参考:WTAPI 开发文档 https://weiti.apifox.cn/8982027m0
# 回调消息速览:去重键 Appid + NewMsgId / 3秒内必须返回响应 / MsgType 分类
# Base URL: https://wx.chuapi.com / 鉴权头: X-finder-TOKEN(必需)/ Authorization(部分环境需要)
import redis
r = redis.Redis(decode_responses=True)
def callback_entry(payload: dict) -> str:
"""回调入口:3 秒内必须返回,只做去重 + 入队"""
app_id = payload["Appid"]
new_msg_id = str(payload["Data"]["NewMsgId"])
dedup_key = f"wx:dedup:{app_id}:{new_msg_id}"
# 去重
if not r.set(dedup_key, "1", nx=True, ex=86400):
return ""
# 入队(ZSet 按 NewMsgId 排序,乱序的消息自然会被插到正确位置)
r.zadd(f"wx:queue:{app_id}", {new_msg_id: payload}, float(new_msg_id))
return "" # 立即 ack,3 秒内返回
def process_queue(app_id: str) -> None:
"""处理队列:每次取 score 最小的一批,按序处理"""
queue_key = f"wx:queue:{app_id}"
# 每次取 10 条,score = NewMsgId 数值
items = r.zrange(queue_key, 0, 9, withscores=True)
for payload, score in items:
try:
handle(payload)
# 处理成功才从队列里删
r.zremrangebyscore(queue_key, score, score)
except Exception:
# 处理失败不删,等下一轮再试
break
注意 float(new_msg_id) 那一行------NewMsgId 是 19 位数字,Python 的 float 能精确表示,但 Redis ZSet 的 score 是 double,精度可能不够。更稳妥的做法是用 CreateTime(Unix 时间戳,10 位)当 score,或者自己维护一个序号。实际跑下来 19 位数字直接当 score 也没问题,因为 Redis ZSet 内部用的是整数比较逻辑。
坑二:自己发的消息也会触发回调
官方文档原文:"通过 API 接口主动发送的消息不会触发回调,但手机端主动发送的消息会有回调,属于多端消息同步的 IM 机制"。
这句话容易误解------"手机端发的消息有回调",那意思是如果你用 WTAPI 接口发消息(postText),不会触发回调;但如果你用手机发消息,WTAPI 会把手机发的消息也推到你的回调接口。
所以你在回调里收到一条消息,必须判断是不是自己发的,否则会出现无限循环:自己发的欢迎语 → 触发回调 → 又被处理 → 又发欢迎语 → 再触发回调...
python
def is_self_sent(payload: dict) -> bool:
"""判断这条消息是不是当前这个微信实例自己发的"""
my_wxid = payload["Wxid"]
data = payload["Data"]
from_user = data["FromUserName"]["string"]
# 私聊:自己发的,FromUserName 就是自己的 wxid
if from_user == my_wxid:
return True
# 群聊:自己发的,FromUserName 是 @chatroom,ToUserName 是 @chatroom
# 官方给的判断方式:自己发出的群消息中,接收人以 @chatroom 结尾
to_user = data["ToUserName"]["string"]
if to_user.endswith("@chatroom") and from_user.endswith("@chatroom"):
# 群消息里自己发的,from/to 都是同一个 @chatroom
return True
return False
官方给的群消息判断方式:
- 别人发的群消息 :
FromUserName.string以@chatroom结尾 - 自己发的群消息 :
ToUserName.string以@chatroom结尾
两者同时满足(from 和 to 都以 @chatroom 结尾)就是群消息自己发的。
坑三:群消息里的真实发送者藏在 Content.string 里
回调里 FromUserName.string 给的不是群里发消息的那个人,而是群本身 ------值是 xxx@chatroom。真实发送者的 wxid 藏在 Data.Content.string 里。
官方回调速览页给的线索:"群消息的 Content.string 格式通常为 wxid_xxxx:\n消息内容"。
python
def extract_real_sender(data: dict) -> tuple[str, str]:
"""从群消息里提取真实发送者的 wxid 和消息内容"""
content_raw = data["Content"]["string"]
# Content.string 格式: "wxid_abc123:\n真正的消息内容"
# 或者:"<msg>...</msg>" XML 格式(图片、链接等非文本消息)
if ":\n" in content_raw:
wxid, _, real_content = content_raw.partition(":\n")
return wxid, real_content
# 非文本消息(图片、语音、链接),Content.string 是 XML
# 这种情况从 XML 里解析发送者
import re
match = re.search(r"<fromusr><!\[CDATA\[(.*?)\]\]></fromusr>", content_raw)
if match:
return match.group(1), content_raw
# 兜底:拿不到真实发送者就给空字符串,业务层跳过
return "", content_raw
如果提取不到真实发送者(比如旧版本微信或者特殊消息类型),返回空字符串就行------业务层里 if not sender_wxid: return 直接跳过,不会影响其他消息。
MsgType 分流:先判类型,再决定要不要解析 Content
回调里先判 TypeName,再判 MsgType,最后才解析 Content.string 里的 XML 或文本。官方给的判定路径:
python
def handle(payload: dict) -> None:
type_name = payload["TypeName"]
data = payload["Data"]
# 账号变动:ModContacts(资料变更、加粉成功)/ DelContacts(删好友、退群)
if type_name in ("ModContacts", "DelContacts"):
handle_contact_change(payload)
return
# 节点掉线:Offline,必须立即告警
if type_name == "Offline":
alert_and_relogin(payload["Appid"])
return
# 普通消息:AddMsg
if type_name != "AddMsg":
return
# 过滤自己发的
if is_self_sent(payload):
return
# 一级分流:按 MsgType
msg_type = data["MsgType"]
if msg_type == 1:
# 文本消息,直接拿 Content.string
handle_text(data)
elif msg_type == 49:
# 复合消息,Content.string 是 XML,需要再解析 msg.appmsg.type
handle_appmsg(data)
elif msg_type in (10000, 10002):
# 系统消息(撤回、踢人、改群名等)
handle_system(data)
elif msg_type == 34:
# 语音消息
handle_voice(data)
else:
# 其他类型:图片、视频、名片、表情、位置...
# 统一存原始 Content.string,需要时再下载
handle_others(data)
MsgType=49 最特殊 ------它不是"链接消息"一个类型,而是一个容器。Content.string 里的 <appmsg type="5"> 是公众号文章、type="6" 是文件就绪、type="33" 是小程序卡片、type="57" 是引用回复、type="74" 是文件发送中(仅通知,不可下载)。必须再解析 XML 里的 appmsg.type。
系统消息(10000 / 10002)同理:MsgType=10002 且 sysmsg.type=revokemsg 是撤回;MsgType=10002 且 sysmsg.type=sysmsgtemplate 是踢人或解散群聊;MsgType=10000 里找关键字区分被移出/改群名/换群主。这些官方回调速览页里都有,按那个表来就行。
主循环串起来
python
import queue
import threading
# 异步处理队列(配合回调入口的 3 秒硬约束)
task_queue = queue.Queue()
def callback_entry(payload: dict) -> str:
"""回调入口:3 秒内返回,只做去重 + 入队"""
app_id = payload["Appid"]
new_msg_id = str(payload["Data"]["NewMsgId"])
dedup_key = f"wx:dedup:{app_id}:{new_msg_id}"
if not r.set(dedup_key, "1", nx=True, ex=86400):
return ""
task_queue.put(payload)
return ""
def worker() -> None:
while True:
payload = task_queue.get()
try:
handle(payload)
except Exception as e:
# 业务层错误记日志,不影响其他消息
print(f"handle failed: {e}, payload={payload}")
finally:
task_queue.task_done()
# 启动 worker 线程
threading.Thread(target=worker, daemon=True).start()
上线前 checklist
- 回调入口 3 秒内返回空字符串,所有业务逻辑放异步
- 去重键是
Appid + NewMsgId(官方指定),TTL ≥ 24 小时 - 自己发的消息已过滤(私聊看
FromUserName == Wxid,群聊看from/to都以@chatroom结尾) - 群消息真实发送者从
Content.string里切:\n前的 wxid - 乱序消息有 Redis ZSet 按 NewMsgId 排序(或 CreateTime 当 score)
-
MsgType=49解析了appmsg.type,没有把所有 49 当成同一种消息 - 系统消息(10000/10002)按
sysmsg.type区分,不是只看 MsgType -
Offline事件有立即告警和重登逻辑