Backend Function(二):后验函数 - 数据脱敏与处理
上一篇讲了先验函数——在写入前做校验。这一篇讲后验函数——在数据返回给前端之前做处理。
手机号、身份证这些敏感信息不能直接暴露给前端;有些字段需要动态计算(比如从生日算年龄);日期格式需要统一。这些都可以在后验函数里集中处理,前端拿到的数据已经是处理好的。
本节你将学到
- 什么是后验函数(After 脚本)
- 如何实现数据脱敏
- 动态计算字段值
- 自动补充关联数据
需求
在数据返回给前端之前,自动完成:
- 敏感数据脱敏(手机号、身份证号隐藏中间位数)
- 动态计算字段(从生日计算年龄、从消费金额计算等级)
- 数据格式转换(日期格式化)
- 关联数据补充(自动查询并填充关联表信息)
数据库查询结果
↓
后验函数(数据处理)
↓
返回给前端(已脱敏、已计算、已格式化)
核心概念
后验函数也叫 After 脚本或 HOOK 后置脚本,在数据操作执行之后、返回给前端之前自动触发。你可以在这里修改返回的数据结构。
| 特性 | 说明 |
|---|---|
| 触发时机 | filter、getOne、create、update 等操作之后 |
| 执行位置 | 后端(Lovrabet 服务端环境) |
| 函数命名 | afterFilter、afterCreate、afterGetOne 等 |
和在前端做数据处理不同,After 脚本在后端统一执行,前端不需要关心脱敏和格式化逻辑。
实现步骤
步骤 1:在平台上创建后验函数
在 Lovrabet 平台上创建 HOOK 脚本:
文件命名:customers_afterFilter.js(按表名_操作类型命名)
平台配置地址:https://app.lovrabet.com/app/{appCode}/data/dataset/{datasetId}#api-list
说明:在数据集的 API 列表页面,为 filter 操作的 After 阶段配置该脚本。
/**
* 客户数据后验函数 - 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}-${month}-${day} ${hours}:${minutes}`;
}
/**
* 获取状态显示名称
*/
function getStatusName(status) {
const names = {
potential: "潜在客户",
dealed: "已成交",
lost: "已流失",
active: "活跃中",
inactive: "非活跃",
};
return names[status] || status;
}
步骤 2:配置后验函数绑定
在 Lovrabet 平台上配置 After 脚本:
平台配置地址:https://app.lovrabet.com/app/{appCode}/data/dataset/{datasetId}#api-list

- 进入数据集的 API 列表页面
- 选择要配置的操作(如
filter查询操作) - 在"业务逻辑扩展"区域,找到"后验函数"卡片
- 点击"编写"按钮,打开脚本编辑器
- 编写 After 脚本代码并保存
说明:
- 每个操作(filter/getOne/create/update)都可以独立配置 After 脚本
- After 脚本在操作执行后、返回数据前触发,可用于数据脱敏、格式转换等
- 状态显示"暂无"表示未配置,配置后会显示函数名
步骤 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:会,因为每次查询都会执行。建议:
- 避免复杂查询:在后验函数中尽量减少数据库查询
- 使用批量查询:处理列表时,使用
filter一次查询所有关联数据 - 添加缓存:对于不常变化的数据使用缓存
// ❌ 不推荐:循环中查询
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 | 列表数据,可直接修改 |
| 数据脱敏 | 手机号、身份证等敏感信息处理 |
| 动态计算 | 年龄、等级等派生字段 |
最佳实践
- 敏感数据必须在后端脱敏,不能依赖前端
- 使用批量查询优化关联数据获取
- 添加权限控制,根据角色隐藏字段
- 捕获异常确保数据能正常返回
下一步
- 复杂统计 SQL — 学习在 Backend Function 中调用 SQL
相关阅读
核心文档
- BFF API 参考 — Backend Function 完整使用指南
- 语法糖 — safe、sqlSafe 等便捷函数
- 错误处理 — BF 错误捕获和处理
进阶主题
- 数据校验:先验函数 — 后端数据校验和权限控制
- 主子表单:事务处理 — BF 独立端点处理复杂业务
- 销售报表:自定义 SQL — 复杂统计使用 SQL 查询
难度等级:L2 | 预计耗时:40 分钟