优化小程序部分
This commit is contained in:
1 parent
727319a6eb
commit
d4e89788c5
46 files changed
+4906
-63
No files matched your search
@@ -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/*
|
||||
|
||||
@@ -0,0 +1,652 @@
|
||||
// Command deploy 本地目录 → 服务器目录的「差异比对 + 增量上传」工具。
|
||||
//
|
||||
// 用途:只上传本地改动过的文件,跳过未改动的文件,避免每次整包覆盖。
|
||||
//
|
||||
// 工作流程:
|
||||
// 1. SSH 连上服务器,执行 md5sum 拿到远端目录的文件清单与指纹
|
||||
// 2. 本地遍历目录计算 MD5(按排除规则跳过日志、可执行文件等)
|
||||
// 3. 比对两份清单,得出「新增 / 已修改」的文件清单并打印
|
||||
// 4. 确认后把差异文件打成 tar 流,通过 SSH 管道在远端解开
|
||||
// 5. 回读远端指纹复核,确认上传结果
|
||||
//
|
||||
// 之所以用 Go 实现而不是直接调 scp:Windows 自带的 OpenSSH scp 不支持
|
||||
// 非交互传密码(sshpass / pscp 都没装),x/crypto/ssh 可以直接用密码登录,
|
||||
// 全程无交互,适合丢进 .bat 里一键执行。
|
||||
//
|
||||
// 用法:
|
||||
//
|
||||
// deploy -host 10.31.100.2 -user root -dir /www/wwwroot/api.yunzer.cn -local .
|
||||
package main
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
// ============================ 默认排除规则 ============================
|
||||
|
||||
// defaultExcludeDirs 不参与同步的目录名
|
||||
var defaultExcludeDirs = []string{
|
||||
".git", ".idea", ".vscode", ".svn",
|
||||
"node_modules", "bin", "dist", "tmp", "temp",
|
||||
}
|
||||
|
||||
// defaultExcludeFiles 不参与同步的文件名(支持 * 通配)
|
||||
var defaultExcludeFiles = []string{
|
||||
"*.exe", "*.log", "*.pid", "*.bak", "*.tmp", "*.pub",
|
||||
"nohup.out", "output.log", "go.log",
|
||||
"test-output-*.txt", "migrate_*.csv",
|
||||
"server", // 无扩展名的编译产物
|
||||
"*_id_ed25519", // SSH 私钥:绝对不能传到服务器
|
||||
".deploy_known_hosts",
|
||||
}
|
||||
|
||||
// ============================ 配置 ============================
|
||||
|
||||
type options struct {
|
||||
host string
|
||||
user string
|
||||
pass string
|
||||
keyFile string
|
||||
dir string
|
||||
local string
|
||||
restart string
|
||||
execCmd string
|
||||
excludes string
|
||||
dryRun bool
|
||||
yes bool
|
||||
del bool
|
||||
quiet bool
|
||||
}
|
||||
|
||||
// errCancelled 用户在上传前确认时选择了取消(区别于真正的失败)
|
||||
var errCancelled = errors.New("用户取消")
|
||||
|
||||
func main() {
|
||||
var opt options
|
||||
flag.StringVar(&opt.host, "host", "10.31.100.2", "服务器地址")
|
||||
flag.StringVar(&opt.user, "user", "root", "SSH 用户名")
|
||||
flag.StringVar(&opt.pass, "pass", "", "SSH 密码(留空则读环境变量 DEPLOY_PASS)")
|
||||
flag.StringVar(&opt.keyFile, "key", "", "SSH 私钥路径(指定后用密钥登录,忽略密码)")
|
||||
flag.StringVar(&opt.dir, "dir", "/www/wwwroot/api.yunzer.cn", "服务器目标目录")
|
||||
flag.StringVar(&opt.local, "local", ".", "本地目录(要同步的根)")
|
||||
flag.StringVar(&opt.restart, "restart", "", "上传后执行的远程重启命令(留空则不重启)")
|
||||
flag.StringVar(&opt.execCmd, "exec", "", "只执行远程命令并输出结果,不做同步")
|
||||
flag.StringVar(&opt.excludes, "exclude", "", "额外排除的文件名(逗号分隔,支持 * 通配)")
|
||||
flag.BoolVar(&opt.dryRun, "dry-run", false, "只比对不上传")
|
||||
flag.BoolVar(&opt.yes, "yes", false, "跳过上传前的确认")
|
||||
flag.BoolVar(&opt.del, "delete", false, "同时删除远端存在、本地已删除的文件")
|
||||
flag.BoolVar(&opt.quiet, "quiet", false, "精简输出")
|
||||
flag.Parse()
|
||||
|
||||
if opt.pass == "" {
|
||||
opt.pass = os.Getenv("DEPLOY_PASS")
|
||||
}
|
||||
if opt.pass == "" && opt.keyFile == "" {
|
||||
fmt.Println("[错误] 未提供密码:请用 -pass 或环境变量 DEPLOY_PASS 指定")
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
if err := run(opt); err != nil {
|
||||
if errors.Is(err, errCancelled) {
|
||||
os.Exit(3) // 取消不是失败,用独立退出码告知调用方
|
||||
}
|
||||
fmt.Printf("[失败] %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================ 主流程 ============================
|
||||
|
||||
func run(opt options) error {
|
||||
localRoot, err := filepath.Abs(opt.local)
|
||||
if err != nil {
|
||||
return fmt.Errorf("解析本地目录失败: %w", err)
|
||||
}
|
||||
if _, err := os.Stat(localRoot); err != nil {
|
||||
return fmt.Errorf("本地目录不存在: %s", localRoot)
|
||||
}
|
||||
|
||||
excludeDirs := append([]string{}, defaultExcludeDirs...)
|
||||
excludeFiles := append([]string{}, defaultExcludeFiles...)
|
||||
for _, extra := range strings.Split(opt.excludes, ",") {
|
||||
extra = strings.TrimSpace(extra)
|
||||
if extra == "" {
|
||||
continue
|
||||
}
|
||||
if strings.ContainsAny(extra, "/\\") {
|
||||
excludeDirs = append(excludeDirs, extra)
|
||||
} else {
|
||||
excludeFiles = append(excludeFiles, extra)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("本地目录 : %s\n", localRoot)
|
||||
fmt.Printf("远程目录 : %s@%s:%s\n", opt.user, opt.host, opt.dir)
|
||||
|
||||
client, err := dial(opt, localRoot)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
// 只执行远程命令(不比对、不上传)
|
||||
if strings.TrimSpace(opt.execCmd) != "" {
|
||||
fmt.Printf("\n[执行] %s\n", opt.execCmd)
|
||||
out, xerr := runRemote(client, opt.execCmd)
|
||||
if strings.TrimSpace(out) != "" {
|
||||
fmt.Println(strings.TrimSpace(out))
|
||||
}
|
||||
if xerr != nil {
|
||||
return fmt.Errorf("远程命令失败: %w", xerr)
|
||||
}
|
||||
fmt.Println("[完成]")
|
||||
return nil
|
||||
}
|
||||
|
||||
// 1. 远端清单
|
||||
remote, err := remoteInventory(client, opt.dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("远端文件 : %d 个\n", len(remote))
|
||||
|
||||
// 2. 本地清单
|
||||
local, err := localInventory(localRoot, excludeDirs, excludeFiles)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("本地文件 : %d 个(已排除日志 / 可执行文件等)\n", len(local))
|
||||
|
||||
// 3. 比对
|
||||
var changed, added []string
|
||||
remoteOnly := make([]string, 0)
|
||||
for rel, lh := range local {
|
||||
rh, ok := remote[rel]
|
||||
if !ok {
|
||||
added = append(added, rel)
|
||||
continue
|
||||
}
|
||||
if lh.MD5 != rh.MD5 {
|
||||
changed = append(changed, rel)
|
||||
}
|
||||
}
|
||||
for rel := range remote {
|
||||
if _, ok := local[rel]; !ok {
|
||||
remoteOnly = append(remoteOnly, rel)
|
||||
}
|
||||
}
|
||||
sort.Strings(changed)
|
||||
sort.Strings(added)
|
||||
sort.Strings(remoteOnly)
|
||||
|
||||
fmt.Println()
|
||||
if len(added) > 0 {
|
||||
fmt.Printf("新增文件 %d 个:\n", len(added))
|
||||
printList("+", added)
|
||||
}
|
||||
if len(changed) > 0 {
|
||||
fmt.Printf("内容有变化 %d 个:\n", len(changed))
|
||||
printList("M", changed)
|
||||
}
|
||||
if len(remoteOnly) > 0 {
|
||||
fmt.Printf("远端存在、本地没有 %d 个%s\n", len(remoteOnly), map[bool]string{true: "(将删除)", false: "(保留,不删除)"}[opt.del])
|
||||
if opt.del {
|
||||
printList("-", remoteOnly)
|
||||
}
|
||||
}
|
||||
fmt.Printf("\n未变化 %d 个(跳过)\n", len(local)-len(changed)-len(added))
|
||||
|
||||
toUpload := append(append([]string{}, changed...), added...)
|
||||
if len(toUpload) == 0 && len(remoteOnly) == 0 {
|
||||
fmt.Println("\n[完成] 没有需要更新的文件。")
|
||||
return nil
|
||||
}
|
||||
if opt.dryRun {
|
||||
fmt.Println("\n[试运行] 未实际上传(-dry-run)。")
|
||||
return nil
|
||||
}
|
||||
|
||||
if !opt.yes && !confirm(fmt.Sprintf("确认上传 %d 个文件到 %s?", len(toUpload), opt.dir)) {
|
||||
fmt.Println("已取消。")
|
||||
return errCancelled
|
||||
}
|
||||
|
||||
// 4. 上传
|
||||
if len(toUpload) > 0 {
|
||||
fmt.Printf("\n[上传] 打包 %d 个文件并传输...\n", len(toUpload))
|
||||
if err := upload(client, opt, localRoot, toUpload); err != nil {
|
||||
return err
|
||||
}
|
||||
// 5. 复核
|
||||
fmt.Println("[复核] 重新读取远端指纹...")
|
||||
after, err := remoteInventory(client, opt.dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
bad := make([]string, 0)
|
||||
for _, rel := range toUpload {
|
||||
rh, ok := after[rel]
|
||||
if !ok {
|
||||
bad = append(bad, rel+"(远端缺失)")
|
||||
continue
|
||||
}
|
||||
if lh := local[rel]; lh.MD5 != rh.MD5 {
|
||||
bad = append(bad, rel+"(指纹不一致)")
|
||||
}
|
||||
}
|
||||
if len(bad) > 0 {
|
||||
for _, b := range bad {
|
||||
fmt.Println(" ! " + b)
|
||||
}
|
||||
return fmt.Errorf("%d 个文件上传后校验不通过", len(bad))
|
||||
}
|
||||
fmt.Printf("[复核] %d 个文件指纹一致 ✓\n", len(toUpload))
|
||||
}
|
||||
|
||||
// 6. 删除远端多余文件
|
||||
if opt.del && len(remoteOnly) > 0 {
|
||||
fmt.Printf("\n[删除] 清理远端 %d 个多余文件...\n", len(remoteOnly))
|
||||
if err := removeRemote(client, opt.dir, remoteOnly); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// 7. 重启
|
||||
if strings.TrimSpace(opt.restart) != "" {
|
||||
fmt.Printf("\n[重启] 执行: %s\n", opt.restart)
|
||||
out, err := runRemote(client, opt.restart)
|
||||
if err != nil {
|
||||
return fmt.Errorf("重启失败: %w(远端输出: %s)", err, strings.TrimSpace(out))
|
||||
}
|
||||
if strings.TrimSpace(out) != "" {
|
||||
fmt.Println(out)
|
||||
}
|
||||
fmt.Println("[重启] 完成")
|
||||
} else {
|
||||
fmt.Println("\n[提示] 未配置重启命令,服务不会自动重启;如需生效请手动重启(如 -restart \"supervisorctl restart <服务名>\")")
|
||||
}
|
||||
|
||||
fmt.Println("\n[完成] 同步结束。")
|
||||
return nil
|
||||
}
|
||||
|
||||
// ============================ 文件清单 ============================
|
||||
|
||||
type fileInfo struct {
|
||||
MD5 string
|
||||
Size int64
|
||||
Path string
|
||||
}
|
||||
|
||||
// localInventory 遍历本地目录,计算 MD5
|
||||
func localInventory(root string, excludeDirs, excludeFiles []string) (map[string]fileInfo, error) {
|
||||
out := make(map[string]fileInfo)
|
||||
err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel, rerr := filepath.Rel(root, path)
|
||||
if rerr != nil {
|
||||
return rerr
|
||||
}
|
||||
rel = filepath.ToSlash(rel)
|
||||
if rel == "." {
|
||||
return nil
|
||||
}
|
||||
name := info.Name()
|
||||
if info.IsDir() {
|
||||
if matchAny(name, excludeDirs) {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return nil // 跳过符号链接 / 设备文件等
|
||||
}
|
||||
if matchAny(name, excludeFiles) {
|
||||
return nil
|
||||
}
|
||||
sum, size, herr := fileMD5(path)
|
||||
if herr != nil {
|
||||
return herr
|
||||
}
|
||||
out[rel] = fileInfo{MD5: sum, Size: size, Path: path}
|
||||
return nil
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
|
||||
// remoteInventory 远端 md5sum 清单
|
||||
func remoteInventory(client *ssh.Client, dir string) (map[string]fileInfo, error) {
|
||||
res := make(map[string]fileInfo)
|
||||
|
||||
// 目录不存在时按空目录处理(上传阶段会创建)
|
||||
if out, err := runRemote(client, fmt.Sprintf("[ -d %s ] && echo yes || echo no", shQuote(dir))); err == nil {
|
||||
if !strings.Contains(out, "yes") {
|
||||
fmt.Printf("[提示] 远端目录 %s 不存在,将新建\n", dir)
|
||||
return res, nil
|
||||
}
|
||||
}
|
||||
|
||||
cmd := fmt.Sprintf("cd %s && find . -type f -exec md5sum {} + 2>/dev/null", shQuote(dir))
|
||||
out, err := runRemote(client, cmd)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取远端清单失败: %w(输出: %s)", err, strings.TrimSpace(out))
|
||||
}
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
line = strings.TrimRight(line, "\r")
|
||||
if strings.TrimSpace(line) == "" {
|
||||
continue
|
||||
}
|
||||
// 格式: <md5> <path>
|
||||
idx := strings.Index(line, " ")
|
||||
if idx < 0 {
|
||||
continue
|
||||
}
|
||||
sum := strings.TrimSpace(line[:idx])
|
||||
rel := strings.TrimSpace(line[idx+2:])
|
||||
rel = strings.TrimPrefix(rel, "./")
|
||||
if rel == "" {
|
||||
continue
|
||||
}
|
||||
res[rel] = fileInfo{MD5: sum, Path: rel}
|
||||
}
|
||||
if len(res) == 0 {
|
||||
fmt.Printf("[提示] 远端目录 %s 目前是空的\n", dir)
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func fileMD5(path string) (string, int64, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
defer f.Close()
|
||||
h := md5.New()
|
||||
size, err := io.Copy(h, f)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil)), size, nil
|
||||
}
|
||||
|
||||
// matchAny 名称是否命中排除规则(支持 * 通配)
|
||||
func matchAny(name string, patterns []string) bool {
|
||||
for _, p := range patterns {
|
||||
if p == name {
|
||||
return true
|
||||
}
|
||||
if ok, err := filepath.Match(p, name); err == nil && ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// printList 打印文件清单(最多 20 条,避免刷屏)
|
||||
func printList(mark string, files []string) {
|
||||
const maxShow = 20
|
||||
for i, f := range files {
|
||||
if i >= maxShow {
|
||||
fmt.Printf(" …… 其余 %d 个省略\n", len(files)-maxShow)
|
||||
break
|
||||
}
|
||||
fmt.Printf(" %s %s\n", mark, f)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================ 上传 ============================
|
||||
|
||||
// upload 把差异文件打成 tar 流,通过 SSH 管道在远端解开
|
||||
func upload(client *ssh.Client, opt options, localRoot string, files []string) error {
|
||||
buf, err := buildTar(localRoot, files)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 远端目录不存在时先创建(tar 解包要求目标目录已存在)
|
||||
if out, merr := runRemote(client, fmt.Sprintf("mkdir -p %s", shQuote(opt.dir))); merr != nil {
|
||||
return fmt.Errorf("创建远端目录失败: %w(%s)", merr, strings.TrimSpace(out))
|
||||
}
|
||||
|
||||
session, err := client.NewSession()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer session.Close()
|
||||
|
||||
var stderr bytes.Buffer
|
||||
session.Stderr = &stderr
|
||||
stdin, err := session.StdinPipe()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stdout, err := session.StdoutPipe()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 远端解包(tar 会自动创建缺失的目录)
|
||||
if err := session.Start(fmt.Sprintf("tar -xf - -C %s", shQuote(opt.dir))); err != nil {
|
||||
return err
|
||||
}
|
||||
go func() {
|
||||
_, _ = stdin.Write(buf)
|
||||
_ = stdin.Close()
|
||||
}()
|
||||
out, _ := io.ReadAll(stdout)
|
||||
if err := session.Wait(); err != nil {
|
||||
return fmt.Errorf("远端解包失败: %w(stderr: %s stdout: %s)", err, strings.TrimSpace(stderr.String()), strings.TrimSpace(string(out)))
|
||||
}
|
||||
|
||||
// 属主对齐目标目录(服务器上多为 www:www,避免解包后变成 root:root)
|
||||
if err := alignOwner(client, opt.dir, files); err != nil {
|
||||
fmt.Printf("[提示] 属主修正跳过: %v\n", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// alignOwner 把上传文件的属主改成目标目录的属主
|
||||
func alignOwner(client *ssh.Client, dir string, files []string) error {
|
||||
if len(files) == 0 {
|
||||
return nil
|
||||
}
|
||||
quoted := make([]string, 0, len(files))
|
||||
for _, rel := range files {
|
||||
quoted = append(quoted, shQuote(strings.TrimRight(dir, "/")+"/"+rel))
|
||||
}
|
||||
cmd := fmt.Sprintf("ref=$(stat -c '%%U:%%G' %s) && for f in %s; do chown -R \"$ref\" \"$f\"; done",
|
||||
shQuote(dir), strings.Join(quoted, " "))
|
||||
out, err := runRemote(client, cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w(%s)", err, strings.TrimSpace(out))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildTar 生成待上传文件的 tar 包(含目录条目,保证远端目录结构存在)
|
||||
func buildTar(localRoot string, files []string) ([]byte, error) {
|
||||
dirs := make(map[string]bool)
|
||||
for _, rel := range files {
|
||||
parts := strings.Split(rel, "/")
|
||||
for i := 1; i < len(parts); i++ {
|
||||
dirs[strings.Join(parts[:i], "/")+"/"] = true
|
||||
}
|
||||
}
|
||||
dirList := make([]string, 0, len(dirs))
|
||||
for d := range dirs {
|
||||
dirList = append(dirList, d)
|
||||
}
|
||||
sort.Strings(dirList)
|
||||
|
||||
buf := &bytes.Buffer{}
|
||||
tw := tar.NewWriter(buf)
|
||||
for _, d := range dirList {
|
||||
if err := tw.WriteHeader(&tar.Header{
|
||||
Name: d,
|
||||
Typeflag: tar.TypeDir,
|
||||
Mode: 0o755,
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
for _, rel := range files {
|
||||
full := filepath.Join(localRoot, filepath.FromSlash(rel))
|
||||
info, err := os.Stat(full)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
f, err := os.Open(full)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := tw.WriteHeader(&tar.Header{
|
||||
Name: rel,
|
||||
Typeflag: tar.TypeReg,
|
||||
Mode: fileMode(rel),
|
||||
Size: info.Size(),
|
||||
ModTime: info.ModTime(),
|
||||
}); err != nil {
|
||||
f.Close()
|
||||
return nil, err
|
||||
}
|
||||
if _, err := io.Copy(tw, f); err != nil {
|
||||
f.Close()
|
||||
return nil, err
|
||||
}
|
||||
f.Close()
|
||||
}
|
||||
if err := tw.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// fileMode 归一化文件权限:脚本可执行,其它一律 0644
|
||||
// (Windows 本地读到的权限恒为 0666,直接带上会让服务器上的文件变成人人可写)
|
||||
func fileMode(rel string) int64 {
|
||||
switch strings.ToLower(filepath.Ext(rel)) {
|
||||
case ".sh", ".bash", ".py", ".pl", ".rb":
|
||||
return 0o755
|
||||
}
|
||||
return 0o644
|
||||
}
|
||||
|
||||
// removeRemote 删除远端多余文件
|
||||
func removeRemote(client *ssh.Client, dir string, files []string) error {
|
||||
for _, rel := range files {
|
||||
cmd := fmt.Sprintf("rm -f -- %s", shQuote(strings.TrimRight(dir, "/")+"/"+rel))
|
||||
if out, err := runRemote(client, cmd); err != nil {
|
||||
return fmt.Errorf("删除 %s 失败: %w(%s)", rel, err, strings.TrimSpace(out))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ============================ SSH ============================
|
||||
|
||||
func dial(opt options, localRoot string) (*ssh.Client, error) {
|
||||
var auth []ssh.AuthMethod
|
||||
if opt.keyFile != "" {
|
||||
key, err := os.ReadFile(opt.keyFile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取私钥失败: %w", err)
|
||||
}
|
||||
signer, err := ssh.ParsePrivateKey(key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("解析私钥失败: %w", err)
|
||||
}
|
||||
auth = append(auth, ssh.PublicKeys(signer))
|
||||
} else {
|
||||
auth = append(auth, ssh.Password(opt.pass))
|
||||
}
|
||||
|
||||
cfg := &ssh.ClientConfig{
|
||||
User: opt.user,
|
||||
Auth: auth,
|
||||
HostKeyCallback: hostKeyChecker(localRoot),
|
||||
Timeout: 20 * time.Second,
|
||||
}
|
||||
addr := fmt.Sprintf("%s:%d", opt.host, 22)
|
||||
client, err := ssh.Dial("tcp", addr, cfg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("连接服务器失败: %w", err)
|
||||
}
|
||||
return client, nil
|
||||
}
|
||||
|
||||
// hostKeyChecker 首次连接记录指纹,之后变化则拒绝连接(防止服务器被替换)
|
||||
func hostKeyChecker(localRoot string) ssh.HostKeyCallback {
|
||||
store := filepath.Join(localRoot, ".deploy_known_hosts")
|
||||
known := map[string]string{}
|
||||
if data, err := os.ReadFile(store); err == nil {
|
||||
_ = json.Unmarshal(data, &known)
|
||||
}
|
||||
return func(hostname string, remote net.Addr, key ssh.PublicKey) error {
|
||||
fp := ssh.FingerprintSHA256(key)
|
||||
if old, ok := known[hostname]; ok {
|
||||
if old != fp {
|
||||
return fmt.Errorf("服务器主机密钥已变化(%s 原 %s 现 %s),请确认后删除 %s 再执行", hostname, old, fp, store)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
known[hostname] = fp
|
||||
if data, err := json.MarshalIndent(known, "", " "); err == nil {
|
||||
_ = os.WriteFile(store, data, 0o644)
|
||||
}
|
||||
fmt.Printf("[安全] 首次连接,已记录服务器指纹 %s(%s)\n", fp, store)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// runRemote 执行远程命令并返回输出
|
||||
func runRemote(client *ssh.Client, cmd string) (string, error) {
|
||||
session, err := client.NewSession()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer session.Close()
|
||||
var out, errBuf bytes.Buffer
|
||||
session.Stdout = &out
|
||||
session.Stderr = &errBuf
|
||||
err = session.Run(cmd)
|
||||
if err != nil {
|
||||
return out.String() + errBuf.String(), err
|
||||
}
|
||||
return out.String(), nil
|
||||
}
|
||||
|
||||
// ============================ 辅助 ============================
|
||||
|
||||
// shQuote 单引号包裹(内部单引号转义),避免命令注入与路径空格问题
|
||||
func shQuote(s string) string {
|
||||
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
|
||||
}
|
||||
|
||||
// confirm 交互确认
|
||||
func confirm(prompt string) bool {
|
||||
fmt.Printf("%s [y/N]: ", prompt)
|
||||
var answer string
|
||||
_, err := fmt.Scanln(&answer)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
answer = strings.ToLower(strings.TrimSpace(answer))
|
||||
return answer == "y" || answer == "yes"
|
||||
}
|
||||
@@ -12,6 +12,21 @@ runmode = dev
|
||||
platform_spa_dir = /www/wwwroot/platform.yunzer.cn/output
|
||||
platform_spa_hosts = platform.yunzer.cn
|
||||
|
||||
# ==================== 根路径站点验证文件 ====================
|
||||
# nginx 对所有域名都是全量反代给 Go,Go 不认识的路径一律回 beego 404,
|
||||
# 导致 1XXl8fPHr9.txt 这类「域名所有权验证文件」访问不到(备案、搜索引擎收录、
|
||||
# 第三方平台域名校验都靠它)。这里由 Go 直接吐 verify 目录下的文件,
|
||||
# 不依赖 nginx 规则(宝塔重生成 vhost 也不会被冲掉)。
|
||||
#
|
||||
# verify_dir 验证文件目录,相对进程工作目录(线上 = /www/wwwroot/api.yunzer.cn/verify)
|
||||
# 留空 = 默认 verify;填 - = 关闭该功能
|
||||
# verify_exts 允许的扩展名(逗号分隔);留空 = 默认 .txt,.html,.htm
|
||||
# verify_hosts 限定生效的域名(不含端口,逗号分隔);留空 = 所有走本服务的域名都生效
|
||||
# (备案常常要同时过 api.yunzer.cn 与官网主域名 yunzer.com.cn / dh2.fun)
|
||||
verify_dir =
|
||||
verify_exts =
|
||||
verify_hosts =
|
||||
|
||||
# 启用请求体复制(允许多次读取请求体)
|
||||
copyrequestbody = true
|
||||
|
||||
@@ -109,6 +124,13 @@ wechat_mp_secret_key = 8f3c2a1d94b74e28a6c5f0187d3e9b4c72a5d0e6f13b8c47d92a6e5f0
|
||||
# wechat_mini_register_env = release
|
||||
wechat_mini_register_env = trial
|
||||
|
||||
# ==================== 微信小程序消息推送 ====================
|
||||
# 消息推送回调地址基址:最终地址 = {基址}/api/wechat/mini/callback,
|
||||
# 填到小程序后台「开发管理 → 开发设置 → 消息推送」的 URL(必须是 80 / 443 端口、微信可直连)。
|
||||
# 缺省回落 wechat_mp_callback_base / payment_callback_base;都留空时退化为相对路径
|
||||
# /api/wechat/mini/callback(需由网关补齐域名)。
|
||||
wechat_mini_callback_base = https://api.yunzer.cn
|
||||
|
||||
# ==================== 支付模块 ====================
|
||||
# 渠道敏感参数(商户密钥/私钥/Secret)入库加密密钥:任意随机串即可,代码内部做 SHA-256 派生。
|
||||
# 注意:更换该值后,历史已保存的渠道参数将无法解密,需在各渠道配置页重新保存一次。
|
||||
|
||||
@@ -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, "没有可用的订阅:模板未配置,或你还没授权订阅消息")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 不应通过")
|
||||
}
|
||||
}
|
||||
@@ -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, "未发送:该用户没有授权订阅,或模板未配置/未启用")
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
# ============================================================
|
||||
# 宝塔站点 api.yunzer.cn 的 nginx 配置(留档副本)
|
||||
# 文件位置(服务器):/www/server/panel/vhost/nginx/api.yunzer.cn.conf
|
||||
# 面板入口:宝塔 → 网站 → api.yunzer.cn → 设置 → 配置文件
|
||||
#
|
||||
# 【2026-10-31 新增】根路径域名校验文件的 http 放行
|
||||
# 微信公众平台 / 小程序配置「业务域名」时,校验服务是走 **http**
|
||||
# 去抓取根目录下的校验文件(如 /1XXl8fPHr9.txt)。
|
||||
# 原配置把 http 一律 301 到 https,微信拿到重定向就判
|
||||
# 「校验文件验证失败,请确认已在域名根目录正确放置校验文件」。
|
||||
# 下面 HTTP_TO_HTTPS 块里新增的 if 例外,就是让根路径下的 .txt
|
||||
# 校验文件在 http 下直接返回文件内容(不跳转)。
|
||||
# 用通配而不是写死文件名:微信每次重新下载校验文件都会生成新随机名。
|
||||
#
|
||||
# 【注意】不要设 verify_hosts
|
||||
# 本文件里 proxy_set_header Host 10.31.100.2; 把 Host 头改成了 IP,
|
||||
# Go 侧看不到 api.yunzer.cn 这个域名。verify_hosts 必须留空
|
||||
# (留空 = 所有走本服务的域名都放行)。
|
||||
#
|
||||
# 【注意】location ^~ / 会让后面的正则 location 失效
|
||||
# ^~ 匹配后 nginx 不再检查正则 location,所以下面两条
|
||||
# 「禁止访问的敏感文件 / 敏感目录」的 location ~* 规则在本站点实际不生效。
|
||||
# 当前无泄露风险(Go 不提供工作目录文件,未知路径一律 404),但要知道这层保护是空的。
|
||||
#
|
||||
# 【注意】proxy_cache_path 定义了缓存区但当前未启用 proxy_cache
|
||||
# 以后若启用全局缓存,301 会被缓存住,微信可能拿到缓存的旧响应,届时需清缓存。
|
||||
# ============================================================
|
||||
|
||||
proxy_cache_path /www/wwwroot/api.yunzer.cn/proxy_cache_dir levels=1:2 keys_zone=api_yunzer_cn_cache:20m inactive=1d max_size=5g;
|
||||
server {
|
||||
listen 80;
|
||||
listen 443 ssl;
|
||||
http2 on;
|
||||
listen [::]:80;
|
||||
server_name api.yunzer.cn;
|
||||
index index.php index.html index.htm default.php default.htm default.html;
|
||||
root /www/wwwroot/api.yunzer.cn;
|
||||
include /www/server/panel/vhost/nginx/extension/api.yunzer.cn/*.conf;
|
||||
#CERT-APPLY-CHECK--START
|
||||
|
||||
# 用于SSL证书申请时的文件验证相关配置 -- 请勿删除
|
||||
include /www/server/panel/vhost/nginx/well-known/api.yunzer.cn.conf;
|
||||
#CERT-APPLY-CHECK--END
|
||||
|
||||
#SSL-START SSL相关配置,请勿删除或修改下一行带注释的404规则
|
||||
|
||||
#error_page 404/404.html;
|
||||
|
||||
#HTTP_TO_HTTPS_START
|
||||
set $isRedcert 1;
|
||||
if ($server_port != 443) {
|
||||
set $isRedcert 2;
|
||||
}
|
||||
if ( $uri ~ /\.well-known/ ) {
|
||||
set $isRedcert 1;
|
||||
}
|
||||
# 域名校验文件(微信/搜索引擎/第三方平台)必须能用 http 直接访问,不能 301 跳 https。
|
||||
# 用 [.] 代替 \. ,避免 nginx 配置解析里反斜杠转义的歧义。
|
||||
if ( $uri ~ ^/[A-Za-z0-9._-]+[.]txt$ ) {
|
||||
set $isRedcert 1;
|
||||
}
|
||||
if ($isRedcert != 1) {
|
||||
rewrite ^(/.*)$ https://$host$1 permanent;
|
||||
}
|
||||
#HTTP_TO_HTTPS_END
|
||||
ssl_certificate /www/server/panel/vhost/cert/api.yunzer.cn/fullchain.pem;
|
||||
ssl_certificate_key /www/server/panel/vhost/cert/api.yunzer.cn/privkey.pem;
|
||||
ssl_protocols TLSv1.1 TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers EECDH+CHACHA20:CHACHA20-draft:EECDH+AES128:RSA+AES128:EECDH+AES256:RSA+AES256:EECDH+3DES:RSA+3DES:!MD5;
|
||||
ssl_prefer_server_ciphers on;
|
||||
ssl_session_tickets on;
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_session_timeout 10m;
|
||||
add_header Strict-Transport-Security "max-age=31536000";
|
||||
error_page 497 https://$host$request_uri;
|
||||
#SSL-END
|
||||
|
||||
#REDIRECT START
|
||||
|
||||
#REDIRECT END
|
||||
|
||||
#ERROR-PAGE-START 错误页配置,可以注释、删除或修改
|
||||
|
||||
#error_page 404 /404.html;
|
||||
|
||||
#error_page 502 /502.html;
|
||||
|
||||
#ERROR-PAGE-END
|
||||
|
||||
#PHP-INFO-START PHP引用配置,可以注释或修改
|
||||
include enable-php-00.conf;
|
||||
#PHP-INFO-END
|
||||
|
||||
#IP-RESTRICT-START 限制访问ip的配置,IP黑白名单
|
||||
|
||||
#IP-RESTRICT-END
|
||||
|
||||
#BASICAUTH START
|
||||
|
||||
#BASICAUTH END
|
||||
|
||||
#SUB_FILTER START
|
||||
|
||||
#SUB_FILTER END
|
||||
|
||||
#GZIP START
|
||||
|
||||
#GZIP END
|
||||
|
||||
#GLOBAL-CACHE START
|
||||
|
||||
#GLOBAL-CACHE END
|
||||
|
||||
#WEBSOCKET-SUPPORT START
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
#WEBSOCKET-SUPPORT END
|
||||
|
||||
#PROXY-CONF-START
|
||||
location ^~ / {
|
||||
proxy_pass http://10.31.100.2:9000;
|
||||
proxy_set_header Host 10.31.100.2;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Real-Port $remote_port;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-Port $server_port;
|
||||
proxy_set_header REMOTE-HOST $remote_addr;
|
||||
# 连接超时(建立连接的时间)
|
||||
proxy_connect_timeout 3600s;
|
||||
# 发送超时(发送请求到后端的时间)
|
||||
proxy_send_timeout 3600s;
|
||||
# 读取超时(等待后端响应的时间)
|
||||
proxy_read_timeout 3600s;
|
||||
# 支持websocket链接
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
# 客户端请求体大小限制(0 表示不限制)
|
||||
client_max_body_size 0;
|
||||
# 客户端请求体超时(客户端发送请求体的时间)
|
||||
client_body_timeout 3600s;
|
||||
# 客户端请求体缓冲区大小
|
||||
client_body_buffer_size 128k;
|
||||
# 禁用请求体缓冲(直接流式传输到后端,不先缓存到磁盘)
|
||||
proxy_request_buffering off;
|
||||
# 禁用响应缓冲(直接流式传输到客户端)
|
||||
proxy_buffering off;
|
||||
}
|
||||
#PROXY-CONF-END
|
||||
|
||||
#SERVER-BLOCK START
|
||||
|
||||
#SERVER-BLOCK END
|
||||
|
||||
# 禁止访问的敏感文件
|
||||
location ~* (\.user.ini|\.htaccess|\.htpasswd|\.env.*|\.project|\.bashrc|\.bash_profile|\.bash_logout|\.DS_Store|\.gitignore|\.gitattributes|LICENSE|README\.md|CLAUDE\.md|CHANGELOG\.md|CHANGELOG|CONTRIBUTING\.md|TODO\.md|FAQ\.md|composer\.json|composer\.lock|package(-lock)?\.json|yarn\.lock|pnpm-lock\.yaml|\.\w+~|\.swp|\.swo|\.bak(up)?|\.old|\.tmp|\.temp|\.log|\.sql(\.gz)?|docker-compose\.yml|docker\.env|Dockerfile|\.csproj|\.sln|Cargo\.toml|Cargo\.lock|go\.mod|go\.sum|phpunit\.xml|phpunit\.xml|pom\.xml|build\.gradl|pyproject\.toml|requirements\.txt|application(-\w+)?\.(ya?ml|properties))$ {
|
||||
return 404;
|
||||
}
|
||||
# 禁止访问的敏感目录
|
||||
location ~* /(\.git|\.svn|\.bzr|\.vscode|\.claude|\.idea|\.ssh|\.github|\.npm|\.yarn|\.pnpm|\.cache|\.husky|\.turbo|\.next|\.nuxt|node_modules|runtime)/ {
|
||||
return 404;
|
||||
}
|
||||
#一键申请SSL证书验证目录相关设置
|
||||
location /.well-known {
|
||||
allow all;
|
||||
}
|
||||
#禁止在证书验证目录放入敏感文件
|
||||
if ( $uri ~ "^/\.well-known/.*\.(php|jsp|py|js|css|lua|ts|go|zip|tar\.gz|rar|7z|sql|bak)$" ) {
|
||||
return 403;
|
||||
}
|
||||
#LOG START
|
||||
access_log /www/wwwlogs/api.yunzer.cn.log;
|
||||
error_log /www/wwwlogs/api.yunzer.cn.error.log;
|
||||
#LOG END
|
||||
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
-- 微信小程序多应用表补充「消息推送」配置字段
|
||||
--
|
||||
-- 对应小程序后台:开发管理 → 开发设置 → 消息推送
|
||||
-- Token / EncodingAESKey / 消息加解密方式 / 数据格式
|
||||
-- 平台端「小程序配置 - 编辑小程序 - 消息推送」维护,保存后把「回调地址」填到后台。
|
||||
--
|
||||
-- 注意:aes_key 以 AES-256-GCM 加密存储(复用 wechatmp 的密钥),与 AppSecret 同一套加密。
|
||||
-- 执行:mysql -h <host> -P <port> -u <user> -p <db> < alter_wechat_mini_app_push.sql
|
||||
-- ============================================================
|
||||
|
||||
ALTER TABLE `yz_platform_wechat_mini_app`
|
||||
ADD COLUMN `token` varchar(128) NOT NULL DEFAULT '' COMMENT '消息推送 Token(小程序后台「消息推送」填写)' AFTER `qrcode_url`,
|
||||
ADD COLUMN `aes_key` varchar(512) NOT NULL DEFAULT '' COMMENT '消息推送 EncodingAESKey(AES-256-GCM 加密存储)' AFTER `token`,
|
||||
ADD COLUMN `encrypt_mode` varchar(16) NOT NULL DEFAULT 'plain' COMMENT '消息加解密方式 plain=明文 compatible=兼容 safe=安全' AFTER `aes_key`,
|
||||
ADD COLUMN `data_format` varchar(16) NOT NULL DEFAULT 'xml' COMMENT '消息数据格式 xml / json' AFTER `encrypt_mode`,
|
||||
ADD COLUMN `push_reply` varchar(512) NOT NULL DEFAULT '' COMMENT '客服消息自动回复文案(空=不自动回复)' AFTER `data_format`;
|
||||
|
||||
-- 结果校验
|
||||
SELECT id, name, app_id, enabled, token, encrypt_mode, data_format, push_reply
|
||||
FROM yz_platform_wechat_mini_app
|
||||
WHERE delete_time IS NULL;
|
||||
@@ -0,0 +1,45 @@
|
||||
-- ============================================================
|
||||
-- 小程序订阅消息:模板配置 + 授权记录
|
||||
-- yz_platform_wechat_mini_template 订阅消息模板(按小程序 + 场景码)
|
||||
-- yz_platform_wechat_mini_subscribe 用户授权记录(一次性订阅的剩余发送次数)
|
||||
--
|
||||
-- 模板 ID 需要在小程序后台「功能 → 订阅消息 → 公共模板库」选用后填入平台端。
|
||||
-- fields 为 JSON:微信模板字段键 → 语义变量名,例如
|
||||
-- {"thing1":"title","phrase2":"content","time3":"time"}
|
||||
--
|
||||
-- 幂等脚本:重复执行不会产生重复数据。
|
||||
-- 执行:mysql -h <host> -P <port> -u <user> -p <db> < create_wechat_mini_template.sql
|
||||
-- ============================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS yz_platform_wechat_mini_template (
|
||||
id bigint unsigned NOT NULL AUTO_INCREMENT,
|
||||
app_id varchar(64) NOT NULL COMMENT '小程序 AppID',
|
||||
code varchar(64) NOT NULL COMMENT '场景码 notice/approval/schedule...',
|
||||
template_id varchar(128) NOT NULL DEFAULT '' COMMENT '微信订阅消息模板 ID',
|
||||
title varchar(255) NOT NULL DEFAULT '' COMMENT '场景名称',
|
||||
page varchar(255) NOT NULL DEFAULT '' COMMENT '点击消息跳转的小程序页面',
|
||||
fields text NULL COMMENT 'JSON:微信字段键 → 语义变量名映射',
|
||||
enabled tinyint NOT NULL DEFAULT 1 COMMENT '1=启用 0=停用',
|
||||
remark varchar(255) NOT NULL DEFAULT '' COMMENT '备注',
|
||||
create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
update_time datetime NULL COMMENT '更新时间',
|
||||
delete_time datetime NULL COMMENT '软删时间',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_mini_tpl_app_code (app_id, code)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='小程序订阅消息模板';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS yz_platform_wechat_mini_subscribe (
|
||||
id bigint unsigned NOT NULL AUTO_INCREMENT,
|
||||
identity_id bigint unsigned NOT NULL COMMENT '身份 ID(yz_auth_identity.id)',
|
||||
app_id varchar(64) NOT NULL COMMENT '小程序 AppID',
|
||||
template_id varchar(128) NOT NULL COMMENT '微信订阅消息模板 ID',
|
||||
remain int NOT NULL DEFAULT 0 COMMENT '剩余可发送次数(一次性订阅)',
|
||||
create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
update_time datetime NULL COMMENT '更新时间',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_mini_sub_identity_tpl (identity_id, app_id, template_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='小程序订阅消息授权记录';
|
||||
|
||||
-- 结果校验
|
||||
SELECT id, app_id, code, template_id, title, page, enabled FROM yz_platform_wechat_mini_template
|
||||
WHERE delete_time IS NULL;
|
||||
@@ -0,0 +1,142 @@
|
||||
# 小程序订阅消息:配置与使用(怎么让用户收到提示)
|
||||
|
||||
## 一、先分清两条通道
|
||||
|
||||
| | 消息推送(服务器配置) | 订阅消息 |
|
||||
| --- | --- | --- |
|
||||
| 方向 | **用户 → 你**(用户发客服消息给你) | **你 → 用户**(你主动推提示给用户) |
|
||||
| 触发 | 用户在小程序点「客服」发消息 | 业务事件发生(审批待办、日程提醒、站内信…) |
|
||||
| 前置条件 | 无 | 用户必须在微信弹窗里点「允许」(一次性订阅:授权一次 = 1 次推送机会) |
|
||||
| 微信后台位置 | 开发管理 → 开发设置 → 消息推送 | 功能 → 订阅消息 |
|
||||
| 本工程实现 | `services/wechatmini/push.go` + `controllers/api_wechat_mini.go` | `services/wechatmini/subscribe.go` + `controllers/api_mini_subscribe.go` |
|
||||
|
||||
**结论**:想让用户「收到提示」,靠的是**订阅消息**,不是消息推送。消息推送只是接收端(顺带做客服自动回复)。
|
||||
|
||||
---
|
||||
|
||||
## 二、完整流程(4 步)
|
||||
|
||||
### 第 1 步:微信后台选一个订阅消息模板
|
||||
|
||||
小程序后台 → **功能 → 订阅消息 → 公共模板库 → 选用模板**(例如「审批进度通知」「待办提醒」)。
|
||||
|
||||
选完后记下两样东西:
|
||||
|
||||
- **模板 ID**(一串字母数字)
|
||||
- **模板的字段名**(如 `thing1`、`phrase2`、`time3`、`character_string4`)
|
||||
|
||||
> 模板选错会导致推送报 40066 / 47003,字段名对不上则报「模板字段不匹配」。
|
||||
|
||||
### 第 2 步:平台端登记模板
|
||||
|
||||
平台后台 → **系统设置 → 小程序配置 → 编辑小程序 → 订阅消息模板 → 新增模板**
|
||||
|
||||
| 字段 | 填什么 |
|
||||
| --- | --- |
|
||||
| 场景码 | `notice`(站内通知/通用提醒;同一个小程序下唯一) |
|
||||
| 场景名称 | 如「站内通知」 |
|
||||
| 模板 ID | 第 1 步复制的模板 ID |
|
||||
| 跳转页面 | 用户点击消息进入的小程序页面,如 `pages/index/index` |
|
||||
| 字段映射 | JSON:`{"thing1":"title","phrase2":"content","time3":"time"}` |
|
||||
|
||||
**字段映射怎么读**:左边是微信模板的字段名,右边是平台这边的变量名。平台会提供三个变量:
|
||||
|
||||
- `title` —— 标题(如「您有新的审批待办」)
|
||||
- `content` —— 内容
|
||||
- `time` —— 推送时间(`2026-10-31 19:00`)
|
||||
|
||||
映射里写了哪个字段,就把对应变量填进去;模板有但没映射的字段会自动填 `-`。
|
||||
|
||||
### 第 3 步:小程序端让用户授权
|
||||
|
||||
已接入的位置:**小程序「我的 → 消息通知」**,点一下会拉起微信的订阅授权弹窗。
|
||||
|
||||
```js
|
||||
import { requestSubscribeNotice } from '@/utils/subscribe.js'
|
||||
await requestSubscribeNotice()
|
||||
```
|
||||
|
||||
建议在**用户确实可能需要收到提醒**的时机也调用一次(比如进入工作台、提交完审批之后)——因为微信一次性订阅,授权一次只有一次推送机会,用完了要再授权。
|
||||
|
||||
小程序端也可以直接调接口:
|
||||
|
||||
- `GET /api/mini/subscribe/templates` 取模板列表
|
||||
- `POST /api/mini/subscribe/authorize` 上报授权结果
|
||||
- `POST /api/mini/subscribe/test` 给自己发一条测试消息
|
||||
|
||||
### 第 4 步:业务侧触发
|
||||
|
||||
已接入的触发点(业务照常发,订阅消息自动跟上,**不影响主流程**,失败只记日志):
|
||||
|
||||
| 业务 | 代码位置 |
|
||||
| --- | --- |
|
||||
| 站内信(平台端手动发通知) | `services/system_sitereminder.go` → `SendSiteReminder` |
|
||||
| OA 日程提醒(定时任务) | `services/reminder_scheduler.go` → `SiteMsgSender.Send` |
|
||||
| 审批待办(租户侧审批人生成待办) | `services/approval/engine.go` → `createTasks` |
|
||||
|
||||
自己接新场景时,直接调:
|
||||
|
||||
```go
|
||||
wechatmini.PushTenantNotice(租户用户ID, "标题", "内容")
|
||||
// 或指定场景码:
|
||||
wechatmini.PushSubscribeToTenantUser(租户用户ID, "notice", "", map[string]string{
|
||||
"title": "标题", "content": "内容", "time": "2026-10-31 19:00",
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、一次性订阅的规则(最容易踩坑)
|
||||
|
||||
- 用户点一次「允许」= **1 次**推送机会,服务器记在 `yz_platform_wechat_mini_subscribe.remain`
|
||||
- 推送成功自动 `remain - 1`;用户拒绝或机会用尽(微信返回 43101)自动清零
|
||||
- `remain = 0` 时不会调微信接口,直接跳过(不浪费配额)
|
||||
- 微信一次弹窗最多 3 个模板,前端已自动分批
|
||||
- 「长期订阅」只有部分类目能用,��务小程序基本用不了,所以要设计好**再次引导授权**的时机
|
||||
|
||||
---
|
||||
|
||||
## 四、常见错误码
|
||||
|
||||
| errcode | 含义 | 处理 |
|
||||
| --- | --- | --- |
|
||||
| 43101 | 用户未订阅 / 拒绝订阅 | 引导用户在小程序里重新点「消息通知」授权 |
|
||||
| 40066 | 模板 ID 无效 | 检查是不是填了「功能 → 订阅消息」里真正的模板 ID |
|
||||
| 47003 | 模板字段不匹配 | 检查字段映射与微信模板字段是否一致 |
|
||||
| 40003 | openid 无效 | 该用户不是用这个小程序登录的,或 openid 与 AppID 不匹配 |
|
||||
| 40001 | access_token 失效 | 工具会自动刷新,稍后重试 |
|
||||
|
||||
平台端「测试」按钮会直接返回中文排查建议。
|
||||
|
||||
---
|
||||
|
||||
## 五、接口清单
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| GET | `/api/mini/subscribe/templates` | 小程序端:可订阅模板列表(含剩余次数) |
|
||||
| POST | `/api/mini/subscribe/authorize` | 小程序端:上报授权结果 |
|
||||
| POST | `/api/mini/subscribe/test` | 小程序端:给自己发一条测试消息 |
|
||||
| GET | `/platform/wechatMini/templates` | 平台端:模板列表 |
|
||||
| POST | `/platform/wechatMini/template/save` | 平台端:保存模板 |
|
||||
| POST | `/platform/wechatMini/template/delete/:id` | 平台端:删除模板 |
|
||||
| POST | `/platform/wechatMini/template/test` | 平台端:给指定租户用户发测试消息 |
|
||||
|
||||
## 六、相关文件
|
||||
|
||||
| 文件 | 作用 |
|
||||
| --- | --- |
|
||||
| `go/services/wechatmini/subscribe.go` | 订阅消息 / 客服消息发送、模板 CRUD、授权记录、推送编排 |
|
||||
| `go/controllers/api_mini_subscribe.go` | 小程序端接口 |
|
||||
| `go/controllers/platform_wechat_mini.go` | 平台端模板管理接口 |
|
||||
| `go/models/wechat_mini.go` | `WechatMiniTemplate` / `WechatMiniSubscribe` 模型 |
|
||||
| `go/docs/sql/create_wechat_mini_template.sql` | 建表脚本 |
|
||||
| `uniapp/utils/subscribe.js` | 小程序端授权助手 |
|
||||
| `uniapp/api/subscribe.js` | 小程序端接口封装 |
|
||||
| `uniapp/pages/profile/profile.vue` | 「我的 → 消息通知」授权入口 |
|
||||
|
||||
建表脚本:
|
||||
|
||||
```bash
|
||||
mysql -h <host> -P <port> -u <user> -p <db> < go/docs/sql/create_wechat_mini_template.sql
|
||||
```
|
||||
@@ -0,0 +1,135 @@
|
||||
# 微信小程序「消息推送」配置指引
|
||||
|
||||
对应小程序后台:**开发管理 → 开发设置 → 消息推送**(就是你截图里的那个表单)。
|
||||
|
||||
---
|
||||
|
||||
## 0. 先搞清楚这个配置是干什么的
|
||||
|
||||
微信后台的「消息推送」= **你的服务器接收微信主动推送的入口**。
|
||||
|
||||
| 场景 | 是否走这个配置 |
|
||||
| --- | --- |
|
||||
| 用户给小程序「客服」发消息、进入客服会话 | ✅ 走(微信 POST 到你的 URL) |
|
||||
| 扫普通链接二维码打开小程序等事件 | ✅ 走 |
|
||||
| 订阅消息(`wx.requestSubscribeMessage`) | ❌ 不走,用服务端 API 主动发 |
|
||||
| 模板消息 | ❌ 已下线,改用订阅消息 |
|
||||
|
||||
---
|
||||
|
||||
## 1. 前置条件(不满足会一直「配置失败」)
|
||||
|
||||
1. **域名已 ICP 备案**(微信后台会校验)。
|
||||
2. **只用 80 / 443 端口**:`https://xxx.com`(443)或 `http://xxx.com`(80),其它端口微信不接受。
|
||||
3. **微信服务器能直连**:公网可达,不能是 `127.0.0.1`、内网 IP 或本机端口。
|
||||
4. **HTTPS 证书链完整**:只部署 `.crt` 不带中间证书,微信侧会握手失败(自签证书不行)。
|
||||
5. **5 秒内返回**:接口要在 5 秒内响应,否则微信断开并重试。
|
||||
|
||||
> 本项目的回调地址在 `go/conf/app.conf` 里配置:
|
||||
> `wechat_mini_callback_base = https://api.yunzer.cn`(留空则退化为相对路径 `/api/wechat/mini/callback`,需由网关补域名)。
|
||||
|
||||
---
|
||||
|
||||
## 2. 第一步:在本平台生成参数
|
||||
|
||||
平台端:**系统设置 → 小程序配置 → 编辑某个小程序 → 消息推送**
|
||||
|
||||
| 字段 | 操作 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| 回调地址 | 自动生成,点「复制」 | 即 `https://你的域名/api/wechat/mini/callback` |
|
||||
| Token | 点「随机生成」 | 3-32 位字母数字 |
|
||||
| EncodingAESKey | 点「随机生成」 | 43 位字母数字,后台也有「随机生成」按钮,两边值必须一致 |
|
||||
| 加解密方式 | 明文 / 兼容 / 安全 | 与微信后台选择保持一致 |
|
||||
| 数据格式 | XML / JSON | 与微信后台选择保持一致 |
|
||||
| 自动回复文案 | 选填 | 留空 = 只接收不回复 |
|
||||
|
||||
填完点 **「保存消息推送配置」**(注意:这一步只存平台端,不会自动同步到微信后台)。
|
||||
|
||||
---
|
||||
|
||||
## 3. 第二步:在微信后台把值抄过去
|
||||
|
||||
小程序后台 → 开发管理 → 开发设置 → 消息推送,逐项对应:
|
||||
|
||||
| 后台字段 | 填什么 |
|
||||
| --- | --- |
|
||||
| URL(服务器地址) | 平台端「回调地址」整条复制,如 `https://api.yunzer.cn/api/wechat/mini/callback` |
|
||||
| Token(令牌) | 平台端「Token」 |
|
||||
| EncodingAESKey(消息加密密钥) | 平台端「EncodingAESKey」(43 位) |
|
||||
| 消息加密方式 | 与平台端一致(明文 / 兼容 / 安全) |
|
||||
| 数据格式 | 与平台端一致(XML / JSON) |
|
||||
|
||||
填完点「提交」,微信会立刻发一个 **GET** 请求到你的 URL 做校验,通过就提示配置成功。
|
||||
|
||||
⚠️ 配置**提交后立即生效**,切换加密方式或数据格式前,务必先把平台端改成同样的值,否则消息会被验签/解密失败直接丢弃。
|
||||
|
||||
---
|
||||
|
||||
## 4. 提交时微信做了什么(校验逻辑)
|
||||
|
||||
微信 GET 你的 URL,参数:`signature` `timestamp` `nonce` `echostr`。
|
||||
|
||||
- **明文 / 兼容模式**:服务端算 `sha1(sort(token, timestamp, nonce) 拼接)` 与 `signature` 比对,通过后**原样返回 `echostr`**。
|
||||
- **安全模式**:`echostr` 是密文,额外带 `msg_signature = sha1(sort(token, timestamp, nonce, echostr))`;服务端验签后解密,再返回解密结果。
|
||||
|
||||
对应实现:
|
||||
|
||||
- `go/services/wechatmini/push.go` —— 签名校验、AES 加解密、XML/JSON 解析、被动回复构造
|
||||
- `go/controllers/api_wechat_mini.go` —— `GET /api/wechat/mini/callback` 校验 + `POST` 收消息
|
||||
|
||||
**多小程序**:每个小程序在自己的后台单独配置,本平台按 AppID 分别维护 Token / AESKey;
|
||||
回调接口不携带 appid,服务端会逐个应用匹配签名,命中哪个就按哪个的密钥解密。
|
||||
|
||||
---
|
||||
|
||||
## 5. 收到消息后的行为
|
||||
|
||||
- 微信 **5 秒**内没收到响应会断开并重试,**总共重试 3 次**(间隔 15s / 5s / 5s)。
|
||||
- 返回**空串**或 `success` = 已处理,微信不再重试(我们的实现返回空串)。
|
||||
- 平台端配了「自动回复文案」时,5 秒内以被动回复形式把文案回给用户。
|
||||
- 复杂业务(例如按关键字回复、转发人工)可接管钩子:
|
||||
`services/wechatmini/push.go` 中的 `PushHandler`。
|
||||
|
||||
---
|
||||
|
||||
## 6. 常见报错排查
|
||||
|
||||
| 现象 | 常见原因 | 处理 |
|
||||
| --- | --- | --- |
|
||||
| 后台一直「配置失败」 | 域名未备案 / 端口不是 80、443 / 证书链不完整 / 返回内容不是 echostr | 逐条对照第 1 节 |
|
||||
| 后台提示超时 | 接口处理超过 5 秒 | 先只回 echostr,业务逻辑异步做 |
|
||||
| 后台提示 Token 校验失败 | 平台端 Token 与后台填的不一致 | 两边都复制一遍核对(区分大小写) |
|
||||
| 配置成功但收不到消息 | 加解密方式或数据格式两边不一致 | 平台端与后台改成一致 |
|
||||
| 小程序会话提示「客服暂时无法提供服务」 | 5 秒内没回复 | 配「自动回复文案」,或让接口先返回空串 |
|
||||
| 明文模式正常,切安全模式收不到 | EncodingAESKey 两边不一致 | 重新复制 43 位密钥 |
|
||||
|
||||
### 自测命令(明文模式)
|
||||
|
||||
```bash
|
||||
# signature = sha1(sort(token, timestamp, nonce) 拼接),用与平台端相同的 token
|
||||
curl "https://api.yunzer.cn/api/wechat/mini/callback?signature=<签名>×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 <host> -P <port> -u <user> -p <db> < go/docs/sql/alter_wechat_mini_app_push.sql
|
||||
```
|
||||
|
||||
`aes_key` 以 AES-256-GCM 加密入库(复用公众号的 `wechat_mp_secret_key`),接口只回显掩码。
|
||||
@@ -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"` 额外排除。
|
||||
@@ -246,6 +246,9 @@ func Init(_ string) {
|
||||
new(WechatMiniConfig),
|
||||
// 微信小程序多应用配置(docs/sql/create_wechat_mini_app.sql)
|
||||
new(WechatMiniApp),
|
||||
// 微信小程序订阅消息模板与授权记录(docs/sql/create_wechat_mini_template.sql)
|
||||
new(WechatMiniTemplate),
|
||||
new(WechatMiniSubscribe),
|
||||
|
||||
// 统一认证中心(UAC):docs/sql/create_auth_tables.sql,人工执行建表
|
||||
new(AuthIdentity),
|
||||
|
||||
@@ -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" }
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package verifyfiles
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// newTestResolver 造一个临时验证目录,返回 resolver。
|
||||
func newTestResolver(t *testing.T, cfg Config) *Resolver {
|
||||
t.Helper()
|
||||
r, err := New(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("创建 Resolver 失败: %v", err)
|
||||
}
|
||||
if r == nil {
|
||||
t.Fatal("Resolver 不应为 nil")
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// TestResolveAllowed 确认合法验证文件能被解析到磁盘路径。
|
||||
func TestResolveAllowed(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "1XXl8fPHr9.txt"), []byte("1XXl8fPHr9"), 0o644); err != nil {
|
||||
t.Fatalf("准备测试文件失败: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "baidu_verify_codexxx.html"), []byte("<html></html>"), 0o644); err != nil {
|
||||
t.Fatalf("准备测试文件失败: %v", err)
|
||||
}
|
||||
|
||||
r := newTestResolver(t, Config{Dir: dir})
|
||||
|
||||
for _, name := range []string{"1XXl8fPHr9.txt", "baidu_verify_codexxx.html"} {
|
||||
got, ok := r.Resolve("/" + name)
|
||||
if !ok {
|
||||
t.Fatalf("合法验证文件 /%s 未被解析出来", name)
|
||||
}
|
||||
want := filepath.Join(dir, name)
|
||||
if got != want {
|
||||
t.Fatalf("路径不对,期望 %s,实际 %s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveRejected 确认非法/敏感/不存在的路径一律不放行。
|
||||
func TestResolveRejected(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
for _, name := range []string{"secret.env", "notes.md", "index.html", "app.conf"} {
|
||||
if err := os.WriteFile(filepath.Join(dir, name), []byte("x"), 0o644); err != nil {
|
||||
t.Fatalf("准备测试文件失败: %v", err)
|
||||
}
|
||||
}
|
||||
if err := os.Mkdir(filepath.Join(dir, "sub.txt"), 0o755); err != nil {
|
||||
t.Fatalf("准备测试目录失败: %v", err)
|
||||
}
|
||||
|
||||
r := newTestResolver(t, Config{Dir: dir})
|
||||
|
||||
cases := []string{
|
||||
"/",
|
||||
"",
|
||||
"/secret.env", // 敏感后缀不在白名单
|
||||
"/notes.md", // 后缀不在白名单
|
||||
"/app.conf", // 配置文件不得外泄
|
||||
"/index.html", // 会被 ServeFile 重定向
|
||||
"/sub/dir.txt", // 只允许根路径单段文件名
|
||||
"/../secret.env", // 路径穿越
|
||||
"/1XXl8fPHr9.txt/../..", // 路径穿越
|
||||
"/missing.txt", // 文件不存在
|
||||
"/sub.txt", // 目录不是文件
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got, ok := r.Resolve(c); ok {
|
||||
t.Fatalf("路径 %q 不应被放行,实际解析为 %s", c, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAllowHost 确认域名白名单按「忽略端口 + 忽略大小写」匹配。
|
||||
func TestAllowHost(t *testing.T) {
|
||||
r := newTestResolver(t, Config{Dir: t.TempDir(), Hosts: "api.yunzer.cn"})
|
||||
|
||||
if !r.AllowHost("api.yunzer.cn") {
|
||||
t.Fatal("api.yunzer.cn 应被允许")
|
||||
}
|
||||
if !r.AllowHost("API.Yunzer.cn:443") {
|
||||
t.Fatal("带端口与大写的 api.yunzer.cn 应被允许")
|
||||
}
|
||||
if r.AllowHost("other.com") {
|
||||
t.Fatal("未在白名单里的域名不应被允许")
|
||||
}
|
||||
|
||||
// 未配置 hosts 时,所有走本服务的域名都生效
|
||||
all := newTestResolver(t, Config{Dir: t.TempDir()})
|
||||
if !all.AllowHost("api.yunzer.cn") || !all.AllowHost("dh2.fun") {
|
||||
t.Fatal("未配置 verify_hosts 时应允许所有域名")
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewDisabled 确认 dir = "-" 表示关闭该功能。
|
||||
func TestNewDisabled(t *testing.T) {
|
||||
r, err := New(Config{Dir: "-"})
|
||||
if err != nil {
|
||||
t.Fatalf("不应报错: %v", err)
|
||||
}
|
||||
if r != nil {
|
||||
t.Fatal("dir = \"-\" 时 Resolver 应为 nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDefaultExts 确认默认扩展名包含 .txt。
|
||||
func TestDefaultExts(t *testing.T) {
|
||||
r := newTestResolver(t, Config{Dir: t.TempDir()})
|
||||
found := false
|
||||
for _, e := range r.Exts() {
|
||||
if e == ".txt" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("默认扩展名应包含 .txt,实际 %v", r.Exts())
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -14,6 +14,9 @@ func Register() {
|
||||
// 微信公众号(服务号):配置 / 绑定 / 粉丝 / 推送 / 微信服务器回调
|
||||
RegisterWechatMpRoutes()
|
||||
|
||||
// 微信小程序:应用配置 / 消息推送回调 / 消息推送参数
|
||||
RegisterWechatMiniRoutes()
|
||||
|
||||
// 数据字典(字典列表 / 字典项)
|
||||
RegisterDictRoutes()
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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) != "" {
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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 为通知节点创建已读通知任务(不阻塞流程)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 列表查询
|
||||
|
||||
@@ -0,0 +1,438 @@
|
||||
// Package wechatmini 微信小程序「消息推送」服务端能力。
|
||||
//
|
||||
// 对应小程序后台:开发管理 → 开发设置 → 消息推送。
|
||||
// 后台需填写 URL / Token / EncodingAESKey / 加解密方式 / 数据格式,
|
||||
// 微信会在保存时向 URL 发一次 GET 校验(signature + echostr),
|
||||
// 保存成功后,用户向小程序客服发消息等事件会以 POST 推送到该 URL。
|
||||
//
|
||||
// 协议与公众号「服务器配置」一致(signature = sha1(sort(token,timestamp,nonce))),
|
||||
// 复用 services/wechatmp 的签名与 AES 加解密原语,本文件补齐小程序侧
|
||||
// 的多应用路由、JSON 数据格式与被动回复构造。
|
||||
package wechatmini
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"server/models"
|
||||
"server/services/wechatmp"
|
||||
|
||||
"github.com/beego/beego/v2/client/orm"
|
||||
beego "github.com/beego/beego/v2/server/web"
|
||||
)
|
||||
|
||||
// 消息数据格式(小程序后台「数据格式」)
|
||||
const (
|
||||
DataFormatXML = "xml"
|
||||
DataFormatJSON = "json"
|
||||
)
|
||||
|
||||
// PushConfig 小程序消息推送运行配置(敏感字段已解密)
|
||||
type PushConfig struct {
|
||||
AppID string
|
||||
Token string
|
||||
AESKey string // 明文(43 位 EncodingAESKey)
|
||||
EncryptMode string // plain / compatible / safe
|
||||
DataFormat string // xml / json
|
||||
ReplyText string // 客服消息自动回复文案(空 = 不自动回复)
|
||||
}
|
||||
|
||||
// ErrPushNotConfigured 未配置消息推送
|
||||
var ErrPushNotConfigured = errors.New("小程序消息推送未配置")
|
||||
|
||||
// pushConfigFromRow 读取行数据并解密 AESKey
|
||||
func pushConfigFromRow(row models.WechatMiniApp) (*PushConfig, error) {
|
||||
cfg := &PushConfig{
|
||||
AppID: strings.TrimSpace(row.AppID),
|
||||
Token: strings.TrimSpace(row.Token),
|
||||
EncryptMode: strings.TrimSpace(row.EncryptMode),
|
||||
DataFormat: strings.ToLower(strings.TrimSpace(row.DataFormat)),
|
||||
ReplyText: strings.TrimSpace(row.PushReply),
|
||||
}
|
||||
if cfg.EncryptMode == "" {
|
||||
cfg.EncryptMode = wechatmp.EncryptModePlain
|
||||
}
|
||||
if cfg.DataFormat == "" {
|
||||
cfg.DataFormat = DataFormatXML
|
||||
}
|
||||
if row.AESKey != "" {
|
||||
plain, err := wechatmp.DecryptSecret(row.AESKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg.AESKey = plain
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// LoadPushConfigs 读取全部已配置 Token 的小程序推送配置(未删除的记录)。
|
||||
// 微信回调不携带 appid(明文模式下 ToUserName 即 appid),故按应用逐个匹配签名。
|
||||
func LoadPushConfigs() ([]PushConfig, error) {
|
||||
var rows []models.WechatMiniApp
|
||||
_, err := models.Orm.QueryTable(new(models.WechatMiniApp)).
|
||||
Filter("delete_time__isnull", true).
|
||||
OrderBy("id").
|
||||
All(&rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
list := make([]PushConfig, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
if strings.TrimSpace(r.Token) == "" {
|
||||
continue // 未配置 Token 的应用不参与签名匹配
|
||||
}
|
||||
cfg, cerr := pushConfigFromRow(r)
|
||||
if cerr != nil {
|
||||
continue // 解密失败的应用跳过,避免阻塞其它小程序
|
||||
}
|
||||
list = append(list, *cfg)
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
||||
// LoadPushConfigByAppID 按 appid 读取单个小程序的推送配置
|
||||
func LoadPushConfigByAppID(appID string) (*PushConfig, error) {
|
||||
appID = strings.TrimSpace(appID)
|
||||
if appID == "" {
|
||||
return nil, ErrPushNotConfigured
|
||||
}
|
||||
var row models.WechatMiniApp
|
||||
err := models.Orm.QueryTable(new(models.WechatMiniApp)).
|
||||
Filter("app_id", appID).
|
||||
Filter("delete_time__isnull", true).
|
||||
One(&row)
|
||||
if err != nil {
|
||||
if err == orm.ErrNoRows {
|
||||
return nil, ErrPushNotConfigured
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return pushConfigFromRow(row)
|
||||
}
|
||||
|
||||
// SavePushConfigInput 保存推送配置入参(敏感字段支持掩码表示不修改)
|
||||
type SavePushConfigInput struct {
|
||||
AppID string
|
||||
Token string
|
||||
AESKey string // 掩码 / 空串 = 保持原值
|
||||
EncryptMode string
|
||||
DataFormat string
|
||||
ReplyText string
|
||||
}
|
||||
|
||||
// SavePushConfig 保存小程序消息推送配置
|
||||
func SavePushConfig(in SavePushConfigInput) error {
|
||||
appID := strings.TrimSpace(in.AppID)
|
||||
if appID == "" {
|
||||
return errors.New("AppID 不能为空")
|
||||
}
|
||||
mode := strings.TrimSpace(in.EncryptMode)
|
||||
switch mode {
|
||||
case wechatmp.EncryptModePlain, wechatmp.EncryptModeCompatible, wechatmp.EncryptModeSafe:
|
||||
case "":
|
||||
mode = wechatmp.EncryptModePlain
|
||||
default:
|
||||
return errors.New("加解密方式无效(plain/compatible/safe)")
|
||||
}
|
||||
format := strings.ToLower(strings.TrimSpace(in.DataFormat))
|
||||
switch format {
|
||||
case DataFormatXML, DataFormatJSON:
|
||||
case "":
|
||||
format = DataFormatXML
|
||||
default:
|
||||
return errors.New("数据格式无效(xml/json)")
|
||||
}
|
||||
token := strings.TrimSpace(in.Token)
|
||||
if token != "" && (len(token) < 3 || len(token) > 32) {
|
||||
return errors.New("Token 长度需为 3-32 个字符")
|
||||
}
|
||||
|
||||
var row models.WechatMiniApp
|
||||
err := models.Orm.QueryTable(new(models.WechatMiniApp)).
|
||||
Filter("app_id", appID).
|
||||
Filter("delete_time__isnull", true).
|
||||
One(&row)
|
||||
if err != nil {
|
||||
if err == orm.ErrNoRows {
|
||||
return errors.New("小程序不存在或已删除")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
aesKey := strings.TrimSpace(in.AESKey)
|
||||
if aesKey != "" && !wechatmp.IsMasked(aesKey) {
|
||||
if len(aesKey) != 43 {
|
||||
return errors.New("EncodingAESKey 长度必须为 43 位")
|
||||
}
|
||||
enc, eerr := wechatmp.EncryptSecret(aesKey)
|
||||
if eerr != nil {
|
||||
return eerr
|
||||
}
|
||||
row.AESKey = enc
|
||||
}
|
||||
if token != "" {
|
||||
row.Token = token
|
||||
}
|
||||
row.EncryptMode = mode
|
||||
row.DataFormat = format
|
||||
row.PushReply = strings.TrimSpace(in.ReplyText)
|
||||
now := time.Now()
|
||||
row.UpdateTime = &now
|
||||
_, err = models.Orm.Update(&row)
|
||||
return err
|
||||
}
|
||||
|
||||
// PushCallbackURL 小程序消息推送回调地址(填到小程序后台「消息推送 - URL」)。
|
||||
// 优先读取 app.conf 的 wechat_mini_callback_base,其次回落 wechat_mp_callback_base
|
||||
// / payment_callback_base(均为对外 API 域名)。
|
||||
func PushCallbackURL() string {
|
||||
base, _ := beego.AppConfig.String("wechat_mini_callback_base")
|
||||
if strings.TrimSpace(base) == "" {
|
||||
base, _ = beego.AppConfig.String("wechat_mp_callback_base")
|
||||
}
|
||||
if strings.TrimSpace(base) == "" {
|
||||
base, _ = beego.AppConfig.String("payment_callback_base")
|
||||
}
|
||||
base = strings.TrimRight(strings.TrimSpace(base), "/")
|
||||
if base == "" {
|
||||
return "/api/wechat/mini/callback"
|
||||
}
|
||||
return base + "/api/wechat/mini/callback"
|
||||
}
|
||||
|
||||
// ============================ 随机 Token / EncodingAESKey ============================
|
||||
|
||||
const aesKeyAlphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
|
||||
// randomString 从指定字符集随机取 n 个字符(crypto/rand 源)
|
||||
func randomString(alphabet string, n int) string {
|
||||
b := []byte(alphabet)
|
||||
out := make([]byte, n)
|
||||
max := big.NewInt(int64(len(b)))
|
||||
for i := range out {
|
||||
idx, err := rand.Int(rand.Reader, max)
|
||||
if err != nil {
|
||||
// 随机源异常时退化为时间派生,保证不返回空串
|
||||
out[i] = b[(time.Now().UnixNano()+int64(i))%int64(len(b))]
|
||||
continue
|
||||
}
|
||||
out[i] = b[idx.Int64()]
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
// GeneratePushToken 生成消息推送 Token(3-32 位字母数字)
|
||||
func GeneratePushToken() string {
|
||||
return randomString(aesKeyAlphabet, 24)
|
||||
}
|
||||
|
||||
// GeneratePushAESKey 生成消息推送 EncodingAESKey(43 位字母数字)
|
||||
// 43 位 base64 字符解码后为 32 字节,符合微信 AES-256-CBC 的密钥长度要求。
|
||||
func GeneratePushAESKey() string {
|
||||
return randomString(aesKeyAlphabet, 43)
|
||||
}
|
||||
|
||||
// ============================ 消息解析 ============================
|
||||
|
||||
// PushMessage 小程序推送的消息 / 事件(已解密)
|
||||
type PushMessage struct {
|
||||
ToUserName string
|
||||
FromUserName string
|
||||
CreateTime int64
|
||||
MsgType string // text / image / event ...
|
||||
Content string
|
||||
Event string // subscribe / unsubscribe / SCAN / CLICK ...
|
||||
EventKey string
|
||||
MsgID int64
|
||||
Encrypt string // 兼容/安全模式外层携带的密文
|
||||
MsgSignature string
|
||||
TimeStamp string
|
||||
Nonce string
|
||||
}
|
||||
|
||||
// IsEvent 是否为事件推送
|
||||
func (m *PushMessage) IsEvent() bool { return strings.EqualFold(m.MsgType, "event") }
|
||||
|
||||
// pushXML 微信 XML 推送体
|
||||
type pushXML struct {
|
||||
XMLName struct{} `xml:"xml"`
|
||||
ToUserName string `xml:"ToUserName"`
|
||||
FromUserName string `xml:"FromUserName"`
|
||||
CreateTime int64 `xml:"CreateTime"`
|
||||
MsgType string `xml:"MsgType"`
|
||||
Content string `xml:"Content"`
|
||||
Event string `xml:"Event"`
|
||||
EventKey string `xml:"EventKey"`
|
||||
MsgID int64 `xml:"MsgId"`
|
||||
Encrypt string `xml:"Encrypt"`
|
||||
MsgSignature string `xml:"MsgSignature"`
|
||||
TimeStamp string `xml:"TimeStamp"`
|
||||
Nonce string `xml:"Nonce"`
|
||||
}
|
||||
|
||||
// pushJSON 微信 JSON 推送体
|
||||
type pushJSON struct {
|
||||
ToUserName string `json:"ToUserName"`
|
||||
FromUserName string `json:"FromUserName"`
|
||||
CreateTime int64 `json:"CreateTime"`
|
||||
MsgType string `json:"MsgType"`
|
||||
Content string `json:"Content"`
|
||||
Event string `json:"Event"`
|
||||
EventKey string `json:"EventKey"`
|
||||
MsgID int64 `json:"MsgId"`
|
||||
Encrypt string `json:"Encrypt"`
|
||||
MsgSignature string `json:"MsgSignature"`
|
||||
TimeStamp string `json:"TimeStamp"`
|
||||
Nonce string `json:"Nonce"`
|
||||
}
|
||||
|
||||
// ParsePushXML 解析 XML 推送体
|
||||
func ParsePushXML(raw string) (*PushMessage, error) {
|
||||
var p pushXML
|
||||
if err := xml.Unmarshal([]byte(raw), &p); err != nil {
|
||||
return nil, fmt.Errorf("解析微信推送消息失败: %w", err)
|
||||
}
|
||||
return &PushMessage{
|
||||
ToUserName: p.ToUserName,
|
||||
FromUserName: p.FromUserName,
|
||||
CreateTime: p.CreateTime,
|
||||
MsgType: p.MsgType,
|
||||
Content: p.Content,
|
||||
Event: p.Event,
|
||||
EventKey: p.EventKey,
|
||||
MsgID: p.MsgID,
|
||||
Encrypt: p.Encrypt,
|
||||
MsgSignature: p.MsgSignature,
|
||||
TimeStamp: p.TimeStamp,
|
||||
Nonce: p.Nonce,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ParsePushJSON 解析 JSON 推送体
|
||||
func ParsePushJSON(raw string) (*PushMessage, error) {
|
||||
var p pushJSON
|
||||
if err := json.Unmarshal([]byte(raw), &p); err != nil {
|
||||
return nil, fmt.Errorf("解析微信推送消息失败: %w", err)
|
||||
}
|
||||
return &PushMessage{
|
||||
ToUserName: p.ToUserName,
|
||||
FromUserName: p.FromUserName,
|
||||
CreateTime: p.CreateTime,
|
||||
MsgType: p.MsgType,
|
||||
Content: p.Content,
|
||||
Event: p.Event,
|
||||
EventKey: p.EventKey,
|
||||
MsgID: p.MsgID,
|
||||
Encrypt: p.Encrypt,
|
||||
MsgSignature: p.MsgSignature,
|
||||
TimeStamp: p.TimeStamp,
|
||||
Nonce: p.Nonce,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// DetectPushFormat 根据请求体判断数据格式(JSON 以 { 开头)
|
||||
func DetectPushFormat(raw string) string {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if strings.HasPrefix(trimmed, "{") {
|
||||
return DataFormatJSON
|
||||
}
|
||||
return DataFormatXML
|
||||
}
|
||||
|
||||
// ============================ 被动回复构造 ============================
|
||||
|
||||
// BuildTextReply 构造文本被动回复(按数据格式输出 XML / JSON)
|
||||
func BuildTextReply(format, toUser, fromUser, content string, timestamp int64) (string, error) {
|
||||
if format == DataFormatJSON {
|
||||
payload := map[string]interface{}{
|
||||
"ToUserName": toUser,
|
||||
"FromUserName": fromUser,
|
||||
"CreateTime": timestamp,
|
||||
"MsgType": "text",
|
||||
"Content": content,
|
||||
}
|
||||
bs, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(bs), nil
|
||||
}
|
||||
esc := strings.NewReplacer("]]>", "]] >")
|
||||
return fmt.Sprintf(
|
||||
`<xml><ToUserName><![CDATA[%s]]></ToUserName><FromUserName><![CDATA[%s]]></FromUserName><CreateTime>%d</CreateTime><MsgType><![CDATA[text]]></MsgType><Content><![CDATA[%s]]></Content></xml>`,
|
||||
esc.Replace(toUser), esc.Replace(fromUser), timestamp, esc.Replace(content)), nil
|
||||
}
|
||||
|
||||
// BuildEncryptedReply 构造加密模式的被动回复(安全模式 / 兼容模式共用)
|
||||
func BuildEncryptedReply(cfg *PushConfig, plainReply, timestamp, nonce, format string) (string, error) {
|
||||
encrypt, err := wechatmp.EncryptMessage(cfg.AESKey, cfg.AppID, plainReply)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
sig := wechatmp.MsgSignature(cfg.Token, timestamp, nonce, encrypt)
|
||||
if format == DataFormatJSON {
|
||||
payload := map[string]interface{}{
|
||||
"Encrypt": encrypt,
|
||||
"MsgSignature": sig,
|
||||
"TimeStamp": timestamp,
|
||||
"Nonce": nonce,
|
||||
}
|
||||
bs, merr := json.Marshal(payload)
|
||||
if merr != nil {
|
||||
return "", merr
|
||||
}
|
||||
return string(bs), nil
|
||||
}
|
||||
return fmt.Sprintf(
|
||||
`<xml><Encrypt><![CDATA[%s]]></Encrypt><MsgSignature><![CDATA[%s]]></MsgSignature><TimeStamp>%s</TimeStamp><Nonce><![CDATA[%s]]></Nonce></xml>`,
|
||||
encrypt, sig, timestamp, nonce), nil
|
||||
}
|
||||
|
||||
// ============================ 多应用匹配 ============================
|
||||
|
||||
// MatchPushConfigBySignature 按明文签名匹配小程序(sha1(sort(token,timestamp,nonce)))
|
||||
func MatchPushConfigBySignature(cfgs []PushConfig, timestamp, nonce, signature string) (*PushConfig, bool) {
|
||||
for i := range cfgs {
|
||||
if wechatmp.CheckSignature(cfgs[i].Token, timestamp, nonce, signature) {
|
||||
return &cfgs[i], true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// MatchPushConfigByMsgSignature 按加密消息签名匹配小程序
|
||||
// (sha1(sort(token,timestamp,nonce,encrypt)))
|
||||
func MatchPushConfigByMsgSignature(cfgs []PushConfig, timestamp, nonce, encrypt, msgSignature string) (*PushConfig, bool) {
|
||||
for i := range cfgs {
|
||||
if wechatmp.CheckMsgSignature(cfgs[i].Token, timestamp, nonce, encrypt, msgSignature) {
|
||||
return &cfgs[i], true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// DecryptPushMessage 用匹配的 AESKey 解密消息体(安全 / 兼容模式)
|
||||
func DecryptPushMessage(cfg *PushConfig, encrypt string) (string, error) {
|
||||
return wechatmp.DecryptMessage(cfg.AESKey, cfg.AppID, encrypt)
|
||||
}
|
||||
|
||||
// PushHandler 消息处理钩子:返回给用户的被动回复文案,空串表示不回复。
|
||||
// 默认按小程序配置的「客服自动回复文案」回复,可在业务层覆盖(如按关键字回复)。
|
||||
var PushHandler func(cfg *PushConfig, msg *PushMessage) string
|
||||
|
||||
// HandlePushMessage 处理推送消息并返回被动回复文案
|
||||
func HandlePushMessage(cfg *PushConfig, msg *PushMessage) string {
|
||||
if PushHandler != nil {
|
||||
return strings.TrimSpace(PushHandler(cfg, msg))
|
||||
}
|
||||
if cfg == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(cfg.ReplyText)
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package wechatmini
|
||||
|
||||
import (
|
||||
"crypto/sha1"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"server/services/wechatmp"
|
||||
)
|
||||
|
||||
// sign 复刻微信签名算法:sha1(sort(token, timestamp, nonce))
|
||||
func sign(token, timestamp, nonce string) string {
|
||||
arr := []string{token, timestamp, nonce}
|
||||
sort.Strings(arr)
|
||||
sum := sha1.Sum([]byte(strings.Join(arr, "")))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func TestGeneratePushSecrets(t *testing.T) {
|
||||
token := GeneratePushToken()
|
||||
if len(token) < 3 || len(token) > 32 {
|
||||
t.Fatalf("Token 长度非法: %d", len(token))
|
||||
}
|
||||
for _, c := range token {
|
||||
if !strings.ContainsRune(aesKeyAlphabet, c) {
|
||||
t.Fatalf("Token 含非法字符: %q", c)
|
||||
}
|
||||
}
|
||||
|
||||
key := GeneratePushAESKey()
|
||||
if len(key) != 43 {
|
||||
t.Fatalf("EncodingAESKey 应为 43 位,实际 %d", len(key))
|
||||
}
|
||||
for _, c := range key {
|
||||
if !strings.ContainsRune(aesKeyAlphabet, c) {
|
||||
t.Fatalf("EncodingAESKey 含非法字符: %q", c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 生成的 EncodingAESKey 必须能被微信 AES-256-CBC 直接使用
|
||||
func TestGeneratedAESKeyUsable(t *testing.T) {
|
||||
key := GeneratePushAESKey()
|
||||
plain := `<xml><ToUserName>wx123</ToUserName></xml>`
|
||||
cipherText, err := wechatmp.EncryptMessage(key, "wx123", plain)
|
||||
if err != nil {
|
||||
t.Fatalf("加密失败: %v", err)
|
||||
}
|
||||
got, err := wechatmp.DecryptMessage(key, "wx123", cipherText)
|
||||
if err != nil {
|
||||
t.Fatalf("解密失败: %v", err)
|
||||
}
|
||||
if got != plain {
|
||||
t.Fatalf("解密内容不一致: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchPushConfigBySignature(t *testing.T) {
|
||||
cfgs := []PushConfig{
|
||||
{AppID: "wxA", Token: "tokenA", EncryptMode: wechatmp.EncryptModePlain, DataFormat: DataFormatXML},
|
||||
{AppID: "wxB", Token: "tokenB", EncryptMode: wechatmp.EncryptModeSafe, DataFormat: DataFormatJSON},
|
||||
}
|
||||
ts, nonce := "1700000000", "abc123"
|
||||
|
||||
cfg, ok := MatchPushConfigBySignature(cfgs, ts, nonce, sign("tokenB", ts, nonce))
|
||||
if !ok || cfg.AppID != "wxB" {
|
||||
t.Fatalf("应匹配到 wxB,实际 ok=%v cfg=%v", ok, cfg)
|
||||
}
|
||||
if _, ok := MatchPushConfigBySignature(cfgs, ts, nonce, "bad-signature"); ok {
|
||||
t.Fatal("错误签名不应匹配")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePushXML(t *testing.T) {
|
||||
raw := `<xml><ToUserName>wxApp</ToUserName><FromUserName>oOpenId</FromUserName><CreateTime>1700000000</CreateTime><MsgType>text</MsgType><Content>你好</Content><MsgId>123456</MsgId></xml>`
|
||||
msg, err := ParsePushXML(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("解析失败: %v", err)
|
||||
}
|
||||
if msg.ToUserName != "wxApp" || msg.FromUserName != "oOpenId" || msg.Content != "你好" || msg.MsgID != 123456 {
|
||||
t.Fatalf("解析结果不符: %+v", msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePushJSON(t *testing.T) {
|
||||
raw := `{"ToUserName":"wxApp","FromUserName":"oOpenId","CreateTime":1700000000,"MsgType":"text","Content":"hello","MsgId":987654}`
|
||||
msg, err := ParsePushJSON(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("解析失败: %v", err)
|
||||
}
|
||||
if msg.ToUserName != "wxApp" || msg.Content != "hello" || msg.MsgID != 987654 {
|
||||
t.Fatalf("解析结果不符: %+v", msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectPushFormat(t *testing.T) {
|
||||
if got := DetectPushFormat(`{"a":1}`); got != DataFormatJSON {
|
||||
t.Fatalf("应为 JSON,实际 %s", got)
|
||||
}
|
||||
if got := DetectPushFormat("<xml></xml>"); got != DataFormatXML {
|
||||
t.Fatalf("应为 XML,实际 %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildTextReply(t *testing.T) {
|
||||
xmlReply, err := BuildTextReply(DataFormatXML, "oOpenId", "wxApp", "收到", 1700000000)
|
||||
if err != nil {
|
||||
t.Fatalf("构造 XML 回复失败: %v", err)
|
||||
}
|
||||
if !strings.Contains(xmlReply, "<Content><![CDATA[收到]]></Content>") {
|
||||
t.Fatalf("XML 回复内容不符: %s", xmlReply)
|
||||
}
|
||||
|
||||
jsonReply, err := BuildTextReply(DataFormatJSON, "oOpenId", "wxApp", "收到", 1700000000)
|
||||
if err != nil {
|
||||
t.Fatalf("构造 JSON 回复失败: %v", err)
|
||||
}
|
||||
var payload map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(jsonReply), &payload); err != nil {
|
||||
t.Fatalf("JSON 回复不是合法 JSON: %v", err)
|
||||
}
|
||||
if payload["Content"] != "收到" || payload["MsgType"] != "text" {
|
||||
t.Fatalf("JSON 回复内容不符: %s", jsonReply)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildEncryptedReply(t *testing.T) {
|
||||
cfg := &PushConfig{AppID: "wxApp", Token: "token", AESKey: GeneratePushAESKey(), EncryptMode: wechatmp.EncryptModeSafe, DataFormat: DataFormatJSON}
|
||||
plain, err := BuildTextReply(DataFormatJSON, "oOpenId", "wxApp", "收到", 1700000000)
|
||||
if err != nil {
|
||||
t.Fatalf("构造明文回复失败: %v", err)
|
||||
}
|
||||
reply, err := BuildEncryptedReply(cfg, plain, "1700000000", "nonce1", DataFormatJSON)
|
||||
if err != nil {
|
||||
t.Fatalf("构造加密回复失败: %v", err)
|
||||
}
|
||||
var payload map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(reply), &payload); err != nil {
|
||||
t.Fatalf("加密回复不是合法 JSON: %v", err)
|
||||
}
|
||||
enc, _ := payload["Encrypt"].(string)
|
||||
if enc == "" {
|
||||
t.Fatal("加密回复缺少 Encrypt")
|
||||
}
|
||||
if payload["MsgSignature"] != wechatmp.MsgSignature(cfg.Token, "1700000000", "nonce1", enc) {
|
||||
t.Fatal("加密回复签名不正确")
|
||||
}
|
||||
decrypted, err := wechatmp.DecryptMessage(cfg.AESKey, cfg.AppID, enc)
|
||||
if err != nil {
|
||||
t.Fatalf("解密回复失败: %v", err)
|
||||
}
|
||||
if decrypted != plain {
|
||||
t.Fatalf("解密回复内容不符: %s", decrypted)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
1XXl8fPHr9
|
||||
@@ -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` 即为公网可达。
|
||||
@@ -39,3 +39,91 @@ export function deleteMiniApp(id) {
|
||||
method: "post",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 消息推送配置(对应小程序后台「开发管理 → 开发设置 → 消息推送」)
|
||||
*/
|
||||
|
||||
/** 获取指定小程序的推送配置(含回调地址,aes_key 掩码回显) */
|
||||
export function getMiniAppPush(appId) {
|
||||
return request({
|
||||
url: "/platform/wechatMini/app/push",
|
||||
method: "get",
|
||||
params: { app_id: appId },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存推送配置
|
||||
* @param {Object} data
|
||||
* @param {string} data.app_id 小程序 AppID
|
||||
* @param {string} [data.token] 令牌(3-32 位字母数字)
|
||||
* @param {string} [data.aes_key] 消息加解密密钥(43 位,掩码/空串 = 不修改原值)
|
||||
* @param {string} [data.encrypt_mode] plain / compatible / safe
|
||||
* @param {string} [data.data_format] xml / json
|
||||
* @param {string} [data.push_reply] 客服消息自动回复文案
|
||||
*/
|
||||
export function saveMiniAppPush(data) {
|
||||
return request({
|
||||
url: "/platform/wechatMini/app/push/save",
|
||||
method: "post",
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
/** 生成随机 Token 与 EncodingAESKey */
|
||||
export function generateMiniAppPushSecret() {
|
||||
return request({
|
||||
url: "/platform/wechatMini/app/push/generate",
|
||||
method: "post",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 订阅消息模板(小程序后台「功能 → 订阅消息」选用模板后登记)
|
||||
*/
|
||||
|
||||
/** 模板列表 */
|
||||
export function listMiniTemplates(appId) {
|
||||
return request({
|
||||
url: "/platform/wechatMini/templates",
|
||||
method: "get",
|
||||
params: { app_id: appId },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存模板
|
||||
* @param {Object} data
|
||||
* @param {string} data.app_id 小程序 AppID
|
||||
* @param {string} data.code 场景码,如 notice
|
||||
* @param {string} data.template_id 微信模板 ID
|
||||
* @param {string} [data.title] 场景名称
|
||||
* @param {string} [data.page] 点击消息跳转的小程序页面
|
||||
* @param {string} [data.fields] JSON:微信字段键 → 语义变量名,如 {"thing1":"title"}
|
||||
* @param {number} data.enabled 1=启用 0=停用
|
||||
*/
|
||||
export function saveMiniTemplate(data) {
|
||||
return request({
|
||||
url: "/platform/wechatMini/template/save",
|
||||
method: "post",
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
/** 删除模板 */
|
||||
export function deleteMiniTemplate(id) {
|
||||
return request({
|
||||
url: `/platform/wechatMini/template/delete/${id}`,
|
||||
method: "post",
|
||||
});
|
||||
}
|
||||
|
||||
/** 给指定租户用户发一条测试订阅消息 */
|
||||
export function testMiniTemplate(data) {
|
||||
return request({
|
||||
url: "/platform/wechatMini/template/test",
|
||||
method: "post",
|
||||
data,
|
||||
});
|
||||
}
|
||||
@@ -61,6 +61,13 @@
|
||||
<span v-else class="empty-tip">未上传</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="消息推送" width="100" align="center">
|
||||
<template #default="scope">
|
||||
<el-tag :type="scope.row.token ? 'success' : 'info'" size="small">
|
||||
{{ scope.row.token ? "已配置" : "未配置" }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="启用" width="80" align="center">
|
||||
<template #default="scope">
|
||||
<el-switch
|
||||
@@ -197,6 +204,157 @@
|
||||
placeholder="选填"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 消息推送配置(对应小程序后台「开发管理 → 开发设置 → 消息推送」) -->
|
||||
<el-divider content-position="left">
|
||||
<span class="divider-text">消息推送</span>
|
||||
</el-divider>
|
||||
|
||||
<el-alert
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="push-alert"
|
||||
title="把下面的「回调地址 / Token / EncodingAESKey」原样填到小程序后台「开发管理 → 开发设置 → 消息推送」,加解密方式与数据格式需与后台保持一致。"
|
||||
/>
|
||||
|
||||
<el-form-item label="回调地址">
|
||||
<div class="callback-field">
|
||||
<el-input
|
||||
v-model="pushForm.callback_url"
|
||||
readonly
|
||||
placeholder="保存后由服务端生成"
|
||||
/>
|
||||
<el-button size="small" @click="copyText(pushForm.callback_url)">
|
||||
复制
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="form-tip block">
|
||||
必须以 http:// 或 https:// 开头(80 / 443 端口),且微信服务器能直接访问。
|
||||
若显示为相对路径,请自行补上域名,如
|
||||
<code>https://api.yunzer.cn/api/wechat/mini/callback</code>。
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="Token">
|
||||
<div class="callback-field">
|
||||
<el-input
|
||||
v-model="pushForm.token"
|
||||
placeholder="3-32 位字母数字"
|
||||
maxlength="32"
|
||||
/>
|
||||
<el-button size="small" @click="generateSecrets">
|
||||
随机生成
|
||||
</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="EncodingAESKey">
|
||||
<el-input
|
||||
v-model="pushForm.aes_key"
|
||||
type="password"
|
||||
show-password
|
||||
:placeholder="
|
||||
pushConfigured ? '已配置,留空 / 掩码表示不修改' : '43 位字母数字'
|
||||
"
|
||||
maxlength="43"
|
||||
/>
|
||||
<div class="form-tip block">
|
||||
安全 / 兼容模式必填,与小程序后台填写的 EncodingAESKey 必须完全一致。
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="加解密方式">
|
||||
<el-radio-group v-model="pushForm.encrypt_mode">
|
||||
<el-radio value="plain">明文模式</el-radio>
|
||||
<el-radio value="compatible">兼容模式</el-radio>
|
||||
<el-radio value="safe">安全模式</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="数据格式">
|
||||
<el-radio-group v-model="pushForm.data_format">
|
||||
<el-radio value="xml">XML</el-radio>
|
||||
<el-radio value="json">JSON</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="自动回复文案">
|
||||
<el-input
|
||||
v-model="pushForm.push_reply"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
maxlength="500"
|
||||
placeholder="选填。用户发来客服消息时,5 秒内自动回复该文案;留空表示只接收不回复。"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-button
|
||||
type="primary"
|
||||
:loading="pushSaving"
|
||||
:disabled="!form.id"
|
||||
@click="handleSavePush"
|
||||
>
|
||||
保存消息推送配置
|
||||
</el-button>
|
||||
<span v-if="!form.id" class="form-tip">请先保存小程序,再配置消息推送</span>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 订阅消息模板 -->
|
||||
<el-divider content-position="left">
|
||||
<span class="divider-text">订阅消息模板</span>
|
||||
</el-divider>
|
||||
|
||||
<el-alert
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="push-alert"
|
||||
title="要主动给用户发提示(审批待办、站内通知等),需要用「订阅消息」:先在小程序后台「功能 → 订阅消息 → 公共模板库」选一个模板,把模板 ID 和字段填到下面。"
|
||||
/>
|
||||
|
||||
<el-table :data="templates" size="small" style="width: 100%">
|
||||
<el-table-column prop="code" label="场景码" width="110" align="center" />
|
||||
<el-table-column prop="title" label="场景名称" min-width="120" align="center">
|
||||
<template #default="{ row }">{{ row.title || "-" }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
prop="template_id"
|
||||
label="模板 ID"
|
||||
min-width="200"
|
||||
align="center"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<el-table-column label="启用" width="70" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.enabled === 1 ? 'success' : 'info'" size="small">
|
||||
{{ row.enabled === 1 ? "启用" : "停用" }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="175" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" type="primary" link @click="openTemplate(row)">
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button size="small" type="success" link @click="sendTestTemplate(row)">
|
||||
测试
|
||||
</el-button>
|
||||
<el-button size="small" type="danger" link @click="removeTemplate(row)">
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-button
|
||||
size="small"
|
||||
style="margin-top: 10px"
|
||||
:disabled="!form.id"
|
||||
@click="openTemplate()"
|
||||
>
|
||||
新增模板
|
||||
</el-button>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
@@ -205,6 +363,65 @@
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
<!-- 订阅消息模板编辑对话框 -->
|
||||
<el-dialog
|
||||
v-model="templateDialogVisible"
|
||||
title="订阅消息模板"
|
||||
width="560px"
|
||||
append-to-body
|
||||
>
|
||||
<el-form :model="templateForm" label-width="100px">
|
||||
<el-form-item label="场景码">
|
||||
<el-input
|
||||
v-model="templateForm.code"
|
||||
placeholder="如 notice(站内通知)"
|
||||
:disabled="!!templateForm.id"
|
||||
/>
|
||||
<div class="form-tip block">
|
||||
业务侧按场景码触发:notice = 站内信 / 审批待办等通用提醒
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="场景名称">
|
||||
<el-input v-model="templateForm.title" placeholder="如 站内通知" maxlength="64" />
|
||||
</el-form-item>
|
||||
<el-form-item label="模板 ID">
|
||||
<el-input
|
||||
v-model="templateForm.template_id"
|
||||
placeholder="小程序后台订阅消息模板的 ID"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="跳转页面">
|
||||
<el-input
|
||||
v-model="templateForm.page"
|
||||
placeholder="如 pages/index/index(点击消息进入的小程序页面)"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="字段映射">
|
||||
<el-input
|
||||
v-model="templateForm.fields"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder='{"thing1":"title","phrase2":"content","time3":"time"}'
|
||||
/>
|
||||
<div class="form-tip block">
|
||||
左边是微信模板的字段名,右边是平台这边的变量名。可用变量:
|
||||
<code>title</code>(标题)、<code>content</code>(内容)、<code>time</code>(时间)
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="启用">
|
||||
<el-switch v-model="templateForm.enabled" :active-value="1" :inactive-value="0" />
|
||||
</el-form-item>
|
||||
<el-form-item label="备注">
|
||||
<el-input v-model="templateForm.remark" type="textarea" :rows="2" maxlength="200" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="templateDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="templateSaving" @click="handleSaveTemplate">
|
||||
保存
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -212,7 +429,18 @@
|
||||
import { ref, onMounted } from "vue";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { Plus } from "@element-plus/icons-vue";
|
||||
import { listMiniApps, saveMiniApp, deleteMiniApp } from "@/api/miniApp";
|
||||
import {
|
||||
listMiniApps,
|
||||
saveMiniApp,
|
||||
deleteMiniApp,
|
||||
getMiniAppPush,
|
||||
saveMiniAppPush,
|
||||
generateMiniAppPushSecret,
|
||||
listMiniTemplates,
|
||||
saveMiniTemplate,
|
||||
deleteMiniTemplate,
|
||||
testMiniTemplate,
|
||||
} from "@/api/miniApp";
|
||||
import { formatDateTime } from "@/utils/datetime";
|
||||
import { smartUpload } from "@/utils/qiniuUpload";
|
||||
|
||||
@@ -228,10 +456,15 @@ const previewUrl = (url) => {
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const qrcodeUploading = ref(false);
|
||||
const pushSaving = ref(false);
|
||||
const templateSaving = ref(false);
|
||||
const templateDialogVisible = ref(false);
|
||||
const templates = ref([]);
|
||||
const apps = ref([]);
|
||||
const dialogVisible = ref(false);
|
||||
const formRef = ref(null);
|
||||
const secretConfigured = ref(false);
|
||||
const pushConfigured = ref(false);
|
||||
|
||||
const emptyForm = () => ({
|
||||
id: 0,
|
||||
@@ -244,7 +477,31 @@ const emptyForm = () => ({
|
||||
remark: "",
|
||||
});
|
||||
|
||||
const emptyPushForm = () => ({
|
||||
app_id: "",
|
||||
token: "",
|
||||
aes_key: "",
|
||||
encrypt_mode: "plain",
|
||||
data_format: "xml",
|
||||
push_reply: "",
|
||||
callback_url: "",
|
||||
});
|
||||
|
||||
const emptyTemplateForm = () => ({
|
||||
id: 0,
|
||||
app_id: "",
|
||||
code: "notice",
|
||||
template_id: "",
|
||||
title: "站内通知",
|
||||
page: "",
|
||||
fields: '{"thing1":"title","phrase2":"content","time3":"time"}',
|
||||
enabled: 1,
|
||||
remark: "",
|
||||
});
|
||||
|
||||
const form = ref(emptyForm());
|
||||
const pushForm = ref(emptyPushForm());
|
||||
const templateForm = ref(emptyTemplateForm());
|
||||
|
||||
const rules = {
|
||||
name: [{ required: true, message: "请输入应用名称", trigger: "blur" }],
|
||||
@@ -273,6 +530,9 @@ const fetchApps = async () => {
|
||||
const openAdd = () => {
|
||||
form.value = emptyForm();
|
||||
secretConfigured.value = false;
|
||||
pushConfigured.value = false;
|
||||
pushForm.value = emptyPushForm();
|
||||
templates.value = [];
|
||||
dialogVisible.value = true;
|
||||
};
|
||||
|
||||
@@ -289,6 +549,199 @@ const openEdit = (row) => {
|
||||
};
|
||||
secretConfigured.value = !!row.configured;
|
||||
dialogVisible.value = true;
|
||||
fetchPushConfig(row.app_id);
|
||||
fetchTemplates(row.app_id);
|
||||
};
|
||||
|
||||
// 读取订阅消息模板列表
|
||||
const fetchTemplates = async (appId) => {
|
||||
if (!appId) return;
|
||||
try {
|
||||
const res = await listMiniTemplates(appId);
|
||||
if (res.code === 200) {
|
||||
templates.value = res.data?.list || [];
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error(error?.message || "获取模板列表失败");
|
||||
}
|
||||
};
|
||||
|
||||
// 新增 / 编辑模板
|
||||
const openTemplate = (row) => {
|
||||
if (row) {
|
||||
templateForm.value = { ...row };
|
||||
} else {
|
||||
templateForm.value = {
|
||||
...emptyTemplateForm(),
|
||||
app_id: form.value.app_id,
|
||||
};
|
||||
}
|
||||
templateDialogVisible.value = true;
|
||||
};
|
||||
|
||||
// 保存模板
|
||||
const handleSaveTemplate = async () => {
|
||||
if (!templateForm.value.code || !templateForm.value.template_id) {
|
||||
ElMessage.warning("请填写场景码和模板 ID");
|
||||
return;
|
||||
}
|
||||
templateSaving.value = true;
|
||||
try {
|
||||
const res = await saveMiniTemplate({ ...templateForm.value });
|
||||
if (res.code === 200) {
|
||||
ElMessage.success(res.msg || "已保存");
|
||||
templateDialogVisible.value = false;
|
||||
fetchTemplates(form.value.app_id);
|
||||
} else {
|
||||
ElMessage.error(res.msg || "保存失败");
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error(error?.message || "保存失败");
|
||||
} finally {
|
||||
templateSaving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 删除模板
|
||||
const removeTemplate = async (row) => {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认删除模板「${row.title || row.code}」?`, "删除模板", {
|
||||
confirmButtonText: "确定",
|
||||
cancelButtonText: "取消",
|
||||
type: "warning",
|
||||
});
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await deleteMiniTemplate(row.id);
|
||||
if (res.code === 200) {
|
||||
ElMessage.success("已删除");
|
||||
fetchTemplates(form.value.app_id);
|
||||
} else {
|
||||
ElMessage.error(res.msg || "删除失败");
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error(error?.message || "删除失败");
|
||||
}
|
||||
};
|
||||
|
||||
// 测试发送:需要指定一个租户用户(该用户需已授权订阅)
|
||||
const sendTestTemplate = async (row) => {
|
||||
let userId = "";
|
||||
try {
|
||||
const { value } = await ElMessageBox.prompt(
|
||||
"请输入测试用的租户用户 ID(该用户必须已用小程序登录并授权过订阅)",
|
||||
"发送测试消息",
|
||||
{
|
||||
confirmButtonText: "发送",
|
||||
cancelButtonText: "取消",
|
||||
inputPattern: /^\d+$/,
|
||||
inputErrorMessage: "请输入数字 ID",
|
||||
type: "info",
|
||||
},
|
||||
);
|
||||
userId = value;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await testMiniTemplate({
|
||||
tenant_user_id: Number(userId),
|
||||
app_id: form.value.app_id,
|
||||
code: row.code,
|
||||
});
|
||||
if (res.code === 200) {
|
||||
ElMessage.success(res.msg || "已发送");
|
||||
} else {
|
||||
ElMessage.error(res.msg || "发送失败");
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error(error?.message || "发送失败");
|
||||
}
|
||||
};
|
||||
|
||||
// 读取消息推送配置(回调地址由服务端生成,Token 直接回显,AESKey 掩码)
|
||||
const fetchPushConfig = async (appId) => {
|
||||
if (!appId) return;
|
||||
pushForm.value = emptyPushForm();
|
||||
pushForm.value.app_id = appId;
|
||||
try {
|
||||
const res = await getMiniAppPush(appId);
|
||||
if (res.code === 200) {
|
||||
const data = res.data || {};
|
||||
pushForm.value = {
|
||||
app_id: appId,
|
||||
token: data.token || "",
|
||||
aes_key: data.aes_key || "",
|
||||
encrypt_mode: data.encrypt_mode || "plain",
|
||||
data_format: data.data_format || "xml",
|
||||
push_reply: data.push_reply || "",
|
||||
callback_url: data.callback_url || "",
|
||||
};
|
||||
pushConfigured.value = !!data.configured;
|
||||
} else {
|
||||
ElMessage.error(res.msg || "获取消息推送配置失败");
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error(error?.message || "获取消息推送配置失败");
|
||||
}
|
||||
};
|
||||
|
||||
// 随机生成 Token 与 EncodingAESKey
|
||||
const generateSecrets = async () => {
|
||||
try {
|
||||
const res = await generateMiniAppPushSecret();
|
||||
if (res.code === 200) {
|
||||
pushForm.value.token = res.data?.token || "";
|
||||
pushForm.value.aes_key = res.data?.aes_key || "";
|
||||
ElMessage.success("已生成,记得保存");
|
||||
} else {
|
||||
ElMessage.error(res.msg || "生成失败");
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error(error?.message || "生成失败");
|
||||
}
|
||||
};
|
||||
|
||||
// 保存消息推送配置
|
||||
const handleSavePush = async () => {
|
||||
if (!pushForm.value.app_id) {
|
||||
ElMessage.warning("请先保存小程序");
|
||||
return;
|
||||
}
|
||||
pushSaving.value = true;
|
||||
try {
|
||||
const res = await saveMiniAppPush({ ...pushForm.value });
|
||||
if (res.code === 200) {
|
||||
ElMessage.success(res.msg || "消息推送配置已保存");
|
||||
if (res.data?.callback_url) {
|
||||
pushForm.value.callback_url = res.data.callback_url;
|
||||
}
|
||||
pushConfigured.value = true;
|
||||
fetchApps();
|
||||
} else {
|
||||
ElMessage.error(res.msg || "保存失败");
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error(error?.message || "保存失败");
|
||||
} finally {
|
||||
pushSaving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 复制文本
|
||||
const copyText = async (text) => {
|
||||
if (!text) {
|
||||
ElMessage.warning("无可复制内容");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
ElMessage.success("已复制");
|
||||
} catch {
|
||||
ElMessage.error("复制失败,请手动选择复制");
|
||||
}
|
||||
};
|
||||
|
||||
// 二维码上传:上传成功后先写入表单,保存时随应用一起落库
|
||||
@@ -422,6 +875,26 @@ onMounted(() => {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.push-alert {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.divider-text {
|
||||
font-size: 13px;
|
||||
color: #5a6478;
|
||||
}
|
||||
|
||||
.callback-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
|
||||
:deep(.el-input) {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.form-tip {
|
||||
margin-left: 10px;
|
||||
color: #8a94ad;
|
||||
|
||||
@@ -1,5 +1,37 @@
|
||||
<script>
|
||||
import { getBaseURL } from '@/api/request.js'
|
||||
import { isLoggedIn } from '@/utils/auth.js'
|
||||
|
||||
const LOGIN_PAGE = '/pages/login/login'
|
||||
// 不需要登录即可停留的页面:登录 / 注册 / 忘记密码 / 人机验证 / 小程序码确认页 / 启动跳板页
|
||||
const PUBLIC_ROUTES = [
|
||||
'pages/login/login',
|
||||
'pages/login/register',
|
||||
'pages/login/forget',
|
||||
'pages/login/geetest-webview',
|
||||
'pages/register/confirm',
|
||||
'pages/index/index'
|
||||
]
|
||||
|
||||
/** 当前页面路由(取不到时返回空串,交给页面级检测兜底) */
|
||||
function currentRoute() {
|
||||
try {
|
||||
const pages = getCurrentPages()
|
||||
if (!pages.length) return ''
|
||||
const cur = pages[pages.length - 1]
|
||||
return (cur && (cur.route || (cur.$page && cur.$page.path))) || ''
|
||||
} catch (e) {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
/** 未登录一律回登录页:覆盖「切后台回来」「从其他入口直接进内页」等场景 */
|
||||
function ensureLogin() {
|
||||
const route = currentRoute()
|
||||
if (!route || PUBLIC_ROUTES.indexOf(route) !== -1) return
|
||||
if (isLoggedIn()) return
|
||||
uni.reLaunch({ url: LOGIN_PAGE })
|
||||
}
|
||||
|
||||
export default {
|
||||
onLaunch() {
|
||||
@@ -7,6 +39,7 @@
|
||||
},
|
||||
onShow() {
|
||||
console.log('App Show')
|
||||
ensureLogin()
|
||||
},
|
||||
onHide() {
|
||||
console.log('App Hide')
|
||||
|
||||
@@ -11,6 +11,20 @@ const DEFAULT_PROD_API = 'https://api.yunzer.cn'
|
||||
/**
|
||||
* @returns {string} 去掉末尾斜杠的 baseURL
|
||||
*/
|
||||
/**
|
||||
* 小程序端 baseURL 兜底:微信要求 request 必须走「已配置的 https 合法域名」,
|
||||
* localhost / 127.0.0.1 / 相对路径 / http 明文在体验版与正式版都会直接报
|
||||
* "url not in domain list"。出现这类取值一律回退生产 API(本地调试请用 H5 端,
|
||||
* 或在开发者工具勾选「不校验合法域名」时用真机调试)。
|
||||
*/
|
||||
function sanitizeMiniProgramBase(base) {
|
||||
const b = String(base || '')
|
||||
if (!b || b.startsWith('/') || !/^https:\/\//i.test(b) || /(localhost|127\.0\.0\.1)/i.test(b)) {
|
||||
return DEFAULT_PROD_API
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
export function resolveBaseURL() {
|
||||
let fromEnv = ''
|
||||
try {
|
||||
@@ -24,7 +38,12 @@ export function resolveBaseURL() {
|
||||
}
|
||||
|
||||
if (fromEnv && fromEnv !== 'undefined' && fromEnv !== 'null') {
|
||||
// #ifdef MP
|
||||
return sanitizeMiniProgramBase(String(fromEnv))
|
||||
// #endif
|
||||
// #ifndef MP
|
||||
return String(fromEnv).replace(/\/$/, '')
|
||||
// #endif
|
||||
}
|
||||
|
||||
// #ifdef H5
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* 小程序订阅消息(对应后端 go/controllers/api_mini_subscribe.go)
|
||||
*
|
||||
* 订阅消息是「用户授权 → 服务器主动推给用户」的通道:
|
||||
* 1. getSubscribeTemplates() 取当前可订阅的模板
|
||||
* 2. 用户在微信弹窗里同意后,authorizeSubscribe() 上报,服务器记一次发送机会
|
||||
* 3. 之后业务侧发站内信/审批待办,服务器自动推送到用户微信
|
||||
*/
|
||||
import { request } from '@/api/request.js'
|
||||
|
||||
/** 取可订阅的模板列表(含模板 ID、跳转页面、剩余可发送次数) */
|
||||
export function getSubscribeTemplates() {
|
||||
return request({
|
||||
url: '/api/mini/subscribe/templates',
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 上报授权结果:每授权一次,服务器记一次可发送机会
|
||||
* @param {string[]} templateIds 用户同意的模板 ID 列表
|
||||
*/
|
||||
export function authorizeSubscribe(templateIds) {
|
||||
return request({
|
||||
url: '/api/mini/subscribe/authorize',
|
||||
method: 'post',
|
||||
data: { template_ids: templateIds }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 发一条测试订阅消息(验证模板配置是否正确)
|
||||
* @param {{title?: string, content?: string}} payload
|
||||
*/
|
||||
export function sendTestSubscribeNotice(payload = {}) {
|
||||
return request({
|
||||
url: '/api/mini/subscribe/test',
|
||||
method: 'post',
|
||||
data: payload
|
||||
})
|
||||
}
|
||||
+3
-4
@@ -412,20 +412,19 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"path {
|
||||
"path": "pages/login/forget",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "忘记密码"
|
||||
}
|
||||
},
|
||||
{
|
||||
},
|
||||
{
|
||||
"path": "pages/register/confirm",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "确认注册"
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
"path": "pages/login/geetest-webview",
|
||||
"style": {
|
||||
|
||||
@@ -1,52 +1,38 @@
|
||||
<template>
|
||||
<view class="content">
|
||||
<image class="logo" src="/static/logo.png"></image>
|
||||
<view class="text-area">
|
||||
<text class="title">{{title}}</text>
|
||||
</view>
|
||||
<view class="boot">
|
||||
<image class="boot-logo" src="/static/logo.png" mode="aspectFit"></image>
|
||||
<text class="boot-text">正在进入…</text>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
title: 'Hello'
|
||||
}
|
||||
},
|
||||
onLoad() {
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import { isLoggedIn } from '@/utils/auth.js'
|
||||
|
||||
},
|
||||
methods: {
|
||||
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 启动跳板页:若启动入口指向本页(脚手架默认首页),先做登录检测再分流,
|
||||
* 未登录直接进登录页,已登录进首页(pages/nav/nav)。
|
||||
*/
|
||||
onShow(() => {
|
||||
uni.reLaunch({ url: isLoggedIn() ? '/pages/nav/nav' : '/pages/login/login' })
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.content {
|
||||
.boot {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 200rpx;
|
||||
width: 200rpx;
|
||||
margin-top: 200rpx;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
margin-bottom: 50rpx;
|
||||
.boot-logo {
|
||||
width: 180rpx;
|
||||
height: 180rpx;
|
||||
margin-bottom: 32rpx;
|
||||
}
|
||||
|
||||
.text-area {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 36rpx;
|
||||
.boot-text {
|
||||
font-size: 28rpx;
|
||||
color: #8f8f94;
|
||||
}
|
||||
</style>
|
||||
@@ -172,11 +172,6 @@
|
||||
<text class="agree-link" @tap.stop="showAgreement">《隐私政策》</text>
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<view class="foot-tip">
|
||||
<FaIcon name="lock" color="#a3acbf" :size="11" />
|
||||
<text class="foot-tip-text">统一身份认证 · 一次登录,全平台通行</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -170,7 +170,12 @@ onMounted(() => {
|
||||
})
|
||||
|
||||
onShow(() => {
|
||||
if (isLoggedIn() && !loading.value) load()
|
||||
// 未登录(含后台切回来时令牌被清)直接回登录页,不再停留在首页
|
||||
if (!isLoggedIn()) {
|
||||
uni.reLaunch({ url: '/pages/login/login' })
|
||||
return
|
||||
}
|
||||
if (!loading.value) load()
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -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' })
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* 订阅消息授权助手
|
||||
*
|
||||
* 用法:在需要提醒用户的入口调用一次即可
|
||||
* import { requestSubscribeNotice } from '@/utils/subscribe.js'
|
||||
* await requestSubscribeNotice()
|
||||
*
|
||||
* 说明:
|
||||
* - 微信一次性订阅:用户每同意一次,服务器才有一次推送机会,所以要挑「用户确实
|
||||
* 可能需要收到提醒」的时机调用(比如进入工作台、提交完审批后)。
|
||||
* - 微信限制一次最多弹 3 个模板,这里自动分批。
|
||||
*/
|
||||
import { getSubscribeTemplates, authorizeSubscribe } from '@/api/subscribe.js'
|
||||
|
||||
/** 单次弹窗最多 3 个模板 ID */
|
||||
const MAX_TMPL_PER_CALL = 3
|
||||
|
||||
/**
|
||||
* 拉起微信订阅授权弹窗,并把授权结果上报给服务器
|
||||
* @param {{silent?: boolean, tip?: string}} options
|
||||
* @returns {Promise<{ok: boolean, reason?: string, accepted?: string[]}>}
|
||||
*/
|
||||
export async function requestSubscribeNotice(options = {}) {
|
||||
const silent = !!options.silent
|
||||
// #ifdef MP-WEIXIN
|
||||
let templates = []
|
||||
try {
|
||||
const data = await getSubscribeTemplates()
|
||||
templates = (data && data.list) || []
|
||||
} catch (e) {
|
||||
if (!silent) uni.showToast({ title: '获取订阅模板失败', icon: 'none' })
|
||||
return { ok: false, reason: 'template_fetch_failed' }
|
||||
}
|
||||
|
||||
const tmplIds = templates.map((t) => t.template_id).filter(Boolean)
|
||||
if (!tmplIds.length) {
|
||||
if (!silent) uni.showToast({ title: '暂未配置订阅消息模板', icon: 'none' })
|
||||
return { ok: false, reason: 'no_template' }
|
||||
}
|
||||
|
||||
const accepted = []
|
||||
for (let i = 0; i < tmplIds.length; i += MAX_TMPL_PER_CALL) {
|
||||
const batch = tmplIds.slice(i, i + MAX_TMPL_PER_CALL)
|
||||
let result = {}
|
||||
try {
|
||||
result = await new Promise((resolve, reject) => {
|
||||
uni.requestSubscribeMessage({
|
||||
tmplIds: batch,
|
||||
success: resolve,
|
||||
fail: reject
|
||||
})
|
||||
})
|
||||
} catch (e) {
|
||||
if (!silent) uni.showToast({ title: '订阅授权失败', icon: 'none' })
|
||||
return { ok: false, reason: 'request_failed' }
|
||||
}
|
||||
batch.forEach((id) => {
|
||||
if (result[id] === 'accept') accepted.push(id)
|
||||
})
|
||||
}
|
||||
|
||||
if (!accepted.length) {
|
||||
if (!silent) uni.showToast({ title: '你取消了订阅授权', icon: 'none' })
|
||||
return { ok: false, reason: 'refuse' }
|
||||
}
|
||||
|
||||
try {
|
||||
await authorizeSubscribe(accepted)
|
||||
} catch (e) {
|
||||
if (!silent) uni.showToast({ title: '授权结果上报失败', icon: 'none' })
|
||||
return { ok: false, reason: 'report_failed' }
|
||||
}
|
||||
|
||||
if (!silent) {
|
||||
uni.showToast({ title: options.tip || '已开启消息提醒', icon: 'success' })
|
||||
}
|
||||
return { ok: true, accepted }
|
||||
// #endif
|
||||
|
||||
// #ifndef MP-WEIXIN
|
||||
if (!silent) uni.showToast({ title: '请在微信小程序中开启消息提醒', icon: 'none' })
|
||||
return { ok: false, reason: 'not_mp' }
|
||||
// #endif
|
||||
}
|
||||
+19
-1
@@ -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/',
|
||||
|
||||
Reference in new issue
Block a user