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) }