5. 先验函数
Backend Function(一):先验函数 - 数据校验与拦截
Backend Function 是 Lovrabet 平台的核心能力之一,它允许你在后端执行自定义业务逻辑。从本节开始,我们将通过三篇文章系统学习 Backend Function 体系:
-
本文(一):先验函数 — 在数据操作前自动校验和拦截
-
下一篇(二):后验函数 — 在数据操作后自动处理和转换
-
下一篇(三):独立接口 — 自定义 API 端点和复杂 SQL 调用
为什么需要先验函数?
前面几篇实现了列表、详情和新建功能,前端也加了基础校验。但有些校验必须在后端做——比如手机号唯一性、复杂业务规则、数据权限控制,这些前端绕不过去的。
这篇介绍 先验函数(Before 脚本):在数据操作执行前自动触发,校验不通过就拦截,通过了才写入数据库。
本节你将学到
-
什么是先验函数(Before 脚本)
-
如何配置先验函数拦截数据操作
-
常见的校验场景实现
-
先验函数和前端校验的区别
需求
在数据写入数据库之前,自动执行业务规则校验:
-
防止重复数据(手机号、邮箱唯一性)
-
业务规则校验(如年龄限制)
-
数据权限控制(如只能操作本部门数据)
-
自动补充数据(如设置所属部门)
整个流程大致是:
用户提交表单
↓
前端校验(基础格式)
↓
先验函数(业务规则校验)
↓
校验通过 → 写入数据库
校验失败 → 返回错误信息
核心概念
先验函数也叫 Before 脚本或 HOOK 前置脚本,在数据操作执行前自动触发。在函数里抛出异常就能阻止操作执行,适合做数据校验、权限控制、数据补充。
特性 | 说明 |
触发时机 |
|
执行位置 | 后端(Lovrabet 服务端环境) |
阻止能力 | 抛出异常即阻止操作执行 |
函数命名 |
|
实现步骤
AI 辅助开发(推荐)
**为什么 AI 能帮你做先验函数?**没有 rabetbase,后端校验意味着你得能改后端代码。但 API 是平台的,你改不了。你的选项:1) 建一个代理服务器拦截所有请求加校验——等于搭了一个中间层,认证转发、请求转发、错误处理全要自己写,又搞了 3 天;2) 提工单让平台团队加校验——排到下个迭代,你等了两周;3) 只靠前端校验——有人用 Postman 绕过前端直接调 API,重复数据进去了。选了方案 1,搭代理服务器又花了 3 天,上线后性能还下降——所有请求多经过一层。一个"手机号不能重复",变成了基础设施项目。有了 rabetbase:不用改后端代码、不用搭代理服务器。BaaS 的 HOOK 独立系统在后端自动拦截——前端表单、API 调用、Postman 都绕不过去。CLI 的 bff new \-\-type HOOK 创建 beforeCreate 脚手架(参数结构就绪),Skill 指导 AI 写校验逻辑。bff push 推送生效。前端代码一行不改,性能零影响。
在 Claude Code 中输入:
用 rabetbase CLI 帮我在客户数据集上创建一个 create 操作的先验函数(HOOK Before),校验手机号和邮箱唯一性,年龄必须满 18 岁,自动补充所属部门。
AI 会做什么
AI 会使用 rabetbase CLI 自动完成以下工作:
-
查询客户数据集结构(
rabetbase dataset detail) -
创建 HOOK 脚手架(
rabetbase bff new \-\-type HOOK) -
编写完整的校验逻辑(手机号/邮箱唯一性、年龄限制、部门补充)
-
推送到平台生效(
rabetbase bff push)
完成后先验函数立即生效,前端代码无需任何修改。下方是 AI 生成的完整代码,供你理解和后续维护。
🔧 手动操作(备选)
方式一:手动 CLI
# 1. 创建脚手架
rabetbase bff new --type HOOK --name beforeCreate --function-node before --alias customers --operation-type create
# 2. 用编辑器打开生成的文件
code .rabetbase/bff/<appCode>/HOOK/customers/create/before/beforeCreate.js
# 3. 推送
rabetbase bff push --yes --type HOOK --name beforeCreate
方式二:平台 UI
-
打开数据集的 API 列表页面:
https://app\.lovrabet\.com/app/\{appCode\}/data/dataset/\{datasetId\}\#api\-list -
选择要配置的操作(如
create) -
在「业务逻辑扩展」区域,找到「先验函数」卡片
-
点击「编写」按钮,粘贴代码并保存
步骤 1:完整代码参考
无论用 AI 还是手动,最终创建的先验函数代码如下:
/**
* 客户创建先验函数 - 数据唯一性校验和业务规则验证
*
* [接口路径] POST /api/{appCode}/customers/create
* 规则:/api/{应用编码}/{数据集编码}/create
*
* [平台配置] https://app.lovrabet.com/app/{appCode}/data/dataset/{datasetId}#api-list
* 说明:appCode 为应用编码,datasetId 为数据集 ID(整型数字,从 MCP get_dataset_detail 获取)
*
* [HTTP 请求体参数]
* { "name": "客户名称", "phone": "手机号", "email": "邮箱" }
*
* [返回数据结构]
* HOOK: 返回修改后的 params 对象
*
* @param {Object} params - 请求参数
* @param {Object} context - 执行上下文(平台自动注入,调用时无需传递)
* @param {Object} context.userInfo - 当前用户信息
* @param {Object} context.client - 数据库操作入口
* @returns {Object} 返回修改后的 params
*/
export default async function beforeCreate(params, context) {
// 数据集编码映射表
// 注释格式:数据集: {数据集名称} | 数据表: {数据表名称}
const TABLES = {
customers: "dataset_XXXXXXXXXX", // 数据集: 客户 | 数据表: customers(需替换为实际32位编码)
};
const models = context.client.models;
console.log("[先验函数] 开始校验客户创建:", params);
// ========== 校验 1:手机号唯一性 ==========
if (params.phone) {
// 数据集: 客户 | 数据表: customers
const existingPhone = await models[TABLES.customers].filter({
where: {
phone: { $eq: params.phone },
...(params.id ? { id: { $ne: params.id } } : {}), // 排除当前记录(更新时)
},
});
if (existingPhone.tableData && existingPhone.tableData.length > 0) {
throw new Error("手机号已被使用,请使用其他手机号");
}
}
// ========== 校验 2:邮箱唯一性 ==========
if (params.email) {
// 数据集: 客户 | 数据表: customers
const existingEmail = await models[TABLES.customers].filter({
where: {
email: { $eq: params.email },
...(params.id ? { id: { $ne: params.id } } : {}),
},
});
if (existingEmail.tableData && existingEmail.tableData.length > 0) {
throw new Error("邮箱已被使用,请使用其他邮箱");
}
}
// ========== 校验 3:年龄必须满18岁 ==========
if (params.birthday) {
const today = new Date();
const birthDate = new Date(params.birthday);
const age = today.getFullYear() - birthDate.getFullYear();
const monthDiff = today.getMonth() - birthDate.getMonth();
if (
monthDiff < 0 ||
(monthDiff === 0 && today.getDate() < birthDate.getDate())
) {
age--;
}
if (age < 18) {
throw new Error("客户年龄必须满18岁");
}
}
// ========== 自动补充:设置所属部门 ==========
if (!params.department && context.userInfo?.department) {
params.department = context.userInfo.department;
}
// 系统字段(create_by, create_time 等)由平台自动维护,无需手动设置
return params;
}
关键修改点:
-
使用
models\[TABLES\.customers\]访问数据集 -
context\.userInfo替代context\.currentUser -
filter返回\{ tableData, paging, tableColumns \}结构 -
系统字段(create_by、create_time)由平台自动维护
步骤 2:推送到平台
代码写好后,推送到平台使其生效。如果是 AI 辅助开发,这一步已自动完成。
手动推送:
# 先预览变更
rabetbase bff status --format json
# dry-run 确认推送内容
rabetbase bff push --type HOOK --name beforeCreate --dry-run --format json
# 正式推送
rabetbase bff push --yes --type HOOK --name beforeCreate
也可以通过平台 UI 编辑:https://app\.lovrabet\.com/app/\{appCode\}/data/dataset/\{datasetId\}\#api\-list,在「先验函数」卡片中点击「编写」。
步骤 3:前端代码无需修改
前端代码保持不变,SDK 会自动触发先验函数:
// 前端提交表单
const result = await lovrabetClient.models.dataset_customers.create({
name: "张三",
phone: "13800138000",
email: "zhangsan@example.com",
birthday: "1990-01-01",
});
// 如果先验函数校验失败,会抛出异常
try {
const result = await lovrabetClient.models.dataset_customers.create(data);
console.log("创建成功:", result);
} catch (error) {
console.error("创建失败:", error.message);
// 输出:手机号已被使用,请使用其他手机号
}
高级场景
以下场景同样可以用 AI 辅助完成——在 Claude Code 中描述需求,AI 会自动创建对应的 HOOK 并推送。代码参考如下:
多租户数据隔离
/**
* 多租户数据隔离先验函数(Before Create)
* 确保用户只能操作自己租户的数据
*
* [接口路径] POST /api/{appCode}/customers/create
* 规则:/api/{应用编码}/{数据集编码}/create
*
* [平台配置] https://app.lovrabet.com/app/{appCode}/data/dataset/{datasetId}#api-list
* 说明:appCode 为应用编码,datasetId 为数据集 ID(整型数字,从 MCP get_dataset_detail 获取)
*
* [HTTP 请求体参数]
* { "name": "客户名称", ... }
*
* [返回数据结构]
* HOOK: 返回修改后的 params 对象
*
* @param {Object} params - 请求参数
* @param {Object} context - 执行上下文(平台自动注入,调用时无需传递)
* @param {Object} context.userInfo - 当前用户信息
* @returns {Object} 返回修改后的 params
*/
export default async function beforeCreate(params, context) {
const TABLES = {
customers: "dataset_XXXXXXXXXX", // 数据集: 客户 | 数据表: customers(需替换为实际32位编码)
};
const models = context.client.models;
// 获取用户所属租户(从 userInfo 获取)
const userTenantId = context.userInfo?.tenantCode;
if (!userTenantId) {
throw new Error("用户未分配租户,无法操作");
}
// 创建操作:自动设置租户ID
params.tenant_id = userTenantId;
return params;
}
数据状态流转校验
/**
* 客户状态流转校验(Before Update)
* 限制状态只能按指定流程变更
*
* [接口路径] POST /api/{appCode}/customers/update
* 规则:/api/{应用编码}/{数据集编码}/update
*
* [平台配置] https://app.lovrabet.com/app/{appCode}/data/dataset/{datasetId}#api-list
* 说明:appCode 为应用编码,datasetId 为数据集 ID(整型数字,从 MCP get_dataset_detail 获取)
*
* [HTTP 请求体参数]
* { "id": 123, "status": "active" }
*
* [返回数据结构]
* HOOK: 返回修改后的 params 对象
*
* @param {Object} params - 请求参数
* @param {string} params.id - 记录ID
* @param {string} params.status - 新状态
* @param {Object} context - 执行上下文(平台自动注入,调用时无需传递)
* @returns {Object} 返回修改后的 params
*/
export default async function beforeUpdate(params, context) {
const TABLES = {
customers: "dataset_XXXXXXXXXX", // 数据集: 客户 | 数据表: customers(需替换为实际32位编码)
};
const models = context.client.models;
if (!params.status || !params.id) {
return params;
}
// 获取当前记录
// 数据集: 客户 | 数据表: customers
const current = await models[TABLES.customers].findOne({ id: params.id });
if (!current) {
throw new Error("记录不存在");
}
const currentStatus = current.status;
const newStatus = params.status;
// 定义允许的状态流转
const allowedTransitions = {
potential: ["dealed", "lost"], // 潜在 → 成交/流失
dealed: ["active", "inactive"], // 成交 → 活跃/非活跃
lost: ["potential"], // 流失 → 潜在(重新激活)
active: ["inactive", "completed"], // 活跃 → 非活跃/完成
inactive: ["active"], // 非活跃 → 活跃
};
const allowed = allowedTransitions[currentStatus] || [];
if (!allowed.includes(newStatus)) {
throw new Error(`状态不允许从"<equation>{currentStatus}"变更为"</equation>{newStatus}"`);
}
return params;
}
function getStatusName(status) {
const names = {
potential: "潜在",
dealed: "成交",
lost: "流失",
active: "活跃",
inactive: "非活跃",
completed: "完成",
};
return names[status] || status;
}
动态获取外部数据进行校验
/**
* 校验客户信用额度
* 通过外部 API 获取客户信用信息
*/
export default async function validateCreditLimit(params, context) {
const { data } = params;
if (!data.customerId) {
return { data };
}
// 调用外部服务查询客户信用
const creditInfo = await fetchCreditInfo(data.customerId);
if (creditInfo.blacklisted) {
throw new Error("该客户已列入黑名单,无法创建订单");
}
const orderAmount = data.totalAmount || 0;
const availableCredit = creditInfo.creditLimit - creditInfo.usedCredit;
if (orderAmount > availableCredit) {
throw new Error(
`超出信用额度。可用额度:¥${availableCredit},订单金额:¥${orderAmount}`
);
}
return { data };
}
/**
* 模拟调用外部信用服务
* 实际使用时替换为真实的 API 调用
*/
async function fetchCreditInfo(customerId) {
// 这里可以是 HTTP 请求或其他方式调用外部服务
// 例如:fetch('https://api.credit-service.com/check/' + customerId)
return {
blacklisted: false,
creditLimit: 100000,
usedCredit: 20000,
};
}
知识点整理
Before 脚本函数签名
export default async function beforeCreate(params, context) {
// params - 请求参数(根据接口类型不同)
// context - 执行上下文(平台自动注入)
// 数据库操作
const models = context.client.models;
// 返回修改后的 params
return params;
}
params 参数(根据操作类型不同)
操作类型 | params 内容 |
| 提交的表单数据 |
| 更新的数据 |
| 删除条件 |
| 查询参数 |
context 对象
字段 | 类型 | 说明 |
|
| 当前登录用户信息( |
|
| 数据库操作入口,通过 |
|
| 当前应用编码 |
数据集操作方式
// 定义数据集映射(函数开头定义)
const TABLES = {
customers: "dataset_XXXXXXXXXX", // 32位数据集编码
};
const models = context.client.models;
// 查询单条
const record = await models[TABLES.customers].findOne({ id: 123 });
// 查询列表
const result = await models[TABLES.customers].filter({
where: { status: { $eq: "active" } },
});
// result.tableData 是数据数组
// 创建
const id = await models[TABLES.customers].create({
name: "张三",
phone: "13800138000",
});
// 更新
await models[TABLES.customers].update({
id: 123,
name: "新名字",
});
阻止操作的方式
// 抛出异常会阻止操作并返回错误消息
throw new Error('错误消息');
// 正常返回会继续执行
return params;
常见问题
Q: 先验函数和前端校验有什么区别?
A:两者都应该有,各司其职:
校验位置 | 作用 | 优势 |
前端校验 | 用户体验 | 立即反馈,减少无效请求 |
先验函数 | 数据安全 | 必须执行,无法绕过 |
推荐做法:前端做基础格式校验(如手机号格式),先验函数做业务规则校验(如手机号唯一性)。
Q: 先验函数可以修改数据吗?
A:可以。返回 \{ data: newData \} 会影响后续操作:
export default async function validate(params, context) {
const { data } = params;
// 自动补充字段
if (!data.createTime) {
data.createTime = new Date().toISOString();
}
// 返回修改后的数据
return { data };
}
Q: 如何调试先验函数?
A:使用 console\.log 输出日志,然后通过 CLI 查看日志:
rabetbase logs show --format json
Q: 先验函数会影响性能吗?
A:每次数据操作都会执行先验函数,所以:
-
避免耗时操作:不要在先验函数中调用慢速外部 API
-
减少数据库查询:合并多个查询,使用索引
-
使用缓存:对于不常变化的数据(如配置信息)可以使用缓存
// ❌ 不推荐:多次查询
const TABLES = {
customers: "dataset_XXXXXXXXXX",
};
const models = context.client.models;
const existing1 = await models[TABLES.customers].filter({
where: { phone: { $eq: "13800138000" } },
});
const existing2 = await models[TABLES.customers].filter({
where: { email: { $eq: "test@example.com" } },
});
// ✅ 推荐:一次查询
const existing = await models[TABLES.customers].filter({
where: {
$or: [
{ phone: { $eq: "13800138000" } },
{ email: { $eq: "test@example.com" } },
],
},
});
本节小结
恭喜你学会了先验函数!关键知识点:
知识点 | 说明 |
Before 脚本 | 数据操作前自动执行的校验函数 |
| 抛出异常阻止操作执行 |
| 获取当前登录用户信息 |
| 在 BF 中访问数据集 |
最佳实践
-
前端校验做格式检查,先验函数做业务规则
-
避免在先验函数中执行耗时操作
-
使用批量查询优化性能
-
添加日志便于调试
下一步
- 数据保存后处理 — 学习数据脱敏和格式转换
相关阅读
核心文档
-
BFF API 参考 — Backend Function 完整使用指南
-
SDK 配置 — 环境配置和认证设置
-
语法糖 — safe、sqlSafe 等便捷函数
进阶主题
-
数据脱敏:后验函数 — 后端统一处理数据格式
-
主子表单:事务处理 — BF 独立端点处理复杂业务
-
销售报表:自定义 SQL — 自定义 SQL 查询
难度等级:L2 | 预计耗时:35 分钟