cangku/backend/src/middleware/pageDataCache.js

261 lines
7.3 KiB
JavaScript

const crypto = require('crypto');
const jwt = require('jsonwebtoken');
const redis = require('../config/redis');
const dataPageCache = require('../services/dataPageCache');
const CACHE_BUSTER_KEYS = new Set([
'_',
'_t',
't',
'timestamp',
'cacheBust',
'cacheBuster',
'nocache',
]);
const DEFAULT_REDIS_TTL_SECONDS = Math.max(
Number(process.env.PAGE_RESPONSE_CACHE_REDIS_TTL_SECONDS) || 300,
30
);
const MAX_RECENT_EVENTS = 200;
const pageCacheStats = {
total: 0,
hits: 0,
misses: 0,
stores: 0,
bypasses: 0,
invalidations: 0,
labels: new Map(),
recentEvents: [],
lastResetAt: new Date().toISOString(),
};
function hashValue(value) {
if (!value) return '';
return crypto.createHash('sha256').update(String(value)).digest('hex');
}
function normalizeQuery(query = {}) {
return Object.keys(query || {})
.filter((key) => !CACHE_BUSTER_KEYS.has(key))
.sort()
.reduce((acc, key) => {
acc[key] = query[key];
return acc;
}, {});
}
function buildUserScope(req) {
if (req.user) {
return {
sub: req.user.sub || req.user.id || '',
role: req.user.role || '',
};
}
return {
authHash: hashValue(req.headers.authorization || ''),
};
}
function verifyAuthHeaderIfPresent(req) {
if (req.user) {
return true;
}
const authHeader = String(req.headers.authorization || '');
if (!authHeader) {
return true;
}
if (!authHeader.startsWith('Bearer ')) {
return false;
}
try {
req.user = jwt.verify(authHeader.substring(7), process.env.CUBEJS_SECRET);
return true;
} catch (_error) {
return false;
}
}
function getNamespacePrefix(namespace) {
return `page-response:${namespace}:`;
}
function buildCacheKey(req, namespace, version) {
return dataPageCache.buildHashedCacheKey(
getNamespacePrefix(namespace),
{
method: req.method,
path: `${req.baseUrl || ''}${req.path || ''}`,
query: normalizeQuery(req.query),
user: buildUserScope(req),
acceptLanguage: req.headers['accept-language'] || '',
},
version
);
}
function isCacheBypassed(req) {
const cacheControl = String(req.headers['cache-control'] || '').toLowerCase();
return (
cacheControl.includes('no-cache')
|| cacheControl.includes('no-store')
|| req.query?._refresh === '1'
|| req.query?.refresh === '1'
);
}
function defaultShouldCacheBody(body) {
if (body === undefined) return false;
if (body?.success === false) return false;
return true;
}
function recordPageCacheEvent(namespace, hit, details = {}) {
pageCacheStats.total += 1;
if (hit === 'redis') pageCacheStats.hits += 1;
if (hit === 'miss') pageCacheStats.misses += 1;
if (hit === 'store') pageCacheStats.stores += 1;
if (hit === 'bypass') pageCacheStats.bypasses += 1;
if (hit === 'invalidate') pageCacheStats.invalidations += 1;
const labelStats = pageCacheStats.labels.get(namespace) || {
label: namespace,
total: 0,
hits: 0,
misses: 0,
stores: 0,
bypasses: 0,
invalidations: 0,
lastHit: null,
lastSeenAt: null,
};
labelStats.total += 1;
labelStats.lastHit = hit;
labelStats.lastSeenAt = new Date().toISOString();
if (hit === 'redis') labelStats.hits += 1;
if (hit === 'miss') labelStats.misses += 1;
if (hit === 'store') labelStats.stores += 1;
if (hit === 'bypass') labelStats.bypasses += 1;
if (hit === 'invalidate') labelStats.invalidations += 1;
pageCacheStats.labels.set(namespace, labelStats);
pageCacheStats.recentEvents.unshift({
namespace,
hit,
path: details.path || '',
timestamp: new Date().toISOString(),
});
if (pageCacheStats.recentEvents.length > MAX_RECENT_EVENTS) {
pageCacheStats.recentEvents.length = MAX_RECENT_EVENTS;
}
}
function getPageDataCacheStats() {
return {
total: pageCacheStats.total,
hits: pageCacheStats.hits,
misses: pageCacheStats.misses,
stores: pageCacheStats.stores,
bypasses: pageCacheStats.bypasses,
invalidations: pageCacheStats.invalidations,
hitRate: pageCacheStats.total ? Number((pageCacheStats.hits * 100 / pageCacheStats.total).toFixed(2)) : 0,
lastResetAt: pageCacheStats.lastResetAt,
labels: Array.from(pageCacheStats.labels.values()).sort((a, b) => b.total - a.total),
recentEvents: pageCacheStats.recentEvents,
};
}
function resetPageDataCacheStats() {
pageCacheStats.total = 0;
pageCacheStats.hits = 0;
pageCacheStats.misses = 0;
pageCacheStats.stores = 0;
pageCacheStats.bypasses = 0;
pageCacheStats.invalidations = 0;
pageCacheStats.labels.clear();
pageCacheStats.recentEvents = [];
pageCacheStats.lastResetAt = new Date().toISOString();
}
function createPageDataCache(options = {}) {
const namespace = options.namespace || 'default';
const version = options.version || 'v1';
const redisTtlSeconds = Math.max(Number(options.redisTtlSeconds) || DEFAULT_REDIS_TTL_SECONDS, 30);
const enabled = options.enabled !== false;
const invalidateOnMutations = options.invalidateOnMutations !== false;
const shouldHandleRequest = typeof options.shouldHandleRequest === 'function'
? options.shouldHandleRequest
: () => true;
const shouldCacheBody = typeof options.shouldCacheBody === 'function'
? options.shouldCacheBody
: defaultShouldCacheBody;
return async (req, res, next) => {
if (!enabled || !shouldHandleRequest(req)) {
recordPageCacheEvent(namespace, 'bypass', { path: req.originalUrl });
return next();
}
if (req.method !== 'GET') {
if (invalidateOnMutations) {
res.on('finish', () => {
if (res.statusCode >= 200 && res.statusCode < 300) {
dataPageCache.deleteByPrefix(getNamespacePrefix(namespace)).catch((error) => {
console.warn(`[PageDataCache] ${namespace} invalidation failed:`, error.message);
});
recordPageCacheEvent(namespace, 'invalidate', { path: req.originalUrl });
}
});
}
return next();
}
if (!verifyAuthHeaderIfPresent(req) || isCacheBypassed(req)) {
recordPageCacheEvent(namespace, 'bypass', { path: req.originalUrl });
res.set('X-Page-Cache', 'bypass');
return next();
}
const cacheKey = buildCacheKey(req, namespace, version);
const cachedPayload = await redis.get(cacheKey);
if (cachedPayload?.body !== undefined) {
recordPageCacheEvent(namespace, 'redis', { path: req.originalUrl });
res.set('X-Page-Cache', 'redis');
return res.status(cachedPayload.statusCode || 200).json(cachedPayload.body);
}
recordPageCacheEvent(namespace, 'miss', { path: req.originalUrl });
res.set('X-Page-Cache', 'miss');
const originalJson = res.json.bind(res);
res.json = (body) => {
if (res.statusCode >= 200 && res.statusCode < 300 && shouldCacheBody(body, req)) {
redis.setex(cacheKey, redisTtlSeconds, {
statusCode: res.statusCode,
body,
cachedAt: new Date().toISOString(),
}).then((stored) => {
if (stored) {
recordPageCacheEvent(namespace, 'store', { path: req.originalUrl });
}
}).catch((error) => {
console.warn(`[PageDataCache] ${namespace} store failed:`, error.message);
});
}
return originalJson(body);
};
return next();
};
}
module.exports = {
createPageDataCache,
getPageDataCacheStats,
resetPageDataCacheStats,
};