package auth import ( "encoding/json" "io" "strings" "server/models" authsvc "server/services/auth" "server/services/wechatmini" "github.com/beego/beego/v2/client/orm" "github.com/beego/beego/v2/core/logs" beego "github.com/beego/beego/v2/server/web" ) // 小程序登录失败原因(前端按此分支给出不同引导) const ( MiniReasonNotConfigured = "not_configured" // 服务端未配置小程序 MiniReasonIdentityNotFound = "identity_not_found" // 微信未匹配到云泽账号 MiniReasonNoTenant = "no_tenant" // 账号未加入任何企业 MiniReasonWechatAPI = "wechat_api_error" // 微信接口报错 ) // idPWechatMini 第三方身份源标识(写入 yz_auth_identity_third.provider) const idPWechatMini = "wechat_mini" // AuthWechatMiniController 微信小程序一键登录(统一认证中心) // // 与公众号确认链接登录(/api/wechat/mp/login)不同,小程序端由前端直接 // 携带 wx.login 的 code 与 getPhoneNumber 的动态 code 兑换登录态: // // POST /auth/wechat/miniapp/login // { "code": "wx.login的code", "phone_code": "getPhoneNumber的动态code", "client_id": "yz-miniapp" } // // 返回结构与 POST /auth/login 完全一致(tokens / need_choose_tenant / tenants 均在顶层), // 前端可复用同一套「写入令牌 → 选企业 → switch-tenant」流程。 type AuthWechatMiniController struct { beego.Controller } func (c *AuthWechatMiniController) serveJSON(data map[string]interface{}) { c.Data["json"] = data _ = c.ServeJSON() } func (c *AuthWechatMiniController) fail(reason string, bizCode int, msg string) { c.Ctx.Output.SetStatus(400) c.serveJSON(map[string]interface{}{"code": bizCode, "msg": msg, "reason": reason}) } // loadMiniConfig 按客户端传入的 appid 选择小程序配置(多应用支持), // 未传 appid 时回退第一条启用配置 func loadMiniConfig(appID string) (*wechatmini.Config, error) { if appID = strings.TrimSpace(appID); appID != "" { return wechatmini.LoadEnabledAppByAppID(appID) } return wechatmini.LoadEnabledConfig() } // Config 小程序登录是否可用 // GET /auth/wechat/miniapp/config?appid=xxx(多应用时可指定,缺省取第一条启用配置) func (c *AuthWechatMiniController) Config() { cfg, err := loadMiniConfig(c.GetString("appid")) if err != nil { c.serveJSON(map[string]interface{}{ "code": 200, "data": map[string]interface{}{"enabled": false, "auto_bind": false}, }) return } c.serveJSON(map[string]interface{}{ "code": 200, "data": map[string]interface{}{"enabled": true, "auto_bind": cfg.AutoBind}, }) } // Login 小程序一键登录 func (c *AuthWechatMiniController) Login() { var req struct { Code string `json:"code"` PhoneCode string `json:"phone_code"` ClientID string `json:"client_id"` AppID string `json:"appid"` } body := c.Ctx.Input.RequestBody if len(body) == 0 { body, _ = io.ReadAll(c.Ctx.Request.Body) } if err := json.Unmarshal(body, &req); err != nil { c.fail("", 400, "参数错误") return } req.Code = strings.TrimSpace(req.Code) req.PhoneCode = strings.TrimSpace(req.PhoneCode) req.ClientID = strings.TrimSpace(req.ClientID) if req.ClientID == "" { req.ClientID = "yz-miniapp" } if req.Code == "" { c.fail("", 400, "缺少登录凭证,请重新进入小程序") return } cfg, err := loadMiniConfig(req.AppID) if err != nil { c.fail(MiniReasonNotConfigured, 400, "小程序登录未开通,请使用账号密码登录") return } // code → openid / unionid session, err := wechatmini.Code2Session(cfg, req.Code) if err != nil { msg := err.Error() if hint, ok := wechatmini.ExplainError(err); ok { msg = hint } logs.Warn("[wechat-mini] code 换取 openid 失败: %v", err) c.fail(MiniReasonWechatAPI, 400, msg) return } // 定位云泽身份:手机号优先(已通过微信验证),其次查已绑定的小程序 openid var mobile string if req.PhoneCode != "" { phone, perr := wechatmini.GetPhoneNumber(cfg, req.PhoneCode) if perr != nil { // 手机号能力未开通 / code 失效时不阻断:仍尝试按已绑定 openid 登录 logs.Warn("[wechat-mini] 获取手机号失败: %v", perr) } else { mobile = strings.TrimSpace(phone.PurePhoneNumber) if mobile == "" { mobile = strings.TrimSpace(phone.PhoneNumber) } } } identity, err := c.resolveIdentity(cfg, mobile, session) if err != nil { c.fail(MiniReasonIdentityNotFound, 404, "该微信未绑定云泽账号,请使用手机号一键登录或先注册") return } if identity.Status != models.AuthIdentityStatusEnabled { c.fail("", 403, "账号已被禁用或锁定,无法登录") return } // 企业选择:单企业直接进入,多企业签发待选令牌由前端展示选择界面 tenants, lerr := authsvc.ListTenantOptions(identity.ID) if lerr != nil || len(tenants) == 0 { c.fail(MiniReasonNoTenant, 403, "该账号未加入任何企业,请联系管理员") return } tid := uint64(0) needChoose := false if len(tenants) == 1 { tid = tenants[0].Tid } else { tid = authsvc.PendingTenantID needChoose = true } username := "微信用户" if identity.Nickname != nil && strings.TrimSpace(*identity.Nickname) != "" { username = strings.TrimSpace(*identity.Nickname) } else if identity.Mobile != nil && *identity.Mobile != "" { username = *identity.Mobile } tokens, terr := c.issueTokens(identity.ID, tid, req.ClientID, username, needChoose) if terr != nil { c.fail("", 400, terr.Error()) return } resp := map[string]interface{}{ "code": 200, "msg": "登录成功", "tokens": tokens, "tenants": tenants, "identity": map[string]interface{}{"id": identity.ID, "nickname": username, "mobile": mobileOr(identity)}, } if needChoose { resp["need_choose_tenant"] = true resp["sid"] = tokens.Sid } c.serveJSON(resp) } // RegisterConfirm 小程序端确认注册(网页注册页展示的小程序码扫码进入) // POST /auth/register/mini/confirm // // { "scene": "小程序码携带的会话标识", "code": "wx.login的code", "phone_code": "可选", "appid": "可选,多小程序" } // // 无登录态:scene + code 即凭证,服务端用 code 换 openid 后标记该会话已确认, // 网页端轮询到确认后继续填写账号密码;手机号快速验证为可选项(失败不阻断)。 func (c *AuthWechatMiniController) RegisterConfirm() { var req struct { Scene string `json:"scene"` Code string `json:"code"` PhoneCode string `json:"phone_code"` AppID string `json:"appid"` } body := c.Ctx.Input.RequestBody if len(body) == 0 { body, _ = io.ReadAll(c.Ctx.Request.Body) } if err := json.Unmarshal(body, &req); err != nil { c.fail("", 400, "参数错误") return } req.Scene = strings.TrimSpace(req.Scene) if req.Scene == "" { c.fail("", 400, "缺少会话标识,请重新扫码") return } if err := wechatmini.ConfirmRegister(req.Scene, req.Code, req.PhoneCode, req.AppID); err != nil { msg := err.Error() if hint, ok := wechatmini.ExplainError(err); ok { msg = hint } logs.Warn("[wechat-mini] 注册确认失败 scene=%s: %v", req.Scene, err) c.fail(MiniReasonWechatAPI, 400, msg) return } c.serveJSON(map[string]interface{}{"code": 200, "msg": "确认成功,请回到电脑继续完成注册"}) } // Bind 把当前小程序 openid 绑定到已登录身份 // POST /auth/wechat/miniapp/bind { "code": "wx.login的code" } Header: Bearer 令牌 // // 用途:小程序认证中 / 未开通「手机号快速验证」时,getPhoneNumber 拿不到手机号, // 一键登录退化为「按 openid 匹配已绑定身份」。用户先用账号密码登录一次, // 前端调用本接口静默绑定 openid,之后即可无手机号完成微信一键登录。 // 显式绑定视为本人操作(同时持有微信与该账号登录态),允许换绑覆盖旧绑定。 func (c *AuthWechatMiniController) Bind() { claims := claimsFromCtxThird(c.Ctx) if claims == nil || claims.UserID <= 0 { c.Ctx.Output.SetStatus(401) c.serveJSON(map[string]interface{}{"code": 401, "msg": "未登录"}) return } var req struct { Code string `json:"code"` AppID string `json:"appid"` } body := c.Ctx.Input.RequestBody if len(body) == 0 { body, _ = io.ReadAll(c.Ctx.Request.Body) } if err := json.Unmarshal(body, &req); err != nil { c.fail("", 400, "参数错误") return } req.Code = strings.TrimSpace(req.Code) if req.Code == "" { c.fail("", 400, "缺少登录凭证,请重新进入小程序") return } cfg, err := loadMiniConfig(req.AppID) if err != nil { c.fail(MiniReasonNotConfigured, 400, "小程序登录未开通") return } session, err := wechatmini.Code2Session(cfg, req.Code) if err != nil { msg := err.Error() if hint, ok := wechatmini.ExplainError(err); ok { msg = hint } logs.Warn("[wechat-mini] 绑定 code 换取 openid 失败: %v", err) c.fail(MiniReasonWechatAPI, 400, msg) return } c.syncBinding(cfg, uint64(claims.UserID), session, true) c.serveJSON(map[string]interface{}{"code": 200, "msg": "绑定成功"}) } // resolveIdentity 定位身份并维护小程序绑定关系。 // mobile 非空表示本次已通过微信手机号验证,是可信的强证据,优先采用。 func (c *AuthWechatMiniController) resolveIdentity(cfg *wechatmini.Config, mobile string, session *wechatmini.Session) (*models.AuthIdentity, error) { // 1) 手机号命中身份(手机号已由微信验证,属于当前用户的强凭证) if mobile != "" { if identity, err := authsvc.FindIdentityByAccount(mobile); err == nil { c.syncBinding(cfg, identity.ID, session, true) return identity, nil } } // 2) 已绑定过该小程序 openid / unionid bind := findMiniBinding(session.OpenID, session.UnionID) if bind == nil { return nil, orm.ErrNoRows } var identity models.AuthIdentity if err := models.Orm.QueryTable(new(models.AuthIdentity)). Filter("id", bind.IdentityID). Filter("delete_time__isnull", true). One(&identity); err != nil { return nil, err } return &identity, nil } // syncBinding 把当前小程序 openid 绑定到该身份。 // verified 表示本次带通过了微信手机号验证:此时手机号即身份凭证, // 允许覆盖历史绑定(用户换绑手机号 / 换微信号场景); // 未验证时只允许「新增绑定」,不抢占已绑定到其他身份的 openid。 func (c *AuthWechatMiniController) syncBinding(cfg *wechatmini.Config, identityID uint64, session *wechatmini.Session, verified bool) { bind := findMiniBinding(session.OpenID, session.UnionID) if bind != nil && bind.IdentityID == identityID { return } if bind != nil { if !verified { return } _, _ = models.Orm.QueryTable(new(models.AuthIdentityThird)).Filter("id", bind.ID).Delete() } if !verified && !cfg.AutoBind { return } item := &models.AuthIdentityThird{ IdentityID: identityID, Provider: idPWechatMini, OpenID: session.OpenID, } if session.UnionID != "" { uid := session.UnionID item.UnionID = &uid } _, _ = models.Orm.Insert(item) } // findMiniBinding 按 openid 查绑定,其次按 unionid 查 func findMiniBinding(openID, unionID string) *models.AuthIdentityThird { qs := models.Orm.QueryTable(new(models.AuthIdentityThird)).Filter("provider", idPWechatMini) if openID != "" { var bind models.AuthIdentityThird if err := qs.Filter("open_id", openID).One(&bind); err == nil { return &bind } } if unionID != "" { var bind models.AuthIdentityThird if err := qs.Filter("union_id", unionID).One(&bind); err == nil { return &bind } } return nil } // issueTokens 建会话并签发 UAC 令牌(含登录日志,失败不影响主流程) func (c *AuthWechatMiniController) issueTokens(identityID, tid uint64, clientID, username 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_mini", Amr: "wechat_mini", }) if serr != nil { return nil, serr } tokens, terr := authsvc.IssueTokens(authsvc.TokenIssue{ IdentityID: identityID, Tid: tid, ClientID: clientID, Sid: sess.Sid, Username: username, UserType: "app", Amr: "wechat_mini", AccessTTL: accessTTL, }) if terr != nil { return nil, terr } identityIDCopy := identityID tidCopy := tid amr := "wechat_mini" _, _ = models.Orm.Insert(&models.AuthLoginLog{ Tid: &tidCopy, IdentityID: &identityIDCopy, UserName: username, ClientID: clientID, LoginType: "wechat_mini", Amr: &amr, Status: 1, Message: "微信小程序一键登录成功", IP: c.Ctx.Input.IP(), UserAgent: c.Ctx.Request.UserAgent(), }) return tokens, nil } func mobileOr(identity *models.AuthIdentity) string { if identity != nil && identity.Mobile != nil { return *identity.Mobile } return "" }