订单配置
This commit is contained in:
1 parent
e81481623c
commit
c7f43e30a9
11 files changed
+492
-19
No files matched your search
@@ -35,6 +35,7 @@ pnpm-debug.log*
|
|||||||
|
|
||||||
# 部署脚本的本地配置(含服务器地址,禁止入库)
|
# 部署脚本的本地配置(含服务器地址,禁止入库)
|
||||||
deploy/deploy.config.ps1
|
deploy/deploy.config.ps1
|
||||||
|
deploy/deploy.config.sh
|
||||||
|
|
||||||
# 小程序构建产物
|
# 小程序构建产物
|
||||||
yelaymaths/miniprogram_npm/
|
yelaymaths/miniprogram_npm/
|
||||||
|
|||||||
+20
-1
@@ -4,13 +4,32 @@
|
|||||||
|
|
||||||
服务器上**不需要安装任何东西**,它复用的就是你已经能用的 SSH。
|
服务器上**不需要安装任何东西**,它复用的就是你已经能用的 SSH。
|
||||||
|
|
||||||
|
## 用哪个脚本
|
||||||
|
|
||||||
|
| 你的系统 | 脚本 | 配置文件 |
|
||||||
|
|---|---|---|
|
||||||
|
| Windows | `deploy.ps1` | `deploy.config.ps1`(照抄 `deploy.config.example.ps1`) |
|
||||||
|
| macOS / Linux | `deploy.sh` | `deploy.config.sh`(照抄 `deploy.config.example.sh`) |
|
||||||
|
|
||||||
|
Mac 上先执行一次 `chmod +x deploy/deploy.sh`,之后用法和 Windows 版完全一样:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./deploy/deploy.sh api
|
||||||
|
./deploy/deploy.sh admin
|
||||||
|
./deploy/deploy.sh
|
||||||
|
./deploy/deploy.sh api --dry-run # 只打包、只显示会上传哪些文件,不真传
|
||||||
|
./deploy/deploy.sh api --with-install # 顺带装依赖(只有改了 package.json 才需要)
|
||||||
|
```
|
||||||
|
|
||||||
|
> 第一次用先跑 `--dry-run`,它会打印打包大小和文件清单,核对无误再去掉这个参数。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 一次性配置
|
## 一次性配置
|
||||||
|
|
||||||
### 1. 填配置文件
|
### 1. 填配置文件
|
||||||
|
|
||||||
编辑 `deploy/deploy.config.ps1`,需要改这几项:
|
编辑 `deploy/deploy.config.ps1`(Mac 上是 `deploy/deploy.config.sh`,字段名多一层下划线,含义完全一样),需要改这几项:
|
||||||
|
|
||||||
| 配置项 | 填什么 | 怎么查 |
|
| 配置项 | 填什么 | 怎么查 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# 复制本文件为 deploy.config.sh 并填写自己的服务器信息。
|
||||||
|
# deploy.config.sh 已 gitignore,服务器地址不会进仓库。
|
||||||
|
|
||||||
|
# ---- SSH ----
|
||||||
|
DEPLOY_HOST='1.2.3.4' # 服务器 IP 或域名
|
||||||
|
DEPLOY_PORT=22
|
||||||
|
DEPLOY_USER='root'
|
||||||
|
|
||||||
|
# 服务器上 Node 的 bin 目录(远端构建时会加进 PATH)。
|
||||||
|
# 宝塔 → 软件商店 → Node 版本管理器里看,例如 /www/server/nodejs/v22.14.0/bin
|
||||||
|
NODE_BIN='/www/server/nodejs/v22/bin'
|
||||||
|
|
||||||
|
# ---- Nginx 反代缓存 ----
|
||||||
|
# 宝塔在 /www/server/nginx/conf/proxy.conf 里全局开了 proxy_cache cache_one,
|
||||||
|
# 而 Next.js 给页面的响应头是 Cache-Control: s-maxage=31536000(允许缓存一年)。
|
||||||
|
# 两者一叠加,新构建传上去也访问不到,而旧 HTML 引用的分包早被删掉,页面会整块消失。
|
||||||
|
# 部署脚本每次清一次这个目录并 reload。留空则跳过。
|
||||||
|
NGINX_CACHE_DIR='/www/server/nginx/proxy_cache_dir'
|
||||||
|
NGINX_BIN='/www/server/nginx/sbin/nginx'
|
||||||
|
|
||||||
|
# ---- yelaymaths_api ----
|
||||||
|
API_LABEL='yelaymaths_api (server API)'
|
||||||
|
API_LOCAL='yelaymaths_api'
|
||||||
|
API_REMOTE='/www/wwwroot/yelaymaths/yelaymaths_api'
|
||||||
|
API_PATHS='src prisma tools package.json pnpm-lock.yaml pnpm-workspace.yaml tsconfig.json tsconfig.build.json nest-cli.json .env.example'
|
||||||
|
API_EXCLUDES=''
|
||||||
|
# 部署前先在服务器上删掉的旧目录(留空表示不删)
|
||||||
|
API_CLEAN=''
|
||||||
|
API_LOCAL_BUILD='' # 想在本机构建就填 'pnpm build',一般留空
|
||||||
|
API_INSTALL='pnpm install --frozen-lockfile'
|
||||||
|
API_BUILD='pnpm build'
|
||||||
|
# 重启命令:杀掉旧进程。服务器上执行 pm2 list 看真实进程名。
|
||||||
|
API_RESTART='pm2 restart yelaymaths-api'
|
||||||
|
# 拉起命令:宝塔不会自动把进程拉回来,所以自己 setsid + nohup 起。留空则只杀不拉。
|
||||||
|
API_START='setsid nohup node dist/main.js >> /www/wwwlogs/yelaymaths-api.out.log 2>&1 < /dev/null &'
|
||||||
|
API_HEALTH='http://127.0.0.1:3002/healthz'
|
||||||
|
|
||||||
|
# ---- yelaymaths_admin ----
|
||||||
|
# 注意:NEXT_PUBLIC_* 是构建时写死的,.env.local 必须在服务器上先准备好(脚本不会上传它)。
|
||||||
|
ADMIN_LABEL='yelaymaths_admin (admin console)'
|
||||||
|
ADMIN_LOCAL='yelaymaths_admin'
|
||||||
|
ADMIN_REMOTE='/www/wwwroot/yelaymaths/yelaymaths_admin'
|
||||||
|
ADMIN_PATHS='src package.json pnpm-lock.yaml tsconfig.json next.config.ts postcss.config.mjs'
|
||||||
|
ADMIN_EXCLUDES=''
|
||||||
|
# Next 的预渲染缓存在 .next/cache 里,命中就不读磁盘新文件,所以每次先删掉重建
|
||||||
|
ADMIN_CLEAN='.next/cache'
|
||||||
|
ADMIN_LOCAL_BUILD=''
|
||||||
|
ADMIN_INSTALL='pnpm install --frozen-lockfile'
|
||||||
|
ADMIN_BUILD='pnpm build'
|
||||||
|
ADMIN_RESTART='pm2 restart yelaymaths-admin'
|
||||||
|
ADMIN_START='setsid nohup node node_modules/next/dist/bin/next start -p 3001 >> /www/wwwlogs/yelaymaths-admin.out.log 2>&1 < /dev/null &'
|
||||||
|
ADMIN_HEALTH='http://127.0.0.1:3001/'
|
||||||
Executable
+193
@@ -0,0 +1,193 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Mac / Linux 版一键部署,行为与 deploy.ps1 一致。
|
||||||
|
#
|
||||||
|
# ./deploy/deploy.sh api 只部署服务端
|
||||||
|
# ./deploy/deploy.sh admin 只部署管理端
|
||||||
|
# ./deploy/deploy.sh 两个都部署
|
||||||
|
# ./deploy/deploy.sh api --dry-run 只打包、只显示会上传什么,不真传
|
||||||
|
# ./deploy/deploy.sh api --with-install 顺带装依赖(只有改了 package.json 才需要)
|
||||||
|
#
|
||||||
|
# 用白名单打包,.env / node_modules / dist / .next 结构上不可能被传上去。
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
repo_root="$(dirname "$script_dir")"
|
||||||
|
config_file="$script_dir/deploy.config.sh"
|
||||||
|
|
||||||
|
target='all'
|
||||||
|
dry_run=0
|
||||||
|
with_install=0
|
||||||
|
|
||||||
|
# bash 3.2(macOS 自带)在 set -u 下对无参数的 "$@" 会报 unbound variable,所以要加这层保护
|
||||||
|
for arg in ${@+"$@"}; do
|
||||||
|
case "$arg" in
|
||||||
|
api|admin|all) target="$arg" ;;
|
||||||
|
-n|--dry-run) dry_run=1 ;;
|
||||||
|
--with-install) with_install=1 ;;
|
||||||
|
-h|--help) sed -n '2,13p' "$0"; exit 0 ;;
|
||||||
|
# ${arg} 必须带花括号:紧跟中文时 bash 会把多字节字符合进变量名
|
||||||
|
*) echo "未知参数:${arg}(可用:api | admin | all | --dry-run | --with-install)" >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ ! -f "$config_file" ]; then
|
||||||
|
echo 'ERROR: 找不到 deploy.config.sh' >&2
|
||||||
|
echo '把 deploy/deploy.config.example.sh 复制成 deploy/deploy.config.sh,填好服务器信息再跑。' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# shellcheck disable=SC1090
|
||||||
|
. "$config_file"
|
||||||
|
|
||||||
|
for cmd in ssh scp tar; do
|
||||||
|
command -v "$cmd" >/dev/null 2>&1 || { echo "缺少命令:$cmd" >&2; exit 1; }
|
||||||
|
done
|
||||||
|
|
||||||
|
ssh_target="$DEPLOY_USER@$DEPLOY_HOST"
|
||||||
|
|
||||||
|
# 读配置里的某一项,如 val API_LABEL
|
||||||
|
val() {
|
||||||
|
local out
|
||||||
|
eval "out=\${$1:-}"
|
||||||
|
printf '%s' "$out"
|
||||||
|
}
|
||||||
|
|
||||||
|
deploy_app() {
|
||||||
|
local key="$1"
|
||||||
|
local up label local_path remote_path paths excludes clean local_build
|
||||||
|
local install build restart start health nginx_cache nginx_bin
|
||||||
|
up="$(printf '%s' "$key" | tr '[:lower:]' '[:upper:]')"
|
||||||
|
|
||||||
|
label="$(val "${up}_LABEL")"
|
||||||
|
local_path="$repo_root/$(val "${up}_LOCAL")"
|
||||||
|
remote_path="$(val "${up}_REMOTE")"
|
||||||
|
paths="$(val "${up}_PATHS")"
|
||||||
|
excludes="$(val "${up}_EXCLUDES")"
|
||||||
|
clean="$(val "${up}_CLEAN")"
|
||||||
|
local_build="$(val "${up}_LOCAL_BUILD")"
|
||||||
|
install="$(val "${up}_INSTALL")"
|
||||||
|
build="$(val "${up}_BUILD")"
|
||||||
|
restart="$(val "${up}_RESTART")"
|
||||||
|
start="$(val "${up}_START")"
|
||||||
|
health="$(val "${up}_HEALTH")"
|
||||||
|
|
||||||
|
[ -d "$local_path" ] || { echo "本地目录不存在:$local_path" >&2; exit 1; }
|
||||||
|
|
||||||
|
echo ''
|
||||||
|
echo "=== $label ==="
|
||||||
|
|
||||||
|
# ---- 本地构建(默认关,配了 $X_LOCAL_BUILD 才跑)----
|
||||||
|
if [ -n "$local_build" ]; then
|
||||||
|
echo "[1/5] Building locally: $local_build"
|
||||||
|
# 国内拉 Prisma 引擎很慢,指向镜像
|
||||||
|
( cd "$local_path" && PRISMA_ENGINES_MIRROR='https://registry.npmmirror.com/-/binary/prisma' bash -c "$local_build" )
|
||||||
|
else
|
||||||
|
echo '[1/5] Skipping local build'
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---- 打包:只收白名单里存在的路径 ----
|
||||||
|
echo "[2/5] Packing $(val "${up}_LOCAL")"
|
||||||
|
local stamp tar_file remote_tar items
|
||||||
|
stamp="$(date +%Y%m%d-%H%M%S)"
|
||||||
|
tar_file="${TMPDIR:-/tmp}/yelay-${key}-${stamp}.tar.gz"
|
||||||
|
remote_tar="/tmp/$(basename "$tar_file")"
|
||||||
|
|
||||||
|
local -a tar_args=()
|
||||||
|
for p in $excludes; do tar_args+=("--exclude=$p"); done
|
||||||
|
local -a file_args=()
|
||||||
|
for p in $paths; do
|
||||||
|
[ -e "$local_path/$p" ] && file_args+=("$p")
|
||||||
|
done
|
||||||
|
[ ${#file_args[@]} -gt 0 ] || { echo "白名单里没有任何存在的路径:$paths" >&2; exit 1; }
|
||||||
|
|
||||||
|
( cd "$local_path" && tar -czf "$tar_file" ${tar_args[@]+"${tar_args[@]}"} "${file_args[@]}" )
|
||||||
|
|
||||||
|
local size_kb
|
||||||
|
size_kb=$(( $(wc -c < "$tar_file") / 1024 ))
|
||||||
|
echo " ${size_kb} KB ($(printf '%s, ' "${file_args[@]}" | sed 's/, $//'))"
|
||||||
|
|
||||||
|
if [ "$dry_run" = 1 ]; then
|
||||||
|
echo ' DryRun: nothing uploaded.'
|
||||||
|
rm -f "$tar_file"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[3/5] Uploading to $remote_path"
|
||||||
|
scp -P "$DEPLOY_PORT" -q "$tar_file" "$ssh_target:$remote_tar"
|
||||||
|
|
||||||
|
# ---- 拼远端脚本 ----
|
||||||
|
local clean_cmd=''
|
||||||
|
for p in $clean; do clean_cmd="$clean_cmd
|
||||||
|
rm -rf '$remote_path/$p'"; done
|
||||||
|
|
||||||
|
local install_cmd=''
|
||||||
|
[ "$with_install" = 1 ] && install_cmd="$install"
|
||||||
|
|
||||||
|
local start_cmd=''
|
||||||
|
# 先 sleep 2 让刚被杀的进程彻底放开端口,否则新进程会 EADDRINUSE
|
||||||
|
[ -n "$start" ] && start_cmd="sleep 2
|
||||||
|
$start"
|
||||||
|
|
||||||
|
# 探活:30 秒还没起来就让部署直接失败,免得「显示成功、线上 502」
|
||||||
|
local health_cmd=''
|
||||||
|
if [ -n "$health" ]; then
|
||||||
|
health_cmd="for i in {1..30}; do curl -sf -o /dev/null '$health' && break; sleep 1; done
|
||||||
|
curl -sf -o /dev/null '$health' || { echo 'ERROR: service did not come up: $health'; exit 1; }"
|
||||||
|
fi
|
||||||
|
|
||||||
|
nginx_cache="$(val NGINX_CACHE_DIR)"
|
||||||
|
nginx_bin="$(val NGINX_BIN)"
|
||||||
|
local nginx_cmd=''
|
||||||
|
if [ -n "$nginx_cache" ]; then
|
||||||
|
nginx_cmd="find '$nginx_cache' -mindepth 1 -delete 2>/dev/null || true"
|
||||||
|
[ -n "$nginx_bin" ] && nginx_cmd="$nginx_cmd
|
||||||
|
'$nginx_bin' -s reload 2>/dev/null || true"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 远端脚本保持 ASCII 注释,避免 SSH 传输编码问题
|
||||||
|
local remote_script
|
||||||
|
remote_script="$(cat <<REMOTE
|
||||||
|
set -e
|
||||||
|
export PATH=${NODE_BIN}:\$PATH
|
||||||
|
mkdir -p '$remote_path'
|
||||||
|
cd '$remote_path'
|
||||||
|
$clean_cmd
|
||||||
|
# macOS 打的包带 com.apple.provenance 之类的 xattr 头,GNU tar 每条警告一行,刷屏且无用
|
||||||
|
tar --warning=no-unknown-keyword -xzf '$remote_tar'
|
||||||
|
rm -f '$remote_tar'
|
||||||
|
$install_cmd
|
||||||
|
$build
|
||||||
|
$restart
|
||||||
|
$start_cmd
|
||||||
|
$health_cmd
|
||||||
|
$nginx_cmd
|
||||||
|
echo '__DEPLOY_DONE__'
|
||||||
|
REMOTE
|
||||||
|
)"
|
||||||
|
|
||||||
|
echo '[4/5] Running on server'
|
||||||
|
ssh -p "$DEPLOY_PORT" "$ssh_target" 'bash -s' <<< "$remote_script"
|
||||||
|
|
||||||
|
rm -f "$tar_file"
|
||||||
|
|
||||||
|
if [ -n "$restart" ]; then
|
||||||
|
echo '[5/5] Restarted'
|
||||||
|
else
|
||||||
|
echo '[5/5] Done'
|
||||||
|
echo " NOTE: 去宝塔面板手动重启 $label"
|
||||||
|
fi
|
||||||
|
[ -n "$start" ] && echo " Started, health check passed: $health"
|
||||||
|
[ -n "$nginx_cmd" ] && echo ' Nginx proxy cache cleared and reloaded'
|
||||||
|
}
|
||||||
|
|
||||||
|
started_at=$(date +%s)
|
||||||
|
case "$target" in
|
||||||
|
api) deploy_app api ;;
|
||||||
|
admin) deploy_app admin ;;
|
||||||
|
all) deploy_app api; deploy_app admin ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
[ "$dry_run" = 1 ] || echo "
|
||||||
|
All done in $(( $(date +%s) - started_at ))s"
|
||||||
@@ -229,11 +229,16 @@ location ~ ^/Course/ {
|
|||||||
| 配置项 | 位置 | 值 |
|
| 配置项 | 位置 | 值 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| request 合法域名 | 开发 → 开发管理 → 开发设置 | `https://<API域名>` |
|
| request 合法域名 | 开发 → 开发管理 → 开发设置 | `https://<API域名>` |
|
||||||
|
| 消息推送 URL / Token | 开发 → 开发管理 → 消息推送(也是虚拟支付的发货推送地址) | URL 填 `https://<API域名>/v1/payments/notify`;Token 随便生成一串,**要和 `.env` 里的 `WX_MSG_TOKEN` 一致** |
|
||||||
| 发货推送 URL | 虚拟支付 → 基本配置 | `https://<API域名>/v1/payments/notify` |
|
| 发货推送 URL | 虚拟支付 → 基本配置 | `https://<API域名>/v1/payments/notify` |
|
||||||
| 小程序简称 | 虚拟支付 → 基础配置 | iOS 支付必需 |
|
| 小程序简称 | 虚拟支付 → 基础配置 | iOS 支付必需 |
|
||||||
| 苹果 IAP 支付 | 虚拟支付 → 基础配置 | iOS 支付需开启 |
|
| 苹果 IAP 支付 | 虚拟支付 → 基础配置 | iOS 支付需开启 |
|
||||||
| 支付道具 | 虚拟支付 → 道具管理 | 每门课建一个,记下道具 ID 与价格 |
|
| 支付道具 | 虚拟支付 → 道具管理 | 每门课建一个,记下道具 ID 与价格 |
|
||||||
|
|
||||||
|
> 消息推送的 URL 一保存,微信就会发一个 `GET` 来验签。服务端要是没配 `WX_MSG_TOKEN`
|
||||||
|
> 或两边不一致,页面会一直显示「服务器地址验证失败,请检查服务器配置」,发货推送也就配不上。
|
||||||
|
> 加解密方式选**明文模式**只需 Token;选**安全模式**则 `WX_MSG_AES_KEY` 也要填上后台那串 EncodingAESKey。
|
||||||
|
|
||||||
> `<image>` 与 `<video>` 的资源域名**不受合法域名限制**,所以数据域名与 CDN 都不需要加白名单。
|
> `<image>` 与 `<video>` 的资源域名**不受合法域名限制**,所以数据域名与 CDN 都不需要加白名单。
|
||||||
|
|
||||||
创建完道具后,在管理后台「支付商品」里把 `课程 ID`、`微信道具 ID`、`价格(分)` 填进去,
|
创建完道具后,在管理后台「支付商品」里把 `课程 ID`、`微信道具 ID`、`价格(分)` 填进去,
|
||||||
|
|||||||
@@ -42,6 +42,11 @@ MEDIA_UPSTREAM=""
|
|||||||
WX_APPID="wx3fbaf897825bf04f"
|
WX_APPID="wx3fbaf897825bf04f"
|
||||||
WX_APPSECRET="change-me"
|
WX_APPSECRET="change-me"
|
||||||
|
|
||||||
|
# 小程序后台 → 开发管理 → 消息推送(发货推送走的就是这个地址,必须与后台填的一致)
|
||||||
|
# 明文模式只需 Token;安全模式两个都要,缺任一都会导致「服务器地址验证失败」
|
||||||
|
WX_MSG_TOKEN="change-me"
|
||||||
|
WX_MSG_AES_KEY=""
|
||||||
|
|
||||||
# 虚拟支付(开通后填写)
|
# 虚拟支付(开通后填写)
|
||||||
WX_OFFER_ID=""
|
WX_OFFER_ID=""
|
||||||
WX_PAY_APPKEY=""
|
WX_PAY_APPKEY=""
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { decryptWechatMessage, sameHex, wechatSignature, xmlField } from './wechat-msg';
|
||||||
|
|
||||||
|
// 用例全部取自微信官方文档「消息推送」的签名与加解密示例,改坏签名算法会立刻失败。
|
||||||
|
describe('wechat-msg', () => {
|
||||||
|
it('明文模式验签与官方示例一致', () => {
|
||||||
|
expect(wechatSignature('AAAAA', '1714036504', '1514711492')).toBe(
|
||||||
|
'f464b24fc39322e44b38aa78f5edd27bd1441696',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('安全模式验签把 Encrypt 一起排序', () => {
|
||||||
|
const encrypt = '+qdx1OKCy+5JPCBFWw70tm0fJGb2Jmeia4FCB7kao+/Q5c/ohsOzQHi8khUOb05JCpj0JB4RvQMkUyus8TPxLKJGQqcvZqzDpVzazhZv6JsXUnnR8XGT740XgXZUXQ7vJVnAG+tE8NUd4yFyjPy7GgiaviNrlCTj+l5kdfMuFUPpRSrfMZuMcp3Fn2Pede2IuQrKEYwKSqFIZoNqJ4M8EajAsjLY2km32IIjdf8YL/P50F7mStwntrA2cPDrM1kb6mOcfBgRtWygb3VIYnSeOBrebufAlr7F9mFUPAJGj04=';
|
||||||
|
expect(wechatSignature('AAAAA', '1714112445', '415670741', encrypt)).toBe(
|
||||||
|
'046e02f8204d34f8ba5fa3b1db94908f3df2e9b3',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('能解出官方示例的密文', () => {
|
||||||
|
const encrypt = '+qdx1OKCy+5JPCBFWw70tm0fJGb2Jmeia4FCB7kao+/Q5c/ohsOzQHi8khUOb05JCpj0JB4RvQMkUyus8TPxLKJGQqcvZqzDpVzazhZv6JsXUnnR8XGT740XgXZUXQ7vJVnAG+tE8NUd4yFyjPy7GgiaviNrlCTj+l5kdfMuFUPpRSrfMZuMcp3Fn2Pede2IuQrKEYwKSqFIZoNqJ4M8EajAsjLY2km32IIjdf8YL/P50F7mStwntrA2cPDrM1kb6mOcfBgRtWygb3VIYnSeOBrebufAlr7F9mFUPAJGj04=';
|
||||||
|
const plain = decryptWechatMessage('AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', encrypt);
|
||||||
|
expect(plain).toContain('"Event"');
|
||||||
|
expect(plain).toContain('debug_demo');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('xmlField 兼容 CDATA 与普通标签', () => {
|
||||||
|
expect(xmlField('<xml><Encrypt><![CDATA[abc=]]></Encrypt></xml>', 'Encrypt')).toBe('abc=');
|
||||||
|
expect(xmlField('<xml><OutTradeNo>T123</OutTradeNo></xml>', 'OutTradeNo')).toBe('T123');
|
||||||
|
expect(xmlField('<xml/>', 'Encrypt')).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sameHex 只认完全相同的签名', () => {
|
||||||
|
expect(sameHex('ab', 'ab')).toBe(true);
|
||||||
|
expect(sameHex('ab', 'ac')).toBe(false);
|
||||||
|
expect(sameHex('ab', 'abc')).toBe(false);
|
||||||
|
expect(sameHex('', '')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { createDecipheriv, createHash, timingSafeEqual } from 'node:crypto';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 微信「消息推送」服务器地址相关的两个协议细节。
|
||||||
|
*
|
||||||
|
* 小程序后台填了 URL 之后,微信会先发一个 GET 来验签,验不过就一直显示
|
||||||
|
* 「服务器地址验证失败」,也就配不上发货推送。虚拟支付文档说的「发货推送 URL」
|
||||||
|
* 指的就是这个地址。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 签名 = sha1(Token 与 URL 参数按字典序排序后拼接)。安全模式下把 Encrypt 也算进去。 */
|
||||||
|
export function wechatSignature(token: string, ...parts: string[]): string {
|
||||||
|
return createHash('sha1').update([token, ...parts].sort().join('')).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sameHex(a: string, b: string): boolean {
|
||||||
|
const x = Buffer.from(a);
|
||||||
|
const y = Buffer.from(b);
|
||||||
|
return x.length > 0 && x.length === y.length && timingSafeEqual(x, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 取 XML 标签文本(兼容 CDATA)。 */
|
||||||
|
export function xmlField(xml: string, field: string): string {
|
||||||
|
const match = new RegExp(`<${field}>(?:<!\\[CDATA\\[)?([\\s\\S]*?)(?:\\]\\]>)?</${field}>`).exec(xml);
|
||||||
|
return match ? match[1]!.trim() : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 安全模式:AES-256-CBC。
|
||||||
|
* 密钥 = Base64(EncodingAESKey + '='),IV 取密钥前 16 字节。
|
||||||
|
* 明文 = 16 字节随机数 + 4 字节长度 + msg + appId,填充是 PKCS#7(块长 32)。
|
||||||
|
* 填充长度可能是 17~32,OpenSSL 的自动去填充只认 ≤16,所以关掉自己剥。
|
||||||
|
*/
|
||||||
|
export function decryptWechatMessage(encodingAesKey: string, base64: string): string {
|
||||||
|
const key = Buffer.from(encodingAesKey + '=', 'base64');
|
||||||
|
const decipher = createDecipheriv('aes-256-cbc', key, key.subarray(0, 16));
|
||||||
|
decipher.setAutoPadding(false);
|
||||||
|
const raw = Buffer.concat([decipher.update(Buffer.from(base64, 'base64')), decipher.final()]);
|
||||||
|
const pad = raw[raw.length - 1]!;
|
||||||
|
const body = pad >= 1 && pad <= 32 ? raw.subarray(0, raw.length - pad) : raw;
|
||||||
|
return body.subarray(20, 20 + body.readUInt32BE(16)).toString('utf8');
|
||||||
|
}
|
||||||
@@ -38,6 +38,14 @@ const envSchema = z.object({
|
|||||||
WX_APPID: z.string().min(1),
|
WX_APPID: z.string().min(1),
|
||||||
WX_APPSECRET: z.string().min(1),
|
WX_APPSECRET: z.string().min(1),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 小程序后台「消息推送」的 Token / EncodingAESKey。
|
||||||
|
* 发货推送走的正是这个地址,配置时微信会发 GET 验签,必须与后台填的值一致。
|
||||||
|
* 选「明文模式」只需 Token;选「安全模式」两个都要。
|
||||||
|
*/
|
||||||
|
WX_MSG_TOKEN: z.string().default(''),
|
||||||
|
WX_MSG_AES_KEY: z.string().default(''),
|
||||||
|
|
||||||
/** 虚拟支付,未开通时留空 */
|
/** 虚拟支付,未开通时留空 */
|
||||||
WX_OFFER_ID: z.string().default(''),
|
WX_OFFER_ID: z.string().default(''),
|
||||||
/** 现网支付密钥 */
|
/** 现网支付密钥 */
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { Controller, Post, Req, Res } from '@nestjs/common';
|
import { Controller, Get, Logger, Post, Query, Req, Res } from '@nestjs/common';
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import { Public } from '../../common/auth';
|
import { Public } from '../../common/auth';
|
||||||
|
import { decryptWechatMessage, sameHex, wechatSignature, xmlField } from '../../common/wechat-msg';
|
||||||
|
import { InjectConfig, type AppConfig } from '../../config/config';
|
||||||
import { PaymentsService } from './payments.service';
|
import { PaymentsService } from './payments.service';
|
||||||
|
|
||||||
/** 取原始请求体。虚拟支付回调是 XML,必须拿到未经解析的字节。 */
|
/** 取原始请求体。虚拟支付回调是 XML,必须拿到未经解析的字节。 */
|
||||||
@@ -16,19 +18,74 @@ function rawBodyOf(req: Request): string {
|
|||||||
*/
|
*/
|
||||||
@Controller('v1/payments')
|
@Controller('v1/payments')
|
||||||
export class PaymentsController {
|
export class PaymentsController {
|
||||||
constructor(private readonly payments: PaymentsService) {}
|
private readonly logger = new Logger(PaymentsController.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly payments: PaymentsService,
|
||||||
|
@InjectConfig() private readonly cfg: AppConfig,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 微信配置「消息推送 URL」时的验签请求。
|
||||||
|
* 后台点保存会发 GET ?signature×tamp&nonce&echostr(安全模式换成 msg_signature + 加密 echostr),
|
||||||
|
* 验签通过必须原样返回 echostr,否则一直提示「服务器地址验证失败」。
|
||||||
|
*/
|
||||||
|
@Public()
|
||||||
|
@Get('notify')
|
||||||
|
verify(@Query() query: Record<string, string>, @Res() res: Response): void {
|
||||||
|
const token = this.cfg.WX_MSG_TOKEN;
|
||||||
|
const aesKey = this.cfg.WX_MSG_AES_KEY;
|
||||||
|
const timestamp = query.timestamp ?? '';
|
||||||
|
const nonce = query.nonce ?? '';
|
||||||
|
const echostr = query.echostr ?? '';
|
||||||
|
|
||||||
|
if (token && query.msg_signature && aesKey) {
|
||||||
|
if (sameHex(wechatSignature(token, timestamp, nonce, echostr), query.msg_signature)) {
|
||||||
|
res.type('text/plain').send(decryptWechatMessage(aesKey, echostr));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else if (token && sameHex(wechatSignature(token, timestamp, nonce), query.signature ?? '')) {
|
||||||
|
res.type('text/plain').send(echostr);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.warn('消息推送地址校验失败:签名不匹配,或 .env 未配置 WX_MSG_TOKEN');
|
||||||
|
res.status(401).send('');
|
||||||
|
}
|
||||||
|
|
||||||
@Public()
|
@Public()
|
||||||
@Post('notify')
|
@Post('notify')
|
||||||
async notify(@Req() req: Request, @Res() res: Response): Promise<void> {
|
async notify(@Req() req: Request, @Res() res: Response): Promise<void> {
|
||||||
const xml = await this.payments.handleDeliverNotify(rawBodyOf(req));
|
const body = this.decode(req);
|
||||||
res.type('application/xml').send(xml);
|
if (body.includes('xpay_goods_deliver_notify')) {
|
||||||
|
res.type('application/xml').send(await this.payments.handleDeliverNotify(body));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 退款推送走的是同一个消息推送地址,漏了这段退款就永远不会被记上
|
||||||
|
if (body.includes('xpay_refund_notify')) {
|
||||||
|
res.type('application/xml').send(await this.payments.handleRefundNotify(body));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 地址会收到全部事件(投诉、风控、代币等),其余统一回 success 表示已收到
|
||||||
|
res.type('text/plain').send('success');
|
||||||
}
|
}
|
||||||
|
|
||||||
@Public()
|
@Public()
|
||||||
@Post('refund-notify')
|
@Post('refund-notify')
|
||||||
async refundNotify(@Req() req: Request, @Res() res: Response): Promise<void> {
|
async refundNotify(@Req() req: Request, @Res() res: Response): Promise<void> {
|
||||||
const xml = await this.payments.handleRefundNotify(rawBodyOf(req));
|
res.type('application/xml').send(await this.payments.handleRefundNotify(this.decode(req)));
|
||||||
res.type('application/xml').send(xml);
|
}
|
||||||
|
|
||||||
|
/** 取原始报文;安全模式下包体是 <Encrypt> 密文,先解出来。 */
|
||||||
|
private decode(req: Request): string {
|
||||||
|
const raw = rawBodyOf(req);
|
||||||
|
const aesKey = this.cfg.WX_MSG_AES_KEY;
|
||||||
|
if (!aesKey || !raw.includes('<Encrypt>')) return raw;
|
||||||
|
try {
|
||||||
|
return decryptWechatMessage(aesKey, xmlField(raw, 'Encrypt'));
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(`消息推送解密失败,请核对 WX_MSG_AES_KEY:${err instanceof Error ? err.message : err}`);
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -22,6 +22,21 @@ interface DeliverNotify {
|
|||||||
GoodsInfo?: { ProductId?: string; Quantity?: number };
|
GoodsInfo?: { ProductId?: string; Quantity?: number };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 退款推送的字段名和发货推送完全不同,别照抄。 */
|
||||||
|
interface RefundNotify {
|
||||||
|
Event?: string;
|
||||||
|
OpenId?: string;
|
||||||
|
WxRefundId?: string;
|
||||||
|
WxOrderId?: string;
|
||||||
|
/** 商户单号,对应我们下单时的 out_trade_no */
|
||||||
|
MchOrderId?: string;
|
||||||
|
/** 退款金额,单位分 */
|
||||||
|
RefundFee?: number;
|
||||||
|
/** 0 为退款成功,非 0 为失败 */
|
||||||
|
RetCode?: number;
|
||||||
|
RetMsg?: string;
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PaymentsService {
|
export class PaymentsService {
|
||||||
private readonly logger = new Logger(PaymentsService.name);
|
private readonly logger = new Logger(PaymentsService.name);
|
||||||
@@ -82,23 +97,62 @@ export class PaymentsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 处理退款推送。 */
|
/**
|
||||||
|
* 处理退款推送。
|
||||||
|
*
|
||||||
|
* 两个坑:
|
||||||
|
* 1. 商户单号在 MchOrderId,不是发货推送里的 OutTradeNo
|
||||||
|
* 2. 退款失败时也会推,必须先看 RetCode,否则会把失败的退款标成已退款
|
||||||
|
*/
|
||||||
async handleRefundNotify(rawXml: string): Promise<string> {
|
async handleRefundNotify(rawXml: string): Promise<string> {
|
||||||
const logId = await this.logNotify(rawXml);
|
const logId = await this.logNotify(rawXml);
|
||||||
try {
|
try {
|
||||||
const parsed = this.parser.parse(rawXml) as { xml?: { OutTradeNo?: string } };
|
const parsed = this.parser.parse(rawXml) as { xml?: RefundNotify };
|
||||||
const outTradeNo = parsed.xml?.OutTradeNo ?? '';
|
const notify = parsed.xml ?? {};
|
||||||
if (outTradeNo) {
|
const outTradeNo = notify.MchOrderId === undefined ? '' : String(notify.MchOrderId);
|
||||||
await this.prisma.active.order.updateMany({
|
if (!outTradeNo) throw new Error('退款推送缺少 MchOrderId');
|
||||||
where: { out_trade_no: outTradeNo },
|
|
||||||
data: { status: OrderStatus.REFUNDED, refunded_at: new Date() },
|
const retCode = notify.RetCode === undefined ? 0 : Number(notify.RetCode);
|
||||||
});
|
if (retCode !== 0) {
|
||||||
|
await this.markNotify(logId, 1, `退款未成功:${notify.RetMsg ?? retCode}`);
|
||||||
|
return this.okXml();
|
||||||
}
|
}
|
||||||
await this.markNotify(logId, 1, '退款已记录');
|
|
||||||
|
const order = await this.prisma.active.order.findUnique({ where: { out_trade_no: outTradeNo } });
|
||||||
|
if (!order) throw new Error(`退款推送找不到订单:${outTradeNo}`);
|
||||||
|
if (order.status === OrderStatus.REFUNDED) {
|
||||||
|
await this.markNotify(logId, 1, '重复推送,已忽略');
|
||||||
|
return this.okXml();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 退款要同时收回权益,否则用户钱退了课还能看。
|
||||||
|
// 只删这笔订单发出去的那一条(source=1 且 ref_id 对上),
|
||||||
|
// 动不到老数据迁移来的 source=2 权益。两个写操作必须同事务。
|
||||||
|
// 已知上限:已经下发出去的播放地址在签名有效期内(默认 30 分钟)仍可播放,
|
||||||
|
// 换密钥或缩短 PLAY_URL_TTL_SECONDS 才能做得更严。
|
||||||
|
await this.prisma.active.$transaction([
|
||||||
|
this.prisma.active.order.update({
|
||||||
|
where: { id: order.id },
|
||||||
|
data: { status: OrderStatus.REFUNDED, refunded_at: new Date() },
|
||||||
|
}),
|
||||||
|
this.prisma.active.entitlement.deleteMany({
|
||||||
|
where: {
|
||||||
|
user_id: order.user_id,
|
||||||
|
course_id: order.course_id,
|
||||||
|
source: 1,
|
||||||
|
ref_id: order.id,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await this.markNotify(logId, 1, `退款成功并收回权益 ${notify.WxRefundId ?? ''} ${notify.RefundFee ?? ''}分`);
|
||||||
|
this.logger.log(`退款已记录 order=${outTradeNo} refund=${notify.WxRefundId ?? '-'}`);
|
||||||
return this.okXml();
|
return this.okXml();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
await this.markNotify(logId, 2, err instanceof Error ? err.message : String(err));
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
return '<xml><ErrCode>1</ErrCode><ErrMsg><![CDATA[fail]]></ErrMsg></xml>';
|
this.logger.error(`退款处理失败:${message}`);
|
||||||
|
await this.markNotify(logId, 2, message);
|
||||||
|
return `<xml><ErrCode>1</ErrCode><ErrMsg><![CDATA[${message}]]></ErrMsg></xml>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,4 +255,3 @@ export class PaymentsService {
|
|||||||
return '<xml><ErrCode>0</ErrCode><ErrMsg><![CDATA[success]]></ErrMsg></xml>';
|
return '<xml><ErrCode>0</ErrCode><ErrMsg><![CDATA[success]]></ErrMsg></xml>';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Reference in new issue
Block a user