写在前面:差点把生产数据删了
上线前最后一次演练,我敲了一条命令:
docker compose down -v
-v 是我想清理一下构建残留,加的时候完全没有犹豫。三秒后五个容器全没了,两个数据卷一起没了——订单、商品、几十个代理商账号,全没了。
好在是测试环境。但那天我才真正明白,-v 这个 flag 的破坏力和它长得有多不显眼。
它就在 down 后面,四个字母,几乎没有存在感,看起来像"顺便清理一下"。而它的实际语义是"删除数据卷"。这个教训后来被我写进了部署清单的第一行。
这篇讲三件事:怎么测(用内置 node:test,零依赖)、怎么部署(Docker 双站)、上线前怎么检查(一份可以直接打印的清单)。所有内容来自真实部署过程,包括上面这个事故。
一、测试:node:test 内置,零依赖
项目用 Node 内置的 node:test,不需要 Jest / Vitest。
1.1 怎么跑
cd backend
# 全量(当前基线:581 个用例全绿)
node --test "src/tests/*.test.js"
# 单个模块
node --test src/tests/stores.test.js
# 按关键词筛(node 22 支持 --test-name-pattern)
node --test --test-name-pattern="拆单" "src/tests/*.test.js"
⚠️ Windows 上有个坑:package.json 里写的是 node --test src/tests/(带尾斜杠),在 Windows 上会 MODULE_NOT_FOUND。
一律用带引号的通配符写法:
node --test "src/tests/*.test.js"
macOS / Linux 两种都行,但为了团队一致,建议统一用通配符版。
--test-name-pattern 这个参数在 Node 22 上特别好用。改完一个模块只想验证相关用例时,不用等全量 581 个跑完,响应快很多。
1.2 测试文件怎么组织
backend/src/tests/
├── stores.test.js # 店铺 + 拆单集成(19 例)
├── products.test.js # 商品
├── orders.test.js # 订单
├── commission-engine.test.js # 佣金引擎
├── agent-hierarchy.test.js # 代理层级(护栏 G01)
├── compliance-guardrails.test.js # ★ 护栏专项(改层级/计酬后必跑)
├── security-orders-scope.test.js # 订单越权访问
├── integration.test.js # 端到端
└── ... 共 30 个文件
带 ★ 的那个必须单独说明:改代理层级或计酬规则之后,compliance-guardrails.test.js 是必跑项,不能等全量测试覆盖,因为合规约束往往不在业务逻辑的测试路径上。
1.3 标准写法
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
createStore, deleteStore, _internalState, _restoreState,
} from '../handlers/stores.js';
// ① 每个用例开头重置状态,避免用例之间互相污染
function reset() { _restoreState([]); }
// ② node:assert 的 throws 不返回错误对象,包一层以便断言 status
function expectError(fn, re) {
let caught = null;
try { fn(); } catch (e) { caught = e; }
if (!caught) throw new Error('预期抛错,但函数正常返回');
if (re && !re.test(caught.message)) {
throw new Error(`错误信息不匹配(期望 ${re}):${caught.message}`);
}
return caught;
}
// ③ 构造数据的 helper,避免每个用例重复写
function mkStore(overrides = {}) {
return createStore({ name: '深圳南山旗舰店', code: 'sz-ns-001', ...overrides });
}
test('创建:默认字段正确(编码大写、状态 active、ID 形如 STO0001)', () => {
reset();
const s = mkStore();
assert.equal(s.code, 'SZ-NS-001', '店铺编码应统一大写');
assert.equal(s.status, 'active');
assert.ok(/^STO\d{4}$/.test(s.id), `ID 应形如 STO0001:${s.id}`);
});
test('删除:仍被商品占用时拒绝(409)', () => {
reset();
const s = mkStore();
const err = expectError(() => deleteStore(s.id), /仍有商品/);
assert.equal(err.status, 409);
});
这三点里,② 那个 expectError 包装函数是我最常用的。node:assert 的 throws() 确实能匹配错误信息,但它拿不到错误对象本身,没法继续断言 err.status。所有涉及 HTTP 状态码的测试(400 校验失败、409 冲突)都靠这个包装。
1.4 三个必须记住的点
① 断言消息要写:
assert.equal(s.code, 'SZ-NS-001', '店铺编码应统一大写');
// ↑ 失败时一眼看出是哪儿错
581 个用例全绿的时候,你不会天天看测试名。一旦红了,有这条消息和没这条消息的排查速度差十倍——前者直接看到失败原因,后者要自己去读代码。
② createProduct() 返回 { product, warnings }:
// ❌ 错
const p = createProduct({ name: 'X' });
p.id; // undefined
// ✅ 对
const { product } = createProduct({ name: 'X' });
product.id; // P0001
warnings 是合规校验给的提示(敏感词、绝对化用语等),别丢。它现在是返回值的一部分,将来合规规则变严格了,这个字段的形状还可能再变,但解构的写法已经留好了位置。
③ 持久化要单独测:
test('持久化:快照导出与恢复', () => {
reset();
createStore({ name: 'A' });
createStore({ name: 'B' });
const snap = _internalState();
_restoreState([]);
assert.equal(listStores().length, 0);
_restoreState(snap);
assert.equal(listStores().length, 2);
});
这个测试专门覆盖了备份恢复的实际场景。 如果线上要从备份恢复,这份快照必须能被原样读回去。往返测试跑通了,才敢说备份是有意义的。
1.5 新增模块至少覆盖这 6 类用例
| 类别 | 例子 |
|---|---|
| 默认字段 | ID 格式、状态默认值、时间戳存在 |
| 必填校验 | 名称为空 → 400 |
| 唯一性 | 编码重复 → 409 |
| 边界值 | 状态不在枚举内 → 400 |
| 删除保护 | 被引用 → 409 |
| 持久化 | _internalState / _restoreState 往返 |
这 6 类基本能覆盖一个 CRUD 模块 80% 的问题。剩下 20% 是业务特有的规则,靠对需求的理解补。
二、Docker 部署
2.1 服务编排
双站架构:main 正式站 + show 演示站,数据完全隔离。
| 容器 | 端口 | 环境变量要点 |
|---|---|---|
disttouch-nginx | 80 / 443 | 多域名反代 |
disttouch-backend | 3000 | STORAGE_MODE=file、EDITION=source |
disttouch-portal | 7080 | API_GATEWAY_URL=http://node-backend:3000 |
disttouch-backend-show | 3001 | EDITION=demo |
disttouch-portal-show | 7081 | API_GATEWAY_URL=http://node-backend-show:3000 |
数据卷也是分开的:
volumes:
backend_data: # main 站业务数据
backend_data_show: # show 站业务数据(独立!)
双站的价值在于验证。show 站跑 EDITION=demo,用来对外做不受影响的演示;正式站跑 EDITION=source。两者数据卷独立,所以演示时怎么点都不会污染生产数据。
2.2 启动与停止
cd services/docker
# 启动
docker compose up -d
# 看状态
docker compose ps
# 看日志
docker compose logs -f node-backend
# 停止
docker compose down
🔴 docker compose down 千万别加 -v
开头那个事故的正解在这里:
docker compose down # ✅ 停容器,保留数据卷
docker compose down -v # ❌ 连数据一起删,除非你确定要重来
-v 会连数据卷一起删掉,业务数据全没了,且不可恢复。真正需要"清空重来"的场景很少,而且应该显式执行、确认过再执行。
2.3 改代码后必须 rebuild
后端和门户都是 COPY 进镜像的,不是 volume 挂载:
# backend/Dockerfile
FROM node:22-alpine
WORKDIR /app
COPY package.json ./
COPY src/ ./src/
CMD ["node", "src/server.js"]
所以:
# 改了后端
docker compose up -d --build node-backend
# 改了 PHP 门户
docker compose up -d --build php-portal
# 改了演示站
docker compose up -d --build node-backend-show php-portal-show
📌 为什么不用 volume 挂载?
生产环境要的是"镜像即部署单元":镜像里是什么,线上跑的就是什么。改文件不重建不生效,反而更安全——避免线上被人手工改乱。挂了 volume 之后,任何人都能在容器里直接编辑文件,改完当场生效,第二天换一个容器部署,这些改动全部消失,而且没人知道曾经有过这些改动。
开发时的替代方案是:用独立容器,或者干脆用本地 PHP 内置服务器(第三篇讲环境搭建时提到的 php -S localhost:8080)。
2.4 改 nginx 配置:先校验,再 reload
# 先校验,通过了再 reload(不要直接 restart)
docker exec disttouch-nginx nginx -t
docker exec disttouch-nginx nginx -s reload
-t 不通过绝对不要 reload,否则 nginx 起不来,整站挂掉。
这条和 2.3 的 rebuild 是同一个思路的延伸:任何"应用之前先验证一下"的步骤都值得单独加。 nginx -t 就是 nginx 自带的 dry-run,一秒钟的成本,换整站可用。
三、数据备份与恢复
业务数据全在 db.json。
3.1 找到它
# 卷挂载到容器内 /app/.data
docker exec disttouch-backend ls -la /app/.data/
# 宿主机上的实际位置
docker volume inspect services_docker_backend_data
3.2 备份
TS=$(date +%Y%m%d-%H%M%S)
docker run --rm \
-v services_docker_backend_data:/data \
-v $(pwd):/backup \
alpine tar czf /backup/dt-data-$TS.tar.gz -C /data .
用一次性的 alpine 容器打包数据卷,不需要在业务容器里装任何东西。--rm 保证跑完自动清理。
3.3 恢复
docker run --rm \
-v services_docker_backend_data:/data \
-v $(pwd):/backup \
alpine sh -c "cd /data && tar xzf /backup/dt-data-20260829.tar.gz"
docker compose restart node-backend
⚠️ 恢复前先停服务,避免写入冲突。
这个注意事项很容易被忽略:解开 tar 的同时业务进程还在往 db.json 里写,两边同时动同一个文件,结果就是一个既不是备份也不是当前状态的混合体。所以正式流程应该是:
docker compose stop node-backend # 1. 停
# 2. 恢复数据
docker compose start node-backend # 3. 起
备份也一样。 打 tar 的时候不停止服务,快照可能处于写入中途的状态。
四、HTTPS 证书:给新增域名单独签,别动原有的
后加域名时会遇到一个典型故障:证书 SAN 不含该域名,浏览器报「连接不是私密连接」,curl 不带 -k 直接返回 000。
推荐做法:给新增域名单独签一张证书,不动原有站点。
# 1. 安装 certbot(Ubuntu)
apt-get update && apt-get install -y certbot
# 2. webroot 方式签发(零停机)
# 验证文件写在官网静态目录,nginx 已有 location /.well-known/
certbot certonly --webroot \
-w /path/to/your/project/services/docker/www \
-d main.example.com -d show.example.com \
--cert-name site-main \
--agree-tos -m you@example.com --no-eff-email --non-interactive
# 3. 复制证书到 nginx ssl 目录(用新文件名,别覆盖原有的)
SSL=/path/to/your/project/services/docker/nginx/ssl
cp /etc/letsencrypt/live/site-main/fullchain.pem $SSL/main-fullchain.pem
cp /etc/letsencrypt/live/site-main/privkey.pem $SSL/main-privkey.pem
chmod 644 $SSL/main-fullchain.pem
chmod 600 $SSL/main-privkey.pem
# 4. 只改 main/show 的 server 块
# ssl_certificate /etc/nginx/ssl/main-fullchain.pem;
# ssl_certificate_key /etc/nginx/ssl/main-privkey.pem;
# 5. 校验 + reload
docker exec disttouch-nginx nginx -t && docker exec disttouch-nginx nginx -s reload
为什么用 webroot 而不是 standalone? standalone 模式要求 certbot 独占 80 端口,得先停 nginx——意味着停机。webroot 只是往静态目录里写一个验证文件,nginx 不用动,签发过程零停机。
4.1 自动续期钩子
Let's Encrypt 证书 90 天有效期,所以续期是常态:
# /etc/letsencrypt/renewal-hooks/deploy/10-nginx-main.sh
#!/bin/bash
set -e
SSL=/path/to/your/project/services/docker/nginx/ssl
cp /etc/letsencrypt/live/site-main/fullchain.pem "$SSL/main-fullchain.pem"
cp /etc/letsencrypt/live/site-main/privkey.pem "$SSL/main-privkey.pem"
chmod 644 "$SSL/main-fullchain.pem"
chmod 600 "$SSL/main-privkey.pem"
# 关键:校验失败就保持旧证书,不要 reload
if docker exec disttouch-nginx nginx -t; then
docker exec disttouch-nginx nginx -s reload
echo "[cert-deploy] nginx reloaded with renewed cert"
else
echo "[cert-deploy] nginx -t FAILED, keeping old cert" >&2
exit 1
fi
启用和演练:
chmod +x /etc/letsencrypt/renewal-hooks/deploy/10-nginx-main.sh
certbot renew --dry-run # 演练
💡 这里的 if/else 不是装饰。 续期脚本是无人值守跑的,如果证书有问题或者 nginx -t 挂了还硬 reload,nginx 可能直接起不来——一个本来只需要续个证书的操作,把线上搞挂了。所以逻辑必须是"校验不过就什么都不做,保留旧证书"。
💡 另一个关键点:容器以 :ro 只读挂载了静态目录,但宿主机上的目录本身可写。certbot 写在宿主机、容器能读到——所以不用改 docker-compose、不用停 nginx。
五、上线检查清单
这份清单建议打印出来,部署前逐项打勾。
🔐 安全
-
DEFAULT_ADMIN_PASS已从出厂弱口令改掉(必须改) -
PortalController::ensureToken()里的硬编码账号已同步改,或改为只读专用账号 -
JWT_SECRET已显式设置(不设的话每次启动随机,token 全失效) -
CORS_ORIGIN已从*收敛到具体域名 -
NODE_ENV=production -
AUTH_ENABLED=true(绝不能在生产关掉) - 敏感文件未入库:
.env/.env.local/*.pem/certs/都在.gitignore里 - 微信支付私钥走环境变量注入,不在
db.json里 - 公开接口逐个确认过:响应里没有联系人、电话、密钥等字段
- 错误日志脱敏已生效(不打完整堆栈)
💾 数据
-
STORAGE_MODE=file(否则用户账号每次重启重来) - 数据卷已挂载且是持久卷
- 已做一次全量备份,且验证过备份文件能解开
- 部署命令里没有
-v
第三条我特意加了"验证过能解开"。没验证过的备份等于没有备份——文件能生成不代表能恢复,中间可能少了什么。
✅ 功能
- 全量测试通过:
node --test "src/tests/*.test.js"(基线 581 全绿) - 合规护栏专项测试通过:
node --test src/tests/compliance-guardrails.test.js - 手工打过 3 级分润请求,确认返回 422 +
x-compliance: G01 - 端到端走过一遍:建店 → 挂商品 → 加购 → 拆单 → 支付 → 佣金入账
- 支付在模拟模式下跑通,再切真实支付
🚀 部署
- 改了后端 →
docker compose up -d --build node-backend - 改了门户 →
docker compose up -d --build php-portal - 改了 nginx → 先
nginx -t再nginx -s reload -
docker compose ps确认容器都 Up - 各站点 curl 验证(注意带
-k会掩盖证书问题,生产验证不要加)
最后一条是血的教训。有人图省事用 curl -k 验证 HTTPS,然后说"没问题",结果证书根本没生效。
📊 监控
-
/api/health加入了健康检查 / 监控告警 -
/api/ready用于就绪探针 - 审计日志有落盘且定期归档(
appendAudit覆盖了所有写操作) - 日志轮转已配置
/api/health 和 /api/ready 是两个不同的端点,这个区分有实际意义:health 表示进程活着,ready 表示依赖都就绪了。滚动更新时,只有 ready 通过的实例才应该接流量。只用 health 的话,新容器起来了但后端依赖还没连上,也会被判定为健康。
六、上线后的运维要点
| 场景 | 做法 |
|---|---|
| 改代码 | rebuild 对应服务,不要手工改容器内的文件 |
| 改配置 | 改环境变量 / .env,重启服务 |
| 数据异常 | 先停服务 → 备份当前 db.json → 从备份恢复 → 重启 |
| 忘记管理员密码 | 停服务 → 删 users.json → 重启(会用 DEFAULT_ADMIN_* 重新种子) |
| 磁盘满了 | db.json + 审计日志是主要占用,归档旧审计 |
| 想加机器 | DATA_DIR 指向共享文件系统(NFS 等),INSTANCE_ID 区分实例 |
倒数第二行值得多说一句。db.json 会随业务增长而变大,审计日志更是只增不减——每一条写操作都留痕,这个是好设计,但长期跑下来磁盘占用主要来自它。所以定期归档旧审计日志是必须的,不是可选项。
小结
把这次部署踩过的坑压缩成几句话:
docker compose down -v会删数据。 这条没有任何技术含量,纯粹是危险默认值的可怕之处。- 改代码必须 rebuild。 因为是
COPY不是挂载——这是刻意的安全设计。 - nginx 改配置先
-t再 reload,-t不过就什么都不做。 - 备份要验证能解开。 恢复要停服务,备份也应该停。
JWT_SECRET必须显式设置,否则每次重启 token 全失效。
贯穿其中的一条思路是:在破坏性操作前加一道验证。 nginx -t 是验证,certbot 钩子里的 if 是验证,备份后试着解开是验证。这些步骤加起来花不了五分钟,但每一道都能避免一次不可逆的事故。
代码已开源,Apache 2.0 协议,可自取:atomgit.com/pangzi_zi/d…
为什么用 webroot 而不是 standalone? standalone 模式要求 certbot 独占 80 端口,得先停 nginx——意味着停机。webroot 只是往静态目录里写一个验证文件,nginx 不用动,签发过程零停机。
4.1 自动续期钩子
Let's Encrypt 证书 90 天有效期,所以续期是常态:
# /etc/letsencrypt/renewal-hooks/deploy/10-nginx-main.sh
#!/bin/bash
set -e
SSL=/path/to/your/project/services/docker/nginx/ssl
cp /etc/letsencrypt/live/site-main/fullchain.pem "$SSL/main-fullchain.pem"
cp /etc/letsencrypt/live/site-main/privkey.pem "$SSL/main-privkey.pem"
chmod 644 "$SSL/main-fullchain.pem"
chmod 600 "$SSL/main-privkey.pem"
# 关键:校验失败就保持旧证书,不要 reload
if docker exec disttouch-nginx nginx -t; then
docker exec disttouch-nginx nginx -s reload
echo "[cert-deploy] nginx reloaded with renewed cert"
else
echo "[cert-deploy] nginx -t FAILED, keeping old cert" >&2
exit 1
fi
```
启用和演练:
```bash
chmod +x /etc/letsencrypt/renewal-hooks/deploy/10-nginx-main.sh
certbot renew --dry-run # 演练
```
💡 **这里的 `if/else` 不是装饰。** 续期脚本是无人值守跑的,如果证书有问题或者 `nginx -t` 挂了还硬 reload,nginx 可能直接起不来——**一个本来只需要续个证书的操作,把线上搞挂了**。所以逻辑必须是"校验不过就什么都不做,保留旧证书"。
💡 另一个关键点:容器以 `:ro` **只读**挂载了静态目录,但**宿主机上的目录本身可写**。certbot 写在宿主机、容器能读到——所以**不用改 docker-compose、不用停 nginx**。
---
## 五、上线检查清单
这份清单建议打印出来,部署前逐项打勾。
### 🔐 安全
- [ ] `DEFAULT_ADMIN_PASS` 已从出厂弱口令改掉(**必须改**)
- [ ] `PortalController::ensureToken()` 里的硬编码账号已同步改,或改为只读专用账号
- [ ] `JWT_SECRET` 已显式设置(不设的话每次启动随机,token 全失效)
- [ ] `CORS_ORIGIN` 已从 `*` 收敛到具体域名
- [ ] `NODE_ENV=production`
- [ ] `AUTH_ENABLED=true`(**绝不能在生产关掉**)
- [ ] 敏感文件未入库:`.env` / `.env.local` / `*.pem` / `certs/` 都在 `.gitignore` 里
- [ ] 微信支付私钥走环境变量注入,不在 `db.json` 里
- [ ] 公开接口逐个确认过:**响应里没有联系人、电话、密钥等字段**
- [ ] 错误日志脱敏已生效(不打完整堆栈)
写在前面:差点把生产数据删了
上线前最后一次演练,我敲了一条命令:
docker compose down -v
-v 是我想清理一下构建残留,加的时候完全没有犹豫。三秒后五个容器全没了,两个数据卷一起没了——订单、商品、几十个代理商账号,全没了。
好在是测试环境。但那天我才真正明白,-v 这个 flag 的破坏力和它长得有多不显眼。
它就在 down 后面,四个字母,几乎没有存在感,看起来像"顺便清理一下"。而它的实际语义是"删除数据卷"。这个教训后来被我写进了部署清单的第一行。
这篇讲三件事:怎么测(用内置 node:test,零依赖)、怎么部署(Docker 双站)、上线前怎么检查(一份可以直接打印的清单)。所有内容来自真实部署过程,包括上面这个事故。
一、测试:node:test 内置,零依赖
项目用 Node 内置的 node:test,不需要 Jest / Vitest。
1.1 怎么跑
cd backend
# 全量(当前基线:581 个用例全绿)
node --test "src/tests/*.test.js"
# 单个模块
node --test src/tests/stores.test.js
# 按关键词筛(node 22 支持 --test-name-pattern)
node --test --test-name-pattern="拆单" "src/tests/*.test.js"
⚠️ Windows 上有个坑:package.json 里写的是 node --test src/tests/(带尾斜杠),在 Windows 上会 MODULE_NOT_FOUND。
一律用带引号的通配符写法:
node --test "src/tests/*.test.js"
macOS / Linux 两种都行,但为了团队一致,建议统一用通配符版。
--test-name-pattern 这个参数在 Node 22 上特别好用。改完一个模块只想验证相关用例时,不用等全量 581 个跑完,响应快很多。
1.2 测试文件怎么组织
backend/src/tests/
├── stores.test.js # 店铺 + 拆单集成(19 例)
├── products.test.js # 商品
├── orders.test.js # 订单
├── commission-engine.test.js # 佣金引擎
├── agent-hierarchy.test.js # 代理层级(护栏 G01)
├── compliance-guardrails.test.js # ★ 护栏专项(改层级/计酬后必跑)
├── security-orders-scope.test.js # 订单越权访问
├── integration.test.js # 端到端
└── ... 共 30 个文件
带 ★ 的那个必须单独说明:改代理层级或计酬规则之后,compliance-guardrails.test.js 是必跑项,不能等全量测试覆盖,因为合规约束往往不在业务逻辑的测试路径上。
1.3 标准写法
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
createStore, deleteStore, _internalState, _restoreState,
} from '../handlers/stores.js';
// ① 每个用例开头重置状态,避免用例之间互相污染
function reset() { _restoreState([]); }
// ② node:assert 的 throws 不返回错误对象,包一层以便断言 status
function expectError(fn, re) {
let caught = null;
try { fn(); } catch (e) { caught = e; }
if (!caught) throw new Error('预期抛错,但函数正常返回');
if (re && !re.test(caught.message)) {
throw new Error(`错误信息不匹配(期望 ${re}):${caught.message}`);
}
return caught;
}
// ③ 构造数据的 helper,避免每个用例重复写
function mkStore(overrides = {}) {
return createStore({ name: '深圳南山旗舰店', code: 'sz-ns-001', ...overrides });
}
test('创建:默认字段正确(编码大写、状态 active、ID 形如 STO0001)', () => {
reset();
const s = mkStore();
assert.equal(s.code, 'SZ-NS-001', '店铺编码应统一大写');
assert.equal(s.status, 'active');
assert.ok(/^STO\d{4}$/.test(s.id), `ID 应形如 STO0001:${s.id}`);
});
test('删除:仍被商品占用时拒绝(409)', () => {
reset();
const s = mkStore();
const err = expectError(() => deleteStore(s.id), /仍有商品/);
assert.equal(err.status, 409);
});
这三点里,② 那个 expectError 包装函数是我最常用的。node:assert 的 throws() 确实能匹配错误信息,但它拿不到错误对象本身,没法继续断言 err.status。所有涉及 HTTP 状态码的测试(400 校验失败、409 冲突)都靠这个包装。
1.4 三个必须记住的点
① 断言消息要写:
assert.equal(s.code, 'SZ-NS-001', '店铺编码应统一大写');
// ↑ 失败时一眼看出是哪儿错
581 个用例全绿的时候,你不会天天看测试名。一旦红了,有这条消息和没这条消息的排查速度差十倍——前者直接看到失败原因,后者要自己去读代码。
② createProduct() 返回 { product, warnings }:
// ❌ 错
const p = createProduct({ name: 'X' });
p.id; // undefined
// ✅ 对
const { product } = createProduct({ name: 'X' });
product.id; // P0001
warnings 是合规校验给的提示(敏感词、绝对化用语等),别丢。它现在是返回值的一部分,将来合规规则变严格了,这个字段的形状还可能再变,但解构的写法已经留好了位置。
③ 持久化要单独测:
test('持久化:快照导出与恢复', () => {
reset();
createStore({ name: 'A' });
createStore({ name: 'B' });
const snap = _internalState();
_restoreState([]);
assert.equal(listStores().length, 0);
_restoreState(snap);
assert.equal(listStores().length, 2);
});
这个测试专门覆盖了备份恢复的实际场景。 如果线上要从备份恢复,这份快照必须能被原样读回去。往返测试跑通了,才敢说备份是有意义的。
1.5 新增模块至少覆盖这 6 类用例
| 类别 | 例子 |
|---|---|
| 默认字段 | ID 格式、状态默认值、时间戳存在 |
| 必填校验 | 名称为空 → 400 |
| 唯一性 | 编码重复 → 409 |
| 边界值 | 状态不在枚举内 → 400 |
| 删除保护 | 被引用 → 409 |
| 持久化 | _internalState / _restoreState 往返 |
这 6 类基本能覆盖一个 CRUD 模块 80% 的问题。剩下 20% 是业务特有的规则,靠对需求的理解补。
二、Docker 部署
2.1 服务编排
双站架构:main 正式站 + show 演示站,数据完全隔离。
| 容器 | 端口 | 环境变量要点 |
|---|---|---|
disttouch-nginx | 80 / 443 | 多域名反代 |
disttouch-backend | 3000 | STORAGE_MODE=file、EDITION=source |
disttouch-portal | 7080 | API_GATEWAY_URL=http://node-backend:3000 |
disttouch-backend-show | 3001 | EDITION=demo |
disttouch-portal-show | 7081 | API_GATEWAY_URL=http://node-backend-show:3000 |
数据卷也是分开的:
volumes:
backend_data: # main 站业务数据
backend_data_show: # show 站业务数据(独立!)
双站的价值在于验证。show 站跑 EDITION=demo,用来对外做不受影响的演示;正式站跑 EDITION=source。两者数据卷独立,所以演示时怎么点都不会污染生产数据。
2.2 启动与停止
cd services/docker
# 启动
docker compose up -d
# 看状态
docker compose ps
# 看日志
docker compose logs -f node-backend
# 停止
docker compose down
🔴 docker compose down 千万别加 -v
开头那个事故的正解在这里:
docker compose down # ✅ 停容器,保留数据卷
docker compose down -v # ❌ 连数据一起删,除非你确定要重来
-v 会连数据卷一起删掉,业务数据全没了,且不可恢复。真正需要"清空重来"的场景很少,而且应该显式执行、确认过再执行。
2.3 改代码后必须 rebuild
后端和门户都是 COPY 进镜像的,不是 volume 挂载:
# backend/Dockerfile
FROM node:22-alpine
WORKDIR /app
COPY package.json ./
COPY src/ ./src/
CMD ["node", "src/server.js"]
所以:
# 改了后端
docker compose up -d --build node-backend
# 改了 PHP 门户
docker compose up -d --build php-portal
# 改了演示站
docker compose up -d --build node-backend-show php-portal-show
📌 为什么不用 volume 挂载?
生产环境要的是"镜像即部署单元":镜像里是什么,线上跑的就是什么。改文件不重建不生效,反而更安全——避免线上被人手工改乱。挂了 volume 之后,任何人都能在容器里直接编辑文件,改完当场生效,第二天换一个容器部署,这些改动全部消失,而且没人知道曾经有过这些改动。
开发时的替代方案是:用独立容器,或者干脆用本地 PHP 内置服务器(第三篇讲环境搭建时提到的 php -S localhost:8080)。
2.4 改 nginx 配置:先校验,再 reload
# 先校验,通过了再 reload(不要直接 restart)
docker exec disttouch-nginx nginx -t
docker exec disttouch-nginx nginx -s reload
-t 不通过绝对不要 reload,否则 nginx 起不来,整站挂掉。
这条和 2.3 的 rebuild 是同一个思路的延伸:任何"应用之前先验证一下"的步骤都值得单独加。 nginx -t 就是 nginx 自带的 dry-run,一秒钟的成本,换整站可用。
三、数据备份与恢复
业务数据全在 db.json。
3.1 找到它
# 卷挂载到容器内 /app/.data
docker exec disttouch-backend ls -la /app/.data/
# 宿主机上的实际位置
docker volume inspect services_docker_backend_data
3.2 备份
TS=$(date +%Y%m%d-%H%M%S)
docker run --rm \
-v services_docker_backend_data:/data \
-v $(pwd):/backup \
alpine tar czf /backup/dt-data-$TS.tar.gz -C /data .
用一次性的 alpine 容器打包数据卷,不需要在业务容器里装任何东西。--rm 保证跑完自动清理。
3.3 恢复
docker run --rm \
-v services_docker_backend_data:/data \
-v $(pwd):/backup \
alpine sh -c "cd /data && tar xzf /backup/dt-data-20260829.tar.gz"
docker compose restart node-backend
⚠️ 恢复前先停服务,避免写入冲突。
这个注意事项很容易被忽略:解开 tar 的同时业务进程还在往 db.json 里写,两边同时动同一个文件,结果就是一个既不是备份也不是当前状态的混合体。所以正式流程应该是:
docker compose stop node-backend # 1. 停
# 2. 恢复数据
docker compose start node-backend # 3. 起
备份也一样。 打 tar 的时候不停止服务,快照可能处于写入中途的状态。
四、HTTPS 证书:给新增域名单独签,别动原有的
后加域名时会遇到一个典型故障:证书 SAN 不含该域名,浏览器报「连接不是私密连接」,curl 不带 -k 直接返回 000。
推荐做法:给新增域名单独签一张证书,不动原有站点。
# 1. 安装 certbot(Ubuntu)
apt-get update && apt-get install -y certbot
# 2. webroot 方式签发(零停机)
# 验证文件写在官网静态目录,nginx 已有 location /.well-known/
certbot certonly --webroot \
-w /path/to/your/project/services/docker/www \
-d main.example.com -d show.example.com \
--cert-name site-main \
--agree-tos -m you@example.com --no-eff-email --non-interactive
# 3. 复制证书到 nginx ssl 目录(用新文件名,别覆盖原有的)
SSL=/path/to/your/project/services/docker/nginx/ssl
cp /etc/letsencrypt/live/site-main/fullchain.pem $SSL/main-fullchain.pem
cp /etc/letsencrypt/live/site-main/privkey.pem $SSL/main-privkey.pem
chmod 644 $SSL/main-fullchain.pem
chmod 600 $SSL/main-privkey.pem
# 4. 只改 main/show 的 server 块
# ssl_certificate /etc/nginx/ssl/main-fullchain.pem;
# ssl_certificate_key /etc/nginx/ssl/main-privkey.pem;
# 5. 校验 + reload
docker exec disttouch-nginx nginx -t && docker exec disttouch-nginx nginx -s reload
为什么用 webroot 而不是 standalone? standalone 模式要求 certbot 独占 80 端口,得先停 nginx——意味着停机。webroot 只是往静态目录里写一个验证文件,nginx 不用动,签发过程零停机。
4.1 自动续期钩子
Let's Encrypt 证书 90 天有效期,所以续期是常态:
# /etc/letsencrypt/renewal-hooks/deploy/10-nginx-main.sh
#!/bin/bash
set -e
SSL=/path/to/your/project/services/docker/nginx/ssl
cp /etc/letsencrypt/live/site-main/fullchain.pem "$SSL/main-fullchain.pem"
cp /etc/letsencrypt/live/site-main/privkey.pem "$SSL/main-privkey.pem"
chmod 644 "$SSL/main-fullchain.pem"
chmod 600 "$SSL/main-privkey.pem"
# 关键:校验失败就保持旧证书,不要 reload
if docker exec disttouch-nginx nginx -t; then
docker exec disttouch-nginx nginx -s reload
echo "[cert-deploy] nginx reloaded with renewed cert"
else
echo "[cert-deploy] nginx -t FAILED, keeping old cert" >&2
exit 1
fi
启用和演练:
chmod +x /etc/letsencrypt/renewal-hooks/deploy/10-nginx-main.sh
certbot renew --dry-run # 演练
💡 这里的 if/else 不是装饰。 续期脚本是无人值守跑的,如果证书有问题或者 nginx -t 挂了还硬 reload,nginx 可能直接起不来——一个本来只需要续个证书的操作,把线上搞挂了。所以逻辑必须是"校验不过就什么都不做,保留旧证书"。
💡 另一个关键点:容器以 :ro 只读挂载了静态目录,但宿主机上的目录本身可写。certbot 写在宿主机、容器能读到——所以不用改 docker-compose、不用停 nginx。
五、上线检查清单
这份清单建议打印出来,部署前逐项打勾。
🔐 安全
-
DEFAULT_ADMIN_PASS已从出厂弱口令改掉(必须改) -
PortalController::ensureToken()里的硬编码账号已同步改,或改为只读专用账号 -
JWT_SECRET已显式设置(不设的话每次启动随机,token 全失效) -
CORS_ORIGIN已从*收敛到具体域名 -
NODE_ENV=production -
AUTH_ENABLED=true(绝不能在生产关掉) - 敏感文件未入库:
.env/.env.local/*.pem/certs/都在.gitignore里 - 微信支付私钥走环境变量注入,不在
db.json里 - 公开接口逐个确认过:响应里没有联系人、电话、密钥等字段
- 错误日志脱敏已生效(不打完整堆栈)
💾 数据
-
STORAGE_MODE=file(否则用户账号每次重启重来) - 数据卷已挂载且是持久卷
- 已做一次全量备份,且验证过备份文件能解开
- 部署命令里没有
-v
第三条我特意加了"验证过能解开"。没验证过的备份等于没有备份——文件能生成不代表能恢复,中间可能少了什么。