ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

SpringBoot+Vue旅游管理系统开发实战:从数据库设计到前后端联调

SpringBoot+Vue旅游管理系统开发实战:从数据库设计到前后端联调 做旅游类管理系统我前后折腾过不下三次从最早的 JSP Servlet 古董组合到后来改用 SpringBoot Vue 这套前后端分离的方案算是把整个流程摸了一遍。今天就拿这个“基于 SpringBoot Vue 的旅游管理系统”当样板把 Java MySQL MyBatis 这套完整实现思路、关键代码、还有那些文档里永远不会写的坑一次性讲清楚。这个项目面向的场景很典型景区景点信息展示、旅游线路规划、酒店/门票预订、用户下单、订单管理、后台数据维护。它几乎覆盖了一个中小型信息化系统的全部标准动作——增删改查、登录鉴权、文件上传、分页搜索、统计报表。不管你是毕业设计、课程设计还是刚入职想练手接个小项目这套技术栈和设计思路都能直接复用到其他业务系统上。1. 项目概述旅游管理系统到底在管理什么先别急着写代码我记得第一次做这种项目时上来就建表结果后面改得欲哭无泪。旅游管理系统的核心是“资源”和“交易”两条线资源是景点、线路、酒店、餐饮这些供给方交易是用户浏览、下单、支付、评价这条消费链路。把这两条线想清楚数据库设计和接口设计就顺了。1.1 核心需求拆解一个常规的旅游管理系统按角色划分大概有这么几块游客/普通用户端注册登录、浏览景点和线路、查看详情、下单预订、查看个人订单、发表评论、收藏景点。管理员端景点信息管理增删改查、上下架、线路管理、酒店管理、订单审核与处理、用户管理、数据统计比如热门景点排行。公共能力图片上传、分页搜索、参数校验、统一异常处理、登录状态校验。说白了这就是一个典型的管理信息系统业务不复杂但五脏俱全。做这个项目最大的价值不是业务本身而是通过它把 SpringBoot、MyBatis、Vue 这几样东西真正串起来。1.2 技术选型为什么是 SpringBoot Vue MySQL MyBatis这套组合现在基本是 Java 后端入门项目的黄金搭配。有人问为什么不用 JPA为什么不用 Redis为什么不用微服务——原因特别朴实这个规模的项目用最主流、最稳妥、面试最好讲的方案就够了。SpringBoot省去了大量繁琐的 XML 配置内嵌 Tomcat一个 jar 就能跑非常适合快速开发中小型系统。MyBatisSQL 由自己控制灵活度高尤其是多表联查、动态条件查询这类场景比 JPA 更直观也更容易排查性能问题。MySQL免费、稳定、生态成熟旅游这种读写比例较高、数据量不大的业务完全够用。Vue前后端分离是现在的主流开发模式Vue 生态成熟Element UI 组件库做后台管理系统效率极高。提示如果项目要求里写了“前后端不分离”用 Thymeleaf 模板也是可以的但 SpringBoot 只提供 RESTful 接口、Vue 独立部署的方案扩展性和可维护性明显更好也是目前团队协作的主流方式。2. 数据库设计与后端核心实现数据库是系统的地基设计得好后面写 Mapper 和 Service 都顺设计得烂每加一个需求就要改表连带改实体类、改 XML、改前端能把人逼疯。所以我一般建议表结构先画 ER 图跑通主流程后再补细节。2.1 表结构设计旅游管理系统我通常会建这几张核心表表名说明关键字段user用户表id, username, password, nickname, phone, avatar, rolescenic_spot景点表id, name, description, address, price, images, status, viewstravel_line线路表id, title, days, price, spot_ids, images, descriptionhotel酒店表id, name, address, price, star, images, statusorders订单表id, order_no, user_id, type, product_id, quantity, total_price, status, create_timecomment评论表id, user_id, product_id, content, rating, create_timefavorite收藏表id, user_id, product_id, create_time几点设计心得订单表用 type 字段区分订单类型景点票、线路、酒店避免每种业务单独建一张订单表。虽然有点违背“范式洁癖”但实际用起来特别省事查询也简单。金额字段用 DECIMAL(10,2)千万别用 float/double否则算总价会出现 0.1 0.2 不等于 0.3 这种经典问题。所有表都加 create_time / update_timeMyBatis 里手动 set 或者用数据库 DEFAULT CURRENT_TIMESTAMP 都行但一定要有排查问题时非常有用。status 字段做逻辑删除用户删除景点、下架线路这些都是软删除不要物理 DELETE保留数据才能做统计和恢复。建表 SQL 的核心部分大概是这种感觉CREATE TABLE scenic_spot ( id INT NOT NULL AUTO_INCREMENT, name VARCHAR(100) NOT NULL COMMENT 景点名称, description TEXT COMMENT 景点介绍, address VARCHAR(200) DEFAULT COMMENT 地址, price DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT 门票价格, images VARCHAR(2000) DEFAULT COMMENT 图片URL逗号分隔, status TINYINT NOT NULL DEFAULT 1 COMMENT 1上架 0下架, views INT NOT NULL DEFAULT 0 COMMENT 浏览次数, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT景点表;注意几个细节utf8mb4比utf8多支持 emoji 和生僻字评论内容、用户昵称这种东西很容易踩坑ON UPDATE CURRENT_TIMESTAMP让更新操作自动维护 update_time省一行 Java 代码images 字段用逗号分隔多张图片 URL简单场景下比另建一张图片表更实用。2.2 SpringBoot 工程结构与分层后端工程我习惯按这种包结构组织com.example.travel ├── controller # 接口层 ├── service # 业务层 │ └── impl ├── mapper # MyBatis Mapper接口 ├── entity # 实体类 ├── dto # 请求/响应对象 ├── config # 配置类跨域、静态资源、拦截器 ├── common # 统一返回、异常处理、工具类 └── TravelApplication.java分层不是走过场。Controller 只做参数接收和结果包装Service 写业务逻辑Mapper 只负责 SQL。刚开始学的时候很容易把业务逻辑全写在 Controller 里图一时爽后面接口一多就全是重复代码改一个规则要动十几个接口。统一返回结果类是我建议所有项目都必须有的就像这样public class ResultT { private Integer code; private String message; private T data; public static T ResultT success(T data) { ResultT r new Result(); r.code 200; r.message 操作成功; r.data data; return r; } public static T ResultT error(String message) { ResultT r new Result(); r.code 500; r.message message; return r; } }有了这个统一包装前端 axios 拦截器里只用判断res.data.code 200不用每个接口各自处理错误结构。2.3 MyBatis Mapper 写法和动态 SQLMyBatis 有两种玩法注解写 SQL 和 XML 写 SQL。我的经验是——单表简单操作用注解多表关联、动态条件查询用 XML。比如景点列表页要支持按名称模糊搜索、按价格区间筛选、按状态筛选这种组合条件用动态 SQL 最舒服。select idselectSpotPage resultTypecom.example.travel.entity.ScenicSpot SELECT * FROM scenic_spot where if testname ! null and name ! AND name LIKE CONCAT(%, #{name}, %) /if if testminPrice ! null AND price gt; #{minPrice} /if if testmaxPrice ! null AND price lt; #{maxPrice} /if if teststatus ! null AND status #{status} /if /where ORDER BY id DESC /select这里有两个点新手必踩where标签会自动去掉第一个多余的 AND/OR你可以在 if 条件里放心写AND不用怕拼出WHERE AND name ?这种 SQL 错误。和在 XML 里必须转义写成gt;和lt;或者用![CDATA[ ]]包起来否则 XML 解析直接报错。分页方面如果就一两个列表页手写 LIMIT 就够了如果列表页多建议直接上 PageHelper 插件。用法特别简单先引入依赖然后在查询前调用PageHelper.startPage(pageNum, pageSize); ListScenicSpot list scenicSpotMapper.selectSpotPage(spot); PageInfoScenicSpot pageInfo new PageInfo(list);返回时把pageInfo.getTotal()和pageInfo.getList()塞给前端。PageHelper 的原理是在执行查询前拦截 SQL自动拼上 LIMIT 语句所以我习惯在 Service 层调用避免放在 Mapper 层导致分页失效。3. 前端 Vue 项目搭建与核心页面后端接口写好后前端的工作量其实更大。旅游管理系统的前端分成用户端和管理端两块用户端要页面好看、交互流畅管理端要表格清晰、操作效率高。好在 Vue Element UI 这套组合让这件事变得没那么痛苦。3.1 Vue 工程结构与环境配置用 Vue CLI 创建项目是最稳的方式vue create travel-web cd travel-web npm install element-ui axios vue-router工程结构大概这样src ├── api # 接口请求封装 │ ├── spot.js │ ├── order.js │ └── user.js ├── router # 路由配置 ├── views # 页面组件 │ ├── home/ │ ├── spot/ │ ├── order/ │ └── admin/ ├── components # 通用组件 ├── utils/request.js # axios 封装 └── main.js注意 Vue CLI 创建项目时有个交互式选项选Router和Babel就行其他先不选后面需要再自己加。Element UI 按需引入能减小打包体积但为了省事我一般直接在 main.js 全量引入反正是内部管理系统不在乎那几百 KB。3.2 路由与 axios 封装前端这块做的第一件事就是封装 axios。因为整个系统所有接口都有统一响应结构还有登录鉴权所以封装一层非常划算import axios from axios import { Message } from element-ui import router from /router const request axios.create({ baseURL: /api, timeout: 10000 }) // 请求拦截器自动携带token request.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers[Authorization] token } return config }) // 响应拦截器统一处理返回结果和错误 request.interceptors.response.use( response { const res response.data if (res.code ! 200) { Message.error(res.message || 请求失败) return Promise.reject(new Error(res.message)) } return res }, error { if (error.response error.response.status 401) { Message.error(登录已过期请重新登录) localStorage.removeItem(token) router.push(/login) } else { Message.error(网络请求异常) } return Promise.reject(error) } ) export default request路由那边用 Vue Router关键点是给需要登录的页面加路由守卫router.beforeEach((to, from, next) { const token localStorage.getItem(token) if (to.meta.requiresAuth !token) { next(/login) } else { next() } })这个逻辑简单直接需要登录的页面没有 token 就踢到登录页。管理后台的所有页面都加上meta: { requiresAuth: true }用户端下单、个人中心也一样。3.3 核心页面实现景点列表、详情与下单景点列表页是用户端的门面我一般用卡片网格布局展示。数据加载很简单template div classspot-grid el-card v-forspot in spotList :keyspot.id classspot-card img :srcspot.images.split(,)[0] classspot-img / h3{{ spot.name }}/h3 p classprice¥{{ spot.price }}/p el-button typeprimary clickgoDetail(spot.id)查看详情/el-button /el-card /div /template script import { getSpotList } from /api/spot export default { data() { return { spotList: [], pageNum: 1, pageSize: 8 } }, created() { this.loadSpots() }, methods: { async loadSpots() { const res await getSpotList({ pageNum: this.pageNum, pageSize: this.pageSize }) this.spotList res.data.list }, goDetail(id) { this.$router.push(/spot/${id}) } } } /script下单流程是另一个核心页面。用户选好景点、填好出行日期和人数点击提交后端生成订单。这里有个比较重要的点——提交订单时不要让前端算总价前端传数量后端根据最新单价算总价否则用户改一下前端代码就能改价格这个漏洞我见过不止一次。4. 前后端联调与权限认证前后端分离的项目联调阶段最容易出问题。跨域、登录状态、文件上传这几个点几乎是百分百要遇到的。4.1 跨域问题与统一配置前端跑在localhost:8080后端跑在localhost:8081这俩端口不一样浏览器就会拦截跨域请求。解决办法很多CORS、代理、Nginx 反代。我个人的习惯是开发环境用 Vue 的代理生产环境用 Nginx 反代。Vue CLI 里在vue.config.js配代理最简单module.exports { devServer: { port: 8080, proxy: { /api: { target: http://localhost:8081, changeOrigin: true } } } }这样前端请求/api/spot/list开发服务器会转发到后端的http://localhost:8081/api/spot/list前端代码里不用写完整的后端地址也绕开了跨域限制。如果后端也要开启 CORS可以通过一个配置类搞定Configuration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/**) .allowedOriginPatterns(*) .allowedMethods(GET, POST, PUT, DELETE, OPTIONS) .allowedHeaders(*) .allowCredentials(true) .maxAge(3600); } }注意如果用了 allowCredentials(true)allowedOrigins 不能写*要用 allowedOriginPatterns这是 SpringBoot 版本升级后的一个常见坑很多人配完接口全部 403 就是卡在这里。4.2 JWT 登录认证实现密码不能明文存数据库登录接口不能裸奔这是两条底线。密码我用 BCrypt 加密SpringSecurity 里自带这个工具但我们没引入 SpringSecurity 的话单独引一个spring-security-crypto包就行。JWT 的流程很简单登录成功 → 后端生成 token 返回 → 前端存 localStorage → 后续请求带上 token → 后端拦截器校验 token。生成 token 的部分我习惯用一个简单的 JwtUtilComponent public class JwtUtil { Value(${jwt.secret}) private String secret; Value(${jwt.expire}) private long expire; // 单位秒 public String generateToken(Integer userId, String username, String role) { return Jwts.builder() .setSubject(username) .claim(userId, userId) .claim(role, role) .setExpiration(new Date(System.currentTimeMillis() expire * 1000)) .signWith(SignatureAlgorithm.HS256, secret) .compact(); } public Claims parseToken(String token) { return Jwts.parser().setSigningKey(secret).parseClaimsJws(token).getBody(); } }后端拦截器校验 token 是核心环节。我写了一个AuthInterceptor注册到拦截器注册表里放行登录和注册接口其他接口全部要过 tokenComponent public class AuthInterceptor implements HandlerInterceptor { Autowired private JwtUtil jwtUtil; Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token request.getHeader(Authorization); if (token null || token.isEmpty()) { return reject(response); } try { Claims claims jwtUtil.parseToken(token); request.setAttribute(userId, claims.get(userId)); request.setAttribute(role, claims.get(role)); return true; } catch (Exception e) { return reject(response); } } private boolean reject(HttpServletResponse response) throws IOException { response.setStatus(401); response.setContentType(application/json;charsetUTF-8); response.getWriter().write({\code\:401,\message\:\未登录或token过期\}); return false; } }这里有个容易被忽略的细节拦截器里校验失败的返回结构要跟正常接口的统一返回结构保持一致不要一个返回 JSON 结构另一个返回纯文本前端拦截器处理起来会非常别扭。4.3 图片上传与静态资源映射旅游系统的图片很多景点图、线路图、酒店图都要传。我的方案是上传到本地磁盘目录然后配置虚拟路径映射让前端通过 URL 直接访问。后端接口大概是这样的PostMapping(/upload) public ResultString upload(RequestParam(file) MultipartFile file) { if (file.isEmpty()) { return Result.error(文件不能为空); } String originalFilename file.getOriginalFilename(); String suffix originalFilename.substring(originalFilename.lastIndexOf(.)); String fileName UUID.randomUUID().toString().replace(-, ) suffix; File dest new File(uploadPath fileName); try { file.transferTo(dest); return Result.success(/upload/ fileName); } catch (IOException e) { log.error(上传失败, e); return Result.error(上传失败); } }虚拟路径映射配置Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler(/upload/**) .addResourceLocations(file: uploadPath); }关于文件上传有几个经验分享文件名一定要重写用 UUID 或时间戳不要用用户上传的原始文件名。一是避免中文名和特殊字符带来的乱码问题二是避免文件名冲突三是防止上传路径穿越之类的安全隐患。限制文件大小在 application.yml 里配置spring.servlet.multipart.max-file-size和max-request-size不配的话默认 1MB传大图会莫名其妙失败配了超大值又容易拖垮服务器。图片存储路径不放项目目录内放到独立目录比如/data/upload部署时容器里挂载出来不然项目升级重打包图片就丢了别问我怎么知道的。5. 常见问题与排查技巧实录这部分是我最想写的。很多问题单独看都是小问题但串起来能卡你好几天。我把做这个项目过程中最有代表性的几个问题整理了一下。5.1 MyBatis 分页查询的隐蔽坑PageHelper 用多了有两个经典坑必定会踩到第一个是分页不生效。查了半天发现 SQL 根本没拼接 LIMIT原因是 PageHelper.startPage 和真正的查询之间隔了别的 SQL 操作或业务逻辑。PageHelper 的原理是基于线程上下文它只对紧接着的下一条查询语句生效如果你在中间又执行了一次 Mapper 查询分页就跑偏了。第二个是导出数据时分页把全部数据截断了。比如导出用户列表时Service 里先分页查询了然后循环查关联数据结果导出的只有当前页的数据。解决方案是在导出场景另外写一个不带分页的查询方法不要复用带分页的接口。5.2 日期时间格式化问题前后端联调时日期字段很容易出问题。SpringBoot 默认返回的 LocalDateTime 序列化之后是一个数组或者一串数字前端根本没法直接展示。我习惯在 application.yml 里统一配置spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT8加上 time-zone 是因为不指定的话默认取服务器时区国内服务器如果配置成了 UTC前端展示的时间就差了 8 个小时。这个问题很阴间因为单看数据库里的数据没问题单看后端返回 JSON 也没问题但前端一展示就发现时间不对。5.3 中文乱码与数据库连接配置数据库连接串里必须显式写编码spring: datasource: url: jdbc:mysql://localhost:3306/travel?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalse username: root password: 123456serverTimezoneAsia/Shanghai这个参数必须加MySQL 8.x 驱动如果不带时区参数连接直接报异常。useSSLfalse是避免本地连接时 SSL 握手警告。字符集要保证四层一致数据库 character_set_server、连接串 characterEncoding、表的 CHARSET、代码里的文件编码四个条件任何一个不对中文就会变成问号。另外如果用的是 MySQL 8.x驱动类名是com.mysql.cj.jdbc.Driver老项目里复制的com.mysql.jdbc.Driver虽然还能用但会打印过时警告新项目直接用新的就行。5.4 Vue 打包部署与刷新 404项目做完要部署Vue 打包后是静态文件扔给后端一起部署或者单独用 Nginx 托管都行。我踩过的最大一个坑是——路由用 history 模式刷新页面就 404。原因是前端路由是浏览器端的Nginx 收到/spot/1的请求后去磁盘找这个路径找不到就返回 404。解决办法是 Nginx 配置 fallbacklocation / { root /usr/share/nginx/html; index index.html; try_files $uri $uri/ /index.html; }try_files的意思是先找真实文件找不到就统一回退到 index.html剩下的交给 Vue Router 处理。如果是 hash 模式就不会有这个问题但 URL 里带#不好看所以我习惯配 history 模式 Nginx 回退。5.5 我的排查工具清单最后分享几个做这个项目时非常顺手的排查手段MyBatis 打印 SQL在 application.yml 里配logging.level.com.example.travel.mapperdebug马上能看到每个 Mapper 接口执行了什么 SQL、传了什么参数。排查动态 SQL 拼接错误和参数绑定问题时这招比任何调试器都好用。Postman / Apifox单独测后端接口确认后端没问题再联调前端能省一半排查时间。查看真实请求参数Vue 里在 axios 请求拦截器打印 config能确定前端到底发出了什么请求、带了什么参数。很多时候前后端联调出问题就是前端传的参数名和后端RequestParam对不上或者传的参数类型不对一打印全明白了。6. 项目扩展方向与我的个人体会如果你做完这个系统想更进一步我有几个建议。首先是引入 Redis 缓存热点数据比如景点详情、线路列表这种读多写少的数据缓存之后接口响应速度能提升一个量级这也是面试时很加分的点。其次是引入 Spring Security 替代手写拦截器虽然学习成本高一些但权限模型的完整性会好很多尤其是要支持更细粒度的权限控制时。再就是文件存储从本地磁盘切到 OSS 或者云存储把图片上传这块的服务化。我个人在实际开发中最大的体会是旅游管理系统这类项目看着简单但真正从头到尾走一遍你会把 SpringBoot 的自动配置机制、MyBatis 的动态 SQL、Vue 的组件通信、前后端联调的整个流程全部串起来。很多人喜欢看教程、看源码然后感叹“一看就会一写就废”——根因就是没真正动手从零建过一个完整项目。跟着这篇文章把表结构建出来把接口一个个调通把前端页面一个个渲染出来你收获的东西比看十篇教程都实在。这个项目里还有一个很容易被忽略的小细节所有接口的返回都必须有明确的 code 和 message哪怕是个登录接口也要让前端清楚地知道失败原因是“密码错误”还是“用户不存在”还是“账号被禁用”。一套清晰统一的错误语义能让你和前端同事在联调时少吵一半的架这也是我从这个项目里得到的最直接的经验。
返回列表