Files
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

655 lines
60 KiB
Markdown
Raw Permalink 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.
# 行知 · 周边出行顾问(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、路况仍为实时数据。