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, "") { // 安全/兼容模式:外层为加密 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() }