尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

SpringBoot2+Vue3+MyBatis-Plus物品租赁系统实战

SpringBoot2+Vue3+MyBatis-Plus物品租赁系统实战 物品租赁系统这种项目我大概是从一个很具体的需求开始入手的公司内部有一批设备、工具、会议室配件要借给同事用之前全靠Excel登记借没借、谁借的、什么时候还全靠一张表时间长了不是漏记就是超期没人管。后来我想干脆做一个在线租赁系统从需求梳理到审码再到部署最后整套源码搭完选的组合就是SpringBoot2 Vue3 MyBatis-Plus MySQL8.0顺便把设计文档、数据库脚本、部署说明也都整理进去。这篇博文就是把这个项目从零到能跑通的全过程、设计逻辑以及踩过的坑都拆开来讲适合正在学SpringBoot全家桶、Vue3后台管理系统或者想找一份能直接抄作业的租赁系统参考的同学。1. 为什么用 SpringBoot2 Vue3 MyBatis-Plus MySQL8.0 组装租赁系统先说结论这套组合不是配置最潮的也不是性能最极端的但它非常适合一个人或一个小团队快速交付一个业务型管理系统这种场景。物品租赁系统本质上是典型的CRUD密集型业务物品管理、用户管理、订单管理、归还记录再加上一些状态流转和金额计算。这种业务不需要微服务不需要分布式事务用一套足够规范、足够省心的单体架构反而是最稳的。1.1 这套组合解决的核心问题是什么租赁系统最麻烦的点不是增删改查本身而是租赁状态的一致性。一个物品有闲置、已借出、已预约、维修中几种状态用户下单时要判断状态归还时要计算费用超期时要变更状态。如果后端逻辑没有统一入口前端页面再多也很难不出现数据打架。SpringBoot2在这里承担的角色是规则制定者。所有的租赁规则、状态流转、费用计算都放在后端Service层前端Vue3只负责展示和收集数据。这样做的最大好处是业务规则改动时前端不用跟着大改接口逻辑也能被单元测试覆盖。MyBatis-Plus解决的是把重复的CRUD从业务代码里剥离。租赁系统的表很多如果每张表都手写Mapper XML工作量会翻倍。MyBatis-Plus提供了BaseMapper、IService、ServiceImpl这套现成的CRUD能力配合LambdaQueryWrapper可以写出几乎没有SQL的查询代码对阅读和维护都友好。MySQL8.0在这个组合里提供的是底层数据安全。相比5.78.0的窗口函数、CTE可以让一些复杂的统计查询比如月度租赁排行、超期率趋势变得非常简单而且默认字符集utf8mb4对中文内容和表情符号都友好避免了老版本常见的乱码问题。1.2 选型时的取舍逻辑选SpringBoot2而不是SpringBoot3是因为当时项目要求的JDK是8SpringBoot2对JDK8支持最成熟第三方生态包括MyBatis-Plus的旧版本兼容性也最稳。如果你直接用SpringBoot3很多老版本插件和教程可能都会踩版本坑。选Vue3而不是Vue2核心是Composition API带来的逻辑聚合能力。租赁系统的订单详情页非常复杂要展示物品信息、租赁人信息、费用明细、状态时间线用Options API把这些逻辑全塞在一个对象里会很散而用setup语法可以把响应的数据、方法、计算属性按业务模块组织代码可读性高很多。选MyBatis-Plus而不是纯MyBatis就是冲着无状态增删改查这几个字。这里说的无状态不是服务无状态而是业务代码里不掺杂状态化的事务和SQL拼接细节。比如新增一个租赁订单你不需要手动写insert into rental_order values(...)直接orderService.save(order)就完成。查询更是只写条件不写SQL比如查所有已借出的物品ListItem items itemService.list( new LambdaQueryWrapperItem() .eq(Item::getStatus, RENTED) .orderByDesc(Item::getUpdatedAt) );这种写法在业务快速迭代阶段非常舒服后面要加条件直接在链式调用里再加一行。选MySQL8.0则完全是数据可靠性导向。租赁系统里有一张rental_order表里面存了押金金额、实收金额、违约金额这些数据如果因为字段类型定义不严谨比如用float存金额产生精度误差后面对账会非常痛苦。MySQL8.0配合decimal(10,2)能在源头保证金额精度配合json类型还能灵活存一些扩展字段。2. 后端用 MyBatis-Plus 把 CRUD 做到无状态后端设计是整个系统的地基。我在开始写接口之前先把数据表和核心Service接口敲定因为租赁业务的复杂点几乎都体现在数据和状态上。我不建议拿到需求就急着写Controller那样很容易写着写着发现表结构不合理返工成本太高。2.1 数据表设计要先把租赁语义立起来物品租赁系统的核心表我拆成了5张物品表(item)、用户表(user)、租赁订单表(rental_order)、归还记录表(return_record)、费用明细表(fee_detail)。物品表最关键的字段是状态字段status我用了string类型存枚举值取值有AVAILABLE可借、RENTED已借出、MAINTENANCE维修中、DISABLED已下架。不用数字枚举的原因很简单数字0、1、2在前后端联调时容易解释不清而字符串一看就懂虽然占用空间稍大但租赁系统的数据量完全不在乎这点。租赁订单表是业务核心我给它设计了几个关键字段字段名类型说明order_novarchar(32)业务单号展示用不依赖数据库自增主键item_idbigint物品IDuser_idbigint租赁人IDstart_timedatetime实际开始时间plan_return_timedatetime计划归还时间actual_return_timedatetime实际归还时间deposit_amountdecimal(10,2)押金金额rental_amountdecimal(10,2)应收租金actual_amountdecimal(10,2)实收金额statusvarchar(20)待取物/租赁中/已归还/已取消/超期这里有个设计细节order_no业务单号之所以单独建字段是为了方便给用户看和做后续对账。数据库自增主键id一旦被用户看到很容易暴露业务数据量而且也不利于多端查询记忆。我生成单号的规则是LS yyyyMMdd 四位随机数保证一天内的单号不重复就行。归还记录表和费用明细表则是为了做历史追踪。归还记录表负责记录什么时候还的、是谁接手的、物品当时状态如何费用明细表则记录每一笔费用的构成比如基础租金、超期费用、损坏赔偿。这样做的好处是结算出了问题可以直接追溯到单笔订单不用翻日志。2.2 通用 Crud 服务与 MyBatis-Plus 的应用MyBatis-Plus的IService接口和ServiceImpl类几乎是租赁系统的基建。每个业务Service都继承ServiceImplMapper, Entity这样save、removeById、getById、page这些基础能力自动就有了。但这里我要提醒一点不要太依赖通用CRUD而省略业务校验。比如删除一个物品如果直接调用itemService.removeById(id)很可能这个物品还有未归还的租赁订单删了之后订单表就成了脏数据。所以我在删除前加了检查Long count rentalOrderService.count( new LambdaQueryWrapperRentalOrder() .eq(RentalOrder::getItemId, id) .in(RentalOrder::getStatus, RENTED, PENDING) ); if (count 0) { throw new BizException(该物品仍有进行中的租赁订单不能删除); }这就是通用CRUD的边界问题通用服务解决的是单表常规操作而跨表一致性必须靠业务代码兜底。关于无状态增删改查我是这样理解的把CRUD的通用部分抽成服务Controller里只保留业务参数解析和规则校验这样Controller只做路由Service只做业务Mapper只做SQL甚至不写SQL。代码结构非常干净后面接手的人也不会迷路。在分页查询上MyBatis-Plus自带的分页插件是我的首选。配置一个MybatisPlusInterceptor注册PaginationInnerInterceptor然后查询时直接传入Page对象PageRentalOrder page rentalOrderService.page( new Page(current, size), new LambdaQueryWrapperRentalOrder() .eq(StringUtils.hasText(status), RentalOrder::getStatus, status) .orderByDesc(RentalOrder::getCreatedAt) );这个分页插件在MySQL8.0下会自动优化count查询数据量在几十万级别内性能完全够用。2.3 关键业务接口租赁、归还、续租租赁接口是整个系统最核心的创建订单逻辑我建议把事务边界设在这里。因为创建订单至少要做三件事检查物品状态、扣减物品可借数量如果没有库存概念则改为状态变更、写入订单记录。我的核心代码如下Transactional(rollbackFor Exception.class) public RentalOrder createOrder(Long itemId, Long userId, LocalDateTime planReturnTime) { Item item itemService.getById(itemId); if (item null || !AVAILABLE.equals(item.getStatus())) { throw new BizException(物品不可租借); } // 计算租金 BigDecimal rentalAmount calcRentalAmount(item, planReturnTime); // 创建订单 RentalOrder order new RentalOrder(); order.setOrderNo(generateOrderNo()); order.setItemId(itemId); order.setUserId(userId); order.setPlanReturnTime(planReturnTime); order.setDepositAmount(item.getDeposit()); order.setRentalAmount(rentalAmount); order.setStatus(PENDING); rentalOrderService.save(order); // 锁定物品 item.setStatus(RENTED); itemService.updateById(item); return order; }这里用了分布式事务吗没有。因为系统是单体架构一个方法里多个Service调用全部交给Spring的Transactional管理即可。但要注意rollbackFor Exception.class这个属性Spring默认只在抛出RuntimeException时回滚如果业务抛的是自定义受检异常事务不会回滚这是个隐蔽问题。归还接口的核心是计算实际费用。我会取出订单判断是否超期然后生成费用明细。这里不要把钱的计算逻辑写在Controller里一定要放在Service并且用BigDecimal计算。归还时同时更新订单状态和物品状态这一步同样要在事务里完成。续租接口本质上是对plan_return_time的修改加费用追加。续租也有边界判断如果订单已经超期应该走超期结算而不是续租因为超期的费用计算逻辑和正常续租不同。我在实现时就遇到过超期后用户想续租结果系统允许修改计划归还时间导致超期费用被凭空抹掉的设计漏洞后来把规则改成超期订单必须先归还结算再重新创建订单。3. 前端Vue3 项目怎么承接复杂的物品流转状态前端的核心诉求是把租赁信息的状态展示清楚。Vue3的响应式系统、Composition API和组件化能力在这类后台管理页面里体现得非常充分。3.1 状态管理选型租赁系统的全局状态其实不多当前登录用户、物品筛选条件、当前选中的订单详情。这些如果用Vuex会显得略重用Pinia则轻量很多。我最终选了Pinia因为它和Vue3配合最自然store的定义很像Composition APIexport const useOrderStore defineStore(order, { state: () ({ currentOrder: null as OrderInfo | null, filterStatus: ALL, }), getters: { showStatusName: (state) { return state.currentOrder ? statusMap[state.currentOrder.status] : ; }, }, actions: { async loadOrderDetail(orderId: number) { const res await fetch(/api/orders/${orderId}).then(r r.json()); this.currentOrder res.data; }, }, });用Pinia之后页面组件之间共享订单状态非常方便不需要层层props和emit。3.2 租赁流程的页面拆解我把前端页面拆成四个核心视图物品列表、创建订单、订单列表、订单详情。物品列表页要做的不是简单表格而是带高亮的状态筛选。我会用computed根据store里的filterStatus过滤数据每个状态用一个Tag组件展示颜色区分el-tag :typestatusType(item.status){{ statusName(item.status) }}/el-tag这里的statusType是一个映射函数把AVAILABLE映射成successRENTED映射成warningMAINTENANCE映射成danger。后台管理系统里颜色本身就是信息用户一眼能看出来哪些物品不能借。创建订单页是前端表单校验的重灾区。除了必填校验还需要前端联动展示租金估算。用户在选择了物品和计划归还时间后应立即向后端请求一个预估价格接口把租金、押金显示在页面上。这个接口也可以直接用后端calcRentalAmount逻辑的只读版本不落库。订单详情页我用了descriptions组件加timeline组件上半部分展示订单基本信息下半部分用时间线展示状态流转历史创建、支付押金、取物、归还、结算。时间线数据来自后端返回的orderTraceList前端只做渲染。3.3 和 Element Plus 的配合Element Plus是Vue3生态里最成熟的组件库我没有用别的UI框架主要是因为它的表单、表格、弹窗组件足够全面文档更新也快。使用Element Plus时有个小坑按需引入比全量引入踩的坑多。全量引入虽然会增加打包体积但对于后台管理系统来说省心更重要。我在项目里先全量app.use(ElementPlus)把系统跑通后续如果包体积太大再考虑按需优化。另外动态el-table列的逻辑我建议用v-for渲染并且每一列的宽度要给足否则表格在窄屏下会出现横向挤压看起来很不专业。前端还有一个容易忽略的点所有接口返回的金额是分还是元要保持一致。我和后端约定统一用元并且后端返回字符串30.00前端直接展示。如果后端返回数字浮点运算会出现0.10.2不等于0.3的问题所以我在前端封装了一个formatMoney函数专门处理金额显示不做加法运算。4. 租赁业务里最容易踩的三类坑这部分其实是实际开发中占了最多调试时间的地方。优先级由高到低金额精度、并发冲突、时间计算。4.1 金额计算的精度问题租赁系统的金额计算涉及租金单价、时长、押金、超期费率几个要素。如果租金单价是小数时长又有按天/按小时两种模式很容易出现浮点运算误差。我的做法是所有金额字段、所有涉及金额的计算一律使用BigDecimal。数据库层面使用decimal(10,2)Java层实体字段使用BigDecimal前端使用字符串接收。严禁在Java代码里出现double、float类型的金额字段。租金计算逻辑举个例子public BigDecimal calcRentalAmount(Item item, LocalDateTime planReturnTime) { LocalDateTime now LocalDateTime.now(); long days ChronoUnit.DAYS.between(now, planReturnTime); if (days 0) { days 1; // 最少按一天算 } BigDecimal dailyRate item.getDailyRate(); // BigDecimal return dailyRate.multiply(BigDecimal.valueOf(days)).setScale(2, RoundingMode.HALF_UP); }这里我特别强调乘除运算的结果直接setScale(2, RoundingMode.HALF_UP)否则数据库插入时如果超出两位小数MyBatis-Plus很可能报Data truncation错误这个错误在集成测试阶段非常常见。4.2 并发租赁冲突物品租赁系统的并发量一般不高但两个用户同时想借同一个物品这件事还是会发生。如果只靠前端按钮禁用后端没有兜底就会出现同一个物品被重复下单的脏数据。解决办法有两个层面。第一层是数据库层面的唯一约束在rental_order表上给item_id加一个只有当订单状态为租赁中/待取物时唯一的部分唯一索引。MySQL8.0支持函数索引吗支持但更简单的做法是在应用层加分布式锁或数据库行锁。第二层是应用层加select ... for update行级锁。我在创建订单时先锁住物品行Item item itemMapper.selectByIdForUpdate(itemId);这里我手写了一个selectByIdForUpdate方法在Mapper里用Select(select * from item where id #{id} for update)实现。这个锁能保证同一时刻只有一个线程能拿到该物品的处理权其他人要么等待要么直接提示物品已被借出。有人可能担心行锁影响性能。对于租赁系统这种低频业务行锁完全不是问题。真正的问题是忘了在事务里使用这个锁导致锁没被持有到事务提交就释放那就失去意义了。4.3 时间跨度和超期归还超期判定是整个租赁系统里最不能用直觉写的逻辑。我的订单里有两个关键时间plan_return_time计划归还时间和actual_return_time实际归还时间。超期费用应该在归还那一刻结算而不是在定时任务里批量改。归还时的超期计算if (order.getActualReturnTime().isAfter(order.getPlanReturnTime())) { Duration duration Duration.between(order.getPlanReturnTime(), order.getActualReturnTime()); BigDecimal overdueDays BigDecimal.valueOf(duration.toDays()); BigDecimal overdueFee overdueDays.multiply(item.getOverdueDailyRate()) .setScale(2, RoundingMode.HALF_UP); // 记录到费用明细 }这个逻辑看起来简单但实际有两个坑要提醒。第一个坑是时间精度。如果只按天数计算还书时间比计划时间晚了1小时按天算就是0天用户可能白嫖一天。建议计算粒度细化到小时或者设定一个宽限期比如2小时内不算超期这个规则要提前和后端约定最好配置在系统参数表里方便以后调整。第二个坑是定时任务与手动结算的一致性。有人会写一个定时任务每天凌晨扫描所有超期但未归还的订单自动计算超期费用。但这样如果用户当天下午才归还定时任务已经算过一次超期费归还时又算一次就会重复计算。我在系统里规定超期费用只在归还接口触发结算时计算定时任务只负责发提醒通知不修改金额。这个约定保证了金额计算只有一条路径。5. MySQL8.0 与部署环节的实践要点编程逻辑再完善如果数据库起不来或者部署后连不上项目还是等于零。我自己在MySQL8.0和部署上折腾的时间真不少这里把最实用的步骤和注意点分享出来。5.1 数据库配置和连接MySQL8.0和5.7在使用体验上有一个最直观的差别驱动类名变了。在SpringBoot2里连接MySQL8.0spring.datasource.driver-class-name要写com.mysql.cj.jdbc.Driver不能再写com.mysql.jdbc.Driver否则会报ClassNotFoundException。另外连接URL里要显式带上时区和字符集参数spring.datasource.urljdbc:mysql://localhost:3306/rental_system?useUnicodetruecharacterEncodingutf8mb4serverTimezoneAsia/ShanghaiuseSSLfalseallowPublicKeyRetrievaltrueallowPublicKeyRetrievaltrue这个参数容易漏。MySQL8.0默认使用caching_sha2_password认证插件如果连接时没有配置公钥检索第一次连接会报Public Key Retrieval is not allowed。这个报错在本地开发时几乎必现直接加上参数就能解决。5.2 用 Docker 快速起 MySQL8.0我开发时最推荐用Docker跑MySQL8.0因为完全不需要手动安装依赖也不用担心宿主机环境被搞乱。一条命令就能拉起来docker run -d \ --name mysql8 \ -p 3306:3306 \ -e MYSQL_ROOT_PASSWORDroot123456 \ -e MYSQL_DATABASErental_system \ -v /myown/mysql/data:/var/lib/mysql \ mysql:8.0这里要注意两点。第一-v数据目录映射一定要做。容器一旦删除没有映射的数据会全部丢失到时候项目代码还在但数据库全空了那种后悔感我不想你再体验一次。第二字符集校验。MySQL8.0默认字符集是utf8mb4_0900_ai_ci8.0才有的新排序规则中文一般没问题但如果你从5.7迁移过来0100_ai_ci和0900_ai_ci在某些排序场景下行为不一致。稳妥起见创建数据库时显式指定CREATE DATABASE IF NOT EXISTS rental_system DEFAULT CHARACTER SET utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci;这样即使以后要把数据平滑迁移到其他版本也少一个变量。5.3 打包部署流程和常见错误后端打包我用Maven命令就一行mvn clean package -DskipTests生成target/rental-system-0.0.1-SNAPSHOT.jar。部署到服务器上直接用java -jar跑但如果服务器内存吃紧建议设个JVM初始堆内存nohup java -Xms256m -Xmx512m -jar rental-system-0.0.1-SNAPSHOT.jar app.log 21 前端Vue3项目打包npm run build生成dist目录用Nginx托管。Nginx的关键配置是反向代理location /api/ { proxy_pass http://127.0.0.1:8080/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }这个配置的意思是把前端发出的/api/**请求转发到后端8080端口。这里的坑是proxy_pass最后有没有斜杠。写了斜杠/api/orders会变成/orders不写斜杠会变成/api/orders。我自己后端的Controller统一以/api/orders开头所以不加斜杠才能转发正确。这一处小差异曾经让我排查了一下午。部署完成后还有几个高频报错值得提前注意第一个是跨域报错。如果前端通过axios直接请求后端的8080端口没有走Nginx代理就一定会遇到CORS问题。此时在后端加一个全局跨域配置类实现WebMvcConfigurer的addCorsMappings方法就行。但生产环境建议还是走Nginx代理尽量避免跨域。第二个是静态资源404。Vue3的history路由模式刷新页面时Nginx默认会去找对应的目录路径找不到就404。需要在Nginx配置中加location / { try_files $uri $uri/ /index.html; }这里加一条try_files规则就能解决刷新404的问题。第三个是数据库连接池爆满。如果第二天早上发现后端日志报Too many connections大概率是连接池默认参数和wait_timeout不匹配。我习惯在application.yml里显式配置Hikari连接池spring: datasource: hikari: maximum-pool-size: 10 minimum-idle: 2 connection-timeout: 30000 idle-timeout: 600000这样既能控制连接数也能让空闲连接及时释放。关于项目里附带的那份文档我多说一句文档里会有完整的数据库初始化脚本、接口文档、部署步骤和常见问题汇总。对初学者来说最容易踩的坑其实是文档中的表结构脚本和代码实体类对不上。我的建议是拿到项目后先执行sql/init.sql建库再打开代码里的application.yml确认数据库名和密码再启动后端然后启动前端。文档里如果标注了先改配置再启动就一定不要跳过。个人体会是这种全栈项目最怕的不是某个技术不会用而是不知道整个调用链上哪里会断。我已经把从数据库到后端的连接配置、从后端到前端的接口约定、从开发机到生产环境的部署细节都踩过一遍这些沉淀下来的经验放在文档里比代码本身还要值钱。
返回列表