电商小程序售后退款系统设计:逆向状态机、退款金额分摊与多系统回滚一致性
正向下单流程各家架构文章讲得很多,但售后退款这条逆向链路才是线上事故和资损的高发区。本文分享我们平台在售后系统重构中的一套完整设计:售后单状态机、退款金额的优惠分摊算法、退款幂等与重试、库存/优惠券/积分/分销佣金的最终一致回滚。
一、为什么售后系统值得单独设计
下单是一条"顺流":创建订单 → 锁库存 → 支付 → 发货。逆向流程完全不同:
- 入口多:仅退款(未发货)、退货退款(已发货)、换货、补发、价保退款,每个入口的资金和库存动作都不一样;
- 金额算不清:一单多商品、用了满减券、平台补贴、积分抵扣,退一件商品到底退多少钱,直接决定会不会资损或客诉;
- 状态机长:商家审核、买家退货、物流签收、质检、退款中、退款成功、退款失败,中间还要处理用户撤销、超时自动处理;
- 回滚系统多:库存要回补、优惠券要不要退回、积分怎么返、分销佣金已结算的怎么扣,任何一步失败都不能让钱退错。
我们第一版售后是写在订单服务里的 if-else,上线三个月后出现过两次资损:一次是部分退款时按订单实付平均退,导致含满减券的订单多退了;另一次是退款成功消息重复消费,库存回补了两次。重构后的整体架构如下:
┌─────────────┐ 售后申请 ┌──────────────────────┐
│ 小程序端 │ ─────────────→ │ after-sales 服务 │
└─────────────┘ │ - 售后单聚合根 │
│ - 逆向状态机 │
│ - 金额分摊计算 │
└──────────┬───────────┘
│ 领域事件
┌─────────────────────┼─────────────────────┐
↓ ↓ ↓
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ payment 服务 │ │ inventory服务 │ │ promo 服务 │
│ 退款(幂等) │ │ 库存回补 │ │ 券退回/积分 │
└──────────────┘ └──────────────┘ └──────────────┘
│ │ │
└──────────────┬──────┴─────────────────────┘
↓
┌──────────────────┐
│ RocketMQ 事务消息 │
│ + 本地消息表兜底 │
└──────────────────┘
↓
┌──────────────────┐
│ distribution 服务 │ 佣金冻结/回冲
└──────────────────┘
核心原则:售后单是逆向链路的聚合根,所有外部动作(退款、回库存、退券)都由售后单状态机驱动,通过领域事件+本地消息表保证最终一致,绝不允许前端直接调用支付退款。
二、售后单数据模型与状态机
2.1 表结构设计
售后单(after_sales_order)核心字段:
CREATE TABLE after_sales_order (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
after_sales_no VARCHAR(32) NOT NULL UNIQUE COMMENT '售后单号 AS+时间戳+随机',
order_no VARCHAR(32) NOT NULL COMMENT '原订单号',
order_item_id BIGINT NOT NULL COMMENT '售后针对的订单商品行,整单退可多行',
type TINYINT NOT NULL COMMENT '1仅退款 2退货退款 3换货 4补发',
reason_code VARCHAR(32) NOT NULL COMMENT '售后原因码',
status VARCHAR(32) NOT NULL COMMENT '状态机状态',
apply_refund_fee DECIMAL(12,2) NOT NULL COMMENT '用户申请退款金额',
real_refund_fee DECIMAL(12,2) COMMENT '实际退款金额(审核后确定)',
refund_channel TINYINT COMMENT '退款渠道: 1原路退回 2余额',
refund_id VARCHAR(64) COMMENT '支付渠道退款单号',
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁',
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
INDEX idx_order_no (order_no),
INDEX idx_status (status)
) COMMENT '售后单主表';
注意一个设计细节:order_item_id 挂在售后单上而不是售后单行子表,是因为我们约定一个售后单只针对一个订单商品行(数量可以是该行的部分数量)。用户一次退3件不同商品,生成3张售后单。这样每张售后单的金额计算、状态流转、退款动作都是独立的,避免"一个商品商家同意、另一个拒绝"时状态机互相牵扯。
2.2 逆向状态机
退货退款(最复杂的类型)的状态流转:
[待商家审核] --同意--> [待买家退货] --用户填物流--> [待商家收货]
│ │ │
│拒绝 │用户撤销 │签收+质检通过
↓ ↓ ↓
[已拒绝/已关闭] [已撤销] [退款中] --退款回调成功--> [退款成功]
│
↓ 退款失败(余额不足/渠道异常)
[退款失败待重试] --人工/定时重试--> [退款中]
状态机用枚举+流转矩阵实现,禁止随意 set status:
public enum AfterSalesStatus {
WAIT_AUDIT, // 待商家审核
WAIT_BUYER_RETURN, // 待买家退货
WAIT_MERCHANT_RECV,// 待商家收货(质检)
REFUNDING, // 退款中
REFUND_SUCCESS, // 退款成功(终态)
REFUND_FAILED, // 退款失败待重试
REJECTED, // 已拒绝(终态)
CANCELED; // 用户撤销(终态)
// 合法流转矩阵:key=当前状态,value=允许到达的状态
private static final Map<AfterSalesStatus, Set<AfterSalesStatus>> TRANSITIONS = Map.of(
WAIT_AUDIT, Set.of(WAIT_BUYER_RETURN, REJECTED, CANCELED),
WAIT_BUYER_RETURN, Set.of(WAIT_MERCHANT_RECV, CANCELED),
WAIT_MERCHANT_RECV, Set.of(REFUNDING, REJECTED),
REFUNDING, Set.of(REFUND_SUCCESS, REFUND_FAILED),
REFUND_FAILED, Set.of(REFUNDING, REJECTED)
);
public void assertCanTransitTo(AfterSalesStatus target) {
if (!TRANSITIONS.getOrDefault(this, Set.of()).contains(target)) {
throw new BizException("非法状态流转: " + this + " -> " + target);
}
}
}
每次状态变更用乐观锁 + 状态校验双保险:
@Transactional
public void transit(Long afterSalesId, AfterSalesStatus target, String operator) {
AfterSalesOrder aso = afterSalesMapper.selectForUpdate(afterSalesId);
aso.getStatus().assertCanTransitTo(target);
int rows = afterSalesMapper.updateStatus(
aso.getId(), aso.getStatus(), target, aso.getVersion() + 1);
if (rows == 0) {
throw new BizException("售后单已被并发处理,请刷新");
}
// 状态变更后发领域事件(本地消息表落库)
eventPublisher.publish(new AfterSalesStatusChangedEvent(aso, target));
}
三、退款金额怎么算:优惠分摊是资损重灾区
3.1 核心原则
部分退款金额计算,业界标准做法是:按商品行实付比例分摊,优惠按"同一比例"冲减,尾差调整到最后一行。绝不能按商品原价平均退,也不能退商品行上"看起来"的金额。
举例:订单A买了两件商品——
| 商品行 | 单价 | 数量 | 小计 | 分摊运费 |
|---|---|---|---|---|
| 坚果礼盒 | 100.00 | 1 | 100.00 | 5.00 |
| 茶叶礼盒 | 200.00 | 1 | 200.00 | 5.00 |
| 订单使用满减券 300-50,实付 | 260.00 | |||
现在退茶叶礼盒。错误算法:退 200 - (50×200/300) = 200 - 33.33 = 166.67?这是按"商品原价比例"分摊优惠,看似合理,但忽略了一个问题——满减券是全单优惠,优惠分摊基数应该是商品实付而不是原价,两种算法在有行级优惠(单品直降)时结果不同。
统一的正确算法:
1. 计算每个商品行的"分摊后实付":
行实付 = 行小计 - 订单级优惠 × (行小计 ÷ Σ行小计)
(订单级优惠=满减券、平台补贴、整单积分抵扣等与单品无关的优惠)
2. 部分数量退货时:
退款金额 = 行实付 × (退货数量 ÷ 购买数量)
3. 运费:未发货全额退运费;已发货因商家原因(质量、发错货)退运费,
用户个人原因不退。运费同样按行分摊后退。
4. 尾差处理:所有退款累加与实付总额的差额(因四舍五入产生),
在最后一笔退款时调整,保证 Σ所有退款 ≤ 订单实付。
上面例子:茶叶行实付 = 200 - 50×(200/300) = 200 - 33.33 = 166.67,加运费5元,退 171.67 元。坚果行剩余实付 = 100 - 16.67 = 83.33。两行实付 171.67+83.33 = 255,加运费10 = 265?不对——运费10元含在实付260里,重新算:商品实付总额250,运费10,实付260。茶叶退:商品166.67 + 运费5 = 171.67。坚果保留:商品83.33 + 运费5 = 88.33。合计 171.67+88.33 = 260.00 ✓ 分毫不差。
3.2 代码实现
金额计算用 BigDecimal,精度统一 HALF_UP,单位元保留两位:
public class RefundFeeCalculator {
/**
* 计算售后单实际退款金额
* @param order 订单聚合(含商品行、优惠明细、运费)
* @param refundItem 退货商品行 + 退货数量
* @param freightRefundable 运费是否可退(未发货/商家责任=true)
*/
public BigDecimal calculate(Order order, OrderItemRefund refundItem,
boolean freightRefundable) {
// 订单级优惠总额(满减券、平台补贴等),行级优惠已在下单时落到行上
BigDecimal orderLevelDiscount = order.getOrderLevelDiscount();
// 商品行小计合计
BigDecimal totalItemAmount = order.getItems().stream()
.map(OrderItem::getPayAmount) // 行实付=行小计-行级优惠
.reduce(BigDecimal.ZERO, BigDecimal::add);
OrderItem targetItem = order.findItem(refundItem.getOrderItemId());
// 该行分摊的订单级优惠
BigDecimal itemShareDiscount = orderLevelDiscount
.multiply(targetItem.getPayAmount())
.divide(totalItemAmount, 2, RoundingMode.HALF_UP);
// 该行分摊后实付
BigDecimal itemActualPaid = targetItem.getPayAmount().subtract(itemShareDiscount);
// 部分数量退货
BigDecimal itemRefund = itemActualPaid
.multiply(BigDecimal.valueOf(refundItem.getRefundQty()))
.divide(BigDecimal.valueOf(targetItem.getQty()), 2, RoundingMode.HALF_UP);
// 运费分摊:按行实付比例
BigDecimal freightRefund = BigDecimal.ZERO;
if (freightRefundable) {
freightRefund = order.getFreightFee()
.multiply(targetItem.getPayAmount())
.divide(totalItemAmount, 2, RoundingMode.HALF_UP);
}
BigDecimal refund = itemRefund.add(freightRefund);
// 尾差校验:累计已退 + 本次退 ≤ 订单实付
BigDecimal refunded = afterSalesMapper.sumRefundedFee(order.getOrderNo());
if (refunded.add(refund).compareTo(order.getActualPayFee()) > 0) {
// 超出部分砍掉,防止资损
refund = order.getActualPayFee().subtract(refunded);
}
return refund.setScale(2, RoundingMode.HALF_UP);
}
}
几个被坑过的点:
- 行级优惠(单品直降、商品券)不参与二次分摊——下单时它就只作用于该行,退款时按该行实付退即可;
- 积分抵扣要分两部分:整单积分抵扣按上面逻辑参与分摊;退款成功后积分按相同比例退回用户账户,比例算出来的积分向上取整,但要在用户积分流水里可查;
- 优惠券退不退:部分退款后订单剩余实付若仍满足券门槛,券不退(用户已享受);整单退且券未过期,券退回用户卡包,已过期不退。这个规则务必写进售后条款。
四、支付退款:幂等、回调与异常兜底
4.1 退款调用幂等
微信支付 V3 退款接口本身用 out_refund_no(商户退款单号)做幂等键:同一个退款单号重复请求,返回第一次的结果。所以关键是售后单维度生成全局唯一退款单号并持久化:
public RefundResult invokeRefund(AfterSalesOrder aso) {
// 退款单号 = 售后单号 + 重试序号,首次为AS20260830xxx-0
String outRefundNo = aso.getAfterSalesNo() + "-" + aso.getRefundRetry();
try {
WxPayRefundResult result = wxPayService.refund(
WxPayRefundRequestV3.newBuilder()
.outTradeNo(aso.getOrderNo())
.outRefundNo(outRefundNo) // 幂等键
.refundFee(aso.getRealRefundFee())
.totalFee(orderService.getActualPay(aso.getOrderNo()))
.reason("售后退款")
.build());
aso.setRefundId(result.getRefundId());
transit(aso.getId(), AfterSalesStatus.REFUNDING, "SYSTEM");
return RefundResult.accepted();
} catch (WxPayException e) {
// 渠道明确返回"退款单号已存在"= 此前请求已受理,查单确认
if (isRefundAlreadyExists(e)) {
return queryAndSyncRefund(aso, outRefundNo);
}
throw new BizException("退款受理失败: " + e.getMessage());
}
}
4.2 退款结果以异步回调为准,主动查单兜底
退款和支付一样,受理成功 ≠ 退款成功(可能账户异常、风控拦截)。状态推进以微信退款结果通知为准:
@PostMapping("/refund/notify")
public String refundNotify(HttpServletRequest req) {
WxPayRefundNotifyResult result = wxPayService.parseRefundNotifyV3(req);
String refundNo = result.getOutRefundNo().split("-")[0]; // 去重试序号
AfterSalesOrder aso = afterSalesMapper.findByAfterSalesNo(refundNo);
if ("SUCCESS".equals(result.getRefundStatus())) {
transit(aso.getId(), AfterSalesStatus.REFUND_SUCCESS, "WX_CALLBACK");
eventPublisher.publish(new RefundSuccessEvent(aso)); // 触发回滚链路
} else if ("ABNORMAL".equals(result.getRefundStatus())) {
transit(aso.getId(), AfterSalesStatus.REFUND_FAILED, "WX_CALLBACK");
alertService.notifyOps("退款异常需人工处理: " + refundNo);
}
return WxPayNotifyResponse.success("OK");
}
回调可能丢失或延迟,用定时任务兜底:所有 REFUNDING 状态超过10分钟的售后单,主动调用退款查询接口对账同步,频率每分钟一次。REFUND_FAILED 的单据进入人工队列,运营核对后可发起重试(重试序号+1,新幂等键)或改走线下退款。
五、退款成功后的多系统回滚:最终一致
退款成功(REFUND_SUCCESS)不是终点——库存要回补、券和积分要退回、分销佣金要回冲。这些动作跨3到4个服务,用本地消息表 + MQ 保证最终一致:
退款成功
│
├─ 本地事务: 更新售后单状态 + 消息表插入4条待发消息 (同一事务)
│
↓
定时任务扫描消息表 → 发MQ → 各消费方幂等消费 → 回执标记已完成
↓ 消费失败
重试(指数退避) → 超次数告警人工
库存回补消费者(要点:幂等 + 区分可退库存):
@RocketMQMessageListener(topic = "REFUND_SUCCESS", consumerGroup = "inventory-cg")
public class InventoryRefundListener implements RocketMQListener<RefundSuccessEvent> {
@Override
@Transactional
public void onMessage(RefundSuccessEvent event) {
// 幂等:以售后单号+订单行做唯一键消费
if (inventoryLogMapper.existsRefundLog(event.getAfterSalesNo())) {
return;
}
for (OrderItem item : event.getRefundItems()) {
// 仅退货退款/未发货仅退款回补可售库存;
// 质量问题商品进入残次仓,不回可售库存
if (event.needReturnStock(item)) {
stockMapper.addAvailableStock(item.getSkuId(), item.getRefundQty());
} else {
stockMapper.addDefectStock(item.getSkuId(), item.getRefundQty());
}
}
inventoryLogMapper.insertRefundLog(event.getAfterSalesNo()); // 幂等标记
}
}
各系统的回滚规则汇总:
| 系统 | 回滚动作 | 特殊规则 |
|---|---|---|
| 库存 | 回补可售库存或残次仓 | 未发货仅退款回可售;退货质检不合格入残次仓 |
| 优惠券 | 整单退且券未过期→退回卡包 | 部分退、券已过期均不退;退回时恢复原始有效期 |
| 积分 | 按实付退款比例退回抵扣积分 | 赠送积分(如购物返积分)同步扣回,余额不足记负 |
| 分销佣金 | 冻结未结算佣金→直接冲销;已结算→记负向佣金账单 | 下级订单退款,上级已提现的佣金从后续佣金中抵扣,不直接向用户追款 |
| 销量统计 | 商品销量扣减退货数量 | T+1 报表以退款成功时间归属 |
分销佣金回冲是最容易扯皮的一环。我们的规则是:订单支付后佣金先记"待结算",确认收货7天后转为"可提现"。退款发生在待结算期,直接冲销;发生在可提现但未提现,冻结对应金额;已经提现的,生成负向佣金记录,从该分销员后续新订单佣金里抵扣,扣完为止——绝不反向向个人用户追讨,那是客服灾难。
六、风控与容易忽略的细节
- 退款频率限制:同一用户短期内高频发起售后、退款金额异常接近实付上限,要进风控队列人工审核,专门防"买真退假"和骗运费险;
- 仅退款与退货退款的时限:未发货订单支持秒级仅退款(商家审核可设自动通过);已签收后售后入口开放15天,超时走平台仲裁;
- 金额双录:
apply_refund_fee(用户申请)和real_refund_fee(审核确定)分开存,商家可核减,审计可追溯; - 对账:每日凌晨对账任务比对三方数据——售后单退款成功总额 = 支付渠道退款成功总额 = 账务流水退款总额,差异自动告警。退款对账必须独立于下单对账,很多系统只对正向账,逆向账长期裸奔;
- 灰度开关:金额分摊算法这种核心逻辑改动,建议用新老算法并行跑一周,结果不一致只告警不生效,确认零差异再切流。
七、总结
售后退款系统的设计可以浓缩成四句话:状态机管住流转、分摊算法守住资金、幂等键挡住重复、消息表兜住一致性。 逆向链路的代码量通常只有正向下单的三分之一,但投入的测试和对账精力应该是正向的两倍——因为正向出错最多是没成交,逆向出错是真金白银地退错钱。建议所有做电商小程序的团队,都把"退款金额计算"和"退款对账"作为代码评审和日常对账的一级检查项。
更多推荐




所有评论(0)