公众号服务器配置与消息加解密实战

admin 微信QQ技巧 2026-10-31 2 0

公众号后台那个「服务器配置」的表单,十个人里有八个第一次都保存不成功,提示 Token 验证失败。你翻日志,发现微信的 GET 请求确实打进来了,代码也回了 echostr,但就是过不去。原因往往不在框架,而在三个地方:signature 的参与字段与拼接顺序、响应体里混进了多余输出、以及 echostr 没有被逐字节原样返回。

接入那一步,微信到底验了什么

你在后台点保存时,微信会向你的 URL 发一个 GET 请求,带四个参数:signature、timestamp、nonce、echostr。你要自己算一遍 signature,和它带来的一致,就把 echostr 原样返回;不一致就返回别的内容,微信认为失败。这段逻辑只在保存配置(以及后台偶尔的复检)时走一次,所以很多人配置成功之后就再没被测过,等换了服务器、换了框架才发现代码早就坏了。

容易翻车的细节列一下:

  • echostr 要原样输出,不包引号、不套 JSON、不做任何格式化。
  • 响应前后不能混进调试输出、BOM、多余的换行。
  • 全局中间件可能给响应加 charset 或压缩,确认最终字节与 echostr 完全一致。
  • 不要写「校验失败也返回 echostr」的兜底,那等于把校验关掉。
  • URL 必须能从公网访问、80 或 443 端口、不能带 query 参数。

后台四项配置分别是什么

四项各管一件事,别把它们混为一谈。

URL

你服务器上接收微信推送的入口地址,只能是完整的 http 或 https 地址,不能带端口号之外的路径参数。这个地址同时承担两件事:接入校验时收 GET,日常运行时收 POST。

Token

由你自己填写的任意字符串,用来参与签名计算,相当于你和微信之间的共享密钥。它不参与消息加密,只用于验证请求确实来自微信。换了 Token 就要同步改代码,改完立刻保存。

EncodingAESKey

43 位由大小写字母和数字组成的字符串,Base64 解码后正好 32 字节,就是 AES-256-CBC 的密钥。明文模式下它不生效,一旦切到安全模式,它就是解密和加密的唯一钥匙。丢了这个值只能重新生成,意味着历史密文全部不可解。

消息加解密方式

明文、兼容、安全三选一。这一项决定了你的代码要处理什么格式的报文,也决定了你在灰度期间要不要同时兼容两种格式。

接入校验的签名算法

算法本身很短:把 token、timestamp、nonce 三个值按字典序排序,直接拼成一个字符串,做 SHA1,取小写十六进制。

sorted = sorted([token, timestamp, nonce])
s = ''.join(sorted)
signature = sha1(s).hexdigest()   # 小写
# 与请求参数里的 signature 做恒等比较

注意三点:排序是字典序,不是按长度或参数出现顺序;拼接时不加任何分隔符;timestamp 与 nonce 取自请求参数,不要自己生成。

明文模式和安全模式怎么选

明文模式下,消息体以明文 XML 推送,安全模式下整段业务数据被加密成一个 Encrypt 字段。中间还有一个兼容模式,入站明文、出站可以加密,专为灰度切换设计。

对比项明文模式兼容模式安全模式
入站报文明文 XML明文 XML密文 Encrypt 字段
出站报文明文 XML明文或密文均可必须是密文
代码改动量无需支持解密需支持解密与加密
报文可读性抓包即见部分可读不可读
适用阶段本地联调、纯内网工具切换过渡期线上正式环境

取舍很简单:服务器在公网、报文含手机号或用户标识,就上安全模式。明文报文在链路上可读,任何一环的日志都会把它原样记下来。

安全模式解密:四段结构与 PKCS#7 去填充

解密这一步只要理解了明文的切片结构,就不会写错。密文放在报文外层 XML 的 Encrypt 字段里,是个 Base64 串,处理顺序如下。

  1. 取出 Encrypt 字段,做 Base64 解码,得到原始密文字节。
  2. 取 EncodingAESKey 拼上一个等号后做 Base64 解码,得到 32 字节的 Key。
  3. 以 Key 前 16 字节作为 IV,用 AES-256-CBC 解密,得到带填充的明文。
  4. 按 PKCS#7 规则去掉尾部填充:读最后一个字节的值 n,删掉末尾 n 个字节。
  5. 跳到第 16 字节处,读 4 个字节的网络字节序整数,得到消息真实长度 L。
  6. 取紧随其后的 L 个字节,就是原始 XML 消息。
  7. 剩下的尾部字节应当等于你的 appid,比对不通过就丢弃整条消息。
key    = base64decode(encoding_aes_key + "=")      # 32 字节
iv     = key[0:16]
plain  = aes_256_cbc_decrypt(cipher, key, iv)
plain  = pkcs7_unpad(plain, block=32)
msg_len = big_endian_uint32(plain[16:20])
msg     = plain[20:20+msg_len]
appid   = plain[20+msg_len:]                       # 必须等于自己的 appid

三处容易错:IV 用的是 Key 的前 16 字节,不是随机值;填充块大小是 32,不是 AES 默认的 16;appid 校验最容易被省略,省略之后,一段用别的公众号密钥加密、结构完全合法的报文也会被当成有效消息,而这一步拦的正是这种混淆。

回复消息的加密与 XML 结构

要回一句被动消息,加密过程是解密的逆运算:拼 16 字节随机串、拼 4 字节消息长度、拼消息本体、拼 appid,整体按 32 字节块做 PKCS#7 填充,AES-256-CBC 加密后 Base64 编码,得到 Encrypt。

<xml>
  <Encrypt><![CDATA[Base64后的密文]]></Encrypt>
  <MsgSignature><![CDATA[签名]]></MsgSignature>
  <TimeStamp>当前秒级时间戳</TimeStamp>
  <Nonce><![CDATA[随机串]]></Nonce>
</xml>

MsgSignature 的算法和接入校验同一套思路,只是参与排序的值从三个变成四个:token、timestamp、nonce,再加上刚算出来的 Encrypt,四个值按字典序排序后拼接做 SHA1。这里最常见的错误是只排了三个值忘了 Encrypt,或者顺序取反了。XML 里一律用 CDATA 包住,否则密文里的特殊字符会破坏解析。

五秒超时、三次重试与幂等

微信推送消息时只等五秒,超时后会断掉连接并重试,最多重试三次。这意味着三件事:你的处理逻辑必须快进快出,耗时任务丢队列;你的处理必须幂等,同一条消息被处理多次只能是同一个结果;超时后你回的任何内容都无效,因为连接已经关了。

幂等的落点在最稳的位置上做:用消息里的 MsgId 作为唯一键,进入业务前先做一次「插入或短路」的原子操作。不要用「先查后插」,并发下必然穿透。

  • 先写唯一键记录,写成功才继续处理业务。
  • 写失败说明已处理过,直接返回成功应答。
  • 业务处理失败要区分可重试与不可重试,可重试的交给自己的重试队列,不要让微信替你做。
  • 日志里记录 MsgId、FromUserName、Event 三个字段,排查时靠它们串联。

被动回复与客服消息的分工

被动回复是「在同一个 HTTP 响应里回包」,用户看到的是你即时回复的那一条,时效受五秒限制;客服消息是主动调接口下发,不受这次请求的时间窗口约束,但有用户最近互动时间与发送量的约束。选型按这条线切:五秒内能算完的、只回一句提示语的走被动回复;要查库、走第三方接口、或者要等几秒之后才发的,先回「收到」,再异步用客服消息补发。

实践上把「解析、解密、验签、幂等」与「业务处理」拆成两层,前者是纯技术管道,团队里只该有一份实现。否则每加一个功能都要重新踩一遍解密的坑。