Files
tripsense/README.md
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

60 KiB
Raw Blame History

行知 · 周边出行顾问(TripSense)

按「时间 + 所在地」分析周边适合出行的目的地,叠加逐日天气、空气质量、公路与铁路可达性,给出可解释的备选清单;再由用户补充具体需求,交给任意 OpenAI 兼容接口生成可执行的逐日行程规划。

零 npm 依赖,仅使用 Node.js 内置模块(http / fs / path + 全局 fetch)。


快速开始

要求 Node.js >= 18。

cd tripsense
node server.js          # 或 npm start

服务默认绑定 0.0.0.0,启动后会打印本机与局域网的全部访问地址,例如:

  ➜  本机访问:  http://127.0.0.1:8788
  ➜  局域网访问:http://192.168.1.100:8788   (WLAN 2)
  ➜  局域网访问:http://100.121.122.41:8788   (Tailscale)

手机、平板或同网段电脑直接用上面局域网访问的地址即可打开。启动日志同时会打印当前的 AI 模式、公路与铁路数据来源。

端口被占用时会自动往后找(最多 10 个),所以实际端口以日志为准。若只想自己用, 用 HOST=127.0.0.1 node server.js 启动,就只监听本机。


三步流程

步骤 用户操作 系统行为
① 出行条件 输入地名联想 / 浏览器定位、选日期区间、单程时长上限、搜索半径、是否排除同城、交通与兴趣偏好 地理编码 → 检索周边候选 → 批量取天气与 AQI → 公路矩阵 + 铁路可达性 → 评分排序
② 备选清单 查看方位罗盘、评分环、逐日天气、推荐理由、数据来源说明;勾选目的地 默认预选 Top 3 并给出提示;支持按评分/天气/距离/单程时长排序;可「只看小众」、可「对比已选」并排看关键指标
③ 行程规划 填写具体需求(同行人、预算、体力、禁忌等),点击生成;生成后可继续提要求迭代 清单数据 + 需求 + 本站预设提示词 → OpenAI 兼容接口流式输出 Markdown 方案

页面右上角「设置」分两块:AI 接口(地址、密钥、模型,含常用供应商预设、预设提示词查看/复制、强制演示模式)与 数据源增强(高德 Web 服务 Key)。两者都只存在浏览器 localStorage,随请求传给本地服务代理。

界面

没有引入任何第三方 UI 框架(React / Vue / Tailwind / Bootstrap 均无),全部是手写 CSS 与原生 DOM。视觉为简约主题:纯白底、中性灰分隔、单一蓝色强调(#2f6fed)、细边框 + 轻阴影 + 圆角,标题与正文统一无衬线、数据用等宽。所有颜色集中在 styles.css 顶部 :root,想换主色改一个 --accent 即可。

表单记忆

出发地、起止日期、单程时长、搜索半径、是否排除同城、交通方式、兴趣偏好都会存进 localStorage(键 tripsense.form),刷新页面或从手机切回来不必重填。

  • 日期会校验:存过的日期若已早于今天,自动回落到「今天 + 3 天」。
  • 只输入地名、还没从下拉里选中时,输入框文字会保留,但不会被当成已选出发地(分析时会提示先选)。
  • 完全重置:清站点数据,或控制台执行 localStorage.removeItem('tripsense.form')。

地名联想

输入出发地时按 340ms 防抖调用 /api/geocode。配了高德 Key 时高德优先,否则走 Open-Meteo。

为什么优先高德:Open-Meteo 基于 GeoNames,中文中小城市 / 区县覆盖很差——实测连「河池」都返回空,「金城江」更是查不到;高德能直接定位到区县中心。

几个兜底:

  • 结构化长地址:「杭州市西湖区」会按行政后缀截成「杭州」再查一次,命中后下拉顶部注明「按「杭州」为你找到这些」。只认「市 / 地区 / 州 / 区 / 县 / 镇 / 乡 / 街道 / 路 / 街 / 号」等后缀,不做无脑截字——否则「金城江」会被截成「金城」,命中台湾金门、美国加州的同名地点,比查不到更糟。
  • 完全查不到:不静默隐藏,而是显示「没找到「xxx」」,并按原因给建议(没配 Key 时提示去配、Key 无效时说明、网络超时时提示重试)。

设置里的「测试这个 Key」会实际请求一次高德,当场告诉你能不能用、为什么不能用。

移动端下拉紧贴输入框(间距 4px)、限高 46vh 可滚动,避免被软键盘顶出视口;窄屏下定位按钮收成 📍 图标,与输入框保持同一行——否则纵向排列会把下拉推到按钮下方,用户会以为没有联想。

排除同城

配了高德 Key 之后,周边检索会顺带返回大量本市城区的 POI(美术馆、寺庙、商场),清单很容易变成「本地一日游」。所以第一步提供 「排除出发地同城地点」 开关,默认开启:

语义要说清楚:排除的是「出发地所在的城市/城区」。 目的地本身所在的城不该被排除——比如出发地在河池市金城江区, 同市下辖的凤山、罗城、南丹(50–110km)恰恰是最该推荐的周边目的地。

判定分三层:

  1. 距离硬排除:< 8km 一律排除。「周边出行」不该包含步行 10 分钟就能到的地方——这一条同时挡掉了「窑埠古镇」(离市中心 1km)这类城郊点位。
  2. 区县级判定:出发地与候选点都有区县信息时,同区县才排除,跨县保留。高德 POI 的 cityname 是地级市(如「柳州市」),adname 才是区县(如「融水苗族自治县」「鱼峰区」),所以必须用 adname 判同城区。
  3. 距离兜底:没有区县信息时(内置库 / Photon),退回按 localKm(默认 30km)判定。
  • 过滤发生在轮转取样之前,不是对结果做后处理——否则同城点会先占满 maxCandidates 名额,把真正周边的地点挤掉。
  • localKm 会被压到不超过搜索半径的 50%,所以选 50km 半径时实际排除约 25km 内,不至于把清单清空。
  • 被排除了多少个会写进 stats.localExcluded 与 dataNotes,副标题也会显示「已排除出发地所在城区」。
  • 想连本地一日游一起看,取消勾选即可(excludeLocal: false)。

实测(河池市金城江区出发,250km 半径)

候选 28 个,覆盖河池下辖县(东兰红水河第一湾 72km、大化八里九弯 82km、巴马百魔洞 109km、凤山三门海 111km、环江月亮山 16km)、柳州方向(柳州古城 141km、窑埠古镇 142km、融水双龙沟 125km、柳州博物馆 141km)以及邻省(荔波小七孔 77km、西江千户苗寨 199km)。localExcluded 13 个。

候选池检索策略

候选来自四路,按「覆盖度从高到低、可信度从高到低」合并去重:

来源 覆盖 说明
内置知名景区库 + 城市库 全国 无密钥、必定有坐标,兜底主干
内置小众地点库 全国 206 条 带 niche 标记,约占候选配额 30%
高德 place/text 跨城 主力:一组目的地类型词(风景区 / 古镇 / 森林公园 / 地质公园 / 湿地公园 / 瀑布 / 溶洞 / 峡谷 / 梯田 / 温泉 / 博物馆 / 古村落 / 山水景区 / 草原 / 湖)在省级范围内检索
高德 place/around 50km 内 只补近处遗漏
Photon(OSM) 有限 公共实例限流严格,仅按分类 + bbox 取少量,失败即降级

关键约束:

  • 不能只查出发地一圈。高德 place/around 的 radius 上限 50km、返回按距离排序、offset 只有 25 条——以出发地为圆心连翻页,拿到的 150 条几乎全是市区的游园、亭子、码头,50km 外一个都搜不到。这是接口覆盖边界,不是参数问题。因此主检索改走 place/text 类型词跨城检索,再用直线距离过滤。
  • place/text 用 city=<省级或目的地市> + citylimit=false,才能拿到跨城目的地;结果每条都带 city(地级市)与 adname(区县),正好支撑「排除同城」的区县级判定。
  • Photon 分类检索用 bbox + 分类 作为稳定缓存键(公共实例限流,重复分析必须复用缓存)。
  • 候选上限 28 个,按「类型轮转取样」抽取,保证小众点位不会被热门点位挤掉。

评分口径

总分 0~100,由六项加权相加:天气舒适度 0~40、时间适配 0~26、兴趣匹配 0~14、距离 0~7、目的地辨识度 0~8、空气质量 0~6。整段行程都是坏天气时会触发硬门槛,不允许给出「强烈推荐」。每张卡片都带 breakdown、reasons、warnings,可解释。

耗时统一按人类可读格式输出(1小时38分 / 4分钟),不出现 1.07h 这类裸小数。AQI 按六档标注:优 / 良 / 轻度污染 / 中度污染 / 重度污染 / 严重污染。

天气数据的四种模式

模式 触发条件 数据来源
forecast 未来 16 天内 Open-Meteo 实时预报
forecast-past 区间含刚过去的日期 past_days 预报
archive 更早的历史日期 Open-Meteo 历史再分析
climatology 超出 16 天预报窗口 取去年同期气候,映射回所选日期,结果明确标注「历史气候参考」

/api/best-window 会在未来 16 天预报中寻找连续平稳天气窗口,前端选择出发地后自动提示并可一键应用。


数据源

数据源 用途 密钥 不可用时
Open-Meteo geocoding / forecast / archive / air-quality 地名检索、天气预报、历史气候、美标 AQI 免 重试 2 次后报错并提示
Photon (OpenStreetMap) 周边景点/公园/博物馆/山峰/古迹/城市检索 免 4 秒快速失败,切内置地名库
OSRM table/v1/driving 1→N 公路里程与耗时矩阵 免 降级为里程-速度估算并标注
BigDataCloud / Photon Reverse 浏览器定位后的反向地理编码 免 提示手动输入地名
内置 lib/places-cn.js 全国知名景区 + 主要城市坐标库 — 始终参与,保证任何城市都有可用清单
内置 lib/places-niche.js 206 条小众地点:古村、遗址、湿地、地质景观、洞穴、小馆、徒步线 — 始终参与,并保证约 30% 的候选名额
高德 Web 服务 geocode/geo / place/text / place/around(含 types=100000 住宿)/ direction/driving / staticmap(路线图真实底图) 中文地名检索、实时周边 POI、周边住宿参考、国内驾车路径、地图图层 AMAP_KEY 或页面「设置 → 数据源增强」 静默跳过并退回 Open-Meteo / OSRM / 内置库 / 方位示意图;Key 无效会在页面顶部或设置里直接报错
RollingGo rgh CLI(本机安装) 酒店实时参考价、星级、距离、可预订链接 npm i -g @rollinggo/hotel + rgh login 跳过,退回高德 POI;只调用只读子命令
铁路接口 真实高铁/城际耗时与票价 RAIL_API_URL 降级为里程-速度估算并标注
OpenAI 兼容接口 生成行程规划 AI_API_KEY 自动进入本地演示模式

Photon 公共实例常被限流,因此 lib/sources.js 采用「每类单独请求 + 并发限制 + 快速失败 + 内置库兜底」。名称过滤分三层:JUNK_NAME 去掉停车场/出入口等设施,POOR_NAME 去掉「土堆」「观景台」等无辨识度条目,LOW_VALUE_NAME 去掉「名字像景点但不是独立目的地」的点位(燕京八景中的小碑、外国使馆旧址等)——这三类过去会把真正值得去的地方挤出榜单。当实时 POI 完全不可用时,结果会带 stats.fallbackOnly,页面提示会明确写出「天气、空气质量与路况仍是实时数据,不受影响」,避免用户误以为整条链路都挂了。

小众地点库的坐标是近似 WGS-84,只用于天气与路网估算;结果里 source 会写「内置小众地点库」,不会伪装成实时检索。候选带 niche: true 与 region 字段,前端据此显示「小众」徽章并提供「只看小众」筛选。


配置

所有配置在 config.js,环境变量优先。

变量 默认值 说明
PORT / HOST 8787 / 0.0.0.0 监听地址;端口被占用时自动往后顺延,实际端口见启动日志
AMAP_KEY 空 高德 Web 服务 Key,开启国内驾车路径、实时周边 POI、中文地名检索与真实地图底图
HOTEL_PRICE_API_URL 空 可选价格源模板,支持 {name} {lat} {lon},期望返回 [{name, price}];用于补高德缺失的酒店价格
RAIL_API_URL 空 铁路接口模板,支持 {fromLat} {fromLon} {toLat} {toLon} {date} {fromName} {toName};期望返回 {available, durationMinutes, distanceKm, fareCny, note}
RAIL_API_KEY 空 铁路接口鉴权
AI_BASE_URL https://api.openai.com/v1 任意兼容 /chat/completions 的服务
AI_API_KEY 空 留空即演示模式
AI_MODEL gpt-4o-mini 模型名
AI_TEMPERATURE / AI_MAX_TOKENS / AI_TIMEOUT_MS 0.7 / 8000 / 120000 生成参数;推理模型思考很吃 token,额度不够会自动放大重试

SEARCH 段为检索调参(maxRadiusKm / photonConcurrency / photonTimeoutMs / maxCandidates 等),一般不改。

临时覆盖(仅原型用途)

前端通过自定义请求头把设置交给本地服务代理:

请求头 含义
X-AI-Base-Url 覆盖 AI_BASE_URL
X-AI-Model 覆盖 AI_MODEL
X-AI-Api-Key 覆盖密钥;__server__ = 用服务端配置;__demo__ = 强制演示模式
X-AMAP-Key 覆盖 AMAP_KEY,只影响 /api/analyze 的周边 POI 与驾车路径

前端「设置 → 数据源增强」填的高德 Key 就是通过 X-AMAP-Key 生效的,优先级高于服务端环境变量;清除后即回到免密钥数据源。Key 无效(如 INVALID_USER_KEY)时不会静默失败,stats.amapError 会带回原因并在页面顶部提示,同时公路自动退回 OSRM。

示例:

AI_BASE_URL="https://api.deepseek.com/v1" \
AI_API_KEY="sk-..." \
AI_MODEL="deepseek-chat" \
node server.js

OpenAI 兼容接口与预设提示词

服务只要求对方提供 POST {BASE_URL}/chat/completions,请求体为标准格式(model / messages / stream: true / temperature / max_tokens),解析 choices[0].delta.content,同时兼容非流式 JSON 响应与 reasoning_content。前端设置里内置以下预设:

供应商 Base URL 示例模型
OpenAI https://api.openai.com/v1 gpt-4o-mini
DeepSeek https://api.deepseek.com/v1 deepseek-chat
通义千问 https://dashscope.aliyuncs.com/compatible-mode/v1 qwen-plus
Kimi https://api.moonshot.cn/v1 moonshot-v1-8k
智谱 GLM https://open.bigmodel.cn/api/paas/v4 glm-4-flash
本地 Ollama http://localhost:11434/v1 qwen2.5:7b

预设提示词写死在 config.js 的 SYSTEM_PROMPT,页面设置弹窗可查看/复制。它向模型说明产品背景与输入 JSON 结构,并规定硬性规则:

  1. 只用输入给出的候选地与数据,禁止编造景点、票价、班次、营业时间;
  2. 估算值与历史气候必须显式标注「估算」/「历史气候参考」;
  3. userNeeds 与数据冲突时必须直接指出并给替代方案,不许假装满足;
  4. 先扣掉往返交通再安排游玩,节奏与同行人体力匹配;
  5. 安全优先,预算用 ¥ 并注明粗估;
  6. 输出固定 Markdown 结构:方案总览 / 逐日安排 / 天气与装备 / 交通方案 / 预算粗估 / 出发前清单 / 风险与备选。

dataNotes 会一并送入模型上下文,避免它把估算数据当作事实。未配置密钥时走本地 demoPlan() 规则引擎,输出同结构的示例方案并标注「演示模式」,因此整条链路无需任何密钥即可完整体验。

版本对比与保存

输出头部有一个版本下拉,把本次会话的每一版和本地保存的方案放在一起:

  • 选任一会话版本即可回看,对比上一版 会把当前版与它前一版左右并排展示(各自独立滚动),再点一次退出。
  • 保存 把当前版写进 localStorage(tripsense.saved,最多 20 条),下拉里显示成 11/5 15:02 · 北京市,选中即可载入并继续调整。
  • 下拉末尾的「清空已保存」可以一次性清掉本地记录。
  • 复制 / 下载都跟随当前查看的那一版,不会永远只导出最新版。

让 AI 能点名具体地点(nearby POI)

只给住宿 POI 时,模型只能说「附近有很多小吃」——因为它手里确实没有餐厅、景点数据,而提示词又禁止编造。所以生成方案前会多查一次目的地周边 POI:

  • GET /api/nearby-pois?lat=&lon=&radius=8 → 高德 place/around,types=050000|110000|140100(餐饮 / 风景名胜 / 博物馆),一次请求拿三类
  • 结果整理成 nearby = { area, radiusKm, food[], sights[] },每项含 name / type / distanceKm / rating / cost
  • 随 /api/plan 一起交给模型,SYSTEM_PROMPT 第 10、11 条要求:必须从 nearby 点名,写成「名称(类型,距目的地区域 X km,评分 Y,人均 ¥Z)」;只有某一类确实缺失时才允许退回「片区 + 筛选方法」,并且必须说明缺的是哪类数据

所以现在方案里会出现具体店名/馆名,而不是「某某古镇附近」。查不到时(没配高德 Key)行为与之前一致,只是没有点名依据。

上下文体积(省 token)

每次生成都要把 SYSTEM_PROMPT + 上下文 JSON 发给模型,所以两者都做了裁剪:

项 之前 现在
SYSTEM_PROMPT 2799 字 1028 字(规则合并,输出结构只留标题与要点)
每个候选的逐日天气 31 天 × 8 字段 只带行程覆盖的天数 × 7 字段
候选里附带的字段 road / rail / options / reasons / warnings 全带 只要 travel.bestHours/bestMode + 结论性字段
dataNotes 12 条全文 4 条 × 80 字
实测单次请求 约 2 万字符 约 4000 字符(system 1028 + 上下文 2956)

weather.summary 保留为对象而不是压成字符串——演示模式的规则引擎要用它的 avgHigh/avgLow 等统计值。

住宿建议与路线示意

两者都在方案定稿之后才出现——先有行程,再谈住哪、怎么走。

住宿建议

  • 落脚点取「已选里评分最高的目的地」,围绕它查 10km 内住宿;用户选的价位既用于筛选,也随需求传给模型。
  • 价位支持 五档预设 + 自定义区间(自定义可填「最低–最高」,如 ¥200–800)。自定义值存 localStorage,切换后会自动按新区间重查住宿,不用重新生成方案。
  • 数据源是高德 POI「住宿服务」(types=100000)。价格取 biz_ext.cost,缺失时明确显示「无标注价」,绝不编造;整片区域都没价格时会直接说明"无法按价位筛选,需自行核对"。每行带「查房价」外链(高德 URI API),方便直接核对。
  • POI 里没有房态和实时房价,预设提示词第 7–9 条要求模型不得编造房型、空房、精确价格,且住宿要靠近当天主要活动地、跨片区要说明是否换住处。
  • 未配置高德 Key 时只提示配置,方案里的住宿预算仍按所选价位给出。
  • 数据源降级要如实说明:实时报价源(RollingGo)没命中而退回到只有位置的高德 POI 时,界面会多写一句「实时报价源这次没命中,已退回到只有位置的酒店数据。」,不会让人以为这就是完整报价。

关于「酒店都没价格」

这是数据源的硬限制,不是解析 bug:高德 POI 的 biz_ext.cost 对住宿类目覆盖很差,多数酒店干脆不返回该字段(extensions=all 也已经带上)。处理方式:

  1. UI 坦白标注「无标注价」,并说明原因,不猜价;
  2. 每行给「查房价」外链,一键到地图/预订平台核对;
  3. 预留可插拔价格源:配置 HOTEL_PRICE_API_URL(模板支持 {name} / {lat} / {lon},返回 [{name, price}])后,后端会按酒店名匹配合并价格,命中时列表会显示 ¥380 起 且脚注标注「价格来自第三方价格源」。未配置时保持 null,不影响主流程。

住宿数据源:RollingGo(可选,能拿到真实报价)

rollinggo-hotel-skill-CN 提供的是 Claude Skill + 本机 CLI,不是 MCP Server:底层是 npm 包 @rollinggo/hotel,可执行文件 rgh,认证走 OAuth 2.1(rgh login),没有 API Key。它能给出酒店的真实参考价、星级、距离与可预订链接,正好补上高德缺失的价格。

接入方式(默认开启 auto,装了才生效):

npm i -g @rollinggo/hotel      # 装 CLI
rgh login                      # 浏览器 OAuth 授权(只有你自己能做这一步)
rgh status                     # 确认已登录

然后在网页「设置 → 数据源增强 → 住宿数据源」点「检测」,会显示 rgh 是否安装、是否已登录。

  • 只读白名单:lib/hotels-rollinggo.js 里 ALLOWED_SUBCOMMANDS 只允许 status / search-hotels / hotel-detail。price-confirm / book / pre-cancel / confirm-cancel 会触发真实资金操作,本适配器永不调用,也没有对应接口对外暴露。
  • 降级链:RollingGo(已登录)→ 高德 POI → 明确提示不可用。任一环节失败都不影响方案生成。
  • 价位直接下推:自定义/预设价位的上限会作为 --max-price-per-night 传给 CLI,由数据源侧筛选,而不是拿回来再过滤。
  • Windows 编码坑:全局安装得到的是 rgh.cmd,经 cmd.exe 转发时中文参数与本地化报错会乱码(实测「不是内部或外部命令」被解成 GBK 乱码,导致"是否安装"判断失效)。适配器会从 bin 脚本位置反查 @rollinggo/hotel/dist/index.js,直接用 node 执行,绕开 shell;同时对自由文本做白名单校验,杜绝命令注入。
变量 默认 说明
HOTEL_PROVIDER auto auto = 装了 rgh 且已登录就用 RollingGo;amap = 只用高德;rollinggo = 只试 RollingGo
RHG_BIN rgh rgh 可执行文件路径,也可指向 .../dist/index.js
ROLLINGGO_TIMEOUT_MS 25000 单次 CLI 调用超时

⚠️ 使用边界(重要):rgh login 把 OAuth 凭据写在本机配置文件(~/.hotel-cli/.env),一台机器只有一份登录态,CLI 也没有「传 token」这类多租户参数。所以:

  • 它天生是单账号工具,不存在「每个访客填自己的」这种用法。给多人用只有两种模式:① 站长统一配置一个账号(所有人共用);② 每个人在自己机器上跑一份服务、各自 rgh login。
  • 站长统一配置时,所有访客的查询都算在站长账号上:额度、计费、风控都在站长身上;把账号提供给第三方查询也可能触及服务条款。只适合自己 / 家人朋友这种可信小范围使用。
  • 要做公开站点,别用 CLI 兜:应走官方 API 或商务合作,或者干脆只保留「跳转到预订平台」的链接。
  • 想彻底关掉它:HOTEL_PROVIDER=amap,服务端就完全不会去碰 rgh。

实测数据(北京 · 故宫周边 5km · 2026-11-09)

查询 结果
1 晚,不限价 8 家,最低 ¥521/晚(北京福庭酒店 3.6km)、¥547/晚(国际艺苑大酒店 1.3km)
3 晚 6 家,最低 ¥456/晚(秋果酒店前门店,3 晚总价 1368)
1 晚 + --max-price-per-night 600 3 家,全部 ≤ ¥600(521 / 524 / 456)
  • price.lowestPrice 是每晚均价(CLI 原文:「3晚总价:1,563CNY(约521CNY/晚)」),所以界面显示 ¥521/晚,不是总价。
  • 每条结果带 bookingUrl(https://rollinggo.cn/pages/hotel/detail/...),界面直接做成「去预订」。

踩到的解析坑:rgh 用 dotenvx 加载配置,启动时会往 stdout 打一行 ◇ injected env (1) from ... // tip: ... { path: '/custom/path/.env' } ——这行自带花括号。原先「取第一个 { 到最后一个 }」会把日志和 JSON 粘在一起,JSON.parse 必然失败,表现为「明明有结果却 0 家」。现在 extractJSON() 先从 "success" / "hotelInformationList" 等业务字段回推对象起点,再按括号配对(跳过字符串内的括号)精确切分,已用 6 个用例覆盖(含字符串里带 } 的情况)。

路线图

路线图画的是方案里实际安排的每个地点,不是用户勾选的候选清单。

旧实现只取「已选候选」,而勾选一个城市目的地时清单往往只有 1 项, 于是图上就一个点——这不是数据问题,是取错了源。

现在的流程:

  1. 提取地点(public/app.js 的 extractPlanPlaces):从方案正文里收集三类地点——① nearby 里模型被强制点名的餐饮/景点(名字必定出现在正文);② 已选目的地;③ 正文里加粗的词。加粗词必须以场所类后缀结尾(景区/公园/博物馆/古镇/寺/山/岛/酒店…)才算地点,否则「演示模式」「自驾」「合计」「若遇降雨或大风」这类说明性词会被误当地点(黑名单永远补不全,所以用白名单)。
  2. 按行程顺序排列:按地点名在正文中首次出现的位置排序,得到先后次序,而不是清单顺序。
  3. 定位坐标:已有坐标的直接用;缺的走 POST /api/route-points,服务端用高德 place/text 按名字检索(city 传目的地所在市、citylimit=false、不限 types,否则会漏掉餐饮/酒店/场馆)。
  4. 画线路:真实高德底图 + 自绘编号圆点 + 虚线动线;下方附完整行程点清单(含类型),图上只画 9 个代表点。

定位的两道护栏(都很重要):

  • 距离护栏:名字里带外地地名会让高德匹配到同名 POI——实测「马鞍山马尾坡野生红花林场」被定位到 1211km 外的安徽马鞍山市。参考点超过 150km 一律不认,宁可返回 null 让界面标注「未能定位」,也不能在地图上画一个完全错误的位置。
  • 城市加分:查询「柳州马鞍山马尾坡」这类名字时,命中 cityname 与目的地一致的那条才加分,避免默认命中外地同名 POI。

其它约束:

  • 地图只画目的地,不画出发地:出发地往往在 100km 外,塞进取景范围会把目的地压成一个点。起点改用文字标注(方位 + 直线距离,如「出发地河池市金城江区在东方 142km」),降级示意图里才画出起点。
  • 静态地图最多承载 9 个点(服务端 staticMapMeta 同样按 9 点截取)。超出的按行程顺序均匀抽样,并保证首尾都在;否则图上只画得出前 9 个、图例却写着 18 个,对不上。
  • 相距 80m 内的点会被合并(同一片街区常见),避免叠在一起连不出线。
  • 标注做了避让:按 y 排序依次分配 6 个标签槽位(上/下/左/右),并记录已占用的矩形,冲突就换槽;全部冲突时只画编号不画名字——宁可少标也不能糊成一团。
  • 取景的 metersPerPx 下限为 6:行程点往往集中在城市几公里内,按真实跨度算会 zoom 到 16 级、几十个点全叠成一坨,反而看不出线路。
  • 有高德 Key 时显示真实地图底图:服务端代理高德静态地图(/v3/staticmap),Key 只在服务端使用,不出现在前端 URL 或 DOM 里。取图失败时自动降级为方位示意,并在图下说明原因。
  • 无 Key 时降级为按真实经纬度等距投影(经度按 cos(lat) 修正)的示意图。
  • 地图服务商只使用合规白名单(高德 / 腾讯 / 百度 / 天地图),不使用 Google / Apple / Mapbox / OSM 直连瓦片。

多轮调整

方案不是一次性的。生成完成后,输出框下方会出现输入条,写一句要求(「第二天改成室内为主」「总预算压到 1200」)点「继续优化」即可迭代。

  • 后端把上一版方案作为 assistant 消息、新要求作为 user 消息一起发给模型,并要求输出完整的新版(不是 diff 片段),免得用户还得自己拼。
  • 输出区顶部显示「第 N 版」,右侧「回到初版」可退回第一版做对比。
  • 演示模式下规则引擎无法真正理解增量需求,会在开头注明并重新生成通用方案,不会假装已经改好。
  • 上一版超过 12000 字会被截断,避免把上下文撑爆;previous 只用于多轮消息,不会重复塞进上下文 JSON。

AI 接口自检

设置里的「测试 AI 接口」会真的发一次最小请求(非流式、max_tokens: 32),把失败定位到具体环节,而不是只丢一句"没有输出":

显示 含义
可用 · 320ms · gpt-4o-mini 地址、密钥、模型都通
HTTP 401:Invalid API key 密钥无效或已过期
HTTP 404:model not found 模型名写错
接口通了但没返回内容(finish_reason=length) max_tokens 太小,或命中内容策略
无法连接:fetch failed 地址不可达 / 少了 /v1 / 需要代理
没有可用的 API Key 当前等同演示模式,不会发网络请求

为什么会出现「未获得任何输出」

这条提示只在流正常结束、但一个字的正文都没拿到时出现,常见原因:

  1. 模型只返回了思考内容(reasoning_content),正文为空。推理模型(DeepSeek 系、o1/o3/gpt-5 等)思考很耗 token,max_tokens 不够时正文一个字都剩不下。默认额度已提到 8000,并且检测到「只有思考没有正文」会自动放大到 16384 重试一次(顶部会显示「已把上限 8000 → 16384 自动重试」),不需要手动改配置。两次都不够才会报错并建议换非推理模型。
  2. 参数不被支持:新版模型会拒绝 temperature / max_tokens,返回 400 Unsupported parameter。代码会自动去掉这两个参数重试。
  3. 额度超过模型上限:重试到 16384 时若模型只支持 8192,会先降到 8192,再退到服务端默认值,避免"重试反而把请求撑爆"。
  4. 地址少了 /v1:请求打到 404 或返回 HTML,流里没有 data: 事件。
  5. 网关返回 200 但 body 为空(部分代理在鉴权失败时这样)。

这几种情况都会在输出区给出具体原因(含 finish_reason、收到的事件数、原始片段),推理模型思考时会显示「模型思考中…已 N 字」。


HTTP API

接口 方法 说明
/api/health GET 健康检查
/api/config GET 当前 AI 状态、数据源、限制、systemPrompt
/api/geocode?name=杭州 GET 地名联想
/api/reverse?lat=..&lon=.. GET 反向地理编码
/api/best-window?lat=..&lon=..&days=2 GET 未来 16 天内连续好天气窗口
/api/analyze POST SSE:progress → result → done
/api/plan POST SSE:meta → delta → done
/api/hotels?lat=..&lon=..&radius=10 GET 主目的地周边住宿参考(高德 POI,不含房态与实时房价;配了 HOTEL_PRICE_API_URL 会补价格)
/api/nearby-pois?lat=..&lon=..&radius=8 GET 目的地周边可点名地点(餐饮 / 景点 / 博物馆),供 AI 规划使用
/api/route-points POST 按地点名列表定位真实坐标(串行 + 120ms 间隔调高德),返回 points 与 missing,供路线图绘制
/api/hotel-providers GET 住宿数据源可用性:高德 Key 是否配置、rgh 是否安装/已登录(只读)
/api/staticmap?pts=lat,lon;lat,lon&w=680&h=400 GET 代理高德静态地图,返回 image/png;失败时返回 {error}。Key 走 X-AMAP-Key 头,不进 URL
/api/ai-test POST 用最小的非流式请求探测 AI 接口,返回各环节诊断(stage / status / ms / message)
/api/cache GET / DELETE 查看缓存条目数与上限;DELETE 清空全部缓存

缓存

服务端有一层内存缓存(lib/cache.js),带 TTL 与 LRU(上限 300 条)。命中时不再打外部数据源,这是高德 QPS 限流与接口抖动的主要缓冲。

数据 TTL 理由
分析结果(/api/analyze) 30 分钟 天气/路况按小时更新,短时间内重复分析没必要重算
地理编码 / 反向地理编码 7 天 行政区划与地名几乎不变
候选发现(Photon + 高德 POI) 6 小时 商家与景点会变,但不至于几分钟一变
周边 POI / 住宿 POI / 静态地图底图 6 小时 / 30 分钟 / 7 天 房价波动快,底图几乎不变
天气 / 空气质量 / 路网 / 铁路 3 小时 对应上游数据的刷新节奏

缓存键 = 规范化请求指纹。高德 Key 的后 8 位参与指纹——换 Key 会改变 POI 与路网结果,不能串味。地理编码缓存键把坐标取整到 3 位(约 100 米),避免浮点尾数导致永远不命中。

前端行为:命中缓存时页面顶部出现提示条「这份清单来自 N 分钟前的缓存」,并提供「重新获取最新数据」按钮(请求体带 forceRefresh: true 跳过缓存)。若改了条件但结果看着像旧的,直接点它。

# 查看缓存状态 / 清空
curl http://127.0.0.1:8787/api/cache
curl -X DELETE http://127.0.0.1:8787/api/cache

缓存只在内存里,重启服务即清空。多实例部署时各实例独立,不共享。

请求体示例:

# 分析(SSE 流)
curl -N -X POST http://127.0.0.1:8787/api/analyze \
  -H 'Content-Type: application/json' \
  -d '{"origin":{"label":"北京市","lat":39.9042,"lon":116.4074},
       "startDate":"2026-11-09","endDate":"2026-11-10",
       "maxOneWayHours":2.5,"radiusKm":150,
       "excludeLocal":true,"localKm":30,
       "transportPrefs":["自驾","高铁/铁路"],"interests":["自然风光","拍照"]}'

# 规划(SSE 流)
curl -N -X POST http://127.0.0.1:8787/api/plan \
  -H 'Content-Type: application/json' \
  -H 'X-AI-Api-Key: __demo__' \
  -d '{"origin":{...},"timeRange":{...},"constraints":{...},
       "shortlist":[...],"userNeeds":"带老人和小孩,两天一晚","dataNotes":[...]}'

/api/analyze 返回体含 origin / timeRange / constraints(含 excludeLocal、localKm)/ summary / candidates[] / dataNotes[] / stats(含 localExcluded、amapError);每个候选带 score / verdict / weather / road / rail / aqi / breakdown / reasons。


已知限制与数据口径

  • Photon 公共实例不稳定,实时 POI 可能整体降级到内置地名库(坐标为近似值),页面会明确提示。
  • 高德 Key 必须是「Web服务」类型(不是「Web端(JS API)」),且需在控制台启用;填错或配额用尽会在页面顶部报出原因,不会静默失败。未配置时周边 POI 与驾车路径自动使用免密钥数据源。
  • 未配置 RAIL_API_URL 时,铁路耗时与票价均为「里程-速度」估算;road.source 也可能为估算。
  • 超出 16 天预报窗口的日期使用去年同期气候,仅作参考,不可当作实际天气。
  • 所有估算、降级、历史气候替代都会写进 dataNotes 并展示在页面;票价、班次、门票、开放时间请临近出发前在官方渠道核对。
  • 本工具是决策辅助原型,不构成出行安全承诺。

开放访问(局域网)

默认绑定 0.0.0.0,同网段设备可直接访问。要让它真正可达,注意三件事:

  1. 防火墙:Windows 首次让 Node 监听端口时会弹窗询问,选「允许访问」。若当时点了取消,需要到「Windows Defender 防火墙 → 允许应用通过防火墙」里给 node.exe 勾上专用/公用网络。可用 Get-NetFirewallRule -DisplayName 'node.exe' 检查是否已有 Inbound Allow 规则。
  2. 网络类型:家用/公司 Wi-Fi 通常是「专用」或「公用」,两种都默认放行;若被识别为「域」网络而规则未覆盖,要单独放行。
  3. 同一网段:手机不能用 127.0.0.1(那是手机自己),要用启动日志里打印的局域网 IP。

安全边界(重要)

这个原型没有任何登录鉴权,绑定 0.0.0.0 意味着同网段任何人都能打开页面并调用 /api/*:

  • 若服务端配置了 AI_API_KEY,同一网络的人可以通过本服务消耗你的额度(X-AI-Api-Key: __server__ 就是走服务端密钥);启动日志在这种情况下会额外警告。
  • CORS 当前是 *,任意网页都能在浏览器里调用你本机的接口。
  • 没有限流,/api/analyze 每次都会打到 Open-Meteo / Photon / OSRM,反复调用可能让这些公共实例限流。

只在可信网络(家里、自己的热点)开放。要暴露到公网,先做:登录或 token、把 CORS 收紧到具体来源、加限流与超时、上 HTTPS 反向代理。

只给自己用:HOST=127.0.0.1 node server.js。

定位与 HTTPS

浏览器的 navigator.geolocation 只在安全上下文里可用:HTTPS,或 localhost / 127.0.0.1。

访问方式 定位
http://127.0.0.1:8788 ✅ 可用(localhost 属安全上下文)
http://localhost:8788 ✅ 可用
http://192.168.1.100:8788(手机 / 其他电脑) ❌ 浏览器拒绝,控制台报 Only secure origins are allowed

这是浏览器规范,不是代码问题。页面会提前把定位按钮置灰并给出中文说明,不会把英文底层报错弹给用户。手机上想用定位有三条路:

  1. 直接输入地名(最省事):地名联想走的是服务端地理编码,与定位无关。
  2. 套一层 HTTPS:用 tailscale serve、cloudflared tunnel 或 caddy 反代到本地端口,拿到可信 HTTPS 域名后用手机访问。
  3. 自签证书:需要在手机上安装并信任根证书,日常用比较麻烦。

局域网 http 下只是定位不可用,分析、天气、清单、AI 规划全部正常。


Linux / 服务器部署

可以直接跑,不需要改代码。 项目是纯 Node、零 npm 依赖,没有任何 Windows 专有调用;平台差异只集中在住宿适配器里,并且都有 Linux 分支。

已在 WSL Debian(内核 6.6,Node v22.11.0)实测通过:

项目 结果
node server.js 启动 正常,banner / 端口回退 / 网卡枚举均正常
/、/styles.css、/app.js 200
/api/config、/api/hotel-providers 正常(无 rgh 时正确报「未安装」)
/api/geocode?name=柳州 命中「柳州 · 广西」,中文无乱码
/api/staticmap 无 Key 时正确返回 {error:"未配置高德 Key"}
/api/analyze(柳州市 / 150km) 24 个候选、roadProvider=OSRM、6.3 秒完成
# 服务器上
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
sudo apt install -y nodejs
git clone <your-repo> /opt/tripsense && cd /opt/tripsense
HOST=0.0.0.0 PORT=8787 node server.js

systemd 示例(/etc/systemd/system/tripsense.service):

[Unit]
Description=TripSense
After=network.target

[Service]
WorkingDirectory=/opt/tripsense
ExecStart=/usr/bin/node server.js
Environment=HOST=0.0.0.0
Environment=PORT=8787
Restart=always
User=www-data

[Install]
WantedBy=multi-user.target

注意事项:

  • 静态资源用 path.join(__dirname, 'public'),所以与启动目录无关,systemd 里 WorkingDirectory 写对即可。
  • 不要把 PORT 改成 80/443:用 Nginx / Caddy 反代,顺带解决 HTTPS(定位功能需要安全上下文)。
  • HOST=0.0.0.0 后记得开放防火墙(ufw allow 8787),生产环境还应先补上鉴权与限流(见下节)。
  • 平台相关代码只有 lib/hotels-rollinggo.js 里的 rgh 调用:Linux 用 which、不需要 shell、全局安装在 /usr/local/bin 或 ~/.local/bin。WSL 特有:which rgh 可能命中 /mnt/c/... 下的 Windows 版(那是 shell 脚本,Linux 跑不起来),适配器已主动排除 /mnt/ 路径。
  • 服务器上装 RollingGo:npm i -g @rollinggo/hotel 后执行 rgh login。该命令是 OAuth 授权、没有「手动粘贴 code」的选项,无图形界面的服务器可能完成不了;兜底是在自己电脑上登录一次,然后把 ~/.hotel-cli/ 整个目录复制到服务器的同一路径。

生产环境建议

  • 密钥只放在服务端环境变量;前端 X-AI-* 覆盖仅为原型调试用途,上线前应移除或改为服务端会话鉴权。
  • 为 /api/* 增加认证、限流与超时保护,不要直接把 8787 暴露到公网。
  • 换用有 SLA 的地理编码 / POI / 路网服务,或自建 Photon / OSRM 实例;内置地名库只应作为兜底。
  • 对天气与路网结果加缓存(按坐标 + 日期),避免重复请求第三方。
  • SYSTEM_PROMPT 若需调整,改 config.js 即可,前后端无需同步(弹窗从 /api/config 读取)。

项目结构

tripsense/
├── server.js              零依赖 HTTP 服务、路由、SSE、静态托管
├── config.js              端点 / 环境变量 / 检索参数 / SYSTEM_PROMPT
├── package.json
├── lib/
│   ├── util.js            fetch 重试、并发限制、haversine、坐标转换、日期工具
│   ├── data.js            WMO 天气码、兴趣标签、三层名称过滤、各地点库汇总
│   ├── places-cn.js       内置全国景区 + 主要城市坐标库
│   ├── places-niche.js    内置小众地点库(206 条,古村/遗址/湿地/地质/洞穴等)
│   ├── sources.js         地理编码 / POI 发现 / 天气 / AQI / 路网 / 铁路 / 天气窗口 / 静态地图
│   ├── hotels-rollinggo.js RollingGo 住宿适配器(本机 rgh CLI,只读白名单)
│   ├── analyze.js         校验 → 编排 → 评分 → 汇总 dataNotes
│   └── planner.js         OpenAI 兼容流式代理 + 演示模式规划
├── public/
│   ├── index.html         三步流程 + 设置弹窗
│   ├── styles.css         FIELD NOTES 设计语言、响应式、罗盘、Markdown 输出
│   └── app.js             表单 / 定位 / SSE / 渲染 / 筛选 / 对比 / 复制下载
└── preview/               三步页面的实际渲染截图

验证记录(本次实测)

  • 全部 JS 文件 node --check 通过;静态资源 /、styles.css、app.js 均 200。
  • /api/health / /api/config / /api/geocode / /api/reverse / /api/best-window 正常。
  • /api/analyze SSE:北京市 2026-11-09 ~ 2026-11-10,total=28、avgScore≈69、rainyCount=0、weatherMode=forecast、roadProvider=OSRM;Top 5 为坡峰岭 78 / 故宫博物院 76 / 天坛公园 76 / 798艺术区 76 / 香山公园 76。
  • 小众地点链路:stats.nicheCount=67(150km 半径内命中数)、候选里 niche=true 占 10/28、dataNotes 写明「内置小众地点 67 个」、region 正常透传到卡片。
  • 逐日天气展开:展开后首卡 455px → 548px(+93px),同一行邻卡 486px → 486px(变化 0),布局不再被连带拉伸;勾选触发重渲染后 details[open] 数量保持不变。
  • 筛选与对比:「只看小众」28 → 10 且全部带徽章;「对比已选」面板 7 行 × 3 列正常渲染。
  • /api/plan 演示模式 SSE:约 2.7k 字符,7 个 Markdown 章节、3 张表格;另用本地 mock OpenAI 兼容服务验证过真实流式代理(stream=true、sysLen=1950)。
  • 无头 Chrome 完整走通三步流程并截图:Step 2 渲染 28 张卡片 / 28 个罗盘点 / 6 条数据说明,Step 3 渲染 7 个章节 / 3 张表格。
  • 排除同城(北京 / 150km):默认开启时 28 个候选、最小距离 30.8km、stats.localExcluded=27、副标题显示「已排除 30km 内同城」,Top 5 全是 60km 以上的异地;关闭后最小距离 0.5km、12 个候选落在 30km 内。50km 半径时 localKm 自动压到 25km,候选从 11 个回升到 16 个。
  • 开放访问:HOST=0.0.0.0 启动后实际监听 0.0.0.0:8788(8787 被占用,自动顺延),http://127.0.0.1:8788 与 http://192.168.1.100:8788 均返回 200,用局域网地址在无头 Chrome 打开页面渲染正常(title / eyebrow / Step 1 / 排除同城开关全部就位)。防火墙已有 node.exe 的 Inbound Allow 规则,覆盖 Private 与 Public。
  • 非安全上下文定位:http://192.168.1.100:8788 下 isSecureContext=false,定位按钮自动置灰(.geo-off)、#originHint 转为 .hint-warn 并给中文说明,点击 toast 为「局域网 http 页面不能调用定位:请直接输入地名,或改用 HTTPS 访问」;http://127.0.0.1:8788 下 isSecureContext=true、按钮与提示保持原样(回归通过)。
  • 地名联想(iPhone 视口 390×844 仿真,走局域网地址):杭州 2 条、西湖 4 条、杭州市西湖区 宽松命中并显示「按「杭州」为你找到这些」、zzzqqqxxx 显示「没找到…」空态;下拉与输入框间距恒为 4px、宽度 328px 与容器右边缘对齐(溢出 0)。
  • 中文地名覆盖:Open-Meteo 实测对「金城江」「金城江区」「河池」「河池市金城江区」全部返回空(GeoNames 中文小城市覆盖不足),「金城」只返回台湾金门 / 美国加州等同名地点。已接入高德 geocode/geo + place/text 作为有 Key 时的首选源;无 Key 或有 Key 但取不到时退回 Open-Meteo。
  • 宽松重试不再乱猜:只对含「市/区/县/镇/路」等后缀的结构化长地址截断(杭州市西湖区 → 杭州),「金城江」这种纯区县名不再被截成「金城」,避免返回同名异地。
  • 设置里「测试这个 Key」:空 Key → 「先填 Key 再测」;无效 Key → 「不可用:高德地理编码:INVALID_USER_KEY」。
  • AI 接口自检(本地 mock 服务,端口 9998):正常 → ok:true, 74ms, sample="可用";401 → HTTP 401:Invalid API key provided;空正文 → 接口通了但没返回内容(finish_reason=length);地址不可达 → 无法连接:fetch failed;拒绝 temperature 的模型 → 自动去掉参数重试后 ok:true 并附 note。
  • /api/plan 失败诊断(同一 mock):空正文 → 「接口返回了流但没有正文(收到 2 个事件,finish_reason=length)」并附原始片段;只有推理内容 → 「模型只输出了 N 字思考过程,没有正文」;401 → 带状态码与上游错误体。设置弹窗实测显示 可用 · 14ms · mock-gpt(绿)/ 没有可用的 API Key(红)。
  • 表单记忆:改单程时长 4h、半径 250km、取消「排除同城」、去掉「自然风光」、设出发地「河池 · 广西」→ 刷新后全部原样恢复(hoursLabel 也是「4 小时」,出发地提示「已定位:河池 · 广西(24.693, 108.085)」)。
  • 多轮调整:第一轮 messages 为 system,user;追加轮变成 system,user,assistant,user,assistant 携带上一版正文,末条 user 含新要求且要求「输出完整方案」。UI 实测:初次生成后出现输入条,追加后 meta 变「第 2 版 · 生成完成」、提示「当前是第 2 版,可以继续改」,点「回到初版」恢复第 1 版且 planText === basePlan。
  • 简约主题:实测 body 背景 rgb(255,255,255)、--accent=#2f6fed、顶栏白底、标题字体不含 Songti(已统一无衬线)、正文 rgb(23,26,31)。
  • 需求输入框:原来不在 .field 里所以没吃到样式,实测高度只有几十像素;修复后 225px、1px rgb(213,217,222) 边框、聚焦有蓝色描边。
  • 版本与保存:生成两版后下拉出现「第 1 版 / 第 2 版 · 第二天改成室内为主」,选「对比上一版」左右并排渲染(左「第 1 版(初版)」右「第 2 版 …」),点「保存」写入 tripsense.saved 并在下拉里出现 11/5 15:02 · 北京市。
  • 住宿接口:无 Key → {hotels:[],available:false,reason:"未配置高德 Key"};无效 Key → {available:true,error:"高德住宿检索:INVALID_USER_KEY"};进入第三步时左侧显示对应提示。
  • 住宿时机:进入第三步时 #planExtra 仍为隐藏(不再提前列酒店),生成方案后同时出现「行程路线示意」与「住宿建议」;#stayNote 显示「围绕「坡峰岭」· ¥200–800」(自定义区间),切换价位后同步更新。
  • 路线图只含目的地:无 Key 时渲染 3 个编号节点(坡峰岭 / 石林峡 / 野鸭湖湿地)、circle 共 6 个(每点两个环),不含出发地圆心与「出发地」图例,图例为「已选目的地(编号为推荐顺序,虚线为行程动线)」;#routeNote 为「3 个目的地 · 方位示意」。
  • 真实地图图层:/api/staticmap 无 Key → {error:"未配置高德 Key"};非法 pts → {error:"pts 不是合法坐标"};无效 Key → {error:"高德静态地图:{\"status\":\"0\",\"info\":\"INVALID_USER_KEY\"...}"},前端自动降级为方位示意,并在图下写明原因(标题保持简短,不换行)。
  • 静态地图 URL 结构核对:三点 74km 跨度 → zoom=9、size=680*400&scale=2、逐点 markers=medium,0x2f6fed,<n>:lng,lat、paths=4,0x2f6fed,0.35,,:...;单点 → zoom=16;5km → 13;200km → 8。坐标均为 GCJ-02(wgs84ToGcj02)。
  • 自定义价位:点「自定义」展开两个输入框 → 填 200 / 800 → currentLodging() 返回 {min:200,max:800,label:"¥200–800"}、chip 高亮「自定义」、提示「当前:每晚 ¥200–800」、localStorage.tripsense.form.lodgingCustom 正确落库;/api/plan 请求体里 lodging 为 {key:"custom",min:200,max:800}。
  • 住宿价格渲染(CDP 拦截 /api/hotels 造数据):三家全部无价格 → 顶部写明「无法按 ¥200–800 筛选」,出现 .price-warn 说明 biz_ext.cost 覆盖问题,每行「查房价」外链指向 uri.amap.com/search?keyword=...;改为 380/168/760/无价 四条 → 「命中 2 家 · 另有 1 家无价格标注」,168 被过滤、760 与 380 保留、无价照常列出并标「无标注价」,脚注变「价格来自第三方价格源」。
  • 演示模式修复:演示方案开头是「> ⚠️ 演示模式…」,原先用 startsWith('> ⚠️') 判断生成失败,导致演示模式下永远看不到路线图与住宿;改为按 errMsg 与「未获得任何输出」判定后恢复正常。
  • RollingGo 适配器(实测 @rollinggo/[email protected],装在临时目录、未登录):
    • rgh --help / search-hotels --help 均可执行,确认参数含 --max-price-per-night / --required-tag / --star-ratings / --distance-in-meter,没有 --json 开关(成功时直接输出 JSON)。
    • 未安装 → {installed:false, reason:"未安装 rgh(npm i -g @rollinggo/hotel)"};已安装未登录 → {installed:true, ready:false, reason:"rgh 已安装但未登录(执行 rgh login)"}。
    • 未登录时调用 search-hotels 不崩,返回可读错误「RollingGo 返回无法解析:搜索失败: 未登录,请先执行 rgh login」。
    • 白名单校验:book / price-confirm 均不在 ALLOWED_SUBCOMMANDS 内。
    • JSON 提取:在输出前混入「📦 有新版本可用!」时仍能正确捞出 hotelInformationList。
    • 注入防护:地点名含 & del /f /q C:\ 时直接拒绝,不进入子进程。
    • 服务端链路:/api/hotels 在无 Key 时返回「未配置高德 Key,且 rgh 已安装但未登录(执行 rgh login)」;设置里「检测」按钮实测显示「rgh 已安装但未登录(执行 rgh login)· 未配高德 Key」。
  • RollingGo 登录后真实数据(rgh status → ready:true):
    • /api/hotels?name=故宫&nights=1 → provider=rollinggo、8 家、带 cost / distanceKm / bookingUrl;nights=3 → 6 家;maxPrice=700&nights=3 → 4 家且全部 ≤ ¥700/晚(证明 --max-price-per-night 下推生效)。
    • 完整三步流程(demo 方案 + 真实住宿):住宿区显示「价位 ¥300–600 · 命中 3 家」,列表为 北京福庭酒店 ★4 · ¥521/晚 · 3.5km 去预订 / 国际艺苑大酒店 ¥547/晚 1.2km / 北京新侨饭店 ¥524/晚 2.3km,脚注「价格为 RollingGo 返回的每晚参考价,实际金额以下单页锁价为准」,无「无标注价」警告。
    • 设置里先填 Key 再点「检测」→「RollingGo 可用(实时报价)· 高德 Key 已配置(页面填写)」,修掉了「只读环境变量、误报未配 Key」的问题(前端用输入框当前值 + X-AMAP-Key 头)。
  • 两个环境相关修复:① 服务进程的 PATH 可能不含 npm 全局目录,resolveBin 补查 %APPDATA%\npm 等位置;② Windows 下 where rgh 首个结果是无扩展名的 sh 脚本(spawn 不起来),改为按 .exe > .cmd > .bat 排序后再取。
  • 静态地图 20003 修复:markers 原先写成多个同名参数(&markers=..&markers=..),高德判为非法并返回 {"status":"0","info":"UNKNOWN_ERROR","infocode":"20003"}(这不是 Key 失效)。改为单个 markers= 参数、组间用 | 分隔;同时 size 从 680×400 降到 640×360,因为 scale=2 会返回双倍像素,680×400×4 会超过高德 1024×1024 的上限。
  • 路线图单点场景:只有 1 个目的地时补出发点(origin 参数 → markers=small,0x6b7280,S),SVG 降级图同样画出发地并连线;多目的地时不含出发地。实测 1 个目的地 → 1 个编号节点 + 出发点 + polyline;3 个目的地 → 3 个节点、无出发点。
  • 住宿地点类型:柳州市 这类行政名原先按「景点」查 → RollingGo 返回 0 家 → 误降级到高德(于是又看到「价格未标注」)。新增 guessPlaceType()(市/地区/自治州→城市,区/县→区-县,其余→景点),并支持查空后换一种类型重试一次。实测 柳州市 → 城市类型 6 家;柳州 → 景点类型也能查到 4 家;坡峰岭 换城市类型虽返回重庆酒店,但被距离过滤全部丢弃,最终 0 家(正确)。
  • 住宿距离修正:数据源返回的 distanceInMeters 是错的(柳州城中区的酒店被标 64527 米,按坐标算实际 2.8km),改为优先用坐标自己算 haversine,并按查询半径过滤(城市类放宽到至少 20km),避免「去柳州却推荐重庆酒店」。
  • 住宿城市兜底:景区名在酒店库里常查不到(酒店按城市建库),查空后用反向地理编码取所属城市再查一次,再按坐标过滤。
  • 地图 20003 的两轮排查:① markers 写成多个同名参数 → 改为单个 + | 分组;② 手工拼 query 会留下裸 |,高德判为非法 → 改用 URL + URLSearchParams 构造,| 编码为 %7C。同时服务端做了渐进降级(full → markers → base),并通过 X-Map-Mode 告诉前端用了哪一档;/api/staticmap?probe=1 会依次试 编码版 / 未编码版 / 只标记 / 只底图 四种组合,设置页「测试这个 Key」直接调用它,一次就能定位是哪个参数被拒(返回里带脱敏后的完整 URL)。
  • 地图 20003 的第三个原因(已改):对照高德官方文档,markers 的 size 只接受 small / mid / large——原来写的是 medium,属于非法值;同时官方所有示例都用裸分隔符(, ; |),所以默认改回裸分隔符拼接,编码版仅作对照。今 URL 形如 markers=mid,0x2f6fed,1:109.41,24.30|mid,0x2f6fed,2:...,paths=4,0x2f6fed,0.35,,:...(weight 4 在 [2,15] 内)。
  • nearby POI:/api/nearby-pois 无 Key 时返回 {available:false, reason:"未配置高德 Key"};有 Key 时按 biz_ext.rating/cost 补齐评分与人均,按距离排序、按名称去重,餐饮与景点分开各取前 12 条。
  • 推理模型 token 不足的自动重试(mock 四个用例):正常模型 1 次请求即成功;「只有思考没有正文」→ 请求序列 [8000, 16384],顶部出现「已把上限 8000 → 16384 自动重试」并最终拿到正文;两次都只有思考 → 报「已把 max_tokens 提到 16384 仍不够,建议换非推理模型」;额度超限 → 序列 [8000, <无 max_tokens>],降级后成功。
  • 高德 Key 链路(用故意无效的 Key 验证):设置弹窗能填写并存入 localStorage → 请求带 X-AMAP-Key → 服务端覆盖 cfg.AMAP_KEY → 高德真实返回 INVALID_USER_KEY → stats.amapError 透传 → 页面顶部红边提示「高德接口拒绝请求:INVALID_USER_KEY」并给出「修改 Key」入口;同时公路正确回退为 roadProvider=OSRM(不再因"配了 Key"就谎称走高德)。清除 Key 后提示切换为「设置已更新,请重新分析」。
  • 路线图消失的根因:routeMapBlob 是从未声明过的模块级变量,'use strict' 下抛 ReferenceError;drawRouteMap() 是 async 且调用处没有 catch,异常变成未处理的 Promise 拒绝 → DOM 保持为空、尺寸 0×0,页面看起来"路线图不显示"。改为 state.routeMapBlob(在 state 里声明)并在调用处补 .catch(),失败时降级为方位示意并写明原因,杜绝静默失败。修复后实测 #routeMap 尺寸 563×326、img 与 svg 各 1 个、3 个编号标记 + 折线。
  • 缓存失效的根因(隐蔽):$('#analyzeBtn').addEventListener('click', runAnalyze) 会把 Event 对象传进 runAnalyze(force),而 Boolean(event) 恒为 true —— 于是每一次点击都是「强制刷新」,缓存永远不命中。改为只认严格的 force === true,并把监听器包成 () => runAnalyze()。实测:冷启动 12.8s → 第二次 518ms(24 倍),提示条正确显示;点「重新获取最新数据」后提示条正确隐藏。
  • 反向地理编码的假地名:reverseGeocode 彻底失败时兜底返回「当前位置」这个假地名,拿它去查酒店库必然落空,还会被误判成"该数据源没数据"。新增 resolveCityName() 把假地名视为失败并重试一次;数据源层缓存也不缓存假地名。
  • 缓存分层实测:/api/analyze 层缓存 30 分钟;数据源层(lib/sources.js)另有 Photon 分类检索 / 高德 POI / 天气 / AQI / 路网 / 铁路各自的缓存。清空后重建:discoverCandidates 首次 8137ms → 第二次 0ms;roadMatrix 首次 127ms → 第二次 0ms。

预览图见 preview/preview-step1.png、preview/preview-step2.png、preview/preview-step2-detail.png(展开逐日天气 + 对比面板)、preview/preview-step3.png、preview/preview-settings.png(设置弹窗含高德 Key)、preview/preview-amap-error.png(Key 无效时的提示)、preview/preview-map-fallback.png 与 preview/preview-map-price.png(只含目的地的路线图 + 自定义价位 + 住宿价格缺失说明)。

本轮(缓存 + 路线图修复 + 真实浏览器验收)新增:preview/preview-diag-route.png(高德真实底图 + 3 个编号标记 + 折线连线)、preview/preview-diag-hotel.png(RollingGo 真实报价 ¥2416/晚)、preview/preview-cache.png(缓存命中提示条)。

说明:上面这组截图恰好是在 Photon 公共实例全部超时(stats.fallbackOnly=true)时拍的,因此 Step 2 顶部能看到降级提示条——这正好是「实时源挂掉时页面怎么表现」的真实记录;天气、AQI、路况仍为实时数据。