分销代理多端触达系统 · 二次开发教程(02)
📌 转载声明:本文为《分销代理多端触达系统 · 二次开发教程》系列第 02 篇。转载请注明出处;可运行的配套代码与最新版本见官方开源仓库(Apache 2.0):atomgit.com/pangzi_zi/distributor-touch
新增一个业务模块:端到端实战
上一篇:01 环境搭建与本地跑通 本篇目标:从零加一个可持久化的业务模块,并把它接到 API、权限和测试上。 全文以仓库里真实存在的 backend/src/handlers/stores.js(店铺模块)为范例逐段拆解 —— 它是为「跨店铺拆单」新加的模块,代码完整、有测试、已上线,是照着抄都不会错的标准样本。
一、先建立心智模型:一个模块的五件套
后端没有框架,每个业务模块靠约定保证一致性。一个标准模块长这样:
handlers/xxx.js
├── ① 内存存储 const items = new Map()
├── ② ID 生成器 genId()
├── ③ 入参校验 normalize(body) ← 抛带 status 的 Error
├── ④ 业务方法 create / update / delete / list / get
├── ⑤ 持久化钩子 _internalState() / _restoreState()
└── ⑥ 审计留痕 appendAudit({ action, detail })
然后要在三个地方注册,缺一不可:
| 注册点 | 文件 | 不注册的后果 |
|---|---|---|
| 持久化 | persist.js 的 MODULES 数组 | 重启后数据全丢 |
| 路由 | server.js 的 route() 函数 | 接口 404 |
| 权限 | auth/middleware.js 的白名单 | 不该公开的接口裸奔,或该公开的接口 401 |
二、范例拆解:stores.js 逐段讲
2.1 头部注释 —— 别小看它
// 店铺(门店)模块 — 跨店铺拆单的分组依据
// 商品可归属到某个店铺(storeId);购物车按店铺分组展示,
// 结算时「每个店铺独立生成一张订单、独立选择支付方式并支付」。
//
// 未归属店铺的商品(storeId 为空)统一归入「平台自营」分组参与拆单。
//
// 持久化:通过 persist.js 注册,导出 _internalState / _restoreState。
项目里每个 handler 都有这种注释,写清它解决什么问题和边界条件。这不是形式主义 —— 30 个模块靠注释区分职责。
2.2 存储与 ID 生成
import { appendAudit } from './audit.js';
import { maxSeqFromIds } from '../utils/id-gen.js';
const stores = new Map(); // id -> store
function genId() {
return `STO${String(maxSeqFromIds(stores.keys(), 'STO') + 1).padStart(4, '0')}`;
}
这里有个关键设计决策,值得单独说:
项目提供了两种 ID 生成方式:
// 方式 A:独立计数器(递增快,但恢复快照后序号会漂移)
import { createIdGenerator } from '../utils/id-gen.js';
const nextId = createIdGenerator();
nextId('STO'); // STO0001
// 方式 B:从现有最大序号推导(恢复安全,stores.js 用的就是这个)
maxSeqFromIds(stores.keys(), 'STO') + 1
为什么 stores.js 选 B?
因为快照恢复时,_restoreState() 会把数据灌回 Map,但方式 A 的计数器是模块级变量,恢复后它还是从 0 开始 → 下次创建会生成已存在的 ID → 覆盖旧数据。
方式 B 每次创建时实时扫描现有 ID 取最大值,天然幂等。代价是 O(n) 扫描,但数据量在这套系统里完全可以接受。
📌 二开建议:数据量小(几千条内)一律用方式 B,省心。只有确认是高频写入且量大的模块才考虑方式 A,并记得在 _restoreState() 里重置计数器。
2.3 入参校验:抛带 status 的错误
const STATUS = ['active', 'closed'];
function normalize(body = {}, excludeId = null) {
const name = String(body.name || '').trim();
if (!name) throw Object.assign(new Error('店铺名称不能为空'), { status: 400 });
const code = String(body.code || '').trim().toUpperCase();
if (code) {
for (const s of stores.values()) {
if (s.code === code && s.id !== excludeId) {
throw Object.assign(new Error(`店铺编码 ${code} 已存在`), { status: 409 });
}
}
}
const status = String(body.status || 'active').trim();
if (!STATUS.includes(status)) {
throw Object.assign(new Error(`店铺状态必须为 ${STATUS.join(' / ')}`), { status: 400 });
}
return { name, code, contact, phone, address, note, status };
}
三个约定:
Object.assign(new Error(msg), { status })—— 给错误挂 HTTP 状态码,上层的handleError()会读它返回对应的响应码。excludeId参数 —— 更新时要排除自己,否则「不改编码直接保存」会撞上自己的唯一性校验。这是新手最常漏的点。- 校验集中在一个
normalize()—— 创建和更新共用,保证规则不会两边漂移。
2.4 业务方法
export function listStores() {
return Array.from(stores.values()).sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1));
}
export function getStore(id) {
const s = stores.get(id);
if (!s) throw Object.assign(new Error(`店铺 ${id} 不存在`), { status: 404 });
return s;
}
/** 不存在返回 null,不抛错 —— 供其他模块安全调用 */
export function getStoreSafe(id) {
return stores.get(id) || null;
}
/** 店铺名称(用于订单/购物车展示);不存在返回 null */
export function getStoreName(id) {
const s = stores.get(id);
return s ? s.name : null;
}
注意 getStore 和 getStoreSafe 的分工:
getStore抛 404 —— 给路由层用,用户传错 ID 就该报错getStoreSafe返回 null —— 给其他模块用,比如orders.js校验订单的storeId是否存在,它自己决定怎么报错
2.5 写操作 + 审计 + 删除保护
export function createStore(body = {}) {
const data = normalize(body);
const id = genId();
const now = new Date().toISOString();
const store = { id, ...data, createdAt: now, updatedAt: now };
stores.set(id, store);
appendAudit({ action: 'store.created', detail: { id, name: store.name } });
return store;
}
export function updateStore(id, body = {}) {
const s = getStore(id);
const data = normalize({ ...s, ...body }, id); // ← excludeId 传自己
Object.assign(s, data, { updatedAt: new Date().toISOString() });
appendAudit({ action: 'store.updated', detail: { id, name: s.name } });
return s;
}
删除保护是这块最有意思的设计:
// 供 products 模块注入:返回当前被商品占用的 storeId 列表,用于删除保护
let storeUsageChecker = () => [];
export function setStoreUsageChecker(fn) {
if (typeof fn === 'function') storeUsageChecker = fn;
}
export function deleteStore(id) {
if (!stores.has(id)) throw Object.assign(new Error(`店铺 ${id} 不存在`), { status: 404 });
const used = storeUsageChecker();
if (used.includes(id)) {
throw Object.assign(
new Error('该店铺下仍有商品,无法删除(请先将商品改归其他店铺)'),
{ status: 409 },
);
}
stores.delete(id);
appendAudit({ action: 'store.deleted', detail: { id } });
return { id };
}
2.6 为什么要用「反向注入」而不是直接 import
问题:店铺想知道「有没有商品在用我」,直觉写法是在 stores.js 里 import { getProducts } from './products.js'。但 products.js 里又要 import { getStoreSafe } from './stores.js' 来校验商品归属的店铺是否存在 —— 循环依赖。
ESM 能处理部分循环依赖,但很容易出现 undefined 的诡异问题,尤其在恢复快照的时序里。
解法:依赖倒置。
// stores.js —— 定义接口,不 import 任何上层模块
let storeUsageChecker = () => [];
export function setStoreUsageChecker(fn) {
if (typeof fn === 'function') storeUsageChecker = fn;
}
// products.js —— 上层模块主动注入实现
import * as storesMod from './stores.js';
storesMod.setStoreUsageChecker(
() => Array.from(products.values()).map((p) => p.storeId).filter(Boolean)
);
规则:
- 下层模块(stores、coupons 这类被别人引用的)不 import 上层,只暴露 setter
- 上层模块(products、orders 这类依赖别人的)在文件底部调用 setter 注入
这样依赖永远是单向的:products → stores,不会绕回来。
📌 判断谁上谁下很简单:谁被引用得多,谁就在下层。
2.7 持久化钩子
export function _internalState() {
return Array.from(stores.values());
}
export function _restoreState(state = []) {
stores.clear();
for (const s of state) stores.set(s.id, s);
}
约定:
_internalState()返回可 JSON 序列化的普通数据(Map 会被file-store.js自动转换,但返回数组更直白)_restoreState()先clear()再灌,保证重复调用幂等- 参数给默认值
= [],防止快照里没这个 key 时炸掉
三、注册点 1:持久化
persist.js 里加两处:
// 1) 顶部 import
import * as storesMod from './handlers/stores.js';
// 2) MODULES 数组末尾追加
const MODULES = [
{ name: 'agents', mod: agentsMod },
// ... 省略 26 个
{ name: 'coupons', mod: couponsMod },
{ name: 'stores', mod: storesMod }, // ← 新增
];
name 就是 db.json 里的 key。启动时会打印恢复情况:
[persist] 快照已加载 (28/28 模块恢复) — 保存时间: 2026-08-29T04:00:00.000Z
如果这里是 27/28,说明有模块的 _restoreState 抛错了 —— 去看上一行的 warn 日志。
⚠️ 忘了注册的后果很隐蔽:接口能增删改,看起来一切正常,但重启后数据全没了。所以写完模块第一件事就是注册它,并重启验证一次。
四、注册点 2:路由
server.js 的 route() 是一个超长的 if 链。找到合适位置加进去:
// ---- 店铺(门店)管理 —— 查询公开(C 端需按店铺分组购物车),写入仅管理员 ----
if (path === '/api/stores' && method === 'GET') {
const items = storesMod.listStores();
return send(res, 200, { count: items.length, items });
}
if (path === '/api/stores' && method === 'POST') {
if (currentUser?.role !== 'admin') return send(res, 403, { error: '需要管理员权限' });
const b = await readBody(req);
return send(res, 201, storesMod.createStore(b));
}
if (path.startsWith('/api/stores/') && method === 'PUT') {
if (currentUser?.role !== 'admin') return send(res, 403, { error: '需要管理员权限' });
const id = path.split('/').pop();
const b = await readBody(req);
return send(res, 200, storesMod.updateStore(id, b));
}
if (path.startsWith('/api/stores/') && method === 'DELETE') {
if (currentUser?.role !== 'admin') return send(res, 403, { error: '需要管理员权限' });
const id = path.split('/').pop();
return send(res, 200, storesMod.deleteStore(id));
}
四个必须注意的点:
- 顺序敏感:
path === '/api/stores'(精确)要写在path.startsWith('/api/stores/')(前缀)前面,否则/api/stores可能先被前缀分支吃掉。 path.split('/').pop()vspath.split('/')[3]:取 ID 时如果路径是/api/stores/STO0001,两种都对;如果后面还有子路径(如/api/orders/xxx/pay),要用[3]这种按位取值。- 权限判断用
currentUser?.role !== 'admin':currentUser在公开路由下是null,可选链不能省。 - 注释要写清权限意图:比如「查询公开(C 端需按店铺分组购物车)」—— 半年后你回头看会感谢自己。
五、注册点 3:权限
这一层最容易漏。想清楚你的接口属于哪一类:
5.1 需要登录才能访问
什么都不用做 —— 默认就是。authenticate() 会拒绝无 token 的请求。
5.2 完全公开
加进 auth/middleware.js 的 PUBLIC_ROUTES:
const PUBLIC_ROUTES = [
{ method: 'GET', path: '/api/themes' },
{ method: 'GET', path: '/api/payment/methods' },
{ method: 'GET', path: '/api/stores' }, // ← C 端匿名也要能读
];
5.3 仅管理员
两种做法,推荐第二种:
// 做法 A:集中声明(适合纯 admin 接口)
const ADMIN_ONLY_ROUTES = [
{ method: 'POST', path: '/api/orders' },
{ method: 'DELETE', prefix: '/api/materials/' },
];
// 做法 B:在路由里内联判断(适合读写分离的接口,stores 就是这种)
if (currentUser?.role !== 'admin') return send(res, 403, { error: '需要管理员权限' });
stores 之所以用做法 B,是因为它GET 公开、写操作仅管理员 —— 这种混合权限没法用集中的表表达。
⚠️ 安全提示:C 端页面是完全匿名的。任何加进PUBLIC_ROUTES的 GET 接口,等于对互联网开放。店铺列表只返回id / name / status,联系人、电话、地址都不该出现在公开响应里 —— 门户层的userStores()专门做了一次字段裁剪,就是为这个。
六、注册点 4(别忘了):测试
backend/src/tests/stores.test.js,19 个用例。关键写法:
6.1 每个用例前重置状态
import {
createStore, updateStore, deleteStore, listStores, getStore, getStoreName,
_internalState, _restoreState,
} from '../handlers/stores.js';
function reset() {
_restoreState([]);
}
test('创建:默认字段正确', () => {
reset();
const s = createStore({ name: '深圳南山旗舰店', code: 'sz-ns-001' });
assert.equal(s.code, 'SZ-NS-001', '店铺编码应统一大写');
assert.ok(/^STO\d{4}$/.test(s.id));
});
6.2 assert.throws() 拿不到错误对象,要包一层
// node:assert 的 throws 不返回错误对象,包一层以便断言 status / message
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;
}
test('删除:仍被商品占用时拒绝(409)', () => {
reset();
const s = createStore({ name: '待删店铺' });
const err = expectError(() => deleteStore(s.id), /仍有商品/);
assert.equal(err.status, 409);
});
6.3 注意 createProduct() 的返回结构
// ❌ 错:createProduct 返回的不是商品本身
const p = createProduct({ name: 'X' });
p.id; // undefined
// ✅ 对:它返回 { product, warnings }
const { product } = createProduct({ name: 'X' });
product.id; // P0001
warnings 是合规校验给的提示(比如文案里的敏感词),别忽略它 —— 生产环境要看这个字段决定要不要拦。
6.4 测持久化快照
test('持久化:快照导出与恢复', () => {
reset();
createStore({ name: 'A' });
createStore({ name: 'B' });
const snap = _internalState();
_restoreState([]);
assert.equal(listStores().length, 0);
_restoreState(snap);
assert.equal(listStores().length, 2);
});
跑测试:
node --test "src/tests/*.test.js" # 全量
node --test src/tests/stores.test.js # 单文件
七、可复制的模块骨架
把下面这段存成 handlers/_template.js,新模块照着改:
// 【模块名】—— 一句话说明它解决什么问题
// 边界条件 / 与其他模块的关系
//
// 持久化:通过 persist.js 注册,导出 _internalState / _restoreState。
import { appendAudit } from './audit.js';
import { maxSeqFromIds } from '../utils/id-gen.js';
const items = new Map(); // id -> item
const PREFIX = 'XXX'; // ← 改:ID 前缀
function genId() {
return `${PREFIX}${String(maxSeqFromIds(items.keys(), PREFIX) + 1).padStart(4, '0')}`;
}
// ── 可选:供上层模块注入的依赖(避免循环 import) ──
let usageChecker = () => [];
export function setUsageChecker(fn) {
if (typeof fn === 'function') usageChecker = fn;
}
// ── 校验 ──
function normalize(body = {}, excludeId = null) {
const name = String(body.name || '').trim();
if (!name) throw Object.assign(new Error('名称不能为空'), { status: 400 });
// 唯一性校验示例
const code = String(body.code || '').trim().toUpperCase();
if (code) {
for (const it of items.values()) {
if (it.code === code && it.id !== excludeId) {
throw Object.assign(new Error(`编码 ${code} 已存在`), { status: 409 });
}
}
}
return {
name,
code,
// TODO: 其余字段,记得 String(...).trim()
};
}
// ── 读 ──
export function listItems() {
return Array.from(items.values()).sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1));
}
export function getItem(id) {
const it = items.get(id);
if (!it) throw Object.assign(new Error(`记录 ${id} 不存在`), { status: 404 });
return it;
}
/** 不抛错版本,供其他模块调用 */
export function getItemSafe(id) {
return items.get(id) || null;
}
// ── 写 ──
export function createItem(body = {}) {
const data = normalize(body);
const now = new Date().toISOString();
const item = { id: genId(), ...data, createdAt: now, updatedAt: now };
items.set(item.id, item);
appendAudit({ action: 'xxx.created', detail: { id: item.id } });
return item;
}
export function updateItem(id, body = {}) {
const it = getItem(id);
const data = normalize({ ...it, ...body }, id);
Object.assign(it, data, { updatedAt: new Date().toISOString() });
appendAudit({ action: 'xxx.updated', detail: { id } });
return it;
}
export function deleteItem(id) {
if (!items.has(id)) throw Object.assign(new Error(`记录 ${id} 不存在`), { status: 404 });
if (usageChecker().includes(id)) {
throw Object.assign(new Error('仍被引用,无法删除'), { status: 409 });
}
items.delete(id);
appendAudit({ action: 'xxx.deleted', detail: { id } });
return { id };
}
// ── 持久化 ──
export function _internalState() {
return Array.from(items.values());
}
export function _restoreState(state = []) {
items.clear();
for (const it of state) items.set(it.id, it);
}
改完后 checklist:
- [ ]
persist.js顶部加了 import - [ ]
persist.js的MODULES加了{ name, mod } - [ ]
server.js加了 4 条路由(GET / POST / PUT / DELETE) - [ ] 权限判断写对了(
currentUser?.role !== 'admin') - [ ] 如果要在
route()里 import,记得在server.js顶部加import * as xxxMod - [ ] 写了测试并全绿
- [ ] 重启一次,确认
[persist] 快照已加载 (N+1/N+1 模块恢复)
7.1 骨架模板已验证可用
上面这段骨架不是纸上谈兵 —— 教程目录下的 配套代码/handler-骨架模板.js 就是它的完整版,配套的 handler-骨架模板.test.js 有 9 个用例,实测全绿:
# tests 9
# pass 9
# fail 0
覆盖了 ID 自增、快照恢复后新 ID 不与已有记录撞号、必填校验 400、唯一性 409、更新时不撞自己的唯一性校验、getItem 抛 404 而 getItemSafe 返回 null、注入式删除保护 409、持久化往返、审计留痕。
想自己跑一遍:
mkdir -p /tmp/skel/handlers /tmp/skel/utils /tmp/skel/tests
cp 配套代码/handler-骨架模板.js /tmp/skel/handlers/warehouse.js
cp 配套代码/handler-骨架模板.test.js /tmp/skel/tests/
# 从仓库里拷 id-gen.js,并给 audit.js 做个桩
cp backend/src/utils/id-gen.js /tmp/skel/utils/
echo 'export function appendAudit(){}' > /tmp/skel/handlers/audit.js
echo '{"type":"module"}' > /tmp/skel/package.json
cd /tmp/skel && node --test "tests/*.test.js"
八、进阶:跨模块富化字段
店铺模块还演示了一个很有用的模式:读取时富化,不落库。
// products.js
function withStoreName(p) {
return { ...p, storeName: p.storeId ? storesMod.getStoreName(p.storeId) : null };
}
export function getProducts(query) {
// ...
return list.map(withStoreName); // 每次读都实时算
}
对比两种做法:
| 做法 | 优点 | 缺点 |
|---|---|---|
| 读时富化(本项目采用) | 店铺改名自动同步;不占存储 | 每次读多一次 Map 查找(可忽略) |
写时冗余 storeName 字段 | 读取快 | 店铺改名要批量洗数据,容易不一致 |
规则:主数据(名称、状态)读时富化;快照类数据(下单时的价格、收货地址)写时冗余。订单里的商品名必须冗余 —— 因为商品后来改名了,历史订单还得显示当时的名字。
💡getProducts/getOrders/getOrderDetail都返回副本({...p}),不是原对象引用。这是为了保护内存态不被调用方误改。
九、本篇涉及的真实文件
| 文件 | 行数 | 作用 |
|---|---|---|
backend/src/handlers/stores.js | 115 | 模块本体(本篇范例) |
backend/src/persist.js | 133 | 持久化注册中心 |
backend/src/server.js | 1553 | 路由分发(stores 路由在 663–683 行) |
backend/src/auth/middleware.js | — | PUBLIC_ROUTES / ADMIN_ONLY_ROUTES |
backend/src/utils/id-gen.js | 40 | ID 生成器 |
backend/src/tests/stores.test.js | — | 19 个用例 |
下一篇
03 管理端页面开发 —— 后端接口有了,怎么给它配一个能用的管理页面:PHP 门户的路由表、控制器、视图、导航,以及那个让人头疼的「demo 版 403」。