439 lines
14 KiB
Go
439 lines
14 KiB
Go
// 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)
|
||
}
|