ARTICLE DETAIL

资讯详情

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

基于Spring Boot和Vue的民宿预订管理系统:从数据建模到部署实战

基于Spring Boot和Vue的民宿预订管理系统:从数据建模到部署实战 简介这是一份面向Java全栈开发者与高校学生的民宿预订管理系统源码基于Java、Spring Boot与Vue技术栈实现前后端分离涵盖民宿信息管理、用户认证、订单处理、支付接口等核心模块适合毕业设计、课程设计或团队实战教学案例。压缩包共381个文件、约10.17MB以125个jpeg图片、79个java源文件、38个vue组件、24个ts类型文件、20个js脚本、39个svg图标及10个xml配置为主。其中后端Java代码覆盖服务端逻辑、数据持久化与接口定义前端Vue组件负责页面渲染与用户交互TypeScript则增强代码可维护性。资源整体分为web前端与server后端两大部分并附有代码说明、readme及doc文档目录便于快速理解架构和二次开发用户可通过前端页面完成民宿浏览、搜索与预订。已有475人学习浏览对学习Spring Boot与Vue整合开发、掌握前后端分离项目组织方式具有直观参考价值。1. 民宿预订管理系统为什么用 Java Spring Boot Vue 这套组合“民宿预订管理系统”这个名字里最容易低估的是“预订”两个字。它不只是把民宿列出来还牵扯到房型、日期、订单状态和评价闭环。Java 提供强类型服务和事务边界Spring Boot 把这些服务自动装配成可独立运行的接口层Vue 负责把接口数据渲染成列表、详情和下单表单。三者组合能覆盖一个小型民宿平台从用户浏览、线上下单到入住评价的完整路径。对正在做课设、毕业设计或小团队交付的人来说这套源码结构的好处是边界清楚一个人维护后端一个人维护前端不会因为模板渲染混在一起。下面按数据模型、后端接口、前端联调、部署验证的顺序展开。2. 民宿预订管理系统的数据模型从民宿、房型到订单状态机2.1 五张表把民宿预订系统的核心业务拆开民宿预订系统里真正决定扩展性的是表拆得够不够细。假设一栋民宿只有一种房型可以把价格直接挂在民宿表上但实际场景里同一个院子的山景房和院景房价格不同可住人数也不同所以最好的起点是把“民宿”和“房型”拆开。民宿表只放地址、封面、介绍这类房源属性房型表放价格、床位、可住人数这类可售卖属性。我把核心表拆成 user、homestay、room_type、reservation、review 五张。user 提供登录身份homestay 描述房源room_type 描述可预订单元reservation 记录一次预订review 绑定一个已完成订单。这样换房型、改价格、加设施都不会影响已经产生的订单结构。订单表是整个系统的重心它需要同时知道谁订的、订了哪个房型、住几晚、花多少钱所以字段一定不能只围绕民宿去设计。2.2 用 SQL 把建表语句跑通下面是一份可以直接执行的 MySQL 建表脚本字段做了精简但已经能支撑列表、详情、下单、评价四个主流程。CREATE TABLE user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, nickname VARCHAR(50) NOT NULL, phone VARCHAR(20) UNIQUE NOT NULL, password VARCHAR(100) NOT NULL, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE homestay ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(100) NOT NULL, city VARCHAR(50) NOT NULL, address VARCHAR(200), cover_url VARCHAR(255), description TEXT, status TINYINT DEFAULT 1, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE room_type ( id BIGINT PRIMARY KEY AUTO_INCREMENT, homestay_id BIGINT NOT NULL, name VARCHAR(50) NOT NULL, area SMALLINT, bed_count TINYINT, max_guests TINYINT, price DECIMAL(10,2) NOT NULL, inventory INT DEFAULT 0, status TINYINT DEFAULT 1, KEY idx_homestay (homestay_id) ); CREATE TABLE reservation ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) UNIQUE NOT NULL, user_id BIGINT NOT NULL, room_type_id BIGINT NOT NULL, check_in_date DATE NOT NULL, check_out_date DATE NOT NULL, guest_count TINYINT NOT NULL, total_amount DECIMAL(10,2) NOT NULL, status TINYINT NOT NULL DEFAULT 0, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_user (user_id), KEY idx_room (room_type_id) ); CREATE TABLE review ( id BIGINT PRIMARY KEY AUTO_INCREMENT, reservation_id BIGINT UNIQUE NOT NULL, user_id BIGINT NOT NULL, rating TINYINT NOT NULL, content VARCHAR(500), create_time DATETIME DEFAULT CURRENT_TIMESTAMP );这段 SQL 适合 MySQL 8也兼容 5.7。第一个要点是金额字段用DECIMAL(10,2)不要用 float 或 double否则会出现 19.99 变成 19.990000000000002 这类误差。第二个要点是入住离店日期用 DATE 类型而不是 DATETIME预订系统的日期粒度到天就够了。第三个要点是order_no加唯一索引因为订单号是用户投诉和后续对账的主要凭据。我没有在这套表里建物理外键。常见做法是只在room_type.homestay_id、reservation.room_type_id上加普通索引让应用层去保证关联关系。这样插入订单时不需要数据库额外检查外键并发高一点也不会拖慢性能。如果你用 MyBatis-Plus 或 JPA实体字段直接映射这些下划线列名map-underscore-to-camel-case打开即可。2.3 订单状态机存数字而不是存文案订单状态在源码里最常见的坑是把“待支付”“已支付”直接存进数据库。需求方改一次文案就要 UPDATE 一次表。更常见的做法是只存数字 code展示文案由后端枚举或前端字典统一维护。public enum OrderStatus { PENDING(0, 待支付), PAID(1, 已支付), CHECKED_IN(2, 已入住), CHECKED_OUT(3, 已退房), CANCELLED(4, 已取消), REFUNDING(5, 退款中); private final int code; private final String desc; OrderStatus(int code, String desc) { this.code code; this.desc desc; } public int getCode() { return code; } public String getDesc() { return desc; } }状态流转建议做成一张明确的表而不是在 Controller 里到处if else。民宿预订的最小状态机可以这样约定当前状态code允许进入的下一状态待支付0已支付、已取消已支付1已入住、已取消、退款中已入住2已退房已退房3无已取消4无后端在做状态更新时先查出旧订单状态再判断旧状态是否允许跳到目标状态。如果允许才执行 UPDATE并把status作为 WHERE 条件之一。这样即使两个请求同时到达也只有一个能真正改掉状态。3. Spring Boot 把民宿预订系统的 API 拆成这几层3.1 Spring Boot 工程依赖与数据库配置后端工程可以从 Spring Initializr 直接生成也可以只建一个 Maven 项目。在脚手架环境不可用时依赖也不需要很多spring-boot-starter-web、MyBatis-Plus、MySQL 驱动就够了。dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3/version /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency如果 Spring Boot 版本比较高要注意 MyBatis-Plus 的兼容版本需要跟着升级常见报错是启动时ClassNotFoundException或者 mapper 方法找不到本质都是依赖版本和 Spring Boot 版本不匹配。server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/homestay?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver jackson: date-format: yyyy-MM-dd time-zone: Asia/Shanghai mybatis-plus: configuration: map-underscore-to-camel-case: true global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0spring.jackson.date-format只对java.util.Date生效对LocalDate和LocalDateTime不生效。Spring Boot 对 JSR-310 时间类型会自动配置序列化但如果你发现接口返回的是数组或数字就要检查是不是把LocalDate用在了实体字段上并且没有引入对应的 Jackson 模块。逻辑删除的配置也要谨慎一旦打开所有查询都会自动带上deleted 0如果表里没有这个字段启动后查询会报错。如果你想偷懒让表自动建出来可以把建表 SQL 放到database/schema.sql然后设置spring.sql.init.modealways。这种方式适合本地快速启动生产环境仍然建议用迁移脚本管理表结构。这套系统最少需要这几个接口方法路径作用是否要登录GET/api/homestay按城市、分页查询民宿列表否GET/api/homestay/{id}获取民宿详情和房型列表否POST/api/reservation创建订单是GET/api/reservation/my查询当前用户订单是3.2 民宿列表接口Controller、Service、Mapper 怎么配合Controller 只负责接收参数和返回结果不要在里面写 SQL。RestController RequestMapping(/api/homestay) public class HomestayController { private final HomestayService homestayService; public HomestayController(HomestayService homestayService) { this.homestayService homestayService; } GetMapping public ResultPageResultHomestayVO list( RequestParam(defaultValue 1) Integer page, RequestParam(defaultValue 10) Integer size, RequestParam(required false) String city) { return Result.success(homestayService.search(page, size, city)); } }Service 实现里用 MyBatis-Plus 的LambdaQueryWrapper拼条件避免把表名和列名散落在 Java 字符串里。Service public class HomestayServiceImpl implements HomestayService { Override public PageResultHomestayVO search(int page, int size, String city) { LambdaQueryWrapperHomestay wrapper new LambdaQueryWrapper(); wrapper.eq(StringUtils.hasText(city), Homestay::getCity, city) .eq(Homestay::getStatus, 1) .orderByDesc(Homestay::getCreateTime); PageHomestay p homestayMapper.selectPage(new Page(page, size), wrapper); ListHomestayVO records p.getRecords().stream().map(h - { HomestayVO vo new HomestayVO(); BeanUtils.copyProperties(h, vo); vo.setRoomTypes(roomTypeMapper.selectList( new LambdaQueryWrapperRoomType() .eq(RoomType::getHomestayId, h.getId()) .eq(RoomType::getStatus, 1))); return vo; }).toList(); return PageResult.of(records, p.getTotal()); } }eq(condition, column, value)的第一个参数是布尔值条件为 true 时才拼接 SQL所以city为空时不会出现WHERE city null。分页对象Page接收page和size页码从 1 开始前端传 0 的话需要做归一化。size建议做上限拦截比如超过 50 直接降成 50防止有人一次拉全表。3.3 下单接口事务、日期冲突与并发防重创建订单必须用事务并且要把“日期是否被占”的判断放在事务里。Transactional(rollbackFor Exception.class) public Long createOrder(OrderCreateRequest req) { RoomType room roomTypeMapper.selectById(req.getRoomTypeId()); if (room null || room.getStatus() ! 1) { throw new BizException(房型不存在或已下架); } if (!req.getCheckOutDate().isAfter(req.getCheckInDate())) { throw new BizException(离店日期必须晚于入住日期); } long nights ChronoUnit.DAYS.between(req.getCheckInDate(), req.getCheckOutDate()); long conflict reservationMapper.countConflict(req.getRoomTypeId(), req.getCheckInDate(), req.getCheckOutDate()); if (conflict 0) { throw new BizException(该时段已被预订); } BigDecimal amount room.getPrice().multiply(BigDecimal.valueOf(nights)); Reservation order new Reservation(); order.setOrderNo(generateOrderNo()); order.setUserId(UserContext.getUserId()); order.setRoomTypeId(req.getRoomTypeId()); order.setCheckInDate(req.getCheckInDate()); order.setCheckOutDate(req.getCheckOutDate()); order.setGuestCount(req.getGuestCount()); order.setTotalAmount(amount); order.setStatus(OrderStatus.PENDING.getCode()); orderMapper.insert(order); return order.getId(); }countConflict的判断条件要注意区间重叠新订单的入住日必须小于已有订单的离店日并且新订单的离店日必须大于已有订单的入住日这样两个时间段只要重叠一天就会命中。Select(SELECT COUNT(*) FROM reservation WHERE room_type_id #{roomTypeId} AND status NOT IN (4, 5) AND check_in_date #{checkOutDate} AND check_out_date #{checkInDate}) long countConflict(Param(roomTypeId) Long roomTypeId, Param(checkInDate) LocalDate checkInDate, Param(checkOutDate) LocalDate checkOutDate);这个写法在低并发下没问题但两个请求同时查到conflict 0后一起插入还是可能超卖。更可靠的做法是加一张“房型日期库存表”主键是room_type_id biz_date下单时执行UPDATE room_inventory SET booked booked 1 WHERE room_type_id #{roomTypeId} AND biz_date BETWEEN #{checkInDate} AND DATE_SUB(#{checkOutDate}, INTERVAL 1 DAY) AND booked inventory这条 UPDATE 影响行数等于可订夜数时才算成功否则回滚。它是原子操作不需要额外加锁也比SELECT ... FOR UPDATE更容易扩展。3.4 登录拦截器用 JWT 守住预订和订单接口登录接口不在这次的核心链路里但订单接口一定要守。用拦截器做 token 校验比在 Controller 里重复写“取 header、解析 token、抛异常”要干净得多。Component public class LoginInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { if (OPTIONS.equalsIgnoreCase(request.getMethod())) { return true; } String token request.getHeader(Authorization); if (token null || !token.startsWith(Bearer )) { response.setStatus(HttpStatus.UNAUTHORIZED.value()); return false; } try { Claims claims JwtUtil.parse(token.substring(7)); UserContext.setUserId(((Number) claims.get(userId)).longValue()); return true; } catch (Exception e) { response.setStatus(HttpStatus.UNAUTHORIZED.value()); return false; } } }拦截器必须放行 OPTIONS 请求否则前端的跨域预检会在进入业务代码之前直接被 401 挡掉联调时你会看到 CORS 报错但后端日志里什么都没有。注册拦截器时把民宿查询放行订单接口拦起来Configuration public class WebConfig implements WebMvcConfigurer { private final LoginInterceptor loginInterceptor; public WebConfig(LoginInterceptor loginInterceptor) { this.loginInterceptor loginInterceptor; } Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(loginInterceptor) .addPathPatterns(/api/**) .excludePathPatterns(/api/homestay/**); } }/api/homestay/**会放行列表和详情但/api/reservation没有匹配到这个规则所以创建订单和查询我的订单仍然要求登录。UserContext用 ThreadLocal 保存当前用户请求结束时要记得清理否则线程池复用会导致用户串号。4. Vue 前端把民宿预订流程从列表页串到下单页4.1 路由与页面文件怎么排前端工程建议用 Vue 3 Vite 组织目录按“页面、接口、组件”三层拆分。页面只放业务编排接口调用集中在api目录复用组件放components。src/ api/ homestay.js reservation.js request.js router/ index.js views/ HomeView.vue DetailView.vue OrderView.vue MyOrders.vue components/ RoomTypeCard.vue路由用懒加载避免首屏把详情页和下单页代码全部加载进来。import { createRouter, createWebHistory } from vue-router const routes [ { path: /, name: home, component: () import(/views/HomeView.vue) }, { path: /homestay/:id, name: detail, component: () import(/views/DetailView.vue) }, { path: /order/confirm, name: order, component: () import(/views/OrderView.vue) }, { path: /orders, name: orders, component: () import(/views/MyOrders.vue) } ] export const router createRouter({ history: createWebHistory(), routes })路由的颗粒度以“用户能否直接打开”为标准。订单确认页需要有roomTypeId和日期参数不要在列表页把状态全挂在 Vuex 里刷新页面后状态就丢了。路径页面说明/HomeView.vue民宿列表与城市筛选/homestay/:idDetailView.vue民宿详情、房型选择/order/confirmOrderView.vue确认日期、人数并下单/ordersMyOrders.vue查看当前用户订单4.2 封装请求axios 拦截器带上登录 token所有接口请求统一走一个 axios 实例token 在请求拦截器里自动带上。import axios from axios const request axios.create({ baseURL: import.meta.env.VITE_API_BASE || /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 response.data, error { if (error.response?.status 401) { localStorage.removeItem(token) // 跳转登录页 } return Promise.reject(error) } ) export default requestVITE_API_BASE是 Vite 的环境变量。本地开发时可以把它配成http://localhost:8080/api后端开启跨域生产环境前端和后端放在同一个域名下baseURL 直接/api由 Web 服务器转发到后端端口。不要在列表页里各自用fetch统一封装后后续加刷新 token 或错误提示都只需要改一个文件。4.3 民宿列表页的数据加载列表页用组合式 API 写响应式数据集中在script setup里。script setup import { reactive, ref, onMounted } from vue import { fetchHomestays } from /api/homestay const list ref([]) const total ref(0) const loading ref(false) const query reactive({ page: 1, size: 10, city: }) async function loadList() { loading.value true try { const { data } await fetchHomestays(query) list.value data.records total.value data.total } finally { loading.value false } } function onCityChange() { query.page 1 loadList() } onMounted(loadList) /scriptlist和total用ref是因为它们会被模板单独读取query用reactive是因为它是个对象多个字段需要统一做响应式。finally里关 loading可以避免接口报错后按钮一直转圈。切换城市时把page重置为 1否则用户从第 3 页开始筛可能会看到空白页。4.4 下单页的日期与夜数处理下单页最容易算错的是“住几晚”。离店日不占房所以 5 月 1 日入住、5 月 3 日离店实际是 2 晚。script setup import { reactive, computed } from vue const form reactive({ checkInDate: , checkOutDate: , guestCount: 1 }) const nights computed(() { if (!form.checkInDate || !form.checkOutDate) return 0 const start new Date(form.checkInDate) const end new Date(form.checkOutDate) const diff (end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24) return diff 0 ? diff : 0 }) /scriptinput typedate在浏览器里返回的是yyyy-MM-dd字符串直接new Date()解析会按 UTC 零点处理在国内时区会变成当天 08:00。两个日期之间做差值时只要同一天都按这个规则解析计算结果不会有偏差。但如果你手动拼了yyyy-MM-dd HH:mm:ss就要小心跨时区问题。更稳妥的做法是引入 dayjs统一用dayjs(form.checkInDate).diff(dayjs(form.checkOutDate), day)计算。提交订单时要同时把房型、日期、人数传给后端后端返回订单号后跳转到我的订单页。async function submitOrder() { if (nights.value 0) { ElMessage.warning(请选择正确的入住日期) return } const { data } await createReservation({ roomTypeId: selectedRoomTypeId.value, checkInDate: form.checkInDate, checkOutDate: form.checkOutDate, guestCount: form.guestCount }) ElMessage.success(订单创建成功${data.orderNo}) router.push(/orders) }这里的请求头会自动带上Authorization后端拦截器解析出userId后写入订单。如果后端抛了“该时段已被预订”前端要保留用户填写的日期和人数只提示重新选择日期不要让整个表单被清空。5. 打包部署民宿预订管理系统先解决跨域和日期格式5.1 后端打包与启动后端打成可执行 jar最直接的是 Maven 命令mvn clean package -DskipTests java -jar target/homestay-0.0.1-SNAPSHOT.jar --spring.profiles.activeprod-DskipTests会跳过测试编译后的执行但不会重新编译测试代码如果测试代码本身编译不通过要改用-Dmaven.test.skiptrue。启动时用--spring.profiles.activeprod切换生产配置不要把数据库密码写在默认application.yml里。前端构建同样简单npm run build构建产物在dist目录Web 服务器直接托管这个目录。如果服务器上已经有 Nginx把/api的请求转发到后端的 8080 端口即可前端页面本身不需要访问后端端口。5.2 CORS 配置allowedOrigins 和 allowCredentials 不能乱组合本地调试时前端如果直接用VITE_API_BASEhttp://localhost:8080/api后端必须开启跨域。Spring Boot 里最常见的是实现WebMvcConfigurerConfiguration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/api/**) .allowedOriginPatterns(http://localhost:5173) .allowedMethods(GET, POST, PUT, DELETE, OPTIONS) .allowedHeaders(*) .allowCredentials(true) .maxAge(3600); } }.allowedOrigins(*)和.allowCredentials(true)不能同时使用否则启动或预检时直接报错。需要携带 cookie 或 token 时用.allowedOriginPatterns把明确的可信来源列出来不要配成通配符。5.3 用 curl 验证接口和跨域预检接口是否通部署后先用 curl 验证再打开浏览器curl http://localhost:8080/api/homestay?city杭州page1size5 curl -i -X OPTIONS http://localhost:8080/api/reservation \ -H Origin: http://localhost:5173 \ -H Access-Control-Request-Method: POST第一条命令验证列表接口能返回 JSON第二条命令验证跨域预检。如果第二条返回 401说明拦截器没有放行 OPTIONS如果返回 403说明 CORS 配置的来源没有匹配上。联调时遇到 401先看请求头里有没有Authorization遇到 CORS 报错先看 OPTIONS 请求有没有正常返回。这两条路径确认完前面写的这套接口和页面就能顺畅跑通。本文还有配套的精品资源点击获取
返回列表