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 1/2] =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=B0=8F=E7=A8=8B?=
=?UTF-8?q?=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 @@
+
+
+
+ 确认注册
+ 你正在为电脑端的账号注册完成身份确认。点击确认后,请回到电脑继续填写账号信息。
+
+
+
+
+
+
+
+ {{ statusText }}
+
+
+ 返回首页
+
+
+
+
+
+
+
+
From d4e89788c53bd4155a1fe90ae2bd77972866c0d5 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 19:17:37 +0800
Subject: [PATCH 2/2] =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=B0=8F=E7=A8=8B?=
=?UTF-8?q?=E5=BA=8F=E9=83=A8=E5=88=86?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.gitignore | 8 +
go/cmd/deploy/main.go | 652 ++++++++++++++++++
go/conf/app.conf | 22 +
go/controllers/api_mini_subscribe.go | 196 ++++++
go/controllers/api_wechat_mini.go | 185 +++++
go/controllers/api_wechat_mini_test.go | 77 +++
go/controllers/platform_wechat_mini.go | 232 +++++++
go/docs/nginx/api.yunzer.cn.conf | 179 +++++
go/docs/sql/alter_wechat_mini_app_push.sql | 21 +
go/docs/sql/create_wechat_mini_template.sql | 45 ++
go/docs/小程序订阅消息配置与使用.md | 142 ++++
go/docs/微信小程序消息推送配置.md | 135 ++++
go/docs/服务器增量部署说明.md | 94 +++
go/models/init.go | 3 +
go/models/wechat_mini.go | 64 +-
go/pkg/verifyfiles/verifyfiles.go | 194 ++++++
go/pkg/verifyfiles/verifyfiles_test.go | 124 ++++
go/routers/api/api.go | 5 +
go/routers/platform/platform.go | 3 +
go/routers/platform/wechat_mini.go | 32 +
go/routers/platform/wechat_mini_test.go | 15 +
go/routers/platform/wechat_mp.go | 7 -
go/routers/router.go | 21 +
go/routers/verifyfiles.go | 117 ++++
go/routers/verifyfiles_test.go | 115 +++
go/services/approval/engine.go | 23 +-
go/services/reminder_scheduler.go | 15 +
go/services/system_sitereminder.go | 30 +-
go/services/wechatmini/push.go | 438 ++++++++++++
go/services/wechatmini/push_test.go | 158 +++++
go/services/wechatmini/subscribe.go | 488 +++++++++++++
go/update-server.bat | 128 ++++
go/verify/1XXl8fPHr9.txt | 1 +
go/verify/README.md | 161 +++++
platform/src/api/miniApp.js | 88 +++
.../components/miniAppSettings.vue | 475 ++++++++++++-
uniapp/App.vue | 33 +
uniapp/api/config.js | 19 +
uniapp/api/subscribe.js | 41 ++
uniapp/pages.json | 7 +-
uniapp/pages/index/index.vue | 54 +-
uniapp/pages/login/login.vue | 5 -
uniapp/pages/nav/nav.vue | 7 +-
uniapp/pages/profile/profile.vue | 6 +
uniapp/utils/subscribe.js | 84 +++
uniapp/vite.config.js | 20 +-
46 files changed, 4906 insertions(+), 63 deletions(-)
create mode 100644 go/cmd/deploy/main.go
create mode 100644 go/controllers/api_mini_subscribe.go
create mode 100644 go/controllers/api_wechat_mini.go
create mode 100644 go/controllers/api_wechat_mini_test.go
create mode 100644 go/docs/nginx/api.yunzer.cn.conf
create mode 100644 go/docs/sql/alter_wechat_mini_app_push.sql
create mode 100644 go/docs/sql/create_wechat_mini_template.sql
create mode 100644 go/docs/小程序订阅消息配置与使用.md
create mode 100644 go/docs/微信小程序消息推送配置.md
create mode 100644 go/docs/服务器增量部署说明.md
create mode 100644 go/pkg/verifyfiles/verifyfiles.go
create mode 100644 go/pkg/verifyfiles/verifyfiles_test.go
create mode 100644 go/routers/platform/wechat_mini.go
create mode 100644 go/routers/platform/wechat_mini_test.go
create mode 100644 go/routers/verifyfiles.go
create mode 100644 go/routers/verifyfiles_test.go
create mode 100644 go/services/wechatmini/push.go
create mode 100644 go/services/wechatmini/push_test.go
create mode 100644 go/services/wechatmini/subscribe.go
create mode 100644 go/update-server.bat
create mode 100644 go/verify/1XXl8fPHr9.txt
create mode 100644 go/verify/README.md
create mode 100644 uniapp/api/subscribe.js
create mode 100644 uniapp/utils/subscribe.js
diff --git a/.gitignore b/.gitignore
index 1884a20..3feb64f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -61,6 +61,14 @@ go/go-platform.zip
# Go 构建产物(go build 默认输出 main.exe / server.exe 等),不入库
go/*.exe
go/conf
+# go test ./routers/ 会在包目录下创建默认验证文件目录(空目录,不入库)
+go/routers/verify/
+# 增量部署工具(update-server.bat)编译产物与运行时文件
+go/bin/
+go/.deploy_known_hosts
+# SSH 私钥(部署用):绝对不能入库
+go/*_id_ed25519
+go/*.pem
# codegraph
.codegraph/*
diff --git a/go/cmd/deploy/main.go b/go/cmd/deploy/main.go
new file mode 100644
index 0000000..9ce4019
--- /dev/null
+++ b/go/cmd/deploy/main.go
@@ -0,0 +1,652 @@
+// Command deploy 本地目录 → 服务器目录的「差异比对 + 增量上传」工具。
+//
+// 用途:只上传本地改动过的文件,跳过未改动的文件,避免每次整包覆盖。
+//
+// 工作流程:
+// 1. SSH 连上服务器,执行 md5sum 拿到远端目录的文件清单与指纹
+// 2. 本地遍历目录计算 MD5(按排除规则跳过日志、可执行文件等)
+// 3. 比对两份清单,得出「新增 / 已修改」的文件清单并打印
+// 4. 确认后把差异文件打成 tar 流,通过 SSH 管道在远端解开
+// 5. 回读远端指纹复核,确认上传结果
+//
+// 之所以用 Go 实现而不是直接调 scp:Windows 自带的 OpenSSH scp 不支持
+// 非交互传密码(sshpass / pscp 都没装),x/crypto/ssh 可以直接用密码登录,
+// 全程无交互,适合丢进 .bat 里一键执行。
+//
+// 用法:
+//
+// deploy -host 10.31.100.2 -user root -dir /www/wwwroot/api.yunzer.cn -local .
+package main
+
+import (
+ "archive/tar"
+ "bytes"
+ "crypto/md5"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "flag"
+ "fmt"
+ "io"
+ "net"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+ "time"
+
+ "golang.org/x/crypto/ssh"
+)
+
+// ============================ 默认排除规则 ============================
+
+// defaultExcludeDirs 不参与同步的目录名
+var defaultExcludeDirs = []string{
+ ".git", ".idea", ".vscode", ".svn",
+ "node_modules", "bin", "dist", "tmp", "temp",
+}
+
+// defaultExcludeFiles 不参与同步的文件名(支持 * 通配)
+var defaultExcludeFiles = []string{
+ "*.exe", "*.log", "*.pid", "*.bak", "*.tmp", "*.pub",
+ "nohup.out", "output.log", "go.log",
+ "test-output-*.txt", "migrate_*.csv",
+ "server", // 无扩展名的编译产物
+ "*_id_ed25519", // SSH 私钥:绝对不能传到服务器
+ ".deploy_known_hosts",
+}
+
+// ============================ 配置 ============================
+
+type options struct {
+ host string
+ user string
+ pass string
+ keyFile string
+ dir string
+ local string
+ restart string
+ execCmd string
+ excludes string
+ dryRun bool
+ yes bool
+ del bool
+ quiet bool
+}
+
+// errCancelled 用户在上传前确认时选择了取消(区别于真正的失败)
+var errCancelled = errors.New("用户取消")
+
+func main() {
+ var opt options
+ flag.StringVar(&opt.host, "host", "10.31.100.2", "服务器地址")
+ flag.StringVar(&opt.user, "user", "root", "SSH 用户名")
+ flag.StringVar(&opt.pass, "pass", "", "SSH 密码(留空则读环境变量 DEPLOY_PASS)")
+ flag.StringVar(&opt.keyFile, "key", "", "SSH 私钥路径(指定后用密钥登录,忽略密码)")
+ flag.StringVar(&opt.dir, "dir", "/www/wwwroot/api.yunzer.cn", "服务器目标目录")
+ flag.StringVar(&opt.local, "local", ".", "本地目录(要同步的根)")
+ flag.StringVar(&opt.restart, "restart", "", "上传后执行的远程重启命令(留空则不重启)")
+ flag.StringVar(&opt.execCmd, "exec", "", "只执行远程命令并输出结果,不做同步")
+ flag.StringVar(&opt.excludes, "exclude", "", "额外排除的文件名(逗号分隔,支持 * 通配)")
+ flag.BoolVar(&opt.dryRun, "dry-run", false, "只比对不上传")
+ flag.BoolVar(&opt.yes, "yes", false, "跳过上传前的确认")
+ flag.BoolVar(&opt.del, "delete", false, "同时删除远端存在、本地已删除的文件")
+ flag.BoolVar(&opt.quiet, "quiet", false, "精简输出")
+ flag.Parse()
+
+ if opt.pass == "" {
+ opt.pass = os.Getenv("DEPLOY_PASS")
+ }
+ if opt.pass == "" && opt.keyFile == "" {
+ fmt.Println("[错误] 未提供密码:请用 -pass 或环境变量 DEPLOY_PASS 指定")
+ os.Exit(2)
+ }
+
+ if err := run(opt); err != nil {
+ if errors.Is(err, errCancelled) {
+ os.Exit(3) // 取消不是失败,用独立退出码告知调用方
+ }
+ fmt.Printf("[失败] %v\n", err)
+ os.Exit(1)
+ }
+}
+
+// ============================ 主流程 ============================
+
+func run(opt options) error {
+ localRoot, err := filepath.Abs(opt.local)
+ if err != nil {
+ return fmt.Errorf("解析本地目录失败: %w", err)
+ }
+ if _, err := os.Stat(localRoot); err != nil {
+ return fmt.Errorf("本地目录不存在: %s", localRoot)
+ }
+
+ excludeDirs := append([]string{}, defaultExcludeDirs...)
+ excludeFiles := append([]string{}, defaultExcludeFiles...)
+ for _, extra := range strings.Split(opt.excludes, ",") {
+ extra = strings.TrimSpace(extra)
+ if extra == "" {
+ continue
+ }
+ if strings.ContainsAny(extra, "/\\") {
+ excludeDirs = append(excludeDirs, extra)
+ } else {
+ excludeFiles = append(excludeFiles, extra)
+ }
+ }
+
+ fmt.Printf("本地目录 : %s\n", localRoot)
+ fmt.Printf("远程目录 : %s@%s:%s\n", opt.user, opt.host, opt.dir)
+
+ client, err := dial(opt, localRoot)
+ if err != nil {
+ return err
+ }
+ defer client.Close()
+
+ // 只执行远程命令(不比对、不上传)
+ if strings.TrimSpace(opt.execCmd) != "" {
+ fmt.Printf("\n[执行] %s\n", opt.execCmd)
+ out, xerr := runRemote(client, opt.execCmd)
+ if strings.TrimSpace(out) != "" {
+ fmt.Println(strings.TrimSpace(out))
+ }
+ if xerr != nil {
+ return fmt.Errorf("远程命令失败: %w", xerr)
+ }
+ fmt.Println("[完成]")
+ return nil
+ }
+
+ // 1. 远端清单
+ remote, err := remoteInventory(client, opt.dir)
+ if err != nil {
+ return err
+ }
+ fmt.Printf("远端文件 : %d 个\n", len(remote))
+
+ // 2. 本地清单
+ local, err := localInventory(localRoot, excludeDirs, excludeFiles)
+ if err != nil {
+ return err
+ }
+ fmt.Printf("本地文件 : %d 个(已排除日志 / 可执行文件等)\n", len(local))
+
+ // 3. 比对
+ var changed, added []string
+ remoteOnly := make([]string, 0)
+ for rel, lh := range local {
+ rh, ok := remote[rel]
+ if !ok {
+ added = append(added, rel)
+ continue
+ }
+ if lh.MD5 != rh.MD5 {
+ changed = append(changed, rel)
+ }
+ }
+ for rel := range remote {
+ if _, ok := local[rel]; !ok {
+ remoteOnly = append(remoteOnly, rel)
+ }
+ }
+ sort.Strings(changed)
+ sort.Strings(added)
+ sort.Strings(remoteOnly)
+
+ fmt.Println()
+ if len(added) > 0 {
+ fmt.Printf("新增文件 %d 个:\n", len(added))
+ printList("+", added)
+ }
+ if len(changed) > 0 {
+ fmt.Printf("内容有变化 %d 个:\n", len(changed))
+ printList("M", changed)
+ }
+ if len(remoteOnly) > 0 {
+ fmt.Printf("远端存在、本地没有 %d 个%s\n", len(remoteOnly), map[bool]string{true: "(将删除)", false: "(保留,不删除)"}[opt.del])
+ if opt.del {
+ printList("-", remoteOnly)
+ }
+ }
+ fmt.Printf("\n未变化 %d 个(跳过)\n", len(local)-len(changed)-len(added))
+
+ toUpload := append(append([]string{}, changed...), added...)
+ if len(toUpload) == 0 && len(remoteOnly) == 0 {
+ fmt.Println("\n[完成] 没有需要更新的文件。")
+ return nil
+ }
+ if opt.dryRun {
+ fmt.Println("\n[试运行] 未实际上传(-dry-run)。")
+ return nil
+ }
+
+ if !opt.yes && !confirm(fmt.Sprintf("确认上传 %d 个文件到 %s?", len(toUpload), opt.dir)) {
+ fmt.Println("已取消。")
+ return errCancelled
+ }
+
+ // 4. 上传
+ if len(toUpload) > 0 {
+ fmt.Printf("\n[上传] 打包 %d 个文件并传输...\n", len(toUpload))
+ if err := upload(client, opt, localRoot, toUpload); err != nil {
+ return err
+ }
+ // 5. 复核
+ fmt.Println("[复核] 重新读取远端指纹...")
+ after, err := remoteInventory(client, opt.dir)
+ if err != nil {
+ return err
+ }
+ bad := make([]string, 0)
+ for _, rel := range toUpload {
+ rh, ok := after[rel]
+ if !ok {
+ bad = append(bad, rel+"(远端缺失)")
+ continue
+ }
+ if lh := local[rel]; lh.MD5 != rh.MD5 {
+ bad = append(bad, rel+"(指纹不一致)")
+ }
+ }
+ if len(bad) > 0 {
+ for _, b := range bad {
+ fmt.Println(" ! " + b)
+ }
+ return fmt.Errorf("%d 个文件上传后校验不通过", len(bad))
+ }
+ fmt.Printf("[复核] %d 个文件指纹一致 ✓\n", len(toUpload))
+ }
+
+ // 6. 删除远端多余文件
+ if opt.del && len(remoteOnly) > 0 {
+ fmt.Printf("\n[删除] 清理远端 %d 个多余文件...\n", len(remoteOnly))
+ if err := removeRemote(client, opt.dir, remoteOnly); err != nil {
+ return err
+ }
+ }
+
+ // 7. 重启
+ if strings.TrimSpace(opt.restart) != "" {
+ fmt.Printf("\n[重启] 执行: %s\n", opt.restart)
+ out, err := runRemote(client, opt.restart)
+ if err != nil {
+ return fmt.Errorf("重启失败: %w(远端输出: %s)", err, strings.TrimSpace(out))
+ }
+ if strings.TrimSpace(out) != "" {
+ fmt.Println(out)
+ }
+ fmt.Println("[重启] 完成")
+ } else {
+ fmt.Println("\n[提示] 未配置重启命令,服务不会自动重启;如需生效请手动重启(如 -restart \"supervisorctl restart <服务名>\")")
+ }
+
+ fmt.Println("\n[完成] 同步结束。")
+ return nil
+}
+
+// ============================ 文件清单 ============================
+
+type fileInfo struct {
+ MD5 string
+ Size int64
+ Path string
+}
+
+// localInventory 遍历本地目录,计算 MD5
+func localInventory(root string, excludeDirs, excludeFiles []string) (map[string]fileInfo, error) {
+ out := make(map[string]fileInfo)
+ err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
+ if err != nil {
+ return err
+ }
+ rel, rerr := filepath.Rel(root, path)
+ if rerr != nil {
+ return rerr
+ }
+ rel = filepath.ToSlash(rel)
+ if rel == "." {
+ return nil
+ }
+ name := info.Name()
+ if info.IsDir() {
+ if matchAny(name, excludeDirs) {
+ return filepath.SkipDir
+ }
+ return nil
+ }
+ if !info.Mode().IsRegular() {
+ return nil // 跳过符号链接 / 设备文件等
+ }
+ if matchAny(name, excludeFiles) {
+ return nil
+ }
+ sum, size, herr := fileMD5(path)
+ if herr != nil {
+ return herr
+ }
+ out[rel] = fileInfo{MD5: sum, Size: size, Path: path}
+ return nil
+ })
+ return out, err
+}
+
+// remoteInventory 远端 md5sum 清单
+func remoteInventory(client *ssh.Client, dir string) (map[string]fileInfo, error) {
+ res := make(map[string]fileInfo)
+
+ // 目录不存在时按空目录处理(上传阶段会创建)
+ if out, err := runRemote(client, fmt.Sprintf("[ -d %s ] && echo yes || echo no", shQuote(dir))); err == nil {
+ if !strings.Contains(out, "yes") {
+ fmt.Printf("[提示] 远端目录 %s 不存在,将新建\n", dir)
+ return res, nil
+ }
+ }
+
+ cmd := fmt.Sprintf("cd %s && find . -type f -exec md5sum {} + 2>/dev/null", shQuote(dir))
+ out, err := runRemote(client, cmd)
+ if err != nil {
+ return nil, fmt.Errorf("读取远端清单失败: %w(输出: %s)", err, strings.TrimSpace(out))
+ }
+ for _, line := range strings.Split(out, "\n") {
+ line = strings.TrimRight(line, "\r")
+ if strings.TrimSpace(line) == "" {
+ continue
+ }
+ // 格式:
+ idx := strings.Index(line, " ")
+ if idx < 0 {
+ continue
+ }
+ sum := strings.TrimSpace(line[:idx])
+ rel := strings.TrimSpace(line[idx+2:])
+ rel = strings.TrimPrefix(rel, "./")
+ if rel == "" {
+ continue
+ }
+ res[rel] = fileInfo{MD5: sum, Path: rel}
+ }
+ if len(res) == 0 {
+ fmt.Printf("[提示] 远端目录 %s 目前是空的\n", dir)
+ }
+ return res, nil
+}
+
+func fileMD5(path string) (string, int64, error) {
+ f, err := os.Open(path)
+ if err != nil {
+ return "", 0, err
+ }
+ defer f.Close()
+ h := md5.New()
+ size, err := io.Copy(h, f)
+ if err != nil {
+ return "", 0, err
+ }
+ return hex.EncodeToString(h.Sum(nil)), size, nil
+}
+
+// matchAny 名称是否命中排除规则(支持 * 通配)
+func matchAny(name string, patterns []string) bool {
+ for _, p := range patterns {
+ if p == name {
+ return true
+ }
+ if ok, err := filepath.Match(p, name); err == nil && ok {
+ return true
+ }
+ }
+ return false
+}
+
+// printList 打印文件清单(最多 20 条,避免刷屏)
+func printList(mark string, files []string) {
+ const maxShow = 20
+ for i, f := range files {
+ if i >= maxShow {
+ fmt.Printf(" …… 其余 %d 个省略\n", len(files)-maxShow)
+ break
+ }
+ fmt.Printf(" %s %s\n", mark, f)
+ }
+}
+
+// ============================ 上传 ============================
+
+// upload 把差异文件打成 tar 流,通过 SSH 管道在远端解开
+func upload(client *ssh.Client, opt options, localRoot string, files []string) error {
+ buf, err := buildTar(localRoot, files)
+ if err != nil {
+ return err
+ }
+
+ // 远端目录不存在时先创建(tar 解包要求目标目录已存在)
+ if out, merr := runRemote(client, fmt.Sprintf("mkdir -p %s", shQuote(opt.dir))); merr != nil {
+ return fmt.Errorf("创建远端目录失败: %w(%s)", merr, strings.TrimSpace(out))
+ }
+
+ session, err := client.NewSession()
+ if err != nil {
+ return err
+ }
+ defer session.Close()
+
+ var stderr bytes.Buffer
+ session.Stderr = &stderr
+ stdin, err := session.StdinPipe()
+ if err != nil {
+ return err
+ }
+ stdout, err := session.StdoutPipe()
+ if err != nil {
+ return err
+ }
+ // 远端解包(tar 会自动创建缺失的目录)
+ if err := session.Start(fmt.Sprintf("tar -xf - -C %s", shQuote(opt.dir))); err != nil {
+ return err
+ }
+ go func() {
+ _, _ = stdin.Write(buf)
+ _ = stdin.Close()
+ }()
+ out, _ := io.ReadAll(stdout)
+ if err := session.Wait(); err != nil {
+ return fmt.Errorf("远端解包失败: %w(stderr: %s stdout: %s)", err, strings.TrimSpace(stderr.String()), strings.TrimSpace(string(out)))
+ }
+
+ // 属主对齐目标目录(服务器上多为 www:www,避免解包后变成 root:root)
+ if err := alignOwner(client, opt.dir, files); err != nil {
+ fmt.Printf("[提示] 属主修正跳过: %v\n", err)
+ }
+ return nil
+}
+
+// alignOwner 把上传文件的属主改成目标目录的属主
+func alignOwner(client *ssh.Client, dir string, files []string) error {
+ if len(files) == 0 {
+ return nil
+ }
+ quoted := make([]string, 0, len(files))
+ for _, rel := range files {
+ quoted = append(quoted, shQuote(strings.TrimRight(dir, "/")+"/"+rel))
+ }
+ cmd := fmt.Sprintf("ref=$(stat -c '%%U:%%G' %s) && for f in %s; do chown -R \"$ref\" \"$f\"; done",
+ shQuote(dir), strings.Join(quoted, " "))
+ out, err := runRemote(client, cmd)
+ if err != nil {
+ return fmt.Errorf("%w(%s)", err, strings.TrimSpace(out))
+ }
+ return nil
+}
+
+// buildTar 生成待上传文件的 tar 包(含目录条目,保证远端目录结构存在)
+func buildTar(localRoot string, files []string) ([]byte, error) {
+ dirs := make(map[string]bool)
+ for _, rel := range files {
+ parts := strings.Split(rel, "/")
+ for i := 1; i < len(parts); i++ {
+ dirs[strings.Join(parts[:i], "/")+"/"] = true
+ }
+ }
+ dirList := make([]string, 0, len(dirs))
+ for d := range dirs {
+ dirList = append(dirList, d)
+ }
+ sort.Strings(dirList)
+
+ buf := &bytes.Buffer{}
+ tw := tar.NewWriter(buf)
+ for _, d := range dirList {
+ if err := tw.WriteHeader(&tar.Header{
+ Name: d,
+ Typeflag: tar.TypeDir,
+ Mode: 0o755,
+ }); err != nil {
+ return nil, err
+ }
+ }
+ for _, rel := range files {
+ full := filepath.Join(localRoot, filepath.FromSlash(rel))
+ info, err := os.Stat(full)
+ if err != nil {
+ return nil, err
+ }
+ f, err := os.Open(full)
+ if err != nil {
+ return nil, err
+ }
+ if err := tw.WriteHeader(&tar.Header{
+ Name: rel,
+ Typeflag: tar.TypeReg,
+ Mode: fileMode(rel),
+ Size: info.Size(),
+ ModTime: info.ModTime(),
+ }); err != nil {
+ f.Close()
+ return nil, err
+ }
+ if _, err := io.Copy(tw, f); err != nil {
+ f.Close()
+ return nil, err
+ }
+ f.Close()
+ }
+ if err := tw.Close(); err != nil {
+ return nil, err
+ }
+ return buf.Bytes(), nil
+}
+
+// fileMode 归一化文件权限:脚本可执行,其它一律 0644
+// (Windows 本地读到的权限恒为 0666,直接带上会让服务器上的文件变成人人可写)
+func fileMode(rel string) int64 {
+ switch strings.ToLower(filepath.Ext(rel)) {
+ case ".sh", ".bash", ".py", ".pl", ".rb":
+ return 0o755
+ }
+ return 0o644
+}
+
+// removeRemote 删除远端多余文件
+func removeRemote(client *ssh.Client, dir string, files []string) error {
+ for _, rel := range files {
+ cmd := fmt.Sprintf("rm -f -- %s", shQuote(strings.TrimRight(dir, "/")+"/"+rel))
+ if out, err := runRemote(client, cmd); err != nil {
+ return fmt.Errorf("删除 %s 失败: %w(%s)", rel, err, strings.TrimSpace(out))
+ }
+ }
+ return nil
+}
+
+// ============================ SSH ============================
+
+func dial(opt options, localRoot string) (*ssh.Client, error) {
+ var auth []ssh.AuthMethod
+ if opt.keyFile != "" {
+ key, err := os.ReadFile(opt.keyFile)
+ if err != nil {
+ return nil, fmt.Errorf("读取私钥失败: %w", err)
+ }
+ signer, err := ssh.ParsePrivateKey(key)
+ if err != nil {
+ return nil, fmt.Errorf("解析私钥失败: %w", err)
+ }
+ auth = append(auth, ssh.PublicKeys(signer))
+ } else {
+ auth = append(auth, ssh.Password(opt.pass))
+ }
+
+ cfg := &ssh.ClientConfig{
+ User: opt.user,
+ Auth: auth,
+ HostKeyCallback: hostKeyChecker(localRoot),
+ Timeout: 20 * time.Second,
+ }
+ addr := fmt.Sprintf("%s:%d", opt.host, 22)
+ client, err := ssh.Dial("tcp", addr, cfg)
+ if err != nil {
+ return nil, fmt.Errorf("连接服务器失败: %w", err)
+ }
+ return client, nil
+}
+
+// hostKeyChecker 首次连接记录指纹,之后变化则拒绝连接(防止服务器被替换)
+func hostKeyChecker(localRoot string) ssh.HostKeyCallback {
+ store := filepath.Join(localRoot, ".deploy_known_hosts")
+ known := map[string]string{}
+ if data, err := os.ReadFile(store); err == nil {
+ _ = json.Unmarshal(data, &known)
+ }
+ return func(hostname string, remote net.Addr, key ssh.PublicKey) error {
+ fp := ssh.FingerprintSHA256(key)
+ if old, ok := known[hostname]; ok {
+ if old != fp {
+ return fmt.Errorf("服务器主机密钥已变化(%s 原 %s 现 %s),请确认后删除 %s 再执行", hostname, old, fp, store)
+ }
+ return nil
+ }
+ known[hostname] = fp
+ if data, err := json.MarshalIndent(known, "", " "); err == nil {
+ _ = os.WriteFile(store, data, 0o644)
+ }
+ fmt.Printf("[安全] 首次连接,已记录服务器指纹 %s(%s)\n", fp, store)
+ return nil
+ }
+}
+
+// runRemote 执行远程命令并返回输出
+func runRemote(client *ssh.Client, cmd string) (string, error) {
+ session, err := client.NewSession()
+ if err != nil {
+ return "", err
+ }
+ defer session.Close()
+ var out, errBuf bytes.Buffer
+ session.Stdout = &out
+ session.Stderr = &errBuf
+ err = session.Run(cmd)
+ if err != nil {
+ return out.String() + errBuf.String(), err
+ }
+ return out.String(), nil
+}
+
+// ============================ 辅助 ============================
+
+// shQuote 单引号包裹(内部单引号转义),避免命令注入与路径空格问题
+func shQuote(s string) string {
+ return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
+}
+
+// confirm 交互确认
+func confirm(prompt string) bool {
+ fmt.Printf("%s [y/N]: ", prompt)
+ var answer string
+ _, err := fmt.Scanln(&answer)
+ if err != nil {
+ return false
+ }
+ answer = strings.ToLower(strings.TrimSpace(answer))
+ return answer == "y" || answer == "yes"
+}
diff --git a/go/conf/app.conf b/go/conf/app.conf
index 17e5aa5..f2e0e11 100644
--- a/go/conf/app.conf
+++ b/go/conf/app.conf
@@ -12,6 +12,21 @@ runmode = dev
platform_spa_dir = /www/wwwroot/platform.yunzer.cn/output
platform_spa_hosts = platform.yunzer.cn
+# ==================== 根路径站点验证文件 ====================
+# nginx 对所有域名都是全量反代给 Go,Go 不认识的路径一律回 beego 404,
+# 导致 1XXl8fPHr9.txt 这类「域名所有权验证文件」访问不到(备案、搜索引擎收录、
+# 第三方平台域名校验都靠它)。这里由 Go 直接吐 verify 目录下的文件,
+# 不依赖 nginx 规则(宝塔重生成 vhost 也不会被冲掉)。
+#
+# verify_dir 验证文件目录,相对进程工作目录(线上 = /www/wwwroot/api.yunzer.cn/verify)
+# 留空 = 默认 verify;填 - = 关闭该功能
+# verify_exts 允许的扩展名(逗号分隔);留空 = 默认 .txt,.html,.htm
+# verify_hosts 限定生效的域名(不含端口,逗号分隔);留空 = 所有走本服务的域名都生效
+# (备案常常要同时过 api.yunzer.cn 与官网主域名 yunzer.com.cn / dh2.fun)
+verify_dir =
+verify_exts =
+verify_hosts =
+
# 启用请求体复制(允许多次读取请求体)
copyrequestbody = true
@@ -109,6 +124,13 @@ wechat_mp_secret_key = 8f3c2a1d94b74e28a6c5f0187d3e9b4c72a5d0e6f13b8c47d92a6e5f0
# wechat_mini_register_env = release
wechat_mini_register_env = trial
+# ==================== 微信小程序消息推送 ====================
+# 消息推送回调地址基址:最终地址 = {基址}/api/wechat/mini/callback,
+# 填到小程序后台「开发管理 → 开发设置 → 消息推送」的 URL(必须是 80 / 443 端口、微信可直连)。
+# 缺省回落 wechat_mp_callback_base / payment_callback_base;都留空时退化为相对路径
+# /api/wechat/mini/callback(需由网关补齐域名)。
+wechat_mini_callback_base = https://api.yunzer.cn
+
# ==================== 支付模块 ====================
# 渠道敏感参数(商户密钥/私钥/Secret)入库加密密钥:任意随机串即可,代码内部做 SHA-256 派生。
# 注意:更换该值后,历史已保存的渠道参数将无法解密,需在各渠道配置页重新保存一次。
diff --git a/go/controllers/api_mini_subscribe.go b/go/controllers/api_mini_subscribe.go
new file mode 100644
index 0000000..7093687
--- /dev/null
+++ b/go/controllers/api_mini_subscribe.go
@@ -0,0 +1,196 @@
+package controllers
+
+import (
+ "encoding/json"
+ "io"
+ "strings"
+
+ "server/pkg/identity"
+ "server/pkg/jwtutil"
+ "server/services/wechatmini"
+
+ beego "github.com/beego/beego/v2/server/web"
+)
+
+// AppMiniSubscribeController 小程序订阅消息(uniapp 调用)
+//
+// 订阅消息是「用户 → 授权 → 服务器主动推给用户」的链路:
+// 1. GET /api/mini/subscribe/templates 取当前可订阅的模板(含模板ID与跳转页)
+// 2. 小程序端调 uni.requestSubscribeMessage 弹授权窗,用户同意后
+// 3. POST /api/mini/subscribe/authorize 上报授权结果,服务器记录一次发送机会
+// 4. 之后业务侧发站内信/审批待办时,服务器自动用掉这次机会推送
+type AppMiniSubscribeController struct {
+ beego.Controller
+}
+
+func (c *AppMiniSubscribeController) appClaims() (*jwtutil.Claims, error) {
+ return identity.Claims(c.Ctx.Request.Header.Get("Authorization"), identity.EndApp, identity.EndBackend)
+}
+
+func (c *AppMiniSubscribeController) jsonErr(httpStatus, bizCode int, msg string) {
+ c.Ctx.Output.SetStatus(httpStatus)
+ c.Data["json"] = map[string]interface{}{"code": bizCode, "msg": msg}
+ _ = c.ServeJSON()
+}
+
+func (c *AppMiniSubscribeController) ok(data interface{}, msg string) {
+ if msg == "" {
+ msg = "success"
+ }
+ c.Data["json"] = map[string]interface{}{"code": 200, "msg": msg, "data": data}
+ _ = c.ServeJSON()
+}
+
+// Templates GET /api/mini/subscribe/templates
+// 返回当前启用的订阅模板:小程序端拿 template_id 去调 requestSubscribeMessage
+func (c *AppMiniSubscribeController) Templates() {
+ claims, err := c.appClaims()
+ if err != nil {
+ code := identity.Status(err)
+ c.jsonErr(code, code, err.Error())
+ return
+ }
+ identityID := uint64(claims.UserID)
+
+ apps, err := wechatmini.ListApps()
+ if err != nil {
+ c.jsonErr(500, 500, "读取小程序配置失败")
+ return
+ }
+ list := make([]map[string]interface{}, 0)
+ for _, app := range apps {
+ if app.Enabled != 1 {
+ continue
+ }
+ tpls, terr := wechatmini.ListTemplates(app.AppID)
+ if terr != nil {
+ continue
+ }
+ for _, tpl := range tpls {
+ if tpl.Enabled != 1 || tpl.TemplateID == "" {
+ continue
+ }
+ list = append(list, map[string]interface{}{
+ "app_id": app.AppID,
+ "code": tpl.Code,
+ "title": tpl.Title,
+ "template_id": tpl.TemplateID,
+ "page": tpl.Page,
+ "remain": wechatmini.SubscribeRemain(identityID, app.AppID, tpl.TemplateID),
+ })
+ }
+ }
+ c.ok(map[string]interface{}{"list": list}, "获取成功")
+}
+
+// Authorize POST /api/mini/subscribe/authorize
+// 上报用户授权结果:每授权一次,服务器记录一次可发送机会
+func (c *AppMiniSubscribeController) Authorize() {
+ claims, err := c.appClaims()
+ if err != nil {
+ code := identity.Status(err)
+ c.jsonErr(code, code, err.Error())
+ return
+ }
+ var p struct {
+ TemplateIDs []string `json:"template_ids"`
+ }
+ raw, err := io.ReadAll(c.Ctx.Request.Body)
+ if err != nil {
+ c.jsonErr(400, 400, "参数错误")
+ return
+ }
+ if err := json.Unmarshal(raw, &p); err != nil {
+ c.jsonErr(400, 400, "参数错误")
+ return
+ }
+ if len(p.TemplateIDs) == 0 {
+ c.jsonErr(400, 400, "缺少 template_ids")
+ return
+ }
+ identityID := uint64(claims.UserID)
+ apps, err := wechatmini.ListApps()
+ if err != nil {
+ c.jsonErr(500, 500, "读取小程序配置失败")
+ return
+ }
+ granted := 0
+ for _, app := range apps {
+ if app.Enabled != 1 {
+ continue
+ }
+ // 只记录平台确实配置过的模板
+ for _, tid := range p.TemplateIDs {
+ tid = strings.TrimSpace(tid)
+ if tid == "" {
+ continue
+ }
+ tpls, terr := wechatmini.ListTemplates(app.AppID)
+ if terr != nil {
+ continue
+ }
+ matched := false
+ for _, tpl := range tpls {
+ if tpl.TemplateID == tid {
+ matched = true
+ break
+ }
+ }
+ if !matched {
+ continue
+ }
+ if gerr := wechatmini.GrantSubscribe(identityID, app.AppID, []string{tid}); gerr == nil {
+ granted++
+ }
+ }
+ }
+ c.ok(map[string]interface{}{"granted": granted}, "已记录授权")
+}
+
+// TestNotice POST /api/mini/subscribe/test
+// 给自己发一条测试订阅消息(用于验证模板配置是否正确)
+func (c *AppMiniSubscribeController) TestNotice() {
+ claims, err := c.appClaims()
+ if err != nil {
+ code := identity.Status(err)
+ c.jsonErr(code, code, err.Error())
+ return
+ }
+ var p struct {
+ Title string `json:"title"`
+ Content string `json:"content"`
+ }
+ raw, _ := io.ReadAll(c.Ctx.Request.Body)
+ _ = json.Unmarshal(raw, &p)
+ title := strings.TrimSpace(p.Title)
+ if title == "" {
+ title = "测试提醒"
+ }
+ content := strings.TrimSpace(p.Content)
+ if content == "" {
+ content = "这是一条订阅消息测试,收到说明配置成功。"
+ }
+
+ identityID := uint64(claims.UserID)
+ if wechatmini.MiniOpenIDByIdentity(identityID) == "" {
+ c.jsonErr(400, 400, "当前账号未绑定小程序,请先用小程序登录")
+ return
+ }
+ res := wechatmini.PushSubscribe(wechatmini.SceneNotice, identityID, map[string]string{
+ "title": title,
+ "content": content,
+ })
+ if res.Sent > 0 {
+ c.ok(map[string]interface{}{"sent": res.Sent}, "已发送")
+ return
+ }
+ if len(res.Errors) > 0 {
+ msg := res.Errors[0].Error()
+ if hint := wechatmini.ExplainSubscribeError(res.Errors[0]); hint != "" {
+ msg = hint
+ }
+ c.jsonErr(400, 400, msg)
+ return
+ }
+ c.jsonErr(400, 400, "没有可用的订阅:模板未配置,或你还没授权订阅消息")
+}
diff --git a/go/controllers/api_wechat_mini.go b/go/controllers/api_wechat_mini.go
new file mode 100644
index 0000000..08a9ba8
--- /dev/null
+++ b/go/controllers/api_wechat_mini.go
@@ -0,0 +1,185 @@
+package controllers
+
+import (
+ "io"
+ "strings"
+ "time"
+
+ "server/services/wechatmini"
+ "server/services/wechatmp"
+
+ "github.com/beego/beego/v2/core/logs"
+ beego "github.com/beego/beego/v2/server/web"
+)
+
+// WechatMiniCallbackController 微信小程序消息推送回调(微信服务器调用,无需平台鉴权)
+//
+// 小程序后台「开发管理 → 开发设置 → 消息推送」填写:
+//
+// URL(服务器地址):https://<你的域名>/api/wechat/mini/callback
+// Token:与平台端「小程序配置 - 消息推送」中的 Token 一致
+// EncodingAESKey:与平台端配置一致(安全 / 兼容模式必填)
+// 消息加解密方式:明文 / 兼容 / 安全(三种均支持)
+// 数据格式:XML / JSON(均支持,按平台端配置输出)
+//
+// 平台端维护入口:系统设置 → 小程序配置 → 编辑小程序 → 消息推送
+type WechatMiniCallbackController struct {
+ beego.Controller
+}
+
+// Callback 服务器地址验证(GET,回显 echostr)与消息 / 事件推送(POST,被动回复)
+func (c *WechatMiniCallbackController) Callback() {
+ cfgs, err := wechatmini.LoadPushConfigs()
+ if err != nil || len(cfgs) == 0 {
+ c.Ctx.Output.SetStatus(403)
+ c.Ctx.Output.Body([]byte("wechat mini push not configured"))
+ return
+ }
+
+ q := c.Ctx.Request.URL.Query()
+ timestamp := q.Get("timestamp")
+ nonce := q.Get("nonce")
+
+ // ---------------- GET:服务器地址校验 ----------------
+ if strings.EqualFold(c.Ctx.Input.Method(), "GET") {
+ echostr := q.Get("echostr")
+ if reply, ok := verifyPushEchostr(cfgs, timestamp, nonce, q.Get("signature"), q.Get("msg_signature"), echostr); ok {
+ c.Ctx.Output.Body([]byte(reply))
+ return
+ }
+ c.Ctx.Output.SetStatus(403)
+ c.Ctx.Output.Body([]byte("invalid signature"))
+ return
+ }
+
+ // ---------------- POST:消息 / 事件 ----------------
+ body, _ := io.ReadAll(c.Ctx.Request.Body)
+ raw := strings.TrimSpace(string(body))
+ if raw == "" {
+ c.Ctx.Output.Body([]byte(""))
+ return
+ }
+
+ format := wechatmini.DetectPushFormat(raw)
+ msg, perr := parsePushBody(raw, format)
+ if perr != nil {
+ logs.Info("[wechat-mini] 推送消息解析失败: %v", perr)
+ c.Ctx.Output.Body([]byte(""))
+ return
+ }
+
+ var cfg *wechatmini.PushConfig
+ encrypted := false
+
+ if msg.Encrypt != "" {
+ // 安全模式:整个请求体是密文信封,验签后解密
+ msgSig := q.Get("msg_signature")
+ if msgSig == "" {
+ msgSig = msg.MsgSignature
+ }
+ if c2, ok := wechatmini.MatchPushConfigByMsgSignature(cfgs, timestamp, nonce, msg.Encrypt, msgSig); ok &&
+ c2.EncryptMode == wechatmp.EncryptModeSafe {
+ plain, derr := wechatmini.DecryptPushMessage(c2, msg.Encrypt)
+ if derr != nil {
+ logs.Info("[wechat-mini] 推送消息解密失败: %v", derr)
+ c.Ctx.Output.Body([]byte(""))
+ return
+ }
+ inner, ierr := parsePushBody(plain, format)
+ if ierr != nil {
+ logs.Info("[wechat-mini] 解密后消息解析失败: %v", ierr)
+ c.Ctx.Output.Body([]byte(""))
+ return
+ }
+ cfg = c2
+ msg = inner
+ encrypted = true
+ }
+ }
+
+ if cfg == nil {
+ // 明文模式 / 兼容模式(兼容模式请求体本身即明文,仅额外携带 Encrypt 字段)
+ if c3, ok := wechatmini.MatchPushConfigBySignature(cfgs, timestamp, nonce, q.Get("signature")); ok {
+ cfg = c3
+ }
+ }
+
+ if cfg == nil {
+ logs.Info("[wechat-mini] 推送消息签名校验失败(timestamp=%s nonce=%s)", timestamp, nonce)
+ c.Ctx.Output.Body([]byte(""))
+ return
+ }
+
+ // 排查用:记录微信推送的消息 / 事件类型
+ logs.Info("[wechat-mini] 收到推送: appid=%s type=%s event=%s from=%s key=%s",
+ cfg.AppID, msg.MsgType, msg.Event, msg.FromUserName, msg.EventKey)
+
+ reply := wechatmini.HandlePushMessage(cfg, msg)
+ if reply == "" {
+ // 无需回复:返回空串,微信视为处理成功(不再重试)
+ c.Ctx.Output.Body([]byte(""))
+ return
+ }
+
+ now := time.Now().Unix()
+ replyBody, berr := wechatmini.BuildTextReply(cfg.DataFormat, msg.FromUserName, msg.ToUserName, reply, now)
+ if berr != nil {
+ c.Ctx.Output.Body([]byte(""))
+ return
+ }
+ if encrypted {
+ ts := q.Get("timestamp")
+ if ts == "" {
+ ts = msg.TimeStamp
+ }
+ replyNonce := nonce
+ if replyNonce == "" {
+ replyNonce = msg.Nonce
+ }
+ enc, eerr := wechatmini.BuildEncryptedReply(cfg, replyBody, ts, replyNonce, cfg.DataFormat)
+ if eerr != nil {
+ c.Ctx.Output.Body([]byte(""))
+ return
+ }
+ replyBody = enc
+ }
+
+ if cfg.DataFormat == wechatmini.DataFormatJSON {
+ c.Ctx.Output.Header("Content-Type", "application/json; charset=utf-8")
+ } else {
+ c.Ctx.Output.Header("Content-Type", "application/xml; charset=utf-8")
+ }
+ c.Ctx.Output.Body([]byte(replyBody))
+}
+
+// parsePushBody 按数据格式解析推送体
+func parsePushBody(raw, format string) (*wechatmini.PushMessage, error) {
+ if format == wechatmini.DataFormatJSON {
+ return wechatmini.ParsePushJSON(raw)
+ }
+ return wechatmini.ParsePushXML(raw)
+}
+
+// verifyPushEchostr 校验微信「服务器地址验证」请求(GET),返回应回显的内容。
+//
+// - 明文 / 兼容模式:校验 sha1(sort(token,timestamp,nonce)) == signature,通过后原样回显 echostr
+// - 安全模式:echostr 为密文,校验 msg_signature 后解密,回显解密后的明文
+//
+// 命中任意一个已配置的小程序即视为验证通过(微信回调不携带 appid)。
+func verifyPushEchostr(cfgs []wechatmini.PushConfig, timestamp, nonce, signature, msgSignature, echostr string) (string, bool) {
+ if echostr == "" {
+ return "", false
+ }
+ // 安全模式优先
+ if cfg, ok := wechatmini.MatchPushConfigByMsgSignature(cfgs, timestamp, nonce, echostr, msgSignature); ok &&
+ cfg.EncryptMode == wechatmp.EncryptModeSafe {
+ if plain, derr := wechatmini.DecryptPushMessage(cfg, echostr); derr == nil {
+ return plain, true
+ }
+ }
+ // 明文 / 兼容模式
+ if _, ok := wechatmini.MatchPushConfigBySignature(cfgs, timestamp, nonce, signature); ok {
+ return echostr, true
+ }
+ return "", false
+}
diff --git a/go/controllers/api_wechat_mini_test.go b/go/controllers/api_wechat_mini_test.go
new file mode 100644
index 0000000..c96195b
--- /dev/null
+++ b/go/controllers/api_wechat_mini_test.go
@@ -0,0 +1,77 @@
+package controllers
+
+import (
+ "crypto/sha1"
+ "encoding/hex"
+ "sort"
+ "strings"
+ "testing"
+
+ "server/services/wechatmini"
+ "server/services/wechatmp"
+)
+
+// sign 复刻微信签名算法:sha1(sort(token, timestamp, nonce))
+func sign(token, timestamp, nonce string) string {
+ arr := []string{token, timestamp, nonce}
+ sort.Strings(arr)
+ sum := sha1.Sum([]byte(strings.Join(arr, "")))
+ return hex.EncodeToString(sum[:])
+}
+
+// signMsg 复刻加密消息签名:sha1(sort(token, timestamp, nonce, encrypt))
+func signMsg(token, timestamp, nonce, encrypt string) string {
+ arr := []string{token, timestamp, nonce, encrypt}
+ sort.Strings(arr)
+ sum := sha1.Sum([]byte(strings.Join(arr, "")))
+ return hex.EncodeToString(sum[:])
+}
+
+// 明文 / 兼容模式:签名正确时原样回显 echostr
+func TestVerifyPushEchostrPlain(t *testing.T) {
+ cfgs := []wechatmini.PushConfig{
+ {AppID: "wxApp", Token: "tokenPlain", EncryptMode: wechatmp.EncryptModePlain, DataFormat: wechatmini.DataFormatXML},
+ }
+ ts, nonce, echostr := "1700000000", "abc123", "hello-wechat"
+
+ got, ok := verifyPushEchostr(cfgs, ts, nonce, sign("tokenPlain", ts, nonce), "", echostr)
+ if !ok {
+ t.Fatal("明文模式签名正确,应验证通过")
+ }
+ if got != echostr {
+ t.Fatalf("应原样回显 echostr,实际 %q", got)
+ }
+
+ if _, ok := verifyPushEchostr(cfgs, ts, nonce, "bad-signature", "", echostr); ok {
+ t.Fatal("错误签名不应通过")
+ }
+ if _, ok := verifyPushEchostr(cfgs, ts, nonce, sign("tokenPlain", ts, nonce), "", ""); ok {
+ t.Fatal("缺少 echostr 不应通过")
+ }
+}
+
+// 安全模式:echostr 为密文,验签 + 解密后回显明文
+func TestVerifyPushEchostrSafeMode(t *testing.T) {
+ key := wechatmini.GeneratePushAESKey()
+ cfgs := []wechatmini.PushConfig{
+ {AppID: "wxApp", Token: "tokenSafe", AESKey: key, EncryptMode: wechatmp.EncryptModeSafe, DataFormat: wechatmini.DataFormatXML},
+ }
+ ts, nonce := "1700000000", "abc123"
+ plain := "encrypted-echo-str"
+ encrypt, err := wechatmp.EncryptMessage(key, "wxApp", plain)
+ if err != nil {
+ t.Fatalf("加密失败: %v", err)
+ }
+
+ got, ok := verifyPushEchostr(cfgs, ts, nonce, "", signMsg("tokenSafe", ts, nonce, encrypt), encrypt)
+ if !ok {
+ t.Fatal("安全模式签名正确,应验证通过")
+ }
+ if got != plain {
+ t.Fatalf("应回显解密后的明文,实际 %q", got)
+ }
+
+ if _, ok := verifyPushEchostr(cfgs, ts, nonce, "", "bad-msg-signature", encrypt); ok {
+ t.Fatal("错误 msg_signature 不应通过")
+ }
+}
diff --git a/go/controllers/platform_wechat_mini.go b/go/controllers/platform_wechat_mini.go
index b316100..2f94909 100644
--- a/go/controllers/platform_wechat_mini.go
+++ b/go/controllers/platform_wechat_mini.go
@@ -208,3 +208,235 @@ func (c *PlatformWechatMiniController) DeleteApp() {
}
c.ok(nil, "已删除")
}
+
+// =============================================================
+// 消息推送配置(小程序后台「开发管理 → 开发设置 → 消息推送」)
+// =============================================================
+
+// GetPushConfig GET /platform/wechatMini/app/push?app_id=xxx
+// 返回该小程序的推送参数与「回调地址」(填到微信后台 URL),AESKey 掩码回显
+func (c *PlatformWechatMiniController) GetPushConfig() {
+ if _, err := c.claims(); err != nil {
+ code := identity.Status(err)
+ c.jsonErr(code, code, err.Error())
+ return
+ }
+ appID := strings.TrimSpace(c.GetString("app_id"))
+ if appID == "" {
+ c.jsonErr(400, 400, "缺少 app_id")
+ return
+ }
+ cfg, err := wechatmini.LoadPushConfigByAppID(appID)
+ if err != nil {
+ c.jsonErr(400, 400, "小程序不存在或消息推送未配置")
+ return
+ }
+ c.ok(map[string]interface{}{
+ "app_id": cfg.AppID,
+ "token": cfg.Token,
+ "aes_key": wechatmp.MaskSecret(cfg.AESKey),
+ "encrypt_mode": cfg.EncryptMode,
+ "data_format": cfg.DataFormat,
+ "push_reply": cfg.ReplyText,
+ "callback_url": wechatmini.PushCallbackURL(),
+ "configured": cfg.Token != "",
+ }, "获取成功")
+}
+
+// SavePushConfig POST /platform/wechatMini/app/push/save
+func (c *PlatformWechatMiniController) SavePushConfig() {
+ if _, err := c.claims(); err != nil {
+ code := identity.Status(err)
+ c.jsonErr(code, code, err.Error())
+ return
+ }
+ var p struct {
+ AppID string `json:"app_id"`
+ Token string `json:"token"`
+ AESKey string `json:"aes_key"`
+ EncryptMode string `json:"encrypt_mode"`
+ DataFormat string `json:"data_format"`
+ PushReply string `json:"push_reply"`
+ }
+ raw, err := io.ReadAll(c.Ctx.Request.Body)
+ if err != nil {
+ c.jsonErr(400, 400, "参数错误")
+ return
+ }
+ if err := json.Unmarshal(raw, &p); err != nil {
+ c.jsonErr(400, 400, "参数错误")
+ return
+ }
+ if strings.TrimSpace(p.AppID) == "" {
+ c.jsonErr(400, 400, "缺少 app_id")
+ return
+ }
+ if err := wechatmini.SavePushConfig(wechatmini.SavePushConfigInput{
+ AppID: p.AppID,
+ Token: p.Token,
+ AESKey: p.AESKey,
+ EncryptMode: p.EncryptMode,
+ DataFormat: p.DataFormat,
+ ReplyText: p.PushReply,
+ }); err != nil {
+ c.jsonErr(400, 400, err.Error())
+ return
+ }
+ c.ok(map[string]interface{}{"callback_url": wechatmini.PushCallbackURL()}, "保存成功")
+}
+
+// GeneratePushSecret POST /platform/wechatMini/app/push/generate
+// 生成随机 Token 与 EncodingAESKey(43 位字母数字)
+func (c *PlatformWechatMiniController) GeneratePushSecret() {
+ if _, err := c.claims(); err != nil {
+ code := identity.Status(err)
+ c.jsonErr(code, code, err.Error())
+ return
+ }
+ c.ok(map[string]interface{}{
+ "token": wechatmini.GeneratePushToken(),
+ "aes_key": wechatmini.GeneratePushAESKey(),
+ }, "生成成功")
+}
+
+// =============================================================
+// 订阅消息模板(小程序后台「功能 → 订阅消息」选用模板后在此登记)
+// =============================================================
+
+// ListTemplates GET /platform/wechatMini/templates?app_id=xxx
+func (c *PlatformWechatMiniController) ListTemplates() {
+ if _, err := c.claims(); err != nil {
+ code := identity.Status(err)
+ c.jsonErr(code, code, err.Error())
+ return
+ }
+ appID := strings.TrimSpace(c.GetString("app_id"))
+ rows, err := wechatmini.ListTemplates(appID)
+ if err != nil {
+ c.jsonErr(500, 500, "获取模板列表失败")
+ return
+ }
+ c.ok(map[string]interface{}{"list": rows}, "获取成功")
+}
+
+// SaveTemplate POST /platform/wechatMini/template/save
+func (c *PlatformWechatMiniController) SaveTemplate() {
+ if _, err := c.claims(); err != nil {
+ code := identity.Status(err)
+ c.jsonErr(code, code, err.Error())
+ return
+ }
+ var p struct {
+ ID uint64 `json:"id"`
+ AppID string `json:"app_id"`
+ Code string `json:"code"`
+ TemplateID string `json:"template_id"`
+ Title string `json:"title"`
+ Page string `json:"page"`
+ Fields string `json:"fields"`
+ Enabled int `json:"enabled"`
+ Remark string `json:"remark"`
+ }
+ raw, err := io.ReadAll(c.Ctx.Request.Body)
+ if err != nil {
+ c.jsonErr(400, 400, "参数错误")
+ return
+ }
+ if err := json.Unmarshal(raw, &p); err != nil {
+ c.jsonErr(400, 400, "参数错误")
+ return
+ }
+ if err := wechatmini.SaveTemplate(wechatmini.TemplateInput{
+ ID: p.ID,
+ AppID: p.AppID,
+ Code: p.Code,
+ TemplateID: p.TemplateID,
+ Title: p.Title,
+ Page: p.Page,
+ Fields: p.Fields,
+ Enabled: p.Enabled == 1,
+ Remark: p.Remark,
+ }); err != nil {
+ c.jsonErr(400, 400, err.Error())
+ return
+ }
+ c.ok(nil, "保存成功")
+}
+
+// DeleteTemplate POST /platform/wechatMini/template/delete/:id
+func (c *PlatformWechatMiniController) DeleteTemplate() {
+ if _, err := c.claims(); err != nil {
+ code := identity.Status(err)
+ c.jsonErr(code, code, err.Error())
+ return
+ }
+ id, err := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
+ if err != nil || id == 0 {
+ c.jsonErr(400, 400, "无效ID")
+ return
+ }
+ if err := wechatmini.DeleteTemplate(id); err != nil {
+ c.jsonErr(400, 400, err.Error())
+ return
+ }
+ c.ok(nil, "已删除")
+}
+
+// TestTemplate POST /platform/wechatMini/template/test
+// 给指定租户用户发一条测试订阅消息,验证模板与字段映射是否正确
+func (c *PlatformWechatMiniController) TestTemplate() {
+ if _, err := c.claims(); err != nil {
+ code := identity.Status(err)
+ c.jsonErr(code, code, err.Error())
+ return
+ }
+ var p struct {
+ TenantUserID uint64 `json:"tenant_user_id"`
+ AppID string `json:"app_id"`
+ Code string `json:"code"`
+ Title string `json:"title"`
+ Content string `json:"content"`
+ }
+ raw, err := io.ReadAll(c.Ctx.Request.Body)
+ if err != nil {
+ c.jsonErr(400, 400, "参数错误")
+ return
+ }
+ if err := json.Unmarshal(raw, &p); err != nil {
+ c.jsonErr(400, 400, "参数错误")
+ return
+ }
+ if p.TenantUserID == 0 {
+ c.jsonErr(400, 400, "请指定测试用户(租户用户ID)")
+ return
+ }
+ code := strings.TrimSpace(p.Code)
+ if code == "" {
+ code = wechatmini.SceneNotice
+ }
+ title := strings.TrimSpace(p.Title)
+ if title == "" {
+ title = "测试提醒"
+ }
+ content := strings.TrimSpace(p.Content)
+ if content == "" {
+ content = "这是一条订阅消息测试,收到说明配置正确。"
+ }
+ res := wechatmini.PushSubscribeToTenantUser(p.TenantUserID, code, strings.TrimSpace(p.AppID), map[string]string{
+ "title": title,
+ "content": content,
+ })
+ if res.Sent > 0 {
+ c.ok(map[string]interface{}{"sent": res.Sent}, "已发送")
+ return
+ }
+ if len(res.Errors) > 0 {
+ msg := res.Errors[0].Error()
+ if hint := wechatmini.ExplainSubscribeError(res.Errors[0]); hint != "" {
+ msg = hint
+ }
+ c.jsonErr(400, 400, msg)
+ return
+ }
+ c.jsonErr(400, 400, "未发送:该用户没有授权订阅,或模板未配置/未启用")
+}
diff --git a/go/docs/nginx/api.yunzer.cn.conf b/go/docs/nginx/api.yunzer.cn.conf
new file mode 100644
index 0000000..f4b744e
--- /dev/null
+++ b/go/docs/nginx/api.yunzer.cn.conf
@@ -0,0 +1,179 @@
+# ============================================================
+# 宝塔站点 api.yunzer.cn 的 nginx 配置(留档副本)
+# 文件位置(服务器):/www/server/panel/vhost/nginx/api.yunzer.cn.conf
+# 面板入口:宝塔 → 网站 → api.yunzer.cn → 设置 → 配置文件
+#
+# 【2026-10-31 新增】根路径域名校验文件的 http 放行
+# 微信公众平台 / 小程序配置「业务域名」时,校验服务是走 **http**
+# 去抓取根目录下的校验文件(如 /1XXl8fPHr9.txt)。
+# 原配置把 http 一律 301 到 https,微信拿到重定向就判
+# 「校验文件验证失败,请确认已在域名根目录正确放置校验文件」。
+# 下面 HTTP_TO_HTTPS 块里新增的 if 例外,就是让根路径下的 .txt
+# 校验文件在 http 下直接返回文件内容(不跳转)。
+# 用通配而不是写死文件名:微信每次重新下载校验文件都会生成新随机名。
+#
+# 【注意】不要设 verify_hosts
+# 本文件里 proxy_set_header Host 10.31.100.2; 把 Host 头改成了 IP,
+# Go 侧看不到 api.yunzer.cn 这个域名。verify_hosts 必须留空
+# (留空 = 所有走本服务的域名都放行)。
+#
+# 【注意】location ^~ / 会让后面的正则 location 失效
+# ^~ 匹配后 nginx 不再检查正则 location,所以下面两条
+# 「禁止访问的敏感文件 / 敏感目录」的 location ~* 规则在本站点实际不生效。
+# 当前无泄露风险(Go 不提供工作目录文件,未知路径一律 404),但要知道这层保护是空的。
+#
+# 【注意】proxy_cache_path 定义了缓存区但当前未启用 proxy_cache
+# 以后若启用全局缓存,301 会被缓存住,微信可能拿到缓存的旧响应,届时需清缓存。
+# ============================================================
+
+proxy_cache_path /www/wwwroot/api.yunzer.cn/proxy_cache_dir levels=1:2 keys_zone=api_yunzer_cn_cache:20m inactive=1d max_size=5g;
+server {
+ listen 80;
+ listen 443 ssl;
+ http2 on;
+ listen [::]:80;
+ server_name api.yunzer.cn;
+ index index.php index.html index.htm default.php default.htm default.html;
+ root /www/wwwroot/api.yunzer.cn;
+ include /www/server/panel/vhost/nginx/extension/api.yunzer.cn/*.conf;
+ #CERT-APPLY-CHECK--START
+
+ # 用于SSL证书申请时的文件验证相关配置 -- 请勿删除
+ include /www/server/panel/vhost/nginx/well-known/api.yunzer.cn.conf;
+ #CERT-APPLY-CHECK--END
+
+ #SSL-START SSL相关配置,请勿删除或修改下一行带注释的404规则
+
+ #error_page 404/404.html;
+
+ #HTTP_TO_HTTPS_START
+ set $isRedcert 1;
+ if ($server_port != 443) {
+ set $isRedcert 2;
+ }
+ if ( $uri ~ /\.well-known/ ) {
+ set $isRedcert 1;
+ }
+ # 域名校验文件(微信/搜索引擎/第三方平台)必须能用 http 直接访问,不能 301 跳 https。
+ # 用 [.] 代替 \. ,避免 nginx 配置解析里反斜杠转义的歧义。
+ if ( $uri ~ ^/[A-Za-z0-9._-]+[.]txt$ ) {
+ set $isRedcert 1;
+ }
+ if ($isRedcert != 1) {
+ rewrite ^(/.*)$ https://$host$1 permanent;
+ }
+ #HTTP_TO_HTTPS_END
+ ssl_certificate /www/server/panel/vhost/cert/api.yunzer.cn/fullchain.pem;
+ ssl_certificate_key /www/server/panel/vhost/cert/api.yunzer.cn/privkey.pem;
+ ssl_protocols TLSv1.1 TLSv1.2 TLSv1.3;
+ ssl_ciphers EECDH+CHACHA20:CHACHA20-draft:EECDH+AES128:RSA+AES128:EECDH+AES256:RSA+AES256:EECDH+3DES:RSA+3DES:!MD5;
+ ssl_prefer_server_ciphers on;
+ ssl_session_tickets on;
+ ssl_session_cache shared:SSL:10m;
+ ssl_session_timeout 10m;
+ add_header Strict-Transport-Security "max-age=31536000";
+ error_page 497 https://$host$request_uri;
+ #SSL-END
+
+ #REDIRECT START
+
+ #REDIRECT END
+
+ #ERROR-PAGE-START 错误页配置,可以注释、删除或修改
+
+ #error_page 404 /404.html;
+
+ #error_page 502 /502.html;
+
+ #ERROR-PAGE-END
+
+ #PHP-INFO-START PHP引用配置,可以注释或修改
+ include enable-php-00.conf;
+ #PHP-INFO-END
+
+ #IP-RESTRICT-START 限制访问ip的配置,IP黑白名单
+
+ #IP-RESTRICT-END
+
+ #BASICAUTH START
+
+ #BASICAUTH END
+
+ #SUB_FILTER START
+
+ #SUB_FILTER END
+
+ #GZIP START
+
+ #GZIP END
+
+ #GLOBAL-CACHE START
+
+ #GLOBAL-CACHE END
+
+ #WEBSOCKET-SUPPORT START
+ proxy_http_version 1.1;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection $connection_upgrade;
+ #WEBSOCKET-SUPPORT END
+
+ #PROXY-CONF-START
+ location ^~ / {
+ proxy_pass http://10.31.100.2:9000;
+ proxy_set_header Host 10.31.100.2;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Real-Port $remote_port;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_set_header X-Forwarded-Host $host;
+ proxy_set_header X-Forwarded-Port $server_port;
+ proxy_set_header REMOTE-HOST $remote_addr;
+ # 连接超时(建立连接的时间)
+ proxy_connect_timeout 3600s;
+ # 发送超时(发送请求到后端的时间)
+ proxy_send_timeout 3600s;
+ # 读取超时(等待后端响应的时间)
+ proxy_read_timeout 3600s;
+ # 支持websocket链接
+ proxy_http_version 1.1;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection $connection_upgrade;
+ # 客户端请求体大小限制(0 表示不限制)
+ client_max_body_size 0;
+ # 客户端请求体超时(客户端发送请求体的时间)
+ client_body_timeout 3600s;
+ # 客户端请求体缓冲区大小
+ client_body_buffer_size 128k;
+ # 禁用请求体缓冲(直接流式传输到后端,不先缓存到磁盘)
+ proxy_request_buffering off;
+ # 禁用响应缓冲(直接流式传输到客户端)
+ proxy_buffering off;
+ }
+ #PROXY-CONF-END
+
+ #SERVER-BLOCK START
+
+ #SERVER-BLOCK END
+
+ # 禁止访问的敏感文件
+ location ~* (\.user.ini|\.htaccess|\.htpasswd|\.env.*|\.project|\.bashrc|\.bash_profile|\.bash_logout|\.DS_Store|\.gitignore|\.gitattributes|LICENSE|README\.md|CLAUDE\.md|CHANGELOG\.md|CHANGELOG|CONTRIBUTING\.md|TODO\.md|FAQ\.md|composer\.json|composer\.lock|package(-lock)?\.json|yarn\.lock|pnpm-lock\.yaml|\.\w+~|\.swp|\.swo|\.bak(up)?|\.old|\.tmp|\.temp|\.log|\.sql(\.gz)?|docker-compose\.yml|docker\.env|Dockerfile|\.csproj|\.sln|Cargo\.toml|Cargo\.lock|go\.mod|go\.sum|phpunit\.xml|phpunit\.xml|pom\.xml|build\.gradl|pyproject\.toml|requirements\.txt|application(-\w+)?\.(ya?ml|properties))$ {
+ return 404;
+ }
+ # 禁止访问的敏感目录
+ location ~* /(\.git|\.svn|\.bzr|\.vscode|\.claude|\.idea|\.ssh|\.github|\.npm|\.yarn|\.pnpm|\.cache|\.husky|\.turbo|\.next|\.nuxt|node_modules|runtime)/ {
+ return 404;
+ }
+ #一键申请SSL证书验证目录相关设置
+ location /.well-known {
+ allow all;
+ }
+ #禁止在证书验证目录放入敏感文件
+ if ( $uri ~ "^/\.well-known/.*\.(php|jsp|py|js|css|lua|ts|go|zip|tar\.gz|rar|7z|sql|bak)$" ) {
+ return 403;
+ }
+ #LOG START
+ access_log /www/wwwlogs/api.yunzer.cn.log;
+ error_log /www/wwwlogs/api.yunzer.cn.error.log;
+ #LOG END
+
+}
diff --git a/go/docs/sql/alter_wechat_mini_app_push.sql b/go/docs/sql/alter_wechat_mini_app_push.sql
new file mode 100644
index 0000000..6656853
--- /dev/null
+++ b/go/docs/sql/alter_wechat_mini_app_push.sql
@@ -0,0 +1,21 @@
+-- 微信小程序多应用表补充「消息推送」配置字段
+--
+-- 对应小程序后台:开发管理 → 开发设置 → 消息推送
+-- Token / EncodingAESKey / 消息加解密方式 / 数据格式
+-- 平台端「小程序配置 - 编辑小程序 - 消息推送」维护,保存后把「回调地址」填到后台。
+--
+-- 注意:aes_key 以 AES-256-GCM 加密存储(复用 wechatmp 的密钥),与 AppSecret 同一套加密。
+-- 执行:mysql -h -P -u -p < alter_wechat_mini_app_push.sql
+-- ============================================================
+
+ALTER TABLE `yz_platform_wechat_mini_app`
+ ADD COLUMN `token` varchar(128) NOT NULL DEFAULT '' COMMENT '消息推送 Token(小程序后台「消息推送」填写)' AFTER `qrcode_url`,
+ ADD COLUMN `aes_key` varchar(512) NOT NULL DEFAULT '' COMMENT '消息推送 EncodingAESKey(AES-256-GCM 加密存储)' AFTER `token`,
+ ADD COLUMN `encrypt_mode` varchar(16) NOT NULL DEFAULT 'plain' COMMENT '消息加解密方式 plain=明文 compatible=兼容 safe=安全' AFTER `aes_key`,
+ ADD COLUMN `data_format` varchar(16) NOT NULL DEFAULT 'xml' COMMENT '消息数据格式 xml / json' AFTER `encrypt_mode`,
+ ADD COLUMN `push_reply` varchar(512) NOT NULL DEFAULT '' COMMENT '客服消息自动回复文案(空=不自动回复)' AFTER `data_format`;
+
+-- 结果校验
+SELECT id, name, app_id, enabled, token, encrypt_mode, data_format, push_reply
+FROM yz_platform_wechat_mini_app
+WHERE delete_time IS NULL;
diff --git a/go/docs/sql/create_wechat_mini_template.sql b/go/docs/sql/create_wechat_mini_template.sql
new file mode 100644
index 0000000..21c6b41
--- /dev/null
+++ b/go/docs/sql/create_wechat_mini_template.sql
@@ -0,0 +1,45 @@
+-- ============================================================
+-- 小程序订阅消息:模板配置 + 授权记录
+-- yz_platform_wechat_mini_template 订阅消息模板(按小程序 + 场景码)
+-- yz_platform_wechat_mini_subscribe 用户授权记录(一次性订阅的剩余发送次数)
+--
+-- 模板 ID 需要在小程序后台「功能 → 订阅消息 → 公共模板库」选用后填入平台端。
+-- fields 为 JSON:微信模板字段键 → 语义变量名,例如
+-- {"thing1":"title","phrase2":"content","time3":"time"}
+--
+-- 幂等脚本:重复执行不会产生重复数据。
+-- 执行:mysql -h -P -u -p < create_wechat_mini_template.sql
+-- ============================================================
+
+CREATE TABLE IF NOT EXISTS yz_platform_wechat_mini_template (
+ id bigint unsigned NOT NULL AUTO_INCREMENT,
+ app_id varchar(64) NOT NULL COMMENT '小程序 AppID',
+ code varchar(64) NOT NULL COMMENT '场景码 notice/approval/schedule...',
+ template_id varchar(128) NOT NULL DEFAULT '' COMMENT '微信订阅消息模板 ID',
+ title varchar(255) NOT NULL DEFAULT '' COMMENT '场景名称',
+ page varchar(255) NOT NULL DEFAULT '' COMMENT '点击消息跳转的小程序页面',
+ fields text NULL COMMENT 'JSON:微信字段键 → 语义变量名映射',
+ enabled tinyint NOT NULL DEFAULT 1 COMMENT '1=启用 0=停用',
+ remark varchar(255) NOT NULL DEFAULT '' COMMENT '备注',
+ create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+ update_time datetime NULL COMMENT '更新时间',
+ delete_time datetime NULL COMMENT '软删时间',
+ PRIMARY KEY (id),
+ UNIQUE KEY uk_mini_tpl_app_code (app_id, code)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='小程序订阅消息模板';
+
+CREATE TABLE IF NOT EXISTS yz_platform_wechat_mini_subscribe (
+ id bigint unsigned NOT NULL AUTO_INCREMENT,
+ identity_id bigint unsigned NOT NULL COMMENT '身份 ID(yz_auth_identity.id)',
+ app_id varchar(64) NOT NULL COMMENT '小程序 AppID',
+ template_id varchar(128) NOT NULL COMMENT '微信订阅消息模板 ID',
+ remain int NOT NULL DEFAULT 0 COMMENT '剩余可发送次数(一次性订阅)',
+ create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+ update_time datetime NULL COMMENT '更新时间',
+ PRIMARY KEY (id),
+ UNIQUE KEY uk_mini_sub_identity_tpl (identity_id, app_id, template_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='小程序订阅消息授权记录';
+
+-- 结果校验
+SELECT id, app_id, code, template_id, title, page, enabled FROM yz_platform_wechat_mini_template
+WHERE delete_time IS NULL;
diff --git a/go/docs/小程序订阅消息配置与使用.md b/go/docs/小程序订阅消息配置与使用.md
new file mode 100644
index 0000000..7639b64
--- /dev/null
+++ b/go/docs/小程序订阅消息配置与使用.md
@@ -0,0 +1,142 @@
+# 小程序订阅消息:配置与使用(怎么让用户收到提示)
+
+## 一、先分清两条通道
+
+| | 消息推送(服务器配置) | 订阅消息 |
+| --- | --- | --- |
+| 方向 | **用户 → 你**(用户发客服消息给你) | **你 → 用户**(你主动推提示给用户) |
+| 触发 | 用户在小程序点「客服」发消息 | 业务事件发生(审批待办、日程提醒、站内信…) |
+| 前置条件 | 无 | 用户必须在微信弹窗里点「允许」(一次性订阅:授权一次 = 1 次推送机会) |
+| 微信后台位置 | 开发管理 → 开发设置 → 消息推送 | 功能 → 订阅消息 |
+| 本工程实现 | `services/wechatmini/push.go` + `controllers/api_wechat_mini.go` | `services/wechatmini/subscribe.go` + `controllers/api_mini_subscribe.go` |
+
+**结论**:想让用户「收到提示」,靠的是**订阅消息**,不是消息推送。消息推送只是接收端(顺带做客服自动回复)。
+
+---
+
+## 二、完整流程(4 步)
+
+### 第 1 步:微信后台选一个订阅消息模板
+
+小程序后台 → **功能 → 订阅消息 → 公共模板库 → 选用模板**(例如「审批进度通知」「待办提醒」)。
+
+选完后记下两样东西:
+
+- **模板 ID**(一串字母数字)
+- **模板的字段名**(如 `thing1`、`phrase2`、`time3`、`character_string4`)
+
+> 模板选错会导致推送报 40066 / 47003,字段名对不上则报「模板字段不匹配」。
+
+### 第 2 步:平台端登记模板
+
+平台后台 → **系统设置 → 小程序配置 → 编辑小程序 → 订阅消息模板 → 新增模板**
+
+| 字段 | 填什么 |
+| --- | --- |
+| 场景码 | `notice`(站内通知/通用提醒;同一个小程序下唯一) |
+| 场景名称 | 如「站内通知」 |
+| 模板 ID | 第 1 步复制的模板 ID |
+| 跳转页面 | 用户点击消息进入的小程序页面,如 `pages/index/index` |
+| 字段映射 | JSON:`{"thing1":"title","phrase2":"content","time3":"time"}` |
+
+**字段映射怎么读**:左边是微信模板的字段名,右边是平台这边的变量名。平台会提供三个变量:
+
+- `title` —— 标题(如「您有新的审批待办」)
+- `content` —— 内容
+- `time` —— 推送时间(`2026-10-31 19:00`)
+
+映射里写了哪个字段,就把对应变量填进去;模板有但没映射的字段会自动填 `-`。
+
+### 第 3 步:小程序端让用户授权
+
+已接入的位置:**小程序「我的 → 消息通知」**,点一下会拉起微信的订阅授权弹窗。
+
+```js
+import { requestSubscribeNotice } from '@/utils/subscribe.js'
+await requestSubscribeNotice()
+```
+
+建议在**用户确实可能需要收到提醒**的时机也调用一次(比如进入工作台、提交完审批之后)——因为微信一次性订阅,授权一次只有一次推送机会,用完了要再授权。
+
+小程序端也可以直接调接口:
+
+- `GET /api/mini/subscribe/templates` 取模板列表
+- `POST /api/mini/subscribe/authorize` 上报授权结果
+- `POST /api/mini/subscribe/test` 给自己发一条测试消息
+
+### 第 4 步:业务侧触发
+
+已接入的触发点(业务照常发,订阅消息自动跟上,**不影响主流程**,失败只记日志):
+
+| 业务 | 代码位置 |
+| --- | --- |
+| 站内信(平台端手动发通知) | `services/system_sitereminder.go` → `SendSiteReminder` |
+| OA 日程提醒(定时任务) | `services/reminder_scheduler.go` → `SiteMsgSender.Send` |
+| 审批待办(租户侧审批人生成待办) | `services/approval/engine.go` → `createTasks` |
+
+自己接新场景时,直接调:
+
+```go
+wechatmini.PushTenantNotice(租户用户ID, "标题", "内容")
+// 或指定场景码:
+wechatmini.PushSubscribeToTenantUser(租户用户ID, "notice", "", map[string]string{
+ "title": "标题", "content": "内容", "time": "2026-10-31 19:00",
+})
+```
+
+---
+
+## 三、一次性订阅的规则(最容易踩坑)
+
+- 用户点一次「允许」= **1 次**推送机会,服务器记在 `yz_platform_wechat_mini_subscribe.remain`
+- 推送成功自动 `remain - 1`;用户拒绝或机会用尽(微信返回 43101)自动清零
+- `remain = 0` 时不会调微信接口,直接跳过(不浪费配额)
+- 微信一次弹窗最多 3 个模板,前端已自动分批
+- 「长期订阅」只有部分类目能用,��务小程序基本用不了,所以要设计好**再次引导授权**的时机
+
+---
+
+## 四、常见错误码
+
+| errcode | 含义 | 处理 |
+| --- | --- | --- |
+| 43101 | 用户未订阅 / 拒绝订阅 | 引导用户在小程序里重新点「消息通知」授权 |
+| 40066 | 模板 ID 无效 | 检查是不是填了「功能 → 订阅消息」里真正的模板 ID |
+| 47003 | 模板字段不匹配 | 检查字段映射与微信模板字段是否一致 |
+| 40003 | openid 无效 | 该用户不是用这个小程序登录的,或 openid 与 AppID 不匹配 |
+| 40001 | access_token 失效 | 工具会自动刷新,稍后重试 |
+
+平台端「测试」按钮会直接返回中文排查建议。
+
+---
+
+## 五、接口清单
+
+| 方法 | 路径 | 说明 |
+| --- | --- | --- |
+| GET | `/api/mini/subscribe/templates` | 小程序端:可订阅模板列表(含剩余次数) |
+| POST | `/api/mini/subscribe/authorize` | 小程序端:上报授权结果 |
+| POST | `/api/mini/subscribe/test` | 小程序端:给自己发一条测试消息 |
+| GET | `/platform/wechatMini/templates` | 平台端:模板列表 |
+| POST | `/platform/wechatMini/template/save` | 平台端:保存模板 |
+| POST | `/platform/wechatMini/template/delete/:id` | 平台端:删除模板 |
+| POST | `/platform/wechatMini/template/test` | 平台端:给指定租户用户发测试消息 |
+
+## 六、相关文件
+
+| 文件 | 作用 |
+| --- | --- |
+| `go/services/wechatmini/subscribe.go` | 订阅消息 / 客服消息发送、模板 CRUD、授权记录、推送编排 |
+| `go/controllers/api_mini_subscribe.go` | 小程序端接口 |
+| `go/controllers/platform_wechat_mini.go` | 平台端模板管理接口 |
+| `go/models/wechat_mini.go` | `WechatMiniTemplate` / `WechatMiniSubscribe` 模型 |
+| `go/docs/sql/create_wechat_mini_template.sql` | 建表脚本 |
+| `uniapp/utils/subscribe.js` | 小程序端授权助手 |
+| `uniapp/api/subscribe.js` | 小程序端接口封装 |
+| `uniapp/pages/profile/profile.vue` | 「我的 → 消息通知」授权入口 |
+
+建表脚本:
+
+```bash
+mysql -h -P -u -p < go/docs/sql/create_wechat_mini_template.sql
+```
diff --git a/go/docs/微信小程序消息推送配置.md b/go/docs/微信小程序消息推送配置.md
new file mode 100644
index 0000000..7daf67e
--- /dev/null
+++ b/go/docs/微信小程序消息推送配置.md
@@ -0,0 +1,135 @@
+# 微信小程序「消息推送」配置指引
+
+对应小程序后台:**开发管理 → 开发设置 → 消息推送**(就是你截图里的那个表单)。
+
+---
+
+## 0. 先搞清楚这个配置是干什么的
+
+微信后台的「消息推送」= **你的服务器接收微信主动推送的入口**。
+
+| 场景 | 是否走这个配置 |
+| --- | --- |
+| 用户给小程序「客服」发消息、进入客服会话 | ✅ 走(微信 POST 到你的 URL) |
+| 扫普通链接二维码打开小程序等事件 | ✅ 走 |
+| 订阅消息(`wx.requestSubscribeMessage`) | ❌ 不走,用服务端 API 主动发 |
+| 模板消息 | ❌ 已下线,改用订阅消息 |
+
+---
+
+## 1. 前置条件(不满足会一直「配置失败」)
+
+1. **域名已 ICP 备案**(微信后台会校验)。
+2. **只用 80 / 443 端口**:`https://xxx.com`(443)或 `http://xxx.com`(80),其它端口微信不接受。
+3. **微信服务器能直连**:公网可达,不能是 `127.0.0.1`、内网 IP 或本机端口。
+4. **HTTPS 证书链完整**:只部署 `.crt` 不带中间证书,微信侧会握手失败(自签证书不行)。
+5. **5 秒内返回**:接口要在 5 秒内响应,否则微信断开并重试。
+
+> 本项目的回调地址在 `go/conf/app.conf` 里配置:
+> `wechat_mini_callback_base = https://api.yunzer.cn`(留空则退化为相对路径 `/api/wechat/mini/callback`,需由网关补域名)。
+
+---
+
+## 2. 第一步:在本平台生成参数
+
+平台端:**系统设置 → 小程序配置 → 编辑某个小程序 → 消息推送**
+
+| 字段 | 操作 | 说明 |
+| --- | --- | --- |
+| 回调地址 | 自动生成,点「复制」 | 即 `https://你的域名/api/wechat/mini/callback` |
+| Token | 点「随机生成」 | 3-32 位字母数字 |
+| EncodingAESKey | 点「随机生成」 | 43 位字母数字,后台也有「随机生成」按钮,两边值必须一致 |
+| 加解密方式 | 明文 / 兼容 / 安全 | 与微信后台选择保持一致 |
+| 数据格式 | XML / JSON | 与微信后台选择保持一致 |
+| 自动回复文案 | 选填 | 留空 = 只接收不回复 |
+
+填完点 **「保存消息推送配置」**(注意:这一步只存平台端,不会自动同步到微信后台)。
+
+---
+
+## 3. 第二步:在微信后台把值抄过去
+
+小程序后台 → 开发管理 → 开发设置 → 消息推送,逐项对应:
+
+| 后台字段 | 填什么 |
+| --- | --- |
+| URL(服务器地址) | 平台端「回调地址」整条复制,如 `https://api.yunzer.cn/api/wechat/mini/callback` |
+| Token(令牌) | 平台端「Token」 |
+| EncodingAESKey(消息加密密钥) | 平台端「EncodingAESKey」(43 位) |
+| 消息加密方式 | 与平台端一致(明文 / 兼容 / 安全) |
+| 数据格式 | 与平台端一致(XML / JSON) |
+
+填完点「提交」,微信会立刻发一个 **GET** 请求到你的 URL 做校验,通过就提示配置成功。
+
+⚠️ 配置**提交后立即生效**,切换加密方式或数据格式前,务必先把平台端改成同样的值,否则消息会被验签/解密失败直接丢弃。
+
+---
+
+## 4. 提交时微信做了什么(校验逻辑)
+
+微信 GET 你的 URL,参数:`signature` `timestamp` `nonce` `echostr`。
+
+- **明文 / 兼容模式**:服务端算 `sha1(sort(token, timestamp, nonce) 拼接)` 与 `signature` 比对,通过后**原样返回 `echostr`**。
+- **安全模式**:`echostr` 是密文,额外带 `msg_signature = sha1(sort(token, timestamp, nonce, echostr))`;服务端验签后解密,再返回解密结果。
+
+对应实现:
+
+- `go/services/wechatmini/push.go` —— 签名校验、AES 加解密、XML/JSON 解析、被动回复构造
+- `go/controllers/api_wechat_mini.go` —— `GET /api/wechat/mini/callback` 校验 + `POST` 收消息
+
+**多小程序**:每个小程序在自己的后台单独配置,本平台按 AppID 分别维护 Token / AESKey;
+回调接口不携带 appid,服务端会逐个应用匹配签名,命中哪个就按哪个的密钥解密。
+
+---
+
+## 5. 收到消息后的行为
+
+- 微信 **5 秒**内没收到响应会断开并重试,**总共重试 3 次**(间隔 15s / 5s / 5s)。
+- 返回**空串**或 `success` = 已处理,微信不再重试(我们的实现返回空串)。
+- 平台端配了「自动回复文案」时,5 秒内以被动回复形式把文案回给用户。
+- 复杂业务(例如按关键字回复、转发人工)可接管钩子:
+ `services/wechatmini/push.go` 中的 `PushHandler`。
+
+---
+
+## 6. 常见报错排查
+
+| 现象 | 常见原因 | 处理 |
+| --- | --- | --- |
+| 后台一直「配置失败」 | 域名未备案 / 端口不是 80、443 / 证书链不完整 / 返回内容不是 echostr | 逐条对照第 1 节 |
+| 后台提示超时 | 接口处理超过 5 秒 | 先只回 echostr,业务逻辑异步做 |
+| 后台提示 Token 校验失败 | 平台端 Token 与后台填的不一致 | 两边都复制一遍核对(区分大小写) |
+| 配置成功但收不到消息 | 加解密方式或数据格式两边不一致 | 平台端与后台改成一致 |
+| 小程序会话提示「客服暂时无法提供服务」 | 5 秒内没回复 | 配「自动回复文案」,或让接口先返回空串 |
+| 明文模式正常,切安全模式收不到 | EncodingAESKey 两边不一致 | 重新复制 43 位密钥 |
+
+### 自测命令(明文模式)
+
+```bash
+# signature = sha1(sort(token, timestamp, nonce) 拼接),用与平台端相同的 token
+curl "https://api.yunzer.cn/api/wechat/mini/callback?signature=<签名>×tamp=1700000000&nonce=abc123&echostr=hello"
+# 期望输出:hello(等于传入的 echostr)
+```
+
+服务端日志会打印 `[wechat-mini] 收到推送: ...`,看不到这行说明微信请求没到服务端(先查域名 / 端口 / 证书)。
+
+---
+
+## 7. 相关接口
+
+| 方法 | 路径 | 说明 |
+| --- | --- | --- |
+| GET/POST | `/api/wechat/mini/callback` | 微信回调(无需鉴权,内部验签) |
+| GET | `/platform/wechatMini/app/push?app_id=xxx` | 读取推送配置(含回调地址) |
+| POST | `/platform/wechatMini/app/push/save` | 保存推送配置 |
+| POST | `/platform/wechatMini/app/push/generate` | 生成随机 Token / EncodingAESKey |
+
+## 8. 数据库
+
+新增字段(`token` / `aes_key` / `encrypt_mode` / `data_format` / `push_reply`):
+
+```bash
+mysql -h -P -u -p < go/docs/sql/alter_wechat_mini_app_push.sql
+```
+
+`aes_key` 以 AES-256-GCM 加密入库(复用公众号的 `wechat_mp_secret_key`),接口只回显掩码。
diff --git a/go/docs/服务器增量部署说明.md b/go/docs/服务器增量部署说明.md
new file mode 100644
index 0000000..d8f3860
--- /dev/null
+++ b/go/docs/服务器增量部署说明.md
@@ -0,0 +1,94 @@
+# 服务器增量部署说明(update-server.bat)
+
+本地 `go/` 目录改动后,只把**改动过的文件**推到服务器,不用再一个个找文件替换。
+
+## 一、用法
+
+在 `go/` 目录下双击 `update-server.bat`,或命令行执行:
+
+```bat
+update-server.bat :: 比对 -> 确认 -> 上传 -> 询问是否重启服务
+update-server.bat dry :: 只比对不上传(先看清单用这个)
+update-server.bat restart :: 只重启服务器服务
+```
+
+bat 会在 `go/` 目录下自动编译部署工具 `bin/deploy.exe`,然后:
+
+1. SSH 连上服务器,对 `go/` 目录和服务器目录**各算一遍文件 MD5**
+2. 比对出「新增」和「内容变了」的文件并打印清单
+3. 确认后把这些文件打包成 tar 通过 SSH 管道传到服务器解包(只传差异文件)
+4. 回读服务器指纹复核,确认每个文件都传对了
+5. 询问是否 `systemctl restart go-api.service` 让改动生效
+
+## 二、配置
+
+bat 顶部「CONFIG」区:
+
+| 变量 | 说明 |
+| --- | --- |
+| `DEPLOY_HOST` | 服务器地址 |
+| `DEPLOY_USER` | SSH 用户名 |
+| `DEPLOY_DIR` | 服务器目录 |
+| `DEPLOY_KEY` | 私钥路径(默认 `%~dp010.31.100.2_id_ed25519`) |
+| `DEPLOY_PASS` | 密码(备用,服务器开了密码认证时才用) |
+| `RESTART_CMD` | 重启命令,默认 `systemctl restart go-api.service` |
+
+> **注意**:这台服务器 sshd 关闭了密码认证(只接受 publickey / gssapi),所以默认走**密钥登录**。
+> 如果以后在服务器上开了密码认证,把 `DEPLOY_KEY` 置空、填上 `DEPLOY_PASS` 即可,工具会自动切换。
+
+### 关于私钥
+
+- 私钥放在 `go/10.31.100.2_id_ed25519`,**已被同步工具自动排除**,不会传到服务器。
+- Windows 上私钥权限过宽会让 OpenSSH 客户端拒绝加载,已用下面的命令收紧过(只需做一次):
+
+```bat
+icacls "10.31.100.2_id_ed25519" /inheritance:r /grant:r "%USERNAME%:(F)"
+```
+
+- `go/.deploy_known_hosts` 记录服务器主机密钥指纹,换服务器时会提示指纹变化(同样被排除,不会上传)。
+
+## 三、不会同步的内容
+
+工具默认跳过:`.git` / `node_modules` / `bin` / `dist` / `tmp`、`*.exe` / `*.log` / `*.bak`、
+`nohup.out`、`output.log`、`go.log`、`test-output-*.txt`、`migrate_*.csv`、
+无扩展名的 `server` 二进制、`*_id_ed25519` 私钥、`.deploy_known_hosts`。
+
+服务器上存在、本地没有的文件**默认保留不删**(服务器上还有 `.git`、历史二进制等)。
+确实要清理就在 bat 里加 `-delete`,但请先想清楚。
+
+## 四、原理
+
+```
+本地 go/ ──MD5──┐
+ ├─ 比对 ──> 只传 新增/变更 的文件 ──> 服务器 /www/wwwroot/api.yunzer.cn
+服务器 ──MD5──┘ └── 传完回读远端指纹复核
+```
+
+- 服务器是 systemd 的 `go-api.service`,`ExecStart=/usr/local/btgo/bin/go run main.go`,
+ **直接用目录里的源码编译运行**,所以传 `.go` 源码即可,重启服务就会重新编译生效。
+- 传到服务器的文件属主会被改成目标目录的属主(`www:www`),权限规范成 0644(脚本 0755)。
+
+## 五、常见问题
+
+| 现象 | 处理 |
+| --- | --- |
+| `Permission denied (publickey,gssapi-...)` | 私钥没配好:检查 `DEPLOY_KEY` 路径,或用 icacls 收紧权限 |
+| `本地目录不存在` | 别从别处调用 bat,bat 里用 `%~dp0` 定位自身目录 |
+| 上传后服务没生效 | 需要重启:`update-server.bat restart`(或看 go.log 是否有编译错误) |
+| 清单里出现奇怪的文件 | 用配置区的 `EXTRA_EXCLUDE` 排除,逗号分隔、支持 `*` |
+| 远端提示 md5sum 不存在 | 极老系统没 md5sum,用 `cksum` 也能工作,可改工具里的命令 |
+
+## 六、单独使用部署工具
+
+bat 之外的进阶用法(在 `go/` 目录下):
+
+```bat
+bin\deploy.exe -host 10.31.100.2 -user root -key 10.31.100.2_id_ed25519 ^
+ -dir /www/wwwroot/api.yunzer.cn -local . -dry-run :: 只看差异
+
+bin\deploy.exe -host 10.31.100.2 -user root -key 10.31.100.2_id_ed25519 ^
+ -exec "systemctl status go-api.service" :: 执行远程命令
+```
+
+常用参数:`-dry-run` 只比对、`-yes` 跳过确认、`-delete` 同步删除远端多余文件、
+`-restart "命令"` 上传后自动重启、`-exclude "a,b"` 额外排除。
diff --git a/go/models/init.go b/go/models/init.go
index c5e3ad5..eeae871 100644
--- a/go/models/init.go
+++ b/go/models/init.go
@@ -246,6 +246,9 @@ func Init(_ string) {
new(WechatMiniConfig),
// 微信小程序多应用配置(docs/sql/create_wechat_mini_app.sql)
new(WechatMiniApp),
+ // 微信小程序订阅消息模板与授权记录(docs/sql/create_wechat_mini_template.sql)
+ new(WechatMiniTemplate),
+ new(WechatMiniSubscribe),
// 统一认证中心(UAC):docs/sql/create_auth_tables.sql,人工执行建表
new(AuthIdentity),
diff --git a/go/models/wechat_mini.go b/go/models/wechat_mini.go
index c220382..550197a 100644
--- a/go/models/wechat_mini.go
+++ b/go/models/wechat_mini.go
@@ -26,18 +26,70 @@ func (m *WechatMiniConfig) TableName() string { return "yz_platform_wechat_mini_
// WechatMiniApp 微信小程序(多应用) yz_platform_wechat_mini_app
// 支持配置多个小程序:认证端登录时按客户端传入的 appid 精确匹配
// (未传 appid 时回退第一条启用记录),AppSecret 加密存储不回显。
+//
+// 消息推送(小程序后台「开发管理 → 开发设置 → 消息推送」)相关字段:
+// Token 与后台填写的令牌一致,用于 sha1 签名校验
+// AESKey 消息加解密密钥(EncodingAESKey,43 位),加密存储不回显
+// EncryptMode 加解密方式 plain / compatible / safe
+// DataFormat 推送数据格式 xml / json
type WechatMiniApp struct {
+ ID uint64 `orm:"column(id);pk;auto" json:"id"`
+ Name string `orm:"column(name);size(128);default('')" json:"name"`
+ AppID string `orm:"column(app_id);size(64)" json:"app_id"`
+ AppSecret string `orm:"column(app_secret);size(512)" json:"-"` // 加密存储,不回显
+ QrcodeURL string `orm:"column(qrcode_url);size(512);default('')" json:"qrcode_url"` // 小程序码图片地址,供官网 / H5 等其他端调取
+ Token string `orm:"column(token);size(128);default('')" json:"token"` // 消息推送 Token
+ AESKey string `orm:"column(aes_key);size(512);default('')" json:"-"` // 消息推送 EncodingAESKey(加密存储,不回显)
+ EncryptMode string `orm:"column(encrypt_mode);size(16);default('plain')" json:"encrypt_mode"` // plain/compatible/safe
+ DataFormat string `orm:"column(data_format);size(16);default('xml')" json:"data_format"` // xml/json
+ PushReply string `orm:"column(push_reply);size(512);default('')" json:"push_reply"` // 客服消息自动回复文案(空 = 不自动回复)
+ Enabled int8 `orm:"column(enabled);default(0)" json:"enabled"`
+ AutoBind int8 `orm:"column(auto_bind);default(1)" json:"auto_bind"` // 1:手机号命中身份后自动绑定
+ Remark string `orm:"column(remark);size(255)" json:"remark"`
+ 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"`
+ DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"`
+}
+
+func (m *WechatMiniApp) TableName() string { return "yz_platform_wechat_mini_app" }
+
+// WechatMiniTemplate 小程序订阅消息模板 yz_platform_wechat_mini_template
+//
+// 一个小程序可配置多个「场景」模板(如站内通知 notice、审批待办 approval)。
+// Fields 存微信模板字段键 → 语义变量名的映射(JSON),例如:
+//
+// {"thing1":"title","phrase2":"content","time3":"time"}
+//
+// 推送时按该映射把 title/content/time 填进微信模板的对应字段。
+type WechatMiniTemplate struct {
ID uint64 `orm:"column(id);pk;auto" json:"id"`
- Name string `orm:"column(name);size(128);default('')" json:"name"`
AppID string `orm:"column(app_id);size(64)" json:"app_id"`
- AppSecret string `orm:"column(app_secret);size(512)" json:"-"` // 加密存储,不回显
- QrcodeURL string `orm:"column(qrcode_url);size(512);default('')" json:"qrcode_url"` // 小程序码图片地址,供官网 / H5 等其他端调取
- Enabled int8 `orm:"column(enabled);default(0)" json:"enabled"`
- AutoBind int8 `orm:"column(auto_bind);default(1)" json:"auto_bind"` // 1:手机号命中身份后自动绑定
+ Code string `orm:"column(code);size(64)" json:"code"` // 场景码:notice / approval / schedule ...
+ TemplateID string `orm:"column(template_id);size(128)" json:"template_id"` // 微信订阅消息模板 ID
+ Title string `orm:"column(title);size(255);default('')" json:"title"`
+ Page string `orm:"column(page);size(255);default('')" json:"page"` // 点击消息跳转的小程序页面
+ Fields string `orm:"column(fields);type(text);null" json:"fields"` // JSON:微信字段键 → 语义变量名
+ Enabled int8 `orm:"column(enabled);default(1)" json:"enabled"`
Remark string `orm:"column(remark);size(255)" json:"remark"`
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"`
DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"`
}
-func (m *WechatMiniApp) TableName() string { return "yz_platform_wechat_mini_app" }
+func (m *WechatMiniTemplate) TableName() string { return "yz_platform_wechat_mini_template" }
+
+// WechatMiniSubscribe 小程序订阅授权记录 yz_platform_wechat_mini_subscribe
+//
+// 微信一次性订阅消息:用户每次授权 = 1 次发送机会,发送成功即消耗 1 次。
+// 这里记录每个身份(用户)对某个模板的剩余可发送次数,避免对未授权用户反复调接口。
+type WechatMiniSubscribe struct {
+ ID uint64 `orm:"column(id);pk;auto" json:"id"`
+ IdentityID uint64 `orm:"column(identity_id)" json:"identity_id"`
+ AppID string `orm:"column(app_id);size(64)" json:"app_id"`
+ TemplateID string `orm:"column(template_id);size(128)" json:"template_id"`
+ Remain int `orm:"column(remain);default(0)" json:"remain"` // 剩余可发送次数
+ UpdateTime *time.Time `orm:"column(update_time);type(datetime);null" json:"update_time"`
+ CreateTime time.Time `orm:"column(create_time);type(datetime);auto_now_add" json:"create_time"`
+}
+
+func (m *WechatMiniSubscribe) TableName() string { return "yz_platform_wechat_mini_subscribe" }
diff --git a/go/pkg/verifyfiles/verifyfiles.go b/go/pkg/verifyfiles/verifyfiles.go
new file mode 100644
index 0000000..f3adfb1
--- /dev/null
+++ b/go/pkg/verifyfiles/verifyfiles.go
@@ -0,0 +1,194 @@
+// Package verifyfiles 解析「根路径站点验证文件」的请求。
+//
+// 背景:nginx 对所有域名都是「全量反代给 Go」,Go 不认识的路径一律回 beego 404,
+// 于是备案 / 搜索引擎 / 第三方平台的域名所有权验证文件
+// (如 1XXl8fPHr9.txt、baidu_verify_xxx.html)根本访问不到。
+// 而这类文件名每次都不同(随机串),不可能逐个注册路由。
+//
+// 这里只做纯逻辑:URL 路径 → 磁盘文件 的安全映射。
+// beego 过滤器胶水放在 routers 包(见 routers/verifyfiles.go)。
+//
+// 安全性:
+// - 只认「根路径下的单段文件名」,不做目录拼接,杜绝 ../ 穿越;
+// - 文件名走白名单正则,扩展名走允许列表(不放行 .go/.conf/.env 等敏感后缀);
+// - 目标文件必须真实存在于验证目录内,否则一律放行给正常路由。
+package verifyfiles
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "regexp"
+ "strings"
+)
+
+const (
+ // DefaultDir 默认验证文件目录(相对进程工作目录,即服务目录)。
+ DefaultDir = "verify"
+
+ // DefaultExts 默认允许的扩展名。
+ DefaultExts = ".txt,.html,.htm"
+)
+
+// fileNameRe 文件名白名单:字母或数字开头,只允许字母数字与 . _ -
+var fileNameRe = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`)
+
+// Config 验证文件托管配置。
+type Config struct {
+ // Dir 验证文件目录。留空用 DefaultDir;填 "-" 表示关闭该功能。
+ Dir string
+ // Exts 允许的扩展名,逗号分隔(如 ".txt,.html")。留空用 DefaultExts。
+ Exts string
+ // Hosts 限定生效的 Host(不含端口),逗号分隔。留空表示不限制。
+ Hosts string
+}
+
+// Resolver 把 URL 路径解析成验证文件的磁盘路径。
+type Resolver struct {
+ dir string
+ exts map[string]bool
+ hosts map[string]bool
+}
+
+// New 按配置创建 Resolver。
+// Dir 为 "-" 时表示关闭该功能,返回 (nil, nil)。
+func New(cfg Config) (*Resolver, error) {
+ dir := strings.TrimSpace(cfg.Dir)
+ if dir == "-" {
+ return nil, nil
+ }
+ if dir == "" {
+ dir = DefaultDir
+ }
+ abs, err := filepath.Abs(dir)
+ if err != nil {
+ return nil, fmt.Errorf("验证文件目录 %q 解析失败: %w", dir, err)
+ }
+
+ r := &Resolver{
+ dir: abs,
+ exts: map[string]bool{},
+ hosts: map[string]bool{},
+ }
+
+ for _, e := range strings.Split(strings.TrimSpace(cfg.Exts), ",") {
+ e = strings.ToLower(strings.TrimSpace(e))
+ if e == "" {
+ continue
+ }
+ if !strings.HasPrefix(e, ".") {
+ e = "." + e
+ }
+ r.exts[e] = true
+ }
+ if len(r.exts) == 0 {
+ for _, e := range strings.Split(DefaultExts, ",") {
+ r.exts[strings.ToLower(strings.TrimSpace(e))] = true
+ }
+ }
+
+ for _, h := range strings.Split(strings.TrimSpace(cfg.Hosts), ",") {
+ h = normalizeHost(h)
+ if h == "" {
+ continue
+ }
+ r.hosts[h] = true
+ }
+
+ return r, nil
+}
+
+// Dir 返回验证文件目录的绝对路径。
+func (r *Resolver) Dir() string {
+ if r == nil {
+ return ""
+ }
+ return r.dir
+}
+
+// Exts 返回允许的扩展名列表(用于启动日志,便于确认配置是否生效)。
+func (r *Resolver) Exts() []string {
+ if r == nil {
+ return nil
+ }
+ out := make([]string, 0, len(r.exts))
+ for e := range r.exts {
+ out = append(out, e)
+ }
+ return out
+}
+
+// AllowHost 判断该 Host 是否允许取验证文件(未配置 hosts 时一律允许)。
+func (r *Resolver) AllowHost(host string) bool {
+ if r == nil {
+ return false
+ }
+ if len(r.hosts) == 0 {
+ return true
+ }
+ _, ok := r.hosts[normalizeHost(host)]
+ return ok
+}
+
+// Resolve 把请求路径解析成磁盘文件路径。
+// 只接受根路径下的单段文件名(如 /1XXl8fPHr9.txt);文件必须真实存在。
+func (r *Resolver) Resolve(urlPath string) (string, bool) {
+ if r == nil {
+ return "", false
+ }
+ name, ok := r.fileName(urlPath)
+ if !ok {
+ return "", false
+ }
+ full := filepath.Join(r.dir, name)
+ // 兜底:拼出来的路径必须仍在验证目录内
+ if !strings.HasPrefix(full, r.dir+string(os.PathSeparator)) {
+ return "", false
+ }
+ f, err := os.Stat(full)
+ if err != nil || f.IsDir() {
+ return "", false
+ }
+ return full, true
+}
+
+// fileName 从 URL 路径里取出合法的验证文件名。
+func (r *Resolver) fileName(urlPath string) (string, bool) {
+ p := strings.TrimSpace(urlPath)
+ if p == "" || p == "/" {
+ return "", false
+ }
+ name := strings.TrimPrefix(p, "/")
+ if name == "" {
+ return "", false
+ }
+ // 只允许根目录下的单段文件名,禁止子目录
+ if strings.ContainsAny(name, "/\\") {
+ return "", false
+ }
+ // 禁止路径穿越
+ if strings.Contains(name, "..") {
+ return "", false
+ }
+ if !fileNameRe.MatchString(name) {
+ return "", false
+ }
+ // index.html 会被 http.ServeFile 重定向到 "./",排除
+ if strings.EqualFold(name, "index.html") {
+ return "", false
+ }
+ ext := strings.ToLower(filepath.Ext(name))
+ if !r.exts[ext] {
+ return "", false
+ }
+ return name, true
+}
+
+// normalizeHost 去掉端口并转小写。
+func normalizeHost(h string) string {
+ h = strings.ToLower(strings.TrimSpace(h))
+ if i := strings.IndexByte(h, ':'); i > 0 {
+ h = h[:i]
+ }
+ return h
+}
diff --git a/go/pkg/verifyfiles/verifyfiles_test.go b/go/pkg/verifyfiles/verifyfiles_test.go
new file mode 100644
index 0000000..ed2bdfe
--- /dev/null
+++ b/go/pkg/verifyfiles/verifyfiles_test.go
@@ -0,0 +1,124 @@
+package verifyfiles
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+// newTestResolver 造一个临时验证目录,返回 resolver。
+func newTestResolver(t *testing.T, cfg Config) *Resolver {
+ t.Helper()
+ r, err := New(cfg)
+ if err != nil {
+ t.Fatalf("创建 Resolver 失败: %v", err)
+ }
+ if r == nil {
+ t.Fatal("Resolver 不应为 nil")
+ }
+ return r
+}
+
+// TestResolveAllowed 确认合法验证文件能被解析到磁盘路径。
+func TestResolveAllowed(t *testing.T) {
+ dir := t.TempDir()
+ if err := os.WriteFile(filepath.Join(dir, "1XXl8fPHr9.txt"), []byte("1XXl8fPHr9"), 0o644); err != nil {
+ t.Fatalf("准备测试文件失败: %v", err)
+ }
+ if err := os.WriteFile(filepath.Join(dir, "baidu_verify_codexxx.html"), []byte(""), 0o644); err != nil {
+ t.Fatalf("准备测试文件失败: %v", err)
+ }
+
+ r := newTestResolver(t, Config{Dir: dir})
+
+ for _, name := range []string{"1XXl8fPHr9.txt", "baidu_verify_codexxx.html"} {
+ got, ok := r.Resolve("/" + name)
+ if !ok {
+ t.Fatalf("合法验证文件 /%s 未被解析出来", name)
+ }
+ want := filepath.Join(dir, name)
+ if got != want {
+ t.Fatalf("路径不对,期望 %s,实际 %s", want, got)
+ }
+ }
+}
+
+// TestResolveRejected 确认非法/敏感/不存在的路径一律不放行。
+func TestResolveRejected(t *testing.T) {
+ dir := t.TempDir()
+ for _, name := range []string{"secret.env", "notes.md", "index.html", "app.conf"} {
+ if err := os.WriteFile(filepath.Join(dir, name), []byte("x"), 0o644); err != nil {
+ t.Fatalf("准备测试文件失败: %v", err)
+ }
+ }
+ if err := os.Mkdir(filepath.Join(dir, "sub.txt"), 0o755); err != nil {
+ t.Fatalf("准备测试目录失败: %v", err)
+ }
+
+ r := newTestResolver(t, Config{Dir: dir})
+
+ cases := []string{
+ "/",
+ "",
+ "/secret.env", // 敏感后缀不在白名单
+ "/notes.md", // 后缀不在白名单
+ "/app.conf", // 配置文件不得外泄
+ "/index.html", // 会被 ServeFile 重定向
+ "/sub/dir.txt", // 只允许根路径单段文件名
+ "/../secret.env", // 路径穿越
+ "/1XXl8fPHr9.txt/../..", // 路径穿越
+ "/missing.txt", // 文件不存在
+ "/sub.txt", // 目录不是文件
+ }
+ for _, c := range cases {
+ if got, ok := r.Resolve(c); ok {
+ t.Fatalf("路径 %q 不应被放行,实际解析为 %s", c, got)
+ }
+ }
+}
+
+// TestAllowHost 确认域名白名单按「忽略端口 + 忽略大小写」匹配。
+func TestAllowHost(t *testing.T) {
+ r := newTestResolver(t, Config{Dir: t.TempDir(), Hosts: "api.yunzer.cn"})
+
+ if !r.AllowHost("api.yunzer.cn") {
+ t.Fatal("api.yunzer.cn 应被允许")
+ }
+ if !r.AllowHost("API.Yunzer.cn:443") {
+ t.Fatal("带端口与大写的 api.yunzer.cn 应被允许")
+ }
+ if r.AllowHost("other.com") {
+ t.Fatal("未在白名单里的域名不应被允许")
+ }
+
+ // 未配置 hosts 时,所有走本服务的域名都生效
+ all := newTestResolver(t, Config{Dir: t.TempDir()})
+ if !all.AllowHost("api.yunzer.cn") || !all.AllowHost("dh2.fun") {
+ t.Fatal("未配置 verify_hosts 时应允许所有域名")
+ }
+}
+
+// TestNewDisabled 确认 dir = "-" 表示关闭该功能。
+func TestNewDisabled(t *testing.T) {
+ r, err := New(Config{Dir: "-"})
+ if err != nil {
+ t.Fatalf("不应报错: %v", err)
+ }
+ if r != nil {
+ t.Fatal("dir = \"-\" 时 Resolver 应为 nil")
+ }
+}
+
+// TestDefaultExts 确认默认扩展名包含 .txt。
+func TestDefaultExts(t *testing.T) {
+ r := newTestResolver(t, Config{Dir: t.TempDir()})
+ found := false
+ for _, e := range r.Exts() {
+ if e == ".txt" {
+ found = true
+ }
+ }
+ if !found {
+ t.Fatalf("默认扩展名应包含 .txt,实际 %v", r.Exts())
+ }
+}
diff --git a/go/routers/api/api.go b/go/routers/api/api.go
index bdb5137..12a4bb1 100644
--- a/go/routers/api/api.go
+++ b/go/routers/api/api.go
@@ -41,6 +41,11 @@ func Register() {
// 日程提醒确认接口
beego.Router("/api/schedule/reminder/ack", &controllers.ApiReminderController{}, "get:AckReminder")
+ // 小程序订阅消息:取模板 / 上报授权 / 测试发送
+ beego.Router("/api/mini/subscribe/templates", &controllers.AppMiniSubscribeController{}, "get:Templates")
+ beego.Router("/api/mini/subscribe/authorize", &controllers.AppMiniSubscribeController{}, "post:Authorize")
+ beego.Router("/api/mini/subscribe/test", &controllers.AppMiniSubscribeController{}, "post:TestNotice")
+
// ===== 移动端(uniapp)登录相关(AppAuthController)=====
beego.Router("/api/login", &controllers.AppAuthController{}, "post:LoginBackend")
beego.Router("/api/sendLoginCode", &controllers.AppAuthController{}, "post:SendLoginCode")
diff --git a/go/routers/platform/platform.go b/go/routers/platform/platform.go
index 0300e62..e2edddf 100644
--- a/go/routers/platform/platform.go
+++ b/go/routers/platform/platform.go
@@ -14,6 +14,9 @@ func Register() {
// 微信公众号(服务号):配置 / 绑定 / 粉丝 / 推送 / 微信服务器回调
RegisterWechatMpRoutes()
+ // 微信小程序:应用配置 / 消息推送回调 / 消息推送参数
+ RegisterWechatMiniRoutes()
+
// 数据字典(字典列表 / 字典项)
RegisterDictRoutes()
diff --git a/go/routers/platform/wechat_mini.go b/go/routers/platform/wechat_mini.go
new file mode 100644
index 0000000..b851901
--- /dev/null
+++ b/go/routers/platform/wechat_mini.go
@@ -0,0 +1,32 @@
+package platform
+
+import (
+ "server/controllers"
+
+ beego "github.com/beego/beego/v2/server/web"
+)
+
+// RegisterWechatMiniRoutes 注册微信小程序相关路由。
+// 注意:/api/wechat/mini/callback 供微信服务器回调,无平台鉴权(控制器内完成签名校验)。
+func RegisterWechatMiniRoutes() {
+ // 微信消息推送回调(URL 填入小程序后台「开发管理 → 开发设置 → 消息推送」)
+ beego.Router("/api/wechat/mini/callback", &controllers.WechatMiniCallbackController{}, "get:Callback;post:Callback")
+
+ // 小程序配置(单行兼容端点;多应用管理见下)
+ beego.Router("/platform/wechatMini/config", &controllers.PlatformWechatMiniController{}, "get:GetConfig;post:SaveConfig")
+ // 小程序多应用管理
+ beego.Router("/platform/wechatMini/apps", &controllers.PlatformWechatMiniController{}, "get:ListApps")
+ beego.Router("/platform/wechatMini/app/save", &controllers.PlatformWechatMiniController{}, "post:SaveApp")
+ beego.Router("/platform/wechatMini/app/delete/:id", &controllers.PlatformWechatMiniController{}, "post:DeleteApp")
+
+ // 消息推送配置(Token / EncodingAESKey / 加解密方式 / 数据格式 / 自动回复文案)
+ beego.Router("/platform/wechatMini/app/push", &controllers.PlatformWechatMiniController{}, "get:GetPushConfig")
+ beego.Router("/platform/wechatMini/app/push/save", &controllers.PlatformWechatMiniController{}, "post:SavePushConfig")
+ beego.Router("/platform/wechatMini/app/push/generate", &controllers.PlatformWechatMiniController{}, "post:GeneratePushSecret")
+
+ // 订阅消息模板(小程序后台「功能 → 订阅消息」选用模板后在此登记)
+ beego.Router("/platform/wechatMini/templates", &controllers.PlatformWechatMiniController{}, "get:ListTemplates")
+ beego.Router("/platform/wechatMini/template/save", &controllers.PlatformWechatMiniController{}, "post:SaveTemplate")
+ beego.Router("/platform/wechatMini/template/delete/:id", &controllers.PlatformWechatMiniController{}, "post:DeleteTemplate")
+ beego.Router("/platform/wechatMini/template/test", &controllers.PlatformWechatMiniController{}, "post:TestTemplate")
+}
diff --git a/go/routers/platform/wechat_mini_test.go b/go/routers/platform/wechat_mini_test.go
new file mode 100644
index 0000000..02c69e0
--- /dev/null
+++ b/go/routers/platform/wechat_mini_test.go
@@ -0,0 +1,15 @@
+package platform
+
+import "testing"
+
+// TestRegisterWechatMiniRoutes 确认小程序路由能完成注册且不与既有路由冲突。
+// beego 在注册重复/歧义路由或非法方法名时会 panic,这里通过实际注册来兜住这类错误。
+func TestRegisterWechatMiniRoutes(t *testing.T) {
+ defer func() {
+ if r := recover(); r != nil {
+ t.Fatalf("注册平台端小程序路由失败: %v", r)
+ }
+ }()
+
+ RegisterWechatMiniRoutes()
+}
diff --git a/go/routers/platform/wechat_mp.go b/go/routers/platform/wechat_mp.go
index 5758f6e..a542c1f 100644
--- a/go/routers/platform/wechat_mp.go
+++ b/go/routers/platform/wechat_mp.go
@@ -38,11 +38,4 @@ func RegisterWechatMpRoutes() {
// 消息推送(认证后可用:提醒 / 公告)
beego.Router("/platform/wechatMp/push", &controllers.PlatformWechatMpController{}, "post:Push")
beego.Router("/platform/wechatMp/push/test", &controllers.PlatformWechatMpController{}, "post:PushTest")
-
- // 小程序配置(一键登录开关;登录端点在认证中心 /auth/wechat/miniapp/*)
- beego.Router("/platform/wechatMini/config", &controllers.PlatformWechatMiniController{}, "get:GetConfig;post:SaveConfig")
- // 小程序多应用管理(支持配置多个小程序)
- beego.Router("/platform/wechatMini/apps", &controllers.PlatformWechatMiniController{}, "get:ListApps")
- beego.Router("/platform/wechatMini/app/save", &controllers.PlatformWechatMiniController{}, "post:SaveApp")
- beego.Router("/platform/wechatMini/app/delete/:id", &controllers.PlatformWechatMiniController{}, "post:DeleteApp")
}
diff --git a/go/routers/router.go b/go/routers/router.go
index 6edf43b..a89d774 100644
--- a/go/routers/router.go
+++ b/go/routers/router.go
@@ -95,6 +95,27 @@ func init() {
api.Register()
}
+ // 根路径站点验证文件(备案 / 搜索引擎 / 第三方平台的域名所有权验证)。
+ // 详见 routers/verifyfiles.go 注释。
+ // 由 app.conf 的 verify_dir / verify_exts / verify_hosts 控制:
+ // verify_dir 验证文件目录,留空 = 默认 verify(相对服务目录);填 - = 关闭
+ // verify_exts 允许的扩展名,留空 = 默认 .txt,.html,.htm
+ // verify_hosts 限定生效域名,留空 = 所有走本服务的域名都生效
+ // 必须注册在 RegisterPlatformSPA 之前:两者都是 BeforeStatic 过滤器,
+ // 先注册者先执行,避免验证文件被 SPA 的 index.html 兜底吃掉。
+ if verifyDir, _ := beego.AppConfig.String("verify_dir"); strings.TrimSpace(verifyDir) != "-" {
+ verifyExts, _ := beego.AppConfig.String("verify_exts")
+ var verifyHosts []string
+ if raw, _ := beego.AppConfig.String("verify_hosts"); raw != "" {
+ for _, h := range strings.Split(raw, ",") {
+ if t := strings.TrimSpace(h); t != "" {
+ verifyHosts = append(verifyHosts, t)
+ }
+ }
+ }
+ RegisterVerifyFiles(verifyDir, verifyExts, verifyHosts...)
+ }
+
// 平台后台 SPA 静态托管(详见 routers/spa.go 注释)。
// 由 app.conf 的 platform_spa_dir / platform_spa_hosts 控制;任一为空则不启用。
if spaDir, _ := beego.AppConfig.String("platform_spa_dir"); strings.TrimSpace(spaDir) != "" {
diff --git a/go/routers/verifyfiles.go b/go/routers/verifyfiles.go
new file mode 100644
index 0000000..c7f29a9
--- /dev/null
+++ b/go/routers/verifyfiles.go
@@ -0,0 +1,117 @@
+package routers
+
+import (
+ "log"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "server/pkg/verifyfiles"
+
+ beego "github.com/beego/beego/v2/server/web"
+ "github.com/beego/beego/v2/server/web/context"
+)
+
+// appRoot 定位服务根目录(conf/app.conf 所在目录的上一级)。
+//
+// beego 找配置的顺序是:当前工作目录 → 可执行文件所在目录。
+// 这里沿用同样的顺序,保证不管是用 `go run main.go`(systemd 的 WorkingDirectory)
+// 还是编译后的二进制、不管从哪个目录启动,验证文件目录都稳定落在服务根目录下,
+// 不会出现「换个目录启动就找不到验证文件」的情况。
+//
+// 线上即 /www/wwwroot/api.yunzer.cn(conf/app.conf 在 /www/wwwroot/api.yunzer.cn/conf/)。
+func appRoot() string {
+ candidates := make([]string, 0, 2)
+ if wd, err := os.Getwd(); err == nil {
+ candidates = append(candidates, wd)
+ }
+ if beego.AppPath != "" {
+ candidates = append(candidates, beego.AppPath)
+ }
+ return appRootFrom(candidates)
+}
+
+// appRootFrom 在候选目录里找第一个含 conf/app.conf 的目录(即服务根目录)。
+func appRootFrom(candidates []string) string {
+ for _, base := range candidates {
+ if base == "" {
+ continue
+ }
+ if _, err := os.Stat(filepath.Join(base, "conf", "app.conf")); err == nil {
+ return base
+ }
+ }
+ return ""
+}
+
+// RegisterVerifyFiles 让 Go 直接吐「根路径站点验证文件」。
+//
+// 背景:nginx 对所有域名都是全量反代给 Go(location / → proxy_pass),
+// Go 不认识的路径一律回 beego 404 页,于是这些文件根本访问不到:
+// - 备案 / 域名所有权验证:1XXl8fPHr9.txt(阿里云、腾讯云)
+// - 搜索引擎站点验证:googleXXXX.html、msXXXXXXXX.txt、Bing 的 .xml
+// - 第三方平台域名校验:各平台给的 .txt / .html
+//
+// 这些文件名每次都不同(随机串),逐个注册路由不现实;改 nginx 又会因为
+// 宝塔重生成 vhost 被冲掉(同 routers/spa.go 里遇到的问题)。
+// 所以由 Go 统一托管:只要把验证文件丢进 verify 目录,
+// https://api.yunzer.cn/1XXl8fPHr9.txt 就能直接取到。
+//
+// 规则:
+// - 只处理 GET / HEAD;
+// - 只认「根路径下的单段文件名」,如 /1XXl8fPHr9.txt;
+// - 文件名过白名单正则,扩展名在允许列表内(默认 .txt/.html/.htm);
+// - 文件必须真实存在于验证目录,否则放行给正常路由(最终仍是 beego 404)。
+//
+// 验证文件目录由 app.conf 的 verify_dir 指定(默认相对进程工作目录的 verify,
+// 即线上服务目录 /www/wwwroot/api.yunzer.cn/verify);verify_hosts 可限定生效域名,
+// 留空表示所有走本服务的域名都生效(备案常常同时要过 api.yunzer.cn 与官网主域名)。
+func RegisterVerifyFiles(dir string, exts string, hosts ...string) {
+ dir = strings.TrimSpace(dir)
+ if dir == "-" {
+ // 显式关闭
+ return
+ }
+ if dir == "" {
+ // 默认:服务根目录下的 verify/(app.conf 所在目录的上一级)
+ dir = filepath.Join(appRoot(), verifyfiles.DefaultDir)
+ }
+
+ resolver, err := verifyfiles.New(verifyfiles.Config{
+ Dir: dir,
+ Exts: exts,
+ Hosts: strings.Join(hosts, ","),
+ })
+ if err != nil {
+ log.Printf("[verify] 根路径验证文件功能未启用: %v", err)
+ return
+ }
+ if resolver == nil {
+ // dir = "-":显式关闭
+ return
+ }
+
+ // 目录不存在就建一个,方便直接往里丢验证文件
+ if err := os.MkdirAll(resolver.Dir(), 0o755); err != nil {
+ log.Printf("[verify] 创建验证文件目录失败: %v", err)
+ }
+
+ beego.InsertFilter("*", beego.BeforeStatic, func(ctx *context.Context) {
+ if ctx.Request.Method != http.MethodGet && ctx.Request.Method != http.MethodHead {
+ return
+ }
+ if !resolver.AllowHost(ctx.Request.Host) {
+ return
+ }
+ full, ok := resolver.Resolve(ctx.Request.URL.Path)
+ if !ok {
+ return
+ }
+ // 文件内容不变,但可能被替换(换 token、换文件名),短缓存即可
+ ctx.Output.Header("Cache-Control", "public, max-age=300")
+ http.ServeFile(ctx.ResponseWriter, ctx.Request, full)
+ })
+
+ log.Printf("[verify] 根路径验证文件目录: %s,允许扩展名: %v", resolver.Dir(), resolver.Exts())
+}
diff --git a/go/routers/verifyfiles_test.go b/go/routers/verifyfiles_test.go
new file mode 100644
index 0000000..e162af9
--- /dev/null
+++ b/go/routers/verifyfiles_test.go
@@ -0,0 +1,115 @@
+package routers
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "server/pkg/verifyfiles"
+
+ beego "github.com/beego/beego/v2/server/web"
+)
+
+// TestVerifyFilesEndToEnd 走真实 HTTP 链路验证:
+// 根路径下的验证文件能被吐出来,且不带白名单的路径仍然 404。
+func TestVerifyFilesEndToEnd(t *testing.T) {
+ dir := t.TempDir()
+ if err := os.WriteFile(filepath.Join(dir, "1XXl8fPHr9.txt"), []byte("1XXl8fPHr9"), 0o644); err != nil {
+ t.Fatalf("准备测试文件失败: %v", err)
+ }
+ // 注册到全局 beego(与线上 router.go 的注册方式一致)
+ RegisterVerifyFiles(dir, "")
+
+ beego.BeeApp.Handlers.Init()
+
+ // 1) 验证文件正常返回
+ req := httptest.NewRequest(http.MethodGet, "/1XXl8fPHr9.txt", nil)
+ req.Host = "api.yunzer.cn"
+ rec := httptest.NewRecorder()
+ beego.BeeApp.Handlers.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("验证文件应返回 200,实际 %d", rec.Code)
+ }
+ if got := rec.Body.String(); got != "1XXl8fPHr9" {
+ t.Fatalf("验证文件内容不对,实际 %q", got)
+ }
+ if ct := rec.Header().Get("Content-Type"); ct == "" || ct[:len("text/plain")] != "text/plain" {
+ t.Logf("Content-Type: %s", ct)
+ }
+
+ // 2) 不存在的验证文件仍走正常路由(404),行为不变
+ req2 := httptest.NewRequest(http.MethodGet, "/not-exist-verify.txt", nil)
+ req2.Host = "api.yunzer.cn"
+ rec2 := httptest.NewRecorder()
+ beego.BeeApp.Handlers.ServeHTTP(rec2, req2)
+
+ if rec2.Code != http.StatusNotFound {
+ t.Fatalf("不存在的验证文件应返回 404,实际 %d", rec2.Code)
+ }
+}
+
+// TestAppRootFrom 确认服务根目录按「含 conf/app.conf 的目录」定位。
+func TestAppRootFrom(t *testing.T) {
+ root := t.TempDir()
+ if err := os.MkdirAll(filepath.Join(root, "conf"), 0o755); err != nil {
+ t.Fatalf("准备测试目录失败: %v", err)
+ }
+ if err := os.WriteFile(filepath.Join(root, "conf", "app.conf"), []byte("appname = server"), 0o644); err != nil {
+ t.Fatalf("准备测试文件失败: %v", err)
+ }
+
+ other := t.TempDir()
+
+ if got := appRootFrom([]string{other, root}); got != root {
+ t.Fatalf("应定位到 %s,实际 %s", root, got)
+ }
+ if got := appRootFrom([]string{other, ""}); got != "" {
+ t.Fatalf("没有 conf/app.conf 时应返回空串,实际 %s", got)
+ }
+ if got := appRootFrom([]string{""}); got != "" {
+ t.Fatalf("空候选应返回空串,实际 %s", got)
+ }
+}
+
+// TestDefaultDirResolvesToRepoVerifyDir 确认默认目录落在「服务根目录/verify」下,
+// 且仓库里的验证文件能被解析出来、内容正确。
+// 线上等价:服务根目录 /www/wwwroot/api.yunzer.cn → verify/1XXl8fPHr9.txt。
+func TestDefaultDirResolvesToRepoVerifyDir(t *testing.T) {
+ orig, err := os.Getwd()
+ if err != nil {
+ t.Fatalf("取当前工作目录失败: %v", err)
+ }
+ defer os.Chdir(orig)
+
+ // 切到 go/ 目录:这里有 conf/app.conf,等价于线上服务根目录
+ goRoot := filepath.Dir(orig)
+ if err := os.Chdir(goRoot); err != nil {
+ t.Fatalf("切换工作目录失败: %v", err)
+ }
+
+ root := appRoot()
+ if root == "" {
+ t.Fatalf("应能从 conf/app.conf 定位服务根目录(工作目录 %s)", goRoot)
+ }
+
+ dir := filepath.Join(root, verifyfiles.DefaultDir)
+ resolver, err := verifyfiles.New(verifyfiles.Config{Dir: dir})
+ if err != nil || resolver == nil {
+ t.Fatalf("创建 Resolver 失败: %v", err)
+ }
+
+ full, ok := resolver.Resolve("/1XXl8fPHr9.txt")
+ if !ok {
+ t.Fatalf("默认目录 %s 下应能解析出验证文件", dir)
+ }
+ b, err := os.ReadFile(full)
+ if err != nil {
+ t.Fatalf("读取验证文件失败: %v", err)
+ }
+ if string(b) != "1XXl8fPHr9" {
+ t.Fatalf("验证文件内容不对,实际 %q", string(b))
+ }
+}
diff --git a/go/services/approval/engine.go b/go/services/approval/engine.go
index 0302fe1..d0de758 100644
--- a/go/services/approval/engine.go
+++ b/go/services/approval/engine.go
@@ -20,8 +20,10 @@ import (
"server/models"
"server/services/erp"
+ "server/services/wechatmini"
"github.com/beego/beego/v2/client/orm"
+ "github.com/beego/beego/v2/core/logs"
)
// Approver 展开后的单个审批人
@@ -946,7 +948,26 @@ func createTasks(instID uint64, in SubmitInput, node Node, approvers []Approver)
})
}
_, err := models.Orm.InsertMulti(len(tasks), tasks)
- return err
+ if err != nil {
+ return err
+ }
+
+ // 租户侧审批人同时推小程序订阅消息(授权过才有一次发送机会,失败不影响流程)
+ if in.Scope == models.ApprovalScopeTenant {
+ for _, a := range approvers {
+ approverID, nodeName := a.ID, node.NodeName
+ go func(uid uint64, name string) {
+ res := wechatmini.PushTenantNotice(uid, "您有新的审批待办", "审批事项:"+name+",请及时处理。")
+ if res.Sent > 0 {
+ logs.Info("[wechat-mini] 审批待办已推送订阅消息: user=%d sent=%d", uid, res.Sent)
+ }
+ for _, e := range res.Errors {
+ logs.Info("[wechat-mini] 订阅消息推送失败: user=%d err=%v", uid, e)
+ }
+ }(approverID, nodeName)
+ }
+ }
+ return nil
}
// fireNotifyTasks 为通知节点创建已读通知任务(不阻塞流程)
diff --git a/go/services/reminder_scheduler.go b/go/services/reminder_scheduler.go
index da18279..5f75d04 100644
--- a/go/services/reminder_scheduler.go
+++ b/go/services/reminder_scheduler.go
@@ -13,6 +13,9 @@ import (
"time"
"server/models"
+ "server/services/wechatmini"
+
+ "github.com/beego/beego/v2/core/logs"
)
type ReminderData struct {
@@ -294,6 +297,18 @@ func (s *SiteMsgSender) Send(ctx context.Context, reminder ReminderData, title,
if err != nil {
return false, err
}
+
+ // 同时推送到小程序订阅消息(用户授权过才有一次发送机会,失败不影响站内信)
+ receiverID := reminder.ReceiverUserID
+ go func(uid uint64, t, c string) {
+ res := wechatmini.PushTenantNotice(uid, t, c)
+ if res.Sent > 0 {
+ logs.Info("[wechat-mini] 日程提醒已推送订阅消息: user=%d sent=%d", uid, res.Sent)
+ }
+ for _, e := range res.Errors {
+ logs.Info("[wechat-mini] 订阅消息推送失败: user=%d err=%v", uid, e)
+ }
+ }(receiverID, title, content)
return true, nil
}
diff --git a/go/services/system_sitereminder.go b/go/services/system_sitereminder.go
index 4ae732b..e1cbe55 100644
--- a/go/services/system_sitereminder.go
+++ b/go/services/system_sitereminder.go
@@ -7,8 +7,11 @@ import (
"strconv"
"time"
- "github.com/beego/beego/v2/client/orm"
"server/models"
+ "server/services/wechatmini"
+
+ "github.com/beego/beego/v2/client/orm"
+ "github.com/beego/beego/v2/core/logs"
)
// GetSiteReminderConfig 获取站内信配置(从 yz_platform_normal_setting 读取)
@@ -296,8 +299,29 @@ func SendSiteReminder(title, content string, senderID uint64, senderType string,
}
// 批量插入
- _, err = models.Orm.InsertMulti(100, reminders)
- return err
+ if _, err = models.Orm.InsertMulti(100, reminders); err != nil {
+ return err
+ }
+
+ // 同步推送到小程序订阅消息(异步执行,不拖慢站内信本身)
+ // 只有用户在小程序端授权过订阅、且平台配了对应场景模板时才会真正下发。
+ for _, r := range receivers {
+ if r.Type != "tenant" {
+ continue // 平台端用户没有小程序 openid
+ }
+ tenantUserID := r.ID
+ titleCopy, contentCopy := title, content
+ go func(uid uint64, t, c string) {
+ res := wechatmini.PushTenantNotice(uid, t, c)
+ if res.Sent > 0 {
+ logs.Info("[wechat-mini] 站内信已推送订阅消息: user=%d sent=%d", uid, res.Sent)
+ }
+ for _, e := range res.Errors {
+ logs.Info("[wechat-mini] 订阅消息推送失败: user=%d err=%v", uid, e)
+ }
+ }(tenantUserID, titleCopy, contentCopy)
+ }
+ return nil
}
// ListReminders 列表查询
diff --git a/go/services/wechatmini/push.go b/go/services/wechatmini/push.go
new file mode 100644
index 0000000..a43a42f
--- /dev/null
+++ b/go/services/wechatmini/push.go
@@ -0,0 +1,438 @@
+// Package wechatmini 微信小程序「消息推送」服务端能力。
+//
+// 对应小程序后台:开发管理 → 开发设置 → 消息推送。
+// 后台需填写 URL / Token / EncodingAESKey / 加解密方式 / 数据格式,
+// 微信会在保存时向 URL 发一次 GET 校验(signature + echostr),
+// 保存成功后,用户向小程序客服发消息等事件会以 POST 推送到该 URL。
+//
+// 协议与公众号「服务器配置」一致(signature = sha1(sort(token,timestamp,nonce))),
+// 复用 services/wechatmp 的签名与 AES 加解密原语,本文件补齐小程序侧
+// 的多应用路由、JSON 数据格式与被动回复构造。
+package wechatmini
+
+import (
+ "crypto/rand"
+ "encoding/json"
+ "encoding/xml"
+ "errors"
+ "fmt"
+ "math/big"
+ "strings"
+ "time"
+
+ "server/models"
+ "server/services/wechatmp"
+
+ "github.com/beego/beego/v2/client/orm"
+ beego "github.com/beego/beego/v2/server/web"
+)
+
+// 消息数据格式(小程序后台「数据格式」)
+const (
+ DataFormatXML = "xml"
+ DataFormatJSON = "json"
+)
+
+// PushConfig 小程序消息推送运行配置(敏感字段已解密)
+type PushConfig struct {
+ AppID string
+ Token string
+ AESKey string // 明文(43 位 EncodingAESKey)
+ EncryptMode string // plain / compatible / safe
+ DataFormat string // xml / json
+ ReplyText string // 客服消息自动回复文案(空 = 不自动回复)
+}
+
+// ErrPushNotConfigured 未配置消息推送
+var ErrPushNotConfigured = errors.New("小程序消息推送未配置")
+
+// pushConfigFromRow 读取行数据并解密 AESKey
+func pushConfigFromRow(row models.WechatMiniApp) (*PushConfig, error) {
+ cfg := &PushConfig{
+ AppID: strings.TrimSpace(row.AppID),
+ Token: strings.TrimSpace(row.Token),
+ EncryptMode: strings.TrimSpace(row.EncryptMode),
+ DataFormat: strings.ToLower(strings.TrimSpace(row.DataFormat)),
+ ReplyText: strings.TrimSpace(row.PushReply),
+ }
+ if cfg.EncryptMode == "" {
+ cfg.EncryptMode = wechatmp.EncryptModePlain
+ }
+ if cfg.DataFormat == "" {
+ cfg.DataFormat = DataFormatXML
+ }
+ if row.AESKey != "" {
+ plain, err := wechatmp.DecryptSecret(row.AESKey)
+ if err != nil {
+ return nil, err
+ }
+ cfg.AESKey = plain
+ }
+ return cfg, nil
+}
+
+// LoadPushConfigs 读取全部已配置 Token 的小程序推送配置(未删除的记录)。
+// 微信回调不携带 appid(明文模式下 ToUserName 即 appid),故按应用逐个匹配签名。
+func LoadPushConfigs() ([]PushConfig, error) {
+ var rows []models.WechatMiniApp
+ _, err := models.Orm.QueryTable(new(models.WechatMiniApp)).
+ Filter("delete_time__isnull", true).
+ OrderBy("id").
+ All(&rows)
+ if err != nil {
+ return nil, err
+ }
+ list := make([]PushConfig, 0, len(rows))
+ for _, r := range rows {
+ if strings.TrimSpace(r.Token) == "" {
+ continue // 未配置 Token 的应用不参与签名匹配
+ }
+ cfg, cerr := pushConfigFromRow(r)
+ if cerr != nil {
+ continue // 解密失败的应用跳过,避免阻塞其它小程序
+ }
+ list = append(list, *cfg)
+ }
+ return list, nil
+}
+
+// LoadPushConfigByAppID 按 appid 读取单个小程序的推送配置
+func LoadPushConfigByAppID(appID string) (*PushConfig, error) {
+ appID = strings.TrimSpace(appID)
+ if appID == "" {
+ return nil, ErrPushNotConfigured
+ }
+ var row models.WechatMiniApp
+ err := models.Orm.QueryTable(new(models.WechatMiniApp)).
+ Filter("app_id", appID).
+ Filter("delete_time__isnull", true).
+ One(&row)
+ if err != nil {
+ if err == orm.ErrNoRows {
+ return nil, ErrPushNotConfigured
+ }
+ return nil, err
+ }
+ return pushConfigFromRow(row)
+}
+
+// SavePushConfigInput 保存推送配置入参(敏感字段支持掩码表示不修改)
+type SavePushConfigInput struct {
+ AppID string
+ Token string
+ AESKey string // 掩码 / 空串 = 保持原值
+ EncryptMode string
+ DataFormat string
+ ReplyText string
+}
+
+// SavePushConfig 保存小程序消息推送配置
+func SavePushConfig(in SavePushConfigInput) error {
+ appID := strings.TrimSpace(in.AppID)
+ if appID == "" {
+ return errors.New("AppID 不能为空")
+ }
+ mode := strings.TrimSpace(in.EncryptMode)
+ switch mode {
+ case wechatmp.EncryptModePlain, wechatmp.EncryptModeCompatible, wechatmp.EncryptModeSafe:
+ case "":
+ mode = wechatmp.EncryptModePlain
+ default:
+ return errors.New("加解密方式无效(plain/compatible/safe)")
+ }
+ format := strings.ToLower(strings.TrimSpace(in.DataFormat))
+ switch format {
+ case DataFormatXML, DataFormatJSON:
+ case "":
+ format = DataFormatXML
+ default:
+ return errors.New("数据格式无效(xml/json)")
+ }
+ token := strings.TrimSpace(in.Token)
+ if token != "" && (len(token) < 3 || len(token) > 32) {
+ return errors.New("Token 长度需为 3-32 个字符")
+ }
+
+ var row models.WechatMiniApp
+ err := models.Orm.QueryTable(new(models.WechatMiniApp)).
+ Filter("app_id", appID).
+ Filter("delete_time__isnull", true).
+ One(&row)
+ if err != nil {
+ if err == orm.ErrNoRows {
+ return errors.New("小程序不存在或已删除")
+ }
+ return err
+ }
+
+ aesKey := strings.TrimSpace(in.AESKey)
+ if aesKey != "" && !wechatmp.IsMasked(aesKey) {
+ if len(aesKey) != 43 {
+ return errors.New("EncodingAESKey 长度必须为 43 位")
+ }
+ enc, eerr := wechatmp.EncryptSecret(aesKey)
+ if eerr != nil {
+ return eerr
+ }
+ row.AESKey = enc
+ }
+ if token != "" {
+ row.Token = token
+ }
+ row.EncryptMode = mode
+ row.DataFormat = format
+ row.PushReply = strings.TrimSpace(in.ReplyText)
+ now := time.Now()
+ row.UpdateTime = &now
+ _, err = models.Orm.Update(&row)
+ return err
+}
+
+// PushCallbackURL 小程序消息推送回调地址(填到小程序后台「消息推送 - URL」)。
+// 优先读取 app.conf 的 wechat_mini_callback_base,其次回落 wechat_mp_callback_base
+// / payment_callback_base(均为对外 API 域名)。
+func PushCallbackURL() string {
+ base, _ := beego.AppConfig.String("wechat_mini_callback_base")
+ if strings.TrimSpace(base) == "" {
+ base, _ = beego.AppConfig.String("wechat_mp_callback_base")
+ }
+ if strings.TrimSpace(base) == "" {
+ base, _ = beego.AppConfig.String("payment_callback_base")
+ }
+ base = strings.TrimRight(strings.TrimSpace(base), "/")
+ if base == "" {
+ return "/api/wechat/mini/callback"
+ }
+ return base + "/api/wechat/mini/callback"
+}
+
+// ============================ 随机 Token / EncodingAESKey ============================
+
+const aesKeyAlphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
+
+// randomString 从指定字符集随机取 n 个字符(crypto/rand 源)
+func randomString(alphabet string, n int) string {
+ b := []byte(alphabet)
+ out := make([]byte, n)
+ max := big.NewInt(int64(len(b)))
+ for i := range out {
+ idx, err := rand.Int(rand.Reader, max)
+ if err != nil {
+ // 随机源异常时退化为时间派生,保证不返回空串
+ out[i] = b[(time.Now().UnixNano()+int64(i))%int64(len(b))]
+ continue
+ }
+ out[i] = b[idx.Int64()]
+ }
+ return string(out)
+}
+
+// GeneratePushToken 生成消息推送 Token(3-32 位字母数字)
+func GeneratePushToken() string {
+ return randomString(aesKeyAlphabet, 24)
+}
+
+// GeneratePushAESKey 生成消息推送 EncodingAESKey(43 位字母数字)
+// 43 位 base64 字符解码后为 32 字节,符合微信 AES-256-CBC 的密钥长度要求。
+func GeneratePushAESKey() string {
+ return randomString(aesKeyAlphabet, 43)
+}
+
+// ============================ 消息解析 ============================
+
+// PushMessage 小程序推送的消息 / 事件(已解密)
+type PushMessage struct {
+ ToUserName string
+ FromUserName string
+ CreateTime int64
+ MsgType string // text / image / event ...
+ Content string
+ Event string // subscribe / unsubscribe / SCAN / CLICK ...
+ EventKey string
+ MsgID int64
+ Encrypt string // 兼容/安全模式外层携带的密文
+ MsgSignature string
+ TimeStamp string
+ Nonce string
+}
+
+// IsEvent 是否为事件推送
+func (m *PushMessage) IsEvent() bool { return strings.EqualFold(m.MsgType, "event") }
+
+// pushXML 微信 XML 推送体
+type pushXML struct {
+ XMLName struct{} `xml:"xml"`
+ ToUserName string `xml:"ToUserName"`
+ FromUserName string `xml:"FromUserName"`
+ CreateTime int64 `xml:"CreateTime"`
+ MsgType string `xml:"MsgType"`
+ Content string `xml:"Content"`
+ Event string `xml:"Event"`
+ EventKey string `xml:"EventKey"`
+ MsgID int64 `xml:"MsgId"`
+ Encrypt string `xml:"Encrypt"`
+ MsgSignature string `xml:"MsgSignature"`
+ TimeStamp string `xml:"TimeStamp"`
+ Nonce string `xml:"Nonce"`
+}
+
+// pushJSON 微信 JSON 推送体
+type pushJSON struct {
+ ToUserName string `json:"ToUserName"`
+ FromUserName string `json:"FromUserName"`
+ CreateTime int64 `json:"CreateTime"`
+ MsgType string `json:"MsgType"`
+ Content string `json:"Content"`
+ Event string `json:"Event"`
+ EventKey string `json:"EventKey"`
+ MsgID int64 `json:"MsgId"`
+ Encrypt string `json:"Encrypt"`
+ MsgSignature string `json:"MsgSignature"`
+ TimeStamp string `json:"TimeStamp"`
+ Nonce string `json:"Nonce"`
+}
+
+// ParsePushXML 解析 XML 推送体
+func ParsePushXML(raw string) (*PushMessage, error) {
+ var p pushXML
+ if err := xml.Unmarshal([]byte(raw), &p); err != nil {
+ return nil, fmt.Errorf("解析微信推送消息失败: %w", err)
+ }
+ return &PushMessage{
+ ToUserName: p.ToUserName,
+ FromUserName: p.FromUserName,
+ CreateTime: p.CreateTime,
+ MsgType: p.MsgType,
+ Content: p.Content,
+ Event: p.Event,
+ EventKey: p.EventKey,
+ MsgID: p.MsgID,
+ Encrypt: p.Encrypt,
+ MsgSignature: p.MsgSignature,
+ TimeStamp: p.TimeStamp,
+ Nonce: p.Nonce,
+ }, nil
+}
+
+// ParsePushJSON 解析 JSON 推送体
+func ParsePushJSON(raw string) (*PushMessage, error) {
+ var p pushJSON
+ if err := json.Unmarshal([]byte(raw), &p); err != nil {
+ return nil, fmt.Errorf("解析微信推送消息失败: %w", err)
+ }
+ return &PushMessage{
+ ToUserName: p.ToUserName,
+ FromUserName: p.FromUserName,
+ CreateTime: p.CreateTime,
+ MsgType: p.MsgType,
+ Content: p.Content,
+ Event: p.Event,
+ EventKey: p.EventKey,
+ MsgID: p.MsgID,
+ Encrypt: p.Encrypt,
+ MsgSignature: p.MsgSignature,
+ TimeStamp: p.TimeStamp,
+ Nonce: p.Nonce,
+ }, nil
+}
+
+// DetectPushFormat 根据请求体判断数据格式(JSON 以 { 开头)
+func DetectPushFormat(raw string) string {
+ trimmed := strings.TrimSpace(raw)
+ if strings.HasPrefix(trimmed, "{") {
+ return DataFormatJSON
+ }
+ return DataFormatXML
+}
+
+// ============================ 被动回复构造 ============================
+
+// BuildTextReply 构造文本被动回复(按数据格式输出 XML / JSON)
+func BuildTextReply(format, toUser, fromUser, content string, timestamp int64) (string, error) {
+ if format == DataFormatJSON {
+ payload := map[string]interface{}{
+ "ToUserName": toUser,
+ "FromUserName": fromUser,
+ "CreateTime": timestamp,
+ "MsgType": "text",
+ "Content": content,
+ }
+ bs, err := json.Marshal(payload)
+ if err != nil {
+ return "", err
+ }
+ return string(bs), nil
+ }
+ esc := strings.NewReplacer("]]>", "]] >")
+ return fmt.Sprintf(
+ `%d`,
+ esc.Replace(toUser), esc.Replace(fromUser), timestamp, esc.Replace(content)), nil
+}
+
+// BuildEncryptedReply 构造加密模式的被动回复(安全模式 / 兼容模式共用)
+func BuildEncryptedReply(cfg *PushConfig, plainReply, timestamp, nonce, format string) (string, error) {
+ encrypt, err := wechatmp.EncryptMessage(cfg.AESKey, cfg.AppID, plainReply)
+ if err != nil {
+ return "", err
+ }
+ sig := wechatmp.MsgSignature(cfg.Token, timestamp, nonce, encrypt)
+ if format == DataFormatJSON {
+ payload := map[string]interface{}{
+ "Encrypt": encrypt,
+ "MsgSignature": sig,
+ "TimeStamp": timestamp,
+ "Nonce": nonce,
+ }
+ bs, merr := json.Marshal(payload)
+ if merr != nil {
+ return "", merr
+ }
+ return string(bs), nil
+ }
+ return fmt.Sprintf(
+ `%s`,
+ encrypt, sig, timestamp, nonce), nil
+}
+
+// ============================ 多应用匹配 ============================
+
+// MatchPushConfigBySignature 按明文签名匹配小程序(sha1(sort(token,timestamp,nonce)))
+func MatchPushConfigBySignature(cfgs []PushConfig, timestamp, nonce, signature string) (*PushConfig, bool) {
+ for i := range cfgs {
+ if wechatmp.CheckSignature(cfgs[i].Token, timestamp, nonce, signature) {
+ return &cfgs[i], true
+ }
+ }
+ return nil, false
+}
+
+// MatchPushConfigByMsgSignature 按加密消息签名匹配小程序
+// (sha1(sort(token,timestamp,nonce,encrypt)))
+func MatchPushConfigByMsgSignature(cfgs []PushConfig, timestamp, nonce, encrypt, msgSignature string) (*PushConfig, bool) {
+ for i := range cfgs {
+ if wechatmp.CheckMsgSignature(cfgs[i].Token, timestamp, nonce, encrypt, msgSignature) {
+ return &cfgs[i], true
+ }
+ }
+ return nil, false
+}
+
+// DecryptPushMessage 用匹配的 AESKey 解密消息体(安全 / 兼容模式)
+func DecryptPushMessage(cfg *PushConfig, encrypt string) (string, error) {
+ return wechatmp.DecryptMessage(cfg.AESKey, cfg.AppID, encrypt)
+}
+
+// PushHandler 消息处理钩子:返回给用户的被动回复文案,空串表示不回复。
+// 默认按小程序配置的「客服自动回复文案」回复,可在业务层覆盖(如按关键字回复)。
+var PushHandler func(cfg *PushConfig, msg *PushMessage) string
+
+// HandlePushMessage 处理推送消息并返回被动回复文案
+func HandlePushMessage(cfg *PushConfig, msg *PushMessage) string {
+ if PushHandler != nil {
+ return strings.TrimSpace(PushHandler(cfg, msg))
+ }
+ if cfg == nil {
+ return ""
+ }
+ return strings.TrimSpace(cfg.ReplyText)
+}
diff --git a/go/services/wechatmini/push_test.go b/go/services/wechatmini/push_test.go
new file mode 100644
index 0000000..ec70142
--- /dev/null
+++ b/go/services/wechatmini/push_test.go
@@ -0,0 +1,158 @@
+package wechatmini
+
+import (
+ "crypto/sha1"
+ "encoding/hex"
+ "encoding/json"
+ "sort"
+ "strings"
+ "testing"
+
+ "server/services/wechatmp"
+)
+
+// sign 复刻微信签名算法:sha1(sort(token, timestamp, nonce))
+func sign(token, timestamp, nonce string) string {
+ arr := []string{token, timestamp, nonce}
+ sort.Strings(arr)
+ sum := sha1.Sum([]byte(strings.Join(arr, "")))
+ return hex.EncodeToString(sum[:])
+}
+
+func TestGeneratePushSecrets(t *testing.T) {
+ token := GeneratePushToken()
+ if len(token) < 3 || len(token) > 32 {
+ t.Fatalf("Token 长度非法: %d", len(token))
+ }
+ for _, c := range token {
+ if !strings.ContainsRune(aesKeyAlphabet, c) {
+ t.Fatalf("Token 含非法字符: %q", c)
+ }
+ }
+
+ key := GeneratePushAESKey()
+ if len(key) != 43 {
+ t.Fatalf("EncodingAESKey 应为 43 位,实际 %d", len(key))
+ }
+ for _, c := range key {
+ if !strings.ContainsRune(aesKeyAlphabet, c) {
+ t.Fatalf("EncodingAESKey 含非法字符: %q", c)
+ }
+ }
+}
+
+// 生成的 EncodingAESKey 必须能被微信 AES-256-CBC 直接使用
+func TestGeneratedAESKeyUsable(t *testing.T) {
+ key := GeneratePushAESKey()
+ plain := `wx123`
+ cipherText, err := wechatmp.EncryptMessage(key, "wx123", plain)
+ if err != nil {
+ t.Fatalf("加密失败: %v", err)
+ }
+ got, err := wechatmp.DecryptMessage(key, "wx123", cipherText)
+ if err != nil {
+ t.Fatalf("解密失败: %v", err)
+ }
+ if got != plain {
+ t.Fatalf("解密内容不一致: %s", got)
+ }
+}
+
+func TestMatchPushConfigBySignature(t *testing.T) {
+ cfgs := []PushConfig{
+ {AppID: "wxA", Token: "tokenA", EncryptMode: wechatmp.EncryptModePlain, DataFormat: DataFormatXML},
+ {AppID: "wxB", Token: "tokenB", EncryptMode: wechatmp.EncryptModeSafe, DataFormat: DataFormatJSON},
+ }
+ ts, nonce := "1700000000", "abc123"
+
+ cfg, ok := MatchPushConfigBySignature(cfgs, ts, nonce, sign("tokenB", ts, nonce))
+ if !ok || cfg.AppID != "wxB" {
+ t.Fatalf("应匹配到 wxB,实际 ok=%v cfg=%v", ok, cfg)
+ }
+ if _, ok := MatchPushConfigBySignature(cfgs, ts, nonce, "bad-signature"); ok {
+ t.Fatal("错误签名不应匹配")
+ }
+}
+
+func TestParsePushXML(t *testing.T) {
+ raw := `wxAppoOpenId1700000000text你好123456`
+ msg, err := ParsePushXML(raw)
+ if err != nil {
+ t.Fatalf("解析失败: %v", err)
+ }
+ if msg.ToUserName != "wxApp" || msg.FromUserName != "oOpenId" || msg.Content != "你好" || msg.MsgID != 123456 {
+ t.Fatalf("解析结果不符: %+v", msg)
+ }
+}
+
+func TestParsePushJSON(t *testing.T) {
+ raw := `{"ToUserName":"wxApp","FromUserName":"oOpenId","CreateTime":1700000000,"MsgType":"text","Content":"hello","MsgId":987654}`
+ msg, err := ParsePushJSON(raw)
+ if err != nil {
+ t.Fatalf("解析失败: %v", err)
+ }
+ if msg.ToUserName != "wxApp" || msg.Content != "hello" || msg.MsgID != 987654 {
+ t.Fatalf("解析结果不符: %+v", msg)
+ }
+}
+
+func TestDetectPushFormat(t *testing.T) {
+ if got := DetectPushFormat(`{"a":1}`); got != DataFormatJSON {
+ t.Fatalf("应为 JSON,实际 %s", got)
+ }
+ if got := DetectPushFormat(""); got != DataFormatXML {
+ t.Fatalf("应为 XML,实际 %s", got)
+ }
+}
+
+func TestBuildTextReply(t *testing.T) {
+ xmlReply, err := BuildTextReply(DataFormatXML, "oOpenId", "wxApp", "收到", 1700000000)
+ if err != nil {
+ t.Fatalf("构造 XML 回复失败: %v", err)
+ }
+ if !strings.Contains(xmlReply, "") {
+ t.Fatalf("XML 回复内容不符: %s", xmlReply)
+ }
+
+ jsonReply, err := BuildTextReply(DataFormatJSON, "oOpenId", "wxApp", "收到", 1700000000)
+ if err != nil {
+ t.Fatalf("构造 JSON 回复失败: %v", err)
+ }
+ var payload map[string]interface{}
+ if err := json.Unmarshal([]byte(jsonReply), &payload); err != nil {
+ t.Fatalf("JSON 回复不是合法 JSON: %v", err)
+ }
+ if payload["Content"] != "收到" || payload["MsgType"] != "text" {
+ t.Fatalf("JSON 回复内容不符: %s", jsonReply)
+ }
+}
+
+func TestBuildEncryptedReply(t *testing.T) {
+ cfg := &PushConfig{AppID: "wxApp", Token: "token", AESKey: GeneratePushAESKey(), EncryptMode: wechatmp.EncryptModeSafe, DataFormat: DataFormatJSON}
+ plain, err := BuildTextReply(DataFormatJSON, "oOpenId", "wxApp", "收到", 1700000000)
+ if err != nil {
+ t.Fatalf("构造明文回复失败: %v", err)
+ }
+ reply, err := BuildEncryptedReply(cfg, plain, "1700000000", "nonce1", DataFormatJSON)
+ if err != nil {
+ t.Fatalf("构造加密回复失败: %v", err)
+ }
+ var payload map[string]interface{}
+ if err := json.Unmarshal([]byte(reply), &payload); err != nil {
+ t.Fatalf("加密回复不是合法 JSON: %v", err)
+ }
+ enc, _ := payload["Encrypt"].(string)
+ if enc == "" {
+ t.Fatal("加密回复缺少 Encrypt")
+ }
+ if payload["MsgSignature"] != wechatmp.MsgSignature(cfg.Token, "1700000000", "nonce1", enc) {
+ t.Fatal("加密回复签名不正确")
+ }
+ decrypted, err := wechatmp.DecryptMessage(cfg.AESKey, cfg.AppID, enc)
+ if err != nil {
+ t.Fatalf("解密回复失败: %v", err)
+ }
+ if decrypted != plain {
+ t.Fatalf("解密回复内容不符: %s", decrypted)
+ }
+}
diff --git a/go/services/wechatmini/subscribe.go b/go/services/wechatmini/subscribe.go
new file mode 100644
index 0000000..bfe1d2d
--- /dev/null
+++ b/go/services/wechatmini/subscribe.go
@@ -0,0 +1,488 @@
+package wechatmini
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "net/url"
+ "strings"
+ "time"
+
+ "server/models"
+
+ "github.com/beego/beego/v2/client/orm"
+)
+
+// =============================================================
+// 小程序订阅消息 / 客服消息发送
+//
+// 订阅消息(用户主动授权后才能收到):
+// POST /wxa/subscribe-message/send?access_token=xxx
+// {"touser":"openid","template_id":"xxx","page":"pages/index/index",
+// "data":{"thing1":{"value":"标题"},"time2":{"value":"2026-01-01 08:00"}}}
+//
+// 客服消息(用户在 48 小时内发过客服消息才能收到):
+// POST /cgi-bin/message/custom/send?access_token=xxx
+// {"touser":"openid","msgtype":"text","text":{"content":"..."}}
+//
+// 与「消息推送」(receive 方向)是两条不同的链路:
+// 消息推送 = 用户发给你(服务器收)
+// 订阅消息 = 你发给用户(用户收),需要用户在客户端授权
+// =============================================================
+
+// 订阅消息常见错误码
+const (
+ ErrCodeUserRefuse = 43101 // 用户未订阅 / 拒绝订阅(一次性订阅用尽)
+ ErrCodeOpenIDInvalid = 40003 // openid 无效
+ ErrCodeTplParamError = 47003 // 模板参数不匹配(字段键或类型不对)
+ ErrCodeTokenInvalid = 40001 // access_token 无效或过期
+)
+
+// ErrNoSubscribe 未找到可用的订阅授权
+var ErrNoSubscribe = errors.New("用户未授权订阅消息")
+
+// SendSubscribeMessage 发送订阅消息。
+// data 为「微信字段键 → 文本值」,例如 {"thing1":"标题","time2":"2026-10-31 18:00"}
+func SendSubscribeMessage(cfg *Config, openID, templateID, page string, data map[string]string) error {
+ if cfg == nil || cfg.AppID == "" || cfg.AppSecret == "" {
+ return ErrNotConfigured
+ }
+ openID = strings.TrimSpace(openID)
+ templateID = strings.TrimSpace(templateID)
+ if openID == "" || templateID == "" {
+ return errors.New("缺少 openid 或 template_id")
+ }
+ token, err := getAccessToken(cfg)
+ if err != nil {
+ return err
+ }
+
+ fields := make(map[string]map[string]string, len(data))
+ for k, v := range data {
+ if strings.TrimSpace(k) == "" {
+ continue
+ }
+ fields[k] = map[string]string{"value": v}
+ }
+
+ payload := map[string]interface{}{
+ "touser": openID,
+ "template_id": templateID,
+ "data": fields,
+ "miniprogram_state": "formal",
+ "lang": "zh_CN",
+ }
+ if strings.TrimSpace(page) != "" {
+ payload["page"] = page
+ }
+
+ api := fmt.Sprintf("%s/wxa/subscribe-message/send?access_token=%s", apiBase, url.QueryEscape(token))
+ var resp struct {
+ ErrCode int `json:"errcode"`
+ ErrMsg string `json:"errmsg"`
+ }
+ if err := httpPostJSON(api, payload, &resp); err != nil {
+ return err
+ }
+ if resp.ErrCode != 0 {
+ return &APIError{API: "发送订阅消息", Code: resp.ErrCode, Msg: resp.ErrMsg}
+ }
+ return nil
+}
+
+// SendCustomerServiceText 发送客服消息(文本)。
+// 前提:用户 48 小时内向小程序客服发过消息,否则微信返回 43101 / 45009。
+func SendCustomerServiceText(cfg *Config, openID, content string) error {
+ if cfg == nil || cfg.AppID == "" || cfg.AppSecret == "" {
+ return ErrNotConfigured
+ }
+ openID = strings.TrimSpace(openID)
+ content = strings.TrimSpace(content)
+ if openID == "" || content == "" {
+ return errors.New("缺少 openid 或消息内容")
+ }
+ token, err := getAccessToken(cfg)
+ if err != nil {
+ return err
+ }
+ payload := map[string]interface{}{
+ "touser": openID,
+ "msgtype": "text",
+ "text": map[string]string{"content": content},
+ }
+ api := fmt.Sprintf("%s/cgi-bin/message/custom/send?access_token=%s", apiBase, url.QueryEscape(token))
+ var resp struct {
+ ErrCode int `json:"errcode"`
+ ErrMsg string `json:"errmsg"`
+ }
+ if err := httpPostJSON(api, payload, &resp); err != nil {
+ return err
+ }
+ if resp.ErrCode != 0 {
+ return &APIError{API: "发送客服消息", Code: resp.ErrCode, Msg: resp.ErrMsg}
+ }
+ return nil
+}
+
+// ExplainSubscribeError 订阅消息错误码的中文排查建议
+func ExplainSubscribeError(err error) string {
+ var ae *APIError
+ if !errors.As(err, &ae) {
+ return ""
+ }
+ switch ae.Code {
+ case ErrCodeUserRefuse:
+ return "用户没有授权订阅(一次性订阅已用尽或被拒绝):请在小程序端引导用户重新点击订阅授权"
+ case ErrCodeOpenIDInvalid:
+ return "openid 无效:确认该用户是用这个小程序登录的,且 openid 属于同一个 AppID"
+ case ErrCodeTplParamError:
+ return "模板字段不匹配:检查模板配置的字段映射(微信字段键 → 变量名)是否与微信后台选的模板一致"
+ case 40066:
+ return "模板 ID 无效:请确认填的是小程序后台「功能 → 订阅消息」里选中的那个模板 ID"
+ case ErrCodeTokenInvalid:
+ return "access_token 失效:稍后重试,工具会自动刷新"
+ case 41001:
+ return "access_token 无效:请重新获取"
+ }
+ return ""
+}
+
+// =============================================================
+// 模板配置(平台端维护)
+// =============================================================
+
+// TemplateInput 保存模板入参
+type TemplateInput struct {
+ ID uint64
+ AppID string
+ Code string
+ TemplateID string
+ Title string
+ Page string
+ Fields string // JSON 字符串
+ Enabled bool
+ Remark string
+}
+
+// ListTemplates 查询某小程序的模板列表
+func ListTemplates(appID string) ([]models.WechatMiniTemplate, error) {
+ var rows []models.WechatMiniTemplate
+ q := models.Orm.QueryTable(new(models.WechatMiniTemplate)).
+ Filter("delete_time__isnull", true).
+ OrderBy("id")
+ appID = strings.TrimSpace(appID)
+ if appID != "" {
+ q = q.Filter("app_id", appID)
+ }
+ _, err := q.All(&rows)
+ return rows, err
+}
+
+// GetTemplateByCode 按场景码取模板
+func GetTemplateByCode(appID, code string) (*models.WechatMiniTemplate, error) {
+ var row models.WechatMiniTemplate
+ err := models.Orm.QueryTable(new(models.WechatMiniTemplate)).
+ Filter("app_id", strings.TrimSpace(appID)).
+ Filter("code", strings.TrimSpace(code)).
+ Filter("delete_time__isnull", true).
+ One(&row)
+ if err != nil {
+ return nil, err
+ }
+ return &row, nil
+}
+
+// SaveTemplate 保存模板
+func SaveTemplate(in TemplateInput) error {
+ appID := strings.TrimSpace(in.AppID)
+ code := strings.TrimSpace(in.Code)
+ if appID == "" {
+ return errors.New("AppID 不能为空")
+ }
+ if code == "" {
+ return errors.New("场景码不能为空")
+ }
+ tplID := strings.TrimSpace(in.TemplateID)
+ if tplID == "" {
+ return errors.New("模板 ID 不能为空")
+ }
+ fields := strings.TrimSpace(in.Fields)
+ if fields != "" {
+ var probe map[string]string
+ if err := json.Unmarshal([]byte(fields), &probe); err != nil {
+ return errors.New("字段映射必须是合法 JSON,例如 {\"thing1\":\"title\"}")
+ }
+ } else {
+ fields = ""
+ }
+
+ now := time.Now()
+ var row models.WechatMiniTemplate
+ err := models.Orm.QueryTable(new(models.WechatMiniTemplate)).
+ Filter("app_id", appID).
+ Filter("code", code).
+ Filter("delete_time__isnull", true).
+ One(&row)
+ if err != nil && err != orm.ErrNoRows {
+ return err
+ }
+ isNew := err == orm.ErrNoRows
+ if isNew {
+ row = models.WechatMiniTemplate{AppID: appID, Code: code, CreateTime: now}
+ }
+ row.TemplateID = tplID
+ row.Title = strings.TrimSpace(in.Title)
+ row.Page = strings.TrimSpace(in.Page)
+ row.Fields = fields
+ row.Remark = strings.TrimSpace(in.Remark)
+ row.Enabled = 0
+ if in.Enabled {
+ row.Enabled = 1
+ }
+ row.UpdateTime = &now
+ if isNew {
+ _, err = models.Orm.Insert(&row)
+ return err
+ }
+ _, err = models.Orm.Update(&row)
+ return err
+}
+
+// DeleteTemplate 软删模板
+func DeleteTemplate(id uint64) error {
+ if id == 0 {
+ return errors.New("无效ID")
+ }
+ now := time.Now()
+ _, err := models.Orm.QueryTable(new(models.WechatMiniTemplate)).
+ Filter("id", id).
+ Update(map[string]interface{}{"delete_time": now, "update_time": now})
+ return err
+}
+
+// =============================================================
+// 订阅授权记录
+// =============================================================
+
+// GrantSubscribe 记录用户授权(一次性订阅:每次授权 +1 次发送机会)
+func GrantSubscribe(identityID uint64, appID string, templateIDs []string) error {
+ if identityID == 0 {
+ return errors.New("未登录")
+ }
+ now := time.Now()
+ for _, tid := range templateIDs {
+ tid = strings.TrimSpace(tid)
+ if tid == "" {
+ continue
+ }
+ var row models.WechatMiniSubscribe
+ err := models.Orm.QueryTable(new(models.WechatMiniSubscribe)).
+ Filter("identity_id", identityID).
+ Filter("app_id", appID).
+ Filter("template_id", tid).
+ One(&row)
+ if err == nil {
+ row.Remain++
+ row.UpdateTime = &now
+ if _, uerr := models.Orm.Update(&row); uerr != nil {
+ return uerr
+ }
+ continue
+ }
+ if err != orm.ErrNoRows {
+ return err
+ }
+ row = models.WechatMiniSubscribe{
+ IdentityID: identityID,
+ AppID: appID,
+ TemplateID: tid,
+ Remain: 1,
+ CreateTime: now,
+ }
+ if _, ierr := models.Orm.Insert(&row); ierr != nil {
+ return ierr
+ }
+ }
+ return nil
+}
+
+// SubscribeRemain 查询剩余可发送次数
+func SubscribeRemain(identityID uint64, appID, templateID string) int {
+ var row models.WechatMiniSubscribe
+ err := models.Orm.QueryTable(new(models.WechatMiniSubscribe)).
+ Filter("identity_id", identityID).
+ Filter("app_id", appID).
+ Filter("template_id", templateID).
+ One(&row)
+ if err != nil {
+ return 0
+ }
+ return row.Remain
+}
+
+// consumeSubscribe 消耗一次发送机会(成功发送后调用)
+func consumeSubscribe(identityID uint64, appID, templateID string) {
+ now := time.Now()
+ models.Orm.QueryTable(new(models.WechatMiniSubscribe)).
+ Filter("identity_id", identityID).
+ Filter("app_id", appID).
+ Filter("template_id", templateID).
+ Filter("remain__gt", 0).
+ Update(map[string]interface{}{"remain": orm.ColValue(orm.ColMinus, 1), "update_time": now})
+}
+
+// =============================================================
+// 推送编排
+// =============================================================
+
+// MiniOpenIDByIdentity 取身份绑定的小程序 openid(未绑定返回空串)
+func MiniOpenIDByIdentity(identityID uint64) string {
+ if identityID == 0 {
+ return ""
+ }
+ var row models.AuthIdentityThird
+ err := models.Orm.QueryTable(new(models.AuthIdentityThird)).
+ Filter("identity_id", identityID).
+ Filter("provider", "wechat_mini").
+ One(&row)
+ if err != nil {
+ return ""
+ }
+ return strings.TrimSpace(row.OpenID)
+}
+
+// PushResult 一次推送的结果汇总
+type PushResult struct {
+ Sent int // 成功条数
+ Skipped int // 跳过(未授权 / 未绑定 / 无模板)
+ Errors []error // 失败原因
+}
+
+// PushSubscribe 按场景向某个身份推送订阅消息。
+// vars 为语义变量(如 title / content / time),按模板配置的字段映射填入微信字段。
+func PushSubscribe(scene string, identityID uint64, vars map[string]string) PushResult {
+ return pushSubscribe(scene, "", identityID, vars)
+}
+
+// PushSubscribeToTenantUser 按场景向租户用户推送(appID 为空表示所有启用的小程序)
+func PushSubscribeToTenantUser(tenantUserID uint64, scene, appID string, vars map[string]string) PushResult {
+ identityID, err := tenantUserIdentityID(tenantUserID)
+ if err != nil || identityID == 0 {
+ return PushResult{Skipped: 1}
+ }
+ return pushSubscribe(scene, appID, identityID, vars)
+}
+
+func pushSubscribe(scene, appIDFilter string, identityID uint64, vars map[string]string) PushResult {
+ res := PushResult{}
+ if identityID == 0 {
+ res.Skipped++
+ return res
+ }
+ openID := MiniOpenIDByIdentity(identityID)
+ if openID == "" {
+ res.Skipped++
+ return res
+ }
+
+ apps, err := ListApps()
+ if err != nil {
+ res.Errors = append(res.Errors, fmt.Errorf("读取小程序列表失败: %w", err))
+ return res
+ }
+ for _, app := range apps {
+ if app.Enabled != 1 {
+ continue
+ }
+ if appIDFilter != "" && app.AppID != appIDFilter {
+ continue
+ }
+ tpl, terr := GetTemplateByCode(app.AppID, scene)
+ if terr != nil || tpl.Enabled != 1 || tpl.TemplateID == "" {
+ res.Skipped++
+ continue
+ }
+ if SubscribeRemain(identityID, app.AppID, tpl.TemplateID) <= 0 {
+ res.Skipped++
+ continue
+ }
+ cfg, cerr := LoadEnabledAppByAppID(app.AppID)
+ if cerr != nil {
+ res.Skipped++
+ continue
+ }
+ data := buildTemplateData(tpl.Fields, vars)
+ serr := SendSubscribeMessage(cfg, openID, tpl.TemplateID, tpl.Page, data)
+ if serr != nil {
+ // 用户拒绝 / 授权用尽:清零机会,避免反复调接口
+ if ae, ok := serr.(*APIError); ok && ae.Code == ErrCodeUserRefuse {
+ models.Orm.QueryTable(new(models.WechatMiniSubscribe)).
+ Filter("identity_id", identityID).
+ Filter("app_id", app.AppID).
+ Filter("template_id", tpl.TemplateID).
+ Update(map[string]interface{}{"remain": 0, "update_time": time.Now()})
+ }
+ res.Errors = append(res.Errors, fmt.Errorf("%s: %w", tpl.Title, serr))
+ continue
+ }
+ consumeSubscribe(identityID, app.AppID, tpl.TemplateID)
+ res.Sent++
+ }
+ return res
+}
+
+// buildTemplateData 按字段映射组装微信模板数据
+// mapping 形如 {"thing1":"title","phrase2":"content"}:微信字段键 → 语义变量名
+func buildTemplateData(mapping string, vars map[string]string) map[string]string {
+ out := make(map[string]string)
+ m := map[string]string{}
+ if strings.TrimSpace(mapping) != "" {
+ _ = json.Unmarshal([]byte(mapping), &m)
+ }
+ for field, semantic := range m {
+ val, ok := vars[semantic]
+ if !ok {
+ // 映射里的变量没有值时给个占位,避免微信报字段缺失
+ val = "-"
+ }
+ out[field] = val
+ }
+ // 变量表里多出来的键也直接按微信字段名填(允许不配映射直接用 thing1 这种键)
+ for k, v := range vars {
+ if _, exists := out[k]; !exists {
+ out[k] = v
+ }
+ }
+ return out
+}
+
+// PushTenantNotice 推送站内通知到小程序(按租户用户)
+func PushTenantNotice(tenantUserID uint64, title, content string) PushResult {
+ vars := map[string]string{
+ "title": title,
+ "content": content,
+ "time": time.Now().Format("2006-01-02 15:04"),
+ }
+ return PushSubscribeToTenantUser(tenantUserID, SceneNotice, "", vars)
+}
+
+// 场景码
+const (
+ SceneNotice = "notice" // 站内通知 / 审批待办等通用提醒
+)
+
+// tenantUserIdentityID 租户用户 ID → 身份 ID(yz_system_tenant_user.uid)
+func tenantUserIdentityID(tenantUserID uint64) (uint64, error) {
+ if tenantUserID == 0 {
+ return 0, errors.New("无效用户")
+ }
+ var row models.SystemTenantUser
+ err := models.Orm.QueryTable(new(models.SystemTenantUser)).
+ Filter("id", tenantUserID).
+ Filter("delete_time__isnull", true).
+ One(&row)
+ if err != nil {
+ return 0, err
+ }
+ return row.Uid, nil
+}
diff --git a/go/update-server.bat b/go/update-server.bat
new file mode 100644
index 0000000..c01db7e
--- /dev/null
+++ b/go/update-server.bat
@@ -0,0 +1,128 @@
+@echo off
+chcp 65001 >nul
+setlocal EnableExtensions
+title Update Server - Go API incremental deploy
+
+rem ==================================================================
+rem Sync only CHANGED files from this go\ folder to the server.
+rem (Batch file is ASCII-only on purpose: cmd.exe mis-parses UTF-8
+rem Chinese text in .bat files. The deploy tool itself prints Chinese.)
+rem
+rem Usage:
+rem update-server.bat diff -> confirm -> upload -> ask to restart
+rem update-server.bat dry diff only, upload nothing
+rem update-server.bat restart only restart the service on the server
+rem ==================================================================
+
+rem ============================ CONFIG ============================
+set "DEPLOY_HOST=10.31.100.2"
+set "DEPLOY_USER=root"
+set "DEPLOY_DIR=/www/wwwroot/api.yunzer.cn"
+
+rem Key auth (this server has password auth disabled in sshd)
+set "DEPLOY_KEY=%~dp010.31.100.2_id_ed25519"
+
+rem Fallback: if password auth is enabled later, blank DEPLOY_KEY and set the password here
+rem set "DEPLOY_KEY="
+set "DEPLOY_PASS=xxxxxx"
+
+rem Restart command (systemd unit: go-api.service)
+set "RESTART_CMD=systemctl restart go-api.service"
+
+rem Extra exclude patterns, comma separated, * wildcard supported
+set "EXTRA_EXCLUDE="
+rem ================================================================
+
+cd /d "%~dp0"
+set "DEPLOY_EXE=%~dp0bin\deploy.exe"
+set "ACTION=%~1"
+if "%ACTION%"=="" set "ACTION=deploy"
+
+rem %~dp0 ends with a backslash; a trailing backslash before the closing quote
+rem escapes it and swallows the following arguments - strip it.
+set "LOCAL_DIR=%~dp0"
+set "LOCAL_DIR=%LOCAL_DIR:~0,-1%"
+
+echo ==================================================================
+echo Server : %DEPLOY_USER%@%DEPLOY_HOST%:%DEPLOY_DIR%
+echo Local : %LOCAL_DIR%
+echo Action : %ACTION%
+echo ==================================================================
+echo.
+
+rem ---------------- 1/3 build the deploy tool ----------------
+where go >nul 2>nul
+if not errorlevel 1 (
+ echo [1/3] Building deploy tool...
+ go build -o "bin\deploy.exe" ./cmd/deploy
+ if errorlevel 1 (
+ echo [ERROR] Failed to build the deploy tool
+ goto :fail
+ )
+) else (
+ if not exist "%DEPLOY_EXE%" (
+ echo [ERROR] go command not found and bin\deploy.exe is missing
+ goto :fail
+ )
+ echo [1/3] go not found, using existing bin\deploy.exe
+)
+
+rem ---------------- auth args ----------------
+if defined DEPLOY_KEY (
+ if not exist "%DEPLOY_KEY%" (
+ echo [ERROR] Private key not found: %DEPLOY_KEY%
+ echo Blank DEPLOY_KEY in the config block to use password auth
+ goto :fail
+ )
+ set "AUTH_ARGS=-key "%DEPLOY_KEY%""
+) else (
+ set "AUTH_ARGS=-pass %DEPLOY_PASS%"
+)
+
+rem ---------------- 2/3 run ----------------
+if /i "%ACTION%"=="restart" goto :do_restart
+
+set "EXTRA_ARGS="
+if /i "%ACTION%"=="dry" set "EXTRA_ARGS=-dry-run"
+
+echo [2/3] Comparing files...
+echo.
+"%DEPLOY_EXE%" -host "%DEPLOY_HOST%" -user "%DEPLOY_USER%" -dir "%DEPLOY_DIR%" -local "%LOCAL_DIR%" %AUTH_ARGS% %EXTRA_ARGS% %EXTRA_EXCLUDE%
+if errorlevel 3 goto :cancelled
+if errorlevel 1 goto :fail
+
+if /i "%ACTION%"=="dry" goto :done
+
+rem ---------------- 3/3 restart ----------------
+echo.
+set "ANSWER="
+set /p "ANSWER=Restart the service so the changes take effect? (Y/N) [Y]: "
+if not defined ANSWER set "ANSWER=Y"
+if /i not "%ANSWER%"=="Y" (
+ echo Skipped. Run "update-server.bat restart" or restart it manually later.
+ goto :done
+)
+
+:do_restart
+echo.
+echo [3/3] Restarting: %RESTART_CMD%
+"%DEPLOY_EXE%" -host "%DEPLOY_HOST%" -user "%DEPLOY_USER%" %AUTH_ARGS% -exec "%RESTART_CMD%"
+if errorlevel 1 goto :fail
+
+:done
+echo.
+echo [DONE]
+if not defined DEPLOY_NOPAUSE pause
+exit /b 0
+
+:cancelled
+echo.
+echo [CANCELLED] Nothing was uploaded, service was NOT restarted.
+if not defined DEPLOY_NOPAUSE pause
+exit /b 0
+
+:fail
+echo.
+echo [FAILED] See the message above
+if not defined DEPLOY_NOPAUSE pause
+exit /b 1
diff --git a/go/verify/1XXl8fPHr9.txt b/go/verify/1XXl8fPHr9.txt
new file mode 100644
index 0000000..b0c6ca6
--- /dev/null
+++ b/go/verify/1XXl8fPHr9.txt
@@ -0,0 +1 @@
+1XXl8fPHr9
\ No newline at end of file
diff --git a/go/verify/README.md b/go/verify/README.md
new file mode 100644
index 0000000..e726595
--- /dev/null
+++ b/go/verify/README.md
@@ -0,0 +1,161 @@
+# 根路径站点验证文件
+
+放在这个目录里的文件,会被 Go 服务直接吐到**域名根路径**,用于各类「域名所有权 / 站点所有权」验证:
+
+- 备案与域名所有权验证:`1XXl8fPHr9.txt`(阿里云、腾讯云)
+- 搜索引擎站点验证:`googleXXXX.html`、`msXXXXXXXX.txt`、`BingSiteAuth.xml`
+- 第三方平台域名校验:各平台下发的 `.txt` / `.html`
+
+## 为什么需要这个目录
+
+线上 nginx 对所有域名都是「全量反代给 Go」(`location /` → `proxy_pass`),
+Go 不认识的路径一律回 beego 404 页,所以放在服务器网站根目录里的验证文件根本取不到。
+改成由 Go 统一托管后,不再依赖 nginx 规则 —— 宝塔重生成 vhost 也不会被冲掉。
+
+实现见 `go/routers/verifyfiles.go`(过滤器)与 `go/pkg/verifyfiles/`(路径解析与安全校验)。
+
+## 生效规则
+
+- 只处理 `GET` / `HEAD`;
+- 只认**根路径下的单段文件名**(如 `/1XXl8fPHr9.txt`),不支持子目录;
+- 文件名必须形如「字母或数字开头 + 字母数字与 `.` `_` `-`」,长度 ≤ 64;
+- 扩展名必须在允许列表内(默认 `.txt` / `.html` / `.htm`);
+- 文件必须真实存在,否则放行给正常路由(最终仍是 beego 404,行为不变)。
+
+配置项在 `app.conf`:
+
+| 配置 | 说明 | 默认值 |
+|---|---|---|
+| `verify_dir` | 验证文件目录 | `<服务根目录>/verify` |
+| `verify_exts` | 允许的扩展名,逗号分隔 | `.txt,.html,.htm` |
+| `verify_hosts` | 限定生效域名,逗号分隔(不含端口) | 空 = 所有走本服务的域名 |
+
+> 「服务根目录」= `conf/app.conf` 所在目录的上一级。
+> 线上即 `/www/wwwroot/api.yunzer.cn`(systemd 的 `WorkingDirectory`),
+> 所以验证文件目录是 `/www/wwwroot/api.yunzer.cn/verify`。
+> 目录跟着 `app.conf` 走,不管用 `go run main.go` 还是编译后的二进制、
+> 不管从哪个目录启动,结果都一致。
+
+## 新增一个验证文件
+
+1. 把平台给的验证文件放进本目录(文件名保持原样,**内容不要加多余字符**);
+2. `git add && git commit && git push`;
+3. 服务器上 `git pull` 后重启 Go 服务:`systemctl restart go-api`(或 `bash scripts/service.sh restart`);
+4. 访问 `https://api.yunzer.cn/1XXl8fPHr9.txt` 验证。
+
+> 注意:本目录下的 `.md` 等非白名单扩展名不会被吐出去,README 不会被当成静态文件泄露。
+
+## 部署注意:本仓库(allinone)与服务器不是同一个仓库
+
+排查问题时确认过,线上是**两套独立的 git 仓库**,别混用:
+
+| | 位置 | 角色 |
+|---|---|---|
+| 源仓库 | 本地 `yunzerwebsiteallinone`(远端 `10.31.100.4:8418/.../yunzerwebsiteallinone.git`) | 唯一真相源,`go/` 只是其中一个子目录 |
+| 部署目录 | 服务器 `/www/wwwroot/api.yunzer.cn` | 只放 `go/` 的内容,里面是**另一个** Go 仓库(HEAD 与 allinone 不一致,例如 `269fbd0`) |
+
+所以:
+
+- **allinone 的 `master` 与服务器仓库的 `master` 是两条线**,`git pull` 拉不到对方;
+- 部署 = 把 `go/` 的内容同步到服务器目录(手工拷文件 / scp / rsync),**新增目录最容易漏**——
+ 本目录就是漏传过一次导致验证 404 的典型;
+- 同步本目录用**文件路径**,别用目录路径,否则会多出一层 `verify/verify`:
+
+```powershell
+# Windows 本地 → 服务器(只同步新增的验证文件)
+scp "E:\Demo\Go\yunzerwebsiteallinone\go\verify\1XXl8fPHr9.txt" root@10.31.100.2:/www/wwwroot/api.yunzer.cn/verify/
+```
+
+```bash
+# 服务器上确认
+ls -l /www/wwwroot/api.yunzer.cn/verify/
+curl -i http://127.0.0.1:9000/1XXl8fPHr9.txt # 直连 Go,绕过 nginx
+```
+
+> Go 每次请求都会实时 `os.Stat` 验证目录,**文件放进去立即生效,不需要重启服务**。
+
+## ⚠️ 本项目 nginx 的两个既定事实(改配置时别踩)
+
+api.yunzer.cn 的 vhost(`/www/server/panel/vhost/nginx/api.yunzer.cn.conf`)有两个特点,
+直接影响本机制的配置方式:
+
+1. **`proxy_set_header Host 10.31.100.2;`** —— 转发给 Go 时 Host 头被改成了 IP,
+ Go 侧**看不到 `api.yunzer.cn` 这个域名**。
+ → 因此 `verify_hosts` **必须留空**(留空 = 所有走本服务的域名都放行)。
+ 一旦填了 `api.yunzer.cn`,Go 会因为 Host 对不上而拒绝提供文件。
+2. **`location ^~ / { proxy_pass ... }`** —— `^~` 匹配后 nginx 不再检查正则 location,
+ 后面那两条「禁止访问的敏感文件 / 敏感目录」的 `location ~*` 规则在这个站点实际不生效。
+ 目前没有泄露风险(Go 侧不提供工作目录文件,未知路径一律 404),但要知道这层保护是空的。
+
+## ⚠️ 微信 / 小程序业务域名校验:必须让 http 也能直接访问
+
+微信公众平台配置「业务域名」时,会去 **`http://`** 你的域名根目录抓取校验文件
+(`http://api.yunzer.cn/1XXl8fPHr9.txt`)。宝塔默认的 HTTP→HTTPS 强制跳转会把
+这个请求 301 到 https,微信拿到重定向就直接判「校验文件验证失败」。
+
+**症状**:浏览器访问 `https://域名/校验文件.txt` 完全正常,微信后台却报
+「校验文件验证失败,请确认已在域名根目录正确放置校验文件」。
+
+**修法**:宝塔 → 网站 → 对应站点 → 设置 → 配置文件,在 `#HTTP_TO_HTTPS_START` 块里
+加一条例外(放在 `if ($isRedcert != 1)` 之前):
+
+```nginx
+# 域名校验文件(微信 / 搜索引擎 / 第三方平台)必须能用 http 直接访问,不能 301 跳 https
+if ( $uri ~ "^/[A-Za-z0-9._-]+\.txt$" ) {
+ set $isRedcert 1;
+}
+```
+
+> 注意:这条必须加在 **HTTP_TO_HTTPS 块里**。在 `location` 里加 `location = /xxx.txt`
+> 没用——server 层的 `rewrite` 在 location 匹配之前就执行了,重定向照样发生。
+
+改完 `nginx -t && nginx -s reload`,然后确认:
+
+```bash
+curl -i http://api.yunzer.cn/1XXl8fPHr9.txt # 期望 200 + 文件内容,且没有 Location 头
+curl -i https://api.yunzer.cn/1XXl8fPHr9.txt # 仍是 200
+```
+
+其它常见原因(按排查顺序):
+
+1. **文件必须原样**:文件名区分大小写、不能改后缀;内容与微信下载的那份字节级一致,
+ 不能带 BOM / 结尾换行。`xxd verify/xxx.txt` 确认没有 `efbbbf` 开头、没有结尾 `0a`。
+2. **备案要同步到微信侧**:截图里写明「新备案域名需 24 小时后才可配置」,工信部数据同步
+ 到微信侧通常 24~48 小时;备案主体须与小程序主体一致,接入商须与服务器提供商一致。
+ 部分省份还要求已完成**公安联网备案**。
+3. **微信侧缓存**:全部正确仍失败时,重新点「下载文件」换一个随机文件名放进 `verify/`,
+ 再保存,可绕过平台短时缓存。
+4. **小程序主体类型**:业务域名**只支持企业主体**小程序,个人主体 / 海外类型小程序
+ 在微信侧根本不支持配置业务域名(报错形态与校验失败一样)。
+5. **修改次数限制**:服务器域名每月只能改 5 次,业务域名至多 20 个,别反复试。
+
+> 判定要点:如果访问日志里能看到微信爬虫(UA 类似 `Mozilla/4.0`)请求校验文件并返回 **200**,
+> 说明**文件校验已通过**,剩下的失败只能是备案 / 主体 / 缓存这类平台侧原因,
+> 不要再改服务器配置。
+
+### 用访问日志判读卡在哪一步
+
+改完 nginx 后,点微信「保存」的**同时**在另一个窗口执行:
+
+```bash
+tail -f /www/wwwlogs/api.yunzer.cn.log | grep -i "1XXl8fPHr9\|MP_verify"
+```
+
+| 日志表现 | 卡在哪 | 怎么办 |
+|---|---|---|
+| 新增一行,状态 `200` | 文件链路正常 | 剩下是备案同步 / 微信缓存,等 24~48 小时或换文件名重试 |
+| 新增一行,状态 `404` | 微信要的文件名和你放的不一致 | 重新「下载文件」,原文件原样传进 `verify/` |
+| 新增一行,状态 `301` | nginx 例外没生效 | 检查 `if ( $uri ~ ^/[A-Za-z0-9._-]+[.]txt$ )` 是否加在 `if ($isRedcert != 1)` 之前 |
+| **一行都没有** | 微信压根没来拉 | 微信在**备案校验**阶段就拦下了,与文件无关 → 等备案同步,核对主体/接入商 |
+
+### 公网可达性自查
+
+服务器本机 curl 走的是回环,不代表公网通。微信爬虫在公网,必须用外部验证:
+
+```bash
+# 从任意一台外部机器/手机流量执行
+curl -i http://api.yunzer.cn/1XXl8fPHr9.txt # 期望 200 + 内容,且无 Location 头
+```
+
+或用手机切 4G/5G(不连 WiFi)打开 `http://api.yunzer.cn/1XXl8fPHr9.txt`,
+能看到 `1XXl8fPHr9` 即为公网可达。
diff --git a/platform/src/api/miniApp.js b/platform/src/api/miniApp.js
index 8380639..0e50697 100644
--- a/platform/src/api/miniApp.js
+++ b/platform/src/api/miniApp.js
@@ -39,3 +39,91 @@ export function deleteMiniApp(id) {
method: "post",
});
}
+
+/**
+ * 消息推送配置(对应小程序后台「开发管理 → 开发设置 → 消息推送」)
+ */
+
+/** 获取指定小程序的推送配置(含回调地址,aes_key 掩码回显) */
+export function getMiniAppPush(appId) {
+ return request({
+ url: "/platform/wechatMini/app/push",
+ method: "get",
+ params: { app_id: appId },
+ });
+}
+
+/**
+ * 保存推送配置
+ * @param {Object} data
+ * @param {string} data.app_id 小程序 AppID
+ * @param {string} [data.token] 令牌(3-32 位字母数字)
+ * @param {string} [data.aes_key] 消息加解密密钥(43 位,掩码/空串 = 不修改原值)
+ * @param {string} [data.encrypt_mode] plain / compatible / safe
+ * @param {string} [data.data_format] xml / json
+ * @param {string} [data.push_reply] 客服消息自动回复文案
+ */
+export function saveMiniAppPush(data) {
+ return request({
+ url: "/platform/wechatMini/app/push/save",
+ method: "post",
+ data,
+ });
+}
+
+/** 生成随机 Token 与 EncodingAESKey */
+export function generateMiniAppPushSecret() {
+ return request({
+ url: "/platform/wechatMini/app/push/generate",
+ method: "post",
+ });
+}
+
+/**
+ * 订阅消息模板(小程序后台「功能 → 订阅消息」选用模板后登记)
+ */
+
+/** 模板列表 */
+export function listMiniTemplates(appId) {
+ return request({
+ url: "/platform/wechatMini/templates",
+ method: "get",
+ params: { app_id: appId },
+ });
+}
+
+/**
+ * 保存模板
+ * @param {Object} data
+ * @param {string} data.app_id 小程序 AppID
+ * @param {string} data.code 场景码,如 notice
+ * @param {string} data.template_id 微信模板 ID
+ * @param {string} [data.title] 场景名称
+ * @param {string} [data.page] 点击消息跳转的小程序页面
+ * @param {string} [data.fields] JSON:微信字段键 → 语义变量名,如 {"thing1":"title"}
+ * @param {number} data.enabled 1=启用 0=停用
+ */
+export function saveMiniTemplate(data) {
+ return request({
+ url: "/platform/wechatMini/template/save",
+ method: "post",
+ data,
+ });
+}
+
+/** 删除模板 */
+export function deleteMiniTemplate(id) {
+ return request({
+ url: `/platform/wechatMini/template/delete/${id}`,
+ method: "post",
+ });
+}
+
+/** 给指定租户用户发一条测试订阅消息 */
+export function testMiniTemplate(data) {
+ return request({
+ url: "/platform/wechatMini/template/test",
+ method: "post",
+ data,
+ });
+}
diff --git a/platform/src/views/system/platformsettings/components/miniAppSettings.vue b/platform/src/views/system/platformsettings/components/miniAppSettings.vue
index bcbdbc1..1e87bae 100644
--- a/platform/src/views/system/platformsettings/components/miniAppSettings.vue
+++ b/platform/src/views/system/platformsettings/components/miniAppSettings.vue
@@ -61,6 +61,13 @@
未上传
+
+
+
+ {{ scope.row.token ? "已配置" : "未配置" }}
+
+
+
+
+
+
+ 消息推送
+
+
+
+
+
+
+
+
+ 复制
+
+
+
+ 必须以 http:// 或 https:// 开头(80 / 443 端口),且微信服务器能直接访问。
+ 若显示为相对路径,请自行补上域名,如
+ https://api.yunzer.cn/api/wechat/mini/callback。
+
+
+
+
+
+
+
+ 随机生成
+
+
+
+
+
+
+
+ 安全 / 兼容模式必填,与小程序后台填写的 EncodingAESKey 必须完全一致。
+
+
+
+
+
+ 明文模式
+ 兼容模式
+ 安全模式
+
+
+
+
+
+ XML
+ JSON
+
+
+
+
+
+
+
+
+
+ 保存消息推送配置
+
+ 请先保存小程序,再配置消息推送
+
+
+
+
+ 订阅消息模板
+
+
+
+
+
+
+
+ {{ row.title || "-" }}
+
+
+
+
+
+ {{ row.enabled === 1 ? "启用" : "停用" }}
+
+
+
+
+
+
+ 编辑
+
+
+ 测试
+
+
+ 删除
+
+
+
+
+
+ 新增模板
+
取消
@@ -205,6 +363,65 @@
+
+
+
+
+
+
+ 业务侧按场景码触发:notice = 站内信 / 审批待办等通用提醒
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 左边是微信模板的字段名,右边是平台这边的变量名。可用变量:
+ title(标题)、content(内容)、time(时间)
+
+
+
+
+
+
+
+
+
+
+ 取消
+
+ 保存
+
+
+
@@ -212,7 +429,18 @@
import { ref, onMounted } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { Plus } from "@element-plus/icons-vue";
-import { listMiniApps, saveMiniApp, deleteMiniApp } from "@/api/miniApp";
+import {
+ listMiniApps,
+ saveMiniApp,
+ deleteMiniApp,
+ getMiniAppPush,
+ saveMiniAppPush,
+ generateMiniAppPushSecret,
+ listMiniTemplates,
+ saveMiniTemplate,
+ deleteMiniTemplate,
+ testMiniTemplate,
+} from "@/api/miniApp";
import { formatDateTime } from "@/utils/datetime";
import { smartUpload } from "@/utils/qiniuUpload";
@@ -228,10 +456,15 @@ const previewUrl = (url) => {
const loading = ref(false);
const saving = ref(false);
const qrcodeUploading = ref(false);
+const pushSaving = ref(false);
+const templateSaving = ref(false);
+const templateDialogVisible = ref(false);
+const templates = ref([]);
const apps = ref([]);
const dialogVisible = ref(false);
const formRef = ref(null);
const secretConfigured = ref(false);
+const pushConfigured = ref(false);
const emptyForm = () => ({
id: 0,
@@ -244,7 +477,31 @@ const emptyForm = () => ({
remark: "",
});
+const emptyPushForm = () => ({
+ app_id: "",
+ token: "",
+ aes_key: "",
+ encrypt_mode: "plain",
+ data_format: "xml",
+ push_reply: "",
+ callback_url: "",
+});
+
+const emptyTemplateForm = () => ({
+ id: 0,
+ app_id: "",
+ code: "notice",
+ template_id: "",
+ title: "站内通知",
+ page: "",
+ fields: '{"thing1":"title","phrase2":"content","time3":"time"}',
+ enabled: 1,
+ remark: "",
+});
+
const form = ref(emptyForm());
+const pushForm = ref(emptyPushForm());
+const templateForm = ref(emptyTemplateForm());
const rules = {
name: [{ required: true, message: "请输入应用名称", trigger: "blur" }],
@@ -273,6 +530,9 @@ const fetchApps = async () => {
const openAdd = () => {
form.value = emptyForm();
secretConfigured.value = false;
+ pushConfigured.value = false;
+ pushForm.value = emptyPushForm();
+ templates.value = [];
dialogVisible.value = true;
};
@@ -289,6 +549,199 @@ const openEdit = (row) => {
};
secretConfigured.value = !!row.configured;
dialogVisible.value = true;
+ fetchPushConfig(row.app_id);
+ fetchTemplates(row.app_id);
+};
+
+// 读取订阅消息模板列表
+const fetchTemplates = async (appId) => {
+ if (!appId) return;
+ try {
+ const res = await listMiniTemplates(appId);
+ if (res.code === 200) {
+ templates.value = res.data?.list || [];
+ }
+ } catch (error) {
+ ElMessage.error(error?.message || "获取模板列表失败");
+ }
+};
+
+// 新增 / 编辑模板
+const openTemplate = (row) => {
+ if (row) {
+ templateForm.value = { ...row };
+ } else {
+ templateForm.value = {
+ ...emptyTemplateForm(),
+ app_id: form.value.app_id,
+ };
+ }
+ templateDialogVisible.value = true;
+};
+
+// 保存模板
+const handleSaveTemplate = async () => {
+ if (!templateForm.value.code || !templateForm.value.template_id) {
+ ElMessage.warning("请填写场景码和模板 ID");
+ return;
+ }
+ templateSaving.value = true;
+ try {
+ const res = await saveMiniTemplate({ ...templateForm.value });
+ if (res.code === 200) {
+ ElMessage.success(res.msg || "已保存");
+ templateDialogVisible.value = false;
+ fetchTemplates(form.value.app_id);
+ } else {
+ ElMessage.error(res.msg || "保存失败");
+ }
+ } catch (error) {
+ ElMessage.error(error?.message || "保存失败");
+ } finally {
+ templateSaving.value = false;
+ }
+};
+
+// 删除模板
+const removeTemplate = async (row) => {
+ try {
+ await ElMessageBox.confirm(`确认删除模板「${row.title || row.code}」?`, "删除模板", {
+ confirmButtonText: "确定",
+ cancelButtonText: "取消",
+ type: "warning",
+ });
+ } catch {
+ return;
+ }
+ try {
+ const res = await deleteMiniTemplate(row.id);
+ if (res.code === 200) {
+ ElMessage.success("已删除");
+ fetchTemplates(form.value.app_id);
+ } else {
+ ElMessage.error(res.msg || "删除失败");
+ }
+ } catch (error) {
+ ElMessage.error(error?.message || "删除失败");
+ }
+};
+
+// 测试发送:需要指定一个租户用户(该用户需已授权订阅)
+const sendTestTemplate = async (row) => {
+ let userId = "";
+ try {
+ const { value } = await ElMessageBox.prompt(
+ "请输入测试用的租户用户 ID(该用户必须已用小程序登录并授权过订阅)",
+ "发送测试消息",
+ {
+ confirmButtonText: "发送",
+ cancelButtonText: "取消",
+ inputPattern: /^\d+$/,
+ inputErrorMessage: "请输入数字 ID",
+ type: "info",
+ },
+ );
+ userId = value;
+ } catch {
+ return;
+ }
+ try {
+ const res = await testMiniTemplate({
+ tenant_user_id: Number(userId),
+ app_id: form.value.app_id,
+ code: row.code,
+ });
+ if (res.code === 200) {
+ ElMessage.success(res.msg || "已发送");
+ } else {
+ ElMessage.error(res.msg || "发送失败");
+ }
+ } catch (error) {
+ ElMessage.error(error?.message || "发送失败");
+ }
+};
+
+// 读取消息推送配置(回调地址由服务端生成,Token 直接回显,AESKey 掩码)
+const fetchPushConfig = async (appId) => {
+ if (!appId) return;
+ pushForm.value = emptyPushForm();
+ pushForm.value.app_id = appId;
+ try {
+ const res = await getMiniAppPush(appId);
+ if (res.code === 200) {
+ const data = res.data || {};
+ pushForm.value = {
+ app_id: appId,
+ token: data.token || "",
+ aes_key: data.aes_key || "",
+ encrypt_mode: data.encrypt_mode || "plain",
+ data_format: data.data_format || "xml",
+ push_reply: data.push_reply || "",
+ callback_url: data.callback_url || "",
+ };
+ pushConfigured.value = !!data.configured;
+ } else {
+ ElMessage.error(res.msg || "获取消息推送配置失败");
+ }
+ } catch (error) {
+ ElMessage.error(error?.message || "获取消息推送配置失败");
+ }
+};
+
+// 随机生成 Token 与 EncodingAESKey
+const generateSecrets = async () => {
+ try {
+ const res = await generateMiniAppPushSecret();
+ if (res.code === 200) {
+ pushForm.value.token = res.data?.token || "";
+ pushForm.value.aes_key = res.data?.aes_key || "";
+ ElMessage.success("已生成,记得保存");
+ } else {
+ ElMessage.error(res.msg || "生成失败");
+ }
+ } catch (error) {
+ ElMessage.error(error?.message || "生成失败");
+ }
+};
+
+// 保存消息推送配置
+const handleSavePush = async () => {
+ if (!pushForm.value.app_id) {
+ ElMessage.warning("请先保存小程序");
+ return;
+ }
+ pushSaving.value = true;
+ try {
+ const res = await saveMiniAppPush({ ...pushForm.value });
+ if (res.code === 200) {
+ ElMessage.success(res.msg || "消息推送配置已保存");
+ if (res.data?.callback_url) {
+ pushForm.value.callback_url = res.data.callback_url;
+ }
+ pushConfigured.value = true;
+ fetchApps();
+ } else {
+ ElMessage.error(res.msg || "保存失败");
+ }
+ } catch (error) {
+ ElMessage.error(error?.message || "保存失败");
+ } finally {
+ pushSaving.value = false;
+ }
+};
+
+// 复制文本
+const copyText = async (text) => {
+ if (!text) {
+ ElMessage.warning("无可复制内容");
+ return;
+ }
+ try {
+ await navigator.clipboard.writeText(text);
+ ElMessage.success("已复制");
+ } catch {
+ ElMessage.error("复制失败,请手动选择复制");
+ }
};
// 二维码上传:上传成功后先写入表单,保存时随应用一起落库
@@ -422,6 +875,26 @@ onMounted(() => {
margin-bottom: 16px;
}
+ .push-alert {
+ margin-bottom: 14px;
+ }
+
+ .divider-text {
+ font-size: 13px;
+ color: #5a6478;
+ }
+
+ .callback-field {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ width: 100%;
+
+ :deep(.el-input) {
+ flex: 1;
+ }
+ }
+
.form-tip {
margin-left: 10px;
color: #8a94ad;
diff --git a/uniapp/App.vue b/uniapp/App.vue
index a919b18..5217962 100644
--- a/uniapp/App.vue
+++ b/uniapp/App.vue
@@ -1,5 +1,37 @@
diff --git a/uniapp/pages/login/login.vue b/uniapp/pages/login/login.vue
index d95c8de..a343c0e 100644
--- a/uniapp/pages/login/login.vue
+++ b/uniapp/pages/login/login.vue
@@ -172,11 +172,6 @@
《隐私政策》
-
-
diff --git a/uniapp/pages/nav/nav.vue b/uniapp/pages/nav/nav.vue
index 6d4efa0..b450809 100644
--- a/uniapp/pages/nav/nav.vue
+++ b/uniapp/pages/nav/nav.vue
@@ -170,7 +170,12 @@ onMounted(() => {
})
onShow(() => {
- if (isLoggedIn() && !loading.value) load()
+ // 未登录(含后台切回来时令牌被清)直接回登录页,不再停留在首页
+ if (!isLoggedIn()) {
+ uni.reLaunch({ url: '/pages/login/login' })
+ return
+ }
+ if (!loading.value) load()
})
diff --git a/uniapp/pages/profile/profile.vue b/uniapp/pages/profile/profile.vue
index 035c42d..5b7735f 100644
--- a/uniapp/pages/profile/profile.vue
+++ b/uniapp/pages/profile/profile.vue
@@ -68,6 +68,7 @@ import { mpHeadPad } from '@/utils/mpHead.js'
const headPad = mpHeadPad()
import { getUser, setUser, logout, isLoggedIn, isSSOEnabled, getToken } from '@/utils/auth.js'
import { getCurrentUser, logoutApi, logoutUAC } from '@/api/auth.js'
+import { requestSubscribeNotice } from '@/utils/subscribe.js'
import AppTabbar from '@/components/MainTabbar.vue'
const user = ref(null)
@@ -147,6 +148,11 @@ function onMenuTap(item) {
uni.navigateTo({ url: '/pages/login/forget' })
return
}
+ if (item.title === '消息通知') {
+ // 订阅消息授权:用户同意后,服务器才有一次主动推送的机会
+ requestSubscribeNotice()
+ return
+ }
uni.showToast({ title: item.title, icon: 'none' })
}
diff --git a/uniapp/utils/subscribe.js b/uniapp/utils/subscribe.js
new file mode 100644
index 0000000..c21de76
--- /dev/null
+++ b/uniapp/utils/subscribe.js
@@ -0,0 +1,84 @@
+/**
+ * 订阅消息授权助手
+ *
+ * 用法:在需要提醒用户的入口调用一次即可
+ * import { requestSubscribeNotice } from '@/utils/subscribe.js'
+ * await requestSubscribeNotice()
+ *
+ * 说明:
+ * - 微信一次性订阅:用户每同意一次,服务器才有一次推送机会,所以要挑「用户确实
+ * 可能需要收到提醒」的时机调用(比如进入工作台、提交完审批后)。
+ * - 微信限制一次最多弹 3 个模板,这里自动分批。
+ */
+import { getSubscribeTemplates, authorizeSubscribe } from '@/api/subscribe.js'
+
+/** 单次弹窗最多 3 个模板 ID */
+const MAX_TMPL_PER_CALL = 3
+
+/**
+ * 拉起微信订阅授权弹窗,并把授权结果上报给服务器
+ * @param {{silent?: boolean, tip?: string}} options
+ * @returns {Promise<{ok: boolean, reason?: string, accepted?: string[]}>}
+ */
+export async function requestSubscribeNotice(options = {}) {
+ const silent = !!options.silent
+ // #ifdef MP-WEIXIN
+ let templates = []
+ try {
+ const data = await getSubscribeTemplates()
+ templates = (data && data.list) || []
+ } catch (e) {
+ if (!silent) uni.showToast({ title: '获取订阅模板失败', icon: 'none' })
+ return { ok: false, reason: 'template_fetch_failed' }
+ }
+
+ const tmplIds = templates.map((t) => t.template_id).filter(Boolean)
+ if (!tmplIds.length) {
+ if (!silent) uni.showToast({ title: '暂未配置订阅消息模板', icon: 'none' })
+ return { ok: false, reason: 'no_template' }
+ }
+
+ const accepted = []
+ for (let i = 0; i < tmplIds.length; i += MAX_TMPL_PER_CALL) {
+ const batch = tmplIds.slice(i, i + MAX_TMPL_PER_CALL)
+ let result = {}
+ try {
+ result = await new Promise((resolve, reject) => {
+ uni.requestSubscribeMessage({
+ tmplIds: batch,
+ success: resolve,
+ fail: reject
+ })
+ })
+ } catch (e) {
+ if (!silent) uni.showToast({ title: '订阅授权失败', icon: 'none' })
+ return { ok: false, reason: 'request_failed' }
+ }
+ batch.forEach((id) => {
+ if (result[id] === 'accept') accepted.push(id)
+ })
+ }
+
+ if (!accepted.length) {
+ if (!silent) uni.showToast({ title: '你取消了订阅授权', icon: 'none' })
+ return { ok: false, reason: 'refuse' }
+ }
+
+ try {
+ await authorizeSubscribe(accepted)
+ } catch (e) {
+ if (!silent) uni.showToast({ title: '授权结果上报失败', icon: 'none' })
+ return { ok: false, reason: 'report_failed' }
+ }
+
+ if (!silent) {
+ uni.showToast({ title: options.tip || '已开启消息提醒', icon: 'success' })
+ }
+ return { ok: true, accepted }
+ // #endif
+
+ // #ifndef MP-WEIXIN
+ if (!silent) uni.showToast({ title: '请在微信小程序中开启消息提醒', icon: 'none' })
+ return { ok: false, reason: 'not_mp' }
+ // #endif
+}
diff --git a/uniapp/vite.config.js b/uniapp/vite.config.js
index 4004722..f719eeb 100644
--- a/uniapp/vite.config.js
+++ b/uniapp/vite.config.js
@@ -3,7 +3,25 @@ import uni from '@dcloudio/vite-plugin-uni'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
-const rootDir = path.dirname(fileURLToPath(import.meta.url))
+// 新版 Vite / uni 编译器会把配置文件预打包成 CJS 加载,此时 import.meta.url 为 undefined,
+// fileURLToPath(undefined) 会抛 "The path argument must be of type string. Received undefined"。
+// 这里做兜底:拿不到模块路径时退回 __dirname / 当前工作目录(构建命令均在 uniapp 目录执行)。
+function resolveRootDir() {
+ try {
+ const url = typeof import.meta !== 'undefined' ? import.meta.url : ''
+ if (url) return path.dirname(fileURLToPath(url))
+ } catch (e) {
+ /* import.meta.url 不可用时继续兜底 */
+ }
+ try {
+ if (typeof __dirname !== 'undefined') return __dirname
+ } catch (e) {
+ /* 忽略 */
+ }
+ return process.cwd()
+}
+
+const rootDir = resolveRootDir()
export default defineConfig({
base: '/h5/',