优化小程序

This commit is contained in:
hero920103 committed 2026-10-31 01:21:55 +08:00
1 parent 2e2b837a20
commit 727319a6eb
24 files changed
+1509 -200

No files matched your search

+80
View File
@@ -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
+244
View File
@@ -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)
}