Files
tripsense/server.js
T
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

616 lines
26 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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;