农机这行有个特别拧巴的现象:农机主花几十万买的收割机,一年真正下地的实际可能不到三个月;而急需用机的农户,到了抢收这几天,求爷爷告奶奶都约不到机器。我最初接触“基于Spring Boot的农机租赁服务平台”这个题目时,第一反应不是技术选型,而是先弄明白这个平台到底要解决什么——它本质上解决的是农机闲置率与农户用机难之间的信息错配。
后来做完整套系统,我越发觉得这类“行业垂直+通用技术”的项目非常适合拿来练手。它不像纯电商那样全是无脑增删改查,也不至于像流式计算那么烧脑:既有用户、农机、订单、评价这些标准 CRUD,又有档期冲突校验、并发防重复预约、订单状态机、定时任务等真正考验设计能力的东西。这篇文章就把我从需求分析到落地的完整思路、核心实现和踩坑记录都摊开来讲,适合准备拿它当毕业设计的同学,也适合想系统走一遍 Spring Boot 项目开发的初级工程师。
1. 项目背景与核心需求拆解
1.1 农机租赁到底在租什么
先讲业务。农机租赁这东西,租的不是“一台机器”这么简单,租的是“一段确定的时间窗口”。一台拖拉机在春耕期间,比如4月20日到5月5日这半个月,每一天都可能被不同的农户预约。所以数据库设计上,不能只存农机表,还要存一张“档期表”,记录这台机器每一天的占用状态,这是这个项目和其他租赁系统最不一样的地方。
有人觉得这和酒店订房差不多。确实像,但农机比酒店房间复杂的地方在于:农机是可移动资产,农机主得考虑接单后运到什么地方作业,距离远不远、路况好不好;农机价格贵,押金和损坏责任划分很敏感;农忙时节的时效性特别强,农户错过三五天,一季收成就受影响。所以系统里除了基础档案,还要有清晰的地址信息、押金规则、违约条款这些字段。
再往深一层想,这个平台还要解决“信任”问题。农机主怕农机被农户开出问题、不按时归还;农户怕付了押金、机器却带病作业耽误农时。所以评价体系和保证金规则不只是电商标配,更是这个业务能不能跑起来的前提。我在设计时给评价模块单独留了扩展位,后面对接信用分、纠纷仲裁都比较方便。
1.2 角色与核心流程
这个平台有三类用户:农户是承租方,农机主,也可能是农机合作社,是供给方,平台管理员负责审核和运营。农户走的是“搜索农机→查看档期→下单→支付→取机使用→归还→评价”的链路;农机主走的是“发布农机→等待审核→管理档期→确认归还→结算收入”的链路;管理员则是审核农机上架、处理纠纷、查看统计报表、做日常运营。
主体链路中最容易出问题的是两个环节:一是下单时的档期冲突,同一台机器同一天被两个人占用,这在业务上属于严重事故;二是订单状态的流转,什么状态下能取消、什么状态下能退款、超时未归还怎么计费,这些规则一定要在代码里固化下来,不能靠人工判断。我在做细化设计时,会把“谁在什么状态下能做什么操作”列成矩阵图,然后照着矩阵把校验代码写进去,这样不会漏。
1.3 功能模块划分
| 模块 | 面向角色 | 核心功能 |
|---|---|---|
| 用户模块 | 全部 | 注册登录、实名认证、角色区分、个人信息 |
| 农机模块 | 农机主/管理员 | 农机发布、图片上传、档期管理、上下架、审核 |
| 检索模块 | 农户 | 按品类/价格/地区筛选、档期日历展示 |
| 订单模块 | 农户/农机主 | 下单、支付、取消、确认完成、超时处理 |
| 评价模块 | 双方 | 双向评价、评分统计 |
| 后台管理 | 管理员 | 用户管理、农机审核、订单监控、数据报表 |
模块拆分的原则是单一职责:订单只管订单状态和金额,农机只管农机档案和档期,这样后面做定时任务、对账结算时不用到处打补丁。实际开发时我一般会先画一遍模块图,把每个模块的输入输出列出来再写代码,宁可前期多花一天,也不要在最后返工。
2. 技术选型与架构设计
2.1 为什么选 Spring Boot,而不是别的
这个项目用 Spring Boot 几乎是顺理成章的选择。核心原因是它的“自动装配”机制把繁琐的框架配置变成了开箱即用的能力。原理其实不复杂:Spring Boot 在启动时会通过@EnableAutoConfiguration去加载META-INF/spring.factories或AutoConfiguration.imports里声明的配置类,再配合@ConditionalOnClass、@ConditionalOnProperty之类的条件注解,决定哪些 Bean 要创建。简单说,就是框架先帮你把主食配好,你只需要告诉它“今天吃面条还是米饭”。
相比十年前的主流 SSM 手动拼 XML,Spring Boot 最大的好处是把“约定优于配置”落到了实处:内嵌 Tomcat 让你不用额外部署 WAR 包,起步依赖帮你锁好依赖版本,配置文件一个application.yml搞定绝大部分设置。对于农机租赁这种业务逻辑为主、架构上不需要微服务的项目,单体 Spring Boot 项目就是投入产出比最高的选择,别为了凑技术栈硬上 Spring Cloud,那样只会增加部署和调试成本。
2.2 技术栈清单与版本选择
我用的技术栈和版本,直接列在下面:
| 组件 | 选型 | 说明 |
|---|---|---|
| Spring Boot | 2.7.18 | 成熟稳定,JDK 8 即可运行 |
| 持久层 | MyBatis-Plus 3.5.x | 省去大量手写 SQL |
| 数据库 | MySQL 8.0 | InnoDB 引擎 |
| 缓存/分布式锁 | Redis + Redisson | 档期并发控制、热点农机缓存 |
| 认证 | JWT + Spring Security | 前后端分离场景下的标准方案 |
| 前端 | Vue 3 + Element Plus | 管理后台和农户端同一套代码 |
| 构建 | Maven 3.8+ | 用 Gradle 还是 Maven 纯看团队习惯 |
| 部署 | Docker Compose | 一键拉起 MySQL + Redis + 应用 |
这里必须多说一句版本问题。很多人都被“Spring Boot 版本太高”坑过:Spring Boot 3.x 要求 JDK 17,包名从javax换成了jakarta,很多老教程的代码直接编译不过,MyBatis-Plus 也得换适配版本。说实话,对于这种以业务实现为主的单体项目,用 Spring Boot 2.7.x + JDK 8/11 是最稳的组合,依赖生态成熟、网上遇到的坑基本都被别人踩过、面试官也不会因为你没上最新版就扣分。我的原则是:生产求稳,学习求懂原理。
2.3 分层架构与工程结构
项目的包结构我按“控制器→服务→Mapper→实体”的分层来组织,这也是 Spring Boot 单体项目最主流的目录规划方式:
com.agri.rental ├── controller // 接口层 ├── service // 业务层,接口+实现 ├── mapper // MyBatis-Plus Mapper ├── entity // 数据库实体 ├── dto // 入参出参对象 ├── config // 配置类:安全、跨域、上传映射 ├── common // 公共返回结果、异常处理、常量 ├── task // 定时任务 └── utils // 工具类controller 只负责接收参数、做最基础的校验、调用 service、包一层统一的 Result 返回;具体的业务逻辑都收拢在 service 里。为什么不在 controller 里写逻辑?因为农机租赁的下单流程牵扯订单创建、档期占用、库存检查、支付单生成好几个动作,放到一个 service 方法里才能保证事务边界清晰,也方便写单元测试。这个结构看起来普通,但真到排查问题和扩展功能的时候,你会感谢当初没有把所有代码堆在一个 controller 里。
3. 数据库设计与核心表结构
3.1 设计思路
数据库设计是这个项目最关键的环节。农机租赁的实体关系并不复杂:user与machine是一对多,一个农机主有多台农机;machine与rental_order是一对多;rental_order与review是一对一。容易出错的是“档期”这个概念,我单独建了一张machine_schedule表,记录每台农机每一天的占用状态,这比在订单表里做日期范围判断要靠谱得多。
为什么单独建表而不是直接在订单表里查“日期区间是否存在重叠”?因为档期表天然适合加数据库唯一索引做到强约束,还能顺带支持农机主自助设置“今天不租”“机器维修中”这类特殊状态。订单表只记录起止日期,档期表按天落数据,查询哪些天被占了,一条 SQL 就能解决。每天一把锁的思路,比给一整段区间加锁简单得多。
3.2 核心表结构
这里给出最关键的两张表结构,其余表比较简单就不全贴了。
machine_schedule表:
| 字段 | 类型 | 说明 |
|---|---|---|
| id | BIGINT | 主键 |
| machine_id | BIGINT | 关联农机 |
| schedule_date | DATE | 档期日期 |
| status | TINYINT | 0可租 1占用 2停租 |
| lock_order_id | BIGINT | 占用的订单id,可空 |
| 唯一索引 | (machine_id, schedule_date) | 一台机器同一天只能有一条记录 |
唯一索引是并发防重的最底层防线。就算业务代码里 Redis 锁偶尔失效,数据库这层也能兜住。
rental_order表,裁剪版:
| 字段 | 类型 | 说明 |
|---|---|---|
| order_no | VARCHAR(32) | 订单号,唯一 |
| machine_id | BIGINT | 农机id |
| renter_id | BIGINT | 农户id |
| owner_id | BIGINT | 农机主id |
| machine_name | VARCHAR | 农机名称快照 |
| unit_price | DECIMAL(10,2) | 下单时单价快照 |
| start_date | DATE | 开始日期 |
| end_date | DATE | 结束日期 |
| rent_amount | DECIMAL(10,2) | 租金 |
| deposit_amount | DECIMAL(10,2) | 押金 |
| overdue_fee | DECIMAL(10,2) | 超时费用 |
| order_status | TINYINT | 状态枚举 |
| actual_return_time | DATETIME | 实际归还时间 |
订单里的名称、单价这类字段为什么要“快照”?因为农机主完全可能在上架后调整租金,如果订单关联的是实时价格,用户下单时看到的和结算时算出来的对不上,纠纷就来了。快照的本质是“下单那一刻的业务事实”。这一点在报表统计时也很重要,统计历史订单金额不能拿现价算。
3.3 数据一致性与并发控制的底层设计
防重复预约是这个项目的核心难点之一。两台农户同时盯上同一台收割机在9月20日这一天,系统怎么保证只有一个能下单成功?我做了三层防护:
第一层,Redisson 分布式锁。以rent:order:{machineId}:{date}为 key,下单前先加锁,拿到锁后再检查档期、创建订单、占用档期,完成后释放锁。第二层,档期表唯一索引。就算锁在极端情况下失效,数据库也会因为唯一索引报错,代码捕获异常返回“该日期已被占用”。第三层,订单状态约束。已支付的订单不能因为重复提交再次占用档期。
这三层按性价比排序的话,唯一索引是最便宜最可靠的,分布式锁解决的是“并发下单时检查与写入之间的线程穿插问题”,订单状态约束解决的是“重复请求打进来的幂等问题”。三层加起来,才算把这个业务风险真正堵死。单靠任何一层都有漏洞,这个我后面在第五章会展开讲。
4. 核心业务功能实现
4.1 订单状态机与流程控制
订单状态我用枚举来定义,推荐大家也这么干,别用魔法数字散落在 if 里:
public enum OrderStatus { PENDING_PAY(0, "待支付"), PAID(1, "已支付"), IN_USE(2, "使用中"), PENDING_RETURN(3, "待归还"), COMPLETED(4, "已完成"), CANCELLED(5, "已取消"), REFUNDED(6, "已退款"); }状态流转的核心是校验合法性。比如“待支付”能走“取消”能走“支付”,但“已完成”绝对不能退回“待支付”。我写了一个集中式的方法transition(from, to, order)做校验,所有业务入口都走它,代码大致是:
public void cancelOrder(Long orderId) { RentalOrder order = orderService.getById(orderId); orderService.transition(order.getOrderStatus(), OrderStatus.CANCELLED, order); // 释放档期、记录日志 }为什么非要集中校验而不是每个入口自己判断?因为这个项目的订单状态特别多,分散判断很容易漏掉某一对的转换关系;集中校验之后,任何状态变化都有统一入口可以加日志,后期排查“这个单子怎么变成这个状态”时直接翻日志就行。
4.2 计费与结算的逻辑实现
租金计算按“天”为最小单位,超时按“小时”计费。核心计算代码:
long days = ChronoUnit.DAYS.between(order.getStartDate(), order.getEndDate()); BigDecimal rentAmount = order.getUnitPrice().multiply(BigDecimal.valueOf(days)); // 超时:实际归还晚于计划结束日期,按小时计算 if (actualReturnTime != null && actualReturnTime.isAfter(endDateTime)) { long overdueHours = ChronoUnit.HOURS.between(endDateTime, actualReturnTime); BigDecimal overdueFee = order.getUnitPrice() .divide(BigDecimal.valueOf(24), 2, RoundingMode.HALF_UP) .multiply(BigDecimal.valueOf(overdueHours)); }注意,这里用的是order.getUnitPrice(),也就是订单快照里的单价,不是农机当前的最新价。明细金额算出来后,会汇总到结算单里:应收总额 = 租金 + 超时费 + 押金,其中押金在确认无损坏后原路退回。这个流程建议用独立的结算表存一次账,别到时候只能从零散的订单字段里拼。
计费这里最容易被忽略的是金额精度。租金、押金、超时费全部用BigDecimal,禁止用double。涉及到钱的东西,精度问题在测试阶段基本看不出来,跑到生产就对不上账了。
4.3 定时任务的正确姿势
这个项目里有几个场景天然适合定时任务:超时未支付订单自动取消、租赁到期未归还提醒、每日经营数据汇总。Spring Boot 里用@Scheduled就能实现,几秒钟就能搞定:
@Component @EnableScheduling public class OrderScheduleTask { @Scheduled(cron = "0 */5 * * * ?") public void cancelExpiredOrders() { List<RentalOrder> list = orderMapper.selectList(new LambdaQueryWrapper<RentalOrder>() .eq(RentalOrder::getOrderStatus, OrderStatus.PENDING_PAY.getCode()) .le(RentalOrder::getCreateTime, LocalDateTime.now().minusMinutes(30))); for (RentalOrder order : list) { // 1. 将订单状态改为取消 // 2. 释放 machine_schedule 中被占用的档期 // 3. 记一条操作日志 } } }你以为这就完了?不是。这里最容易踩的坑是“忘记释放档期”。如果只把订单状态改成取消,档期表里那些天还挂在占用状态,农机主眼睁睁看着机器空着却租不出去。所以定时任务里一定要把“释放档期”当成和“改状态”同样重要的一步。另一个细节是记得在启动类或配置类上加@EnableScheduling,这个太容易被漏掉;如果系统变成多实例部署,@Scheduled会在每台机器上都跑一遍,还要做好幂等兜底。
4.4 认证授权与安全设计
前后端分离的项目,认证我选 JWT + Spring Security。基本思路是:用户登录成功,后端签发一个带过期时间的 JWT Token,前端保存到本地,每次请求放到 Authorization 头里,Spring Security 的过滤器从 Token 里解析出用户身份和角色,注入到上下文中。
权限上分三种角色:ROLE_FARMER、ROLE_OWNER、ROLE_ADMIN。接口上用注解控制:
@PreAuthorize("hasAnyRole('ROLE_OWNER','ROLE_ADMIN')") @PostMapping("/machine") public Result addMachine(@RequestBody MachineDTO dto) { // 只有农机主和管理员能发布农机 }这里有个新手容易忽略的细节:JWT 是无状态的,签发之后在过期之前很难主动让它失效。如果做了“退出登录”功能,前端把 Token 删掉就行,但如果用户手机丢了、账号被盗,那 Token 在过期前仍然有效。所以在做安全设计时,要么把 Token 过期时间设得短一点,比如 2 小时,配合 Refresh Token 机制;要么引入 Redis 黑名单,把主动退出的 Token 拉黑。做毕设可以简单一点,但如果想讲出亮点,这个点值得好好准备。
5. 开发过程中踩过的坑
5.1 版本兼容性陷阱
我开头提到过 Spring Boot 版本问题。实际开发中最经典的坑是:项目搭建在 Spring Boot 2.x 上,一切正常;某天升级到 3.x,代码里的javax.servlet全部报红,因为 3.x 换成了jakarta.servlet;如果再配上 Java 17,很多旧版 Lombok、MyBatis-Plus 的插件还会闹脾气。所以真心建议:这类单体业务项目就用 Spring Boot 2.7.x + JDK 8 或 11,你可以花时间去看 3.x 的新特性源码,但项目本身没必要冒险追新。
5.2 MyBatis-Plus 的几个必修点
用 MyBatis-Plus 能省不少事,但有四个点特别容易出错。
第一是分页插件必须显式配置 PaginationInnerInterceptor,不配置的话Page参数会被无视。第二是逻辑删除:在实体字段上标@TableLogic,再配全局逻辑删除字段,删除操作就变成更新操作,防止农机主下架机器后订单记录跟着没了。第三是字段自动填充:create_time、update_time用MetaObjectHandler统一维护,不要在每个插入语句里手工 set。第四是条件构造器的使用:多用LambdaQueryWrapper,避免硬编码数据库字段名的魔法字符串,重构时才不会处处踩雷。
5.3 图片上传和部署问题
农机照片上传是个典型的小模块,但坑不少。本地存储就够用:配置文件里指定upload.dir,用MultipartFile.transferTo()落盘,再写一个 WebMvcConfigurer 把/upload/**映射到本地磁盘路径。这里有两个坑:一是上传目录如果用相对路径,不同启动目录下会跑到不同位置,最好用绝对路径或者通过配置项注入;二是默认spring.servlet.multipart.max-file-size只有 1MB,农机照片动辄几 MB,必须调大。等到照片传上去打不开再回头查,基本都是这两个原因。
5.4 前端打包放不进 Spring Boot
如果不想分开部署前后端,可以让 Spring Boot “吃掉”Vue 的打包产物:把 Vue 执行npm run build得到的 dist 目录里的内容,复制到src/main/resources/static下。这样访问后端端口就能直接打开前端页面,省了 Nginx 和跨域问题。但要注意,前端路由如果是 history 模式,刷新页面时 URL 直接落到后端路由上会 404,需要在后端加一个转发到index.html的 Controller,或者前端换成 hash 模式。我实际测试下来,毕设场景直接用 hash 模式最省心。
5.5 高频问题排查速查表
| 问题现象 | 可能原因 | 解决办法 |
|---|---|---|
| 页面显示 404,但接口能通 | 前端 history 路由未配置兜底 | 加视图转发或改用 hash 模式 |
| 并发下单出现重复预约 | 分布式锁未生效或锁粒度太大 | 检查锁 key 是否包含日期,检查释放时机 |
| 定时任务不执行 | 忘记@EnableScheduling | 启动类加注解 |
| 上传图片后页面显示不了 | 静态资源映射没配 | 注册 WebMvcConfigurer 映射虚拟路径 |
| 更新操作把字段改成 null | 实体全字段更新 | 使用 updateById 或加字段更新策略注解 |
写在最后
如果让我重新做一遍这个项目,我会在几个地方提前下功夫:一是把支付模块抽象成独立的策略接口,微信、支付宝、余额三种渠道一开始就留好扩展位,而不是先写死一种再回头改;二是给农机检索加上地理位置附近的筛选条件,农机的运输成本很高,距离往往是农户决策的第一要素;三是日志体系从一开始就规范化,每个状态变更都带 traceId,不然线上查订单问题的时候会非常痛苦。
我在实际做这个项目时最深的一个体会是:这种业务系统,真正的难点不在某个技术点的源码,而在于把所有业务流程的边界条件都想清楚——谁能操作、什么状态能操作、失败了怎么办。把这些问题列出来逐个攻破,Spring Boot 本身反而只是工具。希望这篇东西能帮你少踩几个坑,更希望你顺着自己的业务场景,把这套骨架改造成真正属于自己的作品。