'use strict'; const U = require('./util'); /** * 分析结果缓存(服务端内存) * * 为什么需要:一次 analyze 要打 Photon / Open-Meteo / AQI / OSRM / 高德 * 十几次外部请求,耗时 6–15 秒。同样的出发地 + 日期 + 半径 + 兴趣, * 用户往往会反复点「分析」(换个兴趣再点、看到结果想再刷一次), * 而这些数据在几十分钟内基本不变(天气预报按小时更新)。 * * 设计要点: * 1. 键 = 规范化后的请求指纹(origin 坐标取整到 3 位 ≈ 100m,其余字段精确)。 * 高德 Key 也参与指纹——换 Key 会改变 POI 结果,不能串味。 * 2. 缓存的是**完整分析结果**,命中时直接复用,不重打外部请求。 * 3. TTL 默认 30 分钟:超过就重算。天气/路况本来就按小时更新。 * 4. 带 LRU 上限(默认 40 条),避免长时间运行把内存吃满。 * 5. 命中时在结果里写 cacheHit / cachedAt,前端要如实告诉用户"这是缓存"。 */ const DEFAULT_TTL_MS = 30 * 60 * 1000; const DEFAULT_MAX = 300; const store = new Map(); // key -> { at, result } /** 把请求归一化成稳定指纹 */ function fingerprint(body, cfg, extra = {}) { const o = body.origin || {}; const lat = Number(o.lat); const lon = Number(o.lon); const key = { // 坐标取整到 3 位小数(约 100 米),避免浮点尾数造成每次都不命中 lat: Number.isFinite(lat) ? Math.round(lat * 1000) / 1000 : null, lon: Number.isFinite(lon) ? Math.round(lon * 1000) / 1000 : null, name: String(o.name || '').trim().slice(0, 40), start: String(body.startDate || ''), end: String(body.endDate || ''), maxHours: body.maxOneWayHours, radiusKm: body.radiusKm, excludeLocal: body.excludeLocal !== false, localKm: body.localKm, transport: (body.transportPrefs || []).slice().sort().join(','), interests: (body.interests || []).slice().sort().join(','), // 高德 Key 参与指纹:换了 Key,POI 与路网结果会变,不能复用 amap: cfg && cfg.AMAP_KEY ? String(cfg.AMAP_KEY).slice(-8) : '', ...extra, }; return JSON.stringify(key); } function get(key) { const hit = store.get(key); if (!hit) return null; const ttl = hit.ttlMs || DEFAULT_TTL_MS; if (Date.now() - hit.at > ttl) { store.delete(key); return null; } // LRU:命中后移到末尾 store.delete(key); store.set(key, hit); return { result: hit.result, cachedAt: hit.at, ageMs: Date.now() - hit.at }; } function set(key, result, ttlMs) { if (store.size >= DEFAULT_MAX) { // 删最旧的一条 const oldest = store.keys().next().value; if (oldest !== undefined) store.delete(oldest); } store.set(key, { at: Date.now(), result, ttlMs: ttlMs || DEFAULT_TTL_MS }); } /** * 通用包装:命中缓存直接返回,否则执行 fn 并写回。 * @param {string} key 缓存键 * @param {Function} fn 实际取数逻辑 * @param {object} opt { ttlMs, force } */ async function wrap(key, fn, opt = {}) { if (!opt.force) { const hit = get(key); if (hit) return { ...hit.result, cache: { hit: true, ageMs: hit.ageMs, cachedAt: hit.cachedAt } }; } const result = await fn(); set(key, result, opt.ttlMs); return { ...result, cache: { hit: false } }; } /** * 裸缓存:命中直接返回原对象,**不注入 cache 字段**。 * * 用于数据源层(lib/sources.js)。这些函数的返回值会被原样塞进 AI 上下文, * 多一个 cache 字段既污染上下文又白白吃 token,所以这里保持对象干净。 * 「是否命中」由调用方按需通过 peek() 判断,或者让上层接口自己汇报。 */ async function memo(key, fn, ttlMs) { const hit = get(key); if (hit) return hit.result; const result = await fn(); set(key, result, ttlMs); return result; } /** 只看不取:用于判断某条底层数据是否已有缓存(不触发网络请求) */ function peek(key) { return get(key); } function clear() { store.clear(); } function stats() { return { entries: store.size, max: DEFAULT_MAX, ttlMs: DEFAULT_TTL_MS }; } module.exports = { fingerprint, get, set, wrap, memo, peek, clear, stats, DEFAULT_TTL_MS };