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

1939 字
10 分钟
后端开发日记⑤:异常工单、退款与重派单

正常订单沿固定路径前进,异常工单则要决定“原订单接下来怎么办”。项目把用户反馈和管理端处置拆开:用户只描述问题,管理端根据实际情况选择关闭、取消退款或生成重派单。

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 HyExceptionTicket
submitUserExceptionFeedback(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. 本篇小结#

异常处理的核心不是“改成已处理”,而是把处理结果真正落到订单、资金和新配送链路上。明确三种动作、建立父子关系、增加防重复校验并使用事务,才能让异常闭环可追溯。

支持与分享

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

打赏
后端开发日记⑤:异常工单、退款与重派单
https://firefly.cuteleaf.cn/posts/backend-dev-diary-5/
作者
Yore
发布于
2026-07-10
许可协议
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