后端开发日记④:审核、飞行与订单状态流转

管理端订单详情上有多个操作按钮,但后端不能把它们都实现成“接收一个新状态并保存”。状态是业务规则,不是普通字段。本篇记录如何把审核、飞行和送达设计为明确的动作接口。
1. 动作接口代替通用改状态
管理端提供的核心动作包括:
| 动作 | 结果 |
|---|---|
| 审核通过 | 订单进入待调度 |
| 审核回退 | 订单回到可修改、可重提状态 |
| 取消订单 | 订单进入取消终态 |
| 开始飞行 | 关联任务并进入飞行中 |
| 确认送达 | 订单进入已送达终态 |
| 标记失败 | 订单进入失败,并生成异常线索 |
每个动作都有独立 Service 方法,例如 approveAdminDeliveryOrder、startFlightAdminDeliveryOrder。接口只接受动作需要的参数,前端无法随意把订单从待支付直接改成已送达。
2. 当前实现先确认订单,再由动作决定目标状态
每个动作都会先读取最新订单并确认记录存在:
HyDeliveryOrder order = getExistingAdminOrder(orderId);updateAdminOrderStatus( order, "WAIT_DISPATCH", "MANUAL_PASSED", "", "", null);目标状态由 approveAdminDeliveryOrder 等后端方法写死,前端不能在请求体中自由传入任意状态,这是动作接口已经建立的第一层边界。
不过当前 updateAdminOrderStatus 主要负责写入字段,还没有完整校验每个动作允许的来源状态。也就是说,若有人绕过页面直接调用“确认送达”接口,仅靠按钮隐藏并不足以阻止非法跳转。本文后面的条件更新部分会继续说明如何补上这层校验。
3. 审核通过与审核回退
审核通过会记录审核人、审核时间和审核意见,并把订单送入待调度阶段。审核回退除了写入原因,还要保留订单原有资料,让小程序用户能够修改后重新提交。
审核结果不是一段备注,而是一条可追踪的业务事件,因此同时写入过程日志:
processType: AUDITprocessTitle: 审核通过 / 审核回退operatorType: SYS_USERoperatorId: 当前管理员operationReason: 审核意见4. 开始飞行先建立任务关系
订单进入飞行中之前,需要确认飞行任务存在,并把 currentTaskId 与订单关联。这样详情页、轨迹接口和过程日志都能沿同一个任务 ID 找到数据。
如果先改订单状态、后建飞行任务,中途失败就会出现“显示飞行中却没有任务”的记录。正确顺序和事务边界应该共同保证两者一致。
5. 成功和失败都是终态,但含义不同
确认送达会记录实际送达时间,订单进入正常终态。标记配送失败除了更新订单,还要保存失败原因,并按业务需要衔接异常工单。
规则上,终态操作尤其需要防重复:
- 已送达订单不能再次开始飞行;
- 已取消订单不能再次审核;
- 已失败订单不能被普通更新接口改回进行中;
- 重复确认送达不能生成多条互相冲突的日志。
6. 进度不是直接翻译状态
用户订单进度由过程日志整理而来,而不是只根据当前状态猜测。当前状态回答“现在怎样”,日志回答“怎么走到这里”。两者分开后,可以同时展示下单、支付、审核、调度、飞行与完成等历史节点。
管理端还能查看更完整的操作原因,用户端则返回经过整理的 UserOrderProgressVo,避免暴露内部备注和管理员信息。
7. Action DTO 为什么比直接传实体更合适
管理端动作使用 AdminDeliveryOrderActionDto 接收审核意见、操作原因、任务信息和异常类型。它的作用不是把所有字段都变成可选,而是让一次动作只携带必要上下文。
审核通过的 Service 代码很短:
@Transactional(rollbackFor = Exception.class)public HyDeliveryOrder approveAdminDeliveryOrder( Long orderId, AdminDeliveryOrderActionDto actionDto) { AdminDeliveryOrderActionDto action = safeAction(actionDto); HyDeliveryOrder order = getExistingAdminOrder(orderId); updateAdminOrderStatus( order, "WAIT_DISPATCH", "MANUAL_PASSED", "", "", null); insertAdminOrderProcessLog( order, "AUDIT", "审核通过", defaultIfBlank(action.getProcessContent(), "管理员审核通过,订单进入待配送状态"), action.getOperationReason()); return mapper.selectById(orderId);}safeAction 把空请求体转换为空 DTO,后续可以统一使用默认文案。getExistingAdminOrder 负责订单存在性,updateAdminOrderStatus 统一写状态与审核字段,最后重新查询数据库,保证返回值包含实际持久化结果。
如果直接接收 HyDeliveryOrder,前端就可能同时提交费用、用户 ID、删除标记等与审核无关的字段,接口边界会变得模糊。
8. 审核回退如何组织原因
回退动作需要同时保存机器状态和人工说明:
String rejectReason = defaultIfBlank( action.getRejectReason(), action.getOperationReason());String rejectExplain = defaultIfBlank( action.getRejectExplain(), action.getProcessContent());
boolean changed = updateAdminOrderStatus( order, "REJECTED", null, rejectReason, rejectExplain, null);insertAdminOrderProcessLog( order, "AUDIT", "审核回退", defaultIfBlank(action.getProcessContent(), "管理员审核回退,订单需要用户修改后重新提交"), rejectReason);if (changed) { insertOrderStatusNotification(order, "REJECTED");}rejectReason 适合保存结构化或短原因,rejectExplain 保存给用户看的补充说明,过程日志则保留这次操作的时间线内容。
updateAdminOrderStatus 返回是否真正发生状态变化。只有第一次从其他状态进入 REJECTED 时才新增通知,重复请求不会不断制造相同消息。
9. 开始飞行的代码顺序
HyDeliveryOrder order = getExistingAdminOrder(orderId);HyFlightTask task = saveAdminFlightTask(order, action);updateAdminOrderStatus( order, "DELIVERING", null, null, null, task.getId());order.setCurrentTaskId(task.getId());insertAdminOrderProcessLog( order, "FLIGHT_REGISTER", "开始飞行", "管理员登记开始飞行,订单进入配送中", action.getOperationReason());先保存飞行任务,得到本地主键以后再写入订单 currentTaskId。过程日志使用更新后的任务 ID,因此三张表可以沿同一个 ID 建立关系。
这些步骤处在事务中,本地数据库失败时可以一起回滚。不过如果 saveAdminFlightTask 内部已经成功调用真实设备平台,数据库事务无法撤销外部动作。因此外部任务最好使用稳定请求号,并在失败后查询第三方实际状态,而不是简单重发。
10. 确认送达为什么只在首次变化时发消息
updateAdminFlightArriveTime(order.getCurrentTaskId(), action);boolean changed = updateAdminOrderStatus( order, "COMPLETED", null, null, null, null);insertAdminOrderProcessLog( order, "ARRIVED", "确认送达", "管理员确认订单已送达", action.getOperationReason());if (changed) { insertOrderDeliveredNotification(order); sendOrderArrivedWechatMessage(order);}站内通知和微信推送都具有外部可见副作用。将它们放在 changed 分支,可以阻止管理员重复点击时重复提醒用户。
过程日志是否也要防重复,需要按审计要求决定。若每次请求都写日志,可以记录重复操作;若时间线只展示业务节点,则应为日志增加唯一约束或同样受状态变化控制。
11. 配送失败会衍生新业务
失败动作并不只是把状态改成 DELIVERY_FAILED:
boolean changed = updateAdminOrderStatus( order, "DELIVERY_FAILED", null, null, null, null);insertAdminOrderProcessLog( order, "DELIVERY_FAILED", "配送失败", action.getProcessContent(), action.getOperationReason());if (changed) { insertDeliveryFailedExceptionTicket(order, action); insertOrderStatusNotification(order, "DELIVERY_FAILED");}异常类型在创建工单前必须校验,因为后续统计会按类型聚合失败原因。若只把失败原因写在自由文本里,报表无法稳定统计“天气突变”和“设备低电量”等分类。
12. 并发更新还可以怎样加强
当前 Service 会先查询再更新,适合表达业务规则,但两个管理员同时操作仍可能读到相同旧状态。进一步可以使用条件更新:
update hy_delivery_orderset order_status = 'DELIVERING', current_task_id = #{taskId}where order_id = #{orderId} and order_status = 'WAIT_DISPATCH'如果受影响行数为 0,说明订单已被其他请求处理,应重新查询并返回明确提示。也可以增加版本号实现乐观锁。状态检查和条件更新结合后,业务提示清晰,数据库也能在最后一步阻止并发覆盖。
13. 本篇小结
状态机的价值是把不允许发生的跳转挡在 Service 层。为每个业务动作建立明确入口,再把状态、关联任务和过程日志放进同一事务,订单流程才不会因为重复请求或局部失败变得不可解释。
支持与分享
如果这篇文章对你有帮助,欢迎分享给更多人或打赏支持!





























