上个月有个朋友找我,说准备带家人去西安玩五天。白天赶景点、晚上换酒店,还要考虑老人腿脚不能走太多路,他自己查攻略查得头都大了。我帮他排了一晚上的行程,排完坐在电脑前面就在想,这种重复劳动为什么不交给代码来做?于是我用SpringBoot从零搭了一套智能旅游行程规划系统,输入目的地、游玩天数和出行偏好,系统自动输出包含景点排序、通勤衔接、用餐时段、天气提醒的完整行程方案。整个项目从设计到上线大概三周,中间踩了不少坑,这篇就是把完整的实现思路和实操细节拆开聊一聊。
这篇内容不是那种只讲概念的项目说明,而是一份可以照着落地、能直接投入使用的工程记录。适合正在用SpringBoot做毕业设计或中小型项目的同学,也适合想了解“推荐引擎怎么做”“路线优化怎么写”“Vue前端怎么打包进SpringBoot”这类具体问题的开发者。后面有的部分会涉及一些算法和数据库设计的分析,但我尽量用大白话讲清楚,重点是让你看完能直接用。
1. 项目定位与技术选型:旅游规划系统的核心思考
1.1 这个系统到底解决什么问题
旅游行程规划听起来简单,实际上是个典型的“多约束条件下的资源分配”问题。用户的时间是有限的,每天醒着能玩的时间大概十个小时出头,要在这么多时间里塞进尽量多值得去的地方,还要考虑景点开放时间、每个景点建议游玩时长、景点之间的通勤距离、天气变化、用户自己累了要休息……这些变量叠加起来,人工规划确实很痛苦。
我做这个系统的时候,先把用户的核心诉求拆成了三类。第一类是确定性信息:景点有哪些、开门时间、闭门时间、建议游玩时长、评分和热度。第二类是动态信息:天气、限流公告、突发封闭。第三类是用户偏好:喜欢人文历史还是自然风光、是带着老人小孩的休闲游还是特种兵式打卡游。系统要做的,就是把这些不同来源的数据揉在一起,算出一个“当天时间窗口内体验最大化”的方案。
所以我给这个项目的定位很明确:不是做一个地图App,也不是做一个订票平台,而是做一个“决策辅助引擎”——用户在出行前拿到一份行程计划,出行中根据实时变化动态调整计划。这个定位直接决定了后面的模块划分、数据库设计和接口设计方向。
1.2 为什么选SpringBoot而不是别的框架
现在可选的后端框架很多,Node.js的Express/NestJS、Python的Django/FastAPI都能做这类系统。但我还是选了SpringBoot,原因主要有三个。
第一是生态成熟度。旅游行程规划要处理的能力太多了:推荐算法要接存储、外部API调用要做好缓存、定时任务要同步天气数据、异步通知要管理线程池……SpringBoot周边已经把这些都封装好了。MyBatis-Plus操作数据库几乎不用写XML,Spring Data Redis一行依赖就能用,@Scheduled直接开定时任务,@Async一行注解开异步线程,这些能力让我把精力集中在业务逻辑上而不是基建上。
第二是自动装配机制带来的低起步成本。这个点值得多说两句,因为很多人都没搞明白SpringBoot为什么“开箱即用”。核心在@SpringBootApplication这个组合注解,它把@SpringBootConfiguration、@EnableAutoConfiguration、@ComponentScan三个注解合并在一起。其中@EnableAutoConfiguration会通过META-INF/spring.factories文件加载一大堆自动配置类,但这些配置类不会一股脑全部生效,而是通过@ConditionalOnClass、@ConditionalOnMissingBean这类条件注解判断——你引入了相关依赖,它就自动装配;你没引入,它就静默跳过。比如你只引入spring-boot-starter-web,它只帮你配好Web相关的bean;再加一个spring-boot-starter-data-redis,连接工厂和模板类就自动出现在容器里。
第三是团队协作和维护成本。这个项目后面如果要加新功能,比如接入实时交通流或者做社区分享,SpringBoot的分层架构天然适合多人并行开发。而且社区资料实在太多了,遇到问题搜一下就能找到答案,这在地狱难度开局的项目里非常重要。
1.3 总体架构怎么搭
架构上我选了前后端分离的骨架,前端Vue 3 + Vite,后端SpringBoot 2.7,数据库MySQL 8,缓存Redis。但这里有一个很多人会纠结的点:既然前后端分离,为什么部署的时候要把Vue打包放SpringBoot的static目录里?
这里解释一下我的取舍。前后端分离的价值主要体现在开发期——前端可以用独立的开发服务器热更新,后端专注出接口,两边通过明确的API契约并行推进。但部署期如果真拆成两个服务,就得多维护一个Nginx配置、处理跨域、两台服务器的部署节奏还得协调。对于一个中小型项目来说,把前端构建产物直接放进SpringBoot的classpath:/static/目录,启动一个Java进程就全搞定了,代价仅仅是放弃“前端独立扩展”。这个方案尤其适合小团队和毕设场景,我后面会在实操部分详细讲怎么配置。
后端内部的架构路径是这样的:Controller层负责参数校验和结果封装,Service层写业务逻辑,Mapper层走数据库操作,common包里放统一返回结果、异常处理、工具类,config包里放WebMvc、Redis、线程池这些配置类。所有对外接口统一走/api前缀,返回结构固定为{code, message, data},前端不用为了后端返回格式五花八门而头疼。
2. 核心功能模块的设计与实现
2.1 推荐引擎:有限时间内的体验最大化
推荐引擎是整个系统的大脑。我先说清楚它的输入和输出:输入是目的地、天数、出发时间和用户偏好;输出是一份按天划分的行程计划,每天包含若干个景点,每个景点标注建议出发时间、到达时间、游玩时长和下一站通勤时间。
推荐逻辑第一步是过滤。根据用户偏好把不合适的景点剔除掉。比如带老人小孩出行的场景,优先排除需要大量爬山的、游玩动线过长的景区;偏好人文的用户,自然景观类景点降低权重。过滤规则我用的是规则引擎的思路,把条件写成一组可以组合的策略,而不是写死在代码里。比如“带小孩”对应一个检查器,检查景点是否有大量台阶或者超过两小时步行;“爱刺激”对应另一个检查器,检查是否有索道、漂流、高空项目。这种策略模式后面想加新规则很容易。
第二步是打分。每个景点最终得分由基础分、热度分、偏好匹配分加权汇总而来。基础分就是景点的常规评分,热度分来自近七天的搜索量或浏览量,偏好匹配分取决于和用户标签的匹配程度。三个分数的权重不是固定的,我设计了一个简单的调节系数:如果用户在设置里明确勾选了“主打人文”,人文类景点偏好分权重直接拉到0.6,否则默认为0.3。这一步决定了哪些景点会被优先选进当天的行程。
第三步是时间窗口裁剪。这一步最关键,也最容易忽略。景点本身再好,如果当天时间已经不够用,就得放到后面的日期。我把每天可游玩时间设为8:00-18:00,午餐预留1小时,晚餐预留1小时,实际可用游玩窗口大约是10小时。然后从酒店位置出发,按贪心策略轮流挑选下一个景点,每选一个就扣掉通勤时间、游玩时间和缓冲时间,直到当天的窗口用尽或者可选景点已排完。
2.2 天气与景点动态信息接入
天气数据对行程的影响非常大,我见过太多人规划得再好,结果当天一场暴雨全部打乱。所以系统单独做了天气模块,对接第三方天气API,每天凌晨定时获取未来三天的天气数据,存入Redis,key设计为weather:forecast:{cityCode}:{date},有效期为一天。
拿到天气数据后,推荐引擎多了一个输入参数:如果预测某天有大雨,系统会自动把涉及户外的景点从当天剔除,换到后面晴天的日期;如果是博物馆、艺术馆、室内主题场馆这类室内景点,反而会优先分到雨天,既不浪费用户的时间窗口,也提升了体验。这个逻辑不复杂,但很多人做旅游系统的时候完全没想到这层,觉得天气是锦上添花,其实它是决定整个行程能否顺利执行的硬约束。
景点本身的动态信息我们同样要管,比如临时闭馆、限流公告。这种数据没有统一API,我在管理后台留了一个录入入口,运营人员手动更新,每次更新后通过消息通知相关用户。虽然这块工作量不大,但实实在在弥补了纯依赖外部数据的盲区。
2.3 路线优化:把TSP问题简化成贪心决策
路线优化的本质是解决一个“从酒店出发,经过多个景点,最后回酒店”的路径规划问题,学术界叫TSP问题。理论上可以用动态规划求最优解,但在实际业务里,我会劝你不要这么做。
原因有两个。第一,景点数量通常在5到10个之间,TSP的复杂度是城市数量的阶乘级增长,虽然10个点勉强还能算,但你已经引入了一个复杂度不可控的模块。第二,TSP假设“距离对称”,但实际通勤时间是高度不对称的,早高峰去城西和晚高峰去城西完全是两个概念,精确求解的意义不大。所以我的做法是贪心加“性价比”评分。
举个实际例子。假设用户住在坐标(1,1),候选景点A在坐标(2,8),游玩需2小时,评分4.8;景点B在(5,5),游玩需3小时,评分4.5;景点C在(8,2),游玩需1.5小时,评分4.2。先把坐标距离按当地平均车速换算成通勤时间,比如设车速为30km/h,地图上1个坐标单位折算成1公里。从酒店到三个景点的通勤时间大约分别是A点24分钟、B点11分钟、C点15分钟。算出“通勤+游玩总时间成本”,再算“评分/时间成本”的性价比,A约为4.8/154分钟≈0.031,B约为4.5/191≈0.024,C约为4.2/105≈0.04。第一站会选中C。
选完C之后,重新计算当前位置到剩余景点的通勤时间,继续按同样的方式迭代。这套逻辑写起来非常直观,用一个while循环就能实现,而且它天然支持“用户中途新增一个想去的地方”这种动态场景——只需要把新景点丢进候选队列,下一轮贪心自然会把最优解逼出来。实际线上体验下来,贪心方案生成的速度毫秒级,可解释性也好,用户问你“为什么第一站去这里”,你可以明确告诉他因为这一站时间成本最小、评分又高。
2.4 动态调整与任务通知
行程绝对不是一成不变的,真实世界充满了意外。用户在景区里逛得开心多待了一个小时,原定下一站势必要推迟;或者中途某个景点因为临时限流不接待了,后面的计划全得改。这块我用一个“行程变更事件”机制来处理。
用户在行程单上点击“完成游玩”按钮,后端会收到一个事件,系统重新计算当天剩余的时间窗口,再跑一次贪心逻辑,生成一份更新后的当天余下行程,并通过WebSocket推送给前端。同时系统也做了一版“定时体检”机制:每天早上七点,定时任务检查今天所有活跃行程的天气情况,如果遇到天气突变,自动重新规划并给用户发送通知。
通知模块我用的是@Async异步调用实现,没有直接接短信付费通道,而是预留了接口。实际项目里你可以接入阿里云短信或者微信模板消息,SpringBoot这层只是发了一个消息到队列,由消费者负责真正投递,这样避免同步阻塞拖慢主流程。
3. 数据库设计与缓存策略
3.1 核心表结构与关系设计
数据库设计直接决定了后面业务的扩展空间。我先画出核心表的清单,再说几个关键设计决策。
用户表、景点表、偏好表、行程单表、行程明细表、景点公告表,这是六个核心表。用户表存账号密码和基础信息,密码用BCrypt加密存储,这个不用多说。景点表存名称、城市、经度纬度、评分、热度、建议游玩时长、开放时间、闭馆时间、景点类型。偏好表存用户ID和标签集合,标签我按“人群类”“兴趣类”“节奏类”分组,比如“带老人”“亲子”“人文历史”“自然风光”“休闲游”“特种兵式”。
行程单表存用户ID、目的地、开始日期、结束日期、状态(草稿、生效、已完成、已取消)、生成时间。行程明细表是重头戏,每行代表一个已生成的景点安排,字段包括行程单ID、景点ID、游玩日期、当天序号、建议到达时间、建议离开时间、通勤分钟数、坐标快照等。
这里有两个设计决策我认为很关键。第一个是“行程明细表冗余景点名称和坐标快照”。虽然景点ID可以关联到景点表,但景点名称可能变更、坐标可能调整,如果用户拿到的历史行程单里存的只有ID,一旦景点信息改了,他看到的行程就全是错的。快照字段会占一点存储,但换来了历史行程的可回溯性,非常值。
第二个是“行程明细表加一个status字段”。当初我觉得有主表的状态就够了,后来发现用户经常有“今天玩了一半想改明天的行程”这种需求,如果你不知道每一天中哪些景点已经玩过了、哪些还没开始,动态调整就无从下手。所以明细表每行有独立状态:待游玩、进行中、已完成、已取消。这样改明天的行程完全不影响今天已完成的部分。
DDL里大概长这样,景点表的部分字段加个索引演示:
CREATE TABLE scene ( id BIGINT PRIMARY KEY AUTO_INCREMENT, scene_name VARCHAR(128) NOT NULL, city VARCHAR(64) NOT NULL, longitude DECIMAL(10,6), latitude DECIMAL(10,6), rating DECIMAL(2,1) DEFAULT 4.5, heat_score INT DEFAULT 0, duration_min INT DEFAULT 120, open_time TIME, close_time TIME, scene_type VARCHAR(32), snapshot JSON, INDEX idx_city_heat (city, heat_score) );3.2 Redis缓存读写策略
缓存这块我吃了不少亏,后来总结了一套比较稳的策略。核心思想是“热点数据优先缓存,冷数据回源数据库,兜底异步刷新”。
景点基础信息是最适合缓存的,一个景点被成千上万用户搜索,但数据本身很少变化。缓存key我写成scene:info:{id},value用JSON字符串,过期时间设定为24小时,设置随机加减10分钟的抖动,防止大量key同时过期造成数据库压力。热点榜单这类聚合数据,独立用scene:hot:{city}做缓存,十分钟过期,通过定时任务主动刷新。
用户维度数据缓存就要小心了。行程单缓存如果也扔Redis,后续变更、分布式锁、数据一致性会带来一堆麻烦。我的经验是:行程单不要缓存,直接走数据库。原因是它属于“写频繁、读频繁”的数据,一致性要求极高,一旦缓存没有及时失效,用户体验会很糟糕。而景点信息这种“读多写极少”的数据,缓存收益最大。
缓存穿透和缓存击穿的应对也要提前做。我们系统上线初期遇到过一个问题:某些不在景点表里的ID被人恶意批量请求,每次查Redis都没有,直接打到MySQL,数据库压力瞬间拉满。解决方案是标准的两板斧——空值也缓存,key存在但value为一个特殊标记;再加布隆过滤器做第一层拦网,把所有合法景点ID提前加载进去。布隆过滤器的误判率我配置在1%左右,大概用了8000万个bit位,内存占用约40MB,完全在可接受范围。
4. 实操过程:从骨架搭建到接口联调
4.1 项目初始化与目录结构设计
我习惯用Spring Initializr来创建项目,直接在浏览器上选好SpringBoot版本和依赖,下载解压就行。这里有个大前提:选版本前先想清楚自己环境里的JDK版本。SpringBoot 2.7用的是javax命名空间,JDK8和JDK11都能跑;SpringBoot 3.x强制要求JDK17,并且很多依赖包从javax迁移到了jakarta命名空间,网上大量老代码是不能直接用的。我的建议是如果目标是快速稳定上线,选SpringBoot 2.7稳妥;如果完全是新项目、新服务器,愿意处理兼容问题,再上3.x。
创建完的项目结构我做了如下调整,这是实践下来的分层方式:
src/main/java/com/example/travel/ ├── controller/ # 接口层,只做参数接收和结果转换 ├── service/ │ ├── impl/ # 业务实现 │ └── strategy/ # 推荐规则策略类 ├── mapper/ # MyBatis-Plus接口 ├── entity/ # 数据库实体 ├── dto/ # 请求响应对象 ├── vo/ # 视图对象 ├── config/ # Redis、WebMvc等配置类 ├── common/ # 统一返回、异常、常量 └── task/ # 定时任务这个结构最大的好处是依赖方向是单向的——controller依赖service,service依赖mapper,entity不依赖任何上层,改任何一个包不会牵连到其他包。很多新手喜欢把业务逻辑全堆在controller里,项目一大就变成意大利面条,千万别学那个。
4.2 SpringBoot整合MyBatis-Plus与Redis
MyBatis-Plus用起来几乎没有心智负担。引入mybatis-plus-boot-starter后,在application.yml里配置数据源和Mapper扫描路径就可以了。常用的CRUD、分页、条件构造器全是现成的,我只需要在Mapper接口继承BaseMapper,再写几个自定义查询方法。
分页插件需要单独配置一个拦截器,这块很多人会漏配导致分页失效。我在config包下专门建了一个MybatisPlusConfig配置类,注入MybatisPlusInterceptor,添加PaginationInnerInterceptor。这样调用Page对象时,SQL会自动拼上limit,不用每次手写分页。
Redis整合的核心踩坑点在序列化。默认的JdkSerializationRedisSerializer会把对象序列化成二进制字节,存进去Redis里没法读,也不利于排查。我改成StringRedisTemplate处理简单KV,复杂的对象用GenericJackson2JsonRedisSerializer。配置的时候注意把defaultSerializer设置好,不然一起注入多个RedisTemplate会出现类型转换异常。
4.3 Vue打包放进SpringBoot的完整路径
很多人在这一步卡住。我先说明白一个概念:Vite构建后的静态资源就是一堆HTML、JS、CSS文件,SpringBoot天然把classpath:/static/目录映射为根路径,所以你只需要把dist目录里的内容复制到src/main/resources/static/下,就能直接访问。
但有三个坑一定得处理。第一是Vite的base路径,默认是/,如果项目部署后不是挂在域名根路径下,就得改成./或具体子路径,否则JS和CSS资源会404。第二是路由模式,Vue Router如果用history模式,刷新页面时SpringBoot会把路径当成后端接口去匹配,结果404。这个问题的解决办法有两个——要么改用hash模式,URL变成带#的格式,刷新时不会发请求;要么在SpringBoot里加一个forwardController,把非/api路径统一转发到index.html。我推荐后者,因为hash模式有历史遗留的兼容问题,而且URL不好看。
第三是接口路径要统一前缀。SpringBoot接口全部挂在/api下,前端所有请求都走axios封装的client,baseURL设成/api,这样静态资源和接口路径永远不会打架。打包时我顺手写了一小段脚本,把dist目录里的内容清空再复制过来,这样就不会有旧文件残留导致缓存问题。
4.4 定时任务与异步处理
这个系统里有两类典型的定时任务。第一类是通过@Scheduled注解驱动的每日天气同步,在清晨五点执行,拉取当天和未来三天的数据。第二类是行程状态巡检,每十分钟跑一次,把已经过期但状态还是“待游玩”的明细自动置为“已完成”,避免用户忘记点击导致数据不准。
@Scheduled用起来很简单,但它默认是单线程执行的,如果两个任务都阻塞了,后面的任务会排队等。所以我在config包下配了ThreadPoolTaskScheduler,设置核心线程数8。同样,面向外部API的异步调用我用@Async,也建议配一个独立的线程池,不然默认的SimpleAsyncTaskExecutor是每次直接new线程,并发高了资源开销很大。
5. 实战问题排查与优化记录
5.1 SpringBoot版本过高引发的连锁反应
这是我觉得最值得分享的坑。项目初期我图新鲜选了SpringBoot 3.1,还没开始写业务就先被连续的问题打蒙。第一个问题是JDK版本,SpringBoot 3要求JDK17,而很多服务器上还是JDK8,为了部署还得先给服务器装新JDK。第二个问题是包名迁移,javax.servlet变成了jakarta.servlet,网上随便抄一段老代码几乎都会报编译错误。第三个问题是spring-boot-starter-validation里的一些参数校验注解用法也变了,老教程一抄一个错。
后来我老老实实降回SpringBoot 2.7.17,JDK8,整整一天什么都没干,只是把各种依赖版本对齐。这给我一个很深的教训:技术选型不是越新越好,“稳定能用”才是第一原则。尤其是做项目交付,环境不可控因素很多,别拿自己的时间赌框架的新特性。
5.2 一次缓存穿透排查实录
上线后有一段时间,我发现监控里MySQL的QPS时不时飙升,平时的三倍还多。打开数据库慢查询日志,发现大量相同的不存在的景点ID在反复查询。靠这个现象我基本判断是缓存穿透。
排查步骤是这样的。第一步,查Redis的hit rate,发现正常请求的命中率挺高,但异常请求的命中率接近0。第二步,回查最近一周的Nginx访问日志,发现问题请求集中在几个固定的ID,明显是被人写脚本扫接口。第三步,在系统的隔离环境复现,我对一个不存在的ID发请求,果然每次都穿透到MySQL。
解法就三个动作:对不存在的ID也缓存空值,过期时间缩短到五分钟;给所有查询景点信息的入口加一层布隆过滤器,合法ID集合全部预加载;再加一个简单的限流规则,对异常请求返回友好提示。三种措施齐上以后,MySQL的QPS降了下来,问题彻底解决。
5.3 常见问题速查表
我把实际过程中遇到的高频问题整理成一个速查表,方便你排查时快速对照。
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
| 前端打包放static后刷新404 | Vue Router启用了history模式 | 增加前端路由重定向到index.html,或改为hash模式 |
| Redis里key出现乱码 | 使用了默认序列化器 | 配置GenericJackson2JsonRedisSerializer |
| Redis宕机后接口全部变慢 | 缓存层依赖太重 | 配置Redis连接超时和失败降级,回源DB时加限流 |
| MyBatis-Plus分页失效 | 缺PaginationInnerInterceptor | 在配置类中注册分页拦截器 |
| SpringBoot版本3.x运行老代码报错 | jakarta命名空间迁移 | 降级到2.7.x,或全局替换包路径 |
| @Scheduled定时任务不执行 | 没在启动类加@EnableScheduling | 检查启动类注解,确认定时任务类被扫描 |
| 日期参数接收400 | 前端传入格式不匹配 | 统一前后端日期格式,用@JsonFormat标注 |
5.4 自定义自动配置的一点心得
既然用了SpringBoot,顺手再说一个扩展经验。旅途规划中有一个“地区滤镜”的需求,不同城市可用的景点集合和玩法差异巨大。考虑到以后可能会有新城市接入,我把城市特色玩法抽成了一个自定义Starter的思路——通过自定义AutoConfiguration配合@ConditionalOnProperty,在配置中心里动态控制哪些城市启用哪些扩展能力。
具体来说,写一个XxxAutoConfiguration类,用@ConditionalOnProperty(name = "travel.city.feature.xx", havingValue = "true")来控制bean是否装配。这样同一个代码包部署到不同城市环境里,能自动决定启用哪些玩法策略。这个思路其实也是SpringBoot自定义自动配置的典型应用,理解一次以后,很多东西都会触类旁通。
6. 部署上线后的体会
6.1 部署与运维
部署我用的是Docker Compose,一套组合把所有依赖串起来:MySQL容器、Redis容器、SpringBoot应用容器。写一份docker-compose.yml,里面定义好各服务的镜像、端口映射和环境变量,在服务器上执行docker compose up -d就完成部署了。这样后续的启动和升级都是一个命令的事,不用记住一长串操作步骤。
构建方面,如果你用的是阿里云之类的云平台,它们的构建流水线也支持直接打包Docker镜像。这里要提醒一点:构建环境配置一定要和你的开发环境保持一致,尤其是JDK版本。我有一次在本地用JDK11打的包,部署到一个JDK8的环境里直接启动失败,排查了半天才发现是编译字节码版本的问题。后面统一在流水线里固定基础镜像为maven:3.8-jdk-8,这个问题再也没出现过。
6.2 装完后我学到的几件事
这个项目做下来,我最深的感受是“系统好不好用,往往不取决于算法有多高级,而取决于你是否想清楚了边界条件”。贪心算法谁都会写,但真正让用户觉得“这系统真懂我”的,是下雨天自动换室内景点、老人走不动时自动减少当天景点数量、酒店定得远时把通勤时间算进去这些细节。
第二个体会是SpringBoot确实解决了开发效率的大头,但绝不能止步于会用注解。自动装配原理、条件装配机制、自定义Starter——这些底层逻辑在一次次的排查中慢慢才真正理解。面试的时候经常有人问我SpringBoot的自动装配原理,我会用一句话总结:它是通过条件注解,在类路径满足条件时自动提供默认配置,让使用者以极小成本集成第三方组件。看得懂这句话,才算真正入门。
最后想分享一个实用的小技巧:本地开发时,把SpringBoot的日志级别调成DEBUG,你会看到自动配置类生效过程的详细日志。这些日志有助于理解SpringBoot在启动时都做了哪些事。别觉得物理链路远,多打日志多看堆栈,是排查问题、熟悉框架最省事的路径。