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

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

坑一:回调会乱序,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 事件有立即告警和重登逻辑
相关推荐
承渊政道2 小时前
飞牛NAS部署Hermes AI Agent:接入DeepSeek、微信与公网访问完整教程
人工智能·微信·nas·deepseek·hermes
高晶1 天前
微信小程序实战:iOS 降级查询工具从零开发
微信·程序员
软擎2 天前
花店节日布置怎么留记录?用轻虾导出助手把花材替换、现场尺寸与撤场清点钉在一起
微信
jaysee-sjc2 天前
【苍穹外卖】Day12:工作台数据看板与 Excel 报表导出
java·开发语言·数据库·spring boot·spring·微信·excel
极客互动API3 天前
【极客互动】企业微信外部群聊机器人开发实战与效果验证
微信·机器人·企业微信·企微scrm·企业微信api·企微外部联系人
特视界说4 天前
多规格产品动画怎么规划?把“一个型号一条片”改成可管理的内容架构
人工智能·安全·微信·新浪微博
悟空码字5 天前
视频号直播变了
微信·视频号
随性而行3606 天前
企业微信 Webhook 接收不到消息?网络连通性、URL 签名验签与 API 返回错误码排查指南
java·网络·人工智能·后端·微信·企业微信
未秃头的程序猿7 天前
同一个商户号,V2 免密代扣和 V3 分账的证书怎么共存?
java·后端·微信