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

配送订单是整个系统的主线。前端看到的是“提交表单”,后端却要同时处理订单号、服务类型、初始状态、支付记录、过程日志、历史版本和用户归属。本篇先记录用户侧从创建到进入审核的链路。
1. 两类订单共用主表
快递和外卖共享收货人、船舶、接驳点、坐标与费用等字段,差异字段则按服务类型填写:
| 服务类型 | 业务前缀 | 主要差异字段 |
|---|---|---|
TAKEOUT | WM | 平台名称、平台订单号、骑手电话 |
EXPRESS | KD | 快递公司、快递单号 |
Service 会先校验服务类型,再只接受对应业务需要的字段,避免一条记录同时出现外卖平台和快递公司的矛盾数据。
2. 订单号由后端生成
订单号由“业务前缀 + 日期 + 当日四位流水号”组成。后端按当天前缀查询最大订单号,再生成下一序号:
WM202608240001KD202608240001订单号属于业务事实,不能依赖前端生成。当前实现通过同步方法缩小同一实例内的并发窗口,数据库还应使用唯一索引作为最后一道约束。
3. 创建订单不是只插一张表
创建操作位于事务中,至少完成三件事:
- 从
SecurityUtils取得当前微信用户并写入wxUserId; - 保存订单主记录,设置删除标记、操作人类型和初始状态;
- 写入
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,而在一次动作留下完整事实:谁创建、当前处于什么状态、支付是否发生、修改过几次、为什么进入下一阶段。把这些信息拆成订单、支付记录、历史快照和过程日志以后,后续审核、飞行和异常处置才能可靠衔接。
支持与分享
如果这篇文章对你有帮助,欢迎分享给更多人或打赏支持!




























