
做这个项目之前我其实先想明白了一件事像“旅游网站管理系统”这类题目在市面上已经多到数不过来但大部分要么是“前台展示型”要么是“后台CRUD型”真正把用户浏览、下单支付、后台管理串成一个完整闭环的反而少。这套基于SpringBootVue的喀什旅游网站管理系统正好卡在一个比较合适的位置——既有前端页面的展示与交互又有后端的数据管理和权限控制数据层用MyBatis操作MySQL整体是一个典型的前后端分离架构。对于想学Java全栈、准备课程设计或者想看看真实业务怎么落地的朋友这套代码的可参考价值很高。我下面就把这套系统的设计思路、数据库结构、前后端实现细节以及部署踩坑记录完整梳理一遍希望能帮你少走弯路。1. 项目整体设计与技术选型思路1.1 为什么选了这套组合SpringBoot Vue MyBatis MySQL先说技术选型的逻辑。最初我也纠结过到底用传统SSM还是SpringBoot后来权衡下来还是直接上SpringBoot。原因很朴素SpringBoot把配置简化了一大截内嵌Tomcat打成一个jar包就能跑省去了一堆XML配置和外部容器部署的折腾。对于旅游网站这种业务中等复杂度、又要保证开发效率的场景SpringBoot就是最稳妥的底座。前端这块Vue的优势是组件化开发和响应式数据绑定。旅游网站页面上有景点列表、轮播图、线路详情、搜索筛选这类交互密集的需求用Vue写起来比JSP原生JS不知道舒服多少倍。而且前后端分离之后接口归接口、页面归页面调试的时候可以各干各的效率明显高。数据持久层我用MyBatis而不是JPA核心原因就一句话SQL可控。旅游业务的查询逻辑并不简单景点按地区、按类型、按价格区间筛选订单按用户、按状态统计这些场景下写SQL比让ORM自动生成更直观也更好优化。加上MyBatis的动态SQL能力复杂条件拼装时非常灵活。MySQL作为存储层虽然不像Oracle那样企业级功能丰富但胜在轻量、免费、生态成熟对这类管理系统来说完全够用。技术选它理由备选方案对比SpringBoot快速搭建、内嵌容器、生态成熟传统SSM配置繁琐部署成本高Vue组件化、响应式、适合交互密集页面JSJQuery开发效率低维护性差MyBatisSQL可控、动态SQL灵活JPA自动生成SQL复杂查询难优化MySQL免费、轻量、使用广泛Oracle成本高PG当时没有必要1.2 系统功能模块的划分前台展示与后台管理两条线这个系统的功能拆开来看其实是两条主业务线。第一条是面向游客的前台景点展示、旅游线路浏览、酒店查看、门票和套餐预订、订单查询、个人中心。第二条是面向运营管理员的后台景点管理、线路管理、酒店管理、订单管理、用户管理、评论管理、公告发布。两条线共用同一套数据通过角色权限做隔离。你可能会问为什么不在一个模块里直接全部用完事我当时的考虑是前台页面讲究用户体验操作路径要短信息展示要直观后台管理讲究效率需要列表、搜索、分页、批量操作这些能力。如果把它们耦合在一起页面会非常臃肿。所以项目把前台和后台做成了两个独立模块通过JWT令牌区分身份用户在登录后根据角色跳转到不同页面管理员账号可以进入后台管理界面这种结构也方便后续扩展。这套设计里还有一个点值得拿出来说订单流程。旅游网站的订单不是简单的“添加一条记录”而是要扣减库存、关联用户、记录状态变化。我在实现时把订单状态设计为待支付、已支付、已取消、已完成四种每张订单关联景点或线路和游客信息后台还可以看到每个订单的详细游玩时间。这套流程跑通之后整个系统的业务闭环才算成立。2. 数据库设计与持久层落地细节2.1 核心表结构和设计思路数据库是整个系统的地基我先把核心表的结构说清楚。第一张是user用户表字段包括用户ID、用户名、密码、手机号、邮箱、角色USER/ADMIN、头像、注册时间。密码这里一定要提一句绝对不能用明文存储我用的是BCrypt加密注册时密码加盐哈希后入库登录时再用同样的算法校验哪怕数据库泄露也不至于直接暴露密码。第二张是scenic_spot景点表包含景点名称、所在地区、景点类型、简介、详情描述、封面图、门票价格、开放时间、浏览量。景点类型我先单独建了一张scenic_type表避免把类型直接写死成字符串因为运营后续可能会调整分类做成关联表更灵活。第三张是travel_line线路表字段有线路名称、出发地、途径景点、行程天数、成人价格、儿童价格、线路封面、线路介绍。线路和景点是多对多关系我拆了一张中间表line_spot_rel来维护关联。订单相关的表有两张ticket_order门票订单和line_order线路订单。其实也可以统一成一张大订单表加一个类型字段但考虑到门票和线路的字段差异比较大门票关注日期和人数线路关注出发日期和随行儿童数拆开来写反而更清楚。订单表里的核心字段包括订单编号、用户ID、景点或线路ID、数量、单价、总金额、状态、下单时间、游玩日期。我的习惯是订单号用时间戳加随机数生成避免用自增ID当订单号暴露业务量。2.2 MyBatis映射与SQL书写中的几个关键细节持久层的实现我用MyBatis的XML方式管理SQL这里有几个实际开发中特别重要的细节。第一个是resultMap的使用。数据库字段如果都遵循下划线命名如scenic_name而实体类用驼峰scenicName我第一反应是在application.yml里开启map-underscore-to-camel-case: true这样大部分字段都能自动映射。但遇到联表查询或有聚合字段时就得显式声明resultMap了比如查询景点列表连带统计评论数就要在resultMap里用association或collection搞定对象嵌套关系。第二个是动态SQL的写法。前台景点列表的筛选条件是过滤高亮按类型、按地区、按价格区间、按搜索关键词这些条件用户可以任意组合。用where标签配合if判断是MyBatis最经典的方案既不能SQL注入又能自动处理多余的和AND问题。比如判断搜索关键词时我用的是title LIKE CONCAT(%, #{keyword}, %)在Mapper接口里传一个封装好的查询对象比传一堆散落的参数清爽得多。第三个是分页查询。分页我直接引入了MyBatis PageHelper插件配置好拦截器之后只需要在查询前执行一行PageHelper.startPage(pageNum, pageSize)后面紧跟的查询SQL就会被自动加上LIMIT同时返回的PageInfo里还带了总条数信息后台列表页直接拿它渲染分页组件。虽然PageHelper有一些“只能紧跟第一条查询生效”的规则限制但我实际体验下来只要养成“先startPage、紧接着就执行查询”的编码习惯基本没遇到过拦截错SQL的问题。2.3 查询优化中容易忽视的三个索引建议表设计完了不等于查询就快索引还得跟上。第一景点表的scenic_type_id和region字段非常容易被用作筛选条件我建了组合索引(scenic_type_id, region)这样按类型加地区筛选时可以快速定位。第二订单表里的user_id是高频查询字段用户查看“我的订单”时每天都会被调用必须建普通索引。第三评论表的scenic_id字段在外键关联查询时同样要建索引否则前台详情页统计评论列表会出现慢查询。有一个坑在这里分享给各位尽量不要在查询条件里对索引字段做函数操作。比如按创建时间筛选时如果写成DATE(create_time) #{date}MySQL就无法走索引了。正确的做法是传入一个时间区间create_time #{start} AND create_time #{end}这样既能查当天数据索引也完全能用得上。3. SpringBoot后端实现的关键环节3.1 分层架构与统一返回结构后端代码组织我严格遵循了Controller、Service、Mapper三层结构每一层只做自己职责内的事。Controller负责接收请求、参数校验、调用Service、返回结果Service负责业务逻辑比如下单时要校验库存、计算金额、生成订单号Mapper只负责数据库操作不掺业务判断。这样的好处是代码好读出了问题定位也快比如订单金额算错了直接在Service层排查即可不用翻Controller。还有一个每个接口都涉及的点统一返回结构。我封装了一个Result类结构是{code, message, data}code为200表示成功其他表示各类错误。所有Controller方法都返回这个Result前端Axios拦截器里统一判断code如果发现未登录就跳转登录页处理起来非常干净。这里提醒一下不要图省事直接在Controller里返回Map或裸对象一旦需求变更你会后悔的。3.2 JWT认证与权限拦截器系统的登录认证用的是JWTJSON Web Token。用户登录成功后后端把用户ID和角色信息放到JWT的claim里再用一个密钥签发加密的token返回给前端。前端把token存在localStorage每次请求时在请求头加上Authorization: Bearer token。后端写了一个拦截器在进入Controller之前校验token的签名和过期时间校验通过就把用户信息放到ThreadLocal里Controller里通过getCurrentUser()直接取即可。这里有两个实现细节值得注意。第一拦截器里要排除登录、注册、获取景点列表等公开接口否则用户不登录就访问不了页面就很糟糕了。第二后台管理接口单独做了角色校验我用的是自定义注解加HandlerInterceptor的方式在需要管理员权限的Controller方法上打上RequireAdmin注解拦截器里判断当前用户角色不是ADMIN就直接返回403比在方法里手写判断优雅很多。3.3 图片上传与静态资源映射旅游网站全是图片资源景点封面、线路图片、轮播图统统要上传所以图片处理这块我没走“存数据库BLOB”的老路而是在服务器上建了一个upload目录上传的文件直接写到磁盘把访问URL存进数据库。Controller里用的是MultipartFile接收文件随机生成文件名避免重名只允许jpg、png等少数格式大小限制在5MB内防止有人传一个巨大的文件把磁盘撑爆。但这里有个小坑必须说SpringBoot默认只处理/static、/public等目录下的静态资源我上传到自定义目录后用http://localhost:8080/xxx.jpg访问时会404。解决办法是写一个WebMvcConfigurer实现类重写addResourceHandlers方法把本地磁盘的upload路径映射到/files/**这个URL。生产环境里这个upload目录一般还要配合Nginx做静态资源代理由Nginx直接返回图片文件后端不参与图片IO能明显降低Tomcat的压力。3.4 跨域与接口文档配置开发阶段前后端分离必然面对跨域问题。前台项目跑在8080或8081端口后端在8080端口浏览器默认会拦截跨域请求。我的做法是后端统一配置CORS——在SpringMVC中实现WebMvcConfigurer添加addCorsMappings设置允许的源、请求头、请求方法以及是否携带凭证。这里要说一个常见错误如果项目用了Spring Security或JWT拦截器CORS配置要在安全配置或拦截器之前生效否则请求就会被拦截器拦下来前端根本收不到正常的跨域响应头。接口文档方面我集成了SpringDocOpenAPI 3通过http://localhost:8080/doc.html可以查看所有接口的请求参数和返回结构前端开发过程中对着文档联调就不用反复去翻代码了。谁写后端谁知道接口文档这种“额外工作”虽然麻烦但能极大节省前后端联调的时间特别推荐大家都配上。4. Vue前端页面与交互的实现要点4.1 工程化搭建与目录结构前端用的是Vue 2.6配合Vue CLI构建但我建议新开的项目可以直接上Vue 3加Vite构建速度更快Composition API写起来也舒服。这套系统的目录结构没有太复杂src/api存放所有接口请求的封装src/router存放路由配置src/store存放Vuex状态src/views按页面维度拆文件夹每个页面里再把子组件放components目录。路由设计上前台和后台做了懒加载加载component: () import(/views/home/index.vue)。游客访问根路径直接进景点首页点登录页跳转登录登录成功后根据角色动态路由的处理管理员跳转到后台首页普通用户回到前台。路由守卫里判断是否携带token没有token就拦截到登录页这个逻辑写在前置守卫beforeEach里属于基础中的基础但很多新手总会漏掉。4.2 页面拆解首页、列表页、详情页、个人中心首页是整个系统的门面我做成了一块导航栏加三个主要区域顶部轮播图、热门景点推荐、精选线路展示。轮播图我用Element UI的el-carousel组件数据从后端scenic_spot表里面按浏览量排序取前六张图片加载失败时会给一个默认占位图。景点列表页核心特征是搜索栏加卡片网格搜索栏里放景点类型下拉框、地区下拉框和关键词输入框点击搜索后重新请求接口并带上搜索参数数据回来用v-for循环渲染卡片。详情页是这块工作量的重点包含景点图片、门票价格、简介长文开放时间以及底部的评论模块。评论模块用一个子组件实现用户登录后可以提交评论内容提交成功后刷新评论列表未登录用户看到的是“登录后参与评论”的提示框。个人中心这块相对简单显示用户基本资料下面列出订单列表每个订单给一个“查看详情”的按钮后台管理员登录后在管理后台也能看到所有用户的订单区别是在“按用户”维度看。4.3 Axios封装与状态管理的取舍网络请求我非常建议统一封装一个Axios实例。我在src/api/request.js里先创建了一个axios.create({ baseURL: /api, timeout: 10000 })实例然后在请求拦截器里读取localStorage中的token并拼到请求头响应拦截器里处理两个核心情况code为401时清除存储状态并跳转登录页code非200时用Element UI的Message.error弹出后端返回的错误信息。所有页面组件不直接操作Axios而是调用src/api下对应的方法比如scenic.js里导出getScenicList(params)方法组件里引入后调用代码层面就非常干净了。状态管理我只用了Vuex放一个全局变量当前登录用户信息。原因很简单购物车、复杂缓存这些场景在这个系统里碰不到非要强上状态管理反而增加代码复杂度。如果你后期要为系统加一个“收藏功能”那再单独抽一个favorites模块也不迟项目小的时候别搞过度设计。4.4 前后端联调时的代理配置联调阶段最常见的坑是“我接口明明通了前端请求就是报404”。原因多半是前端的Axios请求地址直接写成了localhost:8080但页面跑在localhost:8081跨域请求没有走到后端配置的CORS。我的建议是开发环境下不要直接写死全路径而是在vue.config.js里配置devServer.proxy把/api前缀代理到http://localhost:8080同时去掉后端地址的前缀。这样前端请求/api/scenic/list时代理会自动转发到后端接口既避免了跨域又方便后期切换生产环境的后端地址。生产环境则是Nginx统一反代前后端共用一个域名请求路径里用/api就能区分动态请求和静态资源。5. 部署上线、踩坑清单与优化建议5.1 打包与部署全流程部署这块我给一个可以照抄的流程。先将后端项目根目录执行mvn clean package打包得到target目录下的travel-0.0.1-SNAPSHOT.jar然后上传到服务器用nohup java -jar travel.jar --spring.profiles.activeprod 命令后台启动。生产环境的数据库配置我放在application-prod.yml里通过profiles切换比如数据库地址换成云数据库的连接串、日志级别调成INFO。前端打包执行npm run build生成dist目录把里面的文件上传到Nginx的html目录配置静态路径和反向代理到后端的/api接口。Nginx配置里有一个关键点前端路由如果用的是Vue Router的history模式需要在location里加try_files $uri $uri/ /index.html;否则用户刷新某个子页面时会报404。这个坑我印象特别深第一次部署时刷新页面就白屏折腾了半天才发现是Nginx配置的问题。SPA部署必须要处理这一步不然用户永远不敢刷新浏览器。5.2 常见坑点速查表我把开发过程中遇到的比较典型的问题整理成一个速查表方便大家遇到类似情况时直接对照问题现象根本原因解决办法MySQL连接报错Public Key Retrieval新版驱动默认要求允许公钥检索JDBC URL加allowPublicKeyRetrievaltrueJS前端访问后端图片404自定义上传目录未映射静态资源实现addResourceHandlers做路径映射MyBatis查询结果字段全是null未开启驼峰映射或resultMap缺失yml开启map-underscore-to-camel-casePageHelper分页总数不对startPage和查询之间插入了其他数据操作startPage后立即执行第一条查询SQLVue刷新页面404路由history模式未配置try_filesNginx加try_files $uri $uri/ /index.html;上传图片过大报500未设置最大上传大小yml中配置max-file-size和max-request-sizeJWT接口被拦截无法访问拦截器未放行公开接口在excludePathPatterns里加入白名单5.3 性能与安全优化建议如果这套系统要真正上线运营有几个优化点值得提前做。性能层面景点列表这类读多写少的接口可以引入Redis做缓存热点数据直接从Redis返回MySQL压力能下降一大截。MyBatis一级缓存作用域默认是SqlSession实际作用不大二级缓存如果每个Mapper配了个缓存又会遇到分布式环境下的数据一致性难题。所以我更推荐只对“不大变动”的景点类型、公告信息加Redis缓存订单这类写频繁的数据不要缓存否则容易读出脏数据。安全层面有两件必做的事。第一SQL注入虽然是MyBatis的#{}预编译就能防御的但要注意不能在XML里拼接字符串${}只有做动态排序字段时才考虑而且字段名必须做白名单校验。第二后台管理页面不能让未登录游客请求到要确保拦截器对所有以/admin开头的路径做了校验同时在前端路由守卫里也加一道保护双重保险才是合格的防护。我个人在整个项目落地过程中最深的一个体会是系统本身的工作量并不在某个技术难点上而在于所有细节能否无缝衔接——表结构设计决定后台代码的复杂程度接口约定影响前后端联调的效率路由和静态资源的配置会被部署时的环境反复考验。旅游网站虽然业务不复杂但它的链路完整到足以承载一个新手从0到1建立全栈认知。跟着这套代码把用户浏览、下单、后台管理全流程跑通一遍再去接其他管理系统项目你会发现很多套路都是相通的。最后再分享一个小技巧写Maven pom时把所有依赖版本用父POM统一管理子模块不写版本号这套习惯在任何一个SpringBoot项目里都能让你省掉许多版本冲突的折腾。