主从表:订单与订单明细的事务处理
前面几篇讲的都是单表操作。但实际业务中经常需要同时操作多张表——比如提交订单时,要同时创建订单主表和多条订单明细,而且必须保证要么全部成功,要么全部回滚。
这篇以订单提交为例,介绍如何用 Backend Function 的独立端点实现主子表单的事务性提交。
本节你将学到
- 什么是 Backend Function 独立端点
- 如何使用事务保证数据一致性
- 主子表单的数据提交模式
- 前端如何调用 Backend Function
需求
实现一个订单提交页面,包含主表(客户信息、订单日期、备注)和明细表(多个商品及数量单价)。一次性提交,保证数据一致性,成功后返回订单号。
最终效果:

为什么需要 Backend Function?
普通的 create API 只能操作单张表,无法满足:
- 跨表操作 - 需要同时写入主表和明细表
- 事务保证 - 要么全部成功,要么全部回滚
- 业务逻辑 - 计算金额、校验库存等复杂逻辑
Backend Function 独立端点 可以:
- 在后端一次性完成多表操作
- 使用事务保证数据一致性
- 返回自定义的响应格式
实现步骤
步骤 1:创建 Backend Function
在 Lovrabet 平台上创建独立端点函数:
函数名:submitOrder
/**
* 提交订单 - Backend Function 独立端点
* 创建订单主表和明细表,使用事务保证数据一致性
*
* [接口路径] POST /api/{appCode}/endpoint/submitOrder
*
* [平台配置] https://app.lovrabet.com/app/{appCode}/data/backend-function
* 说明:appCode 为应用编码,创建后可在平台上配置此独立端点
*
* [HTTP 请求体参数]
* {
* "customerName": "客户名称",
* "customerPhone": "客户电话",
* "orderDate": "订单日期",
* "remark": "备注",
* "items": [
* { "productName": "商品A", "quantity": 2, "price": 100 }
* ]
* }
*
* [返回数据结构]
* ENDPOINT: 返回业务数据对象
* {
* "success": true,
* "orderId": "订单ID",
* "totalAmount": "订单总金额",
* "message": "订单创建成功"
* }
*
* @param {Object} params - 请求参数
* @param {string} params.customerName - 客户名称
* @param {string} params.customerPhone - 客户电话
* @param {string} params.orderDate - 订单日期
* @param {string} params.remark - 备注
* @param {Array} params.items - 订单明细
* @param {Object} context - 执行上下文(平台自动注入,调用时无需传递)
* @param {Object} context.userInfo - 当前用户信息
* @param {Object} context.client - 数据库操作入口
* @returns {Object} 返回提交结果
*/
export default async function submitOrder(params, context) {
const { customerName, customerPhone, orderDate, remark, items } = params;
// 数据集编码映射表
const TABLES = {
orders: "dataset_XXXXXXXXXX", // 数据集: 订单 | 数据表: orders(需替换为实际32位编码)
orderItems: "dataset_YYYYYYYYYY", // 数据集: 订单明细 | 数据表: order_items
};
const models = context.client.models;
// 1. 参数校验
if (!items || items.length === 0) {
throw new Error("订单明细不能为空");
}
// 2. 计算订单金额
let totalAmount = 0;
items.forEach((item) => {
const subtotal = item.quantity * item.price;
totalAmount += subtotal;
});
// 3. 使用事务创建订单(主表 + 明细表)
const result = await context.client.db.transaction(async (tx) => {
// 创建主订单(使用 context.client.models 而非 tx.models)
const orderId = await models[TABLES.orders].create({
customerName,
customerPhone,
orderDate,
remark,
totalAmount,
status: "pending",
});
// 创建订单明细
for (const item of items) {
await models[TABLES.orderItems].create({
orderId,
productName: item.productName,
quantity: item.quantity,
price: item.price,
subtotal: item.quantity * item.price,
});
}
return { orderId, totalAmount };
});
return {
success: true,
orderId: result.orderId,
totalAmount: result.totalAmount,
message: "订单创建成功",
};
}
关键点:
context.client.db.transaction()开启事务- 事务中使用
models[TABLES.xxx]而非tx.models[TABLES.xxx] - 事务中任何异常都会回滚
- 返回自定义的结果格式
步骤 2:创建前端表单组件
// src/pages/order-form.tsx
/**
* Title: 新建订单
*/
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { lovrabetClient } from "../api/client";
interface OrderItem {
productName: string;
quantity: number;
price: number;
}
interface OrderForm {
customerName: string;
customerPhone: string;
orderDate: string;
remark: string;
items: OrderItem[];
}
export default function OrderForm() {
const navigate = useNavigate();
const [form, setForm] = useState<OrderForm>({
customerName: "",
customerPhone: "",
orderDate: new Date().toISOString().split("T")[0],
remark: "",
items: [{ productName: "", quantity: 1, price: 0 }],
});
const [submitting, setSubmitting] = useState(false);
// 添加商品
const addItem = () => {
setForm({
...form,
items: [...form.items, { productName: "", quantity: 1, price: 0 }],
});
};
// 删除商品
const removeItem = (index: number) => {
if (form.items.length <= 1) {
alert("至少需要一条商品明细");
return;
}
setForm({
...form,
items: form.items.filter((_, i) => i !== index),
});
};
// 更新商品
const updateItem = (
index: number,
field: keyof OrderItem,
value: string | number
) => {
const newItems = [...form.items];
newItems[index] = { ...newItems[index], [field]: value };
setForm({ ...form, items: newItems });
};
// 计算合计
const totalAmount = form.items.reduce(
(sum, item) => sum + item.quantity * item.price,
0
);
// 表单校验
const validate = (): boolean => {
if (!form.customerName.trim()) {
alert("请输入客户名称");
return false;
}
if (!form.customerPhone.trim()) {
alert("请输入客户电话");
return false;
}
if (!form.orderDate) {
alert("请选择订单日期");
return false;
}
for (let i = 0; i < form.items.length; i++) {
const item = form.items[i];
if (!item.productName.trim()) {
alert(`请输入第 ${i + 1} 行的商品名称`);
return false;
}
if (item.quantity <= 0) {
alert(`第 ${i + 1} 行的商品数量必须大于0`);
return false;
}
if (item.price <= 0) {
alert(`第 ${i + 1} 行的商品单价必须大于0`);
return false;
}
}
return true;
};
// 提交订单
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!validate()) {
return;
}
setSubmitting(true);
try {
// 调用 Backend Function
const result = await lovrabetClient.bff.execute({
scriptName: "submitOrder",
params: form,
});
alert(`订单创建成功!订单号:${result.orderId}`);
navigate("/orders");
} catch (error) {
console.error("创建订单失败:", error);
alert("创建订单失败,请重试");
} finally {
setSubmitting(false);
}
};
return (
<div className="order-form">
<div className="header">
<h1>新建订单</h1>
</div>
<form onSubmit={handleSubmit}>
{/* 主表信息 */}
<div className="section">
<h3>订单信息</h3>
<div className="form-group">
<label>
客户名称 <span className="required">*</span>
</label>
<input
type="text"
value={form.customerName}
onChange={(e) =>
setForm({ ...form, customerName: e.target.value })
}
disabled={submitting}
/>
</div>
<div className="form-group">
<label>
客户电话 <span className="required">*</span>
</label>
<input
type="text"
value={form.customerPhone}
onChange={(e) =>
setForm({ ...form, customerPhone: e.target.value })
}
disabled={submitting}
/>
</div>
<div className="form-group">
<label>
订单日期 <span className="required">*</span>
</label>
<input
type="date"
value={form.orderDate}
onChange={(e) => setForm({ ...form, orderDate: e.target.value })}
disabled={submitting}
/>
</div>
<div className="form-group">
<label>备注</label>
<textarea
value={form.remark}
onChange={(e) => setForm({ ...form, remark: e.target.value })}
rows={3}
disabled={submitting}
/>
</div>
</div>
{/* 明细表 */}
<div className="section">
<h3>商品明细</h3>
<table className="items-table">
<thead>
<tr>
<th>商品名称</th>
<th>数量</th>
<th>单价</th>
<th>小计</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{form.items.map((item, index) => (
<tr key={index}>
<td>
<input
type="text"
value={item.productName}
onChange={(e) =>
updateItem(index, "productName", e.target.value)
}
disabled={submitting}
/>
</td>
<td>
<input
type="number"
value={item.quantity}
onChange={(e) =>
updateItem(index, "quantity", Number(e.target.value))
}
min="1"
disabled={submitting}
/>
</td>
<td>
<input
type="number"
value={item.price}
onChange={(e) =>
updateItem(index, "price", Number(e.target.value))
}
min="0"
step="0.01"
disabled={submitting}
/>
</td>
<td>¥{(item.quantity * item.price).toFixed(2)}</td>
<td>
<button
type="button"
onClick={() => removeItem(index)}
disabled={submitting || form.items.length === 1}
>
删除
</button>
</td>
</tr>
))}
</tbody>
</table>
<button type="button" onClick={addItem} disabled={submitting}>
+ 添加商品
</button>
</div>
{/* 合计 */}
<div className="summary">
<strong>合计:¥{totalAmount.toFixed(2)}</strong>
</div>
{/* 操作按钮 */}
<div className="actions">
<button
type="button"
onClick={() => navigate("/orders")}
disabled={submitting}
>
取消
</button>
<button type="submit" disabled={submitting}>
{submitting ? "提交中..." : "提交订单"}
</button>
</div>
</form>
</div>
);
}
知识点整理
BFF execute API
调用 Backend Function 独立端点:
// 基础用法
const result = await lovrabetClient.bff.execute({
scriptName: "submitOrder",
params: {
/* 参数 */
},
});
// 带类型提示
interface OrderResult {
success: boolean;
orderId: string;
totalAmount: number;
message: string;
}
const result = await lovrabetClient.bff.execute<OrderResult>({
scriptName: "submitOrder",
params: form,
});
console.log(result.orderId);
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
scriptName | string | ✅ | 后端函数名称(与平台配置一致) |
params | Record<string, any> | ❌ | 函数参数 |
返回值:直接返回业务数据(已从 data 字段提取)。
Backend Function 事务语法
// 开启事务
await context.client.db.transaction(async (tx) => {
const models = context.client.models;
// 在这里执行数据库操作
// 正常结束自动提交,抛出异常自动回滚
// 使用 models[TABLES.xxx] 进行数据集操作
await models[TABLES.orders].create({
/* 数据 */
});
// 使用 tx.sql.execute() 执行自定义 SQL
await tx.sql.execute({
sqlCode: "yourSqlCode",
params: {
/* 参数 */
},
});
// 如果这里抛出异常,所有操作都会回滚
if (someError) {
throw new Error("操作失败");
}
});
重要规则:
- 必须使用
await- 事务是异步的 - 数据集操作使用
models[TABLES.xxx]- 使用context.client.models而非tx.models - SQL 操作使用
tx.sql.execute()- 在事务中执行 SQL - 异常自动回滚 - 抛出异常会回滚所有操作
- 不要在事务中执行耗时操作 - 如调用外部 API
几个好的做法
参数校验
export default async function myFunction(params, context) {
// 校验必填参数
if (!params.name) {
throw new Error("name 参数不能为空");
}
// 校验参数类型
if (typeof params.quantity !== "number") {
throw new Error("quantity 必须是数字");
}
// 校验业务规则
if (params.quantity <= 0) {
throw new Error("quantity 必须大于0");
}
// 业务逻辑...
}
返回标准格式
export default async function myFunction(params, context) {
try {
// 业务逻辑...
const result = doSomething();
return {
success: true,
data: result,
message: "操作成功",
};
} catch (error) {
return {
success: false,
message: error.message,
};
}
}
事务中的错误处理
/**
* 提交订单 - 带完整错误处理
*
* [接口路径] POST /api/{appCode}/endpoint/submitOrder
*
* [平台配置] https://app.lovrabet.com/app/{appCode}/data/backend-function
*/
export default async function submitOrder(params, context) {
const TABLES = {
orders: "dataset_XXXXXXXXXX", // 数据集: 订单 | 数据表: orders
orderItems: "dataset_YYYYYYYYYY", // 数据集: 订单明细 | 数据表: order_items
};
const models = context.client.models;
try {
const result = await context.client.db.transaction(async (tx) => {
// 1. 创建主表
const orderId = await models[TABLES.orders].create(params.order);
// 2. 校验明细
if (!params.items || params.items.length === 0) {
throw new Error("订单明细不能为空");
}
// 3. 创建明细
for (const item of params.items) {
await models[TABLES.orderItems].create({
orderId: orderId,
...item,
});
}
return { orderId };
});
return { success: true, orderId: result.orderId };
} catch (error) {
// 事务已自动回滚
return {
success: false,
message: error.message,
};
}
}
常见问题
Q: BFF 调用返回 404?
A: 检查以下几点:
scriptName是否与平台上配置的函数名完全一致- 函数是否已发布
- appCode 是否有访问该函数的权限
Q: 事务执行失败但数据仍然被写入了?
A: 检查是否正确使用 tx:
// ❌ 错误:使用了 tx.models,应该使用 context.client.models
await context.client.db.transaction(async (tx) => {
await tx.models.dataset_xxx.create(); // 错误!
});
// ✅ 正确:使用 context.client.models 和 TABLES 常量
const TABLES = {
orders: "dataset_XXXXXXXXXX",
};
const models = context.client.models;
await context.client.db.transaction(async (tx) => {
await models[TABLES.orders].create({
/* 数据 */
}); // 正确
});
Q: 如何在事务中调用自定义 SQL?
A: 使用 tx.sql.execute():
const TABLES = {
orders: "dataset_XXXXXXXXXX", // 数据集: 订单 | 数据表: orders
};
const models = context.client.models;
await context.client.db.transaction(async (tx) => {
// 调用自定义 SQL
await tx.sql.execute({
sqlCode: "update-stock",
params: { productId: "xxx", quantity: 1 },
});
// 使用数据集 API
await models[TABLES.orders].create({
/* ... */
});
});
Q: 事务超时怎么办?
A:事务执行时间应控制在几秒内:
- 减少循环中的操作次数
- 避免在事务中调用外部 API
- 大数据量操作使用批量 API 或后台任务
本节小结
恭喜你掌握了主子表单的事务处理!关键知识点:
| 知识点 | 说明 |
|---|---|
| Backend Function | 后端函数,处理复杂业务逻辑 |
| 独立端点 | 可被前端直接调用的 BFF 接口 |
| 事务 | 保证多表操作的原子性 |
bff.execute() | 前端调用 Backend Function |
最佳实践
- 涉及多表操作时使用事务
- 事务中使用
models[TABLES.xxx]而非tx.models - 做好参数校验和错误处理
- 返回友好的错误消息
下一步
- 多表关联查询 — 学习如何在查询时关联多张表
相关阅读
核心文档
- BFF API 参考 — Backend Function 完整使用指南
- API 使用指南 — CRUD 操作完整说明
- 错误处理 — 事务错误处理和回滚
进阶主题
- 数据校验:先验函数 — 后端数据校验和权限控制
- 数据脱敏:后验函数 — 后端统一处理数据格式
- Backend Function 调用 SQL — BF 调用自定义 SQL
难度等级:L2 | 预计耗时:45 分钟