分销代理多端触达系统 · 二次开发教程(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-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 站业务数据(独立!)
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 之谜 |
| 04 | C 端商城 | 分组算法 + 逐店结算 + 促销规则扩展 |
| 05 | 小程序 | vite 结构 + 加页面 + i18n + 多端编译 |
| 06 | 合规护栏 | 15 条红线 + 禁区清单 + 正确扩展姿势 |
| 07 | 测试部署 | 测试写法 + Docker 部署 + 上线检查清单 |
最该记住的三句话:
- 合规不是配置,是架构 —— 不可协商的规则放领域层,用常量 + 断言冻结。
- 业务规则只在后端 —— PHP 门户和小程序的职责是透传和渲染,不要在任何一端重写规则。
- 写操作要留痕、要落盘 ——
appendAudit()+scheduleSave(),缺一个都会在生产上付出代价。
项目地址:AtomGitpangzi_zi/distributor-touch在线演示:https://www.chaoyi.club/mp-demo.html许可证:Apache License 2.0