TripSense 行知 · 周边出行顾问:首版

零依赖 Node 服务,三步流程(选出发地 → 分析候选 → 生成方案 + 路线图 + 住宿参考)。

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