From ec50fcab72fec230a781f98f1b7f0f5875e9b77f Mon Sep 17 00:00:00 2001 From: shats Date: Wed, 27 Aug 2025 12:41:06 +0700 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D1=82=D1=8C=20=D0=BA=D1=8D=D1=88=D0=B8=D1=80=D0=BE=D0=B2=D0=B0?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D0=BE=D1=82=D0=B2=D0=B5=D1=82=D0=BE=D0=B2?= =?UTF-8?q?=20GET=20=D1=81=20=D0=B8=D1=81=D0=BF=D0=BE=D0=BB=D1=8C=D0=B7?= =?UTF-8?q?=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=D0=BC=20ETag=20=D0=B4?= =?UTF-8?q?=D0=BB=D1=8F=20=D0=BF=D0=BE=D0=B2=D1=8B=D1=88=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D1=8F=20=D0=BF=D1=80=D0=BE=D0=B8=D0=B7=D0=B2=D0=BE=D0=B4=D0=B8?= =?UTF-8?q?=D1=82=D0=B5=D0=BB=D1=8C=D0=BD=D0=BE=D1=81=D1=82=D0=B8=20=D0=B8?= =?UTF-8?q?=20=D1=83=D0=BB=D1=83=D1=87=D1=88=D0=B5=D0=BD=D0=B8=D1=8F=20?= =?UTF-8?q?=D0=BE=D0=B1=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D0=BA=D0=B8=20=D0=B7?= =?UTF-8?q?=D0=B0=D0=BF=D1=80=D0=BE=D1=81=D0=BE=D0=B2;=20=D0=BE=D0=B1?= =?UTF-8?q?=D0=BD=D0=BE=D0=B2=D0=B8=D1=82=D1=8C=20=D0=BE=D0=B1=D1=80=D0=B0?= =?UTF-8?q?=D0=B1=D0=BE=D1=82=D0=BA=D1=83=20=D0=BE=D1=88=D0=B8=D0=B1=D0=BE?= =?UTF-8?q?=D0=BA=20=D0=B2=20API=20=D0=B4=D0=BB=D1=8F=20=D0=B1=D0=BE=D0=BB?= =?UTF-8?q?=D0=B5=D0=B5=20=D0=B8=D0=BD=D1=84=D0=BE=D1=80=D0=BC=D0=B0=D1=82?= =?UTF-8?q?=D0=B8=D0=B2=D0=BD=D1=8B=D1=85=20=D0=BE=D1=82=D0=B2=D0=B5=D1=82?= =?UTF-8?q?=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/server.js | 33 ++++++++++++++++-------------- frontend/src/lib/api.js | 45 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 62 insertions(+), 16 deletions(-) diff --git a/backend/server.js b/backend/server.js index efa68d5..5ca970f 100644 --- a/backend/server.js +++ b/backend/server.js @@ -518,7 +518,8 @@ app.post('/api/domains', async (req, res) => { if (etag) { const current = await headS3ObjectEtag(FILE_KEY).catch(() => undefined); if (current && current.replace(/\"/g, '"') !== String(etag)) { - return res.status(412).json({ message: 'Precondition Failed: ETag mismatch' }); + const meta = await headMeta(FILE_KEY); + return sendError(res, 412, 'Precondition Failed: ETag mismatch', 'E_ETAG_MISMATCH', { currentEtag: current, meta }); } } } catch (e) { @@ -555,11 +556,11 @@ app.get('/api/asns', async (req, res) => { try { if (countOnly === 'true') { - // только количество - const { total } = await streamPaginatedText({ - key: 'bgp_data/asns.txt', q, offset: 0, limit: 0, - mapLine: (line) => ({}) - }); + const cacheKey = `asns:count:${q}`; + const cached = getCountOnlyCache(cacheKey); + if (cached != null) return res.json(format === 'std' ? { items: [], total: cached, meta: {} } : { total: cached }); + const { total } = await streamPaginatedText({ key: 'bgp_data/asns.txt', q, offset: 0, limit: 0, mapLine: () => ({}) }); + setCountOnlyCache(cacheKey, total); return res.json(format === 'std' ? { items: [], total, meta: {} } : { total }); } else if (limit !== undefined) { const { items, total } = await streamPaginatedText({ @@ -652,10 +653,11 @@ app.get('/api/domains-new', async (req, res) => { try { if (countOnly === 'true') { - const { total } = await streamPaginatedText({ - key: 'bgp_data/domains_community.txt', q, offset: 0, limit: 0, - mapLine: (line) => ({}) - }); + const cacheKey = `domains-new:count:${q}`; + const cached = getCountOnlyCache(cacheKey); + if (cached != null) return res.json(format === 'std' ? { items: [], total: cached, meta: {} } : { total: cached }); + const { total } = await streamPaginatedText({ key: 'bgp_data/domains_community.txt', q, offset: 0, limit: 0, mapLine: () => ({}) }); + setCountOnlyCache(cacheKey, total); return res.json(format === 'std' ? { items: [], total, meta: {} } : { total }); } else if (limit !== undefined) { const { items, total } = await streamPaginatedText({ @@ -748,10 +750,11 @@ app.get('/api/ip-ranges', async (req, res) => { try { if (countOnly === 'true') { - const { total } = await streamPaginatedText({ - key: 'bgp_data/ips.txt', q, offset: 0, limit: 0, - mapLine: (line) => ({}) - }); + const cacheKey = `ip-ranges:count:${q}`; + const cached = getCountOnlyCache(cacheKey); + if (cached != null) return res.json(format === 'std' ? { items: [], total: cached, meta: {} } : { total: cached }); + const { total } = await streamPaginatedText({ key: 'bgp_data/ips.txt', q, offset: 0, limit: 0, mapLine: () => ({}) }); + setCountOnlyCache(cacheKey, total); return res.json(format === 'std' ? { items: [], total, meta: {} } : { total }); } else if (limit !== undefined) { const { items, total } = await streamPaginatedText({ @@ -1219,7 +1222,7 @@ app.post('/api/locks/:resource', (req, res) => { const now = Date.now(); const existing = locks.get(resource); if (existing && existing.expiresAt > now && existing.owner !== owner) { - return res.status(423).json({ message: 'Resource is locked by another user', owner: existing.owner, expiresAt: existing.expiresAt }); + return sendError(res, 423, 'Resource is locked by another user', 'E_RESOURCE_LOCKED', { owner: existing.owner, expiresAt: existing.expiresAt }); } const expiresAt = now + Math.max(30, Math.min(600, Number(ttlSeconds) || 120)) * 1000; locks.set(resource, { owner, expiresAt }); diff --git a/frontend/src/lib/api.js b/frontend/src/lib/api.js index 51e2fc1..a5f9299 100644 --- a/frontend/src/lib/api.js +++ b/frontend/src/lib/api.js @@ -9,9 +9,42 @@ const api = axios.create({ }, }); +// Простое кеширование ответов GET по ключу запроса + ETag +const responseCache = new Map(); // key -> { etag, data, headers } +function buildCacheKey(config) { + try { + const url = config.baseURL ? new URL(config.url, 'http://x').toString().replace('http://x','') : config.url; + const params = config.params || {}; + const keys = Object.keys(params).sort(); + const qs = keys.map(k => `${k}=${encodeURIComponent(params[k])}`).join('&'); + return `${config.method || 'get'} ${url}${qs ? '?' + qs : ''}`; + } catch { + return `${config.method || 'get'} ${config.url}`; + } +} + // Авто-ретрай для идемпотентных GET: до 2 попыток с экспоненциальной задержкой api.interceptors.response.use( - (response) => response, + (response) => { + try { + const method = String(response?.config?.method || 'get').toLowerCase(); + if (method === 'get') { + const key = buildCacheKey(response.config); + const etag = response.headers?.etag; + if (etag) { + responseCache.set(key, { etag, data: response.data, headers: response.headers }); + } + // Если пришёл 304 (на всякий случай, axios обычно не кидает в success 304), подменим данными из кеша + if (response.status === 304) { + const cached = responseCache.get(key); + if (cached) { + return { ...response, status: 200, data: cached.data, headers: { ...cached.headers, 'x-from-cache': '1' } }; + } + } + } + } catch {} + return response; + }, async (error) => { const config = error?.config || {}; const isGet = String(config.method || 'get').toLowerCase() === 'get'; @@ -50,6 +83,16 @@ api.interceptors.response.use( // Request интерсептор: If-Match из etag (если не задан явный заголовок) api.interceptors.request.use((config) => { try { + // If-None-Match для GET на базе кеша + const method = String(config.method || 'get').toLowerCase(); + if (method === 'get') { + const key = buildCacheKey(config); + const cached = responseCache.get(key); + if (cached?.etag) { + config.headers = config.headers || {}; + if (!config.headers['If-None-Match']) config.headers['If-None-Match'] = String(cached.etag); + } + } // Если заголовок не указан, но в теле есть etag — пробуем проставить If-Match if (!config.headers?.['If-Match'] && config.data && typeof config.data === 'object' && config.data.etag) { config.headers = config.headers || {};