bugfix:支付

This commit is contained in:
2026-09-14 02:39:00 +08:00
parent d87f904e33
commit 714d8ff396
11 changed files with 699 additions and 42 deletions
+217
View File
@@ -0,0 +1,217 @@
package wechat
import (
"bytes"
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"encoding/binary"
"encoding/json"
"errors"
"fmt"
"strconv"
"time"
)
// 微信消息推送「安全模式」加解密,与公众号/服务号的消息加解密协议一致:
//
// AESKey = base64.StdEncoding.Decode(EncodingAESKey + "=") // 32 字节
// IV = AESKey[:16](CBC 模式)
// 填充 = PKCS#7(块大小 32)
// 明文结构 = random(16B) + msgLen(4B, 大端) + msg + appid
// 接收签名 = sha1(sort(token, timestamp, nonce, Encrypt)) -> URL 参数 msg_signature
// 回包签名 = sha1(sort(token, TimeStamp, Nonce, Encrypt)) -> 回包字段 MsgSignature
//
// 安全模式下微信推送的包体只含密文(JSON 信封 {"Encrypt":"..."}),开发者的回包也必须为密文;
// 未配置 EncodingAESKey 时按明文模式处理(包体即业务明文,回包为明文应答)。
const (
// wxPKCS7BlockSize 微信 PKCS#7 填充块大小(固定 32,与 AES 的 16 字节分组兼容)。
wxPKCS7BlockSize = 32
// wxEncodingAESKeyLen EncodingAESKey 固定长度(43 位,base64 补 "=" 后解出 32 字节)。
wxEncodingAESKeyLen = 43
)
// IsEncryptedNotify 判断本次推送是否为安全模式(微信会带 msg_signature 参数)。
func IsEncryptedNotify(msgSignature string) bool {
return msgSignature != ""
}
// HasMsgEncryptKey 是否已配置 EncodingAESKey(安全模式可用)。
func HasMsgEncryptKey() bool {
c, err := getVirtualPayClient()
return err == nil && len(c.aesKey) == 32
}
// DecodeEchoStr 安全模式 URL 配置验证:校验 msg_signature 并解密 echostr,返回明文供原样回显。
// echostr 仅用于验证地址归属,不做 appid 校验(避免因 appid 形态差异导致后台无法保存配置)。
func DecodeEchoStr(echostr, timestamp, nonce, msgSignature string) (string, error) {
c, err := getVirtualPayClient()
if err != nil {
return "", err
}
if !c.checkMsgSignature(timestamp, nonce, echostr, msgSignature) {
return "", errors.New("xpay verify msg_signature mismatch")
}
msg, _, derr := c.decrypt(echostr)
if derr != nil {
return "", fmt.Errorf("decrypt echostr: %w", derr)
}
return msg, nil
}
// DecodeEncryptedNotify 安全模式接收:校验 msg_signature 并解密出明文业务报文。
// 任一环节失败都返回错误,调用方应回失败应答让微信重试(fail-closed)。
func DecodeEncryptedNotify(raw []byte, timestamp, nonce, msgSignature string) ([]byte, error) {
c, err := getVirtualPayClient()
if err != nil {
return nil, err
}
encrypt, err := extractEncryptField(raw)
if err != nil {
return nil, err
}
if !c.checkMsgSignature(timestamp, nonce, encrypt, msgSignature) {
return nil, errors.New("xpay notify msg_signature mismatch")
}
msg, appID, derr := c.decrypt(encrypt)
if derr != nil {
return nil, fmt.Errorf("decrypt xpay notify: %w", derr)
}
// 防伪造:密文内的 appid 必须是本小程序。
if appID != c.appID {
return nil, fmt.Errorf("xpay notify appid mismatch: got %q, want %q", appID, c.appID)
}
return []byte(msg), nil
}
// EncodeEncryptedNotify 安全模式回包:把明文业务应答加密为信封
// {"Encrypt":..,"MsgSignature":..,"TimeStamp":..,"Nonce":..}。
func EncodeEncryptedNotify(plain []byte, nonce string) ([]byte, error) {
c, err := getVirtualPayClient()
if err != nil {
return nil, err
}
encrypt, eerr := c.encrypt(string(plain))
if eerr != nil {
return nil, fmt.Errorf("encrypt xpay reply: %w", eerr)
}
now := time.Now().Unix()
return json.Marshal(map[string]any{
"Encrypt": encrypt,
"MsgSignature": sortedSHA1Hex(c.token, strconv.FormatInt(now, 10), nonce, encrypt),
"TimeStamp": now,
"Nonce": nonce,
})
}
// extractEncryptField 从安全模式信封中取出 Encrypt 字段(只支持 JSON 信封)。
func extractEncryptField(raw []byte) (string, error) {
if trimmed := bytes.TrimLeft(raw, " \t\r\n"); len(trimmed) > 0 && trimmed[0] == '<' {
return "", errors.New("xpay notify envelope is XML but only JSON is supported; " +
"please set 消息推送 → 数据格式 = JSON in MP console")
}
var env struct {
Encrypt string `json:"Encrypt"`
}
if err := json.Unmarshal(raw, &env); err != nil {
return "", fmt.Errorf("parse xpay notify envelope: %w", err)
}
if env.Encrypt == "" {
return "", errors.New("xpay notify envelope missing Encrypt")
}
return env.Encrypt, nil
}
// checkMsgSignature 校验安全模式接收签名:sha1(sort(token, timestamp, nonce, encrypt))。
func (c *virtualPayClient) checkMsgSignature(timestamp, nonce, encrypt, msgSignature string) bool {
if c.token == "" || msgSignature == "" {
return false
}
return sortedSHA1Hex(c.token, timestamp, nonce, encrypt) == msgSignature
}
// decrypt 解密微信密文:AES-256-CBC(IV=AESKey[:16])→ 去 PKCS#7 → 切出 msg 与 appid。
func (c *virtualPayClient) decrypt(encrypt string) (msg, appID string, err error) {
if len(c.aesKey) != 32 {
return "", "", errors.New("virtual pay encoding_aes_key not configured")
}
cipherText, err := base64.StdEncoding.DecodeString(encrypt)
if err != nil {
return "", "", fmt.Errorf("base64 decode cipher text: %w", err)
}
block, err := aes.NewCipher(c.aesKey)
if err != nil {
return "", "", fmt.Errorf("new aes cipher: %w", err)
}
if len(cipherText) == 0 || len(cipherText)%block.BlockSize() != 0 {
return "", "", fmt.Errorf("invalid cipher text length %d", len(cipherText))
}
plain := make([]byte, len(cipherText))
cipher.NewCBCDecrypter(block, c.aesKey[:block.BlockSize()]).CryptBlocks(plain, cipherText)
plain, err = pkcs7Unpad(plain, wxPKCS7BlockSize)
if err != nil {
return "", "", err
}
if len(plain) < 20 {
return "", "", fmt.Errorf("decrypted payload too short: %d bytes", len(plain))
}
msgLen := int(binary.BigEndian.Uint32(plain[16:20]))
if 20+msgLen > len(plain) {
return "", "", fmt.Errorf("invalid msg length %d in decrypted payload %d", msgLen, len(plain))
}
return string(plain[20 : 20+msgLen]), string(plain[20+msgLen:]), nil
}
// encrypt 加密:random(16B) + msgLen(4B, 大端) + msg + appid → PKCS#7 → AES-256-CBC → base64。
func (c *virtualPayClient) encrypt(msg string) (string, error) {
if len(c.aesKey) != 32 {
return "", errors.New("virtual pay encoding_aes_key not configured")
}
random := make([]byte, 16)
if _, err := rand.Read(random); err != nil {
return "", fmt.Errorf("read random bytes: %w", err)
}
var buf bytes.Buffer
buf.Write(random)
msgBytes := []byte(msg)
var lenBuf [4]byte
binary.BigEndian.PutUint32(lenBuf[:], uint32(len(msgBytes)))
buf.Write(lenBuf[:])
buf.Write(msgBytes)
buf.WriteString(c.appID)
plain := pkcs7Pad(buf.Bytes(), wxPKCS7BlockSize)
block, err := aes.NewCipher(c.aesKey)
if err != nil {
return "", fmt.Errorf("new aes cipher: %w", err)
}
out := make([]byte, len(plain))
cipher.NewCBCEncrypter(block, c.aesKey[:block.BlockSize()]).CryptBlocks(out, plain)
return base64.StdEncoding.EncodeToString(out), nil
}
// pkcs7Pad 按微信约定填充到 blockSize 的整数倍(每个填充字节的值即填充长度)。
func pkcs7Pad(data []byte, blockSize int) []byte {
padding := blockSize - len(data)%blockSize
return append(data, bytes.Repeat([]byte{byte(padding)}, padding)...)
}
// pkcs7Unpad 去除 PKCS#7 填充并校验填充合法性。
func pkcs7Unpad(data []byte, blockSize int) ([]byte, error) {
if len(data) == 0 || len(data)%blockSize != 0 {
return nil, fmt.Errorf("invalid padded data length %d", len(data))
}
padding := int(data[len(data)-1])
if padding == 0 || padding > blockSize {
return nil, fmt.Errorf("invalid pkcs7 padding value %d", padding)
}
for _, b := range data[len(data)-padding:] {
if int(b) != padding {
return nil, errors.New("invalid pkcs7 padding bytes")
}
}
return data[:len(data)-padding], nil
}