React 管理后台实战 · 列表里突然出现一堆空白行?PIPL 擦除后前端怎么处理才不露馅

React 管理后台实战 · 列表里突然出现一堆空白行?PIPL 擦除后前端怎么处理才不露馅

各位看官,管理后台里但凡涉及个人手机号、微信这类敏感信息,迟早要面对一件事:用户(或合规要求)要你按 PIPL 把一条记录里的个人数据彻底擦除 。这活儿后端要做得干净(我之前在《敏感数据防泄露 PII 脱敏与审计日志》里写过后端怎么脱敏、怎么留审计),但前端要是不配合,照样露馅------擦除之后列表里全是空白行、详情还弹得出明文、编辑按钮还能点,那擦了个寂寞。

我这个项目就踩过这么一个真实坑:擦除完,普通列表里凭空多出来一堆"姓名空、电话空、微信空"的幽灵行,运营一眼就看出来了------"这什么情况?数据怎么没了?"其实数据是被合规清掉了,但前端没把擦除态当回事。今天就聊聊前端这边怎么把 PIPL 擦除接得严丝合缝。

一、擦除不是删库,是"打码 + 置空 + 留痕"

先把概念掰清楚,免得后面代码看晕。PIPL 要的"擦除"和"删除"是两码事:

动作 数据还在吗 个人字段 审计 适用场景
软删除 原样 误删可恢复、保留业务关联
硬删除 不在 --- 彻底不要这条
PIPL 擦除 置空/打码 合规要求清除个人数据,但业务记录不能断

关键点:业务记录不能断 。比如一条成交记录,手机号被擦了,但"谁在什么时候成交的、成交了什么"这条业务线还得在,否则财务对账全乱。所以后端做的是把 phonewechat 这些个人字段置空(或不可逆打码),然后打一个 erasedAt 时间戳。前端要做三件事:列表显形、详情兜底、操作收敛

二、列表显形:别让擦除记录混进正常列表

这是我最想讲的一块,也是那个真实坑的现场。

后端列表接口返回的记录里,已擦除的和没擦除的混在一起------因为默认查询没说"只要没擦除的"。我一开始写的列表查询长这样:

ts 复制代码
// 数据记录列表查询参数(节选)
export interface RecordQuery {
  page: number
  size: number
  q?: string
  level__in?: string
  projectId?: string
  ownerId?: string
  // erased?: 0 | 1  ← 当时压根没用上
}

// 列表页拼查询参数
const query: RecordQuery = useMemo(() => ({
  page,
  size: PAGE_SIZE,
  ...(committed.q ? { q: String(committed.q) } : {}),
  ...(committed.level__in ? { level__in: String(committed.level__in) } : {}),
}), [page, committed.q, committed.level__in])

问题就出在:默认不传 erased,后端就不过滤,已擦除记录(个人字段全空)也一起返回了。于是运营在列表里看到一堆"姓名空、电话空、微信空"的行------技术上看是数据被合规清了,业务上看就是"怎么多出这么多空的怪行"。

修复很简单,但也容易忘:

ts 复制代码
const query: RecordQuery = useMemo(() => ({
  page,
  size: PAGE_SIZE,
  erased: 0, // 默认只拉"未擦除",幽灵行不进普通列表
  ...(committed.q ? { q: String(committed.q) } : {}),
  ...(committed.level__in ? { level__in: String(committed.level__in) } : {}),
  ...(showErased ? { erased: 1 } : {}), // 提供"查看已擦除"开关时才拉 1
}), [page, committed.q, committed.level__in, showErased])

一个 erased: 0 的默认值,幽灵行立刻消失。再补一个"查看已擦除"的开关,合规同学要审计时切到 erased: 1 就能看到。

这又回到我《后台列表的筛选条件,刷新就丢了?URL 做单一数据源彻底解决》里说的:筛选态最好收口到 URL。这个 showErased 开关我同样放进 URL 参数,刷新、分享都不丢。

三、详情兜底:已擦除就别再弹明文

列表解决了,详情抽屉还有个隐患。后端在"列表接口"里已经把 phone 脱敏成 138****0001 了,但详情接口为了审核方便返回的是明文 。正常记录没事,已擦除记录的 phone 被后端置成了空串------这时候前端要是还按"有值才显示"的逻辑渲染,反而没问题;怕的是有些同学图省事,在详情里直接拿原始字段拼"复制手机号"按钮,擦除记录的空串一复制就露了。

我的做法是erasedAt 这个标记做总闸

tsx 复制代码
export const RecordDetailDrawer = ({ recordId, onClose }) => {
  const { data } = useRecordDetail(recordId)
  const role = useAuthStore((s) => s.user?.role)
  const isTA = role === 'tenant_admin' // 仅租户管理员能擦除
  const [eraseOpen, setEraseOpen] = useState(false)

  const handleErase = useCallback(async () => {
    if (!recordId) return
    await eraseRecord(recordId) // POST /tenant/records/:id/erase
    toast.success('已按 PIPL 要求清除该记录全部个人数据')
    // 擦完同时刷新详情和列表,避免抽屉里还残留旧明文
    qc.invalidateQueries({ queryKey: ['record', recordId] })
    qc.invalidateQueries({ queryKey: ['records'] })
  }, [recordId, qc])

  // 头部:擦除过就显示"已擦除"灰标,而不是等级标签
  return (
    <div>
      <h3>{data.name}</h3>
      {data.erasedAt ? (
        <span className="text-xs italic">已擦除</span>
      ) : (
        <StatusTag>{levelLabel[data.level]}</StatusTag>
      )}
      <p>{data.phone || '---'}</p> {/* 擦除后 phone 为空,显示占位符而非空串 */}
      {/* 修改等级按钮:已擦除直接 disabled */}
      <Button disabled={!!data.erasedAt}>修改等级</Button>
      {/* 擦除入口:仅 TA 可见 */}
      {isTA && <Button variant="destructive" onClick={() => setEraseOpen(true)}>擦除</Button>}
    </div>
  )
}

三个细节:

  1. erasedAt 优先于业务标签:擦过就显示"已擦除"灰标,运营一眼知道这行是合规清过的,不是 bug。
  2. 空值用 --- 占位:别让界面出现一串空单元格,既难看又像数据丢了。
  3. 擦除入口只在详情里、且只有租户管理员能点 ------配合《多角色后台的菜单和路由怎么按角色收口》那套角色权限,普通员工连按钮都看不到。

四、客户端脱敏兜底:别完全信后端

后端列表返回的是脱敏串,但有两个例外场景会拿到明文:详情接口导出文件 。前者刚才说了用 erasedAt 兜底;后者(导出)我在《后端异步导出做好了,前端下载却总是失败?》里讲过前端怎么接。但万一哪天后端忘了脱敏、或者你要把明文在界面上"按需显示",前端手里得有一份脱敏函数防身:

ts 复制代码
// 手机号脱敏:138****1234(保留前 3 后 4)
export function maskPhone(phone: string): string {
  const digits = phone.replace(/\D/g, '')
  if (digits.length < 7) return phone
  return `${digits.slice(0, 3)}****${digits.slice(-4)}`
}

// 微信/联系方式脱敏:首尾各留 1 位,中间星号
export function maskContact(value: string): string {
  const v = value.trim()
  if (v.length <= 2) return v
  return `${v[0]}${'*'.repeat(Math.min(v.length - 2, 6))}${v[v.length - 1]}`
}

这套函数我放在 lib/mask.ts列表默认走后端脱敏串,详情/导出等明文场景用它在客户端再打一道码。属于"防御性编程"------后端脱敏是主防线,前端兜底防止任何一处漏网。

五、审计留痕:擦除动作本身也要可见

合规最看重"可证明你擦了"。光数据库里有个 erasedAt 不够,得在操作日志里能查到"谁、在什么时候、擦了哪条"。我的审计页专门给擦除动作加了视觉标记:

动作 日志文案 视觉标记
改等级 将记录等级改为 VIP 普通
分配 分配给 张三 普通
擦除 擦除了 🔴 红色 + ⚠️ 个人数据已清除 提示

这样审计同学翻开日志,哪条被擦了一目了然,出合规检查直接截图就能交差。

六、三个容易翻车的地方

把上面串起来,前端做 PIPL 擦除最容易漏的就三处:

翻车点 现象 修法
列表默认不过滤 erased 幽灵空白行混入正常列表 默认查 erased: 0,开关查 erased: 1
详情仍渲染明文/空串 复制按钮复制出空、空单元格像丢数据 erasedAt 做总闸,--- 占位
擦除后不刷缓存 抽屉里还残留旧明文 擦完 invalidate 详情 + 列表两个 key

说白了,PIPL 擦除前端不是"调一个删除接口"那么简单,它是列表过滤 + 详情兜底 + 客户端脱敏 + 操作收敛 + 审计留痕一整套配合。后端把数据清干净只是第一步,前端不接好,合规该露还是露。

相关阅读

本文由 FungLeo 主导,Deepseek 优化校阅,转发请注明首发地址,谢谢大家!

相关推荐
BigTopOne1 小时前
ArLiveLite — System Architecture
前端
JL151 小时前
如何用 MySQL 持久化、历史快照与游标实现多步 Redo/Undo
前端·数据库·mysql
灯澜忆梦1 小时前
【基于GO的Web开发9】gin框架返回json
前端·后端·golang·gin
IMPYLH1 小时前
HTML 的 <ins> 元素
前端·javascript·html
kymjs张涛1 小时前
DeepSeek Harness源码分析:非 AI 开发者的 Agent 学习总结
前端·后端·面试
Dovis(誓平步青云)1 小时前
番茄钟真正难的不是倒计时:启动、暂停、重置与页面销毁
android·服务器·前端
一千柯橘1 小时前
了解 dev-tools 不同的 debug 模式
前端
晨米酱1 小时前
Umi Mock 如何从文件声明变成 HTTP 响应
前端·javascript·设计
小粉粉hhh1 小时前
记录前端菜鸟的日常——自动打包脚本
前端