上线前那 30 分钟我都在干什么:一份 Docker 生产部署避坑清单

3 阅读24分钟

写在前面:差点把生产数据删了

上线前最后一次演练,我敲了一条命令:

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-nginx80 / 443多域名反代
disttouch-backend3000STORAGE_MODE=file、EDITION=source
disttouch-portal7080API_GATEWAY_URL=http://node-backend:3000
disttouch-backend-show3001EDITION=demo
disttouch-portal-show7081API_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 会随业务增长而变大,审计日志更是只增不减——每一条写操作都留痕,这个是好设计,但长期跑下来磁盘占用主要来自它。所以定期归档旧审计日志是必须的,不是可选项。


小结

把这次部署踩过的坑压缩成几句话:

  1. docker compose down -v 会删数据。 这条没有任何技术含量,纯粹是危险默认值的可怕之处。
  2. 改代码必须 rebuild。 因为是 COPY 不是挂载——这是刻意的安全设计。
  3. nginx 改配置先 -t 再 reload,-t 不过就什么都不做。
  4. 备份要验证能解开。 恢复要停服务,备份也应该停。
  5. 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-nginx80 / 443多域名反代
disttouch-backend3000STORAGE_MODE=file、EDITION=source
disttouch-portal7080API_GATEWAY_URL=http://node-backend:3000
disttouch-backend-show3001EDITION=demo
disttouch-portal-show7081API_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

第三条我特意加了"验证过能解开"。没验证过的备份等于没有备份——文件能生成不代表能恢复,中间可能少了什么。