package wechatmp import ( "crypto/rand" "encoding/hex" "errors" "net/url" "strings" "time" "server/models" beego "github.com/beego/beego/v2/server/web" ) // ============================================================= // 公众号微信登录(免认证方案): // 用户在公众号内发送「登录」→ 后端校验绑定关系并生成一次性登录令牌 → // 被动回复登录链接 → 用户在微信内打开链接 → 前端 /mp-login 页面 // 用令牌调用 /api/wechat/mp/login 换取正式登录态。 // 未认证公众号无法使用网页授权(snsapi_*),一次性链接是替代方案。 // ============================================================= // DefaultLoginTokenTTL 登录令牌默认有效期 const DefaultLoginTokenTTL = 5 * time.Minute var ( ErrLoginTokenInvalid = errors.New("登录链接无效或已过期,请在公众号重新发送「登录」") ErrLoginTokenUsed = errors.New("登录链接已被使用,请在公众号重新发送「登录」") ErrLoginTokenExpired = errors.New("登录链接已过期,请在公众号重新发送「登录」") ) func randomTokenHex(nBytes int) string { b := make([]byte, nBytes) if _, err := rand.Read(b); err != nil { return "" } return hex.EncodeToString(b) } // LoginLinkURL 由一次性令牌拼出前端登录页链接。 // 页面基址读取 app.conf 的 wechat_mp_login_base(前端应用对外可访问地址, // 如 https://back.yunzer.cn),缺省回落 wechat_mp_callback_base / payment_callback_base。 // 未配置时返回空串,由调用方给出提示。 func LoginLinkURL(token string) string { base, _ := beego.AppConfig.String("wechat_mp_login_base") if strings.TrimSpace(base) == "" { base, _ = beego.AppConfig.String("wechat_mp_callback_base") } if strings.TrimSpace(base) == "" { base, _ = beego.AppConfig.String("payment_callback_base") } base = strings.TrimRight(strings.TrimSpace(base), "/") if base == "" { return "" } return base + "/#/mp-login?token=" + url.QueryEscape(token) } // CreateLoginToken 为已绑定的粉丝生成一次性登录令牌。 func CreateLoginToken(f *models.WechatMpFollower, ttl time.Duration) (token string, expireAt time.Time, err error) { if f == nil || strings.TrimSpace(f.BindType) == "" || f.BindID == 0 { return "", time.Time{}, errors.New("该微信尚未绑定账号") } if ttl <= 0 { ttl = DefaultLoginTokenTTL } token = randomTokenHex(32) if token == "" { return "", time.Time{}, errors.New("生成登录令牌失败") } now := time.Now() expireAt = now.Add(ttl) row := &models.WechatMpLoginToken{ Token: token, OpenID: f.OpenID, BindType: f.BindType, BindID: f.BindID, BindTid: f.BindTid, ExpireAt: expireAt, CreateTime: now, } if _, err := models.Orm.Insert(row); err != nil { return "", time.Time{}, err } return token, expireAt, nil } // ConsumeLoginToken 核销一次性登录令牌(CAS 防重放),返回令牌携带的绑定信息。 func ConsumeLoginToken(token string) (*models.WechatMpLoginToken, error) { token = strings.TrimSpace(token) if token == "" { return nil, ErrLoginTokenInvalid } var row models.WechatMpLoginToken if err := models.Orm.QueryTable(new(models.WechatMpLoginToken)). Filter("token", token).One(&row); err != nil { return nil, ErrLoginTokenInvalid } if row.UsedAt != nil { return nil, ErrLoginTokenUsed } if row.ExpireAt.Before(time.Now()) { return nil, ErrLoginTokenExpired } now := time.Now() affected, err := models.Orm.QueryTable(new(models.WechatMpLoginToken)). Filter("id", row.ID). Filter("used_at__isnull", true). Update(map[string]interface{}{"used_at": now}) if err != nil { return nil, err } if affected == 0 { return nil, ErrLoginTokenUsed } return &row, nil } // isLoginKeyword 判断文本是否为「登录」意图 func isLoginKeyword(text string) bool { switch strings.TrimSpace(text) { case "登录", "登陸", "login", "Login", "微信登录", "微信登陆": return true } return false } // isLoginMenuKey 判断自定义菜单点击(CLICK 事件)的 EventKey 是否为「登录」入口。 // 菜单在公众号后台手动配置即可(未认证公众号同样支持), // 用户点菜单 → CLICK 事件 → 自动回复确认登录链接,免输入。 func isLoginMenuKey(key string) bool { k := strings.TrimSpace(key) if k == "" { return false } switch strings.ToLower(k) { case "login", "登录", "扫码登录", "微信登录": return true } return false }