做上门回收系统这类项目,我一开始以为难点在"回收"两个字上——以为业务特殊、规则复杂、对接很繁琐。真正动手把一套 Java 后端从零搭起来之后才意识到,这个系统本质上就是一个典型的 O2O 预约履约系统,核心链路是"用户下单 → 回收员接单 → 上门称重 → 计价结算"。你只要把这条线吃透,废品回收、上门保洁、家电维修、宠物上门服务,这些项目的后端都能做。
这篇文章不搞那种"从入门到放弃"的泛泛教程,直接从一个能跑通的上门回收系统 Java 后端源码出发,讲清楚业务模型怎么设计、数据库表怎么建、核心接口的代码怎么写、订单状态机怎么流转,以及我在实际开发中踩过哪些坑。适合人群是:有 Java 基础、想系统了解 O2O 类项目后端如何搭建的开发者,或者正在做类似预约上门类系统的团队。
1. 先盘清楚业务再定技术栈:三种角色与一条订单主线
很多新手拿到需求第一件事就是建工程写代码,结果写到一半发现业务逻辑理不清。做回收系统也一样,第一步不是选框架,而是把业务角色、核心流程、关键状态全部盘清楚。
1.1 三种角色与一条订单主线
上门回收系统至少有三种角色,在实际工程中对应三个端:
- 用户端:在小程序里选择回收品类(废纸、塑料、金属、旧家电等),预约上门时间,填写地址和联系方式,下单后等待回收员上门。
- 回收员端:接收订单推送、抢单/接单、按预约时间上门,对物品进行分类称重,在手机上录入实际重量,系统自动计算金额,用户确认后完成结算。
- 运营后台:管理品类和价格、查看订单数据、处理纠纷和退款、管理回收员信息。
整个系统所有的业务动作都围绕一条主线展开:订单从创建到完成的履约链路。我把它分成下面几个关键节点:
- 用户提交预约单,订单状态为"待接单"
- 回收员接单,状态变为"已接单"
- 回收员上门开始服务,状态变为"已上门"
- 回收员录入各类物品实际称重数据,系统计算金额,状态变为"已称重"
- 用户确认金额,系统发起结算,状态变为"已完成"
- 过程中任意一方取消,状态变为"已取消"
这个流程看起来简单,但涉及的问题可不少:用户取消了怎么办?超时没人接单怎么办?称重后金额有争议怎么办?这些东西在技术设计时都要提前想好。
1.2 技术选型:本系统采用单体应用加核心中间件
对于大多数上门回收业务,初期订单量没有想象中那么大,一上来就搞微服务纯粹是给自己找麻烦。我这套系统就是标准的单体应用加中间件架构:
- Spring Boot 2.7.x:成熟稳定,社区资料多,出了问题也容易搜到解决方案。
- MyBatis-Plus 3.5.x:单表 CRUD 效率极高,复杂查询可以手写 SQL,兼顾开发速度和可控性。
- MySQL 8.0:存储核心业务数据,InnoDB 引擎,事务和行锁都靠它。
- Redis:存验证码、用户登录态、分布式锁,还承担了延迟队列的部分逻辑。
- RabbitMQ:订单状态变更后发消息,通知回收员端刷新列表、触发短信提醒。
- XXL-Job 或 Spring Task:定时扫描超时未接单的订单。
为什么不用微服务?这个业务初期用户量可能就是几千到几万,单体应用一台 4 核 8G 的服务器完全够用。微服务带来的注册中心、配置中心、链路追踪、网关,每一样都要额外维护成本。等订单量真正起来了,再按照"订单中心、用户中心、结算中心"拆也不迟,数据库设计和接口边界现在预留好就行。
为什么用 MyBatis-Plus 而不是 Spring Data JPA?回收系统里有很多多表关联查询和自定义统计 SQL,比如运营后台的订单报表、回收员业绩排行,手写 SQL 更容易控制和优化;团队成员接手也很容易上手,不太需要学习曲线。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 数据库建模:从用户预约到回收结算的核心表设计
业务理清了,接下来就是最关键的数据库设计。我见过太多项目因为表设计不合理,后面写接口的时候疯狂改表,费时费力还容易出数据问题。上门回收系统的表不算多,但每张表都有讲究。
2.1 六张核心业务表的关系全景
我这套系统里核心表一共六张,既有主数据也有过程数据:
| 表名 | 用途 | 关键字段 |
|---|---|---|
| recycle_user | 用户表 | openid、手机号、昵称、状态 |
| recycler | 回收员表 | 姓名、手机号、经纬度、接单状态 |
| recycle_category | 回收品类表 | 品类名称、计价单位、单价、状态 |
| recycle_order | 回收订单主表 | 订单号、用户ID、回收员ID、状态、地址快照 |
| recycle_order_item | 订单明细表 | 品类ID、预估重量、实际重量、单价、金额 |
| settlement_record | 结算记录表 | 订单号、用户ID、金额、结算状态、第三方流水号 |
另外还建议加一张 order_cancel_record 订单取消记录表,把取消人、取消原因、取消时间记录下来。做运营后台时你会发现这表很有用,纠纷处理全靠它。
2.2 回收订单主表:字段设计背后的考量
订单主表是整个系统的核心,字段设计直接影响后续所有接口的实现复杂度。我当初设计这张表的 SQL 大致如下(生产环境有调整,但主体结构一致):
sql复制CREATE TABLE `recycle_order` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`order_no` varchar(32) NOT NULL COMMENT '业务订单号',
`user_id` bigint(20) NOT NULL COMMENT '用户ID',
`recycler_id` bigint(20) DEFAULT NULL COMMENT '回收员ID,接单后写入',
`category_id` bigint(20) NOT NULL COMMENT '主品类ID',
`appointment_time` datetime NOT NULL COMMENT '预约上门时间',
`address` varchar(255) NOT NULL COMMENT '上门地址快照',
`contact_name` varchar(64) NOT NULL COMMENT '联系人姓名快照',
`contact_phone` varchar(20) NOT NULL COMMENT '联系电话快照',
`longitude` decimal(10,6) DEFAULT NULL COMMENT '上门地址经度',
`latitude` decimal(10,6) DEFAULT NULL COMMENT '上门地址纬度',
`total_amount` decimal(10,2) DEFAULT NULL COMMENT '结算总金额,称重后写入',
`status` tinyint(4) NOT NULL DEFAULT '0' COMMENT '0待接单 1已接单 2已上门 3已称重 4已完成 5已取消',
`cancel_reason` varchar(255) DEFAULT NULL COMMENT '取消原因',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_order_no` (`order_no`),
KEY `idx_user_id` (`user_id`),
KEY `idx_recycler_id` (`recycler_id`),
KEY `idx_status_create_time` (`status`, `create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
几个关键设计点聊聊我的思考:
- 业务订单号
order_no用唯一索引,而不是直接用自增 id 对外暴露。自增 id 一旦暴露,竞争对手可以根据 id 差值估算订单量,而且恶意用户还能遍历接口。订单号我采用的是"yyyyMMdd + 业务类型 + 当天自增序列",比如20250612001,再加上一个随机串防止并发重复。生成规则不复杂,后续做分库分表也不会冲突。 - 状态字段
status用 tinyint 而不是字符串。字符串状态看起来直观,比如WAIT_ACCEPT、ACCEPTED,但存储空间大、索引效率低、拼 SQL 还容易写错。用数字状态,Java 里定义枚举类做映射,写代码的时候可读性不会差。 - address、contact_name、contact_phone 这些字段为什么叫"快照"?因为用户下单之后可能修改自己的资料或常用地址,但订单履约必须以下单那一刻的信息为准。订单表里冗余保存这些信息,为的是之后发生纠纷时有据可查。这是电商订单设计里很经典的做法,新手容易忽略。
2.3 明细表与结算表:金额过程数据必须留痕
recycle_order_item 订单明细表的作用是记录每类回收物品的预估和实际数据。用户下单时可能预估为 5 公斤废纸、2 公斤塑料,但回收员上门称重后实际是 4.2 公斤废纸、1.8 公斤塑料。这些数据都要保留,方便后续价格争议时追溯。每次插入明细时,金额由后端计算后落库,而不是让前端传来多少就是多少。
settlement_record 结算表则记录结算的完整过程,包括结算金额、微信商户转账单号、回调状态等。回收员把称重数据提交后,系统计算金额,用户确认,紧接着系统调用微信商家转账接口把回收款转到用户微信零钱。这个调用是异步的,所以结算表必须有状态字段,标识"待转账、转账中、成功、失败",并且要记录第三方返回的流水号,对账的时候全靠它。
3. 核心接口源码走读:预约下单、接单派单、称重结算
表设计做完就可以写接口了。我第一次做这类项目时踩的一个坑是:上来就写 Controller,把业务逻辑全堆在 Controller 里,结果后面加需求时改得头都大了。这次我采用了标准的 Controller - Service - Mapper 三层结构,核心逻辑全部放在 Service 层。
3.1 用户预约下单接口
用户端的核心接口是创建预约单。请求参数大概长这样:品类ID、预估重量、联系人、联系电话、上门地址、预约上门时间、备注。Controller 层薄薄一层,只做参数校验和身份获取:
java复制@PostMapping("/api/order/appointment")
public Result<AppointmentVO> appointment(@RequestBody @Valid AppointmentRequest req) {
Long userId = UserContext.getUserId();
Long orderId = orderService.createAppointment(userId, req);
return Result.success(new AppointmentVO(orderId));
}
真正复杂的逻辑在 Service 层。createAppointment 方法内部大概分四步:
- 校验用户状态正常、品类是启用状态、预约时间在当前时间 30 分钟之后。
- 生成业务订单号,订单状态初始化为"待接单"。
- 插入订单主表和订单明细表,这两步放在同一个事务里。
- 发送 MQ 消息,通知附近的回收员有新订单可接。
这里有个细节很多人容易漏:预约时间不能太早也不能太晚。太早(比如当前时间 10 分钟内)用户可能还没准备好,回收员根本赶不过去;太晚(比如超过 7 天)对回收员来说排期没意义。这个校验规则我放在了 Service 层,作为一个独立方法,方便后续调整。
下单还有第二个重要问题:用户手快点了两次提交按钮怎么办?这就要靠幂等性处理,我后面专门讲。
3.2 回收员接单与附近派单
订单创建之后,回收员端要能收到新订单。这里有两种模式可以选:
- 抢单模式:系统把新订单推给附近的回收员,谁手快谁接。
- 派单模式:系统自动把订单派给距离最近且空闲的回收员。
我的系统里默认跑的是派单模式,因为上门回收业务强调时效性,让用户等太久体验不好,回收员主动抢单的意愿反而没那么重要。实现时用一条 SQL 查附近的空闲回收员,核心逻辑是基于经纬度算距离:
sql复制SELECT id, name, phone,
ROUND(6371 * 2 * ASIN(SQRT(
POWER(SIN((#{lat} - latitude) * PI() / 180 / 2), 2) +
COS(#{lat} * PI() / 180) * COS(latitude * PI() / 180) *
POWER(SIN((#{lng} - longitude) * PI() / 180 / 2), 2)
)), 2) AS distance
FROM recycler
WHERE status = 1
HAVING distance <= 3
ORDER BY distance
LIMIT 1;
这条 SQL 用了 Haversine 公式计算球面距离,单位是公里,WHERE status = 1 表示回收员在线且空闲。初期回收员数量在几千以内,这种实时计算完全没问题。等回收员数量上来了,可以引入 geohash 或者 Redis GEO 来做空间索引,但现在没必要过度设计。
派单逻辑确定之后,接单接口就得考虑并发问题——万一系统派给了 A 回收员,B 回收员刚好也点了接单,两个人都以为这单是自己的怎么办?我的做法是使用乐观更新,核心就一句话:
java复制int rows = recycleOrderMapper.updateStatusByIdAndOldStatus(
orderId, OrderStatus.PENDING.getCode(), OrderStatus.ACCEPTED.getCode(), recyclerId);
if (rows == 0) {
throw new BizException("订单已被其他回收员接走");
}
updateStatusByIdAndOldStatus 对应的 SQL 是 UPDATE recycle_order SET status = #{newStatus}, recycler_id = #{recyclerId} WHERE id = #{orderId} AND status = #{oldStatus}。UPDATE 语句自带行锁,两个并发请求同时执行时只会有一个影响行数为 1,另一个为 0,直接抛异常提示。这样做不需要显式加分布式锁,简单高效。
3.3 称重计价与结算
回收员上门后,在回收员端录入每类物品的实际重量。后端收到请求后,先查询品类单价表,然后逐条计算金额:
java复制for (OrderItemCmd item : items) {
BigDecimal categoryPrice = categoryMapper.selectPriceById(item.getCategoryId());
BigDecimal itemAmount = item.getActualWeight()
.multiply(categoryPrice)
.setScale(2, RoundingMode.HALF_UP);
// 累加总金额 ...
}
这里的金额计算可能看起来只是一个乘法,但如果不注意类型就会出大问题。重量和单价都必须用 BigDecimal,如果哪一步用了 double,0.1 加 0.2 就会得到 0.30000000000000004 这种结果,用户端显示金额时会造成严重的信任危机。
称重数据提交后,订单状态变为"已称重",系统还需要等待用户确认。用户确认后调用结算服务,生成结算记录并向微信发起商家转账。这个流程跨了系统和第三方接口,不可能在一个事务里完成,我采用的是"本地事务 + 消息通知 + 回调确认"的模式:先落库结算记录状态为"待转账",再通过 MQ 发送转账请求,微信回调成功后再更新状态。这样即使 MQ 挂了或者微信接口超时,结算记录还在,可以靠定时任务做对账和补偿。
4. 订单状态机与超时取消:这类系统最重要的隐性复杂度
很多做 CRUD 出身的人会忽略状态管理,觉得无非就是一个字段而已。实际上订单状态不管理好,就会出现"用户取消了一单,回收员还在上门路上"这种状态错乱的事故。我在这块花的时间比写接口还多。
4.1 状态定义与六种流转路径
我在代码里定义了一个订单状态枚举:
| 状态 | 数值 | 含义 | 可操作角色 |
|---|---|---|---|
| PENDING | 0 | 待接单 | 用户可取消 |
| ACCEPTED | 1 | 已接单 | 回收员履约,用户可取消(需联系客服) |
| ARRIVED | 2 | 已上门 | 回收员操作 |
| WEIGHED | 3 | 已称重 | 用户确认金额 |
| COMPLETED | 4 | 已完成 | 系统结算 |
| CANCELLED | 5 | 已取消 | 系统/用户/运营 |
核心流转路径其实就三条:
- 正常履约:0 → 1 → 2 → 3 → 4
- 下单后取消:0 → 5,或者接单后协商取消 1 → 5
- 超时未接单自动取消:0 → 5
我强烈建议把状态校验收敛到一个统一的方法里,比如 OrderStateMachine.transit(order, expectedState, targetState)。每个状态变更入口都走这个方法,而不是在 Service 里到处写 if (order.getStatus() != 1) throw ...。这样做的好处是状态流转规则一目了然,后面新增状态或者调整流转路径时只改一处。
4.2 超过 30 分钟未接单自动取消
用户下单后如果一直没人接单,体验会越来越差。我配置了一个定时任务,每分钟执行一次,把状态为"待接单"且创建时间超过 30 分钟的订单自动取消,并发通知用户"可稍后重新下单或联系客服"。
实现方式建议用 Spring Task,简单直接:
java复制@Scheduled(fixedDelay = 60_000)
public void cancelTimeoutOrders() {
List<Long> orderIds = recycleOrderMapper.selectTimeoutOrders(30);
if (CollectionUtils.isEmpty(orderIds)) {
return;
}
for (Long orderId : orderIds) {
recycleOrderMapper.cancelOrder(orderId, OrderStatus.CANCELLED.getCode(), "超时未接单自动取消");
}
}
selectTimeoutOrders 对应的 SQL 是 SELECT id FROM recycle_order WHERE status = 0 AND create_time < NOW() - INTERVAL 30 MINUTE LIMIT 200。注意加 LIMIT,避免一次扫描太多数据拖垮数据库。有人可能会问,用 Redis 延迟队列不更优雅吗?确实可以,但考虑到订单量,定时任务扫描已经够用,而且逻辑直观、出了问题也好排查。如果未来订单量涨到每天几十万单,再考虑引入 Redis ZSet 做延迟队列也不迟。
4.3 用户取消与"已接单后取消"的处理策略
用户取消订单不能一刀切。我最初的版本是用户随便取消,结果回收员白跑了一趟,投诉率飙升。后来调整成策略模式:
- 订单状态为"待接单"(0):用户可以直接取消,无任何成本。
- 订单状态为"已接单"(1)、"已上门"(2):用户不能一键取消,页面提示请联系客服处理。因为回收员已经出发或者已经到达,盲取消会造成人力浪费。运营人员在后台看到申请后,根据实际情况判断是否扣除部分违约金或者全额取消。
- 订单状态为"已称重"(3):流程已经走到结算环节,不再允许取消,有纠纷只能走售后流程。
这个策略在业务上不算复杂,但如果不写清楚,前端很容易把取消按钮一直亮着,让用户产生错误预期。
5. 源码里值得借鉴的工程化细节与避坑经验
业务功能做完之后,考验工程水平的就是这些看似不起眼的细节。以下几条是我实际开发中被坑过之后总结出来的,每一条背后都有真实教训。
5.1 金额计算一律用 BigDecimal,代码 Review 时把这条写进规范
做回收系统必然涉及金额计算,所有涉及金额的字段在 Java 代码里一律用 BigDecimal,数据库里用 decimal(10,2)。double 和 float 是科学计算用的,不是金融计算用的。
有人说 BigDecimal 性能不好、代码啰嗦,但这属于典型的用性能换正确性。回收订单金额通常就是几块到几十块,性能损失可以忽略,但算错一分钱都会被用户骂。常用写法:
java复制BigDecimal amount = actualWeight
.multiply(unitPrice) // 重量 × 单价
.setScale(2, RoundingMode.HALF_UP); // 保留两位小数,四舍五入
另外一个教训:不要在代码里到处散落 setScale。封装一个 MoneyUtils 工具类,统一提供 calculateAmount(weight, price) 和 format(BigDecimal) 方法,这样所有金额计算的精度规则只需维护一处。
5.2 下单接口的幂等设计:防止用户重复提交
用户在小程序里点了下单一瞬间,网络卡顿、手抖多点了一下,就可能产生两条订单。解决方式有很多,我采用的是"客户端 token + 唯一索引"方案:
用户端每次进入下单页面时,向后端请求一个 clientToken(UUID)。真正提交下单接口时必须带上这个 token。后端在下单事务里执行两步:
sql复制INSERT INTO order_token (token, user_id, create_time) VALUES (#{token}, #{userId}, NOW());
INSERT INTO recycle_order (...) VALUES (...); -- 订单数据
order_token 表的 token 字段有唯一索引。重复请求时,第二个请求插入 token 直接报主键冲突,事务回滚,根本不会走到插入订单那一步。后端捕获异常后返回"请勿重复提交"。
这套方案不需要加 Redis 分布式锁,实现简单,而且 token 记录还能用来做下单转化率分析,一举两得。
5.3 事务边界:绝不在事务里做远程调用
我之前在结算功能里犯过一个典型的错误:把"插入结算记录 + 调用微信转账接口"放在了同一个事务里。结果微信转账接口响应慢,数据库连接被长时间占用,后台日志报了一堆连接池不够用的警告。
后来我把流程拆成了两步:
- 事务 A:插入结算记录,状态为"待转账",然后提交事务。
- 事务外:发送 MQ 消息,由消费端调用微信转账接口。
- 微信异步回调:再开一个事务,更新结算记录状态为"成功",同时更新订单状态为"已完成"。
这样做的好处是,数据库事务的粒度小,连接占用时间短;微信接口失败也不影响主流程,后续有重试和补偿机制兜底。对于所有涉及第三方接口的操作,这个原则都适用。
5.4 数据库连接池、索引与慢 SQL 排查
系统上线初期订单量不大,很多问题不会暴露;等订单增长后,慢 SQL 会最先找上门。我提前做的一件正确的事,是在订单表的 status + create_time 上建了联合索引。定时任务扫描超时订单、运营后台查询待处理订单,走的都是这个索引,速度稳定在几十毫秒。
还有一点是关于 MyBatis-Plus 的 selectById 和自定义 SQL 的取舍。简单的单表查询用 MyBatis-Plus 没问题,但涉及多表关联、统计汇总,一定要手写 SQL,并且在本地用 EXPLAIN 看一下执行计划。我见过同事图省事用 MyBatis-Plus 的 list 方法查出全表数据后在内存里做分组计算,数据量到 10 万条的时候接口直接超时。这类问题在系统上线前就要通过代码 Review 拦住。
最后再分享一点我自己的体会。做这类"传统行业 + 互联网"的系统,后端开发最核心的能力不是会用多少框架,而是把业务规则正确翻译成代码。上门回收系统的业务并不复杂,但"预约、接单、称重、计价、结算"这五个环节环环相扣,任何一环的状态和金额出了问题,都会直接影响用户和回收员的信任。把状态机理顺、把金额精度控好、把并发和幂等处理明白,这个项目就已经成功了大半。如果你也是刚接触这类 O2O 预约项目,建议先别急着把微服务、分布式事务这些重型武器全上,老老实实把单体应用做扎实,等业务量验证了再演进,才是更务实的路径。
