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