186 lines
5.8 KiB
Go
186 lines
5.8 KiB
Go
package controllers
|
||
|
||
import (
|
||
"io"
|
||
"strings"
|
||
"time"
|
||
|
||
"server/services/wechatmini"
|
||
"server/services/wechatmp"
|
||
|
||
"github.com/beego/beego/v2/core/logs"
|
||
beego "github.com/beego/beego/v2/server/web"
|
||
)
|
||
|
||
// WechatMiniCallbackController 微信小程序消息推送回调(微信服务器调用,无需平台鉴权)
|
||
//
|
||
// 小程序后台「开发管理 → 开发设置 → 消息推送」填写:
|
||
//
|
||
// URL(服务器地址):https://<你的域名>/api/wechat/mini/callback
|
||
// Token:与平台端「小程序配置 - 消息推送」中的 Token 一致
|
||
// EncodingAESKey:与平台端配置一致(安全 / 兼容模式必填)
|
||
// 消息加解密方式:明文 / 兼容 / 安全(三种均支持)
|
||
// 数据格式:XML / JSON(均支持,按平台端配置输出)
|
||
//
|
||
// 平台端维护入口:系统设置 → 小程序配置 → 编辑小程序 → 消息推送
|
||
type WechatMiniCallbackController struct {
|
||
beego.Controller
|
||
}
|
||
|
||
// Callback 服务器地址验证(GET,回显 echostr)与消息 / 事件推送(POST,被动回复)
|
||
func (c *WechatMiniCallbackController) Callback() {
|
||
cfgs, err := wechatmini.LoadPushConfigs()
|
||
if err != nil || len(cfgs) == 0 {
|
||
c.Ctx.Output.SetStatus(403)
|
||
c.Ctx.Output.Body([]byte("wechat mini push not configured"))
|
||
return
|
||
}
|
||
|
||
q := c.Ctx.Request.URL.Query()
|
||
timestamp := q.Get("timestamp")
|
||
nonce := q.Get("nonce")
|
||
|
||
// ---------------- GET:服务器地址校验 ----------------
|
||
if strings.EqualFold(c.Ctx.Input.Method(), "GET") {
|
||
echostr := q.Get("echostr")
|
||
if reply, ok := verifyPushEchostr(cfgs, timestamp, nonce, q.Get("signature"), q.Get("msg_signature"), echostr); ok {
|
||
c.Ctx.Output.Body([]byte(reply))
|
||
return
|
||
}
|
||
c.Ctx.Output.SetStatus(403)
|
||
c.Ctx.Output.Body([]byte("invalid signature"))
|
||
return
|
||
}
|
||
|
||
// ---------------- POST:消息 / 事件 ----------------
|
||
body, _ := io.ReadAll(c.Ctx.Request.Body)
|
||
raw := strings.TrimSpace(string(body))
|
||
if raw == "" {
|
||
c.Ctx.Output.Body([]byte(""))
|
||
return
|
||
}
|
||
|
||
format := wechatmini.DetectPushFormat(raw)
|
||
msg, perr := parsePushBody(raw, format)
|
||
if perr != nil {
|
||
logs.Info("[wechat-mini] 推送消息解析失败: %v", perr)
|
||
c.Ctx.Output.Body([]byte(""))
|
||
return
|
||
}
|
||
|
||
var cfg *wechatmini.PushConfig
|
||
encrypted := false
|
||
|
||
if msg.Encrypt != "" {
|
||
// 安全模式:整个请求体是密文信封,验签后解密
|
||
msgSig := q.Get("msg_signature")
|
||
if msgSig == "" {
|
||
msgSig = msg.MsgSignature
|
||
}
|
||
if c2, ok := wechatmini.MatchPushConfigByMsgSignature(cfgs, timestamp, nonce, msg.Encrypt, msgSig); ok &&
|
||
c2.EncryptMode == wechatmp.EncryptModeSafe {
|
||
plain, derr := wechatmini.DecryptPushMessage(c2, msg.Encrypt)
|
||
if derr != nil {
|
||
logs.Info("[wechat-mini] 推送消息解密失败: %v", derr)
|
||
c.Ctx.Output.Body([]byte(""))
|
||
return
|
||
}
|
||
inner, ierr := parsePushBody(plain, format)
|
||
if ierr != nil {
|
||
logs.Info("[wechat-mini] 解密后消息解析失败: %v", ierr)
|
||
c.Ctx.Output.Body([]byte(""))
|
||
return
|
||
}
|
||
cfg = c2
|
||
msg = inner
|
||
encrypted = true
|
||
}
|
||
}
|
||
|
||
if cfg == nil {
|
||
// 明文模式 / 兼容模式(兼容模式请求体本身即明文,仅额外携带 Encrypt 字段)
|
||
if c3, ok := wechatmini.MatchPushConfigBySignature(cfgs, timestamp, nonce, q.Get("signature")); ok {
|
||
cfg = c3
|
||
}
|
||
}
|
||
|
||
if cfg == nil {
|
||
logs.Info("[wechat-mini] 推送消息签名校验失败(timestamp=%s nonce=%s)", timestamp, nonce)
|
||
c.Ctx.Output.Body([]byte(""))
|
||
return
|
||
}
|
||
|
||
// 排查用:记录微信推送的消息 / 事件类型
|
||
logs.Info("[wechat-mini] 收到推送: appid=%s type=%s event=%s from=%s key=%s",
|
||
cfg.AppID, msg.MsgType, msg.Event, msg.FromUserName, msg.EventKey)
|
||
|
||
reply := wechatmini.HandlePushMessage(cfg, msg)
|
||
if reply == "" {
|
||
// 无需回复:返回空串,微信视为处理成功(不再重试)
|
||
c.Ctx.Output.Body([]byte(""))
|
||
return
|
||
}
|
||
|
||
now := time.Now().Unix()
|
||
replyBody, berr := wechatmini.BuildTextReply(cfg.DataFormat, msg.FromUserName, msg.ToUserName, reply, now)
|
||
if berr != nil {
|
||
c.Ctx.Output.Body([]byte(""))
|
||
return
|
||
}
|
||
if encrypted {
|
||
ts := q.Get("timestamp")
|
||
if ts == "" {
|
||
ts = msg.TimeStamp
|
||
}
|
||
replyNonce := nonce
|
||
if replyNonce == "" {
|
||
replyNonce = msg.Nonce
|
||
}
|
||
enc, eerr := wechatmini.BuildEncryptedReply(cfg, replyBody, ts, replyNonce, cfg.DataFormat)
|
||
if eerr != nil {
|
||
c.Ctx.Output.Body([]byte(""))
|
||
return
|
||
}
|
||
replyBody = enc
|
||
}
|
||
|
||
if cfg.DataFormat == wechatmini.DataFormatJSON {
|
||
c.Ctx.Output.Header("Content-Type", "application/json; charset=utf-8")
|
||
} else {
|
||
c.Ctx.Output.Header("Content-Type", "application/xml; charset=utf-8")
|
||
}
|
||
c.Ctx.Output.Body([]byte(replyBody))
|
||
}
|
||
|
||
// parsePushBody 按数据格式解析推送体
|
||
func parsePushBody(raw, format string) (*wechatmini.PushMessage, error) {
|
||
if format == wechatmini.DataFormatJSON {
|
||
return wechatmini.ParsePushJSON(raw)
|
||
}
|
||
return wechatmini.ParsePushXML(raw)
|
||
}
|
||
|
||
// verifyPushEchostr 校验微信「服务器地址验证」请求(GET),返回应回显的内容。
|
||
//
|
||
// - 明文 / 兼容模式:校验 sha1(sort(token,timestamp,nonce)) == signature,通过后原样回显 echostr
|
||
// - 安全模式:echostr 为密文,校验 msg_signature 后解密,回显解密后的明文
|
||
//
|
||
// 命中任意一个已配置的小程序即视为验证通过(微信回调不携带 appid)。
|
||
func verifyPushEchostr(cfgs []wechatmini.PushConfig, timestamp, nonce, signature, msgSignature, echostr string) (string, bool) {
|
||
if echostr == "" {
|
||
return "", false
|
||
}
|
||
// 安全模式优先
|
||
if cfg, ok := wechatmini.MatchPushConfigByMsgSignature(cfgs, timestamp, nonce, echostr, msgSignature); ok &&
|
||
cfg.EncryptMode == wechatmp.EncryptModeSafe {
|
||
if plain, derr := wechatmini.DecryptPushMessage(cfg, echostr); derr == nil {
|
||
return plain, true
|
||
}
|
||
}
|
||
// 明文 / 兼容模式
|
||
if _, ok := wechatmini.MatchPushConfigBySignature(cfgs, timestamp, nonce, signature); ok {
|
||
return echostr, true
|
||
}
|
||
return "", false
|
||
}
|