
每年到了毕业设计高峰期总有人跑来问我这类旅游系统到底该怎么做。说实话旅游类项目在Java全栈毕设里属于特别能打的那一类业务链条清晰、角色边界明确前端后端的技术栈都能完整覆盖而且演示效果好答辩时讲用户故事也不费劲。这个项目以“豫见你旅行社”为业务背景把旅游规划、酒店预订、订单管理、后台运维整合到一套系统里技术组合就是最常见的SpringBoot Vue附完整源码拿来当本科毕业设计或者Java全栈练手项目都非常合适。我先说结论这个选题最大的价值在于“规划 预订”两条业务线天然互补。游客可以浏览景点、自助规划每天行程然后把对应日期的酒店预订下来管理员在后台维护景点、路线、酒店房型和订单。整个过程覆盖了权限控制、关联表查询、库存约束、日期校验、状态流转这些高频考点每一个都是答辩时老师喜欢追问的点。接下来我从设计思路、数据库、后端接口、前端交互到实操经验和避坑记录完整拆一遍。1. 项目整体设计与选题拆解1.1 为什么要把“旅游规划”和“酒店预订”做进同一个系统很多旅游类毕设只做了一端要么只有景点展示要么只做酒店下单看起来功能不少实际上业务是散的。这个项目把两条线合并核心是打造一个闭环用户先浏览景点和推荐线路再按天排出自己的行程系统根据行程涉及的日期和目的地推荐合适的酒店最后提交预订。这样一来用户行为是一条连续的链路数据库表之间的关联也顺理成章。这里有个容易被忽略的点旅游规划不是简单的“收藏景点”而是把多个景点按天排序、组合成路线。如果只做景点列表那和新闻展示系统没本质区别。加入“按天规划”之后至少要处理两张表的关联也就是路线表和路线明细表这就在表设计里增加了难度但也是加分项。从答辩角度说这种闭环设计也更好讲。老师问你“为什么需要这两个模块”你可以直接说用户先选景点安排行程再根据行程订酒店这是真实的旅游消费路径系统按这个路径设计业务逻辑自然成立。1.2 功能模块全景与角色权限整个系统按使用角色拆成两端我在做的时候是这样划分的。用户端功能注册登录手机号加密码注册支持JWT令牌登录密码加密存储景点浏览按分类、目的地关键词搜索景点看详情和图片旅游规划选择景点添加到某一天的行程可拖拽排序可一键清空或保存为模板线路展示浏览管理员发布的团队游线路按天数、价格筛选酒店预订按城市、星级、价格区间筛选酒店查看房型、选择入住和离店日期提交预订订单中心查看旅游订单和酒店订单管理取消、确认状态评价与收藏对住过的酒店、去过的景点写评论和打分管理端功能仪表盘统计用户数、景点数、订单量和营收金额景点管理新增、编辑、上下架景点上传图片线路管理维护团队游路线的天数和景点组合酒店管理维护酒店信息、房型价格、每日库存订单管理查看全部订单处理取消和退款申请用户管理重置密码、禁用账号我在实现时用了一个最简单的角色字段用户表里存role1表示管理员0表示普通用户。前端根据登录返回的role判断显示哪些菜单后端在需要管理的接口上做权限校验。这比单独建一张权限表要省事但答辩时如果有人问“如果以后有多个角色怎么办”你可以回答扩展为独立的角色权限表。2. 技术选型与项目结构设计2.1 为什么是SpringBoot Vue MySQL这个组合在近几年的Java毕设里几乎是默认选择原因很简单。后端用SpringBoot因为它内嵌了Tomcat不用单独部署Web容器一个jar包就能跑起来。它对MyBatis、Redis、JWT这些常用组件都有非常成熟的集成方案省去大量XML配置。对比早期用SSH框架SpringBoot的自动配置机制能让项目启动成本低得多对于本科阶段来说学习的曲线也友好。前端用Vue核心是组件化开发。旅游系统页面多每个页面又有搜索栏、列表、分页、弹窗这类重复结构抽成组件之后复用率很高。Vue配合Vue Router做路由跳转、Axios做请求、Element UI做后台界面整个开发效率和展示效果都比传统JSP加JQuery高一截。数据库我用的MySQL理由很直接业务数据量小表结构清晰事务和锁机制足够应付订单流程而且学校机房和演示环境普遍都有。如果后面想让系统更有亮点可以把Redis引进来做热点景点缓存或库存预扣但作为毕设基础版本MySQL单库完全够。2.2 项目目录与代码组织方式后端我按照常见的分层结构来组织这种做法在代码检查时很加分。项目结构大致如下controller接收前端请求参数校验调用serviceservice业务逻辑比如订单状态流转、库存校验mapper基于MyBatis-Plus的数据访问层entity数据库实体类dto前端传递的数据对象比如登录请求、订单提交数据common统一返回结果、全局异常、拦截器配置有几个细节值得说。第一返回给前端的数据不要直接用Map拼我封装了一个Result对象里面放code、message、data三个字段。前端拿到返回值先判断code是否为200再做后续逻辑这样联调的时候接口查询很清晰。第二全局异常处理一定要做用RestControllerAdvice把业务异常、参数校验异常、系统异常分开捕获返回给前端时带上具体的提示信息否则前端一报错就是500根本不知道哪一步出了问题。前端目录相对简单views页面组件router路由配置store状态管理我用Vuex存用户信息和登录状态api统一封装axios请求components复用组件比如景点卡片、酒店列表项、分页条实际开发时有个小技巧所有请求路径在api目录里统一用函数封装页面里不要直接写axios字符串。这样后端接口一改前端只需要改一个文件避免满项目找路径。3. 数据库设计核心表与业务关系3.1 用户、景点与线路规划表这个项目的表不算多我设计时一共是十张左右但每张表的字段和关联关系都需要理清楚。用户表是最基础的id主键自增username登录名唯一password加密后的密码phone手机号role角色0用户1管理员avatar头像地址create_time注册时间景点表稍微多一点idname景点名称description文字介绍location所在城市category分类比如自然风光、古镇、博物馆pics图片地址多个图片用逗号分隔price门票参考价status上下架状态0下架1上架heat热度值用于推荐排序线路规划这里是最需要说清楚的地方。我没有把整条路线塞在一个字段里而是拆成两张表travel_route存线路的基本信息比如线路名称、总天数、封面图、发布时间route_item存每一天的景点明细字段是route_id、day_number、scenic_id。这样设计的好处是查询某条线路第几天包含哪些景点时只需要按day_number排序查route_item表再批量关联景点详情页面渲染非常方便。如果只用一个字段存景点ID列表比如“1,2,3,4,5”前端确实也能展示但要做景点信息回显、调整顺序就非常痛苦这也是我踩过坑之后的选择。3.2 酒店、房型与订单体系酒店的部分比景点复杂因为它涉及库存和价格要特别小心设计。hotel表酒店名称、地址、星级、简介、封面图room_type表所属酒店id、房型名称、门市价、面积、床型、早餐、可住人数room_inventory表当天库存表字段包括room_type_id、日期、剩余房间数这里为什么单独做一张库存表而不直接在room_type里存一个总库存因为酒店的价格和房间余量是随时间变化的。同一间房今天剩5间明天可能就满了所以必须按日期记录。前端预订时传入住日期和离店日期后端要校验每一天都还有余量才能允许下单。订单表我分了两类hotel_order订单号、用户id、房型id、入住日期、离店日期、房间数、总价、状态、创建时间route_order订单号、用户id、线路id、出发日期、旅游人数、总价、状态另外还有评论表和收藏表都是通用结构。评论表用target_type区分是景点评论还是酒店评论用target_id存对应的景点或酒店id这样一张表搞定两种业务不会冗余。表之间的关联很直接用户表是订单的外键酒店表和房型表是一对多线路表和明细表是一对多库存表挂在房型下面。数据库外键约束我建议建上虽然MyBatis-Plus不需要外键也能查但建上外键能让表关系更清晰答辩时看ER图也更好讲。4. 后端核心功能实现鉴权、接口与业务逻辑4.1 基于JWT的登录鉴权机制旅游系统里的用户操作大多需要登录比如提交订单、查看订单、发表评论所以必须有个可靠的登录鉴权方案。Session方案在前后端分离场景下不太好用因为前端可能跑在8080端口后端跑在9090端口Session的Cookie跨域问题处理起来很绕。我用了JWT无状态、不在服务端保存会话前端拿到token存在localStorage里每次请求通过Axios拦截器放到请求头中。登录接口的核心逻辑是这样的用户提交用户名和密码后端先从库里查用户用BCrypt的matches方法比对密码比对通过后生成一个token。token里我放了用户id和role两个字段然后设置一个过期时间一般给24小时。生成之后把token和用户信息一起返回前端。在拦截器的实现上我用Spring的HandlerInterceptor对需要登录的路径做校验。前端每次请求带上Authorization请求头拦截器里解析token如果解析失败就返回401如果通过就把用户id放到request的attribute里供后续controller使用。代码很简洁核心部分大概是public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token request.getHeader(Authorization); if (token null || token.isEmpty()) { return noAuth(response, 未登录); } try { Claims claims JwtUtil.parseToken(token); request.setAttribute(userId, claims.get(userId)); request.setAttribute(userRole, claims.get(role)); return true; } catch (Exception e) { return noAuth(response, 登录已过期); } }这里有个容易忽略的细节不要只判断token能不能解析还要判断token里的用户是不是还存在比如用户可能已经被管理员禁用删除了。我是在需要获取用户的接口里再查一次最新数据库状态避免残留在token里的失效用户继续操作。4.2 酒店库存防超卖设计酒店预订如果只是简单地“查一下余量然后insert订单”一旦有两个人同时订最后一间房就会出现超卖。毕设可能没什么并发压力但老师很喜欢问“你如何处理并发下单”所以这块要提前准备。我的方案是乐观锁思路。room_inventory表里加了一个version字段下单时先查库存和版本号满足条件后执行更新语句在更新条件里带上version并且把version1。如果更新影响行数为0说明版本已经变了就返回“手慢无”的提示。插入订单和扣减库存的操作必须放在同一个事务里用Transactional注解保证一致性。如果订单创建成功但库存扣减失败整个事务回滚不会留下半个订单。核心更新语句大概是int result roomInventoryMapper.deductStock(inventoryId, updateTime); // 对应SQL: update room_inventory set stock stock - 1, version version 1 // where id #{id} and stock 0 and version #{version}如果你想把这块再提升一个台阶可以提Redis分布式锁或者Redis预扣库存的思路不过基础版本用乐观锁已经足够而且实现简单、容易解释。4.3 订单状态机与价格计算订单设计时必须规划好状态不能让状态字段随意写。我定义了五个状态用数字存0待支付用户刚提交订单1已支付模拟支付成功后进入待确认2已确认管理员确认订单房型锁定3已完成入住结束订单完成4已取消用户取消或超时取消状态流转我严格限制方向比如已完成订单不能取消已取消订单不能回到待支付。实现方式是在service层写一个统一的方法来处理状态变更不直接允许前端随意改状态同时在后端接口里校验当前状态是否允许目标状态流转。酒店价格计算有一个常见的坑同一天入住又离店是不合理的前端要校验后端也要兜底校验。正确逻辑是晚数等于离店日期减去入住日期的天数总价等于每晚价格乘以晚数再乘以房间数量。比如客人订了3月1日入住、3月4日离店那就是3晚。我碰到过有人把离店当天也计入收费导致价格多算一晚被用户投诉这种细节在演示前一定要自己测一遍。日期参数从前端传过来的时候我使用的是yyyy-MM-dd格式的字符串后端用LocalDate接收。如果要用Date类型就要注意时区问题很容易出现相差8小时的情况后面排查部分会详细说。5. 前端Vue页面与交互实现5.1 页面框架与路由设计前端的页面框架我分成了用户端和管理端两套布局。用户端顶部导航是Logo、景点、线路、酒店、我的订单底部是页脚整体风格走清新简洁路线。管理端则用侧边栏布局左侧是菜单右侧是内容区使用Element UI的容器组件搭建。路由设计时我先把页面分成公开页面和登录页面两类。公开页面包括首页、景点列表、酒店列表这些不需要登录就能访问。而提交订单、查看订单、评论这些操作必须登录。实现方式不是单纯在前端隐藏按钮而是在axios的请求拦截器里检查localStorage有没有token如果没有就直接跳转登录页。在这里我踩过一个坑当时只想在前端判断登录状态结果只要手动改一下localStorage就能伪造登录后端接口也没校验。后来我坚持后端所有敏感接口都做拦截器校验前端只做体验优化数据安全还是后端兜底。后端的接口设计遵循了REST风格比如景点查询是GET /api/scenic/list提交订单是POST /api/hotel/order订单列表是GET /api/order/hotel。状态变更用POST而不是PUT因为这里的状态修改带业务含义不只是资源更新。5.2 核心交互行程规划与预订联动前端最难的部分其实不在页面多好看而在“行程规划”这个交互。用户体验上我希望用户能像搭积木一样把景点按天放进去。我做的交互流程是左侧展示景点列表点击“加入行程”后弹窗选择加到第几天行程区域按天展示卡片每张卡片上有景点名字、日期和删除按钮还能调整顺序。这部分的实现难点是把“第几天”这个概念落实到数据结构里。我在前端维护了一个行程数组每个元素是{ day: 1, scenicList: [{scenicId, name, image}] }。加入景点时根据day字段分组提交给后端时组装成线路明细的格式。后端保存时把行程数组展开成一条条route_item记录。这个交互在答辩演示时非常抓眼球因为评委能直观看到用户怎么一步步把行程排出来。酒店预订页面上的关键交互是日期选择联动。用户选择入住日期和离店日期后系统要立即显示出“共计几晚”同时查询该时段内的可用房型。查询可用房型的接口我是在后端按日期范围批量查库存表把每天都大于0的房型返回给前端。这个查询要特别注意SQL写法用group by和having day_count 需要的天数不能简单用between因为某个中间日期无库存时不应该返回。Axios封装这里我给一个通用写法开发的时候很顺手const service axios.create({ baseURL: /api, timeout: 10000 }); service.interceptors.request.use(config { const token localStorage.getItem(token); if (token) { config.headers[Authorization] token; } return config; }); service.interceptors.response.use( response { const res response.data; if (res.code ! 200) { ElMessage.error(res.message); return Promise.reject(new Error(res.message)); } return res; }, error { ElMessage.error(error.response?.data?.message || 网络异常); return Promise.reject(error); } );前端表单校验我一般用Element UI自带的rules但要注意提交订单前还要二次校验日期逻辑因为用户可能先选完日期再去改日期这时候校验要通过事件实时触发。我见过好多次前端传过去的离店日期比入住日期还早导致后端返回“日期不合法”这种错误在演示时出现会很尴尬。6. 实操过程记录从环境搭建到完成部署6.1 环境准备与后端起步复现这个项目时环境版本一定要匹配我用的环境如下照着踩比较稳JDK 1.8不要用太新的版本部分老依赖可能不兼容Maven 3.6以上MySQL 5.7或8.0Node.js 14以上Vue CLI 4.x或5.x后端工程直接用Spring Initializr生成选择Web、MyBatis、MySQL驱动依赖然后手动加入MyBatis-Plus和JWT相关依赖。这里我提醒一下用Spring Initializr生成时Java版本选8打包方式选jar依赖不需要全勾后面按需添加就行。application.yml里有几个关键配置值得注意server: port: 9090 spring: datasource: url: jdbc:mysql://localhost:3306/yujianni_travel?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl map-underscore-to-camel-case: true global-config: db-config: logic-delete-field: deleted数据库连接串里加serverTimezoneAsia/Shanghai这个必须写否则在用JDBC连接MySQL 8时会报时区错误。数据库建表脚本我放在了项目doc目录下直接执行就能生成全部表和初始数据里面预置了管理员账号和几条演示用的景点酒店数据避免联调时库里空荡荡的。6.2 前端脚手架与联调部署前端直接使用Vue CLI创建项目命令很简单vue create yujianni-web。选上Router和Vuex样式用Element UI安装命令是npm i element-ui。如果是Vue 3版本应该安装element-plus注意组件引入方式略有不同。开发阶段最大的痛点是跨域。前端默认跑在8080后端跑在9090直接请求必然报CORS。我采用的方法是配置Vue CLI代理在vue.config.js里设置devServer.proxy把/api开头的请求转发到localhost:9090。这样开发时前端请求地址保持相对路径联调顺畅。部署阶段的做法是后端用Maven打成jar包在服务器上执行java -jar命令启动前端执行npm run build生成dist静态目录交给Nginx托管。Nginx里配置两个要点一是root指向dist目录二是把/api开头的请求反向代理到后端9090端口。这里有个容易出错的地方如果前端用了history模式的路由Nginx还要配置try_files机制避免用户直接访问某个子路径时出现404。我记得第一次部署到服务器时一直出现页面刷新404印象很深。原因是刷新时浏览器请求了当前完整路径而静态服务器上并没有这个路径对应的物理文件。后来在Nginx的location块里加了try_files $uri $uri/ /index.html问题才解决。这个如果没踩过坑光看文档很难意识到。7. 常见问题与排查技巧实录项目做多了问题来来回回就那么几类我把实际遇到过的高频问题整理成了表格按这个顺序排查能省一半时间。现象可能原因解决方案前端请求后端接口CORS报错前后端分离后端口不同开发用Vue代理生产用Nginx反向代理图片上传成功但页面显示不出后端未配置静态资源映射WebMvcConfigurer里addResourceHandlers映射上传目录数据库查询时日期差8小时MySQL连接未设置时区url里加serverTimezoneAsia/Shanghai请求接口返回404但接口存在前端请求前缀与后端context-path不一致统一确认/api路径前缀插入中文数据变成问号数据库或连接字符集不正确数据库创建时用utf8mb4连接串加characterEncodingutf8MyBatis-Plus分页不生效未配置分页插件依赖注册MybatisPlusInterceptor并添加PaginationInnerInterceptor多人同时订最后间房超卖缺少并发控制乐观锁version字段校验库存刷新页面后路由404history模式未配置Nginx配置try_files $uri $uri/ /index.html每个问题实际操作里都有一段故事。比如图片上传访问404我当时以为文件上传成功了就完事结果数据库存的是D:/upload/xxx.jpg这种绝对路径前端直接拼接就访问不了。后来我把上传目录映射成虚拟路径前端统一通过/files/xxx.jpg访问才解决。再比如日期的坑前端传参明明打印出来是对的但后端接收以后变成前一天。排查了半天才发现是JSON序列化框架在处理时间时按UTC时间输出比北京时间少8小时。这个问题的根治方法是在application.yml里配置统一的Jackson时间格式并指定时区。数据库分页插件这个问题也很经典。只引入MyBatis-Plus的依赖并不代表分页就能用必须手动把分页拦截器注册进容器。很多人忘了这一步结果page方法返回的记录数不对或者直接不分页。排查时只要看控制台有没有打印出LIMIT语句就能判断。8. 答辩讲解思路与个人优化建议说点实在的答辩老师对旅游类项目一般会关注三件事一是系统有哪些角色、分别能做什么二是数据库表之间如何关联三是某个核心业务比如订单或库存的实现细节。我建议你把演示顺序固定下来先讲项目背景和功能模块再演示用户端注册登录、规划行程、预订酒店最后切到管理端展示订单处理整个流程控制在十分钟左右。把酒店库存验证明白和订单状态流转捋顺基本就能应对大部分追问。我个人在完成这个项目后又往里加了两个小优化性价比很高。第一个是热门景点排行直接用heat字段按访问次数排序展示在首页。第二个是订单详情页显示完整时间线比如下单时间、支付时间、确认时间不用新增字段在订单表里记录几个时间戳就行。这两个改动对表结构几乎零成本但演示效果和答辩内容明显丰富。最后再分享一个经验像这种前后端分离项目别最后两天才联调。我见过很多组同学前端后端各自开发时都没问题一联调就全是路径和参数格式对不上。把接口文档先定好字段命名保持一致前端请求参数和后端实体字段用同一个名字能大幅减少联调时间。开发时先用模拟数据把界面跑起来后端接口就绪后只改api层的请求函数这样两边节奏互不拖累。这个项目本身代码量不大但逻辑完整认真做下来SpringBoot和Vue的常规开发套路基本能掌握得很扎实。