diff --git a/backend/src/services/mkhBomService.js b/backend/src/services/mkhBomService.js index 94c7428..f9f6a90 100644 --- a/backend/src/services/mkhBomService.js +++ b/backend/src/services/mkhBomService.js @@ -280,6 +280,21 @@ const officialBomDetailCache = new Map(); const componentLocationCache = new Map(); let productBomCache = null; +/** + * 缓存代际(epoch):`clearMkhBomCache()` 每次自增。 + * + * 为什么需要它 ——「同步最新数据」按钮点完反而看到旧数据: + * 所有缓存都是「先挂 pending,await 到结果后无条件写回」的写法。若在某个查询 + * in-flight 期间用户点了同步(清空全部缓存),那个**发起于清缓存之前**的查询 + * 读到的仍是旧快照,它返回时会无条件把旧值写回缓存,把清缓存后新请求填入的 + * 新值覆盖掉 —— 之后 2~10 分钟(各缓存 TTL)内页面持续显示旧数据。 + * 并发越高(多人同时用、或先点刷新再点同步)越容易撞上。 + * + * 修法:查询发起时记下当时的 epoch,写回前比对;epoch 变了说明中途被清过缓存, + * 这次结果已经过期,**只返回给发起它的那个请求,不写缓存**。 + */ +let cacheEpoch = 0; + // MKH 模块化成品必须同时是指定仓库商品档案和正式 BOM 父项。 // 上游存在被 item_type/labels 误标为成品的配件,不能仅依赖商品档案标签。 const DESIGNATED_WAREHOUSE = '简尚-崔桥工厂品牌仓'; @@ -637,13 +652,18 @@ async function loadOfficialBomIndex() { return { bySku, bySpuPrefix: [], entries: [] }; }); + const epoch = cacheEpoch; officialBomIndexCache = { pending, value: null, expiresAt: 0 }; try { const value = await pending; - officialBomIndexCache = { pending: null, value, expiresAt: Date.now() + BOM_CACHE_TTL_MS }; + // epoch 变了 ⇒ 期间用户点过「同步最新数据」,本次结果来自清缓存前的旧快照, + // 只还给发起它的请求,不写回缓存(否则会把新值覆盖成旧值) + if (epoch === cacheEpoch) { + officialBomIndexCache = { pending: null, value, expiresAt: Date.now() + BOM_CACHE_TTL_MS }; + } return value; } catch (error) { - officialBomIndexCache = null; + if (epoch === cacheEpoch) officialBomIndexCache = null; throw error; } } @@ -660,6 +680,8 @@ async function loadOfficialBomForSkus(skuIds = []) { .filter(([key, value]) => key && value) ).entries()).slice(0, MAX_PAGE_SIZE); const now = Date.now(); + // 见 cacheEpoch 注释:清缓存后才返回的旧查询结果不得写回缓存 + const epoch = cacheEpoch; const missing = requested.filter(([key]) => { const cached = officialBomDetailCache.get(key); return !cached || cached.expiresAt <= now; @@ -690,11 +712,14 @@ async function loadOfficialBomForSkus(skuIds = []) { ); const grouped = groupOfficialBomRows(rows); const expiresAt = Date.now() + OFFICIAL_BOM_DETAIL_CACHE_TTL_MS; + const writable = epoch === cacheEpoch; missing.forEach(([key]) => { - officialBomDetailCache.set(key, { - components: grouped.bySku.get(key) || [], - expiresAt, - }); + if (writable) { + officialBomDetailCache.set(key, { + components: grouped.bySku.get(key) || [], + expiresAt, + }); + } }); } @@ -751,13 +776,16 @@ async function loadBusinessBom() { return bom; }); + const epoch = cacheEpoch; businessBomCache = { pending, value: null, expiresAt: 0 }; try { const value = await pending; - businessBomCache = { pending: null, value, expiresAt: Date.now() + BOM_CACHE_TTL_MS }; + if (epoch === cacheEpoch) { + businessBomCache = { pending: null, value, expiresAt: Date.now() + BOM_CACHE_TTL_MS }; + } return value; } catch (error) { - businessBomCache = null; + if (epoch === cacheEpoch) businessBomCache = null; throw error; } } @@ -834,13 +862,16 @@ async function loadPgBom() { return { bySku, bySpuPrefix, entries }; })(); + const epoch = cacheEpoch; productBomCache = { pending, value: null, expiresAt: 0 }; try { const value = await pending; - productBomCache = { pending: null, value, expiresAt: Date.now() + BOM_CACHE_TTL_MS }; + if (epoch === cacheEpoch) { + productBomCache = { pending: null, value, expiresAt: Date.now() + BOM_CACHE_TTL_MS }; + } return value; } catch (error) { - productBomCache = null; + if (epoch === cacheEpoch) productBomCache = null; throw error; } } @@ -1158,13 +1189,16 @@ async function loadMkhProductIndex() { material: toText(row.material), })).filter((row) => row.skuId)); + const epoch = cacheEpoch; mkhProductCache = { pending, value: null, expiresAt: 0 }; try { const value = await pending; - mkhProductCache = { pending: null, value, expiresAt: Date.now() + MKH_CACHE_TTL_MS }; + if (epoch === cacheEpoch) { + mkhProductCache = { pending: null, value, expiresAt: Date.now() + MKH_CACHE_TTL_MS }; + } return value; } catch (error) { - mkhProductCache = null; + if (epoch === cacheEpoch) mkhProductCache = null; throw error; } } @@ -1225,13 +1259,16 @@ async function loadPendingWorkOrderIndex() { return { bySku: new Map(), degraded: true, error: error.message, ttl: WORK_ORDER_DEGRADED_TTL_MS }; }); + const epoch = cacheEpoch; workOrderIndexCache = { pending, value: null, expiresAt: 0 }; try { const value = await pending; - workOrderIndexCache = { pending: null, value, expiresAt: Date.now() + value.ttl }; + if (epoch === cacheEpoch) { + workOrderIndexCache = { pending: null, value, expiresAt: Date.now() + value.ttl }; + } return value; } catch (error) { - workOrderIndexCache = null; + if (epoch === cacheEpoch) workOrderIndexCache = null; throw error; } } @@ -1382,13 +1419,16 @@ async function loadOrderIndex() { return { pendingBySku, shippedBySku, degraded: false, error: null, ttl: MKH_CACHE_TTL_MS }; }); + const epoch = cacheEpoch; orderIndexCache = { pending, value: null, expiresAt: 0 }; try { const value = await pending; - orderIndexCache = { pending: null, value, expiresAt: Date.now() + value.ttl }; + if (epoch === cacheEpoch) { + orderIndexCache = { pending: null, value, expiresAt: Date.now() + value.ttl }; + } return value; } catch (error) { - orderIndexCache = null; + if (epoch === cacheEpoch) orderIndexCache = null; throw error; } } @@ -1473,13 +1513,16 @@ async function loadReplenishIndex() { }; }); + const epoch = cacheEpoch; replenishIndexCache = { pending, value: null, expiresAt: 0 }; try { const value = await pending; - replenishIndexCache = { pending: null, value, expiresAt: Date.now() + MKH_CACHE_TTL_MS }; + if (epoch === cacheEpoch) { + replenishIndexCache = { pending: null, value, expiresAt: Date.now() + MKH_CACHE_TTL_MS }; + } return value; } catch (error) { - replenishIndexCache = null; + if (epoch === cacheEpoch) replenishIndexCache = null; throw error; } } @@ -1494,6 +1537,8 @@ async function loadReplenishIndex() { */ async function loadComponentStock(codes = []) { const now = Date.now(); + // 见 cacheEpoch 注释:清缓存后才返回的旧查询结果不得写回缓存 + const epoch = cacheEpoch; const unique = Array.from(new Set(codes.map((code) => toText(code)).filter(Boolean))); const stockMap = new Map(); const missing = []; @@ -1528,9 +1573,11 @@ async function loadComponentStock(codes = []) { if (code) found.set(code, toNumber(row.qty) ?? 0); }); const expiresAt = Date.now() + COMPONENT_STOCK_CACHE_TTL_MS; + const writable = epoch === cacheEpoch; chunk.forEach((code) => { const qty = found.get(code) ?? 0; - componentStockCache.set(code, { qty, expiresAt }); + // 本批结果仍要返回给调用方(stockMap),只是不再写缓存 + if (writable) componentStockCache.set(code, { qty, expiresAt }); stockMap.set(code, qty); }); } catch (error) { @@ -1741,6 +1788,8 @@ function buildPackLocationMap(rows, componentCodes) { */ async function loadComponentLocations(codes = []) { const now = Date.now(); + // 见 cacheEpoch 注释:清缓存后才返回的旧查询结果不得写回缓存 + const epoch = cacheEpoch; const unique = Array.from(new Set(codes.map((code) => toText(code)).filter(Boolean))); const locationMap = new Map(); const missing = []; @@ -1787,9 +1836,11 @@ async function loadComponentLocations(codes = []) { ); const byCode = buildPackLocationMap(rows, chunk); const expiresAt = Date.now() + COMPONENT_LOCATION_CACHE_TTL_MS; + const writable = epoch === cacheEpoch; chunk.forEach((code) => { const location = byCode.get(code) || null; - componentLocationCache.set(code, { location, expiresAt }); + // 本批结果仍要返回给调用方(locationMap),只是不再写缓存 + if (writable) componentLocationCache.set(code, { location, expiresAt }); if (location) locationMap.set(code, location); }); } catch (error) { @@ -2127,6 +2178,11 @@ async function listMkhBomExportRows(query = {}) { // 而不是 2~15 分钟前的缓存结果。 // 注意:副本库本身由聚水潭侧同步,应用层无法触发上游拉取,本函数只保证「不读旧缓存」。 function clearMkhBomCache() { + // 🔥 先自增代际,再清各缓存:这样「本次清缓存之前已经发起、还没返回」的查询, + // 回来时 epoch 对不上,就不会把旧快照写回缓存(否则会覆盖掉清缓存后新查到的值, + // 导致按钮点完反而 2~10 分钟一直显示旧数据)。 + cacheEpoch += 1; + businessBomCache = null; officialBomIndexCache = null; productBomCache = null; @@ -2146,6 +2202,7 @@ function clearMkhBomCache() { return { syncedAt: new Date().toISOString(), + cacheEpoch, cleared: cleared.map(([name, entries]) => ({ name, entries })), }; } diff --git a/backend/test/mkhBomService.test.js b/backend/test/mkhBomService.test.js index 3034c53..ad3ead5 100644 --- a/backend/test/mkhBomService.test.js +++ b/backend/test/mkhBomService.test.js @@ -939,3 +939,100 @@ test('export: 配件不足 rows are flagged and carry no replenish target', () = // 可组装数一并导出,便于业务核对短板 assert.equal(rows[0].assemblableQty, 0); }); + +// ───────────────────────────────────────────────────────────────────────────── +// 缓存代际(cacheEpoch):修复「点同步最新数据,反而显示旧数据」 +// +// 症状:用户点「同步最新数据」后,页面 2~10 分钟内持续显示旧值,像在用缓存。 +// 成因:缓存是「先挂 pending → await 结果 → 无条件写回」。若某个查询在途时用户点了 +// 同步(清空缓存),那个发起于清缓存之前的查询读到的是旧快照,返回时会把旧值 +// 写回缓存,覆盖掉清缓存后新请求填入的新值 —— 谁后完成谁生效。 +// +// 下面第一个用例复现旧行为(修复前失败),第二个用例锁定正确语义。 +// ───────────────────────────────────────────────────────────────────────────── + +test('cache epoch: 清缓存后在途的旧查询结果不得写回缓存(否则同步按钮点完仍是旧数据)', async () => { + const cloudDatabase = require('../src/config/cloudDatabase'); + const svc = require('../src/services/mkhBomService'); + const originalQuery = cloudDatabase.query; + + const CODE = 'DNZTESTCACHE01'; + const staleQty = 9; // 清缓存前那一刻库里/旧快照的值 + const freshQty = 1; // 清缓存后重新查到的值 + + let phase = 'old'; + let stockQueries = 0; + let releaseStale; + const staleGate = new Promise((resolve) => { releaseStale = resolve; }); + let markStaleStarted; + const staleStarted = new Promise((resolve) => { markStaleStarted = resolve; }); + + cloudDatabase.query = async (sql) => { + if (!sql.includes('jst_inventoryInfo')) return []; + stockQueries += 1; + if (phase === 'old') { + markStaleStarted(); + await staleGate; // 卡住旧查询:模拟「发起于此、期间用户点了同步」的慢查询 + return [{ code: CODE, qty: staleQty }]; + } + return [{ code: CODE, qty: freshQty }]; + }; + + try { + // 1) 旧查询发起并卡住 + const inFlight = svc._private.loadComponentStock([CODE]); + await staleStarted; + + // 2) 用户点「同步最新数据」→ 清缓存并推进代际 + const cleared = svc.clearMkhBomCache(); + assert.ok(cleared.cacheEpoch >= 1, '清缓存必须推进代际号'); + phase = 'new'; + + // 3) 清缓存后的新查询先返回,拿到新值并写缓存 + const fresh = await svc._private.loadComponentStock([CODE]); + assert.equal(fresh.stockMap.get(CODE), freshQty); + assert.equal(stockQueries, 2, '清缓存后必须真正重查数据源'); + + // 4) 旧查询这才返回(它读到的仍是旧快照) + releaseStale(); + const staleResult = await inFlight; + assert.equal(staleResult.stockMap.get(CODE), staleQty, + '过期结果仍应原样返回给发起它的那个请求,不能改变它自己的语义'); + + // 5) 🔥 关键断言:缓存里必须还是新值 + const after = await svc._private.loadComponentStock([CODE]); + assert.equal(after.stockMap.get(CODE), freshQty, + '清缓存后返回的过期结果写回了缓存 → 用户会看到「点了同步还是旧数据」'); + assert.equal(stockQueries, 2, + '第 5 步应直接命中缓存,不应再打数据库(说明缓存确实被写入了新值)'); + } finally { + cloudDatabase.query = originalQuery; + svc.clearMkhBomCache(); + } +}); + +test('cache epoch: 未经清缓存时缓存照常命中(回归保护)', async () => { + const cloudDatabase = require('../src/config/cloudDatabase'); + const svc = require('../src/services/mkhBomService'); + const originalQuery = cloudDatabase.query; + + const CODE = 'DNZTESTCACHE02'; + let stockQueries = 0; + cloudDatabase.query = async (sql) => { + if (!sql.includes('jst_inventoryInfo')) return []; + stockQueries += 1; + return [{ code: CODE, qty: 3 }]; + }; + + try { + svc.clearMkhBomCache(); + const first = await svc._private.loadComponentStock([CODE]); + const second = await svc._private.loadComponentStock([CODE]); + assert.equal(first.stockMap.get(CODE), 3); + assert.equal(second.stockMap.get(CODE), 3); + assert.equal(stockQueries, 1, '同一次缓存代际内,第二次必须命中缓存不再查库'); + } finally { + cloudDatabase.query = originalQuery; + svc.clearMkhBomCache(); + } +});