news 2026/10/11 6:17:52

SpringBoot+Vue景区民宿预约系统:状态机设计与防超卖实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
SpringBoot+Vue景区民宿预约系统:状态机设计与防超卖实战

做了好几个民宿预约类的管理系统后,我发现一个规律:绝大多数教程和毕业设计项目都在讲CRUD、讲页面美化,但真正决定一个预约系统能不能用的,是订单状态怎么流转、房间库存怎么防止超卖、日期区间怎么判断冲突。这些才是预约业务的地基,也是面试时最容易暴露短板的地方。

这个项目我用了SpringBoot + Vue + MySQL + MyBatis这套经典组合,做的是一个前后端分离的景区民宿预约系统。游客可以在前端按日期选房、下单、查看订单;前台或管理员可以在后台确认入住、办理退房、管理民宿和房型数据。整套系统覆盖了从房源展示、日期筛选、创建订单,到支付回调、状态流转的完整闭环。无论你是准备毕业设计答辩,还是想找一个能往简历上写、能讲清楚业务逻辑的全栈项目,都值得把这篇看完。

我会从这套系统最核心的部分开始讲起,包括功能边界怎么划、数据库表怎么设计、订单状态机怎么定义、后端接口怎么写才不容易出并发问题、前端页面怎么组织,以及我实际开发中踩过的几个坑。

1. 需求拆解:民宿预约系统真正难的不是CRUD

很多人在动手写这类系统时,第一步就偏了。拿到需求后立刻建表、写接口、画页面,结果做到一半发现订单状态乱套、库存对不上、日期判断出错,推倒重来。做民宿预约系统,先要把业务闭环想清楚,再谈技术实现。

1.1 三类用户和一条核心业务线

景区民宿和普通酒店有点不一样。酒店的房间规格相对统一,民宿则是一栋楼里可能有一间大床房、两间双床房、一间家庭套房,每种的挂牌价和可订数量都不同。所以我的角色设计是:

  • 游客(前端用户):浏览民宿列表、查看房型与价格、按日期查询可订房间、创建订单、模拟支付后查看订单状态。
  • 前台(运营人员):处理订单,执行确认入住和退房操作,管理游客的入住登记信息。
  • 管理员:维护民宿基础信息、管理房型与库存、查看全部订单、处理退款请求。

核心业务线只有一条:游客选日期 → 查可用房型 → 创建订单 → 支付 → 到店办理入住 → 退房结算。所有功能都是围绕这条线展开的。

1.2 功能边界:哪些必须做,哪些可以砍掉

做课程设计或毕业设计,最忌讳的就是功能堆砌。结合大多数景区民宿的实际管理场景,我最终确定的功能清单是这样的:

功能模块具体功能优先级
民宿与房型管理民宿信息增删改查、房型设置、价格设置、库存设置必须有
日期查询与库存展示按入住/离店日期查询可订房型与剩余数量必须有
在线预约下单选择房型、填写入住人信息、生成订单必须有
支付模拟模拟支付成功回调,改变订单状态必须有
订单管理游客查看自己的订单、取消待支付订单必须有
前台入住/退房操作确认入住、办理退房必须有
数据统计今日入住数、本月营业额、房型热度加分项

我项目里没有做真实的第三方支付对接,支付环节用模拟回调替代。原因很简单,真实支付需要商户号、回调验签、证书配置,复杂度会翻好几倍,但对预约业务本身的理解帮助不大。这个取舍在答辩时可以主动讲出来,说明你清楚业务和技术之间的边界,这反而是加分点。

1.3 前后端分离的模块划分

技术栈选的是SpringBoot 2.x + Vue 2.x(Element UI)+ MySQL 5.7/8.0 + MyBatis。前后端通过RESTful接口通信,统一返回结构是{ code, message, data }。

后端模块划分我按业务域而不是按技术层拆:

  • controller:接收请求,参数校验,返回统一响应。
  • service:业务逻辑,这是核心。订单创建、状态校验、库存判断都在这一层。
  • mapper:MyBatis的Mapper接口,配合XML里的SQL语句。
  • entity:与数据库表对应的实体类。
  • common:统一返回结果、异常处理、工具类。

前端按页面划分:

  • 游客端:民宿列表页、民宿详情页(含房型展示)、创建订单页、订单列表页。
  • 管理端:登录页、Dashboard、民宿管理页、房型管理页、订单管理页。

提示:前后端分离项目,跨域问题一定要处理。我在后端加了一个CorsConfig配置类,允许指定前端的地址跨域访问。测试时如果前端请求报跨域错误,优先检查这里。

2. 数据库设计:订单状态机的建模是关键

数据库设计直接决定后面写代码的顺畅程度。民宿预约系统的表结构不少,我挑最核心的几张表展开讲。

2.1 数据表结构与字段说明

用户表(user)

用户表比较简单,存账号、密码、手机号、角色。密码我用的MD5加盐存储,实际商用会换成BCrypt,但这个项目里MD5加盐足够说明你对安全的意识。

CREATE TABLE `user` ( `id` INT NOT NULL AUTO_INCREMENT, `username` VARCHAR(50) NOT NULL COMMENT '登录账号', `password` VARCHAR(100) NOT NULL COMMENT '密码(MD5加盐)', `phone` VARCHAR(20) DEFAULT NULL, `role` TINYINT DEFAULT 1 COMMENT '角色: 0-管理员 1-游客', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB;

民宿表(homestay)和房型表(room_type)

民宿表存景区信息、地址、简介、封面图。房型表挂在民宿下面,存房型名称、挂牌价、总库存,比如"大床房,¥388/晚,共5间"。

CREATE TABLE `homestay` ( `id` INT NOT NULL AUTO_INCREMENT, `name` VARCHAR(100) NOT NULL, `address` VARCHAR(200) DEFAULT NULL, `description` TEXT, `cover_url` VARCHAR(255) DEFAULT NULL, `status` TINYINT DEFAULT 1 COMMENT '1-营业 0-停业', PRIMARY KEY (`id`) ) ENGINE=InnoDB; CREATE TABLE `room_type` ( `id` INT NOT NULL AUTO_INCREMENT, `homestay_id` INT NOT NULL, `type_name` VARCHAR(50) NOT NULL COMMENT '如: 大床房/双床房', `price` DECIMAL(10,2) NOT NULL COMMENT '每晚价格', `total_count` INT NOT NULL COMMENT '该类型的房间总数', PRIMARY KEY (`id`) ) ENGINE=InnoDB;

订单表(orders)

这是全系统最核心的表,字段设计直接反映业务理解深度。

CREATE TABLE `orders` ( `id` INT NOT NULL AUTO_INCREMENT, `order_no` VARCHAR(32) NOT NULL COMMENT '订单编号', `user_id` INT NOT NULL COMMENT '下单游客ID', `room_type_id` INT NOT NULL COMMENT '预订的房型ID', `check_in_date` DATE NOT NULL COMMENT '入住日期', `check_out_date` DATE NOT NULL COMMENT '离店日期', `nights` INT NOT NULL COMMENT '入住晚数', `guest_name` VARCHAR(50) NOT NULL COMMENT '入住人姓名', `guest_phone` VARCHAR(20) NOT NULL COMMENT '入住人电话', `total_price` DECIMAL(10,2) NOT NULL COMMENT '订单总价', `status` TINYINT NOT NULL DEFAULT 0 COMMENT '订单状态: 0-待支付 1-已支付 2-已入住 3-已退房 4-已取消 5-退款中', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`), KEY `idx_user_id` (`user_id`), KEY `idx_room_type_date` (`room_type_id`, `check_in_date`, `check_out_date`) ) ENGINE=InnoDB;

订单编号我生成规则是yyyyMMddHHmmss + 4位随机数,再用唯一索引兜底。为什么不用自增ID当订单号?因为订单号会出现在前端、短信、对账单里,连续自增的ID容易暴露平台单量,而且从业务上订单号本就应该具备一定随机性。

2.2 状态机设计:为什么推荐用数字状态而非字符串

订单状态是整个系统的灵魂。我见过不少项目用字符串状态,比如pending、paid、checked_in,好处是直观,坏处是扩展和判断麻烦,写SQL时还得注意大小写。我用的是数字状态枚举,在Java里定义一个枚举来承载状态和状态流转规则:

public enum OrderStatus { PENDING_PAY(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 static OrderStatus fromCode(int code) { for (OrderStatus status : values()) { if (status.code == code) { return status; } } throw new IllegalArgumentException("未知状态: " + code); } }

状态流转规则我用一张表明确控制,绝不允许订单状态随意跳转:

当前状态允许执行的操作目标状态
待支付(0)支付、取消已支付(1)、已取消(4)
已支付(1)办理入住、申请退款已入住(2)、退款中(5)
已入住(2)办理退房已退房(3)
退款中(5)同意退款(管理员)已取消(4)
已退房(3)无终态
已取消(4)无终态

这个状态机在代码层面我用一个transition方法统一校验:

public OrderStatus transition(OrderStatus target) { switch (this) { case PENDING_PAY: if (target == PAID || target == CANCELLED) return target; break; case PAID: if (target == CHECKED_IN || target == REFUNDING) return target; break; case CHECKED_IN: if (target == CHECKED_OUT) return target; break; case REFUNDING: if (target == CANCELLED) return target; break; default: break; } throw new IllegalStateException("非法状态流转: " + this + " -> " + target); }

提示:订单状态变更必须走统一的Service方法(比如updateStatus(orderId, targetStatus)),在里面先判断当前状态再更新。禁止到处直接写UPDATE语句改状态,否则状态机形同虚设。

2.3 日期重叠与房间占用判断

预约系统的宿命是处理日期冲突。游客入住时间是连续的,判断某个房型在某个区间是否可订,其实就是判断这个区间内是否每一天都有余房。

实现思路有两种:

思路一:库存扣减型。room_type表里存总库存,下单成功就扣减库存,退房/取消就加回库存。这种方式适合房型数量不多、按房型售卖的场景,实现简单,但要注意并发和状态流转时的库存回补。

思路二:占用明细型。单独建一张room_occupied表,每成功一单就插入一条占用记录。查询时统计对应时间内的占用数量,再用总库存减去占用数量得到剩余可订量。这种方式更灵活,能支撑"哪几天满房"的日历展示。

我最终选了思路一作为主逻辑,因为代码量少、清晰。但在"可订房型展示"这个接口上,我用了思路一的变体:查询某日期区间内的有效订单,按房型分组统计已占用的房间数,用总库存减掉它。

SELECT room_type_id, SUM(nights) AS occupied_nights FROM orders WHERE status IN (1, 2) -- 已支付和已入住才占用房间 AND check_in_date < #{checkOutDate} AND check_out_date > #{checkInDate} GROUP BY room_type_id;

这个SQL里的日期判断条件值得我们仔细看一遍:check_in_date < #{checkOutDate} AND check_out_date > #{checkInDate},这是判断两个区间是否重叠的标准写法。假设一个订单是6月1日入住、6月3日离店(占6月1日、2日两晚),你查6月2日入住、6月4日离店是否可订:

  • 订单的 check_in_date(6.1)< 查询的 check_out_date(6.4)→ 成立
  • 订单的 check_out_date(6.3)> 查询的 check_in_date(6.2)→ 成立

两条都为true,说明重叠,这个房型在6月2日不可订。反之,如果查6月3日入住(新订单check_in=6.3):

  • 6.1 < 6.3 成立
  • 6.3 > 6.3 不成立(相等说明前一个订单刚好退房,不冲突)

所以"边界当天退房、当天入住"是允许的,正好符合民宿的惯例:退房日房间腾出来,新客人当天下午入住。

3. 后端核心接口:下单与状态流转的完整实现

数据库设计好了,后端最重要的就是两块:创建订单时怎么保证数据不出错,状态变更时怎么保证流程不乱。这两个接口写明白了,其余接口都是常规增删改查。

3.1 创建订单接口:事务、校验、库存三步缺一不可

创建订单的接口路径我定义为POST /api/orders,参数包含房型ID、入住日期、离店日期、入住人姓名、入住人电话。Service层的核心逻辑有五步:

  1. 校验房型存在且民宿营业中。
  2. 校验日期合法性:入住日期不能早于今天,离店日期必须晚于入住日期。
  3. 查询该房型在目标区间内是否还有余房。
  4. 计算总价:(离店日期 - 入住日期) × 每晚价格。
  5. 生成订单号,插入订单记录。

第3步是最容易出问题的地方。我写了一个独立的可用库存方法:

public boolean isRoomAvailable(Integer roomTypeId, LocalDate checkIn, LocalDate checkOut) { // 查询该房型的有效订单(占用记录) List<Order> occupiedOrders = orderMapper.selectOccupiedOrders(roomTypeId, checkIn, checkOut); int occupiedCount = 0; for (Order order : occupiedOrders) { // 简单处理:一单占用一间房。如果订单横跨整个查询区间,就计为1。 occupiedCount += 1; } RoomType roomType = roomTypeMapper.selectById(roomTypeId); return occupiedCount < roomType.getTotalCount(); }

这里我按"一单占一间房"处理,因为系统里每种房型的订单默认对应一间房。如果你的业务支持同一订单订多间,那要引入room_count字段,把这部分数量加进去。

然后整个方法加上@Transactional:

@Transactional(rollbackFor = Exception.class) public Order createOrder(OrderCreateDTO dto) { // 1. 校验房型 RoomType roomType = roomTypeMapper.selectById(dto.getRoomTypeId()); if (roomType == null) { throw new BizException(500, "房型不存在"); } // 2. 校验日期 if (dto.getCheckInDate().isBefore(LocalDate.now())) { throw new BizException(400, "入住日期不能早于今天"); } if (!dto.getCheckOutDate().isAfter(dto.getCheckInDate())) { throw new BizException(400, "离店日期必须晚于入住日期"); } // 3. 校验库存 if (!isRoomAvailable(dto.getRoomTypeId(), dto.getCheckInDate(), dto.getCheckOutDate())) { throw new BizException(500, "该日期区间已满房"); } // 4. 计算金额 long nights = ChronoUnit.DAYS.between(dto.getCheckInDate(), dto.getCheckOutDate()); BigDecimal totalPrice = roomType.getPrice().multiply(new BigDecimal(nights)); // 5. 生成订单 Order order = new Order(); order.setOrderNo(generateOrderNo()); order.setUserId(dto.getUserId()); order.setRoomTypeId(roomType.getId()); order.setCheckInDate(dto.getCheckInDate()); order.setCheckOutDate(dto.getCheckOutDate()); order.setNights((int) nights); order.setGuestName(dto.getGuestName()); order.setGuestPhone(dto.getGuestPhone()); order.setTotalPrice(totalPrice); order.setStatus(OrderStatus.PENDING_PAY.getCode()); orderMapper.insert(order); return order; }

@Transactional保证第5步插入订单时如果出异常,前面的任何写操作都回滚。特别注意:默认情况下事务只对RuntimeException回滚,所以我把异常定义成BizException这个RuntimeException的子类,并且显式声明rollbackFor = Exception.class,防止以后有人抛受检异常导致不回滚。

提示:有经验的面试官会追一个问题——"如果下单时不加锁,两个并发请求同时查到有余房怎么办?"这个问题在文章第5节专门展开讲,这里先留个悬念。

3.2 状态变更接口:同一个方法里做校验和更新

支付回调、取消订单、确认入住、退房、退款,本质上都是同一个动作:把订单从状态A改成状态B。我封装了一个统一方法:

public void changeOrderStatus(Long orderId, OrderStatus targetStatus) { Order order = orderMapper.selectById(orderId); if (order == null) { throw new BizException(404, "订单不存在"); } OrderStatus currentStatus = OrderStatus.fromCode(order.getStatus()); OrderStatus newStatus = currentStatus.transition(targetStatus); orderMapper.updateStatus(orderId, newStatus.getCode()); }

支付回调我单独写了一个payCallback方法,模拟第三方支付结果通知。实际的回调接口会包含签名验签、幂等处理,这里简化为:校验订单存在、状态为待支付,然后调用changeOrderStatus(orderId, PAID)。

取消订单有一个细节:待支付状态可以随便取消,但已支付订单要取消,必须走退款流程。所以前端的"取消订单"按钮,只有在订单状态为待支付时才显示;已支付订单如果要取消,要调退款申请接口。

3.3 MyBatis中那些容易忽略的配置与SQL写法

项目里我用的是XML方式写Mapper,相比注解,XML能处理更复杂的动态SQL,排查问题也更方便。几个容易踩的配置点单独拎出来说。

驼峰映射。数据库字段check_in_date对应Java属性checkInDate,很多新手在这里踩坑,结果查出全是null。在application.yml里一定要开启:

mybatis: configuration: map-underscore-to-camel-case: true

动态SQL用<set>标签。更新订单状态时,只更新需要的字段,避免误伤。用<set>标签能把字段列表拼装中多余的逗号自动去掉:

<update id="updateStatus"> UPDATE orders <set> <if test="status != null">status = #{status},</if> <if test="updateTime != null">update_time = #{updateTime},</if> </set> WHERE id = #{id} </update>

时间段条件查询用<where>标签:

<select id="selectOrdersByCondition" resultType="com.example.entity.Order"> SELECT * FROM orders <where> <if test="userId != null">AND user_id = #{userId}</if> <if test="status != null">AND status = #{status}</if> <if test="checkInDate != null">AND check_in_date &gt;= #{checkInDate}</if> <if test="checkOutDate != null">AND check_out_date &lt;= #{checkOutDate}</if> </where> ORDER BY create_time DESC </select>

注意XML里>和<要转义成&gt;和&lt;,否则XML解析直接报错。这个坑我当年排了半天。

还有一个细节:MyBatis的一级缓存默认是开启的,且作用域是SqlSession。Spring整合后,每次数据库操作都是独立开启和关闭SqlSession的,所以一级缓存一般不会造成脏读。但如果你在同一个事务里先查询订单、再修改订单状态、再查询订单,第二次查询可能会命中一级缓存,拿到旧数据。解决办法是在修改后手动调用sqlSession.clearCache(),或者干脆在测试时禁止一级缓存。这个在开发调试阶段特别容易让人困惑。

4. 前端Vue页面与预约流程体验设计

前端这块,我用的是Vue 2.6 + Element UI + Vue Router + Axios。页面逻辑不复杂,但有几个地方比较考验设计:

4.1 页面路由结构与权限控制

路由分两块:游客端和管理端。游客端不需要登录就能浏览民宿列表,下单前要求登录;管理端必须登录且角色为管理员才能访问。

const router = new VueRouter({ routes: [ { path: '/', component: Home, meta: { title: '民宿列表' } }, { path: '/homestay/:id', component: HomestayDetail, meta: { title: '民宿详情' } }, { path: '/order/create', component: OrderCreate, meta: { requiresAuth: true, title: '下单' } }, { path: '/order/list', component: OrderList, meta: { requiresAuth: true, title: '我的订单' } }, { path: '/login', component: Login, meta: { title: '登录' } }, { path: '/admin', component: AdminLayout, meta: { requiresAuth: true, requiresAdmin: true }, children: [ { path: 'dashboard', component: Dashboard }, { path: 'homestay', component: AdminHomestay }, { path: 'orders', component: AdminOrders } ] } ] });

路由守卫里做权限控制,核心代码:

router.beforeEach((to, from, next) => { const token = localStorage.getItem('token'); if (to.meta.requiresAuth && !token) { next({ path: '/login', query: { redirect: to.fullPath } }); return; } if (to.meta.requiresAdmin) { const role = localStorage.getItem('role'); if (role !== '0') { next({ path: '/' }); return; } } next(); });

提示:前端的权限控制只能决定"显示不显示",真正的安全校验必须在后端做。后端的接口要判断登录用户的角色,不能让游客直接调用管理接口。我后端的做法是写了一个简单的拦截器,校验请求头里的Token,再通过Token解析出UserId和Role。

4.2 选日期与可订房型联动

民宿详情页是整个前端交互最核心的页面。游客先选择入住日期和离店日期,页面会实时请求后端查询该区间内有哪些房型可订、剩余几间。这里用到了Element UI的日期范围选择器,并做了两个限制:

  • 今天之前的日期不可选。
  • 离店日期必须在入住日期之后。

联动查询的接口是GET /api/room-types/available?homestayId=xx&checkIn=2025-06-01&checkOut=2025-06-03。返回的数据结构:

[ { "roomTypeId": 1, "typeName": "大床房", "price": 388.00, "remaining": 3 }, { "roomTypeId": 2, "typeName": "双床房", "price": 458.00, "remaining": 0 } ]

剩余为0的房型,前端要置灰并在卡片上显示"已满房"。这个交互细节很加分,因为它比用户提交后才提示"无房"体验好太多。

4.3 Axios封装与统一错误处理

请求封装我做了统一处理,核心是把Token自动加进请求头,把后端的code != 200时弹出提示,把401状态跳转到登录页:

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) { this.$message.error(res.message); return Promise.reject(new Error(res.message)); } return res.data; }, error => { if (error.response && error.response.status === 401) { localStorage.removeItem('token'); this.$router.push('/login'); } return Promise.reject(error); } );

4.4 订单列表与状态按钮的显隐控制

订单列表页有个很容易被忽视的细节:不同状态的订单,可操作按钮完全不同。下拉框里待支付订单显示"去支付"和"取消订单";已支付订单显示"申请退款";已入住订单显示"确认退房"(这一步通常由前台操作,但为了演示方便,我在模拟环境里也开放给用户)。前端用v-if按状态控制按钮组:

<div v-if="order.status === 0"> <el-button type="primary" @click="handlePay(order)">去支付</el-button> <el-button @click="handleCancel(order)">取消订单</el-button> </div> <div v-else-if="order.status === 1"> <el-button @click="handleRefund(order)">申请退款</el-button> </div> <div v-else-if="order.status === 2"> <el-button type="warning" @click="handleCheckOut(order)">办理退房</el-button> </div>

5. 实测中踩过的坑:并发超卖、日期边界与缓存脏读

做这类系统,跑通正常流程不算本事,真正考验人的是那些"偶然出现"的问题。我把实际开发中遇到过的四个问题完整记录在这里,这些问题在答辩时讲出来会非常加分。

5.1 并发下单导致超卖:不加锁的库存校验形同虚设

问题是这么暴露的:我写了一个压测脚本,模拟两个游客同时对只剩最后一间的房型下单。结果两个订单都创建成功,但房型只有一间。原因不难理解:两个请求并发进入,都先执行isRoomAvailable()查询,发现剩余1间,满足条件,然后各自插入订单。两个插入都成功,但库存逻辑上已经被占用了2间。

解决方案:用MySQL的SELECT ... FOR UPDATE对房型记录加悲观锁。在事务里查询房型时锁定这一行,其他事务必须等当前事务提交后才能继续查。

<select id="selectByIdForUpdate" resultType="com.example.entity.RoomType"> SELECT * FROM room_type WHERE id = #{id} FOR UPDATE </select>

然后在createOrder方法里,把查房型语句换成这个加锁版本:

RoomType roomType = roomTypeMapper.selectByIdForUpdate(dto.getRoomTypeId());

注意,加悲观锁的前提是createOrder方法里有@Transactional,因为锁要持有到事务提交才释放。如果方法没有事务,FOR UPDATE的锁会在语句执行完就释放,等于白加。

有锁之后的效果:第一个请求查到房型并加锁,第二个请求的selectByIdForUpdate会阻塞,等第一个事务提交后才继续,此时它再查库存,发现已经不足,抛出"该日期区间已满房"。

提示:悲观锁适合并发量不高的管理类系统,实现简单、逻辑直观。如果日订单量上了几千,再考虑把库存放到Redis里用Lua脚本做原子扣减,或者在数据库加乐观锁版本号。做课程设计,讲清楚悲观锁的原理和适用场景就够了。

5.2 日期区间重叠判断写反了:边界日期的处理讲究

最初我写日期重叠判断时用的是check_in_date <= #{checkOutDate} AND check_out_date >= #{checkInDate}。这个写法边界很尴尬:某订单当日退房,新游客当日入住,本应该允许,但这个判断会判定两个区间重叠,导致房间被误判为不可订。

我之前解释过:退房日的房间当天会腾出来,所以重叠判断的边界应该是"左闭右开"——订单占用的日期区间是[check_in_date, check_out_date),即包含入住日,不包含离店日。对应SQL就是:

check_in_date < #{checkOutDate} AND check_out_date > #{checkInDate}

这个细节特别适合在答辩时讲——能讲清楚"边界日期的处理",证明你真的理解业务,而不是只会照着教程敲代码。

5.3 MyBatis一级缓存导致的订单状态"不刷新"

有段时间测试人员反馈一个诡异问题:订单支付后,再查订单详情,状态还是"待支付"。反复刷新都不变。最后定位到原因:在同一个SqlSession内,先查了订单(状态0),然后执行支付回调把状态改成1,再查订单时命中一级缓存,拿到的还是状态0的旧对象。

Spring整合MyBatis后,每次Mapper操作通常是用新的SqlSession执行的,所以这个问题正常开发中很少爆发。但在同一个事务方法里,如果你先selectById再执行updateStatus再selectById,第二次查询就会拿到缓存里的旧数据。

解决办法:

  1. 在更新后调用SqlSession.clearCache(),强迫下一次查询走数据库。
  2. 把更新和查询拆到不同事务里,或者避免在同一事务内"改后再查"。
  3. 在开发环境中配置MyBatislocal-cache-scope: statement,禁用一级缓存,方便尽快暴露问题。

我个人建议第2种思路:事务方法的职责要单一。"更新状态"就只更新状态,返回void;调用方如果还需要展示最新订单,就再走一次独立的查询。职责分离能避免很多隐蔽问题。

5.4 MySQL时区导致的时间偏差

另一个很隐蔽的问题是时间。我在本机测试时一切正常,部署到云服务器后,订单创建时间比本地时间慢了8个小时。排查后发现是MySQL连接串没指定时区,而服务器的系统时区和MySQL默认时区不一致。

解决办法是在JDBC连接串里显式指定:

spring: datasource: url: jdbc:mysql://localhost:3306/homestay_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai

这个问题本身不复杂,但排查过程可能很费劲,因为这个"慢8小时"的现象会让你下意识怀疑是代码逻辑问题,而不会想到是数据库连接的时区配置。

6. 从零搭建环境到跑通演示:一份可直接照做的清单

最后整理一份我从零到跑通的完整清单,方便你复现,也方便你在答辩前快速准备演示环境。

6.1 环境准备与项目初始化

后端环境:JDK 1.8+、Maven 3.6+、MySQL 5.7+、IDEA。前端环境:Node.js 14+、Vue CLI 4.x+。

MySQL侧的操作:

CREATE DATABASE homestay_db DEFAULT CHARACTER SET utf8mb4;

然后导入项目的schema.sql,里面包含上面设计的全部表和若干演示数据。我通常会预置以下演示数据:

  • 管理员账号:admin / admin123
  • 游客账号:zhangsan / 123456
  • 两到三家景区周边民宿,每家2~3种房型,价格有梯度,方便演示价格计算。

6.2 后端启动与常见配置

application.yml核心配置:

server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/homestay_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: 123456 mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.entity configuration: map-underscore-to-camel-case: true

启动后端:在项目根目录执行mvn spring-boot:run,或者先把项目打成jar包:

mvn clean package -DskipTests java -jar target/homestay-1.0.0.jar

6.3 前端启动与部署

前端项目在frontend目录下,先装依赖再启动开发服务器:

npm install --registry=https://registry.npmmirror.com npm run serve

开发环境访问http://localhost:8081,Vue CLI默认端口是8080,和后端冲突的话要改一下vue.config.js里的devServer.port为8081。

这里有个重点:开发环境下前端请求后端的地址要配置代理,或者在后端开启CORS。我两种方式都试过,最终选择在后端加CORS全局配置,因为我喜欢把最终演示打包成单体应用。

演示时的最优部署方案:用npm run build把Vue项目打包成静态文件,然后把dist目录下的所有文件复制到SpringBoot项目的src/main/resources/static目录下。重新打包SpringBoot项目后,浏览器访问http://localhost:8080就能直接看到前端页面,接口走同源请求,没有跨域问题,演示时只需要启动一个Java进程,干净利落。

6.4 演示流程脚本

答辩或演示时,建议按这条流程走,逻辑最顺:

  1. 游客注册/登录,浏览民宿列表。
  2. 进入某民宿详情页,选择未来两天日期,展示可订房型。
  3. 选择房型,填写入住人信息,提交订单。
  4. 在"我的订单"里看到待支付订单,模拟支付后状态变为已支付。
  5. 管理员登录后台,看到这笔订单,将状态置为已入住。
  6. 前台办理退房,订单状态变为已退房。
  7. 在Dashboard里看到今日入住数、本月营业额的统计变化。

这套流程覆盖了全部核心功能,也能把状态机、库存判断、权限控制这些技术点讲清楚。

做完这个项目后,我最大的体会是:预约类系统的复杂度不在页面多不多、接口多不多,而在状态是否收敛、数据在并发下是否一致。状态机的设计与实现直接决定了系统上线后运维时会不会焦头烂额。如果你后续想在这个项目上继续扩展,我建议优先加两样:真实支付对接(学习回调验签和幂等处理),以及用Redis做热点房型的库存扣减(学习生产级并发控制)。把这两块吃透,这个项目从"课程设计"到"可上线的小系统"之间的距离就真正拉近了。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/11 6:14:57

Agent沙箱实战:从隔离原理到Daytona落地与选型

1. 为什么 Agent 需要一个"沙箱"而不是一台真机先把结论摆在前面&#xff1a;Agent 沙箱的本质&#xff0c;是给一个会自己写代码、自己执行命令的智能体&#xff0c;划出一块"随便折腾、炸了也不心疼"的隔离地盘。它不是虚拟机的新马甲&#xff0c;也不是…

作者头像 李华
网站建设 2026/10/11 6:12:56

企业级AI Agent治理框架:从公民开发到全生命周期管控

上个月跟几位做企业数字化的朋友碰头&#xff0c;一位信息化负责人讲了件特别典型的事&#xff1a;他们公司销售运营团队瞒着IT部门&#xff0c;在外部平台上一周内创建了十几个Agent&#xff0c;有的接上了内部知识库&#xff0c;有的绑定了客户订单查询权限&#xff0c;等信息…

作者头像 李华
网站建设 2026/10/11 6:11:08

宠物服务系统毕设实战:Spring Boot+Vue全栈开发排坑指南

毕设季又来了&#xff0c;每年这个时候都能在各类技术社区看到“求一个Spring BootVue毕设项目”“宠物服务系统怎么做”这类帖子。我自己也在带毕设的过程中反复讲过类似题目&#xff0c;说句实话&#xff0c;像“基于Spring BootVue的宠物服务系统”这种题&#xff0c;几乎是…

作者头像 李华
网站建设 2026/10/11 6:10:03

Solidity通用部署器:从EVM原理到CREATE2实战,部署任意合约

写Solidity写到后面&#xff0c;你会发现一个分水岭&#xff1a;新手在调函数&#xff0c;老手在设计合约之间的协作方式。尤其是当你开始做工厂、聚合层协议、多链部署这类事情&#xff0c;“部署”本身就不再是开发流程最后点一下按钮的动作&#xff0c;而是要写进合约逻辑里…

作者头像 李华
网站建设 2026/10/11 6:09:55

开源AI辅导老师DeepTutor部署指南:个性化学习助手搭建与优化

1. 为什么我要自己搭一个AI辅导老师市面上打着“AI学习助手”旗号的产品不少&#xff0c;但真正用起来你会发现几个绕不开的痛点&#xff1a;要么是按月订阅费用不低&#xff0c;要么是对话记录留在别人服务器上心里不踏实&#xff0c;要么是通用模型对学科知识的把握浮于表面&…

作者头像 李华
网站建设 2026/10/11 6:09:22

线程同步深度解析:条件变量与POSIX信号量核心原理及实战

1. 线程同步的下半场&#xff1a;为什么互斥锁不够用1.1 轮询加锁的最大问题不是性能很多新手写多线程代码&#xff0c;第一步能想到的永远是pthread_mutex_lock和pthread_mutex_unlock。互斥锁能保证临界区不被打断&#xff0c;这没错&#xff0c;可一旦遇到“某个条件满足后再…

作者头像 李华