从想到做这个“基于SpringBootVue智能旅游行程规划系统”开始到真正跑通上线前后花了差不多两个月时间。中途踩过的坑、推翻重来的设计比代码本身更有说头。这篇算不上教程更像是我自己做这个毕业设计级项目的一份完整复盘——从需求怎么拆、表怎么建到贪心算法怎么落地、跨域和并发会遇到什么问题全记录在内。如果你正准备做类似的系统或者想用SpringBootVue快速搭一个带业务逻辑的全栈项目这篇应该能让你少走不少弯路。1. 项目定位与整体设计思路1.1 这个系统到底解决什么痛点先说背景。现在旅游APP已经很多了但大多数偏向“酒店机票”的预订真正把“旅客到了目的地之后怎么玩”这件事做好的不多。尤其对于第一次去某座城市的游客面对一堆景点时间有限、交通不熟、预算不明最典型的场景就是手机里收藏了十几个“必打卡”地点最后实际只去了两三个路上还浪费了大量时间在景点之间的通勤上。我当时的目标很明确做一个智能旅游行程规划系统让用户输入出发地、目的地、游玩天数、偏好类型比如偏向自然风光、人文历史、亲子乐园还是美食打卡系统自动生成一份按天排序、包含景点、交通时间、用餐地点、休息时段的完整行程单。核心是把“规划”从人工翻攻略变成算法自动算出来。从项目属性来说这是一个典型的管理系统推荐引擎混合体。管理端负责景点、酒店、线路、标签等基础数据的维护用户端负责浏览、收藏、规划行程。教学过程或求职展示中最能体现技术含量的部分集中在两个地方一是行程规划的核心算法二是前后端数据交互和权限控制的工程化能力。面试官和导师一般先看项目是不是完整再看关键逻辑是不是自己写的而不是套模板。1.2 技术选型为什么是SpringBootVue而不是其他组合这个标题看着像标配但做之前我是真犹豫过。备选项里还有SpringCloud微服务、DjangoVue、甚至SSMSpringSpringMVCMyBatis。最终敲定SpringBootVue理由有这么几条SpringBoot适合快速搭建稳定的后端服务。它内嵌Tomcat、自动配置、Starter生态完善不需要像SSM那样配置一堆XML。对一个以业务逻辑和算法实现为主的项目SpringBoot能把更多精力留给业务代码。Vue在前端开发效率上有明显优势。组件化开发配合Element UI或Vant很快就能搭出管理后台和移动端风格的页面。比起原生JS和JQueryVue的双向绑定和组件通信让交互逻辑清晰很多。前后端分离符合当前主流协作模式。也就是前端通过HTTP请求调用后端RESTful API后端不再关心页面渲染。这既是行业现状也能在项目答辩时展示你对工程化概念的理解。微服务不是不能用但一个行程规划系统拆成多个服务就需要引入注册中心、配置中心、网关、分布式事务复杂度骤然上升。除非你的场景明确有高并发扩展需求否则在课程设计和毕业设计阶段单体应用足够说明问题。我的判断是用最简单的方式把业务做扎实比堆技术栈更有说服力。还有一个隐藏考虑是学习成本和报错排查。SpringBootVue遇到问题随便一搜就是大量解决方案社区成熟度极高。相比冷门组合这种“大众技术栈”能让你在卡壳时快速找到出路。2. 核心功能模块拆解与数据库设计2.1 模块划分与功能清单功能模块我最终划分成三个端用户端小程序风格Web页面、管理端后台管理页面、服务端API接口。注意这里没有做原生小程序而是用Vue做了仿小程序的响应式页面部署后直接用浏览器访问也方便演示。先从用户端说起。未登录用户可以浏览景点列表、查看景点详情、筛选景点类型登录用户可以收藏景点、提交行程规划请求、查看历史行程。行程规划是本系统的灵魂用户输入“3天2晚游”目标游览城市为杭州举例偏好人文历史系统会返回一份精细到小时的每日行程包含路线顺序、交通方式建议、每个景点停留时间、午餐/晚餐推荐位置。管理端功能就比较常规但不可缺失管理员登录、景点管理增删改查、景点类型管理、城市管理、用户管理、行程方案管理可以手动调整算法生成的方案后保存为模板、数据统计每日新增用户、热门景点Top10、规划次数。服务端除了常规的CRUD接口还提供了两个核心接口/planner/generate生成行程和/planner/adjust手动调整行程。这两个接口背后是算法代码不是简单的数据库查询。很多人做类似系统时会把行程写死成几条固定路线那样也能把页面跑起来但“智能”两个字就名不副实了。2.2 数据库表设计与关键字段说明表结构是整个系统的基础。设计得不够好后续算法和查询会很痛苦。我维护了九张核心表把关键的表和字段列出来用户表sys_userid、username、password加密存储、nickname、phone、avatar、create_time城市表cityid、city_name、province、description、cover_url这里注意城市需要支持热门程度排序我加了一个hot_rank字段。景点表scenic_spotid、spot_name、city_id、type_id关联类型表、address、latitude、longitude、open_time、close_time、suggest_duration建议停留时长单位小时、ticket_price、score、cover_url、description这个表是算法的核心数据源。suggest_duration特别重要算法分配时间全靠它。latitude和longitude用于计算景点之间的交通耗时而不是只存一个“大约30分钟”的字符串。景点类型表spot_typeid、type_name如自然风光、人文古迹、主题乐园、城市观光收藏表user_favoriteid、user_id、spot_id、create_time唯一索引(user_id, spot_id)行程表trip_planid、user_id、city_id、plan_name、days、start_date、budget、generate_status、total_score、create_time行程明细表trip_plan_detailid、plan_id、day_index、seq当日顺序、spot_id、start_time、end_time、transport从上一个景点到达这里的交通方式、transport_duration、note管理员表admin_userid、username、password系统日志表sys_log记录关键操作日志比如用户生成了行程、管理员修改了景点。为什么要单独把行程拆成主表和明细表因为一个行程包含多天每天包含多个景点典型的一对多。主表保存用户、城市、天数等概要信息明细表保存每一天每一个景点的顺序和时间这样在展示行程详情时只需根据plan_id一次查出明细按day_index和seq排序。另外还有一张交通时长表我没做成表而是写在算法里利用景点经纬度直接计算两地点间的直线距离再除以一个经验车速得到估时。原因后面章节会细说。2.3 行程规划的算法思路贪心约束满足这是项目中我最看重的一部分。面试问道核心功能时八成就围绕这里。行程规划本质上是一个带有约束的路径优化问题。景点之间有多条线路选择每天的时间有限还要考虑开放时间、游逛时长、用餐时间。理论上可以用动态规划求解最优解但景点数量一旦超过二三十个计算量会指数增长对实时请求不友好。实际项目里也不需要全局最优只需要一个在几分钟内生成、看起来合理、符合用户偏好的方案就够了。我采用的方案是贪心算法构造初始解 简单约束修正。具体分四个步骤过滤景点根据城市和用户偏好的景点类型从数据库中取出候选景点列表。如果用户没有指定偏好按综合评分score排序取前N个。按天切分先把每天可用的时间计算出来。假设一天游玩时间是9:00-18:00扣掉午餐1小时实际可用约8小时。再按固定节奏分配到景点上。逐日贪心选择每天从候选景点里挑一个“性价比”最高的加入行程。性价比怎么定义不能只看评分高还要看与上一个景点的距离是否合适。所以我用了一个加权公式weight score - alpha * travel_time - beta * (suggest_duration - ideal_duration)^2其中alpha和beta是经验参数我调过几轮最终alpha取0.5beta取0.1单位按小时折算。这个公式的意思是评分高要加分但路程远和停留时间不合适的景点会被降权。安排具体时间段为选中的每个景点安排起止时间从最早景点开放时间开始依次累加交通时长和建议停留时长遇到午餐时段就自动插入用餐休息点时间不够就把剩余景点放到下一天。算法听起来简单但落地时有很多细节要考虑。比如景点有固定开门时间如果贪心选中的景点是早上8点开门而前一天晚上换酒店导致出发到那里要1小时那8点前的时间就浪费了。我的做法是每个景点的start_time要满足start_time open_time 到达时间如果冲突会尝试把行程的出发时间延后或者调整景点顺序。再比如用户偏好“亲子”那么在贪心权重里可以把“评分”换成“适合亲子指数”这些数据可以做成景点的扩展字段。为了让算法更灵活我单独写了一个策略类PlannerStrategy接口不同的偏好对应不同的权重计算实现符合Java面向对象的设计原则也方便后续扩展新玩法。整个算法的时间复杂度是O(N*M)N是景点数M是计划游览的景点数量实际场景中完全够用。用户体验上生成一个3天行程基本在1秒内返回一点不卡顿。3. 前后端实现的关键细节3.1 后端SpringBoot分层架构与核心接口设计我采用经典的四层结构Controller-Service-Mapper数据访问 -Entity。项目包结构如下com.travel.planner ├── controller ├── service │ └── impl ├── mapper ├── entity ├── dto ├── common │ ├── result │ ├── exception │ └── utils └── configController层只做参数接收和结果封装不写业务逻辑。Service层是核心尤其是TripPlanService和PlannerService。Mapper层用MyBatis Plus省去大量XML配合LambdaQueryWrapper做条件查询非常方便比JPA更直白可控性强。这里分享一个接口设计的细节不要把所有字段都返回给前端。比如用户密码就不应该在用户信息接口出现。我在UserVOView Object中只返回安全字段通过Spring的BeanUtils拷贝属性。这样既安全又让前端拿到的数据结构稳定。核心接口统一返回格式我定义了一个ResultT类public class ResultT implements Serializable { private Integer code; private String message; private T data; // 静态方法 success()、fail() 等 }所有Controller方法都返回Result?这样前端可以统一判断成功与否不需要每个接口各自定义返回格式。这个习惯如果从项目一开始就养成后期联调会省很多精力。行程生成接口的核心逻辑大概长这样PostMapping(/planner/generate) public ResultTripPlanVO generate(RequestBody GeneratePlanRequest req) { Long userId TokenUtil.getCurrentUserId(); TripPlanVO vo tripPlanService.generatePlan(userId, req); return Result.success(vo); }Service里的generatePlan方法先读城市和景点再调用PlannerStrategy按偏好计算然后逐日构建TripPlanDetail保存最后组装成VO返回。每天都会把当天总耗时和自由度也就是可以灵活变通的时间算出来方便前端展示“合理度星级”。除此之外权限控制我用了拦截器模式配置InterceptorRegistry拦截需要登录的接口验证请求头里的token。没有引入Spring Security因为至少要应付一个系统的登录鉴权JWT方案更轻量。注意token过期后要返回特定的错误码比如401前端捕获后跳转登录页而不是闷头报错。3.2 前端Vue页面结构、组件化与状态管理前端我用Vue 3 Vite Element Plus没有用Vuex而是用了Pinia。Pinia对Vue 3支持更好API更简洁。整个前端项目结构src ├── api // axios封装每个模块一个js文件 ├── assets ├── components // 公共组件 ├── router ├── store // pinia模块 ├── views // 页面 │ ├── home │ ├── spot │ ├── planner │ ├── user │ └── admin └── utilsaxios封装要注意统一拦截器。请求拦截器里从localStorage取token加到请求头响应拦截器里判断code如果是401就跳转登录、清除本地状态如果是500就弹出统一错误提示。这样业务代码里就不需要每个接口都去catch错误。页面层面最复杂的是行程结果页。它展示的是一份“时间轴式”的行程单顶部是城市和总天数卡片下面按“第1天”“第2天”分组每天里面有一个时间线组件每个节点显示景点名、时间、交通方式和备注。我用了一个TimelineItem组件接收一个detail对象组件内部渲染。前端调用行程生成接口时需要把用户选的偏好、天数、城市ID传过去。这里有一个交互细节生成过程如果超过两秒用户容易以为卡死了所以我在发起请求时把按钮状态设为loading并且禁用重复提交如果后端返回失败会把失败原因回显到页面上而不是默默失败。为了演示更直观我还加了一个“模拟浏览”功能把行程按时间顺序自动打开每个景点的详情弹窗配上图片和简介视觉上比纯文本列表有冲击力答辩和展示时很加分。3.3 核心流程代码演示行程生成接口虽然前面说了大概但行程生成的实际代码值得摊开来看。我用一个简化版去掉一些校验和边界处理保留最核心的计算逻辑。public TripPlanVO generatePlan(Long userId, GeneratePlanRequest req) { // 1. 查询候选景点列表 ListScenicSpot candidates scenicSpotMapper.selectList( new LambdaQueryWrapperScenicSpot() .eq(ScenicSpot::getCityId, req.getCityId()) .eq(req.getPreferenceId() ! null, ScenicSpot::getTypeId, req.getPreferenceId()) .orderByDesc(ScenicSpot::getScore) ); if (candidates.isEmpty()) { throw new BizException(该城市暂无符合条件的景点); } // 2. 按天数切分 ListListScenicSpot daySpots new ArrayList(); ListScenicSpot remaining new ArrayList(candidates); int totalDays req.getDays(); for (int d 0; d totalDays; d) { // 这里调用贪心选择当天景点 ListScenicSpot dayList selectDaySpots(remaining, d); daySpots.add(dayList); remaining.removeAll(dayList); if (remaining.isEmpty()) break; } // 3. 保存行程主表和明细表 TripPlan plan new TripPlan(); plan.setUserId(userId); plan.setCityId(req.getCityId()); plan.setDays(totalDays); plan.setPlanName(req.getCityName() totalDays 日游); tripPlanMapper.insert(plan); ListTripPlanDetail details new ArrayList(); for (int day 0; day daySpots.size(); day) { ListScenicSpot spots daySpots.get(day); LocalTime currentTime LocalTime.of(9, 0); int seq 0; ScenicSpot prevSpot null; for (ScenicSpot spot : spots) { double travelMinutes 0; if (prevSpot ! null) { travelMinutes TrafficUtils.estimateMinutes(prevSpot, spot); } // 考虑到开放时间当前时间交通时间不能早于开放时间 LocalTime arrivalTime currentTime.plusMinutes((long) travelMinutes); LocalTime actualStart arrivalTime.isBefore(spot.getOpenTime()) ? spot.getOpenTime() : arrivalTime; LocalTime actualEnd actualStart.plusMinutes((long)(spot.getSuggestDuration() * 60)); TripPlanDetail detail new TripPlanDetail(); detail.setPlanId(plan.getId()); detail.setDayIndex(day 1); detail.setSeq(seq); detail.setSpotId(spot.getId()); detail.setStartTime(actualStart); detail.setEndTime(actualEnd); detail.setTransport(prevSpot null ? 出发 : 乘车); detail.setTransportDuration((int) travelMinutes); details.add(detail); currentTime actualEnd.plusMinutes(30); // 每个景点结束后休息30分钟 prevSpot spot; } } // 批量保存明细 tripPlanDetailMapper.insertBatchSomeColumn(details); // 4. 组装VO返回 return planConverter.toVO(plan, details); }selectDaySpots方法是贪心的核心我单独提出来private ListScenicSpot selectDaySpots(ListScenicSpot remaining, int dayIndex) { ListScenicSpot result new ArrayList(); LocalTime cursor LocalTime.of(9, 0); ScenicSpot prev null; double dayBudget 8.0; // 每天可游玩8小时单位小时 for (ScenicSpot next : remaining) { if (prev null) { double duration next.getSuggestDuration(); if (duration dayBudget) { result.add(next); dayBudget - duration; prev next; cursor cursor.plusMinutes((long)(duration * 60)); } } else { double travel TrafficUtils.estimateMinutes(prev, next) / 60.0; double duration next.getSuggestDuration() travel 0.5; // 加休息缓冲 if (duration dayBudget) { result.add(next); dayBudget - duration; prev next; } } if (dayBudget 0) break; } return result; }这段代码比较简单但它说明了贪心的基本思路顺序遍历候选景点已经按评分排序只要时间够就加入当天行程。实际项目中我还加了一步——当剩余时间较短但不能塞入下一个完整景点时会尝试找适合打卡拍照的“低时长”景点suggestDuration 1让碎片时间不被浪费。3.4 交通时间估算的补充方案前面提到交通时长表没有单独成表我用了坐标距离换算。具体来说用数学库算出两个景点经纬度之间的球面距离然后除以一个经验车速市内自驾取30km/h公共交通取20km/h再乘以1.3的绕路系数。这个方法肯定不如地图API精确但好处是不依赖外部服务、零成本、响应快。对于行程估算完全够用反正用户到了真实场景中本来就会按实时路况调整。如果你想让结果更精确一点也可以在管理端手动维护“景点A到景点B”的距离表但那需要维护N*(N-1)/2条数据城市景点多了以后维护成本很高不是这个体量的项目应该做的事。4. 实操中踩过的坑与排查技巧4.1 跨域、Token失效这几个老问题前后端分离最先遇到的坑就是跨域。我在后端写了一个全局CORS配置类Configuration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(Registry registry) { registry.addMapping(/**) .allowedOriginPatterns(*) .allowedMethods(GET,POST,PUT,DELETE,OPTIONS) .allowedHeaders(*) .allowCredentials(true) .maxAge(3600); } }看似简单但有一个隐藏坑如果使用了自定义拦截器OPTIONS预检请求会被拦截器拦住返回401导致前端报跨域错误。这类问题表现为前端console里报CORS error但其实后端接口已经执行了。解决方法是拦截器里放行OPTIONS请求if (OPTIONS.equals(request.getMethod())) { return true; }其次Token过期的问题也容易出现。JWT过期时间我设的是24小时但前端页面如果长期挂机过期后第一次请求会返回401。我在响应拦截器里做统一处理收到401就清空localStorage并跳转登录页。这样用户虽然会被迫重新登录但不会看到一堆报错。4.2 行程算法参数调参与可用性优化算法不是写完就完事需要反复调整参数。刚开始我把每天可用时间定为9小时结果生成出来的行程经常出现景点关门时间已到但还在“游览”的情况。后来增加了一个校验——每个景点的结束时间不能晚于该景点的close_time冲突时自动跳过该景点并尝试下一个候选。这种“跳过”策略其实是大优化因为行程里如果塞进一个实际去不了的景点用户到了现场才傻眼体验很差。宁可让某一天少一两个景点也不要把不可能实现的路线展示给用户。另外不要忽略午餐时间的插入。最初的行程里12:00-13:00这个时段可能正在某个景点里“游览”用户根本没法吃饭。我后来把每天的12:00到13:00强制设为用餐时间在景点安排时如果时间跨过这个区间就会把它切成两段并在前后景点之间自动插入“午餐”节点。这个细节在展示时非常打动用户因为行程关系到了实际生活的节奏。4.3 性能和并发处理的小经验先说说数据库查询。刚开始用MyBatis Plus的selectList查全部景点然后代码里再用stream过滤数据量小的时候没问题但景点数据多了以后每个请求都全表扫描显然不划算。后来改用了LambdaQueryWrapper条件查询直接下推到SQL性能提升明显。也加了一层Cacheable做Redis缓存热点景点列表基本不再打数据库。再谈谈并发。生成行程接口本身不重但如果不做防护用户连续点击多次会产生多条废弃行程。我在前端加了loading防重复点击后端也在TripPlanService里做了一个简单的幂等控制同一个用户一分钟内最多生成一次行程超过了就提示“操作过于频繁”。用小而实用的防刷逻辑避免资源浪费。5. 部署上线与运维中的心得体会5.1 环境准备与项目打包开发环境是Windows 本地MySQL部署到Linux服务器时统一跑在Docker里。简单说一下打包流程后端使用Maven打包执行mvn clean package -DskipTests生成一个可执行的jar包。这个jar包用SpringBoot内置的Tomcat直接跑不需要额外装Tomcat部署成本低是单体项目最大的优势。前端使用Vite构建执行npm run build生成dist目录。Nginx配置上将dist目录作为静态资源根目录把所有非/api开头的路径都重定向到index.html便于Vue Router的history模式正常路由/api开头则反向代理到后端Java服务的8080端口。Nginx一段关键配置示例server { listen 80; server_name yourdomain.com; root /usr/share/nginx/html; index index.html; location / { try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8080/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }这个方案部署成本低前后端完全分离符合生产环境的标准做法。不过要提醒一下如果直接把后端jar包暴露在公网一定要把MySQL的密码和JWT的secret改成环境变量不要写死在代码里。5.2 服务器配置和上线后的监控我的服务器配置是2核4G内存对于这个项目来说绰绰有余。Java进程占用大约800MB左右MySQL占用约400MB前端静态资源被Nginx服务内存基本稳定在60%以下。上线后我记录了几个关键指标行程生成接口平均响应时间在200ms以内。首页加载时间在无缓存时约1.5秒有缓存后降到500ms以下。日常无并发压力Java进程的GC情况良好Young GC次数都很少。如果你想把项目做得更正规可以接一个简单的看板监控接口QPS、错误率、慢请求数。这些不是必须的但如果你在简历或答辩中能说出“我上线后对系统做了哪些监控”会让人觉得你不是只做了个demo而是真的考虑过运维场景。6. 写在最后的一点体会这个项目做下来我对“智能”两个字的理解变深了。真正的智能不是堆一堆高深算法而是把用户需求转化成明确的约束再用代码解出来最后让用户觉得“这行程安排得还挺像回事”。那种感觉不是写完一个CRUD就能体会到的。如果让我重新做一次我会把算法部分换成约束规划或者简单的动态规划在数据量不大时也能跑出最优解还会加上一个“行程导出为PDF”的功能方便用户打印。这些其实都不难只是当时时间没排开。最后再分享一个小技巧写这类项目时先把“最独特的功能”做出来哪怕页面丑一点再回头补常规功能。因为答辩或展示时考官最想看到的不是你的增删改查而是你面对一个具体问题时的思考过程和解决路径。把行程规划算法讲透了项目就已经成功了一半。