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 27f059e..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 @@ -100,6 +115,22 @@ wechat_mp_secret_key = 8f3c2a1d94b74e28a6c5f0187d3e9b4c72a5d0e6f13b8c47d92a6e5f0 # 缺省回落 wechat_mp_callback_base / payment_callback_base;本地开发可填 http://127.0.0.1:4401(仅同机可点) # wechat_mp_login_base = https://back.yunzer.cn +# ==================== 微信小程序注册核验 ==================== +# 注册页「微信身份核验」优先用小程序码:网页展示小程序码 → 用户扫码在小程序内确认 → +# 网页继续注册(无需公众号)。小程序未配置或小程序码生成失败时自动回退公众号验证码 / 短信。 +# 小程序码指向的页面路径(该页面需在小程序已发布,不带前导 /): +# wechat_mini_register_page = pages/register/confirm +# 小程序码环境:release(正式版,默认)/ trial(体验版)/ develop(开发版,需开发者扫码) +# wechat_mini_register_env = release +wechat_mini_register_env = trial + +# ==================== 微信小程序消息推送 ==================== +# 消息推送回调地址基址:最终地址 = {基址}/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/auth/account.go b/go/controllers/auth/account.go index bfc4e10..9f7657f 100644 --- a/go/controllers/auth/account.go +++ b/go/controllers/auth/account.go @@ -7,7 +7,9 @@ import ( "strings" "time" + "server/models" "server/services" + "server/services/wechatmini" "server/services/wechatmp" beego "github.com/beego/beego/v2/server/web" @@ -101,16 +103,24 @@ func (c *AuthAccountController) SendRegisterCode() { func (c *AuthAccountController) WechatStart() { scene, qrURL, notice, expireAt, err := wechatmp.StartRegisterVerify(wechatmp.DefaultVerifyTTL) if err != nil { - c.serveJSON(map[string]interface{}{"code": 400, "msg": err.Error()}) + // 公众号未配置 / 未启用:仍回传「关注二维码」,用户可扫码进公众号发送「注册」取码 + c.serveJSON(map[string]interface{}{ + "code": 400, + "msg": "微信扫码核验暂不可用,请改用手机号验证码注册", + "data": map[string]interface{}{"follow_qrcode": wechatmp.FollowQrcodeURL()}, + }) return } c.serveJSON(map[string]interface{}{ "code": 200, "data": map[string]interface{}{ - "scene": scene, - "qr_url": qrURL, - "notice": notice, - "expire_at": expireAt.Format(time.RFC3339), + "scene": scene, + "qr_url": qrURL, + // 带参二维码生成失败(IP 白名单 / 未认证 48001 等)时前端改展示关注二维码, + // 用户扫码关注后在公众号内发送「注册」照样能拿到验证码 + "follow_qrcode": wechatmp.FollowQrcodeURL(), + "notice": notice, + "expire_at": expireAt.Format(time.RFC3339), }, }) } @@ -133,11 +143,54 @@ func (c *AuthAccountController) WechatStatus() { }) } +// WechatMiniStart 账号注册:获取小程序码(扫码后在小程序内确认注册)。 +// 小程序未配置 / 小程序码生成失败时返回 400 + reason=mini_unavailable, +// 前端据此回退公众号验证码或短信通道(详细错误只落服务端日志)。 +// GET /auth/register/mini/start +func (c *AuthAccountController) WechatMiniStart() { + scene, qrURL, expireAt, err := wechatmini.StartRegisterVerify(wechatmini.DefaultRegisterTTL) + if err != nil { + c.serveJSON(map[string]interface{}{ + "code": 400, + "msg": "微信扫码核验暂不可用,请改用手机号验证码注册", + "reason": "mini_unavailable", + }) + return + } + c.serveJSON(map[string]interface{}{ + "code": 200, + "data": map[string]interface{}{ + "scene": scene, + "qr_url": qrURL, + "expire_at": expireAt.Format(time.RFC3339), + }, + }) +} + +// WechatMiniStatus 账号注册:轮询小程序端确认状态(展示「已确认」) +// GET /auth/register/mini/status?scene=xxx +func (c *AuthAccountController) WechatMiniStatus() { + scene := strings.TrimSpace(c.GetString("scene")) + confirmed, expireAt, err := wechatmini.GetRegisterStatus(scene) + if err != nil { + c.serveJSON(map[string]interface{}{"code": 400, "msg": err.Error()}) + return + } + c.serveJSON(map[string]interface{}{ + "code": 200, + "data": map[string]interface{}{ + "confirmed": confirmed, + "expire_at": expireAt.Format(time.RFC3339), + }, + }) +} + // RegisterVerify 第一步核验:验证码通过即创建统一身份,返回 register_token // POST /auth/register/verify // // mode=phone { mode, phone, code, password, confirm } // mode=account { mode, account, scene, code, password, confirm } +// mode=mini { mode, account, scene, password, confirm } // scene 为小程序码会话,已在小程序内确认 func (c *AuthAccountController) RegisterVerify() { var req struct { Mode string `json:"mode"` @@ -153,22 +206,35 @@ func (c *AuthAccountController) RegisterVerify() { } openID := "" - if req.Mode == "account" { + unionID := "" + provider := "" + switch req.Mode { + case "account": // 核销服务号验证码,得到扫码人 openid(注册完成后绑定微信登录): - // 有 scene 走二维码会话核销;无 scene(公众号发送「注册」)按验证码认领 + // 先按 scene 核销二维码会话,未扫码(用户改走公众号内发送「注册」)时按验证码认领。 + // 账号注册不依赖手机号,服务号无法按手机号关联,故不以「是否已扫码」为前置条件。 var err error - if strings.TrimSpace(req.Scene) != "" { - openID, err = wechatmp.ConfirmRegisterVerify(req.Scene, req.Code) - } else { - openID, err = wechatmp.ConfirmRegisterVerifyByCode(req.Code) - } + openID, err = wechatmp.ConfirmAccountRegisterVerify(req.Scene, req.Code) if err != nil { c.serveJSON(map[string]interface{}{"code": 400, "msg": err.Error()}) return } + provider = models.IdPWechat + case "mini": + // 核销小程序注册会话:扫码人已在小程序内用 wx.login 确认,取 openid/unionid + oid, uid, _, cerr := wechatmini.ConsumeRegister(req.Scene) + if cerr != nil { + c.serveJSON(map[string]interface{}{"code": 400, "msg": cerr.Error()}) + return + } + openID = oid + unionID = uid + provider = models.IdPWechatMini } - identity, token, err := services.VerifyAndCreateIdentity(services.RegisterVerifyInput{ + // 身份推迟到第二步归属操作时创建(services.CompleteRegister), + // 第一步只签发待注册令牌,避免超时未完成留下占用账号的半成品身份 + token, err := services.VerifyAndIssueRegisterToken(services.RegisterVerifyInput{ Mode: req.Mode, Phone: req.Phone, Account: req.Account, @@ -176,6 +242,8 @@ func (c *AuthAccountController) RegisterVerify() { Password: req.Password, Confirm: req.Confirm, OpenID: openID, + UnionID: unionID, + Provider: provider, }) if err != nil { c.serveJSON(map[string]interface{}{"code": 400, "msg": err.Error()}) @@ -184,7 +252,7 @@ func (c *AuthAccountController) RegisterVerify() { c.serveJSON(map[string]interface{}{ "code": 200, "msg": "验证通过", - "data": map[string]interface{}{"register_token": token, "identity_id": identity.ID}, + "data": map[string]interface{}{"register_token": token}, }) } @@ -218,7 +286,14 @@ func (c *AuthAccountController) ApplyJoin() { if !c.readJSON(&req) { return } - identityID, openID, err := services.PeekRegisterToken(req.Token) + // 第二步归属操作开始:此刻才真正创建身份(核验通过 ≠ 已建号) + if strings.TrimSpace(req.Token) == "" { + c.serveJSON(map[string]interface{}{ + "code": 400, "msg": "注册会话已失效:未携带注册令牌,请返回第一步重新完成身份核验", + }) + return + } + identityID, err := services.CompleteRegister(req.Token) if err != nil { c.serveJSON(map[string]interface{}{"code": 400, "msg": err.Error()}) return @@ -229,7 +304,6 @@ func (c *AuthAccountController) ApplyJoin() { return } services.ConsumeRegisterToken(req.Token) - services.BindRegisterWechat(identityID, openID) c.serveJSON(map[string]interface{}{ "code": 200, @@ -258,7 +332,14 @@ func (c *AuthAccountController) ApplyCreate() { if !c.readJSON(&req) { return } - identityID, openID, err := services.PeekRegisterToken(req.Token) + // 第二步归属操作开始:此刻才真正创建身份(核验通过 ≠ 已建号) + if strings.TrimSpace(req.Token) == "" { + c.serveJSON(map[string]interface{}{ + "code": 400, "msg": "注册会话已失效:未携带注册令牌,请返回第一步重新完成身份核验", + }) + return + } + identityID, err := services.CompleteRegister(req.Token) if err != nil { c.serveJSON(map[string]interface{}{"code": 400, "msg": err.Error()}) return @@ -277,7 +358,6 @@ func (c *AuthAccountController) ApplyCreate() { return } services.ConsumeRegisterToken(req.Token) - services.BindRegisterWechat(identityID, openID) c.serveJSON(map[string]interface{}{ "code": 200, @@ -298,13 +378,18 @@ func (c *AuthAccountController) FinishSkip() { if !c.readJSON(&req) { return } - identityID, openID, err := services.PeekRegisterToken(req.Token) - if err != nil { + // 第二步归属操作开始:此刻才真正创建身份(核验通过 ≠ 已建号) + if strings.TrimSpace(req.Token) == "" { + c.serveJSON(map[string]interface{}{ + "code": 400, "msg": "注册会话已失效:未携带注册令牌,请返回第一步重新完成身份核验", + }) + return + } + if _, err := services.CompleteRegister(req.Token); err != nil { c.serveJSON(map[string]interface{}{"code": 400, "msg": err.Error()}) return } services.ConsumeRegisterToken(req.Token) - services.BindRegisterWechat(identityID, openID) c.serveJSON(map[string]interface{}{ "code": 200, diff --git a/go/controllers/auth/wechat_miniapp.go b/go/controllers/auth/wechat_miniapp.go index 5879089..4dc59c8 100644 --- a/go/controllers/auth/wechat_miniapp.go +++ b/go/controllers/auth/wechat_miniapp.go @@ -187,6 +187,45 @@ func (c *AuthWechatMiniController) Login() { c.serveJSON(resp) } +// RegisterConfirm 小程序端确认注册(网页注册页展示的小程序码扫码进入) +// POST /auth/register/mini/confirm +// +// { "scene": "小程序码携带的会话标识", "code": "wx.login的code", "phone_code": "可选", "appid": "可选,多小程序" } +// +// 无登录态:scene + code 即凭证,服务端用 code 换 openid 后标记该会话已确认, +// 网页端轮询到确认后继续填写账号密码;手机号快速验证为可选项(失败不阻断)。 +func (c *AuthWechatMiniController) RegisterConfirm() { + var req struct { + Scene string `json:"scene"` + Code string `json:"code"` + PhoneCode string `json:"phone_code"` + AppID string `json:"appid"` + } + body := c.Ctx.Input.RequestBody + if len(body) == 0 { + body, _ = io.ReadAll(c.Ctx.Request.Body) + } + if err := json.Unmarshal(body, &req); err != nil { + c.fail("", 400, "参数错误") + return + } + req.Scene = strings.TrimSpace(req.Scene) + if req.Scene == "" { + c.fail("", 400, "缺少会话标识,请重新扫码") + return + } + if err := wechatmini.ConfirmRegister(req.Scene, req.Code, req.PhoneCode, req.AppID); err != nil { + msg := err.Error() + if hint, ok := wechatmini.ExplainError(err); ok { + msg = hint + } + logs.Warn("[wechat-mini] 注册确认失败 scene=%s: %v", req.Scene, err) + c.fail(MiniReasonWechatAPI, 400, msg) + return + } + c.serveJSON(map[string]interface{}{"code": 200, "msg": "确认成功,请回到电脑继续完成注册"}) +} + // Bind 把当前小程序 openid 绑定到已登录身份 // POST /auth/wechat/miniapp/bind { "code": "wx.login的code" } Header: Bearer 令牌 // diff --git a/go/controllers/platform_wechat_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/controllers/platform_wechat_mp.go b/go/controllers/platform_wechat_mp.go index b2e94a7..9ab0e0b 100644 --- a/go/controllers/platform_wechat_mp.go +++ b/go/controllers/platform_wechat_mp.go @@ -163,20 +163,30 @@ func (c *PlatformWechatMpController) TestConnection() { c.jsonErr(400, 400, "请先保存 AppID 与 AppSecret") return } + // 出口 IP 一并返回:40164(IP 不在白名单)时管理员可直接复制去微信后台配置 + outboundIP := wechatmp.OutboundIP() token, err := wechatmp.GetAccessToken(cfg) if err != nil { msg := "连接失败:" + err.Error() if hint, ok := wechatmp.ExplainError(err); ok { msg += ";" + hint } + if outboundIP != "" { + msg += ";本服务器出口 IP:" + outboundIP + } c.jsonErr(400, 400, msg) return } + msg := "连接成功:AppID 与 AppSecret 校验通过" + if outboundIP != "" { + msg += "(本服务器出口 IP:" + outboundIP + ")" + } c.ok(map[string]interface{}{ "access_token_suffix": wechatmp.MaskSecret(token), "callback_url": wechatmp.CallbackURL(), "verified": boolToInt8(cfg.Verified), - }, "连接成功:AppID 与 AppSecret 校验通过") + "outbound_ip": outboundIP, + }, msg) } // ============================ 绑定(当前平台用户) ============================ diff --git a/go/docs/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/cleanup_pending_register_identity.sql b/go/docs/sql/cleanup_pending_register_identity.sql new file mode 100644 index 0000000..382dc00 --- /dev/null +++ b/go/docs/sql/cleanup_pending_register_identity.sql @@ -0,0 +1,27 @@ +-- 清理「注册未完成」的残留身份(历史遗留)。 +-- 旧版本在第一步核验通过时就建号,第二步企业归属超时未完成时, +-- 账号名/手机号被占用却无法使用,重新注册会提示「该账号已被占用」。 +-- 新版本已做到:第二步才建号 + 令牌过期回滚 + 校验时自动回收孤儿身份, +-- 本脚本仅用于手动清理历史残留(注意:account / mobile 有唯一索引, +-- 软删仍然占着索引,因此这里用物理删除)。 +-- +-- ① 先查:确认这些身份确实没企业归属、也没登录过 +SELECT i.id, i.account, i.mobile, i.nickname, i.create_time +FROM yz_auth_identity i +WHERE i.last_login_at IS NULL + AND NOT EXISTS ( + SELECT 1 FROM yz_auth_tenant_user t + WHERE t.identity_id = i.id AND t.delete_time IS NULL + ) + AND NOT EXISTS ( + SELECT 1 FROM yz_auth_tenant_apply a + WHERE a.identity_id = i.id AND a.delete_time IS NULL + ); + +-- ② 再清:把 '账号名' 换成第①步查到的账号(手机号注册则改用 mobile = '手机号') +DELETE FROM yz_auth_identity_third +WHERE identity_id IN ( + SELECT id FROM (SELECT id FROM yz_auth_identity WHERE account = '账号名') x +); + +DELETE FROM yz_auth_identity WHERE account = '账号名'; diff --git a/go/docs/sql/create_auth_mini_register.sql b/go/docs/sql/create_auth_mini_register.sql new file mode 100644 index 0000000..7071244 --- /dev/null +++ b/go/docs/sql/create_auth_mini_register.sql @@ -0,0 +1,21 @@ +-- 微信小程序扫码注册核验会话: +-- PC 注册页发起会话并展示小程序码(scene)→ 用户微信扫码进入小程序「注册确认」页 → +-- 小程序端 wx.login 拿 code(可选手机号快速验证)调 /auth/register/mini/confirm → +-- 服务端 code2session 得 openid/unionid 写入本表 → PC 端轮询到已确认后继续注册, +-- 第一步核验(mode=mini)按 scene 核销并取出 openid(注册完成后绑定小程序登录)。 +CREATE TABLE IF NOT EXISTS `yz_auth_mini_register` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `scene` VARCHAR(64) NOT NULL COMMENT '注册会话标识(小程序码 scene)', + `openid` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '确认用户的小程序 openid', + `unionid` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '确认用户的 unionid(同主体多端互通)', + `mobile` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '手机号快速验证拿到的手机号(可选)', + `app_id` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '确认时所用的小程序 AppID(多应用场景)', + `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0待确认 1已确认 2已核销 3已过期', + `expire_at` DATETIME NOT NULL COMMENT '过期时间', + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `update_time` DATETIME DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_scene` (`scene`), + KEY `idx_openid` (`openid`), + KEY `idx_expire_at` (`expire_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='微信小程序扫码注册核验会话'; diff --git a/go/docs/sql/create_auth_register_session.sql b/go/docs/sql/create_auth_register_session.sql new file mode 100644 index 0000000..359930a --- /dev/null +++ b/go/docs/sql/create_auth_register_session.sql @@ -0,0 +1,25 @@ +-- 统一认证:注册会话(第一步核验 → 第二步企业归属) +-- 之前注册会话只存在进程内存里,服务重启 / 多副本部署 / 填表超时都会导致 +-- 「注册会话已失效,请重新注册」。改为落库后: +-- ① 重启、多实例共享都不丢;② 有效期可放宽到 2 小时(填企业资料+上传执照足够); +-- ③ 身份仍在第二步才创建,超时/放弃时不会留下占用账号的半成品身份。 +CREATE TABLE IF NOT EXISTS `yz_auth_register_session` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `token` VARCHAR(64) NOT NULL COMMENT '注册令牌(第一步核验通过后签发)', + `account` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '账号名(账号 / 小程序注册;短信注册为空)', + `mobile` VARCHAR(20) NOT NULL DEFAULT '' COMMENT '手机号(短信注册)', + `nickname` VARCHAR(64) NOT NULL DEFAULT '', + `password_hash` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '待注册密码哈希(argon2id PHC 格式)', + `password_algo` VARCHAR(16) NOT NULL DEFAULT 'argon2id', + `openid` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '核验得到的微信 openid', + `unionid` VARCHAR(64) NOT NULL DEFAULT '', + `provider` VARCHAR(32) NOT NULL DEFAULT '' COMMENT 'wechat(公众号) / wechat_mini(小程序)', + `identity_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '第二步建号后写回;0 = 尚未建号', + `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0待完成 1已完成', + `expire_at` DATETIME NOT NULL COMMENT '过期时间', + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `update_time` DATETIME DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_token` (`token`), + KEY `idx_expire_at` (`expire_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='统一认证-注册会话'; diff --git a/go/docs/sql/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/sql/fix_tenant_apply_bind.sql b/go/docs/sql/fix_tenant_apply_bind.sql new file mode 100644 index 0000000..d10d705 --- /dev/null +++ b/go/docs/sql/fix_tenant_apply_bind.sql @@ -0,0 +1,82 @@ +-- 修复:注册「创建企业」审批通过后,申请人登录提示「该账号未绑定任何企业」。 +-- +-- 两个叠在一起的 bug: +-- ① finishApply 没把新建租户 tid 回写到申请单 → yz_auth_tenant_apply.tid 一直是 0, +-- 事后无法按 tid 追溯/修数据(代码已修复)。 +-- ② 绑定时调用 SyncTenantUser 按「手机/邮箱/账号」归并键匹配身份,账号注册的身份 +-- 没有手机号、邮箱,企业内账号又是拼出来的 user{id},匹配不到 → 新建了一个身份 +-- (account=user{id})并绑定到新租户,申请人自己的身份反而没有绑定记录 +-- (代码已改为按 identity_id 直接写绑定)。 +-- 本脚本修正【已审批的历史数据】,下列语句均可直接执行,无需替换占位符。 + +-- ============ ① 回填申请单的 tid(按企业名称匹配新建的租户) ============ +UPDATE yz_auth_tenant_apply a +JOIN yz_system_tenant t + ON t.tenant_name = a.tenant_name AND t.delete_time IS NULL +SET a.tid = t.id, a.update_time = NOW() +WHERE a.type = 2 AND a.status = 1 AND a.delete_time IS NULL + AND (a.tid = 0 OR a.tid IS NULL); + +-- ============ ② 排查:申请 / 身份 / 绑定 / 租户对照 ============ +SELECT a.id AS apply_id, a.status AS apply_status, a.tid, a.tenant_name, + a.identity_id AS apply_identity, ai.account AS apply_account, ai.mobile AS apply_mobile, + tu.id AS bind_id, tu.identity_id AS bind_identity, bi.account AS bind_account, + tu.status AS bind_status, tu.delete_time AS bind_deleted, tu.group_id, + t.status AS tenant_status +FROM yz_auth_tenant_apply a +LEFT JOIN yz_auth_identity ai ON ai.id = a.identity_id +LEFT JOIN yz_auth_tenant_user tu ON tu.tid = a.tid +LEFT JOIN yz_auth_identity bi ON bi.id = tu.identity_id +LEFT JOIN yz_system_tenant t ON t.id = a.tid +WHERE a.delete_time IS NULL +ORDER BY a.id DESC +LIMIT 20; +-- 关注:bind_identity 是否 = apply_identity;bind_status=1;bind_deleted=NULL;tenant_status=1 + +-- ============ ③ 修正绑错人的行(改指到申请人,并复位状态) ============ +UPDATE yz_auth_tenant_user tu +JOIN yz_auth_tenant_apply a + ON a.tid = tu.tid AND a.type = 2 AND a.status = 1 AND a.delete_time IS NULL +SET tu.identity_id = a.identity_id, tu.status = 1, tu.is_default = 1, + tu.delete_time = NULL, tu.update_time = NOW() +WHERE tu.identity_id <> a.identity_id; + +-- ============ ④ 修正「绑对人但状态异常」的行(status≠1 或已软删) ============ +UPDATE yz_auth_tenant_user tu +JOIN yz_auth_tenant_apply a + ON a.tid = tu.tid AND a.type = 2 AND a.status = 1 AND a.delete_time IS NULL +SET tu.status = 1, tu.is_default = 1, tu.delete_time = NULL, tu.update_time = NOW() +WHERE tu.identity_id = a.identity_id + AND (tu.status <> 1 OR tu.delete_time IS NOT NULL); + +-- ============ ⑤ 兜底:申请人压根没有绑定行时补一条 ============ +INSERT INTO yz_auth_tenant_user + (tid, identity_id, account, name, phone, email, group_id, org_id, is_default, status, create_time) +SELECT a.tid, a.identity_id, + COALESCE(NULLIF(i.mobile, ''), NULLIF(i.account, ''), CONCAT('user', a.identity_id)), + COALESCE(i.nickname, ''), i.mobile, i.email, + 0, 0, 1, 1, NOW() +FROM yz_auth_tenant_apply a +JOIN yz_auth_identity i ON i.id = a.identity_id +WHERE a.type = 2 AND a.status = 1 AND a.tid > 0 AND a.delete_time IS NULL + AND NOT EXISTS ( + SELECT 1 FROM yz_auth_tenant_user tu + WHERE tu.tid = a.tid AND tu.identity_id = a.identity_id + ); + +-- ============ ⑥ 核对:申请人名下应能看到企业 ============ +SELECT tu.identity_id, tu.tid, tu.status AS bind_status, tu.is_default, tu.group_id, + t.tenant_name, t.status AS tenant_status +FROM yz_auth_tenant_user tu +JOIN yz_system_tenant t ON t.id = tu.tid +JOIN yz_auth_tenant_apply a + ON a.tid = tu.tid AND a.type = 2 AND a.status = 1 AND a.identity_id = tu.identity_id; + +-- ============ ⑦ 可选:清理审批时误建的身份(确认无任何绑定后再执行) ============ +-- SELECT id, account, mobile, last_login_at FROM yz_auth_identity +-- WHERE account LIKE 'user%' AND last_login_at IS NULL +-- AND NOT EXISTS (SELECT 1 FROM yz_auth_tenant_user tu WHERE tu.identity_id = yz_auth_identity.id); +-- DELETE i FROM yz_auth_identity i +-- WHERE i.account LIKE 'user%' AND i.last_login_at IS NULL +-- AND NOT EXISTS (SELECT 1 FROM yz_auth_tenant_user tu WHERE tu.identity_id = i.id) +-- AND NOT EXISTS (SELECT 1 FROM yz_auth_identity_third th WHERE th.identity_id = i.id); diff --git a/go/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/auth_identity.go b/go/models/auth_identity.go index 277ba26..af11ee7 100644 --- a/go/models/auth_identity.go +++ b/go/models/auth_identity.go @@ -46,12 +46,13 @@ func (m *AuthIdentity) TableName() string { // 第三方登录 provider 常量 const ( - IdPWechat = "wechat" - IdPDingTalk = "dingtalk" - IdPFeishu = "feishu" - IdPQQ = "qq" - IdPGitHub = "github" - IdPGoogle = "google" + IdPWechat = "wechat" + IdPWechatMini = "wechat_mini" + IdPDingTalk = "dingtalk" + IdPFeishu = "feishu" + IdPQQ = "qq" + IdPGitHub = "github" + IdPGoogle = "google" ) // AuthIdentityThird 第三方身份绑定:yz_auth_identity_third diff --git a/go/models/auth_mini_register.go b/go/models/auth_mini_register.go new file mode 100644 index 0000000..782ce04 --- /dev/null +++ b/go/models/auth_mini_register.go @@ -0,0 +1,34 @@ +package models + +import "time" + +// ============================================================= +// 微信小程序扫码注册核验会话 yz_auth_mini_register +// (建表脚本:docs/sql/create_auth_mini_register.sql) +// PC 注册页发起会话并展示小程序码 → 用户扫码在小程序内确认(wx.login)→ +// 服务端写入 openid/unionid → PC 端轮询到已确认后继续注册,核验时按 scene 核销。 +// ============================================================= + +// 会话状态 +const ( + MiniRegisterStatusWaiting = 0 // 待确认(已生成小程序码) + MiniRegisterStatusConfirmed = 1 // 已确认(小程序端已上报 openid) + MiniRegisterStatusUsed = 2 // 已核销(注册第一步已取用) + MiniRegisterStatusExpired = 3 // 已过期 +) + +// AuthMiniRegister 微信小程序扫码注册核验会话 +type AuthMiniRegister struct { + ID uint64 `orm:"column(id);pk;auto" json:"id"` + Scene string `orm:"column(scene);size(64)" json:"scene"` + OpenID string `orm:"column(openid);size(64)" json:"openid"` + UnionID string `orm:"column(unionid);size(64)" json:"unionid"` + Mobile string `orm:"column(mobile);size(32)" json:"mobile"` + AppID string `orm:"column(app_id);size(64)" json:"app_id"` + Status int8 `orm:"column(status)" json:"status"` + ExpireAt time.Time `orm:"column(expire_at);type(datetime)" json:"expire_at"` + CreateTime time.Time `orm:"column(create_time);type(datetime);auto_now_add" json:"create_time"` + UpdateTime *time.Time `orm:"column(update_time);type(datetime);null" json:"update_time"` +} + +func (m *AuthMiniRegister) TableName() string { return "yz_auth_mini_register" } diff --git a/go/models/auth_register_session.go b/go/models/auth_register_session.go new file mode 100644 index 0000000..e294b8f --- /dev/null +++ b/go/models/auth_register_session.go @@ -0,0 +1,38 @@ +package models + +import "time" + +// ============================================================= +// 注册会话 yz_auth_register_session +// (建表脚本:docs/sql/create_auth_register_session.sql) +// 第一步核验通过 → 写入本表并签发 token(此时尚未创建身份); +// 第二步企业归属时按 token 取出、建号并写回 identity_id;业务成功后删除。 +// 落库而非放内存:服务重启 / 多副本部署都不会让用户的注册流程中途失效。 +// ============================================================= + +// 注册会话状态 +const ( + RegisterSessionPending = 0 // 待完成(第二步归属未提交) + RegisterSessionDone = 1 // 已完成 +) + +// AuthRegisterSession 注册会话 +type AuthRegisterSession struct { + ID uint64 `orm:"column(id);pk;auto" json:"id"` + Token string `orm:"column(token);size(64)" json:"-"` + Account string `orm:"column(account);size(64)" json:"account"` + Mobile string `orm:"column(mobile);size(20)" json:"mobile"` + Nickname string `orm:"column(nickname);size(64)" json:"nickname"` + PasswordHash string `orm:"column(password_hash);size(255)" json:"-"` + PasswordAlgo string `orm:"column(password_algo);size(16)" json:"password_algo"` + OpenID string `orm:"column(openid);size(64)" json:"openid"` + UnionID string `orm:"column(unionid);size(64)" json:"unionid"` + Provider string `orm:"column(provider);size(32)" json:"provider"` + IdentityID uint64 `orm:"column(identity_id)" json:"identity_id"` + Status int8 `orm:"column(status)" json:"status"` + ExpireAt time.Time `orm:"column(expire_at);type(datetime)" json:"expire_at"` + CreateTime time.Time `orm:"column(create_time);type(datetime);auto_now_add" json:"create_time"` + UpdateTime *time.Time `orm:"column(update_time);type(datetime);null" json:"update_time"` +} + +func (m *AuthRegisterSession) TableName() string { return "yz_auth_register_session" } diff --git a/go/models/init.go b/go/models/init.go index 5e6cbce..eeae871 100644 --- a/go/models/init.go +++ b/go/models/init.go @@ -237,10 +237,18 @@ func Init(_ string) { new(WechatMpLoginToken), new(WechatMpScanLogin), + // 微信小程序扫码注册核验会话(docs/sql/create_auth_mini_register.sql) + new(AuthMiniRegister), + // 注册会话(第一步核验 → 第二步企业归属,docs/sql/create_auth_register_session.sql) + new(AuthRegisterSession), + // 微信小程序配置(docs/sql/create_wechat_mini_config.sql) new(WechatMiniConfig), // 微信小程序多应用配置(docs/sql/create_wechat_mini_app.sql) 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/auth/auth.go b/go/routers/auth/auth.go index 3786215..4876e5d 100644 --- a/go/routers/auth/auth.go +++ b/go/routers/auth/auth.go @@ -52,6 +52,10 @@ func Register() { beego.Router("/auth/register/verify", &authctl.AuthAccountController{}, "post:RegisterVerify") beego.Router("/auth/register/wechat/start", &authctl.AuthAccountController{}, "get:WechatStart") beego.Router("/auth/register/wechat/status", &authctl.AuthAccountController{}, "get:WechatStatus") + // 账号注册的微信核验(小程序方案):小程序码扫码 → 小程序内确认 → 网页继续 + beego.Router("/auth/register/mini/start", &authctl.AuthAccountController{}, "get:WechatMiniStart") + beego.Router("/auth/register/mini/status", &authctl.AuthAccountController{}, "get:WechatMiniStatus") + beego.Router("/auth/register/mini/confirm", &authctl.AuthWechatMiniController{}, "post:RegisterConfirm") beego.Router("/auth/register/tenant/search", &authctl.AuthAccountController{}, "get:TenantSearch") beego.Router("/auth/register/apply-join", &authctl.AuthAccountController{}, "post:ApplyJoin") beego.Router("/auth/register/apply-create", &authctl.AuthAccountController{}, "post:ApplyCreate") diff --git a/go/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/identity_register.go b/go/services/identity_register.go index aab910c..e99cc4b 100644 --- a/go/services/identity_register.go +++ b/go/services/identity_register.go @@ -7,7 +7,6 @@ import ( "fmt" "regexp" "strings" - "sync" "time" "server/models" @@ -15,6 +14,7 @@ import ( authsvc "server/services/auth" "github.com/beego/beego/v2/client/orm" + "github.com/beego/beego/v2/core/logs" "github.com/google/uuid" ) @@ -30,30 +30,67 @@ import ( // - 暂不加入:只完成身份注册。 // ============================================================= -// registerTokenTTL register_token 有效期 -const registerTokenTTL = 10 * time.Minute +// registerTokenTTL register_token 有效期(覆盖第一步核验到第二步企业归属的完整操作: +// 填企业资料 + 上传营业执照可能较久,给 2 小时;会话已落库,服务重启也不会丢) +const registerTokenTTL = 2 * time.Hour -// registerTokenItem 一次性注册令牌:身份已创建,等待第二步归属操作 +// registerTokenItem 一次性注册令牌: +// 第一步核验通过时只保存「待注册信息」,身份尚未创建; +// 第二步归属操作调用 CompleteRegister 时才真正建号并写回 IdentityID。 +// 这样令牌过期(或用户放弃)时不会有「已占用账号但没完成注册」的残留身份。 type registerTokenItem struct { - IdentityID uint64 - OpenID string // 账号注册模式扫码人的微信 openid(用于注册后绑定微信登录) - ExpiredAt time.Time + IdentityID uint64 // 第二步完成建号后写回;0 = 尚未建号 + Account string // 账号注册 / 小程序核验的账号名 + Mobile string // 手机号注册的手机号 + Nickname string + PasswordHash string + PasswordAlgo string + OpenID string // 核验扫码人的微信 openid(公众号 / 小程序,用于注册后绑定微信登录) + UnionID string // 小程序核验时的 unionid(同主体多端互通,可为空) + Provider string // models.IdPWechat(公众号) / models.IdPWechatMini(小程序) + ExpiredAt time.Time } -var registerTokenStore sync.Map // key: token +// RegisterPending 第一步核验结果(尚未落库为身份) +type RegisterPending struct { + Account string + Mobile string + Nickname string + PasswordHash string + PasswordAlgo string + OpenID string + UnionID string + Provider string +} -// IssueRegisterToken 身份创建成功后签发注册令牌 -func IssueRegisterToken(identityID uint64, openid string) string { +// IssuePendingRegisterToken 保存第一步核验结果并签发待注册令牌(此时尚未创建身份)。 +// 会话落库(yz_auth_register_session):服务重启 / 多副本部署都不会中途失效。 +func IssuePendingRegisterToken(p RegisterPending) string { b := make([]byte, 24) if _, err := rand.Read(b); err != nil { - return fmt.Sprintf("rt%d%d", identityID, time.Now().UnixNano()) + return fmt.Sprintf("rt%d%d", time.Now().UnixNano(), time.Now().UnixNano()) } token := hex.EncodeToString(b) - registerTokenStore.Store(token, registerTokenItem{ - IdentityID: identityID, - OpenID: openid, - ExpiredAt: time.Now().Add(registerTokenTTL), - }) + now := time.Now() + row := &models.AuthRegisterSession{ + Token: token, + Account: strings.TrimSpace(p.Account), + Mobile: strings.TrimSpace(p.Mobile), + Nickname: strings.TrimSpace(p.Nickname), + PasswordHash: p.PasswordHash, + PasswordAlgo: p.PasswordAlgo, + OpenID: strings.TrimSpace(p.OpenID), + UnionID: strings.TrimSpace(p.UnionID), + Provider: p.Provider, + Status: models.RegisterSessionPending, + ExpireAt: now.Add(registerTokenTTL), + CreateTime: now, + UpdateTime: &now, + } + if _, err := models.Orm.Insert(row); err != nil { + logs.Error("[register] 保存注册会话失败: %v", err) + return "" + } return token } @@ -63,43 +100,53 @@ func ConsumeRegisterToken(token string) (identityID uint64, openid string, err e if err != nil { return 0, "", err } - registerTokenStore.Delete(strings.TrimSpace(token)) + _, _ = models.Orm.QueryTable(new(models.AuthRegisterSession)). + Filter("token", strings.TrimSpace(token)). + Delete() return identityID, openid, nil } // PeekRegisterToken 查看注册令牌(不消费);第二步先 Peek 校验, // 业务成功后再 Consume,避免申请失败导致令牌作废无法重试。 func PeekRegisterToken(token string) (identityID uint64, openid string, err error) { + identityID, openid, _, _, err = PeekRegisterTokenThird(token) + return identityID, openid, err +} + +// PeekRegisterTokenThird 同 PeekRegisterToken,额外返回 unionid 与第三方来源 +// (公众号 / 小程序),供第二步按来源绑定对应 provider。 +func PeekRegisterTokenThird(token string) (identityID uint64, openid, unionid, provider string, err error) { token = strings.TrimSpace(token) if token == "" { - return 0, "", errors.New("注册会话已失效,请重新注册") + return 0, "", "", "", errors.New("注册会话已失效,请重新注册") } - v, ok := registerTokenStore.Load(token) - if !ok { - return 0, "", errors.New("注册会话已失效,请重新注册") + var row models.AuthRegisterSession + if err = models.Orm.QueryTable(new(models.AuthRegisterSession)). + Filter("token", token).One(&row); err != nil { + return 0, "", "", "", errors.New("注册会话已失效,请重新注册") } - item, ok := v.(registerTokenItem) - if !ok { - return 0, "", errors.New("注册会话已失效,请重新注册") + if time.Now().After(row.ExpireAt) { + // 令牌过期:若期间已建号但没走完归属,回滚,避免留下占用账号的半成品 + rollbackRegisterIdentity(row.IdentityID) + _, _ = models.Orm.QueryTable(new(models.AuthRegisterSession)).Filter("token", token).Delete() + return 0, "", "", "", errors.New("注册会话已过期,请重新注册") } - if time.Now().After(item.ExpiredAt) { - registerTokenStore.Delete(token) - return 0, "", errors.New("注册会话已过期,请重新注册") - } - return item.IdentityID, item.OpenID, nil + return row.IdentityID, row.OpenID, row.UnionID, row.Provider, nil } // ---------------- 第一步:核验并创建身份 ---------------- // RegisterVerifyInput 第一步核验入参 type RegisterVerifyInput struct { - Mode string // phone / account + Mode string // phone / account / mini Phone string // mode=phone:手机号 - Account string // mode=account:自定义账号 - Code string // 验证码(短信 或 服务号下发) + Account string // mode=account|mini:自定义账号 + Code string // 验证码(短信 或 服务号下发);mode=mini 不使用 Password string Confirm string - OpenID string // mode=account:核销服务号验证码得到的 openid + OpenID string // 核验得到的微信 openid(公众号 或 小程序) + UnionID string // mode=mini:小程序 unionid(可为空) + Provider string // 微信来源:models.IdPWechat / models.IdPWechatMini } // validateAccountName 账号名规则:4-32 位,字母开头,字母/数字/下划线/中划线 @@ -132,68 +179,207 @@ func findIdentityByAccountName(account string) *models.AuthIdentity { return &m } -// VerifyAndCreateIdentity 第一步:校验验证码与密码,创建统一身份,返回身份与新令牌。 -// 调用方保证:mode=phone 已发短信验证码;mode=account 已核销服务号验证码并传入 openid。 -func VerifyAndCreateIdentity(in RegisterVerifyInput) (*models.AuthIdentity, string, error) { +// VerifyAndIssueRegisterToken 第一步:校验验证码与密码、检查账号唯一性,签发待注册令牌。 +// 注意:此处【不创建身份】——身份推迟到第二步归属操作(CompleteRegister)才落库, +// 避免「核验通过但第二步超时未完成」留下占用账号却无法使用的半成品账号。 +// 调用方保证:mode=phone 已发短信验证码;mode=account 已核销服务号验证码并传入 openid; +// mode=mini 已核销小程序注册会话并传入小程序 openid(Provider=wechat_mini)。 +func VerifyAndIssueRegisterToken(in RegisterVerifyInput) (string, error) { in.Mode = strings.TrimSpace(in.Mode) - if in.Mode != "phone" && in.Mode != "account" { - return nil, "", errors.New("请选择注册方式") + if in.Mode != "phone" && in.Mode != "account" && in.Mode != "mini" { + return "", errors.New("请选择注册方式") } if len(strings.TrimSpace(in.Password)) < 6 { - return nil, "", errors.New("密码长度不能少于6个字符") + return "", errors.New("密码长度不能少于6个字符") } if in.Password != in.Confirm { - return nil, "", errors.New("两次输入的密码不一致") + return "", errors.New("两次输入的密码不一致") } - identity := &models.AuthIdentity{ - UnionID: strings.ReplaceAll(uuid.NewString(), "-", ""), + pending := RegisterPending{ + OpenID: strings.TrimSpace(in.OpenID), + UnionID: strings.TrimSpace(in.UnionID), + Provider: in.Provider, } switch in.Mode { case "phone": phone := strings.TrimSpace(in.Phone) if !IsMobile(phone) { - return nil, "", errors.New("请输入正确的手机号") + return "", errors.New("请输入正确的手机号") } if err := VerifyIdentityCode("register", phone, in.Code); err != nil { - return nil, "", err + return "", err } - if findIdentityByMobile(phone) != nil { - return nil, "", errors.New("该手机号已注册,请直接登录") + if exist := findIdentityByMobile(phone); exist != nil { + // 孤儿身份(注册未完成、从未登录、无企业归属)自动回收,账号/手机号可重新注册 + if !isOrphanIdentity(exist) { + return "", errors.New("该手机号已注册,请直接登录") + } + discardOrphanIdentity(exist.ID) } - identity.Mobile = &phone - nick := "用户" + phone[len(phone)-4:] - identity.Nickname = &nick - case "account": + pending.Mobile = phone + pending.Nickname = "用户" + phone[len(phone)-4:] + case "account", "mini": account := strings.TrimSpace(in.Account) if err := validateAccountName(account); err != nil { - return nil, "", err + return "", err } - if findIdentityByAccountName(account) != nil { - return nil, "", errors.New("该账号已被占用,请换一个") + if exist := findIdentityByAccountName(account); exist != nil { + // 同上:回收「核验通过但没走完第二步」留下的孤儿身份 + if !isOrphanIdentity(exist) { + return "", errors.New("该账号已被注册,请直接登录,或换一个账号名") + } + discardOrphanIdentity(exist.ID) } - identity.Account = &account - nick := account - identity.Nickname = &nick + if in.Mode == "mini" && strings.TrimSpace(in.OpenID) == "" { + return "", errors.New("微信核验已失效,请重新扫码确认") + } + pending.Account = account + pending.Nickname = account } hashed, err := passwordutil.Hash(strings.TrimSpace(in.Password)) if err != nil { - return nil, "", errors.New("密码处理失败") + return "", errors.New("密码处理失败") } - identity.PasswordHash = &hashed - identity.PasswordAlgo = passwordutil.AlgoOf(hashed) - identity.Status = models.AuthIdentityStatusEnabled + pending.PasswordHash = hashed + pending.PasswordAlgo = passwordutil.AlgoOf(hashed) + token := IssuePendingRegisterToken(pending) + if token == "" { + return "", errors.New("注册会话创建失败,请稍后重试") + } + return token, nil +} + +// CompleteRegister 第二步归属操作开始时真正创建身份: +// 取出第一步核验结果 → 复核唯一性 → 建号 → 绑定微信(若有)→ 写回令牌。 +// 令牌此时不消费(业务失败可重试);令牌过期时由 rollbackRegisterIdentity 清理半成品身份。 +func CompleteRegister(token string) (uint64, error) { + tk := strings.TrimSpace(token) + var row models.AuthRegisterSession + if err := models.Orm.QueryTable(new(models.AuthRegisterSession)). + Filter("token", tk).One(&row); err != nil { + return 0, errors.New("注册会话已失效,请重新注册") + } + if time.Now().After(row.ExpireAt) { + rollbackRegisterIdentity(row.IdentityID) + _, _ = models.Orm.QueryTable(new(models.AuthRegisterSession)).Filter("token", tk).Delete() + return 0, errors.New("注册会话已过期,请重新注册") + } + if row.IdentityID != 0 { + return row.IdentityID, nil // 已建号(重试场景),直接复用 + } + + // 建号前复核:核验后到这一步之间可能被他人抢注;孤儿身份仍可回收 + if row.Account != "" { + if exist := findIdentityByAccountName(row.Account); exist != nil { + if !isOrphanIdentity(exist) { + return 0, errors.New("该账号已被注册,请直接登录,或换一个账号名") + } + discardOrphanIdentity(exist.ID) + } + } + if row.Mobile != "" { + if exist := findIdentityByMobile(row.Mobile); exist != nil { + if !isOrphanIdentity(exist) { + return 0, errors.New("该手机号已注册,请直接登录") + } + discardOrphanIdentity(exist.ID) + } + } + + identity := &models.AuthIdentity{ + UnionID: strings.ReplaceAll(uuid.NewString(), "-", ""), + PasswordAlgo: row.PasswordAlgo, + PasswordHash: &row.PasswordHash, + Status: models.AuthIdentityStatusEnabled, + } + if row.Account != "" { + account := row.Account + identity.Account = &account + } + if row.Mobile != "" { + mobile := row.Mobile + identity.Mobile = &mobile + } + if row.Nickname != "" { + nick := row.Nickname + identity.Nickname = &nick + } id, err := models.Orm.Insert(identity) if err != nil { - return nil, "", errors.New("注册失败,请稍后重试") + return 0, errors.New("注册失败,请稍后重试") } identity.ID = uint64(id) + BindRegisterThird(identity.ID, row.OpenID, row.UnionID, row.Provider) - token := IssueRegisterToken(identity.ID, in.OpenID) - return identity, token, nil + now := time.Now() + _, _ = models.Orm.QueryTable(new(models.AuthRegisterSession)). + Filter("id", row.ID). + Update(map[string]interface{}{"identity_id": identity.ID, "update_time": now}) + return identity.ID, nil +} + +// isOrphanIdentity 判定「注册未完成」的孤儿身份:从未登录成功、没有任何企业归属、 +// 也没有待审批的加入/创建企业申请。这类身份占用账号名(或手机号)却无法使用, +// 回收后同名账号可正常重新注册。 +func isOrphanIdentity(m *models.AuthIdentity) bool { + if m == nil || m.ID == 0 { + return false + } + if m.LastLoginAt != nil { + return false + } + var tu models.AuthTenantUser + if err := models.Orm.QueryTable(new(models.AuthTenantUser)). + Filter("identity_id", m.ID). + Filter("delete_time__isnull", true). + One(&tu); err == nil { + return false + } + var ta models.AuthTenantApply + if err := models.Orm.QueryTable(new(models.AuthTenantApply)). + Filter("identity_id", m.ID). + Filter("delete_time__isnull", true). + One(&ta); err == nil { + return false + } + return true +} + +// discardOrphanIdentity 物理删除孤儿身份及其第三方绑定。 +// 用物理删而非软删:account/mobile 上有唯一索引,软删行仍占着索引会导致同名无法重新注册。 +// 调用方必须先用 isOrphanIdentity 确认该身份没有任何关联数据。 +func discardOrphanIdentity(identityID uint64) { + if identityID == 0 { + return + } + _, _ = models.Orm.QueryTable(new(models.AuthIdentityThird)). + Filter("identity_id", identityID). + Delete() + _, _ = models.Orm.QueryTable(new(models.AuthIdentity)). + Filter("id", identityID). + Delete() +} + +// rollbackRegisterIdentity 令牌过期 / 放弃注册时清理已建但未完成归属的身份, +// 防止占用账号名却没有企业归属的「半成品账号」(同名账号可正常重新注册)。 +func rollbackRegisterIdentity(identityID uint64) { + if identityID == 0 { + return + } + var m models.AuthIdentity + if err := models.Orm.QueryTable(new(models.AuthIdentity)). + Filter("id", identityID).One(&m); err != nil { + return + } + // 已产生企业归属或申请(业务已成功)时绝不能删 + if !isOrphanIdentity(&m) { + return + } + discardOrphanIdentity(identityID) } // ---------------- 第二步:企业归属 ---------------- @@ -558,17 +744,23 @@ func getPendingApply(applyID uint64, applyType int8, tid uint64) (*models.AuthTe // finishApply CAS 置申请终态,防止并发重复审批 func finishApply(apply *models.AuthTenantApply, status int8, reason string, reviewerID uint64, reviewerName string) error { now := time.Now() + update := map[string]interface{}{ + "status": status, + "reject_reason": reason, + "reviewer_id": reviewerID, + "reviewer_name": reviewerName, + "review_time": now, + "update_time": now, + } + // 创建企业审批通过时新建的租户必须回写到申请单(否则申请记录里 tid 一直为 0, + // 事后无法按 tid 追溯/修数据) + if apply.Tid > 0 { + update["tid"] = apply.Tid + } affected, err := models.Orm.QueryTable(new(models.AuthTenantApply)). Filter("id", apply.ID). Filter("status", models.TenantApplyStatusPending). - Update(map[string]interface{}{ - "status": status, - "reject_reason": reason, - "reviewer_id": reviewerID, - "reviewer_name": reviewerName, - "review_time": now, - "update_time": now, - }) + Update(update) if err != nil { return err } @@ -622,22 +814,66 @@ func bindIdentityToTenant(identity *models.AuthIdentity, tid uint64, rolePicker Update(map[string]interface{}{"group_id": roleID}) } - if err := authsvc.SyncTenantUser(authsvc.SyncTenantUserInput{ - Tid: tid, - Account: account, - Name: name, - Phone: applyStrVal(identity.Mobile), - Email: applyStrVal(identity.Email), - PasswordHash: applyStrVal(identity.PasswordHash), - GroupID: groupID, - Status: 1, - IsDefault: 0, - }); err != nil { - return errors.New("认证中心同步失败:" + err.Error()) + if err := upsertAuthTenantBind(identity, tid, account, name, groupID); err != nil { + return errors.New("认证中心绑定失败:" + err.Error()) } return nil } +// upsertAuthTenantBind 按已知身份 ID 直接写认证中心绑定表 yz_auth_tenant_user +// (登录时的企业列表读的就是这张表)。 +// +// 不能用 SyncTenantUser 的「账号/手机/邮箱归并键」来匹配:账号注册的身份没有手机号、 +// 邮箱,企业内账号又是拼出来的 user{id},归并键匹配不到既有身份,会新建一个空身份并 +// 绑定到新租户,申请人自己的身份反而没有绑定 → 登录后提示「该账号未绑定任何企业」。 +func upsertAuthTenantBind(identity *models.AuthIdentity, tid uint64, account, name string, groupID uint64) error { + if identity == nil || identity.ID == 0 || tid == 0 { + return errors.New("参数错误") + } + now := time.Now() + var existed models.AuthTenantUser + if err := models.Orm.QueryTable(new(models.AuthTenantUser)). + Filter("tid", tid). + Filter("identity_id", identity.ID). + One(&existed); err == nil { + _, uerr := models.Orm.QueryTable(new(models.AuthTenantUser)). + Filter("id", existed.ID). + Update(map[string]interface{}{ + "account": account, + "name": name, + "phone": identity.Mobile, + "email": identity.Email, + "group_id": groupID, + "status": 1, + "update_time": now, + }) + return uerr + } + item := &models.AuthTenantUser{ + Tid: tid, + IdentityID: identity.ID, + GroupID: groupID, + Status: 1, + IsDefault: 1, // 申请人是该企业的第一个成员,设为默认企业 + CreateTime: now, + UpdateTime: &now, + } + if account != "" { + item.Account = &account + } + if name != "" { + item.Name = &name + } + if identity.Mobile != nil { + item.Phone = identity.Mobile + } + if identity.Email != nil { + item.Email = identity.Email + } + _, err := models.Orm.Insert(item) + return err +} + // applyStrVal 取指针字符串值(nil 返回空串) func applyStrVal(s *string) string { if s == nil { @@ -674,16 +910,25 @@ func genUniqueTenantCode() (string, error) { return "", errors.New("生成租户编码失败,请稍后重试") } -// BindRegisterWechat 注册完成后把服务号 openid 绑到新身份(账号注册模式)。 +// BindRegisterWechat 注册完成后把服务号 openid 绑到新身份(公众号核验模式)。 // openid 已绑定其他身份时不抢占,静默跳过。 func BindRegisterWechat(identityID uint64, openid string) { + BindRegisterThird(identityID, openid, "", models.IdPWechat) +} + +// BindRegisterThird 注册完成后把微信身份绑到新身份。 +// provider 区分公众号(wechat)与小程序(wechat_mini);openid 已绑定时不抢占,静默跳过。 +func BindRegisterThird(identityID uint64, openid, unionid, provider string) { openid = strings.TrimSpace(openid) if openid == "" || identityID == 0 { return } + if provider == "" { + provider = models.IdPWechat + } var row models.AuthIdentityThird err := models.Orm.QueryTable(new(models.AuthIdentityThird)). - Filter("provider", models.IdPWechat). + Filter("provider", provider). Filter("open_id", openid). One(&row) if err == nil { @@ -692,9 +937,13 @@ func BindRegisterWechat(identityID uint64, openid string) { if err != orm.ErrNoRows { return } - _, _ = models.Orm.Insert(&models.AuthIdentityThird{ + item := &models.AuthIdentityThird{ IdentityID: identityID, - Provider: models.IdPWechat, + Provider: provider, OpenID: openid, - }) + } + if uid := strings.TrimSpace(unionid); uid != "" { + item.UnionID = &uid + } + _, _ = models.Orm.Insert(item) } diff --git a/go/services/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/api.go b/go/services/wechatmini/api.go index 7050e7e..51330db 100644 --- a/go/services/wechatmini/api.go +++ b/go/services/wechatmini/api.go @@ -2,11 +2,14 @@ package wechatmini import ( "bytes" + "encoding/base64" "encoding/json" + "errors" "fmt" "io" "net/http" "net/url" + "strings" "sync" "time" ) @@ -138,6 +141,65 @@ func GetPhoneNumber(cfg *Config, phoneCode string) (*PhoneNumber, error) { }, nil } +// ============================ 小程序码 ============================ + +// CreateUnlimitedQRCode 生成小程序码(wxa/getwxacodeunlimited,数量无限制), +// 返回图片 base64 data URL,可直接用于 。 +// +// scene 会话标识,扫码后作为 options.scene 传给目标页面,1-32 个字符 +// page 小程序页面路径(如 pages/register/confirm),必须已发布,不带前导 / +// envVersion release(正式版,默认)/ trial(体验版)/ develop(开发版) +func CreateUnlimitedQRCode(cfg *Config, scene, page, envVersion string) (string, error) { + if cfg == nil || cfg.AppID == "" || cfg.AppSecret == "" { + return "", ErrNotConfigured + } + page = strings.TrimPrefix(strings.TrimSpace(page), "/") + if page == "" { + return "", errors.New("未配置小程序码页面路径") + } + scene = strings.TrimSpace(scene) + if scene == "" || len(scene) > 32 { + return "", errors.New("小程序码 scene 无效(需 1-32 个字符)") + } + envVersion = strings.TrimSpace(envVersion) + if envVersion == "" { + envVersion = "release" + } + token, err := getAccessToken(cfg) + if err != nil { + return "", err + } + payload := map[string]interface{}{ + "scene": scene, + "page": page, + "env_version": envVersion, + "width": 280, + } + // 体验版 / 开发版下该页面尚未发布到正式版,必须关闭路径校验, + // 否则微信返回 40066 invalid url(正式版可保留默认校验) + if envVersion != "release" { + payload["check_path"] = false + } + api := fmt.Sprintf("%s/wxa/getwxacodeunlimited?access_token=%s", apiBase, url.QueryEscape(token)) + body, contentType, err := httpPostBinary(api, payload) + if err != nil { + return "", err + } + // 失败时微信仍返回 200,但响应体是 JSON 错误而非图片 + if strings.HasPrefix(strings.TrimSpace(string(body)), "{") { + var resp struct { + ErrCode int `json:"errcode"` + ErrMsg string `json:"errmsg"` + } + _ = json.Unmarshal(body, &resp) + return "", &APIError{API: "生成小程序码", Code: resp.ErrCode, Msg: resp.ErrMsg} + } + if contentType == "" { + contentType = "image/jpeg" + } + return "data:" + contentType + ";base64," + base64.StdEncoding.EncodeToString(body), nil +} + // ============================ access_token 缓存 ============================ type tokenEntry struct { @@ -226,6 +288,24 @@ func httpPostJSON(api string, payload interface{}, out interface{}) error { return nil } +// httpPostBinary POST JSON 并返回原始响应体与 Content-Type(小程序码接口返回图片流) +func httpPostBinary(api string, payload interface{}) ([]byte, string, error) { + bs, err := json.Marshal(payload) + if err != nil { + return nil, "", err + } + resp, err := httpClient.Post(api, "application/json; charset=utf-8", bytes.NewReader(bs)) + if err != nil { + return nil, "", fmt.Errorf("请求微信接口失败: %w", err) + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, "", fmt.Errorf("读取微信响应失败: %w", err) + } + return body, resp.Header.Get("Content-Type"), nil +} + func truncate(s string, n int) string { if len(s) <= n { return s diff --git a/go/services/wechatmini/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/register.go b/go/services/wechatmini/register.go new file mode 100644 index 0000000..44cd3ba --- /dev/null +++ b/go/services/wechatmini/register.go @@ -0,0 +1,244 @@ +package wechatmini + +import ( + "crypto/rand" + "errors" + "strconv" + "strings" + "time" + + "github.com/beego/beego/v2/client/orm" + "github.com/beego/beego/v2/core/logs" + beego "github.com/beego/beego/v2/server/web" + + "server/models" +) + +// ============================================================= +// 注册「账号注册」身份核验(小程序方案): +// 网页发起会话 → 展示小程序码(scene)→ 用户微信扫码进入小程序「注册确认」页 → +// 小程序端 wx.login 拿 code(可选手机号快速验证)调 confirm 上报 openid → +// 网页轮询到「已确认」后继续填写账号密码 → 第一步核验按 scene 核销取 openid, +// 注册完成后把该 openid 绑定到新身份(provider=wechat_mini),即可用小程序一键登录。 +// ============================================================= + +const ( + // DefaultRegisterTTL 注册会话有效期 + DefaultRegisterTTL = 10 * time.Minute + // RegisterSceneLen 会话 scene 长度(微信限制 scene 不超过 32 字符) + RegisterSceneLen = 20 + // registerSceneChars scene 字符集(只用小写字母与数字,避免扫码参数转义问题) + registerSceneChars = "abcdefghijklmnopqrstuvwxyz0123456789" +) + +var ( + ErrRegisterSceneInvalid = errors.New("注册会话不存在或已过期,请重新获取二维码") + ErrRegisterNotConfirmed = errors.New("请先用微信扫码并在小程序内完成确认") + ErrRegisterSceneUsed = errors.New("该二维码已完成核验,请重新获取") +) + +// RegisterPagePath 小程序码指向的页面(app.conf:wechat_mini_register_page,默认 pages/register/confirm) +func RegisterPagePath() string { + page, _ := beego.AppConfig.String("wechat_mini_register_page") + page = strings.TrimPrefix(strings.TrimSpace(page), "/") + if page == "" { + return "pages/register/confirm" + } + return page +} + +// RegisterEnvVersion 小程序码环境(app.conf:wechat_mini_register_env,默认 release; +// 未发布的小程序可填 trial 指向体验版、develop 指向开发版) +func RegisterEnvVersion() string { + env, _ := beego.AppConfig.String("wechat_mini_register_env") + env = strings.ToLower(strings.TrimSpace(env)) + switch env { + case "trial", "develop": + return env + } + return "release" +} + +// StartRegisterVerify 创建注册会话并生成小程序码(返回 data URL 可直接展示)。 +// 小程序未配置 / 小程序码生成失败时返回 err,由上层回退公众号或短信通道。 +func StartRegisterVerify(ttl time.Duration) (scene, qrURL string, expireAt time.Time, err error) { + cfg, err := LoadEnabledConfig() + if err != nil { + return "", "", time.Time{}, err + } + if ttl <= 0 { + ttl = DefaultRegisterTTL + } + scene = randomScene(RegisterSceneLen) + now := time.Now() + expireAt = now.Add(ttl) + row := &models.AuthMiniRegister{ + Scene: scene, + AppID: cfg.AppID, + Status: models.MiniRegisterStatusWaiting, + ExpireAt: expireAt, + CreateTime: now, + UpdateTime: &now, + } + if _, err = models.Orm.Insert(row); err != nil { + return "", "", time.Time{}, err + } + qrURL, err = CreateUnlimitedQRCode(cfg, scene, RegisterPagePath(), RegisterEnvVersion()) + if err != nil { + logs.Warning("[wechat-mini] 生成注册小程序码失败(页面 %s / 环境 %s):%v", + RegisterPagePath(), RegisterEnvVersion(), err) + return "", "", time.Time{}, err + } + return scene, qrURL, expireAt, nil +} + +// GetRegisterStatus 查询会话是否已被小程序端确认(过期惰性标记) +func GetRegisterStatus(scene string) (confirmed bool, expireAt time.Time, err error) { + row, err := loadRegister(scene) + if err != nil { + return false, time.Time{}, err + } + expireAt = row.ExpireAt + if row.Status == models.MiniRegisterStatusWaiting && expireAt.Before(time.Now()) { + markRegisterExpired(row) + return false, expireAt, nil + } + return row.Status == models.MiniRegisterStatusConfirmed, expireAt, nil +} + +// ConfirmRegister 小程序端确认注册:code → openid/unionid(可选手机号),CAS 标记已确认。 +// appID 为空时取第一条启用的小程序配置;多小程序场景由小程序端传入自身 AppID。 +func ConfirmRegister(scene, code, phoneCode, appID string) error { + scene = strings.TrimSpace(scene) + code = strings.TrimSpace(code) + if scene == "" || code == "" { + return errors.New("参数不完整") + } + cfg, err := loadConfigByAppID(strings.TrimSpace(appID)) + if err != nil { + return err + } + session, err := Code2Session(cfg, code) + if err != nil { + logs.Warning("[wechat-mini] 注册确认 code 换取 openid 失败: %v", err) + return err + } + // 手机号快速验证为可选项:未开通 / code 失效时不阻断确认 + mobile := "" + if strings.TrimSpace(phoneCode) != "" { + phone, perr := GetPhoneNumber(cfg, strings.TrimSpace(phoneCode)) + if perr != nil { + logs.Warning("[wechat-mini] 注册确认获取手机号失败: %v", perr) + } else if phone != nil { + mobile = strings.TrimSpace(phone.PurePhoneNumber) + if mobile == "" { + mobile = strings.TrimSpace(phone.PhoneNumber) + } + } + } + + row, err := loadRegister(scene) + if err != nil { + return err + } + if row.ExpireAt.Before(time.Now()) { + markRegisterExpired(row) + return ErrRegisterSceneInvalid + } + now := time.Now() + affected, err := models.Orm.QueryTable(new(models.AuthMiniRegister)). + Filter("id", row.ID). + Filter("status", models.MiniRegisterStatusWaiting). + Update(map[string]interface{}{ + "openid": session.OpenID, + "unionid": session.UnionID, + "mobile": mobile, + "app_id": cfg.AppID, + "status": models.MiniRegisterStatusConfirmed, + "update_time": now, + }) + if err != nil { + return err + } + if affected == 0 { + return errors.New("该二维码已确认或已失效,请重新获取") + } + return nil +} + +// ConsumeRegister 核销会话并取出确认结果(一次性,供注册第一步核验使用) +func ConsumeRegister(scene string) (openid, unionid, mobile string, err error) { + row, err := loadRegister(scene) + if err != nil { + return "", "", "", err + } + if row.ExpireAt.Before(time.Now()) { + markRegisterExpired(row) + return "", "", "", ErrRegisterSceneInvalid + } + switch row.Status { + case models.MiniRegisterStatusWaiting: + return "", "", "", ErrRegisterNotConfirmed + case models.MiniRegisterStatusUsed: + return "", "", "", ErrRegisterSceneUsed + case models.MiniRegisterStatusExpired: + return "", "", "", ErrRegisterSceneInvalid + } + now := time.Now() + affected, err := models.Orm.QueryTable(new(models.AuthMiniRegister)). + Filter("id", row.ID). + Filter("status", models.MiniRegisterStatusConfirmed). + Update(map[string]interface{}{"status": models.MiniRegisterStatusUsed, "update_time": now}) + if err != nil { + return "", "", "", err + } + if affected == 0 { + return "", "", "", ErrRegisterSceneUsed + } + return row.OpenID, row.UnionID, row.Mobile, nil +} + +// loadRegister 按 scene 读取会话 +func loadRegister(scene string) (*models.AuthMiniRegister, error) { + scene = strings.TrimSpace(scene) + if scene == "" { + return nil, ErrRegisterSceneInvalid + } + var row models.AuthMiniRegister + if err := models.Orm.QueryTable(new(models.AuthMiniRegister)).Filter("scene", scene).One(&row); err != nil { + if err == orm.ErrNoRows { + return nil, ErrRegisterSceneInvalid + } + return nil, err + } + return &row, nil +} + +// markRegisterExpired 惰性标记过期 +func markRegisterExpired(row *models.AuthMiniRegister) { + now := time.Now() + _, _ = models.Orm.QueryTable(new(models.AuthMiniRegister)). + Filter("id", row.ID). + Filter("status", models.MiniRegisterStatusWaiting). + Update(map[string]interface{}{"status": models.MiniRegisterStatusExpired, "update_time": now}) +} + +// loadConfigByAppID 按 AppID 选择小程序配置,缺省取第一条启用配置 +func loadConfigByAppID(appID string) (*Config, error) { + if appID != "" { + return LoadEnabledAppByAppID(appID) + } + return LoadEnabledConfig() +} + +func randomScene(n int) string { + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + // 极低概率:退化为时间戳兜底,保证 scene 非空且合法 + return "s" + strconv.FormatInt(time.Now().UnixNano(), 10) + } + for i := range b { + b[i] = registerSceneChars[int(b[i])%len(registerSceneChars)] + } + return string(b) +} diff --git a/go/services/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/services/wechatmp/api.go b/go/services/wechatmp/api.go index c6f5e02..dc7c537 100644 --- a/go/services/wechatmp/api.go +++ b/go/services/wechatmp/api.go @@ -6,9 +6,15 @@ import ( "errors" "fmt" "io" + "net" "net/http" "net/url" + "regexp" + "strings" + "sync" "time" + + "github.com/beego/beego/v2/core/logs" ) // ============================================================= @@ -48,6 +54,106 @@ func (e *APIError) Explain() string { return "" } +// ============================ 出口 IP 探测(40164 IP 白名单排查用) ============================ + +var ( + outboundIPMu sync.Mutex + outboundIPCache string + outboundIPAt time.Time +) + +// outboundIPTTL 出口 IP 缓存时长(探测失败也按此负缓存,避免每次请求都外网探测) +const outboundIPTTL = 5 * time.Minute + +// ipDetectServices 出口 IP 探测服务,按顺序尝试,任一返回 IPv4 即采用 +var ipDetectServices = []string{"https://ifconfig.me/ip", "https://api.ipify.org", "https://myip.ipip.net"} + +var ipv4Re = regexp.MustCompile(`\d{1,3}(?:\.\d{1,3}){3}`) + +// OutboundIP 返回本服务器访问公网时的出口 IP(微信后台「IP白名单」需填入该 IP)。 +// 结果内存缓存 5 分钟;服务器无法访问探测服务时返回空串,不影响主流程。 +func OutboundIP() string { + outboundIPMu.Lock() + if time.Since(outboundIPAt) < outboundIPTTL { + ip := outboundIPCache + outboundIPMu.Unlock() + return ip + } + outboundIPMu.Unlock() + + ip := detectOutboundIP() + outboundIPMu.Lock() + outboundIPCache = ip + outboundIPAt = time.Now() + outboundIPMu.Unlock() + return ip +} + +func detectOutboundIP() string { + client := &http.Client{Timeout: 3 * time.Second} + for _, svc := range ipDetectServices { + resp, err := client.Get(svc) + if err != nil { + continue + } + body, rerr := io.ReadAll(io.LimitReader(resp.Body, 256)) + resp.Body.Close() + if rerr != nil { + continue + } + if ip := firstIPv4(string(body)); ip != "" { + return ip + } + } + return "" +} + +// firstIPv4 取文本中第一个合法 IPv4(形如 1.2.3.4),无则返回空串。 +func firstIPv4(s string) string { + m := ipv4Re.FindString(s) + if m == "" || net.ParseIP(m) == nil { + return "" + } + return m +} + +// ErrMsgIPs 从微信 errmsg 中提取 IP(如 "invalid ip 1.2.3.4 ipv6 ::ffff:1.2.3.4")。 +func ErrMsgIPs(errmsg string) []string { + var out []string + seen := make(map[string]bool) + for _, m := range ipv4Re.FindAllString(errmsg, -1) { + if net.ParseIP(m) == nil || seen[m] { + continue + } + seen[m] = true + out = append(out, m) + } + return out +} + +// logAPIError 记录微信接口错误详情: +// errcode / errmsg / 微信提示的 IP / 本服务器出口 IP / 排查建议只落服务端日志。 +// 注册页、登录页等匿名可访问页面不应回显这些信息(暴露服务器出口 IP 与内部错误)。 +func logAPIError(scene string, err error) { + if err == nil { + return + } + detail := err.Error() + var ae *APIError + if errors.As(err, &ae) && ae != nil { + if hint := ae.Explain(); hint != "" { + detail += ";" + hint + } + if ips := ErrMsgIPs(ae.Msg); len(ips) > 0 { + detail += ";微信提示的 IP:" + strings.Join(ips, ", ") + } + } + if ip := OutboundIP(); ip != "" { + detail += ";本服务器出口 IP:" + ip + } + logs.Warning("[wechat-mp] %s 失败:%s", scene, detail) +} + // ExplainError 提取微信接口错误的中文排查建议;无可用建议时返回 ("", false)。 func ExplainError(err error) (string, bool) { var ae *APIError @@ -84,7 +190,9 @@ func GetAccessToken(cfg *Config) (string, error) { return "", err } if resp.AccessToken == "" { - return "", &APIError{API: "获取 access_token", Code: resp.ErrCode, Msg: resp.ErrMsg} + apiErr := &APIError{API: "获取 access_token", Code: resp.ErrCode, Msg: resp.ErrMsg} + logAPIError("获取 access_token", apiErr) + return "", apiErr } ttl := time.Duration(resp.ExpiresIn) * time.Second if ttl <= tokenMinTTL { @@ -125,7 +233,9 @@ func CreateQRCode(cfg *Config, scene string, expireSeconds int) (string, error) return "", err } if resp.Ticket == "" { - return "", &APIError{API: "创建带参二维码", Code: resp.ErrCode, Msg: resp.ErrMsg} + apiErr := &APIError{API: "创建带参二维码", Code: resp.ErrCode, Msg: resp.ErrMsg} + logAPIError("创建带参二维码", apiErr) + return "", apiErr } return "https://mp.weixin.qq.com/cgi-bin/showqrcode?ticket=" + url.QueryEscape(resp.Ticket), nil } diff --git a/go/services/wechatmp/register.go b/go/services/wechatmp/register.go index 1c13e8f..36b0fba 100644 --- a/go/services/wechatmp/register.go +++ b/go/services/wechatmp/register.go @@ -5,6 +5,8 @@ import ( "strings" "time" + "github.com/beego/beego/v2/core/logs" + "server/models" ) @@ -18,6 +20,13 @@ import ( // WechatBindTypeRegister 注册验证码会话类型(yz_platform_wechat_mp_verify_code.bind_type) const WechatBindTypeRegister = "register" +// ErrRegisterCodeInvalid 账号注册:验证码无效 / 已使用(引导语指向「注册」而非绑定用的「验证码」) +var ErrRegisterCodeInvalid = errors.New("验证码不正确或已过期:请在公众号内重新发送「注册」获取") + +// RegisterQRDownNotice 带参二维码不可用(IP 白名单 40164 / 未认证 48001 等)时, +// 回显给注册页的用户级引导:详细原因见服务端日志,不暴露服务器出口 IP。 +const RegisterQRDownNotice = "二维码暂不可用:请在公众号内直接发送「注册」获取验证码" + // StartRegisterVerify 创建注册验证码会话并生成带参二维码。 // 带参二维码生成失败(未认证公众号 48001 等)时回退:qrURL 为空、 // notice 返回引导(用户在公众号内发送「注册」获取验证码),scene 仍有效可轮询。 @@ -48,24 +57,43 @@ func StartRegisterVerify(ttl time.Duration) (scene, qrURL, notice string, expire } qrURL, err = CreateQRCode(cfg, scene, int(ttl.Seconds())+120) if err != nil { - // 不吞真实错误:与扫码登录 StartScanLogin 一致,把微信返回的 - // errcode 与排查建议(IP 白名单 / 48001 未认证等)透出,便于定位; - // 同时保留「公众号内发送注册」兜底路径,会话仍有效可轮询核销。 - notice := "请在公众号内直接发送「注册」获取验证码" - var apiErr *APIError - if errors.As(err, &apiErr) { - notice = "生成带参二维码失败(" + apiErr.Error() + ")。" + notice - if hint := apiErr.Explain(); hint != "" { - notice += ":" + hint - } - } else { - notice = "生成带参二维码失败:" + err.Error() + "。" + notice - } - return scene, "", notice, expireAt, nil + // 注册页匿名可访问:errcode / errmsg / 服务器出口 IP 等排查信息已在 + // CreateQRCode 内落到服务端日志(见 logAPIError),此处只回显用户级引导, + // 避免向访客泄露服务器出口 IP 与内部错误细节; + // 会话仍然有效,「公众号内发送注册」这条兜底路径可正常核销。 + logs.Warning("[wechat-mp] 注册二维码不可用,已回退公众号发送「注册」:%v", err) + return scene, "", RegisterQRDownNotice, expireAt, nil } return scene, qrURL, "", expireAt, nil } +// ConfirmAccountRegisterVerify 账号注册的验证码核销: +// 优先按 scene 核销二维码会话;该会话尚未扫码(用户没扫码,改为在公众号内发送 +// 「注册」取码)、scene 失效或验证码不属于该会话时,直接按验证码认领会话。 +// +// 账号注册不绑定手机号,服务号无法按手机号把「发送者」与网页会话关联起来, +// 因此不要求网页会话必须先被扫码:验证码有效即视为公众号内本人操作。 +func ConfirmAccountRegisterVerify(scene, code string) (string, error) { + code = strings.TrimSpace(code) + if code == "" { + return "", ErrCodeNotPending + } + if strings.TrimSpace(scene) != "" { + if openID, err := ConfirmRegisterVerify(scene, code); err == nil { + return openID, nil + } + } + openID, err := ConfirmRegisterVerifyByCode(code) + if err != nil { + // 按码认领失败的提示要指向注册场景(发送「注册」),而非绑定场景的「验证码」 + if errors.Is(err, ErrCodeNotPending) || errors.Is(err, ErrCodeUsed) { + return "", ErrRegisterCodeInvalid + } + return "", err + } + return openID, nil +} + // GetRegisterVerifyStatus 查询注册会话扫码状态(网页轮询展示「已扫码」) func GetRegisterVerifyStatus(scene string) (scanned bool, expireAt time.Time, err error) { row, err := GetVerifyCode(scene) diff --git a/go/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/go/views/auth/register.tpl b/go/views/auth/register.tpl index 48bfb34..d52900f 100644 --- a/go/views/auth/register.tpl +++ b/go/views/auth/register.tpl @@ -106,7 +106,6 @@ .wechat-box img { width: 168px; height: 168px; object-fit: contain; } .wechat-box .scan-tip { font-size: 13px; color: var(--text-sub); margin-top: 8px; } .wechat-box .scan-tip.ok { color: #34b96f; font-weight: 600; } - .wechat-box .mp-notice { font-size: 12px; color: #d08700; margin-top: 6px; } /* 第二步:两个卡片 */ .cards { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; } @@ -221,15 +220,14 @@
- +
正在获取二维码…
-
-
+
-
用微信扫码关注公众号后,验证码将通过公众号自动发送;无法扫码时可在公众号内直接发送「注册」获取验证码。
+
点击「获取二维码」,用微信扫码完成身份核验。
@@ -300,7 +298,8 @@ var CLIENT_ID = app.getAttribute('data-client-id') || ''; var REDIRECT = app.getAttribute('data-redirect') || ''; - var state = { mode: 'phone', token: '', scene: '', qrTimer: null, selectedTid: 0 }; + // wxChannel:微信核验通道,mini=小程序码确认(优先)/ mp=公众号验证码(回退) + var state = { mode: 'phone', token: '', scene: '', qrTimer: null, selectedTid: 0, wxChannel: '' }; // ---------- 工具 ---------- function $(id) { return document.getElementById(id); } @@ -393,39 +392,76 @@ }); }); - // ---------- 账号注册 ---------- + // ---------- 账号注册:微信身份核验(小程序优先,不可用时回退公众号) ---------- $('btnQr').addEventListener('click', function () { var btn = this; btn.disabled = true; $('wechatBox').classList.add('show'); $('wechatQr').style.display = 'none'; $('scanTip').textContent = '正在获取二维码…'; $('scanTip').className = 'scan-tip'; - $('mpNotice').textContent = ''; + startMiniVerify(function (ok) { + btn.disabled = false; + if (ok) return; + startMpVerify(btn); // 小程序未配置 / 小程序码生成失败 → 回退公众号验证码通道 + }); + }); + + function showQr(qrURL, tip) { + $('wechatQr').src = qrURL; + $('wechatQr').style.display = ''; + $('scanTip').textContent = tip; + } + + // 小程序方案:展示小程序码,用户在小程序内确认后无需填验证码 + function startMiniVerify(next) { + fetch('/auth/register/mini/start').then(function (r) { return r.json(); }).then(function (res) { + if (res.code !== 200 || !res.data || !res.data.qr_url) { next(false); return; } + state.wxChannel = 'mini'; + state.scene = res.data.scene || ''; + $('codeRow').style.display = 'none'; + $('wxHint').textContent = '用微信扫码,在小程序内确认后即可继续填写(无需验证码)。'; + showQr(res.data.qr_url, '请用微信扫码,在小程序内确认注册'); + next(true); + startQrPolling(); + }).catch(function () { next(false); }); + } + + // 公众号回退:优先带参二维码;生成失败时展示「关注二维码」, + // 用户扫码关注后在公众号内发送「注册」照样能收到验证码 + function startMpVerify(btn) { fetch('/auth/register/wechat/start').then(function (r) { return r.json(); }).then(function (res) { btn.disabled = false; - if (res.code !== 200) { $('scanTip').textContent = res.msg || '获取二维码失败'; return; } - state.scene = res.data.scene || ''; - if (res.data.qr_url) { - $('wechatQr').src = res.data.qr_url; - $('wechatQr').style.display = ''; - $('scanTip').textContent = '请用微信扫码并关注公众号'; + var d = res.data || {}; + state.wxChannel = 'mp'; + state.scene = d.scene || ''; + $('codeRow').style.display = ''; + $('wxHint').textContent = '扫码关注公众号后,在公众号内发送「注册」即可收到 6 位验证码。'; + var qr = d.qr_url || d.follow_qrcode || ''; + if (qr) { + showQr(qr, d.qr_url ? '请用微信扫码并关注公众号' : '扫码关注公众号后,在公众号内发送「注册」获取验证码'); + if (d.qr_url) startQrPolling(); // 关注二维码不带 scene,无需轮询 + } else if (res.code === 200) { + $('scanTip').textContent = d.notice || '无法展示二维码:请在公众号内直接发送「注册」获取验证码'; } else { - $('scanTip').textContent = '无法展示二维码:请在公众号内直接发送「注册」获取验证码'; + $('scanTip').textContent = '微信核验暂不可用,请改用手机号验证码注册'; } - if (res.data.notice) { $('mpNotice').textContent = res.data.notice; } - startQrPolling(); }).catch(function () { btn.disabled = false; $('scanTip').textContent = '网络异常,请重试'; }); - }); + } function startQrPolling() { if (state.qrTimer) clearInterval(state.qrTimer); if (!state.scene) return; state.qrTimer = setInterval(function () { - fetch('/auth/register/wechat/status?scene=' + encodeURIComponent(state.scene)) + var mini = state.wxChannel === 'mini'; + var url = mini ? '/auth/register/mini/status' : '/auth/register/wechat/status'; + fetch(url + '?scene=' + encodeURIComponent(state.scene)) .then(function (r) { return r.json(); }) .then(function (res) { - if (res.code === 200 && res.data && res.data.scanned) { - $('scanTip').textContent = '已扫码,验证码已发送到公众号,请填入下方输入框'; + var ok = res.code === 200 && res.data && (mini ? res.data.confirmed : res.data.scanned); + if (ok) { + $('scanTip').textContent = mini + ? '已在小程序内确认,请继续填写下方信息' + : '已扫码,验证码已发送到公众号,请填入下方输入框'; $('scanTip').className = 'scan-tip ok'; clearInterval(state.qrTimer); } @@ -439,9 +475,20 @@ if (!/^[A-Za-z][A-Za-z0-9_-]{3,31}$/.test(account)) { showMsg('账号需 4-32 位,字母开头,仅含字母/数字/下划线/中划线'); return; } if (pwd.length < 6) { showMsg('密码长度不能少于6位'); return; } if (pwd !== pwd2) { showMsg('两次输入的密码不一致'); return; } - if (!code) { showMsg('请输入公众号下发的验证码'); return; } + if (!state.wxChannel) { showMsg('请先点击「获取二维码」完成微信核验'); return; } + var mini = state.wxChannel === 'mini'; + // 小程序通道必须持有已确认的会话;公众号通道允许无 scene(用户手填公众号下发的验证码) + if (mini && !state.scene) { showMsg('请重新获取二维码并在小程序内完成确认'); return; } + if (!mini && !code) { showMsg('请输入公众号下发的验证码(在公众号内发送「注册」获取)'); return; } var btn = this; btn.disabled = true; - post('/auth/register/verify', { mode: 'account', account: account, scene: state.scene, code: code, password: pwd, confirm: pwd2 }, function (res) { + post('/auth/register/verify', { + mode: mini ? 'mini' : 'account', + account: account, + scene: state.scene, + code: code, + password: pwd, + confirm: pwd2 + }, function (res) { btn.disabled = false; if (res.code !== 200) { showMsg(res.msg || '验证失败'); return; } state.token = res.data.register_token; @@ -483,12 +530,24 @@ }).catch(function () { list.innerHTML = '
搜索失败,请重试
'; }); }); + // 第二步提交失败:注册会话过期时退回第一步重新核验(此时账号尚未占用,可重新注册) + function failStep2(res, fallback) { + var msg = (res && res.msg) || fallback || '提交失败'; + if (/注册会话已过期|注册会话已失效/.test(msg)) { + state.token = ''; + goStep(1); + showMsg(msg + ',请重新完成身份核验'); + return; + } + showMsg(msg); + } + $('btnJoin').addEventListener('click', function () { if (!state.selectedTid) { showMsg('请先选择企业'); return; } var btn = this; btn.disabled = true; post('/auth/register/apply-join', { token: state.token, tid: state.selectedTid }, function (res) { btn.disabled = false; - if (res.code !== 200) { showMsg(res.msg || '提交失败'); return; } + if (res.code !== 200) { failStep2(res, '提交失败'); return; } done('申请已提交', '加入企业的申请已提交,请等待企业管理员在后台审核,审核通过后即可登录。', res.redirect || buildLoginURL('')); }); }); @@ -516,7 +575,9 @@ }); $('btnCreate').addEventListener('click', function () { + if (!state.token) { failStep2({ msg: '注册会话已失效' }, '注册会话已失效'); return; } var form = { + token: state.token, tenant_name: $('cName').value.trim(), credit_code: $('cCredit').value.trim(), legal_person: $('cLegal').value.trim(), @@ -541,7 +602,7 @@ var btn = this; btn.disabled = true; post('/auth/register/apply-create', form, function (res) { btn.disabled = false; - if (res.code !== 200) { showMsg(res.msg || '提交失败'); return; } + if (res.code !== 200) { failStep2(res, '提交失败'); return; } done('申请已提交', '创建企业的申请已提交,请等待平台审核;审核通过后当前账号将成为企业管理员账号。', res.redirect || buildLoginURL('')); }); }); @@ -550,7 +611,7 @@ $('btnSkip').addEventListener('click', function () { if (!confirm('暂不加入企业将直接完成注册,之后可在登录后申请加入。确定吗?')) return; post('/auth/register/finish', { token: state.token }, function (res) { - if (res.code !== 200) { showMsg(res.msg || '操作失败'); return; } + if (res.code !== 200) { failStep2(res, '操作失败'); return; } done('注册成功', '账号已创建。你当前未加入任何企业,可稍后申请加入。', res.redirect || buildLoginURL('')); }); }); diff --git a/platform/src/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/platform/src/views/system/platformsettings/components/notificationSettings.vue b/platform/src/views/system/platformsettings/components/notificationSettings.vue index ff9d986..71aa001 100644 --- a/platform/src/views/system/platformsettings/components/notificationSettings.vue +++ b/platform/src/views/system/platformsettings/components/notificationSettings.vue @@ -381,7 +381,8 @@
上传公众号的「关注二维码」(公众号后台「账号设置 → 二维码」下载,2MB 内图片)。
- 用户在租户端「绑定微信」页扫码进入公众号会话 → 发送「验证码」→ 回页填码完成绑定(未认证公众号无法生成带参二维码,统一使用此图片)。 + 用户在租户端「绑定微信」页扫码进入公众号会话 → 发送「验证码」→ 回页填码完成绑定(未认证公众号无法生成带参二维码,统一使用此图片)。
+ 注册页同样依赖它:带参二维码生成失败(40164 IP 白名单 / 48001 未认证)时,注册页会展示这张图片,引导用户扫码关注后发送「注册」取验证码。
@@ -393,7 +394,7 @@
① 微信后台(公众平台 / 微信开发者平台)「设置与开发 → 基本配置」→ 复制 AppID / AppSecret 填入上方,保存并启用;
② 将本地址连同上方 Token / EncodingAESKey 填入微信后台「服务器配置」(加解密方式保持一致)——提交时微信会立即请求本地址校验,需本页已保存并启用;
- ③ 若「连接测试」提示 40164,请在微信后台「基本配置 → IP白名单」加入本服务器出口 IP(服务器执行 curl ifconfig.me 可查看)。 + ③ 若「连接测试」提示 40164,请在微信后台「基本配置 → IP白名单」加入本服务器出口 IP——点「连接测试」即可看到当前出口 IP(也可在服务器执行 curl ifconfig.me 查看)。
diff --git a/uniapp/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/nav/nav.vue b/uniapp/pages/nav/nav.vue index e380231..c4b6cd3 100644 --- a/uniapp/pages/nav/nav.vue +++ b/uniapp/pages/nav/nav.vue @@ -239,7 +239,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/pages/register/confirm.vue b/uniapp/pages/register/confirm.vue new file mode 100644 index 0000000..d90affa --- /dev/null +++ b/uniapp/pages/register/confirm.vue @@ -0,0 +1,174 @@ + + + + + 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/',