274 lines
8.3 KiB
Go
274 lines
8.3 KiB
Go
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()
|
||
}
|