文章摘要: 将前九篇的教学工程部署到独立验收环境,提供Linux进程托管、Nginx代理、PostgreSQL备份恢复和冒烟检查步骤,并梳理Cocos Android与iOS构建所需的工具、配置和真机验收项目。
文章目录
-
- 前言
- 一、整理交付目录和版本记录
- 二、建立独立数据库和验收账号
- 三、在Linux准备服务目录
- 四、配置环境变量和进程托管
- 五、接入回环地址上的Nginx代理
- 六、通过代理执行完整冒烟检查
- 七、生成可以恢复的数据库备份
- 八、恢复到新库验证,不覆盖原库
- [九、准备Cocos Android构建](#九、准备Cocos Android构建)
- 十、准备iOS构建与真机验证
- 十一、按用户路径填写验收结果
- 十二、把部署结果变成可交接的记录
-
- [1. 发布前先确认旧版本仍可定位](#1. 发布前先确认旧版本仍可定位)
- [2. 停止服务时观察连接收尾](#2. 停止服务时观察连接收尾)
- [3. 日志和健康检查分开保存](#3. 日志和健康检查分开保存)
- [4. 备份恢复要记录耗时和核对范围](#4. 备份恢复要记录耗时和核对范围)
- [5. 手机验收保留失败样本](#5. 手机验收保留失败样本)
- [6. 将可展示内容与待开发内容列清楚](#6. 将可展示内容与待开发内容列清楚)
- 十三、部署前先校验交付文件,而不是直接启动
-
- [1. 路径检查为什么与摘要检查放在一起](#1. 路径检查为什么与摘要检查放在一起)
- [2. 先只检查文件,再检查服务](#2. 先只检查文件,再检查服务)
- 十四、将备份步骤整理成可保存记录的脚本
-
- [1. 每一步都检查外部命令退出码](#1. 每一步都检查外部命令退出码)
- [2. 二进制文件不通过文本管道](#2. 二进制文件不通过文本管道)
- [3. 执行与查看备份记录](#3. 执行与查看备份记录)
- 十五、从记录恢复到全新的隔离数据库
-
- [1. 为什么恢复时不用原数据库名称](#1. 为什么恢复时不用原数据库名称)
- [2. 摘要一致也要继续恢复和查询](#2. 摘要一致也要继续恢复和查询)
- [3. 运行恢复脚本](#3. 运行恢复脚本)
- 十六、给恢复库授权,再在独立端口验证业务
- 十七、验证发布检查脚本自身能发现坏结果
- 十八、检查PowerShell脚本语法时不要误执行恢复
- 十九、将代理和WebSocket纳入同一轮外部验收
- 二十、为双端验收准备可以填写的证据表
- 二十一、本篇完成后应交付什么
前言

前九篇已经将账号、房间、规则、同步、结算和管理接口串成教学工程。最后一篇把这些内容放到可重复的验收流程中,回答如何启动、如何定位失败、如何恢复数据,以及客户端打包之后应检查什么。
这里的目标是隔离验收部署。当前代码尚未实现公开服务所需的注册滥用控制、完整客户端场景、地方牌类规则与完整运营后台,因此不能把执行下面命令等同于一套商业成品上线。本次作者环境实际验证了Node、SQL流程和进程内通信;没有执行Docker、Linux托管、Cocos编辑器和手机构建,相关步骤必须在目标环境补测。
一、整理交付目录和版本记录
部署时只带源码、锁文件、迁移文件和必要配置模板,不复制Windows上的node_modules到Linux。不同系统的依赖安装结果可能不同,目标环境应依据锁文件重新安装。
text
baiyou-release/
package.json
pnpm-lock.yaml
src/
sql/
scripts/
test/
web/
deploy/
cocos/
.env.example
compose.yaml
交付记录至少填写源码包SHA256、Node版本、pnpm版本、数据库主版本、迁移编号和客户端构建编号。不要把"最新版"作为版本记录;一个月后重新验收时,需要知道当时具体用了哪一份材料。
powershell
Get-FileHash -LiteralPath './百游棋牌开发搭建系列-实操重写版.zip' -Algorithm SHA256
node --version
pnpm --version
锁文件控制依赖解析结果,不控制数据库镜像内容。第二篇使用postgres:17便于跟随该主版本修订,正式验收时还应记录镜像摘要,保证后续能够定位到同一镜像。
二、建立独立数据库和验收账号
按照第二篇启动PostgreSQL、运行迁移并授予应用账号必要权限。数据库端口保持回环地址监听,应用使用baiyou_app,不使用postgres长期运行。
powershell
docker compose --env-file .env.db up -d db
docker compose --env-file .env.db ps
pnpm run migrate
pnpm test
迁移成功并不表示应用账号权限正确。用完整服务启动后访问/health/ready,再执行注册、建房和完成对局,才能检查运行账号对表与序列的访问。只测SELECT 1会漏掉迁移表缺失、业务表权限不足等问题。
验收环境应使用专用账号和测试数据。冒烟脚本会创建新用户并写入战绩,备份恢复也可能包含这些账号。记录数据库名称,避免把教学演练执行到真实业务数据上。
三、在Linux准备服务目录
以下命令在使用systemd的Linux主机执行,要求已安装Node 24、pnpm和Nginx;安装方式按主机发行版和组织的软件源规范处理。先运行command -v node确认Node绝对路径,后面的服务文件示例使用/usr/bin/node,路径不同必须调整。
bash
node --version
pnpm --version
command -v node
sudo useradd --system --home /opt/baiyou --shell /usr/sbin/nologin baiyou
sudo install -d -o baiyou -g baiyou /opt/baiyou/releases/tutorial-v1
sudo install -d -m 750 -o root -g baiyou /etc/baiyou
如果用户已存在,不重复执行useradd。将解压后的示例工程内容复制到/opt/baiyou/releases/tutorial-v1,确认package.json直接位于这个目录,而不是多套了一层示例工程文件夹。随后授予服务账号读取权限,并在该目录安装依赖。
bash
sudo chown -R baiyou:baiyou /opt/baiyou/releases/tutorial-v1
cd /opt/baiyou/releases/tutorial-v1
sudo -u baiyou env HOME=/opt/baiyou/releases/tutorial-v1 XDG_CACHE_HOME=/opt/baiyou/releases/tutorial-v1/.cache pnpm install --frozen-lockfile --store-dir /opt/baiyou/releases/tutorial-v1/.pnpm-store
sudo -u baiyou pnpm test
sudo ln -s /opt/baiyou/releases/tutorial-v1 /opt/baiyou/current
这里的current是首次创建的符号链接;如果已经存在,先检查其指向,不要盲目覆盖正在运行的版本。依赖安装需要目标机网络可用,失败时先检查锁文件和包源连通性,不能通过删除锁文件随意升级来绕过问题。
四、配置环境变量和进程托管
用文本编辑器创建/etc/baiyou/app.env,填入实际应用数据库地址。运行配置中不需要保留迁移管理员密码,迁移可以通过独立受控终端执行。
dotenv
APP_HOST=127.0.0.1
APP_PORT=3000
DATABASE_URL=postgresql://baiyou_app:替换为URL编码后的密码@127.0.0.1:5432/baiyou
ALLOWED_ORIGINS=http://127.0.0.1:8080,http://localhost:8080
环境文件由systemd读取,不要写成PowerShell的$env:语法。密码中的特殊字符需要URL编码;应用连接失败时只检查配置结构,不要把包含完整密码的连接串粘贴到公开日志。
附件deploy/baiyou.service内容如下:
ini
[Unit]
Description=Baiyou tutorial acceptance service
After=network.target
[Service]
Type=simple
User=baiyou
Group=baiyou
WorkingDirectory=/opt/baiyou/current
EnvironmentFile=/etc/baiyou/app.env
ExecStart=/usr/bin/node /opt/baiyou/current/src/main.mjs
Restart=on-failure
RestartSec=3
TimeoutStopSec=20
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
UMask=0077
[Install]
WantedBy=multi-user.target
将它安装到系统服务目录并启动:
bash
sudo chmod 640 /etc/baiyou/app.env
sudo chown root:baiyou /etc/baiyou/app.env
sudo cp deploy/baiyou.service /etc/systemd/system/baiyou.service
sudo systemctl daemon-reload
sudo systemctl enable --now baiyou
sudo systemctl status baiyou --no-pager
curl --fail http://127.0.0.1:3000/health/ready
ProtectSystem等设置让服务代码目录保持只读;应用日志写标准输出,由systemd收集。environment-report脚本会写runtime文件,因此在部署准备阶段手动运行,不把它塞进受只读限制的服务启动流程。
服务启动失败时查看sudo journalctl -u baiyou -n 80 --no-pager。常见原因包括Node路径错误、WorkingDirectory层级错误、环境文件缺失和数据库不可达。先看第一条有意义的错误,不要反复重启掩盖现场。
五、接入回环地址上的Nginx代理
为了先验证代理链路,示例只监听127.0.0.1:8080。附件配置需要加载在Nginx的http上下文中;通常可以放到发行版已包含的conf.d目录,但应先检查现有nginx.conf。
nginx
# Include inside nginx's http context. Loopback-only acceptance proxy.
map $http_upgrade $baiyou_connection_upgrade {
default upgrade;
'' close;
}
server {
listen 127.0.0.1:8080;
server_name localhost;
client_max_body_size 8k;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $baiyou_connection_upgrade;
proxy_read_timeout 60s;
}
}
bash
sudo cp deploy/nginx-acceptance.conf /etc/nginx/conf.d/baiyou-acceptance.conf
sudo nginx -t
sudo systemctl reload nginx
curl --fail http://127.0.0.1:8080/health/ready
WebSocket升级所需的Upgrade和Connection头在配置中显式转发,避免HTTP接口正常而/ws无法升级。六十秒读取超时大于本工程五秒快照间隔,但数据库持续故障时依然可能断开,客户端应按第七篇恢复。
从自己的电脑访问远程验收机,可以通过SSH转发这个本地端口:
bash
ssh -N -L 8080:127.0.0.1:8080 your-user@your-test-host
然后在本机打开http://127.0.0.1:8080/demo。此时页面Origin应与app.env一致。公开域名部署还要配置有效证书、HTTPS/WSS和访问控制,这份回环配置没有替你完成证书申请或公网入口设置。
六、通过代理执行完整冒烟检查
健康检查成功后,在安装了Node的终端运行第八篇脚本,BASE_URL指向代理入口,而不是绕过代理直连3000。
powershell
$env:BASE_URL = 'http://127.0.0.1:8080'
node scripts/smoke.mjs
Remove-Item Env:BASE_URL
预期脚本完成两个账号注册登录、创建房间、重复创建确认、加入准备、九步落子、最后一步重试和积分核对。保留输出中的房间编号,用事件接口或回放校验再次检查。
脚本失败时不要只记录"测试不通过"。记录失败步骤、HTTP状态和错误码,再用/demo复现同一类操作。若直连成功、代理失败,重点检查代理路径和头部;若两者都失败,回到应用日志和数据库状态。
七、生成可以恢复的数据库备份
下面使用第二篇的Docker PostgreSQL服务。先在容器内部创建custom格式文件,再通过docker compose cp复制到宿主机,避免旧版Windows PowerShell把二进制重定向当成文本处理。
powershell
New-Item -ItemType Directory -Force backups
$stamp = Get-Date -Format 'yyyyMMdd-HHmmss'
$backupFile = "backups/baiyou-$stamp.dump"
docker compose --env-file .env.db exec -T db `
pg_dump -U postgres -d baiyou -Fc -f /tmp/baiyou-acceptance.dump
if ($LASTEXITCODE -ne 0) { throw 'pg_dump failed' }
docker compose --env-file .env.db cp db:/tmp/baiyou-acceptance.dump $backupFile
if ($LASTEXITCODE -ne 0) { throw 'backup copy failed' }
Get-FileHash -LiteralPath $backupFile -Algorithm SHA256
单库备份不包含集群角色定义;跨主机恢复前需要重新创建应用角色和权限。备份也不包含客户端源码、签名材料或环境文件,应分别管理。数据库文件包含账号与战绩信息,不能作为公开文章附件上传。
八、恢复到新库验证,不覆盖原库
本次演练创建baiyou_restore_check新库,保留原baiyou数据库。若同名恢复库已经存在,先确认其用途并选择新的验收库名;不要为了让命令通过直接删除未知数据库。
powershell
docker compose --env-file .env.db cp $backupFile db:/tmp/baiyou-restore.dump
if ($LASTEXITCODE -ne 0) { throw 'restore input copy failed' }
docker compose --env-file .env.db exec -T db `
createdb -U postgres -T template0 baiyou_restore_check
if ($LASTEXITCODE -ne 0) { throw 'restore database creation failed' }
docker compose --env-file .env.db exec -T db `
pg_restore -U postgres -d baiyou_restore_check --single-transaction /tmp/baiyou-restore.dump
if ($LASTEXITCODE -ne 0) { throw 'pg_restore failed' }
docker compose --env-file .env.db exec -T db `
psql -U postgres -d baiyou_restore_check -c 'SELECT count(*) FROM settlements;'
比较关键表数量、抽查已知房间结果,再用单独端口启动应用指向恢复库,重新登录、查询战绩并执行回放检查。只看到dump文件存在不能证明备份可用;只有恢复并查询成功,才有一次可核对的恢复演练记录。PostgreSQL官方备份说明
九、准备Cocos Android构建
先完成第四篇的编辑器登录联调,再进入Creator 3.8的构建发布面板添加Android任务。项目名称、包名、首场景、横竖屏和目标平台设置应记录下来;本附件提供的是网络脚本,不含已经配置好的完整Creator场景,因此需要先按第四篇创建场景和绑定组件。
按所选Creator 3.8补丁版本的原生环境要求配置Android SDK、NDK和JDK。不要凭文章发布日期写死一组未经编辑器验证的路径,也不要把旧工程里的工具版本直接复制到新版本。先构建空项目确认工具链,再构建带登录脚本的项目,这样更容易区分环境错误和业务错误。
真机中127.0.0.1指向手机自身。服务地址应换成手机可访问的验收地址;需要局域网测试时,单独设置受限验收入口,并确保手机和电脑能够通信。正式分发使用HTTPS/WSS,调试阶段对明文请求的临时设置不能直接沿用到发布包。
调试包与正式签名包分开管理。正式密钥、别名和密码不写进文章或源码仓库;同一应用后续升级还需要保持签名连续性。构建成功后在真实设备安装验证,不能只以编辑器输出"完成"作为验收。Android应用签名说明
十、准备iOS构建与真机验证
iOS原生构建需要macOS和对应Xcode工具链。Windows上完成JavaScript服务测试,不等于已经生成可安装iOS包。把同一份Creator工程放到Mac,使用一致的编辑器版本和资源配置,构建iOS工程后用Xcode打开生成项目。
在Xcode配置实际Bundle Identifier、开发团队和签名方式,选择可用设备。先验证网络连接和登录,再检查进入后台、锁屏和恢复前台之后的会话与WebSocket状态。账号会话失效时应引导重新登录,不能无限等待连接图标。
原生包更新与资源热更新是不同机制。本系列没有实现完整热更新清单、签名校验、资源回滚和版本兼容门槛;需要这些能力时应单独开发并按平台规则验证,不能只加一个"热更新"按钮就宣称已支持全端升级。
十一、按用户路径填写验收结果
| 场景 | 要观察的结果 | 需要保留的证据 |
|---|---|---|
| 首次安装登录 | 输入有效账号进入联调页面 | 设备与包版本、接口结果 |
| 双人建房准备 | 同一房间进入PLAYING | roomId与seq |
| 断线恢复 | 使用连接类恢复最新快照 | 前后seq、恢复耗时 |
| 获胜结算 | 一条结果、两条积分流水 | 查询输出 |
| 重复指令 | 返回旧响应,不重复写分 | requestId与行数 |
| 服务重启 | 数据保留,客户端重新认证订阅 | 重启时间与房间查询 |
| 数据恢复 | 新库可查询并回放旧局 | 备份摘要与恢复记录 |
| 越权管理 | 非所有者返回403 | 请求错误码 |
延迟、并发人数、耗电和内存需要在具体设备与网络环境中测量。本系列没有提供虚构的万人并发或上线运行数据;验收记录应填写实际观测值,并写明设备、网络和测试持续时间。
十二、把部署结果变成可交接的记录
1. 发布前先确认旧版本仍可定位
本篇使用releases目录和current链接,是为了让每个部署候选版本有明确位置。后续升级前记录current实际指向、代码包摘要和迁移状态。不要直接在正在运行的目录里覆盖一半文件,再启动服务测试,这样出错时很难知道进程加载了哪一份代码。
切换代码版本不等于数据库可以自动降级。新增结构如果不兼容旧代码,单纯把链接指回旧目录仍可能启动失败。迁移应尽量考虑新旧版本过渡,并单独记录恢复方案;本教学工程只有初始迁移,没有提供任意版本自动回滚能力。
2. 停止服务时观察连接收尾
通过systemctl停止服务后,HTTP应不再接受新请求,WebSocket客户端收到关闭或进入恢复状态。数据库仍保存已提交房间;未确认操作由客户端保留原requestId,在服务恢复后按原请求确认。不要因为界面尚未显示成功,就手工再给玩家增加一次积分。
当前服务关闭长连接并关闭连接池,但没有实现完整的滚动升级协调和房间迁移。未来多实例部署时,需要额外设计停止接收新房间、等待或转移存量连接等过程,并测量用户实际感知的中断时间,不能把进程自动重启描述成无感升级。
3. 日志和健康检查分开保存
live和ready适合判断服务是否可用,应用日志用于解释具体失败。持续记录完整响应体既占空间又可能暴露会话信息;更适合保留时间、请求编号、路径、状态和必要错误码。日志轮转和磁盘容量也要检查,不能让排错记录最终挤满数据库所在磁盘。
出现ready失败时先看数据库和迁移状态,不要仅配置无限重启。数据库维护期间重启应用十次不会让数据库自动恢复,反而可能增加连接压力。进程托管解决意外退出后的拉起,依赖故障仍需独立判断与处理。
4. 备份恢复要记录耗时和核对范围
保存备份文件大小、摘要、开始结束时间、恢复目标库和关键查询结果。首次演练不必追求复杂自动化,先证明有人能按步骤恢复。后续再依据数据量和业务要求设定备份频率、异地保存与恢复目标,不能从一个小型教学数据库的速度推算真实业务恢复时间。
恢复库验证后仍可能包含有效测试会话和历史数据,访问范围应保持隔离。复制数据到新环境不代表应该向外开放所有账号;验收使用重新登录的测试用户,并避免把恢复环境地址误配置到正式客户端中。
5. 手机验收保留失败样本
除了记录成功登录,也要测试错误密码、服务不可达、切后台、锁屏、网络切换和会话过期。每个场景记录设备型号、系统版本、包编号和复现步骤。只写"安卓正常、苹果正常"无法帮助开发者复现某台设备上的证书或生命周期问题。
截图只能证明某一时刻的画面,不能单独证明完整对局与结算。将截图对应到房间编号和服务端查询结果,才能确认显示与业务数据一致。当前没有实际编辑器与真机环境,因此这些项目在交付验收表中保持待执行,而不是填上未经观察的通过结论。
6. 将可展示内容与待开发内容列清楚
本系列可以展示教学服务的账号、双人房间、G1规则、快照同步、积分流水和基础管理接口,也可以展示已经执行的自动检查结果。完整Cocos场景、地方牌类、商业后台和公开服务容量属于后续开发与验收内容,应在项目清单里分别标记。
客户沟通时可以围绕这些实际材料说明技术路线、可扩展位置和交付步骤。需要某项新玩法时,先确认具体规则,再形成代码与验收记录。这样后续项目范围可核对,文章中的技术细节也能够成为真实开发的起点。
十三、部署前先校验交付文件,而不是直接启动
前面已经给出服务、代理与手工备份步骤。实际交接时还需要确认收到的压缩包完整、解压目录正确、关键文件没有在传输或修改中发生变化。下面新增scripts/release-check.mjs,按照系列目录内的SHA256SUMS.txt逐项读取文件并核对摘要,再按需检查HTTP健康入口。
该脚本位于示例工程内部,但清单位于上一级系列目录,所以运行参数要指向包含SHA256SUMS.txt的目录。代码只读取清单中的文件,不自动安装依赖、不修改配置、不启动服务。
javascript
import { readFile, realpath } from 'node:fs/promises';
import { createHash } from 'node:crypto';
import { resolve, relative, isAbsolute, sep } from 'node:path';
import { pathToFileURL } from 'node:url';
export async function verifyPackage(directory) {
const root = await realpath(directory);
const manifest = await readFile(resolve(root, 'SHA256SUMS.txt'), 'utf8');
const seen = new Set();
let count = 0;
for (const line of manifest.split(/\r?\n/).filter(Boolean)) {
const match = /^([a-f0-9]{64}) (.+)$/.exec(line);
if (!match) throw new Error('BAD_MANIFEST_LINE');
const [, expected, name] = match;
if (isAbsolute(name) || name.includes('\\') || name.split('/').includes('..') || seen.has(name)) {
throw new Error('UNSAFE_OR_DUPLICATE_PATH');
}
seen.add(name);
const path = await realpath(resolve(root, name));
const rel = relative(root, path);
if (rel === '..' || rel.startsWith('..' + sep) || isAbsolute(rel)) throw new Error('PATH_ESCAPES_PACKAGE');
const bytes = await readFile(path);
const actual = createHash('sha256').update(bytes).digest('hex');
if (actual !== expected) throw new Error('CHECKSUM_MISMATCH: ' + name);
count++;
}
if (!count) throw new Error('EMPTY_MANIFEST');
return { checkedFiles: count, verified: true };
}
export async function checkHealth(base, request = fetch) {
const target = new URL(base);
if (!['http:', 'https:'].includes(target.protocol) || target.username || target.password) {
throw new Error('BAD_BASE_URL');
}
const results = [];
for (const name of ['live', 'ready']) {
const started = performance.now();
const response = await request(new URL('/health/' + name, target), { signal: AbortSignal.timeout(5000) });
const value = await response.json();
const expected = name === 'live' ? 'alive' : 'ready';
if (response.status !== 200 || value.status !== expected) throw new Error('HEALTH_FAILED: ' + name);
results.push({ check: name, status: response.status, milliseconds: Math.round(performance.now() - started) });
}
return results;
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
const directory = process.argv[2];
if (!directory) throw new Error('Pass series directory containing SHA256SUMS.txt');
const artifact = await verifyPackage(directory);
const health = process.env.BASE_URL ? await checkHealth(process.env.BASE_URL) : 'not_requested';
console.log(JSON.stringify({ artifact, health }, null, 2));
}
1. 路径检查为什么与摘要检查放在一起
清单中的文件名不能跳出交付目录。脚本先拒绝绝对路径、反斜线和父目录片段,再解析真实路径,防止符号链接把读取目标带到目录之外。重复文件名也被拒绝,避免同一条目出现两种预期摘要造成混淆。
摘要只证明文件内容与这份清单相同,不证明清单来自可信作者。如果文件与清单同时被改,普通SHA256核对无法发现来源问题。交接时还需通过可信渠道确认包摘要;本篇没有实现数字签名验证,不把文件校验描述成来源认证。
清单也不会阻止目录里出现额外文件。脚本只核对列出的文件,不会自动删除.env、运行日志或node_modules。部署应使用清楚的版本目录,额外配置分开管理,避免把未知残留当作发布内容。
2. 先只检查文件,再检查服务
在示例工程根目录执行下面命令,双点指向系列目录。第一次可不设置BASE_URL,只验证本地文件:
powershell
node scripts/release-check.mjs ..
if ($LASTEXITCODE -ne 0) { throw 'Package verification failed' }
如果已经按步骤修改了包内配置,清单当然可能与当前文件不一致。应先在原始交付目录校验,再复制示例工程到部署工作目录进行配置;不要为了看到通过就随手重新生成清单,这会失去核对原始包的意义。
服务和代理启动后,指定代理入口再次检查:
powershell
$env:BASE_URL = 'http://127.0.0.1:8080'
try {
node scripts/release-check.mjs ..
if ($LASTEXITCODE -ne 0) { throw 'Package or health verification failed' }
} finally {
Remove-Item Env:BASE_URL -ErrorAction SilentlyContinue
}
健康检查不仅要求HTTP为200,还要求live正文为alive、ready正文为ready。某些错误代理可能返回一个200状态的静态错误页,仅看状态码会漏判。JSON解析失败也会终止检查,不会当成正常响应。
输出中的milliseconds是单次观察,不是性能基准。要评估延迟需要重复采样、记录设备与网络并区分冷启动和稳定状态,本脚本没有据此生成任何容量结论。
十四、将备份步骤整理成可保存记录的脚本
手工命令容易漏掉退出码或忘记保存摘要。新增deploy/backup-check.ps1,将执行、复制、摘要和记录写入同一流程。默认使用当前compose.yaml与.env.db,数据库服务名为db,与第二篇一致。
脚本应从示例工程目录运行。它会在backups下创建带时间与随机后缀的新目录,每次运行独立保存,不覆盖之前的备份。数据库密码仍由既有环境配置管理,脚本不把密码复制到记录JSON。
powershell
param(
[string]$ComposeFile = 'compose.yaml',
[string]$EnvFile = '.env.db',
[ValidatePattern('^[a-z][a-z0-9_]{0,62}$')][string]$Database = 'baiyou',
[string]$OutputDirectory = 'backups'
)
$ErrorActionPreference = 'Stop'
Get-Command docker -ErrorAction Stop | Out-Null
if (!(Test-Path -LiteralPath $ComposeFile -PathType Leaf)) { throw 'Compose file missing' }
if (!(Test-Path -LiteralPath $EnvFile -PathType Leaf)) { throw 'Environment file missing' }
$runId = (Get-Date -Format 'yyyyMMdd-HHmmss') + '-' + [guid]::NewGuid().ToString('N').Substring(0,8)
$runDirectory = Join-Path $OutputDirectory $runId
New-Item -ItemType Directory -Path $runDirectory -ErrorAction Stop | Out-Null
$dumpPath = Join-Path $runDirectory "$Database.dump"
$containerPath = "/tmp/$Database-$runId.dump"
$common = @('compose', '-f', $ComposeFile, '--env-file', $EnvFile)
function Invoke-CheckedDocker {
param([string[]]$Arguments)
& docker @common @Arguments
if ($LASTEXITCODE -ne 0) { throw "Docker command failed with exit code $LASTEXITCODE" }
}
$started = [DateTime]::UtcNow
# The binary archive is written to a file, never passed through text redirection.
Invoke-CheckedDocker -Arguments @('exec','-T','db','pg_dump','-U','postgres','-d',$Database,'-Fc','-f',$containerPath)
Invoke-CheckedDocker -Arguments @('exec','-T','db','pg_restore','--list',$containerPath) | Out-Null
Invoke-CheckedDocker -Arguments @('cp',"db:$containerPath",$dumpPath)
$file = Get-Item -LiteralPath $dumpPath
if ($file.Length -le 0) { throw 'Empty archive' }
$digest = (Get-FileHash -LiteralPath $dumpPath -Algorithm SHA256).Hash.ToLowerInvariant()
$record = [ordered]@{
database = $Database
archive = $file.Name
bytes = $file.Length
sha256 = $digest
startedUtc = $started.ToString('o')
completedUtc = [DateTime]::UtcNow.ToString('o')
archiveListingChecked = $true
restoreVerified = $false
containerArchive = $containerPath
}
$record | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $runDirectory 'backup.json') -Encoding utf8
Write-Output "Backup saved: $dumpPath"
Write-Output "SHA256: $digest"
# Retain this specific container archive for diagnosis; schedule cleanup separately.
1. 每一步都检查外部命令退出码
PowerShell的ErrorActionPreference处理脚本错误,但不能在所有使用环境中自动把外部程序非零退出码转换为异常。因此Invoke-CheckedDocker在每次docker执行后立即检查LASTEXITCODE,失败就停止,不继续生成看起来成功的记录。
例如pg_dump失败后,如果仍然复制上一次同名文件,最后可能得到一个有效但过期的备份。本脚本使用唯一容器路径和唯一输出目录,结合退出码检查,减少把旧文件误当成这次结果的可能。生成记录的时点也放在复制与摘要检查之后。
2. 二进制文件不通过文本管道
pg_dump使用custom格式写到容器文件,再由docker compose cp复制到宿主机。脚本不会把二进制输出接到Set-Content或旧版PowerShell的文本重定向。这个处理保持文件字节完整,也便于单独计算摘要。
pg_restore --list检查归档目录可读,不能证明全部数据一定能成功恢复,因此记录明确写restoreVerified为false。文件非空、目录可读与实际恢复成功是三个不同检查,不应该合并成一个"备份成功即可恢复"的结论。PostgreSQL pg_dump说明
3. 执行与查看备份记录
powershell
.\deploy\backup-check.ps1 -Database baiyou -OutputDirectory backups
if (!$?) { throw 'Backup script failed' }
执行后会输出dump路径与SHA256。进入对应目录查看backup.json,其中记录源库、文件名、字节数、摘要和时间。该文件不保存数据行内容,但dump本身包含账号与业务资料,应保存在受控位置,不随文章源码包公开分发。
容器内临时归档会保留,便于诊断,不会自动删除。长期使用时需要单独定义保留周期和清理流程,核对明确路径后再清理。备份脚本不负责删除历史文件,避免一次运行同时承担生成和销毁两类任务。
十五、从记录恢复到全新的隔离数据库
新增deploy/restore-check.ps1。它接收backup.json,先检查文件名、大小和SHA256,再生成一个新的恢复库名,执行恢复并查询关键表数量。没有DROP、--clean或覆盖源库操作,同名创建失败会直接停止。
powershell
param(
[Parameter(Mandatory=$true)][string]$BackupRecord,
[string]$ComposeFile = 'compose.yaml',
[string]$EnvFile = '.env.db'
)
$ErrorActionPreference = 'Stop'
Get-Command docker -ErrorAction Stop | Out-Null
$recordFile = Get-Item -LiteralPath $BackupRecord
$record = Get-Content -LiteralPath $recordFile.FullName -Raw | ConvertFrom-Json
if ([IO.Path]::GetFileName($record.archive) -ne $record.archive -or $record.archive -notmatch '^[a-z][a-z0-9_]*\.dump$') {
throw 'Invalid archive filename in backup record'
}
$dumpPath = Join-Path $recordFile.DirectoryName $record.archive
$file = Get-Item -LiteralPath $dumpPath
if ($file.Length -ne $record.bytes) { throw 'Archive size mismatch' }
$digest = (Get-FileHash -LiteralPath $dumpPath -Algorithm SHA256).Hash.ToLowerInvariant()
if ($digest -ne $record.sha256) { throw 'Archive checksum mismatch' }
$runId = [guid]::NewGuid().ToString('N')
$restoreDb = 'baiyou_restore_' + $runId.Substring(0,12)
$containerPath = "/tmp/restore-$runId.dump"
$common = @('compose', '-f', $ComposeFile, '--env-file', $EnvFile)
function Invoke-CheckedDocker {
param([string[]]$Arguments)
& docker @common @Arguments
if ($LASTEXITCODE -ne 0) { throw "Docker command failed with exit code $LASTEXITCODE" }
}
$started = [DateTime]::UtcNow
Invoke-CheckedDocker -Arguments @('cp',$dumpPath,"db:$containerPath")
# A fresh unique database: no DROP, no --clean, no --create that could select the source name.
Invoke-CheckedDocker -Arguments @('exec','-T','db','createdb','-U','postgres','-T','template0',$restoreDb)
Invoke-CheckedDocker -Arguments @('exec','-T','db','pg_restore','-U','postgres','-d',$restoreDb,'--single-transaction','--no-owner','--no-acl',$containerPath)
$sql = "SELECT json_build_object('users',(SELECT count(*) FROM users),'rooms',(SELECT count(*) FROM rooms),'events',(SELECT count(*) FROM room_events),'settlements',(SELECT count(*) FROM settlements),'scores',(SELECT count(*) FROM score_entries));"
$raw = Invoke-CheckedDocker -Arguments @('exec','-T','db','psql','-X','-U','postgres','-d',$restoreDb,'-v','ON_ERROR_STOP=1','-t','-A','-c',$sql)
$counts = ($raw -join "`n") | ConvertFrom-Json
$report = [ordered]@{
targetDatabase = $restoreDb
sourceArchiveSHA256 = $digest
restoredUtc = [DateTime]::UtcNow.ToString('o')
seconds = [Math]::Round(([DateTime]::UtcNow - $started).TotalSeconds, 2)
counts = $counts
sqlReadVerified = $true
applicationLoginVerified = $false
replayVerified = $false
permissionsConfigured = $false
}
$reportPath = Join-Path $recordFile.DirectoryName "restore-$runId.json"
$report | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $reportPath -Encoding utf8
Write-Output "Restored into isolated database: $restoreDb"
Write-Output "Report: $reportPath"
# --no-acl intentionally leaves application grants for a separate explicit step.
1. 为什么恢复时不用原数据库名称
演练的目的是证明备份可用,而不是替换正在工作的数据库。脚本自动生成baiyou_restore_前缀加随机后缀的新名称,后续查询和报告都指向该库。失败时保留新库供检查,不把错误恢复现场自动删除。
pg_restore使用明确的-d目标库,没有使用--create,避免归档中的源库名称控制恢复目标。--single-transaction让恢复作为一个事务执行,遇到错误不会提交一半已恢复对象;它不与并行恢复参数一起使用。这里还通过--no-owner与--no-acl跳过原归属和授权,应用权限随后单独配置。PostgreSQL pg_restore说明
2. 摘要一致也要继续恢复和查询
SHA256与记录一致,只说明当前dump与记录生成时的文件相同。备份时若业务数据已经有问题,摘要不会让数据变正确;备份记录本身也不是不可篡改的签名。因此恢复后还要查询、登录和回放已知房间,观察业务含义是否正确。
脚本恢复后查询users、rooms、room_events、settlements和score_entries的数量,保存到新的restore报告。applicationLoginVerified、replayVerified和permissionsConfigured都保持false,因为这些步骤尚未执行。不要让脚本在没有验证时先填true,再要求验收人员事后确认。
3. 运行恢复脚本
把路径换成刚才生成的实际记录:
powershell
$backupRecord = 'backups/替换为实际备份目录/backup.json'
.\deploy\restore-check.ps1 -BackupRecord $backupRecord
if (!$?) { throw 'Restore script failed' }
命令会打印新库名称和报告路径。报告中的counts用于确认关键表存在并可读取,不自动与备份前的在线数据库做精确比较,因为源库可能在备份期间继续变化。严谨演练可以在测试写入暂停时记录基线,或者预先选定在备份前已经结束的房间作业务抽查。
脚本默认连接现有Docker数据库服务中的postgres账号执行恢复,仅用于隔离演练。它不意味着应用服务应该使用postgres运行。恢复库进入应用验证前,仍需配置应用角色权限,运行账号与维护账号继续分开。
十六、给恢复库授权,再在独立端口验证业务
恢复时跳过了ACL,所以应用账号不会自动得到完整表权限。在psql中连接实际恢复库,由维护账号执行下列授权。baiyou_app角色应按第二篇已在该集群创建;跨主机恢复时需要先重新建立角色,单库dump不会替你创建整个集群的角色。
sql
GRANT USAGE ON SCHEMA public TO baiyou_app;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO baiyou_app;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO baiyou_app;
这套权限匹配当前教学服务,序列权限支持审计身份列。它不是所有正式业务的最小权限最终方案,后续可以按照读写职责进一步拆角色。授权时确认连接的是恢复库,不能因终端提示相似就误改另一个数据库。
为恢复验证建立一份单独配置文件,DATABASE_URL中的库名改成报告输出的恢复库,APP_PORT改为3001。不要覆盖正常服务的.env,两个实例应能明确区分。环境文件只在受控本机保存,示意内容如下:
dotenv
APP_HOST=127.0.0.1
APP_PORT=3001
DATABASE_URL=postgresql://baiyou_app:替换为URL编码密码@127.0.0.1:5432/替换为恢复库名
ALLOWED_ORIGINS=http://127.0.0.1:3001
保存为.env.restore,然后启动独立服务:
powershell
node --env-file=.env.restore src/main.mjs
另开终端访问3001的ready,在该地址重新登录原测试账号,查询备份前已经结束的roomId并运行回放核对。不要把新建的验收房间当作备份恢复证据;新建房间只能证明恢复后可写,历史房间重演才能证明旧资料可用。
恢复库仍包含备份中的会话记录,不应直接对外开放。验收重新登录以取得明确会话,同时核对旧账号资料。业务测试会在恢复库增加新记录,因此需要在报告里区分"恢复时表数量"与"验证后表数量",不能把新增测试数据当成恢复误差。
十七、验证发布检查脚本自身能发现坏结果
新增test/release-check.test.mjs,通过临时目录验证摘要错误和越界路径会被拒绝,并通过模拟HTTP响应验证正文错误不会被200状态掩盖。测试结束只删除自己创建的临时目录,不访问部署目录或数据库。
javascript
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtemp, writeFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createHash } from 'node:crypto';
import { verifyPackage, checkHealth } from '../scripts/release-check.mjs';
test('release check validates bytes and rejects modified or escaping entries', async () => {
const directory = await mkdtemp(join(tmpdir(), 'baiyou-release-test-'));
try {
const hash = createHash('sha256').update('version-one').digest('hex');
await writeFile(join(directory, 'app.txt'), 'version-one');
await writeFile(join(directory, 'SHA256SUMS.txt'), `${hash} app.txt\n`);
assert.equal((await verifyPackage(directory)).checkedFiles, 1);
await writeFile(join(directory, 'app.txt'), 'version-two');
await assert.rejects(verifyPackage(directory), /CHECKSUM_MISMATCH/);
await writeFile(join(directory, 'SHA256SUMS.txt'), `${hash} ../outside.txt\n`);
await assert.rejects(verifyPackage(directory), /UNSAFE_OR_DUPLICATE_PATH/);
} finally {
// Only the exact directory just created by mkdtemp is removed.
await rm(directory, { recursive: true, force: true });
}
});
test('health probe verifies readiness body, not just HTTP 200', async () => {
const calls = [];
const request = async url => {
calls.push(url.pathname);
return { status: 200, json: async () => ({ status: url.pathname.endsWith('live') ? 'alive' : 'ready' }) };
};
assert.equal((await checkHealth('http://127.0.0.1:3000', request)).length, 2);
assert.deepEqual(calls, ['/health/live', '/health/ready']);
await assert.rejects(checkHealth('http://127.0.0.1:3000', async () => ({ status: 200, json: async () => ({ status: 'wrong' }) })), /HEALTH_FAILED/);
await assert.rejects(checkHealth('http://user:password@localhost', request), /BAD_BASE_URL/);
});
第一个用例先写一份合法文件和对应清单,确认通过,再修改文件内容,期待CHECKSUM_MISMATCH。随后把清单路径改成父目录引用,期待路径错误。它验证的是核对器的失败能力,而不是只检查一份恰好正确的包。
第二个用例给live和ready提供正确结构,再用200加错误status模拟代理或服务配置异常,要求失败。真实外部HTTP仍需在部署主机运行探针,这个测试只确认脚本判断逻辑正确。
powershell
node --test test/release-check.test.mjs
十八、检查PowerShell脚本语法时不要误执行恢复
备份恢复需要真实Docker和数据库,不能为了检查语法就直接启动它。PowerShell解析器可以只解析代码,不执行外部命令。下面语句扫描新增的两个文件,发现语法错误就抛出:
powershell
$paths = @('deploy/backup-check.ps1', 'deploy/restore-check.ps1')
foreach ($path in $paths) {
$tokens = $null
$syntaxErrors = $null
[System.Management.Automation.Language.Parser]::ParseFile(
(Resolve-Path -LiteralPath $path).Path,
[ref]$tokens,
[ref]$syntaxErrors
) | Out-Null
if ($syntaxErrors.Count -gt 0) {
throw ($syntaxErrors | Out-String)
}
}
语法通过不证明docker已安装、容器运行或数据库密码正确,也不证明pg_restore实际成功。交付验证记录应分开填写"语法通过"和"目标环境恢复通过",不能把前者替换为后者。本次新增脚本完成语法检查,真实恢复仍待部署环境运行。
同理,systemd和Nginx配置需要在对应Linux环境检查。Windows上的文本检查不能替代systemd-analyze或nginx -t,Cocos原生构建也不能由Node测试代替。明确每个工具真正执行了什么,验收结论才有可复查意义。
十九、将代理和WebSocket纳入同一轮外部验收
代理配置完成后,先用release-check检查8080的live与ready,再通过同一个入口运行第六篇的rules-online脚本完成一局。如果只检查3000直连成功,无法证明Nginx转发、Origin与请求体限制正确。
powershell
$env:BASE_URL = 'http://127.0.0.1:8080'
try {
node scripts/rules-online.mjs
if ($LASTEXITCODE -ne 0) { throw 'Game acceptance through proxy failed' }
} finally {
Remove-Item Env:BASE_URL -ErrorAction SilentlyContinue
}
该脚本会在结束时注销自己创建的会话,不能直接拿它的已撤销令牌做WebSocket探针。订阅检查应使用另一个仍有效的参与者会话,按第七篇设置API_TOKEN和ROOM_ID,并同样将BASE_URL指向8080。
代理需要显式传递Upgrade与Connection头,普通HTTP正常不能证明WebSocket升级正常。现有Nginx模板已包含这些配置;实际装载路径、证书与网络访问仍需目标主机验证。Nginx WebSocket代理说明
若直连探针成功而代理失败,依次核对入口端口、实际加载配置、Upgrade头与Origin;若两者都失败,检查应用和会话。不要在没有定位层次时把所有超时都增大,可能只是把明确错误变成更长等待。
二十、为双端验收准备可以填写的证据表
以下表格保存为实际项目验收记录时,填写真实版本、时间和结果。本文给出的是字段和操作,不预填未运行设备的通过结论。
| 阶段 | 必填内容 | 验证动作 | 通过依据 |
|---|---|---|---|
| 代码包 | 清单摘要、版本目录 | release-check | 清单内文件一致 |
| 应用进程 | Node版本、端口、启动时间 | live与ready | 正确状态与正文 |
| 代理 | 实际入口、配置版本 | 完整对局与订阅 | 同一代理路径可用 |
| Android | 设备、系统、包版本 | 登录、对局、切后台 | 操作与服务端记录一致 |
| iOS | 设备、系统、构建编号 | 登录、锁屏、恢复 | 恢复后快照与会话正确 |
| 数据恢复 | dump摘要、恢复库名 | 旧局查询与重演 | 历史结果一致 |
| 管理权限 | 两个账号、对象编号 | 越权与旧版本请求 | 拒绝且数据未改变 |
对设备侧失败,应记录发生在哪个动作、当时网络类型和服务端错误编号。仅写"打不开"或"卡住"无法区分证书、地址、登录、后台暂停和房间状态。截图可以作为表现证据,但要关联roomId与seq,才能与服务端核对。
部署切换还应记录旧版本目录与数据库迁移状态。当前只有初始迁移,不支持任意数据库版本降级。出现问题时不能只把current链接指回去就假定一定恢复;先判断旧代码与当前结构是否兼容,再按已有恢复计划处理。
二十一、本篇完成后应交付什么
交接资料应包含原始包与清单、部署配置位置、服务与代理检查结果、一次真实完整对局、一次备份和新库恢复记录,以及各平台实际执行的设备验收。未执行的部分保留待验收,不能因为文章代码完整就自动填为通过。
本次新增release-check具备可运行的文件和健康检查,备份恢复脚本给出完整退出码、摘要和目标库保护流程;配套自动测试已经执行,PowerShell脚本完成语法解析。Docker恢复、Linux服务装载、真实PostgreSQL竞争和Android/iOS构建仍需要对应环境。
后续增加地方牌类或完整客户端时,继续沿用这种交付方式:每项功能都留下可执行文件、输入条件、预期结果、错误路径和实际验证记录。这样系列文章既能逐步阅读,也能作为真实开发和交接的操作依据。