# 订单系统怎么做才不乱?从状态机、库存到对账的完整设计 > 很多团队做订单系统,最后做成了"带金额的 CRUD"。这篇不讲框架,讲清楚订单系统的业务设计:状态怎么流转、库存什么时候扣、价格怎么算、退款退货怎么走、月底怎么对账。 做过订单系统的开发者,大概率被这些问题折磨过: - 用户取消了订单,库存怎么还没回来? - 订单状态改了,但没人知道是谁改的? - 退款退了一半,订单还显示"已完成"? - 月底对账,订单系统和支付系统金额对不上? - 并发下单,同一个库存被扣了两次? 这些问题不是 bug,是**业务设计没想清楚**。 订单系统的难度不在代码量,在于**状态、库存、金额这三件事必须严丝合缝**。这篇就把这三件事讲透。 --- ## 一、订单的核心数据模型 先建模型,别一上来就写接口。 ```text 订单(Order) ├── 订单明细(OrderItem) 一个订单多个商品 ├── 支付记录(Payment) 一个订单多笔支付(定金+尾款) ├── 退款记录(Refund) 一个订单多笔退款 ├── 订单操作日志(OrderLog) 状态变更全记录 └── 发货记录(Shipment) 一个订单多次发货 ``` | 实体 | 核心字段 | 说明 | |------|----------|------| | 订单 | 订单号、客户ID、总金额、实付金额、状态、支付方式、下单时间 | 订单主体 | | 订单明细 | 订单ID、商品ID、数量、单价、优惠金额 | 一个订单多行商品 | | 支付记录 | 订单ID、支付金额、支付方式、支付时间、第三方流水号 | 对账用 | | 退款记录 | 订单ID、退款金额、退款原因、退款状态、退款时间 | 退款追溯 | | 操作日志 | 订单ID、操作人、操作类型、操作前状态、操作后状态、时间 | 谁改了什么 | **最常见的坑:把所有信息塞进一张订单表。** 商品信息、支付信息、物流信息全塞订单字段里。一个订单买 5 个商品时存不下,改状态时没有日志可查。 **原则:订单表只存订单级别的信息,明细、支付、退款、日志各自独立成表。** --- ## 二、订单状态机:最容易出乱子的地方 订单状态是订单系统的命脉。状态设计不好,后面全是坑。 ### 标准状态流转 ```text 待付款 → 待发货 → 待收货 → 已完成 ↓ ↓ ↓ 已取消 已取消 已取消 ``` | 状态 | 含义 | 触发条件 | |------|------|----------| | 待付款 | 下单成功,等用户付款 | 创建订单 | | 待发货 | 已付款,等商家发货 | 支付成功回调 | | 待收货 | 已发货,等用户签收 | 商家发货 | | 已完成 | 用户签收 / 自动确认 | 确认收货 | | 已取消 | 用户取消 / 超时取消 | 用户操作 / 定时任务 | ### 状态流转的 3 条铁律 **1. 状态只能往前走,不能跳** 不能从"待付款"直接跳到"已完成"。必须经过"待发货→待收货→已完成"。每一步都有业务含义,跳了就丢失了业务节点。 **2. 取消只能从特定状态走** - 待付款 → 可取消(没付钱,库存要还) - 待发货 → 可取消(付了钱,要退款) - 待收货 → 一般不可取消(货已经发了,只能走退货) - 已完成 → 不可取消(只能走售后退货) **3. 每次状态变更必须记日志** ```sql INSERT INTO order_log (order_id, operator_id, from_status, to_status, remark) VALUES (1001, 456, 'PENDING_PAYMENT', 'PENDING_SHIPMENT', '用户支付成功'); ``` 三个月后老板问"这个订单怎么回事",翻日志就知道每一步谁干的、什么时候干的。 ### 用枚举 + 状态机管理,不要用 if/else ```java public enum OrderStatus { PENDING_PAYMENT, // 待付款 PENDING_SHIPMENT, // 待发货 PENDING_RECEIPT, // 待收货 COMPLETED, // 已完成 CANCELED; // 已取消 // 定义合法的状态流转 public boolean canTransitionTo(OrderStatus target) { return switch (this) { case PENDING_PAYMENT -> target == PENDING_SHIPMENT || target == CANCELED; case PENDING_SHIPMENT -> target == PENDING_RECEIPT || target == CANCELED; case PENDING_RECEIPT -> target == COMPLETED; default -> false; }; } } ``` 每次改状态前校验: ```java public void updateStatus(Long orderId, OrderStatus target) { Order order = orderMapper.selectById(orderId); if (!order.getStatus().canTransitionTo(target)) { throw new BizException("订单状态不允许从 " + order.getStatus() + " 变更为 " + target); } orderMapper.updateStatus(orderId, target); orderLogMapper.insert(new OrderLog(orderId, currentUserId, order.getStatus(), target)); } ``` 非法状态变更直接拦截,比一堆 `if (status == xxx)` 清晰得多。 --- ## 三、库存扣减:最容易被并发坑的地方 库存是订单系统里最容易出 bug 的环节。核心问题是:**什么时候扣库存?** ### 三种扣减时机 | 方案 | 时机 | 优点 | 风险 | |------|------|------|------| | 下单扣库存 | 创建订单时扣 | 不会超卖 | 大量未付款订单占库存 | | 付款扣库存 | 支付成功后扣 | 库存占用时间短 | 付款时可能没库存了 | | 预扣库存 | 下单预扣,超时释放 | 兼顾两者 | 实现复杂 | **推荐方案:下单预扣 + 超时释放。** - 下单时预扣库存(锁定,不是真扣) - 支付成功后,预扣转真扣 - 30 分钟未支付,预扣自动释放 ### 并发问题怎么解决? 100 个人同时下单买最后 1 件商品,不能都下单成功。 **不要在应用层用 `synchronized`**——多实例部署就失效了。用数据库乐观锁: ```sql -- 扣库存时加条件:库存必须 >= 扣减量 UPDATE product_stock SET stock = stock - #{quantity} WHERE product_id = #{productId} AND stock >= #{quantity} ``` 如果返回影响行数 = 0,说明库存不够,下单失败。这一条 SQL 就能防超卖。 如果并发量更高,可以用 Redis 预扣 + 异步落库: ```java // Redis 原子扣减 Long remaining = redisTemplate.opsForValue() .decrement("stock:" + productId, quantity); if (remaining < 0) { redisTemplate.opsForValue().increment("stock:" + productId, quantity); throw new BizException("库存不足"); } ``` ### 取消订单时库存怎么还? ```text 待付款取消 → 释放预扣库存 待发货取消 → 退款 + 增加库存 ``` **关键:还库存也要做幂等。** 一个取消操作不能触发两次还库存。用订单号 + 操作类型做唯一约束: ```sql INSERT INTO stock_change (order_id, product_id, change_type, quantity) VALUES (1001, 501, 'RELEASE', 3) ON DUPLICATE KEY UPDATE id = id; ``` --- ## 四、价格计算:不要相信前端传的金额 这是订单系统最大的安全红线。 ### 错误做法 ```java // 直接用前端传的金额下单 Order order = new Order(); order.setTotalAmount(dto.getTotalAmount()); // 危险! orderMapper.insert(order); ``` 前端传 `totalAmount = 0.01`,你信了,用户用 1 分钱买了 1000 块的东西。 ### 正确做法 ```java public Order createOrder(OrderDTO dto) { BigDecimal totalAmount = BigDecimal.ZERO; for (OrderItemDTO item : dto.getItems()) { // 从数据库查商品当前价格,不用前端传的价格 Product product = productMapper.selectById(item.getProductId()); BigDecimal itemAmount = product.getPrice() .multiply(new BigDecimal(item.getQuantity())); totalAmount = totalAmount.add(itemAmount); } // 优惠金额也在后端算 BigDecimal discount = calculateDiscount(totalAmount, dto.getCouponId()); BigDecimal actualAmount = totalAmount.subtract(discount); Order order = new Order(); order.setTotalAmount(totalAmount); order.setDiscountAmount(discount); order.setActualAmount(actualAmount); // ... } ``` **原则:前端只传"买什么、买多少、用哪张券",金额全部后端算。** ### 金额用什么类型? - **用 `BigDecimal`,不要用 `double`/`float`**——浮点数有精度问题 - **数据库用 `DECIMAL(12,2)`**,不要用 `FLOAT` - **金额单位用"分"存 `Long` 也行**,但要注意前端展示时除以 100 --- ## 五、退款退货:正向流程的反向操作 退款和退货是两件事: - **退款**:还钱,不一定退货(虚拟商品、服务) - **退货**:还货,退款是伴随的 ### 退款流程 ```text 用户申请退款 ↓ 商家审核 ↓ 审核通过 → 调用支付渠道退款 ↓ 退款成功 → 更新订单状态 → 还库存(如果退货) ``` ### 退款的 3 个坑 **1. 退款不能改原订单状态** 很多系统直接把订单状态改成"已退款"。这是错的。 一个订单可能有部分退款(买了 3 个商品退 1 个)。如果直接改原订单状态,"已完成"变成"已退款",那剩下 2 个商品的成交记录就没了。 正确做法:**退款独立成表,原订单状态不变,通过退款记录关联。** 查一个订单的完整生命周期,要同时看订单 + 退款记录。 **2. 退款要幂等** 支付渠道的退款接口可能超时重试。如果重试时又退了一次款,就出大事了。 ```java public void refund(Long orderId, BigDecimal amount) { // 检查是否已退款 Refund exist = refundMapper.findByRequestId(requestId); if (exist != null) { return; // 已退款,幂等返回 } // 调支付渠道退款 paymentClient.refund(orderId, amount); // 记录退款 refundMapper.insert(new Refund(orderId, amount, requestId)); } ``` **3. 退款金额不能超过实付金额** ```java BigDecimal totalRefunded = refundMapper.sumByOrderId(orderId); if (totalRefunded.add(refundAmount).compareTo(order.getActualAmount()) > 0) { throw new BizException("退款总额不能超过实付金额"); } ``` --- ## 六、对账:月底别让财务加班 订单系统和支付系统是两套系统,金额必须对得上。 ### 对账逻辑 ```text 订单系统的实付金额 vs 支付系统的收款金额 订单系统的退款金额 vs 支付系统的退款金额 ``` 每天跑一次对账任务,找出差异: ```sql -- 找出订单有金额但支付系统无记录的 SELECT o.* FROM orders o LEFT JOIN payment p ON o.id = p.order_id WHERE o.status != 'CANCELED' AND p.id IS NULL; -- 找出金额不一致的 SELECT o.id, o.actual_amount, p.paid_amount FROM orders o JOIN payment p ON o.id = p.order_id WHERE o.actual_amount != p.paid_amount; ``` 对账差异通常有几种原因: - 支付回调丢失(订单没更新状态但钱到了) - 退款异步延迟(退款提交了但渠道还没处理完) - 手动改了订单金额(没有同步到支付系统) **有对账才有兜底。** 没有对账的系统,金额出问题可能要等用户投诉才发现。 --- ## 七、数据权限:谁能看哪些订单 和 CRM 一样,订单系统也要数据权限: | 角色 | 数据范围 | 效果 | |------|----------|------| | 销售 | 本人 | 只看自己负责客户下的单 | | 主管 | 本部门 | 看本部门所有销售的订单 | | 财务 | 全部金额 | 看所有订单但不能改 | | 老板 | 全部 | 看全部 | 用数据权限拦截器按 `owner_id` 或 `dept_id` 过滤,业务代码不用管。 --- ## 八、订单看板:老板要看什么 | 指标 | 说明 | |------|------| | 本月订单数 | 业务量 | | 本月成交金额 | 营收 | | 待发货订单 | 该发货了 | | 待付款超时数 | 该取消了 | | 退款金额 | 售后成本 | | 转化率 | 线索→订单 | --- ## 九、踩坑总结 ### 1. 订单号不要用自增 ID 自增 ID 暴露业务量,而且分库分表时冲突。用雪花算法或"日期 + 随机数"。 ### 2. 不要硬编码状态值 `if (status == 1)` 这种代码,3 个月后没人记得 1 是什么。用枚举或字典。 ### 3. 支付回调要做幂等 支付渠道会重复回调。同一个订单号回调两次,不能改两次状态。 ### 4. 取消订单要有时间窗口 待付款订单 30 分钟不付自动取消。不要让待付款订单永久占着库存。 ### 5. 金额不要做加减运算 `amount + discount` 可能出现精度问题。用 `BigDecimal` 的 `add` 方法。 --- ## 十、总结:订单系统的本质是状态 + 库存 + 金额 | 核心问题 | 解决方案 | |----------|----------| | 状态怎么流转 | 状态机 + 日志 | | 库存什么时候扣 | 下单预扣 + 超时释放 | | 价格怎么算 | 后端重算,不信前端 | | 退款怎么走 | 独立退款表,不改原订单状态 | | 金额怎么保证 | BigDecimal + 对账 | | 并发怎么防 | 乐观锁 / Redis 原子扣减 | 订单系统不是代码写不出来,是业务设计想不清楚。状态、库存、金额这三件事想透了,代码就是照着实现而已。 --- **体验完整的订单管理能力**: - 后台演示:http://www.dlforgelab.com:8084/forge/login (admin / 123456) - Gitee:https://gitee.com/ForgeLab/forge-admin - GitHub:https://github.com/yaomindong1996/forge-admin > 你做订单系统踩过最大的坑是什么?超卖、金额不一致、还是状态乱跳?评论区聊聊。