写在前面:我以为数据存在一个地方
第一次把这个项目的后端跑起来,我只花了 10 分钟。然后我花了三个小时找"我明明加的那条商品去哪了"。
原因很蠢:这个项目有两套独立的持久化机制,而且默认只开了一套。
我建了个店铺,改了个密码,把服务重启——店铺还在,密码没了。一开始我以为是快照没保存成功,去翻 db.json,发现里面确实有店铺数据。再翻用户文件,发现文件根本不存在。
翻代码才看明白:业务数据无条件落盘,用户账号只在 STORAGE_MODE=file 时才落盘,而这个配置默认值是 memory。
这不是 bug,是个取舍。但默认值对新人不友好,所以这篇就从这里开始讲。目标是10 分钟把四端跑起来,并且明确知道数据存在哪、怎么重置。
一、前置条件:少一样都不行
| 组件 | 版本 | 必需性 | 说明 |
|---|---|---|---|
| Node.js | ≥ 22 | 必需 | 后端与小程序构建都靠它 |
| PHP | ≥ 8.0 | 二开 B/S 门户时需要 | 无框架,能跑内置服务器或 FPM 即可 |
| Docker + Compose | 任意近期版本 | 全栈联调时推荐 | 一键起 nginx + 后端 + 门户 |
| Git | 任意 | 必需 |
验证一下:
node -v # v22.x 或更高
php -v # PHP 8.x
docker compose version
这里有个值得说的点:后端是零依赖的,不需要 npm install。
整个后端只用 Node 内置模块,没有任何第三方运行时依赖。package.json 里唯一有意义的依赖就是测试要用的 node:test——而它也是内置的。
这在 2026 年是个越来越稀缺的特点。零依赖意味着没有供应链攻击面、没有
node_modules地雷、没有"昨天还能跑今天装不上"的版本漂移。代价是很多轮子得自己造,比如鉴权、持久化、测试。
二、方式一:只跑后端(最快)
改后端时用这个,两秒起来:
cd backend
node src/server.js
看到这段输出就成了:
[persist] 无快照文件,将执行种子初始化
[server] 无持久化数据,执行种子初始化...
[persist] 快照已保存 (28 模块)
[distributor-touch] 后端 MVP α 监听 http://0.0.0.0:3000/api
[distributor-touch] 实例ID: your-hostname-12345
[distributor-touch] 认证: 已启用
[distributor-touch] 持久化: 已启用(自动落盘 + 关闭保存)
[distributor-touch] 环境: development
那行 「快照已保存 (28 模块)」 很关键——它告诉你业务数据已经开始落盘了。如果没看到这行,说明你的 DATA_DIR 路径可能有问题。
验证:
# 健康检查(免鉴权)
curl http://localhost:3000/api/health
# 登录拿 token(首次启动会用 DEFAULT_ADMIN_* 种子一个管理员)
curl -X POST http://localhost:3000/api/auth/login \
-H 'Content-Type: application/json' \
-d '{"username":"admin","password":"<你的密码>"}'
# 带 token 查店铺
TOKEN=<上一步拿到的 accessToken>
curl http://localhost:3000/api/stores -H "Authorization: Bearer $TOKEN"
种子管理员的初始密码由 DEFAULT_ADMIN_PASS 决定,出厂默认值是一串弱口令(形如 admin + 一串数字,源码里写在 config.js 的默认值中)。本地随便用,上线前必须改成你自己的强密码。
三、方式二:Docker Compose 全栈
前后端联调用这个:
cd services/docker
docker compose up -d
docker compose ps
起出来是双站结构(main 正式站 + show 演示站,数据完全隔离):
| 容器 | 端口 | 作用 |
|---|---|---|
disttouch-nginx | 80 / 443 | 反向代理,多域名分发 |
disttouch-backend | 3000 | 正式站后端 |
disttouch-portal | 7080 | 正式站 PHP 门户 |
disttouch-backend-show | 3001 | 演示站后端(独立数据卷 backend_data_show) |
disttouch-portal-show | 7081 | 演示站 PHP 门户 |
访问入口:
- 管理端:
http://localhost/portal/login(账号admin / <你的密码>) - 代理端:
http://localhost/agent/dashboard(免登录) - C 端商城:
http://localhost/portal/u/shop(免登录) - 后端 API:
http://localhost/api/health
⚠️ 改了 services/php/portal/ 下的任何文件,必须 rebuild 才生效:
docker compose up -d --build php-portal
因为门户代码是 COPY 进镜像的,不是 volume 挂载。改完文件发现没生效,然后疯狂检查 PHP 语法、缓存、权限——最后发现是没 rebuild。这个坑我踩过不止一次,所以单独标出来。
四、方式三:单独跑 PHP 门户
改前端页面时最快的方式,不用 Docker:
cd services/php/portal
php -S localhost:8080
门户会去连 config.php 里配置的后端地址,默认需要后端在 3000 端口跑着。
这个方式适合改 PHP + JS,不需要为了一次样式调整 rebuild 整个镜像。
三种方式怎么选,我一般这么判断:
| 你要做的事 | 用哪个 |
|---|---|
| 改后端 handler / 路由 / 测试 | 方式一,裸跑 node src/server.js,两秒重启 |
| 改门户页面(PHP / CSS / JS) | 方式三,php -S localhost:8080 |
| 前后端一起改、要验证 nginx 转发 | 方式二,Docker Compose 全栈 |
方式一和方式三可以同时开着:后端跑 3000(裸跑,改完重启就行),门户跑 8080(内置服务器)。这是我日常改后端时的工作方式——比走 Docker 快一个数量级,因为省掉了 rebuild。
五、重点:两套存储机制,别搞混
这是新手最容易困惑的地方,也是我一开始栽跟头的地方。
5.1 业务数据 → db.json(永远生效)
// persist.js —— 业务模块快照
saveSnapshot() // 把所有注册模块的 _internalState() 写进 db.json
loadSnapshot() // 启动时读回来,有数据就跳过 seed
它的特征:
- 文件位置:
config.dataDir,默认backend/.data/db.json - 触发时机:所有写操作后调用
scheduleSave(),2 秒去抖 - 覆盖范围:
products/orders/stores/coupons/agents/commission等 28 个模块
注意这行——不管 STORAGE_MODE 是什么,业务数据始终落盘。这是有意的:业务数据丢了就没了,没有讨论空间。
那个 2 秒去抖也值得说一下。一秒钟改十次配置,就该只写一次盘。不去抖的话,连续改字段会产生几十次全量快照写入。
5.2 用户账号 → users.json(只有 STORAGE_MODE=file 才落盘)
// auth/user-store.js
function persistUsers() {
if (config.storageMode === 'file') { // ← 关键判断在这
saveJSON(USERS_FILE, Object.fromEntries(users));
}
}
而配置默认值:
// config.js
storageMode: getEnv('STORAGE_MODE', 'memory'), // 默认 memory
所以实际效果是这样的:
| 场景 | 业务数据 | 用户账号 |
|---|---|---|
裸跑 node src/server.js | ✅ 落 db.json,重启恢复 | ❌ 每次重启重新种子(改的密码会丢) |
Docker(STORAGE_MODE=file) | ✅ 落 db.json | ✅ 落 users.json |
单元测试(NODE_ENV=test) | 内存态,跑完即弃 | 内存态 |
本地开发建议显式开 file 模式:
cd backend
STORAGE_MODE=file node src/server.js
或者写进环境文件(该文件已被 .gitignore 忽略,不会误提交):
# backend/src/.env.local
STORAGE_MODE=file
PORT=3000
NODE_ENV=development
AUTH_ENABLED=true
DEFAULT_ADMIN_USER=admin
DEFAULT_ADMIN_PASS=<你的密码>
配置优先级:环境变量 > .env.local > .env > 代码默认值。
.env.local 放本地私密覆盖,.env 放团队共享的默认值,两者都不入库。这个分层是我从"想把本地密码提交上去"这个冲动里逼出来的——分层之后,想提交也没地方提交。
5.3 为什么用 JSON 快照而不是数据库
这是个会被质疑的设计,所以单独说说取舍。
业务数据落 db.json 而不是数据库,理由有三条:
① 零依赖是硬约束。 一旦引入数据库驱动,"不需要 npm install"这个特性就没了,整个项目多出一个必须管理的进程、端口、连接串和版本兼容问题。对于一个要快速在各种机器上跑起来做二开的项目,这个代价太大。
② 28 个模块的数据结构是完全异构的。 快照机制靠的是每个模块自己实现 _internalState() 和 _restoreState(),导出的是任意形状的 JS 对象。用数据库就得给 28 个模块各写一套表结构、迁移和映射,工作量大到离谱。而快照只要每个模块管好自己的两个函数。
③ 备份和恢复是天然的一次文件复制。 第三章会讲备份——tar czf 打一个卷就是一份完整备份,恢复也是解开 tar。这个操作简单到不需要写脚本。
代价也很明确,我不打算回避:
- 不适合大数据量。 全量读写,几十万条记录会明显变慢。
- 不适合高并发写。 多个进程同时写同一个文件会互相覆盖,这也是为什么多机部署必须让
DATA_DIR指向共享存储、并用INSTANCE_ID区分实例。 - 进程崩溃可能丢最后一次写入。 2 秒去抖窗口内的改动会丢。
所以这套设计的适用边界很清楚:中小规模、二开调试、单机部署。真要做生产级高并发,持久化层需要换成数据库——但因为每个模块只依赖 _internalState() / _restoreState() 这两个函数,换掉持久化实现的影响面是可控的,这也是当时这么抽象的原因。
六、重置数据
二开时经常需要把数据打回初始状态:
# 停掉服务后
rm -rf backend/.data/ # 业务快照
rm -f backend/.data/users.json # 用户(file 模式才有)
# 重启,会自动重新跑种子
cd backend && node src/server.js
也可以只补种商城数据、保留代理商数据:服务启动时会自动检测,如果快照里没有商品或订单就调用 seedMallIfEmpty() 补种。
这个设计挺实用——大多数时候我只是想清掉测试时加的一堆脏商品,并不想把辛辛苦苦建的十几个代理账号也一起清掉。
七、跑测试:Windows 上的一个坑
cd backend
node --test "src/tests/*.test.js"
预期 500+ 个用例全绿。
⚠️ Windows 专属坑:package.json 里写的是 node --test src/tests/(带尾斜杠),在 Windows 上会报 MODULE_NOT_FOUND。
必须用带通配符的写法:
node --test "src/tests/*.test.js"
macOS / Linux 上两种写法都能跑通。这不是 Node 的 bug,是不同平台 glob 处理差异导致的。为了团队一致,建议统一用通配符版。
只看某个模块:
node --test src/tests/stores.test.js
node --test src/tests/orders.test.js
八、环境变量速查
这张表我建议存下来:
| 变量 | 默认值 | 说明 |
|---|---|---|
PORT | 3000 | 后端端口 |
HOST | 0.0.0.0 | 监听地址 |
NODE_ENV | development | test / production |
AUTH_ENABLED | true | 关闭后所有接口免鉴权(仅本地调试) |
STORAGE_MODE | memory | 设为 file 持久化用户账号 |
DATA_DIR | backend/.data | 数据目录,多机部署可指向共享存储 |
JWT_SECRET | 随机 | 生产必须显式设置,否则每次启动都变,token 全失效 |
ACCESS_TOKEN_TTL | 28800(8h) | 访问令牌有效期(秒) |
REFRESH_TOKEN_TTL | 604800(7d) | 刷新令牌有效期 |
CORS_ORIGIN | * | 生产应收敛到具体域名 |
DEFAULT_ADMIN_USER | admin | 首次启动种子管理员 |
DEFAULT_ADMIN_PASS | 弱口令默认值 | 上线前必须改,建议用环境变量注入 |
PAYMENT_ACTIVE_METHODS | mock | 启用的支付方式,逗号分隔 |
INSTANCE_ID | hostname-pid | 多机部署标识 |
其中 JWT_SECRET 那行我标红了。默认值是随机的,意味着每次重启 token 全失效——本地开发无所谓(反正在反复登录),生产环境就是"用户每隔一段时间集体掉线",而且这个 bug 只在重启后出现,排查起来很费时间。
九、跑通之后,验证这 6 个端点
按顺序敲一遍,确认整条链路是通的:
BASE=http://localhost:3000/api
# 1. 健康检查(公开)
curl $BASE/health
# 2. 登录
TOKEN=$(curl -s -X POST $BASE/auth/login \
-H 'Content-Type: application/json' \
-d '{"username":"admin","password":"<你的密码>"}' | jq -r .accessToken)
# 3. 店铺列表(GET 公开,C 端要用)
curl $BASE/stores
# 4. 商品列表(带 storeName 富化)
curl $BASE/products -H "Authorization: Bearer $TOKEN"
# 5. 合规护栏清单
curl $BASE/../compliance/guardrails -H "Authorization: Bearer $TOKEN"
# 6. 审计日志(确认操作有留痕)
curl $BASE/../audit -H "Authorization: Bearer $TOKEN"
全通说明环境没问题,可以开始二开了。
第 3 步值得单独说一句:/api/stores 的 GET 是公开的。C 端购物车要靠它拿店名,所以不需要 token。但正因如此,这个接口必须做字段裁剪——店铺表里有 contact / phone / address,不能直接吐给匿名用户。
十、常见启动问题排查
| 现象 | 原因 | 解决 |
|---|---|---|
跑测试报 MODULE_NOT_FOUND | Windows 路径尾斜杠 | 改用 node --test "src/tests/*.test.js" |
EADDRINUSE :3000 | 端口被占 | PORT=3001 node src/server.js 或杀掉占用进程 |
| 登录返回 401 | 用户没种子 / 密码被改过 | 删 backend/.data/ 重启 |
| 改了门户代码没效果 | php-portal 是 COPY 进镜像 | docker compose up -d --build php-portal |
| 重启后数据没了 | STORAGE_MODE=memory | 设 STORAGE_MODE=file |
| 接口返回 403 | 新增路由没注册权限 | 查 middleware.js 的 PUBLIC_ROUTES / ADMIN_ONLY_ROUTES |
| 门户页面 403 | demo 版禁透传 | 在 index.php 路由表显式注册该 /api/xxx |
最后两行是二开时新增代码才会碰到的。权限控制分两层:middleware.js 里维护公开路由和仅管理员路由的白名单;demo 版本还会在 PHP 侧禁掉透传。加新接口时这两处都要过一遍,否则会得到一个"接口直接调得通,但页面上 403"的诡异现象。
小结
环境搭建本身没什么难度,真正花时间的是搞清楚数据在哪:
- 业务数据无条件落
db.json,用户账号默认不落盘 —— 这就是一开始密码丢失的原因。 STORAGE_MODE默认memory,本地开发请显式设成file。- 零依赖意味着没有
npm install,但node --test的 Windows 路径坑要记住。
一个通用体会:默认值应该对最安全的情况友好。这个项目 STORAGE_MODE 默认 memory(避免误写文件),但对新手不友好;反过来默认 file 又有"以为会持久化结果没写成功"的误判风险。两种选择都有代价,关键是必须在文档和启动日志里写清楚——那行「持久化: 已启用(自动落盘 + 关闭保存)」就是为此存在的。
代码已开源,Apache 2.0 协议,可自取:atomgit.com/pangzi_zi/d…