返回博客

表单提交发全量状态,把 403 和审计污染一起发过去了

某后台管理系统有一张多节点审批表单,字段按角色和节点设有锁定规则。某天接到 bug:用户点击「审批通过」按钮,后端直接返回 403,提示「字段 X 无编辑权限」。但用户压根没有碰过字段 X——它是上一节点填的,本节点是只读的。

翻了一遍提交逻辑,发现问题出在这里:

// 问题写法——全量表单状态直接丢进 payload
await fetch('/api/approvals/[id]/action', {
  method: 'POST',
  body: JSON.stringify({
    action: 'approve',
    fieldUpdates: formValues,  // ← 30 个字段,用户只改了 1 个
  }),
})

现象

  1. 用户点「审批通过」,后端 403:field customerName needs unlock to edit
  2. 审计日志显示每次操作都「修改了全部 30 个字段」
  3. 两个用户同时打开同一张表,A 在另一个标签页改了备注,B 点提交后备注被 B 的旧值覆盖

根因

前端 formValues 是完整的表单状态,包含所有字段的当前值——无论用户有没有编辑过。把它整体放进 fieldUpdates,等于告诉后端:「我刚才改了所有字段,请全部写入。」

后端的字段级权限检查是对的:它看到 customerName 出现在 fieldUpdates 里,按规则拦截——而 customerName 在本节点确实没有编辑权限。整个流程逻辑自洽,但用户意图被错误传达了。

用户点「审批通过」的真实意图是:「提交我这次编辑的改动,推进流程。」不是「用我当前看到的值覆盖所有字段。」

坑:全量表单状态 ≠ 用户编辑意图

formValues 包含的是「表单当前渲染值」,不是「用户本次会话的改动」。两者在大多数情况下看起来一样,但只要涉及字段级权限、并发编辑、跨节点继承,差异就会爆炸式放大:未触碰的锁定字段触发 403、审计变成噪音、并发写互相踩踏。

正解

只发用户实际改动的字段(dirty diff)。

第一层:加载时快照原始值

// 页面加载完成,formValues 初始化后立刻存一份镜像
setFormValues(initValues)
setOriginalFormValues(initValues)  // ← 键值完全相同的快照

第二层:提交时计算 dirty diff

export function buildFieldUpdates(
  originalValues: Record<string, unknown>,
  formValues: Record<string, unknown>
): Record<string, unknown> {
  const diff: Record<string, unknown> = {}
  for (const key of Object.keys(formValues)) {
    if (!isSameFieldValue(originalValues[key], formValues[key])) {
      diff[key] = formValues[key]
    }
  }
  return diff
}

第三层:比较前先 normalize

不 normalize 会产生大量伪脏字段:'8000' vs 8000null vs ''' 备注 ' vs '备注' 都会被误判为已修改。

export function normalizeFieldValue(value: unknown): unknown {
  if (value === null || value === undefined) return null
  if (typeof value === 'string') {
    const trimmed = value.trim()
    if (trimmed === '') return null
    if (/^-?\d+(\.\d+)?$/.test(trimmed)) {
      const n = Number(trimmed)
      if (!Number.isNaN(n)) return n
    }
    return trimmed
  }
  if (typeof value === 'number') {
    return Number.isNaN(value) ? null : value
  }
  return value
}

第四层:按操作类型决定是否带 fieldUpdates

「驳回」「撤销」类操作通常不写字段,不应附带 fieldUpdates

const needsFieldUpdates =
  selectedAction === 'approve' ||
  selectedAction === 'advance' ||
  selectedAction === 'submit'
 
const dirtyFieldUpdates = needsFieldUpdates
  ? buildFieldUpdates(originalFormValues, formValues)
  : null
 
await authFetch(`/api/approvals/${id}/action`, {
  method: 'POST',
  body: JSON.stringify({
    action: selectedAction,
    ...(dirtyFieldUpdates ? { fieldUpdates: dirtyFieldUpdates } : {}),
  }),
})

diff === {} 也是合法的——表示用户没有编辑任何字段,后端直接跳过字段权限循环。

正解:提交 dirty diff,不提交全量状态

加载时快照 originalFormValues,提交前用 normalize 后的 diff 计算 fieldUpdates,只发用户本次实际改动的字段。后端的字段级权限检查不变,两层互补:前端减少误报,后端防止绕过。

排查线索

快速在代码库定位同类问题:

grep -rn "fieldUpdates: formValues\|updates: form\|body: form " src/
grep -rn "JSON.stringify(\{.*formValues\b" src/

handler body 直接展开 formValues 的,基本都是全量提交。

一句话外卖

表单 payload 应传达「用户改了什么」,而非「用户现在看到什么」——前者是意图,后者是快照,混淆两者会让字段级权限、审计日志和并发写同时失效。