
做一套喀什旅游网站系统我最终选择了SpringBootVue3MyBatisMySQL这套前后端分离方案。项目本身是个典型的区域性旅游平台用户端要看景点、选线路、下单、写评价管理端要维护景点和线路、处理订单、管理轮播内容。这套技术栈放在今天依然是中小型Web系统里最稳的组合之一——SpringBoot负责把REST接口快速撑起来MyBatis在处理多表关联和动态查询时足够透明Vue3负责前端交互体验MySQL则承载全部业务数据。我整理源码时发现网上很多“旅游网站”demo要么只给了一堆Controller要么前端页面写得很敷衍真正能完整跑通前后端联调、覆盖核心业务闭环的并不多。这篇文章会把数据建模、后端接口、前端页面、部署联调几个关键环节逐一拆开讲适合正在做毕设、想系统学全栈项目或者要给小团队快速搭业务系统的开发者参考。1. 这个系统要解决什么业务拆解与技术选型逻辑1.1 从一个真实的业务需求说起我接到这个需求时对方给的描述其实很朴素要一个喀什旅游网站游客能看景点介绍、浏览旅游线路、在线预订管理员能在后台维护数据。但“朴素”需求背后藏着不少细节。旅游网站的业务闭环不是简单的增删改查至少要串起这样一条线用户注册登录区分普通用户和管理员前台展示景点、线路、酒店等信息支持按关键词搜索和分页浏览用户查看详情后可以下单预订生成订单用户可以对已完成的线路或景点发表评论管理端维护景点、线路、轮播图查看和处理订单状态。这个闭环看起来是一个常规电商系统的缩小版但实际上和电商有个明显差异旅游业务的“商品”是组合型的。一条线路往往包含多个景点景点又可能关联酒店、餐饮、交通等信息。如果表结构设计时没想清楚这种聚合关系后面接口会越写越别扭。所以我在动手写代码之前先把系统的角色和功能边界画清楚了。整体分为两个端前端用户端和管理后台。用户端包括首页、景点列表、线路详情、订单提交、个人中心管理端包括数据看板入口、内容管理、订单管理。很多初学者容易一上来就写页面结果写到一半发现功能之间互相打架这就是典型的业务拆解没做到位。1.2 技术栈为什么是这套组合选技术栈不能只看“流行”要看它和业务场景的匹配度。我把这套组合的理由整理成了表格方便对照技术选择理由不选替代方案的原因SpringBoot自动装配、内置Tomcat、生态成熟能快速搭建REST服务传统SSM需要大量XML配置开发效率低Vue3Composition API组织业务代码清晰配合Vite启动快Vue2维护期已过Options API在复杂页面容易混乱MyBatisSQL可控性强适合多表关联和动态SQL场景JPA/Hibernate在复杂查询时不如XML直观排错成本高MySQL事务支持好部署简单和小型业务系统是黄金搭档引入重型数据库如Oracle、PostgreSQL对这个项目没必要这里我要特别说一下MyBatis和MyBatis-Plus的选择。这个项目我刻意用的原生MyBatis没有上MyBatis-Plus。原因有两个一是MyBatis-Plus虽然把单表CRUD简化到了极致但很多初学者用完之后对SQL的执行过程完全没概念一旦遇到复杂的多表查询反而不知道怎么调XML二是原生MyBatis让你必须自己理解SqlSessionFactory、Mapper代理、#{}预处理这些底层机制这对后续面试和排错都有实实在在的好处。前后端分离也是必须的。开发时前端用Vite的dev server后端用SpringBoot的内置Tomcat双方通过HTTP接口通信各自独立启动、独立调试。生产环境前端构建成静态文件交给Nginx后端打jar包独立运行。这种架构的好处是团队可以并行开发前端不用等后端接口写完再动工只要约定好接口文档就能同步推进。1.3 前端用户端与后台管理端的模块划分项目的前端我拆成了两个大的路由区域而不是两个完全分离的工程。这样做的好处是共用一套登录逻辑、一套Axios封装、一套组件库同时又能通过路由守卫把管理端和用户端隔离开。用户端功能模块首页轮播图、景点推荐、热门线路景点模块列表页、详情页、搜索筛选线路模块列表页、详情页、线路包含的景点展示订单模块提交订单、订单列表、取消订单个人中心登录注册、个人信息、我的收藏。管理端功能模块景点管理新增、编辑、上下架、图片维护线路管理线路基本信息、关联景点、价格和天数维护订单管理订单列表、状态变更、按用户查询轮播图管理首页轮播的图片和跳转链接维护评论管理查看评论、审核和删除。后端的分包则按业务域划分而不是按技术层堆在一起。我习惯用controller、service、mapper、entity、dto、common这样的包结构先按模块再按层分。比如景点相关的接口都在scenic包下内部再分Controller、Service、Mapper。这种做法在小项目里看起来多了一层目录但等业务扩大到十几个模块时找代码会非常快。2. MySQL数据层从景点、线路到订单的数据模型2.1 核心表的职责拆分数据库设计是整个系统最值得花时间的地方。很多人写旅游网站喜欢把所有信息塞进一张大表结果后面每个功能都要面对大量冗余字段。我的原则是一张表只负责一个业务实体数据尽量少重复。这套系统我设计了6张核心表表名职责关键字段t_user用户与管理员id、username、password、nickname、phone、rolet_scenic景点信息id、name、cover、images、summary、content、price、statust_line旅游线路id、name、days、price、cover、content、statust_order用户订单id、order_no、user_id、line_id、total_price、statust_comment用户评论id、user_id、target_type、target_id、content、ratingt_favorite用户收藏id、user_id、target_type、target_id先说图片字段。景点通常有多张图片最简单的方案是单独建一张景点图片表。但考虑到这个项目的数据量级我用了一个images字段存JSON数组比如[url1,url2]。查询时一次性取出前端直接渲染。这样做不是偷懒而是对中小型网站的一种合理取舍——少一张关联表就少一次JOIN查询代码也少一层维护成本。如果哪天图片量真的上来了再拆表迁移也不难。订单表里有个细节值得注意total_price是冗余存储的。也就是说下单时把线路当前价格快照到订单表里。这样做的原因很简单——线路价格以后可能会调整但用户下单时确认的价格必须永远不变。如果订单表不存价格快照而是每次去线路表取当前价格那历史订单的金额就会跟着线路改价一起变这在业务上是绝对不允许的。2.2 建表SQL与索引设计要点核心的建表语句我贴出来这段SQL基本能覆盖这类系统的常用结构CREATE TABLE t_user ( id bigint NOT NULL AUTO_INCREMENT, username varchar(50) NOT NULL COMMENT 登录用户名, password varchar(100) NOT NULL COMMENT 密码BCrypt加密存储, nickname varchar(50) DEFAULT NULL, phone varchar(20) DEFAULT NULL, avatar varchar(255) DEFAULT NULL, role varchar(20) NOT NULL DEFAULT USER COMMENT USER/ADMIN, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用户表; CREATE TABLE t_order ( id bigint NOT NULL AUTO_INCREMENT, order_no varchar(32) NOT NULL COMMENT 订单编号, user_id bigint NOT NULL COMMENT 下单用户, line_id bigint NOT NULL COMMENT 关联线路, total_price decimal(10,2) NOT NULL COMMENT 下单时价格快照, status tinyint NOT NULL DEFAULT 1 COMMENT 1待支付 2已支付 3已完成 4已取消, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no), KEY idx_user_id (user_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT订单表;索引设计上我做了几点考虑。order_no必须唯一索引因为订单号在业务逻辑里经常被当作查询条件。t_order.user_id建普通索引因为“我的订单”列表是高频查询。景点表的status字段我反而没建索引因为状态值只有上架和下架两种区分度太低索引扫描可能比全表扫描还慢。很多教程会告诉你“查询多就加索引”但真实场景里区分度低的字段加了索引收益很小这个判断能力比背规则重要得多。另外所有表我都用了utf8mb4字符集。原因很简单景点介绍和用户评论里可能出现特殊字符和表情符号utf8mb4是MySQL里能完整支持四字节字符的编码用utf8会遇到部分字符存不进去的尴尬情况。2.3 演示数据的准备思路系统要跑起来光有表结构是不够的必须准备一套完整的演示数据。我踩过这个坑一开始只往景点表插了两条记录结果前端列表页、搜索、分页全部只能靠两条数据验证很多问题测不出来。正确的做法是准备一个init.sql按照依赖顺序插入数据先插用户再插景点再插线路最后插订单和评论。数据量至少每个核心表10条以上景点和线路的图片字段要留多个URL这样前端轮播图、列表卡片、详情页才能看到完整效果。我还建议把管理员账号一起初始化进去用户名admin、密码用BCrypt加密串存进去。如果密码是明文写的登录接口那一层就得为了演示数据做特殊处理这个容易埋雷。3. SpringBoot后端与MyBatis落地分层、接口与XML实践3.1 分层架构和统一响应体后端我用的是标准的Controller-Service-Mapper三层结构。有人觉得小项目不需要Service层直接在Controller里调Mapper就行。我不建议这么干因为Controller一旦直接操作Mapper接口复用的能力就没了。比如用户下单需要同时校验线路状态、生成订单号、扣减库存这些步骤如果散落在Controller里换个入口就要重写一遍。放进Service层Controller只需要一行调用。所有接口返回的数据格式我统一封装成一个Result对象。这是项目开始前就先定好的约定不是写到一半才加的public class ResultT { private Integer code; private String message; private T data; public static T ResultT success(T data) { ResultT result new Result(); result.code 200; result.message success; result.data data; return result; } public static T ResultT error(Integer code, String message) { ResultT result new Result(); result.code code; result.message message; return result; } }统一响应体的意义在前端联调时体现得最明显。前端Axios拦截器只需要判断code是不是200不成功就统一弹提示不需要每个页面单独处理错误逻辑。如果后端每个接口返回结构都不一样前端就得为每个接口写一种解析方式这是很典型的设计混乱。全局异常处理也在这个阶段一起做了。我用RestControllerAdvice统一捕获业务异常和未知异常避免把堆栈信息直接抛给前端。判断业务失败的异常类叫BusinessException在Service层通过throw new BusinessException(库存不足)这种方式表达比返回null或者返回布尔值更清晰。3.2 登录鉴权与拦截器前后端分离项目里Session方案有几个天然的痛点跨域时Cookie处理麻烦、服务端需要维护会话状态、移动端接入不方便。所以这个项目用的是JWT方案。登录流程是这样的用户提交用户名密码后端校验通过后生成一个JWT返回给前端前端把它存在localStorage里每次请求在Header中带Authorization: Bearer token。后端用一个拦截器统一解析token解析成功就把用户信息放进ThreadLocal业务代码里直接通过工具类获取当前登录用户。JWT本身不需要服务端存储天然无状态扩展性好。但它有个明显的短板token在过期之前无法主动失效。我的处理办法是给token设置一个合理的过期时间比如7天并且在用户修改密码时强制重新登录。这个方案对这个业务量级完全够用。拦截器注册时有个细节就是放行白名单。登录接口、注册接口、景点列表和详情这类公开接口不应该被拦截否则用户没登录就没法浏览网站这不符合旅游网站的业务逻辑。我的放行清单和拦截规则是这样的registry.addInterceptor(jwtInterceptor) .addPathPatterns(/api/**) .excludePathPatterns(/api/auth/login, /api/auth/register) .excludePathPatterns(/api/scenic/**, /api/line/**);管理端接口我会单独加一个权限校验判断当前用户角色是不是ADMIN。最偷懒也最实用的做法是再写一个AdminInterceptor只拦截/api/admin/**路径里面校验角色没有管理员权限直接返回403。3.3 用XML处理动态SQL和多表查询MyBatis最核心的使用场景就是XML里的动态SQL。旅游业务里“线路列表按价格区间、天数、关键词筛选”这种需求很典型筛选条件可能是空的也可能是组合的。如果每个条件都写一个方法代码会爆炸。用XML的where和if组合一条SQL就能覆盖所有情况select idsearchLines resultTypecom.example.entity.Line SELECT * FROM t_line where if testkeyword ! null and keyword ! AND name LIKE CONCAT(%, #{keyword}, %) /if if testminPrice ! null AND price gt; #{minPrice} /if if testmaxPrice ! null AND price lt; #{maxPrice} /if if testdays ! null AND days #{days} /if AND status 1 /where ORDER BY create_time DESC /select这里有个新手经常踩的坑在where里写条件时if后面的SQL片段前面要不要加AND。MyBatis的where标签会自动去掉第一个多余的AND或OR所以第一个条件写AND name LIKE...完全没问题。但如果你用的是trim标签那就得手动配置prefixOverrides两种写法的逻辑不太一样。还有就是防SQL注入的问题。#{}在MyBatis里会被解析成预编译占位符?参数值由JDBC的PreparedStatement处理不会拼接进SQL字符串这是安全的。而${}是直接字符串替换如果参数来自用户输入就会形成SQL注入风险。我在项目里明确了一条规矩所有用户输入的地方必须用#{}${}只允许出现在少数固定的排序列名场景中而且列名必须由后端白名单校验。多表查询我也用XML处理。比如线路详情要把关联的景点查出来我写了一个resultMap通过collection标签映射一对多关系一次SQL查出线路和它的全部景点。XML的可读性比不可见的ORM自动SQL要好很多这也是我坚持用MyBatis的原因。3.4 列表分页接口的设计分页是列表页避不开的需求。网上很多人一上来就引入PageHelper但我更建议先理解手写分页的原理因为它本质上就是一个LIMIT参数计算的问题。我在这个项目里引入PageHelper的理由纯粹是为了节省代码量但它的使用有两条铁律必须遵守第一分页插件必须在紧挨着执行的Mapper方法前调用中间不能有任何其他SQL操作否则会把无关查询也套上分页第二分页只对紧接着这一条查询生效用完就失效所以每个分页查询都要在方法内部独立设置。接口的设计上前端通过pageNum和pageSize两个参数传分页条件后端返回结构里除了列表数据还要包含total总数。这样前端才能正确渲染分页组件的总页数。类似这样{ code: 200, data: { list: [], total: 128, pageNum: 1, pageSize: 10 } }搜索条件我统一放在QueryParam对象里包含pageNum、pageSize、keyword以及其他业务字段。这样Controller的入参清爽Service层做参数校验和默认值处理也更集中。比如pageNum默认1pageSize默认10且上限设为100防止有人恶意传一个10000把数据库打满。4. Vue3前端实现组件化的页面与接口交互4.1 初始化工程与目录划分前端我直接用Vite创建Vue3工程相比Vue CLIVite的冷启动速度和热更新体验明显更好。基础依赖装了五个vue-router做路由pinia做状态管理axios做HTTP请求element-plus做UI组件库sass处理样式。目录结构是项目健壮性的第一步我分成这样src/ ├── api/ # 每个模块的接口请求函数 ├── assets/ # 静态资源 ├── components/ # 通用组件 ├── router/ # 路由配置 ├── store/ # Pinia状态 ├── utils/ # 请求封装、工具函数 ├── views/ # 页面组件 │ ├── user/ # 用户端页面 │ └── admin/ # 管理端页面api目录单独划分是我特别想强调的一点。新手经常在页面组件里直接写axios.get(/api/scenic)短期看没问题但接口一旦变动你需要在所有用到它的文件里逐个查找替换。把接口请求全部集中在api目录每个页面只import对应的函数前后端联调时改一处就行。状态管理我选的是Pinia而不是Vuex。Pinia的API设计更符合直觉没有Vuex那套mutations和actions的区分改状态直接一个store.xxx value就完事而且对TypeScript的支持非常友好代码量也比Vuex少很多。这个项目里我主要拿它存登录用户信息和一些全局配置。4.2 路由与登录权限控制路由表我按用户端和管理端分组组织。用户端的页面基本都是公开可访问的比如首页、景点列表、线路详情但“提交订单”“个人中心”这类操作必须要求登录。管理端的所有页面都要求登录且必须是管理员。权限控制的实现思路是在路由配置里给每个需要保护的路由加一个meta字段{ path: /order/submit, component: () import(/views/user/OrderSubmit.vue), meta: { requiresAuth: true } }, { path: /admin, component: () import(/views/admin/Layout.vue), meta: { requiresAuth: true, requiresAdmin: true } }然后在Vue Router的全局前置守卫里统一判断router.beforeEach((to, from, next) { const userStore useUserStore(); if (to.meta.requiresAuth !userStore.token) { next({ path: /login, query: { redirect: to.fullPath } }); return; } if (to.meta.requiresAdmin userStore.role ! ADMIN) { next({ path: / }); return; } next(); });这里有个体验细节未登录用户访问需要登录的页面时要带上redirect参数登录成功后跳回原页面。这个设计虽然简单但会让用户觉得系统“记得他刚才想去哪”比登录后一律回首页体验要好得多。另外一个细节是路由懒加载。这个项目页面不多但我还是给所有页面组件都用了() import()的形式确保前端首屏加载只下载必要的文件而不是把全部页面代码一次性打包这对用户体验有明显帮助特别是移动端访问时。4.3 Axios封装Token注入和错误统一处理Axios封装是Vue3项目里最实用的公共层。我在utils/request.js里创建一个实例统一做三件事请求拦截器注入token、响应拦截器处理业务码、统一错误提示。核心代码大概是这样的const service axios.create({ baseURL: /api, timeout: 15000 }); service.interceptors.request.use(config { const token localStorage.getItem(token); if (token) { config.headers.Authorization Bearer ${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.data; }, error { if (error.response error.response.status 401) { localStorage.removeItem(token); router.push({ path: /login, query: { redirect: router.currentRoute.value.fullPath } }); } else { ElMessage.error(网络请求失败请稍后再试); } return Promise.reject(error); } );响应拦截器里直接返回res.data而不是完整的响应体好处是业务代码里不需要每次都写response.data.data.xxx拿到的就是后端返回的业务数据。401的全局处理尤其重要token过期时用户正在浏览页面如果没有这段逻辑后续每个请求都会报错而真正应该做的是把用户送回登录页重新登录。我还封装了一个技巧后端返回code为401时同时在前端清除掉本地存储的登录态避免登录页显示后用户token还在产生“明明跳登录页了但状态里还有登录信息”的奇怪状态。这种细节在联调时不显眼但真实用户会遇到。4.4 首页和列表页的组合式API写法Vue3里我全部使用script setup的写法配合Composition API。相比Vue2的Options API业务逻辑可以从分散在data、methods、computed里的状态中解放出来把相关的数据和方法放在一起组织。拿景点列表页举例整体的结构是这样的const query ref({ pageNum: 1, pageSize: 10, keyword: }); const list ref([]); const total ref(0); const loading ref(false); const loadList async () { loading.value true; try { const data await getScenicList(query.value); list.value data.list; total.value data.total; } finally { loading.value false; } }; const handleSearch () { query.value.pageNum 1; loadList(); };ref和reactive的选择上我的经验是基础类型和单值用ref对象直接用ref包一层其实也够用reactive更适合深层嵌套的对象但它自动解包的特性在解构赋值时会丢失响应性新手容易踩坑。这个项目里我统一用ref代码风格更一致也少出错。首页的模块化我拆成了几个组件BannerCarousel负责轮播图ScenicRecommend负责景点推荐LineRecommend负责精品线路。这些组件内部各自调用对应的接口函数互不依赖。拆分的好处是每个组件只管一件事出问题的时候定位起来非常快。首页轮播图的数据来源是管理端维护的轮播图表。后端返回的图片URL可能是相对路径也可能是完整URL我在渲染前统一处理一下避免混合路径导致图片404。5. 联调部署与踩坑记录让系统真正跑起来5.1 开发环境跨域Vite代理与后端CORS前后端分离开发时前端跑在http://localhost:5173后端跑在http://localhost:8080端口不同就必然产生跨域问题。我在项目里采用的是前端代理方案Vite的配置文件里加一段server.proxyserver: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } }这样前端代码里请求/api/xxx时Vite的dev server会自动把请求转发到后端的8080端口浏览器看到的还是同源请求不需要后端额外配置CORS。为什么开发环境不用后端CORS方案因为后端配CrossOrigin虽然也能解决但开发时每加一个域名或端口都得改后端代码而且生产环境Nginx代理之后CORS配置经常和后端冲突产生奇怪的“跨域请求成功但带不了Cookie”之类的问题。用前端代理开发环境干净生产环境也不需要后端操心跨域两全其美。5.2 MySQL连接时的时区和SSL问题这个坑几乎每个用MySQL 8的人都会遇到。启动后端报The server time zone value Öйú±ê׼ʱ¼ä is unrecognized或者SSL connection error。前者是MySQL 8的时区配置导致的后者是驱动默认开启SSL但服务端没配证书。我的application.yml里最终的连接配置是这样spring: datasource: url: jdbc:mysql://localhost:3306/kashi_travel?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalseallowPublicKeyRetrievaltrue username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.DriverserverTimezoneAsia/Shanghai明确指定北京时间useSSLfalse关闭SSL检测allowPublicKeyRetrievaltrue是因为MySQL 8的驱动在用到caching_sha2_password认证时需要这个参数不加的话会有概率遇到Public Key Retrieval异常。另外驱动类名必须写com.mysql.cj.jdbc.Driver这是MySQL 8的新驱动路径。如果你用mysql-connector-java 5.x的旧驱动类名是com.mysql.jdbc.Driver这两种在SpringBoot 2.7和3.x下混用容易出现兼容问题。项目里我统一用的MySQL 8.0.x驱动和5.7也能正常连。5.3 前后端分离部署方案生产环境我采用的是前端Nginx托管、后端独立Java进程的方案。后端的打包和启动很简单mvn clean package -DskipTests java -jar target/travel-server.jar --spring.profiles.activeprod前端构建出dist目录后把它放到Nginx的静态目录下。Nginx配置的关键是处理两个问题一是静态资源缓存二是把/api开头的请求反向代理到后端。核心配置大致如下server { listen 80; server_name your-domain.com; root /var/www/travel-frontend/dist; index 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; } location / { try_files $uri $uri/ /index.html; } }try_files $uri $uri/ /index.html;这行非常关键。Vue Router如果用的是history模式用户在浏览器直接访问/line/detail/3时Nginx找不到对应的物理文件如果不加这行就会返回404加上之后会统一回到index.html交给前端路由去解析。我也被问过“能不能把前端dist直接放进SpringBoot的static目录单端口部署”。这个方案技术上可行但我不推荐用在前后端分离项目里因为它会把前端打包和后端发布耦合在一起前端改一行也要重新打后端jar包。Nginx方案看起来多了个组件但一旦拆开两边可以独立升级这才是前后端分离的完整收益。5.4 复盘这套系统最值得记住的三个经验最后分享几条我觉得这套项目做完之后最值得沉淀的经验。第一数据库字段命名和类型一定要统一。我这个项目初期有张表用create_time另一张用createtime联调阶段前端字段映射就出了不少错。后来我定了个规矩所有时间字段统一xxx_time所有主键统一id所有业务名称字段统一name。这个规范越早定后期改动越少。第二前后端接口的参数命名要“从一而终”。前端传pageSize后端就不能叫size列表返回的字段叫total前端就用total。联调时很多时间都耗在这种不一致上。最有效的办法是开工前先写一份接口文档哪怕写在Excel里都行把每个接口的请求参数和返回字段说清楚再开始写代码。第三不要把表的逻辑关系硬编码在SQL的魔法值里。比如订单状态1、2、3、4如果后端代码里到处写着if (status 2)过两周你自己都会忘。在Java里定义一个常量类或在枚举里把状态语义写清楚代码的可读性和维护性会好很多。我自己的体会是旅游网站这种业务系统难点从来不是某个技术点有多深而是把一条完整的业务链路从前到后理清楚。从数据库表到后端接口再到前端页面每一步都在为下一步铺路。如果你也想从头完整做一个全栈项目建议先别急着写代码把业务闭环、表结构、接口约定这三件事想明白再动手后面会顺畅得多。