一对多的数据库姻缘:ArkTS 为鸿蒙订单设计外键与明细表
实例:订单与明细(Order)|技术:订单表 + 明细表、外键约束、OrderDao
一、业务需求分析:订单为什么需要两张表
「一个订单包含多件商品」是最经典的一对多关系:一张订单(SO20250601001)包含蓝牙耳机、数据线、手机壳三件商品。如果把商品直接塞进订单表(逗号分隔),查询「这件商品卖了多少单」会变成噩梦;正确的建模是拆成两张表:
- 订单表 orders:订单本体(订单号、状态、总价、收货人、时间)——一单一行;
- 明细表 order_item:订单里的每件商品(商品名、单价、数量、小计)——一商品一行,用 order_id 指向所属订单。
这个「一(订单)对多(明细)」的结构,是电商、点餐、进销存(实例 6 已见雏形)的通用数据模型,也是 JOIN 联表查询的天然舞台。
核心业务需求:
- 下单:一次提交「订单信息 + 多件商品明细」,事务保证原子(全成或全撤);
- 订单列表:每个订单卡片内嵌其明细(主从嵌套列表);
- 状态流转:待支付 → 已支付 → 已发货 → 已完成(或取消);
- 总价计算:明细小计之和 = 订单总价(事务内计算);
- 状态统计:每种状态的订单数(GROUP BY)。
二、双表字段设计
订单表 orders
| 字段名 | 类型 | 约束 | 说明 |
|---|---|---|---|
| id | INTEGER | PRIMARY KEY AUTOINCREMENT | 自增主键 |
| order_no | TEXT | NOT NULL UNIQUE | 订单号(SO+时间戳) |
| status | INTEGER | NOT NULL DEFAULT 0 | 0 待支付 / 1 已支付 / 2 已发货 / 3 已完成 / 4 已取消 |
| total_amount | REAL | NOT NULL DEFAULT 0 | 订单总价 |
| customer | TEXT | NOT NULL | 收货人 |
| phone | TEXT | DEFAULT ‘’ | 联系电话 |
| address | TEXT | DEFAULT ‘’ | 收货地址 |
| created_time | INTEGER | NOT NULL | 下单时间戳 |
明细表 order_item
| 字段名 | 类型 | 约束 | 说明 |
|---|---|---|---|
| id | INTEGER | PRIMARY KEY AUTOINCREMENT | 自增主键 |
| order_id | INTEGER | NOT NULL | 所属订单(逻辑外键) |
| product_name | TEXT | NOT NULL | 商品名(快照) |
| price | REAL | NOT NULL | 成交单价 |
| quantity | INTEGER | NOT NULL | 数量 |
| subtotal | REAL | NOT NULL | 小计(price × quantity) |
设计要点拆解:
1. order_no 业务唯一键。UNIQUE约束保证订单号唯一——业务上订单号是「对外标识」(客服查单、物流对账都用它),比自增 id 更有业务意义。'SO' + Date.now()生成规则简单可靠(毫秒级不会撞号)。
2. status 用 0~4 数字枚举。五个状态:待支付 → 已支付 → 已发货 → 已完成 → 已取消。数字编码可参与WHERE status=0过滤和状态统计,页面映射成文案。
3. product_name 存快照而非外键。明细里的商品名直接存文本(不引用商品表)——这是「快照」设计:订单确定后商品名/价格就是历史事实,不能随商品表变化。如果用户买了「无线蓝牙耳机」后商家改名「蓝牙耳机 Pro」,订单明细应保持当时的名称。价格同理(price快照成交价)。
4. total_amount 冗余在订单表。总价 = Σ(明细小计),但订单列表高频读取总价(卡片、统计、导出),冗余存储避免每次都 JOIN + SUM。冗余的代价是写入时必须保证一致——下单事务里先算 total 再写入(见 9-3 文章)。
5. 逻辑外键 order_id。与实例 6 相同,用order_id INTEGER NOT NULL+ 代码级联删除(deleteOrder 先删明细再删订单),不启用物理 FOREIGN KEY。
三、建表 SQL:双表 + 明细索引
CREATETABLEIFNOTEXISTSorders(idINTEGERPRIMARYKEYAUTOINCREMENT,order_noTEXTNOTNULLUNIQUE,statusINTEGERNOTNULLDEFAULT0,total_amountREALNOTNULLDEFAULT0,customerTEXTNOTNULL,phoneTEXTDEFAULT'',addressTEXTDEFAULT'',created_timeINTEGERNOTNULL);CREATETABLEIFNOTEXISTSorder_item(idINTEGERPRIMARYKEYAUTOINCREMENT,order_idINTEGERNOTNULL,product_nameTEXTNOTNULL,priceREALNOTNULL,quantityINTEGERNOTNULL,subtotalREALNOTNULL);CREATEINDEXIFNOTEXISTSidx_item_orderONorder_item(order_id);idx_item_order索引的意义:明细表的高频查询是「某订单的全部明细」(WHERE order_id = ?)——没有索引,每次全表扫描;有索引,直接定位。外键列必须建索引,这是一对多建模的铁律。
表名orders而非order:order是 SQL 的保留关键字(ORDER BY),用orders复数避开歧义——这是表命名的一个实用技巧。
四、OrderDao 封装:实体与聚合体
数据层核心OrderDao,实体接口:
exportinterfaceOrder{id:number;orderNo:string;status:number;totalAmount:number;customer:string;phone:string;address:string;createdTime:number;}exportinterfaceOrderItem{id:number;orderId:number;productName:string;price:number;quantity:number;subtotal:number;}/** 订单 + 明细聚合体(页面渲染用) */exportinterfaceOrderWithItems{order:Order;items:OrderItem[];}OrderWithItems聚合体接口是本实例的亮点——它不是数据库表对应的实体,而是**「查询结果导向」的组合体**:一个订单 + 它的明细数组。页面ForEach(this.orders)直接渲染聚合体,数据层负责组装。聚合体接口让 DAO 返回「页面想要的形状」,UI 零组装。
下单参数用OrderDraft(订单草稿)+OrderItemDraft(明细草稿)表达「待创建的数据」:
exportinterfaceOrderDraft{customer:string;phone:string;address:string;items:OrderItemDraft[];}exportinterfaceOrderItemDraft{productName:string;price:number;quantity:number;}为什么分开 Draft 和实体?草稿没有 id、orderId、subtotal(下单时才计算),语义上区分「输入数据」与「存储数据」——这是分层架构的细节修养。
五、基础查询:订单列表与单订单明细
全部订单(时间倒序):
staticasyncqueryOrders(context:common.Context):Promise<Order[]>{conststore=awaitOrderDao.getStore(context);constpredicates=newrelationalStore.RdbPredicates(OrderDao.TABLE);predicates.orderByDesc('created_time');constresult=awaitstore.query(predicates);returnOrderDao.collectOrders(result);}某订单的全部明细:
staticasyncqueryItemsByOrder(context:common.Context,orderId:number):Promise<OrderItem[]>{conststore=awaitOrderDao.getStore(context);constpredicates=newrelationalStore.RdbPredicates(OrderDao.ITEM_TABLE);predicates.equalTo('order_id',orderId).orderByAsc('id');constresult=awaitstore.query(predicates);returnOrderDao.collectItems(result);}这两个基础查询是一对多关系的「按需加载」形态——先查订单列表,再按需查每个订单的明细。9-3 文章会介绍更高效的「一次 JOIN 查出全部」形态。
六、技术要点对照表
| 技术点 | 实现方式 | 生产价值 |
|---|---|---|
| 一对多 | orders + order_item 双表 | 订单与商品解耦 |
| 外键索引 | idx_item_order | 明细查询走索引 |
| 业务唯一键 | order_no UNIQUE | 对外标识唯一 |
| 快照 | product_name/price 存当时值 | 历史事实不变 |
| 冗余总价 | total_amount | 列表高频读 O(1) |
| 聚合体 | OrderWithItems 接口 | 页面零组装 |
| 枚举状态 | status 0~4 | 可过滤可统计 |
七、文章小结
订单实例建立了一对多双表模型:orders 存订单本体,order_item 存明细,order_id 关联 + 索引加速;明细用「快照」保存商品名和价格(历史事实不随主数据变),总价冗余在订单表(高频读 O(1))。OrderWithItems聚合体接口让 DAO 直接返回「页面想要的形状」。这是电商/点餐/进销存共同的骨架。
下一篇(9-2)展示主从嵌套列表 UI——订单卡片内嵌明细,状态标签 + 总价一目了然。
八、双表设计再深挖:主外键关联、状态枚举与金额精度
8.1 主外键关联:逻辑外键与物理外键的取舍
orders 与 order_item 通过order_id关联。SQLite 默认不强制外键(PRAGMA foreign_keys默认 OFF),所以本实例采用逻辑外键:order_id INTEGER NOT NULL+ 应用层保证一致性(删除订单先删明细)。为什么不用物理 FOREIGN KEY?
| 方案 | 优点 | 缺点 | 本实例选择 |
|---|---|---|---|
| 物理外键 FOREIGN KEY … REFERENCES orders(id) | 数据库强制引用完整性 | 需开 PRAGMA、SQLite 支持弱、级联行为繁琐 | ✗ |
| 逻辑外键(代码关联) | 简单直观、删除可控、无 PRAGMA 依赖 | 应用层要自己保证一致 | ✓ |
relationalStore 的 delete 支持 predicates,「先删明细再删订单」两行代码即可闭环:
// 删除订单:先删明细,再删订单(代码级联)constitemPredicates=newrelationalStore.RdbPredicates(OrderDao.ITEM_TABLE);itemPredicates.equalTo('order_id',orderId);awaitstore.delete(itemPredicates);constorderPredicates=newrelationalStore.RdbPredicates(OrderDao.TABLE);orderPredicates.equalTo('id',orderId);awaitstore.delete(orderPredicates);8.2 订单状态枚举:0~4 数字编码的工程意义
status 用数字枚举而非文本,三个理由:
- 可过滤可统计:
WHERE status = 1、GROUP BY status直接数值运算; - 存储紧凑:INTEGER 4 字节 vs TEXT 变长;
- 可排序:状态流转 0→1→2→3 天然有序(数字序 = 流程序)。
页面侧用映射函数把数字翻译成文案 + 标签色:
| status | 文案 | 标签色 |
|---|---|---|
| 0 | 待支付 | 橙色 |
| 1 | 已支付 | 蓝色 |
| 2 | 已发货 | 紫色 |
| 3 | 已完成 | 绿色 |
| 4 | 已取消 | 灰色 |
exportfunctionstatusText(status:number):string{constmap:string[]=['待支付','已支付','已发货','已完成','已取消'];returnmap[status]??'未知';}8.3 金额为什么用 REAL:SQLite 没有 DECIMAL
SQLite 只有 INTEGER/REAL/TEXT/BLOB 四种存储类,没有数据库界的 DECIMAL(10,2)。金额用 REAL 后,0.1 + 0.2这类浮点误差真实存在(0.30000000000000004)。本实例的应对策略:下单计算用Math.round(total * 100) / 100保留两位小数(见 9-3),页面展示用toFixed(2)格式化(见 9-2):
// 金额规整:先乘 100 取整再除 100,消除浮点尾差consttotal=Math.round(sum*100)/100;九、为什么主从表:一单多品的范式设计
如果只建一张表「订单+商品平铺」,每件商品一行就会重复订单头信息(收货人、地址、电话各重复 N 遍),产生三类问题:
- 冗余:同一订单的收货人存 N 份,改地址要改 N 行;
- 更新异常:漏改一行就数据不一致;
- 语义错乱:「一笔订单」和「一件商品」混在一张表,订单状态、总价无从归属。
主从表(一对多)设计让 orders 每行代表「一个订单实体」,order_item 每行代表「一件商品」,用外键连接——这就是**第三范式(3NF)**的落地:非主属性完全依赖主键,明细只依赖 order_id,不重复订单头信息。
| 范式维度 | 单表平铺 | 主从双表 |
|---|---|---|
| 订单头冗余 | N 件商品重复 N 次 | 0 冗余 |
| 改收货人 | 改 N 行 | 改 1 行 |
| 查询某订单明细 | 全表过滤 | order_id 索引直达 |
| 删除订单 | 逐行删 | 先明细后订单,两行代码 |
十、JOIN 联表查询的思路预览
9-1 的「按需加载」先查订单再查明细,N 个订单要 N+1 次查询;JOIN 一次查出全部:
SELECTo.*,i.product_name,i.price,i.quantity,i.subtotalFROMorders oLEFTJOINorder_item iONo.id=i.order_idORDERBYo.created_timeDESC,o.idDESC;JOIN 的两种形态在 9-3 深入:INNER JOIN(只出有明细的订单)与LEFT JOIN(保留无明细的订单)。配合idx_item_order,JOIN 的关联条件o.id = i.order_id走索引而非全表扫描——索引是 JOIN 性能的前提,这就是为什么外键列必须建索引。
十一、索引设计复盘:订单号与外键
| 索引 | 所在表 | 字段 | 服务的高频查询 |
|---|---|---|---|
| 主键索引(自动) | orders | id | WHERE id = ?单订单查询 |
| UNIQUE 隐式索引(自动) | orders | order_no | WHERE order_no = ?客服查单 |
| idx_item_order(手动) | order_item | order_id | WHERE order_id = ?明细查询 / JOIN 关联 |
SQLite 中PRIMARY KEY与UNIQUE会自动建索引,idx_item_order是唯一需要手动建的。三把索引覆盖了订单模块的全部高频查询路径——建表时想清楚「哪些列会被 WHERE/JOIN 命中」,索引设计就完成了。
十二、FAQ
Q1:为什么不用物理外键?
SQLite 的外键需要每次连接PRAGMA foreign_keys = ON,relationalStore 封装下开启繁琐;且级联删除的默认行为(RESTRICT)反而要写额外代码。逻辑外键 + 应用层两行级联更直白。
Q2:status 为什么不用字符串 ‘PAID’ 而用数字?
数字可参与GROUP BY status统计和区间比较,字符串只能等值匹配;且数字编码天然对应流程顺序。代价是阅读性差,用statusText()映射函数弥补。
Q3:金额 REAL 会不会出错?
有理论误差,但两位小数的场景下Math.round(x * 100) / 100规整后展示无感。生产环境金额敏感系统可考虑「以分为单位存 INTEGER」——本实例为教学清晰选 REAL。
Q4:order_no 用 Date.now() 会不会撞号?
毫秒级时间戳在单机单线程下不会撞;多设备离线场景可加设备前缀(如SO${deviceId}${Date.now()}),UNIQUE 约束兜底防重。
Q5:为什么明细表存 product_name 快照而不存商品 id?
订单是历史事实。若引用商品表,商家改价/改名后历史订单会被「篡改」,财务对账就出问题。快照 + 成交价才是订单明细的正解。
Q6:查询明细时orderByAsc('id')的意义?
明细按插入顺序(id 自增)返回,保证商品顺序与下单时一致——UI 展示的明细顺序就是用户加购的顺序。
