Files
yunzerwebsiteallinone/go/controllers/api_wechat_mp.go
T
2026-09-30 00:57:36 +08:00

274 lines
8.3 KiB
Go
Raw 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 controllers
import (
"fmt"
"io"
"strconv"
"strings"
"time"
"server/models"
authsvc "server/services/auth"
"server/services/wechatmp"
beego "github.com/beego/beego/v2/server/web"
"github.com/beego/beego/v2/core/logs"
)
// WechatMpCallbackController 微信公众号服务器回调(微信服务器调用,无需平台鉴权)
//
// 公众号后台「设置与开发 → 服务器配置」填写:
// URL(服务器地址):https://<你的域名>/api/wechat/mp/callback
// Token:与平台「通知设置 → 微信配置」中的 Token 保持一致
// 消息加解密方式:明文 / 兼容 / 安全(三种均支持)
type WechatMpCallbackController struct {
beego.Controller
}
// Callback 服务器地址验证(GET,回显 echostr)与消息/事件推送(POST,被动回复)
func (c *WechatMpCallbackController) Callback() {
cfg, err := wechatmp.LoadEnabledConfig()
if err != nil {
c.Ctx.Output.SetStatus(403)
c.Ctx.Output.Body([]byte("wechat mp 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 cfg.EncryptMode == wechatmp.EncryptModeSafe {
if !wechatmp.CheckMsgSignature(cfg.Token, timestamp, nonce, echostr, q.Get("msg_signature")) {
c.Ctx.Output.SetStatus(403)
c.Ctx.Output.Body([]byte("invalid signature"))
return
}
plain, derr := wechatmp.DecryptMessage(cfg.AESKey, cfg.AppID, echostr)
if derr != nil {
c.Ctx.Output.SetStatus(403)
c.Ctx.Output.Body([]byte("decrypt failed"))
return
}
c.Ctx.Output.Body([]byte(plain))
return
}
if !wechatmp.CheckSignature(cfg.Token, timestamp, nonce, q.Get("signature")) {
c.Ctx.Output.SetStatus(403)
c.Ctx.Output.Body([]byte("invalid signature"))
return
}
c.Ctx.Output.Body([]byte(echostr))
return
}
// ---------------- POST:消息 / 事件 ----------------
body, _ := io.ReadAll(c.Ctx.Request.Body)
rawBody := string(body)
if strings.TrimSpace(rawBody) == "" {
c.Ctx.Output.Body([]byte(""))
return
}
needEncryptReply := false
if strings.Contains(rawBody, "<Encrypt>") {
// 安全/兼容模式:外层为加密 XML,先验签再解密
outer, perr := wechatmp.ParseInboundXML(rawBody)
if perr != nil {
c.Ctx.Output.Body([]byte(""))
return
}
if !wechatmp.CheckMsgSignature(cfg.Token, timestamp, nonce, outer.Encrypt, q.Get("msg_signature")) {
c.Ctx.Output.Body([]byte(""))
return
}
plain, derr := wechatmp.DecryptMessage(cfg.AESKey, cfg.AppID, outer.Encrypt)
if derr != nil {
c.Ctx.Output.Body([]byte(""))
return
}
rawBody = plain
needEncryptReply = true
} else if !wechatmp.CheckSignature(cfg.Token, timestamp, nonce, q.Get("signature")) {
// 明文模式:签名校验不通过直接拒绝(微信会重试)
c.Ctx.Output.Body([]byte(""))
return
}
msg, merr := wechatmp.ParseInboundXML(rawBody)
if merr != nil {
c.Ctx.Output.Body([]byte(""))
return
}
// 排查用:记录微信推送的消息/事件类型
logs.Info("[wechat-mp] 收到推送: type=%s event=%s from=%s key=%s",
msg.MsgType, msg.Event, msg.FromUserName, msg.EventKey)
reply := wechatmp.HandleInbound(cfg, msg)
if strings.TrimSpace(reply) == "" {
// 无需回复:返回空串,微信视为处理成功
c.Ctx.Output.Body([]byte(""))
return
}
now := time.Now().Unix()
plainReply := wechatmp.BuildTextReply(msg.FromUserName, msg.ToUserName, reply, now)
c.Ctx.Output.Header("Content-Type", "application/xml; charset=utf-8")
if needEncryptReply {
ts := strconv.FormatInt(now, 10)
enc, eerr := wechatmp.BuildEncryptedReply(cfg, plainReply, ts, nonce)
if eerr != nil {
c.Ctx.Output.Body([]byte(""))
return
}
c.Ctx.Output.Body([]byte(enc))
return
}
c.Ctx.Output.Body([]byte(plainReply))
}
// Login GET/POST /api/wechat/mp/login?token=xxx&client_id=yyy
// 公众号「确认登录」一次性令牌兑换登录态(无平台鉴权:令牌本身即凭证,一次性 + 短时效)。
// 已绑定身份:单企业直接签发令牌;多企业签发待选令牌并由前端展示企业选择。
// 同时认领等待中的 PC 扫码会话(存在时),PC 端轮询后自动登录。
func (c *WechatMpCallbackController) Login() {
token := strings.TrimSpace(c.GetString("token"))
clientID := strings.TrimSpace(c.GetString("client_id"))
if clientID == "" {
clientID = "yz-backend"
}
row, err := wechatmp.ConsumeLoginToken(token)
if err != nil {
c.Ctx.Output.SetStatus(400)
c.Data["json"] = map[string]interface{}{"code": 400, "msg": err.Error()}
_ = c.ServeJSON()
return
}
switch row.BindType {
case models.WechatBindTypeTenantUser:
// 认领 PC 扫码会话:确认链接携带 scene 时精确认领;「登录」文本路径无 scene,FIFO 兜底
if scene := strings.TrimSpace(c.GetString("scene")); scene != "" {
wechatmp.ConfirmScanLoginByToken(scene, token, row.BindType, row.BindID, row.BindTid)
} else {
wechatmp.ConfirmOldestPendingScanLogin(row.BindType, row.BindID, row.BindTid)
}
// 身份状态校验(禁用/锁定则拒绝登录)
var identity models.AuthIdentity
if err := models.Orm.QueryTable(new(models.AuthIdentity)).
Filter("id", row.BindID).One(&identity); err != nil {
c.failJSON(400, "账号不存在或已注销")
return
}
if identity.Status != models.AuthIdentityStatusEnabled {
c.failJSON(400, "账号已被禁用或锁定,无法登录")
return
}
username := "微信用户"
if identity.Nickname != nil && strings.TrimSpace(*identity.Nickname) != "" {
username = *identity.Nickname
}
// 企业选择:单企业直接登录;多企业签发待选令牌,由前端展示选择界面
tenants, lerr := authsvc.ListTenantOptions(row.BindID)
if lerr != nil || len(tenants) == 0 {
c.failJSON(400, "该账号未加入任何企业,无法登录")
return
}
tid := uint64(0)
needChoose := false
if len(tenants) == 1 {
tid = tenants[0].Tid
} else {
tid = authsvc.PendingTenantID
needChoose = true
}
tokens, terr := c.issueMpLoginTokens(row.BindID, tid, clientID, username, "公众号确认链接登录成功", needChoose)
if terr != nil {
c.failJSON(400, terr.Error())
return
}
data := map[string]interface{}{
"type": "backend",
"tokens": tokens,
"tenant": row.BindTid,
}
if needChoose {
data["need_choose_tenant"] = true
data["tenants"] = tenants
}
c.Data["json"] = map[string]interface{}{
"code": 200,
"msg": "登录成功",
"data": data,
}
_ = c.ServeJSON()
default:
c.failJSON(400, "该微信绑定的账号类型暂不支持链接登录")
}
}
// issueMpLoginTokens 为绑定身份建会话并签发 UAC 令牌(含登录日志,失败不影响主流程)
// tid 为 authsvc.PendingTenantID 时签发待选企业用的短期令牌
func (c *WechatMpCallbackController) issueMpLoginTokens(identityID, tid uint64, clientID, username, logMsg string, needChoose bool) (*authsvc.TokenPair, error) {
accessTTL := 0
if needChoose {
accessTTL = 600 // 待选状态令牌只够用户完成企业选择
}
sess, serr := authsvc.CreateSession(authsvc.SessionInfo{
IdentityID: identityID,
Tid: tid,
ClientID: clientID,
IP: c.Ctx.Input.IP(),
UserAgent: c.Ctx.Request.UserAgent(),
LoginType: "wechat_mp",
Amr: "wechat_mp",
})
if serr != nil {
return nil, fmt.Errorf("登录失败:%s", serr.Error())
}
tokens, terr := authsvc.IssueTokens(authsvc.TokenIssue{
IdentityID: identityID,
Tid: tid,
ClientID: clientID,
Sid: sess.Sid,
Username: username,
Amr: "wechat_mp",
AccessTTL: accessTTL,
})
if terr != nil {
return nil, fmt.Errorf("签发令牌失败:%s", terr.Error())
}
identityIDCopy := identityID
tidCopy := tid
amr := "wechat_mp"
_, _ = models.Orm.Insert(&models.AuthLoginLog{
Tid: &tidCopy,
IdentityID: &identityIDCopy,
UserName: username,
ClientID: clientID,
LoginType: "wechat_mp",
Amr: &amr,
Status: 1,
Message: logMsg,
IP: c.Ctx.Input.IP(),
UserAgent: c.Ctx.Request.UserAgent(),
})
return tokens, nil
}
func (c *WechatMpCallbackController) failJSON(bizCode int, msg string) {
c.Ctx.Output.SetStatus(400)
c.Data["json"] = map[string]interface{}{"code": bizCode, "msg": msg}
_ = c.ServeJSON()
}