欢迎光临
我们一直在努力

一对多的数据库姻缘: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:双表 + 明细索引

    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 而非 order:order 是 SQL 的保留关键字(ORDER BY),用 orders 复数避开歧义——这是表命名的一个实用技巧。

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

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

    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(明细草稿)表达「待创建的数据」:

    export interface OrderDraft {
    customer: string;
    phone: string;
    address: string;
    items: OrderItemDraft[];
    }

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

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

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

    全部订单(时间倒序):

    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);
    }

    某订单的全部明细:

    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,「先删明细再删订单」两行代码即可闭环:

    // 删除订单:先删明细,再删订单(代码级联)
    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 用数字枚举而非文本,三个理由:

  • 可过滤可统计:WHERE status = 1、GROUP BY status 直接数值运算;
  • 存储紧凑:INTEGER 4 字节 vs TEXT 变长;
  • 可排序:状态流转 0→1→2→3 天然有序(数字序 = 流程序)。
  • 页面侧用映射函数把数字翻译成文案 + 标签色:

    status文案标签色
    0 待支付 橙色
    1 已支付 蓝色
    2 已发货 紫色
    3 已完成 绿色
    4 已取消 灰色

    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):

    // 金额规整:先乘 100 取整再除 100,消除浮点尾差
    const total = 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 一次查出全部:

    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 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 展示的明细顺序就是用户加购的顺序。

    赞(0)
    未经允许不得转载:171主机测评 » 一对多的数据库姻缘:ArkTS 为鸿蒙订单设计外键与明细表
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址