// 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( `%d`, 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( `%s`, 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) }