简介:一份基于SpringBoot的民宿在线预定平台设计与实现,面向Java方向毕业设计、课程作业及想掌握前后端分离开发的学习者。资源完整覆盖了民宿预订的典型业务流程:用户注册登录、条件搜索与筛选、房源浏览与评价、房型选择、在线支付、订单管理、实时客服及收藏功能,同时提供民宿业主端与管理员端的管理能力,适合作为SpringBoot+Vue全栈项目的实战参考。压缩包共780个文件,包含Java后端源码、Vue前端页面、JS逻辑、HTML/CSS静态资源、SVG图标、XML/yml配置及SQL数据库脚本等,整体大小17.62MB,目录结构清晰,便于对照学习与部署运行。目前已有85人学习下载。整套资料既能帮助理解真实项目从数据库设计到前后端交互的实现细节,也可为毕业设计写作提供系统性的代码支撑与功能范本,适合毕业答辩前的快速实操与二次开发。
1. 拿到一套民宿预定毕设,先别急着点运行
拿到一套 SpringBoot 民宿预定毕设源码,第一反应通常是双击 2-run.bat 等它跑起来。但这类项目真正花时间的不是启动,而是三件事:同一房型在重叠日期段怎么防重复预定,三种角色(用户、业主、管理员)的权限怎么落到接口上,前后端分离时登录态怎么维持。这三处也正是毕设答辩时老师最爱追问的细节。这套基于 Spring Boot + Vue 前后端分离的民宿在线预定平台,覆盖民宿搜索、房型下单、订单状态流转、业主管理房源、管理员审核等模块,适合正在做同类毕设需要对照实现的人,也适合接手一套 Vue+SpringBoot 源码但没理清调用链路的开发者。本文从工程结构、数据模型、前后端接口对接讲到可实际演示的验证流程,中间会给出可直接抄走的 SQL、Java 和 JavaScript 代码。
2. 工程结构与Spring Boot配置:三个bat一台戏
2.1 Maven生命周期:install、run、package分别干了什么
拿到压缩包后建议先别急着用 IDE 打开,把根目录的文件清单过一遍。.classpath说明这套工程曾被 Eclipse/STS 系工具打开过,mvnw.cmd是 Maven Wrapper,1-install.bat、2-run.bat、3-build.bat三个脚本基本决定了项目的构建方式。
# 1-install.bat 核心命令 mvnw.cmd clean install -DskipTests # 2-run.bat 核心命令 mvnw.cmd spring-boot:run # 3-build.bat 核心命令 mvnw.cmd clean package -DskipTests三个脚本对应的阶段和产出物如下。
| 脚本 | 执行目标 | 典型产物 |
|---|---|---|
| 1-install.bat | clean install | 安装到本地 Maven 仓库,供多模块联调使用 |
| 2-run.bat | spring-boot:run | 本地直接启动进程,默认监听 8080 |
| 3-build.bat | clean package | target 目录下生成可执行 jar |
-DskipTests表示跳过测试代码的编译和执行,毕业设计里 test 目录常残留不完整用例或依赖本地环境的断言,直接跳过能省掉大量环境问题。如果连测试类都不想编译,可以换-Dmaven.test.skip=true。在多模块工程里install和package区别明显,install 会把产物装进本地仓库供其他模块引用;这套民宿项目如果是单模块,日常开发用 2-run.bat,最终交付用 3-build.bat 就够了。
Maven Wrapper 解决的是「本机没装 Maven 也能构建」的问题。mvnw.cmd会读取.mvn/wrapper/maven-wrapper.properties中的 distributionUrl 去下载指定版本的 Maven。国内网络环境下载官方地址经常卡死,常见做法是把 distributionUrl 改成阿里云镜像仓库中对应版本的 zip 包。如果下载还是失败,打开 IDEA 的 Maven 设置,把 Maven home path 指向 IDEA 自带的 Maven,效果一致。
2.2 application.yml里决定成败的参数
Spring Boot 项目的核心配置集中在src/main/resources/application.yml。毕业设计中大量「启动报错」都出在数据源参数上,尤其是 MySQL 8 的时区、SSL、公钥检索三个配置项。
server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/homestay?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true username: root password: root driver-class-name: com.mysql.cj.jdbc.Driver jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: Asia/Shanghai mybatis-plus: mapper-locations: classpath:mapper/*.xml configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImplserverTimezone=Asia/Shanghai必须加,否则 MySQL 8 驱动会因无法识别本地时区抛异常;useSSL=false避免本机 SSL 握手警告;allowPublicKeyRetrieval=true解决 MySQL 8 使用 caching_sha2_password 插件时客户端报 Public Key Retrieval is not allowed 的问题。map-underscore-to-camel-case把数据库的create_time自动映射为实体的createTime,这是 MyBatis-Plus 下最省事的命名转换方式。log-impl设置为 StdOutImpl 后,每次 SQL 执行都会打印完整语句和参数,排查联表查询、分页、条件构造器问题时比看业务日志快得多。
2.3 从.classpath和.bak文件反推项目历史
.classpath是 Eclipse 系工具生成的工程文件,用 IDEA 打开时直接选择 pom.xml 重新解析,不要双击.classpath。而那一批.bak文件是前人在改代码时留下的回退副本:update-password.vue.bak对应修改密码页面,IndexAsideStatic.vue.bak、IndexHeader.vue.bak、BreadCrumbs.vue.bak是后台管理布局中的侧边栏、顶栏和面包屑组件,index.html.bak则是前端入口页的备份。.bak文件只要没有被 import 就不会参与打包,但对全局搜索和后续接手的人会造成干扰,改完功能确认稳定后应当清理掉。保留一份原始版本再动手改代码,这个习惯本身没问题——真正的坑在于改完后忘了把.bak挪出 src 目录,导致 vite/webpack 构建时对不认识的文件扩展名报错。
3. 民宿预定数据模型与防重叠预定:库存和日期的博弈
3.1 用户、民宿、房型三张底层表怎么建
这套系统的角色分为用户、民宿业主、管理员三类。落地到数据库,不需要建三张独立用户表,一个用户表加角色字段即可。民宿业主可以理解为「拥有民宿资源的用户」,管理员则是后台维护者。
CREATE TABLE t_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) UNIQUE NOT NULL COMMENT '登录名', password VARCHAR(255) NOT NULL COMMENT 'BCrypt加密', role TINYINT DEFAULT 1 COMMENT '0管理员 1用户 2业主', phone VARCHAR(20), email VARCHAR(100), create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) COMMENT '用户表'; CREATE TABLE t_homestay ( id BIGINT PRIMARY KEY AUTO_INCREMENT, owner_id BIGINT NOT NULL COMMENT '关联t_user.id', name VARCHAR(100) NOT NULL, city VARCHAR(50) NOT NULL, address VARCHAR(200), description TEXT, main_picture VARCHAR(500), min_price_per_night DECIMAL(10,2) COMMENT '用于列表页价格筛选', avg_rating DECIMAL(2,1) DEFAULT 5.0, facilities VARCHAR(500) COMMENT 'WiFi,停车,厨房,空调', status TINYINT DEFAULT 0 COMMENT '0待审核 1上架 2下架', create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) COMMENT '民宿表'; CREATE TABLE t_room_type ( id BIGINT PRIMARY KEY AUTO_INCREMENT, homestay_id BIGINT NOT NULL, name VARCHAR(50) NOT NULL COMMENT '大床房/双床房', price_per_night DECIMAL(10,2) NOT NULL, stock INT DEFAULT 1 COMMENT '该房型房间数', area SMALLINT, bed_type VARCHAR(30), create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) COMMENT '房型表';民宿的facilities字段用逗号分隔,满足毕设级别的模糊搜索够了;如果要做价格区间、设施数量、评分组合筛选,更规范的做法是拆出民宿-设施关联表,但这会把查询语句复杂化,多数毕设并不会走到这一步。status用于民宿审核流:业主提交后是 0,管理员审核通过置 1,违规下架置 2。这里要注意,游客只能看到 status=1 的民宿,业主只能查到 owner_id 等于自己 id 的民宿,列表查询接口必须把这些过滤条件带上。
3.2 订单表与状态机设计
订单是整个系统的核心数据载体,也是最容易暴露设计缺陷的地方。很多毕设直接把订单表建成「用户 id + 民宿 id + 日期 + 金额」四件套,缺少房型粒度,也没考虑状态流转,演示时一取消订单就乱了套。
CREATE TABLE t_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL COMMENT '业务单号', user_id BIGINT NOT NULL, room_type_id BIGINT NOT NULL, check_in_date DATE NOT NULL, check_out_date DATE NOT NULL, nights INT NOT NULL, total_amount DECIMAL(10,2) NOT NULL, status VARCHAR(20) NOT NULL COMMENT 'PENDING/PAID/CONFIRMED/CANCELLED/FINISHED', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_order_no (order_no) ) COMMENT '订单表';订单不直接关联民宿表,而出关联到room_type_id,因为同一个民宿下有多个房型,不同房型价格、库存都不同,下单最小粒度是「某个房型的某个日期段」。check_in_date和check_out_date用 DATE 而不是 DATETIME,避免用户选日期时被时分秒干扰。order_no用业务单号而不是自增 id,在支付回调、客户报障、对账时都能避免暴露订单量并且更好记。
订单状态流转建议控制在五个值以内:
| 状态值 | 含义 | 允许流转 |
|---|---|---|
| PENDING | 待付款,下单后锁定库存 | PAID、CANCELLED |
| PAID | 已支付 | CONFIRMED、CANCELLED(退款) |
| CONFIRMED | 业主确认入住 | FINISHED |
| FINISHED | 已完成退房 | 无 |
| CANCELLED | 已取消 | 无 |
待付款订单也占用库存,否则会出现「用户下单未付款,另一个用户同一日期订到同一房间」的问题。毕设里可以用定时任务把超过 30 分钟未支付的 PENDING 订单批量置为 CANCELLED,释放库存。用户主动取消的订单同样要释放库存,这个动作要放在删除或修改订单状态的事务里一起做。
3.3 日期重叠判定:为什么count和between都会出错
民宿预定最关键的一段逻辑是:同一个房型,新订单的入住日期区间不能与已有有效订单重叠。新手最容易写错两个地方。第一种写成等值判断,check_in_date = #{date},这只处理了入住日期恰好相同的场景,用户 8 月 1 日入住、8 月 3 日退房,和已有订单 8 月 2 日入住、8 月 4 日退房完全重叠却查不出来。第二种用 BETWEEN,只能覆盖单日落在区间内的情况,两头都超出区间时会漏掉。
正确的区间重叠判定条件是这样:
SELECT COUNT(*) FROM t_order WHERE room_type_id = #{roomTypeId} AND status IN ('PENDING', 'PAID', 'CONFIRMED') AND check_in_date < #{newCheckOut} AND check_out_date > #{newCheckIn}逻辑就是两条线段相交的数学条件:已有订单的入住日要早于新订单的退房日,已有订单的退房日要晚于新订单的入住日。两个条件同时成立,说明日期段有交集。查出来的数量如果大于等于该房型的stock,说明这个时段已经没有可用房间。状态只算 PENDING、PAID、CONFIRMED,CANCELLED 和 FINISHED 不占用库存——FINISHED 是退房完成之后的终态,日期已经过去,不需要参与可用性判断。
举个例子验证:已有订单是 8 月 1 日到 8 月 3 日,新订单是 8 月 3 日到 8 月 5 日。check_in_date(8月1日) < 8月5日成立,check_out_date(8月3日) > 8月3日不成立,所以判定为不重叠。这符合民宿行业「同一天只允许一个订单入住,退房当天可以再接新客」的惯例。如果业务上要求退房当天也不放房,把第二个条件改成>=即可。
3.4 超卖与并发:事务、乐观锁和分布式锁的边界
检查库存和插入订单必须放在同一个事务里,否则两个并发请求同时读到剩余库存为 1,两个都能通过校验,就会超卖。这是 java 面试里事务隔离级别和锁机制最常见的问法,也是答辩时老师一定会追问的点。实际的 Service 层是这样组织的:
@Transactional(rollbackFor = Exception.class) public OrderVO createOrder(OrderCreateDTO dto) { RoomType roomType = roomTypeMapper.selectById(dto.getRoomTypeId()); if (roomType == null) { throw new BizException("房型不存在"); } int nights = (int) ChronoUnit.DAYS.between(dto.getCheckInDate(), dto.getCheckOutDate()); if (nights <= 0) { throw new BizException("入住日期必须早于退房日期"); } Integer occupied = orderMapper.countOccupied( dto.getRoomTypeId(), dto.getCheckInDate(), dto.getCheckOutDate()); if (occupied >= roomType.getStock()) { throw new BizException("该时段已无空房"); } Order order = new Order(); order.setOrderNo(generateOrderNo()); order.setUserId(dto.getUserId()); order.setRoomTypeId(dto.getRoomTypeId()); order.setCheckInDate(dto.getCheckInDate()); order.setCheckOutDate(dto.getCheckOutDate()); order.setNights(nights); order.setTotalAmount(roomType.getPricePerNight() .multiply(BigDecimal.valueOf(nights))); order.setStatus(OrderStatus.PENDING); orderMapper.insert(order); return convertToVO(order); }@Transactional保证 countOccupied 和 insert 要么一起成功,要么一起回滚。rollbackFor = Exception.class很关键,Spring 默认只对 RuntimeException 回滚,如果抛出的是受检异常,事务不会回滚,库存状态就错乱了。countOccupied对应 3.3 节那段重叠判定 SQL,它查出来的是当前已经被占用的房型数量,只有这个数量小于房型库存时才允许插入订单。
事务解决了原子性,但解决不了高并发下的「都查到库存为 1,都通过校验」这类问题。毕设答辩中有两种可行的升级方案。第一种是给t_room_type加一个version整数字段,更新时执行UPDATE t_room_type SET stock = stock - 1, version = version + 1 WHERE id = ? AND version = ?,受影响行数为 0 就说明房屋已被其他人预定,这是乐观锁方案。第二种是用 Redis 的SETNX以房型 id 为 key 加锁,释放时用 Lua 脚本删除,这是分布式锁方案。对单机部署的毕设来说,事务加乐观锁已经完全够用,能在答辩时说出两种方案的适用边界就已经超出大多数同学的水平。
4. Vue + Spring Boot前后端分离:登录、搜索与axios封装
4.1 JWT登录与HandlerInterceptor拦截器
前后端分离项目里,Session 方案要处理跨域携带 Cookie、CSRF 等问题,毕业设计里用 JWT 反而是实现成本最低、演示效果最直观的方案。用户登录成功后,后端签发一个 token,前端每次请求把它塞进请求头,后端拦截器统一校验。
@PostMapping("/api/auth/login") public Result<String> login(@RequestBody LoginDTO dto) { User user = userMapper.selectOne(new LambdaQueryWrapper<User>() .eq(User::getUsername, dto.getUsername())); if (user == null || !passwordEncoder.matches(dto.getPassword(), user.getPassword())) { throw new BizException("用户名或密码错误"); } String token = JwtUtil.createToken(user.getId(), user.getRole()); return Result.success(token); }LambdaQueryWrapper是 MyBatis-Plus 提供的条件构造器,.eq(User::getUsername, dto.getUsername())相当于WHERE username = ?,比手写 XML 少一步且类型安全。passwordEncoder是BCryptPasswordEncoder的实例,注册时用encode加密,登录时用matches校验,这里不能把数据库里的密文拿出来和dto.getPassword()做 equals 比较,因为 BCrypt 每次加密盐值都不同。
拦截器侧的逻辑是:放行/api/auth/login,其余/api/**请求先取 Authorization 头,解析 JWT,把 userId 存到 Request attribute 或 ThreadLocal 里供 Controller 使用。游客浏览房源列表的接口不在拦截范围内,但下单、收藏、修改密码这些接口必须登录。这里有个容易漏掉的细节:登录接口返回的 token 中要带上角色字段,后面判断「当前用户是不是这个民宿的业主」「是不是管理员」都靠它,避免每查一次权限就要回表查一次用户。
4.2 民宿搜索接口与MyBatis-Plus分页
摘要里提到的「按目的地、入住日期、退房日期、人数搜索」是列表页核心接口。搜索列表不需要先做日期过滤,把日期可用性放到房型详情接口里判断,理由有两个:列表页一次要返回一二十条民宿,每条都去查订单表,SQL 复杂且响应慢;用户通常先看民宿条件再选日期,详情页做日期校验已经足够。
@GetMapping("/api/homestay/search") public Result<Page<HomestayVO>> search(HomestayQueryDTO query) { Page<Homestay> page = new Page<>(query.getPage(), query.getSize()); LambdaQueryWrapper<Homestay> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(Homestay::getStatus, 1); if (StringUtils.hasText(query.getCity())) { wrapper.like(Homestay::getCity, query.getCity()); } if (query.getMinPrice() != null) { wrapper.ge(Homestay::getMinPricePerNight, query.getMinPrice()); } if (query.getMaxPrice() != null) { wrapper.le(Homestay::getMinPricePerNight, query.getMaxPrice()); } wrapper.orderByDesc(Homestay::getAvgRating); Page<Homestay> result = homestayMapper.selectPage(page, wrapper); return Result.success(convertToVO(result)); }分页对象Page两个构造参数是页码和每页条数,selectPage会自动拼接 LIMIT 并查出总记录数。ge和le对应>=和<=,like做模糊匹配。城市筛选用like而不是eq,因为传过来的可能是「杭州」也可能是「浙江杭州」这种带省市的表述,模糊匹配容错率更高。前端传.size参数时要限制最大值,比如限制到 50,防止一次拉全表数据拖垮数据库。价格筛选设计在民宿表的min_price_per_night上而不是房型价格上,是为了让列表页响应够快——列表只需要给出「这家民宿最低多少钱起步」,真正精确价格留给房型维度计算。
4.3 update-password.vue背后的axios拦截器
update-password.vue.bak这个文件对应前端「修改密码」页面,它背后依赖 axios 封装。页面里只负责表单校验和调接口,所有带 token、处理统一错误的事情都收敛在 request 拦截器里。
import axios from 'axios' import { Message } from 'element-ui' import { getToken, removeToken } from './auth' const service = axios.create({ baseURL: '/api', timeout: 10000 }) service.interceptors.request.use(config => { if (getToken()) { config.headers['Authorization'] = getToken() } return config }) service.interceptors.response.use( res => { if (res.data.code === 200) { return res.data.data } Message.error(res.data.msg || '请求失败') return Promise.reject(new Error(res.data.msg)) }, err => { if (err.response && err.response.status === 401) { removeToken() location.href = '/login' } else { Message.error(err.message || '系统异常') } return Promise.reject(err) } ) export default service这段代码把公共逻辑收拢到两处:请求拦截器负责给每一个请求自动附带 token,响应拦截器负责统一处理错误码和 401 跳转。修改密码页面实际调用时只用关心业务数据流:
export function updatePassword(data) { return request({ url: '/user/password', method: 'PUT', data }) }开发环境前后端端口不同,需要配置代理。Vue 项目根目录的vue.config.js里加上这一段:
devServer: { port: 3000, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } }/api开头的请求会被转发到http://localhost:8080,浏览器里看到的请求地址始终是同源的,不触发跨域,也不需要后端额外配置 CORS。很多毕设前后端接口通了但浏览器控制台报 CORS 错误,多半就是少了这个代理或后端没有放行跨域。前端用代理解决开发环境跨域,生产环境则用 Nginx 把/api反向代理到后端服务,这种部署方式在答辩时提一句会比「我直接改了后端允许所有跨域」专业得多。
5. 从运行到答辩:验证链路、金额精度与两个加分改造
5.1 全链路验证顺序
先把数据库建好,执行项目里 sql 目录下的初始化脚本,确认 t_user 里有测试账号、t_homestay 里至少有一条 status=1 的民宿数据。然后执行 1-install.bat 装依赖,再执行 2-run.bat 启动后端,日志出现Tomcat started on port(s): 8080后启动前端。验证时不要跳着点,按用户视角走完整条链路:登录取得 token,搜索城市民宿,进入详情,选房型,选日期,下单,把订单状态从 PENDING 改成 PAID,再到后台确认入住。每走一步看一次数据库对应记录,确认 t_order 的 status、total_amount、nights 都在按预期变化。
5.2 金额计算的两个细节
订单金额计算里,价格和总金额的字段类型必须用BigDecimal,不能用 double。0.1 + 0.2在浮点数里等于0.30000000000000004,金额差一分钱在演示时不容易发现,但后续接支付平台对账必然出问题。nights用ChronoUnit.DAYS.between(checkInDate, checkOutDate)计算,不要手写日期相减再除以 86400000,前者会自动处理跨月和夏令时。下单日期校验时,入住日期不能早于今天,退房日期必须晚于入住日期,这两个校验放在 Controller 层还是 Service 层?我一般把它放在 Service 层入口做,避免绕过 Controller 直接调 Service 的调用方式跳过校验。
5.3 支付宝沙箱与乐观锁防重复支付
演示完模拟支付后,可以顺手做两个低成本改造,这比增加新页面更能体现工程能力。
第一,把「确认支付」替换为支付宝沙箱支付。在支付宝开放平台申请一个沙箱应用,后端增加两个接口:一个创建支付订单并返回支付链接,一个接收支付宝异步通知。核心代码只有两处,验证签名的逻辑放在 Service 层入口,Controller 只做参数绑定,支付回调成功后执行orderService.paySuccess(orderNo),把状态从 PENDING 流转到 PAID 并释放对应支付单。
第二,给 t_order 增加version字段,支付回调里用乐观锁更新状态:
int updated = orderMapper.updateStatusWithVersion( orderNo, OrderStatus.PAID, OrderStatus.PENDING, version); if (updated == 0) { throw new BizException("订单状态已变更,请刷新后重试"); }对应 SQL 是UPDATE t_order SET status = #{newStatus}, version = version + 1 WHERE order_no = #{orderNo} AND status = #{oldStatus} AND version = #{version}。受影响行数为 0 说明订单已经被并发处理过,防止支付平台重复通知导致同一订单被结算两次。这套逻辑同样适用于「取消订单」和「确认入住」。不管换成微信支付还是云闪付,验签和状态流转都放在 Service 层入口,替换的只是支付通道实现类,订单状态机本身不用动。
本文还有配套的精品资源,点击获取