返回博客

API 投影裁掉权限字段,前端过滤返回空表

同一个接口、同一个用户、同一段数据——详情页显示 7 行,嵌入到工作台卡片里却显示「共 0 行」。没有报错,没有网络失败,DevTools 里 API 响应清清楚楚 data: [..7 items..]。这种沉默的数据消失,往往比崩溃更难定位。

现象

  • 详情报表页:7 行数据,权限范围「仅看自己」正常展示属于当前用户的 3 行
  • 工作台嵌入卡片:同接口、同参数、同用户,表格渲染「共 0 行」
  • useScopedData(data, user, 'self') 返回空数组
  • 无 console 报错,无 API 4xx/5xx,纯粹的 UI 静默归零

根因

这个坑藏在两层各自「正确」的逻辑之间:

第一层:API 行投影——工作台卡片只订阅了「单号 + 客户 + 金额」三列,后端为了节省传输量,把 row 裁剪成只含这三个字段(+ id)。

第二层:前端范围过滤——useScopedData 判断「仅看自己」时,需要读 writerassignedWriterserviceStaff 等十几个身份字段,看当前用户名是否命中其中之一。

当投影层只保留「展示字段」而丢弃「身份字段」后,过滤 hook 拿到的每一行里这些字段全是 undefinedrow[f] === userName 永远为 false,整张表归零。

坑:展示字段 ≠ 过滤字段,两者集合不相交时数据静默消失

身份字段是过滤器的输入,不是屏幕上要渲染的列。它们不出现在表头里,但必须存在于每一个 row payload 中。只按「用户选择展示哪些列」做投影,等于默默删掉了过滤器赖以工作的原材料。

排查三连问

遇到「同数据两个 UI 面结果不一致」:

  1. 两个面调用的是同一个接口吗? 是 → 继续;否 → 排查接口/参数差异。
  2. 两次 API 响应的 row shape 一样吗? 把两份响应的第一行并排 console.log —— 字段集合是否相同?
  3. 前端 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 行投影不能只看「要显示什么」,还要问「谁会用这行数据做进一步过滤」——展示字段和过滤字段的并集,才是安全的最小投影集。