前阵子刚折腾完一套基于SpringBoot+Vue的物品租赁系统管理系统,从需求分析到数据库建模,再到前后端联调、打包部署,整个流程走下来最大的感触是:租赁系统表面看就是个“带日期的商城”,真拆开做才发现比商城麻烦不少。物品在某个时间段内只能属于一个订单,订单要经历上架、下架、押金、租用中、归还、结算一大堆状态,每一环都要有校验。做毕业设计、求职简历项目或者公司内部的小租借工具,这套Java+MySQL+MyBatis+Vue的组合都很合适。这篇把完整设计思路和实操细节写出来,包括数据库怎么建模、状态机怎么设计、并发下单怎么防超租、MyBatis分页和缓存怎么用,以及部署时最容易踩的坑,给后面要做类似系统的人一个能直接对照的参考。
1. 物品租赁与电商的本质差异:订单生命周期和时间冲突
1.1 租赁订单的生命周期比买卖订单多出不止一步
传统电商订单通常就是下单、支付、发货、收货、完成,顶多加一个退款。但租赁订单不行,它多了一个“归还”环节,而且押金的收退和租金结算也完全不是一回事。
我第一版设计时想得很简单:把租赁订单当成“带租期字段的普通订单”,结果业务状态根本表达不清楚。比如物品已经寄给租客了,这笔订单是“已完成”吗?不行,租期还没到,物品还在别人手里,得有一个明确的“租用中”状态。租客申请归还之后,还要有核对环节——物品有没有损坏、有没有超期、押金扣多少,这些都发生在订单接近尾声的时候。
最后我把订单的生命周期定成这样:待支付押金 → 待发货 → 租用中 → 待确认归还 → 已完成;中间还有已取消、已关闭这些异常态,逾期这种特殊情况放在后文细说。这个状态机是整个系统的骨架,后面所有的接口、页面按钮、权限判断,全部围绕它展开。
1.2 时间冲突是租赁系统的第一约束
租赁和买卖最本质的区别在于:买卖商品可以卖完就没了,库存总量是静态的;租赁物品则强调“某个时间段是否可用”。同一台相机,今天被A租了,那今天就不能再租给B,哪怕B只租两个小时也不行,只要时间段有重叠就冲突。
这意味着每次提交租赁订单前,系统必须判断物品在目标租期内有没有其他有效订单占用。光靠Java代码里查列表再循环判断不够安全,必须把这种校验下推到数据库层面,用带时间条件的SQL去查重叠记录。这块SQL的写法其实不复杂,关键是索引和状态条件要设计对,后面章节会专门讲。
1.3 为什么是SpringBoot+Vue+MySQL+MyBatis这套组合
做这套系统前我也纠结过其他方案,但综合下来这套组合在学习和实际开发之间平衡得最好。
SpringBoot解决了配置地狱的问题,内嵌Tomcat、自动装配,一个main方法就能起服务,非常适合快速搭后台。MyBatis看起来比MyBatis-Plus麻烦,但胜在SQL完全可控,租赁系统里有大量自定义查询,比如时间重叠判断、多条件筛选、分组统计,自己写SQL反而心里有底。MySQL在这种单机业务量下性能完全够用,运维也简单。前端选Vue是因为组件化和开发效率都高,用户端和管理端界面可以抽出大量公共组件复用。
如果只为了快速交差,用MyBatis-Plus也行,但我建议学习项目还是手写SQL把原理吃透。这套技术栈招人面也广,写进简历里认可度高。
2. 数据库建模:把“可租状态”落到表和SQL索引上
2.1 四张核心表与一张流水表
我先明确一点:租赁系统不建议把押金记录和订单记录混在一张表里。押金有收有退,还可能存在扣除赔偿、部分退还的情况,单独一张流水表记录起来清晰得多,对账也方便。
核心表我设计成下面几张:
| 表名 | 作用 | 关键字段 |
|---|---|---|
| user | 用户表,租客和物品所有者共用 | id、username、password、nickname、phone、balance、status |
| item | 物品表,记录可租赁的货品 | id、user_id、name、description、category、daily_price、deposit、cover_img、status |
| rental_order | 租赁订单表,业务核心 | id、order_no、item_id、user_id、rent_start、rent_end、daily_price、deposit_amount、total_amount、status、create_time |
| pay_log | 押金与租金流水表 | id、order_id、type、amount、status、create_time |
物品表里的user_id表示物品的上架人,可以是C端用户上传的物品,也可以由管理员统一录入。status字段管理物品自身状态:0待上架、1已上架可租、2已出租、3下架。
订单表则必须冗余一份daily_price和deposit_amount,不能直接去join物品表拿当前价格。原因是物品价格可能在中途调整,但历史订单的租金和押金应当按下单时快照来结算,否则会出现租客下单时200一天,到期时价格改成300,结算金额跟着飘的严重问题。
2.2 订单状态机的取值约定
订单表里我用的status是int类型,取值约定如下:
| 值 | 含义 | 说明 |
|---|---|---|
| 0 | 待支付押金 | 下单成功但尚未支付,超时未付自动关闭 |
| 1 | 待发货 | 押金已付,等待物品所有者或管理员发货 |
| 2 | 租用中 | 租客确认收货,租期开始计算 |
| 3 | 待确认归还 | 租客申请归还,等待确认物品状态 |
| 4 | 已完成 | 归还确认、押金结算完毕 |
| 5 | 已取消 | 租客或系统取消的无效订单 |
为什么用int不用字符串?一是存储占用小、索引效率高,二是避免手写字符串状态时大小写拼写出错。页面展示状态名时,由前端定义映射关系,后端返回数字即可。
物品表和订单表是联动的:物品状态为1(可租)才能下单,下单支付押金后物品状态要改成2(已出租);订单回到已完成或已取消,物品状态再恢复成1。这个联动逻辑必须放在Service层事务里,前面加状态预检,后面更新状态,顺序不能乱。
2.3 时间冲突查询与索引设计
判断时间重叠的SQL是我认为整个系统里最值得抠的部分。逻辑上,两个租期冲突的条件是:新租期开始时间小于已有订单的结束时间,并且新租期结束时间大于已有订单的开始时间。也就是“新开始 < 旧结束 且 新结束 > 旧开始”。
对应查询大概是这样的:
SELECT id FROM rental_order WHERE item_id = #{itemId} AND status IN (1, 2, 3) AND rent_start < #{newEnd} AND rent_end > #{newStart} LIMIT 1注意status IN只包含那些仍然占用物品时间段的订单:待发货、租用中、待确认归还。已完成和已取消都不占用,必须排除。
索引我建的是(id, status, rent_start, rent_end)联合索引,查询时能通过item_id快速锁定物品,再用status过滤状态,最后在时间范围内筛选。如果表数据量大了,这个查询也可以改成用租期结束时间做倒序,先查最近要归还的订单。
一个小坑:如果订单要修改租期,比如租客申请续租,那么做冲突校验时要记得排除当前订单自己,否则会出现“修改后和自己的原租期冲突”的假阳性。我之前就漏掉过这一步,测试续租功能时排查了半天。
2.4 金额类型与默认值细节
金额字段统一用DECIMAL(10,2),不要用FLOAT或DOUBLE。金额计算要求精确,浮点数会有精度误差,这在租金结算、押金退还时是不能接受的。
建表时几个容易被忽略的默认值设置:状态字段一般要DEFAULT 0,create_time直接DEFAULT CURRENT_TIMESTAMP,update_time建议设置成DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,省去每次手动更新时间的代码。
MySQL用户创建表的时候,要注意把字符集设置为utf8mb4,排序规则用utf8mb4_general_ci,否则用户昵称里存个特殊字符或者表情,插入时直接报错。这个坑几乎每个人都会踩一次。
3. 后端实现:SpringBoot+MyBatis的交易链路与并发控制
3.1 分层结构与核心接口设计
后端我按标准的Controller-Service-Mapper三层来组织,entity对应表结构,dto接收前端参数,vo返回给前端展示。结构大概这样:
com.example.rental ├── controller // 接口层,只做参数接收和响应封装 ├── service // 业务层,状态机和事务都在这层 ├── mapper // MyBatis数据访问层 ├── entity // 数据库实体 ├── dto // 请求参数对象 └── vo // 视图返回对象核心接口大致分三类:物品管理(发布、上下架、分页查询、详情)、下单流程(提交订单、支付押金、发货、确认收货、申请归还、确认归还、取消订单)、个人中心(我的订单列表、我的发布、流水记录)。
以提交租赁订单为例,Controller只接收itemId、rentStart、rentEnd这些参数,组装成dto后交给Service。Service内部要做的事很多:判断物品是否存在且上架、校验租期合法性、查时间冲突、计算金额、创建订单、修改物品状态。这一串操作必须在一个事务里,任何一个环节失败都要全部回滚。
3.2 下单+支付押金的事务边界
我把“提交订单”和“支付押金”拆成两个接口,而不是一个接口同时完成,原因是现实中允许用户先下单、稍后再付款,而且超时未支付要自动关单。但从事务设计角度看,创建订单时必须同时锁定物品资源,否则会出现A创建了订单还没付款,B也创建了订单,两个订单指向同一个物品的同一段租期。
我的做法是:创建订单这一步就把物品状态从“1可租”改成“2已出租”,同时生成状态为“0待支付押金”的订单。如果用户最终不付款导致订单超时关闭,再把物品状态改回去。这样设计的好处是资源占用的时间点非常明确,并发环境下不容易超租。
伪代码如下:
@Transactional(rollbackFor = Exception.class) public Long createOrder(CreateOrderDTO dto) { Item item = itemMapper.selectForUpdate(dto.getItemId()); if (item == null || item.getStatus() != 1) { throw new BizException("物品不可租"); } // 时间冲突校验 int conflict = orderMapper.countConflictOrder(dto.getItemId(), dto.getRentStart(), dto.getRentEnd()); if (conflict > 0) { throw new BizException("该时段已被预订"); } // 创建订单,状态为待支付押金 RentalOrder order = buildOrder(item, dto); orderMapper.insert(order); // 锁定物品 itemMapper.updateStatus(item.getId(), 1, 2); return order.getId(); }selectForUpdate是悲观锁,直接锁住物品行,事务提交后释放。这种方案在物品数量不大、并发不高的场景下完全够用,也能保证判断状态到修改状态之间不会插入其他事务。
3.3 用行锁和状态条件解决“同时下单”问题
上面代码里我用了SELECT ... FOR UPDATE,这是悲观锁方案。后来我优化过一版,改成纯乐观的原子更新,减少锁等待的时间。核心思路是利用SQL中的条件更新:
UPDATE item SET status = 2 WHERE id = #{itemId} AND status = 1执行这条UPDATE后,如果影响行数是1,说明抢锁成功,可以继续创建订单;影响行数是0,说明物品已经被别人租走了,直接返回“物品已下架或者已被预订”。
这种方式的妙处在于MySQL的UPDATE本身就是行级锁加原子操作,判断和修改一步完成,不需要先SELECT再UPDATE,也省去了显式加锁带来的死锁风险。在实际项目中,我推荐优先用这个方案。如果还要更稳妥,可以对(item_id, rent_start, rent_end)设计防重约束,双保险。
3.4 MyBatis缓存、分页与动态SQL
很多教程上来就开MyBatis二级缓存,但在租赁系统这种状态频繁变化的场景里,我劝你谨慎。一级缓存是SqlSession级别的,一次请求默认开启,基本不会有问题;二级缓存是namespace级别的,如果开启了,订单状态更新后,缓存里的旧状态可能还没失效,导致查询到过期的物品状态。尤其是物品表status字段变化极多,开二级缓存等于给自己埋雷。
分页直接用PageHelper插件,用法很简单:
PageHelper.startPage(pageNum, pageSize); List<ItemVO> list = itemMapper.queryPage(condition); PageInfo<ItemVO> pageInfo = new PageInfo<>(list);注意PageHelper.startPage后面要紧接着执行Mapper查询,中间不能穿插其他查询语句,否则分页会作用到错误的SQL上。这是官方文档写了但很多人不看的地方。
动态SQL主要用在物品列表的多条件筛选上,按分类、按价格区间、按名称模糊查询都是可选条件,用 标签拼装即可。这里有一个被问过很多次的坑:在XML里写时间比较的小于号,比如rent_start < #{newEnd},直接写<号会报XML解析错误,要写成<或者整个包进 里。
3.5 数据一致性的兜底:状态机校验与幂等
Java后端保证数据一致性,不能只靠数据库事务,业务层也要做状态机校验。我的经验是:每个状态流转接口进入Service时,先根据orderId查出当前订单,然后判断当前状态是否等于“允许进入下一步的前置状态”。比如确认发货接口,前置状态必须是1待发货;如果订单已经是2租用中,理论上这个接口就不该被调用成功。
if (order.getStatus() != RentalOrderStatus.WAIT_SEND) { throw new BizException("当前订单状态不允许发货"); }这套校验能挡住绝大多数重复请求和乱序请求。押金退还也要做幂等:退款接口先查pay_log里这笔订单是否已经存在成功的退款流水,存在就直接返回成功,或者用数据库唯一约束挡住重复退款记录,否则网络重试会导致租客收到两次退款。
@Transactional还有一个隐蔽坑:同一个类内部方法调用,比如Service里面A方法调用同类B方法,B上的@Transactional是不生效的,因为Spring事务是基于代理实现的。要拆到不同Bean里,或者自己注入自己再调用,否则你以为有事务,实际每一条SQL都是自动提交,出了异常数据对不上账。
4. 前端Vue页面组织:路由、组件与订单状态的联动
4.1 目录结构与页面清单
前端用Vue 2加Element UI,虽然Vue 3已经普及,但这套组合资料多、坑少,做这类管理系统足够稳。目录结构大致如下:
src ├── api // axios请求封装 ├── assets ├── components // 公共组件 ├── router // 路由配置 ├── store // Vuex状态管理 ├── views │ ├── admin // 管理端页面 │ └── user // 用户端页面 └── App.vue用户端页面包括首页物品列表、物品详情、下单页、我的订单、订单详情、个人中心;管理端包括物品管理、订单管理、用户管理、数据概览。两类端共用一套后端接口,只是页面结构和权限不同。
组件拆分的思路是:物品卡片ItemCard、订单状态标签OrderStatusTag、日期范围选择器RentDatePicker这些高频出现的区块单独抽成组件,详情页和列表页都能复用。
4.2 路由参数与动态路由的复用陷阱
物品详情页和订单详情页都属于动态路由,路径像/item/:id和/order/:id。接收参数时用this.$route.params.id获取,这本身没什么难度,真正容易踩坑的是组件复用问题。
Vue Router在切换同一个路由时,默认会复用组件实例。什么意思?你在物品列表页点A物品进详情页,返回列表再点B物品,详情页组件不会重新创建,created钩子不会再次触发,页面显示的很可能还是A物品的数据,只是路由参数变成了B的id。
解决办法有两种:一种是在组件里watch $route变化,参数变了重新拉数据;另一种更简单,在router-view上加一个key:
<router-view :key="$route.fullPath"></router-view>强制路由变化时重建组件。我个人倾向于第二种,代码改动少,理解和维护都容易。路由守卫也要注意:我的订单页面需要登录才能访问,在router.beforeEach里判断Vuex中是否存在token,没有就跳登录页。
4.3 日期选择与租金试算
下单页是整个前端的核心交互,用户要选租期,看到租金和押金的实时试算。Element UI的日期选择器要配置两点:一是禁选过去的日期,二是结束日期必须晚于开始日期。
<el-date-picker v-model="rentRange" type="datetimerange" :picker-options="pickerOptions" @change="calcAmount"> </el-date-picker>pickerOptions里面的disabledDate函数把今天之前的日期禁用掉。租金试算就是拿两个时间戳的差值除以一天的毫秒数,向上取整得到天数,再乘日租金加押金。
这里要提一个前后端很容易不一致的细节:后端按自然日计算租期还是按24小时计算租期,必须提前商量好。如果后端按自然日,前端试算也要按自然日;如果两端算法不一致,用户看到的试算金额和最终扣款金额对不上,就等着挨投诉吧。
前端校验不能只限日期,还要在提交前判断所选日期类型能不能覆盖后端要求的时间精度。比如后端字段是datetime,前端传的是date,那“当天租当天还”可能因为少了时间部分被后端判定为非法时间段。
4.4 axios封装与登录态处理
axios请求我统一封了一层,主要做两件事:请求拦截器里把token塞进header,响应拦截器里处理统一错误码。
service.interceptors.request.use(config => { config.headers['token'] = getToken(); return config; }); service.interceptors.response.use(response => { if (response.data.code === 401) { router.push('/login'); return Promise.reject('未登录'); } return response.data; });401统一跳登录,前端就不用每个请求单独处理登录失效了。订单状态标签我封装成OrderStatusTag组件,传入订单状态的数字,组件内部根据映射关系渲染对应的颜色和文字,这样订单列表和订单详情都能用同一套展示规则,避免不同页面写出的状态文案不一致。
两个页面对同样状态的文案都有过对不上的问题,后来统一收敛到一个filter文件里,所有页面从这个文件取值,彻底治好强迫症。
5. 从本机跑通到打包部署:实操记录与避坑复盘
5.1 本地环境准备与数据库导入
整套系统的本地运行环境:JDK 8+、Maven 3.6+、MySQL 8.0、Node 14+。MySQL安装时最容易出的问题是安装完成后命令行连不上,多半是服务没启动或者root密码策略太复杂。建议安装时选择“使用传统加密方式”而不是默认的强密码插件,否则老版本的JDBC驱动连库会报错。
数据库初始化我准备了一个db.sql脚本,里面包含建库、建表和基础测试数据。导入直接用Navicat右键运行SQL文件就行。建库时要注意两点:字符集选utf8mb4,排序规则选utf8mb4_general_ci,我已经在前面强调过,这里再说一次,因为太常见了。
5.2 后端配置与前端代理
后端application.yml里最关键的是数据源配置:
spring: datasource: url: jdbc:mysql://localhost:3306/rental?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: 你的密码 driver-class-name: com.mysql.cj.jdbc.DriverserverTimezone必须指定,否则MySQL 8下很容易报时区错误。MyBatis侧开启驼峰映射:
mybatis: configuration: map-underscore-to-camel-case: true不然数据库字段create_time映射到实体createTime直接是null,排查起来还以为是数据没插进去。
前端的跨域问题分开发环境两个思路。开发环境直接用Vue CLI的proxy代理,vue.config.js里把/api开头的请求转发到后端,浏览器无感知,也不用后端配置CORS:
module.exports = { devServer: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } } };生产环境用Nginx转发,同样只暴露前端域名,所有/api请求都反代到后端服务。
5.3 打包构建与Nginx部署
后端打包很简单:
mvn clean package -DskipTests java -jar target/rental-0.0.1-SNAPSHOT.jar前端构建:
npm run build构建产物是dist目录,把它丢到Nginx的html目录,再配置一个server块。这里必须注意:如果前端使用了history路由模式,刷新页面会出现404,Nginx要加try_files配置:
location / { root /usr/share/nginx/html; index index.html; try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }try_files是很多Vue项目部署后刷新白屏的根源,100个人里有90个栽在这里。
5.4 实测中遇到的四个坑
第一个坑是前端传时间少了几秒。租期结束时间选择的是当天23:59:59,但前端date-picker只传日期不传时间,到了后端变成当天00:00:00,导致最后一天实际没被计算进去。方案是把时间值统一定义成字符串格式,后端用LocalDateTime解析前先补齐时间部分。
第二个坑是MyBatis返回Map时create_time字段变null。如果查询结果的接收对象是Map而不是实体类,驼峰自动映射失效,必须用别名或者resultMap显式指定。我后来把所有涉及时间的展示全部改用了实体VO接收,属性映射有保证。
第三个坑是同一物品重复上架后,物品ID和时间冲突判断把已取消的订单也算进去了。加状态过滤前,被取消的订单依然占用着时间段,新订单永远创建不了。这个问题排查了挺久,最后在SQL里补了status IN条件解决。
第四个坑是端口占用。后端8080端口被占用导致启动失败,直接换端口或者杀掉占用进程即可,但要注意后端端口一变,前端proxy和Nginx转发配置都要同步改,别只改一处。
这些坑没有一条是算法级别的难题,全部是细节问题,但任何一个都能让系统跑不起来或者账目对不上。做项目最花时间的其实不是写代码,是排这些细节。
最后分享一点个人体会:做这类管理系统,数据库设计和状态机约定才是重头戏,页面代码反而是体力活。动手写接口前,先把状态流转表研究透、把每张表每个字段默认值定好,后面写代码会顺畅很多。如果后续想往商用方向扩展,还可以加站内提醒、租凭日历、信用积分、消息通知这些模块,核心的表结构不用大改,扩展性是够的。