正向下单流程各家架构文章讲得很多,但售后退款这条逆向链路才是线上事故和资损的高发区。本文分享我们平台在售后系统重构中的一套完整设计:售后单状态机、退款金额的优惠分摊算法、退款幂等与重试、库存/优惠券/积分/分销佣金的最终一致回滚。

一、为什么售后系统值得单独设计

下单是一条"顺流":创建订单 → 锁库存 → 支付 → 发货。逆向流程完全不同:

  • 入口多:仅退款(未发货)、退货退款(已发货)、换货、补发、价保退款,每个入口的资金和库存动作都不一样;
  • 金额算不清:一单多商品、用了满减券、平台补贴、积分抵扣,退一件商品到底退多少钱,直接决定会不会资损或客诉;
  • 状态机长:商家审核、买家退货、物流签收、质检、退款中、退款成功、退款失败,中间还要处理用户撤销、超时自动处理;
  • 回滚系统多:库存要回补、优惠券要不要退回、积分怎么返、分销佣金已结算的怎么扣,任何一步失败都不能让钱退错。

我们第一版售后是写在订单服务里的 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.001100.005.00
茶叶礼盒200.001200.005.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);
    }
}

几个被坑过的点:

  1. 行级优惠(单品直降、商品券)不参与二次分摊——下单时它就只作用于该行,退款时按该行实付退即可;
  2. 积分抵扣要分两部分:整单积分抵扣按上面逻辑参与分摊;退款成功后积分按相同比例退回用户账户,比例算出来的积分向上取整,但要在用户积分流水里可查;
  3. 优惠券退不退:部分退款后订单剩余实付若仍满足券门槛,券不退(用户已享受);整单退且券未过期,券退回用户卡包,已过期不退。这个规则务必写进售后条款。

四、支付退款:幂等、回调与异常兜底

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天后转为"可提现"。退款发生在待结算期,直接冲销;发生在可提现但未提现,冻结对应金额;已经提现的,生成负向佣金记录,从该分销员后续新订单佣金里抵扣,扣完为止——绝不反向向个人用户追讨,那是客服灾难。

六、风控与容易忽略的细节

  1. 退款频率限制:同一用户短期内高频发起售后、退款金额异常接近实付上限,要进风控队列人工审核,专门防"买真退假"和骗运费险;
  2. 仅退款与退货退款的时限:未发货订单支持秒级仅退款(商家审核可设自动通过);已签收后售后入口开放15天,超时走平台仲裁;
  3. 金额双录apply_refund_fee(用户申请)和 real_refund_fee(审核确定)分开存,商家可核减,审计可追溯;
  4. 对账:每日凌晨对账任务比对三方数据——售后单退款成功总额 = 支付渠道退款成功总额 = 账务流水退款总额,差异自动告警。退款对账必须独立于下单对账,很多系统只对正向账,逆向账长期裸奔;
  5. 灰度开关:金额分摊算法这种核心逻辑改动,建议用新老算法并行跑一周,结果不一致只告警不生效,确认零差异再切流。

七、总结

售后退款系统的设计可以浓缩成四句话:状态机管住流转、分摊算法守住资金、幂等键挡住重复、消息表兜住一致性。 逆向链路的代码量通常只有正向下单的三分之一,但投入的测试和对账精力应该是正向的两倍——因为正向出错最多是没成交,逆向出错是真金白银地退错钱。建议所有做电商小程序的团队,都把"退款金额计算"和"退款对账"作为代码评审和日常对账的一级检查项。


Logo

电商企业物流数字化转型必备!快递鸟 API 接口,72 小时快速完成物流系统集成。全流程实战1V1指导,营造开放的API技术生态圈。

更多推荐