优化小程序
This commit is contained in:
1 parent
2e2b837a20
commit
727319a6eb
24 files changed
+1509
-200
No files matched your search
@@ -100,6 +100,15 @@ wechat_mp_secret_key = 8f3c2a1d94b74e28a6c5f0187d3e9b4c72a5d0e6f13b8c47d92a6e5f0
|
||||
# 缺省回落 wechat_mp_callback_base / payment_callback_base;本地开发可填 http://127.0.0.1:4401(仅同机可点)
|
||||
# wechat_mp_login_base = https://back.yunzer.cn
|
||||
|
||||
# ==================== 微信小程序注册核验 ====================
|
||||
# 注册页「微信身份核验」优先用小程序码:网页展示小程序码 → 用户扫码在小程序内确认 →
|
||||
# 网页继续注册(无需公众号)。小程序未配置或小程序码生成失败时自动回退公众号验证码 / 短信。
|
||||
# 小程序码指向的页面路径(该页面需在小程序已发布,不带前导 /):
|
||||
# wechat_mini_register_page = pages/register/confirm
|
||||
# 小程序码环境:release(正式版,默认)/ trial(体验版)/ develop(开发版,需开发者扫码)
|
||||
# wechat_mini_register_env = release
|
||||
wechat_mini_register_env = trial
|
||||
|
||||
# ==================== 支付模块 ====================
|
||||
# 渠道敏感参数(商户密钥/私钥/Secret)入库加密密钥:任意随机串即可,代码内部做 SHA-256 派生。
|
||||
# 注意:更换该值后,历史已保存的渠道参数将无法解密,需在各渠道配置页重新保存一次。
|
||||
|
||||
+106
-21
@@ -7,7 +7,9 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"server/models"
|
||||
"server/services"
|
||||
"server/services/wechatmini"
|
||||
"server/services/wechatmp"
|
||||
|
||||
beego "github.com/beego/beego/v2/server/web"
|
||||
@@ -101,16 +103,24 @@ func (c *AuthAccountController) SendRegisterCode() {
|
||||
func (c *AuthAccountController) WechatStart() {
|
||||
scene, qrURL, notice, expireAt, err := wechatmp.StartRegisterVerify(wechatmp.DefaultVerifyTTL)
|
||||
if err != nil {
|
||||
c.serveJSON(map[string]interface{}{"code": 400, "msg": err.Error()})
|
||||
// 公众号未配置 / 未启用:仍回传「关注二维码」,用户可扫码进公众号发送「注册」取码
|
||||
c.serveJSON(map[string]interface{}{
|
||||
"code": 400,
|
||||
"msg": "微信扫码核验暂不可用,请改用手机号验证码注册",
|
||||
"data": map[string]interface{}{"follow_qrcode": wechatmp.FollowQrcodeURL()},
|
||||
})
|
||||
return
|
||||
}
|
||||
c.serveJSON(map[string]interface{}{
|
||||
"code": 200,
|
||||
"data": map[string]interface{}{
|
||||
"scene": scene,
|
||||
"qr_url": qrURL,
|
||||
"notice": notice,
|
||||
"expire_at": expireAt.Format(time.RFC3339),
|
||||
"scene": scene,
|
||||
"qr_url": qrURL,
|
||||
// 带参二维码生成失败(IP 白名单 / 未认证 48001 等)时前端改展示关注二维码,
|
||||
// 用户扫码关注后在公众号内发送「注册」照样能拿到验证码
|
||||
"follow_qrcode": wechatmp.FollowQrcodeURL(),
|
||||
"notice": notice,
|
||||
"expire_at": expireAt.Format(time.RFC3339),
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -133,11 +143,54 @@ func (c *AuthAccountController) WechatStatus() {
|
||||
})
|
||||
}
|
||||
|
||||
// WechatMiniStart 账号注册:获取小程序码(扫码后在小程序内确认注册)。
|
||||
// 小程序未配置 / 小程序码生成失败时返回 400 + reason=mini_unavailable,
|
||||
// 前端据此回退公众号验证码或短信通道(详细错误只落服务端日志)。
|
||||
// GET /auth/register/mini/start
|
||||
func (c *AuthAccountController) WechatMiniStart() {
|
||||
scene, qrURL, expireAt, err := wechatmini.StartRegisterVerify(wechatmini.DefaultRegisterTTL)
|
||||
if err != nil {
|
||||
c.serveJSON(map[string]interface{}{
|
||||
"code": 400,
|
||||
"msg": "微信扫码核验暂不可用,请改用手机号验证码注册",
|
||||
"reason": "mini_unavailable",
|
||||
})
|
||||
return
|
||||
}
|
||||
c.serveJSON(map[string]interface{}{
|
||||
"code": 200,
|
||||
"data": map[string]interface{}{
|
||||
"scene": scene,
|
||||
"qr_url": qrURL,
|
||||
"expire_at": expireAt.Format(time.RFC3339),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// WechatMiniStatus 账号注册:轮询小程序端确认状态(展示「已确认」)
|
||||
// GET /auth/register/mini/status?scene=xxx
|
||||
func (c *AuthAccountController) WechatMiniStatus() {
|
||||
scene := strings.TrimSpace(c.GetString("scene"))
|
||||
confirmed, expireAt, err := wechatmini.GetRegisterStatus(scene)
|
||||
if err != nil {
|
||||
c.serveJSON(map[string]interface{}{"code": 400, "msg": err.Error()})
|
||||
return
|
||||
}
|
||||
c.serveJSON(map[string]interface{}{
|
||||
"code": 200,
|
||||
"data": map[string]interface{}{
|
||||
"confirmed": confirmed,
|
||||
"expire_at": expireAt.Format(time.RFC3339),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// RegisterVerify 第一步核验:验证码通过即创建统一身份,返回 register_token
|
||||
// POST /auth/register/verify
|
||||
//
|
||||
// mode=phone { mode, phone, code, password, confirm }
|
||||
// mode=account { mode, account, scene, code, password, confirm }
|
||||
// mode=mini { mode, account, scene, password, confirm } // scene 为小程序码会话,已在小程序内确认
|
||||
func (c *AuthAccountController) RegisterVerify() {
|
||||
var req struct {
|
||||
Mode string `json:"mode"`
|
||||
@@ -153,22 +206,35 @@ func (c *AuthAccountController) RegisterVerify() {
|
||||
}
|
||||
|
||||
openID := ""
|
||||
if req.Mode == "account" {
|
||||
unionID := ""
|
||||
provider := ""
|
||||
switch req.Mode {
|
||||
case "account":
|
||||
// 核销服务号验证码,得到扫码人 openid(注册完成后绑定微信登录):
|
||||
// 有 scene 走二维码会话核销;无 scene(公众号发送「注册」)按验证码认领
|
||||
// 先按 scene 核销二维码会话,未扫码(用户改走公众号内发送「注册」)时按验证码认领。
|
||||
// 账号注册不依赖手机号,服务号无法按手机号关联,故不以「是否已扫码」为前置条件。
|
||||
var err error
|
||||
if strings.TrimSpace(req.Scene) != "" {
|
||||
openID, err = wechatmp.ConfirmRegisterVerify(req.Scene, req.Code)
|
||||
} else {
|
||||
openID, err = wechatmp.ConfirmRegisterVerifyByCode(req.Code)
|
||||
}
|
||||
openID, err = wechatmp.ConfirmAccountRegisterVerify(req.Scene, req.Code)
|
||||
if err != nil {
|
||||
c.serveJSON(map[string]interface{}{"code": 400, "msg": err.Error()})
|
||||
return
|
||||
}
|
||||
provider = models.IdPWechat
|
||||
case "mini":
|
||||
// 核销小程序注册会话:扫码人已在小程序内用 wx.login 确认,取 openid/unionid
|
||||
oid, uid, _, cerr := wechatmini.ConsumeRegister(req.Scene)
|
||||
if cerr != nil {
|
||||
c.serveJSON(map[string]interface{}{"code": 400, "msg": cerr.Error()})
|
||||
return
|
||||
}
|
||||
openID = oid
|
||||
unionID = uid
|
||||
provider = models.IdPWechatMini
|
||||
}
|
||||
|
||||
identity, token, err := services.VerifyAndCreateIdentity(services.RegisterVerifyInput{
|
||||
// 身份推迟到第二步归属操作时创建(services.CompleteRegister),
|
||||
// 第一步只签发待注册令牌,避免超时未完成留下占用账号的半成品身份
|
||||
token, err := services.VerifyAndIssueRegisterToken(services.RegisterVerifyInput{
|
||||
Mode: req.Mode,
|
||||
Phone: req.Phone,
|
||||
Account: req.Account,
|
||||
@@ -176,6 +242,8 @@ func (c *AuthAccountController) RegisterVerify() {
|
||||
Password: req.Password,
|
||||
Confirm: req.Confirm,
|
||||
OpenID: openID,
|
||||
UnionID: unionID,
|
||||
Provider: provider,
|
||||
})
|
||||
if err != nil {
|
||||
c.serveJSON(map[string]interface{}{"code": 400, "msg": err.Error()})
|
||||
@@ -184,7 +252,7 @@ func (c *AuthAccountController) RegisterVerify() {
|
||||
c.serveJSON(map[string]interface{}{
|
||||
"code": 200,
|
||||
"msg": "验证通过",
|
||||
"data": map[string]interface{}{"register_token": token, "identity_id": identity.ID},
|
||||
"data": map[string]interface{}{"register_token": token},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -218,7 +286,14 @@ func (c *AuthAccountController) ApplyJoin() {
|
||||
if !c.readJSON(&req) {
|
||||
return
|
||||
}
|
||||
identityID, openID, err := services.PeekRegisterToken(req.Token)
|
||||
// 第二步归属操作开始:此刻才真正创建身份(核验通过 ≠ 已建号)
|
||||
if strings.TrimSpace(req.Token) == "" {
|
||||
c.serveJSON(map[string]interface{}{
|
||||
"code": 400, "msg": "注册会话已失效:未携带注册令牌,请返回第一步重新完成身份核验",
|
||||
})
|
||||
return
|
||||
}
|
||||
identityID, err := services.CompleteRegister(req.Token)
|
||||
if err != nil {
|
||||
c.serveJSON(map[string]interface{}{"code": 400, "msg": err.Error()})
|
||||
return
|
||||
@@ -229,7 +304,6 @@ func (c *AuthAccountController) ApplyJoin() {
|
||||
return
|
||||
}
|
||||
services.ConsumeRegisterToken(req.Token)
|
||||
services.BindRegisterWechat(identityID, openID)
|
||||
|
||||
c.serveJSON(map[string]interface{}{
|
||||
"code": 200,
|
||||
@@ -258,7 +332,14 @@ func (c *AuthAccountController) ApplyCreate() {
|
||||
if !c.readJSON(&req) {
|
||||
return
|
||||
}
|
||||
identityID, openID, err := services.PeekRegisterToken(req.Token)
|
||||
// 第二步归属操作开始:此刻才真正创建身份(核验通过 ≠ 已建号)
|
||||
if strings.TrimSpace(req.Token) == "" {
|
||||
c.serveJSON(map[string]interface{}{
|
||||
"code": 400, "msg": "注册会话已失效:未携带注册令牌,请返回第一步重新完成身份核验",
|
||||
})
|
||||
return
|
||||
}
|
||||
identityID, err := services.CompleteRegister(req.Token)
|
||||
if err != nil {
|
||||
c.serveJSON(map[string]interface{}{"code": 400, "msg": err.Error()})
|
||||
return
|
||||
@@ -277,7 +358,6 @@ func (c *AuthAccountController) ApplyCreate() {
|
||||
return
|
||||
}
|
||||
services.ConsumeRegisterToken(req.Token)
|
||||
services.BindRegisterWechat(identityID, openID)
|
||||
|
||||
c.serveJSON(map[string]interface{}{
|
||||
"code": 200,
|
||||
@@ -298,13 +378,18 @@ func (c *AuthAccountController) FinishSkip() {
|
||||
if !c.readJSON(&req) {
|
||||
return
|
||||
}
|
||||
identityID, openID, err := services.PeekRegisterToken(req.Token)
|
||||
if err != nil {
|
||||
// 第二步归属操作开始:此刻才真正创建身份(核验通过 ≠ 已建号)
|
||||
if strings.TrimSpace(req.Token) == "" {
|
||||
c.serveJSON(map[string]interface{}{
|
||||
"code": 400, "msg": "注册会话已失效:未携带注册令牌,请返回第一步重新完成身份核验",
|
||||
})
|
||||
return
|
||||
}
|
||||
if _, err := services.CompleteRegister(req.Token); err != nil {
|
||||
c.serveJSON(map[string]interface{}{"code": 400, "msg": err.Error()})
|
||||
return
|
||||
}
|
||||
services.ConsumeRegisterToken(req.Token)
|
||||
services.BindRegisterWechat(identityID, openID)
|
||||
|
||||
c.serveJSON(map[string]interface{}{
|
||||
"code": 200,
|
||||
|
||||
@@ -187,6 +187,45 @@ func (c *AuthWechatMiniController) Login() {
|
||||
c.serveJSON(resp)
|
||||
}
|
||||
|
||||
// RegisterConfirm 小程序端确认注册(网页注册页展示的小程序码扫码进入)
|
||||
// POST /auth/register/mini/confirm
|
||||
//
|
||||
// { "scene": "小程序码携带的会话标识", "code": "wx.login的code", "phone_code": "可选", "appid": "可选,多小程序" }
|
||||
//
|
||||
// 无登录态:scene + code 即凭证,服务端用 code 换 openid 后标记该会话已确认,
|
||||
// 网页端轮询到确认后继续填写账号密码;手机号快速验证为可选项(失败不阻断)。
|
||||
func (c *AuthWechatMiniController) RegisterConfirm() {
|
||||
var req struct {
|
||||
Scene string `json:"scene"`
|
||||
Code string `json:"code"`
|
||||
PhoneCode string `json:"phone_code"`
|
||||
AppID string `json:"appid"`
|
||||
}
|
||||
body := c.Ctx.Input.RequestBody
|
||||
if len(body) == 0 {
|
||||
body, _ = io.ReadAll(c.Ctx.Request.Body)
|
||||
}
|
||||
if err := json.Unmarshal(body, &req); err != nil {
|
||||
c.fail("", 400, "参数错误")
|
||||
return
|
||||
}
|
||||
req.Scene = strings.TrimSpace(req.Scene)
|
||||
if req.Scene == "" {
|
||||
c.fail("", 400, "缺少会话标识,请重新扫码")
|
||||
return
|
||||
}
|
||||
if err := wechatmini.ConfirmRegister(req.Scene, req.Code, req.PhoneCode, req.AppID); err != nil {
|
||||
msg := err.Error()
|
||||
if hint, ok := wechatmini.ExplainError(err); ok {
|
||||
msg = hint
|
||||
}
|
||||
logs.Warn("[wechat-mini] 注册确认失败 scene=%s: %v", req.Scene, err)
|
||||
c.fail(MiniReasonWechatAPI, 400, msg)
|
||||
return
|
||||
}
|
||||
c.serveJSON(map[string]interface{}{"code": 200, "msg": "确认成功,请回到电脑继续完成注册"})
|
||||
}
|
||||
|
||||
// Bind 把当前小程序 openid 绑定到已登录身份
|
||||
// POST /auth/wechat/miniapp/bind { "code": "wx.login的code" } Header: Bearer 令牌
|
||||
//
|
||||
|
||||
@@ -163,20 +163,30 @@ func (c *PlatformWechatMpController) TestConnection() {
|
||||
c.jsonErr(400, 400, "请先保存 AppID 与 AppSecret")
|
||||
return
|
||||
}
|
||||
// 出口 IP 一并返回:40164(IP 不在白名单)时管理员可直接复制去微信后台配置
|
||||
outboundIP := wechatmp.OutboundIP()
|
||||
token, err := wechatmp.GetAccessToken(cfg)
|
||||
if err != nil {
|
||||
msg := "连接失败:" + err.Error()
|
||||
if hint, ok := wechatmp.ExplainError(err); ok {
|
||||
msg += ";" + hint
|
||||
}
|
||||
if outboundIP != "" {
|
||||
msg += ";本服务器出口 IP:" + outboundIP
|
||||
}
|
||||
c.jsonErr(400, 400, msg)
|
||||
return
|
||||
}
|
||||
msg := "连接成功:AppID 与 AppSecret 校验通过"
|
||||
if outboundIP != "" {
|
||||
msg += "(本服务器出口 IP:" + outboundIP + ")"
|
||||
}
|
||||
c.ok(map[string]interface{}{
|
||||
"access_token_suffix": wechatmp.MaskSecret(token),
|
||||
"callback_url": wechatmp.CallbackURL(),
|
||||
"verified": boolToInt8(cfg.Verified),
|
||||
}, "连接成功:AppID 与 AppSecret 校验通过")
|
||||
"outbound_ip": outboundIP,
|
||||
}, msg)
|
||||
}
|
||||
|
||||
// ============================ 绑定(当前平台用户) ============================
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
-- 清理「注册未完成」的残留身份(历史遗留)。
|
||||
-- 旧版本在第一步核验通过时就建号,第二步企业归属超时未完成时,
|
||||
-- 账号名/手机号被占用却无法使用,重新注册会提示「该账号已被占用」。
|
||||
-- 新版本已做到:第二步才建号 + 令牌过期回滚 + 校验时自动回收孤儿身份,
|
||||
-- 本脚本仅用于手动清理历史残留(注意:account / mobile 有唯一索引,
|
||||
-- 软删仍然占着索引,因此这里用物理删除)。
|
||||
--
|
||||
-- ① 先查:确认这些身份确实没企业归属、也没登录过
|
||||
SELECT i.id, i.account, i.mobile, i.nickname, i.create_time
|
||||
FROM yz_auth_identity i
|
||||
WHERE i.last_login_at IS NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM yz_auth_tenant_user t
|
||||
WHERE t.identity_id = i.id AND t.delete_time IS NULL
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM yz_auth_tenant_apply a
|
||||
WHERE a.identity_id = i.id AND a.delete_time IS NULL
|
||||
);
|
||||
|
||||
-- ② 再清:把 '账号名' 换成第①步查到的账号(手机号注册则改用 mobile = '手机号')
|
||||
DELETE FROM yz_auth_identity_third
|
||||
WHERE identity_id IN (
|
||||
SELECT id FROM (SELECT id FROM yz_auth_identity WHERE account = '账号名') x
|
||||
);
|
||||
|
||||
DELETE FROM yz_auth_identity WHERE account = '账号名';
|
||||
@@ -0,0 +1,21 @@
|
||||
-- 微信小程序扫码注册核验会话:
|
||||
-- PC 注册页发起会话并展示小程序码(scene)→ 用户微信扫码进入小程序「注册确认」页 →
|
||||
-- 小程序端 wx.login 拿 code(可选手机号快速验证)调 /auth/register/mini/confirm →
|
||||
-- 服务端 code2session 得 openid/unionid 写入本表 → PC 端轮询到已确认后继续注册,
|
||||
-- 第一步核验(mode=mini)按 scene 核销并取出 openid(注册完成后绑定小程序登录)。
|
||||
CREATE TABLE IF NOT EXISTS `yz_auth_mini_register` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`scene` VARCHAR(64) NOT NULL COMMENT '注册会话标识(小程序码 scene)',
|
||||
`openid` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '确认用户的小程序 openid',
|
||||
`unionid` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '确认用户的 unionid(同主体多端互通)',
|
||||
`mobile` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '手机号快速验证拿到的手机号(可选)',
|
||||
`app_id` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '确认时所用的小程序 AppID(多应用场景)',
|
||||
`status` TINYINT NOT NULL DEFAULT 0 COMMENT '0待确认 1已确认 2已核销 3已过期',
|
||||
`expire_at` DATETIME NOT NULL COMMENT '过期时间',
|
||||
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`update_time` DATETIME DEFAULT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_scene` (`scene`),
|
||||
KEY `idx_openid` (`openid`),
|
||||
KEY `idx_expire_at` (`expire_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='微信小程序扫码注册核验会话';
|
||||
@@ -0,0 +1,25 @@
|
||||
-- 统一认证:注册会话(第一步核验 → 第二步企业归属)
|
||||
-- 之前注册会话只存在进程内存里,服务重启 / 多副本部署 / 填表超时都会导致
|
||||
-- 「注册会话已失效,请重新注册」。改为落库后:
|
||||
-- ① 重启、多实例共享都不丢;② 有效期可放宽到 2 小时(填企业资料+上传执照足够);
|
||||
-- ③ 身份仍在第二步才创建,超时/放弃时不会留下占用账号的半成品身份。
|
||||
CREATE TABLE IF NOT EXISTS `yz_auth_register_session` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`token` VARCHAR(64) NOT NULL COMMENT '注册令牌(第一步核验通过后签发)',
|
||||
`account` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '账号名(账号 / 小程序注册;短信注册为空)',
|
||||
`mobile` VARCHAR(20) NOT NULL DEFAULT '' COMMENT '手机号(短信注册)',
|
||||
`nickname` VARCHAR(64) NOT NULL DEFAULT '',
|
||||
`password_hash` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '待注册密码哈希(argon2id PHC 格式)',
|
||||
`password_algo` VARCHAR(16) NOT NULL DEFAULT 'argon2id',
|
||||
`openid` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '核验得到的微信 openid',
|
||||
`unionid` VARCHAR(64) NOT NULL DEFAULT '',
|
||||
`provider` VARCHAR(32) NOT NULL DEFAULT '' COMMENT 'wechat(公众号) / wechat_mini(小程序)',
|
||||
`identity_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '第二步建号后写回;0 = 尚未建号',
|
||||
`status` TINYINT NOT NULL DEFAULT 0 COMMENT '0待完成 1已完成',
|
||||
`expire_at` DATETIME NOT NULL COMMENT '过期时间',
|
||||
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`update_time` DATETIME DEFAULT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_token` (`token`),
|
||||
KEY `idx_expire_at` (`expire_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='统一认证-注册会话';
|
||||
@@ -0,0 +1,82 @@
|
||||
-- 修复:注册「创建企业」审批通过后,申请人登录提示「该账号未绑定任何企业」。
|
||||
--
|
||||
-- 两个叠在一起的 bug:
|
||||
-- ① finishApply 没把新建租户 tid 回写到申请单 → yz_auth_tenant_apply.tid 一直是 0,
|
||||
-- 事后无法按 tid 追溯/修数据(代码已修复)。
|
||||
-- ② 绑定时调用 SyncTenantUser 按「手机/邮箱/账号」归并键匹配身份,账号注册的身份
|
||||
-- 没有手机号、邮箱,企业内账号又是拼出来的 user{id},匹配不到 → 新建了一个身份
|
||||
-- (account=user{id})并绑定到新租户,申请人自己的身份反而没有绑定记录
|
||||
-- (代码已改为按 identity_id 直接写绑定)。
|
||||
-- 本脚本修正【已审批的历史数据】,下列语句均可直接执行,无需替换占位符。
|
||||
|
||||
-- ============ ① 回填申请单的 tid(按企业名称匹配新建的租户) ============
|
||||
UPDATE yz_auth_tenant_apply a
|
||||
JOIN yz_system_tenant t
|
||||
ON t.tenant_name = a.tenant_name AND t.delete_time IS NULL
|
||||
SET a.tid = t.id, a.update_time = NOW()
|
||||
WHERE a.type = 2 AND a.status = 1 AND a.delete_time IS NULL
|
||||
AND (a.tid = 0 OR a.tid IS NULL);
|
||||
|
||||
-- ============ ② 排查:申请 / 身份 / 绑定 / 租户对照 ============
|
||||
SELECT a.id AS apply_id, a.status AS apply_status, a.tid, a.tenant_name,
|
||||
a.identity_id AS apply_identity, ai.account AS apply_account, ai.mobile AS apply_mobile,
|
||||
tu.id AS bind_id, tu.identity_id AS bind_identity, bi.account AS bind_account,
|
||||
tu.status AS bind_status, tu.delete_time AS bind_deleted, tu.group_id,
|
||||
t.status AS tenant_status
|
||||
FROM yz_auth_tenant_apply a
|
||||
LEFT JOIN yz_auth_identity ai ON ai.id = a.identity_id
|
||||
LEFT JOIN yz_auth_tenant_user tu ON tu.tid = a.tid
|
||||
LEFT JOIN yz_auth_identity bi ON bi.id = tu.identity_id
|
||||
LEFT JOIN yz_system_tenant t ON t.id = a.tid
|
||||
WHERE a.delete_time IS NULL
|
||||
ORDER BY a.id DESC
|
||||
LIMIT 20;
|
||||
-- 关注:bind_identity 是否 = apply_identity;bind_status=1;bind_deleted=NULL;tenant_status=1
|
||||
|
||||
-- ============ ③ 修正绑错人的行(改指到申请人,并复位状态) ============
|
||||
UPDATE yz_auth_tenant_user tu
|
||||
JOIN yz_auth_tenant_apply a
|
||||
ON a.tid = tu.tid AND a.type = 2 AND a.status = 1 AND a.delete_time IS NULL
|
||||
SET tu.identity_id = a.identity_id, tu.status = 1, tu.is_default = 1,
|
||||
tu.delete_time = NULL, tu.update_time = NOW()
|
||||
WHERE tu.identity_id <> a.identity_id;
|
||||
|
||||
-- ============ ④ 修正「绑对人但状态异常」的行(status≠1 或已软删) ============
|
||||
UPDATE yz_auth_tenant_user tu
|
||||
JOIN yz_auth_tenant_apply a
|
||||
ON a.tid = tu.tid AND a.type = 2 AND a.status = 1 AND a.delete_time IS NULL
|
||||
SET tu.status = 1, tu.is_default = 1, tu.delete_time = NULL, tu.update_time = NOW()
|
||||
WHERE tu.identity_id = a.identity_id
|
||||
AND (tu.status <> 1 OR tu.delete_time IS NOT NULL);
|
||||
|
||||
-- ============ ⑤ 兜底:申请人压根没有绑定行时补一条 ============
|
||||
INSERT INTO yz_auth_tenant_user
|
||||
(tid, identity_id, account, name, phone, email, group_id, org_id, is_default, status, create_time)
|
||||
SELECT a.tid, a.identity_id,
|
||||
COALESCE(NULLIF(i.mobile, ''), NULLIF(i.account, ''), CONCAT('user', a.identity_id)),
|
||||
COALESCE(i.nickname, ''), i.mobile, i.email,
|
||||
0, 0, 1, 1, NOW()
|
||||
FROM yz_auth_tenant_apply a
|
||||
JOIN yz_auth_identity i ON i.id = a.identity_id
|
||||
WHERE a.type = 2 AND a.status = 1 AND a.tid > 0 AND a.delete_time IS NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM yz_auth_tenant_user tu
|
||||
WHERE tu.tid = a.tid AND tu.identity_id = a.identity_id
|
||||
);
|
||||
|
||||
-- ============ ⑥ 核对:申请人名下应能看到企业 ============
|
||||
SELECT tu.identity_id, tu.tid, tu.status AS bind_status, tu.is_default, tu.group_id,
|
||||
t.tenant_name, t.status AS tenant_status
|
||||
FROM yz_auth_tenant_user tu
|
||||
JOIN yz_system_tenant t ON t.id = tu.tid
|
||||
JOIN yz_auth_tenant_apply a
|
||||
ON a.tid = tu.tid AND a.type = 2 AND a.status = 1 AND a.identity_id = tu.identity_id;
|
||||
|
||||
-- ============ ⑦ 可选:清理审批时误建的身份(确认无任何绑定后再执行) ============
|
||||
-- SELECT id, account, mobile, last_login_at FROM yz_auth_identity
|
||||
-- WHERE account LIKE 'user%' AND last_login_at IS NULL
|
||||
-- AND NOT EXISTS (SELECT 1 FROM yz_auth_tenant_user tu WHERE tu.identity_id = yz_auth_identity.id);
|
||||
-- DELETE i FROM yz_auth_identity i
|
||||
-- WHERE i.account LIKE 'user%' AND i.last_login_at IS NULL
|
||||
-- AND NOT EXISTS (SELECT 1 FROM yz_auth_tenant_user tu WHERE tu.identity_id = i.id)
|
||||
-- AND NOT EXISTS (SELECT 1 FROM yz_auth_identity_third th WHERE th.identity_id = i.id);
|
||||
@@ -46,12 +46,13 @@ func (m *AuthIdentity) TableName() string {
|
||||
|
||||
// 第三方登录 provider 常量
|
||||
const (
|
||||
IdPWechat = "wechat"
|
||||
IdPDingTalk = "dingtalk"
|
||||
IdPFeishu = "feishu"
|
||||
IdPQQ = "qq"
|
||||
IdPGitHub = "github"
|
||||
IdPGoogle = "google"
|
||||
IdPWechat = "wechat"
|
||||
IdPWechatMini = "wechat_mini"
|
||||
IdPDingTalk = "dingtalk"
|
||||
IdPFeishu = "feishu"
|
||||
IdPQQ = "qq"
|
||||
IdPGitHub = "github"
|
||||
IdPGoogle = "google"
|
||||
)
|
||||
|
||||
// AuthIdentityThird 第三方身份绑定:yz_auth_identity_third
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// =============================================================
|
||||
// 微信小程序扫码注册核验会话 yz_auth_mini_register
|
||||
// (建表脚本:docs/sql/create_auth_mini_register.sql)
|
||||
// PC 注册页发起会话并展示小程序码 → 用户扫码在小程序内确认(wx.login)→
|
||||
// 服务端写入 openid/unionid → PC 端轮询到已确认后继续注册,核验时按 scene 核销。
|
||||
// =============================================================
|
||||
|
||||
// 会话状态
|
||||
const (
|
||||
MiniRegisterStatusWaiting = 0 // 待确认(已生成小程序码)
|
||||
MiniRegisterStatusConfirmed = 1 // 已确认(小程序端已上报 openid)
|
||||
MiniRegisterStatusUsed = 2 // 已核销(注册第一步已取用)
|
||||
MiniRegisterStatusExpired = 3 // 已过期
|
||||
)
|
||||
|
||||
// AuthMiniRegister 微信小程序扫码注册核验会话
|
||||
type AuthMiniRegister struct {
|
||||
ID uint64 `orm:"column(id);pk;auto" json:"id"`
|
||||
Scene string `orm:"column(scene);size(64)" json:"scene"`
|
||||
OpenID string `orm:"column(openid);size(64)" json:"openid"`
|
||||
UnionID string `orm:"column(unionid);size(64)" json:"unionid"`
|
||||
Mobile string `orm:"column(mobile);size(32)" json:"mobile"`
|
||||
AppID string `orm:"column(app_id);size(64)" json:"app_id"`
|
||||
Status int8 `orm:"column(status)" json:"status"`
|
||||
ExpireAt time.Time `orm:"column(expire_at);type(datetime)" json:"expire_at"`
|
||||
CreateTime time.Time `orm:"column(create_time);type(datetime);auto_now_add" json:"create_time"`
|
||||
UpdateTime *time.Time `orm:"column(update_time);type(datetime);null" json:"update_time"`
|
||||
}
|
||||
|
||||
func (m *AuthMiniRegister) TableName() string { return "yz_auth_mini_register" }
|
||||
@@ -0,0 +1,38 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// =============================================================
|
||||
// 注册会话 yz_auth_register_session
|
||||
// (建表脚本:docs/sql/create_auth_register_session.sql)
|
||||
// 第一步核验通过 → 写入本表并签发 token(此时尚未创建身份);
|
||||
// 第二步企业归属时按 token 取出、建号并写回 identity_id;业务成功后删除。
|
||||
// 落库而非放内存:服务重启 / 多副本部署都不会让用户的注册流程中途失效。
|
||||
// =============================================================
|
||||
|
||||
// 注册会话状态
|
||||
const (
|
||||
RegisterSessionPending = 0 // 待完成(第二步归属未提交)
|
||||
RegisterSessionDone = 1 // 已完成
|
||||
)
|
||||
|
||||
// AuthRegisterSession 注册会话
|
||||
type AuthRegisterSession struct {
|
||||
ID uint64 `orm:"column(id);pk;auto" json:"id"`
|
||||
Token string `orm:"column(token);size(64)" json:"-"`
|
||||
Account string `orm:"column(account);size(64)" json:"account"`
|
||||
Mobile string `orm:"column(mobile);size(20)" json:"mobile"`
|
||||
Nickname string `orm:"column(nickname);size(64)" json:"nickname"`
|
||||
PasswordHash string `orm:"column(password_hash);size(255)" json:"-"`
|
||||
PasswordAlgo string `orm:"column(password_algo);size(16)" json:"password_algo"`
|
||||
OpenID string `orm:"column(openid);size(64)" json:"openid"`
|
||||
UnionID string `orm:"column(unionid);size(64)" json:"unionid"`
|
||||
Provider string `orm:"column(provider);size(32)" json:"provider"`
|
||||
IdentityID uint64 `orm:"column(identity_id)" json:"identity_id"`
|
||||
Status int8 `orm:"column(status)" json:"status"`
|
||||
ExpireAt time.Time `orm:"column(expire_at);type(datetime)" json:"expire_at"`
|
||||
CreateTime time.Time `orm:"column(create_time);type(datetime);auto_now_add" json:"create_time"`
|
||||
UpdateTime *time.Time `orm:"column(update_time);type(datetime);null" json:"update_time"`
|
||||
}
|
||||
|
||||
func (m *AuthRegisterSession) TableName() string { return "yz_auth_register_session" }
|
||||
@@ -237,6 +237,11 @@ func Init(_ string) {
|
||||
new(WechatMpLoginToken),
|
||||
new(WechatMpScanLogin),
|
||||
|
||||
// 微信小程序扫码注册核验会话(docs/sql/create_auth_mini_register.sql)
|
||||
new(AuthMiniRegister),
|
||||
// 注册会话(第一步核验 → 第二步企业归属,docs/sql/create_auth_register_session.sql)
|
||||
new(AuthRegisterSession),
|
||||
|
||||
// 微信小程序配置(docs/sql/create_wechat_mini_config.sql)
|
||||
new(WechatMiniConfig),
|
||||
// 微信小程序多应用配置(docs/sql/create_wechat_mini_app.sql)
|
||||
|
||||
@@ -52,6 +52,10 @@ func Register() {
|
||||
beego.Router("/auth/register/verify", &authctl.AuthAccountController{}, "post:RegisterVerify")
|
||||
beego.Router("/auth/register/wechat/start", &authctl.AuthAccountController{}, "get:WechatStart")
|
||||
beego.Router("/auth/register/wechat/status", &authctl.AuthAccountController{}, "get:WechatStatus")
|
||||
// 账号注册的微信核验(小程序方案):小程序码扫码 → 小程序内确认 → 网页继续
|
||||
beego.Router("/auth/register/mini/start", &authctl.AuthAccountController{}, "get:WechatMiniStart")
|
||||
beego.Router("/auth/register/mini/status", &authctl.AuthAccountController{}, "get:WechatMiniStatus")
|
||||
beego.Router("/auth/register/mini/confirm", &authctl.AuthWechatMiniController{}, "post:RegisterConfirm")
|
||||
beego.Router("/auth/register/tenant/search", &authctl.AuthAccountController{}, "get:TenantSearch")
|
||||
beego.Router("/auth/register/apply-join", &authctl.AuthAccountController{}, "post:ApplyJoin")
|
||||
beego.Router("/auth/register/apply-create", &authctl.AuthAccountController{}, "post:ApplyCreate")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
+112
-2
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
+87
-26
@@ -106,7 +106,6 @@
|
||||
.wechat-box img { width: 168px; height: 168px; object-fit: contain; }
|
||||
.wechat-box .scan-tip { font-size: 13px; color: var(--text-sub); margin-top: 8px; }
|
||||
.wechat-box .scan-tip.ok { color: #34b96f; font-weight: 600; }
|
||||
.wechat-box .mp-notice { font-size: 12px; color: #d08700; margin-top: 6px; }
|
||||
|
||||
/* 第二步:两个卡片 */
|
||||
.cards { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; }
|
||||
@@ -221,15 +220,14 @@
|
||||
<div class="field">
|
||||
<label>微信身份核验<span class="req">*</span></label>
|
||||
<div class="wechat-box" id="wechatBox">
|
||||
<img id="wechatQr" alt="公众号二维码" style="display:none">
|
||||
<img id="wechatQr" alt="微信核验二维码" style="display:none">
|
||||
<div class="scan-tip" id="scanTip">正在获取二维码…</div>
|
||||
<div class="mp-notice" id="mpNotice"></div>
|
||||
</div>
|
||||
<div class="input-row">
|
||||
<div class="input-row" id="codeRow">
|
||||
<input class="ipt" id="accountCode" maxlength="6" placeholder="公众号下发的 6 位验证码">
|
||||
<button class="btn code-btn" id="btnQr" type="button">获取二维码</button>
|
||||
</div>
|
||||
<div class="hint">用微信扫码关注公众号后,验证码将通过公众号自动发送;无法扫码时可在公众号内直接发送「注册」获取验证码。</div>
|
||||
<div class="hint" id="wxHint">点击「获取二维码」,用微信扫码完成身份核验。</div>
|
||||
</div>
|
||||
<button class="btn btn-primary" id="btnVerifyAccount" type="button">验证并下一步</button>
|
||||
</div>
|
||||
@@ -300,7 +298,8 @@
|
||||
var CLIENT_ID = app.getAttribute('data-client-id') || '';
|
||||
var REDIRECT = app.getAttribute('data-redirect') || '';
|
||||
|
||||
var state = { mode: 'phone', token: '', scene: '', qrTimer: null, selectedTid: 0 };
|
||||
// wxChannel:微信核验通道,mini=小程序码确认(优先)/ mp=公众号验证码(回退)
|
||||
var state = { mode: 'phone', token: '', scene: '', qrTimer: null, selectedTid: 0, wxChannel: '' };
|
||||
|
||||
// ---------- 工具 ----------
|
||||
function $(id) { return document.getElementById(id); }
|
||||
@@ -393,39 +392,76 @@
|
||||
});
|
||||
});
|
||||
|
||||
// ---------- 账号注册 ----------
|
||||
// ---------- 账号注册:微信身份核验(小程序优先,不可用时回退公众号) ----------
|
||||
$('btnQr').addEventListener('click', function () {
|
||||
var btn = this; btn.disabled = true;
|
||||
$('wechatBox').classList.add('show');
|
||||
$('wechatQr').style.display = 'none';
|
||||
$('scanTip').textContent = '正在获取二维码…';
|
||||
$('scanTip').className = 'scan-tip';
|
||||
$('mpNotice').textContent = '';
|
||||
startMiniVerify(function (ok) {
|
||||
btn.disabled = false;
|
||||
if (ok) return;
|
||||
startMpVerify(btn); // 小程序未配置 / 小程序码生成失败 → 回退公众号验证码通道
|
||||
});
|
||||
});
|
||||
|
||||
function showQr(qrURL, tip) {
|
||||
$('wechatQr').src = qrURL;
|
||||
$('wechatQr').style.display = '';
|
||||
$('scanTip').textContent = tip;
|
||||
}
|
||||
|
||||
// 小程序方案:展示小程序码,用户在小程序内确认后无需填验证码
|
||||
function startMiniVerify(next) {
|
||||
fetch('/auth/register/mini/start').then(function (r) { return r.json(); }).then(function (res) {
|
||||
if (res.code !== 200 || !res.data || !res.data.qr_url) { next(false); return; }
|
||||
state.wxChannel = 'mini';
|
||||
state.scene = res.data.scene || '';
|
||||
$('codeRow').style.display = 'none';
|
||||
$('wxHint').textContent = '用微信扫码,在小程序内确认后即可继续填写(无需验证码)。';
|
||||
showQr(res.data.qr_url, '请用微信扫码,在小程序内确认注册');
|
||||
next(true);
|
||||
startQrPolling();
|
||||
}).catch(function () { next(false); });
|
||||
}
|
||||
|
||||
// 公众号回退:优先带参二维码;生成失败时展示「关注二维码」,
|
||||
// 用户扫码关注后在公众号内发送「注册」照样能收到验证码
|
||||
function startMpVerify(btn) {
|
||||
fetch('/auth/register/wechat/start').then(function (r) { return r.json(); }).then(function (res) {
|
||||
btn.disabled = false;
|
||||
if (res.code !== 200) { $('scanTip').textContent = res.msg || '获取二维码失败'; return; }
|
||||
state.scene = res.data.scene || '';
|
||||
if (res.data.qr_url) {
|
||||
$('wechatQr').src = res.data.qr_url;
|
||||
$('wechatQr').style.display = '';
|
||||
$('scanTip').textContent = '请用微信扫码并关注公众号';
|
||||
var d = res.data || {};
|
||||
state.wxChannel = 'mp';
|
||||
state.scene = d.scene || '';
|
||||
$('codeRow').style.display = '';
|
||||
$('wxHint').textContent = '扫码关注公众号后,在公众号内发送「注册」即可收到 6 位验证码。';
|
||||
var qr = d.qr_url || d.follow_qrcode || '';
|
||||
if (qr) {
|
||||
showQr(qr, d.qr_url ? '请用微信扫码并关注公众号' : '扫码关注公众号后,在公众号内发送「注册」获取验证码');
|
||||
if (d.qr_url) startQrPolling(); // 关注二维码不带 scene,无需轮询
|
||||
} else if (res.code === 200) {
|
||||
$('scanTip').textContent = d.notice || '无法展示二维码:请在公众号内直接发送「注册」获取验证码';
|
||||
} else {
|
||||
$('scanTip').textContent = '无法展示二维码:请在公众号内直接发送「注册」获取验证码';
|
||||
$('scanTip').textContent = '微信核验暂不可用,请改用手机号验证码注册';
|
||||
}
|
||||
if (res.data.notice) { $('mpNotice').textContent = res.data.notice; }
|
||||
startQrPolling();
|
||||
}).catch(function () { btn.disabled = false; $('scanTip').textContent = '网络异常,请重试'; });
|
||||
});
|
||||
}
|
||||
|
||||
function startQrPolling() {
|
||||
if (state.qrTimer) clearInterval(state.qrTimer);
|
||||
if (!state.scene) return;
|
||||
state.qrTimer = setInterval(function () {
|
||||
fetch('/auth/register/wechat/status?scene=' + encodeURIComponent(state.scene))
|
||||
var mini = state.wxChannel === 'mini';
|
||||
var url = mini ? '/auth/register/mini/status' : '/auth/register/wechat/status';
|
||||
fetch(url + '?scene=' + encodeURIComponent(state.scene))
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (res) {
|
||||
if (res.code === 200 && res.data && res.data.scanned) {
|
||||
$('scanTip').textContent = '已扫码,验证码已发送到公众号,请填入下方输入框';
|
||||
var ok = res.code === 200 && res.data && (mini ? res.data.confirmed : res.data.scanned);
|
||||
if (ok) {
|
||||
$('scanTip').textContent = mini
|
||||
? '已在小程序内确认,请继续填写下方信息'
|
||||
: '已扫码,验证码已发送到公众号,请填入下方输入框';
|
||||
$('scanTip').className = 'scan-tip ok';
|
||||
clearInterval(state.qrTimer);
|
||||
}
|
||||
@@ -439,9 +475,20 @@
|
||||
if (!/^[A-Za-z][A-Za-z0-9_-]{3,31}$/.test(account)) { showMsg('账号需 4-32 位,字母开头,仅含字母/数字/下划线/中划线'); return; }
|
||||
if (pwd.length < 6) { showMsg('密码长度不能少于6位'); return; }
|
||||
if (pwd !== pwd2) { showMsg('两次输入的密码不一致'); return; }
|
||||
if (!code) { showMsg('请输入公众号下发的验证码'); return; }
|
||||
if (!state.wxChannel) { showMsg('请先点击「获取二维码」完成微信核验'); return; }
|
||||
var mini = state.wxChannel === 'mini';
|
||||
// 小程序通道必须持有已确认的会话;公众号通道允许无 scene(用户手填公众号下发的验证码)
|
||||
if (mini && !state.scene) { showMsg('请重新获取二维码并在小程序内完成确认'); return; }
|
||||
if (!mini && !code) { showMsg('请输入公众号下发的验证码(在公众号内发送「注册」获取)'); return; }
|
||||
var btn = this; btn.disabled = true;
|
||||
post('/auth/register/verify', { mode: 'account', account: account, scene: state.scene, code: code, password: pwd, confirm: pwd2 }, function (res) {
|
||||
post('/auth/register/verify', {
|
||||
mode: mini ? 'mini' : 'account',
|
||||
account: account,
|
||||
scene: state.scene,
|
||||
code: code,
|
||||
password: pwd,
|
||||
confirm: pwd2
|
||||
}, function (res) {
|
||||
btn.disabled = false;
|
||||
if (res.code !== 200) { showMsg(res.msg || '验证失败'); return; }
|
||||
state.token = res.data.register_token;
|
||||
@@ -483,12 +530,24 @@
|
||||
}).catch(function () { list.innerHTML = '<div class="search-empty">搜索失败,请重试</div>'; });
|
||||
});
|
||||
|
||||
// 第二步提交失败:注册会话过期时退回第一步重新核验(此时账号尚未占用,可重新注册)
|
||||
function failStep2(res, fallback) {
|
||||
var msg = (res && res.msg) || fallback || '提交失败';
|
||||
if (/注册会话已过期|注册会话已失效/.test(msg)) {
|
||||
state.token = '';
|
||||
goStep(1);
|
||||
showMsg(msg + ',请重新完成身份核验');
|
||||
return;
|
||||
}
|
||||
showMsg(msg);
|
||||
}
|
||||
|
||||
$('btnJoin').addEventListener('click', function () {
|
||||
if (!state.selectedTid) { showMsg('请先选择企业'); return; }
|
||||
var btn = this; btn.disabled = true;
|
||||
post('/auth/register/apply-join', { token: state.token, tid: state.selectedTid }, function (res) {
|
||||
btn.disabled = false;
|
||||
if (res.code !== 200) { showMsg(res.msg || '提交失败'); return; }
|
||||
if (res.code !== 200) { failStep2(res, '提交失败'); return; }
|
||||
done('申请已提交', '加入企业的申请已提交,请等待企业管理员在后台审核,审核通过后即可登录。', res.redirect || buildLoginURL(''));
|
||||
});
|
||||
});
|
||||
@@ -516,7 +575,9 @@
|
||||
});
|
||||
|
||||
$('btnCreate').addEventListener('click', function () {
|
||||
if (!state.token) { failStep2({ msg: '注册会话已失效' }, '注册会话已失效'); return; }
|
||||
var form = {
|
||||
token: state.token,
|
||||
tenant_name: $('cName').value.trim(),
|
||||
credit_code: $('cCredit').value.trim(),
|
||||
legal_person: $('cLegal').value.trim(),
|
||||
@@ -541,7 +602,7 @@
|
||||
var btn = this; btn.disabled = true;
|
||||
post('/auth/register/apply-create', form, function (res) {
|
||||
btn.disabled = false;
|
||||
if (res.code !== 200) { showMsg(res.msg || '提交失败'); return; }
|
||||
if (res.code !== 200) { failStep2(res, '提交失败'); return; }
|
||||
done('申请已提交', '创建企业的申请已提交,请等待平台审核;审核通过后当前账号将成为企业管理员账号。', res.redirect || buildLoginURL(''));
|
||||
});
|
||||
});
|
||||
@@ -550,7 +611,7 @@
|
||||
$('btnSkip').addEventListener('click', function () {
|
||||
if (!confirm('暂不加入企业将直接完成注册,之后可在登录后申请加入。确定吗?')) return;
|
||||
post('/auth/register/finish', { token: state.token }, function (res) {
|
||||
if (res.code !== 200) { showMsg(res.msg || '操作失败'); return; }
|
||||
if (res.code !== 200) { failStep2(res, '操作失败'); return; }
|
||||
done('注册成功', '账号已创建。你当前未加入任何企业,可稍后申请加入。', res.redirect || buildLoginURL(''));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -381,7 +381,8 @@
|
||||
</div>
|
||||
<div class="form-tip">
|
||||
上传公众号的「关注二维码」(公众号后台「账号设置 → 二维码」下载,2MB 内图片)。<br />
|
||||
用户在租户端「绑定微信」页扫码进入公众号会话 → 发送「验证码」→ 回页填码完成绑定(未认证公众号无法生成带参二维码,统一使用此图片)。
|
||||
用户在租户端「绑定微信」页扫码进入公众号会话 → 发送「验证码」→ 回页填码完成绑定(未认证公众号无法生成带参二维码,统一使用此图片)。<br />
|
||||
注册页同样依赖它:带参二维码生成失败(40164 IP 白名单 / 48001 未认证)时,注册页会展示这张图片,引导用户扫码关注后发送「注册」取验证码。
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="服务器地址">
|
||||
@@ -393,7 +394,7 @@
|
||||
<div class="form-tip">
|
||||
① 微信后台(公众平台 / 微信开发者平台)「设置与开发 → 基本配置」→ 复制 AppID / AppSecret 填入上方,保存并启用;<br />
|
||||
② 将本地址连同上方 Token / EncodingAESKey 填入微信后台「服务器配置」(加解密方式保持一致)——提交时微信会立即请求本地址校验,需本页已保存并启用;<br />
|
||||
③ 若「连接测试」提示 40164,请在微信后台「基本配置 → IP白名单」加入本服务器出口 IP(服务器执行 curl ifconfig.me 可查看)。
|
||||
③ 若「连接测试」提示 40164,请在微信后台「基本配置 → IP白名单」加入本服务器出口 IP——点「连接测试」即可看到当前出口 IP(也可在服务器执行 curl ifconfig.me 查看)。
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
|
||||
@@ -118,6 +118,19 @@ export function loginByMiniapp({ code, phoneCode = '' } = {}) {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 小程序端确认注册:网页注册页展示的小程序码扫码进入本页,
|
||||
* 用户点确认后由服务端把 openid 绑定到该注册会话,网页端即可继续注册。
|
||||
* @param {{ scene: string, code: string, phoneCode?: string, appid?: string }} params
|
||||
*/
|
||||
export function confirmMiniappRegister({ scene, code, phoneCode = '', appid = '' } = {}) {
|
||||
return request({
|
||||
url: '/auth/register/mini/confirm',
|
||||
method: 'POST',
|
||||
data: { scene, code, phone_code: phoneCode, appid }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 已登录态下把当前微信 openid 绑定到该账号(静默调用,失败不提示)。
|
||||
* 小程序认证中 / 未开通手机号能力时,一键登录靠这条绑定按 openid 认人。
|
||||
|
||||
Generated
-39
@@ -4789,9 +4789,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -4812,9 +4809,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -4835,9 +4829,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -4858,9 +4849,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -4881,9 +4869,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -4904,9 +4889,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5080,9 +5062,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5096,9 +5075,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5112,9 +5088,6 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5128,9 +5101,6 @@
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5144,9 +5114,6 @@
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5160,9 +5127,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5176,9 +5140,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
||||
+9
-1
@@ -412,12 +412,20 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"path {
|
||||
"path": "pages/login/forget",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "忘记密码"
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
"path": "pages/register/confirm",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "确认注册"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/login/geetest-webview",
|
||||
"style": {
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
<template>
|
||||
<view class="rp-page">
|
||||
<view class="rp-card">
|
||||
<text class="rp-title">确认注册</text>
|
||||
<text class="rp-desc">你正在为电脑端的账号注册完成身份确认。点击确认后,请回到电脑继续填写账号信息。</text>
|
||||
|
||||
<button class="rp-btn" :class="{ disabled: loading }" :disabled="loading" @tap="confirm('')">
|
||||
{{ loading ? '确认中…' : '确认是我本人注册' }}
|
||||
</button>
|
||||
|
||||
<!-- #ifdef MP-WEIXIN -->
|
||||
<button class="rp-btn ghost" :disabled="loading" open-type="getPhoneNumber" @getphonenumber="onPhone">
|
||||
验证手机号并确认(可选)
|
||||
</button>
|
||||
<!-- #endif -->
|
||||
|
||||
<text class="rp-status" :class="{ ok: success, err: !!errorMsg }">{{ statusText }}</text>
|
||||
|
||||
<view class="rp-back" @tap="backHome">
|
||||
<text class="rp-link">返回首页</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/**
|
||||
* 小程序码扫码注册确认页:
|
||||
* 电脑端注册页展示小程序码(scene=注册会话)→ 微信扫码进入本页 →
|
||||
* uni.login 取 code 调 /auth/register/mini/confirm → 电脑端轮询到已确认后继续注册。
|
||||
*/
|
||||
import { ref } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { confirmMiniappRegister } from '@/api/auth.js'
|
||||
|
||||
const scene = ref('')
|
||||
const loading = ref(false)
|
||||
const success = ref(false)
|
||||
const errorMsg = ref('')
|
||||
const statusText = ref('')
|
||||
|
||||
onLoad((options = {}) => {
|
||||
// scene 由小程序码携带,微信会自动做一次 URL 解码
|
||||
scene.value = options.scene || ''
|
||||
if (!scene.value) {
|
||||
errorMsg.value = '缺少会话标识,请回到电脑端重新获取二维码'
|
||||
statusText.value = errorMsg.value
|
||||
}
|
||||
})
|
||||
|
||||
function miniAppId() {
|
||||
try {
|
||||
const info = uni.getAccountInfoSync()
|
||||
return (info && info.miniProgram && info.miniProgram.appId) || ''
|
||||
} catch (e) {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
function uniLoginCode() {
|
||||
return new Promise((resolve, reject) => {
|
||||
uni.login({
|
||||
provider: 'weixin',
|
||||
success: (res) => (res && res.code ? resolve(res.code) : reject(new Error('获取登录凭证失败'))),
|
||||
fail: () => reject(new Error('获取登录凭证失败'))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function confirm(phoneCode = '') {
|
||||
if (loading.value) return
|
||||
if (!scene.value) {
|
||||
errorMsg.value = '缺少会话标识,请回到电脑端重新获取二维码'
|
||||
statusText.value = errorMsg.value
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
errorMsg.value = ''
|
||||
statusText.value = '正在确认…'
|
||||
try {
|
||||
const code = await uniLoginCode()
|
||||
await confirmMiniappRegister({ scene: scene.value, code, phoneCode, appid: miniAppId() })
|
||||
success.value = true
|
||||
statusText.value = '确认成功,请回到电脑继续完成注册'
|
||||
uni.showToast({ title: '确认成功', icon: 'success' })
|
||||
} catch (err) {
|
||||
errorMsg.value = (err && err.message) || '确认失败,请返回电脑端重新获取二维码'
|
||||
statusText.value = errorMsg.value
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 手机号快速验证为可选项:未开通或用户拒绝时仍按 openid 完成确认 */
|
||||
function onPhone(e) {
|
||||
const detail = (e && e.detail) || {}
|
||||
const errMsg = detail.errMsg || ''
|
||||
if (errMsg.indexOf('deny') !== -1) {
|
||||
uni.showToast({ title: '已取消手机号授权', icon: 'none' })
|
||||
return
|
||||
}
|
||||
const phoneCode = detail.code && errMsg.indexOf('fail') === -1 ? detail.code : ''
|
||||
confirm(phoneCode)
|
||||
}
|
||||
|
||||
function backHome() {
|
||||
uni.reLaunch({ url: '/pages/index/index' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.rp-page {
|
||||
min-height: 100vh;
|
||||
padding: 48rpx 32rpx;
|
||||
box-sizing: border-box;
|
||||
background: #f6f8fb;
|
||||
}
|
||||
.rp-card {
|
||||
background: #ffffff;
|
||||
border-radius: 20rpx;
|
||||
padding: 48rpx 36rpx;
|
||||
box-shadow: 0 8rpx 24rpx rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
.rp-title {
|
||||
font-size: 40rpx;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
}
|
||||
.rp-desc {
|
||||
display: block;
|
||||
margin-top: 16rpx;
|
||||
font-size: 26rpx;
|
||||
line-height: 1.7;
|
||||
color: #6b7280;
|
||||
}
|
||||
.rp-btn {
|
||||
margin-top: 40rpx;
|
||||
height: 88rpx;
|
||||
line-height: 88rpx;
|
||||
font-size: 30rpx;
|
||||
color: #ffffff;
|
||||
background: #2563eb;
|
||||
border-radius: 12rpx;
|
||||
}
|
||||
.rp-btn.ghost {
|
||||
margin-top: 20rpx;
|
||||
color: #2563eb;
|
||||
background: #ffffff;
|
||||
border: 1rpx solid #2563eb;
|
||||
}
|
||||
.rp-btn.disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
.rp-status {
|
||||
display: block;
|
||||
margin-top: 28rpx;
|
||||
font-size: 26rpx;
|
||||
color: #6b7280;
|
||||
}
|
||||
.rp-status.ok {
|
||||
color: #16a34a;
|
||||
}
|
||||
.rp-status.err {
|
||||
color: #dc2626;
|
||||
}
|
||||
.rp-back {
|
||||
margin-top: 36rpx;
|
||||
text-align: center;
|
||||
}
|
||||
.rp-link {
|
||||
font-size: 26rpx;
|
||||
color: #2563eb;
|
||||
}
|
||||
</style>
|
||||
Reference in new issue
Block a user