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

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

管理端订单详情上有多个操作按钮,但后端不能把它们都实现成“接收一个新状态并保存”。状态是业务规则,不是普通字段。本篇记录如何把审核、飞行和送达设计为明确的动作接口。

1. 动作接口代替通用改状态#

管理端提供的核心动作包括:

动作结果
审核通过订单进入待调度
审核回退订单回到可修改、可重提状态
取消订单订单进入取消终态
开始飞行关联任务并进入飞行中
确认送达订单进入已送达终态
标记失败订单进入失败,并生成异常线索

每个动作都有独立 Service 方法,例如 approveAdminDeliveryOrder、startFlightAdminDeliveryOrder。接口只接受动作需要的参数,前端无法随意把订单从待支付直接改成已送达。

2. 当前实现先确认订单,再由动作决定目标状态#

每个动作都会先读取最新订单并确认记录存在:

HyDeliveryOrder order = getExistingAdminOrder(orderId);
updateAdminOrderStatus(
order, "WAIT_DISPATCH", "MANUAL_PASSED",
"", "", null);

目标状态由 approveAdminDeliveryOrder 等后端方法写死,前端不能在请求体中自由传入任意状态,这是动作接口已经建立的第一层边界。

不过当前 updateAdminOrderStatus 主要负责写入字段,还没有完整校验每个动作允许的来源状态。也就是说,若有人绕过页面直接调用“确认送达”接口,仅靠按钮隐藏并不足以阻止非法跳转。本文后面的条件更新部分会继续说明如何补上这层校验。

3. 审核通过与审核回退#

审核通过会记录审核人、审核时间和审核意见,并把订单送入待调度阶段。审核回退除了写入原因,还要保留订单原有资料,让小程序用户能够修改后重新提交。

审核结果不是一段备注,而是一条可追踪的业务事件,因此同时写入过程日志:

processType: AUDIT
processTitle: 审核通过 / 审核回退
operatorType: SYS_USER
operatorId: 当前管理员
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_order
set order_status = 'DELIVERING',
current_task_id = #{taskId}
where order_id = #{orderId}
and order_status = 'WAIT_DISPATCH'

如果受影响行数为 0,说明订单已被其他请求处理,应重新查询并返回明确提示。也可以增加版本号实现乐观锁。状态检查和条件更新结合后,业务提示清晰,数据库也能在最后一步阻止并发覆盖。

13. 本篇小结#

状态机的价值是把不允许发生的跳转挡在 Service 层。为每个业务动作建立明确入口,再把状态、关联任务和过程日志放进同一事务,订单流程才不会因为重复请求或局部失败变得不可解释。

支持与分享

如果这篇文章对你有帮助,欢迎分享给更多人或打赏支持!

打赏
后端开发日记④:审核、飞行与订单状态流转
https://firefly.cuteleaf.cn/posts/backend-dev-diary-4/
作者
Yore
发布于
2026-07-07
许可协议
CC BY-NC-SA 4.0

评论区

Profile Image of the Author
Yore
Hello, I'm Yore.
公告
欢迎来到Yore的博客!本站基于原创作者夏叶的博客网站模版创作。
分类
标签
最新动态
站点统计
文章
51
分类
2
标签
124
总字数
94,115
运行时长
0 天
最后活动
0 天前
站点信息
构建平台
EdgeOne Pages
博客版本
Firefly v6.15.0
文章许可
CC BY-NC-SA 4.0