上篇《从 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

京都大区详情:Hero + 预订 CTA + 热门景点

特色美食与四季特色

子区域详情:金阁寺与岚山

景点详情:实用信息 + 找酒店/AI 规划 CTA

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

导航 mega menu:两列国家 + 悬停联动大图卡

5. 区域上下文预订(本次最有价值的设计)

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

区域上下文酒店:云南 3 城合并列表

实测云南(昆明+丽江+大理)31 家、东京+横滨 23 家,无越界城市混入;景点详情页的「找京都的酒店」则带 cityCode 精确到城市。这样”区域”就贯通了内容浏览到交易闭环,而不是一个纯展示层。

三、落地交通接驳

行程结果页新增「落地交通」卡,回答下机后”怎么去酒店”这个高频问题:

落地交通卡:打车估算 + 步行方案

  • API:GET /api/transfer/plan?itineraryId=,由行程里的到达机场与推荐酒店坐标计算;
  • 策略:优先高德实时公交/地铁整合规划(含票价)→ 驾车打车费估算(起步 13 元含 3km + 2.3 元/km)→ 近距(约 2km 内)补步行方案;拿不到实时数据时明确标注”示意估算,实际以平台计价为准”;
  • 实测参考:萧山机场 → 西湖酒店,地铁 19 号线 → 1 号线 66 分钟 7 元、打车 32.5km 约 81 元。

设计取舍:交通方案每次实时查询而不入库——票价与运营时间随时变,缓存的意义远小于准确性。

四、行程地图升级

上一版的逐日路线是两点直线,本版改为真实道路路线:逐日按途经点逐腿调用高德步行/驾车路径规划(≤5km 自动走步行),拼接为折线渲染,图例里程同步改为实际道路里程;规划结果服务端缓存,避免重复计费调用。

地图:周边 POI 图层 + 真实道路路线

地图上方新增周边图层按钮(🍜美食 / 🏨酒店 / 🏞️景点),点击即以行程位置为中心调高德 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),抓取 → 拼校验图 → 人工审 → 入库,形成可复跑的流水线。

七、踩坑记录(本周期)

  1. vue-router 同路由换参数不刷新:组件实例复用,onMounted 不重跑。全局修法是 RouterView 绑 :key="route.fullPath"。
  2. mega 下拉”闪没”:mouseleave 必须绑在包装层——绑在面板上时,鼠标从链接到面板之间还没进面板就先离开了;链接与面板之间的空隙用 .nav-mega::after 隐形桥盖住。
  3. 酒店页默认城市吃掉 region 参数:带 ?region=yn 进入时,cityCode 的默认值必须为空,否则默认 SYX 永远覆盖区域参数。
  4. target 里混入 IDE 编译的 ECJ 坏类:运行时报 “Unresolved compilation problem”——部署一律 mvn clean package,别信增量。
  5. open-in-view=false 下的 LAZY 关联:视图层访问懒关联直接 LazyInitializationException,懒得改 DTO 层级时,@Transactional 加在 controller 方法上可解(权衡过的取舍)。
  6. 浏览器自动化里 JsonNode 没有 stream():遍历用 for-each,别照抄 JDK 文档。

八、下一步

  • 区域内容扩容:中国大区的子区域化(目前仅日本 25 个)与美食/季节文案补齐;
  • 落地交通接入返程与站间换乘;
  • 酒店区域合并检索分页优化(当前一次拉全量再排序);
  • AI 规划结果与区域内容打通(行程 Day N 直接关联景点详情与子区域攻略)。

两篇文档覆盖了从 0 到 1 与从 1 到 1.5。工程上没有惊为天人的技术,全是”把坑一个个填平”的笨功夫——但 Demo 和产品之间的距离,恰恰就是这些笨功夫。