在这里插入图片描述
在这里插入图片描述

实例:订单与明细(Order)|技术:JOIN 联表查询、事务下单、状态流转

一、三个核心操作的实现

订单数据层的三个核心操作:事务下单(写两表)、JOIN 联表查询(读两表)、状态流转(更新一表)。它们分别对应数据库的「事务」「JOIN」「条件更新」三大能力,本篇文章逐一深入。

二、事务下单:订单 + 明细一次提交

下单是一个「写两张表」的原子操作——订单表和明细表要么都成功要么都回滚(实例 6 已建立事务认知,这里实战升级版:循环写多行明细):

static async createOrder(context: common.Context, draft: OrderDraft): Promise<number> {
  const store = await OrderDao.getStore(context);
  let orderId = -1;
  try {
    await store.beginTransaction();
    // 1. 计算总价
    let total = 0;
    for (const it of draft.items) {
      total += it.price * it.quantity;
    }
    // 2. 插入订单(先插主表拿 id)
    const orderValues: relationalStore.ValuesBucket = {
      order_no: `SO${Date.now()}`,
      status: 0,
      total_amount: Math.round(total * 100) / 100,
      customer: draft.customer,
      phone: draft.phone,
      address: draft.address,
      created_time: Date.now(),
    };
    orderId = await store.insert(OrderDao.TABLE, orderValues);
    // 3. 循环插入明细(引用订单 id)
    for (const it of draft.items) {
      const itemValues: relationalStore.ValuesBucket = {
        order_id: orderId,
        product_name: it.productName,
        price: it.price,
        quantity: it.quantity,
        subtotal: Math.round(it.price * it.quantity * 100) / 100,
      };
      await store.insert(OrderDao.ITEM_TABLE, itemValues);
    }
    await store.commit();
  } catch (e) {
    try {
      await store.rollBack();
    } catch (e2) {
      // 忽略回滚失败
    }
    hilog.error(DOMAIN, TAG, `下单事务失败: ${e}`);
  }
  return orderId;
}

事务内四步

  1. 先算总价:遍历 draft.items 累加 price × quantityMath.round(total * 100) / 100 四舍五入到分;
  2. 先插订单store.insert 返回自增 orderId——明细要引用它,所以订单必须先插;
  3. 循环插明细:每件商品一行,order_id = orderId 关联,subtotal 逐个计算;
  4. commit:全部成功一次性提交;任何一步异常 rollBack——订单和明细同生共死。

返回 orderId 而非 boolean:下单成功后页面可能需要用订单号/跳转详情,返回 id 比 boolean 信息量更大。失败返回 -1(初始值),页面用 orderId === -1 判断失败。

与实例 6 事务的对比:实例 6 是「2 次写」(流水 + 库存),本实例是「1 + N 次写」(订单 + N 条明细)——事务的循环写模式(先插主表、循环插从表)是一对多写入的标准形态。

三、JOIN 联表查询:一次查出订单 + 明细

订单列表需要「每个订单 + 它的全部明细」,有两个方案:

方案 A:N+1 查询(先查订单列表,再逐个查明细)——N 个订单就 N+1 次查询,N 大时性能差。

方案 B:一次 JOIN 查出全部(落地版采用):

static async queryWithItems(context: common.Context, status?: number): Promise<OrderWithItems[]> {
  const store = await OrderDao.getStore(context);
  const where = status === undefined ? '' : ` WHERE o.status = ${status}`;
  const result = await store.querySql(
    `SELECT o.*, i.id AS item_id, i.product_name, i.price AS item_price,
            i.quantity, i.subtotal
     FROM ${OrderDao.TABLE} o
     LEFT JOIN ${OrderDao.ITEM_TABLE} i ON i.order_id = o.id${where}
     ORDER BY o.created_time DESC`
  );
  // ...内存中组装聚合体
}

LEFT JOIN 的结果形态:订单 1 有 3 件明细,JOIN 后产生 3 行结果(每行 = 订单字段 + 一件明细字段);订单 2 有 2 件明细,产生 2 行。总行数 = Σ(每个订单的明细数)。这就是「一对多 JOIN 会把主表行复制成多行」的经典现象。

内存组装:把 JOIN 后的扁平行重新组装成「订单 → 明细数组」的嵌套结构:

const map: Record<number, OrderWithItems> = {};
const orderList: OrderWithItems[] = [];
while (result.goToNextRow()) {
  const orderId = result.getLong(result.getColumnIndex('id'));
  let entry = map[orderId];  // 按订单 id 归组
  if (!entry) {
    entry = {
      order: OrderDao.rowToOrder(result),
      items: [],
    };
    map[orderId] = entry;
    orderList.push(entry);
  }
  const itemId = result.getLong(result.getColumnIndex('item_id'));
  if (itemId > 0) {  // 有明细才添加
    entry.items.push({ ... });
  }
}
result.close();
return orderList;

组装逻辑:用 Record<number, OrderWithItems> 按订单 id 归组——同一订单的多行 JOIN 结果累加到同一个 entry 的 items 数组。JOIN 展平 → 内存归组,这是「联表查询 + 聚合体组装」的标准两步。

item_id > 0 的判空:LEFT JOIN 下,无明细的订单(理论上不应存在)item_id 为 NULL(getLong 得 0),item_id > 0 跳过空明细行。

WHERE 子句的拼接status === undefined ? '' : ' WHERE o.status = ...'——可选参数过滤。注意这里 ${status} 数字内联安全(来自页面筛选选择)。

一次 JOIN vs N+1 的性能对比

方案 查询次数 数据量 适用
N+1 1 + N 次 N 个订单各查一次 明细懒加载(点开才查)
JOIN 1 次 全部明细一次拉回 列表全展示(本实例)

订单列表要在卡片内直接显示全部明细,所以用 JOIN 一次拉回。方案选择看「明细是否需要立即展示」

四、状态流转:条件更新

状态流转是「更新一个字段」的简单操作:

static async updateStatus(context: common.Context, id: number, status: number): Promise<number> {
  const store = await OrderDao.getStore(context);
  const values: relationalStore.ValuesBucket = { status: status };
  const predicates = new relationalStore.RdbPredicates(OrderDao.TABLE);
  predicates.equalTo('id', id);
  return await store.update(values, predicates);
}

只更新 status 字段——ValuesBucket 只放需要改的列,其他列不动。这是「部分更新」的最小实现(实例 11 便签会系统讲部分更新)。

状态统计(GROUP BY status):

static async statusStats(context: common.Context): Promise<Record<string, number>> {
  const store = await OrderDao.getStore(context);
  const result = await store.querySql(
    `SELECT status, COUNT(*) AS cnt FROM ${OrderDao.TABLE} GROUP BY status`
  );
  const map: Record<string, number> = {};
  while (result.goToNextRow()) {
    const status = result.getLong(result.getColumnIndex('status'));
    map[String(status)] = result.getLong(result.getColumnIndex('cnt'));
  }
  result.close();
  return map;
}

返回 Record<string, number>,键是状态数字的字符串——页面 stats[String(idx)] 取值。GROUP BY 状态 → 筛选条徽标,与 8-1 的分类计数同款模式。

五、级联删除:先删明细再删订单

static async deleteOrder(context: common.Context, id: number): Promise<void> {
  const store = await OrderDao.getStore(context);
  const itemPred = new relationalStore.RdbPredicates(OrderDao.ITEM_TABLE);
  itemPred.equalTo('order_id', id);
  await store.delete(itemPred);   // 先删明细
  const orderPred = new relationalStore.RdbPredicates(OrderDao.TABLE);
  orderPred.equalTo('id', id);
  await store.delete(orderPred);  // 再删订单
}

先子后主,避免孤儿明细(与实例 6 deleteProduct 同款)。

六、技术要点对照表

技术点 实现方式 生产价值
事务下单 begin + 插订单 + 循环插明细 + commit 一对多原子写入
JOIN 联表 LEFT JOIN + 内存归组 一次查出主从
聚合体 OrderWithItems 组装 页面零组装
状态流转 部分更新 status 单字段轻量更新
状态统计 GROUP BY status 筛选条徽标
级联删除 先子后主 无孤儿数据

七、常见问题 FAQ

Q1:LEFT JOIN 和 INNER JOIN 怎么选?
A:INNER JOIN 只返回「两表都有匹配」的行(无明细的订单被丢弃);LEFT JOIN 保留主表全部行(无明细的订单也有,明细字段为 NULL)。订单场景明细必有(下单必含商品),两者结果几乎相同;但 LEFT 语义更安全——即使出现异常订单(无明细),也不丢数据。拿不准就用 LEFT

Q2:JOIN 后行数变多了,怎么保证不重复统计?
A:JOIN 的行数 = Σ明细数,这是「一对多展平」的正常现象。只有明细行才统计,订单字段在每行重复——组装聚合体时用 map 按订单 id 归组去重(每订单一个 entry),统计订单数用 orderList.length 而非结果行数。

Q3:订单号 SO${Date.now()} 会不会冲突?
A:Date.now() 是毫秒时间戳,同一毫秒内两次下单才会撞——单用户 App 几乎不可能。生产环境可加随机后缀(SO${Date.now()}${random})或数据库序列。UNIQUE 约束兜底——真冲突会报错进 catch 回滚。

Q4:下单的事务里可以边插边查吗?
A:可以。事务内自身写入对自身可见——如果需要「插入后立即读回」,事务内查询没问题。本实例不需要,直接 commit。

Q5:状态流转的合法性由谁保证?
A:页面(nextStatus)负责业务规则(status < 3 才推进),数据层只做「无条件更新」。更严谨的方案是数据层加条件更新(UPDATE ... WHERE status = ${old},受影响行数为 0 说明状态已被并发改过)——单用户场景页面守卫已够。

Q6:queryWithItems 的 status 参数为什么是可选(undefined 表示全部)?
A:可选参数让「全部查询」和「按状态查询」共用一个方法,页面传 undefined 即全部。相比两个方法(queryAllWithItems / queryByStatusWithItems),一个可选参数更简洁。

八、文章小结

本篇文章深入讲解了订单数据层的三个核心:事务下单(先插主表 + 循环插从表的原子写入)LEFT JOIN 联表查询(展平 + 内存归组组装聚合体)状态流转(部分更新 + GROUP BY 统计)。JOIN 的「展平 → 归组」两步法是联表查询的灵魂,事务的「1 + N 写」模式是一对多写入的标准——这两个模式将在 CRM(实例 12)再次复用。

下一篇(9-4)展示 6 单 × 15 明细的种子数据,让主从列表一开屏就层次分明。

九、查询路径决策与金额汇总

9.1 两条路径的代码级对比

第三节从 SQL 层面对比了 JOIN 与 N+1,这里给出「先查订单、再逐单查明细」的完整代码,与 JOIN 版并排对照:

static async queryWithItemsN1(context: common.Context): Promise<OrderWithItems[]> {
  const store = await OrderDao.getStore(context);
  const orders = await OrderDao.queryOrders(context);                 // 第 1 次:查订单列表
  const list: OrderWithItems[] = [];
  for (const order of orders) {
    const items = await OrderDao.queryItemsByOrderId(context, order.id); // 第 N 次:逐单查明细
    list.push({ order, items });
  }
  return list;
}
维度 N+1(先订单再明细) JOIN(一次拿全)
查询次数 1 + N 次 1 次
明细展示 可懒加载(点开才查) 列表全量返回
内存占用 低(只持有当前单明细) 高(一次持有全部)
适用场景 明细多、卡片不展示明细 明细少、卡片内嵌展示
本实例选择 ✓(每单仅 1~3 条)

决策公式:明细总数 = 订单数 × 平均明细数。总量低于 100 用 JOIN 一劳永逸;过千条且明细不全展示时再考虑 N+1 懒加载。

9.2 金额汇总:SUM 聚合

明细表的总金额对账——每单明细小计之和应等于订单表 total_amount:

SELECT order_id, SUM(subtotal) AS total, COUNT(*) AS item_count
FROM order_item GROUP BY order_id;
static async sumByOrder(context: common.Context): Promise<Record<number, number>> {
  const store = await OrderDao.getStore(context);
  const result = await store.querySql(
    `SELECT order_id, SUM(subtotal) AS total
     FROM ${OrderDao.ITEM_TABLE} GROUP BY order_id`
  );
  const map: Record<number, number> = {};
  while (result.goToNextRow()) {
    const orderId = result.getLong(result.getColumnIndex('order_id'));
    map[orderId] = result.getDouble(result.getColumnIndex('total'));
  }
  result.close();
  return map;
}

SUM 的三个用途:① 下单对账——校验明细小计之和与 total_amount 一致,种子数据回填就靠它;② 销售看板——SELECT SUM(total_amount) FROM orders WHERE status != 4 得到有效销售额;③ 客单价 = SUM / COUNT。取值注意:REAL 列用 getDouble,整数列才用 getLong

9.3 状态统计的结果形态

statusStats 的返回结果(以 9-4 种子数据为例):

status 含义 COUNT(*)
0 待支付 1
1 已支付 1
2 已发货 1
3 已完成 2
4 已取消 1

GROUP BY 的边界:按状态分组只列出「有数据」的状态,0 单的状态不会出现该行——页面徽标要对缺失键兜底(stats[String(idx)] ?? 0),否则徽标显示 undefined。

9.4 补充 FAQ

Q7:N+1 一定比 JOIN 慢吗?
A:不一定。N 很小或明细懒加载时,N+1 反而省内存;明细要全展示且订单量大时,JOIN 把 1+N 次查询压成 1 次,省掉 N-1 次 SQL 解析与往返。真正的反面教材是「先查订单再逐单查、还全量组装」——查询次数与内存双浪费。

Q8:SUM 对账发现不一致怎么办?
A:逐单比对 sumByOrderorders.total_amount,不一致说明下单事务总价算错、或数据被外部修改。先查该单明细的 subtotal 是否有误,再查 total_amount 是否被手改——对账是数据完整性的最后防线,种子数据注入后跑一遍即可验证。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐