朋友问我你帮我看看去某城市玩三天怎么安排才不折腾我打开好几个旅游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 ListTripItem greedyRoute(ListScenicSpot candidates, TimeWindow dailyWindow, Coordinate startPoint) { ListScenicSpot remain new ArrayList(candidates); ListTripItem 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:spotIdBvalue是移动分钟数。缓存之后一次完整规划的耗时从秒级降到几百毫秒以内。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只做纯内存计算。流程拆开如下Controller收到请求用JSR-303注解先校验必填字段比如城市编码不能为空、天数必须在1到7之间Service层加载用户偏好、入住酒店坐标、候选景点列表Engine执行“召回 → 分簇 → 贪心构造 → 2-opt调整 → 时间窗口修正”这条算法链路返回内存中的行程明细对象列表Service在事务内先插入行程主表拿到tripId再批量插入明细表如果预算字段不为空查询所有景点的门票费总和超出预算则在行程单的“预估费用”里给出提示但不会自动把景点从行程里删除——因为删除哪个景点需要用户决策。事务边界的细节很关键算法过程不涉及任何数据库写入纯粹是在内存里计算因此耗时再长也不会把事务锁在数据库层。真正落库的只有最后那几行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把工程骨架搭起来真的不难难的是把你平时排路线时脑子里那些“潜规则”一条条翻译成代码里的约束和目标函数。如果你也要做类似系统我的建议是先别碰花哨的算法框架找一张地图手工排一次路线把手工决策的依据列成清单你的算法骨架自然就出来了。
阅读完成 · 觉得有帮助?