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
+654
View File
@@ -0,0 +1,654 @@
# 行知 · 周边出行顾问(TripSense)
按「时间 + 所在地」分析周边适合出行的目的地,叠加逐日天气、空气质量、公路与铁路可达性,给出可解释的备选清单;再由用户补充具体需求,交给任意 **OpenAI 兼容接口**生成可执行的逐日行程规划。
零 npm 依赖,仅使用 Node.js 内置模块(`http` / `fs` / `path` + 全局 `fetch`)。
---
## 快速开始
要求 Node.js >= 18。
```bash
cd tripsense
node server.js # 或 npm start
```
服务默认绑定 `0.0.0.0`,启动后会打印本机与局域网的全部访问地址,例如:
```text
➜ 本机访问: 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。
示例:
```bash
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](https://github.com/RollingGo-AI/rollinggo-hotel-skill-CN) 提供的是 **Claude Skill + 本机 CLI**,不是 MCP Server:底层是 npm 包 `@rollinggo/hotel`,可执行文件 `rgh`,认证走 OAuth 2.1(`rgh login`),没有 API Key。它能给出酒店的真实参考价、星级、距离与可预订链接,正好补上高德缺失的价格。
接入方式(默认开启 `auto`,装了才生效):
```bash
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` 跳过缓存)。若改了条件但结果看着像旧的,直接点它。
```bash
# 查看缓存状态 / 清空
curl http://127.0.0.1:8787/api/cache
curl -X DELETE http://127.0.0.1:8787/api/cache
```
> 缓存只在内存里,重启服务即清空。多实例部署时各实例独立,不共享。
请求体示例:
```bash
# 分析(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 秒完成 |
```bash
# 服务器上
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`):
```ini
[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、路况仍为实时数据。