零依赖 Node 后端怎么跑起来?我在两套存储机制上绕了很久

2 阅读5分钟

写在前面:我以为数据存在一个地方

第一次把这个项目的后端跑起来,我只花了 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-nginx80 / 443反向代理,多域名分发
disttouch-backend3000正式站后端
disttouch-portal7080正式站 PHP 门户
disttouch-backend-show3001演示站后端(独立数据卷 backend_data_show)
disttouch-portal-show7081演示站 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

八、环境变量速查

这张表我建议存下来:

变量默认值说明
PORT3000后端端口
HOST0.0.0.0监听地址
NODE_ENVdevelopmenttest / production
AUTH_ENABLEDtrue关闭后所有接口免鉴权(仅本地调试)
STORAGE_MODEmemory设为 file 持久化用户账号
DATA_DIRbackend/.data数据目录,多机部署可指向共享存储
JWT_SECRET随机生产必须显式设置,否则每次启动都变,token 全失效
ACCESS_TOKEN_TTL28800(8h)访问令牌有效期(秒)
REFRESH_TOKEN_TTL604800(7d)刷新令牌有效期
CORS_ORIGIN*生产应收敛到具体域名
DEFAULT_ADMIN_USERadmin首次启动种子管理员
DEFAULT_ADMIN_PASS弱口令默认值上线前必须改,建议用环境变量注入
PAYMENT_ACTIVE_METHODSmock启用的支付方式,逗号分隔
INSTANCE_IDhostname-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_FOUNDWindows 路径尾斜杠改用 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
门户页面 403demo 版禁透传在 index.php 路由表显式注册该 /api/xxx

最后两行是二开时新增代码才会碰到的。权限控制分两层:middleware.js 里维护公开路由和仅管理员路由的白名单;demo 版本还会在 PHP 侧禁掉透传。加新接口时这两处都要过一遍,否则会得到一个"接口直接调得通,但页面上 403"的诡异现象。


小结

环境搭建本身没什么难度,真正花时间的是搞清楚数据在哪:

  1. 业务数据无条件落 db.json,用户账号默认不落盘 —— 这就是一开始密码丢失的原因。
  2. STORAGE_MODE 默认 memory,本地开发请显式设成 file。
  3. 零依赖意味着没有 npm install,但 node --test 的 Windows 路径坑要记住。

一个通用体会:默认值应该对最安全的情况友好。这个项目 STORAGE_MODE 默认 memory(避免误写文件),但对新手不友好;反过来默认 file 又有"以为会持久化结果没写成功"的误判风险。两种选择都有代价,关键是必须在文档和启动日志里写清楚——那行「持久化: 已启用(自动落盘 + 关闭保存)」就是为此存在的。

代码已开源,Apache 2.0 协议,可自取:atomgit.com/pangzi_zi/d…