旅游行程规划系统,说白了就是把“今天去哪、明天去哪、几点去、坐什么车、玩多久”这件事交给程序去排。我做完这个项目之后最深的感受是:这个系统的难点根本不在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 表结构设计:五张核心表撑起整套规划逻辑
表不用太多,但关系一定要想清楚。我核心就五张业务表加两张关联表,字段刻意做了收敛。下面是主要表结构,按常见做法补全了关键字段。
| 表名 | 关键字段 | 作用说明 |
|---|---|---|
| user | id, nickname, phone, preference_json | preference_json存用户偏好标签ID数组,整体读写 |
| scenic_spot | id, name, city, latitude, longitude, open_time, close_time, visit_duration, score, price, intro, season_tag | 景点基础信息,经纬度和游玩时长是规划必需 |
| tag | id, tag_name | 标签字典,如“亲子”“自然风光”“城市夜景” |
| spot_tag | spot_id, tag_id | 景点和标签多对多关联 |
| trip | id, user_id, city, start_date, days, status, total_score | 一次行程的总体信息 |
| trip_item | id, 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 List<TripItem> planOneDay(List<SpotCandidate> candidates, DayContext ctx) { List<TripItem> 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/plan | POST | 城市、开始日期、天数、偏好标签、每日起止时间 | 返回taskId,异步结果需轮询 |
| /trip/{id} | GET | 行程ID | 完整行程,含每日明细、坐标、预计耗时 |
| /trip/{id}/item/{itemId} | PUT | 调整顺序或时间 | 返回受影响当天的最新列表 |
| /trip/{id}/replan | POST | 天数、替换景点列表 | 返回新的行程明细快照 |
统一返回结构我定义了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的全过程。需求是先收缩再展开的,表结构是为算法服务的,接口设计是跟用户体验绑定的。这三件事互相牵制,任何一个环节想得不周全,后面都要返工。
如果你正准备做类似的系统,我的核心建议是:先把规划引擎的算法跑通,哪怕是最笨的贪心算法也行,再去补花哨的功能。核心算法没跑通之前,前端做得再漂亮都是空中楼阁,用户随便输入一个条件就把系统问住了,这种项目你自己都不好意思演示。
还有一条比较实用的经验:一定要把第三方服务当成不可靠组件来看待。超时、限流、配额超了、返回脏数据,这些都要有预案。别假设地图服务永远不会挂,别假设天气接口永远有数据,适配层加上降级逻辑,哪怕降级方案是“暂时不推荐室外景点”,也比整个系统崩掉强。
最后分享一个小技巧:把每一次规划的用户输入、候选池大小、计算耗时、结果评分都打日志打到数据库里。调算法参数的时候,这些历史数据是你唯一的决策依据。我后来调整权重系数,完全靠这批日志定位问题,不然凭感觉调参,调一个礼拜都不知道改了有没有效果。这个习惯,建议从一开始就养成。