拿到一个 3 万行的陌生代码库,你第一反应是翻 package.json 还是翻目录树?
我接手这套系统时两个都翻了,结果发现真正决定你改哪一行代码的,不是技术栈,是它有没有框架。有框架,你照着约定写;没框架,约定就散落在每个人的脑子里,你得自己把它们捞出来。
这套系统是后者。后端 backend/package.json 的 dependencies 是空的 ------没有 Express,没有 Koa,没有 Mongoose,连 ORM 都没有。整个 backend/src 才 2.9 万行(其中 1.28 万行在 42 个 handler 里),全部用 Node 内置模块手搓。
没有框架意味着没有约定来兜底。好在这套代码把约定明文写在文件里 了:3 条铁律、9 类改动的落点、1 条请求链路。我把这张地图完整拆给你------看完你就知道该改哪个文件,不用再全文搜 grep。
项目:Distributor Touch(快消 B2B 分销 SaaS)· 后端 Node.js 22+ 零运行时依赖 · 许可证 Apache 2.0
一、这套系统到底解决什么
一句话:品牌商 → 代理商 → 消费者 的一条链路打透,并且从架构上保证不会踩传销红线。
它不是一个单纯的商城,也不是一个单纯的代理管理系统,而是四端一体的组合:
| 端 | 使用者 | 形态 | 职责 |
|---|---|---|---|
| 管理端 | 品牌商运营 | B/S(PHP 门户页) | 管商品、订单、代理商、佣金、店铺、合规 |
| 代理端 | 代理商 | B/S + 小程序 | 看业绩、佣金、素材、培训、海报 |
| C 端商城 | 消费者 | B/S + 小程序 | 逛店、加购、下单、支付 |
| 小程序 | 代理 + 消费者 | UniApp 一套代码 | 编译到 H5 / 微信小程序 / App / 企微侧边栏 |
注意这里的关键词是四端一体。这四个字直接决定了后端的设计------同一套业务规则要同时喂给 PHP 门户、UniApp 小程序、开放 API、企微侧边栏四条链路,所以规则只能写一份。
二、目录结构:改哪里先看这里
先把这棵树铺开,后面所有内容都在这上面定位:
text
distributor-touch/
├── backend/ # Node.js 后端(零依赖,ESM)
│ └── src/
│ ├── server.js # ★ 单一路由分发入口(2300+ 行,所有 API 都在这)
│ ├── persist.js # ★ 持久化注册中心
│ ├── seed.js # 种子数据
│ ├── config.js # .env / 环境变量
│ ├── handlers/ # ★ 业务模块(42 个,一个领域一个文件)
│ ├── domain/ # ★ 领域规则(合规护栏、佣金引擎、代理层级)
│ ├── auth/ # JWT + API Key + 中间件
│ ├── agent/ # AI 助手(意图解析 / 工具表 / 编排)
│ ├── ai/ # ★ AI 引擎(选品/预测/销后/对账 四条能力线)
│ ├── store/file-store.js # 原子写入的 JSON 存储层
│ ├── utils/id-gen.js # ID 生成器
│ └── tests/ # node:test 单元测试(42 个文件)
│
├── services/
│ ├── php/portal/ # ★ B/S 门户(无框架)
│ │ ├── index.php # 路由表
│ │ ├── config.php # 配置 + 导航项
│ │ ├── controllers/ # PortalController / AgentController / AuthController
│ │ └── views/ # 55 个视图模板
│ ├── docker/ # Docker Compose(nginx + 多服务)
│ │ ├── nginx/nginx.conf
│ │ ├── www/ # 官网静态站
│ │ └── docker-compose.yml
│ ├── go/ java/ # 多语言服务端示例
│
├── frontend/ # 管理端前端资源
├── harmony/ # 鸿蒙端
├── uniapp/ # ★ UniApp 小程序(vite 结构,源码在 src/)
│ └── src/
│ ├── pages.json # 路由 + tabBar
│ ├── manifest.json # 多端编译配置
│ ├── pages/ # 页面(含 shop/ 商城 5 页)
│ ├── utils/ # api / cart / i18n / auth / config / nav
│ └── static/tabbar/ # 图标
│
└── docs/ openapi.yaml # 文档与 API 契约
读这棵树有三个要点:
带 ★ 的四行是你需要动手的地方 ,其余的都是被它们调用的叶子。domain/ 只有 3 个文件(compliance-guardrails.js、commission-engine.js、agent-hierarchy.js),但它是整个系统的规则内核------后面讲合规那篇会专门展开。
handlers/ 下面 42 个文件是平铺的,一个领域一个文件 。没有 orders/、products/ 这种二级目录,因为单文件都不大(stores.js 只有 122 行),平铺反而好找。
ai/ 目录是后来新增的,它和 agent/ 不是一回事,这点特别容易混:
agent/是对话式 AI 助手------把已有 handler 包装成工具给大模型调用,关键词是"意图解析 / 工具表 / 编排"ai/是业务 AI 引擎------选品、预测、销后、对账四条能力线,每条线独立跑、留痕、可回放
三、技术栈与三条设计铁律
3.1 后端零运行时依赖
所有能力用 Node 内置模块实现:
- HTTP 服务:
node:http - 路由:手写
if (path === '...' && method === '...')分发 - JWT:
node:crypto手写 HMAC - 持久化:
node:fs原子写 JSON - 测试:
node:test内置
这对你意味着什么 :克隆下来 node src/server.js 就能跑,没有 npm install 的等待,也没有供应链风险。
但代价是没有框架帮你做约定------所以下面的规范必须自己遵守。这是一笔明码标交易的交换:用「约定要自己守」换「装起来零阻力、跑起来零依赖」。
3.2 三条铁律
这三条是这个代码库能不能长期维护的全部前提:
| 铁律 | 说明 | 违反后果 |
|---|---|---|
写操作必须 scheduleSave() |
改了内存里的 Map 一定要触发落盘,否则重启丢数据 | 数据丢失 |
| 新增可持久化模块必须注册三处 | handler 导出 _internalState/_restoreState → persist.js MODULES → server.js 路由 |
重启数据不恢复 |
领域规则放 domain/,不放 handler |
合规校验、佣金计算属于领域层 | 护栏被绕过 |
第三条最容易被违反,而且违反之后短期完全看不出问题 。你把"层级不能超过 2 级"的校验写进了 handlers/commission.js,测试也过了,但另一个新加的定时任务直接调 domain/commission-engine.js 就绕过去了。领域层是所有入口的必经之路,写在那儿才绕不过。
四、二次开发场景速查表
拿到源码后最常做的 9 件事,和对应要改的地方:
| # | 场景 | 主要改动点 | 难度 |
|---|---|---|---|
| 1 | 加一个业务实体(店铺、仓库、会员等级) | handlers/xxx.js + persist.js + server.js + tests/ |
中 |
| 2 | 加一个管理端页面 | index.php 路由 + PortalController 方法 + views/ + config.php 导航 |
低 |
| 3 | 改商城业务规则(满减、拆单、优惠券) | handlers/orders.js + coupons.js + 门户 cart.js + 小程序 utils/cart.js |
高 |
| 4 | 小程序加页面 / 改 UI | pages.json + pages/xxx.vue + utils/api.js + utils/i18n.js |
低 |
| 5 | 接支付 / 短信 / 物流 | handlers/payment.js + gateway.js + config.js |
中 |
| 6 | 加一种语言 | 门户 i18n/ + 小程序 utils/i18n.js |
低 |
| 7 | 改主题 / 品牌换肤 | handlers/theme.js + frontend/ 样式变量 |
低 |
| 8 | 扩展合规校验 | domain/compliance-guardrails.js |
高(禁区) |
| 9 | 加 AI 能力 | ai/config.js + ai/client.js + handlers/ai-*.js |
中 |
难度列是我自己加的,判据是改动的耦合面:只碰一个文件是低,同时要改"后端 + 门户 + 小程序"三端的是高。
第 3 行和第 8 行两个高危项有共同点------它们动的是规则 而不是数据通路。规则错了不会报错,只会算出错的佣金或者踩到法律红线。
五、核心链路:一个请求是怎么走完的
理解这条链路,二开时就知道该在哪一层动手。
5.1 管理端打开「店铺管理」页
text
浏览器 GET /portal/stores
↓ nginx: location /portal/ { rewrite ^/portal/(.*)$ /$1 break; proxy_pass php-portal }
↓ PHP: index.php 路由表查到 '/stores' => PortalController::adminStores
↓ PortalController::adminStores()
├─ ensureToken() 会话里没 token 就用 admin 账号换一个
├─ apiCall('/api/stores') → 后端
├─ apiCall('/api/products') → 后端(用来统计每店商品数)
└─ render('admin_stores', $data)
↓ 后端 GET /api/stores
├─ authenticate() 中间件:token 有效?角色够吗?
└─ storesMod.listStores() 读内存 Map
↓ 返回 JSON → PHP 组装 → 渲染 views/admin_stores.php
关键点 :PHP 门户不直接碰数据库,它只是后端的透传层 + 视图渲染层。所有业务规则都在 Node 后端。
⚠️ 很多二开同学在门户改了半天发现没效果,就是因为改错了层------表单字段要生效,必须同时改 PHP 的转发链和后端的 handler,少一头字段就静默丢失。
这句话值得反复读。"静默丢失"是这里最恶劣的地方:不报错、不告警,页面照常渲染,只是那个字段永远是空。你会以为是后端的问题,其实是门户没转发。
5.2 消费者下一笔跨店订单
text
购物车(前端 localStorage,按 storeId 分组)
↓ 点「结算」→ 逐店调用 POST /api/orders(一店一单)
↓ orders.js: 校验 storeId 存在 → 计算金额 → 落库 → scheduleSave()
↓ 前端拿到 N 个订单号 → 依次调用 POST /api/orders/:id/pay
↓ 成功一个就从购物车移除该店商品;失败的店保留
注意最后一行:支付是逐笔独立的,不是一笔事务。这是产品决定的(每个店铺独立选择支付方式),但它意味着你必须处理"部分成功"------所以那句"失败的店保留"是业务必需,不是偷懒。
六、数据是怎么存的
没有数据库,用的是单文件 JSON 快照 db.json。
text
写操作 → 改内存 Map → scheduleSave()(2 秒去抖)→ saveSnapshot() → db.json
启动 → loadSnapshot() 读 db.json → 各模块 _restoreState() → 跳过 seed
存储层 store/file-store.js 做了三件重要的事:
- 原子写入 :先写
db.json.tmp再rename,避免进程被杀时留下半截文件 - Map/Set 序列化:JSON 不认 Map,写入时自动转 Object / Array
- 多机支持 :
config.dataDir可指向共享文件系统
第 1 点值得多说一句。writeFileSync 到一半被 kill -9,你会得到一个语法错误的 JSON ,然后整个系统启动就崩,连兜底的机会都没有。tmp + rename 在同一个文件系统内是原子操作------要么是旧的完整文件,要么是新的完整文件,不会出现中间态。
快照结构(每个注册的模块占一个 key):
json
{
"_savedAt": "2026-08-29T04:00:00.000Z",
"agents": [...],
"products": [...],
"orders": [...],
"stores": [...],
"coupons": [...]
}
💡 这个设计的取舍:零依赖、易备份、易调试(
db.json直接cat);代价是不适合百万级数据和高并发写。如果你的客户量级上来了,把store/file-store.js换成 MySQL/PostgreSQL 实现即可,上层 handler 代码一行都不用改------这是预留好的替换点。
这个替换点不是随便说说的。handler 只认 Map 和几个纯函数,从来不碰文件;文件的存在性只有 persist.js 知道。所以换存储层改的是一个文件,不是 42 个。
七、权限模型
三级角色 + 两种认证方式:
| 角色 | 能做什么 |
|---|---|
admin |
全部(品牌商管理员) |
agent |
只看自己的数据(佣金、业绩、素材) |
user |
C 端公开数据 |
认证中间件 auth/middleware.js 用三张表控制:
js
const PUBLIC_ROUTES = [ // 无需登录(精确匹配)
{ method: 'GET', path: '/api/health' },
{ method: 'GET', path: '/api/themes' },
// ...
];
const PUBLIC_READ_PREFIXES = [ // C 端公开只读(按前缀匹配)
{ method: 'GET', prefix: '/api/products/' },
{ method: 'GET', path: '/api/products' },
{ method: 'GET', prefix: '/api/stores/' },
{ method: 'GET', path: '/api/stores' },
];
const ADMIN_ONLY_ROUTES = [ // 必须 admin 角色
{ method: 'POST', path: '/api/commission/rules' },
{ method: 'POST', path: '/api/materials' },
// ...
];
二开时加接口,第一件事就是想清楚它属于哪一类:
- 公开 → 加进
PUBLIC_ROUTES(C 端匿名能访问) - 管理员 → 加进
ADMIN_ONLY_ROUTES,或在 handler 里判断currentUser?.role !== 'admin' - 其他 → 只要求登录态即可
⚠️ 别图省事把接口全设成公开。C 端页面是完全匿名的,公开接口等于对互联网开放。
这个警告不是空话。PUBLIC_READ_PREFIXES 是前缀匹配 ------它下面挂着 /api/products/ 和 /api/stores/ 这类 C 端浏览接口,前缀匹配比精确匹配危险得多。
所以中间件里专门加了排除项:
js
// isPublicRoute() ------ 前缀匹配前先排除管理类子端点
for (const r of PUBLIC_READ_PREFIXES) {
if (r.method !== method) continue;
// 排除管理类子端点(stats/status/skus 等不得匿名可见)
if (path.startsWith('/api/products/stats')) continue;
if (path.startsWith('/api/products/') && path.includes('/skus/')) continue;
if (r.prefix && path.startsWith(r.prefix)) return true;
if (r.path && path === r.path) return true;
}
前缀规则一开,/api/products/ 底下的一切默认公开 ,你新加的 GET /api/products/export 会被前缀规则直接捞成公开接口。所以才需要这层显式排除------默认开放 + 显式收紧,而不是默认关闭 + 逐个加白。
你加新接口时得记住:只要路径落在某个已公开的前缀下,就默认是公开的,得自己加排除项。
八、新增的 AI 引擎长什么样
前面提过 ai/ 目录是后加的,它的设计有一处我觉得特别值得抄。
ai/client.js 提供唯一入口 chatJSON(),四大能力线(选品 / 预测 / 销后 / 对账)都只调它,不关心厂商差异:
js
// ai/client.js ------ 统一入口的守门逻辑
const cfg = getAIConfig(brandId);
const startedAt = Date.now();
// ① 订阅校验:到期后 AI 能力关闭(不删数据、不影响基础功能)
if (scene !== 'chat') {
const access = checkAIAccess(brandId);
if (!access.allowed) {
const entry = log({ scene, brandId, actor, status: 'blocked', ms: 0, model: cfg.model, reason: access.message });
const data = fallback ? await fallback({ reason: access.message, cfg }) : null;
return { data, meta: { status: 'blocked', reason: access.message, blockReason: access.reason, logId: entry.id, model: cfg.model, ms: 0 } };
}
// ② 配额校验:超额不烧 token
const quota = consumeQuota(brandId, scene);
if (!quota.allowed) {
const entry = log({ scene, brandId, actor, status: 'blocked', ms: 0, model: cfg.model, reason: quota.message });
const data = fallback ? await fallback({ reason: quota.message, cfg }) : null;
return { data, meta: { status: 'blocked', reason: quota.message, blockReason: 'quota', logId: entry.id, model: cfg.model, ms: 0, quota } };
}
}
为什么要在入口就拦,而不是在每个业务 handler 里判?
因为 AI 调用是唯一有边际成本 的能力。选品、预测这些每次都要调大模型,token 是真金白银。把配额检查放在 chatJSON() 入口,意味着新增任何一条能力线都自动受配额约束------你不可能忘记写检查,因为它压根不在你的代码里。
而 ai/schema.js 处理的是另一个现实问题:大模型即使被要求"只输出 JSON",也经常不听话:
js
// ai/schema.js ------ 从模型输出中提取 JSON 对象
export function extractJSON(raw) {
if (!raw || typeof raw !== 'string') return null;
let s = raw.trim();
// 1. 去掉 Markdown 代码块包裹
const fence = s.match(/```(?:json)?\s*([\s\S]*?)```/i);
if (fence) s = fence[1].trim();
// 2. 直接尝试
const direct = tryParse(s);
if (direct) return direct;
// 3. 截取第一个 { 到最后一个 } 之间
const start = s.indexOf('{');
const end = s.lastIndexOf('}');
if (start !== -1 && end > start) {
const sliced = tryParse(s.slice(start, end + 1));
if (sliced) return sliced;
// 4. 括号不平衡时逐段尝试(截断输出)
const obj = tryParse(s.slice(start, end + 1) + '}'.repeat(Math.max(0, countOpen(s.slice(start)) - countClose(s.slice(start)))));
if (obj) return obj;
}
// 5. 兜底:找所有 {...} 平衡块,取第一个能解析的
const blocks = balancedBlocks(s);
for (const b of blocks) {
const obj = tryParse(b);
if (obj) return obj;
}
return null; // 拿不到就返回 null,由 client 触发一次纠正重问
}
配套的还有一次自纠正重问:如果 JSON 解析出来但缺了 schema 里的必需字段,client 会带着"你上次少了 XXX、YYY"再问一次模型。这招把"字段缺失直接报错"变成了"字段缺失自动修复一次"。
九、先记住这 5 个坑
这 5 个坑是实战中真踩过的,写在最前面帮你省时间:
- Windows 下
npm test会 MODULE_NOT_FOUND ------node --test src/tests/的尾斜杠在 Windows 解析不了,要用node --test "src/tests/*.test.js"。 createProduct()返回的是{ product, warnings }不是商品本身 ------ 直接拿返回值读.id会 undefined。assert.throws()不返回错误对象 ------ 想断言err.status得自己包一层expectError(fn, re)。- PHP 门户的 demo 版禁止接口透传 ------ 新增的
/api/xxx必须在index.php路由表里显式注册,否则 403。 - 改了 php-portal 的代码必须 rebuild 镜像 ------ 它是
COPY进容器的,不是 volume 挂载,改文件不重建不生效。
第 1 条和第 3 条是跨平台 + Node API 语义 的双重坑,第 4、5 条是部署形态 的坑------它们共同的特点是本地不复现。你本地跑得好好的,提交上去才发现。
总结
拆完这张地图,最值得带走的其实不是某个文件路径,是三个判断:
**零依赖不是炫技,是把"装不起来"这个最大的失败模式消灭在源头。**代价是你得自己维护约定,这套系统用"把约定写进注释、写进铁律表"的方式还回去了。
**没有框架的系统,二开的成本几乎全在"找对文件"上。**所以一份准确的改动地图比十篇架构原则有用。
**分层不清的代价是沉默的。**改错层不会报错,只会没效果。所以每加一个字段都问一遍"这三处都要改吗"。
想动手的话,建议路线是:先跑通本地环境 → 复刻一个已有的业务模块(照着 handlers/stores.js 抄)→ 再加管理端页面。第一个模块最难,之后都是重复。
项目已开源,代码可自取:atomgit.com/pangzi_zi/d...
采用 Apache 2.0 许可证,可自由使用、修改与商用。