后端开发日记⑤:异常工单、退款与重派单

正常订单沿固定路径前进,异常工单则要决定“原订单接下来怎么办”。项目把用户反馈和管理端处置拆开:用户只描述问题,管理端根据实际情况选择关闭、取消退款或生成重派单。
1. 用户提交异常反馈
提交接口接收订单 ID、异常类型和描述。Service 首先校验参数,再检查订单确实属于当前微信用户:
Long wxUserId = SecurityUtils.getUserId();HyDeliveryOrder order = orderMapper.selectById(dto.getOrderId());checkUserOrderOwner(order, wxUserId);创建人类型、创建人 ID、异常来源、初始严重程度和处理状态全部由后端补充,不能由前端决定。
2. 异常单号表达业务来源
异常单号按类型使用不同前缀:取消类使用 QX,补发类使用 BF,普通差错使用 CP,后面拼接日期和四位流水号。
单号不仅用于展示,也方便客服看到编号时快速判断大类。不过单号前缀不能代替数据库里的 exceptionType,机器判断仍应使用稳定枚举字段。
3. 三种处置不能强行合并
直接关闭
适用于无需改变订单和资金的情况。后端把工单设为 CLOSED,记录关闭结果、处理人和关闭时间,并向原订单追加一条异常处理日志。
取消退款
这是一个跨表动作:
新增退款方向的支付记录 -> 原订单改为 CANCELED -> 工单改为 COMPLETED -> 记录 REFUND 过程日志退款金额默认取订单配送费,也允许管理端传入实际金额。支付方向、渠道、状态和退款原因分别保存,不能只在备注里写“已退款”。
生成重派单
重派不是修改原订单,而是复制配送所需字段生成新订单,并通过 parentOrderId 关联原订单。新订单直接进入 WAIT_DISPATCH,工单记录 resultOrderId,原订单与新订单各写一条过程日志。
4. 防止重复处置
工单处于 COMPLETED 或 CLOSED 后不允许再次处理。生成重派单前还要检查 resultOrderId,避免重复点击创建多个子订单。
这些判断必须发生在事务内。单纯依靠前端 loading 或按钮禁用,无法阻止两个并发请求同时到达服务器。
5. 事务为什么重要
退款和重派都会同时修改多张表。任意一步失败都应该整体回滚:
@Transactional(rollbackFor = Exception.class)public HyExceptionTicket refundCancel(...) { // 支付记录、订单、工单、过程日志}如果没有事务,可能出现退款记录已生成但订单仍在配送,或者子订单已创建但工单没有关联结果。这样的数据比接口报错更难处理,因为每张表单独看都像是有效记录。
6. 原订单、工单和结果订单
异常链路最终形成三个角色:
| 记录 | 回答的问题 |
|---|---|
| 原订单 | 哪次配送发生了问题 |
| 异常工单 | 问题是什么、由谁处理、处理结果如何 |
| 结果订单 | 是否产生了重派或补发任务 |
关系字段比把所有信息塞进一张超长备注更适合查询和统计,也方便前端从异常详情跳转到原订单与重派单。
7. 用户反馈方法逐段理解
用户提交反馈的入口使用事务和同步方法:
@Transactional(rollbackFor = Exception.class)public synchronized HyExceptionTicketsubmitUserExceptionFeedback(UserExceptionFeedbackDto dto) { if (dto == null || dto.getOrderId() == null) { throw new ServiceException("订单ID不能为空"); } String exceptionType = getRequestExceptionType(dto); if (isBlank(exceptionType)) { throw new ServiceException("异常类型不能为空"); } if (isBlank(dto.getDescription())) { throw new ServiceException("异常描述不能为空"); }
Long wxUserId = SecurityUtils.getUserId(); HyDeliveryOrder order = orderMapper.selectById(dto.getOrderId()); checkUserOrderOwner(order, wxUserId); // 构造并插入工单}getRequestExceptionType 兼容 exceptionType 和旧字段 exceptionFeedback,兼容逻辑集中在 Service 边界,后面的数据库和处置代码始终使用统一值。
synchronized 可以减少单实例内生成相同流水号的概率,但它不是跨节点锁。因此异常单号仍应有数据库唯一索引,并准备好冲突后的重试策略。
8. 异常单号如何解析最大流水
String prefix = getExceptionOrderPrefix(exceptionType);String dateText = DateUtils.dateTime();String maxNo = mapper .selectMaxExceptionOrderNoByPrefixAndDate(prefix, dateText);int nextSerial = 1;int serialStart = prefix.length() + dateText.length();if (!isBlank(maxNo) && maxNo.length() >= serialStart + 4) { nextSerial = Integer.parseInt( maxNo.substring(serialStart, serialStart + 4)) + 1;}return prefix + dateText + String.format("%04d", nextSerial);例如当天已有 CP202608260009,下一张普通差错单就是 CP202608260010。serialStart 根据前缀和日期长度计算,避免在字符串中写死截取位置。
查询最大值再加一仍有并发窗口。更高并发下可以使用数据库序列表、Redis 原子自增或插入冲突后重试,最终都应配合唯一索引。
9. 取消退款代码为什么分成四步
@Transactional(rollbackFor = Exception.class)public HyExceptionTicket refundCancelAdminExceptionTicket( Long exceptionId, AdminExceptionTicketActionDto actionDto) { HyExceptionTicket ticket = getExistingExceptionTicket(exceptionId); HyDeliveryOrder order = getExistingOrder(ticket.getOrderId()); checkExceptionTicketCanProcess(ticket);
BigDecimal amount = actionDto.getRefundAmount() == null ? defaultAmount(order.getDeliveryFee()) : actionDto.getRefundAmount(); insertCancelRefundPaymentRecord(order, amount, actionDto); updateDeliveryOrderStatus(order.getOrderId(), "CANCELED"); updateExceptionTicketStatus( ticket, "COMPLETED", "订单已取消,退款记录已生成", null); insertAdminOrderProcessLog( order, "REFUND", "取消退款", "记录退款过程", null); return mapper.selectById(exceptionId);}先读取工单,再沿 orderId 找到订单,保证处置对象一致。checkExceptionTicketCanProcess 阻止已完成或已关闭工单再次处理。
资金记录用 paymentDirection = OUT 表示支出,订单改为取消,工单记录处理完成,日志留下操作原因。四份数据共同描述一次退款,缺少任何一份都会让对账或详情展示不完整。
10. 重派单为什么复制字段而不是复制对象
重派单构造时逐项复制配送所需字段:
HyDeliveryOrder child = new HyDeliveryOrder();child.setOrderNo(generateDerivedOrderNo("CP"));child.setParentOrderId(source.getOrderId());child.setServiceType(source.getServiceType());child.setWxUserId(source.getWxUserId());child.setTransferPointId(source.getTransferPointId());child.setReceiverName(source.getReceiverName());child.setReceiverPhone(source.getReceiverPhone());child.setShipLongitude(source.getShipLongitude());child.setShipLatitude(source.getShipLatitude());child.setDeliveryFee(source.getDeliveryFee());child.setOrderStatus("WAIT_DISPATCH");child.setVerifyStatus("MANUAL_PASSED");child.setDelFlag("0");如果直接序列化或反射复制整个对象,原订单主键、当前任务 ID、审核时间、失败状态等字段也可能被带入。白名单复制虽然代码更长,却明确说明哪些业务信息可以继承。
子订单使用新的订单号和主键,通过 parentOrderId 指回原订单。它已经经过人工处置,因此初始状态是待调度、审核状态是人工通过,而不是重新走用户支付流程。
11. resultOrderId 是怎样防重复的
if (ticket.getResultOrderId() != null) { throw new ServiceException("该异常工单已生成重派单");}子订单插入成功后,工单保存 resultOrderId。后续请求不仅能判断是否已经重派,也能直接跳转到结果订单。
并发情况下两个请求仍可能同时在检查时看到空值。可以进一步对工单行加锁,或使用条件更新:只有 result_order_id is null 时才能写入。受影响行数为零的请求应回滚自己刚创建的子订单。
12. 事务之外还需要补偿意识
当前退款记录是本地资金记录,可以和订单事务一起回滚。未来接入真实微信退款后,外部退款成功不能被数据库回滚。
更完整的流程可以拆为:
创建本地退款单 PENDING -> 提交数据库事务 -> 调用支付平台 -> 根据平台结果更新 SUCCESS / FAILED -> 定时查询长时间 PENDING 的记录这样外部调用失败不会让订单数据库长事务一直占用连接,也能通过退款单号进行幂等和对账。
13. 本篇小结
异常处理的核心不是“改成已处理”,而是把处理结果真正落到订单、资金和新配送链路上。明确三种动作、建立父子关系、增加防重复校验并使用事务,才能让异常闭环可追溯。
支持与分享
如果这篇文章对你有帮助,欢迎分享给更多人或打赏支持!





























