Merge pull request 'fix: MKH-BOM 修复在途旧查询回写缓存,导致「同步最新数据」点完仍是旧数据' (#20) from codex/mkh-cache-stale-write-fix-0922 into main
This commit is contained in:
commit
a58fc7d67b
@ -360,6 +360,21 @@ const officialBomDetailCache = new Map();
|
|||||||
const componentLocationCache = new Map();
|
const componentLocationCache = new Map();
|
||||||
let productBomCache = null;
|
let productBomCache = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 缓存代际(epoch):`clearMkhBomCache()` 每次自增。
|
||||||
|
*
|
||||||
|
* 为什么需要它 ——「同步最新数据」按钮点完反而看到旧数据:
|
||||||
|
* 所有缓存都是「先挂 pending,await 到结果后无条件写回」的写法。若在某个查询
|
||||||
|
* in-flight 期间用户点了同步(清空全部缓存),那个**发起于清缓存之前**的查询
|
||||||
|
* 读到的仍是旧快照,它返回时会无条件把旧值写回缓存,把清缓存后新请求填入的
|
||||||
|
* 新值覆盖掉 —— 之后 2~10 分钟(各缓存 TTL)内页面持续显示旧数据。
|
||||||
|
* 并发越高(多人同时用、或先点刷新再点同步)越容易撞上。
|
||||||
|
*
|
||||||
|
* 修法:查询发起时记下当时的 epoch,写回前比对;epoch 变了说明中途被清过缓存,
|
||||||
|
* 这次结果已经过期,**只返回给发起它的那个请求,不写缓存**。
|
||||||
|
*/
|
||||||
|
let cacheEpoch = 0;
|
||||||
|
|
||||||
// MKH 模块化成品必须同时是指定仓库商品档案和正式 BOM 父项。
|
// MKH 模块化成品必须同时是指定仓库商品档案和正式 BOM 父项。
|
||||||
// 上游存在被 item_type/labels 误标为成品的配件,不能仅依赖商品档案标签。
|
// 上游存在被 item_type/labels 误标为成品的配件,不能仅依赖商品档案标签。
|
||||||
const DESIGNATED_WAREHOUSE = '简尚-崔桥工厂品牌仓';
|
const DESIGNATED_WAREHOUSE = '简尚-崔桥工厂品牌仓';
|
||||||
@ -742,13 +757,18 @@ async function loadOfficialBomIndex() {
|
|||||||
return { bySku, bySpuPrefix: [], entries: [] };
|
return { bySku, bySpuPrefix: [], entries: [] };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const epoch = cacheEpoch;
|
||||||
officialBomIndexCache = { pending, value: null, expiresAt: 0 };
|
officialBomIndexCache = { pending, value: null, expiresAt: 0 };
|
||||||
try {
|
try {
|
||||||
const value = await pending;
|
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;
|
return value;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
officialBomIndexCache = null;
|
if (epoch === cacheEpoch) officialBomIndexCache = null;
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -765,6 +785,8 @@ async function loadOfficialBomForSkus(skuIds = []) {
|
|||||||
.filter(([key, value]) => key && value)
|
.filter(([key, value]) => key && value)
|
||||||
).entries()).slice(0, MAX_PAGE_SIZE);
|
).entries()).slice(0, MAX_PAGE_SIZE);
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
// 见 cacheEpoch 注释:清缓存后才返回的旧查询结果不得写回缓存
|
||||||
|
const epoch = cacheEpoch;
|
||||||
const missing = requested.filter(([key]) => {
|
const missing = requested.filter(([key]) => {
|
||||||
const cached = officialBomDetailCache.get(key);
|
const cached = officialBomDetailCache.get(key);
|
||||||
return !cached || cached.expiresAt <= now;
|
return !cached || cached.expiresAt <= now;
|
||||||
@ -795,11 +817,14 @@ async function loadOfficialBomForSkus(skuIds = []) {
|
|||||||
);
|
);
|
||||||
const grouped = groupOfficialBomRows(rows);
|
const grouped = groupOfficialBomRows(rows);
|
||||||
const expiresAt = Date.now() + OFFICIAL_BOM_DETAIL_CACHE_TTL_MS;
|
const expiresAt = Date.now() + OFFICIAL_BOM_DETAIL_CACHE_TTL_MS;
|
||||||
|
const writable = epoch === cacheEpoch;
|
||||||
missing.forEach(([key]) => {
|
missing.forEach(([key]) => {
|
||||||
officialBomDetailCache.set(key, {
|
if (writable) {
|
||||||
components: grouped.bySku.get(key) || [],
|
officialBomDetailCache.set(key, {
|
||||||
expiresAt,
|
components: grouped.bySku.get(key) || [],
|
||||||
});
|
expiresAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -856,13 +881,16 @@ async function loadBusinessBom() {
|
|||||||
return bom;
|
return bom;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const epoch = cacheEpoch;
|
||||||
businessBomCache = { pending, value: null, expiresAt: 0 };
|
businessBomCache = { pending, value: null, expiresAt: 0 };
|
||||||
try {
|
try {
|
||||||
const value = await pending;
|
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;
|
return value;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
businessBomCache = null;
|
if (epoch === cacheEpoch) businessBomCache = null;
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -939,13 +967,16 @@ async function loadPgBom() {
|
|||||||
return { bySku, bySpuPrefix, entries };
|
return { bySku, bySpuPrefix, entries };
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
const epoch = cacheEpoch;
|
||||||
productBomCache = { pending, value: null, expiresAt: 0 };
|
productBomCache = { pending, value: null, expiresAt: 0 };
|
||||||
try {
|
try {
|
||||||
const value = await pending;
|
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;
|
return value;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
productBomCache = null;
|
if (epoch === cacheEpoch) productBomCache = null;
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1283,13 +1314,16 @@ async function loadMkhProductIndex() {
|
|||||||
material: toText(row.material),
|
material: toText(row.material),
|
||||||
})).filter((row) => row.skuId));
|
})).filter((row) => row.skuId));
|
||||||
|
|
||||||
|
const epoch = cacheEpoch;
|
||||||
mkhProductCache = { pending, value: null, expiresAt: 0 };
|
mkhProductCache = { pending, value: null, expiresAt: 0 };
|
||||||
try {
|
try {
|
||||||
const value = await pending;
|
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;
|
return value;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
mkhProductCache = null;
|
if (epoch === cacheEpoch) mkhProductCache = null;
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1350,13 +1384,16 @@ async function loadPendingWorkOrderIndex() {
|
|||||||
return { bySku: new Map(), degraded: true, error: error.message, ttl: WORK_ORDER_DEGRADED_TTL_MS };
|
return { bySku: new Map(), degraded: true, error: error.message, ttl: WORK_ORDER_DEGRADED_TTL_MS };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const epoch = cacheEpoch;
|
||||||
workOrderIndexCache = { pending, value: null, expiresAt: 0 };
|
workOrderIndexCache = { pending, value: null, expiresAt: 0 };
|
||||||
try {
|
try {
|
||||||
const value = await pending;
|
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;
|
return value;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
workOrderIndexCache = null;
|
if (epoch === cacheEpoch) workOrderIndexCache = null;
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1642,13 +1679,16 @@ async function loadOrderIndex() {
|
|||||||
return { pendingBySku, shippedBySku, degraded: false, error: null, ttl: MKH_CACHE_TTL_MS };
|
return { pendingBySku, shippedBySku, degraded: false, error: null, ttl: MKH_CACHE_TTL_MS };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const epoch = cacheEpoch;
|
||||||
orderIndexCache = { pending, value: null, expiresAt: 0 };
|
orderIndexCache = { pending, value: null, expiresAt: 0 };
|
||||||
try {
|
try {
|
||||||
const value = await pending;
|
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;
|
return value;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
orderIndexCache = null;
|
if (epoch === cacheEpoch) orderIndexCache = null;
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1751,13 +1791,16 @@ async function loadReplenishIndex() {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const epoch = cacheEpoch;
|
||||||
replenishIndexCache = { pending, value: null, expiresAt: 0 };
|
replenishIndexCache = { pending, value: null, expiresAt: 0 };
|
||||||
try {
|
try {
|
||||||
const value = await pending;
|
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;
|
return value;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
replenishIndexCache = null;
|
if (epoch === cacheEpoch) replenishIndexCache = null;
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1772,6 +1815,8 @@ async function loadReplenishIndex() {
|
|||||||
*/
|
*/
|
||||||
async function loadComponentStock(codes = []) {
|
async function loadComponentStock(codes = []) {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
// 见 cacheEpoch 注释:清缓存后才返回的旧查询结果不得写回缓存
|
||||||
|
const epoch = cacheEpoch;
|
||||||
const unique = Array.from(new Set(codes.map((code) => toText(code)).filter(Boolean)));
|
const unique = Array.from(new Set(codes.map((code) => toText(code)).filter(Boolean)));
|
||||||
const stockMap = new Map();
|
const stockMap = new Map();
|
||||||
const missing = [];
|
const missing = [];
|
||||||
@ -1806,9 +1851,11 @@ async function loadComponentStock(codes = []) {
|
|||||||
if (code) found.set(code, toNumber(row.qty) ?? 0);
|
if (code) found.set(code, toNumber(row.qty) ?? 0);
|
||||||
});
|
});
|
||||||
const expiresAt = Date.now() + COMPONENT_STOCK_CACHE_TTL_MS;
|
const expiresAt = Date.now() + COMPONENT_STOCK_CACHE_TTL_MS;
|
||||||
|
const writable = epoch === cacheEpoch;
|
||||||
chunk.forEach((code) => {
|
chunk.forEach((code) => {
|
||||||
const qty = found.get(code) ?? 0;
|
const qty = found.get(code) ?? 0;
|
||||||
componentStockCache.set(code, { qty, expiresAt });
|
// 本批结果仍要返回给调用方(stockMap),只是不再写缓存
|
||||||
|
if (writable) componentStockCache.set(code, { qty, expiresAt });
|
||||||
stockMap.set(code, qty);
|
stockMap.set(code, qty);
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -2019,6 +2066,8 @@ function buildPackLocationMap(rows, componentCodes) {
|
|||||||
*/
|
*/
|
||||||
async function loadComponentLocations(codes = []) {
|
async function loadComponentLocations(codes = []) {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
// 见 cacheEpoch 注释:清缓存后才返回的旧查询结果不得写回缓存
|
||||||
|
const epoch = cacheEpoch;
|
||||||
const unique = Array.from(new Set(codes.map((code) => toText(code)).filter(Boolean)));
|
const unique = Array.from(new Set(codes.map((code) => toText(code)).filter(Boolean)));
|
||||||
const locationMap = new Map();
|
const locationMap = new Map();
|
||||||
const missing = [];
|
const missing = [];
|
||||||
@ -2065,9 +2114,11 @@ async function loadComponentLocations(codes = []) {
|
|||||||
);
|
);
|
||||||
const byCode = buildPackLocationMap(rows, chunk);
|
const byCode = buildPackLocationMap(rows, chunk);
|
||||||
const expiresAt = Date.now() + COMPONENT_LOCATION_CACHE_TTL_MS;
|
const expiresAt = Date.now() + COMPONENT_LOCATION_CACHE_TTL_MS;
|
||||||
|
const writable = epoch === cacheEpoch;
|
||||||
chunk.forEach((code) => {
|
chunk.forEach((code) => {
|
||||||
const location = byCode.get(code) || null;
|
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);
|
if (location) locationMap.set(code, location);
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -2405,6 +2456,11 @@ async function listMkhBomExportRows(query = {}) {
|
|||||||
// 而不是 2~15 分钟前的缓存结果。
|
// 而不是 2~15 分钟前的缓存结果。
|
||||||
// 注意:副本库本身由聚水潭侧同步,应用层无法触发上游拉取,本函数只保证「不读旧缓存」。
|
// 注意:副本库本身由聚水潭侧同步,应用层无法触发上游拉取,本函数只保证「不读旧缓存」。
|
||||||
function clearMkhBomCache() {
|
function clearMkhBomCache() {
|
||||||
|
// 🔥 先自增代际,再清各缓存:这样「本次清缓存之前已经发起、还没返回」的查询,
|
||||||
|
// 回来时 epoch 对不上,就不会把旧快照写回缓存(否则会覆盖掉清缓存后新查到的值,
|
||||||
|
// 导致按钮点完反而 2~10 分钟一直显示旧数据)。
|
||||||
|
cacheEpoch += 1;
|
||||||
|
|
||||||
businessBomCache = null;
|
businessBomCache = null;
|
||||||
officialBomIndexCache = null;
|
officialBomIndexCache = null;
|
||||||
productBomCache = null;
|
productBomCache = null;
|
||||||
@ -2424,6 +2480,7 @@ function clearMkhBomCache() {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
syncedAt: new Date().toISOString(),
|
syncedAt: new Date().toISOString(),
|
||||||
|
cacheEpoch,
|
||||||
cleared: cleared.map(([name, entries]) => ({ name, entries })),
|
cleared: cleared.map(([name, entries]) => ({ name, entries })),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1167,3 +1167,100 @@ test('export: 配件不足 rows are flagged and carry no replenish target', () =
|
|||||||
// 可组装数一并导出,便于业务核对短板
|
// 可组装数一并导出,便于业务核对短板
|
||||||
assert.equal(rows[0].assemblableQty, 0);
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user