API 投影裁掉权限字段,前端过滤返回空表
同一个接口、同一个用户、同一段数据——详情页显示 7 行,嵌入到工作台卡片里却显示「共 0 行」。没有报错,没有网络失败,DevTools 里 API 响应清清楚楚 data: [..7 items..]。这种沉默的数据消失,往往比崩溃更难定位。
现象
- 详情报表页:7 行数据,权限范围「仅看自己」正常展示属于当前用户的 3 行
- 工作台嵌入卡片:同接口、同参数、同用户,表格渲染「共 0 行」
useScopedData(data, user, 'self')返回空数组- 无 console 报错,无 API 4xx/5xx,纯粹的 UI 静默归零
根因
这个坑藏在两层各自「正确」的逻辑之间:
第一层:API 行投影——工作台卡片只订阅了「单号 + 客户 + 金额」三列,后端为了节省传输量,把 row 裁剪成只含这三个字段(+ id)。
第二层:前端范围过滤——useScopedData 判断「仅看自己」时,需要读 writer、assignedWriter、serviceStaff 等十几个身份字段,看当前用户名是否命中其中之一。
当投影层只保留「展示字段」而丢弃「身份字段」后,过滤 hook 拿到的每一行里这些字段全是 undefined。row[f] === userName 永远为 false,整张表归零。
身份字段是过滤器的输入,不是屏幕上要渲染的列。它们不出现在表头里,但必须存在于每一个 row payload 中。只按「用户选择展示哪些列」做投影,等于默默删掉了过滤器赖以工作的原材料。
排查三连问
遇到「同数据两个 UI 面结果不一致」:
- 两个面调用的是同一个接口吗? 是 → 继续;否 → 排查接口/参数差异。
- 两次 API 响应的 row shape 一样吗? 把两份响应的第一行并排
console.log—— 字段集合是否相同? - 前端 transform/filter 逻辑一样吗? 相同 → 差异只可能在 row shape(问题 2 已定位)。
大多数人卡在第 2 步:「接口一样,肯定不是后端问题」——忽视了同一接口对不同调用方做了不同投影。
正解
在投影层维护一份范围身份字段白名单,与展示字段取并集后再裁剪:
// api/widgets/data/route.ts
// 前端 useScopedData 判定 self/department 数据范围时依赖的身份字段。
// 即使当前卡片未订阅这些字段用于展示,也必须回传,否则过滤全部剔除。
const SCOPE_IDENTITY_FIELDS = [
'groupId',
'groupLeader',
'teamLeader',
'writer',
'assignedWriter',
'serviceStaff',
// ...其余身份字段
] as const
// 展示字段 ∪ 身份字段 → 作为最终投影集合
const allVisibleKeys = new Set(columns.map((col) => col.key))
for (const scopeField of SCOPE_IDENTITY_FIELDS) {
allVisibleKeys.add(scopeField) // Set.add 已有的 key 静默忽略,安全
}
const projectedData = data.map((row) => {
const out: Record<string, unknown> = { id: row.id }
for (const key of allVisibleKeys) {
if (key in row) out[key] = row[key]
}
return out
})身份字段「隐形搭车」——表头仍只渲染订阅列,但 payload 里带着过滤所需的原料。
前端 hook 对应的字段集合:
// hooks/use-scoped-data.ts
const ASSIGNEE_FIELDS = [
'writer', 'assignedWriter', 'serviceStaff', 'groupLeader', /* ... */
] as const
if (dataScope === 'self') {
const name = user.name
return data.filter((row) =>
ASSIGNEE_FIELDS.some((f) => row[f] === name)
)
}把 SCOPE_IDENTITY_FIELDS 从共享模块同时 import 到 API 投影层和前端 hook,两侧常量同源——任何一侧漏加字段,TypeScript 会在类型比对时报错,防止静默漂移。
预防
- 常量同源:后端
SCOPE_IDENTITY_FIELDS与前端ASSIGNEE_FIELDS从同一个 shared 模块导出,改一处两处同步 - 合约测试:对任何按用户配置投影的接口,写一个测试——订阅列故意不包含身份字段,断言响应里仍有完整的身份字段集合
- Code Review 触发词:PR 里出现
data.map(row => { ... pick ... })时,grep 一遍前端的useScopedData/useFilteredData,核对 picked 字段是否是这些 hook 读取字段的超集
一句话外卖
API 行投影不能只看「要显示什么」,还要问「谁会用这行数据做进一步过滤」——展示字段和过滤字段的并集,才是安全的最小投影集。