分销代理多端触达系统 · 二次开发教程(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 };
}

三个约定:

  1. Object.assign(new Error(msg), { status }) —— 给错误挂 HTTP 状态码,上层的 handleError() 会读它返回对应的响应码。
  2. excludeId 参数 —— 更新时要排除自己,否则「不改编码直接保存」会撞上自己的唯一性校验。这是新手最常漏的点。
  3. 校验集中在一个 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));
}

四个必须注意的点:

  1. 顺序敏感:path === '/api/stores'(精确)要写在 path.startsWith('/api/stores/')(前缀)前面,否则 /api/stores 可能先被前缀分支吃掉。
  2. path.split('/').pop() vs path.split('/')[3]:取 ID 时如果路径是 /api/stores/STO0001,两种都对;如果后面还有子路径(如 /api/orders/xxx/pay),要用 [3] 这种按位取值。
  3. 权限判断用 currentUser?.role !== 'admin':currentUser 在公开路由下是 null,可选链不能省。
  4. 注释要写清权限意图:比如「查询公开(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.js115模块本体(本篇范例)
backend/src/persist.js133持久化注册中心
backend/src/server.js1553路由分发(stores 路由在 663–683 行)
backend/src/auth/middleware.js—PUBLIC_ROUTES / ADMIN_ONLY_ROUTES
backend/src/utils/id-gen.js40ID 生成器
backend/src/tests/stores.test.js—19 个用例

下一篇

03 管理端页面开发 —— 后端接口有了,怎么给它配一个能用的管理页面:PHP 门户的路由表、控制器、视图、导航,以及那个让人头疼的「demo 版 403」。