很多团队做订单系统,最后做成了"带金额的 CRUD"。这篇不讲框架,讲清楚订单系统的业务设计:状态怎么流转、库存什么时候扣、价格怎么算、退款退货怎么走、月底怎么对账。
做过订单系统的开发者,大概率被这些问题折磨过:
这些问题不是 bug,是业务设计没想清楚。
订单系统的难度不在代码量,在于状态、库存、金额这三件事必须严丝合缝。这篇就把这三件事讲透。
先建模型,别一上来就写接口。
订单(Order)
├── 订单明细(OrderItem) 一个订单多个商品
├── 支付记录(Payment) 一个订单多笔支付(定金+尾款)
├── 退款记录(Refund) 一个订单多笔退款
├── 订单操作日志(OrderLog) 状态变更全记录
└── 发货记录(Shipment) 一个订单多次发货
| 实体 | 核心字段 | 说明 |
|---|---|---|
| 订单 | 订单号、客户ID、总金额、实付金额、状态、支付方式、下单时间 | 订单主体 |
| 订单明细 | 订单ID、商品ID、数量、单价、优惠金额 | 一个订单多行商品 |
| 支付记录 | 订单ID、支付金额、支付方式、支付时间、第三方流水号 | 对账用 |
| 退款记录 | 订单ID、退款金额、退款原因、退款状态、退款时间 | 退款追溯 |
| 操作日志 | 订单ID、操作人、操作类型、操作前状态、操作后状态、时间 | 谁改了什么 |
最常见的坑:把所有信息塞进一张订单表。 商品信息、支付信息、物流信息全塞订单字段里。一个订单买 5 个商品时存不下,改状态时没有日志可查。
原则:订单表只存订单级别的信息,明细、支付、退款、日志各自独立成表。
订单状态是订单系统的命脉。状态设计不好,后面全是坑。
待付款 → 待发货 → 待收货 → 已完成
↓ ↓ ↓
已取消 已取消 已取消
| 状态 | 含义 | 触发条件 |
|---|---|---|
| 待付款 | 下单成功,等用户付款 | 创建订单 |
| 待发货 | 已付款,等商家发货 | 支付成功回调 |
| 待收货 | 已发货,等用户签收 | 商家发货 |
| 已完成 | 用户签收 / 自动确认 | 确认收货 |
| 已取消 | 用户取消 / 超时取消 | 用户操作 / 定时任务 |
1. 状态只能往前走,不能跳
不能从"待付款"直接跳到"已完成"。必须经过"待发货→待收货→已完成"。每一步都有业务含义,跳了就丢失了业务节点。
2. 取消只能从特定状态走
3. 每次状态变更必须记日志
INSERT INTO order_log (order_id, operator_id, from_status, to_status, remark)
VALUES (1001, 456, 'PENDING_PAYMENT', 'PENDING_SHIPMENT', '用户支付成功');
三个月后老板问"这个订单怎么回事",翻日志就知道每一步谁干的、什么时候干的。
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;
};
}
}
每次改状态前校验:
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 的环节。核心问题是:什么时候扣库存?
| 方案 | 时机 | 优点 | 风险 |
|---|---|---|---|
| 下单扣库存 | 创建订单时扣 | 不会超卖 | 大量未付款订单占库存 |
| 付款扣库存 | 支付成功后扣 | 库存占用时间短 | 付款时可能没库存了 |
| 预扣库存 | 下单预扣,超时释放 | 兼顾两者 | 实现复杂 |
推荐方案:下单预扣 + 超时释放。
100 个人同时下单买最后 1 件商品,不能都下单成功。
不要在应用层用 synchronized——多实例部署就失效了。用数据库乐观锁:
-- 扣库存时加条件:库存必须 >= 扣减量
UPDATE product_stock
SET stock = stock - #{quantity}
WHERE product_id = #{productId}
AND stock >= #{quantity}
如果返回影响行数 = 0,说明库存不够,下单失败。这一条 SQL 就能防超卖。
如果并发量更高,可以用 Redis 预扣 + 异步落库:
// Redis 原子扣减
Long remaining = redisTemplate.opsForValue()
.decrement("stock:" + productId, quantity);
if (remaining < 0) {
redisTemplate.opsForValue().increment("stock:" + productId, quantity);
throw new BizException("库存不足");
}
待付款取消 → 释放预扣库存
待发货取消 → 退款 + 增加库存
关键:还库存也要做幂等。 一个取消操作不能触发两次还库存。用订单号 + 操作类型做唯一约束:
INSERT INTO stock_change (order_id, product_id, change_type, quantity)
VALUES (1001, 501, 'RELEASE', 3)
ON DUPLICATE KEY UPDATE id = id;
这是订单系统最大的安全红线。
// 直接用前端传的金额下单
Order order = new Order();
order.setTotalAmount(dto.getTotalAmount()); // 危险!
orderMapper.insert(order);
前端传 totalAmount = 0.01,你信了,用户用 1 分钱买了 1000 块的东西。
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),不要用 FLOATLong 也行,但要注意前端展示时除以 100退款和退货是两件事:
用户申请退款
↓
商家审核
↓
审核通过 → 调用支付渠道退款
↓
退款成功 → 更新订单状态 → 还库存(如果退货)
1. 退款不能改原订单状态
很多系统直接把订单状态改成"已退款"。这是错的。
一个订单可能有部分退款(买了 3 个商品退 1 个)。如果直接改原订单状态,"已完成"变成"已退款",那剩下 2 个商品的成交记录就没了。
正确做法:退款独立成表,原订单状态不变,通过退款记录关联。 查一个订单的完整生命周期,要同时看订单 + 退款记录。
2. 退款要幂等
支付渠道的退款接口可能超时重试。如果重试时又退了一次款,就出大事了。
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. 退款金额不能超过实付金额
BigDecimal totalRefunded = refundMapper.sumByOrderId(orderId);
if (totalRefunded.add(refundAmount).compareTo(order.getActualAmount()) > 0) {
throw new BizException("退款总额不能超过实付金额");
}
订单系统和支付系统是两套系统,金额必须对得上。
订单系统的实付金额 vs 支付系统的收款金额
订单系统的退款金额 vs 支付系统的退款金额
每天跑一次对账任务,找出差异:
-- 找出订单有金额但支付系统无记录的
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 过滤,业务代码不用管。
| 指标 | 说明 |
|---|---|
| 本月订单数 | 业务量 |
| 本月成交金额 | 营收 |
| 待发货订单 | 该发货了 |
| 待付款超时数 | 该取消了 |
| 退款金额 | 售后成本 |
| 转化率 | 线索→订单 |
自增 ID 暴露业务量,而且分库分表时冲突。用雪花算法或"日期 + 随机数"。
if (status == 1) 这种代码,3 个月后没人记得 1 是什么。用枚举或字典。
支付渠道会重复回调。同一个订单号回调两次,不能改两次状态。
待付款订单 30 分钟不付自动取消。不要让待付款订单永久占着库存。
amount + discount 可能出现精度问题。用 BigDecimal 的 add 方法。
| 核心问题 | 解决方案 |
|---|---|
| 状态怎么流转 | 状态机 + 日志 |
| 库存什么时候扣 | 下单预扣 + 超时释放 |
| 价格怎么算 | 后端重算,不信前端 |
| 退款怎么走 | 独立退款表,不改原订单状态 |
| 金额怎么保证 | BigDecimal + 对账 |
| 并发怎么防 | 乐观锁 / Redis 原子扣减 |
订单系统不是代码写不出来,是业务设计想不清楚。状态、库存、金额这三件事想透了,代码就是照着实现而已。
体验完整的订单管理能力:
你做订单系统踩过最大的坑是什么?超卖、金额不一致、还是状态乱跳?评论区聊聊。