增加微信通知功能

This commit is contained in:
2026-09-16 18:06:18 +08:00
parent b48688cbab
commit f386899625
23 changed files with 3539 additions and 18 deletions
+214
View File
@@ -0,0 +1,214 @@
package wechatmp
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha1"
"encoding/base64"
"encoding/binary"
"encoding/hex"
"encoding/xml"
"errors"
"fmt"
"io"
"sort"
"strings"
)
// =============================================================
// 微信被动消息:签名校验 / 安全模式 AES 加解密 / XML 解析与回复构造
// 规范参考:https://developers.weixin.qq.com/doc/offiaccount/Message_Management/Message_encryption_and_decryption_instructions.html
// =============================================================
// pkcs7BlockSize 微信官方填充块大小(注意不是 16,是 32)
const pkcs7BlockSize = 32
// InboundMessage 接收到的消息/事件(已解密后的 XML 内容)
type InboundMessage struct {
XMLName xml.Name `xml:"xml"`
ToUserName string `xml:"ToUserName"`
FromUserName string `xml:"FromUserName"`
CreateTime int64 `xml:"CreateTime"`
MsgType string `xml:"MsgType"` // text / image / event ...
Content string `xml:"Content"`
Event string `xml:"Event"` // subscribe / unsubscribe / SCAN / CLICK ...
EventKey string `xml:"EventKey"` // qrscene_<scene> 或 <scene>
Ticket string `xml:"Ticket"`
MsgID int64 `xml:"MsgId"`
Encrypt string `xml:"Encrypt"` // 安全模式下外层 XML 携带的密文
}
// ParseInboundXML 解析(明文/已解密的)消息 XML
func ParseInboundXML(raw string) (*InboundMessage, error) {
var msg InboundMessage
if err := xml.Unmarshal([]byte(raw), &msg); err != nil {
return nil, fmt.Errorf("解析微信消息失败: %w", err)
}
return &msg, nil
}
// CheckSignature 明文模式签名校验:sha1(sort(token, timestamp, nonce))
func CheckSignature(token, timestamp, nonce, signature string) bool {
if token == "" || signature == "" {
return false
}
arr := []string{token, timestamp, nonce}
sort.Strings(arr)
sum := sha1.Sum([]byte(strings.Join(arr, "")))
return hex.EncodeToString(sum[:]) == signature
}
// CheckMsgSignature 安全/兼容模式签名校验:sha1(sort(token, timestamp, nonce, encrypt))
func CheckMsgSignature(token, timestamp, nonce, encrypt, msgSignature string) bool {
if token == "" || msgSignature == "" {
return false
}
arr := []string{token, timestamp, nonce, encrypt}
sort.Strings(arr)
sum := sha1.Sum([]byte(strings.Join(arr, "")))
return hex.EncodeToString(sum[:]) == msgSignature
}
// MsgSignature 计算安全模式消息签名(回复加密时使用)
func MsgSignature(token, timestamp, nonce, encrypt string) string {
arr := []string{token, timestamp, nonce, encrypt}
sort.Strings(arr)
sum := sha1.Sum([]byte(strings.Join(arr, "")))
return hex.EncodeToString(sum[:])
}
// ============================ AES 加解密 ============================
func decodeAESKey(encodingAESKey string) ([]byte, error) {
key := strings.TrimSpace(encodingAESKey)
if len(key) != 43 {
return nil, fmt.Errorf("EncodingAESKey 长度应为 43 位(当前 %d)", len(key))
}
return base64.StdEncoding.DecodeString(key + "=")
}
// DecryptMessage 安全模式消息解密,返回明文 XML;appID 非空时会校验一致性
func DecryptMessage(aesKey, appID, encryptBase64 string) (string, error) {
key, err := decodeAESKey(aesKey)
if err != nil {
return "", err
}
raw, err := base64.StdEncoding.DecodeString(encryptBase64)
if err != nil {
return "", errors.New("密文不是有效的 base64")
}
block, err := aes.NewCipher(key)
if err != nil {
return "", err
}
if len(raw) < aes.BlockSize || len(raw)%aes.BlockSize != 0 {
return "", errors.New("密文长度不合法")
}
iv := key[:aes.BlockSize]
plain := make([]byte, len(raw))
cipher.NewCBCDecrypter(block, iv).CryptBlocks(plain, raw)
plain, err = pkcs7Unpad(plain)
if err != nil {
return "", err
}
if len(plain) < 20 {
return "", errors.New("解密内容长度不合法")
}
msgLen := int(binary.BigEndian.Uint32(plain[16:20]))
if msgLen < 0 || 20+msgLen > len(plain) {
return "", errors.New("解密内容长度不合法")
}
msg := string(plain[20 : 20+msgLen])
gotAppID := strings.TrimSpace(string(plain[20+msgLen:]))
if appID != "" && gotAppID != appID {
return "", fmt.Errorf("AppID 校验失败(收到 %s)", gotAppID)
}
return msg, nil
}
// EncryptMessage 安全模式消息加密,返回 base64 密文
func EncryptMessage(aesKey, appID, msg string) (string, error) {
key, err := decodeAESKey(aesKey)
if err != nil {
return "", err
}
random := make([]byte, 16)
if _, err := io.ReadFull(rand.Reader, random); err != nil {
return "", err
}
msgBytes := []byte(msg)
lenBytes := make([]byte, 4)
binary.BigEndian.PutUint32(lenBytes, uint32(len(msgBytes)))
buf := make([]byte, 0, 16+4+len(msgBytes)+len(appID))
buf = append(buf, random...)
buf = append(buf, lenBytes...)
buf = append(buf, msgBytes...)
buf = append(buf, []byte(appID)...)
buf = pkcs7Pad(buf)
block, err := aes.NewCipher(key)
if err != nil {
return "", err
}
iv := key[:aes.BlockSize]
ciphertext := make([]byte, len(buf))
cipher.NewCBCEncrypter(block, iv).CryptBlocks(ciphertext, buf)
return base64.StdEncoding.EncodeToString(ciphertext), nil
}
func pkcs7Pad(data []byte) []byte {
pad := pkcs7BlockSize - len(data)%pkcs7BlockSize
if pad <= 0 {
pad = pkcs7BlockSize
}
out := make([]byte, 0, len(data)+pad)
out = append(out, data...)
for i := 0; i < pad; i++ {
out = append(out, byte(pad))
}
return out
}
func pkcs7Unpad(data []byte) ([]byte, error) {
if len(data) == 0 {
return nil, errors.New("空数据")
}
pad := int(data[len(data)-1])
if pad < 1 || pad > pkcs7BlockSize || pad > len(data) {
return nil, errors.New("PKCS7 填充不合法")
}
for i := len(data) - pad; i < len(data); i++ {
if int(data[i]) != pad {
return nil, errors.New("PKCS7 填充不合法")
}
}
return data[:len(data)-pad], nil
}
// ============================ 被动回复构造 ============================
// BuildTextReply 构造文本消息回复 XML(明文模式直接返回)
func BuildTextReply(toUser, fromUser, content string, timestamp int64) string {
esc := func(s string) string {
return strings.NewReplacer(
"]]>", "]] >",
).Replace(s)
}
return fmt.Sprintf(
`<xml><ToUserName><![CDATA[%s]]></ToUserName><FromUserName><![CDATA[%s]]></FromUserName><CreateTime>%d</CreateTime><MsgType><![CDATA[text]]></MsgType><Content><![CDATA[%s]]></Content></xml>`,
esc(toUser), esc(fromUser), timestamp, esc(content))
}
// BuildEncryptedReply 安全模式回复:对明文回复加密并组装外层 XML
func BuildEncryptedReply(cfg *Config, plainReply, timestamp, nonce string) (string, error) {
encrypt, err := EncryptMessage(cfg.AESKey, cfg.AppID, plainReply)
if err != nil {
return "", err
}
sig := MsgSignature(cfg.Token, timestamp, nonce, encrypt)
return fmt.Sprintf(
`<xml><Encrypt><![CDATA[%s]]></Encrypt><MsgSignature><![CDATA[%s]]></MsgSignature><TimeStamp>%s</TimeStamp><Nonce><![CDATA[%s]]></Nonce></xml>`,
encrypt, sig, timestamp, nonce), nil
}