结论先行
不需要轮询全量数据,也不需要 WebSocket。 核心是三个改动叠加:
- 后端加一个 3 字段的 meta 端点(<200 bytes 响应),把「检测」和「拉数据」解耦;
- 前端用
visibilitychange+ 60s 定时器双触发,只在version或status真正变化时打断用户; - 409 响应带
reason字段,让前端能区分「过期」和「被别人改」,走不同的 UI 分支。
再加一条 localStorage 草稿兜底,40 min 的输入就保住了。下面逐个展开。
一、后端:加一个 meta 端点 + 409 带 reason
1.1 meta 端点(解决「校验 payload 最小化」)
你的 80 个字段里 70% 是只读展示,全量 GET 做轮询既浪费又容易误报。真正会变且影响提交正确性的只有 3 个值:version、status、updated_at。
// Controller —— 新增一个轻量端点,不走业务 Service
@GetMapping("/api/approvals/{id}/meta")
public ResponseEntity<RecordMeta> getMeta(@PathVariable Long id) {
// 单行主键查询,不走任何 JOIN / 重计算
RecordMeta meta = approvalRepo.findMetaById(id); // SELECT version, status, updated_at FROM approval WHERE id = ?
return ResponseEntity.ok(meta);
}
// 返回体 < 100 bytes
record RecordMeta(long version, String status, Instant updatedAt) {}
为什么 3 个字段够?
version变了 → 有人改过(不管是谁);status从 PENDING 变成 EXPIRED / APPROVING → 必须阻断或走不同分支;updated_at是辅助判据:如果 version 没变但updated_at变了(理论上不该发生),说明有 bug,可以告警。
ETag 能不能替代? 能,但没必要——If-None-Match 是 HTTP 层的优化(304 省 body),你的 meta 端点 body 本身就只有 3 个字段,ETag 省不了什么。直接用 JSON 字段对比更直观,前端不需要维护 ETag 状态。
1.2 409 响应带 reason(解决「EXPIRED 特殊处理」)
// 你的 GlobalExceptionHandler 或 ApprovalController
@ExceptionHandler(OptimisticLockException.class)
public ResponseEntity<ConflictResponse> handleConflict(OptimisticLockException ex) {
// 关键:查一次当前状态,区分「过期」和「被别人改」
Approval current = approvalRepo.findById(ex.getId()).orElseThrow();
String reason;
if ("EXPIRED".equals(current.getStatus())) {
reason = "expired"; // 定时任务改的,必须走重新发起
} else if (!"PENDING".equals(current.getStatus())) {
reason = "status_changed"; // 被审批流转走了(APPROVING/APPROVED/REJECTED)
} else {
reason = "modified"; // version 变了但 status 还是 PENDING → 有人改了字段
}
return ResponseEntity.status(409).body(new ConflictResponse(
"CONFLICT",
reason,
current.getVersion(),
current.getStatus(),
current.getUpdatedAt()
));
}
record ConflictResponse(String code, String reason, long version, String status, Instant updatedAt) {}
这个判断该前端做还是后端做? 后端做。理由:前端的 status 是「提交那一刻」的快照,可能已经过期;后端在 409 处理时查的是「当前 DB 状态」,是权威值。前端只负责根据 reason 走不同 UI 分支。
二、前端:检测时机 + 打断策略(解决「检测时机」和「用户打断策略」)
2.1 检测:visibilitychange + 60s 定时器,双触发
// useRecordMeta.ts
import { useEffect, useRef, useCallback } from 'react';
interface MetaState {
version: number;
status: string;
updatedAt: string;
stale: boolean; // 是否检测到变化
staleReason: 'modified' | 'expired' | 'status_changed' | null;
}
export function useRecordMeta(recordId: number, initialVersion: number, initialStatus: string) {
const metaRef = useRef<MetaState>({
version: initialVersion,
status: initialStatus,
updatedAt: '',
stale: false,
staleReason: null,
});
const [meta, setMeta] = useState<MetaState>(metaRef.current);
const checkMeta = useCallback(async () => {
try {
const res = await fetch(`/api/approvals/${recordId}/meta`);
if (!res.ok) return; // 网络抖动不告警
const data = await res.json();
const prev = metaRef.current;
const versionChanged = data.version !== prev.version;
const statusChanged = data.status !== prev.status;
if (versionChanged || statusChanged) {
const staleReason =
data.status === 'EXPIRED' ? 'expired' :
data.status !== initialStatus ? 'status_changed' :
'modified';
metaRef.current = { ...data, stale: true, staleReason };
setMeta(metaRef.current);
// ← 这里触发 UI 打断(见 2.2)
} else {
// version 和 status 都没变 → 静默更新 updatedAt,不打断用户
metaRef.current = { ...metaRef.current, updatedAt: data.updatedAt };
}
} catch {
// 网络错误:静默忽略,下次触发再试
}
}, [recordId, initialStatus]);
useEffect(() => {
// 触发源 1:标签页从后台切回前台(用户切走了 5 分钟回来)
const onVisibility = () => { if (document.visibilityState === 'visible') checkMeta(); };
document.addEventListener('visibilitychange', onVisibility);
// 触发源 2:每 60s 一次(兜底,覆盖用户一直盯着页面但后台任务改了记录的情况)
const timer = setInterval(checkMeta, 60_000);
return () => {
document.removeEventListener('visibilitychange', onVisibility);
clearInterval(timer);
};
}, [checkMeta]);
return meta;
}
为什么不用 focus/blur? 你的表单有 80 个控件,focus/blur 事件密度太高(用户每切一个输入框就触发一次),60s 间隔 + visibilitychange 已经覆盖了 95% 的场景,且请求频率可控(每 60s 一次 <200 bytes 的 GET,对后端几乎零压力)。
为什么不用 10s 轮询? 60s 是你的定时任务周期(30 min)的 1/30,足够提前发现;10s 轮询 80 个字段组的页面 × 多用户并发,QPS 会翻 6 倍,收益边际递减。
2.2 打断策略:分 reason 走不同 UI(解决「用户打断策略」)
B 端用户对打断容忍度低,所以分级打断:
// 在表单页面顶部,根据 meta.staleReason 渲染不同组件
{meta.stale && meta.staleReason === 'expired' && (
<Banner
type="error"
title="该审批单已过期"
description="系统已将其标记为 EXPIRED,当前填写内容已保存为草稿。"
actions={[
{ label: '查看草稿', onClick: () => restoreDraft(recordId) },
{ label: '重新发起审批', onClick: () => navigate(`/approvals/new?ref=${recordId}`) },
]}
/>
)}
{meta.stale && meta.staleReason === 'modified' && (
<Banner
type="warning"
title="该审批单已被其他同事修改"
description="你看到的只读区域(申请人信息、历史审批记录等)可能已更新。你填写的字段不受影响,提交时系统会做最终校验。"
actions={[
{ label: '刷新只读区域', onClick: () => refreshReadOnlySections(recordId) },
{ label: '忽略,继续填写', onClick: () => dismissStale() },
]}
/>
)}
{meta.stale && meta.staleReason === 'status_changed' && (
<Banner
type="info"
title={`审批状态已变更为 ${meta.status}`}
description="该单已不在 PENDING 状态,当前操作可能无效。"
actions={[
{ label: '查看详情', onClick: () => navigate(`/approvals/${recordId}`) },
{ label: '关闭此页', onClick: () => window.close() },
]}
/>
)}
关键设计决策:
modified(有人改了字段)→ 不灰掉提交按钮,只提示「只读区域可能更新」,让用户自己决定。因为乐观锁最终会在提交时兜底,提前阻断反而打断心流。expired(过期)→ 必须阻断,因为提交一定会失败,且用户无法通过「覆盖」解决(EXPIRED 不是 version 冲突,是状态机不允许)。status_changed(状态流转)→ 必须阻断,同理。
为什么不用模态框? 模态框是「强制打断」,B 端用户正在填 80 个字段时弹模态框 = 丧失心流。顶部 Banner 是「非模态提示」,用户可以继续填,也可以点击处理——把选择权留给用户。
2.3 提交前 2s 预检(最后一道防线)
async function handleSubmit() {
// 提交前最后检查一次(覆盖「用户忽略 Banner 强行提交」的场景)
const res = await fetch(`/api/approvals/${recordId}/meta`);
const meta = await res.json();
if (meta.version !== initialVersion || meta.status !== initialStatus) {
// 走 409 处理流程(后端也会兜底,这里是前端提前拦截)
showConflictDialog(meta);
return;
}
// 正常提交,带上初始 version
await submitApproval(recordId, formData, initialVersion);
}
三、草稿兜底(解决「40 min 输入能不能保住」)
// useDraft.ts —— 自动草稿
import { useEffect, useRef } from 'react';
const DRAFT_KEY = (id: number) => `approval_draft_${id}`;
const DRAFT_INTERVAL = 30_000; // 30s 存一次
export function useDraft(recordId: number, getFormData: () => Record<string, unknown>) {
const lastSavedRef = useRef(0);
const saveDraft = useCallback(() => {
const now = Date.now();
if (now - lastSavedRef.current < DRAFT_INTERVAL) return;
try {
const data = getFormData();
// 只存用户可编辑的字段(排除只读的 70%)
const editable = pickEditableFields(data);
localStorage.setItem(DRAFT_KEY(recordId), JSON.stringify({
data: editable,
savedAt: now,
version: initialVersion, // 记录保存时的 version,恢复时校验
}));
lastSavedRef.current = now;
} catch {
// localStorage 满了(5MB)→ 降级:只存最近 10 个字段
}
}, [recordId, getFormData]);
useEffect(() => {
const timer = setInterval(saveDraft, DRAFT_INTERVAL);
// 页面关闭前强制存一次
const onBeforeUnload = () => saveDraft();
window.addEventListener('beforeunload', onBeforeUnload);
return () => { clearInterval(timer); window.removeEventListener('beforeunload', onBeforeUnload); };
}, [saveDraft]);
return { saveDraft, restoreDraft: () => restoreFromDraft(recordId) };
}
存哪些? 只存 pickEditableFields 筛出来的用户可编辑字段(你 12 个字段组里约 3~4 个可编辑组),不存只读的申请人信息、历史审批记录。一个 80 字段的表单,可编辑部分通常 < 5KB,localStorage 5MB 上限足够存几十个草稿。
多久存一次? 30s。太频繁(5s)→ 用户正在输入中间态(比如金额汇总正在算);太稀疏(5min)→ 崩溃丢太多。30s 是「用户切走一个字段组」的典型时间粒度。
怎么避免存脏中间态? 不在 onChange 时存,而是在 onBlur(字段失焦)时触发一次 saveDraft。失焦意味着用户「确认了这个值」,此时存快照是干净的。
四、完整时序(一张图说清)
用户打开表单 (t=0)
│
├── 初始 GET /api/approvals/{id} → 拿到 version=5, status=PENDING
│
├── t=0 ~ t=40min: 用户填 80 个字段
│ ├── 每 30s / 每 blur: localStorage 存草稿
│ ├── 每 60s: GET /meta (3 字段) → version=5, status=PENDING → 静默
│ ├── t=15min: 用户切到另一个标签页
│ └── t=25min: 用户切回来 → visibilitychange → GET /meta
│ └── version=5, status=PENDING → 无变化,不打断
│
├── t=30min: 后端定时任务 → status=EXPIRED, version=6
│
├── t=30min+60s: 定时 GET /meta → version=6, status=EXPIRED
│ └── 检测到变化 → 显示红色 Banner "已过期"
│
├── t=35min: 用户点提交
│ └── 提交前预检 → 发现 EXPIRED → 阻止提交
│ └── 提示 "内容已存草稿,可重新发起"
│
└── 用户恢复草稿 → 重新发起审批 → 成功
五、留一个开放问题
上面这套方案假设「一个记录同一时间只有一个人在编辑」是常态,「多人并发编辑」是异常。但你说「多租户下 3~5 人共享同一审批单是常态」——如果常态是 3 人同时编辑同一条记录,那乐观锁的 409 就不是 bug 而是 feature,真正该问的问题是:这个审批单的数据模型是不是该拆成「每个审批人一个子记录」,把并发编辑变成并发写不同行? 这个改动比前端检测方案大得多,但可能才是根治。想听听你们审批流里「多人编辑同一单」的真实比例——如果 >20%,我倾向于先做数据模型拆分,再考虑前端方案。