百游棋牌源代码开发搭建教程(十):隔离部署、备份恢复与双端验收

文章摘要: 将前九篇的教学工程部署到独立验收环境,提供Linux进程托管、Nginx代理、PostgreSQL备份恢复和冒烟检查步骤,并梳理Cocos Android与iOS构建所需的工具、配置和真机验收项目。

文章目录


前言

前九篇已经将账号、房间、规则、同步、结算和管理接口串成教学工程。最后一篇把这些内容放到可重复的验收流程中,回答如何启动、如何定位失败、如何恢复数据,以及客户端打包之后应检查什么。

这里的目标是隔离验收部署。当前代码尚未实现公开服务所需的注册滥用控制、完整客户端场景、地方牌类规则与完整运营后台,因此不能把执行下面命令等同于一套商业成品上线。本次作者环境实际验证了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构建仍需要对应环境。

后续增加地方牌类或完整客户端时,继续沿用这种交付方式:每项功能都留下可执行文件、输入条件、预期结果、错误路径和实际验证记录。这样系列文章既能逐步阅读,也能作为真实开发和交接的操作依据。

相关推荐
xcLeigh1 小时前
让大模型长出手脚,自己写SQL查数据库(Function Calling初探)
数据库·人工智能·sql·时序数据库·timechoai
yume_sibai2 小时前
06-Rust Web 开发实战(Axum 框架 + 数据库 + JWT 认证 + 中间件 + 部署)
前端·数据库·rust
sunshine22 girl2 小时前
Idea中如何搜索
java·ide·intellij-idea
猫吻鱼2 小时前
【AI 01】【Spring AI 基础使用】
java·spring·spring ai
梨涡泥窝2 小时前
基于SSM的校园二手交易平台的设计与实现
java·tomcat
许彰午3 小时前
44-useRowSet镜像实现
java·低代码·架构
AugustRed3 小时前
Neo4j 图数据库原理 + 应用场景简单介绍
数据库·neo4j
Gl�ria3 小时前
Redis 高可用架构对比
数据库·redis
bamboolm3 小时前
springboot+Ollama整合
java·ollama