优化小程序

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

+337 -88
View File
@@ -7,7 +7,6 @@ import (
"fmt"
"regexp"
"strings"
"sync"
"time"
"server/models"
@@ -15,6 +14,7 @@ import (
authsvc "server/services/auth"
"github.com/beego/beego/v2/client/orm"
"github.com/beego/beego/v2/core/logs"
"github.com/google/uuid"
)
@@ -30,30 +30,67 @@ import (
// - 暂不加入:只完成身份注册。
// =============================================================
// registerTokenTTL register_token 有效期
const registerTokenTTL = 10 * time.Minute
// registerTokenTTL register_token 有效期(覆盖第一步核验到第二步企业归属的完整操作:
// 填企业资料 + 上传营业执照可能较久,给 2 小时;会话已落库,服务重启也不会丢)
const registerTokenTTL = 2 * time.Hour
// registerTokenItem 一次性注册令牌:身份已创建,等待第二步归属操作
// registerTokenItem 一次性注册令牌:
// 第一步核验通过时只保存「待注册信息」,身份尚未创建;
// 第二步归属操作调用 CompleteRegister 时才真正建号并写回 IdentityID。
// 这样令牌过期(或用户放弃)时不会有「已占用账号但没完成注册」的残留身份。
type registerTokenItem struct {
IdentityID uint64
OpenID string // 账号注册模式扫码人的微信 openid(用于注册后绑定微信登录)
ExpiredAt time.Time
IdentityID uint64 // 第二步完成建号后写回;0 = 尚未建号
Account string // 账号注册 / 小程序核验的账号名
Mobile string // 手机号注册的手机号
Nickname string
PasswordHash string
PasswordAlgo string
OpenID string // 核验扫码人的微信 openid(公众号 / 小程序,用于注册后绑定微信登录)
UnionID string // 小程序核验时的 unionid(同主体多端互通,可为空)
Provider string // models.IdPWechat(公众号) / models.IdPWechatMini(小程序)
ExpiredAt time.Time
}
var registerTokenStore sync.Map // key: token
// RegisterPending 第一步核验结果(尚未落库为身份)
type RegisterPending struct {
Account string
Mobile string
Nickname string
PasswordHash string
PasswordAlgo string
OpenID string
UnionID string
Provider string
}
// IssueRegisterToken 身份创建成功后签发注册令牌
func IssueRegisterToken(identityID uint64, openid string) string {
// IssuePendingRegisterToken 保存第一步核验结果并签发待注册令牌(此时尚未创建身份)。
// 会话落库(yz_auth_register_session):服务重启 / 多副本部署都不会中途失效。
func IssuePendingRegisterToken(p RegisterPending) string {
b := make([]byte, 24)
if _, err := rand.Read(b); err != nil {
return fmt.Sprintf("rt%d%d", identityID, time.Now().UnixNano())
return fmt.Sprintf("rt%d%d", time.Now().UnixNano(), time.Now().UnixNano())
}
token := hex.EncodeToString(b)
registerTokenStore.Store(token, registerTokenItem{
IdentityID: identityID,
OpenID: openid,
ExpiredAt: time.Now().Add(registerTokenTTL),
})
now := time.Now()
row := &models.AuthRegisterSession{
Token: token,
Account: strings.TrimSpace(p.Account),
Mobile: strings.TrimSpace(p.Mobile),
Nickname: strings.TrimSpace(p.Nickname),
PasswordHash: p.PasswordHash,
PasswordAlgo: p.PasswordAlgo,
OpenID: strings.TrimSpace(p.OpenID),
UnionID: strings.TrimSpace(p.UnionID),
Provider: p.Provider,
Status: models.RegisterSessionPending,
ExpireAt: now.Add(registerTokenTTL),
CreateTime: now,
UpdateTime: &now,
}
if _, err := models.Orm.Insert(row); err != nil {
logs.Error("[register] 保存注册会话失败: %v", err)
return ""
}
return token
}
@@ -63,43 +100,53 @@ func ConsumeRegisterToken(token string) (identityID uint64, openid string, err e
if err != nil {
return 0, "", err
}
registerTokenStore.Delete(strings.TrimSpace(token))
_, _ = models.Orm.QueryTable(new(models.AuthRegisterSession)).
Filter("token", strings.TrimSpace(token)).
Delete()
return identityID, openid, nil
}
// PeekRegisterToken 查看注册令牌(不消费);第二步先 Peek 校验,
// 业务成功后再 Consume,避免申请失败导致令牌作废无法重试。
func PeekRegisterToken(token string) (identityID uint64, openid string, err error) {
identityID, openid, _, _, err = PeekRegisterTokenThird(token)
return identityID, openid, err
}
// PeekRegisterTokenThird 同 PeekRegisterToken,额外返回 unionid 与第三方来源
// (公众号 / 小程序),供第二步按来源绑定对应 provider。
func PeekRegisterTokenThird(token string) (identityID uint64, openid, unionid, provider string, err error) {
token = strings.TrimSpace(token)
if token == "" {
return 0, "", errors.New("注册会话已失效,请重新注册")
return 0, "", "", "", errors.New("注册会话已失效,请重新注册")
}
v, ok := registerTokenStore.Load(token)
if !ok {
return 0, "", errors.New("注册会话已失效,请重新注册")
var row models.AuthRegisterSession
if err = models.Orm.QueryTable(new(models.AuthRegisterSession)).
Filter("token", token).One(&row); err != nil {
return 0, "", "", "", errors.New("注册会话已失效,请重新注册")
}
item, ok := v.(registerTokenItem)
if !ok {
return 0, "", errors.New("注册会话已失效,请重新注册")
if time.Now().After(row.ExpireAt) {
// 令牌过期:若期间已建号但没走完归属,回滚,避免留下占用账号的半成品
rollbackRegisterIdentity(row.IdentityID)
_, _ = models.Orm.QueryTable(new(models.AuthRegisterSession)).Filter("token", token).Delete()
return 0, "", "", "", errors.New("注册会话已过期,请重新注册")
}
if time.Now().After(item.ExpiredAt) {
registerTokenStore.Delete(token)
return 0, "", errors.New("注册会话已过期,请重新注册")
}
return item.IdentityID, item.OpenID, nil
return row.IdentityID, row.OpenID, row.UnionID, row.Provider, nil
}
// ---------------- 第一步:核验并创建身份 ----------------
// RegisterVerifyInput 第一步核验入参
type RegisterVerifyInput struct {
Mode string // phone / account
Mode string // phone / account / mini
Phone string // mode=phone:手机号
Account string // mode=account:自定义账号
Code string // 验证码(短信 或 服务号下发)
Account string // mode=account|mini:自定义账号
Code string // 验证码(短信 或 服务号下发);mode=mini 不使用
Password string
Confirm string
OpenID string // mode=account:核销服务号验证码得到的 openid
OpenID string // 核验得到的微信 openid(公众号 或 小程序)
UnionID string // mode=mini:小程序 unionid(可为空)
Provider string // 微信来源:models.IdPWechat / models.IdPWechatMini
}
// validateAccountName 账号名规则:4-32 位,字母开头,字母/数字/下划线/中划线
@@ -132,68 +179,207 @@ func findIdentityByAccountName(account string) *models.AuthIdentity {
return &m
}
// VerifyAndCreateIdentity 第一步:校验验证码与密码,创建统一身份,返回身份与新令牌。
// 调用方保证:mode=phone 已发短信验证码;mode=account 已核销服务号验证码并传入 openid。
func VerifyAndCreateIdentity(in RegisterVerifyInput) (*models.AuthIdentity, string, error) {
// VerifyAndIssueRegisterToken 第一步:校验验证码与密码、检查账号唯一性,签发待注册令牌。
// 注意:此处【不创建身份】——身份推迟到第二步归属操作(CompleteRegister)才落库,
// 避免「核验通过但第二步超时未完成」留下占用账号却无法使用的半成品账号。
// 调用方保证:mode=phone 已发短信验证码;mode=account 已核销服务号验证码并传入 openid;
// mode=mini 已核销小程序注册会话并传入小程序 openid(Provider=wechat_mini)。
func VerifyAndIssueRegisterToken(in RegisterVerifyInput) (string, error) {
in.Mode = strings.TrimSpace(in.Mode)
if in.Mode != "phone" && in.Mode != "account" {
return nil, "", errors.New("请选择注册方式")
if in.Mode != "phone" && in.Mode != "account" && in.Mode != "mini" {
return "", errors.New("请选择注册方式")
}
if len(strings.TrimSpace(in.Password)) < 6 {
return nil, "", errors.New("密码长度不能少于6个字符")
return "", errors.New("密码长度不能少于6个字符")
}
if in.Password != in.Confirm {
return nil, "", errors.New("两次输入的密码不一致")
return "", errors.New("两次输入的密码不一致")
}
identity := &models.AuthIdentity{
UnionID: strings.ReplaceAll(uuid.NewString(), "-", ""),
pending := RegisterPending{
OpenID: strings.TrimSpace(in.OpenID),
UnionID: strings.TrimSpace(in.UnionID),
Provider: in.Provider,
}
switch in.Mode {
case "phone":
phone := strings.TrimSpace(in.Phone)
if !IsMobile(phone) {
return nil, "", errors.New("请输入正确的手机号")
return "", errors.New("请输入正确的手机号")
}
if err := VerifyIdentityCode("register", phone, in.Code); err != nil {
return nil, "", err
return "", err
}
if findIdentityByMobile(phone) != nil {
return nil, "", errors.New("该手机号已注册,请直接登录")
if exist := findIdentityByMobile(phone); exist != nil {
// 孤儿身份(注册未完成、从未登录、无企业归属)自动回收,账号/手机号可重新注册
if !isOrphanIdentity(exist) {
return "", errors.New("该手机号已注册,请直接登录")
}
discardOrphanIdentity(exist.ID)
}
identity.Mobile = &phone
nick := "用户" + phone[len(phone)-4:]
identity.Nickname = &nick
case "account":
pending.Mobile = phone
pending.Nickname = "用户" + phone[len(phone)-4:]
case "account", "mini":
account := strings.TrimSpace(in.Account)
if err := validateAccountName(account); err != nil {
return nil, "", err
return "", err
}
if findIdentityByAccountName(account) != nil {
return nil, "", errors.New("该账号已被占用,请换一个")
if exist := findIdentityByAccountName(account); exist != nil {
// 同上:回收「核验通过但没走完第二步」留下的孤儿身份
if !isOrphanIdentity(exist) {
return "", errors.New("该账号已被注册,请直接登录,或换一个账号名")
}
discardOrphanIdentity(exist.ID)
}
identity.Account = &account
nick := account
identity.Nickname = &nick
if in.Mode == "mini" && strings.TrimSpace(in.OpenID) == "" {
return "", errors.New("微信核验已失效,请重新扫码确认")
}
pending.Account = account
pending.Nickname = account
}
hashed, err := passwordutil.Hash(strings.TrimSpace(in.Password))
if err != nil {
return nil, "", errors.New("密码处理失败")
return "", errors.New("密码处理失败")
}
identity.PasswordHash = &hashed
identity.PasswordAlgo = passwordutil.AlgoOf(hashed)
identity.Status = models.AuthIdentityStatusEnabled
pending.PasswordHash = hashed
pending.PasswordAlgo = passwordutil.AlgoOf(hashed)
token := IssuePendingRegisterToken(pending)
if token == "" {
return "", errors.New("注册会话创建失败,请稍后重试")
}
return token, nil
}
// CompleteRegister 第二步归属操作开始时真正创建身份:
// 取出第一步核验结果 → 复核唯一性 → 建号 → 绑定微信(若有)→ 写回令牌。
// 令牌此时不消费(业务失败可重试);令牌过期时由 rollbackRegisterIdentity 清理半成品身份。
func CompleteRegister(token string) (uint64, error) {
tk := strings.TrimSpace(token)
var row models.AuthRegisterSession
if err := models.Orm.QueryTable(new(models.AuthRegisterSession)).
Filter("token", tk).One(&row); err != nil {
return 0, errors.New("注册会话已失效,请重新注册")
}
if time.Now().After(row.ExpireAt) {
rollbackRegisterIdentity(row.IdentityID)
_, _ = models.Orm.QueryTable(new(models.AuthRegisterSession)).Filter("token", tk).Delete()
return 0, errors.New("注册会话已过期,请重新注册")
}
if row.IdentityID != 0 {
return row.IdentityID, nil // 已建号(重试场景),直接复用
}
// 建号前复核:核验后到这一步之间可能被他人抢注;孤儿身份仍可回收
if row.Account != "" {
if exist := findIdentityByAccountName(row.Account); exist != nil {
if !isOrphanIdentity(exist) {
return 0, errors.New("该账号已被注册,请直接登录,或换一个账号名")
}
discardOrphanIdentity(exist.ID)
}
}
if row.Mobile != "" {
if exist := findIdentityByMobile(row.Mobile); exist != nil {
if !isOrphanIdentity(exist) {
return 0, errors.New("该手机号已注册,请直接登录")
}
discardOrphanIdentity(exist.ID)
}
}
identity := &models.AuthIdentity{
UnionID: strings.ReplaceAll(uuid.NewString(), "-", ""),
PasswordAlgo: row.PasswordAlgo,
PasswordHash: &row.PasswordHash,
Status: models.AuthIdentityStatusEnabled,
}
if row.Account != "" {
account := row.Account
identity.Account = &account
}
if row.Mobile != "" {
mobile := row.Mobile
identity.Mobile = &mobile
}
if row.Nickname != "" {
nick := row.Nickname
identity.Nickname = &nick
}
id, err := models.Orm.Insert(identity)
if err != nil {
return nil, "", errors.New("注册失败,请稍后重试")
return 0, errors.New("注册失败,请稍后重试")
}
identity.ID = uint64(id)
BindRegisterThird(identity.ID, row.OpenID, row.UnionID, row.Provider)
token := IssueRegisterToken(identity.ID, in.OpenID)
return identity, token, nil
now := time.Now()
_, _ = models.Orm.QueryTable(new(models.AuthRegisterSession)).
Filter("id", row.ID).
Update(map[string]interface{}{"identity_id": identity.ID, "update_time": now})
return identity.ID, nil
}
// isOrphanIdentity 判定「注册未完成」的孤儿身份:从未登录成功、没有任何企业归属、
// 也没有待审批的加入/创建企业申请。这类身份占用账号名(或手机号)却无法使用,
// 回收后同名账号可正常重新注册。
func isOrphanIdentity(m *models.AuthIdentity) bool {
if m == nil || m.ID == 0 {
return false
}
if m.LastLoginAt != nil {
return false
}
var tu models.AuthTenantUser
if err := models.Orm.QueryTable(new(models.AuthTenantUser)).
Filter("identity_id", m.ID).
Filter("delete_time__isnull", true).
One(&tu); err == nil {
return false
}
var ta models.AuthTenantApply
if err := models.Orm.QueryTable(new(models.AuthTenantApply)).
Filter("identity_id", m.ID).
Filter("delete_time__isnull", true).
One(&ta); err == nil {
return false
}
return true
}
// discardOrphanIdentity 物理删除孤儿身份及其第三方绑定。
// 用物理删而非软删:account/mobile 上有唯一索引,软删行仍占着索引会导致同名无法重新注册。
// 调用方必须先用 isOrphanIdentity 确认该身份没有任何关联数据。
func discardOrphanIdentity(identityID uint64) {
if identityID == 0 {
return
}
_, _ = models.Orm.QueryTable(new(models.AuthIdentityThird)).
Filter("identity_id", identityID).
Delete()
_, _ = models.Orm.QueryTable(new(models.AuthIdentity)).
Filter("id", identityID).
Delete()
}
// rollbackRegisterIdentity 令牌过期 / 放弃注册时清理已建但未完成归属的身份,
// 防止占用账号名却没有企业归属的「半成品账号」(同名账号可正常重新注册)。
func rollbackRegisterIdentity(identityID uint64) {
if identityID == 0 {
return
}
var m models.AuthIdentity
if err := models.Orm.QueryTable(new(models.AuthIdentity)).
Filter("id", identityID).One(&m); err != nil {
return
}
// 已产生企业归属或申请(业务已成功)时绝不能删
if !isOrphanIdentity(&m) {
return
}
discardOrphanIdentity(identityID)
}
// ---------------- 第二步:企业归属 ----------------
@@ -558,17 +744,23 @@ func getPendingApply(applyID uint64, applyType int8, tid uint64) (*models.AuthTe
// finishApply CAS 置申请终态,防止并发重复审批
func finishApply(apply *models.AuthTenantApply, status int8, reason string, reviewerID uint64, reviewerName string) error {
now := time.Now()
update := map[string]interface{}{
"status": status,
"reject_reason": reason,
"reviewer_id": reviewerID,
"reviewer_name": reviewerName,
"review_time": now,
"update_time": now,
}
// 创建企业审批通过时新建的租户必须回写到申请单(否则申请记录里 tid 一直为 0,
// 事后无法按 tid 追溯/修数据)
if apply.Tid > 0 {
update["tid"] = apply.Tid
}
affected, err := models.Orm.QueryTable(new(models.AuthTenantApply)).
Filter("id", apply.ID).
Filter("status", models.TenantApplyStatusPending).
Update(map[string]interface{}{
"status": status,
"reject_reason": reason,
"reviewer_id": reviewerID,
"reviewer_name": reviewerName,
"review_time": now,
"update_time": now,
})
Update(update)
if err != nil {
return err
}
@@ -622,22 +814,66 @@ func bindIdentityToTenant(identity *models.AuthIdentity, tid uint64, rolePicker
Update(map[string]interface{}{"group_id": roleID})
}
if err := authsvc.SyncTenantUser(authsvc.SyncTenantUserInput{
Tid: tid,
Account: account,
Name: name,
Phone: applyStrVal(identity.Mobile),
Email: applyStrVal(identity.Email),
PasswordHash: applyStrVal(identity.PasswordHash),
GroupID: groupID,
Status: 1,
IsDefault: 0,
}); err != nil {
return errors.New("认证中心同步失败:" + err.Error())
if err := upsertAuthTenantBind(identity, tid, account, name, groupID); err != nil {
return errors.New("认证中心绑定失败:" + err.Error())
}
return nil
}
// upsertAuthTenantBind 按已知身份 ID 直接写认证中心绑定表 yz_auth_tenant_user
// (登录时的企业列表读的就是这张表)。
//
// 不能用 SyncTenantUser 的「账号/手机/邮箱归并键」来匹配:账号注册的身份没有手机号、
// 邮箱,企业内账号又是拼出来的 user{id},归并键匹配不到既有身份,会新建一个空身份并
// 绑定到新租户,申请人自己的身份反而没有绑定 → 登录后提示「该账号未绑定任何企业」。
func upsertAuthTenantBind(identity *models.AuthIdentity, tid uint64, account, name string, groupID uint64) error {
if identity == nil || identity.ID == 0 || tid == 0 {
return errors.New("参数错误")
}
now := time.Now()
var existed models.AuthTenantUser
if err := models.Orm.QueryTable(new(models.AuthTenantUser)).
Filter("tid", tid).
Filter("identity_id", identity.ID).
One(&existed); err == nil {
_, uerr := models.Orm.QueryTable(new(models.AuthTenantUser)).
Filter("id", existed.ID).
Update(map[string]interface{}{
"account": account,
"name": name,
"phone": identity.Mobile,
"email": identity.Email,
"group_id": groupID,
"status": 1,
"update_time": now,
})
return uerr
}
item := &models.AuthTenantUser{
Tid: tid,
IdentityID: identity.ID,
GroupID: groupID,
Status: 1,
IsDefault: 1, // 申请人是该企业的第一个成员,设为默认企业
CreateTime: now,
UpdateTime: &now,
}
if account != "" {
item.Account = &account
}
if name != "" {
item.Name = &name
}
if identity.Mobile != nil {
item.Phone = identity.Mobile
}
if identity.Email != nil {
item.Email = identity.Email
}
_, err := models.Orm.Insert(item)
return err
}
// applyStrVal 取指针字符串值(nil 返回空串)
func applyStrVal(s *string) string {
if s == nil {
@@ -674,16 +910,25 @@ func genUniqueTenantCode() (string, error) {
return "", errors.New("生成租户编码失败,请稍后重试")
}
// BindRegisterWechat 注册完成后把服务号 openid 绑到新身份(账号注册模式)。
// BindRegisterWechat 注册完成后把服务号 openid 绑到新身份(公众号核验模式)。
// openid 已绑定其他身份时不抢占,静默跳过。
func BindRegisterWechat(identityID uint64, openid string) {
BindRegisterThird(identityID, openid, "", models.IdPWechat)
}
// BindRegisterThird 注册完成后把微信身份绑到新身份。
// provider 区分公众号(wechat)与小程序(wechat_mini);openid 已绑定时不抢占,静默跳过。
func BindRegisterThird(identityID uint64, openid, unionid, provider string) {
openid = strings.TrimSpace(openid)
if openid == "" || identityID == 0 {
return
}
if provider == "" {
provider = models.IdPWechat
}
var row models.AuthIdentityThird
err := models.Orm.QueryTable(new(models.AuthIdentityThird)).
Filter("provider", models.IdPWechat).
Filter("provider", provider).
Filter("open_id", openid).
One(&row)
if err == nil {
@@ -692,9 +937,13 @@ func BindRegisterWechat(identityID uint64, openid string) {
if err != orm.ErrNoRows {
return
}
_, _ = models.Orm.Insert(&models.AuthIdentityThird{
item := &models.AuthIdentityThird{
IdentityID: identityID,
Provider: models.IdPWechat,
Provider: provider,
OpenID: openid,
})
}
if uid := strings.TrimSpace(unionid); uid != "" {
item.UnionID = &uid
}
_, _ = models.Orm.Insert(item)
}
+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)
}
+112 -2
View File
@@ -6,9 +6,15 @@ import (
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"regexp"
"strings"
"sync"
"time"
"github.com/beego/beego/v2/core/logs"
)
// =============================================================
@@ -48,6 +54,106 @@ func (e *APIError) Explain() string {
return ""
}
// ============================ 出口 IP 探测(40164 IP 白名单排查用) ============================
var (
outboundIPMu sync.Mutex
outboundIPCache string
outboundIPAt time.Time
)
// outboundIPTTL 出口 IP 缓存时长(探测失败也按此负缓存,避免每次请求都外网探测)
const outboundIPTTL = 5 * time.Minute
// ipDetectServices 出口 IP 探测服务,按顺序尝试,任一返回 IPv4 即采用
var ipDetectServices = []string{"https://ifconfig.me/ip", "https://api.ipify.org", "https://myip.ipip.net"}
var ipv4Re = regexp.MustCompile(`\d{1,3}(?:\.\d{1,3}){3}`)
// OutboundIP 返回本服务器访问公网时的出口 IP(微信后台「IP白名单」需填入该 IP)。
// 结果内存缓存 5 分钟;服务器无法访问探测服务时返回空串,不影响主流程。
func OutboundIP() string {
outboundIPMu.Lock()
if time.Since(outboundIPAt) < outboundIPTTL {
ip := outboundIPCache
outboundIPMu.Unlock()
return ip
}
outboundIPMu.Unlock()
ip := detectOutboundIP()
outboundIPMu.Lock()
outboundIPCache = ip
outboundIPAt = time.Now()
outboundIPMu.Unlock()
return ip
}
func detectOutboundIP() string {
client := &http.Client{Timeout: 3 * time.Second}
for _, svc := range ipDetectServices {
resp, err := client.Get(svc)
if err != nil {
continue
}
body, rerr := io.ReadAll(io.LimitReader(resp.Body, 256))
resp.Body.Close()
if rerr != nil {
continue
}
if ip := firstIPv4(string(body)); ip != "" {
return ip
}
}
return ""
}
// firstIPv4 取文本中第一个合法 IPv4(形如 1.2.3.4),无则返回空串。
func firstIPv4(s string) string {
m := ipv4Re.FindString(s)
if m == "" || net.ParseIP(m) == nil {
return ""
}
return m
}
// ErrMsgIPs 从微信 errmsg 中提取 IP(如 "invalid ip 1.2.3.4 ipv6 ::ffff:1.2.3.4")。
func ErrMsgIPs(errmsg string) []string {
var out []string
seen := make(map[string]bool)
for _, m := range ipv4Re.FindAllString(errmsg, -1) {
if net.ParseIP(m) == nil || seen[m] {
continue
}
seen[m] = true
out = append(out, m)
}
return out
}
// logAPIError 记录微信接口错误详情:
// errcode / errmsg / 微信提示的 IP / 本服务器出口 IP / 排查建议只落服务端日志。
// 注册页、登录页等匿名可访问页面不应回显这些信息(暴露服务器出口 IP 与内部错误)。
func logAPIError(scene string, err error) {
if err == nil {
return
}
detail := err.Error()
var ae *APIError
if errors.As(err, &ae) && ae != nil {
if hint := ae.Explain(); hint != "" {
detail += ";" + hint
}
if ips := ErrMsgIPs(ae.Msg); len(ips) > 0 {
detail += ";微信提示的 IP:" + strings.Join(ips, ", ")
}
}
if ip := OutboundIP(); ip != "" {
detail += ";本服务器出口 IP:" + ip
}
logs.Warning("[wechat-mp] %s 失败:%s", scene, detail)
}
// ExplainError 提取微信接口错误的中文排查建议;无可用建议时返回 ("", false)。
func ExplainError(err error) (string, bool) {
var ae *APIError
@@ -84,7 +190,9 @@ func GetAccessToken(cfg *Config) (string, error) {
return "", err
}
if resp.AccessToken == "" {
return "", &APIError{API: "获取 access_token", Code: resp.ErrCode, Msg: resp.ErrMsg}
apiErr := &APIError{API: "获取 access_token", Code: resp.ErrCode, Msg: resp.ErrMsg}
logAPIError("获取 access_token", apiErr)
return "", apiErr
}
ttl := time.Duration(resp.ExpiresIn) * time.Second
if ttl <= tokenMinTTL {
@@ -125,7 +233,9 @@ func CreateQRCode(cfg *Config, scene string, expireSeconds int) (string, error)
return "", err
}
if resp.Ticket == "" {
return "", &APIError{API: "创建带参二维码", Code: resp.ErrCode, Msg: resp.ErrMsg}
apiErr := &APIError{API: "创建带参二维码", Code: resp.ErrCode, Msg: resp.ErrMsg}
logAPIError("创建带参二维码", apiErr)
return "", apiErr
}
return "https://mp.weixin.qq.com/cgi-bin/showqrcode?ticket=" + url.QueryEscape(resp.Ticket), nil
}
+42 -14
View File
@@ -5,6 +5,8 @@ import (
"strings"
"time"
"github.com/beego/beego/v2/core/logs"
"server/models"
)
@@ -18,6 +20,13 @@ import (
// WechatBindTypeRegister 注册验证码会话类型(yz_platform_wechat_mp_verify_code.bind_type)
const WechatBindTypeRegister = "register"
// ErrRegisterCodeInvalid 账号注册:验证码无效 / 已使用(引导语指向「注册」而非绑定用的「验证码」)
var ErrRegisterCodeInvalid = errors.New("验证码不正确或已过期:请在公众号内重新发送「注册」获取")
// RegisterQRDownNotice 带参二维码不可用(IP 白名单 40164 / 未认证 48001 等)时,
// 回显给注册页的用户级引导:详细原因见服务端日志,不暴露服务器出口 IP。
const RegisterQRDownNotice = "二维码暂不可用:请在公众号内直接发送「注册」获取验证码"
// StartRegisterVerify 创建注册验证码会话并生成带参二维码。
// 带参二维码生成失败(未认证公众号 48001 等)时回退:qrURL 为空、
// notice 返回引导(用户在公众号内发送「注册」获取验证码),scene 仍有效可轮询。
@@ -48,24 +57,43 @@ func StartRegisterVerify(ttl time.Duration) (scene, qrURL, notice string, expire
}
qrURL, err = CreateQRCode(cfg, scene, int(ttl.Seconds())+120)
if err != nil {
// 不吞真实错误:与扫码登录 StartScanLogin 一致,把微信返回的
// errcode 与排查建议(IP 白名单 / 48001 未认证等)透出,便于定位;
// 同时保留「公众号内发送注册」兜底路径,会话仍有效可轮询核销。
notice := "请在公众号内直接发送「注册」获取验证码"
var apiErr *APIError
if errors.As(err, &apiErr) {
notice = "生成带参二维码失败(" + apiErr.Error() + ")。" + notice
if hint := apiErr.Explain(); hint != "" {
notice += ":" + hint
}
} else {
notice = "生成带参二维码失败:" + err.Error() + "。" + notice
}
return scene, "", notice, expireAt, nil
// 注册页匿名可访问:errcode / errmsg / 服务器出口 IP 等排查信息已在
// CreateQRCode 内落到服务端日志(见 logAPIError),此处只回显用户级引导,
// 避免向访客泄露服务器出口 IP 与内部错误细节;
// 会话仍然有效,「公众号内发送注册」这条兜底路径可正常核销。
logs.Warning("[wechat-mp] 注册二维码不可用,已回退公众号发送「注册」:%v", err)
return scene, "", RegisterQRDownNotice, expireAt, nil
}
return scene, qrURL, "", expireAt, nil
}
// ConfirmAccountRegisterVerify 账号注册的验证码核销:
// 优先按 scene 核销二维码会话;该会话尚未扫码(用户没扫码,改为在公众号内发送
// 「注册」取码)、scene 失效或验证码不属于该会话时,直接按验证码认领会话。
//
// 账号注册不绑定手机号,服务号无法按手机号把「发送者」与网页会话关联起来,
// 因此不要求网页会话必须先被扫码:验证码有效即视为公众号内本人操作。
func ConfirmAccountRegisterVerify(scene, code string) (string, error) {
code = strings.TrimSpace(code)
if code == "" {
return "", ErrCodeNotPending
}
if strings.TrimSpace(scene) != "" {
if openID, err := ConfirmRegisterVerify(scene, code); err == nil {
return openID, nil
}
}
openID, err := ConfirmRegisterVerifyByCode(code)
if err != nil {
// 按码认领失败的提示要指向注册场景(发送「注册」),而非绑定场景的「验证码」
if errors.Is(err, ErrCodeNotPending) || errors.Is(err, ErrCodeUsed) {
return "", ErrRegisterCodeInvalid
}
return "", err
}
return openID, nil
}
// GetRegisterVerifyStatus 查询注册会话扫码状态(网页轮询展示「已扫码」)
func GetRegisterVerifyStatus(scene string) (scanned bool, expireAt time.Time, err error) {
row, err := GetVerifyCode(scene)