优化小程序部分

This commit is contained in:
hero920103 committed 2026-10-31 19:17:37 +08:00
1 parent 727319a6eb
commit d4e89788c5
46 files changed
+4906 -63

No files matched your search

+8
View File
@@ -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/*
+652
View File
@@ -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
}
// 格式: <md5> <path>
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"
}
+22
View File
@@ -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 派生。
# 注意:更换该值后,历史已保存的渠道参数将无法解密,需在各渠道配置页重新保存一次。
+196
View File
@@ -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, "没有可用的订阅:模板未配置,或你还没授权订阅消息")
}
+185
View File
@@ -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
}
+77
View File
@@ -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 不应通过")
}
}
+232
View File
@@ -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, "未发送:该用户没有授权订阅,或模板未配置/未启用")
}
+179
View File
@@ -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
}
@@ -0,0 +1,21 @@
-- 微信小程序多应用表补充「消息推送」配置字段
--
-- 对应小程序后台:开发管理 → 开发设置 → 消息推送
-- Token / EncodingAESKey / 消息加解密方式 / 数据格式
-- 平台端「小程序配置 - 编辑小程序 - 消息推送」维护,保存后把「回调地址」填到后台。
--
-- 注意:aes_key 以 AES-256-GCM 加密存储(复用 wechatmp 的密钥),与 AppSecret 同一套加密。
-- 执行:mysql -h <host> -P <port> -u <user> -p <db> < 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;
@@ -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 <host> -P <port> -u <user> -p <db> < 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;
@@ -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 <host> -P <port> -u <user> -p <db> < go/docs/sql/create_wechat_mini_template.sql
```
@@ -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=<签名>&timestamp=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 <host> -P <port> -u <user> -p <db> < go/docs/sql/alter_wechat_mini_app_push.sql
```
`aes_key` 以 AES-256-GCM 加密入库(复用公众号的 `wechat_mp_secret_key`),接口只回显掩码。
+94
View File
@@ -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"` 额外排除。
+3
View File
@@ -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),
+58 -6
View File
@@ -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" }
+194
View File
@@ -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
}
+124
View File
@@ -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("<html></html>"), 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())
}
}
+5
View File
@@ -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")
+3
View File
@@ -14,6 +14,9 @@ func Register() {
// 微信公众号(服务号):配置 / 绑定 / 粉丝 / 推送 / 微信服务器回调
RegisterWechatMpRoutes()
// 微信小程序:应用配置 / 消息推送回调 / 消息推送参数
RegisterWechatMiniRoutes()
// 数据字典(字典列表 / 字典项)
RegisterDictRoutes()
+32
View File
@@ -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")
}
+15
View File
@@ -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()
}
-7
View File
@@ -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")
}
+21
View File
@@ -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) != "" {
+117
View File
@@ -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())
}
+115
View File
@@ -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))
}
}
+22 -1
View File
@@ -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 为通知节点创建已读通知任务(不阻塞流程)
+15
View File
@@ -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
}
+27 -3
View File
@@ -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 列表查询
+438
View File
@@ -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(
`<xml><ToUserName><![CDATA[%s]]></ToUserName><FromUserName><![CDATA[%s]]></FromUserName><CreateTime>%d</CreateTime><MsgType><![CDATA[text]]></MsgType><Content><![CDATA[%s]]></Content></xml>`,
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(
`<xml><Encrypt><![CDATA[%s]]></Encrypt><MsgSignature><![CDATA[%s]]></MsgSignature><TimeStamp>%s</TimeStamp><Nonce><![CDATA[%s]]></Nonce></xml>`,
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)
}
+158
View File
@@ -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 := `<xml><ToUserName>wx123</ToUserName></xml>`
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 := `<xml><ToUserName>wxApp</ToUserName><FromUserName>oOpenId</FromUserName><CreateTime>1700000000</CreateTime><MsgType>text</MsgType><Content>你好</Content><MsgId>123456</MsgId></xml>`
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("<xml></xml>"); 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, "<Content><![CDATA[收到]]></Content>") {
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)
}
}
+488
View File
@@ -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
}
+128
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
1XXl8fPHr9
+161
View File
@@ -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` 即为公网可达。
+88
View File
@@ -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,
});
}
@@ -61,6 +61,13 @@
<span v-else class="empty-tip">未上传</span>
</template>
</el-table-column>
<el-table-column label="消息推送" width="100" align="center">
<template #default="scope">
<el-tag :type="scope.row.token ? 'success' : 'info'" size="small">
{{ scope.row.token ? "已配置" : "未配置" }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="启用" width="80" align="center">
<template #default="scope">
<el-switch
@@ -197,6 +204,157 @@
placeholder="选填"
/>
</el-form-item>
<!-- 消息推送配置(对应小程序后台「开发管理 → 开发设置 → 消息推送」) -->
<el-divider content-position="left">
<span class="divider-text">消息推送</span>
</el-divider>
<el-alert
type="info"
:closable="false"
show-icon
class="push-alert"
title="把下面的「回调地址 / Token / EncodingAESKey」原样填到小程序后台「开发管理 → 开发设置 → 消息推送」,加解密方式与数据格式需与后台保持一致。"
/>
<el-form-item label="回调地址">
<div class="callback-field">
<el-input
v-model="pushForm.callback_url"
readonly
placeholder="保存后由服务端生成"
/>
<el-button size="small" @click="copyText(pushForm.callback_url)">
复制
</el-button>
</div>
<div class="form-tip block">
必须以 http:// 或 https:// 开头(80 / 443 端口),且微信服务器能直接访问。
若显示为相对路径,请自行补上域名,如
<code>https://api.yunzer.cn/api/wechat/mini/callback</code>。
</div>
</el-form-item>
<el-form-item label="Token">
<div class="callback-field">
<el-input
v-model="pushForm.token"
placeholder="3-32 位字母数字"
maxlength="32"
/>
<el-button size="small" @click="generateSecrets">
随机生成
</el-button>
</div>
</el-form-item>
<el-form-item label="EncodingAESKey">
<el-input
v-model="pushForm.aes_key"
type="password"
show-password
:placeholder="
pushConfigured ? '已配置,留空 / 掩码表示不修改' : '43 位字母数字'
"
maxlength="43"
/>
<div class="form-tip block">
安全 / 兼容模式必填,与小程序后台填写的 EncodingAESKey 必须完全一致。
</div>
</el-form-item>
<el-form-item label="加解密方式">
<el-radio-group v-model="pushForm.encrypt_mode">
<el-radio value="plain">明文模式</el-radio>
<el-radio value="compatible">兼容模式</el-radio>
<el-radio value="safe">安全模式</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="数据格式">
<el-radio-group v-model="pushForm.data_format">
<el-radio value="xml">XML</el-radio>
<el-radio value="json">JSON</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="自动回复文案">
<el-input
v-model="pushForm.push_reply"
type="textarea"
:rows="2"
maxlength="500"
placeholder="选填。用户发来客服消息时,5 秒内自动回复该文案;留空表示只接收不回复。"
/>
</el-form-item>
<el-form-item>
<el-button
type="primary"
:loading="pushSaving"
:disabled="!form.id"
@click="handleSavePush"
>
保存消息推送配置
</el-button>
<span v-if="!form.id" class="form-tip">请先保存小程序,再配置消息推送</span>
</el-form-item>
<!-- 订阅消息模板 -->
<el-divider content-position="left">
<span class="divider-text">订阅消息模板</span>
</el-divider>
<el-alert
type="info"
:closable="false"
show-icon
class="push-alert"
title="要主动给用户发提示(审批待办、站内通知等),需要用「订阅消息」:先在小程序后台「功能 → 订阅消息 → 公共模板库」选一个模板,把模板 ID 和字段填到下面。"
/>
<el-table :data="templates" size="small" style="width: 100%">
<el-table-column prop="code" label="场景码" width="110" align="center" />
<el-table-column prop="title" label="场景名称" min-width="120" align="center">
<template #default="{ row }">{{ row.title || "-" }}</template>
</el-table-column>
<el-table-column
prop="template_id"
label="模板 ID"
min-width="200"
align="center"
show-overflow-tooltip
/>
<el-table-column label="启用" width="70" align="center">
<template #default="{ row }">
<el-tag :type="row.enabled === 1 ? 'success' : 'info'" size="small">
{{ row.enabled === 1 ? "启用" : "停用" }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="175" align="center">
<template #default="{ row }">
<el-button size="small" type="primary" link @click="openTemplate(row)">
编辑
</el-button>
<el-button size="small" type="success" link @click="sendTestTemplate(row)">
测试
</el-button>
<el-button size="small" type="danger" link @click="removeTemplate(row)">
删除
</el-button>
</template>
</el-table-column>
</el-table>
<el-button
size="small"
style="margin-top: 10px"
:disabled="!form.id"
@click="openTemplate()"
>
新增模板
</el-button>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
@@ -205,6 +363,65 @@
</el-button>
</template>
</el-dialog>
<!-- 订阅消息模板编辑对话框 -->
<el-dialog
v-model="templateDialogVisible"
title="订阅消息模板"
width="560px"
append-to-body
>
<el-form :model="templateForm" label-width="100px">
<el-form-item label="场景码">
<el-input
v-model="templateForm.code"
placeholder="如 notice(站内通知)"
:disabled="!!templateForm.id"
/>
<div class="form-tip block">
业务侧按场景码触发:notice = 站内信 / 审批待办等通用提醒
</div>
</el-form-item>
<el-form-item label="场景名称">
<el-input v-model="templateForm.title" placeholder="如 站内通知" maxlength="64" />
</el-form-item>
<el-form-item label="模板 ID">
<el-input
v-model="templateForm.template_id"
placeholder="小程序后台订阅消息模板的 ID"
/>
</el-form-item>
<el-form-item label="跳转页面">
<el-input
v-model="templateForm.page"
placeholder="如 pages/index/index(点击消息进入的小程序页面)"
/>
</el-form-item>
<el-form-item label="字段映射">
<el-input
v-model="templateForm.fields"
type="textarea"
:rows="3"
placeholder='{"thing1":"title","phrase2":"content","time3":"time"}'
/>
<div class="form-tip block">
左边是微信模板的字段名,右边是平台这边的变量名。可用变量:
<code>title</code>(标题)、<code>content</code>(内容)、<code>time</code>(时间)
</div>
</el-form-item>
<el-form-item label="启用">
<el-switch v-model="templateForm.enabled" :active-value="1" :inactive-value="0" />
</el-form-item>
<el-form-item label="备注">
<el-input v-model="templateForm.remark" type="textarea" :rows="2" maxlength="200" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="templateDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="templateSaving" @click="handleSaveTemplate">
保存
</el-button>
</template>
</el-dialog>
</div>
</template>
@@ -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;
+33
View File
@@ -1,5 +1,37 @@
<script>
import { getBaseURL } from '@/api/request.js'
import { isLoggedIn } from '@/utils/auth.js'
const LOGIN_PAGE = '/pages/login/login'
// 不需要登录即可停留的页面:登录 / 注册 / 忘记密码 / 人机验证 / 小程序码确认页 / 启动跳板页
const PUBLIC_ROUTES = [
'pages/login/login',
'pages/login/register',
'pages/login/forget',
'pages/login/geetest-webview',
'pages/register/confirm',
'pages/index/index'
]
/** 当前页面路由(取不到时返回空串,交给页面级检测兜底) */
function currentRoute() {
try {
const pages = getCurrentPages()
if (!pages.length) return ''
const cur = pages[pages.length - 1]
return (cur && (cur.route || (cur.$page && cur.$page.path))) || ''
} catch (e) {
return ''
}
}
/** 未登录一律回登录页:覆盖「切后台回来」「从其他入口直接进内页」等场景 */
function ensureLogin() {
const route = currentRoute()
if (!route || PUBLIC_ROUTES.indexOf(route) !== -1) return
if (isLoggedIn()) return
uni.reLaunch({ url: LOGIN_PAGE })
}
export default {
onLaunch() {
@@ -7,6 +39,7 @@
},
onShow() {
console.log('App Show')
ensureLogin()
},
onHide() {
console.log('App Hide')
+19
View File
@@ -11,6 +11,20 @@ const DEFAULT_PROD_API = 'https://api.yunzer.cn'
/**
* @returns {string} 去掉末尾斜杠的 baseURL
*/
/**
* 小程序端 baseURL 兜底:微信要求 request 必须走「已配置的 https 合法域名」,
* localhost / 127.0.0.1 / 相对路径 / http 明文在体验版与正式版都会直接报
* "url not in domain list"。出现这类取值一律回退生产 API(本地调试请用 H5 端,
* 或在开发者工具勾选「不校验合法域名」时用真机调试)。
*/
function sanitizeMiniProgramBase(base) {
const b = String(base || '')
if (!b || b.startsWith('/') || !/^https:\/\//i.test(b) || /(localhost|127\.0\.0\.1)/i.test(b)) {
return DEFAULT_PROD_API
}
return b
}
export function resolveBaseURL() {
let fromEnv = ''
try {
@@ -24,7 +38,12 @@ export function resolveBaseURL() {
}
if (fromEnv && fromEnv !== 'undefined' && fromEnv !== 'null') {
// #ifdef MP
return sanitizeMiniProgramBase(String(fromEnv))
// #endif
// #ifndef MP
return String(fromEnv).replace(/\/$/, '')
// #endif
}
// #ifdef H5
+41
View File
@@ -0,0 +1,41 @@
/**
* 小程序订阅消息(对应后端 go/controllers/api_mini_subscribe.go)
*
* 订阅消息是「用户授权 → 服务器主动推给用户」的通道:
* 1. getSubscribeTemplates() 取当前可订阅的模板
* 2. 用户在微信弹窗里同意后,authorizeSubscribe() 上报,服务器记一次发送机会
* 3. 之后业务侧发站内信/审批待办,服务器自动推送到用户微信
*/
import { request } from '@/api/request.js'
/** 取可订阅的模板列表(含模板 ID、跳转页面、剩余可发送次数) */
export function getSubscribeTemplates() {
return request({
url: '/api/mini/subscribe/templates',
method: 'get'
})
}
/**
* 上报授权结果:每授权一次,服务器记一次可发送机会
* @param {string[]} templateIds 用户同意的模板 ID 列表
*/
export function authorizeSubscribe(templateIds) {
return request({
url: '/api/mini/subscribe/authorize',
method: 'post',
data: { template_ids: templateIds }
})
}
/**
* 发一条测试订阅消息(验证模板配置是否正确)
* @param {{title?: string, content?: string}} payload
*/
export function sendTestSubscribeNotice(payload = {}) {
return request({
url: '/api/mini/subscribe/test',
method: 'post',
data: payload
})
}
+3 -4
View File
@@ -412,20 +412,19 @@
}
},
{
"path {
"path": "pages/login/forget",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "忘记密码"
}
},
{
},
{
"path": "pages/register/confirm",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "确认注册"
}
},
},
{
"path": "pages/login/geetest-webview",
"style": {
+20 -34
View File
@@ -1,52 +1,38 @@
<template>
<view class="content">
<image class="logo" src="/static/logo.png"></image>
<view class="text-area">
<text class="title">{{title}}</text>
</view>
<view class="boot">
<image class="boot-logo" src="/static/logo.png" mode="aspectFit"></image>
<text class="boot-text">正在进入…</text>
</view>
</template>
<script>
export default {
data() {
return {
title: 'Hello'
}
},
onLoad() {
import { onShow } from '@dcloudio/uni-app'
import { isLoggedIn } from '@/utils/auth.js'
},
methods: {
}
}
/**
* 启动跳板页:若启动入口指向本页(脚手架默认首页),先做登录检测再分流,
* 未登录直接进登录页,已登录进首页(pages/nav/nav)。
*/
onShow(() => {
uni.reLaunch({ url: isLoggedIn() ? '/pages/nav/nav' : '/pages/login/login' })
})
</script>
<style>
.content {
.boot {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100vh;
}
.logo {
height: 200rpx;
width: 200rpx;
margin-top: 200rpx;
margin-left: auto;
margin-right: auto;
margin-bottom: 50rpx;
.boot-logo {
width: 180rpx;
height: 180rpx;
margin-bottom: 32rpx;
}
.text-area {
display: flex;
justify-content: center;
}
.title {
font-size: 36rpx;
.boot-text {
font-size: 28rpx;
color: #8f8f94;
}
</style>
-5
View File
@@ -172,11 +172,6 @@
<text class="agree-link" @tap.stop="showAgreement">《隐私政策》</text>
</text>
</view>
<view class="foot-tip">
<FaIcon name="lock" color="#a3acbf" :size="11" />
<text class="foot-tip-text">统一身份认证 · 一次登录,全平台通行</text>
</view>
</view>
</template>
+6 -1
View File
@@ -170,7 +170,12 @@ onMounted(() => {
})
onShow(() => {
if (isLoggedIn() && !loading.value) load()
// 未登录(含后台切回来时令牌被清)直接回登录页,不再停留在首页
if (!isLoggedIn()) {
uni.reLaunch({ url: '/pages/login/login' })
return
}
if (!loading.value) load()
})
</script>
+6
View File
@@ -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' })
}
+84
View File
@@ -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
}
+19 -1
View File
@@ -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/',