速查版:一屏看懂你项目里那27 张表是怎么组织的、主键外键索引到底是什么、SQL 四种操作对应前端哪个按钮、状态机为什么不能用魔法数字,以及那个把整库写崩的 JSON 事故。
你盘上有两套数据库,都是 SQLite 家族:
① C:\Users\Administrator\WorkBuddy\delimao-system\packages\db——德莉玛产供销一体化系统的数据层,用 drizzle-orm 0.38 + drizzle-kit + @libsql/client,表结构写在 src/schema.ts 里,一共 27 张表。
②德莉玛门店订购单系统 v2.5.0——Express + better-sqlite3,开了 SQLite WAL 模式,用 pm2 挂进程。
③ 另外两处历史项目:2026-08-09-task-100(淋浴房 P3)用 better-sqlite3 13,2026-07-01-task-63/kb-api 用 better-sqlite3 11。
数据库就是一张巨大的、按行按列规矩排好的表。
「正经的表格」是给人看的:歪着写、加个颜色、画个框。数据库的表格是给机器用的:每列的类型固定、每行的宽度固定、谁在第几行不能乱。规矩换来的是「找得快、不认错、并发不乱」。
.db 文件(本地开发库),德莉玛门店系统也是一个 .db 文件。整个库就是一个文件——备份就是拷这个文件,迁移服务器就是拷这个文件。这是 SQLite 最大的好处。数据库的词汇就这几个。先用一句话说清,再看你项目里长什么样:
| 词 | 一句话 | 你项目里长什么样(真实) |
|---|---|---|
| 表 table | 一类东西的清单,比如「所有订单」 | order(订单主表)、sku(标准件库)、supplier(供应商)——一共 27 张 |
| 行 row | 清单里的具体一条 | order 表里的一行 = 一张订单单(订单号 + 经销商 + 状态 + 总金额区间) |
| 列 column | 清单上的一个项目名 | order 表的列:order_no / dealer_id / status / total_min / total_max |
| 主键 primary key | 唯一标识一行,全表不许重复 | 你 27 张表里绝大多数主键都是 id,integer 类型 + primaryKey({ autoIncrement: true })——数字自增。只有两张例外:param.key 和 std_parts.part_key 用 text 主键(因为它们天然有唯一编码) |
| 字段类型 | 这一列只准放这一类东西 | SQLite 只有四种:TEXT 文本 / INTEGER 整数 / REAL 小数 / BLOB 二进制。布尔没有真布尔类型,你项目里用 integer({ mode: 'boolean' }),实际存 0 和 1(比如 inventory_movement 之外的 bom_line.allocated、production_model_map.confirmed) |
| 外键 foreign key | 指向另一张表的主键,意思是「这一行的那个东西必须真的存在」 | 德莉玛 order_line.order_id 指向 order.id——没有订单就不可能有订单明细 |
| 索引 index | 书的目录页:它不存任何数据,只为了让你查得快 | 你项目里真实存在的索引:product_model_code_idx(code 唯一)、series_sku_default_idx(series+bom_cat 组合唯一)、inventory_sku_wh_idx(sku+warehouse_id 组合唯一)。另外所有 .notNull().unique() 的列(如 sku.sku、order.order_no)SQLite 会自动给它建唯一索引 |
unique() 是一种「顺便」的索引——它不只是加速,还顺手保证了「不许重复」这条规则。| 主键 primary key | 唯一键 unique | |
|---|---|---|
| 作用 | 唯一标识一行,一行必须有且只有一个 | 保证某列的值不重复,一行可以一个都不设 |
| 能不能为空 | 不能 | 普通 unique 列可以有空值(dealer.code 就是 .unique() 但允许 null) |
| 数量 | 一张表只能一个 | 可以有很多个 |
| 你项目里的例子 | order.id、sku.id、dealer.id(27 张表里绝大多数) | sku.sku、order.order_no、supplier.code、series.code |
id 自增做主键、order_no 另设唯一。原因:主键会进所有索引(外键要引它),用整数最省空间最快;订单号是业务规则生成的字符串,万一将来要改格式(比如从 DL20261002001 改成别的),改主键会连带所有引用它的外键一起改,而改唯一列只是一次 UPDATE。表名全部来自 packages/db/src/schema.ts,一个不多一个不少。文件名里已经写了分组注释:
| 层 | 表 | 干什么 |
|---|---|---|
| ① 产品库 | series(系列)、product_model(型号)、product_option(选配)、production_model_map(生产型号映射) | 淋浴房有哪些系列(平开门/推拉门)、每个系列下有哪些型号(如 H4-T52)、可选件、生产型号 PV102 属于哪个系列 |
| ② 引擎库 | param(参数)、series_sku_default(系列默认 SKU)、std_parts(9 类标准件)、extra_product(附加产品)、bom_rule(BOM 规则) | 算 BOM 和报价要用的规则和参数。bom_rule.rule 是 jsonb(存规则结构),std_parts 只有两列:part_key + data(整个标准件塞在一个字段里——这个字段是合法的,因为 9 个标准件的字段结构本来就统一且不需要单独查) |
| ③ SKU 库 | sku、sku_price | 标准件主数据(如 HINGE-H4002,含类别/规格/材质/单位/玻璃范围/载荷/孔位/颜色/价格区间/供货路线),sku_price 是多供应商报价(同一 SKU 不同供应商不同价,还带 effective_from 生效时间) |
| ④ 供应商库 | supplier、material_route | 玻璃厂/型材厂/五金厂/胶条厂,material_route 记谁供什么料、lead_time_days 备货几天 |
| ⑤ 库存 | warehouse、inventory、inventory_movement | inventory 是当前存量(qty 在库 / locked 已锁 / safety_stock 安全库存);inventory_movement 是流水(type 取 in/out/lock/unlock/count,带 ref_type+ref_id 指回来源单据) |
| ⑥ 用户 / 经销商 | user、dealer | user.role 三个值 admin/factory/dealer;dealer 就是 4 家经销商门店,订单挂在它下面 |
| ⑦ 订单 / 拆单 | order、order_line、bom_line | 三层:订单主表→ 订单明细 → 拆单出的 BOM 行 |
| ⑧ 采购 | po、po_line、receiving | 采购单/ 采购明细 / 收货记录。po_line.bom_line_id 指回 bom_line,闭环回到订单 |
| ⑨ 工单 / 领料 | work_order、work_order_item、material_requisition | 生产工单 / 工单明细 / 领料单 |
// ================= ① 产品库 =================,一直数到 ⑨ 工单 / 领料。9 个分区,27 张表。这就是「按业务分层」的具体含义,不是抽象原则。外键是「指针」。下面每一条都是 schema.ts 里真的写了 .references(() => xxx.id) 的:
dealer.id ← order.dealer_idorder.id ← order_line.order_idmodel 型号 + spec 规格 + thick 厚度 + process 玻璃工艺 + qty 数量 + area 面积)。order_line 的外键是 .notNull().references(),强制不为空。order_line.id ← bom_line.order_line_idbom_line 有 bom_cat(玻璃/五金/密封/型材)、sku、qty、route(玻璃厂/型材厂/五金厂/胶条厂)、supplier、line_cost、allocated(有没有被采购占用)。这就是拆单结果的落库位置。bom_line.id ← po_line.bom_line_idsku.sku ← inventory.sku / sku_price.skuid,而是唯一键 sku 这个字符串(HINGE-H4002 那种)。SQLite 允许指向唯一列。好处:库存和价格直接用业务编码连,查数据一眼看得懂,不用先查 sku.id 再做转换。因为一条订单天然有多行商品。如果硬塞进一张表,只有两个办法,两个都错:
| 做法 | 后果 |
|---|---|
为每个型号都开一列(model_a model_b model_c…) | 订单有 5 个型号就多5 列,有 6 个就崩。而且加一个字段就要改表结构。列名也没地方放型号数量、单价、面积这些信息 |
把多个型号拼成一个字段('H4-T52 x2, H4-T60 x1') | 这就是你踩过的那个坑的另一种形态。查询「H4-T52 卖了多少」只能全表扫 + 在应用层拆字符串;改一个数量要把整串重写;一行坏了整行读不出来 |
order/order_line、po/po_line、work_order/work_order_item 三对都是这个模式。所有 SQL 就四个动作,动词开头:
| 动作 | 关键词 | 大白话 | 对应前端哪个动作 |
|---|---|---|---|
| 查 | SELECT | 拿数据出来看,不改 | 页面加载时显示列表、点「查询」按钮出结果 |
| 增 | INSERT | 加一行新的 | 点「新增订单」「提交报价」 |
| 改 | UPDATE | 把已有的某几列换掉 | 点「审核通过」「改数量」 |
| 删 | DELETE | 把整行拿走 | 点「删除」「作废」 |
下面三条是你项目里真实会跑的查询。左边是纯 SQL,右边是 drizzle 写法(packages/db 用的就是右边这种,但它只是帮你拼字符串,底下还是左边这条 SQL):
-- ①查:某一家经销商门店的全部订单(前端:门店订单列表页) SELECT o.id, o.order_no, o.status, o.total_min, o.total_max, o.created_at FROM order o JOIN dealer d ON d.id = o.dealer_id WHERE d.code = 'DL01' ORDER BY o.created_at DESC; -- ② 查:某个型号的 BOM(前端:点型号看用料清单) --注意 order_line.model 是业务型号,直接可读 SELECT l.bom_cat, l.item, l.sku, l.spec, l.material, l.qty, l.unit, l.route, l.supplier, l.line_cost FROM bom_line l JOIN order_line ol ON ol.id = l.order_line_id WHERE ol.model = 'H4-T52' ORDER BY l.bom_cat; -- ③ 查:某SKU 在各仓库的存量(前端:库存看板) SELECT i.sku, w.name AS warehouse, i.qty, i.locked, i.safety_stock, i.updated_at FROM inventory i JOIN warehouse w ON w.id = i.warehouse_id WHERE i.sku = 'HINGE-H4002';
// drizzle 写法(等价于上面第 ① 条),你项目 packages/db 里的真调用形态
const rows = await db
.select({ id: order.id, no: order.orderNo, status: order.status })
.from(order)
.innerJoin(dealer, eq(dealer.id, order.dealerId))
.where(eq(dealer.code, 'DL01'))
.orderBy(desc(order.createdAt));
order.id 这样的属性,写错字段名编译期就报错;②自动拼 SQL 并参数化(值用 ?占位 传进去,不做字符串拼接,所以不会被 SQL 注入);③结果自动带类型。它没帮你干的事:复杂查询还是得你会写 SQL——遇到「三张表关联 + 聚合 + 分组」的时候,你必须能自己写出那条 SQL 才对得上debug。WHERE。漏掉 WHERE 的 UPDATE order SET status='done' 会把整张表改成同一个状态;漏掉 WHERE 的 DELETE FROM order 会清空订单表。这是所有生产事故里最常见的一类手滑。正确做法是:先 SELECT 同样的条件,看看要改的几行对不对,再 UPDATE。你项目里所有状态都用TS 常量数组声明,这是最省心的做法。源码长这样(schema.ts 开头部分,真实代码):
export const USER_ROLES = ['admin', 'factory', 'dealer'] as const;
export const ORDER_STATUS = ['draft', 'submitted', 'reviewed', 'approved',
'rejected', 'in_production', 'done', 'cancelled'] as const;
export const PO_STATUS = ['draft', 'submitted', 'partial', 'received', 'cancelled'] as const;
export const WO_STATUS = ['open', 'in_progress', 'completed', 'cancelled'] as const;
export const REQ_STATUS = ['pending', 'issued', 'cancelled'] as const;
export const MOVE_TYPES = ['in', 'out', 'lock', 'unlock', 'count'] as const;
as const 是关键:它让这个数组的类型变成「只读的字面量联合类型」,所以 status 列的类型推导出来是 'draft' | 'submitted' | ...。你写一个 status: 'shipped',TypeScript 当场报错——因为拼写错了或者状态压根不存在。
// ✗ 魔法数字:三个月后你自己都忘了3 是啥
await db.update(order).set({ status: 3 });
// ✓ 常量:一眼看懂
await db.update(order).set({ status: ORDER_STATUS[3] }); // 或者直接写常量名
// 更常见的写法是给状态配一个对象/映射
await db.update(order).set({ status: 'in_production' });
为什么第2 行也不算错但仍然别这么写:靠数组下标取值,等于把状态和数字焊死了。你哪天在数组中间插一个状态,所有下标全部错位,而且编译器不会报错——这是最危险的一类 bug。
| 写法 | 半年后你还能看懂吗 | 插一个状态会崩吗 |
|---|---|---|
status: 3 | 不能。得回去翻代码找注释 | — |
status: ORDER_STATUS[3] | 能,但要多想一步 | 会。全部错位,且不报错 |
status: 'in_production' + 类型来自常量数组 | 能 | 不会。写错拼写当场编译失败 |
schema.ts 里用常量数组导出所有合法状态;②表定义里写成 text('status', { enum: ORDER_STATUS }).notNull().default('submitted')。数组管「有哪些合法值」,enum 管「这一列只准用这些值」,两层一叠,状态就再也不会乱了。'in_production'),代码里用的是常量名。这两边必须对得上。对不上就是状态乱——典型症状是:数据库里存了 'in_production',代码里判断写的是 'inproduction'(少个下划线),于是所有行都显示成「未知状态」,而代码也不会报错。ORDER_STATUS 里的成员。先说痛点。你改了 schema.ts,以为数据库变了——其实没有。
sku 表加了一个字段 load_kg,代码里已经读得到。但服务器上那个 .db 文件里根本没有这一列,一查就报 no such column: load_kg。schema.ts 只是一份说明书(图纸),它不会自动改造已有的库。库是之前某次执行时按旧图纸盖的,现在你改了图纸,总得有人去按新图纸改造现场。迁移(migrate)就是「按新图纸改造现场」这个动作。你项目有两条命令,一前一后,顺序不能换:
npm run db:generate —— 生成迁移文件(不动数据库)实际执行 drizzle-kit generate:对比当前 schema.ts 和数据库的现状,算出「需要改什么」,写成一个带时间戳的迁移文件(里面是真正的 ALTER TABLE 语句)。这一步只生成文件,一个字节都没写进数据库。所以它可以随时跑、跑多少次都没副作用。
npm run db:migrate —— 执行迁移(真正改数据库)实际执行 tsx src/migrate.ts:读迁移目录里还没跑过的文件,按顺序对数据库逐条执行,并记录「这条已经跑过了」。这一步才真的改库。
# 完整流程,你项目根目录敲 npm run db:generate# 1. 改了 schema.ts 之后先跑这个 → 生成迁移文件 npm run db:migrate # 2. 再跑这个 → 迁移文件真正应用到 .db npm run db:seed # 3.(可选)灌初始主数据:SKU 主数据、系列价格、真实型号
schema.ts 就去重启服务,忘了跑这两步。结果就是「代码里写得好好的,一查就报 no such column」,然后开始怀疑是不是缓存、是不是驱动版本不对、要不要重启 pm2——方向全错。数据库不会因为你觉得「我改了」就跟着改,迁移文件才是改的凭据。.db 文件(比如拿个工具加列)。这会导致「数据库实际结构」和「迁移记录」对不上,下一次 migrate 会尝试执行一个「把这列删掉」或「这列已存在」的语句,直接失败。数据库结构的变更只走 generate + migrate 这一条路,手工改是给自己埋雷。德莉玛门店系统的 collections 表曾经把一整个对象存成一个字段(单行 JSON blob)。后果不是「不好看」,是三件事同时崩:①写坏整行就崩(改一个字段要重写整行,JSON 一旦截断就整行读不出来,连正常数据也取不到);②没法查询(想找「某个型号的那条」只能全表扫,扫出来还得在应用层 JSON.parse 再筛);③并发直接串行(同一行只能一个人写,别人全等)。这就是「存成一坨」和「拆成可独立查询的最小单元」的真实差距。
刚开发时表小,怎么查都飞快,于是「用这个字段查一下」就顺手写进代码了。数据涨到几万行后同一个页面要几秒。回过头看,那个字段既没 unique() 也没建索引。数据库只能一行一行扫(叫全表扫描),扫完才算得出结果。判断方法:任何出现在 WHERE 右边的列,都要问一句「它有索引吗」。
比如用 uuid 或时间戳字符串当主键。两个坏处:①顺序乱——UUID 是随机的,插到表里到处乱放,「按主键取前 20 条最新」这种需求就没法用「顺序」实现;②索引失效变慢——主键会进所有索引,随机字符串让索引页不断分裂、频繁重排,比自增整数慢好几倍。你项目的27 张表里 25 张用 integer 自增做主键,就是这个原因。
见第 06 节。status: 3 这种写法三个月后没人知道是啥,改的时候还得先猜对下标。用常量数组 + enum,让编译器帮你盯着。
见第 07 节。no such column 的头号原因。改了 schema.ts 之后必须 generate + migrate。
这个坑你没踩,但你项目做对了,值得认一下:inventory(当前存量:qty/locked)和 inventory_movement(流水:谁在什么时候加减了多少、ref_type 指回来源单据)是两张表。如果只留一张、只存当前值,那「上周三这批货为什么少了 20」就永远答不上来——因为历史被覆盖了。
schema.ts 里27 张表的名字,能按①~⑨ 九个业务层归类integer 自增主键,只有 param.key 和 std_parts.part_key 用 text 主键sku_price.sku 和 inventory.sku 指向的是唯一键 sku 字符串不是主键unique() 是「顺便」的索引SELECT/INSERT/UPDATE/DELETE,并知道 UPDATE/DELETE 漏WHERE 会清全表ORDER_STATUS 的 8 个态as const 的作用:写错状态拼写当场编译报错db:generate(生成迁移文件,不碰库)和 db:migrate(真正改库),且知道顺序不能换collections 表JSON blob 事故的三个后果:写坏整行就崩、没法查询、并发串行先说规模和技术选型。我这边有两套,都是 SQLite 家族:产供销一体化的数据层用 drizzle-orm 0.38 + libsql client,表结构集中写在 packages/db/src/schema.ts,一共 27 张表;门店订购单系统用 better-sqlite3,开了 WAL 模式,pm2 挂进程。为什么是 SQLite:整个库就是一个文件,备份是拷文件、迁移服务器是拷文件,我的部署方式是 pm2 + 单机,不需要 MySQL/PostgreSQL 那一套。
再说设计:我按业务分成 9 层——产品库(series / product_model / product_option / production_model_map)、引擎库(param / series_sku_default / std_parts / extra_product / bom_rule)、SKU 库(sku / sku_price)、供应商库(supplier / material_route)、库存(warehouse / inventory / inventory_movement)、用户经销商(user / dealer)、订单拆单(order / order_line / bom_line)、采购(po / po_line / receiving)、工单领料(work_order / work_order_item / material_requisition)。
层与层之间靠外键串起来,主链路是:dealer → order → order_line → bom_line → po_line → receiving。从一张订单出发能一路 JOIN 走到收货记录,这就是整条业务链在库里的样子。订单拆主表 + 明细表是因为一条订单天然有多行商品——共有的信息(订单号、客户、状态、总额)放主表,各自的(型号、规格、数量、单价)放明细,加第五个型号只需多插一行,不改表结构。
状态机我用 TS 常量数组声明,不写魔法数字。比如订单 8 个态:draft / submitted / reviewed / approved / rejected / in_production / done / cancelled,导出成 ORDER_STATUS,然后表定义写成 text('status', { enum: ORDER_STATUS })。关键在 as const——它把数组变成字面量联合类型,业务代码里写一个不存在的状态,TypeScript 当场报错。反例是 status: 3,三个月后没人知道3 是什么。
最大的坑是我自己踩过一次,性质很严重:门店系统的 collections 表曾经把一整个对象存成单行 JSON blob。开始只是图省事——「反正对象里有这些字段,一行存完多简单」。结果出了三件事:第一,写坏整行就崩。改一个字段要重写整行,JSON 一旦写截断或者并发写互相覆盖,整行读不出来,连带正常数据也一起取不到,最后是整个功能挂掉;第二,没法查询。想找「某个型号的那条」,SQL 完全帮不上忙,只能全表扫出来在应用层 JSON.parse 再筛,数据量一大就卡死;第三,并发直接串行。同一行只能一个人写,别人全排队等着。
改造的思路是「把可独立查询的最小单元拆出来」:一个对象里凡是「会被单独查、会被单独改、会被单独引用」的字段,都该自己占一列;剩下的整体结构(比如 BOM 规则这种内部就要整体读的)才留在 JSON 字段里。我判断标准是:这个字段需不需要出现在 WHERE 里?需要,就必须独立成列。改完之后查询能走索引、写入只改一列不再重写整行、并发也只锁被改的那几行。
另外两个我主动防的坑:①任何出现在 WHERE 里的列必须有索引,没索引就是全表扫描,数据涨到几万行直接卡住;②主键一律用 integer 自增,不用随机字符串——随机 UUID 会让插入顺序乱掉,「取最新 20 条」和索引效率同时受影响,所以 27 张表里 25 张都用自增,例外是 param.key 和 std_parts.part_key 这种天生有唯一编码的。
最后说迁移纪律:改 schema.ts 之后必须 db:generate(drizzle-kit 生成迁移文件,不碰库)→ db:migrate(执行文件,真正改库),两步顺序不能换,也不能只跑第一步就以为生效了。数据库结构变更只走这一条路,手工改 .db 文件会让实际结构和迁移记录对不上,下一次 migrate 直接失败。
上面是速查版,只讲「是什么、怎么用」。4KB 页与 B+ 树索引原理、为什么JSON blob 必然拖垮整库、聚簇索引、索引失效的四种场景、JOIN 的 INNER vs LEFT、drizzle 的代价与什么时候该手写 SQL、生产库迁移前必须备份 + md5 校验都在深入版: