Files
yunzerwebsiteallinone/go/services/wechatmp/login.go
T
2026-09-30 00:57:36 +08:00

143 lines
4.5 KiB
Go

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
}