分销代理多端触达系统 · 二次开发教程(07)

📌 转载声明:本文为《分销代理多端触达系统 · 二次开发教程》系列第 07 篇。转载请注明出处;可运行的配套代码与最新版本见官方开源仓库(Apache 2.0):atomgit.com/pangzi_zi/distributor-touch

测试、部署与生产上线

上一篇:06 合规护栏与二开禁区 本篇目标:改完代码能验证、能上线、上线后数据不丢。 含一份可直接打印使用的上线检查清单。

一、测试: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 两种都行,但为了团队一致,建议统一用通配符版。

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 个文件

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);
});

1.4 三个必须记住的点

① 断言消息要写

assert.equal(s.code, 'SZ-NS-001', '店铺编码应统一大写');
//                              ↑ 失败时一眼看出是哪儿错

② 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 往返

二、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 站业务数据(独立!)

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 -v 会连数据卷一起删掉,业务数据(订单、商品、代理商)全没了,且不可恢复。 正确做法: ``bash docker compose down # ✅ 停容器,保留数据卷 docker compose down -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 挂载? 生产环境要的是"镜像即部署单元",改文件不重建不生效反而更安全 —— 避免线上被人手工改乱。开发时可以用独立容器或本地 PHP 内置服务器。

2.4 改了 nginx 配置

# 先校验,再 reload(不要直接 restart)
docker exec disttouch-nginx nginx -t
docker exec disttouch-nginx nginx -s reload

-t 不通过绝对不要 reload,否则 nginx 起不来整站挂掉。


三、数据备份与恢复

业务数据全在 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 .

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
⚠️ 恢复前先停服务,避免写入冲突。

四、HTTPS 证书

项目默认部署是 www 一个证书 + main/show 共用一个证书。

如果 main / show 是后加的域名,会遇到证书 SAN 不含该域名,浏览器报「连接不是私密连接」(curl 不带 -k 直接返回 000)。

推荐做法:给新增域名单独签 Let's Encrypt 证书,不动原有站点。

# 1. 安装 certbot(Ubuntu)
apt-get update && apt-get install -y certbot

# 2. webroot 方式签发(零停机)
#    验证文件写在官网静态目录,nginx 已有 location /.well-known/
certbot certonly --webroot \
  -w /root/distributor-touch/services/docker/www \
  -d main.chaoyi.club -d show.chaoyi.club \
  --cert-name chaoyi-main \
  --agree-tos -m admin@example.com --no-eff-email --non-interactive

# 3. 复制证书到 nginx ssl 目录(用新文件名,别覆盖原有的)
SSL=/root/distributor-touch/services/docker/nginx/ssl
cp /etc/letsencrypt/live/chaoyi-main/fullchain.pem $SSL/main-fullchain.pem
cp /etc/letsencrypt/live/chaoyi-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

配自动续期钩子(LE 证书 90 天有效期):

# /etc/letsencrypt/renewal-hooks/deploy/10-nginx-main.sh
#!/bin/bash
set -e
SSL=/root/distributor-touch/services/docker/nginx/ssl
cp /etc/letsencrypt/live/chaoyi-main/fullchain.pem "$SSL/main-fullchain.pem"
cp /etc/letsencrypt/live/chaoyi-main/privkey.pem  "$SSL/main-privkey.pem"
chmod 644 "$SSL/main-fullchain.pem"
chmod 600 "$SSL/main-privkey.pem"
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    # 演练
💡 关键点:容器以 :ro 只读挂载 ./www,但 宿主机上的目录本身可写,certbot 写在宿主机、容器能读到 —— 所以不用改 docker-compose、不用停 nginx。

五、上线检查清单

打印出来,部署前逐项打勾。

🔐 安全

  • [ ] DEFAULT_ADMIN_PASS 已改(默认 admin123 必须改)
  • [ ] 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 会掩盖证书问题,生产验证不要加)

📊 监控

  • [ ] /api/health 加入了健康检查 / 监控告警
  • [ ] /api/ready 用于就绪探针
  • [ ] 审计日志有落盘且定期归档(appendAudit 覆盖了所有写操作)
  • [ ] 日志轮转已配置

六、上线后的运维要点

场景做法
改代码rebuild 对应服务,不要手工改容器内的文件
改配置改环境变量 / .env,重启服务
数据异常先停服务 → 备份当前 db.json → 从备份恢复 → 重启
忘记管理员密码停服务 → 删 users.json → 重启(会用 DEFAULT_ADMIN_* 重新种子)
磁盘满了db.json + 审计日志是主要占用,归档旧审计
想加机器DATA_DIR 指向共享文件系统(NFS 等),INSTANCE_ID 区分实例

七、一套教程的收尾

到这里,七篇教程覆盖了从架构认知到生产上线的完整链路:

篇主题你拿走什么
00架构地图全局认知 + 场景速查表
01环境搭建三种启动方式 + 两套存储机制的坑
02新增模块handler 五件套 + 三个注册点 + 骨架模板
03管理端页面路由 / Controller / View / 导航 + demo 版 403 之谜
04C 端商城分组算法 + 逐店结算 + 促销规则扩展
05小程序vite 结构 + 加页面 + i18n + 多端编译
06合规护栏15 条红线 + 禁区清单 + 正确扩展姿势
07测试部署测试写法 + Docker 部署 + 上线检查清单

最该记住的三句话:

  1. 合规不是配置,是架构 —— 不可协商的规则放领域层,用常量 + 断言冻结。
  2. 业务规则只在后端 —— PHP 门户和小程序的职责是透传和渲染,不要在任何一端重写规则。
  3. 写操作要留痕、要落盘 —— appendAudit() + scheduleSave(),缺一个都会在生产上付出代价。

项目地址:AtomGit pangzi_zi/distributor-touch 在线演示:https://www.chaoyi.club/mp-demo.html 许可证:Apache License 2.0