Files
tamakyi c5c2ffae68 TripSense 行知 · 周边出行顾问:首版
零依赖 Node 服务,三步流程(选出发地 → 分析候选 → 生成方案 + 路线图 + 住宿参考)。

- 候选发现:高德 place/text 类型词跨城检索 + 内置景区/城市/小众库 + Photon 兜底
- 区县级「排除同城」判定(adname),过滤发生在轮转取样之前
- 路线图画的是 AI 方案里实际安排的每个地点,按正文顺序编号 + 虚线动线 + 真实高德底图
- 住宿:RollingGo 只读 CLI 取实时报价,无价格源时如实标注不编造
- 服务端内存缓存(指纹 + TTL + LRU),数据源层与 HTTP 层分别缓存
- 上下文瘦身:逐日天气只给 rank1,紧凑 JSON,实测约 1335 token
2026-11-05 20:25:03 +08:00

120 lines
4.2 KiB
JavaScript

'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 };