Files
2026-10-31 19:17:37 +08:00

439 lines
14 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package wechatmini 微信小程序「消息推送」服务端能力。
//
// 对应小程序后台:开发管理 → 开发设置 → 消息推送。
// 后台需填写 URL / Token / EncodingAESKey / 加解密方式 / 数据格式,
// 微信会在保存时向 URL 发一次 GET 校验(signature + echostr),
// 保存成功后,用户向小程序客服发消息等事件会以 POST 推送到该 URL。
//
// 协议与公众号「服务器配置」一致(signature = sha1(sort(token,timestamp,nonce))),
// 复用 services/wechatmp 的签名与 AES 加解密原语,本文件补齐小程序侧
// 的多应用路由、JSON 数据格式与被动回复构造。
package wechatmini
import (
"crypto/rand"
"encoding/json"
"encoding/xml"
"errors"
"fmt"
"math/big"
"strings"
"time"
"server/models"
"server/services/wechatmp"
"github.com/beego/beego/v2/client/orm"
beego "github.com/beego/beego/v2/server/web"
)
// 消息数据格式(小程序后台「数据格式」)
const (
DataFormatXML = "xml"
DataFormatJSON = "json"
)
// PushConfig 小程序消息推送运行配置(敏感字段已解密)
type PushConfig struct {
AppID string
Token string
AESKey string // 明文(43 位 EncodingAESKey)
EncryptMode string // plain / compatible / safe
DataFormat string // xml / json
ReplyText string // 客服消息自动回复文案(空 = 不自动回复)
}
// ErrPushNotConfigured 未配置消息推送
var ErrPushNotConfigured = errors.New("小程序消息推送未配置")
// pushConfigFromRow 读取行数据并解密 AESKey
func pushConfigFromRow(row models.WechatMiniApp) (*PushConfig, error) {
cfg := &PushConfig{
AppID: strings.TrimSpace(row.AppID),
Token: strings.TrimSpace(row.Token),
EncryptMode: strings.TrimSpace(row.EncryptMode),
DataFormat: strings.ToLower(strings.TrimSpace(row.DataFormat)),
ReplyText: strings.TrimSpace(row.PushReply),
}
if cfg.EncryptMode == "" {
cfg.EncryptMode = wechatmp.EncryptModePlain
}
if cfg.DataFormat == "" {
cfg.DataFormat = DataFormatXML
}
if row.AESKey != "" {
plain, err := wechatmp.DecryptSecret(row.AESKey)
if err != nil {
return nil, err
}
cfg.AESKey = plain
}
return cfg, nil
}
// LoadPushConfigs 读取全部已配置 Token 的小程序推送配置(未删除的记录)。
// 微信回调不携带 appid(明文模式下 ToUserName 即 appid),故按应用逐个匹配签名。
func LoadPushConfigs() ([]PushConfig, error) {
var rows []models.WechatMiniApp
_, err := models.Orm.QueryTable(new(models.WechatMiniApp)).
Filter("delete_time__isnull", true).
OrderBy("id").
All(&rows)
if err != nil {
return nil, err
}
list := make([]PushConfig, 0, len(rows))
for _, r := range rows {
if strings.TrimSpace(r.Token) == "" {
continue // 未配置 Token 的应用不参与签名匹配
}
cfg, cerr := pushConfigFromRow(r)
if cerr != nil {
continue // 解密失败的应用跳过,避免阻塞其它小程序
}
list = append(list, *cfg)
}
return list, nil
}
// LoadPushConfigByAppID 按 appid 读取单个小程序的推送配置
func LoadPushConfigByAppID(appID string) (*PushConfig, error) {
appID = strings.TrimSpace(appID)
if appID == "" {
return nil, ErrPushNotConfigured
}
var row models.WechatMiniApp
err := models.Orm.QueryTable(new(models.WechatMiniApp)).
Filter("app_id", appID).
Filter("delete_time__isnull", true).
One(&row)
if err != nil {
if err == orm.ErrNoRows {
return nil, ErrPushNotConfigured
}
return nil, err
}
return pushConfigFromRow(row)
}
// SavePushConfigInput 保存推送配置入参(敏感字段支持掩码表示不修改)
type SavePushConfigInput struct {
AppID string
Token string
AESKey string // 掩码 / 空串 = 保持原值
EncryptMode string
DataFormat string
ReplyText string
}
// SavePushConfig 保存小程序消息推送配置
func SavePushConfig(in SavePushConfigInput) error {
appID := strings.TrimSpace(in.AppID)
if appID == "" {
return errors.New("AppID 不能为空")
}
mode := strings.TrimSpace(in.EncryptMode)
switch mode {
case wechatmp.EncryptModePlain, wechatmp.EncryptModeCompatible, wechatmp.EncryptModeSafe:
case "":
mode = wechatmp.EncryptModePlain
default:
return errors.New("加解密方式无效(plain/compatible/safe)")
}
format := strings.ToLower(strings.TrimSpace(in.DataFormat))
switch format {
case DataFormatXML, DataFormatJSON:
case "":
format = DataFormatXML
default:
return errors.New("数据格式无效(xml/json)")
}
token := strings.TrimSpace(in.Token)
if token != "" && (len(token) < 3 || len(token) > 32) {
return errors.New("Token 长度需为 3-32 个字符")
}
var row models.WechatMiniApp
err := models.Orm.QueryTable(new(models.WechatMiniApp)).
Filter("app_id", appID).
Filter("delete_time__isnull", true).
One(&row)
if err != nil {
if err == orm.ErrNoRows {
return errors.New("小程序不存在或已删除")
}
return err
}
aesKey := strings.TrimSpace(in.AESKey)
if aesKey != "" && !wechatmp.IsMasked(aesKey) {
if len(aesKey) != 43 {
return errors.New("EncodingAESKey 长度必须为 43 位")
}
enc, eerr := wechatmp.EncryptSecret(aesKey)
if eerr != nil {
return eerr
}
row.AESKey = enc
}
if token != "" {
row.Token = token
}
row.EncryptMode = mode
row.DataFormat = format
row.PushReply = strings.TrimSpace(in.ReplyText)
now := time.Now()
row.UpdateTime = &now
_, err = models.Orm.Update(&row)
return err
}
// PushCallbackURL 小程序消息推送回调地址(填到小程序后台「消息推送 - URL」)。
// 优先读取 app.conf 的 wechat_mini_callback_base,其次回落 wechat_mp_callback_base
// / payment_callback_base(均为对外 API 域名)。
func PushCallbackURL() string {
base, _ := beego.AppConfig.String("wechat_mini_callback_base")
if strings.TrimSpace(base) == "" {
base, _ = beego.AppConfig.String("wechat_mp_callback_base")
}
if strings.TrimSpace(base) == "" {
base, _ = beego.AppConfig.String("payment_callback_base")
}
base = strings.TrimRight(strings.TrimSpace(base), "/")
if base == "" {
return "/api/wechat/mini/callback"
}
return base + "/api/wechat/mini/callback"
}
// ============================ 随机 Token / EncodingAESKey ============================
const aesKeyAlphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
// randomString 从指定字符集随机取 n 个字符(crypto/rand 源)
func randomString(alphabet string, n int) string {
b := []byte(alphabet)
out := make([]byte, n)
max := big.NewInt(int64(len(b)))
for i := range out {
idx, err := rand.Int(rand.Reader, max)
if err != nil {
// 随机源异常时退化为时间派生,保证不返回空串
out[i] = b[(time.Now().UnixNano()+int64(i))%int64(len(b))]
continue
}
out[i] = b[idx.Int64()]
}
return string(out)
}
// GeneratePushToken 生成消息推送 Token(3-32 位字母数字)
func GeneratePushToken() string {
return randomString(aesKeyAlphabet, 24)
}
// GeneratePushAESKey 生成消息推送 EncodingAESKey(43 位字母数字)
// 43 位 base64 字符解码后为 32 字节,符合微信 AES-256-CBC 的密钥长度要求。
func GeneratePushAESKey() string {
return randomString(aesKeyAlphabet, 43)
}
// ============================ 消息解析 ============================
// PushMessage 小程序推送的消息 / 事件(已解密)
type PushMessage struct {
ToUserName string
FromUserName string
CreateTime int64
MsgType string // text / image / event ...
Content string
Event string // subscribe / unsubscribe / SCAN / CLICK ...
EventKey string
MsgID int64
Encrypt string // 兼容/安全模式外层携带的密文
MsgSignature string
TimeStamp string
Nonce string
}
// IsEvent 是否为事件推送
func (m *PushMessage) IsEvent() bool { return strings.EqualFold(m.MsgType, "event") }
// pushXML 微信 XML 推送体
type pushXML struct {
XMLName struct{} `xml:"xml"`
ToUserName string `xml:"ToUserName"`
FromUserName string `xml:"FromUserName"`
CreateTime int64 `xml:"CreateTime"`
MsgType string `xml:"MsgType"`
Content string `xml:"Content"`
Event string `xml:"Event"`
EventKey string `xml:"EventKey"`
MsgID int64 `xml:"MsgId"`
Encrypt string `xml:"Encrypt"`
MsgSignature string `xml:"MsgSignature"`
TimeStamp string `xml:"TimeStamp"`
Nonce string `xml:"Nonce"`
}
// pushJSON 微信 JSON 推送体
type pushJSON struct {
ToUserName string `json:"ToUserName"`
FromUserName string `json:"FromUserName"`
CreateTime int64 `json:"CreateTime"`
MsgType string `json:"MsgType"`
Content string `json:"Content"`
Event string `json:"Event"`
EventKey string `json:"EventKey"`
MsgID int64 `json:"MsgId"`
Encrypt string `json:"Encrypt"`
MsgSignature string `json:"MsgSignature"`
TimeStamp string `json:"TimeStamp"`
Nonce string `json:"Nonce"`
}
// ParsePushXML 解析 XML 推送体
func ParsePushXML(raw string) (*PushMessage, error) {
var p pushXML
if err := xml.Unmarshal([]byte(raw), &p); err != nil {
return nil, fmt.Errorf("解析微信推送消息失败: %w", err)
}
return &PushMessage{
ToUserName: p.ToUserName,
FromUserName: p.FromUserName,
CreateTime: p.CreateTime,
MsgType: p.MsgType,
Content: p.Content,
Event: p.Event,
EventKey: p.EventKey,
MsgID: p.MsgID,
Encrypt: p.Encrypt,
MsgSignature: p.MsgSignature,
TimeStamp: p.TimeStamp,
Nonce: p.Nonce,
}, nil
}
// ParsePushJSON 解析 JSON 推送体
func ParsePushJSON(raw string) (*PushMessage, error) {
var p pushJSON
if err := json.Unmarshal([]byte(raw), &p); err != nil {
return nil, fmt.Errorf("解析微信推送消息失败: %w", err)
}
return &PushMessage{
ToUserName: p.ToUserName,
FromUserName: p.FromUserName,
CreateTime: p.CreateTime,
MsgType: p.MsgType,
Content: p.Content,
Event: p.Event,
EventKey: p.EventKey,
MsgID: p.MsgID,
Encrypt: p.Encrypt,
MsgSignature: p.MsgSignature,
TimeStamp: p.TimeStamp,
Nonce: p.Nonce,
}, nil
}
// DetectPushFormat 根据请求体判断数据格式(JSON 以 { 开头)
func DetectPushFormat(raw string) string {
trimmed := strings.TrimSpace(raw)
if strings.HasPrefix(trimmed, "{") {
return DataFormatJSON
}
return DataFormatXML
}
// ============================ 被动回复构造 ============================
// BuildTextReply 构造文本被动回复(按数据格式输出 XML / JSON)
func BuildTextReply(format, toUser, fromUser, content string, timestamp int64) (string, error) {
if format == DataFormatJSON {
payload := map[string]interface{}{
"ToUserName": toUser,
"FromUserName": fromUser,
"CreateTime": timestamp,
"MsgType": "text",
"Content": content,
}
bs, err := json.Marshal(payload)
if err != nil {
return "", err
}
return string(bs), nil
}
esc := strings.NewReplacer("]]>", "]] >")
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.Replace(toUser), esc.Replace(fromUser), timestamp, esc.Replace(content)), nil
}
// BuildEncryptedReply 构造加密模式的被动回复(安全模式 / 兼容模式共用)
func BuildEncryptedReply(cfg *PushConfig, plainReply, timestamp, nonce, format string) (string, error) {
encrypt, err := wechatmp.EncryptMessage(cfg.AESKey, cfg.AppID, plainReply)
if err != nil {
return "", err
}
sig := wechatmp.MsgSignature(cfg.Token, timestamp, nonce, encrypt)
if format == DataFormatJSON {
payload := map[string]interface{}{
"Encrypt": encrypt,
"MsgSignature": sig,
"TimeStamp": timestamp,
"Nonce": nonce,
}
bs, merr := json.Marshal(payload)
if merr != nil {
return "", merr
}
return string(bs), nil
}
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
}
// ============================ 多应用匹配 ============================
// MatchPushConfigBySignature 按明文签名匹配小程序(sha1(sort(token,timestamp,nonce)))
func MatchPushConfigBySignature(cfgs []PushConfig, timestamp, nonce, signature string) (*PushConfig, bool) {
for i := range cfgs {
if wechatmp.CheckSignature(cfgs[i].Token, timestamp, nonce, signature) {
return &cfgs[i], true
}
}
return nil, false
}
// MatchPushConfigByMsgSignature 按加密消息签名匹配小程序
// (sha1(sort(token,timestamp,nonce,encrypt)))
func MatchPushConfigByMsgSignature(cfgs []PushConfig, timestamp, nonce, encrypt, msgSignature string) (*PushConfig, bool) {
for i := range cfgs {
if wechatmp.CheckMsgSignature(cfgs[i].Token, timestamp, nonce, encrypt, msgSignature) {
return &cfgs[i], true
}
}
return nil, false
}
// DecryptPushMessage 用匹配的 AESKey 解密消息体(安全 / 兼容模式)
func DecryptPushMessage(cfg *PushConfig, encrypt string) (string, error) {
return wechatmp.DecryptMessage(cfg.AESKey, cfg.AppID, encrypt)
}
// PushHandler 消息处理钩子:返回给用户的被动回复文案,空串表示不回复。
// 默认按小程序配置的「客服自动回复文案」回复,可在业务层覆盖(如按关键字回复)。
var PushHandler func(cfg *PushConfig, msg *PushMessage) string
// HandlePushMessage 处理推送消息并返回被动回复文案
func HandlePushMessage(cfg *PushConfig, msg *PushMessage) string {
if PushHandler != nil {
return strings.TrimSpace(PushHandler(cfg, msg))
}
if cfg == nil {
return ""
}
return strings.TrimSpace(cfg.ReplyText)
}