ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Java家政服务平台实战:订单状态机与并发控制避坑指南

Java家政服务平台实战:订单状态机与并发控制避坑指南

简介:《基于Java的家政服务平台的设计和实现》是一份完整的毕业设计论文文档,面向计算机相关专业学生、Java初学者及需要完成类似课题的开发者,可用于毕业设计选题、项目方案规划或系统设计参考。文档详细规划了一个面向管理员、雇主和雇员三类角色的家政服务系统,功能覆盖个人中心、雇主与雇员管理、资料认证、服务项目、需求信息、预约申请、签订合同、评价留言及系统管理等模块,并采用Spring Boot、Java与MySQL作为技术栈,对系统架构、数据库设计、业务流程与数据安全管理均做了说明。文档还着重分析了角色权限划分、服务预约处理流程、界面友好性设计以及用户身份验证、数据加密等实用方案,有助于读者理解信息管理系统从需求到落地的完整思路。资源包共1个docx文件、大小约1.07MB,内容包含中英文摘要、目录、需求分析、系统设计及实现描述等,便于整体学习。目前已有49人学习下载,适合作为论文撰写、项目答辩或Spring Boot实践学习的参考资料。

1. 基于 Java 的家政服务平台:需求比想象中多一环

提到“基于 Java 的家政服务平台”,很多人第一反应是“用户下单、阿姨接单”的 CRUD 项目。真动手后会发现,它远不止两张表加几个接口:预约时间冲突、自动派单、服务状态流转、支付回调、平台抽佣、阿姨提现、退款取消、评价结算,这些都是订单数据在真实业务里的延伸。只要有一条链路没理顺,系统就只是“能跑”,不是“能用”。这篇笔记写给两类人:准备用 Java 做家政平台毕设或项目实战的同学,以及想快速搭一套可运营原型的初创团队。我会按自己做过类似项目的顺序,讲清业务建模、表结构、接口实现、并发控制和几个最容易翻车的部署坑。

2. 技术选型与系统边界:把“能跑”和“能上线”分开

家政服务平台的业务链条比普通后台管理系统长,但并发量通常又没到需要微服务的程度。技术选型的核心不是“哪个框架新”,而是“出了问题你能不能定位”。我见过不少团队一上来就拆七八个微服务,结果一次简单的改派订单,要排查三个服务之间的消息是否到账。家政平台的关键在于把订单状态这条主线守住,其余都是围绕它转的辅助模块。

2.1 为什么不用微服务:家政平台的业务规模决定技术边界

一个家政平台从单城市起步,常见规模是几百个阿姨、每天几百到几千单。这个量级下,一台中等配置的服务器跑单体应用绰绰有余。微服务带来的分布式事务、服务间调用链路、重复消息处理,每一个都会消耗大量开发时间。我一般会按“模块化单体”来做:Maven 多模块,业务代码按 user、order、aunt、pay、evaluate 分包,但最终打成一个 war 或 jar 部署。这样代码边界清晰,将来某个模块压力大了,再单独拆服务也不伤筋骨。

模块拆分上,我习惯分成四个部分:

  • homeservice-api:对外接口层,放 Controller 和 DTO;
  • homeservice-core:领域服务层,订单、派单、结算等核心逻辑;
  • homeservice-dao:数据访问层,实体和 Mapper;
  • homeservice-common:工具类、常量、全局异常处理。

这个分法不是为了“像微服务”,而是让新人接手时一眼知道该改哪里。家政平台最常见的改动是调整派单规则或加一种优惠券,如果这些逻辑散落在 Controller 里,后期维护会非常痛苦。

2.2 四条核心业务流程:从预约到结算的链路拆解

家政服务的主链路不是一条直线,而是四条互相咬合的线。把这四条线的状态流转画清楚,表结构才能定下来。

用户侧流程:选服务、选地址、选时间、选阿姨、支付、等待上门;阿姨侧流程:接单、按时上门、开始服务、上传完成凭证、等待用户确认;平台侧流程:审核阿姨、自动派单、改派、退款处理、佣金结算、阿姨提现;资金侧流程:用户支付、平台服务费、阿姨补贴、优惠券核销、退款入账。

这些流程最终都会落到订单状态上。我常用的订单状态如下:

状态码状态名说明
10待支付用户下单但未付款
20待派单已支付,等待系统或管理员派单
30待服务已派单,阿姨未开始服务
40服务中阿姨已开始服务
50待确认服务完成,等待用户确认
60已完成用户确认或超时自动确认
70已取消订单取消,按规则退款

注意“待确认”和“已完成”一定要分开,因为结算和评价依赖“用户确认”这个时间点。如果服务一完成就自动结算,很容易出现阿姨提前点完成、用户实际没见到人的纠纷。

2.3 技术栈清单:Spring Boot + MyBatis-Plus + MySQL + Redis 的取舍

对于家政平台这种业务,我推荐 Spring Boot + MyBatis-Plus + MySQL + Redis,理由很直接:上手成本低、生态文档全、招人容易,而且单机性能完全够用。具体选型如下:

技术选型理由边界
JDK8 或 11 即可不要为“新语法”升级
Spring Boot内嵌 Tomcat,快速启动用你团队熟悉的稳定版本
MyBatis-Plus单表 CRUD 效率高,分页插件好用复杂报表自己写 XML
MySQLInnoDB 事务可靠,支持行锁不要用 MyISAM
Redis缓存验证码、Token、热点配置不要用来存核心账单
定时任务Spring@Scheduled够用暂时不上分布式调度

工程骨架先用命令建出来,我一般是:

mkdir -p homeservice/{api,core,dao,common} cd homeservice

然后创建根目录pom.xml,引入核心依赖:

<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> </parent> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> </dependencies>

这里的重点是 version 交给 Spring Boot BOM 管理,不要自己写死某个具体版本号。实际项目中因为版本踩坑的次数远多于业务逻辑问题,少写版本号就是少给自己挖坑。工程骨架跑起来后,下一步不是写代码,而是先定表和状态机。

3. 从需求到表结构:一张订单状态机撑起核心链路

很多人在设计表时把订单表当成“快递单”,只记录谁买了什么、什么时候发货。家政订单的特殊性在于状态多、时间敏感、涉及服务人员和资金结算。如果表结构里没有把状态流转和金额字段设计好,后面每一个接口都会写得很难看。

3.1 订单状态机设计:六个状态与流转约束

状态机不是一张字典表,而是一组业务规则。比如“待支付”只能取消或支付,“待派单”在支付后退款前不能直接变成“服务中”,“服务中”不能允许用户随意取消。这些约束如果散落在 if 判断里,迟早会漏。

我习惯建一个order_state配置表来维护合法流转,同时代码里用枚举做硬校验:

当前状态触发事件下一状态约束条件
待支付支付成功待派单实付金额与订单一致
待支付用户取消已取消无实际扣款
待派单自动派单待服务阿姨可接单且时间不冲突
待派单用户申请退款已取消全额退款
待服务阿姨开始服务服务中必须由接单的阿姨操作
服务中用户确认完成待确认服务时长至少达标
待确认用户确认已完成确认后触发结算
待确认超时自动确认已完成服务完成后 24 小时无人处理

这个表的价值在于,测试人员可以按表逐条设计用例。开发时只要保证每个状态变更入口都调用同一个changeOrderStatus方法,就不容易出现“用户取消了,阿姨还在服务中”的尴尬状态。

3.2 核心表 ER 设计:用户、阿姨、服务项、订单、订单明细

家政平台最少需要六张业务表:用户表、阿姨表、服务项表、订单主表、订单状态流水表、阿姨请假表。地址信息可以单独拆user_address,订单里存一份地址快照,避免用户改了默认地址后历史订单跟着变。

设计时有两个容易忽略的点。一是阿姨和订单之间的关系:一个阿姨同一时间只能服务一个订单,但订单表里aunt_id在“待派单”时是空的,不能把阿姨作为订单表非空字段。二是金额字段必须精确:订单金额、优惠金额、实付金额、平台抽佣、阿姨结算额都要分开存,不要只存一个“最终价格”。否则月底对账时少一分钱都查不出来。

3.3 用 SQL 建表:关键 DDL 与字段说明

订单主表是整张业务网的中心,我会这样建:

CREATE TABLE `home_order` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `sn` VARCHAR(32) NOT NULL COMMENT '订单号', `business_id` VARCHAR(64) NOT NULL COMMENT '幂等业务号,前端生成', `user_id` BIGINT NOT NULL COMMENT '下单用户', `aunt_id` BIGINT DEFAULT NULL COMMENT '接单阿姨,派单前为空', `service_item_id` BIGINT NOT NULL COMMENT '服务项', `status` TINYINT NOT NULL DEFAULT 10 COMMENT '订单状态:10待支付 20待派单 30待服务 40服务中 50待确认 60已完成 70已取消', `service_time` DATETIME NOT NULL COMMENT '预约服务时间', `address_snapshot` VARCHAR(512) NOT NULL COMMENT '地址快照,JSON字符串', `amount` DECIMAL(10,2) NOT NULL COMMENT '订单原价', `discount_amount` DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT '优惠金额', `pay_amount` DECIMAL(10,2) NOT NULL COMMENT '实付金额', `version` INT NOT NULL DEFAULT 0 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_business_id` (`business_id`), KEY `idx_user_status` (`user_id`, `status`), KEY `idx_aunt_status` (`aunt_id`, `status`), KEY `idx_service_time` (`service_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='家政订单主表';

这里重点说明几点。business_id的唯一索引是幂等的兜底,比“先查再插”可靠得多。status用 TINYINT 而不是 VARCHAR,虽然枚举更直观,但 TINYINT 占空间小、索引效率高,代码里维护枚举映射即可。amount系列必须用 DECIMAL(10,2),不要用 DOUBLE,否则前端可能出现 199.999999 的精度问题。version是并发控制的关键,派单和接单操作都会用到。

订单状态流水表也不可省略:

CREATE TABLE `order_log` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `order_id` BIGINT NOT NULL, `from_status` TINYINT DEFAULT NULL, `to_status` TINYINT NOT NULL, `operator_type` TINYINT NOT NULL COMMENT '1用户 2阿姨 3系统 4管理员', `operator_id` BIGINT DEFAULT NULL, `remark` VARCHAR(255) DEFAULT NULL, `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_order_id` (`order_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单状态流水表';

这张表在排查问题时价值巨大。有一天用户投诉“我没取消,订单怎么被取消了”,有了流水表就能直接看出是谁在什么时间把状态从待支付改成了已取消。没有这张表,就只能翻日志或者靠当事人口述。

4. 核心接口实现:下单、派单与并发控制的三个关键点

表结构确定后,核心接口就是三件事:下单要防重复,派单要防乱派,接单要防并发。这三个问题在单机调试时都不明显,一旦真实用户并发操作就会原形毕露。

4.1 下单接口:事务边界与幂等性设计

用户点击“立即预约”时,如果网络抖动,前端可能会自动重试。请求发两次,订单就会建两条。常见做法是前端生成一个businessId,后端在事务里根据唯一索引拦截重复请求。

@Transactional(rollbackFor = Exception.class) public Long createOrder(CreateOrderRequest req) { // 1. 幂等校验:business_id 唯一索引兜底 if (orderMapper.existsByBusinessId(req.getBusinessId())) { throw new BizException("请勿重复提交"); } // 2. 校验服务项和地址 ServiceItem item = serviceItemMapper.selectById(req.getItemId()); if (item == null || item.getStatus() != 1) { throw new BizException("服务项已下架"); } // 3. 计算金额:原价 * 时长 - 优惠 BigDecimal amount = item.getPrice() .multiply(req.getHours()) .subtract(couponService.discount(req.getCouponId())) .setScale(2, RoundingMode.HALF_UP); // 4. 插入订单,初始状态为待支付 HomeOrder order = new HomeOrder(); order.setSn(generateOrderSn()); order.setBusinessId(req.getBusinessId()); order.setUserId(req.getUserId()); order.setServiceItemId(item.getId()); order.setAmount(amount); order.setPayAmount(amount); order.setStatus(OrderStatus.PENDING_PAY.getCode()); orderMapper.insert(order); // 5. 写状态流水,便于追溯 orderLogMapper.insert(OrderLog.create(order.getId(), null, order.getStatus())); return order.getId(); }

这段代码的事务边界是“校验 + 插入订单 + 写流水”,不包括调用支付网关。原因很简单:支付是外部系统,不能占用本地事务太久。如果这里同步调支付,支付超时会导致整个事务回滚,用户反而无法下单。常见做法是先落一条“待支付”订单,再异步发起支付,通过支付回调把状态改成“待派单”。

@Transactional(rollbackFor = Exception.class)里的rollbackFor一定要写,否则自定义BizException是运行时异常才能触发回滚,但 Spring 默认只回滚RuntimeException,如果抛的是受检异常,订单可能被莫名其妙提交。

4.2 派单策略:按距离、评分、接单量的可配置规则

派单有两种模式:用户自己选阿姨,或系统自动派。家政平台更适合“系统推荐 + 用户确认”,因为用户对阿姨的可用时间不确定。自动派单的核心是候选池筛选和排序。

public List<Aunt> recommendAunts(HomeOrder order) { // 1. 基础过滤:已过审核 + 可接单 + 服务城市匹配 LambdaQueryWrapper<Aunt> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(Aunt::getAuditStatus, 1) .eq(Aunt::getStatus, 1) .eq(Aunt::getServiceCity, order.getCity()); List<Aunt> aunts = auntMapper.selectList(wrapper); // 2. 排除与预约时间重叠的阿姨(请假、已有订单) aunts.removeIf(aunt -> auntLeaveService.overlap(aunt.getId(), order.getServiceTime()) || orderService.hasOrder(aunt.getId(), order.getServiceTime())); // 3. 按距离、评分、近七日接单量加权排序 aunts.sort(Comparator .comparingDouble((Aunt a) -> distance(a, order) * distanceWeight) .thenComparingDouble(a -> -a.getScore() * scoreWeight) .thenComparingInt(Aunt::getOrderCount)); return aunts.stream().limit(3).collect(Collectors.toList()); }

distanceWeight、scoreWeight不要写死在代码里,放到配置中心或数据库里,运营改权重不用重新发版。这个示例用的是先全表查、再内存过滤,适合阿姨量在几千以内的情况。当阿姨量超过几万,就要改成 MySQL 的ST_Distance_Sphere做半径圈选,否则接口会越来越慢。

另一个容易忽略的坑:排序中a.getScore()已经是上线评分,如果直接做乘法,评分越高权重越大,但那会导致高分阿姨永远被派单、低分阿姨永远没单,平台留不住人。常见做法是用“近 7 日接单量”做反向调节,同样分数下优先派给单量少的阿姨,实现人工平衡。

4.3 订单状态流转与乐观锁:防止“阿姨重复接单”

阿姨端抢单是高并发场景。两个阿姨同时点“接单”,如果代码是“先查状态,再 update”,两个请求都会查到订单还是待派单,然后都更新成功,最后订单的aunt_id被后写入的人覆盖。这个问题必须用数据库层面的条件更新解决。

@Transactional(rollbackFor = Exception.class) public boolean acceptOrder(Long orderId, Long auntId) { HomeOrder order = orderMapper.selectById(orderId); if (order == null) { throw new BizException("订单不存在"); } // 乐观锁:状态 + version 双重校验 int rows = orderMapper.acceptByOrderIdAndVersion( orderId, auntId, OrderStatus.PENDING_ASSIGN.getCode(), OrderStatus.PENDING_SERVICE.getCode(), order.getVersion()); if (rows != 1) { throw new BizException("手慢了,订单已被接"); } return true; }

对应的 Mapper SQL 是:

UPDATE home_order SET aunt_id = #{auntId}, status = #{toStatus}, version = version + 1, update_time = NOW() WHERE id = #{orderId} AND status = #{fromStatus} AND version = #{version}

rows != 1表示更新被拒:要么订单状态已经不是待派单,要么 version 已经变了,也就是有人抢先完成操作。通过where status = ? and version = ?两个条件,数据库行锁把并发问题挡在底层,应用层只需要处理“抢单失败”的提示。

如果你的系统做了集群部署,多个实例同时更新同一行时,数据库的version条件依然有效,不依赖本地锁,所以这个方案在将来水平扩展时也不用推翻。只有在阿姨接单接口需要“先到先得”且响应要求极高时,才会考虑 Redis 分布式锁,但那就不是家政平台当前要考虑的级别了。

5. 避坑排查:从本地调试到部署的 5 个现场问题

这部分是家政平台从开发到上线最容易踩的坑。每一条我都见过真实翻车现场,按“现象、原因、解决”记下来,给你当排障手册。

5.1 本地没乱码,部署后中文全变问号

  • 现象:本地 Windows 开发环境一切正常,部署到 Linux 服务器后,接口查询订单地址、备注等信息,中文全部显示为 “??”。
  • 原因:本地 MySQL 客户端连接的库是 utf8mb4,而服务器上 Docker 启动 MySQL 时没有指定字符集,建库语句用了默认的 latin1。连接串里也没加characterEncoding=UTF-8,导致写入时字符被转码丢失。
  • 解决:建库时显式声明字符集,连接串也强制指定。
CREATE DATABASE homeservice DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;

同时检查application.yml中的数据库连接配置:

url: jdbc:mysql://localhost:3306/homeservice?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai

Docker 启动 MySQL 时也建议直接指定:

docker run -d -p 3306:3306 \ -e MYSQL_ROOT_PASSWORD=yourpass \ -e MYSQL_DATABASE=homeservice \ mysql:8.0 \ --character-set-server=utf8mb4 \ --collation-server=utf8mb4_unicode_ci

这个坑的迷惑性在于本地可能因为连接工具自动配置而正常,一定要在部署前检查“建库字符集 + 连接字符集 + 表字符集”三处。

5.2 阿姨同时接单,订单状态被覆盖成“同一个人”

  • 现象:压测时模拟 10 个阿姨同时抢同一订单,接口全部返回成功,但最后订单关联的阿姨是最后一次请求的那位,前面几位都收到了“抢单成功”的假消息。
  • 原因:接单接口只做了“查状态 -> 更新 aunt_id -> 更新状态”,两个操作之间没有原子性保证。应用层无法阻止两个请求同时通过状态检查。
  • 解决:把“检查状态 + 更新”合并成一条 UPDATE 语句,用WHERE status = ? AND version = ?做条件,影响行数为 1 才算成功。不要先 select 再 update。

额外提示:有些同学会把“接单成功”的消息放在事务提交前发,导致事务回滚了但消息已经推给阿姨。正确做法是在事务提交后,通过@TransactionalEventListener(phase = AFTER_COMMIT)发通知。

5.3 定时派单把单派给了当天请假的阿姨

  • 现象:凌晨 2 点系统自动派单,把 10 点的保洁订单派给了一位当天请假的阿姨,用户在 9 点收到“阿姨已接单”,结果阿姨根本没来。
  • 原因:派单任务只查询了阿姨表的status = 1,但这个状态表示“阿姨审核通过、可接单”,不代表阿姨当天没有请假。请假数据存在另外一张表,没有被派单逻辑关联。
  • 解决:在recommendAunts的过滤条件里加入请假表排除。
SELECT a.* FROM aunt a WHERE a.status = 1 AND a.audit_status = 1 AND NOT EXISTS ( SELECT 1 FROM aunt_leave l WHERE l.aunt_id = a.id AND l.leave_date = #{serviceDate} AND l.start_time < #{serviceEndTime} AND l.end_time > #{serviceStartTime} )

除了查询层面,还要在阿姨请假的接口里做联动:请假当天如果已有“待服务”订单,必须触发改派或提醒用户换人,不能只改阿姨状态。

5.4 上传阿姨身份证后,应用频繁 Full GC

  • 现象:管理后台批量上传阿姨身份证照片后,接口响应越来越慢,日志里频繁出现 Full GC,最后应用 OOM。
  • 原因:上传接口接收的是前端 base64 字符串,后端Base64.decode后在内存中生成了一张 5MB 的 byte 数组,同时 Spring MVC 还要把整个请求体解析进内存。多个请求并发时,老年代瞬间被打满。
  • 解决:限制上传大小,改用MultipartFile流式接收,把文件直接写入磁盘或云对象存储,数据库只存文件 URL,不要存二进制内容。
spring: servlet: multipart: max-file-size: 2MB max-request-size: 5MB

对应接口参数改为@RequestParam("file") MultipartFile file。之后还要在异常处理器里捕获MaxUploadSizeExceededException,返回给前端明确的“图片不能超过 2MB”,否则用户只看到 500,又该来问客服了。

5.5 金额在前端显示成 199.999999

  • 现象:用户下单后,前端在订单详情页看到应付金额是 199.999999,而不是 200.00。
  • 原因:实体类的金额字段用了Double,JSON 序列化时把浮点数最原始的二进制转换结果输出了。
  • 解决:数据库用DECIMAL(10,2),Java 用BigDecimal,响应给前端的金额字段再统一处理成字符串。
public class OrderVO { @JsonSerialize(using = ToStringSerializer.class) private BigDecimal payAmount; }

ToStringSerializer会让payAmount序列化成字符串,避免 JavaScript 对大数的精度丢失。前端在计算优惠、续费等场景也要用 decimal 库,不要直接0.1 + 0.2。

6. 进阶技巧:用对账模块给平台补上“可追溯”的一环

家政平台的资损问题往往不在大额订单,而在每天几十笔优惠券分摊、退款、抽佣组合后产生的几分钱差异。这些差异靠人工对账很痛苦,所以我的建议是:在核心流程稳定后,加一个只读的对账模块,把订单、支付流水、结算单三张表每天拉一次比对,输出差异报告。

对账的核心是对pay_amount和实际支付流水金额。用一条 SQL 就能找出“订单已支付但流水缺失”的情况:

SELECT o.id, o.sn, o.pay_amount, p.pay_no, p.amount FROM home_order o LEFT JOIN payment_log p ON o.id = p.order_id WHERE o.status IN (20, 30, 40, 50, 60) AND DATE(o.create_time) = #{bizDate} AND (p.id IS NULL OR o.pay_amount != p.amount);

如果查出了差异,再结合第 3.3 节建的那张order_log状态流水表,按订单号把状态变化从头到尾拉出来,基本就能定位是支付回调没更新状态,还是结算时金额被错误分摊。

我在项目里吃过一次亏:阿姨提现时平台少发了 0.01 元,用户不痛不痒,但月底一堆阿姨来投诉。后来发现是优惠券分摊时用了两位小数,两笔订单组合后总会有尾差。现在我的习惯是:所有中间计算用四位小数,最终入账时setScale(2, RoundingMode.HALF_UP),并且每次结算生成一条附带计算明细的流水记录。这样再怎么查,都有据可依。对账模块不会给系统带来直接收益,却能让你在数据出错时不必靠玄学排障。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进