后端开发日记③:订单创建、模拟支付与重新提交

2211 字
11 分钟
后端开发日记③:订单创建、模拟支付与重新提交

配送订单是整个系统的主线。前端看到的是“提交表单”,后端却要同时处理订单号、服务类型、初始状态、支付记录、过程日志、历史版本和用户归属。本篇先记录用户侧从创建到进入审核的链路。

1. 两类订单共用主表#

快递和外卖共享收货人、船舶、接驳点、坐标与费用等字段,差异字段则按服务类型填写:

服务类型业务前缀主要差异字段
TAKEOUTWM平台名称、平台订单号、骑手电话
EXPRESSKD快递公司、快递单号

Service 会先校验服务类型,再只接受对应业务需要的字段,避免一条记录同时出现外卖平台和快递公司的矛盾数据。

2. 订单号由后端生成#

订单号由“业务前缀 + 日期 + 当日四位流水号”组成。后端按当天前缀查询最大订单号,再生成下一序号:

WM202608240001
KD202608240001

订单号属于业务事实,不能依赖前端生成。当前实现通过同步方法缩小同一实例内的并发窗口,数据库还应使用唯一索引作为最后一道约束。

3. 创建订单不是只插一张表#

创建操作位于事务中,至少完成三件事:

  1. 从 SecurityUtils 取得当前微信用户并写入 wxUserId;
  2. 保存订单主记录,设置删除标记、操作人类型和初始状态;
  3. 写入 CREATE_ORDER 过程日志,让进度页有第一条可追踪记录。

DTO 只承载用户能够填写的内容,订单状态、审核状态、用户 ID 等字段由后端决定,避免越权赋值。

4. 模拟支付也要保存支付事实#

项目暂未接入真实支付,因此提供模拟支付接口,但后端仍按正式流程建模。支付时先检查订单归属与当前状态,再写入 HyOrderPaymentRecord,随后更新订单状态并增加 PAY 流程日志。

订单状态校验
-> 新增支付记录
-> 更新订单状态
-> 新增过程日志

这些操作必须在同一事务内完成。否则可能出现“支付记录成功但订单仍待支付”,或者“订单已支付却没有账务记录”的不一致。

5. 修改与历史快照#

订单被修改前会保存历史快照。快照不是简单复制一份无意义的数据,而是为凭证版本、驳回重提和管理端追溯提供依据。

用户修改订单时需要同时满足:

  • 订单属于当前登录用户;
  • 订单未被逻辑删除;
  • 当前状态允许编辑;
  • 请求只更新用户端允许维护的字段。

配送凭证会记录上传时对应的订单版本,因此查看历史订单时可以知道图片属于哪次提交。

6. 驳回后的重新提交#

审核驳回不是创建新订单,而是在保留历史的前提下修正原订单。重新提交会保存旧版本、应用新的业务字段、重置审核相关状态,并写入 RESUBMIT_AUDIT 日志。

这样既保持订单号连续,也保留了“为什么修改、修改前是什么”的审计线索。

7. 超时未支付自动取消#

定时任务调用 cancelTimeoutUnpaidOrders(15) 处理超过 15 分钟仍未支付的订单。批量更新状态之外,还要为每个订单补充 PAYMENT_TIMEOUT_CANCEL 日志,用户才能在进度中理解订单为什么结束。

定时任务只负责触发,具体判断仍放在订单 Service 中。这样人工调用、调度调用和后续补偿任务可以复用同一规则。

8. 创建方法逐行拆解#

创建订单的核心方法没有直接保存 DTO:

@Transactional(rollbackFor = Exception.class)
public HyDeliveryOrder createUserDeliveryOrder(
UserDeliveryOrderCreateDto createDto) {
if (createDto == null) {
throw new ServiceException("订单参数不能为空");
}
if (isBlank(createDto.getServiceType())) {
throw new ServiceException("服务类型不能为空");
}
checkUserOrderRequired(createDto);
HyDeliveryOrder order = buildUserDeliveryOrder(createDto);
deliveryOrderMapper.insertHyDeliveryOrder(order);
insertUserOrderProcessLog(
order, PROCESS_TYPE_CREATE_ORDER,
"用户下单", "用户提交订单,订单进入待支付状态");
return order;
}

第一层校验处理对象和关键枚举为空,checkUserOrderRequired 再根据快递或外卖检查差异字段。两种校验分开后,错误信息可以更具体,也避免构造实体后才发现缺少必要数据。

buildUserDeliveryOrder 负责生成订单号、写入当前用户 ID、设置初始 UNPAID 状态和系统字段。insertHyDeliveryOrder 使用数据库生成的主键回填 orderId,后面的过程日志才能正确关联订单。

最后两次插入处在同一个事务中。如果日志插入失败,订单也会回滚,不会出现进度页找不到创建节点的半成品记录。

9. 支付方法为什么先判断超时#

模拟支付的关键顺序是:

HyDeliveryOrder order = mapper.selectById(orderId);
checkUserOrderOwner(order, currentUserId);
if (!"UNPAID".equals(order.getOrderStatus())) {
return order;
}
Date payTime = DateUtils.getNowDate();
if (isPaymentTimeout(order, 15, payTime)) {
cancelTimeoutUnpaidOrder(order, payTime, 15);
return mapper.selectById(orderId);
}
insertSuccessPaymentRecord(order, payTime);
updateOrderStatus(orderId, "PENDING_AUDIT");
insertUserOrderProcessLog(order, "PAY", "支付成功", ...);

顺序不能改成“先支付成功,再判断创建时间”。用户在第 16 分钟点击支付时,定时任务可能还没有扫描到该订单,但业务上它已经超时,所以支付接口自身也必须执行同样的超时规则。

当订单已经不是 UNPAID 时直接返回最新订单,体现了基础幂等性:重复点击不会再次新增支付流水。更严格的真实支付还需要支付平台流水号唯一索引和回调幂等表。

10. 支付流水字段表达什么#

支付成功记录的构造如下:

HyOrderPaymentRecord payment = new HyOrderPaymentRecord();
payment.setOrderId(order.getOrderId());
payment.setWxUserId(order.getWxUserId());
payment.setPaymentType("ORDER_INCOME");
payment.setPaymentDirection("IN");
payment.setPaymentAmount(
defaultDecimal(order.getDeliveryFee()));
payment.setPaymentStatus("SUCCESS");
payment.setPayChannel("OTHER");
payment.setCreateTime(payTime);

paymentType 说明为什么产生资金变化,paymentDirection 说明资金方向,paymentStatus 说明当前结果。三者不能合并成一个“已支付”字段,因为后续还有取消退款、退款处理中和失败等情况。

配送费为空时归一化为零,而不是把 null 写入金额字段。真正接入支付平台后,金额还要以最小货币单位或固定精度类型传输,不能使用浮点数。

11. 修改订单先保存哪个版本#

修改方法先读取数据库中的旧订单,然后计算版本号并写历史:

Long oldVersionNo = getCurrentOrderVersionNo(
existOrder.getOrderId());
HyDeliveryOrderHistory history = buildOrderHistory(
existOrder, oldVersionNo,
"用户修改订单基础信息", wxUserId);
historyMapper.insertHyDeliveryOrderHistory(history);
HyDeliveryOrder update = buildUserDeliveryOrderUpdate(
updateDto, existOrder);
deliveryOrderMapper.updateHyDeliveryOrder(update);

历史表保存的是“修改前”而不是“修改后”。如果先更新主表再复制,快照和当前记录会完全相同,失去审计意义。

这里还把 updateDto.serviceType 强制设为原订单类型。快递订单不能通过修改接口变成外卖订单,因为两种订单的单号前缀、差异字段和校验规则已经在创建时确定。

12. 重新提交如何防止重复#

重新提交前先处理两个状态:

if ("PENDING_AUDIT".equals(order.getOrderStatus())) {
return order;
}
if (!isRejectedOrder(order.getOrderStatus())) {
throw new ServiceException(
"当前订单状态不允许重新提交审核");
}

第一个分支让重复请求获得相同结果,第二个分支阻止从待支付、配送中或终态跳到待审核。通过后才保存历史、更新业务字段、清空驳回原因并写 RESUBMIT_AUDIT 日志。

这类“重复时返回成功结果,非法路径时明确报错”的区分很有用。重复请求属于网络和交互中可能出现的正常情况,非法状态跳转则说明调用方逻辑有问题。

13. 定时取消为什么仍要逐单处理#

任务先按截止时间查询所有超时未支付订单:

Date deadline = DateUtils.addMinutes(now, -timeoutMinutes);
List<HyDeliveryOrder> rows =
mapper.selectTimeoutUnpaidOrderList(deadline);
for (HyDeliveryOrder row : rows) {
if (cancelTimeoutUnpaidOrder(row, now, timeoutMinutes)) {
canceledCount++;
}
}

直接一条 SQL 批量改状态虽然更快,却不容易为每笔订单生成过程日志、通知和取消原因。当前逐单处理优先保证业务记录完整。

数据量变大后,可以分页扫描或按主键分批,避免一个大事务锁住过多记录;还可以用条件更新再次限制 order_status = 'UNPAID',防止扫描后用户刚好完成支付造成竞态。

14. 本篇小结#

订单创建的重点不在 CRUD,而在一次动作留下完整事实:谁创建、当前处于什么状态、支付是否发生、修改过几次、为什么进入下一阶段。把这些信息拆成订单、支付记录、历史快照和过程日志以后,后续审核、飞行和异常处置才能可靠衔接。

支持与分享

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

打赏
后端开发日记③:订单创建、模拟支付与重新提交
https://firefly.cuteleaf.cn/posts/backend-dev-diary-3/
作者
Yore
发布于
2026-07-04
许可协议
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