TripSense 行知 · 周边出行顾问:首版
零依赖 Node 服务,三步流程(选出发地 → 分析候选 → 生成方案 + 路线图 + 住宿参考)。 - 候选发现:高德 place/text 类型词跨城检索 + 内置景区/城市/小众库 + Photon 兜底 - 区县级「排除同城」判定(adname),过滤发生在轮转取样之前 - 路线图画的是 AI 方案里实际安排的每个地点,按正文顺序编号 + 虚线动线 + 真实高德底图 - 住宿:RollingGo 只读 CLI 取实时报价,无价格源时如实标注不编造 - 服务端内存缓存(指纹 + TTL + LRU),数据源层与 HTTP 层分别缓存 - 上下文瘦身:逐日天气只给 rank1,紧凑 JSON,实测约 1335 token
This commit is contained in:
@@ -0,0 +1,615 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* TripSense · 周边出行顾问
|
||||
* 零依赖 Node HTTP 服务:静态托管 + 数据代理 + SSE 流式响应
|
||||
* 启动:node server.js (默认 http://0.0.0.0:8787,同网段设备可直接访问)
|
||||
*/
|
||||
|
||||
const http = require('http');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
|
||||
const cfg = require('./config');
|
||||
const S = require('./lib/sources');
|
||||
const R = require('./lib/hotels-rollinggo');
|
||||
const { analyze } = require('./lib/analyze');
|
||||
const { planEvents, resolveAI, testAI } = require('./lib/planner');
|
||||
const C = require('./lib/cache');
|
||||
|
||||
/**
|
||||
* 取「目的地所属城市名」,用于酒店库的城市兜底查询。
|
||||
*
|
||||
* 反向地理编码偶尔会因为网络抖动失败,reverseGeocode 会兜底返回「当前位置」——
|
||||
* 那是假地名,拿去查酒店库必然查空,然后被误判成「这家数据源没数据」。
|
||||
* 所以这里把假地名一律视为失败,并且重试一次。
|
||||
*/
|
||||
async function resolveCityName(lat, lon, gcfg) {
|
||||
for (let i = 0; i < 2; i += 1) {
|
||||
try {
|
||||
const rev = await S.reverseGeocode(lat, lon, gcfg);
|
||||
const name = String(rev?.name || '').replace(/\s+/g, '');
|
||||
if (!name || name === '当前位置') continue;
|
||||
return name;
|
||||
} catch { /* 重试 */ }
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
const PUBLIC_DIR = path.join(__dirname, 'public');
|
||||
const MIME = {
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
'.js': 'application/javascript; charset=utf-8',
|
||||
'.json': 'application/json; charset=utf-8',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.ico': 'image/x-icon',
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.woff2': 'font/woff2',
|
||||
'.map': 'application/json; charset=utf-8',
|
||||
};
|
||||
|
||||
function cors(res) {
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-AI-Base-Url, X-AI-Api-Key, X-AI-Model, X-AMAP-Key');
|
||||
}
|
||||
|
||||
function json(res, code, obj) {
|
||||
cors(res);
|
||||
res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8' });
|
||||
res.end(JSON.stringify(obj));
|
||||
}
|
||||
|
||||
function readBody(req, limit = 1024 * 1024) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let size = 0;
|
||||
const chunks = [];
|
||||
req.on('data', (c) => {
|
||||
size += c.length;
|
||||
if (size > limit) { reject(new Error('请求体过大')); req.destroy(); return; }
|
||||
chunks.push(c);
|
||||
});
|
||||
req.on('end', () => {
|
||||
const raw = Buffer.concat(chunks).toString('utf8');
|
||||
if (!raw) return resolve({});
|
||||
try { resolve(JSON.parse(raw)); } catch (e) { reject(new Error('请求体不是合法 JSON')); }
|
||||
});
|
||||
req.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
function startSSE(res) {
|
||||
cors(res);
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream; charset=utf-8',
|
||||
'Cache-Control': 'no-cache, no-transform',
|
||||
Connection: 'keep-alive',
|
||||
'X-Accel-Buffering': 'no',
|
||||
});
|
||||
res.write(': connected\n\n');
|
||||
}
|
||||
function sse(res, obj) {
|
||||
if (!res.writableEnded) res.write(`data: ${JSON.stringify(obj)}\n\n`);
|
||||
}
|
||||
|
||||
function serveStatic(req, res, pathname) {
|
||||
const rel = pathname === '/' ? 'index.html' : decodeURIComponent(pathname).replace(/^\/+/, '');
|
||||
const filePath = path.resolve(PUBLIC_DIR, rel);
|
||||
if (!filePath.startsWith(PUBLIC_DIR + path.sep) && filePath !== path.join(PUBLIC_DIR, 'index.html')) {
|
||||
return json(res, 403, { error: 'Forbidden' });
|
||||
}
|
||||
fs.stat(filePath, (err, stat) => {
|
||||
if (err || !stat.isFile()) {
|
||||
// SPA 兜底
|
||||
const fallback = path.join(PUBLIC_DIR, 'index.html');
|
||||
return fs.readFile(fallback, (e2, buf) => {
|
||||
if (e2) return json(res, 404, { error: 'Not found' });
|
||||
cors(res);
|
||||
res.writeHead(200, { 'Content-Type': MIME['.html'] });
|
||||
res.end(buf);
|
||||
});
|
||||
}
|
||||
cors(res);
|
||||
res.writeHead(200, {
|
||||
'Content-Type': MIME[path.extname(filePath).toLowerCase()] || 'application/octet-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
});
|
||||
fs.createReadStream(filePath).pipe(res);
|
||||
});
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ 路由
|
||||
|
||||
async function handle(req, res) {
|
||||
const u = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
|
||||
const pathname = u.pathname;
|
||||
if (req.method === 'OPTIONS') { cors(res); res.writeHead(204); res.end(); return; }
|
||||
|
||||
if (pathname === '/api/health') {
|
||||
return json(res, 200, { ok: true, service: 'tripsense', time: new Date().toISOString(), cache: C.stats() });
|
||||
}
|
||||
|
||||
// 缓存管理:查看 / 清空(本地调试用,不对外暴露敏感信息)
|
||||
if (pathname === '/api/cache' && req.method === 'GET') {
|
||||
return json(res, 200, { ...C.stats(), note: '分析/地理编码/周边POI/住宿/地图底图均已缓存' });
|
||||
}
|
||||
if (pathname === '/api/cache' && req.method === 'DELETE') {
|
||||
C.clear();
|
||||
return json(res, 200, { ok: true, cleared: true });
|
||||
}
|
||||
|
||||
if (pathname === '/api/config') {
|
||||
const ai = resolveAI(req.headers, cfg);
|
||||
return json(res, 200, {
|
||||
service: '行知 · 周边出行顾问 (TripSense)',
|
||||
ai: {
|
||||
baseUrl: ai.baseUrl,
|
||||
model: ai.model,
|
||||
hasKey: Boolean(ai.apiKey),
|
||||
mode: ai.apiKey ? 'live' : 'demo',
|
||||
configuredByEnv: Boolean(cfg.AI.API_KEY),
|
||||
},
|
||||
features: {
|
||||
amapRoad: Boolean(cfg.AMAP_KEY),
|
||||
railApi: Boolean(cfg.RAIL_API_URL),
|
||||
},
|
||||
limits: cfg.SEARCH,
|
||||
systemPrompt: cfg.SYSTEM_PROMPT,
|
||||
sources: [
|
||||
{ name: 'Open-Meteo', use: '地理编码 / 天气预报 / 历史气候 / 空气质量', key: false },
|
||||
{ name: 'Photon (OpenStreetMap)', use: '周边景点、公园、博物馆、山峰、邻近城市检索', key: false },
|
||||
{ name: 'OSRM', use: '公路驾车里程与耗时矩阵', key: false },
|
||||
{ name: 'BigDataCloud / Photon Reverse', use: '浏览器定位后的反向地理编码', key: false },
|
||||
{ name: '高德 Web 服务', use: '可选:国内驾车路径(需 AMAP_KEY)', key: true },
|
||||
{ name: '铁路接口', use: '可选:真实铁路/高铁耗时(需 RAIL_API_URL)', key: true },
|
||||
{ name: 'OpenAI 兼容接口', use: '结合用户需求生成行程规划(需 AI_API_KEY)', key: true },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
if (pathname === '/api/geocode' && req.method === 'GET') {
|
||||
const name = (u.searchParams.get('name') || '').trim();
|
||||
if (!name) return json(res, 400, { error: '缺少 name 参数' });
|
||||
// 高德对中文地名的覆盖远好于 Open-Meteo(后者连「河池」都查不到),
|
||||
// 所以有 Key 时优先走高德,取不到再退回 Open-Meteo。
|
||||
const amapKey = String(req.headers['x-amap-key'] || '').trim();
|
||||
const gcfg = amapKey ? { ...cfg, AMAP_KEY: amapKey } : cfg;
|
||||
// 地名是稳定的静态信息,缓存 7 天
|
||||
const out = await C.wrap(`geo:${name}:${gcfg.AMAP_KEY ? 'a' : 'o'}`, async () => {
|
||||
let results = [];
|
||||
let source = '';
|
||||
let amapError = '';
|
||||
let meteoError = '';
|
||||
if (gcfg.AMAP_KEY) {
|
||||
try {
|
||||
results = await S.amapGeocode(name, gcfg);
|
||||
if (results.length) source = '高德';
|
||||
} catch (err) { amapError = err.message; }
|
||||
}
|
||||
if (!results.length) {
|
||||
try {
|
||||
results = await S.geocode(name, gcfg, 8);
|
||||
if (results.length) source = 'Open-Meteo';
|
||||
} catch (err) { meteoError = err.message; }
|
||||
}
|
||||
return {
|
||||
results, source,
|
||||
amapError: amapError || undefined,
|
||||
meteoError: meteoError || undefined,
|
||||
};
|
||||
}, { ttlMs: 7 * 24 * 3600 * 1000 });
|
||||
return json(res, 200, out);
|
||||
}
|
||||
|
||||
if (pathname === '/api/reverse' && req.method === 'GET') {
|
||||
const lat = Number(u.searchParams.get('lat'));
|
||||
const lon = Number(u.searchParams.get('lon'));
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return json(res, 400, { error: '缺少合法 lat/lon' });
|
||||
// 反向地理编码结果稳定,缓存 7 天
|
||||
const k = `rev:${lat.toFixed(3)},${lon.toFixed(3)}`;
|
||||
const out = await C.wrap(k, async () => ({ place: await S.reverseGeocode(lat, lon, cfg) }),
|
||||
{ ttlMs: 7 * 24 * 3600 * 1000 });
|
||||
return json(res, 200, out);
|
||||
}
|
||||
|
||||
if (pathname === '/api/best-window' && req.method === 'GET') {
|
||||
const lat = Number(u.searchParams.get('lat'));
|
||||
const lon = Number(u.searchParams.get('lon'));
|
||||
const days = Math.min(5, Math.max(1, Number(u.searchParams.get('days')) || 2));
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return json(res, 400, { error: '缺少合法 lat/lon' });
|
||||
// 天气窗口随预报更新,缓存 3 小时
|
||||
const out = await C.wrap(`win:${lat.toFixed(3)},${lon.toFixed(3)},${days}`, async () => ({
|
||||
window: await S.bestWeatherWindow(lat, lon, days, cfg),
|
||||
}), { ttlMs: 3 * 3600 * 1000 });
|
||||
return json(res, 200, out);
|
||||
}
|
||||
|
||||
// 周边住宿参考:优先 RollingGo(有实时报价),否则退回高德 POI(无房态无价格)
|
||||
if (pathname === '/api/hotels' && req.method === 'GET') {
|
||||
const lat = Number(u.searchParams.get('lat'));
|
||||
const lon = Number(u.searchParams.get('lon'));
|
||||
const radius = Math.min(20, Math.max(1, Number(u.searchParams.get('radius')) || 10));
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return json(res, 400, { error: '缺少合法 lat/lon' });
|
||||
|
||||
const name = (u.searchParams.get('name') || '').trim();
|
||||
const checkIn = (u.searchParams.get('checkIn') || '').trim();
|
||||
const nights = Math.min(30, Math.max(1, Number(u.searchParams.get('nights')) || 1));
|
||||
const maxPrice = Number(u.searchParams.get('maxPrice'));
|
||||
const amapKey = String(req.headers['x-amap-key'] || '').trim();
|
||||
const gcfg = amapKey ? { ...cfg, AMAP_KEY: amapKey } : cfg;
|
||||
const wantRolling = cfg.HOTEL_PROVIDER !== 'amap';
|
||||
|
||||
// 房价随时间波动,缓存 30 分钟;
|
||||
// 查询本身要 spawn rgh CLI(慢),命中缓存能省掉这几百毫秒到数秒。
|
||||
const ck = `hotel:${name}:${lat.toFixed(3)},${lon.toFixed(3)}:${radius}:${checkIn}:${nights}:${maxPrice || ''}:${gcfg.AMAP_KEY ? 'a' : 'o'}`;
|
||||
const out = await C.wrap(ck, async () => {
|
||||
// 1) RollingGo:只读子命令,未装/未登录会自动跳过
|
||||
if (wantRolling && name) {
|
||||
const av = await R.availability(cfg);
|
||||
if (av.ready) {
|
||||
let rr = await R.searchHotels({
|
||||
name, lat, lon, checkIn, nights,
|
||||
maxPricePerNight: Number.isFinite(maxPrice) && maxPrice > 0 ? maxPrice : null,
|
||||
distanceMeters: radius * 1000,
|
||||
size: 8,
|
||||
}, cfg);
|
||||
// 景区名在酒店库里常常查不到(酒店是按城市建的),
|
||||
// 这时用反向地理编码拿到所属城市再查一次,再按坐标距离过滤,而不是直接放弃。
|
||||
let usedArea = name;
|
||||
if (!rr.hotels.length && !rr.error) {
|
||||
const city = await resolveCityName(lat, lon, gcfg);
|
||||
if (city && city !== name) {
|
||||
const rr2 = await R.searchHotels({
|
||||
name: city, lat, lon, checkIn, nights,
|
||||
maxPricePerNight: Number.isFinite(maxPrice) && maxPrice > 0 ? maxPrice : null,
|
||||
distanceMeters: Math.max(radius, 20) * 1000,
|
||||
placeType: '城市',
|
||||
size: 8,
|
||||
}, cfg);
|
||||
if (rr2.hotels.length) { rr = rr2; usedArea = city; }
|
||||
}
|
||||
}
|
||||
if (rr.hotels.length) {
|
||||
return {
|
||||
hotels: rr.hotels,
|
||||
priceSource: 'RollingGo 实时报价',
|
||||
provider: 'rollinggo',
|
||||
available: true,
|
||||
radiusKm: radius,
|
||||
area: usedArea,
|
||||
};
|
||||
}
|
||||
// 有 Key 时仍可退回高德;没有 Key 就把 RollingGo 的原因带回去
|
||||
if (!gcfg.AMAP_KEY) {
|
||||
return {
|
||||
hotels: [],
|
||||
provider: 'rollinggo',
|
||||
available: true,
|
||||
reason: rr.error || rr.message || 'RollingGo 没有返回结果',
|
||||
};
|
||||
}
|
||||
} else if (!gcfg.AMAP_KEY && cfg.HOTEL_PROVIDER === 'rollinggo') {
|
||||
return { hotels: [], provider: 'rollinggo', available: false, reason: av.reason };
|
||||
}
|
||||
}
|
||||
|
||||
if (!gcfg.AMAP_KEY) {
|
||||
const av = await R.availability(cfg);
|
||||
return {
|
||||
hotels: [], available: false,
|
||||
reason: av.ready ? '未提供目的地名称' : `未配置高德 Key,且 ${av.reason || 'RollingGo 不可用'}`,
|
||||
};
|
||||
}
|
||||
try {
|
||||
const r = await S.amapHotels(lat, lon, radius, gcfg);
|
||||
// 高德价格字段覆盖很差:配了价格源就补一次,补不到保持 null,不猜价
|
||||
const merged = await S.mergeHotelPrices(r.hotels, gcfg);
|
||||
return {
|
||||
hotels: merged.hotels,
|
||||
priceSource: merged.priceSource,
|
||||
provider: 'amap',
|
||||
available: true,
|
||||
radiusKm: radius,
|
||||
// 告诉前端这是降级来的,用户才知道为什么没有价格
|
||||
fallbackFrom: wantRolling && name ? 'rollinggo' : '',
|
||||
};
|
||||
} catch (err) {
|
||||
return { hotels: [], provider: 'amap', available: true, error: err.message };
|
||||
}
|
||||
}, { ttlMs: 30 * 60 * 1000 });
|
||||
return json(res, 200, out);
|
||||
}
|
||||
|
||||
// 住宿数据源可用性:给设置页显示 rgh 装没装、登没登录
|
||||
// 目的地周边的可点名地点(餐饮 / 景点 / 博物馆),供 AI 规划使用
|
||||
if (pathname === '/api/nearby-pois' && req.method === 'GET') {
|
||||
const lat = Number(u.searchParams.get('lat'));
|
||||
const lon = Number(u.searchParams.get('lon'));
|
||||
const radius = Math.min(20, Math.max(1, Number(u.searchParams.get('radius')) || 8));
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return json(res, 400, { error: '缺少合法 lat/lon' });
|
||||
const amapKey = String(req.headers['x-amap-key'] || '').trim();
|
||||
const gcfg = amapKey ? { ...cfg, AMAP_KEY: amapKey } : cfg;
|
||||
if (!gcfg.AMAP_KEY) {
|
||||
return json(res, 200, { available: false, reason: '未配置高德 Key', food: [], sights: [] });
|
||||
}
|
||||
// 周边 POI 是慢变数据,缓存 6 小时
|
||||
const k = `near:${lat.toFixed(3)},${lon.toFixed(3)},${radius},${gcfg.AMAP_KEY.slice(-6)}`;
|
||||
try {
|
||||
const out = await C.wrap(k, async () => {
|
||||
const r = await S.amapNearbyPois(lat, lon, radius, gcfg);
|
||||
return { ...r, radiusKm: radius };
|
||||
}, { ttlMs: 6 * 3600 * 1000 });
|
||||
return json(res, 200, out);
|
||||
} catch (err) {
|
||||
return json(res, 200, { available: true, error: err.message, food: [], sights: [] });
|
||||
}
|
||||
}
|
||||
|
||||
// 路线点解析:把方案正文里点名的地点逐个定位成坐标。
|
||||
// 前端把方案里出现的地点名按顺序 POST 过来,服务端用高德 POI 定位,
|
||||
// 返回有序路线点(画线路用)。名称查不到就返回 null,前端跳过并在图上注明。
|
||||
if (pathname === '/api/route-points' && req.method === 'POST') {
|
||||
let body;
|
||||
try { body = await readBody(req); } catch (err) { return json(res, 400, { error: err.message }); }
|
||||
const names = Array.isArray(body.names)
|
||||
? body.names.map((s) => String(s || '').trim()).filter(Boolean).slice(0, 20)
|
||||
: [];
|
||||
if (!names.length) return json(res, 400, { error: '缺少 names' });
|
||||
const amapKey = String(req.headers['x-amap-key'] || '').trim();
|
||||
const gcfg = amapKey ? { ...cfg, AMAP_KEY: amapKey } : cfg;
|
||||
if (!gcfg.AMAP_KEY) {
|
||||
return json(res, 200, { available: false, reason: '未配置高德 Key', points: [] });
|
||||
}
|
||||
const city = String(body.city || '').trim();
|
||||
const near = (Number.isFinite(Number(body.lat)) && Number.isFinite(Number(body.lon)))
|
||||
? { lat: Number(body.lat), lon: Number(body.lon) } : null;
|
||||
const points = [];
|
||||
const missing = [];
|
||||
for (const nm of names) {
|
||||
// 串行 + 间隔:高德并发 5 就会 CUQPS_HAS_EXCEEDED_THE_LIMIT
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const hit = await S.amapResolvePlace(nm, city, gcfg, near).catch((e) => {
|
||||
console.warn('[route-points]', nm, e.message);
|
||||
return null;
|
||||
});
|
||||
if (hit) points.push({ query: nm, ...hit });
|
||||
else missing.push(nm);
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await new Promise((r) => setTimeout(r, 120));
|
||||
}
|
||||
return json(res, 200, { available: true, points, missing });
|
||||
}
|
||||
|
||||
if (pathname === '/api/hotel-providers' && req.method === 'GET') {
|
||||
const av = await R.availability(cfg, true);
|
||||
// 设置页里填的 Key 只存在浏览器,必须由请求头带上来,否则会误报「未配高德 Key」
|
||||
const amapKey = String(req.headers['x-amap-key'] || '').trim();
|
||||
return json(res, 200, {
|
||||
provider: cfg.HOTEL_PROVIDER,
|
||||
amap: Boolean(amapKey) || Boolean(cfg.AMAP_KEY),
|
||||
amapSource: amapKey ? '页面设置' : (cfg.AMAP_KEY ? '服务端环境变量' : ''),
|
||||
rollinggo: { installed: Boolean(av.installed), ready: av.ready, reason: av.reason },
|
||||
readonly: true,
|
||||
note: '仅使用 status / search-hotels / hotel-detail 三个只读子命令,不会下单或取消订单',
|
||||
});
|
||||
}
|
||||
|
||||
// 真实地图底图:服务端代理高德静态地图,Key 不进前端 URL。
|
||||
// 只返回纯底图(不含 markers/paths),标记与连线由前端按 X-Map-Meta 投影后叠加。
|
||||
if (pathname === '/api/staticmap' && req.method === 'GET') {
|
||||
const raw = (u.searchParams.get('pts') || '').trim();
|
||||
if (!raw) return json(res, 400, { error: '缺少 pts 参数(lat,lon;lat,lon)' });
|
||||
const pts = raw.split(';').map((s) => {
|
||||
const [la, lo] = s.split(',').map(Number);
|
||||
return Number.isFinite(la) && Number.isFinite(lo) ? { lat: la, lon: lo } : null;
|
||||
}).filter(Boolean);
|
||||
if (!pts.length) return json(res, 400, { error: 'pts 不是合法坐标' });
|
||||
|
||||
const amapKey = String(req.headers['x-amap-key'] || '').trim();
|
||||
const gcfg = amapKey ? { ...cfg, AMAP_KEY: amapKey } : cfg;
|
||||
if (!gcfg.AMAP_KEY) return json(res, 200, { error: '未配置高德 Key' });
|
||||
|
||||
const width = Number(u.searchParams.get('w')) || 640;
|
||||
const height = Number(u.searchParams.get('h')) || 360;
|
||||
const meta = S.staticMapMeta(pts, gcfg, { width, height });
|
||||
const mapUrl = S.staticMapUrl(pts, gcfg, { width, height });
|
||||
if (!mapUrl || !meta) return json(res, 200, { error: '无法构造地图 URL' });
|
||||
|
||||
try {
|
||||
// 静态地图底图几乎不变,缓存 7 天(PNG 二进制直接存 Buffer)
|
||||
const k = `map:${pts.map((p) => `${p.lat.toFixed(3)},${p.lon.toFixed(3)}`).join(';')}:${width}x${height}:${gcfg.AMAP_KEY.slice(-6)}`;
|
||||
let hit = C.get(k);
|
||||
if (!hit) {
|
||||
const r = await fetch(mapUrl, { signal: AbortSignal.timeout(12000) });
|
||||
const ct = r.headers.get('content-type') || '';
|
||||
if (!ct.startsWith('image')) {
|
||||
const txt = await r.text().catch(() => '');
|
||||
return json(res, 200, {
|
||||
error: `高德静态地图:${txt.replace(/<[^>]+>/g, ' ').trim().slice(0, 200) || `HTTP ${r.status}`}`,
|
||||
});
|
||||
}
|
||||
const buf = Buffer.from(await r.arrayBuffer());
|
||||
C.set(k, { buf, ct }, 7 * 24 * 3600 * 1000);
|
||||
hit = { result: { buf, ct } };
|
||||
}
|
||||
const { buf, ct } = hit.result;
|
||||
cors(res);
|
||||
res.writeHead(200, {
|
||||
'Content-Type': ct,
|
||||
'Cache-Control': 'public, max-age=604800',
|
||||
'Content-Length': buf.length,
|
||||
// 前端靠它把经纬度投影成像素(center / zoom / size / GCJ-02 坐标)
|
||||
'X-Map-Meta': Buffer.from(JSON.stringify(meta), 'utf8').toString('base64'),
|
||||
});
|
||||
return res.end(buf);
|
||||
} catch (err) {
|
||||
return json(res, 200, { error: `静态地图请求失败:${err.message}` });
|
||||
}
|
||||
}
|
||||
if (pathname === '/api/analyze' && req.method === 'POST') {
|
||||
let body;
|
||||
try { body = await readBody(req); } catch (err) { return json(res, 400, { error: err.message }); }
|
||||
startSSE(res);
|
||||
let closed = false;
|
||||
res.on('close', () => { closed = true; });
|
||||
const progress = (stage, pct, text) => {
|
||||
if (!closed) sse(res, { type: 'progress', stage, pct, text });
|
||||
};
|
||||
try {
|
||||
// 前端设置里填的高德 Key 优先于服务端环境变量(仅影响周边 POI 与驾车路径)
|
||||
const amapKey = String(req.headers['x-amap-key'] || '').trim();
|
||||
const runCfg = amapKey ? { ...cfg, AMAP_KEY: amapKey } : cfg;
|
||||
|
||||
// 缓存命中就直接复用,不再打十几次外部请求(天气/路况几十分钟内不会变)
|
||||
const force = body.forceRefresh === true || body.forceRefresh === 'true';
|
||||
const ck = C.fingerprint(body, runCfg);
|
||||
const cached = force ? null : C.get(ck);
|
||||
if (cached) {
|
||||
progress('done', 100, '命中缓存');
|
||||
const result = {
|
||||
...cached.result,
|
||||
cache: { hit: true, ageMs: cached.ageMs, cachedAt: cached.cachedAt },
|
||||
};
|
||||
if (!closed) sse(res, { type: 'result', data: result });
|
||||
if (!closed) sse(res, { type: 'done' });
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
progress('start', 2, '开始分析');
|
||||
const result = await analyze(body, runCfg, progress);
|
||||
C.set(ck, result);
|
||||
if (!closed) sse(res, { type: 'result', data: { ...result, cache: { hit: false } } });
|
||||
} catch (err) {
|
||||
console.error('[analyze]', err);
|
||||
if (!closed) sse(res, { type: 'error', message: err.message });
|
||||
} finally {
|
||||
if (!closed) sse(res, { type: 'done' });
|
||||
res.end();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname === '/api/ai-test' && req.method === 'POST') {
|
||||
let body = {};
|
||||
try { body = await readBody(req); } catch (err) { return json(res, 400, { error: err.message }); }
|
||||
const ai = resolveAI(req.headers, cfg);
|
||||
if (body.baseUrl) ai.baseUrl = String(body.baseUrl).replace(/\/+$/, '');
|
||||
if (body.model) ai.model = String(body.model);
|
||||
if (body.apiKey) ai.apiKey = String(body.apiKey);
|
||||
try {
|
||||
return json(res, 200, await testAI(ai));
|
||||
} catch (err) {
|
||||
return json(res, 500, { ok: false, stage: 'server', message: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
if (pathname === '/api/plan' && req.method === 'POST') {
|
||||
let body;
|
||||
try { body = await readBody(req); } catch (err) { return json(res, 400, { error: err.message }); }
|
||||
startSSE(res);
|
||||
let closed = false;
|
||||
res.on('close', () => { closed = true; });
|
||||
const ai = resolveAI(req.headers, cfg);
|
||||
try {
|
||||
for await (const ev of planEvents(body, ai, cfg)) {
|
||||
if (closed) break;
|
||||
sse(res, ev);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[plan]', err);
|
||||
if (!closed) sse(res, { type: 'error', message: err.message });
|
||||
} finally {
|
||||
res.end();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname.startsWith('/api/')) return json(res, 404, { error: 'Unknown API' });
|
||||
return serveStatic(req, res, pathname);
|
||||
}
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
handle(req, res).catch((err) => {
|
||||
console.error('[server]', err);
|
||||
if (!res.headersSent) json(res, 500, { error: err.message });
|
||||
else res.end();
|
||||
});
|
||||
});
|
||||
|
||||
/** 列出本机所有可用的 IPv4 地址,用于打印局域网访问入口 */
|
||||
function lanAddresses() {
|
||||
const out = [];
|
||||
const ifaces = os.networkInterfaces();
|
||||
for (const [name, list] of Object.entries(ifaces)) {
|
||||
for (const ni of list || []) {
|
||||
if (ni.family === 'IPv4' && !ni.internal) out.push({ name, address: ni.address });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function printBanner(port) {
|
||||
const openToLan = cfg.HOST === '0.0.0.0' || cfg.HOST === '::';
|
||||
console.log('');
|
||||
console.log(' ┌──────────────────────────────────────────────────┐');
|
||||
console.log(' │ 行知 · 周边出行顾问 TripSense │');
|
||||
console.log(' └──────────────────────────────────────────────────┘');
|
||||
console.log(` ➜ 本机访问: http://127.0.0.1:${port}`);
|
||||
if (openToLan) {
|
||||
const addrs = lanAddresses();
|
||||
if (addrs.length) {
|
||||
for (const a of addrs) {
|
||||
console.log(` ➜ 局域网访问:http://${a.address}:${port} (${a.name})`);
|
||||
}
|
||||
} else {
|
||||
console.log(' ➜ 局域网访问:未检测到可用网卡地址');
|
||||
}
|
||||
}
|
||||
console.log(` ➜ AI 模式: ${cfg.AI.API_KEY ? `已配置 (${cfg.AI.BASE_URL} / ${cfg.AI.MODEL})` : '演示模式(未配置 AI_API_KEY,可在网页设置里临时填)'}`);
|
||||
console.log(` ➜ 公路数据: ${cfg.AMAP_KEY ? '高德 Web 服务' : 'OSRM(失败自动降级估算)'}`);
|
||||
console.log(` ➜ 铁路数据: ${cfg.RAIL_API_URL ? '自定义铁路接口' : '里程-速度估算'}`);
|
||||
if (openToLan) {
|
||||
console.log('');
|
||||
console.log(' ⚠ 已绑定 0.0.0.0:同网段的设备都能打开这个页面,且本项目没有登录鉴权。');
|
||||
if (cfg.AI.API_KEY) {
|
||||
console.log(' 服务端已配置 AI_API_KEY——同一网络内的其他人可以通过本服务消耗你的额度。');
|
||||
}
|
||||
console.log(' 仅在可信网络使用;要暴露到公网,请先加认证、限流与 HTTPS。');
|
||||
}
|
||||
console.log('');
|
||||
}
|
||||
|
||||
/**
|
||||
* 端口被占用时自动往后找(最多 10 个),避免因为桌面工具占了 8787 就起不来。
|
||||
* 做法是先用独立 socket 探测,确认空闲后再 listen——
|
||||
* 同一个 http.Server 反复 listen 在 Windows 上会 error 与 callback 同时触发,
|
||||
* 表现为 banner 打印两次、日志里的端口和实际监听不一致。
|
||||
*/
|
||||
function probePort(port) {
|
||||
return new Promise((resolve) => {
|
||||
const s = require('net').createServer();
|
||||
s.once('error', () => resolve(false));
|
||||
s.once('listening', () => s.close(() => resolve(true)));
|
||||
s.listen(port, cfg.HOST);
|
||||
});
|
||||
}
|
||||
|
||||
(async () => {
|
||||
let port = cfg.PORT;
|
||||
for (let i = 0; i < 10; i += 1) {
|
||||
if (await probePort(port)) break;
|
||||
console.warn(` ⚠ 端口 ${port} 已被占用,改用 ${port + 1}`);
|
||||
port += 1;
|
||||
}
|
||||
server.listen(port, cfg.HOST, () => printBanner(port));
|
||||
server.on('error', (e) => {
|
||||
console.error(`[server] 启动失败:${e.message}`);
|
||||
process.exit(1);
|
||||
});
|
||||
})();
|
||||
|
||||
module.exports = server;
|
||||
Reference in New Issue
Block a user