ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

SpringBoot智能旅游行程规划系统:从算法到工程落地

SpringBoot智能旅游行程规划系统:从算法到工程落地

朋友问我:你帮我看看,去某城市玩三天,怎么安排才不折腾?我打开好几个旅游App,收藏了一堆攻略,结果发现同一个博物馆被三个攻略推荐了三个不同时段,而市中心的老牌景点和城南的古镇,在攻略里被安排在相邻两天,实际打车要一个多小时。折腾久了我就想,智能旅游行程规划系统这类工具,缺的从来不是景点数量,而是把景点、距离、时间、个人口味捏在一起的那个“排序大脑”。

这个项目就是我做的一套基于SpringBoot的智能旅游行程规划系统。用户只需要填下城市、天数、偏好标签、必去清单,后台就能自动算出一份按天、按时间线展开的行程单,包含每个景点的建议游玩时长、景点之间的移动时间、每日是否顺路、费用估算甚至雨天备选方案。它不仅适合正在做Java后端课程设计、毕业设计的人参考,更适合想把手里的SpringBoot工程从“增删改查”往“有算法含量”方向推一把的开发者。下面我把整个设计和实现过程拆开讲清楚,包括踩过的坑和最后保留下来的取舍。

1. 痛点是真痛点:为什么收藏了50篇攻略反而更焦虑

1.1 旅游攻略解决的是“有什么可玩”,不是“怎么玩得顺”

市面上的攻略App、博主游记,本质上都在做一件事:把“这个城市有什么值得去”讲清楚。它们的信息组织方式是景点维度的——每个景点配一段介绍、几张照片、一篇打卡心得。但用户真正到决策阶段,面对的问题是:我只有三天,住在城市某个位置,想逛博物馆也想吃美食街,还不想一天之内在城南城北之间来回跑。这个问题没有任何一篇攻略能直接回答,因为答案高度依赖你的位置、你的时间预算、你的体力和你的偏好。

我当时调研了一圈,发现很多人的“手工规划流程”是这样的:先按名气筛出15个景点,再按地理位置打开地图手动分组,最后再估算每个景点停留时间,把一天塞满8小时。整个过程半小时起步,换一个城市全部重来。一旦中途发现某个景点周二闭馆,整条路线就要推倒重排。这种重复劳动,说到底就是规划逻辑没有被系统化。

1.2 把“行程规划”翻译成系统能计算的三个子问题

做这套系统的第一件事,是把模糊需求拆成可计算的问题。我把它拆成了三个:

  • 子问题一:候选景点召回。给定城市和用户偏好,从景点库里筛选出值得排进行程的Top N景点,并给出一个优先级得分。
  • 子问题二:行程分天与排序。给定N个候选景点的坐标、建议游玩时长、开放时间,以及用户住址和总天数,生成每天一组顺序合理的景点序列。
  • 子问题三:动态修正。在时间窗口有冲突、天气变化、用户手动增删景点后,局部调整而不是每次全量重算。

这三个子问题对应到工程上,分别是推荐模块、路线规划引擎和调整接口。我在前期设计时反复提醒自己:不要一上来就追求“全自动完美路线”,先把这三个子问题跑通,再逐步加复杂度。事实证明这个策略是对的——第一部分纯粹是数据与CRUD,第二部分是算法模型,第三部分才是真正体现“智能”的地方。

2. 模块化单体:SpringBoot工程的边界划分与目录设计

2.1 为什么是SpringBoot + 模块化单体,而不是微服务

这个系统定位是个人或小团队能维护的中等复杂度后端工程,目标场景是单机部署、几万用户量级、核心流程串行计算。它不需要微服务那套注册中心、配置中心、链路追踪,加了反而让开发和调试成本翻倍。SpringBoot在这个场景的优势很明显:起步快、生态全、约定优于配置,一个内嵌容器就能把Web层、数据访问层、定时任务、缓存客户端全部收进同一个进程。

我选的是SpringBoot 2.7.x + JDK11的稳定组合,构建工具用Maven。如果你新开工程直接用SpringBoot 3.x也没问题,但要注意JDK至少要17,并且原来的javax.包要切换成jakarta.,网上大部分老教程里这段代码直接复制会编译报错。这个版本选择在2025年看来已经不算激进,但胜在资料多、踩坑经验齐全。

2.2 工程目录怎么划分,算法引擎为什么独立出来

我采用的不是按技术分层的大平铺结构,而是按业务域拆包。这样做的核心原因是路线规划引擎本身是纯计算逻辑,依赖很少,独立出来可以单独做单元测试,也可以在未来需要时直接抽成一个独立服务。

com.demo.travel ├── controller // Web层:接收参数、返回视图模型 │ ├── TripController │ └── SpotController ├── service // 业务编排:事务控制、调用引擎、组装结果 │ ├── TripPlanService │ └── UserPreferenceService ├── domain // 领域对象:用户、景点、行程、行程明细 │ ├── ScenicSpot │ ├── Trip │ └── TripItem ├── repository // 数据访问层:MyBatis-Plus的Mapper与实体 ├── engine // 算法引擎:与Web层完全隔离 │ ├── recommend // 偏好评分、候选召回 │ ├── route // 贪心构造、2-opt局部优化、分簇 │ └── util // 经纬度距离计算、时间窗口工具 └── common // 统一响应体、异常处理、参数校验

这个结构里最关键的是engine包的独立性。它不依赖Controller,甚至不依赖Spring容器,所有算法入口都是“输入普通Java对象、输出普通Java对象”。这样我可以在测试里直接构造景点列表,跑一遍贪心加2-opt的链路,不需要启动整个SpringBoot应用。代码写起来快,出了问题定位也快。

持久层我用了MyBatis-Plus,核心原因是它兼顾了CRUD效率和复杂SQL的灵活性。行程明细批量插入这种场景,MyBatis-Plus的batchInsert比一条条insert快得多。推荐系统和路线规划这种重计算场景,瓶颈在算法和数据访问两个地方,ORM对性能的影响反而没那么重要。

3. 引擎是灵魂:从“去哪玩”到“怎么串”的算法链路

3.1 候选景点召回:偏好向量与标签体系的匹配

候选召回的输入是用户偏好,输出是一个有序的景点列表。我这里没有直接用复杂的深度学习模型,而是用“标签向量 + 余弦相似度 + 热度补偿”的组合。每个景点在录入时打3到6个标签,比如“自然风光”“历史古迹”“亲子”“美食”“网红打卡”“博物馆”等。用户填偏好时也选同样的标签集合,形成一个0/1向量或者带权向量。

计算一个景点对当前用户的匹配度,我用的是这样的加权评分:

public double score(ScenicSpot spot, UserPreference pref) { double contentScore = cosine(pref.getTagVector(), spot.getTagVector()); double hotScore = normalize(spot.getPopularity()); // 用户把某个景点标为“必去”,直接给一个较大加成 double mustPlayScore = pref.getMustPlayIds().contains(spot.getId()) ? 1.0 : 0.0; return 0.55 * contentScore + 0.25 * hotScore + 0.2 * mustPlayScore; }

热度补偿是必须的,否则会出现一个“文艺”标签匹配到三个冷门小店,结果推荐质量很差的冷启动问题。对于新用户,行为数据几乎为零,这时候热门景点兜底比什么都重要。我另外加了一个标签扩展表,把“文艺”映射到“博物馆”“书店”“艺术街区”等近邻标签,扩大召回范围。这一点在后面的实测坑里还会详细说。

候选召回通常只取前20到30个景点参与路线计算,而不是全量参与。原因是路线规划是非常典型的组合优化问题,候选集稍微一大,计算量立刻指数级膨胀,后面会有专门一节讲这个问题。

3.2 路线构造:贪心初始解 + 2-opt局部优化

行程规划的核心问题,可以抽象成这样一个带约束的路径优化模型:给定一个候选景点集合,每个景点有坐标、建议游玩分钟数、开放时间窗口、偏好得分,用户给出总天数、每天可游玩时间段、住宿地点坐标,要求生成一份行程单,使总体体验得分最高,同时满足:

  • 每天从酒店出发,最终回到酒店;
  • 景点间时间不重叠,入园时间必须在开放窗口内;
  • 每天总游玩时间不超过用户选择的节奏(紧凑型或宽松型);
  • 尽量少出现跨区域来回跑的情况。

这个模型本质上是带时间窗口和收益的旅行商问题变种,NP难问题,不可能在多项式时间得到最优解。工程上我选择了“贪心构造初始解 + 2-opt改进”的启发式方案,后续如果要更好的稳定性,可以再加模拟退火或遗传算法。

贪心构造的逻辑很直白:每天从酒店或前一天结束的位置出发,每次从未排景点里选一个“单位时间得分最高”且“时间窗口放得下”的景点加进来,直到当天时间排满。

public List<TripItem> greedyRoute(List<ScenicSpot> candidates, TimeWindow dailyWindow, Coordinate startPoint) { List<ScenicSpot> remain = new ArrayList<>(candidates); List<TripItem> route = new ArrayList<>(); Coordinate current = startPoint; int currentMinute = dailyWindow.getStartMinutes(); while (!remain.isEmpty()) { ScenicSpot best = null; double bestValue = -1.0; for (ScenicSpot spot : remain) { int travel = travelMinutes(current, spot.getCoordinate()); int arrival = currentMinute + travel; if (!spot.canEnterAt(arrival)) { continue; } if (arrival > dailyWindow.getEndMinutes()) { continue; } int leave = arrival + spot.getSuggestMinutes(); if (leave > dailyWindow.getEndMinutes()) { continue; } double value = spot.getScore() / (double) (travel + spot.getSuggestMinutes()); if (value > bestValue) { bestValue = value; best = spot; } } if (best == null) { break; } TripItem item = buildItem(best, currentMinute + travel, arriveTime + suggestMinutes); route.add(item); current = best.getCoordinate(); currentMinute = item.getLeaveMinutes(); remain.remove(best); } return route; }

这个环节有两个容易被忽略的细节:第一个是“能否进入”和“能否玩完”是两个条件,很多景点是16:00停止入园,而不是18:00闭园;第二个是贪心算法选的是“单位时间得分”,不是“得分最高”,否则很容易把一个高分但需要跑很远的景点排在前面,导致后续大量时间耗在路上。

2-opt改进是用来消除贪心初始解里的交叉绕路的。原理很简单:把一条路线中任意两条边断开,尝试反转中间一段的顺序,如果新的路线总耗时变短,就接受这个交换。对于单日行程这种节点数量不超过10个的小规模序列,2-opt的硬币效果非常明显,计算开销可以忽略。

3.3 分簇先行:把“景点属于哪个区域”变成硬约束

第一次跑通贪心算法时,我遇到了一个后来反复被提到的典型问题:系统把城南景点和城北景点排在了同一天,导致用户一整天跨城赶路。算法从单个景点的视角看每一步都合理,但从整天的视角看路线非常散。解决办法是在路线构造之前先做一次空间分簇。

我用的是简化版KMeans:把所有候选景点的经纬度标准化后聚成K簇,K等于游玩总天数。然后每天只从一个簇里选景点,簇之间距离较近的可以合并,避免出现一天内跨越大半个城市的情况。这一步看起来朴素,但效果立竿见影——行程单从“理论上最优”变成了“实际上能走通”。做完分簇之后,每日酒店的出发地点才真正成了路线的锚点,而不仅仅是第一站的起点。

4. 数据地基:支撑智能规划的表结构与距离计算

4.1 五张核心表:用户画像、景点库与行程结构

算法再聪明,数据模型设计不对也跑不起来。我最后沉淀下来的核心表是五张:用户表、景点表、用户偏好标签表、行程主表、行程明细表。这里我把景点标签直接存成了JSON字符串,而不是拆成景点-标签关联表,原因是标签的查询场景非常固定,就是一个景点多个String标签的读取和写入,用JSON字段会更简单。如果到了需要按标签做复杂统计分析的阶段,再拆关联表也不迟。

CREATE TABLE scenic_spot ( id BIGINT PRIMARY KEY AUTO_INCREMENT, city_code VARCHAR(32) NOT NULL, name VARCHAR(128) NOT NULL, lat DECIMAL(10, 6) NOT NULL, lng DECIMAL(10, 6) NOT NULL, suggest_minutes INT NOT NULL COMMENT '建议游玩时长,单位分钟', open_minutes INT COMMENT '开门时间,单位分钟,如480表示08:00', close_minutes INT COMMENT '闭园时间,单位分钟', last_enter_minutes INT COMMENT '最晚入园时间,单位分钟', ticket_price DECIMAL(10, 2) DEFAULT 0, popularity DECIMAL(10, 4) DEFAULT 0, tags_json VARCHAR(512), status TINYINT DEFAULT 1, INDEX idx_city (city_code), INDEX idx_status (status) );

行程主表和明细表是一对多关系。主表记录用户、城市、开始日期、总天数、整体偏好快照;明细表记录每一天每个景点的顺序、到达时间、离开时间、当天的移动时间估算。为什么要在明细表里冗余存一份景点名称和坐标快照?因为行程是一次快照,今天排好的路线不应该因为景点库后天改了个名称或坐标就变样。用户查看历史行程时,读的是明细表里的冗余字段,而不是实时关联景点表。

4.2 经纬度距离:Haversine公式与移动时间估算

景点之间、酒店与景点之间的距离,底层靠经纬度计算。Haversine公式是工程里最常用的球面距离公式,我封装成一个工具类:

public static int distanceMeters(Coordinate a, Coordinate b) { double earthRadius = 6371000; double latRad1 = Math.toRadians(a.getLat()); double latRad2 = Math.toRadians(b.getLat()); double dLat = Math.toRadians(b.getLat() - a.getLat()); double dLng = Math.toRadians(b.getLng() - a.getLng()); double h = Math.sin(dLat / 2) * Math.sin(dLat / 2) + Math.cos(latRad1) * Math.cos(latRad2) * Math.sin(dLng / 2) * Math.sin(dLng / 2); return (int) (2 * earthRadius * Math.asin(Math.sqrt(h))); }

但有了距离之后,要变成“移动时间”还有一个关键假设:交通工具的时速。我最初统一按30公里/小时估算,后来发现不同城市的市区交通状况差距很大,30km/h在大城市打车可能高估,在中小城市又可能低估。这里我做了一个折中:默认按30km/h算,另加10分钟的“从景点门口到实际目的地”的缓冲时间。想接真实路况的话,可以换成第三方地图API,但项目早期为了可离线运行,这个默认估算已经够用。

距离计算还有一个性能问题。候选景点30个,两两组合就有900次距离计算,如果每次都现场跑Haversine,一次规划就要算几万次。我的做法是预计算距离矩阵并缓存到Redis里,key格式是distance:{cityCode},field是spotIdA:spotIdB,value是移动分钟数。缓存之后,一次完整规划的耗时从秒级降到几百毫秒以内。

5. 接口闭环:一次自动规划从请求到落库的完整过程

5.1 RESTful接口设计:让前端拿到的直接是可用的行程单

接口设计遵循一个原则:前端拿到的不是“候选景点列表”,而是已经排好序、带时间线、可直接渲染的行程单。接口列表如下:

方法路径用途
POST/api/v1/trips/auto-plan根据城市、天数、偏好生成行程
GET/api/v1/trips/{tripId}查看行程详情
PUT/api/v1/trips/{tripId}/items手动调整行程顺序
POST/api/v1/trips/{tripId}/weather-adjust根据天气替换室外景点
GET/api/v1/spots按城市+标签检索景点库

请求体设计成JSON:

{ "cityCode": "demo_city", "startDate": "2025-05-01", "days": 3, "hotel": { "lat": 30.27, "lng": 120.15 }, "pace": "RELAXED", "preferences": ["nature", "food", "museum"], "mustPlay": [1, 12], "budget": 1500 }

pace字段控制节奏:RELAXED模式下每天最多排5个景点,上午和下午之间留足午餐时间;COMPACT模式可以排到8个,中午吃饭时间压缩到1小时左右。这个字段直接影响贪心算法里的每日时间窗口设定。

5.2 行程生成服务的完整流程:Controller → Service → Engine → Mapper

一次自动规划的调用链,我花了很大心思保证事务边界清晰。Controller只做参数格式校验,不碰任何业务逻辑;Service负责开启事务、调用引擎、落库;Engine只做纯内存计算。流程拆开如下:

  1. Controller收到请求,用JSR-303注解先校验必填字段,比如城市编码不能为空、天数必须在1到7之间;
  2. Service层加载用户偏好、入住酒店坐标、候选景点列表;
  3. Engine执行“召回 → 分簇 → 贪心构造 → 2-opt调整 → 时间窗口修正”这条算法链路,返回内存中的行程明细对象列表;
  4. Service在事务内先插入行程主表拿到tripId,再批量插入明细表;
  5. 如果预算字段不为空,查询所有景点的门票费总和,超出预算则在行程单的“预估费用”里给出提示,但不会自动把景点从行程里删除——因为删除哪个景点需要用户决策。

事务边界的细节很关键:算法过程不涉及任何数据库写入,纯粹是在内存里计算,因此耗时再长也不会把事务锁在数据库层。真正落库的只有最后那几行insert,速度极快。这样设计之后,即使算法某一步异常,也不会产生脏数据。

另一个我踩过的坑是“一步错全部重来”的问题。用户只是想挪动某一个景点的时间段,前端如果触发全量重算,耗时没必要,而且会把用户手工调好的其他安排全部打乱。所以我单独提供了调整接口,只对受影响的那一天做局部重排:把该天的其他景点保持不动,只重新计算被挪动景点的到达和离开时间。这个局部调整逻辑基于一个简单事实:行程调整大多数时候是在同一天内发生的,跨天调整占比很小。

6. 实测三大坑:绕路、冷启动误推与性能组合爆炸

6.1 坑一:最近邻贪心让路线变成了“Z字形”

第一版生成出来的路线让我非常失望。直观现象是:单看每一天,景点A在城东,下一个景点B在城西,再下一个景点C又回到城东,整条路线在地图上画出来就是一条锯齿线。问题出在贪心的评估函数只考虑了“当前点到下一个点的距离”,没有考虑“这个点在空间上是否和今天的其他点属于同一片区域”。换句话说,算法每一步都很短视。

排查过程我印象很深。我没有直接去改算法,而是给每个行程明细加了一个输出字段,记录“从酒店出发的累计移动距离”和“相邻景点间移动距离”。数据打出来之后发现,看似每一步只移动了15分钟,但半天下来累计移动距离比原来手工排的方案多了接近40%。那时候我才确认:问题不是距离函数错了,是目标函数缺了“今天的行动半径”这个约束。

修复方案是分簇+2-opt的组合拳。分簇保证每天只在一个地理范围内活动,2-opt把簇内的顺序理顺。这两个改动都不复杂,但路线地图从“锯齿形”变成了“一个区域内的折返”,用户观感完全不一样。这个坑给我的教训是:路径规划里,空间归属性往往比单点最短距离更重要。

6.2 坑二:新用户冷启动导致推荐翻车

第二个坑出现在推荐模块上线冷启动测试时。我用一个完全没有任何历史行为的新用户账号,选了“文艺”“小众”两个标签,结果系统推出来的都是一些评分偏低、位置偏僻的小店。虽然确实符合“文艺”标签,但用户第一次用这个系统,没有建立信任,看到推荐质量这么差,根本不会继续用。

排查时我打印了推荐得分明细:这些冷门小店的contentScore很高,但popularity非常低,hotScore权重只有0.25,最终得分却还是排进了前列。问题本质是在用户行为数据为零的冷启动阶段,单一标签匹配信号太强,热度信号太弱。修复做了三件事:

  • 第一,在冷启动阶段把热度权重从0.25提到0.4,标签匹配权重降到0.4;
  • 第二,加标签扩展表,把“文艺”映射到“博物馆”“艺术街区”“独立书店”等更主流的标签,扩大召回池;
  • 第三,增加保底机制:如果某个标签召回不足三个景点,自动从该城市的热门景点Top10里补位,保证每天至少有一个体验分过硬的“保底景点”。

这个调整上线后,新用户看到的推荐质量显著提升。推荐系统最怕的就是“忠实执行用户说过的话”,结果把用户坑了。冷启动阶段需要的是“热门兜底 + 偏好引导”,而不是纯偏好匹配。

6.3 坑三:组合爆炸与耗时优化

第三个坑是性能问题。我把候选集放开到50个景点、6天行程时,一次全量重算的接口响应时间飙到了2.7秒左右。表面上看2.7秒还能接受,但前端交互里用户每调整一次顺序就触发一次重算,体感就会变得很糟糕。而且我当时的服务器配置只是普通开发机,线上并发一高,这个接口必然拖垮整个服务。

我定位耗时的办法是分段打点:召回耗时、分簇耗时、贪心耗时、2-opt耗时分别统计。结果发现大头不在算法本身的循环,而在两点:一是距离矩阵没有缓存,每次规划都现场算两两距离;二是候选集为50时,2-opt对多天路线的组合判断次数太多。优化组合拳如下:

  • 距离矩阵预计算并缓存到Redis,这一项就把距离计算量降到了接近零;
  • 候选集从50收敛到30,因为Top30之后的景点在得分上已经和前面的拉开明显差距,排进行程的意义很小;
  • 对每天行程项数做上限控制,RELAXED模式5个、COMPACT模式8个,从源头上压缩2-opt的解空间。

优化后接口响应降到500毫秒以内,整个系统才算真正具备交互价值。这个坑的通用教训是:算法类接口的性能优化不能只盯着循环体本身,要先把重复计算砍掉,再限制问题规模。

7. 还能深挖的方向:多目标、实时数据与开放能力

7.1 从单一得分到多目标优化

现在的版本把“体验得分”作为一个综合标量来优化,但真实旅行需求其实有多个目标同时存在:时间最短、体验最高、花费最低,这三个目标经常互相冲突。想进一步做深的话,可以引入Pareto多目标优化思路,用NSGA-II或简单的带权求和去生成一组“不互相支配”的候选方案,然后让用户自己选偏好倾向——是“多玩景点”还是“少走路”还是“控预算”。这在工程上就是把单个最优解的接口改成返回若干个候选方案。

我在设计之初没有直接上多目标,是因为第一版最需要的是“能用、不绕路、推荐别翻车”,多目标优化会让参数调优的难度翻倍。但它确实是这个系统下一个真正的智能升级点。数据量多了之后,还可以根据用户历史生成的行程,学习用户对“紧凑型排法”和“宽松型排法”的接受程度,做个人化节奏调节。

7.2 接入实时动态数据,把静态约束变成动态约束

目前的开放时间、天气、交通速度都是静态估算。接上第三方天气和实时路况API之后,整个系统会变得更有价值:预报下雨时,自动把当天的室外自然景区和室内博物馆对调;某个景区当前排队时间超过两小时,自动建议提前到第二天早上或改为备选景点。这些动态调整不需要改动引擎核心,只要在调用路线规划前更新每个景点的“有效开放时间窗口”和“当前预期停留时间”即可。

要注意的是,接入外部API时一定要做降级方案。比如第三方天气接口超时,就直接跳过动态调整,用静态默认值继续规划,不能因为外部依赖导致行程生成失败。这块我在项目里虽然没有完整实现,但架构上已经预留了接口,外部数据以“数据源适配器”的形式注入,替换成本很低。

7.3 开放能力:从个人工具变成可复用服务

如果把这套系统的规划能力封装成开放API,给其他旅游小程序、酒店PMS、企业差旅系统调用,就是一个更完整的产品方向。多目标优化和实时数据能力一旦成熟,后端算法引擎本身会比前端App更有商业价值。当然,这条路需要的工程化投入远不止当前这些——要加租户隔离、配额管理、调用审计、SLA监控,已经超出了“毕设/练手项目”的讨论范围,但作为演进方向很清楚。

我最后想说的是,做完这个项目最大的感受是:智能不是模型多高级,而是约束建模有多贴近真实生活。用SpringBoot把工程骨架搭起来真的不难,难的是把你平时排路线时脑子里那些“潜规则”一条条翻译成代码里的约束和目标函数。如果你也要做类似系统,我的建议是先别碰花哨的算法框架,找一张地图,手工排一次路线,把手工决策的依据列成清单,你的算法骨架自然就出来了。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进