电商小程序售后退款系统设计:逆向状态机、退款金额分摊与多系统回滚一致性

发布时间:2026/8/31 23:57:07
电商小程序售后退款系统设计:逆向状态机、退款金额分摊与多系统回滚一致性 正向下单流程各家架构文章讲得很多但售后退款这条逆向链路才是线上事故和资损的高发区。本文分享我们平台在售后系统重构中的一套完整设计售后单状态机、退款金额的优惠分摊算法、退款幂等与重试、库存/优惠券/积分/分销佣金的最终一致回滚。一、为什么售后系统值得单独设计下单是一条顺流创建订单 → 锁库存 → 支付 → 发货。逆向流程完全不同入口多仅退款未发货、退货退款已发货、换货、补发、价保退款每个入口的资金和库存动作都不一样金额算不清一单多商品、用了满减券、平台补贴、积分抵扣退一件商品到底退多少钱直接决定会不会资损或客诉状态机长商家审核、买家退货、物流签收、质检、退款中、退款成功、退款失败中间还要处理用户撤销、超时自动处理回滚系统多库存要回补、优惠券要不要退回、积分怎么返、分销佣金已结算的怎么扣任何一步失败都不能让钱退错。我们第一版售后是写在订单服务里的 if-else上线三个月后出现过两次资损一次是部分退款时按订单实付平均退导致含满减券的订单多退了另一次是退款成功消息重复消费库存回补了两次。重构后的整体架构如下┌─────────────┐ 售后申请 ┌──────────────────────┐ │ 小程序端 │ ─────────────→ │ after-sales 服务 │ └─────────────┘ │ - 售后单聚合根 │ │ - 逆向状态机 │ │ - 金额分摊计算 │ └──────────┬───────────┘ │ 领域事件 ┌─────────────────────┼─────────────────────┐ ↓ ↓ ↓ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ payment 服务 │ │ inventory服务 │ │ promo 服务 │ │ 退款(幂等) │ │ 库存回补 │ │ 券退回/积分 │ └──────────────┘ └──────────────┘ └──────────────┘ │ │ │ └──────────────┬──────┴─────────────────────┘ ↓ ┌──────────────────┐ │ RocketMQ 事务消息 │ │ 本地消息表兜底 │ └──────────────────┘ ↓ ┌──────────────────┐ │ distribution 服务 │ 佣金冻结/回冲 └──────────────────┘核心原则售后单是逆向链路的聚合根所有外部动作退款、回库存、退券都由售后单状态机驱动通过领域事件本地消息表保证最终一致绝不允许前端直接调用支付退款。二、售后单数据模型与状态机2.1 表结构设计售后单after_sales_order核心字段CREATETABLEafter_sales_order(idBIGINTPRIMARYKEYAUTO_INCREMENT,after_sales_noVARCHAR(32)NOTNULLUNIQUECOMMENT售后单号 AS时间戳随机,order_noVARCHAR(32)NOTNULLCOMMENT原订单号,order_item_idBIGINTNOTNULLCOMMENT售后针对的订单商品行整单退可多行,typeTINYINTNOTNULLCOMMENT1仅退款 2退货退款 3换货 4补发,reason_codeVARCHAR(32)NOTNULLCOMMENT售后原因码,statusVARCHAR(32)NOTNULLCOMMENT状态机状态,apply_refund_feeDECIMAL(12,2)NOTNULLCOMMENT用户申请退款金额,real_refund_feeDECIMAL(12,2)COMMENT实际退款金额(审核后确定),refund_channelTINYINTCOMMENT退款渠道: 1原路退回 2余额,refund_idVARCHAR(64)COMMENT支付渠道退款单号,versionINTNOTNULLDEFAULT0COMMENT乐观锁,created_atDATETIMENOTNULL,updated_atDATETIMENOTNULL,INDEXidx_order_no(order_no),INDEXidx_status(status))COMMENT售后单主表;注意一个设计细节order_item_id挂在售后单上而不是售后单行子表是因为我们约定一个售后单只针对一个订单商品行数量可以是该行的部分数量。用户一次退3件不同商品生成3张售后单。这样每张售后单的金额计算、状态流转、退款动作都是独立的避免一个商品商家同意、另一个拒绝时状态机互相牵扯。2.2 逆向状态机退货退款最复杂的类型的状态流转[待商家审核] --同意-- [待买家退货] --用户填物流-- [待商家收货] │ │ │ │拒绝 │用户撤销 │签收质检通过 ↓ ↓ ↓ [已拒绝/已关闭] [已撤销] [退款中] --退款回调成功-- [退款成功] │ ↓ 退款失败(余额不足/渠道异常) [退款失败待重试] --人工/定时重试-- [退款中]状态机用枚举流转矩阵实现禁止随意 set statuspublicenumAfterSalesStatus{WAIT_AUDIT,// 待商家审核WAIT_BUYER_RETURN,// 待买家退货WAIT_MERCHANT_RECV,// 待商家收货(质检)REFUNDING,// 退款中REFUND_SUCCESS,// 退款成功(终态)REFUND_FAILED,// 退款失败待重试REJECTED,// 已拒绝(终态)CANCELED;// 用户撤销(终态)// 合法流转矩阵key当前状态value允许到达的状态privatestaticfinalMapAfterSalesStatus,SetAfterSalesStatusTRANSITIONSMap.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));publicvoidassertCanTransitTo(AfterSalesStatustarget){if(!TRANSITIONS.getOrDefault(this,Set.of()).contains(target)){thrownewBizException(非法状态流转: this - target);}}}每次状态变更用乐观锁 状态校验双保险Transactionalpublicvoidtransit(LongafterSalesId,AfterSalesStatustarget,Stringoperator){AfterSalesOrderasoafterSalesMapper.selectForUpdate(afterSalesId);aso.getStatus().assertCanTransitTo(target);introwsafterSalesMapper.updateStatus(aso.getId(),aso.getStatus(),target,aso.getVersion()1);if(rows0){thrownewBizException(售后单已被并发处理请刷新);}// 状态变更后发领域事件本地消息表落库eventPublisher.publish(newAfterSalesStatusChangedEvent(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.6783.33 255加运费10 265不对——运费10元含在实付260里重新算商品实付总额250运费10实付260。茶叶退商品166.67 运费5 171.67。坚果保留商品83.33 运费5 88.33。合计 171.6788.33 260.00 ✓ 分毫不差。3.2 代码实现金额计算用BigDecimal精度统一 HALF_UP单位元保留两位publicclassRefundFeeCalculator{/** * 计算售后单实际退款金额 * param order 订单聚合含商品行、优惠明细、运费 * param refundItem 退货商品行 退货数量 * param freightRefundable 运费是否可退未发货/商家责任true */publicBigDecimalcalculate(Orderorder,OrderItemRefundrefundItem,booleanfreightRefundable){// 订单级优惠总额满减券、平台补贴等行级优惠已在下单时落到行上BigDecimalorderLevelDiscountorder.getOrderLevelDiscount();// 商品行小计合计BigDecimaltotalItemAmountorder.getItems().stream().map(OrderItem::getPayAmount)// 行实付行小计-行级优惠.reduce(BigDecimal.ZERO,BigDecimal::add);OrderItemtargetItemorder.findItem(refundItem.getOrderItemId());// 该行分摊的订单级优惠BigDecimalitemShareDiscountorderLevelDiscount.multiply(targetItem.getPayAmount()).divide(totalItemAmount,2,RoundingMode.HALF_UP);// 该行分摊后实付BigDecimalitemActualPaidtargetItem.getPayAmount().subtract(itemShareDiscount);// 部分数量退货BigDecimalitemRefunditemActualPaid.multiply(BigDecimal.valueOf(refundItem.getRefundQty())).divide(BigDecimal.valueOf(targetItem.getQty()),2,RoundingMode.HALF_UP);// 运费分摊按行实付比例BigDecimalfreightRefundBigDecimal.ZERO;if(freightRefundable){freightRefundorder.getFreightFee().multiply(targetItem.getPayAmount()).divide(totalItemAmount,2,RoundingMode.HALF_UP);}BigDecimalrefunditemRefund.add(freightRefund);// 尾差校验累计已退 本次退 ≤ 订单实付BigDecimalrefundedafterSalesMapper.sumRefundedFee(order.getOrderNo());if(refunded.add(refund).compareTo(order.getActualPayFee())0){// 超出部分砍掉防止资损refundorder.getActualPayFee().subtract(refunded);}returnrefund.setScale(2,RoundingMode.HALF_UP);}}几个被坑过的点行级优惠单品直降、商品券不参与二次分摊——下单时它就只作用于该行退款时按该行实付退即可积分抵扣要分两部分整单积分抵扣按上面逻辑参与分摊退款成功后积分按相同比例退回用户账户比例算出来的积分向上取整但要在用户积分流水里可查优惠券退不退部分退款后订单剩余实付若仍满足券门槛券不退用户已享受整单退且券未过期券退回用户卡包已过期不退。这个规则务必写进售后条款。四、支付退款幂等、回调与异常兜底4.1 退款调用幂等微信支付 V3 退款接口本身用out_refund_no商户退款单号做幂等键同一个退款单号重复请求返回第一次的结果。所以关键是售后单维度生成全局唯一退款单号并持久化publicRefundResultinvokeRefund(AfterSalesOrderaso){// 退款单号 售后单号 重试序号首次为AS20260830xxx-0StringoutRefundNoaso.getAfterSalesNo()-aso.getRefundRetry();try{WxPayRefundResultresultwxPayService.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);returnRefundResult.accepted();}catch(WxPayExceptione){// 渠道明确返回退款单号已存在 此前请求已受理查单确认if(isRefundAlreadyExists(e)){returnqueryAndSyncRefund(aso,outRefundNo);}thrownewBizException(退款受理失败: e.getMessage());}}4.2 退款结果以异步回调为准主动查单兜底退款和支付一样受理成功 ≠ 退款成功可能账户异常、风控拦截。状态推进以微信退款结果通知为准PostMapping(/refund/notify)publicStringrefundNotify(HttpServletRequestreq){WxPayRefundNotifyResultresultwxPayService.parseRefundNotifyV3(req);StringrefundNoresult.getOutRefundNo().split(-)[0];// 去重试序号AfterSalesOrderasoafterSalesMapper.findByAfterSalesNo(refundNo);if(SUCCESS.equals(result.getRefundStatus())){transit(aso.getId(),AfterSalesStatus.REFUND_SUCCESS,WX_CALLBACK);eventPublisher.publish(newRefundSuccessEvent(aso));// 触发回滚链路}elseif(ABNORMAL.equals(result.getRefundStatus())){transit(aso.getId(),AfterSalesStatus.REFUND_FAILED,WX_CALLBACK);alertService.notifyOps(退款异常需人工处理: refundNo);}returnWxPayNotifyResponse.success(OK);}回调可能丢失或延迟用定时任务兜底所有REFUNDING状态超过10分钟的售后单主动调用退款查询接口对账同步频率每分钟一次。REFUND_FAILED的单据进入人工队列运营核对后可发起重试重试序号1新幂等键或改走线下退款。五、退款成功后的多系统回滚最终一致退款成功REFUND_SUCCESS不是终点——库存要回补、券和积分要退回、分销佣金要回冲。这些动作跨3到4个服务用本地消息表 MQ 保证最终一致退款成功 │ ├─ 本地事务: 更新售后单状态 消息表插入4条待发消息 (同一事务) │ ↓ 定时任务扫描消息表 → 发MQ → 各消费方幂等消费 → 回执标记已完成 ↓ 消费失败 重试(指数退避) → 超次数告警人工库存回补消费者要点幂等 区分可退库存RocketMQMessageListener(topicREFUND_SUCCESS,consumerGroupinventory-cg)publicclassInventoryRefundListenerimplementsRocketMQListenerRefundSuccessEvent{OverrideTransactionalpublicvoidonMessage(RefundSuccessEventevent){// 幂等以售后单号订单行做唯一键消费if(inventoryLogMapper.existsRefundLog(event.getAfterSalesNo())){return;}for(OrderItemitem:event.getRefundItems()){// 仅退货退款/未发货仅退款回补可售库存// 质量问题商品进入残次仓不回可售库存if(event.needReturnStock(item)){stockMapper.addAvailableStock(item.getSkuId(),item.getRefundQty());}else{stockMapper.addDefectStock(item.getSkuId(),item.getRefundQty());}}inventoryLogMapper.insertRefundLog(event.getAfterSalesNo());// 幂等标记}}各系统的回滚规则汇总系统回滚动作特殊规则库存回补可售库存或残次仓未发货仅退款回可售退货质检不合格入残次仓优惠券整单退且券未过期→退回卡包部分退、券已过期均不退退回时恢复原始有效期积分按实付退款比例退回抵扣积分赠送积分如购物返积分同步扣回余额不足记负分销佣金冻结未结算佣金→直接冲销已结算→记负向佣金账单下级订单退款上级已提现的佣金从后续佣金中抵扣不直接向用户追款销量统计商品销量扣减退货数量T1 报表以退款成功时间归属分销佣金回冲是最容易扯皮的一环。我们的规则是订单支付后佣金先记待结算确认收货7天后转为可提现。退款发生在待结算期直接冲销发生在可提现但未提现冻结对应金额已经提现的生成负向佣金记录从该分销员后续新订单佣金里抵扣扣完为止——绝不反向向个人用户追讨那是客服灾难。六、风控与容易忽略的细节退款频率限制同一用户短期内高频发起售后、退款金额异常接近实付上限要进风控队列人工审核专门防买真退假和骗运费险仅退款与退货退款的时限未发货订单支持秒级仅退款商家审核可设自动通过已签收后售后入口开放15天超时走平台仲裁金额双录apply_refund_fee用户申请和real_refund_fee审核确定分开存商家可核减审计可追溯对账每日凌晨对账任务比对三方数据——售后单退款成功总额 支付渠道退款成功总额 账务流水退款总额差异自动告警。退款对账必须独立于下单对账很多系统只对正向账逆向账长期裸奔灰度开关金额分摊算法这种核心逻辑改动建议用新老算法并行跑一周结果不一致只告警不生效确认零差异再切流。七、总结售后退款系统的设计可以浓缩成四句话状态机管住流转、分摊算法守住资金、幂等键挡住重复、消息表兜住一致性。逆向链路的代码量通常只有正向下单的三分之一但投入的测试和对账精力应该是正向的两倍——因为正向出错最多是没成交逆向出错是真金白银地退错钱。建议所有做电商小程序的团队都把退款金额计算和退款对账作为代码评审和日常对账的一级检查项。