表单提交发全量状态,把 403 和审计污染一起发过去了
某后台管理系统有一张多节点审批表单,字段按角色和节点设有锁定规则。某天接到 bug:用户点击「审批通过」按钮,后端直接返回 403,提示「字段 X 无编辑权限」。但用户压根没有碰过字段 X——它是上一节点填的,本节点是只读的。
翻了一遍提交逻辑,发现问题出在这里:
// 问题写法——全量表单状态直接丢进 payload
await fetch('/api/approvals/[id]/action', {
method: 'POST',
body: JSON.stringify({
action: 'approve',
fieldUpdates: formValues, // ← 30 个字段,用户只改了 1 个
}),
})现象
- 用户点「审批通过」,后端 403:
field customerName needs unlock to edit - 审计日志显示每次操作都「修改了全部 30 个字段」
- 两个用户同时打开同一张表,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 8000、null 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 === {} 也是合法的——表示用户没有编辑任何字段,后端直接跳过字段权限循环。
加载时快照 originalFormValues,提交前用 normalize 后的 diff 计算 fieldUpdates,只发用户本次实际改动的字段。后端的字段级权限检查不变,两层互补:前端减少误报,后端防止绕过。
排查线索
快速在代码库定位同类问题:
grep -rn "fieldUpdates: formValues\|updates: form\|body: form " src/
grep -rn "JSON.stringify(\{.*formValues\b" src/handler body 直接展开 formValues 的,基本都是全量提交。
一句话外卖
表单 payload 应传达「用户改了什么」,而非「用户现在看到什么」——前者是意图,后者是快照,混淆两者会让字段级权限、审计日志和并发写同时失效。