返回博客

前后端双层权限过滤漂移:让两个错误数字对齐并不等于修好了 Bug

某后台管理系统里有一个工作台,工作台上摆着若干卡片,每张卡片是一份报表的快捷入口。用户反馈:「卡片标题显示 7 条记录,点进去表格一行都没有。」

这个问题看起来简单——徽标和表格用的不是同一个数据源,改一行代码就能对齐。但这次,我差点把一个真正的 Bug 变成了一个更难发现的 Bug。

现象

  • 工作台卡片头部的 Tab 徽标显示「报表 A:7 条」
  • 点开同一个卡片,表格区域渲染「共 0 行」
  • API 返回 200,网络请求看起来正常,控制台没有报错
  • 独立打开该报表的详情页 /reports/detail,同一用户看到的是 3 行

三个数字:7、0、3——哪个才是对的?

根因

系统在两处各自做了权限/作用域过滤:

  1. 后端 SQL 层:查询时按 WHERE scope = 'self' 过滤
  2. 前端 hook 层useScopedData(rows, user, dataScope) 拿到 API 返回的数组后再做一次客户端过滤

两套规则在绝大多数情况下行为一致,但隐藏了一个断层:工作台路由/api/dashboards/template-data)在将数据打包进响应时,做了一次字段投影裁剪——把行里的 assigneeIdownerCode 等身份字段给裁掉了,以减小响应体积。

useScopedData 的匹配逻辑依赖这些身份字段做 scope 判定。字段不在,匹配全部失败,7 行全被过滤掉,结果是 0。

徽标读的是过滤前的 tab.data.length(7),表格渲染的是过滤后的 scopedData.length(0),两者出于同一个被裁掉身份字段的数组,只是读取时机不同。

坑:让两个错误数字对齐,不等于修好了 Bug

最直觉的修法是:把徽标也改成读 scopedData.length,这样 badge=0、rows=0,视觉上一致了。

但用户期待的是 3,不是 0。「两个错误数字对齐」只是把真正的 bug 藏得更深——你以为修好了,实际上丢失了 3 行真实数据。

判断修复是否正确的唯一锚点:找到同一份数据在其他入口的正确渲染结果,以此为基准回溯

排查路径

正确的诊断分三步:

第一步:确立用户契约。「工作台卡片显示的报表 A」和「报表 A 详情页」应该展示相同的行。详情页显示 3 行,3 就是正确答案。

第二步:枚举两条数据路径,并逐项对比:

| 路径 | API 端点 | Service 方法 | 前端 hook | 结果 | |------|----------|-------------|----------|------| | 详情页 | /api/reports/[id] | findByReportForDisplay | useScopedData | 3 行 | | 工作台卡片 | /api/dashboards/template-data | 同上 | useScopedData | 0 行 |

API 端点不同,但 Service 方法相同、前端 hook 相同——差异必定在路由层。

第三步:定位路由层的投影。工作台路由在 route.ts:184-198 做了字段裁剪,把 assigneeId 等身份字段踢出了响应。useScopedData 拿到的行没有身份字段,scope 比对全部失败。

正解

修工作台路由的字段投影,保留 useScopedData 依赖的所有身份字段:

// 修复前(路由层裁剪时漏掉了身份字段)
const projected = rows.map(({ title, status, createdAt }) => ({
  title, status, createdAt,
}));
 
// 修复后(保留 scope 判定所需字段)
const projected = rows.map(({ title, status, createdAt, assigneeId, ownerCode }) => ({
  title, status, createdAt, assigneeId, ownerCode,
}));

两条路径现在都返回带完整身份字段的行,useScopedData 能正确匹配,两个入口都显示 3 行。

正解:保留前端 hook 所需的所有身份字段

后端做字段投影(压缩响应体积)是合理的,但投影前必须确认:前端 hook 读哪些字段做 scope/permission 判定?这些字段必须原样透传

反向也成立:修改前端 scope hook 的判断逻辑时,必须 grep 所有向它传数据的 API 端点,确认每个端点的 projection 都包含新增的判定字段。

防御措施

  • 设计时:每个 feature 的 scope 过滤要明确选 SSoT——要么全在后端、要么前端保留并由后端保证字段完整性,两者都做时必须共享同一份字段白名单常量。
  • Code Review:任何改动前端 scope hook 的 PR,必须 grep 后端所有给它喂数据的端点,核查 projection;反向亦然。
  • 集成测试:凡是「同一份数据多个 UI 入口」的契约,写断言:通过所有路径加载同一报表,断言行数相等。

一句话外卖

两个数字不一致,不要急着让它们相等——先找到哪个是正确的,再回溯为什么另一个错了。