开春那阵子,我接到一个农机租赁服务平台的设计与开发需求。最开始我以为是又一个普通的二手交易平台套壳,等真正开始梳理业务才发现,农机租赁和想象中差别很大:设备动辄十几万,作业窗口期就那十几天,机主和农户之间相互不信任,跨地区调度乱七八糟。把这些现实问题翻译成系统设计,再落到Spring Boot这一套技术栈上,整个过程踩了不少坑,也总结了不少经验。这篇就把整个项目的设计与开发过程完整复盘一遍,从业务痛点、技术选型、数据库建模到核心接口、部署优化,尽量讲透。
1. 农机租赁为什么难做:季节错峰与履约信任的底层矛盾
1.1 农机租赁和普通货品租赁的本质差异
很多人一提租赁平台,脑子里自动带入共享单车或者网约车那套逻辑:用户注册、下单、按时间计费、还的时候验一下损耗。农机租赁如果照搬这个模式,根本跑不通。
先说时间窗口。春耕和秋收就那么二十来天,一台收割机在湖南收完早稻,马上得拉到河北收小麦,设备和劳动力是跟着节气走的。这意味着平台不能只看用户所在地,还得考虑农机当前的实际位置和作业半径。我最初设计农机信息表的时候只存了"设备所属地区",后来被现实的跨区作业教训了一顿,才老老实实加上实时定位和作业半径两个字段。
然后是履约方式。普通租赁是"人找货",用户自己取还就行;农机租赁是"人找货+货找人"。农户下单之后,机手可能要带着机器跑几百公里,燃料费、过路费、驾驶员食宿怎么算,这都不是简单的订单金额能覆盖的。系统里必须把调度费用、运输补贴、跨区作业附加费这些条目独立出来,否则账根本算不清。
还有一个更麻烦的事情是损耗认定。农机在田里跑一天,漆面刮花、零件磨损是必然的,但到底算正常损耗还是人为损坏,双方经常扯皮。平台如果只是做个信息撮合,这问题可以甩给线下。可一旦平台要介入交易做担保,就不得不设计一套"取机验车—作业日志—退机复检"的流程,把证据链留在系统里。这也是后来我坚持在订单流程里插入验车环节的原因。
1.2 平台功能边界的取舍:撮合、交易、履约要抓到哪一层
项目启动的时候,需求方给了一句话:做一个农机租赁服务平台。这句话看着简单,实际上信息量巨大。到底是做58同城那种信息发布,还是做闲鱼那种交易撮合,还是做携程那种交易+履约全闭环,三者的系统复杂度差了不止一个量级。
我最终选的是"交易撮合+过程担保"的中间路线。理由很直白:纯信息发布留给小程序和微信群就行,平台没有黏性;全闭环的物流、验车、保险、赔付太重,第一版做不下来。而中间路线把订单、支付、保证金、计费、评价这些核心交易环节攥在平台手里,既能让双方信任平台,又不需要真的下场搞线下运输队。
这条边界直接决定了Spring Boot项目里的模块划分。我需要重点做的是:机主端的农机发布与出租管理、农户端的搜索与下单、平台的订单流转与计费、双方的实名认证和信用记录。至于运力调度、维修工单这类重线下业务,第一版一律不做。
1.3 两个被行业默认却容易被忽略的设计约束
第一是地域约束。农机作业是强地域性的,跨区作业虽然存在,但大部分需求还是发生在方圆几十公里内。所以搜索接口的设计不能只按关键字匹配,必须支持按经纬度范围或者省市区县做过滤。这看起来是个小功能,但对数据库索引和查询语句的写法影响非常大。
第二是信用与保证金。农机是高价值资产,机主不可能把设备交给一个没有任何信用记录的人。平台的注册设计必须包含实名认证,下单设计必须包含押金的缴纳和冻结。押金不是支付给机主,而是冻结在平台账户里,等退机验车没问题之后再解冻。这套资金流的逻辑如果不提前想清楚,后面对账会非常痛苦。
这两点决定了整个数据模型和接口设计的方向,属于问需求方都问不出来的隐含需求,只能靠对行业的理解去补齐。
2. Spring Boot技术选型复盘:单体架构与依赖组合的取舍逻辑
2.1 为什么不用微服务:业务体量决定架构厚度
项目刚开始,有人提议用Spring Cloud全家桶,说是以后扩展方便。我当场给拦下来了。一个农机租赁平台第一版撑死也就万级用户、几千台设备,用微服务等于拿大炮打蚊子,光服务注册、配置中心、网关、链路追踪这些基础设施就够折腾一个月。
我选的是Spring Boot单体应用,但要讲究一点,做成模块化单体。说白了就是代码层面把用户、农机、订单、支付、消息这些领域拆成清晰的包结构,但部署上还是一个jar包。这样既保留了单体架构的开发效率和部署简单,又不会让代码烂成一锅粥。等哪天业务量真的大到必须拆分,沿着模块边界切就能拆成微服务。
Spring Boot本身的好处不需要我多说,自动配置、内嵌容器、生态丰富,最关键的是国内招人容易,会Spring Boot的Java开发满地都是,后续维护成本低。
2.2 核心依赖清单与版本避坑
这套项目我用到的核心依赖没有太花哨的东西,讲究的是稳:
| 组件 | 版本与用途 | 选型理由 |
|---|---|---|
| Spring Boot | 2.7.18 | 稳定成熟,javax命名空间,适配JDK 8 |
| MyBatis-Plus | 3.5.3 | 单表CRUD不用写SQL,内置分页插件 |
| MySQL | 5.7+ | 数据存储主力,关系型事务可靠 |
| Redis | 6.x | 缓存、登录态、分布式锁、定时任务辅助 |
| JWT + Spring Security | 0.9.1 + 5.7 | 无状态认证,适合前后端分离 |
| MinIO | 8.x SDK | 农机图片、验车照片的对象存储 |
这里必须说一个版本相关的坑。当时Spring Boot 3.x已经发布,默认使用jakarta命名空间,javax.servlet这种老包全部要换。如果你直接用最新的Spring Boot 3.x配上老教程里的代码,十个有九个编译不过。我特意锁了Spring Boot 2.7.18,不是因为新版本不好,而是因为MyBatis-Plus、某些第三方支付SDK对2.x的支持更加成熟,团队也最熟悉,求稳才是项目第一诉求。等这些生态彻底跟上Spring Boot 3再升级不迟,没必要在开发期给自己找麻烦。
2.3 工程结构划分与统一响应模型
工程结构我沿用了经典的分层方式,但包名的组织花了一点心思:
com.farmlease ├── common -- 通用返回体、异常处理、工具类 ├── config -- Spring Security、Redis、MinIO等配置 ├── controller -- 接口层,只做参数接收和返回 ├── service -- 业务层,核心逻辑全部在这里 ├── mapper -- MyBatis-Plus的数据访问层 ├── entity -- 数据库实体 ├── dto -- 前端交互的数据对象 └── job -- 定时任务统一返回体这块,很多人图省事直接返回Map,后患无穷。我定义了一个泛型Result类,code、message、data三个字段,所有接口统一返回,前端只需要封装一次axios拦截器,后面所有接口的异常处理都走同一个管道。全局异常处理器配合自定义业务异常,把"校验失败"、"余额不足"、"农机已被租赁"这类业务错误转换成明确的提示信息返回给前端,而不是直接把500堆到用户脸上。
public class Result<T> { private Integer code; private String message; private T data; // 省略构造方法与getter/setter }这一套东西虽然琐碎,但它是后面所有业务功能的地基。地基打不好,后面每加一个接口都要重复处理返回值的包装和异常,写着写着就变成意大利面条。
3. 数据库建模的核心:农机台账、订单状态机与灵活计费
3.1 农机信息表的设计:不止是设备参数
农机信息表是平台最核心的资产表,设计上不能只存一个"品牌+型号"了事。我最终设计的字段大致如下:
- id:主键
- user_id:机主用户ID
- machine_name:设备名称,比如"久保田PRO688Q收割机"
- category:分类,收割机/拖拉机/插秧机/旋耕机等
- brand、model:品牌和型号
- buy_year:购买年份,直接影响租金定价
- work_hours:累计作业时长,类似二手车的行驶里程
- work_width:作业幅宽,农户很关注这个参数
- horsepower:马力
- images:图片URL列表,JSON格式存储
- location:当前所在经纬度或地区编码
- service_radius:作业半径,单位公里
- daily_price、hourly_price、acre_price:三种计费单价
- status:农机状态,空闲/已预约/租赁中/维修中/已下架
- audit_status:审核状态,待审核/通过/拒绝
- create_time、update_time
这里特别说一下location和images两个字段。location既要支持按地区搜索,又要支持未来按距离排序和范围筛选。考虑到第一版没有引入GIS服务,我在表里存一个省市区编码,再预留一个经纬度字段,方便后续用Redis Geo或者接入地图SDK做距离计算。images用JSON数组存,查询的时候直接返回给前端,避免一次农机详情要查两次图片表。
3.2 订单状态机:从"等待支付"到"已完成"的五次跃迁
订单表是整张业务网的枢纽,状态设计必须一次到位,不然后期改状态机成本极高。我把订单状态定义成五个:
| 状态码 | 状态名称 | 含义 |
|---|---|---|
| 0 | 待支付 | 农户下单,押金+租金未支付 |
| 1 | 待取机 | 已支付成功,等待线下交接农机 |
| 2 | 租赁中 | 机主确认已交机,开始计算租期 |
| 3 | 待退机 | 农户发起退机,等待机主验车 |
| 4 | 已完成 | 验车通过,押金退回,订单结束 |
| -1 | 已取消 | 超时未支付或双方协商取消 |
| -2 | 售后中 | 验车有争议,进入平台仲裁 |
这个状态机不是随便拍脑袋定的。核心思路是钱货分离:支付成功不等于交机成功,交机成功不等于计费开始。每个状态切换都需要一个明确的触发动作,要么是农户扫码支付,要么是机主在APP点击"已交机",要么是验车员上传复检报告。每一条状态变更都要求记录操作人和备注,形成完整的审计日志。
踩过的坑是状态流转没有做幂等。最开始机主连续点两下"确认交机",系统里就生成两条操作记录,订单金额差点算重。后来所有状态变更接口都加上了前置状态判断,用乐观锁或者SQL的update...where status=?来保证同一时刻只有一个状态变更生效。
3.3 计费规则的配置化设计思想
农机计费最麻烦的一点是口径不统一。有的设备按小时租,有的按天租,还有的按作业亩数算。农户说"我这一亩地你给我收多少钱",机主说"我这机器出场就得按半天算"。这两种口径根本不在一个维度上。
所以我把计费做成了规则配置化,而不是写死在代码里。每个农机在发布的时候,可以选择一种或多种计费方式,每种方式包含的是:
- 计费单位:小时/天/亩次
- 单价:数值
- 最低计费单位:比如"不足1小时按1小时算""不足2小时按半天算"
- 封顶费用:比如日租封顶XX元,即便按小时算超过封顶也不再加
- 超时费率:超过约定归还时间后,按正常单价的1.5倍计费
订单创建时根据选中的计费方式冻结预估费用,退机结算是再根据真实使用量计算最终费用,多退少补。这个设计一开始看着复杂,但真正上线以后灵活性极高,机主想调价只需要改配置,不需要重新发版。
4. 核心接口落地:认证链路、农机发布与权限控制
4.1 JWT + Redis双令牌:处理登录态与设备问题
农机租赁平台的用户有两类,机主和农户,还有一个后台管理员角色。如果只给一份JWT,过期以后用户要重新输密码,体验很差;如果JWT有效期设得太长,被盗的风险又高。最后我做了双令牌方案:
- access_token:有效期2小时,用于访问业务接口
- refresh_token:有效期7天,用于续期
校验逻辑是:访问接口带access_token,Redis里存一份当前用户的有效会话;如果access_token过期,前端用refresh_token换新的;如果refresh_token也过期,才需要重新登录。Redis在这里干了两件事,一是存储token对应的用户信息和权限列表,方便接口校验时快速查;二是把用户退出登录后的token拉黑,解决JWT无法主动失效的痛点。
// 登录成功后返回双令牌 String accessToken = Jwts.builder() .setSubject(userId.toString()) .setExpiration(new Date(System.currentTimeMillis() + ACCESS_EXPIRE)) .signWith(SignatureAlgorithm.HS256, secretKey) .compact(); String refreshToken = Jwts.builder() .setSubject(userId.toString()) .setExpiration(new Date(System.currentTimeMillis() + REFRESH_EXPIRE)) .signWith(SignatureAlgorithm.HS256, secretKey) .compact(); redisTemplate.opsForValue().set("login:token:" + userId, accessToken, 2, TimeUnit.HOURS);4.2 农机发布接口的完整实现与校验细节
农机发布是机主端的核心入口,接口本身不复杂,但校验逻辑非常多。我列举一下当时的参数校验清单:
- 分类、品牌、型号不能为空
- 价格必须在合理区间,比如日租价不能低于10元
- 图片至少上传一张,且URL必须是平台自己的存储域名,防止外链盗图
- 农机信息中的省市区编码必须是合法编码
- 同一机主同一台设备不能重复发布(重复提交拦截)
这里有个很实际的场景:机主在农田里边打电话边填发布信息,填到一半把手机锁屏了,再打开页面表单已经清空,他可能就放弃发布了。所以发布接口特意设计成草稿模式,前端调"保存草稿"接口,后端在Redis里缓存草稿内容,24小时有效。这个功能技术难度为零,但对业务留存率的提升非常明显。
4.3 自定义注解切面:把权限校验从业务代码中拆出来
平台有三种角色:机主、农户、管理员。如果每个接口都在业务代码里写死if(user.getRole()!="机主")则抛异常,代码会变成一团浆糊。我用自定义注解+Spring AOP解决:
@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface RequireRole { String[] value(); }在需要机主权限的接口上标注@RequireRole("ROLE_OWNER"),然后写一个切面,在方法执行前从Redis里取当前用户的角色,和注解中的角色做比对,不匹配直接返回403。这样业务代码里完全不用关注权限,新增接口的权限控制就是加一行注解的事。同样的套路我还用来做操作日志切面,记录关键业务动作的操作人、时间、请求参数和结果。
5. 租赁状态流转中的自动化处理:超时、到期与逆向流程
5.1 未支付订单自动关闭:Redis过期事件与定时任务的对决
订单创建后如果15分钟不支付,系统要自动关闭并释放农机占用。最开始我用的是Redis的key过期事件:每次创建订单的时候,往Redis里放一个带过期时间的key,key过期时触发监听器去关闭订单。
这个方案在测试环境一切正常,上线没几天就有订单"蒸发"了。查了半天发现,Redis的过期事件默认是不保证实时触发的,而且如果开启了持久化,key的过期事件在某些配置下根本不推送。过期事件本质是一个"尽力而为"的通知,根本不能作为业务闭环的触发器。
后来我用Spring Boot自带定时任务兜底:每两分钟扫一次订单表,查所有状态为"待支付"且创建时间超过15分钟的订单,批量关闭。为了不锁全表,我分批处理,每批100条。定时任务虽然有时间延迟,但胜在可靠,而且两分钟一次的粒度对用户完全无感。
5.2 租期到期的判定口径:自然日、工作时、系统时间谁说了算
计费到期这个问题,业务方和研发吵了半天。机主说"我的机器是农户白天干活用的,晚上他放在地里第二天还给我,这也算一天吗?",农户说"我租的时候说好24小时的,你怎么按自然日给我多算半天?"
最终定的口径是:按自然小时计算,但以订单创建时选择的时间口径为准。农户下单的时候必须选择计时方式,是按小时租、按天租还是按亩次租。按天租的时候,默认租期是24的倍数,允许超出2小时宽限期,超过宽限期开始算超时费。这个逻辑我用一个独立的租期计算服务封装,所有涉及费用计算的地方都走同一个服务,避免出现订单列表显示"已超时"但详情页费用没变的诡异情况。
5.3 退机验车与保证金原路退回的逆向流程
退机是整个租赁链条里最容易爆发纠纷的环节,平台绝对不能在这里做甩手掌柜。我的流程设计是:
- 农户在APP发起退机申请,上传农机当前照片和油量、仪表数据
- 系统将工单推送给机主,机主有24小时时间进行线下验车
- 机主验车通过后,系统根据实际用机时长计算租金,扣除后原路退回押金
- 如果机主发起异议,订单进入"售后中",后台管理员介入,双方上传证据,平台裁定
这一套流程实现时的核心难点是资金操作的一致性。押金退回必须是原路退回,如果订单同时存在优惠券抵扣、账户余额、第三方支付混合支付的情况,退款计算就会变得特别复杂。我的处理策略是:支付和退款全部通过统一支付服务处理,数据库里对每一笔资金流都记录快照,确保对账有据可查。
6. 上线前后必须复盘的三件事:查询性能、文件存储与部署方案
6.1 农机列表页卡顿的排查与优化过程
功能上线测试那天,打开农机列表页居然要三秒多,直接被测试骂了一顿。我把SQL打印出来看,问题一目了然:列表查询联了用户表、分类表、图片表、最新的订单状态,六个表left join,还做了like模糊查询分类名,能不慢吗?
当时的优化方案有几步:
- 列表接口只查农机主表和必要的冗余字段(比如机主昵称直接冗余到农机表里,不再实时join用户表)
- 分类名这种低频变化的数据全部走Redis缓存,本地数据库只保留分类表
- 列表分页用MyBatis-Plus的分页插件,配合索引优化where条件,尤其是status和location这两个高频筛选字段建联合索引
- 农机列表的热门数据做一级缓存,缓存5分钟
优化完之后列表接口从三秒降到了两百毫秒以内。这里想强调一下:能用冗余解决的性能问题就不要用join,尤其在农机这类数据量不大但查询条件复杂的业务场景里,一张宽表比一堆规范化的关联表实用得多。
6.2 图片存储从本地磁盘迈向MinIO的迁移
开发初期图省事,农机图片直接存在了服务器本地目录,接口通过Nginx做静态文件映射。本来到这也够了,但后来遇到两个问题:一是上线后用户量增长,图片一多,磁盘空间吃紧,备份开始变得笨重;二是如果用docker部署在云上,容器重建一次,本地文件就没了。
后来迁移到了MinIO:部署一个MinIO服务,创建bucket,Java后端通过SDK上传和生成对外访问的URL。迁移本身不复杂,但有几个细节值得注意:
- bucket的访问权限设置成只读,写操作只能通过后端接口完成,避免有人直接往bucket里灌垃圾文件
- 图片URL里面带一个签名参数,后端可以控制访问有效期
- 原来已上传的本地图片写了个一次性脚本迁移到MinIO,迁移完做一遍URL替换
如果你也是第一个版本用本地存储,不要觉得后面迁移很麻烦。说白了就是写一个文件读出来再传到MinIO的脚本,跑一遍就好。真正麻烦的是写清楚bucket目录结构,我按machine/{machineId}/{timestamp}.jpg这样存,后面按设备ID追溯图片非常方便。
6.3 Docker Compose一键部署编排细节
项目部署我用了Docker Compose,把Spring Boot应用、MySQL、Redis、MinIO四个服务编排在一起:
version: '3.8' services: mysql: image: mysql:5.7 environment: MYSQL_ROOT_PASSWORD: farmlease123 volumes: - mysql-data:/var/lib/mysql redis: image: redis:6 command: redis-server --requirepass farmlease123 minio: image: minio/minio command: server /data --console-address ":9001" app: build: . ports: - "8080:8080" depends_on: - mysql - redis - minio volumes: mysql-data:这里面最容易被忽略的是depends_on只是启动顺序的依赖,不保证数据库已经ready。Spring Boot应用启动时如果MySQL还没初始化完成,就会疯狂报错重试。解决办法是在应用启动命令里加一个等待脚本,或者直接用Spring Boot的重连机制配合initialization-mode: always,让数据库建表语句自动执行。
7. 项目迭代中踩过的几个坑,以及从中的实际经验
回头总结一下,有几个坑是教科书上不写,但实际做项目一定会遇到的。
第一个坑是MyBatis-Plus逻辑删除和唯一索引的冲突。农机表里有一个业务唯一键,原本想着用户删除设备后重新发布同一台机器也不会冲突,但因为逻辑删除只是update一个deleted字段,唯一索引仍然生效,导致第二次发布直接报异常。后来把唯一索引改成了联合唯一,把deleted字段纳入索引,才算解决。
第二个坑是定时任务的时区问题。线上订单明明到期了,自动关闭任务却迟迟没跑。查日志发现服务器时区是UTC,和业务要求的北京时间差8小时。这里的经验是,所有涉及时间比较的数据库字段统一用bigint存毫秒时间戳,既不受时区影响,又方便做前后端传输和排序。
第三个坑是事务内调用定时任务产生脏数据。有一次我在一个事务方法里直接调用了自动关单的Service,结果事务还没提交,查出来的订单状态还是旧的,造成了重复关单。后来强制约定:定时任务入口不允许有任何事务上下文,任务内的方法全部独立开启新事务执行,同时把处理过的订单ID记录到Redis,做一层天然的去重。
最后一个建议给做类似平台的同学:农机租赁平台上线的第一个月不要迷信复杂算法和薅羊毛的风控模型,先把基础流程跑通。支付回调、退款原路退回、验车记录存证、订单状态机不丢单,这些基本功做到位,平台的信任基础就立住了。Spring Boot在这套项目里的角色就是一辆皮卡,不华丽但皮实,能装货、能跑烂路,关键时刻不会把你扔在路上。
如果你也准备做这类行业垂直平台,不要一上来就想着大而全。先把某一个环节做到闭环,比如就用Spring Boot做出一个可靠的"发布—预约—计费—退机"主链路,你就已经跑赢市场上80%靠表单堆积起来的所谓平台了。