fix: 根治MKH同步后旧请求覆盖最新数据

This commit is contained in:
134 2026-09-23 11:05:28 +08:00
parent 97a93766e8
commit fa91b9242c
5 changed files with 95 additions and 18 deletions

View File

@ -32,6 +32,7 @@ function sendError(res, error, fallback) {
// MKH-BOM 资料列表(模块化组合商品 + 配件汇总)
router.get('/list', async (req, res) => {
try {
res.set('Cache-Control', 'no-store');
res.json({ success: true, data: await getMkhBomList(req.query || {}) });
} catch (error) {
sendError(res, error, '加载 MKH-BOM 资料列表失败');
@ -42,6 +43,7 @@ router.get('/list', async (req, res) => {
router.get('/detail', async (req, res) => {
try {
const skuId = req.query?.skuId || req.query?.comboSkuId;
res.set('Cache-Control', 'no-store');
res.json({ success: true, data: await getMkhBomDetail(skuId) });
} catch (error) {
sendError(res, error, '加载 MKH-BOM 明细失败');
@ -102,11 +104,14 @@ router.put('/notification-center', async (req, res) => {
}
});
// 手动同步:清空本页服务端缓存(BOM / 补货索引 / 加工单 / 待发货 / 配件库存 / 仓位),
// 前端「同步」按钮调用后再拉一次列表即可直连副本库取最新快照
router.post('/sync', async (_req, res) => {
// 手动同步:先清空本页服务端缓存(BOM / 补货索引 / 加工单 / 待发货 / 配件库存 / 仓位),
// 再在同一次请求中直连副本库读取新快照。这样不会被同步前尚未完成的旧列表请求覆盖。
router.post('/sync', async (req, res) => {
try {
res.json({ success: true, data: clearMkhBomCache() });
const cache = clearMkhBomCache();
const snapshot = await getMkhBomList(req.body?.query || {});
res.set('Cache-Control', 'no-store');
res.json({ success: true, data: { cache, snapshot } });
} catch (error) {
sendError(res, error, '同步 MKH-BOM 数据失败');
}

View File

@ -7,7 +7,11 @@ const EXPORT_TIMEOUT_MS = 5 * 60 * 1000;
// MKH-BOM 资料列表(模块化组合商品 + 配件汇总)
export const getMkhBomList = (params = {}) => request.get(
'/api/mkh-bom/list',
{ params, timeout: LIST_TIMEOUT_MS }
{
params,
timeout: LIST_TIMEOUT_MS,
headers: { 'Cache-Control': 'no-cache' },
}
);
// 单个 MKH 组合商品的 BOM 配件明细(列表展开 / 打印预览共用)
@ -46,10 +50,9 @@ export const saveMkhNotificationCenter = (payload = {}) => request.put(
payload
);
// 手动同步:清空服务端缓存(BOM / 补货索引 / 加工单 / 待发货 / 配件库存 / 仓位),
// 随后重新拉列表即直连副本库取最新快照,可随时点击
export const syncMkhBom = () => request.post(
// 手动同步:服务端清空全部 MKH 缓存,并在同一次请求中返回当前筛选条件的数据库新快照
export const syncMkhBom = (query = {}) => request.post(
'/api/mkh-bom/sync',
null,
{ query },
{ timeout: LIST_TIMEOUT_MS }
);

View File

@ -17,6 +17,7 @@ import {
checkMkhReplenishAlert,
syncMkhBom,
} from '../../api/mkhBom';
import { createRequestGate } from '../../utils/requestGate';
import { exportTableFile } from '../../utils/tableExport';
import './style.css';
@ -273,6 +274,8 @@ const MkhBomData = () => {
const [centerSaving, setCenterSaving] = useState(false);
const tableWrapRef = useRef(null);
const listRequestGateRef = useRef(createRequestGate());
const detailGenerationRef = useRef(0);
const [bodyHeight, setBodyHeight] = useState(420);
useEffect(() => {
@ -310,19 +313,22 @@ const MkhBomData = () => {
}, [page, pageSize, sorter, filters]);
const load = useCallback(async () => {
const requestGeneration = listRequestGateRef.current.begin();
setLoading(true);
setError('');
try {
const result = await getMkhBomList(query);
const result = await getMkhBomList({ ...query, _refresh: Date.now() });
if (!listRequestGateRef.current.isCurrent(requestGeneration)) return;
if (!result || result.success === false) {
setError(result?.error || '加载 MKH-BOM 资料失败');
return;
}
setData(result?.data || { rows: [], total: 0, page: 1, pageSize: 50 });
} catch (requestError) {
if (!listRequestGateRef.current.isCurrent(requestGeneration)) return;
setError(requestError?.response?.data?.error || requestError?.message || '加载 MKH-BOM 资料失败');
} finally {
setLoading(false);
if (listRequestGateRef.current.isCurrent(requestGeneration)) setLoading(false);
}
}, [query]);
@ -482,18 +488,23 @@ const MkhBomData = () => {
const loadDetail = useCallback(async (skuId) => {
if (detailCache[skuId] || detailLoading[skuId]) return;
const detailGeneration = detailGenerationRef.current;
setDetailLoading((old) => ({ ...old, [skuId]: true }));
try {
const result = await getMkhBomDetail(skuId);
if (detailGeneration !== detailGenerationRef.current) return;
if (!result || result.success === false) {
message.error(result?.error || '加载配件明细失败');
return;
}
setDetailCache((old) => ({ ...old, [skuId]: result?.data || { items: [] } }));
} catch (e) {
if (detailGeneration !== detailGenerationRef.current) return;
message.error(e?.response?.data?.error || e?.message || '加载配件明细失败');
} finally {
setDetailLoading((old) => ({ ...old, [skuId]: false }));
if (detailGeneration === detailGenerationRef.current) {
setDetailLoading((old) => ({ ...old, [skuId]: false }));
}
}
}, [detailCache, detailLoading]);
@ -791,18 +802,35 @@ const MkhBomData = () => {
);
};
// 手动同步:清掉服务端缓存(BOM / 补货索引 / 加工单 / 待发货 / 配件库存 / 仓位),
// 再拉一次列表即可直连副本库取最新快照;可随时点击,不必等凌晨的定时刷新
// 手动同步:服务端清缓存后在同一次请求中直连副本库返回新快照。
// 请求代际会阻止同步前尚未完成的旧请求覆盖这个新快照。
const handleSync = async () => {
setSyncing(true);
setLoading(true);
// 立即废弃同步前发出的列表/明细请求;它们即使稍后返回,也不能覆盖新快照。
listRequestGateRef.current.invalidate();
detailGenerationRef.current += 1;
setDetailCache({});
setDetailLoading({});
setExpandedKeys({});
const syncGeneration = listRequestGateRef.current.begin();
try {
await syncMkhBom();
setDetailCache({});
await load();
message.success('已同步最新数据');
const result = await syncMkhBom(query);
if (!listRequestGateRef.current.isCurrent(syncGeneration)) return;
if (!result || result.success === false || !result?.data?.snapshot) {
throw new Error(result?.error || '同步接口未返回数据库快照');
}
setData(result.data.snapshot);
setError('');
const monitorDate = result.data.snapshot.monitorDate;
message.success(monitorDate ? `已读取数据库最新快照(监控日 ${monitorDate})` : '已读取数据库最新快照');
} catch (syncError) {
if (listRequestGateRef.current.isCurrent(syncGeneration)) {
setError(syncError?.response?.data?.error || syncError?.message || '同步失败');
}
message.error(syncError?.response?.data?.error || syncError?.message || '同步失败');
} finally {
if (listRequestGateRef.current.isCurrent(syncGeneration)) setLoading(false);
setSyncing(false);
}
};

View File

@ -0,0 +1,17 @@
export function createRequestGate() {
let generation = 0;
return {
begin() {
generation += 1;
return generation;
},
invalidate() {
generation += 1;
return generation;
},
isCurrent(requestGeneration) {
return requestGeneration === generation;
},
};
}

View File

@ -0,0 +1,24 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createRequestGate } from '../src/utils/requestGate.js';
test('only the newest request may update the page', () => {
const gate = createRequestGate();
const oldRequest = gate.begin();
const newRequest = gate.begin();
assert.equal(gate.isCurrent(oldRequest), false);
assert.equal(gate.isCurrent(newRequest), true);
});
test('sync invalidates every request that started before cache clearing', () => {
const gate = createRequestGate();
const beforeSync = gate.begin();
gate.invalidate();
assert.equal(gate.isCurrent(beforeSync), false);
const freshSnapshot = gate.begin();
assert.equal(gate.isCurrent(freshSnapshot), true);
});