事务下单与状态流转:ArkTS 的 JOIN 联表在鸿蒙订单里实战


实例:订单与明细(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;
}
事务内四步:
- 先算总价:遍历 draft.items 累加
price × quantity,Math.round(total * 100) / 100四舍五入到分; - 先插订单:
store.insert返回自增 orderId——明细要引用它,所以订单必须先插; - 循环插明细:每件商品一行,
order_id = orderId关联,subtotal 逐个计算; - 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:逐单比对 sumByOrder 与 orders.total_amount,不一致说明下单事务总价算错、或数据被外部修改。先查该单明细的 subtotal 是否有误,再查 total_amount 是否被手改——对账是数据完整性的最后防线,种子数据注入后跑一遍即可验证。
更多推荐




所有评论(0)