做Java Web这么多年最深的体会就是技术栈本身不值钱把技术栈组合起来解决一个具体业务问题才值钱。这套喀什旅游网站系统就是典型例子——Java SpringBoot Vue3 MyBatis MySQL 四个关键词单独拆开每个都是面试八股里的常客合在一起做成一个前后端分离的web系统就覆盖了景点展示、线路查询、酒店信息、留言互动、后台管理等一套真实业务场景。你要是在学后端、准备课程设计或者想练手一个能写在简历上的完整项目这篇拆解思路和实操记录应该能帮你少走不少弯路。先说清楚这套系统能干什么游客端可以浏览喀什的景点、旅游线路、酒店信息查看详情、发表留言、注册登录、提交线路咨询或预订管理端可以维护景点、线路、酒店等所有数据处理留言和订单。技术分工非常明确SpringBoot负责提供REST接口Vue3负责页面渲染和用户交互MyBatis负责数据库操作MySQL存数据。下文我会把项目从需求拆解、数据库设计、后端实现、前端联调到上线部署的完整链路都过一遍重点讲为什么要这么做以及真正动手时容易踩哪些坑。1. 项目定位与技术选型这套组合的逻辑1.1 一个旅游网站系统到底要解决什么问题很多新手拿到旅游网站这个需求第一反应就是开始建表写接口结果做着做着发现功能越堆越多、代码越来越乱。我拿到这个项目时先做的一件事是把这个系统的核心问题圈定下来它本质上是一个内容展示信息检索轻量交互的站点。内容展示指的是景点、线路、酒店这些信息要有分类、有详情、有图片信息检索指的是用户能不能按地区、按名称、按价格区间找到想要的内容轻量交互指的是注册、登录、留言、下单这类操作。这三层需求决定了系统的复杂度不需要上微服务、不需要分布式缓存用一个单体后端加上一个前端SPA应用就能很好覆盖。明确了业务边界之后技术选型就顺理成章了。业务简单不代表可以乱选型恰恰因为业务简单选型更应该考虑生态成熟度和团队上手成本。SpringBoot Vue3 MyBatis MySQL这套组合几乎是国内Java后端项目最常见的主流搭配遇到问题网上资料最多招人也好找这本身就是一种隐形的技术红利。1.2 前后端分离和传统模板渲染的取舍早期做Java网站很多人用的是SpringBoot Thymeleaf或者JSP服务端直接渲染页面。那种模式不是不能用而是开发和联调体验比较痛苦前端改个样式要碰后端代码后端改个接口参数又影响页面渲染两边的工作完全耦合在一起。这套系统采用前后端分离本质上是把页面渲染和数据处理彻底分开。前端用Vue3开发通过axios发起HTTP请求拿JSON数据后端只负责处理请求、执行逻辑、返回数据完全不关心数据最终在页面上长什么样。这样带来的直接好处有两个第一前后端可以并行开发。后端把接口文档定好前端Mock数据就能开始写页面不用等后端代码跑起来。我实操的时候前端页面和后端接口几乎是同时动工的整体开发周期至少缩短了三分之一。第二部署和演进更灵活。前端打包出来是一堆静态文件扔到Nginx里就能跑后端是独立的Java服务。后面就算要换一套前端框架重做界面后端接口一行都不用改。当然前后端分离也引入了新的问题比如跨域、联调效率、接口约定不一致等。这些坑我会在第5章专门讲排查经验这里先记住一个原则接口文档要前置联调效率才能有保障。1.3 SpringBoot、Vue3、MyBatis、MySQL各自承担什么一句话总结这套技术栈的分工MySQL负责把数据落盘MyBatis负责在Java和MySQL之间架桥SpringBoot负责把桥上的业务逻辑串起来并以接口形式暴露Vue3负责把这些接口返回的数据变成用户能看懂、能操作的页面。SpringBoot的优势在于约定优于配置。以前写SSH或者SSM各种XML配置能把人绕晕SpringBoot把绝大多数配置都自动化了内嵌Tomcat一个java -jar就能启动对中小项目来说非常友好。MyBatis在这套系统里承担的是SQL控制权和开发效率之间的平衡。相比MyBatis-Plus那种自动生成SQL的框架原生MyBatis需要手写SQL这看起来麻烦但换来的是对SQL的完全掌控。旅游网站涉及很多多表关联查询手写SQL反而更容易调优和排查问题而且面试的时候MyBatis部分能讲的点也更多。Vue3相比Vue2最大的变化是组合式API逻辑复用比Options API舒服得多。配合Element Plus组件库做后台管理页面表格、表单、弹窗这些现成的组件能省下大量时间。MySQL则不用多说社区版免费、稳定、资料多这个量级的项目用MySQL绰绰有余。2. 数据库设计数据是一切功能的地基2.1 核心表结构与业务建模数据库设计是整个项目里最不应该省时间的环节。我见过太多项目上来就建表建着建着发现字段不够、关系理不清最后只能一边写代码一边改表结构项目就这么烂尾了。这套系统的核心业务可以拆成四个部分内容信息、用户体系、互动记录、订单交易。内容信息相关的表包括scenic_spot景点表id、name、area_id、summary、content、cover_image、images、ticket_price、open_time、click_count、status、create_time。travel_route线路表id、title、days、summary、price、cover_image、status、create_time。route_scenic线路景点关联表id、route_id、scenic_id、day_order。hotel酒店表id、name、level、price、address、cover_image、status。food美食表id、name、category、address、cover_image、recommend。用户体系和互动记录相关的表包括user用户表id、username、password、nickname、avatar、phone、create_time。comment留言/评论表id、user_id、target_type、target_id、content、create_time。订单交易相关的表orders订单表id、order_no、user_id、route_id、contact_name、contact_phone、travel_date、people_count、total_price、status、create_time。这套设计里有两个点需要解释一下。第一个是为什么订单表和线路表关联而不和具体的景点关联。因为旅游网站的预订行为通常以线路为单位——比如喀什老城帕米尔高原三日游一次预订涉及多个景点如果订单去关联景点表结构会非常复杂。以线路为单位订单逻辑简单清晰也更贴近真实业务。第二个是为什么用逻辑关联而不是数据库外键。早期我写项目喜欢加外键约束后来在真实项目里吃过亏外键约束在数据量大、并发高的时候会影响写入性能而且业务逻辑改了之后外键结构会变成沉重的包袱。现在的做法是在SQL查询里用JOIN来维护关系数据完整性由Service层代码保证这也是主流互联网项目的做法。2.2 表关系映射与MyBatis结果集设计表结构定好之后要思考MyBatis怎么把这些关系映射到Java对象里。这个过程最容易出错也最能体现MyBatis的设计功力。比如景点和地区是多对一关系一个地区有多个景点。在Java里景点实体类会有一个Area area属性在MyBatis的XML里就要用association来映射resultMap idScenicVOMap typecom.kashgar.vo.ScenicVO id propertyid columnscenic_id / result propertyname columnscenic_name / result propertysummary columnsummary / result propertycoverImage columncover_image / result propertyticketPrice columnticket_price / association propertyarea javaTypecom.kashgar.entity.Area id propertyid columnarea_id / result propertyname columnarea_name / /association /resultMap查询语句就通过JOIN把地区名称带出来SELECT s.id AS scenic_id, s.name AS scenic_name, s.summary, s.cover_image, s.ticket_price, a.id AS area_id, a.name AS area_name FROM scenic_spot s LEFT JOIN area a ON s.area_id a.id WHERE s.status 1这里有个容易被忽略的细节如果查询结果里的列名和Java属性名对不上一定要起别名。比如两条记录都有id字段MyBatis分不清哪个是景点的id哪个是地区的id必须通过AS scenic_id和AS area_id让列名唯一再在resultMap里用column属性精确绑定。线路和景点是多对多关系一条线路包含多个景点一个景点可能出现在多条线路上。这种关系在Java里体现为ListScenicSpot scenicList在MyBatis里用collection映射collection propertyscenicList ofTypecom.kashgar.entity.ScenicSpot id propertyid columnscenic_id / result propertyname columnscenic_name / /collection查询线路详情时先查线路主信息再用LEFT JOIN把关联景点查出来MyBatis会自动根据主键做一对多的结果集合并。2.3 喀什旅游数据的初始化思路数据库结构搭好只是第一步系统跑起来还需要真实的数据。很多人的项目做完demo感很强就是因为里面存的全是测试数据1测试数据2看起来就不专业。这套系统的业务场景是喀什旅游数据初始化就有很多可以发挥的地方。比如景点表可以准备这些数据喀什古城、艾提尕尔清真寺、香妃园、帕米尔高原、慕士塔格峰、喀拉库勒湖、白沙湖、盘龙古道等。每个景点配上简介、门票价格、开放时间图片可以先放网络URL占位后续再替换成MinIO存储的上传图片。线路表可以围绕景点组合设计比如喀什老城文化一日游帕米尔高原两日游这种真实线路。酒店表可以准备喀什当地的特色酒店美食表可以录入烤包子、缸子肉、手抓饭这些当地特色。初始化数据的SQL建议单独整理成一个data.sql文件和建表语句分开放。每次重置数据库时直接执行建表脚本再导入数据脚本开发环境就能恢复到一致的初始状态。这一步做得规范后面测试接口和调前端的时候会省很多事情。3. 后端代码实现SpringBoot与MyBatis的实战落地3.1 工程搭建与核心配置后端工程可以直接用Spring Initializr生成也可以自己搭Maven骨架。依赖上需要引入这几个核心组件spring-boot-starter-web、mybatis-spring-boot-starter、mysql-connector-j、lombok如果做分页查询再加一个pagehelper-spring-boot-starter。如果是自己手动建Maven工程注意SpringBoot 2.x和3.x对Java版本要求不同3.x必须用Java 17及以上很多朋友一上来版本没对齐编译就各种报错。application.yml里的配置有几个关键点要仔细处理spring: datasource: url: jdbc:mysql://localhost:3306/kashgar_travel?useSSLfalseserverTimezoneAsia/ShanghaicharacterEncodingutf8mb4 username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.kashgar.entity configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl server: port: 8080useSSLfalse是为了避免连接MySQL时的SSL证书警告本地开发完全不需要SSL加密serverTimezoneAsia/Shanghai是因为MySQL 8.0以上的驱动默认时区是UTC不改的话日期时间会差8个小时characterEncodingutf8mb4是为了支持存储emoji和生僻字。map-underscore-to-camel-case这个配置一定要开。数据库里的cover_image映射到Java的coverImage全靠这个开关自动转换否则每写一个实体类都要手动加一堆TableField注解。log-impl配置成StdOutImpl后控制台会打印每一条SQL语句后端调试时看SQL日志是最直接的排查手段。3.2 列表查询、分页与DTO设计后端代码的分层标准是Controller、Service、Mapper三层。Controller只做参数接收和结果返回不写业务逻辑Service层处理业务规则比如参数校验、状态流转、事务控制Mapper层负责SQL执行。这个分层看起来简单但很多项目做着做着就变形了Controller里堆满了SQL拼接和业务代码维护起来非常痛苦。以景点列表接口为例前端传入page、pageSize、areaId、keyword几个参数后端接口设计大概是这样的GetMapping(/scenic/list) public ResultPageResultScenicVO list( RequestParam(defaultValue 1) Integer page, RequestParam(defaultValue 10) Integer pageSize, RequestParam(required false) Integer areaId, RequestParam(required false) String keyword) { return Result.success(scenicService.pageList(page, pageSize, areaId, keyword)); }页面返回的数据结构建议统一封装成Result对象包含code、message、data三个字段前端统一处理成功和失败分支整个项目看起来规范得多。分页这里我用的是PageHelper插件使用方式极其简单查询前调用PageHelper.startPage(page, pageSize)紧接着执行的Mapper查询方法就是分页查询返回结果再用PageInfo包装一下就能拿到总记录数、当前页数据等完整分页信息。很多朋友第一次用PageHelper会踩一个坑startPage只对接下来的一条查询生效如果在它之后又执行了别的查询分页就会落在错误的SQL上。所以一定要养成习惯startPage和Mapper查询之间不要插入任何其他数据库操作。3.3 动态SQL与MyBatis高级玩法旅游网站的列表页有个典型场景用户搜索线路时可能按名称模糊查可能按天数过滤也可能按价格区间过滤关键是有时候只有一个条件有时候几个条件都传。如果为每种组合写一个SQL那代码会膨胀得没法看。MyBatis的动态SQL就是为解决这种问题设计的select idselectRouteList resultTypecom.kashgar.entity.TravelRoute SELECT * FROM travel_route where if testkeyword ! null and keyword ! AND title LIKE CONCAT(%, #{keyword}, %) /if if testdays ! null AND days #{days} /if if testminPrice ! null AND price gt; #{minPrice} /if if testmaxPrice ! null AND price lt; #{maxPrice} /if /where ORDER BY create_time DESC /selectwhere标签会自动处理开头多余的AND这是最常用的动态SQL写法。要特别注意的是XML里面的和符号会被解析成XML标签所以大于号小于号必须写成gt;、lt;或者用CDATA包起来。我第一次写的时候没注意直接在XML里写price 5000MyBatis直接报错排查了半天才发现是XML转义的问题。MyBatis的高级特性在这套系统里也用了两个。第一个是缓存。MyBatis默认开启一级缓存也就是同一个SqlSession内相同查询条件会直接走缓存二级缓存需要显式开启适合放热门景点详情这种读取多、更新少的数据。但要注意增删改操作会清空缓存如果业务对数据一致性要求很高还是建议少用二级缓存。第二个是TypeHandler。景点详情里的images字段在数据库里存的是一段JSON字符串比如[url1,url2]在Java里想直接映射成ListString。这就可以自定义一个TypeHandler在写入时把List转成JSON字符串读取时把JSON字符串转回List。这个技巧在真实项目中非常实用能省掉大量手动转换代码。3.4 登录鉴权与事务处理用户登录功能每个系统都有但实现方式差别很大。早期很多项目用Session保存登录态前后端分离之后Session跨域不好使主流方案变成了JWT。JWT的原理一句话就能说清用户登录成功后服务端生成一个包含用户信息的加密Token返回给前端前端后续请求都带上这个Token服务端验证Token合法就认为是已登录用户。这套系统里我用的方案是JWT SpringBoot拦截器。拦截器统一校验所有以/api/user/开头的接口从请求头里取Token校验通过就把用户信息放到ThreadLocal里方便Controller直接获取当前登录用户。Token里只放userId和用户名不要放密码之类敏感信息。密码存储用的是BCrypt加密不是MD5。MD5虽然快但没有加盐很容易被彩虹表破解BCrypt每次加密结果都不同安全性高得多。事务控制是另一个要重点关注的点。用户提交订单涉及多个操作创建订单记录、更新线路的报名人数、可能还要给用户增加一条订单历史。任何一个环节失败都必须把整个操作回滚否则就会出现下完单没扣名额、或者扣了名额没生成订单的脏数据。SpringBoot里只需要在Service方法上加一个Transactional注解即可Transactional(rollbackFor Exception.class) public Order createOrder(OrderCreateDTO dto) { // 1. 校验线路是否存在、是否有余位 // 2. 创建订单记录 // 3. 更新线路报名人数 // 4. 返回订单信息 }rollbackFor Exception.class这个参数要写SpringBoot默认只回滚RuntimeException如果业务代码抛的是检查异常不加这个参数事务就不会回滚这属于一个很隐蔽的坑。4. Vue3前端从页面搭建到联调踩坑4.1 基于Vite创建Vue3工程前端部分我用Vite来搭建Vue3工程创建命令很直接npm create vuelatest这个命令会引导你选择需要的功能模块建议勾上Vue Router和Pinia。Vite相比Webpack最大的优势就是启动速度快开发时改代码热更新几乎是即时的。工程创建完成后再安装几个必用依赖npm install element-plus axios piniaElement Plus是Vue3生态里最成熟的组件库表格、表单、分页、弹窗、消息提示这些后台管理页面高频使用的组件都有现成的不用自己手写样式。axios负责发HTTP请求Pinia负责全局状态管理主要存登录用户的Token和用户信息。目录结构我习惯这么组织views目录放页面组件components目录放公共组件api目录放接口请求方法router目录放路由配置store目录放Pinia状态utils目录放axios封装。这种按功能划分的方式在中小型项目里足够清晰。4.2 页面模块与组件拆分方式前台页面需要覆盖这些模块首页、景点列表页、景点详情页、线路列表页、线路详情页、酒店列表页、登录注册页、个人中心页。后台管理端需要覆盖景点管理、线路管理、酒店管理、留言管理、订单管理、用户管理。页面多组件拆分就显得特别重要。以景点列表为例一个景点卡片被首页和景点列表页共用所以抽成一个ScenicCard.vue组件接收一个景点对象作为props内部渲染图片、名称、简介、价格。首页和列表页只需要传入不同的数据就能复用整套卡片UI这就是组件化的核心价值。后台管理页面是Element Plus的主场。景点管理页面就是典型的表格页顶部是搜索栏中间是表格底部是分页器。新增和编辑功能用el-dialog弹窗承载表单删除操作先用el-popconfirm弹窗二次确认再调接口。这套交互模式在后台管理里几乎通用第一个页面写熟练之后后续的管理页面基本都是复制粘贴改字段名。4.3 接口封装、状态管理与本地转发axios封装是前端工程质量的关键。我习惯在utils/request.js里创建axios实例设置基础配置和拦截器import axios from axios; const request axios.create({ baseURL: /api, timeout: 10000 }); request.interceptors.request.use(config { const token localStorage.getItem(token); if (token) { config.headers.Authorization Bearer ${token}; } return config; }); request.interceptors.response.use( response response.data, error { if (error.response?.status 401) { window.location.href /login; } return Promise.reject(error); } ); export default request;请求拦截器统一在Header里加Token响应拦截器统一处理错误状态码所有接口请求都基于这个封装好的实例去写。接口文件单独放在api目录下比如api/scenic.js里导出getScenicList、getScenicDetail等方法页面里只负责调用不直接接触axios这样后端的接口路径统一管理换接口时改一处就行。开发环境的联调配置是前后端分离项目最关键的环节。前端开发服务器默认跑在5173端口后端接口跑在8080端口浏览器直接跨域。解决方法是在Vite配置文件里设置本地转发server: { port: 5173, proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } }这样前端请求/api/scenic/list时Vite开发服务器会自动把请求转发到后端的http://localhost:8080/api/scenic/list从浏览器的角度看请求是同源的就不会出现跨域报错。5. 上线路上的常见问题与排查经验5.1 前后端联调典型难点联调阶段是bug高发期说几个我实际碰到最多的问题。第一个是返回的数据结构和前端预期不一致。后端返回的数据里某个字段是null前端直接访问这个字段的属性就报错了比如后端返回的景点对象里area是null前端模板里写scenic.area.name就直接抛TypeError。解决思路是后端统一在VO里把可能的空对象初始化成空字符串或空对象前端也要用可选链?.兜底。第二个问题是路由模式导致404。Vue Router用的是HTML5的History模式路径好看但不带#号。这种模式在开发环境没问题部署到Nginx后一刷新页面就404因为Nginx不知道该把/scenic/detail/1这个路径交给前端路由处理。解决办法是在Nginx配置里加上try_fileslocation / { try_files $uri $uri/ /index.html; }网上大量文章只教你Vue代码怎么写却很少提这个部署坑等真上线刷新一下才发现问题就晚了。第三个问题是接口字段命名风格不统一。数据库字段是下划线风格Java属性是驼峰前端如果直接用Java Entity返回的数据下划线和驼峰混在一起很难看。所以后端接口的数据返回建议都用VO视图对象把要返回的字段整理成前端友好的结构这也是后端规范化的体现。5.2 MyBatis高频报错与排查MyBatis的报错信息看起来吓人但常见的其实就那么几类。Invalid bound statement (not found)是出现频率最高的问题。这个错误的核心原因是Mapper接口和XML映射文件没有正确对应排查顺序是这样的先看XML文件的namespace是否和Mapper接口的全限定名一致再看方法名是否和XML里的select id一致最后看配置文件里mapper-locations是否指向了正确的路径。还有一个隐藏点如果XML文件放在src/main/java目录下打包的时候会被Maven过滤掉必须把XML放resources/mapper目录或者在pom.xml里配置resources时把XML类型也包含进去。动态SQL里参数为空导致查询结果不对也经常发生。比如if testareaId ! null前端传了areaId0数据库里area_id是从1开始自增的所以查不到数据前端没传areaId参数是一个空字符串! null判断通过但等于空串SQL就变成了AND area_id 数据库不报错但结果为空。合理的写法是areaId ! null and areaId ! 前端传参时尤其要注意未选择的值不要传空字符串。分页查询数据不准确的问题也很典型。用了PageHelper之后发现总记录数不对或者每页数据重复。大概率是没有在同一个Mapper方法里既查了列表又查了count或者同时传了多个startPage导致线程复用。PageHelper底层用的ThreadLocal存储分页参数如果有多线程环境分页参数会串台。排查时尽量把分页查询单独隔离在一个Service方法里。5.3 MySQL连接、部署与运维细节MySQL连接相关的坑主要集中在连接串配置和驱动版本上。启动时报Public Key Retrieval is not allowed需要在连接串上加allowPublicKeyRetrievaltrue这是因为MySQL 8.0默认使用caching_sha2_password认证插件客户端第一次连接时需要拿服务器的公钥做密码加密传输。加了这个参数连接就会正常建立。报The server time zone value Öйú±ê׼ʱ¼ä is unrecognized是时区没配置的原因这个问题在3.1节已经说过连接串里加上serverTimezoneAsia/Shanghai就能解决。上线部署时后端直接打jar包运行前端把dist目录里的静态文件部署到Nginx。数据方面有几个点要提前检查数据库字符集要确保是utf8mb4而不是utf8否则存emoji和生僻字会报错MySQL的sql_mode不要开启STRICT_TRANS_TABLES再往日期列插默认值否则插入0000-00-00这种日期会直接失败生产环境数据库账号不要直接用root创建独立账号并只授权业务库的权限安全性会好很多。6. 一点个人体会和后续扩展整套系统做下来我最大的体会是这个量级的项目技术深度不是关键把业务梳理清楚、把工程规范立住、把常见坑提前避开才是项目能顺利交付的核心。比如数据库设计和接口约定前期花的时间多一点后面写代码速度会快很多前后端联调文档不清晰改来改去浪费的时间才是最让人崩溃的。如果你做完这套系统还想继续扩展我建议按这个顺序往深走第一步加图片上传和MinIO对象存储服务让景点图片从写死的URL变成系统自定义上传第二步引入日志框架统一记录请求日志和异常日志第三步给搜索功能加上分词比如用HanLP做中文分词让关键词能匹配到更丰富的内容第四步做权限控制细分普通用户、运营、管理员三种角色对应不同的操作权限。做项目这件事代码只是最后一个环节想清楚为什么这么设计、怎么做才不容易出问题才是真正值钱的部分。希望这篇拆解能给你一些有价值的参考。
阅读完成 · 觉得有帮助?