
1. 从技术选型说起为什么这套组合适合旅游出行类系统做旅游出行指南这类系统选型考虑的不只是能不能跑通更要紧的是后面维护舒不舒服。我见过不少毕设和中小型项目技术栈堆得花里胡哨结果业务代码写了一坨改个需求痛不欲生。这套SpringBoot2 Vue3 MyBatis-Plus MySQL8.0的组合恰好是当下Java Web方向里性价比最高的一套既有足够的主流性又不像微服务全家桶那样把复杂度拉满。1.1 SpringBoot2在中小型Web项目里的地位很多人在Spring和SpringBoot之间纠结。其实SpringBoot就是Spring的开箱即用版本它帮你把繁琐的XML配置、Bean装配、事务管理全部收敛成了自动配置和Starter依赖。做一个旅游指南系统你需要的核心能力无非是处理HTTP请求、操作数据库、做登录鉴权、返回JSON数据。SpringBoot2全部覆盖而且生态成熟到随便搜一个报错都能找到解决方案。这套系统选SpringBoot2而不是SpringBoot3我猜是出于稳定性和教程资源的考虑。SpringBoot2基于Spring5搭配JDK8/11非常流畅绝大多数高校和企业项目还在这个版本上跑。你如果选SpringBoot3那可是JDK17起步很多老依赖要重配光踩兼容性坑就能耗掉一两天。对旅游出行指南这种偏业务展示型的项目SpringBoot2完全够用还省心。1.2 Vue3相比Vue2到底强在哪Vue3发布这么多年已经不是新东西了但很多团队依然停留在Vue2的舒适区。做这个项目时我直接选了Vue3理由非常务实第一Composition API让代码组织更清晰。旅游指南系统里有大量列表页、详情页、搜索筛选逻辑如果用Vue2的Options API一个组件几十个data、methods、computed堆在一起看一圈下来脑子都炸了。用Composition API按业务逻辑拆分比如把景点搜索的响应式数据、请求方法、计算结果放进同一个组合式函数里维护起来舒服太多。第二Vue3响应式系统优化过性能更好。旅游指南系统通常要展示图片列表、路线图、天气信息数据量不小Vue3的Proxy响应式在深层对象监听上比Vue2的defineProperty更高效。第三Vite构建速度真的香。Vue3配Vite冷启动基本是秒级改一行代码热更新瞬间生效。写前端最烦的就是改完等编译Vite把这部分体验拉到了一个新的高度。1.3 MyBatis-Plus怎么提升开发效率MyBatis-Plus不是MyBatis的替换品而是它的增强工具。核心价值就一句话单表CRUD不用写SQL了。旅游指南系统的核心表比如景点表、用户表、收藏表、评论表绝大多数操作是标准单表增删改查。MyBatis-Plus提供了BaseMapper你定义一个接口继承它就自动拥有selectById、insert、updateById、deleteById、selectPage这些方法。相比原生MyBatis里每个方法都要配SQL映射效率翻倍还不止。更实用的是它的条件构造器QueryWrapper。比如我要按地区筛选景点、按评分排序、按关键词模糊搜索直接链式调用QueryWrapperScenicSpot wrapper new QueryWrapper(); wrapper.like(name, keyword) .eq(province, province) .orderByDesc(rating);完全不用手写动态SQLMyBatis-Plus会在底层帮你拼接好。这套项目如果坚持用原生MyBatis写光增删改查的XML文件就能多出几十个维护压力很大。1.4 MySQL8.0选择的现实理由MySQL8.0发布到现在已经是事实上的标准版本了。它的默认字符集是utf8mb4存emoji、生僻字都没问题——旅游指南里景点简介经常出现特殊地名、少数民俗文字用老版本MySQL的utf8很可能直接报错或者乱码。另外MySQL8.0的窗口函数、公用表表达式CTE让复杂查询变得简单。比如统计每个城市的景点数量排名用窗口函数一条SQL就搞定SELECT city, COUNT(*) AS spot_count, RANK() OVER (ORDER BY COUNT(*) DESC) AS rank_num FROM scenic_spot GROUP BY city;在老版本MySQL里这种需求得写子查询加临时表绕来绕去。MySQL8.0的这些特性让SQL本身的表达能力上了一个台阶。还有一个务实考虑Docker部署MySQL8.0非常成熟团队协作环境一致性好文档也多踩坑几乎都能查到解决方案。对做毕设或者中小型项目的人来说这点非常省心。2. 旅游出行指南系统的功能拆解与数据库设计技术选型定下来后第一件事绝对不是写代码而是把功能模块和数据库表结构理清楚。很多新手上来就建表建到一半发现漏字段又回头改来回折腾。我习惯先画一遍用户故事站在使用者的角度把整个流程走一遍再倒推表设计。2.1 核心业务模块与用户场景一个旅游出行指南系统面向的典型用户是准备出行的人。他们想做的事情有几类查目的地有哪些景点、看景点的详细介绍和评分、规划一条旅行路线、收藏心仪的景点、发表评论分享体验。管理员则需要维护景点信息、审核评论、统计访问数据。拆解下来系统至少需要这些功能模块用户模块注册、登录、个人信息维护。登录方案用JWTJSON Web Token比较合适前后端分离场景下天生友好。景点模块景点列表展示、按城市/省份筛选、关键词搜索、景点详情页图文介绍、开放时间、门票价格、评分。路线推荐模块按天生成出行路线比如三日游路线每天包含若干个景点支持管理员后台维护。收藏模块用户收藏景点个人中心查看收藏列表。评论模块用户对景点打分和评论管理员可删除违规评论。公告模块平台发布出行提示、节假日须知。这些模块听起来多但数据关系并不复杂核心就是景点表辐射出去通过外键关联用户、收藏、评论、路线。难点在详情页的信息整合和搜索筛选的组合条件上。2.2 表结构设计思路我按业务边界把表拆成六个核心表外加一个关联表。下面把每张表的用途和关键字段列出来供参考。用户表sys_user字段类型说明idbigint主键雪花算法生成usernamevarchar(50)登录名唯一索引passwordvarchar(100)BCrypt加密后的密码nicknamevarchar(50)昵称展示用avatarvarchar(255)头像URLphonevarchar(20)手机号statustinyint0禁用 1正常create_timedatetime注册时间密码绝不允许明文存储。Spring Security自带的BCryptPasswordEncoder是标准做法加密后再入库即使数据库泄露用户密码也不会直接暴露。景点表scenic_spot字段类型说明idbigint主键namevarchar(100)景点名称provincevarchar(50)省cityvarchar(50)市addressvarchar(255)详细地址descriptiontext景点介绍cover_imagevarchar(255)封面图URLimagestext图集逗号分隔存储open_timevarchar(100)开放时间描述ticket_pricedecimal(10,2)门票价格ratingdecimal(3,2)平均评分recommend_leveltinyint推荐等级 1-5create_timedatetime录入时间景点图片多张的情况我在单表里用逗号分隔URL字符串存储。这种做法在表结构上是反规范的但对于旅游类系统是务实的选择——图片列表很少需要单独更新某一张通常是一整组替换没必要为这个需求建一张子表把复杂度提上去。收藏表user_favorite字段类型说明idbigint主键user_idbigint用户IDspot_idbigint景点IDcreate_timedatetime收藏时间这里加一个user_id和spot_id的联合唯一索引防止用户重复收藏同一个景点。评论表comment_info字段类型说明idbigint主键user_idbigint评论人spot_idbigint被评论景点contentvarchar(500)评论内容scoretinyint评分 1-5statustinyint0待审核 1通过 2删除create_timedatetime评论时间评分字段放在评论表里景点的rating字段就是所有有效评论score的平均值。这里要写一个联动逻辑评论审核通过时更新对应景点的平均评分。路线表travel_route以及路线明细表route_item路线是一对多的关系。主表存路线名称、天数、适用季节描述明细表存每天去哪些景点、安排顺序。字段说明travel_route.id路线主键travel_route.title路线标题travel_route.days行程天数route_item.id明细主键route_item.route_id路线外键route_item.day_num第几天route_item.spot_id当天景点route_item.sort_order当天顺序公告表notice_info字段相对简单id、title、content、publish_time、status。数据库设计这里我再强调一个点所有时间字段统一用datetime不要用timestamp避免时区问题在前后端协作时踩坑。主键用MyBatis-Plus默认的雪花ID策略ASSIGN_ID不依赖数据库自增后面做分库分表或者数据迁移时都不需要动主键逻辑。3. 后端落地的关键细节SpringBoot2 MyBatis-Plus 的工程化实践表结构设计完后端代码写起来就有章法了。这套项目的后端分层通常是这样Controller接收请求Service处理业务逻辑Mapper层访问数据库各司其职。我讲几个真正影响开发体验的落地细节。3.1 项目初始化与依赖搭配用Spring Initializr或者IDEA自带工具创建SpringBoot项目版本选2.7.x系列。核心依赖就这几组dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3.2/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.33/version /dependency dependency groupIdcom.auth0/groupId artifactIdjava-jwt/artifactId version4.3.0/version /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId /dependency这里有个容易忽略的点MyBatis-Plus版本和SpringBoot版本要兼容。3.5.x版本对SpringBoot2.7支持良好别用太老的3.3.x否则分页插件可能不生效。在application.yml里配置数据源MySQL8.0的驱动类名和5.x不同要用com.mysql.cj.jdbc.DriverURL里还要带上时区参数spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/travel_guide?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalse username: root password: your_passwordserverTimezone不配置连MySQL8.0会直接报时区错误这是新手经常卡住的第一道坎。3.2 实体类与Mapper层的写法实体类字段和数据库表字段对应注意驼峰命名映射。MyBatis-Plus默认开启下划线转驼峰所以表里的create_time能自动映射到实体的createTime不需要额外配置。实体类上加TableName指定表名主键用TableId(type IdType.ASSIGN_ID)Data TableName(scenic_spot) public class ScenicSpot { TableId(type IdType.ASSIGN_ID) private Long id; private String name; private String province; private String city; private String address; private String description; private String coverImage; private String images; private String openTime; private BigDecimal ticketPrice; private BigDecimal rating; private Integer recommendLevel; private LocalDateTime createTime; }Mapper层干净到让人怀疑是不是少写了东西Mapper public interface ScenicSpotMapper extends BaseMapperScenicSpot { }这就够了。选selectPage做分页selectList做列表insert做新增。真遇到复杂多表关联查询在Mapper接口里加方法配合XML写联表SQL即可。MyBatis-Plus不会限制你写自定义SQL它是懒人工具不是枷锁。3.3 Service层如何避免写成贫血模型很多项目的Service层就是Controller和Mapper之间的传话筒这种情况让人很头大。真正的Service层应该有足够厚的业务判断逻辑比如景点查询接口除了查表还要处理三件事浏览量累加、评论评分汇总、缓存热点数据。这些逻辑放在Controller里就是灾难。我做旅游指南系统时Service层起码承载了这些逻辑景点搜索方法searchSpots的处理流程接收关键词、省份、城市、排序方式几个参数用QueryWrapper动态拼接查询条件调用Page分页查询遍历结果把图片URL字符串拆成列表前端直接渲染返回带总条数的统一结果集。这里写代码有个实用习惯每页数据返回时total一定要返回前端分页组件要依据它渲染总页码。MyBatis-Plus的Page对象自带getTotal()方法很多人忘了用还得自己写count查询多此一举。3.4 Controller层与统一返回结果前后端分离项目接口返回格式必须统一。我习惯定义一个ResultT类Data public class ResultT { private Integer code; private String message; private T data; public static T ResultT success(T data) { ResultT result new Result(); result.setCode(200); result.setMessage(success); result.setData(data); return result; } public static T ResultT error(String message) { ResultT result new Result(); result.setCode(500); result.setMessage(message); return result; } }Controller里的每个接口返回值统一包一层Result前端axios就能根据code字段做统一拦截处理。拿景点详情接口举例GetMapping(/spot/{id}) public ResultScenicSpotVO getSpotDetail(PathVariable Long id) { ScenicSpotVO vo scenicSpotService.getDetail(id); return Result.success(vo); }ScenicSpotVO是视图对象和实体类的区别在于它可能多出评论列表、收藏状态、图片数组等字段。Controller层永远不应该直接把实体类返回给前端因为实体类的字段结构是数据库视角前端需要的是页面视角。这个习惯越早养成越好。JWT登录鉴权用一个拦截器实现拦截除了登录注册和景点查询之外的所有请求从Header里的Authorization字段取出Token校验通过后把用户ID放到ThreadLocal里供后续使用。这个方案实现简单性能也好比Session方案更适合前后端分离的架构。4. Vue3前端实现的组件化思路与接口对接后端接口跑通了前端这块才能真正推进。Vue3项目用Vite创建npm create vuelatest选好TypeScript和Vue Router后就直接开写。我讲几个页面开发中的核心点。4.1 路由配置与页面骨架前端路由设计要跟着页面结构走。旅游指南系统的页面不算多但每个页面都有自己的职责/login登录注册页/首页展示推荐景点和热门路线/spots景点列表页带筛选和搜索/spot/:id景点详情页/routes路线推荐页/favorites收藏页需登录/profile个人中心需登录/admin后台管理页面管理员权限路由配置用懒加载方式每个页面对应的组件在访问时才加载减少首屏白屏时间const routes [ { path: /, name: Home, component: () import(/views/HomeView.vue) }, { path: /spots, name: Spots, component: () import(/views/SpotListView.vue) }, // ... ];导航守卫是必须写的。判断用户是否登录没有Token的跳转到登录页router.beforeEach((to, from, next) { const token localStorage.getItem(token); if (to.meta.requiresAuth !token) { next(/login); } else { next(); } });后台管理页额外加一层角色校验从Token里解析出用户角色不是管理员直接踢回首页。Vue3的vuex或pinia用于保存用户信息。旅游系统用户信息其实很简单——用户名、头像、收藏数量我推荐用Pinia比Vuex的API简洁不少Vue3官方的推荐状态库也是它。4.2 核心页面组件拆解景点列表页是整站交互最复杂的页面。它要满足按关键词搜索、按省份筛选、按预算排序、分页加载。数据结构化做不好后面改需求就很痛苦。我在组合式API里把筛选状态抽成一个响应式对象和请求逻辑放在同一个函数里import { ref, onMounted } from vue; import { searchSpots } from /api/spot; export function useSpotSearch() { const keyword ref(); const province ref(); const pageNum ref(1); const pageSize ref(10); const total ref(0); const spotList ref([]); const loadSpots async () { const params { keyword: keyword.value, province: province.value, pageNum: pageNum.value, pageSize: pageSize.value }; const res await searchSpots(params); spotList.value res.data.records; total.value res.data.total; }; const handleSearch () { pageNum.value 1; loadSpots(); }; onMounted(loadSpots); return { keyword, province, pageNum, pageSize, total, spotList, loadSpots, handleSearch }; }模板里用el-card网格布局来展示景点卡片封面图、名称、评分、门票价格一屏呈现。评分用Star组件5分制Vue3封装一个星星组件其实很简单用v-for渲染5个图标根据分值控制高亮状态。景点详情页要从后端拿详情数据展示景点图片、介绍、开放时间、门票、位置、用户评论列表。这里我把图片画廊、评分信息、评论列表拆成了三个独立组件各自维护自己的数据请求逻辑。这样代码复用性高详情页也清爽。4.3 Axios请求封装与Token处理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 { const res response.data; if (res.code ! 200) { ElMessage.error(res.message); return Promise.reject(new Error(res.message)); } return res; }, error { if (error.response error.response.status 401) { localStorage.removeItem(token); window.location.href /login; } ElMessage.error(error.message); return Promise.reject(error); } );统一处理Token挂载和HTTP异常状态码的逻辑放在拦截器里业务页面请求时就不用重复写。Vite开发环境下前端本地访问/api路径需要在vite.config.js里配代理转发到后端端口server: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } }这样前端开发时请求没有跨域问题等部署到生产环境时再用Nginx做统一反向代理。5. MySQL8.0的安装部署与数据库初始化数据库这块我推荐直接用Docker跑MySQL8.0比本地装省心得多尤其对需要换电脑继续开发的场景。但Docker不是万能的前期踩的坑也不少我把它最常见的坑一次讲清楚。5.1 Docker安装MySQL8.0的完整步骤首先拉取8.0版本的镜像docker pull mysql:8.0然后跑容器注意几个参数的含义docker run -d \ --name mysql8 \ -p 3306:3306 \ -e MYSQL_ROOT_PASSWORDroot123456 \ -e MYSQL_DATABASEtravel_guide \ -v mysql_data:/var/lib/mysql \ -v mysql_conf:/etc/mysql/conf.d \ mysql:8.0这里-v mysql_data是数据卷挂载容器删掉数据不丢。MYSQL_DATABASE会自动创建数据库省一步手动建库的操作。进入容器的交互式环境docker exec -it mysql8 mysql -uroot -proot123456建库建表、导入数据等工作都在这步完成。我习惯写一个init.sql脚本把表结构、初始数据比如几个城市的热门景点、一条默认三日游路线一次性导入这样团队协作时大家拿到的初始环境是一致的。5.2 字符集乱码问题的根治方案MySQL8.0默认字符集虽然已经是utf8mb4但如果你在建库时没显式指定或者连接参数少了characterEncoding照样会出现中文乱码。我用组合拳一次解决创建数据库时显式指定CREATE DATABASE travel_guide DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;表结构里varchar字段也指定字符集特别是在导入数据时如果SQL文件编码不是UTF-8很可能会数据错乱。所以SQL脚本文件务必用UTF-8无BOM格式保存我是用VS Code设置好默认编码后再写脚本的。最后在Docker容器配置里加一段[mysqld] character-set-serverutf8mb4 collation-serverutf8mb4_unicode_ci做到这三层乱码问题基本与你无缘。5.3 初始数据的准备策略系统跑起来总得有内容不能是个空壳。旅游指南系统的种子数据至少要覆盖5个以上省份的知名景点、每个景点3-5张图片、2-3条完整路线、1条测试账号记录。这些数据我建议手写SQL插入不要在前端页面上人为造数据那样数据质量不统一联调时干扰判断。测试账号的密码必须用和注册逻辑一样的BCrypt加密这样登录接口才能验证通过。一个容易忽略的细节如果你在SQL里直接写明文密码服务端比对时永远失败。正确做法是先写一段Java代码加密出哈希值再粘贴到SQL脚本里。6. 联调、部署与踩坑记录从能跑到跑得稳后端和前端都写完项目只是完成了70%剩下的是联调和部署。这里遇到的问题五花八门我挑几个有代表性的复盘一下。6.1 联调阶段的高频Bug复盘跨域问题前后端分离必须处理跨域。SpringBoot端可以配全局CORSConfiguration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(Registry registry) { registry.addMapping(/**) .allowedOriginPatterns(*) .allowedMethods(GET, POST, PUT, DELETE, OPTIONS) .allowCredentials(true) .maxAge(3600); } }开发环境正确配了Vite代理的话其实不会触发跨域但生产环境如果前端静态资源由Nginx托管、后端接口独立部署CORS配置就必须正确。我踩过最深的一次坑是配置了allowCredentials(true)又同时用了allowedOrigins(*)结果前端带Cookie的请求一律被浏览器拦截。正确做法是用allowedOriginPatterns来代替。日期字段格式化问题MySQL的datetime映射到Java的LocalDateTime再序列化成JSON返回前端默认格式是2024-01-15T10:30:00不太符合展示习惯。统一在application.yml里配置spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT8这样前端拿到的评论时间、公告发布时间都是2024-01-15 10:30:00这种常见格式不需要额外处理。MyBatis-Plus分页插件失效这是使用MyBatis-Plus最经典的问题。分页查询返回的List里能看到数据但total一直是0或者分页完全无效。原因几乎都是忘加了分页插件拦截器Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; }不加这段配置selectPage实际上不会生成LIMIT语句等于把全表查出来再内存分页数据量一大直接内存溢出。这个问题排查时可以打开MyBatis的SQL日志看到打印出来的SQL没有LIMIT基本就能确诊。Vue3响应式丢失问题前端联调时有一个很隐蔽的问题从接口返回的数据直接赋值给ref页面有时不更新。原因是接口返回的数据被包了一层Result你用res.data.data拿到真正的数组赋值给ref按理说是响应式的但如果这个赋值发生在异步回调外或者你把响应式对象拆分成了普通变量响应式就断了。Vue3里记住一个原则所有响应式数据用ref或reactive包装模板里访问时间接通过xxx.value或者解构后的toRefs来操作不要把响应式对象拆开单独用。6.2 我的部署建议与项目文档整理部署方案我推荐最简架构一台云服务器Nginx托管前端打包产物SpringBoot的Jar包用java -jar后台运行MySQL8.0用Docker跑。Nginx关键配置就两段静态资源服务、接口反向代理server { listen 80; server_name your-domain.com; location / { root /usr/share/nginx/html; index index.html; try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://localhost:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }注意前端项目中axios的baseURL配了/apiNginx这里把/api开头的请求全部转发到8080端口的SpringBoot服务解决跨域的同时让客户端请求路径保持简洁。项目文档这块我在交付时至少包含四份README.md项目简介技术栈启动步骤、数据库初始化脚本建库建表种子数据、接口文档每个接口的请求参数和返回示例、部署文档从零到可访问的完整步骤。接口文档如果不想手写可以用Swagger/knife4j自动生成SpringBoot2集成非常方便加一个依赖就有一套在线调试页面省下大量写文档的时间。7. 结合实际体验谈几点建议写完整个旅游出行指南系统我有几个真实的体会要分享。第一个是项目不要贪大。很多同学一上来就想把全部功能堆齐结果就是每个模块都是半成品。不如集中精力把景点列表和详情页做深做透比如加入地图选点、天气查询这类贴合场景的功能比凑十个鸡肋模块有用得多。第二个是遇到问题先看日志。整个联调过程中九成以上的Bug都能在后端控制台的堆栈日志里找到线索前端Console面板里报的错也通常直接指向问题代码。很多人习惯东猜西猜不如静下心看报错信息尤其是MyBatis打印的SQL语句对照真实执行情况能快速定位查询问题。第三个是保持技术栈的克制。这套SpringBoot2 Vue3 MyBatis-Plus MySQL8.0的组合足够支撑旅游出行指南这类系统的全部需求。不要因为看到别人用了Redis就跟着上看到别人用了Elasticsearch就也想集成——旅游指南系统的数据量远没到需要搜索引擎的程度MySQL的LIKE查询加合理索引就是最务实的方案。技术是为业务服务的不是用来秀的。最后讲一个小技巧发布前把核心流程走一遍自动化冒烟测试用Postman把景点查询、用户注册登录、收藏、评论的主链路全部跑一遍断言返回状态码。我接手项目时习惯先建一个Postman集合把主流程的接口存好每次改完代码就批量跑一次改动引入的回归问题几秒钟就暴露出来效率提升非常明显。技术栈再新落到具体项目上无非是一行行代码、一张张表、一个个接口的堆叠。真正拉开差距的是扎不扎实、踩没踩过坑、懂不懂为什么这样设计。希望这篇记录能帮你少走一些弯路把更多精力放在真正有意思的业务逻辑上。