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