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