245 lines
8.0 KiB
Go
245 lines
8.0 KiB
Go
package wechatmini
|
||
|
||
import (
|
||
"crypto/rand"
|
||
"errors"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/beego/beego/v2/client/orm"
|
||
"github.com/beego/beego/v2/core/logs"
|
||
beego "github.com/beego/beego/v2/server/web"
|
||
|
||
"server/models"
|
||
)
|
||
|
||
// =============================================================
|
||
// 注册「账号注册」身份核验(小程序方案):
|
||
// 网页发起会话 → 展示小程序码(scene)→ 用户微信扫码进入小程序「注册确认」页 →
|
||
// 小程序端 wx.login 拿 code(可选手机号快速验证)调 confirm 上报 openid →
|
||
// 网页轮询到「已确认」后继续填写账号密码 → 第一步核验按 scene 核销取 openid,
|
||
// 注册完成后把该 openid 绑定到新身份(provider=wechat_mini),即可用小程序一键登录。
|
||
// =============================================================
|
||
|
||
const (
|
||
// DefaultRegisterTTL 注册会话有效期
|
||
DefaultRegisterTTL = 10 * time.Minute
|
||
// RegisterSceneLen 会话 scene 长度(微信限制 scene 不超过 32 字符)
|
||
RegisterSceneLen = 20
|
||
// registerSceneChars scene 字符集(只用小写字母与数字,避免扫码参数转义问题)
|
||
registerSceneChars = "abcdefghijklmnopqrstuvwxyz0123456789"
|
||
)
|
||
|
||
var (
|
||
ErrRegisterSceneInvalid = errors.New("注册会话不存在或已过期,请重新获取二维码")
|
||
ErrRegisterNotConfirmed = errors.New("请先用微信扫码并在小程序内完成确认")
|
||
ErrRegisterSceneUsed = errors.New("该二维码已完成核验,请重新获取")
|
||
)
|
||
|
||
// RegisterPagePath 小程序码指向的页面(app.conf:wechat_mini_register_page,默认 pages/register/confirm)
|
||
func RegisterPagePath() string {
|
||
page, _ := beego.AppConfig.String("wechat_mini_register_page")
|
||
page = strings.TrimPrefix(strings.TrimSpace(page), "/")
|
||
if page == "" {
|
||
return "pages/register/confirm"
|
||
}
|
||
return page
|
||
}
|
||
|
||
// RegisterEnvVersion 小程序码环境(app.conf:wechat_mini_register_env,默认 release;
|
||
// 未发布的小程序可填 trial 指向体验版、develop 指向开发版)
|
||
func RegisterEnvVersion() string {
|
||
env, _ := beego.AppConfig.String("wechat_mini_register_env")
|
||
env = strings.ToLower(strings.TrimSpace(env))
|
||
switch env {
|
||
case "trial", "develop":
|
||
return env
|
||
}
|
||
return "release"
|
||
}
|
||
|
||
// StartRegisterVerify 创建注册会话并生成小程序码(返回 data URL 可直接展示)。
|
||
// 小程序未配置 / 小程序码生成失败时返回 err,由上层回退公众号或短信通道。
|
||
func StartRegisterVerify(ttl time.Duration) (scene, qrURL string, expireAt time.Time, err error) {
|
||
cfg, err := LoadEnabledConfig()
|
||
if err != nil {
|
||
return "", "", time.Time{}, err
|
||
}
|
||
if ttl <= 0 {
|
||
ttl = DefaultRegisterTTL
|
||
}
|
||
scene = randomScene(RegisterSceneLen)
|
||
now := time.Now()
|
||
expireAt = now.Add(ttl)
|
||
row := &models.AuthMiniRegister{
|
||
Scene: scene,
|
||
AppID: cfg.AppID,
|
||
Status: models.MiniRegisterStatusWaiting,
|
||
ExpireAt: expireAt,
|
||
CreateTime: now,
|
||
UpdateTime: &now,
|
||
}
|
||
if _, err = models.Orm.Insert(row); err != nil {
|
||
return "", "", time.Time{}, err
|
||
}
|
||
qrURL, err = CreateUnlimitedQRCode(cfg, scene, RegisterPagePath(), RegisterEnvVersion())
|
||
if err != nil {
|
||
logs.Warning("[wechat-mini] 生成注册小程序码失败(页面 %s / 环境 %s):%v",
|
||
RegisterPagePath(), RegisterEnvVersion(), err)
|
||
return "", "", time.Time{}, err
|
||
}
|
||
return scene, qrURL, expireAt, nil
|
||
}
|
||
|
||
// GetRegisterStatus 查询会话是否已被小程序端确认(过期惰性标记)
|
||
func GetRegisterStatus(scene string) (confirmed bool, expireAt time.Time, err error) {
|
||
row, err := loadRegister(scene)
|
||
if err != nil {
|
||
return false, time.Time{}, err
|
||
}
|
||
expireAt = row.ExpireAt
|
||
if row.Status == models.MiniRegisterStatusWaiting && expireAt.Before(time.Now()) {
|
||
markRegisterExpired(row)
|
||
return false, expireAt, nil
|
||
}
|
||
return row.Status == models.MiniRegisterStatusConfirmed, expireAt, nil
|
||
}
|
||
|
||
// ConfirmRegister 小程序端确认注册:code → openid/unionid(可选手机号),CAS 标记已确认。
|
||
// appID 为空时取第一条启用的小程序配置;多小程序场景由小程序端传入自身 AppID。
|
||
func ConfirmRegister(scene, code, phoneCode, appID string) error {
|
||
scene = strings.TrimSpace(scene)
|
||
code = strings.TrimSpace(code)
|
||
if scene == "" || code == "" {
|
||
return errors.New("参数不完整")
|
||
}
|
||
cfg, err := loadConfigByAppID(strings.TrimSpace(appID))
|
||
if err != nil {
|
||
return err
|
||
}
|
||
session, err := Code2Session(cfg, code)
|
||
if err != nil {
|
||
logs.Warning("[wechat-mini] 注册确认 code 换取 openid 失败: %v", err)
|
||
return err
|
||
}
|
||
// 手机号快速验证为可选项:未开通 / code 失效时不阻断确认
|
||
mobile := ""
|
||
if strings.TrimSpace(phoneCode) != "" {
|
||
phone, perr := GetPhoneNumber(cfg, strings.TrimSpace(phoneCode))
|
||
if perr != nil {
|
||
logs.Warning("[wechat-mini] 注册确认获取手机号失败: %v", perr)
|
||
} else if phone != nil {
|
||
mobile = strings.TrimSpace(phone.PurePhoneNumber)
|
||
if mobile == "" {
|
||
mobile = strings.TrimSpace(phone.PhoneNumber)
|
||
}
|
||
}
|
||
}
|
||
|
||
row, err := loadRegister(scene)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if row.ExpireAt.Before(time.Now()) {
|
||
markRegisterExpired(row)
|
||
return ErrRegisterSceneInvalid
|
||
}
|
||
now := time.Now()
|
||
affected, err := models.Orm.QueryTable(new(models.AuthMiniRegister)).
|
||
Filter("id", row.ID).
|
||
Filter("status", models.MiniRegisterStatusWaiting).
|
||
Update(map[string]interface{}{
|
||
"openid": session.OpenID,
|
||
"unionid": session.UnionID,
|
||
"mobile": mobile,
|
||
"app_id": cfg.AppID,
|
||
"status": models.MiniRegisterStatusConfirmed,
|
||
"update_time": now,
|
||
})
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if affected == 0 {
|
||
return errors.New("该二维码已确认或已失效,请重新获取")
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// ConsumeRegister 核销会话并取出确认结果(一次性,供注册第一步核验使用)
|
||
func ConsumeRegister(scene string) (openid, unionid, mobile string, err error) {
|
||
row, err := loadRegister(scene)
|
||
if err != nil {
|
||
return "", "", "", err
|
||
}
|
||
if row.ExpireAt.Before(time.Now()) {
|
||
markRegisterExpired(row)
|
||
return "", "", "", ErrRegisterSceneInvalid
|
||
}
|
||
switch row.Status {
|
||
case models.MiniRegisterStatusWaiting:
|
||
return "", "", "", ErrRegisterNotConfirmed
|
||
case models.MiniRegisterStatusUsed:
|
||
return "", "", "", ErrRegisterSceneUsed
|
||
case models.MiniRegisterStatusExpired:
|
||
return "", "", "", ErrRegisterSceneInvalid
|
||
}
|
||
now := time.Now()
|
||
affected, err := models.Orm.QueryTable(new(models.AuthMiniRegister)).
|
||
Filter("id", row.ID).
|
||
Filter("status", models.MiniRegisterStatusConfirmed).
|
||
Update(map[string]interface{}{"status": models.MiniRegisterStatusUsed, "update_time": now})
|
||
if err != nil {
|
||
return "", "", "", err
|
||
}
|
||
if affected == 0 {
|
||
return "", "", "", ErrRegisterSceneUsed
|
||
}
|
||
return row.OpenID, row.UnionID, row.Mobile, nil
|
||
}
|
||
|
||
// loadRegister 按 scene 读取会话
|
||
func loadRegister(scene string) (*models.AuthMiniRegister, error) {
|
||
scene = strings.TrimSpace(scene)
|
||
if scene == "" {
|
||
return nil, ErrRegisterSceneInvalid
|
||
}
|
||
var row models.AuthMiniRegister
|
||
if err := models.Orm.QueryTable(new(models.AuthMiniRegister)).Filter("scene", scene).One(&row); err != nil {
|
||
if err == orm.ErrNoRows {
|
||
return nil, ErrRegisterSceneInvalid
|
||
}
|
||
return nil, err
|
||
}
|
||
return &row, nil
|
||
}
|
||
|
||
// markRegisterExpired 惰性标记过期
|
||
func markRegisterExpired(row *models.AuthMiniRegister) {
|
||
now := time.Now()
|
||
_, _ = models.Orm.QueryTable(new(models.AuthMiniRegister)).
|
||
Filter("id", row.ID).
|
||
Filter("status", models.MiniRegisterStatusWaiting).
|
||
Update(map[string]interface{}{"status": models.MiniRegisterStatusExpired, "update_time": now})
|
||
}
|
||
|
||
// loadConfigByAppID 按 AppID 选择小程序配置,缺省取第一条启用配置
|
||
func loadConfigByAppID(appID string) (*Config, error) {
|
||
if appID != "" {
|
||
return LoadEnabledAppByAppID(appID)
|
||
}
|
||
return LoadEnabledConfig()
|
||
}
|
||
|
||
func randomScene(n int) string {
|
||
b := make([]byte, n)
|
||
if _, err := rand.Read(b); err != nil {
|
||
// 极低概率:退化为时间戳兜底,保证 scene 非空且合法
|
||
return "s" + strconv.FormatInt(time.Now().UnixNano(), 10)
|
||
}
|
||
for i := range b {
|
||
b[i] = registerSceneChars[int(b[i])%len(registerSceneChars)]
|
||
}
|
||
return string(b)
|
||
}
|