资讯动态

基于SpringBoot的智能旅游行程规划系统:算法设计与工程实践

发布时间:2026/10/17 22:27:09 来源:尧图企业网站定制
旅游行程规划系统说白了就是把“今天去哪、明天去哪、几点去、坐什么车、玩多久”这件事交给程序去排。我做完这个项目之后最深的感受是这个系统的难点根本不在CRUD而在“规划”这两个字上。你光是把景点存进数据库那只是一个景点库只有当程序能根据用户的偏好、时间、天气、路线耗时自动生成一条说得过去的路程它才有资格叫“智能行程规划”。这个系统跑通之后能做的事很具体用户输入目标城市、游玩天数和几个偏好标签后端自动把行程拆成多日安排每天按时间窗排好景点顺序中途天气突变还能自动替换不合适的户外景点。后端就是SpringBoot那一套MySQL存业务数据Redis做缓存和防重核心是自研的规划引擎。前端用Vue搭界面地图和天气数据走第三方服务商的API。适合谁看这篇文章一类是要交课程项目或者需要完整项目经历的人另一类是想在简历上写“我独立做过一个带算法模块的后端系统”的Java开发者还有刚入行想看看业务系统怎么落地的同学。我会把需求分析、表结构、算法思路、接口设计、踩坑记录全写出来不掺水。1. 项目从哪来需求分析与整体定位1.1 这类系统到底解决了什么痛点自由行用户最头疼的不是不知道目的地有什么景点而是信息太散。攻略说东、地图说西、天气说要下雨最后全靠自己手动开十几个页面来回切。市面上很多旅游平台要么是卖票的要么是发攻略的真正能帮你把“早上几点出门、先去哪再去哪、路上多久、中午在哪解决”这一整套决策自动做掉的少之又少。所以我做这个系统时第一个原则就是不做大而全的平台只做“行程决策自动化”。出发点很简单用户告诉系统“我3天时间喜欢自然风光和拍照不想太赶”系统直接吐给你一份能照着走的每日行程包含每个景点的预计到达时间、停留时间、交通耗时甚至能因为暴雨把某天下午的爬山改成室内展馆。这个定位决定了后面所有技术决策。系统的核心价值在规划引擎不在登录注册不在社区评论不在那些凑数的管理后台。想清楚这件事比急着写代码重要得多。我在一开始就把功能范围砍到四个闭环选景点、排路线、看行程、改行程。其他功能都是在这四个闭环上长的枝叶不是主干。1.2 功能模块怎么拆才不会烂尾常见翻车姿势是需求阶段把功能铺得很宽又要社交、又要攻略、又要预订门票结果一个月做出来全是空壳页面。我的做法是按后端职责拆成五个模块每个模块的边界非常清楚。用户模块登录、偏好设置、历史行程管理。景点库模块景点的CRUD、标签管理、按城市和条件筛选。规划引擎模块偏好打分、路线生成、动态调整。这是独立的一个服务包。行程管理模块行程的创建、查询、修改、删除和明细顺序调整。外部数据适配层地图服务、天气服务的调用统一收口到这里业务代码不直接碰第三方SDK。这里强烈建议把规划引擎单独拆成一个包不要跟普通service混在一起。我见过不少项目把推荐算法直接写在service方法里后面想换算法或者加约束条件牵一发动全身维护起来非常痛苦。规划引擎只依赖数据访问层和外部适配层不依赖controller接口层换了它都不受影响。这个模块划分还有一个好处可以单独对规划引擎做单元测试不必启动整个Web容器。后面调算法参数的时候写几个测试用例直接跑核心逻辑比每次用Postman发请求再打开浏览器看结果效率高一个量级。2. 技术选型与架构设计的关键决策2.1 后端骨架为什么是SpringBoot 2.7 MyBatis Plus选型我没有追新最终落在SpringBoot 2.7.x JDK 1.8这个组合。有人可能会问SpringBoot 3都出这么久了为什么不直接用因为行程规划系统要接大量第三方SDK不少地图和天气服务商的Java包还在用javax命名空间SpringBoot 3切到jakarta之后不是不能用但遇到老依赖就得额外适配。如果是自己玩SpringBoot 3 JDK 17完全没问题但时间紧、要稳定交付的场景2.7是最务实的选择。ORM我选MyBatis Plus而不是JPA。行程规划里有大量多条件动态查询比如按城市、标签、评分、价格区间去筛景点MP的条件构造器写起来比JPA的Specification直观很多而且自带分页插件省事。JPA在表关系特别复杂的时候确实舒服但这类系统的查询条件高度不确定MP更顺手。当然这也看个人习惯关键是别在同一个项目里混用两套ORM。Redis在这个项目里不是可有可无的装饰品。它承担三件事第一景点库和天气数据是典型的读多写少缓存命中率很高第二规划引擎计算可能要好几秒任务中间态和结果集放Redis重启不丢第三防止用户重复提交生成请求。我见过有人把所有数据都放在HashMap里程序一重启行程就没了这种方案只能用于演示不能叫系统。2.2 表结构设计五张核心表撑起整套规划逻辑表不用太多但关系一定要想清楚。我核心就五张业务表加两张关联表字段刻意做了收敛。下面是主要表结构按常见做法补全了关键字段。表名关键字段作用说明userid, nickname, phone, preference_jsonpreference_json存用户偏好标签ID数组整体读写scenic_spotid, name, city, latitude, longitude, open_time, close_time, visit_duration, score, price, intro, season_tag景点基础信息经纬度和游玩时长是规划必需tagid, tag_name标签字典如“亲子”“自然风光”“城市夜景”spot_tagspot_id, tag_id景点和标签多对多关联tripid, user_id, city, start_date, days, status, total_score一次行程的总体信息trip_itemid, trip_id, day_no, spot_id, sort_no, visit_start, visit_end, transport_time行程明细一行一个景点记录当日顺序和时间这里我要重点说一个坑不要图省事把整条路线存成一个大JSON字符串塞进一个字段。短期看确实省事但一旦要支持调整顺序、删除某个点、统计每天玩几个景点、计算总耗时解析JSON的逻辑会越滚越大最后变成没人敢动的屎山。行程明细拆成一张表每行存一个景点、当天序号、预计到达和离开时间所有操作都变成简单的增删改查逻辑干净得多。关于景点的游玩时长我建议在景点表里加一个visit_duration字段单位是小时。这个值是估算的不需要很精确但必须有。没有这个字段后面路线生成的时间窗计算就无从谈起。数据初始化时按景点类型给默认值博物馆给2.5小时观景台给1.5小时用户后续可以自己在行程里微调。2.3 第三方服务接入地图和天气要当“不可靠组件”来设计地图服务和天气服务是整个系统里最大的外部变量。地图主要用两个能力地点检索把用户输入的景点名转成经纬度路线规划计算两个景点间的驾车或公共交通耗时。天气用来做动态调整依据比如当天下雨就减少户外景点。第三方API一定要做适配层不要在业务代码里散落地调。我的做法是定义两个接口一个MapService一个WeatherService底层实现各自封装一个SDK业务层只依赖接口。这样以后换服务商或者从免费版切到付费版只改适配层业务代码一行不用动。接第三方服务最容易翻车的是配额和限流。地图路线规划是计费的而且批量接口有限制所以规划引擎调路线API时必须做缓存。同一个出发地到目的地的路线耗时一天内基本不会变缓存住能省掉90%的配额消耗。天气接口免费版一般几小时更新一次缓存30分钟完全够用。记住一个原则外部服务是按调用次数花钱的缓存就是省钱。3. 核心引擎智能行程规划到底怎么实现3.1 景点打分偏好标签不是简单where查询规划引擎的第一步是给候选景点打分。用户不选具体景点只给标签比如“亲子”“自然风光”“网红打卡”系统怎么知道推荐哪些做法是标签匹配加基础分加权。分数公式我是这样设计的总分 标签匹配分 × 0.5 热度分 × 0.3 季节适配分 × 0.2。标签匹配分计算用户偏好标签集合和景点标签集合的Jaccard相似度两个集合交集大小除以并集大小值越接近1说明越契合。热度分把景点的评分或浏览量做线性归一化满分5分的评分除以5得到一个0到1的值。季节适配分景点表里有一个season_tag字段标记这个景点适合的季节。冬天给温泉、滑雪类打高分夏天给水上乐园、山岳避暑类打高分。如果用户一个标签都没选就按热度分和季节分加权排序取前若干作为候选池。这里有个细节值得注意候选池不要只取TopN而是取Top 2N范围可以放宽到按城市筛选后评分前50的景点。原因是后面路线生成时还要受地理位置的约束提前把那些评分略低但正好顺路的景点筛掉太可惜了。候选池宁可大一点留给路线算法去筛选。3.2 路线生成贪心插入加时间窗约束路线生成本质上是一个带约束的排序问题。把一天当作一条时间轴早上8点出发晚上18点结束每去一个景点都要消耗游览时间和交通时间。目标函数是让整天的累计分数最大约束是每个景点必须落在营业时间窗口内相邻景点的路线必须可达。我采用的算法是贪心插入法思路很朴素先把当天分数最高的景点放进去然后循环遍历候选景点尝试把每个景点插入到当前序列的各个位置检查时间窗是否冲突、总时长是否超限选择能插入且累计分数增益最大的那个位置。这种思路每次只做当下最划算的选择不一定得到全局最优解但工程上完全够用而且结果可解释用户问你为什么这么排你能说清楚逻辑。核心逻辑用伪代码表示就是这样public ListTripItem planOneDay(ListSpotCandidate candidates, DayContext ctx) { ListTripItem result new ArrayList(); for (SpotCandidate spot : candidates) { if (!canInsert(result, spot, ctx)) { continue; } int bestPos findBestInsertPosition(result, spot, ctx); result.add(bestPos, toTripItem(spot, ctx)); ctx.updateTimeline(result); } return result; }时间窗计算的细节是整个算法的关键。每个景点都有open_time和close_time还要保证至少停留visit_duration这么久。判断插入是否冲突不能只算一个累计总时长就完事必须维护一条实际的时间轴遍历插入位置前后的所有景点重新计算它们的到达和离开时间看是否跟营业时间冲突。这块我写了一个ScheduleTimeline模型类专门维护每个已插入景点的起止时间插入时只更新受影响的一段而不是全量重算。我刚开始实现的时候偷过懒用一个累计总时长去判断结果排出来的路线表面上看没超时仔细一看上午10点在一个城市下午3点跑到另一个城市去了交通时间完全没考虑。这不叫行程规划这叫景点点名。交通耗时必须作为硬约束参与计算不是锦上添花。多日行程的实现方式是分天迭代。第一天排完之后把已经被选走的景点从候选池里移除再排第二天。如果城市很大、景点之间地理跨度也大可以先按行政区或商圈做一次聚类每天围绕一个区域玩避免一整天都在路上。3.3 动态调整天气变化和换景点不能全盘重算动态调整主要应对两类情况天气变化导致部分景点不适宜比如暴雨天去爬山用户临时想替换某个景点。处理方式不是重新全盘规划而是局部重排。具体做法是把受影响的景点从行程明细里删掉然后在该天的剩余候选池里找评分最高且能插入的替代景点重新计算时间窗。这个操作的计算量比全盘规划小得多用户体感上就是“刷一下就换好了”。我单独做了一个接口POST /trip/{id}/replan入参是哪一天、要替换哪些景点、希望什么时间段返回新的行程明细快照。天气联动的触发逻辑放在每天早上。系统定时任务拉取目标城市当天天气如果判定为暴雨等不适合户外的天气就把当天景点标签里带“户外”的替换成室内标签的高分景点。这个逻辑要放在适配层后面天气数据本身有缓存判断逻辑要写在业务层方便以后调整阈值。动态调整这块最容易翻车的是替换景点时只看了标签匹配度没检查替代景点的营业时间和当前位置结果换出一个交通耗时一个半小时的“顺路”景点。所以局部重排也要走完整的时间窗校验不能图省事直接塞进序列。4. 实操过程中的几个关键环节4.1 接口设计REST风格和异步任务的配合接口设计上我基本走REST风格但有一条核心原则把复杂计算留在后端前端只负责展示和轻交互。核心接口就这几个接口方法入参要点返回说明/trip/planPOST城市、开始日期、天数、偏好标签、每日起止时间返回taskId异步结果需轮询/trip/{id}GET行程ID完整行程含每日明细、坐标、预计耗时/trip/{id}/item/{itemId}PUT调整顺序或时间返回受影响当天的最新列表/trip/{id}/replanPOST天数、替换景点列表返回新的行程明细快照统一返回结构我定义了Result 包含code、message、data三个字段。这个东西本身没什么稀奇但很多项目翻车就翻在错误码不统一前端拿到后端返回的字符串到处判断加一个字段就要改十几个页面。建议用枚举定义错误码至少覆盖参数错误、数据不存在、外部服务超时、规划无解这几类。有一个场景特别容易被忽略规划无解。比如用户只给半天时间偏好标签限定得又很死候选池里根本没有能排进去的景点组合。这个接口不能硬返回200否则前端拿到一条空路线用户只会觉得系统是坏的。应该返回一个业务错误码提示“行程排不出来建议放宽条件或增加天数”前端弹个提示体验好得多。4.2 缓存设计Redis在行程规划系统里的三个位置规划引擎计算一次可能要1到3秒期间还要调多个外部接口用户盯着进度条转好几秒特别容易焦虑。这里我做了一个常规但很有效的方案规划请求进来后先校验参数然后丢给线程池异步执行同时把任务状态写进Redis前端轮询任务状态等任务完成后再拉完整行程。简单说POST /trip/plan 返回的不是行程JSON而是taskId。前端轮询 GET /task/{taskId}任务状态变为成功后再调 GET /trip/{id} 拿结果。这样做有三个好处接口响应快地图算路这种慢操作不阻塞请求线程用户中途刷新页面任务也不会丢。Redis在这套设计里的位置很清晰三类数据都放里面task:plan:{taskId}规划任务状态和执行结果快照。spot:city:{city}按城市缓存的景点候选列表。route:cache:{fromId}:{toId}两个景点之间的交通耗时缓存。key的命名规范一定要提前定好后面清理缓存写批处理脚本时按前缀扫就完了。我见过项目里缓存key随手写到后期想清理都不知道哪些是哪些。4.3 联调经验地图绘制和拖拽调整的隐藏成本前后端联调阶段最容易漏掉的是地图绘制相关数据。行程明细里每个景点都要带经纬度前端拿到之后在地图上画连线和点位。很多同学第一次做的时候接口只返回景点名字和时长前端拿到数据根本画不了路线还得自己再调一次地图API去查坐标等于让前端重复做了一遍后端该做的事。我最后直接在行程接口里把routePolyline字段返回了就是按顺序排好的坐标点串前端直接撒到地图上渲染。这个字段是后端在规划完成后一次性算好存下来的前端不需要自己调地图算路也省掉了联调时最常见的一类扯皮。还有一个体验细节行程列表里的拖拽排序交互。用户拖拽一个景点到新位置前端调调整顺序接口如果后端把整个行程数据全部返回景点一多渲染就会明显卡顿。我最后只返回受影响当天的最新列表前端做局部替换响应快很多代码也没复杂多少。5. 踩坑记录与问题排查实录5.1 批量算路超时按需计算救了我联调阶段踩的第一个坑是批量算路。一开始我想得挺美把当天所有候选景点之间的两两交通耗时一次性算完存到缓存里后面插入判断直接取数。结果发现地图路线规划接口对单次请求的坐标点数量有限制量一大直接超时而且费用也扛不住。后来改成按需计算。贪心插入时每个候选景点只计算它和当前序列里相邻景点的交通耗时需要多少算多少再配一个LRU缓存。性能立刻上去了路线规划接口的平均耗时降了一半不止。这个经验可以通用能用局部计算解决的就不要全量预计算尤其外部服务还要花钱的时候。5.2 缓存脏数据更新数据库必须同步删缓存排第二个坑的时候我一度很困惑。用户前一天已经去过的景点第二天刷新还出现在待选列表里而且景点显示的营业时间也是旧的。排查下来发现是热点数据缓存时间设得太长更新数据库之后又没有同步更新缓存导致旧数据一直顶着。这类问题没有黑科技解法就是遵守一条铁律更新数据库之后主动删掉Redis里对应的key下次读取时再回填不要等缓存过期。关键是这个逻辑要收敛到一个统一入口。我最后把所有写景点、写价格、写营业时间的操作全部收敛到一个数据维护服务里谁都不能绕过这个服务直接操作表缓存一致性就不会散掉。5.3 重复生成行程锁和唯一索引双保险第三个问题是用户手速太快连续点了好几次“生成行程”同一用户在同一时间段生成了好几条重复行程。这不是严重错误但数据会变脏后面统计用户行程数量时会很难受。我在Redis里加了一把分布式锁key用userId加城市加开始日期拼出来持锁期间相同的请求直接返回已有的任务不重复触发规划。有一个细节很容易被忽略锁的过期时间必须大于规划任务可能的最大执行时间。我一开始锁设了5秒过期结果规划任务跑了8秒锁先没了用户确实刷新页面又触发了一次重复规划。把锁的过期时间调到30秒之后这个坑就消失了。数据库层面也做了兜底行程明细表加了联合唯一索引比如day_no、sort_no、spot_id、trip_id的组合唯一。程序逻辑出现极端并发问题时数据库直接拒绝重复插入比应用层的if判断可靠得多。这里给个问题排查速查表都是我实际遇到过的场景异常现象可能原因解决思路行程页面转圈10秒以上规划接口同步执行外部算路慢改为异步任务加轮询明明更新了景点却还显示旧数据Redis缓存未失效或时间过长更新后主动删缓存key用户重复生成多条行程缺少幂等控制Redis锁加联合唯一索引路线耗时明显不合理交通耗时未参与硬约束计算时间窗校验必须重算受影响链路地图路线一直画不出来接口未返回坐标点串后端规划时算好routePolyline字段6. 做完这个项目之后我的一些实在话做完这个系统我最大的收获不是把SpringBoot用得更熟练了而是理解了一个带算法模块的业务系统从0到1的全过程。需求是先收缩再展开的表结构是为算法服务的接口设计是跟用户体验绑定的。这三件事互相牵制任何一个环节想得不周全后面都要返工。如果你正准备做类似的系统我的核心建议是先把规划引擎的算法跑通哪怕是最笨的贪心算法也行再去补花哨的功能。核心算法没跑通之前前端做得再漂亮都是空中楼阁用户随便输入一个条件就把系统问住了这种项目你自己都不好意思演示。还有一条比较实用的经验一定要把第三方服务当成不可靠组件来看待。超时、限流、配额超了、返回脏数据这些都要有预案。别假设地图服务永远不会挂别假设天气接口永远有数据适配层加上降级逻辑哪怕降级方案是“暂时不推荐室外景点”也比整个系统崩掉强。最后分享一个小技巧把每一次规划的用户输入、候选池大小、计算耗时、结果评分都打日志打到数据库里。调算法参数的时候这些历史数据是你唯一的决策依据。我后来调整权重系数完全靠这批日志定位问题不然凭感觉调参调一个礼拜都不知道改了有没有效果。这个习惯建议从一开始就养成。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑