上篇《从 0 到上线:我用 Spring AI + Vue3 做了一个 AI 旅游预订平台》复盘了 TripAgent 的整体架构与 AI 行程规划。
本篇是第二篇技术文档,记录 10 月 4 日这次大版本迭代:目的地从”城市平铺”重构为”34 大区”内容体系、行程落地交通接驳、地图真实道路路线与周边图层、账号安全加固与频控、数据质量治理。依然老规矩:功能、API 契约、实现要点、踩坑,全部来自实测。
一、本版本变更总览
| 模块 | 变更 | 状态 |
|---|---|---|
| 目的地体系 | 34 大区重构(中国 27 省级 + 日本 7 大区),25 个日本子区域,4 个新前端页面族 | ✅ 已验证 |
| 落地交通 | 行程结果页”落地交通”卡:机场 → 酒店高德实时公交/打车规划 | ✅ 已验证 |
| 行程地图 | 逐日路线改真实道路规划折线;行程条目带景点封面;周边 POI 图层 | ✅ 已验证 |
| 账号安全 | 邮箱验证码注册 + 图形验证码 + BCrypt + opaque token + 五类频控 | ✅ 已验证 |
| 数据质量 | 38 城封面逐张人工校验重抓;34 个日本景点权威坐标;H2 坐标精度修复 | ✅ 已验证 |
| 部署 | 公网入口全部关闭,仅留 SSH 隧道访问(安全优先) | ✅ 已验证 |
二、大区域目的地体系
1. 为什么要重构
旧版目的地是 30 个城市平铺,两个问题:一是颗粒度错位——用户心智里的旅行单位是”云南””京都”,不是”昆明代码 YIN”;二是内容深度不够——一个城市的 summary + 景点列表撑不起”种草”环节。参考 japan-travel.cn 的信息架构,把目的地重组为三层:
大区 Region(34 个) 中国 27 省级(云南=昆明+丽江+大理…)+ 日本 7 大区(北海道/东京/京都/大阪/冲绳/九州·福冈/中部·名古屋)
├── 城市 City 原有 Destination,新增 regionCode 归属
└── 子区域 SubRegion 25 个(仅日本):金阁寺与岚山、祇园清水、伏见稻荷…

2. 内容模型:region-content.json
大区内容走静态 JSON 而不是数据库——文案型内容(summary/美食/季节)变更频率低、需要人工润色,静态文件 + 启动加载是最省力的方案。每个大区的结构:
{
"code": "jp-kyo", "name": "京都", "country": "日本",
"cities": ["KYO"],
"summary": "千年古都,十七处世界文化遗产散落全城……",
"foods": [
{ "name": "怀石料理", "desc": "茶道衍生的极致会席……", "image": "/images/region-food/jp-kyo_1.jpg" },
{ "name": "抹茶甜品", "desc": "宇治抹茶的抹茶芭菲、生茶冻……" }
],
"seasons": [
{ "season": "春", "title": "哲学之道樱花 · 岚山花灯", "desc": "……" },
{ "season": "秋", "title": "红叶狩 · 永观堂夜枫", "desc": "……" }
]
}
美食图从 360 图片接口抓取后逐张人工校验入库(这个流程在数据治理一节展开)。日本大区额外带子区域列表,子区域有独立的地区介绍与景点集合。
3. API 契约
GET /api/regions # 34 个大区(国家筛选、搜索)
GET /api/regions/{code} # 聚合:summary + 城市列表 + 热门景点 + 美食 + 季节 + 子区域
GET /api/regions/{code}/sub/{subCode} # 日本子区域详情 + 景点
GET /api/regions/jp-kyo 返回节选(热门景点已带权威坐标与本地化封面):
{
"code": "jp-kyo", "name": "京都",
"cities": [{ "code": "KYO", "name": "京都", "hotScore": 94, "avgDailyBudget": 1000.0 }],
"hotAttractions": [
{ "id": 129, "name": "清水寺", "ticketPrice": 30.0, "openTime": "08:00-18:00",
"latitude": 34.9949, "longitude": 135.785, "coverImage": "/images/attractions-jp/KYO_1.jpg" }
],
"subRegions": [{ "code": "kiyomizu-gion" }, { "code": "kinkakuji-arashiyama" }]
}
4. 前端页面族
新增四个页面,形成完整的”种草 → 决策 → 预订”漏斗:
| 页面 | 路由 | 职责 |
|---|---|---|
| RegionListView | /destinations |
大图卡片流 + 国家筛选 + 城市搜索 |
| RegionDetailView | /destinations/region/:code |
japan-travel 风格长页:Hero/热门景点/特色美食/季节特色/按地区探索/城市入口 |
| SubRegionDetailView | /destinations/region/:code/sub/:subCode |
子区域介绍 + 景点 |
| AttractionDetailView | /attractions/:id |
景点介绍/门票/开放时间/地址 + “找酒店” CTA |




导航栏同步升级为 mega menu(目的地:中国/日本两列,悬停联动大图卡;旅游攻略:精选区卡;旅游计划:AI 规划入口):

5. 区域上下文预订(本次最有价值的设计)
大区页的「预订京都的酒店」跳 /hotels?region=jp-kyo,后端把区域内全部城市的酒店合并检索、统一排序,前端横幅提示”正在浏览该区域的酒店(含区域内全部城市)”:

实测云南(昆明+丽江+大理)31 家、东京+横滨 23 家,无越界城市混入;景点详情页的「找京都的酒店」则带 cityCode 精确到城市。这样”区域”就贯通了内容浏览到交易闭环,而不是一个纯展示层。
三、落地交通接驳
行程结果页新增「落地交通」卡,回答下机后”怎么去酒店”这个高频问题:

- API:
GET /api/transfer/plan?itineraryId=,由行程里的到达机场与推荐酒店坐标计算; - 策略:优先高德实时公交/地铁整合规划(含票价)→ 驾车打车费估算(起步 13 元含 3km + 2.3 元/km)→ 近距(约 2km 内)补步行方案;拿不到实时数据时明确标注”示意估算,实际以平台计价为准”;
- 实测参考:萧山机场 → 西湖酒店,地铁 19 号线 → 1 号线 66 分钟 7 元、打车 32.5km 约 81 元。
设计取舍:交通方案每次实时查询而不入库——票价与运营时间随时变,缓存的意义远小于准确性。
四、行程地图升级
上一版的逐日路线是两点直线,本版改为真实道路路线:逐日按途经点逐腿调用高德步行/驾车路径规划(≤5km 自动走步行),拼接为折线渲染,图例里程同步改为实际道路里程;规划结果服务端缓存,避免重复计费调用。

地图上方新增周边图层按钮(🍜美食 / 🏨酒店 / 🏞️景点),点击即以行程位置为中心调高德 place/around 搜索 3km POI 叠加标记(GET /api/map/nearby)。行程条目也补上了景点封面图(PlanItem.imageUrl,老行程打开时自动从景点库回填)。
五、账号体系与安全加固
上篇提到的”游客模式”已退役,本版补齐了完整账号体系,核心原则一句话:身份与额度都以服务端为准,前端传什么都不算数。
1. 认证链路
注册:邮箱 → 图形验证码(真人校验)→ 邮箱 6 位验证码(5min/一次性/错5次作废/60s冷却)→ 落库(BCrypt)
登录:用户名密码 + 图形验证码 → 签发 opaque token(auth_tokens 表,7 天滑动过期)
鉴权:订单/预订/AI 接口强制登录,userId 一律从 token 解析,请求体里的 userId 字段直接忽略(防越权)
- SMTP 未配置时自动回落”日志模拟模式”(验证码打印到应用日志),本地开发零成本;
- 密码 BCrypt 哈希存储;token 是不透明随机串而非 JWT——服务端可即时吊销,代价小收益大。
2. 频控矩阵
| 维度 | 限额 |
|---|---|
| AI 行程 | 单用户 30/天、单 IP 30/小时、全站 300/天兜底 |
| 验证码 | 30/10min·IP |
| 登录 | 10/15min·IP + 用户名 |
| 注册 | 5/小时·IP |
取 clientIp 时取 X-Forwarded-For 的最后一跳,防止伪造头绕过限流。
3. 部署安全
公网入口全部关闭:阿里云防火墙只留 22/ICMP,80/443 全禁,后端仅监听 127.0.0.1,H2 控制台关闭。访问走 SSH 隧道:
ssh -N -L 9999:127.0.0.1:80 admin@116.62.42.1 # 本地打开 http://localhost:9999
Demo 项目不承接公网流量,把攻击面收到一条 SSH 通道,是性价比最高的安全方案。
六、数据质量治理
这轮花时间最多、也最值得的一块。上篇说过”演示数据再好看也是假的”,但这轮发现:爬来的数据不校验,比假数据更有害。
- 封面图翻车:目的地/景点封面最初来自必应图片爬虫、无内容校验——福冈的封面赫然是一张”扁平疣”医学科普图。修复:38 城封面用收紧的地标查询词重抓,生成校验拼图逐张人工过目,不合格换词重抓;图片全部下载本地化(
static/images/destinations/{CODE}.jpg),启动时强制替换,不再依赖外链。 - 日本景点坐标缺失:DB 里日本景点没有坐标,地图标记其实是前端伪随机撒点。修复:34 个景点补权威坐标 + 校验过的封面图,启动时 initializer 修正。
- H2 精度坑:坐标列被默认建成
DECIMAL(?, 2)——小数只有 2 位,约 1km 的定位误差,再权威的坐标存进去也会漂。扩宽为DECIMAL(9,6)(注意 H2 表名是复数ATTRACTIONS,单数表名 ALTER 直接 Table not found)。 - 搜索源结论:必应对”日本景点中文名”这类小众查询的图片结果基本不可用;360 图片 JSON 接口国内稳定可用,作为兜底源。
- 脚本沉淀在
tools/city-images/(fetch_bing_covers.py/fetch_japan_attrs_360.py/fetch_food_images.py),抓取 → 拼校验图 → 人工审 → 入库,形成可复跑的流水线。
七、踩坑记录(本周期)
- vue-router 同路由换参数不刷新:组件实例复用,
onMounted不重跑。全局修法是RouterView绑:key="route.fullPath"。 - mega 下拉”闪没”:
mouseleave必须绑在包装层——绑在面板上时,鼠标从链接到面板之间还没进面板就先离开了;链接与面板之间的空隙用.nav-mega::after隐形桥盖住。 - 酒店页默认城市吃掉 region 参数:带
?region=yn进入时,cityCode 的默认值必须为空,否则默认 SYX 永远覆盖区域参数。 - target 里混入 IDE 编译的 ECJ 坏类:运行时报 “Unresolved compilation problem”——部署一律
mvn clean package,别信增量。 open-in-view=false下的 LAZY 关联:视图层访问懒关联直接 LazyInitializationException,懒得改 DTO 层级时,@Transactional加在 controller 方法上可解(权衡过的取舍)。- 浏览器自动化里 JsonNode 没有
stream():遍历用 for-each,别照抄 JDK 文档。
八、下一步
- 区域内容扩容:中国大区的子区域化(目前仅日本 25 个)与美食/季节文案补齐;
- 落地交通接入返程与站间换乘;
- 酒店区域合并检索分页优化(当前一次拉全量再排序);
- AI 规划结果与区域内容打通(行程 Day N 直接关联景点详情与子区域攻略)。
两篇文档覆盖了从 0 到 1 与从 1 到 1.5。工程上没有惊为天人的技术,全是”把坑一个个填平”的笨功夫——但 Demo 和产品之间的距离,恰恰就是这些笨功夫。