跳到主要内容

5. 先验函数

Backend Function(一):先验函数 - 数据校验与拦截

Backend Function 是 Lovrabet 平台的核心能力之一,它允许你在后端执行自定义业务逻辑。从本节开始,我们将通过三篇文章系统学习 Backend Function 体系:


为什么需要先验函数?

前面几篇实现了列表、详情和新建功能,前端也加了基础校验。但有些校验必须在后端做——比如手机号唯一性、复杂业务规则、数据权限控制,这些前端绕不过去的。

这篇介绍 先验函数(Before 脚本):在数据操作执行前自动触发,校验不通过就拦截,通过了才写入数据库。

本节你将学到

  • 什么是先验函数(Before 脚本)

  • 如何配置先验函数拦截数据操作

  • 常见的校验场景实现

  • 先验函数和前端校验的区别


需求

在数据写入数据库之前,自动执行业务规则校验:

  • 防止重复数据(手机号、邮箱唯一性)

  • 业务规则校验(如年龄限制)

  • 数据权限控制(如只能操作本部门数据)

  • 自动补充数据(如设置所属部门)

整个流程大致是:

用户提交表单

前端校验(基础格式)

先验函数(业务规则校验)

校验通过 → 写入数据库
校验失败 → 返回错误信息


核心概念

先验函数也叫 Before 脚本或 HOOK 前置脚本,在数据操作执行前自动触发。在函数里抛出异常就能阻止操作执行,适合做数据校验、权限控制、数据补充。

特性

说明

触发时机

createupdatedeletefilter 等操作前

执行位置

后端(Lovrabet 服务端环境)

阻止能力

抛出异常即阻止操作执行

函数命名

beforeCreatebeforeUpdatebeforeFilter


实现步骤

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 自动完成以下工作:

  1. 查询客户数据集结构(rabetbase dataset detail

  2. 创建 HOOK 脚手架(rabetbase bff new \-\-type HOOK

  3. 编写完整的校验逻辑(手机号/邮箱唯一性、年龄限制、部门补充)

  4. 推送到平台生效(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

  1. 打开数据集的 API 列表页面:https://app\.lovrabet\.com/app/\{appCode\}/data/dataset/\{datasetId\}\#api\-list

  2. 选择要配置的操作(如 create

  3. 在「业务逻辑扩展」区域,找到「先验函数」卡片

  4. 点击「编写」按钮,粘贴代码并保存


步骤 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 内容

create

提交的表单数据 \{ name: \&\#34;xxx\&\#34;, phone: \&\#34;xxx\&\#34; \}

update

更新的数据 \{ id: 123, name: \&\#34;new\&\#34; \}

delete

删除条件 \{ id: 123 \}

filter

查询参数 \{ where: \{\.\.\.\}, select: \[\.\.\.\] \}

context 对象

字段

类型

说明

userInfo

Object

当前登录用户信息(idusernametenantCode

client

Object

数据库操作入口,通过 models 访问数据集

appCode

String

当前应用编码

数据集操作方式

// 定义数据集映射(函数开头定义)
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:每次数据操作都会执行先验函数,所以:

  1. 避免耗时操作:不要在先验函数中调用慢速外部 API

  2. 减少数据库查询:合并多个查询,使用索引

  3. 使用缓存:对于不常变化的数据(如配置信息)可以使用缓存

// ❌ 不推荐:多次查询
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 脚本

数据操作前自动执行的校验函数

throw Error\(\)

抛出异常阻止操作执行

context\.userInfo

获取当前登录用户信息

models\[TABLES\.xxx\]

在 BF 中访问数据集

最佳实践

  • 前端校验做格式检查,先验函数做业务规则

  • 避免在先验函数中执行耗时操作

  • 使用批量查询优化性能

  • 添加日志便于调试


下一步

  • 数据保存后处理 — 学习数据脱敏和格式转换

相关阅读

核心文档

  • BFF API 参考 — Backend Function 完整使用指南

  • SDK 配置 — 环境配置和认证设置

  • 语法糖 — safe、sqlSafe 等便捷函数

进阶主题

  • 数据脱敏:后验函数 — 后端统一处理数据格式

  • 主子表单:事务处理 — BF 独立端点处理复杂业务

  • 销售报表:自定义 SQL — 自定义 SQL 查询


难度等级:L2 | 预计耗时:35 分钟