去年帮一家户外装备租赁工作室梳理业务时,我看到的景象是这样的:三十几顶帐篷、七十多个睡袋分散在三处存放点,预约记录在微信聊天记录和Excel里来回倒,周末档期靠人脑判断,押金收了多少、退还时扣多少全凭店主记忆。每到周五下午,店员都在翻手机,就为了回答一句“周末还有没有双人帐”。聊了两次之后,方向就定了——把整套预约、出库、归还、结算动作搬上线,做一个基于Spring Boot的露营活动装备租赁系统。这篇文章是我对这个项目从调研、设计到编码落地的完整复盘,包括业务痛点怎么转成需求、数据库状态机怎么建、库存与档期冲突怎么解决、超期罚金怎么算,以及开发过程中真实踩过的坑。准备做Java Web方向毕业设计的朋友,或者想了解一个共享租赁业务如何落地成系统的开发者,这篇可以直接拿去当参照。
1. 选题动机与业务边界:露营租赁乱在哪、系统管到哪
1.1 我看到的真实租赁痛点
户外装备租赁看起来是个简单的“借东西收钱”的生意,但真正跑起来之后,痛点非常具体:
库存黑盒:装备分散在多个库房,谁取走了什么、现在哪些在外面,没有一个实时事实。店员问一圈才能回答“有没有货”。
档期冲突:同一天同一顶帐篷被两拨人订走的情况不是笑话,而是聊天记录预约制下的正常事故。客户到了店发现没装备,体验直接崩。
押金与账目混乱:押金有的收现金,有的微信转账,退还时扣不扣折旧全凭记忆,月结对账时经常差几百块。
逾期无感知:装备超期还在外头,店方不知道,等客户主动还才反应过来,装备周转率白白损失。
这些痛点翻译成系统需求就非常清晰:装备要有唯一档案,订单要有完整的生命周期状态,库存档期要被程序校验,押金和租金要自动算,超期订单要被定时任务捞出来。这正是Spring Boot露营装备租赁系统要解决的核心问题。
1.2 边界感:这个系统刻意不做的事
做毕业设计最常见的毛病是“什么功能都想加”。我在这个项目里刻意砍了不少东西,砍掉的原因也很明确:
不做物流配送:业务以自取自还为主,引入物流会让订单状态复杂一个量级,对核心演示价值不大。
不做设备定位追踪:给每件装备挂定位模块成本高且涉及隐私,现阶段用“订单状态 + 逾期标记”足以表达装备去向。
不做维修保养排期管理:装备损坏需要维修,这个需求确实存在,但我只用装备实例上的“状态字段”来表达“维护中”,不单独做一套工单系统。
不做完整财务ERP:只做订单级流水,算清楚每一单的租金、押金、罚金就够了,不做报销、固定资产折旧这些重功能。
边界感的核心逻辑是:一个初期系统或者毕业设计,最重要的是把主链路做深做扎实,而不是把十个功能做到半吊子。预约、出库、归还、结算这四件事能闭环,系统的价值就已经立住了。
1.3 为什么选中Spring Boot这一套组合
技术选型我直奔主流,没有用冷门框架,原因很简单:毕业设计答辩和后续面试,技术上被考官追问的概率越低越好。最终定的技术栈如下:
| 组件 | 用途 | 选择理由 |
|---|---|---|
| Spring Boot 2.7 + JDK 1.8 | 后端基础框架 | 生态成熟,资料多,稳定 |
| MySQL 8.0 | 数据存储 | 业务数据关系明确 |
| MyBatis-Plus 3.5.x | ORM | CRUD快,减少重复SQL |
| Redis | 库存锁与热点缓存 | 并发预约时兜底 |
| JWT | 登录认证 | 前后端分离场景方便 |
| Vue3 + Element Plus | 管理端界面 | 组件完善,演示效果好 |
| Maven | 构建 | 团队标准配置 |
为什么必须加Redis?很多同学觉得租赁系统并发不高,不加缓存也能跑。但“预约锁定”这个动作天然存在并发问题,两个用户同时提交同一件装备的预约单,如果没有锁机制,数据库层面就会出现两条重叠订单。Redis的setIfAbsent做分布式锁很简单,在演示和真实部署里都说得通,这是我把Redis放进来的主要原因。Spring Boot项目里集成Redis其实也就几行配置,但对系统健壮性的提升非常明显。
2. 需求分析与模块划分:把“线下台账”翻译成“线上状态机”
2.1 角色与权限:两种用户就够了
系统把用户分成两类,权限模型很简单:
普通用户:注册登录后可浏览装备详情、查看装备可租档期、提交预约订单、支付押金与租金、发起归还申请、查看自己的订单记录。
管理员:维护装备档案、审核预约、确认出库、登记归还验收、调整押金与租金规则、查看统计报表。
实现方式没有引入Spring Security那套复杂配置,而是基于用户表的role字段加一个登录拦截器。拦截器负责解析JWT、取出角色,再判断当前请求路径是否匹配管理接口的前缀。这样做的好处是思路清晰,答辩时也能解释明白:权限的本质就是“路由对不对得上角色”。
2.2 五大功能模块拆解
| 模块 | 核心功能 | 关键点 |
|---|---|---|
| 装备管理 | 分类维护、型号维护、实例维护 | 每个实物装备都有唯一编号 |
| 预约订单 | 日期选择、档期校验、库存锁、订单创建 | 下单必须过锁和冲突校验 |
| 流转管理 | 出库确认、归还登记、逾期标记 | 每次流转都留记录 |
| 结算中心 | 租金计算、押金收取与退还、罚金核算 | 金额全部用BigDecimal |
| 统计报表 | 装备利用率、订单量、收入汇总 | 为运营决策提供依据 |
装备管理模块里的“分类、型号、实例”三层结构是这个项目的关键。分类是“帐篷 / 睡袋 / 炉具 / 照明”这种大方向,型号是“双人四季帐”、“三人自动帐”这种具体产品定义,实例才是一顶具体的帐篷,贴上编号,能被租出去。下面的数据库设计章节我会详细展开。
2.3 核心业务流:从浏览到退押金的完整闭环
整个系统的主流程我画成了一条业务链,文字描述如下:
用户浏览装备列表,进入详情页查看规格、日租金、押金、当前是否可租。
用户选择取货日期和归还日期,系统实时发起档期冲突校验。
校验通过后创建订单,同时锁定对应装备实例的档期,用户需要在规定时间内完成支付。
支付环节同时收取押金和首日租金(为简化流程,押金随首笔支付一起收,退款时走线下或原路返还)。
管理员在后台看到新订单,确认装备可用后操作“出库”,订单状态变为租赁中。
用户使用装备,到计划归还日发起归还申请,线下把装备送回仓库。
管理员验收装备,确认无损坏后操作“验收通过”,系统自动结算总租金、抵扣已收费用、退还押金余款,订单归档。
如果用户超过应还日期仍未归还,定时任务每天扫描,将相关订单标记为逾期并累计罚金。
这套流程里我最看重的是第七步和第八步。租金不是创建订单时一次性算死的,因为用户可能提前还、逾期还,或者中途续租,只有归还登记时计算出来的金额才是最终金额。所以订单里的rent_amount字段存的是“按原计划天数的预估租金”,结算时才更新为实际租金。
3. 数据库设计与状态机建模:库存不只是一个数字
3.1 装备建模:型号与实物实例必须分开
很多同学设计租借系统时会直接把装备表设计成一张“装备表 + 库存数量”,比如“双人帐篷 库存10顶”。这种设计有个天然缺陷:无法追踪具体是哪顶帐篷在谁手里,损坏、维修、超期都说不清楚。而且订单表里如果只存“型号和数量”,归还时也不知道用户还回来的具体是哪一件。
我的设计是三层:
equipment_category:分类表,字段就是id, name, sort_order, create_time。equipment_model:型号表,记录分类、名称、日租金、押金、图片URL、规格描述。这里的租金和押金是基准价。equipment_item:装备实例表,一张具体装备一张卡,记录型号ID、设备编号、当前状态、入库时间。状态有AVAILABLE(可租)、RENTED(已出租)、MAINTAINING(维护中)、RETIRED(报废)。
equipment_item表是核心,它承担着“这件装备现在在哪”的追踪责任。订单表里的外键直接指向equipment_item.id,而不是指向型号。这样设计的连锁好处是:库存扣减不再是一个计数器加减,而是状态的流转,每一件装备的履历都可以查。
3.2 订单状态机:用七种状态覆盖整个租赁周期
状态机是这个系统最容易讲清楚、也最容易在答辩时被追问的设计点。订单状态我用了一个字段status,取值和含义如下:
| 状态编码 | 含义 | 触发动作 |
|---|---|---|
| PENDING_PAYMENT | 待支付 | 订单创建后、用户尚未支付 |
| PAID | 已预约 | 支付成功后,库存已被锁定 |
| PICKED_UP | 租赁中 | 管理员确认出库 |
| OVERDUE | 已逾期 | 定时任务扫描发现超期 |
| RETURNED | 已归还待结算 | 管理员登记归还 |
| COMPLETED | 已完成 | 费用结清、押金退还 |
| CANCELED | 已取消 | 用户取消或超时未支付自动取消 |
我强烈建议不要用多个布尔字段来表示订单状态,比如is_paid,is_picked_up,is_returned,那样会出现大量非法组合,维护起来非常头疼。单字段状态机配合状态流转表,逻辑清晰,而且能方便地写定时任务去扫描特定状态。
当然,单纯一个status字段还不能完整描述时间线,所以订单表里还保留了create_time,pay_time,pick_up_time,return_time,finish_time这几个时间字段。更严格的审计可以再拆一张order_log流水表,记录每一步是谁在什么时间把状态改成了什么,这个扩展我会写在后面的坑里。
3.3 档期冲突校验的SQL设计
租赁库存和电商库存最大的区别是“时间维度”。电商卖出去一件就少一件,租赁则是“同一件装备,6月1日到6月3日被占用,6月5日之后还能再借”。所以库存校验的核心在档期重叠判断。
我用的核心SQL如下,它的是整个订单创建逻辑的数据库兜底:
SELECT COUNT(*) FROM rent_order WHERE item_id = #{itemId} AND status IN ('PENDING_PAYMENT', 'PAID', 'PICKED_UP', 'OVERDUE') AND start_date <= #{endDate} AND end_date >= #{startDate}先解释这段SQL的每一个条件:
item_id定位到具体一件装备;status IN (...)排除已取消和已完成的订单,因为只有占用档期的订单才参与冲突判断;start_date <= #{endDate} AND end_date >= #{startDate}用来判断两个时间段是否重叠,这是经典的重叠区间判断:只要新订单开始时间不晚于旧订单结束时间,且新订单结束时间不早于旧订单开始时间,就说明有重叠。
用例子验证:订单A租期是6月1日到6月3日,新用户想租6月3日到6月5日,代入条件:A的start_date是1号小于新end_date5号,A的end_date是3号大于等于新start_date3号,结果查询出来有重叠。那是否允许还当天再租,就是一个业务规则问题了。如果系统规定必须中午12点后归还、当天可以再出库,那可以接受重叠;如果严格不允许,后面的章节我会讲讲怎么改这个边界条件。
4. 核心业务代码落地:锁库存、算租金、扫超期
4.1 预约下单:库存锁 + 事务双重保障
预约下单是整个系统技术含量最高的地方,因为它要同时解决并发和事务两个问题。先看代码,再逐步解释。
@Override @Transactional(rollbackFor = Exception.class) public Long createOrder(CreateOrderDTO dto, Long userId) { // 1. 构造装备档期维度的锁key String lockKey = "rent:lock:item:" + dto.getItemId() + ":" + dto.getStartDate() + ":" + dto.getEndDate(); // 2. 使用Redis setIfAbsent尝试加锁,同时设置5分钟过期防止死锁 boolean locked = redisTemplate.opsForValue() .setIfAbsent(lockKey, userId.toString(), Duration.ofMinutes(5)); if (!locked) { throw new BizException("该装备在所选档期内正在被其他人预订,请刷新重试"); } try { // 3. 数据库再次校验档期冲突,防止Redis锁失效后穿透 Long conflictCount = orderMapper.countConflict( dto.getItemId(), dto.getStartDate(), dto.getEndDate()); if (conflictCount > 0) { throw new BizException("所选档期已被占用,请更换日期"); } // 4. 查询装备实例,确认它的状态是可租 EquipmentItem item = equipmentItemMapper.selectById(dto.getItemId()); if (item == null || !"AVAILABLE".equals(item.getStatus())) { throw new BizException("该装备当前不可预订"); } // 5. 创建订单,计算预估租金 Order order = new Order(); order.setOrderNo(generateOrderNo()); order.setUserId(userId); order.setItemId(dto.getItemId()); order.setStartDate(dto.getStartDate()); order.setEndDate(dto.getEndDate()); order.setRentAmount(computeRentAmount(item, dto.getStartDate(), dto.getEndDate())); order.setDepositAmount(item.getDepositAmount()); order.setStatus("PENDING_PAYMENT"); orderMapper.insert(order); return order.getId(); } finally { // 6. 释放锁 redisTemplate.delete(lockKey); } }这段代码有几个细节值得展开讲:
Redis锁的key为什么包含日期?因为一个装备不同时间的档期是不同的可用资源,锁必须粒度到“装备实例 + 起始日期 + 结束日期”。如果只锁装备ID,会导致不同时间段的下单互相阻塞,看起来没问题,实际业务上会被同行吐槽。
Redis锁和数据库校验为什么都要?Redis锁防的是两个人同时进入校验和下单代码段;数据库校验兜底的是Redis锁如果异常过期、或者后面代码部署成多实例时锁失效的情况。两道关同时过,才能保证系统在演示时按下两次提交按钮也不会产生脏数据。
@Transactional的作用范围:整个方法都在事务里,中间任何一个校验失败抛出异常,已经插入的订单数据就会回滚。这里特别注意,Redis锁的释放放在finally里,不在事务回滚的范围内。
4.2 租金与押金计算:用BigDecimal别用double
租金计算看似简单,实际是精度问题的重灾区。我先说反例:用double存金额,算完再转字符串,大概率遇到浮点数精度问题,比如日租金58.5元租3天,double计算结果可能变成175.4999999。所以在项目里,所有金额字段都定义为DECIMAL(10,2),Java代码里统一用BigDecimal。
private BigDecimal computeRentAmount(EquipmentItem item, LocalDate startDate, LocalDate endDate) { // 归还当天算作一天,所以天数 = 结束日期 - 开始日期 + 1 long days = ChronoUnit.DAYS.between(startDate, endDate) + 1; BigDecimal dayRate = item.getDayRentAmount(); return dayRate.multiply(BigDecimal.valueOf(days)); }这里有几个约定要提前想清楚:租赁的最小时长是一天,取货日算第一天,归还日算最后一天;用户如果当天借当天还,也要按一天计费,这个规则在需求阶段就要跟业务方对齐,否则结算时会有争议。
押金的逻辑相对独立。我采用“下单时和首日租金一起收押金,归还验收通过后原路退还”的简化流程,所以创建订单时就把押金金额冗余存进了订单表,防止后面装备单价调整导致历史订单押金对不上。这个设计叫“快照”,在电商和租赁里都很常见——历史订单必须保留成交那一刻的金额,不能用当前价格去算旧订单。
4.3 超期罚金:定时扫描与实际归还时的二次核算
逾期处理有两个动作:定时任务把超期订单标记为逾期,归还时重新核算最终罚金金额。两者结合才能形成闭环。
定时任务我用Spring的@Scheduled,每天凌晨1点30分执行一次:
@Component public class OverdueScanTask { @Scheduled(cron = "0 30 1 * * ?") public void scanOverdueOrders() { List<Order> orders = orderMapper.selectOverdueOrders(new Date()); for (Order order : orders) { order.setStatus("OVERDUE"); // 按日租金50%计算罚金,先估算一个值便于列表展示 BigDecimal overDays = BigDecimal.valueOf( ChronoUnit.DAYS.between(order.getEndDate(), LocalDate.now())); BigDecimal penalty = order.getDayRentAmount() .multiply(new BigDecimal("0.5")) .multiply(overDays); order.setPenaltyAmount(penalty); orderMapper.updateById(order); } } }这里重点在于,定时任务算出的罚金是“截至目前”的估算值,用户真正归还时,还需要根据实际归还日期重新计算一次。所以归还接口里有一段类似逻辑,但用的是实际归还日期:
@Transactional(rollbackFor = Exception.class) public void returnItem(Long orderId) { Order order = orderMapper.selectById(orderId); // 计算实际租用天数:从start_date到实际归还日 LocalDate actualReturnDate = LocalDate.now(); long rentDays = ChronoUnit.DAYS.between(order.getStartDate(), actualReturnDate) + 1; // 实际租金 BigDecimal actualRent = order.getDayRentAmount() .multiply(BigDecimal.valueOf(rentDays)); // 如果存在逾期,重新计算罚金 if (actualReturnDate.isAfter(order.getEndDate())) { long overDays = ChronoUnit.DAYS.between(order.getEndDate(), actualReturnDate); BigDecimal penalty = order.getDayRentAmount() .multiply(new BigDecimal("0.5")) .multiply(BigDecimal.valueOf(overDays)); order.setPenaltyAmount(penalty); } order.setActualRentAmount(actualRent); order.setStatus("RETURNED"); orderMapper.updateById(order); // 退还押金逻辑:实际租金 + 罚金 从押金中扣除,剩余部分退回 }这套“定时任务估算 + 实际归还核算”的设计我建议在答辩时主动讲一讲,它体现的是对真实业务的理解:定时任务不等于最终账单,一切以实际发生为准。很多演示环境里,逾期订单的罚金从创建订单到归还都没变化,问到细节就露馅了。
5. 开发过程中踩过的坑与答辩准备
5.1 并发重复预订:测试按下两次提交按钮就露馅
做好的第一个版本,我在测试环境里做“快速连续点击提交”测试,结果数据库里出现了两条重叠订单。排查下来原因很典型:Controller层的接口没有做防重,两个请求一前一后都通过了start_date <= endDate AND end_date >= startDate这个校验,因为前一个请求的事务还没提交,后一个请求查不到未提交的数据,于是一前一后都把SQL校验“骗”过去了。
这个问题的本质是“检查再插入”模式在并发场景下的固有问题。解决办法就是我上面说的Redis锁:让同一装备同一档期的下单动作串行化,第一个请求释放锁之后,第二个请求才能进入校验逻辑,此时第一条事务已提交,数据库校验自然能拦住。
我还在订单创建接口上加了一个小技巧:前端提交按钮在收到响应前置为loading并禁用,双重防止重复提交。虽然这不是数据库层面的保护,但对演示体验帮助很大——用户狂点按钮也不怕出脏数据。
5.2 档期重叠SQL的边界条件
我在测试时发现一个有意思的场景:租期6月1日到6月3日的订单,另一个用户想租6月3日到6月5日,系统默认可接受,因为归还当天用户会来店归还,中午验收后装备可以继续出库。但如果业务方要求“严格不可重叠”,那end_date >= #{startDate}这个条件就要改成end_date > #{startDate},同时需要处理归还当天的时间窗口问题,不能简单一改了之。
这个边界条件在真实租赁系统里必须和业务确认清楚。我在项目里设计了两个参数:allow_same_day_handover(是否允许归还当天再次出库),放在系统配置表里,校验SQL根据这个参数动态拼接条件。这样既灵活又不增加太多复杂度。
5.3 图片上传后部署就丢文件
装备列表不能没有图片,所以我把图片上传做了。最开始图省事,直接把文件写到本地的D:/upload目录,数据库里存了完整路径。本地测试一切正常,部署到服务器后图片全部404——因为服务器上根本没有这个目录。
后来我把方案改成了两步。第一步是本地存储路径外置到配置文件:
upload: dir: ${UPLOAD_DIR:./data/upload}默认是当前用户目录下,可以用环境变量覆盖,部署时指定服务器上的实际路径。第二步是增加一个简单的文件访问映射,用本地目录映射到/files/**这个URL前缀。同时我也把MinIO集成的示例代码写好放在项目里作为扩展方案,如果上生产环境,建议直接换对象存储,本地磁盘方案只适合演示和小规模使用。
这个坑的教训是:任何涉及文件路径的代码,都不能写死绝对路径。
5.4 答辩演示环境最容易被问住的三个问题
演示环节翻车往往不是因为代码写错了,而是环境细节没准备好。我根据实际经验列出最容易翻车的三个问题:
问题一:数据库里是空表,找不到数据演示。解决办法是提前写好种子数据的SQL脚本,装备分类、型号、实例各准备十几条,订单也准备两三条历史记录。演示时先展示列表页,再走一遍完整的新增订单流程,比从空表开始展示有说服力得多。
问题二:中文乱码。数据库连接串一定要加characterEncoding=utf8,建表时用utf8mb4字符集。这个问题在本地可能不出现,换一台机器就连不上或乱码,答辩前一定要确认。
问题三:被问到“怎么证明你的系统不会重复下单”。这个问题最值得提前准备一个演示脚本:用同一个浏览器开两个登录身份,对同一件装备同一个档期同时提交订单,展示只有一个成功、另一个被提示“档期已被占用”。这一手演示比口头解释一百句都有用,也能证明你是真的理解并发问题,而不是只会跑通CRUD。
关于演示环境,我的建议是本地跑通后用同一套数据库和Redis配置在服务器上再做一次部署验证,至少保证在答辩室的网络环境下能正常打开页面。如果要演示扫码支付,提前准备好沙箱环境的测试账号,避免现场因为账户校验失败卡住。
写在最后
整套做下来,我最大的体会是:租赁系统的难点不在于CRUD写得多熟练,而在于把“库存、档期、押金、罚金”这几个环节想明白。很多同学做类似系统时喜欢把精力花在页面漂亮不漂亮、字段多不多上,但评审老师真正追问的往往是“用户逾期了怎么办”“押金怎么退”“两个人同时订同一件装备会不会冲突”。这些脏场景才是系统设计的试金石。如果你正在做一个Spring Boot方向的租赁类毕业设计,我的建议很直接:先把状态机走顺,再把并发校验和异常流程补上,最后再管界面。这套顺序下来,项目既经得起问,也拿得出手。