微信群消息处理:回调乱序、自己发的消息、群内真实发送者

1 阅读7分钟

群成员同步、进群审核之后,自然就是消息处理——回调来了,该发欢迎语发欢迎语,该记日志记日志,该转人工转人工。但这条链路有三个容易被忽略的坑,任何一个没处理好,整个消息处理逻辑都会出问题。

坑一:回调会乱序,NewMsgId 是唯一排序依据

WTAPI开发文档里写得很直白:"因服务重启、同步历史消息、失败重试等原因,同一条消息可能会重复推送",还有一种没明说但实际存在的情况——历史消息可能在当前消息之后才推送过来。

如果你的代码直接按回调到达顺序处理,而不做排序,会出现:

  • 旧消息的处理逻辑覆盖了新消息的状态
  • 撤回消息(MsgType=10002 + revokemsg)先到,被撤回的那条正文后到,结果正文重新被处理了一遍

解法:所有回调先存一个临时队列,按 NewMsgId 排序后再处理。用 Redis 或者数据库临时表都行。

# 接口参考: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 会把手机发的消息也推到你的回调接口。

所以你在回调里收到一条消息,必须判断是不是自己发的,否则会出现无限循环:自己发的欢迎语 → 触发回调 → 又被处理 → 又发欢迎语 → 再触发回调...

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消息内容"。

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 或文本。官方给的判定路径:

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 里找关键字区分被移出/改群名/换群主。这些官方回调速览页里都有,按那个表来就行。

主循环串起来

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 事件有立即告警和重登逻辑