零工平台的工单,生命周期比想象中长:用户提交申请 → 雇主确认 → 开工打卡 → 完工结算 → 双方评价 → 完成。早期版本里状态就是个字符串字段,service 里随手order.setOrderStatus("WORKING"),改着改着就乱了——有人从"待确认"直接改成"已完成",支付回调还没到,订单先跳到终态了,钱和状态对不上,客服天天来要人。
后来我把状态迁移收敛成一张"合法跳转表":OrderStatusMachine。凡是没登记的迁移,直接抛异常拒绝。这篇把设计思路和踩过的坑讲清楚。
六个状态,一张迁移表
状态常量在BizConstants里定义:WAIT_ENSURE(待确认)→ WAIT_START(待开工)→ WORKING(工作中)→ WAIT_PAY(待结算)→ WAIT_COMMENT(待评价)→ FINISH(已完成),外加 CANCEL(已取消)作为分支出口。
状态机核心就一个静态表,从哪个状态能到哪些状态,写死:
publicfinalclassOrderStatusMachine{privatestaticfinalMap>ALLOWED=newHashMap<>();static{// 待确认 → 待开工 / 取消allow(BizConstants.ORDER_STATUS_WAIT_ENSURE,BizConstants.ORDER_STATUS_WAIT_START,BizConstants.ORDER_STATUS_CANCEL);// 待开工 → 工作中 / 取消allow(BizConstants.ORDER_STATUS_WAIT_START,BizConstants.ORDER_STATUS_WORKING,BizConstants.ORDER_STATUS_CANCEL);// 工作中 → 待结算allow(BizConstants.ORDER_STATUS_WORKING,BizConstants.ORDER_STATUS_WAIT_PAY);// 待结算 → 待评价(支付回调)allow(BizConstants.ORDER_STATUS_WAIT_PAY,BizConstants.ORDER_STATUS_WAIT_COMMENT);// 待评价 → 已完成(仅评价双方完成后由评价服务迁移)allow(BizConstants.ORDER_STATUS_WAIT_COMMENT,BizConstants.ORDER_STATUS_FINISH);}publicstaticvoidassertTransition(StringfromStatus,StringtoStatus){if(fromStatus==null||toStatus==null){thrownewJeecgBootException("订单状态参数无效");}if(fromStatus.equals(toStatus)){thrownewJeecgBootException("订单状态未变化");}Setnext=ALLOWED.get(fromStatus);if(next==null||!next.contains(toStatus)){thrownewJeecgBootException("订单状态不允许从["+statusLabel(fromStatus)+"]变更为["+statusLabel(toStatus)+"]");}}publicstaticbooleanisTerminal(Stringstatus){returnBizConstants.ORDER_STATUS_FINISH.equals(status)||BizConstants.ORDER_STATUS_CANCEL.equals(status);}}注意"待评价 → 已完成"这一条:注释里写了"仅评价服务迁移",意思是这段迁移不在通用的改状态接口里开放,谁都不许碰。
通用改状态接口:该挡的全挡住
对外有一个updateOrderStatus(id, orderStatus, imgs),它先做三道拦截:
// 1. 终态不可再变if(OrderStatusMachine.isTerminal(order.getOrderStatus())){throwBizException.of(BizErrorCodes.ORDER_STATUS_INVALID,"订单已结束,无法变更状态");}// 2. 高级状态必须走专用接口:打卡、结算、评价if(BizConstants.ORDER_STATUS_WORKING.equals(orderStatus)||BizConstants.ORDER_STATUS_WAIT_PAY.equals(orderStatus)||BizConstants.ORDER_STATUS_WAIT_COMMENT.equals(orderStatus)||BizConstants.ORDER_STATUS_FINISH.equals(orderStatus)){throwBizException.of(BizErrorCodes.ORDER_STATUS_INVALID,"该状态请使用打卡/结算/评价专用接口");}// 3. 状态机校验合法迁移OrderStatusMachine.assertTransition(order.getOrderStatus(),orderStatus);第二道拦截是精髓。打卡、结算、评价这些高价值状态变更,必须走各自专用接口,里面有完整的业务校验(打卡要传图片、结算要核对金额),防止有人拿通用接口绕过业务逻辑直接改状态。
支付回调里迁移状态,还要记日志
工资结算后,微信支付回调触发WAIT_PAY → WAIT_COMMENT,迁移后立刻写一条订单日志,把状态流转轨迹留下来:
publicbooleanpaySalarySuccess(StringorderSn,BigDecimalpaidAmount){JobOrderorder=queryByOrderSn(orderSn);if(order==null||!BizConstants.ORDER_STATUS_WAIT_PAY.equals(order.getOrderStatus())){throwBizException.of(BizErrorCodes.ORDER_STATUS_INVALID,"订单非待结算状态,支付回调被忽略");}OrderStatusMachine.assertTransition(order.getOrderStatus(),BizConstants.ORDER_STATUS_WAIT_COMMENT);// 条件更新,防止重复回调重复迁移booleanupdated=updateLambda().eq("id",order.getId()).eq("order_status",BizConstants.ORDER_STATUS_WAIT_PAY)// 乐观锁:status 也参与条件.set("order_status",BizConstants.ORDER_STATUS_WAIT_COMMENT).set("pay_money",paidAmount).update();if(updated){orderLogService.addOrderLog(BizConstants.ORDER_STATUS_WAIT_COMMENT,order.getId(),null,null);}returnupdated;}order_status同时出现在 WHERE 条件里,这就是最朴素的乐观锁——两个支付回调并发进来,只有一个能 update 成功,另一个 updated=false 直接丢弃,天然防重复迁移。
踩坑记录:重复回调把订单状态改穿了
现象:线上出现一笔订单,支付回调触发后订单直接到了"已完成",中间的"待评价"状态没出现过,评价表里也没有任何记录。
排查过程:查job_order_log,只有两条记录:待结算→待评价、然后直接就出现一条"已完成"。按代码逻辑,待评价→已完成只能由评价服务迁移,评价都没做怎么会完成?继续追日志,发现同一个 orderSn 的支付回调进来了两次——第一次成功把状态改成待评价,第二次回调进来时,旧代码里没有状态校验,直接又执行了评价服务的一个内部方法把状态顶到了已完成。
定位思路:问题出在"迁移入口太多"。评价服务内部为了省事,自己写了一段"如果状态是待支付就顺手改成已完成"的兼容逻辑,绕过了状态机,也绕过了日志。状态迁移没有统一收口到状态机 + 日志,谁都能偷偷改。
最终解决:两条硬规矩。第一,支付回调迁移必须带order_status乐观锁条件,重复回调只能有一次生效。第二,全项目 grep 掉所有绕过assertTransition的裸 setOrderStatus 调用,评价服务改成走专用接口,内部兼容代码删干净。从此订单日志完整,状态再没穿过。
可直接复用的清单
- 状态迁移表 +
assertTransition统一收口,非法迁移直接抛异常,别让 service 各自判断。 - 高价值状态(工作中/待结算/待评价/已完成)禁用通用改状态接口,强制走打卡/结算/评价专用接口。
- 回调类迁移用
order_status参与 WHERE 做乐观锁,重复回调天然幂等。 - 每次合法迁移写订单日志,状态轨迹可审计,排查"状态怎么变的"不用猜。
- 终态(FINISH/CANCEL)一律不可再迁移,接口入口先挡。
项目源码:https://gitee.com/gzqkl/xllg