优化小程序
This commit is contained in:
1 parent
2e2b837a20
commit
727319a6eb
24 files changed
+1509
-200
No files matched your search
@@ -2,11 +2,14 @@ package wechatmini
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
@@ -138,6 +141,65 @@ func GetPhoneNumber(cfg *Config, phoneCode string) (*PhoneNumber, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ============================ 小程序码 ============================
|
||||
|
||||
// CreateUnlimitedQRCode 生成小程序码(wxa/getwxacodeunlimited,数量无限制),
|
||||
// 返回图片 base64 data URL,可直接用于 <img src>。
|
||||
//
|
||||
// scene 会话标识,扫码后作为 options.scene 传给目标页面,1-32 个字符
|
||||
// page 小程序页面路径(如 pages/register/confirm),必须已发布,不带前导 /
|
||||
// envVersion release(正式版,默认)/ trial(体验版)/ develop(开发版)
|
||||
func CreateUnlimitedQRCode(cfg *Config, scene, page, envVersion string) (string, error) {
|
||||
if cfg == nil || cfg.AppID == "" || cfg.AppSecret == "" {
|
||||
return "", ErrNotConfigured
|
||||
}
|
||||
page = strings.TrimPrefix(strings.TrimSpace(page), "/")
|
||||
if page == "" {
|
||||
return "", errors.New("未配置小程序码页面路径")
|
||||
}
|
||||
scene = strings.TrimSpace(scene)
|
||||
if scene == "" || len(scene) > 32 {
|
||||
return "", errors.New("小程序码 scene 无效(需 1-32 个字符)")
|
||||
}
|
||||
envVersion = strings.TrimSpace(envVersion)
|
||||
if envVersion == "" {
|
||||
envVersion = "release"
|
||||
}
|
||||
token, err := getAccessToken(cfg)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
payload := map[string]interface{}{
|
||||
"scene": scene,
|
||||
"page": page,
|
||||
"env_version": envVersion,
|
||||
"width": 280,
|
||||
}
|
||||
// 体验版 / 开发版下该页面尚未发布到正式版,必须关闭路径校验,
|
||||
// 否则微信返回 40066 invalid url(正式版可保留默认校验)
|
||||
if envVersion != "release" {
|
||||
payload["check_path"] = false
|
||||
}
|
||||
api := fmt.Sprintf("%s/wxa/getwxacodeunlimited?access_token=%s", apiBase, url.QueryEscape(token))
|
||||
body, contentType, err := httpPostBinary(api, payload)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
// 失败时微信仍返回 200,但响应体是 JSON 错误而非图片
|
||||
if strings.HasPrefix(strings.TrimSpace(string(body)), "{") {
|
||||
var resp struct {
|
||||
ErrCode int `json:"errcode"`
|
||||
ErrMsg string `json:"errmsg"`
|
||||
}
|
||||
_ = json.Unmarshal(body, &resp)
|
||||
return "", &APIError{API: "生成小程序码", Code: resp.ErrCode, Msg: resp.ErrMsg}
|
||||
}
|
||||
if contentType == "" {
|
||||
contentType = "image/jpeg"
|
||||
}
|
||||
return "data:" + contentType + ";base64," + base64.StdEncoding.EncodeToString(body), nil
|
||||
}
|
||||
|
||||
// ============================ access_token 缓存 ============================
|
||||
|
||||
type tokenEntry struct {
|
||||
@@ -226,6 +288,24 @@ func httpPostJSON(api string, payload interface{}, out interface{}) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// httpPostBinary POST JSON 并返回原始响应体与 Content-Type(小程序码接口返回图片流)
|
||||
func httpPostBinary(api string, payload interface{}) ([]byte, string, error) {
|
||||
bs, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
resp, err := httpClient.Post(api, "application/json; charset=utf-8", bytes.NewReader(bs))
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("请求微信接口失败: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("读取微信响应失败: %w", err)
|
||||
}
|
||||
return body, resp.Header.Get("Content-Type"), nil
|
||||
}
|
||||
|
||||
func truncate(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
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)
|
||||
}
|
||||
Reference in new issue
Block a user