一对多的数据库姻缘:ArkTS 为鸿蒙订单设计外键与明细表

实例:订单与明细(Order)|技术:订单表 + 明细表、外键约束、OrderDao

一、业务需求分析:订单为什么需要两张表

「一个订单包含多件商品」是最经典的一对多关系:一张订单(SO20250601001)包含蓝牙耳机、数据线、手机壳三件商品。如果把商品直接塞进订单表(逗号分隔),查询「这件商品卖了多少单」会变成噩梦;正确的建模是拆成两张表

  • 订单表 orders:订单本体(订单号、状态、总价、收货人、时间)------一单一行;
  • 明细表 order_item:订单里的每件商品(商品名、单价、数量、小计)------一商品一行,用 order_id 指向所属订单。

这个「一(订单)对多(明细)」的结构,是电商、点餐、进销存(实例 6 已见雏形)的通用数据模型,也是 JOIN 联表查询的天然舞台。

核心业务需求:

  1. 下单:一次提交「订单信息 + 多件商品明细」,事务保证原子(全成或全撤);
  2. 订单列表:每个订单卡片内嵌其明细(主从嵌套列表);
  3. 状态流转:待支付 → 已支付 → 已发货 → 已完成(或取消);
  4. 总价计算:明细小计之和 = 订单总价(事务内计算);
  5. 状态统计:每种状态的订单数(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:双表 + 明细索引

sql 复制代码
CREATE TABLE IF NOT EXISTS orders (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  order_no TEXT NOT NULL UNIQUE,
  status INTEGER NOT NULL DEFAULT 0,
  total_amount REAL NOT NULL DEFAULT 0,
  customer TEXT NOT NULL,
  phone TEXT DEFAULT '',
  address TEXT DEFAULT '',
  created_time INTEGER NOT NULL
);

CREATE TABLE IF NOT EXISTS 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
);

CREATE INDEX IF NOT EXISTS idx_item_order ON order_item (order_id);

idx_item_order 索引的意义 :明细表的高频查询是「某订单的全部明细」(WHERE order_id = ?)------没有索引,每次全表扫描;有索引,直接定位。外键列必须建索引,这是一对多建模的铁律。

表名 orders 而非 orderorder 是 SQL 的保留关键字(ORDER BY),用 orders 复数避开歧义------这是表命名的一个实用技巧。

四、OrderDao 封装:实体与聚合体

数据层核心 OrderDao,实体接口:

typescript 复制代码
export interface Order {
  id: number;
  orderNo: string;
  status: number;
  totalAmount: number;
  customer: string;
  phone: string;
  address: string;
  createdTime: number;
}

export interface OrderItem {
  id: number;
  orderId: number;
  productName: string;
  price: number;
  quantity: number;
  subtotal: number;
}

/** 订单 + 明细聚合体(页面渲染用) */
export interface OrderWithItems {
  order: Order;
  items: OrderItem[];
}

OrderWithItems 聚合体接口 是本实例的亮点------它不是数据库表对应的实体,而是**「查询结果导向」的组合体**:一个订单 + 它的明细数组。页面 ForEach(this.orders) 直接渲染聚合体,数据层负责组装。聚合体接口让 DAO 返回「页面想要的形状」,UI 零组装。

下单参数用 OrderDraft(订单草稿)+ OrderItemDraft(明细草稿)表达「待创建的数据」:

typescript 复制代码
export interface OrderDraft {
  customer: string;
  phone: string;
  address: string;
  items: OrderItemDraft[];
}

export interface OrderItemDraft {
  productName: string;
  price: number;
  quantity: number;
}

为什么分开 Draft 和实体? 草稿没有 id、orderId、subtotal(下单时才计算),语义上区分「输入数据」与「存储数据」------这是分层架构的细节修养。

五、基础查询:订单列表与单订单明细

全部订单(时间倒序)

typescript 复制代码
static async queryOrders(context: common.Context): Promise<Order[]> {
  const store = await OrderDao.getStore(context);
  const predicates = new relationalStore.RdbPredicates(OrderDao.TABLE);
  predicates.orderByDesc('created_time');
  const result = await store.query(predicates);
  return OrderDao.collectOrders(result);
}

某订单的全部明细

typescript 复制代码
static async queryItemsByOrder(context: common.Context, orderId: number): Promise<OrderItem[]> {
  const store = await OrderDao.getStore(context);
  const predicates = new relationalStore.RdbPredicates(OrderDao.ITEM_TABLE);
  predicates.equalTo('order_id', orderId).orderByAsc('id');
  const result = await store.query(predicates);
  return OrderDao.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,「先删明细再删订单」两行代码即可闭环:

typescript 复制代码
// 删除订单:先删明细,再删订单(代码级联)
const itemPredicates = new relationalStore.RdbPredicates(OrderDao.ITEM_TABLE);
itemPredicates.equalTo('order_id', orderId);
await store.delete(itemPredicates);
const orderPredicates = new relationalStore.RdbPredicates(OrderDao.TABLE);
orderPredicates.equalTo('id', orderId);
await store.delete(orderPredicates);

8.2 订单状态枚举:0~4 数字编码的工程意义

status 用数字枚举而非文本,三个理由:

  1. 可过滤可统计WHERE status = 1GROUP BY status 直接数值运算;
  2. 存储紧凑:INTEGER 4 字节 vs TEXT 变长;
  3. 可排序:状态流转 0→1→2→3 天然有序(数字序 = 流程序)。

页面侧用映射函数把数字翻译成文案 + 标签色:

status 文案 标签色
0 待支付 橙色
1 已支付 蓝色
2 已发货 紫色
3 已完成 绿色
4 已取消 灰色
typescript 复制代码
export function statusText(status: number): string {
  const map: string[] = ['待支付', '已支付', '已发货', '已完成', '已取消'];
  return map[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):

typescript 复制代码
// 金额规整:先乘 100 取整再除 100,消除浮点尾差
const total = Math.round(sum * 100) / 100;

九、为什么主从表:一单多品的范式设计

如果只建一张表「订单+商品平铺」,每件商品一行就会重复订单头信息(收货人、地址、电话各重复 N 遍),产生三类问题:

  1. 冗余:同一订单的收货人存 N 份,改地址要改 N 行;
  2. 更新异常:漏改一行就数据不一致;
  3. 语义错乱:「一笔订单」和「一件商品」混在一张表,订单状态、总价无从归属。

主从表(一对多)设计让 orders 每行代表「一个订单实体」,order_item 每行代表「一件商品」,用外键连接------这就是**第三范式(3NF)**的落地:非主属性完全依赖主键,明细只依赖 order_id,不重复订单头信息。

范式维度 单表平铺 主从双表
订单头冗余 N 件商品重复 N 次 0 冗余
改收货人 改 N 行 改 1 行
查询某订单明细 全表过滤 order_id 索引直达
删除订单 逐行删 先明细后订单,两行代码

十、JOIN 联表查询的思路预览

9-1 的「按需加载」先查订单再查明细,N 个订单要 N+1 次查询;JOIN 一次查出全部:

sql 复制代码
SELECT o.*, i.product_name, i.price, i.quantity, i.subtotal
FROM orders o
LEFT JOIN order_item i ON o.id = i.order_id
ORDER BY o.created_time DESC, o.id DESC;

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 KEYUNIQUE 会自动建索引,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 展示的明细顺序就是用户加购的顺序。

相关推荐
城管不管1 小时前
MySQL 慢查询完整排查
java·服务器·jvm·数据库·mysql·spring·面试
XLYcmy1 小时前
PDF论文处理器 - 功能总结
数据库·python·pdf·csv·pymupdf·dify·文本分割
树码小子1 小时前
MySQL 的事务隔离级别(重点面试题)
数据库·mysql
01_ice1 小时前
MySQL表的约束
数据库·sql·mysql
Deepsek20051 小时前
鸿蒙系统回退至4.3操作指南(手机端操作)
经验分享·科技·华为·微信·智能手机·harmonyos
北漂燕郊杨哥2 小时前
Win11 环境下 phpStudy 安装 Redis 最新版,并为 PHP 8.5.9(NTS)配置 redis 扩展
数据库·redis·php
2501_919749032 小时前
华为鸿蒙免费外卖记录APP—小羊外卖
华为·harmonyos·鸿蒙
2603_9651481110 小时前
如何解析JSON数据?API返回的商品信息处理教程
开发语言·数据库·python·自动化·json·api
ltl10 小时前
RocksDB 经典故障排查:L0、compaction 与 write stall
数据库