"这个项目我想加个仓库管理功能,怎么弄?"
这是我被问得最多的问题之一。而我的回答从来不是"看 handlers/ 目录"——那等于让对方自己读完 1.28 万行代码再总结。
真正的问题在于:这个后端没有框架。backend/package.json 的 dependencies 是空的,路由是手写的 if 链,持久化是自己实现的 JSON 快照。没有框架意味着没有 generator、没有 DI、没有约定插件——你加一个业务模块,框架不会替你做这些事。
所以我把整个流程固化成了 6 步,附一份实测 9 个用例全绿的模块骨架。看完你就能独立加出一个可持久化、有测试、有权限控制的业务模块。
全文以仓库里真实存在的
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 })
这六件里,③ 校验、⑤ 持久化钩子、⑥ 审计最容易漏。漏了 ③,脏数据进库;漏了 ⑤,重启数据消失;漏了 ⑥,出了事没法自证清白(合规场景是致命的)。
写完 handlers/xxx.js 还要在三个地方注册:
| 注册点 | 文件 | 不注册的后果 |
|---|---|---|
| 持久化 | persist.js 的 MODULES 数组 | 重启后数据全丢 |
| 路由 | server.js 的 route() 函数 | 接口 404 |
| 权限 | auth/middleware.js 的白名单 | 不该公开的接口裸奔,或该公开的接口 401 |
为什么是"三处"而不是"一处"? 因为这套架构刻意做了分层解耦:handler 不知道自己有被路由,路由不知道数据怎么存的,持久化不知道业务规则长什么样。解耦的代价就是注册要写三遍——改一个模块要动 5 个文件,但改任何一个都不会意外影响其他模块。
二、范例拆解:stores.js 逐段讲
2.1 头部注释 —— 别小看它
// 店铺(门店)模块 — 跨店铺拆单的分组依据
// 商品可归属到某个店铺(storeId);购物车按店铺分组展示,
// 结算时「每个店铺独立生成一张订单、独立选择支付方式并支付」。
//
// 未归属店铺的商品(storeId 为空)统一归入「平台自营」分组参与拆单。
//
// 持久化:通过 persist.js 注册,导出 _internalState / _restoreState。
项目里每个 handler 都有这种注释,写清它解决什么问题和边界条件。
这不是形式主义——42 个模块靠注释区分职责。stores.js、suppliers.js、procurements.js 名字上都是"某某管理",光看文件名你根本不知道哪个是"商品挂在哪个门店"、哪个是"进货单挂在哪个供应商"。注释第一句直接告诉你它在业务链条上的位置。
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) 扫描,但这个量级完全可以接受。
// 真实项目里的 id-gen.js 实现
export function maxSeqFromIds(ids, prefix) {
let max = 0;
const re = new RegExp(`^${prefix}(\\d+)$`);
for (const id of ids) {
const m = re.exec(id);
if (m) { const n = parseInt(m[1], 10);
if (n > max) max = n;
}
}
return max;
}
📌 二开建议:数据量小(几千条内)一律用方式 B,省心。只有确认是高频写入且量大的模块才考虑方式 A,并记得在
_restoreState()里重置计数器。
方式 A 也不是不能用——createIdGenerator(padWidth = 4, startSeq = 0) 的第二个参数就是给你在恢复时手动对齐用的。但那是要你记得做的事,方式 B 是忘了也不会出事的事。
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 });
}
const posCode = String(body.posCode || '').trim().toUpperCase();
return {
name,
code,
posCode,
contact: String(body.contact || '').trim(),
phone: String(body.phone || '').trim(),
address: String(body.address || '').trim(),
note: String(body.note || '').trim(),
status,
};
}
三个约定:
Object.assign(new Error(msg), { status })—— 给错误挂 HTTP 状态码,上层的handleError()会读它返回对应的响应码。excludeId参数 —— 更新时要排除自己,否则「不改编码直接保存」会撞上自己的唯一性校验。这是新手最常漏的点。- 校验集中在一个
normalize()—— 创建和更新共用,规则不会两边漂移。
第 2 点值得再展开。excludeId 的存在是因为唯一性校验要遍历全表比对,而更新时表里那条记录就是它自己。如果只写了 if (s.code === code),那么用户不改编码直接点保存,就会收到"编码已存在"的报错——而他明明没改这个字段。这种 bug 极难自查:新建路径正常,只有"编辑并保存"时才炸。
第 3 点解决的是另一类漂移:把校验写两遍(一份在 create、一份在 update),改一处忘一处,两条路径行为就不一致了。共用一个 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是否存在,它自己决定怎么报错
为什么要拆成两个?因为**"资源不存在"在调用方的语境里不总是错误**。用户请求一个不存在的店铺,404 是对的;但订单创建时校验 storeId,"店铺不存在"应该报 400(参数错)而不是 404(资源错),文案大概率还要带"请重新选择店铺"这种业务提示。一个抛 404 的函数满足不了两种需求。
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;
}
注意 updateStore 里 normalize({ ...s, ...body }, id) 这个写法:先合并旧数据,再用 body 覆盖,调用方只传要改的字段,不用传全量。
删除保护是这块最有意思的设计:
// 供 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 这类依赖别人的)在文件底部注入
这样依赖永远是单向的:products → stores,不会绕回来。
📌 判断谁上谁下很简单:谁被引用得多,谁就在下层。这个判据可测量——
stores被products、orders、cart引;products被stores(经注入)、orders、报表引。谁的 import 扇入大谁在下层。扇入扇出比"业务上谁更重要"可靠:后者随需求变化,前者是代码事实。
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 时炸掉
第 2 条的"幂等"很关键。persist.js 启动时会调一次 _restoreState(),测试里每个用例前也会调。如果 clear() 漏了,测试就会串数据——第一个用例建的店铺跑到第二个用例还在,你会得到一堆莫名其妙的失败。
三、注册点 1:持久化
persist.js 里加两处:
// 1) 顶部 import
import * as storesMod from './handlers/stores.js';
// 2) MODULES 数组末尾追加
const MODULES = [
{ name: 'agents', mod: agentsMod },
// ... 省略 38 个
{ name: 'coupons', mod: couponsMod },
{ name: 'stores', mod: storesMod }, // ← 新增
];
name 就是 db.json 里的 key。启动时会打印恢复情况:
[persist] 快照已加载 (39/39 模块恢复) — 保存时间: 2026-08-29T04:00:00.000Z
如果这里是 38/39,说明有模块的 _restoreState 抛错了 —— 去看上一行的 warn 日志。
⚠️ 忘了注册的后果很隐蔽:接口能增删改,看起来一切正常,但重启后数据全没了。所以写完模块第一件事就是注册它,并重启验证一次。
这条"看起来一切正常"是这类 bug 最坑的地方:开发机上重启一次数据还在(用的是 seed 数据),你就以为没事了。部署到生产才第一次真正重启,然后发现历史数据全空。
四、注册点 2:路由
server.js 的 route() 是一个超长的 if 链(全文 2343 行)。找到合适位置加进去:
// ---- 店铺(门店)管理 —— 查询公开(C 端需按店铺分组购物车),写入仅管理员 ----
if (path === '/api/stores' && method === 'GET') {
const isAdmin = currentUser?.role === 'admin';
let items = storesMod.listStores();
if (!isAdmin) {
// 公开视图:仅营业中门店,剔除内部运营字段(code/contact/note)
items = items
.filter((s) => s.status === 'active')
.map(({ id, name, phone, address, status, createdAt }) => ({ id, name, phone, address, status, createdAt }));
}
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 端需按店铺分组购物车)"—— 半年后你回头看会感谢自己。
第 1 点是 if 链写法的原罪。只要你在前缀规则里写成 startsWith('/api/stores')(不带尾斜杠),冲突立刻发生:/api/stores/xxx 和 /api/stores 都会被它捞走。所以精确判断永远放前面这条规则是防御性的,成本几乎为零,收益是彻底消除顺序依赖。
五、注册点 3:权限
这一层最容易漏。想清楚你的接口属于哪一类:
5.1 需要登录才能访问
什么都不用做 —— 默认就是。authenticate() 会拒绝无 token 的请求。
5.2 完全公开
加进 auth/middleware.js 的 PUBLIC_ROUTES(精确匹配)或者 PUBLIC_READ_PREFIXES(前缀匹配):
const PUBLIC_ROUTES = [
{ method: 'GET', path: '/api/themes' },
{ method: 'GET', path: '/api/payment/methods' },
{ method: 'GET', path: '/api/stores' }, // ← C 端匿名也要能读
];
const PUBLIC_READ_PREFIXES = [
{ method: 'GET', prefix: '/api/stores/' }, // 店铺详情等子路径
];
5.3 仅管理员
两种做法,推荐第二种:
// 做法 A:集中声明(适合纯 admin 接口)
const ADMIN_ONLY_ROUTES = [
{ method: 'POST', path: '/api/commission/rules' },
{ method: 'DELETE', prefix: '/api/materials/' },
];
// 做法 B:在路由里内联判断(适合读写分离的接口,stores 就是这种)
if (currentUser?.role !== 'admin') return send(res, 403, { error: '需要管理员权限' });
stores 用做法 B,是因为它GET 公开、写操作仅管理员 —— 这种混合权限没法用集中的表表达。
⚠️ 安全提示:C 端页面是完全匿名的。任何加进
PUBLIC_ROUTES的 GET 接口,等于对互联网开放。这里有个很容易翻车的细节:
GET /api/stores是公开的,如果直接返回listStores()的结果,全部字段都会出去——包括contact、note这类内部运营字段。真实项目的做法是在路由里按角色裁剪:管理员看全量,公开视图只保留 C 端需要的字段(见上面代码的
isAdmin分支)。门户的userStores()还会再裁一刀,只回id / name / status。两层裁剪是有意的:后端那道保证 API 直连不泄露,门户那道保证页面只展示必要信息。
六、注册点 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('创建:默认字段正确(编码大写、状态 active、ID 形如 STO0001)', () => {
reset();
const s = createStore({ name: '深圳南山旗舰店', code: 'sz-ns-001' });
assert.equal(s.code, 'SZ-NS-001', '店铺编码应统一大写');
assert.ok(/^STO\d{4}$/.test(s.id), `ID 应形如 STO0001:${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);
});
node:test 的 assert.throws(fn, ErrorClass) 只能断言"抛了某类错误",拿不到错误对象本身,所以你没法断言 err.status === 409。抛错是"出错了",409 是"以 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('持久化:_internalState / _restoreState 可完整恢复店铺', () => {
reset();
createStore({ name: 'A' });
createStore({ name: 'B' });
const snap = _internalState();
_restoreState([]);
assert.equal(listStores().length, 0);
_restoreState(snap);
assert.equal(listStores().length, 2);
});
跑测试(Windows 下必须带引号,否则 src/tests/ 的尾斜杠解析不了):
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 骨架模板已验证可用
上面这段骨架不是纸上谈兵——仓库 docs/二次开发教程/配套代码/ 下的 handler-骨架模板.js 就是完整版,配套的 handler-骨架模板.test.js 有 9 个用例,实测全绿:
# tests 9
# pass 9
# fail 0
覆盖了 ID 自增、快照恢复后新 ID 不与已有记录撞号、必填校验 400、唯一性 409、更新时不撞自己的唯一性校验、getItem 抛 404 而 getItemSafe 返回 null、注入式删除保护 409、持久化往返、审计留痕。
想自己跑一遍,注意 4 个文件要平铺在同一目录(模板内部是同目录相对引用 ./audit.js、./id-gen.js,测试也是同目录引用模板):
mkdir -p /tmp/skel && cd /tmp/skel
cp <仓库>/docs/二次开发教程/配套代码/handler-骨架模板.js .
cp <仓库>/docs/二次开发教程/配套代码/handler-骨架模板.test.js .
cp <仓库>/docs/二次开发教程/配套代码/id-gen.js .
cp <仓库>/docs/二次开发教程/配套代码/audit.js .
echo '{"type":"module"}' > package.json
node --test "*.test.js"
⚠️ 如果你把模板改名成
warehouse.js放进handlers/,同时把测试留在tests/,测试会报ERR_MODULE_NOT_FOUND—— 因为测试里的import ... from './handler-骨架模板.js'是相对于测试文件自身解析的。要么保持 4 个文件平铺同目录,要么同步改测试里的 import 路径。
八、进阶:跨模块富化字段
店铺模块还演示了一个模式:读取时富化,不落库。
// 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}),不是原对象引用。这是为了保护内存态不被调用方误改——调用方顺手obj.amount = 0改一下,内存里的原数据就变了,而且没有任何报错。
九、本篇涉及的真实文件
| 文件 | 行数 | 作用 |
|---|---|---|
backend/src/handlers/stores.js | 122 | 模块本体(本篇范例) |
backend/src/persist.js | 155 | 持久化注册中心 |
backend/src/server.js | 2343 | 路由分发(stores 路由在 845–870 行) |
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 个用例 |
总结
加一个业务模块,真正难的不是写代码,是记得全。这套流程把它压成了 6 步:
- 照骨架写
handlers/xxx.js(六件套齐) persist.js注册持久化server.js加 4 条路由auth/middleware.js定权限tests/写用例- 重启一次,看
[persist] 快照已加载 (N+1/N+1 模块恢复)
第 6 步是唯一「不像开发步骤」的步骤,也是唯一能发现第 2 步漏做的步骤。它之所以啰嗦,是因为它替代了框架——没有框架的系统,一致性只能靠约定 + 清单 + 测试来保证。
项目已开源,代码可自取:atomgit.com/pangzi_zi/d…
采用 Apache 2.0 许可证,可自由使用、修改与商用。