跳到主要内容

5. 后验函数

Backend Function(二):后验函数 - 数据脱敏与处理

上一篇 先验函数讲了在写入前做校验。这一篇讲后验函数——在数据返回给前端之前做处理。手机号、身份证这些敏感信息不能直接暴露给前端;有些字段需要动态计算(比如从生日算年龄);日期格式需要统一。这些都可以在后验函数里集中处理,前端拿到的数据已经是处理好的。

本节你将学到

  • 什么是后验函数(After 脚本)

  • 如何实现数据脱敏

  • 动态计算字段值

  • 自动补充关联数据

需求

在数据返回给前端之前,自动完成:

  • 敏感数据脱敏(手机号、身份证号隐藏中间位数)

  • 动态计算字段(从生日计算年龄、从消费金额计算等级)

  • 数据格式转换(日期格式化)

  • 关联数据补充(自动查询并填充关联表信息)

数据库查询结果

后验函数(数据处理)

返回给前端(已脱敏、已计算、已格式化)



核心概念

后验函数也叫 After 脚本或 HOOK 后置脚本,在数据操作执行之后、返回给前端之前自动触发。你可以在这里修改返回的数据结构。

特性

说明

触发时机

filtergetOnecreateupdate 等操作之后

执行位置

后端(Lovrabet 服务端环境)

函数命名

afterFilterafterCreateafterGetOne

和在前端做数据处理不同,After 脚本在后端统一执行,前端不需要关心脱敏和格式化逻辑。

实现步骤

AI 辅助开发(推荐)

**为什么 AI 能帮你做后验函数?**没有 rabetbase,后端返回的是原始数据——手机号明文、身份证明文、日期是时间戳。你又改不了后端代码。只能在前端每个组件里做脱敏:10 个组件显示客户信息,脱敏逻辑复制了 10 份。合规说"脱敏规则改一下,中间 3 位改成 4 位"——你全局搜了 47 处,改漏了 2 处,数据泄露了。而且原始数据仍然在接口返回里,F12 一打开全部明文。前端脱敏 = 自欺欺人。更要命的是:每个新来的前端同事不知道哪些字段要脱敏,新组件里又忘了加,又泄露一次。有了 rabetbase:不用在每个组件里复制脱敏逻辑。BaaS 的 afterFilter 在后端统一处理——所有查询返回的数据都经过脱敏,F12 看到的也是脱敏后的。CLI 的 bff new \-\-type HOOK 创建 afterFilter 脚手架,脱敏规则写在一个地方,改一次全局生效。bff push 推送生效。前端组件一行不用改,新来的同事也不会遗漏。

在 Claude Code 中输入:

用 rabetbase CLI 帮我在客户数据集上创建一个 filter 操作的后验函数(HOOK After),对手机号、身份证、银行卡号做脱敏处理,同时计算年龄和客户等级,格式化日期,根据用户角色隐藏内部字段。

AI 会做什么

AI 会使用 rabetbase CLI 自动完成以下工作:

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

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

  3. 编写完整的脱敏与计算逻辑(手机号/身份证/银行卡脱敏、年龄计算、等级计算、日期格式化、权限控制)

  4. 推送到平台生效(rabetbase bff push)完成后,所有通过 filter 查询的客户数据都会自动脱敏和计算。前端代码无需修改。下方是 AI 生成的完整代码。


🔧 手动操作(备选)

方式一:手动 CLI

# 1. 创建脚手架
rabetbase bff new --type HOOK --name afterFilter --function-node after --alias customers --operation-type filter

# 2. 用编辑器打开生成的文件
code .rabetbase/bff/<appCode>/HOOK/customers/filter/after/afterFilter.js

# 3. 推送
rabetbase bff push --yes --type HOOK --name afterFilter


方式二:平台 UI

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

  2. 选择要配置的操作(如 filter 查询操作)

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

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

后验函数配置

步骤 1:完整代码参考

无论用 AI 还是手动,最终创建的后验函数代码如下:

/**
* 客户数据后验函数 - After 脚本
* 在返回客户数据前自动进行脱敏和格式转换
*
* [接口路径] POST /api/{appCode}/customers/filter
* 规则:/api/{应用编码}/{数据集编码}/filter
*
* [平台配置] https://app.lovrabet.com/app/{appCode}/data/dataset/{datasetId}#api-list
* 说明:appCode 为应用编码,datasetId 为数据集 ID(整型数字,从 MCP get_dataset_detail 获取)
*
* [HTTP 请求体参数]
* { "where": {...}, "select": [...] }
*
* [返回数据结构]
* HOOK: 返回修改后的 params 对象(包含 tableData, tableColumns)
*
* @param {Object} params - filter 返回结果
* @param {Array} params.tableData - 数据列表
* @param {Object} params.tableColumns - 列定义
* @param {Object} context - 执行上下文(平台自动注入,调用时无需传递)
* @param {Object} context.userInfo - 当前用户信息
* @returns {Object} 返回修改后的 params
*/
export default async function afterFilter(params, context) {
// 数据集编码映射表
const TABLES = {
customers: "dataset_XXXXXXXXXX", // 数据集: 客户 | 数据表: customers(需替换为实际32位编码)
};
const models = context.client.models;

console.log("[后验函数] 开始处理数据");

// 处理列表数据(After 脚本操作 params.tableData)
if (params.tableData) {
params.tableData = params.tableData.map((item) =>
processCustomer(item, context)
);
}

return params;
}

/**
* 处理单条客户记录
*/
function processCustomer(customer, context) {
if (!customer) return customer;

// ========== 1. 敏感数据脱敏 ==========
if (customer.phone) {
customer.phone = maskPhone(customer.phone);
customer.phoneOriginal = undefined; // 确保原始数据不返回
}

if (customer.idCard) {
customer.idCard = maskIdCard(customer.idCard);
}

if (customer.bankAccount) {
customer.bankAccount = maskBankAccount(customer.bankAccount);
}

// ========== 2. 动态计算字段 ==========
// 计算客户年龄
if (customer.birthday) {
customer.age = calculateAge(customer.birthday);
}

// 计算客户等级(基于消费金额)
if (customer.totalAmount !== undefined) {
customer.level = calculateLevel(customer.totalAmount);
}

// ========== 3. 格式转换 ==========
// 日期格式化
if (customer.createTime) {
customer.createTimeFormatted = formatDate(customer.createTime);
}

if (customer.lastLoginTime) {
customer.lastLoginTimeFormatted = formatDate(customer.lastLoginTime);
}

// ========== 4. 状态显示名称 ==========
if (customer.status) {
customer.statusName = getStatusName(customer.status);
}

// ========== 5. 权限控制:隐藏内部字段 ==========
const user = context.userInfo;
if (user?.role !== "admin") {
delete customer.internalNotes;
delete customer.creditScore;
}

return customer;
}

// ========== 工具函数 ==========

/**
* 手机号脱敏:138****8000
*/
function maskPhone(phone) {
if (!phone || phone.length < 11) return phone;
return phone.replace(/(\d{3})\d{4}(\d{4})/, "$1****$2");
}

/**
* 身份证号脱敏:320***********1234
*/
function maskIdCard(idCard) {
if (!idCard || idCard.length < 18) return idCard;
return idCard.replace(/^(.{3}).*(.{4})$/, "$1***********$2");
}

/**
* 银行卡号脱敏:6222***********1234
*/
function maskBankAccount(account) {
if (!account || account.length < 16) return account;
return account.replace(/^(.{4}).*(.{4})$/, "$1***********$2");
}

/**
* 计算年龄
*/
function calculateAge(birthdayStr) {
const today = new Date();
const birthDate = new Date(birthdayStr);
let age = today.getFullYear() - birthDate.getFullYear();
const monthDiff = today.getMonth() - birthDate.getMonth();

if (
monthDiff < 0 ||
(monthDiff === 0 && today.getDate() < birthDate.getDate())
) {
age--;
}

return age;
}

/**
* 根据消费金额计算客户等级
*/
function calculateLevel(totalAmount) {
if (totalAmount >= 50000) return "钻石会员";
if (totalAmount >= 20000) return "黄金会员";
if (totalAmount >= 5000) return "白银会员";
return "普通会员";
}

/**
* 格式化日期
*/
function formatDate(dateStr) {
const date = new Date(dateStr);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
const hours = String(date.getHours()).padStart(2, "0");
const minutes = String(date.getMinutes()).padStart(2, "0");

return `${year}-<equation>{month}-</equation>{day} <equation>{hours}:</equation>{minutes}`;
}

/**
* 获取状态显示名称
*/
function getStatusName(status) {
const names = {
potential: "潜在客户",
dealed: "已成交",
lost: "已流失",
active: "活跃中",
inactive: "非活跃",
};
return names[status] || status;
}



步骤 2:推送到平台

代码写好后,推送到平台使其生效。如果是 AI 辅助开发,这一步已自动完成。手动推送:

rabetbase bff status --format json
rabetbase bff push --dry-run --type HOOK --name afterFilter --format json
rabetbase bff push --yes --type HOOK --name afterFilter


也可以通过平台 UI 编辑:https://app\.lovrabet\.com/app/\{appCode\}/data/dataset/\{datasetId\}\#api\-list,在「后验函数」卡片中点击「编写」。

步骤 3:前端获取的数据已处理

前端代码无需修改,获取的数据已经是处理后的:

// 查询列表
const result = await lovrabetClient.models.dataset_customers.filter({
where: { status: { $eq: "active" } },
});

// 数据已经过脱敏和计算
console.log(result.tableData[0]);
// 输出:
// {
// id: 123,
// name: "张三",
// phone: "138****8000", // 已脱敏
// age: 28, // 已计算
// level: "黄金会员", // 已计算
// statusName: "活跃中", // 已格式化
// createTimeFormatted: "2024-01-15 10:30" // 已格式化
// }



高级场景

关联数据自动补充

/**
* 订单查询后置脚本 - 自动补充关联数据
* 在返回订单时自动查询并填充客户信息和订单明细
*
* [接口路径] POST /api/{appCode}/orders/filter
*
* [平台配置] https://app.lovrabet.com/app/{appCode}/data/dataset/{datasetId}#api-list
*
* @param {Object} params - filter 返回结果
* @param {Array} params.tableData - 数据列表
* @param {Object} context - 执行上下文(平台自动注入)
* @returns {Object} 返回修改后的 params
*/
export default async function afterFilter(params, context) {
// 数据集编码映射表
const TABLES = {
orders: "dataset_XXXXXXXXXX", // 数据集: 订单 | 数据表: orders
customers: "dataset_YYYYYYYYYY", // 数据集: 客户 | 数据表: customers
orderItems: "dataset_ZZZZZZZZZZ", // 数据集: 订单明细 | 数据表: order_items
};
const models = context.client.models;

if (!params.tableData || params.tableData.length === 0) {
return params;
}

// 批量获取客户ID和订单ID
const customerIds = [
...new Set(params.tableData.map((o) => o.customerId).filter(Boolean)),
];
const orderIds = [
...new Set(params.tableData.map((o) => o.id).filter(Boolean)),
];

// 批量查询客户数据
let customerMap = new Map();
if (customerIds.length > 0) {
const customers = await models[TABLES.customers].filter({
where: { id: { $in: customerIds } },
});
customerMap = new Map(customers.tableData.map((c) => [c.id, c]));
}

// 批量查询订单明细
let itemsMap = new Map();
if (orderIds.length > 0) {
const allItems = await models[TABLES.orderItems].filter({
where: { orderId: { $in: orderIds } },
});
// 按订单ID分组
allItems.tableData.forEach((item) => {
if (!itemsMap.has(item.orderId)) {
itemsMap.set(item.orderId, []);
}
itemsMap.get(item.orderId).push(item);
});
}

// 补充订单数据
params.tableData.forEach((order) => {
// 补充客户信息
const customer = customerMap.get(order.customerId);
if (customer) {
order.customerName = customer.name;
order.customerPhone = customer.phone; // 已脱敏
order.customerLevel = customer.level;
}

// 补充订单明细
order.items = itemsMap.get(order.id) || [];

// 计算订单状态显示
order.statusText = getOrderStatusText(order.status);
});

return params;
}

function getOrderStatusText(status) {
const texts = {
pending: "待付款",
paid: "已付款",
shipped: "已发货",
completed: "已完成",
cancelled: "已取消",
};
return texts[status] || status;
}



多语言数据转换

/**
* 商品查询后置脚本 - 根据用户语言偏好返回对应语言
*
* [接口路径] POST /api/{appCode}/products/filter
*
* [平台配置] https://app.lovrabet.com/app/{appCode}/data/dataset/{datasetId}#api-list
*
* @param {Object} params - filter 返回结果
* @param {Array} params.tableData - 数据列表
* @param {Object} context - 执行上下文(平台自动注入)
* @returns {Object} 返回修改后的 params
*/
export default async function afterFilter(params, context) {
// 获取用户语言偏好(默认中文)
const lang = context.userInfo?.language || "zh-CN";

const processItem = (item) => {
// 根据语言选择字段
if (lang.startsWith("en")) {
item.displayName = item.nameEn || item.name;
item.description = item.descriptionEn || item.description;
} else if (lang.startsWith("zh")) {
item.displayName = item.nameZh || item.name;
item.description = item.descriptionZh || item.description;
} else {
item.displayName = item.name;
item.description = item.description;
}

// 格式化金额(根据地区)
if (item.amount) {
if (lang === "en-US") {
item.amountFormatted = `$${item.amount.toLocaleString()}`;
} else {
item.amountFormatted = `¥${item.amount.toLocaleString()}`;
}
}

return item;
};

if (params.tableData) {
params.tableData = params.tableData.map(processItem);
}

return params;
}



数据聚合与统计

/**
* 订单查询后置脚本 - 为列表数据添加聚合统计
*
* [接口路径] POST /api/{appCode}/orders/filter
*
* [平台配置] https://app.lovrabet.com/app/{appCode}/data/dataset/{datasetId}#api-list
*
* @param {Object} params - filter 返回结果
* @param {Array} params.tableData - 数据列表
* @param {Object} context - 执行上下文(平台自动注入)
* @returns {Object} 返回修改后的 params
*/
export default async function afterFilter(params, context) {
if (!params.tableData || params.tableData.length === 0) {
return params;
}

const items = params.tableData;

// 计算统计信息
const stats = {
total: items.length,
totalAmount: items.reduce((sum, item) => sum + (item.amount || 0), 0),
highValueCount: items.filter((item) => (item.amount || 0) > 10000).length,
statusDistribution: {},
};

// 状态分布
items.forEach((item) => {
const status = item.status || "unknown";
stats.statusDistribution[status] =
(stats.statusDistribution[status] || 0) + 1;
});

// 将统计信息添加到结果中
params.statistics = stats;

return params;
}



树形结构数据构建

/**
* 部门查询后置脚本 - 将平铺的数据转换为树形结构
* 适用于部门、分类等层级数据
*
* [接口路径] POST /api/{appCode}/departments/filter
*
* [平台配置] https://app.lovrabet.com/app/{appCode}/data/dataset/{datasetId}#api-list
*
* @param {Object} params - filter 返回结果
* @param {Array} params.tableData - 数据列表
* @param {Object} context - 执行上下文(平台自动注入)
* @returns {Object} 返回修改后的 params
*/
export default async function afterFilter(params, context) {
if (!params.tableData || params.tableData.length === 0) {
return params;
}

const items = params.tableData;

// 构建树形结构
const buildTree = (parentId = null) => {
return items
.filter((item) => item.parentId === parentId)
.map((item) => ({
...item,
children: buildTree(item.id),
}));
};

const tree = buildTree(null);

// 同时返回平铺数据和树形数据
params.treeData = tree;

return params;
}



知识点整理

After 脚本函数签名

/**
* After 脚本示例
*
* [接口路径] POST /api/{appCode}/{datasetCode}/filter
*
* [平台配置] https://app.lovrabet.com/app/{appCode}/data/dataset/{datasetId}#api-list
*
* @param {Object} params - filter 返回结果
* @param {Array} params.tableData - 数据列表
* @param {Object} params.tableColumns - 列定义
* @param {Object} context - 执行上下文(平台自动注入)
* @returns {Object} 返回修改后的 params
*/
export default async function afterFilter(params, context) {
// 操作 params.tableData
if (params.tableData) {
params.tableData = params.tableData.map((item) => {
// 处理数据
return item;
});
}

return params;
}


params 参数(After 阶段)

字段

类型

说明

tableData

Array

数据列表

tableColumns

Object

列定义

paging

Object

分页信息(filter 接口)

数据处理注意事项

// ✅ 正确:返回处理后的 params
export default async function afterFilter(params, context) {
if (params.tableData) {
params.tableData = params.tableData.map(item => {
item.processed = true;
return item;
});
}

return params; // 必须返回
}

// ❌ 错误:忘记返回数据
export default async function afterFilter(params, context) {
// 处理数据...
// 忘记 return
}



几个好的做法

敏感数据脱敏

// 统一的脱敏函数
function maskSensitive(value, type) {
if (!value) return value;

const masks = {
phone: (v) => v.replace(/(\d{3})\d{4}(\d{4})/, "$1****$2"),
email: (v) => v.replace(/(.{2}).*(@.*)/, "$1***$2"),
idcard: (v) => v.replace(/^(.{3}).*(.{4})$/, "$1***********$2"),
bankcard: (v) => v.replace(/^(.{4}).*(.{4})$/, "$1***********$2"),
};

return masks[type] ? masks[type](value) : value;
}


权限控制

// 根据用户角色决定是否显示某些字段
function filterFieldsByRole(data, user) {
const sensitiveFields = ["internalNotes", "creditScore"];

if (user?.role !== "admin") {
sensitiveFields.forEach((field) => delete data[field]);
}

return data;
}


批量查询优化

// 对于批量查询,使用一次查询获取所有数据
async function enrichWithCustomer(orders, context) {
const TABLES = {
customers: "dataset_XXXXXXXXXX", // 数据集: 客户 | 数据表: customers
};
const models = context.client.models;

// 收集所有客户ID
const customerIds = [
...new Set(orders.map((o) => o.customerId).filter(Boolean)),
];

// 一次性查询所有客户
const customers = await models[TABLES.customers].filter({
where: { id: { $in: customerIds } },
});

// 构建 Map
const customerMap = new Map(customers.tableData.map((c) => [c.id, c]));

// 补充订单数据
orders.forEach((order) => {
const customer = customerMap.get(order.customerId);
if (customer) {
order.customerName = customer.name;
}
});

return orders;
}



常见问题

Q: 后验函数会影响性能吗?

A:会,因为每次查询都会执行。建议:

  1. 避免复杂查询:在后验函数中尽量减少数据库查询

  2. 使用批量查询:处理列表时,使用 filter 一次查询所有关联数据

  3. 添加缓存:对于不常变化的数据使用缓存

// ❌ 不推荐:循环中查询
for (const order of orders) {
const customer = await models[TABLES.customers].findOne({
id: order.customerId,
});
order.customerName = customer.name;
}

// ✅ 推荐:批量查询
const customerIds = orders.map((o) => o.customerId);
const result = await models[TABLES.customers].filter({
where: { id: { $in: customerIds } },
});
const customerMap = new Map(result.tableData.map((c) => [c.id, c]));
orders.forEach((order) => {
order.customerName = customerMap.get(order.customerId)?.name;
});


Q: 先验函数和后验函数可以同时使用吗?

A:可以。它们是独立的,可以同时配置:

函数

执行时机

用途

先验函数

操作前

校验、权限控制、数据补充

后验函数

操作后

脱敏、格式转换、关联数据

Q: 后验函数中出错怎么办?

A:后验函数中的错误不会影响数据操作,但会影响返回结果:

export default async function transform(params, context) {
try {
// 处理逻辑
return processResult(params.result);
} catch (error) {
console.error("[后验函数] 处理失败:", error);
// 返回原始数据,确保前端至少能拿到数据
return params.result;
}
}


Q: 如何在前端判断数据是否经过处理?

A:可以添加标记字段:

function processCustomer(customer) {
customer._processed = true; // 添加处理标记
customer.phone = maskPhone(customer.phone);
return customer;
}

// 前端判断
if (customer._processed) {
console.log("数据已脱敏处理");
}



本节小结

恭喜你学会了后验函数!关键知识点:

知识点

说明

After 脚本

数据操作后自动执行的函数

params\.tableData

列表数据,可直接修改

数据脱敏

手机号、身份证等敏感信息处理

动态计算

年龄、等级等派生字段

最佳实践

  • 敏感数据必须在后端脱敏,不能依赖前端

  • 使用批量查询优化关联数据获取

  • 添加权限控制,根据角色隐藏字段

  • 捕获异常确保数据能正常返回

下一步

相关阅读

核心文档

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

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

  • 错误处理 — BF 错误捕获和处理

进阶主题


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