简介:这套酒店管理系统是一份面向毕业设计与课程设计的完整实战源码,采用 SpringBoot3 与 Vue.js3 搭建前后端分离架构,后端借助 SpringBoot3 简化配置、快速构建 REST 服务,前端通过 Vue.js3 实现组件化页面与响应式交互,配合 MySQL8 保证数据一致性与事务处理。系统覆盖房间预订、客户管理、订单管理、房价设定、员工管理等典型业务,用户前台负责预订与支付,管理后台完成订单维护和参数设置,完整呈现从需求分析、系统设计到编码实现、测试部署的开发链路。资源包共 7 个文件,整体约为 77.48MB,包含项目源码压缩包、数据库 SQL 脚本、开题报告与任务书文档以及操作录屏 mp4。目前已有 69 人学习下载。其中文档与录屏尤其适合边看边操作,既能为毕业设计的开题、答辩提供材料支撑,也能帮助初学者按步骤搭建后台管理和用户前台,在此项目基础上进行功能扩展与二次开发。
1. 酒店管理系统选 SpringBoot3+Vue.js3:给 2025 届毕设的一条现实路线
这几天陆续有学弟学妹来问同一个问题:酒店管理系统做毕业设计,用 SpringBoot3 和 Vue.js3 到底行不行?我的回答一直很直接——行,而且这是目前性价比最高的一条路线。酒店管理系统表面看是典型的增删改查,但把房间预订、入住退房、账单结算这些环节串起来之后,并发控制、权限校验、跨域联调、日志排查全都会轮到,难度刚好卡在"能讲出深度、又不至于做不完"的位置。
选 SpringBoot3 而不是 SpringBoot2,是因为 2025 年这个时间点,JDK17 + SpringBoot3 已经成了多数新项目的默认组合,网上那些基于 SpringBoot2 + Vue2 的旧教程大多不能直接照抄,照着做反而容易在包名和依赖上卡住。Vue.js3 配合 Vite 开发效率比 Vue2 + Vue CLI 高出一截,组合式 API 写业务也顺手许多。这套技术栈拿去做别的管理系统题目一样能复用,不算白学。
这篇笔记会沿着一条可实现的主线拆开:前后端骨架怎么搭、数据库怎么设计、后端核心接口怎么写、Vue3 前端怎么接、哪些坑每年都有一批人踩,以及答辩前用什么方法把系统验到敢上台。适合已经选了酒店管理系统这个题、准备真的把项目跑起来再去答辩的同学,也适合想快速评估这个题目工作量的朋友。
2. 搭前后端骨架:SpringBoot3 项目初始化与数据库设计
2.1 技术栈选型:JDK17、SpringBoot3 与 Vue3 的版本配套
先说版本配套,这是最容易翻车的起点。SpringBoot3 要求 JDK17 起,所以不要一上来就照抄 SpringBoot2 教程里的 Java8 配置。以我手头常用的组合为例:后端 JDK17 + SpringBoot 3.2.x + MyBatis-Plus 3.5.5 及以上 + MySQL 8.x;前端 Node.js 18 以上 + Vite + Vue3 + Element Plus + Pinia + Axios。这个组合在 2025 年做毕设是够得着、讲得清的。
为什么要强调"3.5.5 及以上"的 MyBatis-Plus?因为 SpringBoot3 把包名前缀从 javax 换成了 jakarta,旧版 MyBatis-Plus 内部依赖的 mybatis-spring 还是老坐标,直接引进来会在启动阶段报 NoClassDefFoundError。这类版本问题在答辩前两周集中爆发,基本都是当初图省事复制了旧依赖导致的。
新旧技术栈的差异可以用下面这张对比表理清,答辩被问到"为什么不用旧版"时也方便递话:
| 对比项 | SpringBoot2 旧链路 | SpringBoot3 这套 | 影响 |
|---|---|---|---|
| JDK 要求 | Java 8 即可 | 最低 Java 17 | 环境不对则编译直接失败 |
| Servlet 包名 | javax.servlet | jakarta.servlet | 旧代码 import 全部报红 |
| 依赖管理 | Spring Boot 2.x | Spring Boot 3.x | 版本号不能混用 |
| MyBatis-Plus 坐标 | mybatis-plus-boot-starter | mybatis-plus-spring-boot3-starter | 引错坐标启动报错 |
| 前端构建 | Vue CLI / Webpack | Vite | 配置方式完全不同 |
另一个常被忽略的选择是 MySQL 8。虽然 MariaDB、PostgreSQL 也能跑,但绝大多数毕业设计相关的教程、同学的互助、老师的常规提问都基于 MySQL,遇到问题时搜到答案的概率高得多。数据库引擎和字符集用默认即可,记得把默认字符集设成 utf8mb4,防止录入客人姓名或备注时遇到 emoji 直接报 Incorrect string value。
2.2 后端骨架:pom.xml 关键依赖与 application.yml 参数说明
创建项目我习惯直接在 IDEA 里用 Spring Initializr,勾选 Spring Web 和 Lombok,Java 版本选 17,然后手动补依赖。最小可跑的 pom.xml 里,核心依赖是这样的:
<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>3.2.5</version> <relativePath/> </parent> <properties> <java.version>17</java.version> </properties> <dependencies> <!-- Web 依赖:SpringBoot3 内置 MVC 与 Tomcat --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- MyBatis-Plus 的 SpringBoot3 专用坐标,注意不是 mybatis-plus-boot-starter --> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-spring-boot3-starter</artifactId> <version>3.5.5</version> </dependency> <!-- MySQL 8 驱动,SpringBoot3 自动管理版本 --> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> <!-- JWT 用 jjwt 0.12 系列,API 与 0.9/0.11 差别很大 --> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-api</artifactId> <version>0.12.5</version> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-impl</artifactId> <version>0.12.5</version> <scope>runtime</scope> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-jackson</artifactId> <version>0.12.5</version> <scope>runtime</scope> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> </dependencies>这里把三个 jjwt 依赖都写上,是因为 jjwt 从 0.12 开始把 API、实现和 JSON 序列化拆成了三个模块,漏掉 jjwt-jackson 会在解析 token 时报 Jackson 相关的类找不到。MyBatis-Plus 用 3.5.5 这个下限值,我实际用过没问题,如果你新建项目时已经有了更新的小版本,直接往上走也行。
接着是 application.yml。数据源相关的几个参数一定要按自己的环境改,我一般这样写:
server: port: 8080 spring: application: name: hotel-management datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/hotel_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: '你的数据库密码' mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpldriver-class-name 必须写成 com.mysql.cj.jdbc.Driver,旧教程里的 com.mysql.jdbc.Driver 在 MySQL 8 驱动里已经移除了。连接串里的 serverTimezone=Asia/Shanghai 是止血用的,不加它,LocalDateTime 字段存进数据库常常差 8 小时,后面避坑章节再展开。mybatis-plus 的 map-underscore-to-camel-case 建议打开,这样数据库里的 room_type_name 能自动映射到实体类的 roomTypeName,少写一堆 @TableField。log-impl 配成 StdOutImpl 是为了开发期在控制台直接看到 SQL,项目快答辩时可以去掉,因为日志量有点吵。
2.3 数据库设计:酒店管理系统从房型到账单的数据流转
数据库是这类系统的地基。地面上的房间、房型、订单、账单如果关系没理清,后期写接口会处处别扭。我建议按一条完整入住链路来设计:客人先预订某个房型,到店后分配到具体房间,离店时按房型价格和入住天数结算。顺着链路,核心表可以控制在七张以内。
| 表名 | 作用 | 关键字段 | 状态设计 |
|---|---|---|---|
| sys_user | 后台登录账号 | id, username, password, real_name, role | status 1启用 0禁用 |
| room_type | 房型及基础价格 | id, type_name, price, bed_count, area | 无 |
| room | 具体房间 | id, room_no, room_type_id, floor, status | 0空闲 1已预订 2入住 3维修 |
| customer | 住客资料 | id, name, phone, id_card | 无 |
| booking | 预订单 | id, room_type_id, customer_name, customer_phone, check_in_date, check_out_date, status | pending confirmed checked_in cancelled |
| check_in_record | 入住登记 | id, booking_id, room_id, actual_check_in, actual_check_out | 无 |
| bill | 账单 | id, check_in_record_id, nights, total_amount, pay_time | unpaid paid |
room_type 和 room 为什么要拆两张表?因为"大床房"是一个类别,而"301 房间、302 房间"是具体资源。预订时只需要锁定房型,入住时才绑定具体房间。如果把房型信息直接塞进 room 表,改一次价格要批量更新几十个房间,后患无穷。
预订和入住的状态也值得多说一句。我见过很多半成品系统把状态做成布尔值,入住状态 0/1 看着简单,但"已预订但未入住"这种中间态就没法表示。所以 room 表用 0 到 3 四个值,booking 表用字符串枚举,虽然多写几行判断,但业务流程完整。
建表时一个容易后悔的细节是字段类型。价格用 DECIMAL(10,2) 而不是 DOUBLE,金额用浮点算会出现 0.1+0.2 不等于 0.3 的问题,账单金额对不上时那叫一个难受。日期字段,预订用 DATE 就够,入住登记里的时间才需要 DATETIME。
2.4 前端骨架:Vite 创建 Vue3 工程与代理配置
前端骨架我用 Vite 创建,命令是 npm create vue@latest,交互式选项里选 Vue3 和 JavaScript,Router 和 Pinia 都勾上,然后用 npm 安装 Element Plus 和 Axios。目录结构我习惯保持这样:
my-hotel-frontend/ ├── src/ │ ├── api/ # 所有请求封装,按模块拆文件 │ ├── assets/ │ ├── components/ # 公共组件 │ ├── router/ # 路由与守卫 │ ├── stores/ # Pinia 状态 │ ├── views/ # 页面 │ │ ├── LoginView.vue │ │ ├── RoomView.vue │ │ ├── BookingView.vue │ │ └── DashboardView.vue │ ├── App.vue │ └── main.js └── vite.config.jsview 层按业务模块命名,不用 Component 的思路去拆,路由和页面一一对应,答辩时讲结构也省事。vite.config.js 里一定要提前配好代理,否则前端 5173 端口向后端 8080 发请求会被浏览器跨域策略拦掉:
import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' export default defineConfig({ plugins: [vue()], server: { port: 5173, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } } })代理的作用是让浏览器以为请求是同源的,前端代码里所有接口都写成 /api/...,不用写完整域名。这个配置在开发期能省掉一半跨域烦恼,但注意它只在开发服务器模式下生效,打包后部署到 Nginx 时需要另配 location 转发,这个区别答辩老师偶尔会问。
3. 后端核心模块落地:登录鉴权、预订并发与日志配置
3.1 JWT 登录鉴权:SpringBoot3 下的拦截器注册方式
酒店管理系统的后台不可能裸奔,至少要有登录和登录校验。主流做法是用 JWT:登录成功后后端签发一个 token,前端存到 localStorage,之后每个请求在 Authorization 头里带回来。相比 session,JWT 不需要在服务端存登录状态,对毕设来说实现简单,答辩也能讲清无状态认证的思路。
先写一个生成和解析 token 的工具类,以 jjwt 0.12 的 API 为例:
import io.jsonwebtoken.Claims; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.security.Keys; import javax.crypto.SecretKey; import java.nio.charset.StandardCharsets; import java.util.Date; public class JwtUtil { private static final String SECRET_KEY = "hotel-management-2025-secret-key-must-be-long-enough"; private static final long EXPIRE_TIME = 24 * 60 * 60 * 1000L; public static String createToken(String username, String role) { SecretKey key = Keys.hmacShaKeyFor(SECRET_KEY.getBytes(StandardCharsets.UTF_8)); return Jwts.builder() .subject(username) .claim("role", role) .issuedAt(new Date()) .expiration(new Date(System.currentTimeMillis() + EXPIRE_TIME)) .signWith(key) .compact(); } public static Claims parseToken(String token) { SecretKey key = Keys.hmacShaKeyFor(SECRET_KEY.getBytes(StandardCharsets.UTF_8)); return Jwts.parser() .verifyWith(key) .build() .parseSignedClaims(token) .getPayload(); } }这里提醒一下,jjwt 0.9 时代常用的 setSigningKey 在 0.12 里已经废弃,换成 verifyWith。网上很多中文教程还在用旧 API,照抄会得到一堆编译错误,这也是我把 jjwt 版本单独拿出来强调的原因。密钥字符串要足够长,HS256 算法对长度有校验,太短会抛 WeakKeyException。
拦截器负责统一校验:
@Component public class JwtInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String header = request.getHeader("Authorization"); if (StringUtils.hasText(header) && header.startsWith("Bearer ")) { try { Claims claims = JwtUtil.parseToken(header.substring(7)); request.setAttribute("username", claims.getSubject()); request.setAttribute("role", claims.get("role")); return true; } catch (Exception e) { // token 过期或非法,统一按未登录处理 } } response.setStatus(401); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"code\":401,\"message\":\"未登录或登录已过期\"}"); return false; } }SpringBoot3 里注册拦截器的方式是实现 WebMvcConfigurer,注意是"实现接口",不是"继承 WebMvcConfigurationSupport",后者会覆盖掉 Spring MVC 的自动配置,导致静态资源和默认配置失效。这是 SpringBoot2 时代的老坑,现在依然有人踩:
@Configuration public class WebConfig implements WebMvcConfigurer { @Autowired private JwtInterceptor jwtInterceptor; @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(jwtInterceptor) .addPathPatterns("/api/**") .excludePathPatterns("/api/auth/login"); } }excludePathPatterns 里要把登录接口、以及留给接口文档的 /v3/api-docs 和 /swagger-ui/** 都排除掉,否则接口文档页面也进不来。
3.2 预订与入住的并发控制:事务和条件更新怎么配合
酒店管理系统最容易出问题的不是 CRUD,而是"同一间房被两个人同时订走"。虽然毕设演示时很难制造出真实并发,但答辩老师一定会问:你怎么保证一间房不会被重复分配?答案就这一句话:用条件更新保证原子性,而不是先查再改。
先查再改的问题在于,两个请求同时查到房间状态是 0(空闲),然后都执行更新,房间就可能被开出两张单。正确做法是把"判断状态"和"更新状态"合并成一条 SQL,让数据库来保证原子性。
@Transactional(rollbackFor = Exception.class) public void checkIn(Long bookingId, Long roomId) { Booking booking = bookingMapper.selectById(bookingId); if (booking == null || !"confirmed".equals(booking.getStatus())) { throw new BizException("预订不存在或状态异常"); } // 关键:只有状态是空闲(0)时才能改成入住(2),影响行数为 0 说明被别人抢了 int rows = roomMapper.updateStatusIfStatusIs(roomId, 2, 0); if (rows == 0) { throw new BizException("该房间当前不可用,请刷新后重试"); } CheckInRecord record = new CheckInRecord(); record.setBookingId(bookingId); record.setRoomId(roomId); record.setCustomerName(booking.getCustomerName()); record.setCustomerPhone(booking.getCustomerPhone()); record.setCheckInDate(LocalDate.now()); checkInRecordMapper.insert(record); bookingMapper.updateStatus(bookingId, "checked_in"); }对应的 Mapper 方法要这样写:
@Update("UPDATE room SET status = #{targetStatus} " + "WHERE id = #{roomId} AND status = #{expectStatus}") int updateStatusIfStatusIs(@Param("roomId") Long roomId, @Param("targetStatus") Integer targetStatus, @Param("expectStatus") Integer expectStatus);updateStatusIfStatusIs 返回的 int 是受影响行数。只有当房间还是空闲状态时,更新才会成功,返回 1;如果别的请求已经把房间占了,条件不成立,返回 0。再用 @Transactional 把整个方法包起来,中途任何一步抛异常,前面的更新都会回滚。这个方法规模不大,但它是这个系统里最值得在答辩时展开讲的一块,比堆十个增删改查接口有价值得多。
预订阶段同理,可以根据日期范围先去统计该房型在目标时间段内的空闲房间数,再插入预订记录。如果是自用系统不需要那么严谨,可以简化成"预订时不锁房间、入住时再校验",但你要能说清楚为什么可以简化,而不是没想过这个问题。
3.3 logback-spring.xml 落地:按天滚动日志与 log4j2 的选择
日志配置在毕设里常常被忽略,但联调阶段你会感谢它。SpringBoot3 默认的日志实现是 Logback,配置文件名用 logback-spring.xml 而不是 logback.xml,区别在于前者支持 springProfile 标签,可以按开发、生产环境切换日志级别,后者不行。我给这类管理系统的配置一般长这样:
<?xml version="1.0" encoding="UTF-8"?> <configuration> <property name="LOG_HOME" value="./logs"/> <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender"> <encoder> <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern> </encoder> </appender> <appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender"> <file>${LOG_HOME}/hotel.log</file> <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy"> <fileNamePattern>${LOG_HOME}/hotel-%d{yyyy-MM-dd}.log</fileNamePattern> <maxHistory>30</maxHistory> </rollingPolicy> <encoder> <pattern>%d{yyyy-MM-dd HH:mm:ss} %-5level [%thread] %logger - %msg%n</pattern> </encoder> </appender> <root level="INFO"> <appender-ref ref="CONSOLE"/> <appender-ref ref="FILE"/> </root> <logger name="com.yourcompany.hotel" level="DEBUG"/> </configuration>pattern 里的 %d 是时间,%-5level 是日志级别,%logger{36} 是类名缩写,%msg 是消息内容。关键参数是 fileNamePattern 里的 %d{yyyy-MM-dd},它让日志按天滚动,每天生成一个文件,maxHistory 保留 30 天。这样联调时想看某天的报错,直接去 logs 目录找对应日期的文件。
你可能在检索时会看到 springboot3 log4j2 的相关讨论。Log4j2 确实是一个可选项,优点是基于 LMAX Disruptor 的异步性能,但需要先在 pom 里排除 spring-boot-starter-logging,再加入 spring-boot-starter-log4j2,然后写 log4j2-spring.xml。对酒店管理系统这个量级,Log4j2 的性能优势根本用不出来,反而多两处配置失误的风险。我的建议是:老老实实用 Logback,把时间花在把日志级别调对、把 SQL 打出来这类"真的能帮你 debug"的事情上。如果为了简历上多写一行"熟悉 Log4j2"而强行换,答辩时被问到配置细节反而容易露怯。
3.4 接口文档:springdoc-openapi 替代 Swagger2 的迁移
给后端写接口文档,能直接生成可在线调试的页面,答辩演示时打开 Swagger UI 秀一下,比翻代码讲接口直观得多。但注意 SpringBoot3 下不能再用 springfox 那套 Swagger2,它的底层停留在 javax 时代,启动就会失败。现在主流做法是用 springdoc 的 starter:
<dependency> <groupId>org.springdoc</groupId> <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId> <version>2.6.0</version> </dependency>依赖加进去后,在 application.yml 里补两行:
springdoc: api-docs: enabled: true swagger-ui: path: /swagger-ui.html启动后访问 http://localhost:8080/swagger-ui.html 就能看到页面。注解也换了:原来的 @Api 换成 @Tag,@ApiOperation 换成 @Operation,@ApiParam 换成 @Parameter。写接口时顺手加这些注解,工作量不大,但生成的文档可读性完全不一样。
有一个细节要留意:如果拦截器把 /api/** 全部拦了,springdoc 的接口文档路径不在放行名单里,页面就打不开。我在 3.1 提过要在 excludePathPatterns 里放行 /v3/api-docs 和 /swagger-ui/**,这里再强调一次,因为这两类问题经常一起出现,排查时容易绕圈子。
4. Vue3 前端落地:登录态、房间管理页与预订联调
4.1 Axios 拦截器:让每个请求自动带上 Token
前端接入后端的第一步不是写页面,而是把请求层封装好。所有接口请求统一走一个 Axios 实例,在拦截器里做两件事:请求发出前带上 token,响应返回时统一处理业务码和 401。
// src/api/request.js import axios from 'axios' import { ElMessage } from 'element-plus' import router from '@/router' const request = axios.create({ baseURL: '/api', timeout: 10000 }) // 请求拦截器:把登录后存的 token 塞进请求头 request.interceptors.request.use(config => { const token = localStorage.getItem('hotel_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('hotel_token') router.push('/login') ElMessage.warning('登录已过期,请重新登录') } else { ElMessage.error(error.response?.data?.message || '网络异常') } return Promise.reject(error) } ) export default requestbaseURL 写成 /api 而不是 http://localhost:8080,是为了配合上一章 Vite 代理的工作方式。后端返回结构约定成{ code, message, data }三件套,前端拦截器里统一判断 code,页面上就不再需要每个请求都写一遍错误提示。token 放 localStorage 对毕设系统足够,不用担心 XSS 那层攻击,答辩能说清这个取舍就行。
4.2 Element Plus 房间管理页:表格加弹窗的最小实现
房间管理页是这类系统最标准的 CRUD 页面,用 Element Plus 的 table + dialog 就能完成。下面这个示例是去掉大量样式后的核心骨架:
<template> <el-table :data="rooms" border stripe> <el-table-column prop="roomNo" label="房间号" width="120"/> <el-table-column prop="roomTypeName" label="房型"/> <el-table-column prop="floor" label="楼层" width="80"/> <el-table-column label="状态" width="100"> <template #default="{ row }"> <el-tag :type="tagType(row.status)"> {{ statusLabel(row.status) }} </el-tag> </template> </el-table-column> <el-table-column label="操作" width="160"> <template #default="{ row }"> <el-button link type="primary" @click="openDialog(row)">编辑</el-button> <el-button link type="danger" @click="removeRoom(row)">删除</el-button> </template> </el-table-column> </el-table> <el-button type="primary" @click="openDialog()">新增房间</el-button> <el-dialog v-model="dialogVisible" :title="form.id ? '编辑房间' : '新增房间'" width="500px"> <el-form :model="form" label-width="80px"> <el-form-item label="房间号"> <el-input v-model="form.roomNo"/> </el-form-item> <el-form-item label="房型"> <el-select v-model="form.roomTypeId" placeholder="选择房型"> <el-option v-for="t in roomTypes" :key="t.id" :label="t.typeName" :value="t.id"/> </el-select> </el-form-item> <el-form-item label="楼层"> <el-input-number v-model="form.floor" :min="1"/> </el-form-item> </el-form> <template #footer> <el-button @click="dialogVisible = false">取消</el-button> <el-button type="primary" @click="saveRoom">保存</el-button> </template> </el-dialog> </template> <script setup> import { ref, onMounted } from 'vue' import { listRoomsApi, saveRoomApi, deleteRoomApi } from '@/api/room' const rooms = ref([]) const dialogVisible = ref(false) const form = ref({}) const statusLabel = (s) => ({ 0: '空闲', 2: '入住', 3: '维修' }[s] ?? '已预订') const tagType = (s) => (s === 0 ? 'success' : s === 2 ? 'danger' : 'warning') async function loadRooms() { const res = await listRoomsApi() rooms.value = res.data } function openDialog(row) { form.value = row ? { ...row } : { roomNo: '', roomTypeId: null, floor: 1 } dialogVisible.value = true } async function saveRoom() { await saveRoomApi(form.value) dialogVisible.value = false await loadRooms() } async function removeRoom(row) { await deleteRoomApi(row.id) await loadRooms() } onMounted(loadRooms) </script>有两个 Vue3 的细节容易出错。第一,表格数据用 ref 管理而不是 reactive,因为接口返回后直接给 rooms.value 赋值,ref 能保持响应式,reactive 直接赋值新数组会断开响应链接,页面不更新。第二,弹窗编辑时用{ ...row }展开对象,避免直接修改表格里的原数据,否则用户取消编辑,表格里也已经变了。
4.3 Pinia 管登录态:路由守卫与刷新恢复
登录态管理我推荐 Pinia,Vue3 官方状态库,比 Vuex 少一层样板代码。核心思路是:token 存在 Pinia 里同时同步到 localStorage,页面刷新后从 localStorage 恢复。
// src/stores/user.js import { defineStore } from 'pinia' export const useUserStore = defineStore('user', { state: () => ({ token: localStorage.getItem('hotel_token') || '', userInfo: null }), actions: { setToken(token) { this.token = token localStorage.setItem('hotel_token', token) }, setUserInfo(info) { this.userInfo = info }, logout() { this.token = '' this.userInfo = null localStorage.removeItem('hotel_token') } } })路由守卫负责挡住未登录的访问:
// src/router/index.js import { createRouter, createWebHistory } from 'vue-router' import { useUserStore } from '@/stores/user' router.beforeEach((to) => { const userStore = useUserStore() if (to.meta.requiresAuth && !userStore.token) { return { path: '/login' } } return true })这里把 token 是否存在的判断放在 Pinia 的 state 里,而不是每次都读 localStorage,好处是登录和注销动作都收敛到 store 的 action 里,路由守卫和 Axios 拦截器读的是同一份状态,不会出现"页面显示已登录、请求却被 401"的错位。
4.4 联调配置:Vite 代理解决跨域,后端 CORS 兜底
开发时 Vite 代理已经处理了大部分跨域问题,但有些场景比如直接访问后端接口文档、或者临时用 Postman 测试,还是会走后端 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); } }这里有个 2025 年依然有人在踩的坑:allowedOrigins("") 和 allowCredentials(true) 同时使用,Spring 会直接报 IllegalArgumentException。解决办法一是 allowedOriginPatterns("") 代替,二是把 allowCredentials 设成 false。对这个毕设系统,前端和后端都是自己写的,安全边界没那么严格,用 allowedOriginPatterns 省事。
联调时如果发现请求发出去了但响应被浏览器拦下,先看控制台里 CORS error 的具体信息,别着急改代码。最常见的其实是代理没生效——比如前端请求写成了全路径 http://localhost:8080,绕过了 Vite 代理,又回到了跨域的老问题上。
5. 避坑清单:SpringBoot3+Vue3 联调阶段最容易栽的五个坑
5.1 javax 全部报红:SpringBoot3 的 jakarta 包名迁移
现象:从旧教程或学长代码里复制过来的文件,出现大面积 import 报红,提示 package javax.servlet 不存在。原因:SpringBoot3 基于 Jakarta EE 9,官方把 javax 开头的包名整体迁移到了 jakarta。这个改动是 SpringBoot2 升 3 最直观的破坏性变化,很多老代码片段直接失去了意义。解决:在 IDEA 里用全局替换把 import javax. 改成 import jakarta.。需要注意 servlet 相关 API 里的类名本身没变,变的只有包名,所以批量替换是安全的。
5.2 MyBatis-Plus 配置了但启动就报错
现象:项目启动时抛异常,堆栈里有 ClassNotFoundError 或者关于 SqlSessionFactory 的 BeanCreationException,而且信息指向 mybatis-spring 相关类。原因:用了老坐标 mybatis-plus-boot-starter,而它默认带的是适配 SpringBoot2 的 mybatis-spring,在 SpringBoot3 的类加载环境下直接不兼容。解决:换成 mybatis-plus-spring-boot3-starter,并把版本提到 3.5.5 及以上。这个坑的特点是一旦踩中,报错时机在启动早期,信息也不直接指向"版本不兼容",不熟悉的人会在数据源配置上反复排查,浪费不少时间。
5.3 数据库时间和页面时间差了 8 小时
现象:前端提交的入住日期是 2025-05-01,存到数据库后查出来变成 2025-04-30。原因:MySQL 连接的时区没有设置,JDBC 驱动默认按照 JVM 所在时区去解析,而连接串里的 serverTimezone 缺失时,双方各按各的时区解释时间。解决:连接串加上 serverTimezone=Asia/Shanghai,同时检查 Jackson 侧的时间序列化配置。如果后端给前端返回时间也差 8 小时,那就要在 application.yml 里给 Jackson 配 time-zone。这个坑不到实际联调不会暴露,而且出错位置可能在数据库、可能在 JSON 序列化、也可能在浏览器显示,排查时先统一确认某一层,别两头乱改。
5.4 Vue3 里 reactive 赋值后页面不更新
现象:接口数据返回后,console.log 能看到数据,但页面上表格空白或者还是旧数据。原因:reactive 声明的对象,如果整体赋一个新数组,等于把响应式代理对象换成了一个普通对象,Vue3 的依赖收集失效。这是 Vue3 新手最常见的"玄学问题",也是从 Vue2 的 data 写法迁移过来最容易踩的。解决:要么列表数据用 ref 声明,赋值时写 rooms.value = res.data;要么保留 reactive 但用 push 或 splice 更新。我的习惯是:列表这种整体替换频繁的数据一律 ref,表单这种嵌套结构用 reactive 配合 Object.assign 更新。
5.5 CORS 配了还是被拦
现象:前端控制台报 CORS error,后端也有 CORS 配置,但请求就是过不去。原因:有两种常见情况。一是 allowedOrigins("") 配了 credentials,Spring 直接拒绝这种组合;二是后端同时有 Spring Security,安全过滤器链的顺序比 MVC 的 CORS 配置更靠前,请求在到达你的 CorsConfig 之前就被拦了。解决:先用 Vite 代理兜底,把跨域问题从开发期挪走;后端 CORS 配置用 allowedOriginPatterns("") 并加上 OPTIONS 方法;如果引了 Spring Security,在 SecurityConfig 里调用 cors() 让安全框架感知跨域配置。跨域问题看起来是配置问题,实际上经常是过滤器顺序问题,排查时按"先确认请求到达了哪一层"这个思路来。
6. 答辩前用一条业务流把系统验到能上台
6.1 完整业务流自测清单
很多毕设翻车不是功能没写完,而是功能之间没有串起来。我的习惯是答辩前两周开始每天跑一遍完整业务流,用一个固定的操作清单:
| 序号 | 操作 | 预期结果 |
|---|---|---|
| 1 | 用管理员账号登录 | 跳转首页,顶部显示用户名 |
| 2 | 新增两个房型、四间房 | 房间列表状态均为空闲 |
| 3 | 创建一个两天后的预订 | 预订状态为已确认,对应房型房间标记已预订 |
| 4 | 对该预订办理入住 | 分配具体房间,房间状态变为入住 |
| 5 | 查询该订单详情 | 订单状态变为已入住 |
| 6 | 办理退房并结算 | 生成账单,金额=天数×房型价格 |
| 7 | 查看房间列表 | 该房间恢复空闲 |
| 8 | 未登录直接访问后台页面 | 被重定向到登录页 |
| 9 | 刷新页面后再操作 | 登录态保持,不需要重新登录 |
| 10 | 看当天日志文件 | 能查到每一步的请求与关键 SQL |
这十条能稳定跑通,答辩演示环节基本不会出现"老师指到哪,项目死在哪"的尴尬。金额对不上、状态跳不对这类问题,也都会在反复跑流程时暴露出来。
6.2 从日志定位问题的基本姿势
联调阶段接口出问题,先别急着加打印。按这个顺序走:看浏览器 Network 里请求是否到达后端,看控制台有没有业务异常堆栈,再去 logs 目录里查当天的滚动日志。logback-spring.xml 里配了按天滚动,所以查问题直接打开对应日期的文件,搜索异常时间点前后几十行,基本就能定位到是哪个接口、哪条 SQL 出的问题。
一个值得养的调试习惯:把日志级别里的 SQL 输出保留到答辩前一两天,再改成只输出 INFO。这样平时能看到 MyBatis-Plus 实际执行的 SQL,排查参数绑定问题时非常有帮助,答辩演示时控制台又不会被大量 SQL 刷屏。
6.3 演示时的三个加分细节
演示时不用把增删改查都点一遍,老师更想看你对自己系统的理解深度。我会重点准备三块:登录鉴权的完整链路——登录、发 token、前端拦截器带头、后端拦截器校验;预订入住的并发控制——把条件更新的那条 SQL 打开给老师看,讲清楚为什么这样能防止超卖;日志体系——现场打开当天的日志文件,指出一次完整操作在日志里的痕迹。这三个点都讲明白,比页面点多十个更有说服力。
答辩前最深的感受是:能给老师稳定演示一条完整业务流,比准备一堆"这个功能我还没来得及做"的解释要踏实得多。我做这类系统有个习惯,每次改完代码都先跑一遍上面的十项清单,不通过不继续往下写,这个习惯救了我不止一次。现在把这套流程写出来,希望帮到你。
本文还有配套的精品资源,点击获取