From 727319a6ebd9eecbb0d16dbb4cf3b0ad57bd5b7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=89=AB=E5=9C=B0=E5=83=A7?= <357099073@qq.com> Date: Sat, 31 Oct 2026 01:21:55 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=B0=8F=E7=A8=8B=E5=BA=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- go/conf/app.conf | 9 + go/controllers/auth/account.go | 127 +++++- go/controllers/auth/wechat_miniapp.go | 39 ++ go/controllers/platform_wechat_mp.go | 12 +- .../sql/cleanup_pending_register_identity.sql | 27 ++ go/docs/sql/create_auth_mini_register.sql | 21 + go/docs/sql/create_auth_register_session.sql | 25 ++ go/docs/sql/fix_tenant_apply_bind.sql | 82 ++++ go/models/auth_identity.go | 13 +- go/models/auth_mini_register.go | 34 ++ go/models/auth_register_session.go | 38 ++ go/models/init.go | 5 + go/routers/auth/auth.go | 4 + go/services/identity_register.go | 425 ++++++++++++++---- go/services/wechatmini/api.go | 80 ++++ go/services/wechatmini/register.go | 244 ++++++++++ go/services/wechatmp/api.go | 114 ++++- go/services/wechatmp/register.go | 56 ++- go/views/auth/register.tpl | 113 +++-- .../components/notificationSettings.vue | 5 +- uniapp/api/auth.js | 13 + uniapp/package-lock.json | 39 -- uniapp/pages.json | 10 +- uniapp/pages/register/confirm.vue | 174 +++++++ 24 files changed, 1509 insertions(+), 200 deletions(-) create mode 100644 go/docs/sql/cleanup_pending_register_identity.sql create mode 100644 go/docs/sql/create_auth_mini_register.sql create mode 100644 go/docs/sql/create_auth_register_session.sql create mode 100644 go/docs/sql/fix_tenant_apply_bind.sql create mode 100644 go/models/auth_mini_register.go create mode 100644 go/models/auth_register_session.go create mode 100644 go/services/wechatmini/register.go create mode 100644 uniapp/pages/register/confirm.vue diff --git a/go/conf/app.conf b/go/conf/app.conf index 27f059e..17e5aa5 100644 --- a/go/conf/app.conf +++ b/go/conf/app.conf @@ -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 派生。 # 注意:更换该值后,历史已保存的渠道参数将无法解密,需在各渠道配置页重新保存一次。 diff --git a/go/controllers/auth/account.go b/go/controllers/auth/account.go index bfc4e10..9f7657f 100644 --- a/go/controllers/auth/account.go +++ b/go/controllers/auth/account.go @@ -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, diff --git a/go/controllers/auth/wechat_miniapp.go b/go/controllers/auth/wechat_miniapp.go index 5879089..4dc59c8 100644 --- a/go/controllers/auth/wechat_miniapp.go +++ b/go/controllers/auth/wechat_miniapp.go @@ -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 令牌 // diff --git a/go/controllers/platform_wechat_mp.go b/go/controllers/platform_wechat_mp.go index b2e94a7..9ab0e0b 100644 --- a/go/controllers/platform_wechat_mp.go +++ b/go/controllers/platform_wechat_mp.go @@ -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) } // ============================ 绑定(当前平台用户) ============================ diff --git a/go/docs/sql/cleanup_pending_register_identity.sql b/go/docs/sql/cleanup_pending_register_identity.sql new file mode 100644 index 0000000..382dc00 --- /dev/null +++ b/go/docs/sql/cleanup_pending_register_identity.sql @@ -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 = '账号名'; diff --git a/go/docs/sql/create_auth_mini_register.sql b/go/docs/sql/create_auth_mini_register.sql new file mode 100644 index 0000000..7071244 --- /dev/null +++ b/go/docs/sql/create_auth_mini_register.sql @@ -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='微信小程序扫码注册核验会话'; diff --git a/go/docs/sql/create_auth_register_session.sql b/go/docs/sql/create_auth_register_session.sql new file mode 100644 index 0000000..359930a --- /dev/null +++ b/go/docs/sql/create_auth_register_session.sql @@ -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='统一认证-注册会话'; diff --git a/go/docs/sql/fix_tenant_apply_bind.sql b/go/docs/sql/fix_tenant_apply_bind.sql new file mode 100644 index 0000000..d10d705 --- /dev/null +++ b/go/docs/sql/fix_tenant_apply_bind.sql @@ -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); diff --git a/go/models/auth_identity.go b/go/models/auth_identity.go index 277ba26..af11ee7 100644 --- a/go/models/auth_identity.go +++ b/go/models/auth_identity.go @@ -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 diff --git a/go/models/auth_mini_register.go b/go/models/auth_mini_register.go new file mode 100644 index 0000000..782ce04 --- /dev/null +++ b/go/models/auth_mini_register.go @@ -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" } diff --git a/go/models/auth_register_session.go b/go/models/auth_register_session.go new file mode 100644 index 0000000..e294b8f --- /dev/null +++ b/go/models/auth_register_session.go @@ -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" } diff --git a/go/models/init.go b/go/models/init.go index 5e6cbce..c5e3ad5 100644 --- a/go/models/init.go +++ b/go/models/init.go @@ -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) diff --git a/go/routers/auth/auth.go b/go/routers/auth/auth.go index 3786215..4876e5d 100644 --- a/go/routers/auth/auth.go +++ b/go/routers/auth/auth.go @@ -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") diff --git a/go/services/identity_register.go b/go/services/identity_register.go index aab910c..e99cc4b 100644 --- a/go/services/identity_register.go +++ b/go/services/identity_register.go @@ -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) } diff --git a/go/services/wechatmini/api.go b/go/services/wechatmini/api.go index 7050e7e..51330db 100644 --- a/go/services/wechatmini/api.go +++ b/go/services/wechatmini/api.go @@ -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,可直接用于 。 +// +// 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 diff --git a/go/services/wechatmini/register.go b/go/services/wechatmini/register.go new file mode 100644 index 0000000..44cd3ba --- /dev/null +++ b/go/services/wechatmini/register.go @@ -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) +} diff --git a/go/services/wechatmp/api.go b/go/services/wechatmp/api.go index c6f5e02..dc7c537 100644 --- a/go/services/wechatmp/api.go +++ b/go/services/wechatmp/api.go @@ -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 } diff --git a/go/services/wechatmp/register.go b/go/services/wechatmp/register.go index 1c13e8f..36b0fba 100644 --- a/go/services/wechatmp/register.go +++ b/go/services/wechatmp/register.go @@ -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) diff --git a/go/views/auth/register.tpl b/go/views/auth/register.tpl index 48bfb34..d52900f 100644 --- a/go/views/auth/register.tpl +++ b/go/views/auth/register.tpl @@ -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 @@
- +
正在获取二维码…
-
-
+
-
用微信扫码关注公众号后,验证码将通过公众号自动发送;无法扫码时可在公众号内直接发送「注册」获取验证码。
+
点击「获取二维码」,用微信扫码完成身份核验。
@@ -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 = '
搜索失败,请重试
'; }); }); + // 第二步提交失败:注册会话过期时退回第一步重新核验(此时账号尚未占用,可重新注册) + 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('')); }); }); diff --git a/platform/src/views/system/platformsettings/components/notificationSettings.vue b/platform/src/views/system/platformsettings/components/notificationSettings.vue index ff9d986..71aa001 100644 --- a/platform/src/views/system/platformsettings/components/notificationSettings.vue +++ b/platform/src/views/system/platformsettings/components/notificationSettings.vue @@ -381,7 +381,8 @@
上传公众号的「关注二维码」(公众号后台「账号设置 → 二维码」下载,2MB 内图片)。
- 用户在租户端「绑定微信」页扫码进入公众号会话 → 发送「验证码」→ 回页填码完成绑定(未认证公众号无法生成带参二维码,统一使用此图片)。 + 用户在租户端「绑定微信」页扫码进入公众号会话 → 发送「验证码」→ 回页填码完成绑定(未认证公众号无法生成带参二维码,统一使用此图片)。
+ 注册页同样依赖它:带参二维码生成失败(40164 IP 白名单 / 48001 未认证)时,注册页会展示这张图片,引导用户扫码关注后发送「注册」取验证码。
@@ -393,7 +394,7 @@
① 微信后台(公众平台 / 微信开发者平台)「设置与开发 → 基本配置」→ 复制 AppID / AppSecret 填入上方,保存并启用;
② 将本地址连同上方 Token / EncodingAESKey 填入微信后台「服务器配置」(加解密方式保持一致)——提交时微信会立即请求本地址校验,需本页已保存并启用;
- ③ 若「连接测试」提示 40164,请在微信后台「基本配置 → IP白名单」加入本服务器出口 IP(服务器执行 curl ifconfig.me 可查看)。 + ③ 若「连接测试」提示 40164,请在微信后台「基本配置 → IP白名单」加入本服务器出口 IP——点「连接测试」即可看到当前出口 IP(也可在服务器执行 curl ifconfig.me 查看)。
diff --git a/uniapp/api/auth.js b/uniapp/api/auth.js index 6f4b02a..4883a58 100644 --- a/uniapp/api/auth.js +++ b/uniapp/api/auth.js @@ -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 认人。 diff --git a/uniapp/package-lock.json b/uniapp/package-lock.json index 6f19a2d..f51c1d4 100644 --- a/uniapp/package-lock.json +++ b/uniapp/package-lock.json @@ -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": [ diff --git a/uniapp/pages.json b/uniapp/pages.json index 174f0dd..8e45310 100644 --- a/uniapp/pages.json +++ b/uniapp/pages.json @@ -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": { diff --git a/uniapp/pages/register/confirm.vue b/uniapp/pages/register/confirm.vue new file mode 100644 index 0000000..d90affa --- /dev/null +++ b/uniapp/pages/register/confirm.vue @@ -0,0 +1,174 @@ + + + + +