公众号服务器配置与消息加解密实战
公众号后台那个「服务器配置」的表单,十个人里有八个第一次都保存不成功,提示 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 串,处理顺序如下。
- 取出 Encrypt 字段,做 Base64 解码,得到原始密文字节。
- 取 EncodingAESKey 拼上一个等号后做 Base64 解码,得到 32 字节的 Key。
- 以 Key 前 16 字节作为 IV,用 AES-256-CBC 解密,得到带填充的明文。
- 按 PKCS#7 规则去掉尾部填充:读最后一个字节的值 n,删掉末尾 n 个字节。
- 跳到第 16 字节处,读 4 个字节的网络字节序整数,得到消息真实长度 L。
- 取紧随其后的 L 个字节,就是原始 XML 消息。
- 剩下的尾部字节应当等于你的 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 响应里回包」,用户看到的是你即时回复的那一条,时效受五秒限制;客服消息是主动调接口下发,不受这次请求的时间窗口约束,但有用户最近互动时间与发送量的约束。选型按这条线切:五秒内能算完的、只回一句提示语的走被动回复;要查库、走第三方接口、或者要等几秒之后才发的,先回「收到」,再异步用客服消息补发。
实践上把「解析、解密、验签、幂等」与「业务处理」拆成两层,前者是纯技术管道,团队里只该有一份实现。否则每加一个功能都要重新踩一遍解密的坑。






