支付与退款幂等

08 · 核心亮点

亮点八 · 支付回调会重投,退款能分 20 次

策略工厂 + 分布式锁 + 状态短路 + 分片对账。幂等做得比较扎实,但常量层面有一个值写错的地雷。

涉及模块:chain-cloud-ms-trade-service :18079

亮点 8 · 支付回调会重投,退款能分 20 次

业务问题: 用户点了支付,微信/支付宝会多次给我们的回调接口发通知。 第一次说"付了 15 块",第二次说"付了 15 块",第三次还是"付了 15 块"。 我们必须保证:订单只被标记为已支付一次,消息只发一次。

除此之外还有两个业务难点: 退款可以分多次退(最多 20 次),以及用户可能一直不付,要自动关单。


一、业务场景

1.1 支付回调为什么一定会重复

重投原因频率说明
HTTP 响应没在 5 秒内返回高支付平台会重投,最多 8 次
我们返回了 500中平台认为是失败,会重投
网络抖动但实际已处理中我们收到了,平台没收到响应
人工重放低运维排障

关键点:回调是"至少一次"投递,不是"恰好一次"。 所以幂等不是优化,是必需品。

而且我们返回 500 反而更糟:

java
// NotifyController
// 微信:成功返回 200 + 空 body,失败返回 500 → 微信会一直重投
// 支付宝:成功返回 "success",失败返回 500 → 支付宝最多重试 25 小时

所以失败时返回 500 是刻意的——让平台重投, 比我们自己重试更可靠(因为重投有官方背书的顺序保证)。

1.2 三种支付方式的差异

方式用户交互服务端职责
Native 扫码支付出二维码 → 微信/支付宝扫 → 轮询查状态生成二维码 + 轮询兜底(用户不扫也要能查到已付)
JSAPI 小程序支付直接调起微信支付下单拿 prepay_id → 前端调起 → 必须靠回调
Basic 基础支付管理端手动退款主要做退款和对账

二、方案设计

2.1 策略模式 + 工厂:多渠道怎么统一

java
// chain-cloud-ms-trade-service handler/HandlerFactory.java
public class HandlerFactory {

    public static <T> T get(PayChannelEnum payChannel, Class<T> handler) {
        Map<String, T> beans = SpringUtil.getBeansOfType(handler);
        for (Map.Entry<String, T> entry : beans.entrySet()) {
            PayChannel payChannelAnnotation =
                    entry.getValue().getClass().getAnnotation(PayChannel.class);
            if (ObjectUtil.isNotEmpty(payChannelAnnotation)
                    && ObjectUtil.equal(payChannel, payChannelAnnotation.type())) {
                return entry.getValue();
            }
        }
        return null;      // ⚠️ 找不到渠道返回 null
    }
}

每个 Handler 用注解声明自己负责哪个渠道:

java
@PayChannel(PayChannelEnum.WECHAT_PAY)
public class WeChatJsapiPayHandler implements JsapiPayHandler { ... }

@PayChannel(PayChannelEnum.ALI_PAY)
public class AliJsapiPayHandler implements JsapiPayHandler { ... }

这个设计的价值: 新增一个支付渠道(比如加"云闪付")只需要: 写一个实现类 + 加一个注解,不需要改任何调用方代码。

而且 getBeansOfType 是从 Spring 容器里拿的,所以自动支持了 Spring 的生命周期管理。

2.2 创建交易:锁 + 幂等前置检查

java
// chain-cloud-ms-trade-service JsapiPayServiceImpl#createJsapiTrading
@Override
public TradingEntity createJsapiTrading(TradingEntity tradingEntity) {
    // 1. 交易前置处理:检测交易单参数
    Boolean flag = beforePayHandler.checkCreateTrading(tradingEntity);
    if (!flag) throw new CCException(TradingEnum.NATIVE_PAY_FAIL);

    tradingEntity.setEnableFlag(Constants.YES);
    tradingEntity.setTradingType(TradingConstant.TRADING_TYPE_FK);

    // 2. 对交易订单加锁(同一 productOrderNo 串行)
    Long productOrderNo = tradingEntity.getProductOrderNo();
    String key = TradingCacheConstant.CREATE_PAY + productOrderNo;
    RLock lock = redissonClient.getFairLock(key);
    try {
        if (lock.tryLock(TradingCacheConstant.REDIS_WAIT_TIME, TimeUnit.SECONDS)) {
            // 3. 幂等性处理
            this.beforePayHandler.idempotentCreateTrading(tradingEntity);

            // 4. 调用不同渠道
            JsapiPayHandler jsapiPayHandler = HandlerFactory.get(
                    tradingEntity.getTradingChannel(), JsapiPayHandler.class);
            jsapiPayHandler.createJsapiTrading(tradingEntity);

            flag = this.tradingService.saveOrUpdate(tradingEntity);
            if (!flag) throw new CCException(TradingEnum.SAVE_OR_UPDATE_FAIL);
            return tradingEntity;
        }
        throw new CCException(TradingEnum.NATIVE_PAY_FAIL);
    } catch (CCException e) {
        throw e;
    } catch (Exception e) {
        log.error("Jsapi预创建异常: tradingDTO = {}", tradingEntity, e);
        throw new CCException(TradingEnum.NATIVE_PAY_FAIL);
    } finally {
        lock.unlock();      // ⚠️ 见亮点 7 坑 2
    }
}

四步的分工:

步骤作用失败处理
checkCreateTrading参数校验、金额校验抛异常,不加锁
getFairLock同一 productOrderNo 串行拿不到锁直接抛"支付失败"
idempotentCreateTrading幂等:已存在则复用内部逻辑
HandlerFactory.get(...)渠道分发返回 null 会 NPE

注意锁的 key 是 productOrderNo(业务订单号)而不是 tradingOrderNo(交易单号)。 这是对的——因为幂等要保证的是"同一个业务订单不能创建两笔待支付交易", 如果按交易单号加锁,每次新交易单号都不一样,锁就失效了。

2.3 支付回调:锁 + 状态短路(幂等的关键)

java
// NotifyServiceImpl#updateTrading
private void updateTrading(Long tradingOrderNo, String resultMsg, String resultJson) {
    String key = TradingCacheConstant.CREATE_PAY + tradingOrderNo;
    RLock lock = redissonClient.getFairLock(key);
    try {
        if (lock.tryLock(TradingCacheConstant.REDIS_WAIT_TIME, TimeUnit.SECONDS)) {
            TradingEntity trading = this.tradingService.findTradByTradingOrderNo(tradingOrderNo);

            if (trading.getTradingState() == TradingStateEnum.YJS) {
                // 已付款 —— ★ 幂等短路:不重复发消息
                return;
            }

            trading.setTradingState(TradingStateEnum.YJS);
            trading.setQrCode("");                  // 清空二维码,避免被重复扫
            trading.setResultMsg(resultMsg);
            trading.setResultJson(resultJson);
            this.tradingService.saveOrUpdate(trading);

            // 发消息通知 OMS 更新订单支付状态
            TradeStatusMsg tradeStatusMsg = TradeStatusMsg.builder()
                    .tradingOrderNo(trading.getTradingOrderNo())
                    .productOrderNo(trading.getProductOrderNo())
                    .statusCode(TradingStateEnum.YJS.getCode())
                    .statusName(TradingStateEnum.YJS.name())
                    .build();
            String msg = JSONUtil.toJsonStr(Collections.singletonList(tradeStatusMsg));
            this.mqFeign.sendMsg(Constants.MQ.Exchanges.TRADE,
                    Constants.MQ.RoutingKeys.TRADE_UPDATE_STATUS, msg);
            return;
        }
    } catch (Exception e) {
        throw new CCException("处理业务失败");
    } finally {
        lock.unlock();      // ⚠️
    }
    throw new CCException("处理业务失败");
}

这里的幂等关键是 if (状态 == 已付款) return; 这一行。

为什么它比"加锁"更关键?

手段防的是能否防住"回调重投"
锁并发处理同一笔交易✅ 能
状态短路重复投递✅ 能,而且是根本的
两者一起✅ 双重保险

只用锁不够——因为重投是先后的,不是同时的: 第一次处理完成、释放锁;第二次回调进来,锁能拿到, 但状态已经是 YJS 了。这时只有状态短路能挡住。

而且状态短路还避免了重复发 MQ 消息—— 如果第二次回调又发一次消息,OMS 那边会重复处理(虽然 update 是幂等的, 但会产生无谓的 DB 压力和日志噪音)。

2.4 退款:最多 20 次的业务约束

java
// handler/impl/BeforePayHandlerImpl#idempotentRefundTrading
public RefundRecordEntity idempotentRefundTrading(TradingEntity trading, BigDecimal refundAmount) {
    // 查询退款次数,不能大于 20 次
    List<RefundRecordEntity> recordList =
            this.refundRecordService.findListByTradingOrderNo(trading.getTradingOrderNo());
    int size = CollUtil.size(recordList);
    if (size >= 20) {
        return null;      // ← 返回 null 表示"超限"
    }

    RefundRecordEntity refundRecord = new RefundRecordEntity();
    refundRecord.setRefundNo(Convert.toLong(this.identifierGenerator.nextId(refundRecord)));
    refundRecord.setTradingOrderNo(trading.getTradingOrderNo());
    ...
    refundRecord.setRefundStatus(RefundStatusEnum.SENDING);     // 退款中
    refundRecord.setTotal(trading.getTradingAmount());
    refundRecord.setMemo(StrUtil.format("退款({})", size + 1));   // 备注:第几次
    return refundRecord;
}
java
// BasicPayServiceImpl#refundTrading
if (null == refundRecord) {
    return false;      // ← 退款次数超限
}
...
// BasicPayController
if (!result) {
    throw new CCException(TradingEnum.BASIC_REFUND_COUNT_OUT_FAIL);   // "退款次数超出限制,最多20次"
}

退款流程:

java
@Transactional
public Boolean refundTrading(String tradingOrderNo, BigDecimal refundAmount) {
    TradingEntity trading = this.tradingService.findTradByTradingOrderNo(tradingOrderNo);
    trading.setRefund(NumberUtil.add(refundAmount, trading.getRefund()));   // 累加已退金额
    this.beforePayHandler.checkRefundTrading(trading);     // 前置校验

    String key = TradingCacheConstant.REFUND_PAY + tradingOrderNo;
    RLock lock = redissonClient.getFairLock(key);
    if (lock.tryLock(TradingCacheConstant.REDIS_WAIT_TIME, TimeUnit.SECONDS)) {
        RefundRecordEntity refundRecord = this.beforePayHandler.idempotentRefundTrading(trading, refundAmount);
        if (null == refundRecord) return false;

        BasicPayHandler handler = HandlerFactory.get(refundRecord.getTradingChannel(), BasicPayHandler.class);
        Boolean result = handler.refundTrading(refundRecord);
        if (result) {
            this.refundRecordService.saveOrUpdate(refundRecord);
            trading.setIsRefund(Constants.YES);
            this.tradingService.saveOrUpdate(trading);
        }
        return true;
    }
    ...
}

退款前置校验(两个):

java
// BeforePayHandlerImpl#checkRefundTrading
// 1. 必须已结算
if (tradingState != TradingStateEnum.YJS)  →  拒绝
// 2. 累计退款金额不能超过支付金额
if (refund > tradingAmount)  →  拒绝

微信退款的幂等键设计(值得讲):

java
// WeChatBasicPayHandler#refundTrading
POST /v3/refund/domestic/refunds
{
  "out_trade_no": 交易单号,
  "out_refund_no": refundNo,      // ★ 用退款单号做幂等键
  "refund": 退款金额 × 100,
  "total": 支付金额 × 100,
  "currency": "CNY"
}

out_refund_no 是微信侧的幂等键——相同 out_refund_no 重复提交, 微信返回第一次的结果,不会重复退款。 所以我们必须每笔退款生成一个唯一单号(这里用的是雪花 ID)。

支付宝用 out_request_no 实现多次部分退款:

java
// AliBasicPayHandler#refundTrading
Factory.Payment.Common()
    .optional("out_request_no", refundNo)
    .refund(tradingOrderNo, refundAmount);

微信和支付宝在多次部分退款上的模型不同:

  • 微信:out_refund_no 每笔唯一,天然支持多次退款;
  • 支付宝:必须传同一个 out_request_no 才认为是同一笔,

换句话说支付宝的 out_request_no 是幂等键而不是单号。

这个差异很容易踩坑,属于要主动讲出来加分的地方。

2.5 主动查单 + 自动关单

用户支付后可能直接关掉页面不回跳,所以必须有主动查询:

java
// WeChatBasicPayHandler#queryTrading
String apiPath = StrUtil.format("/v3/pay/transactions/out-trade-no/{}", trading.getTradingOrderNo());
...
String tradeStatus = jsonObject.getStr("trade_state");

if (StrUtil.equalsAny(tradeStatus, TradingConstant.WECHAT_TRADE_CLOSED,
                                 TradingConstant.WECHAT_TRADE_REVOKED)) {
    trading.setTradingState(TradingStateEnum.QXDD);
} else if (StrUtil.equalsAny(tradeStatus, TradingConstant.WECHAT_REFUND_SUCCESS,
                                       TradingConstant.WECHAT_TRADE_REFUND)) {
    trading.setTradingState(TradingStateEnum.YJS);
} else if (StrUtil.equalsAny(tradeStatus, TradingConstant.WECHAT_TRADE_NOTPAY)) {
    // 未支付:判断时间,超过 2 小时未支付需要关闭订单
    long between = LocalDateTimeUtil.between(
            trading.getCreated(), LocalDateTimeUtil.now(), ChronoUnit.HOURS);
    if (between >= 2) {
        return this.closeTrading(trading);
    }
} else {
    return false;      // 非最终状态不处理
}

"2 小时未支付自动关单"是业务规则,不是技术规则。 它解决的是:占位资源(预留库存、锁定优惠、占用运力)。 所以这个时长应该是运营配置项,不是硬编码。

2.6 分片对账任务

java
// chain-cloud-ms-trade-service job/TradeJob.java
@XxlJob("tradingJob")
public void tradingJob() {
    int shardIndex = NumberUtil.max(XxlJobHelper.getShardIndex(), 0);
    int shardTotal = NumberUtil.max(XxlJobHelper.getShardTotal(), 1);
    ...
    // 分片过滤:每个执行器只处理属于自己分片的数据
    if (trading.getTradingOrderNo() % shardTotal != shardIndex) {
        continue;
    }
}

分片广播(sharding broadcast)是 XXL-Job 的一种路由策略: 调度中心把任务分发给所有执行器,每个执行器拿到 shardIndex / shardTotal, 通过取模过滤只处理 1/N 的数据。

为什么用取模而不是按 ID 区间? 因为 ID 是雪花算法生成的,不连续, 按区间切分会出现数据倾斜(某段 ID 密集)。 取模能保证均匀分布。

这个设计的价值: 交易单量可能有百万级,单个执行器全量扫描 + 逐个调微信查单会超时。 分片之后每个执行器只处理 1/N,并且多个执行器并行。

批量发送消息而不是一条一条发:

java
// 把所有状态有变化的交易单组装成一个数组,一次性发
mqFeign.sendMsg(Exchanges.TRADE, RoutingKeys.TRADE_UPDATE_STATUS, msg);
// msg 是 List<TradeStatusMsg> 的 JSON

这个优化很关键: 如果有 100 笔需要更新,发 1 条消息 vs 发 100 条消息, MQ 的吞吐、DB 的压力、日志的噪音,差 100 倍。

而且消费侧也要能处理数组:

java
// oms MQListener
List<TradeStatusMsg> tradeStatusMsgList = JSONUtil.toBean(msg, new TypeReference<List<TradeStatusMsg>>() {}, false);
var msgList = tradeStatusMsgList.stream()
        .filter(v -> v.getStatusCode().equals(TradingStateEnum.YJS.getCode()))
        .collect(Collectors.toList());

三、难点拆解

难点 1:幂等要做到"哪一层"

难点在哪: 支付回调的幂等可以做在三个层次,各有不同的取舍:

层次手段优点缺点
业务层状态判断(当前做法)零成本依赖状态机的正确性
框架层唯一索引 + DuplicateKeyException数据库兜底,绝对可靠需要业务表有唯一约束
中间件层消息去重表 / Redis 幂等键通用多一次 IO

怎么解(三层一起用):

java
// 第 1 层(已有):锁 + 状态短路
if (trading.getTradingState() == TradingStateEnum.YJS) return;

// 第 2 层(建议加):数据库条件更新做兜底
// UPDATE cc_trading SET trading_state = 'YJS' WHERE trading_order_no = ?
//   AND trading_state <> 'YJS'
// 影响行数为 0 → 说明已经被别人处理过了 → 直接返回
int rows = tradingMapper.markAsPaid(tradingOrderNo, ...);
if (rows == 0) {
    log.info("交易单 {} 已经是已结算状态,忽略重复回调", tradingOrderNo);
    return;
}

// 第 3 层(可选):状态转换表(最彻底)
// 建一张 cc_trading_state_log(trading_order_no, from_status, to_status, created)
// 插入时用 (trading_order_no, from_status) 做唯一索引
// 这样任何一次状态变更都有唯一记录,重复变更直接被数据库挡住

第 2 层的价值最大,因为它把幂等从"应用逻辑"变成了"数据约束"。 应用逻辑可能被改错,数据库约束不会。

难点 2:多渠道的抽象边界

难点在哪: 微信和支付宝的接口形态差异很大:

维度微信支付宝
签名算法SHA256withRSARSA2
证书管理平台证书(需定期下载)+ 商户证书应用公私钥
回调加解密AES-GCM 解密RSA2 解密 + 验签
金额单位分(×100)元(字符串)
多次退款out_refund_no 每笔唯一out_request_no 需一致
查单接口/v3/pay/transactions/out-trade-no/{no}alipay.trade.query

如果不做抽象:

java
// 不抽象的写法
public TradingEntity createJsapiTrading(TradingEntity e) {
    if (WECHAT.equals(e.getChannel())) {
        // 微信的逻辑
    } else if (ALI.equals(e.getChannel())) {
        // 支付宝的逻辑
    }
}

每加一个渠道,所有方法都要改一遍,而且 if-else 会越写越长。

怎么解(当前做法 + 改进):

当前做法是策略模式 + 注解 + 工厂,方向是对的。但还有两个可以改进的点:

java
// 改进 1:HandlerFactory 返回 null 是个坑
// 调用方 HandlerFactory.get(channel, JsapiPayHandler.class).createJsapiTrading(...) 会 NPE
// 应该:
public static <T> T get(PayChannelEnum payChannel, Class<T> handler) {
    T result = null;
    for (T bean : SpringUtil.getBeansOfType(handler)) {
        PayChannel anno = AnnotationUtil.getAnnotation(bean.getClass(), PayChannel.class);
        if (anno != null && ObjectUtil.equal(payChannel, anno.type())) { result = bean; break; }
    }
    if (result == null) {
        throw new CCException(TradingEnum.PAY_CHANNEL_NOT_SUPPORT, payChannel);  // 明确的业务异常
    }
    return result;
}
java
// 改进 2:抽象层次再抽一层,把"渠道差异"和"业务流程"分离
// 签名/加解密这种纯技术差异,抽到 SDK 层或适配器层
// Handler 只表达业务流程
public interface JsapiPayHandler {
    /** 创建预支付交易,返回前端调起支付所需的参数 */
    JsapiPayParam createJsapiTrading(TradingEntity trading);
    /** 验签并解密回调,返回统一的支付结果 */
    NotifyResult verifyAndParseNotify(HttpServletRequest request);
}

改进 2 的价值在于: verifyAndParseNotify 抽出来之后, 上层就完全不知道渠道差异了,返回的是统一的 NotifyResult。

难点 3:支付状态机的完整性

难点在哪: TradingStateEnum 有 7 个状态:

java
DFK(1, 待付款) FKZ(2, 付款中) FKSB(3, 付款失败)
YJS(4, 已结算) QXDD(5, 取消订单) MD(6, 免单) GZ(7, 挂账)

但代码里没有任何一处定义"哪些状态可以流转到哪些状态"。 所有地方都是 setTradingState(...) 直接改。

后果:理论上可以写出 QXDD → YJS(已取消变已支付)这种荒谬的状态。

怎么解(状态机显式化):

java
// 1. 在枚举里定义合法流转
public enum TradingStateEnum {
    DFK(1, "待付款",   EnumSet.of(FKZ, QXDD, MD)),
    FKZ(2, "付款中",   EnumSet.of(YJS, FKSB, QXDD)),
    FKSB(3, "付款失败", EnumSet.of(FKZ, QXDD)),
    YJS(4, "已结算",   EnumSet.of()),        // 终态
    QXDD(5, "取消订单", EnumSet.of()),        // 终态
    MD(6, "免单",     EnumSet.of()),
    GZ(7, "挂账",     EnumSet.of(FKZ, YJS));

    private final Set<TradingStateEnum> allowedNext;
    public boolean canTransferTo(TradingStateEnum target) {
        return allowedNext.contains(target);
    }
}

// 2. 统一的状态变更入口,内部校验
public boolean changeStatus(Long id, TradingStateEnum target) {
    TradingEntity e = this.getById(id);
    if (!e.getTradingState().canTransferTo(target)) {
        throw new CCException(TradingEnum.ILLEGAL_STATUS_TRANSFER,
                e.getTradingState(), target);
    }
    // 条件更新:WHERE status = 当前状态,防止并发
    LambdaUpdateWrapper<TradingEntity> w = Wrappers.lambdaUpdate();
    w.eq(TradingEntity::getId, id)
     .eq(TradingEntity::getTradingState, e.getTradingState().getCode())
     .set(TradingEntity::getTradingState, target.getCode());
    return super.update(w);
}

注意这里 update 里带了 .eq(status, 当前状态)—— 这是乐观锁式的条件更新,一行代码同时实现了:

  1. 状态流转合法性校验(间接)
  2. 并发保护(CAS)
  3. 幂等(重复调用时 WHERE 不匹配,affected rows = 0)

一石三鸟。这是我最想推荐的做法。


四、实际遇到的困难与踩坑

坑 1:WECHAT_REFUND_ABNORMAL 常量值写错

java
// chain-cloud-ms-trade-service constant/TradingConstant.java
//【微信退款返回状态】
//SUCCESS:退款成功
public static final String WECHAT_REFUND_SUCCESS = "SUCCESS";
//CLOSED:退款关闭
public static final String WECHAT_REFUND_CLOSED = "CLOSED";
//PROCESSING:退款处理中
public static final String WECHAT_REFUND_PROCESSING = "PROCESSING";
//ABNORMAL:退款异常
public static final String WECHAT_REFUND_ABNORMAL = "TRADE_CLOSED";    // ★★ 错 ★★

注释写的是"ABNORMAL:退款异常",值却是 "TRADE_CLOSED"(这是"交易关闭"的状态)。

为什么没爆炸? 因为这个常量全模块零引用,是个死常量。

但它的危险在于: 一旦有人开始用这个常量做退款异常判断(比如重试策略), 它会静默地做出完全错误的判断——把"退款异常"当成"交易关闭", 导致退款逻辑走错分支。

面试话术: "这个错挺典型的。它没造成线上事故,因为这个常量压根没被用到。 但我觉得死代码比活代码更危险: 活代码至少还有测试在跑着它, 死代码是唯一一个'看起来合理但结论完全相反'的陷阱, 而且编译器不会告诉你,Code Review 时一个 WECHAT_REFUND_ABNORMAL = "TRADE_CLOSED" 在一屏代码里很难注意到。

我后来会在 CI 里加一个简单的一致性检查: 拿枚举类的中文注释和常量值做个交叉验证的单元测试。 类似的我还发现一处:WeChatBasicPayHandler#queryTrading 里 用退款常量 WECHAT_REFUND_SUCCESS(值为 "SUCCESS") 去判断支付状态——值恰好相同所以能跑对,但语义完全错了。"

同一文件里的另一个问题:

java
// WeChatBasicPayHandler.java:90
throw new CCException(response.getBody(), NATIVE_REFUND_FAIL.getCode(),
                      NATIVE_REFUND_FAIL.getCode());
//                              ↑ 第三个参数应该是 getStatus(),这里传了 getCode()

后果:异常对象的 HTTP status 会被设成业务码 1016 而不是 500。 同文件 156 行和 193 行写的是对的(NATIVE_REFUND_FAIL.getStatus())。

这类"同一个文件里三处调用只有一处写错"的问题,靠人眼 review 很难保证。

坑 2:HandlerFactory.get() 返回 null

java
public static <T> T get(PayChannelEnum payChannel, Class<T> handler) {
    ...
    return null;      // 找不到渠道返回 null
}

三个调用点全是裸调:

java
JsapiPayHandler jsapiPayHandler = HandlerFactory.get(channel, JsapiPayHandler.class);
jsapiPayHandler.createJsapiTrading(tradingEntity);       // ← null 会 NPE
java
BasicPayHandler handler = HandlerFactory.get(channel, BasicPayHandler.class);
Boolean result = handler.refundTrading(refundRecord);   // ← NPE

为什么会返回 null?

  1. tradingChannel 传了一个没有 Handler 的渠道;
  2. 代码里定义了两个"幽灵渠道":
java
public static final String TRADING_CHANNEL_CASH_PAY = "CASH_PAY";
public static final String TRADING_CHANNEL_CREDIT_PAY = "CREDIT_PAY";

这两个常量定义了,但没有对应的 PayChannelEnum 枚举值,也没有任何 Handler。 如果哪个前端传了 payChannel = "CASH_PAY",HandlerFactory 返回 null → NPE → 500。

修复: HandlerFactory.get() 应该找不到就抛明确的业务异常, 并且 PayChannelEnum 和 TradingConstant 的渠道集合要在启动时做一致性校验。

坑 3:支付渠道查询没有缓存

java
// PayChannelServiceImpl#findByEnterpriseId
@Override
public PayChannelEntity findByEnterpriseId(Long enterpriseId, String channelLabel) {
    LambdaQueryWrapper<PayChannelEntity> queryWrapper = new LambdaQueryWrapper<>();
    queryWrapper.eq(PayChannelEntity::getEnterpriseId, enterpriseId)
            .eq(PayChannelEntity::getChannelLabel, channelLabel)
            .eq(PayChannelEntity::getEnableFlag, Constants.YES);
    //TODO 缓存                                    ← 源码里就写着 TODO
    return super.getOne(queryWrapper);
}

而对应的缓存 key 常量已经定义好了:

java
public static final String PAY_CHANNEL_LIST = "trading:pay_channel_list&ttl=-1";

定义了但零引用。

这个查询在什么场景被调用? 每次创建交易单都要查支付渠道配置(商户号、证书、密钥)。 配置数据几乎不变,但每次下单都要查一次 MySQL。

修复(按贴进度):

java
@Override
@Cacheable(value = "payChannel", key = "#enterpriseId + ':' + #channelLabel",
           unless = "#result == null")
public PayChannelEntity findByEnterpriseId(Long enterpriseId, String channelLabel) { ... }

// 渠道配置变更时主动失效
@CacheEvict(value = "payChannel", key = "#p.enterpriseId + ':' + #p.channelLabel")
public void saveOrUpdate(PayChannelDTO p) { ... }

坑 4:回调处理没有"验签失败"和"业务失败"的区分

java
// NotifyServiceImpl#wxPayNotify
try {
    client.createHttpClient();
    ...
    Notification notification = notificationHandler.parse(request);
    if (!StrUtil.equals("TRANSACTION.SUCCESS", notification.getEventType())) return;
    jsonData = JSONUtil.parseObj(notification.getDecryptData());
} catch (Exception e) {
    throw new CCException("验签失败");           // ← 所有异常都归为"验签失败"
}

问题:一个 catch (Exception e) 把所有异常都归为"验签失败"。 但实际上这里的异常可能是:

  • 证书过期(运维问题)
  • 网络超时(网络问题)
  • 平台密钥配置错了(配置问题)
  • AEAD 密文长度不对(真的是验签问题)

对应的处置完全不同:

  • 证书过期 → 应该告警,不要返回 500 让微信一直重投(重投 8 次也没用);
  • 真的验签失败 → 必须返回 500(或者干脆丢弃),因为这可能是攻击流量。

更严重的是: updateTrading 里如果拿到的 trading 是 null(交易单不存在), trading.getTradingState() 会 NPE, 而这个 NPE 被外层的 catch (Exception e) { throw new CCException("处理业务失败"); } 吞掉, 返回 500 → 微信会重投 8 次,每次都是同样的 NPE。

应该做的是:

java
// 1. 验签失败:区分"配置问题"和"请求问题"
} catch (Exception e) {
    log.error("微信回调处理异常", e);
    if (isConfigError(e)) {
        // 配置问题:告警 + 返回成功,避免无效重投
        return;
    }
    throw new CCException(TradingEnum.NOTIFY_VERIFY_FAILED);
}

// 2. 交易单不存在:明确拒绝,不再重投
TradingEntity trading = this.tradingService.findTradByTradingOrderNo(tradingOrderNo);
if (ObjectUtil.isEmpty(trading)) {
    log.error("回调对应的交易单不存在:{}", tradingOrderNo);
    throw new CCException(TradingEnum.TRADING_NOT_FOUND);   // 明确业务异常
}

// 3. 用条件更新做原子判断,而不是先查后改
// UPDATE ... SET trading_state='YJS' WHERE trading_order_no=? AND trading_state <> 'YJS'
// affected=0 → 已处理过 → 直接返回成功

坑 5:MQ 消费侧同样可能重复

java
// oms MQListener#listenTradeUpdatePayStatusMsg
@RabbitListener(bindings = @QueueBinding(
        value = @Queue(name = Constants.MQ.Queues.OMS_TRADE_UPDATE_STATUS),
        exchange = @Exchange(name = Constants.MQ.Exchanges.TRADE, type = ExchangeTypes.TOPIC),
        key = Constants.MQ.RoutingKeys.TRADE_UPDATE_STATUS))
public void listenTradeUpdatePayStatusMsg(String msg) {
    List<TradeStatusMsg> tradeStatusMsgList =
            JSONUtil.toBean(msg, new TypeReference<List<TradeStatusMsg>>() {}, false);
    // 只处理支付成功的
    var msgList = tradeStatusMsgList.stream()
            .filter(v -> v.getStatusCode().equals(TradingStateEnum.YJS.getCode()))
            .collect(Collectors.toList());
    if (CollUtil.isEmpty(msgList)) return;
    this.crudOrderService.updatePayStatus(msgList, OrderPaymentStatusEnum.PAID.getCode());
}

updatePayStatus 里是 UPDATE cc_order SET payment_status = '已支付' WHERE id = ?—— 这是幂等写,重复执行结果一样。

所以 OMS 这一侧侥幸没出问题——但这是"因为操作本身幂等", 不是"因为做了幂等处理"。

如果这个操作不是幂等的(比如"支付成功后给用户发 100 积分"),就会重复发。

通用的解法(消费侧幂等):

java
// 1. 业务唯一索引
// cc_trading_status_log(trading_order_no, status_code) 唯一
// 插入冲突 → DuplicateKeyException → 说明处理过 → 直接 ack

// 2. 或者条件更新
UPDATE cc_order SET payment_status = 'PAID'
WHERE id = ? AND payment_status = 'UNPAID'          // ← 关键
// affected = 0 → 已经付过了 → 忽略

五、如果重做,我会怎么改

优先级改动收益
P04 处 finally unlock() 改条件解锁消除异常被覆盖
P0状态变更改条件更新(WHERE status = 当前状态)幂等从应用逻辑下沉到数据约束
P0HandlerFactory.get() 找不到时抛业务异常消除 NPE
P1交易状态机显式化(枚举里定义合法流转)消除 QXDD → YJS 这类非法流转
P1修 WECHAT_REFUND_ABNORMAL 常量值 + 清理死渠道常量消除定时炸弹
P1回调处理区分验签失败/配置错误/业务失败消除无效重投和告警噪音
P1支付渠道配置加本地缓存(常量已定义,方法上 TODO)下单链路去掉一次 MySQL
P2微信/支付宝的签名、加解密抽到适配器层降低渠道接入成本
P2消费者加条件更新幂等从"侥幸幂等"变成"设计幂等"
P22 小时关单时长改为运营可配业务规则不该硬编码

关于"条件更新"这条,值得再展开

这个技巧值得单独讲,因为它是最实用的幂等手段:

sql
-- 普通写法(不幂等)
UPDATE cc_trading SET trading_state = 'YJS' WHERE trading_order_no = ?;

-- 幂等写法
UPDATE cc_trading SET trading_state = 'YJS'
WHERE trading_order_no = ? AND trading_state <> 'YJS';

对比:

先查后改条件更新(CAS)
正确性依赖"查"和"改"之间没有并发由数据库保证原子
代码量3 行(查 + 判断 + 改)1 行 SQL
并发安全❌(两个线程都查到旧状态)✅
幂等❌✅
影响范围大(多处都要加判断)小(只改一处)

而且它还能表达业务语义: "只有当交易不是已结算时,才把它置为已结算" ——这句话本身就是业务规则,用一行 SQL 表达最清楚。

这个技巧适用于:

  • 状态机流转(支付、订单、作业单、运单)
  • 库存扣减(WHERE stock >= n)
  • 余额扣减(WHERE balance >= amount)
  • 任何"只生效一次"的副作用(发积分、发优惠券、扣库存)

面试时用这句话总结: "条件更新是把幂等性从应用代码下沉到数据约束。 应用代码可以被改错,数据库约束不会。 而且它一行 SQL 同时解决了并发、幂等和业务语义表达三件事, 比'先查后改'更简洁也更可靠。"


六、面试问答

Q1:支付回调的幂等怎么做的?

A: 两层:

text
第一层:分布式锁(按 tradingOrderNo)
  → 保证同一笔交易的并发回调只有一个在处理

第二层:状态短路
  if (tradingState == 已结算) return;    ← ★ 这是关键
  → 保证重复投递(先后到达)被挡住

为什么两层都要: 锁防的是"同时来",状态短路防的是"先后��"。 只加锁的话,第二次回调拿到锁后状态已经是 YJS 了, 还是会重复处理和重复发消息。

如果重做,我会再加一层:把状态变更改成条件更新

sql
UPDATE cc_trading SET trading_state = 'YJS'
WHERE trading_order_no = ? AND trading_state <> 'YJS'

条件更新把幂等性从应用逻辑下沉到了数据库约束。

追问:为什么这里用状态判断而不是唯一索引? 因为支付的"重复"不是重复插入,而是重复更新。 UPDATE 天然不会插入新行,唯一索引帮不上忙。 只有当操作是 INSERT 时,唯一索引才是最好的幂等手段。

Q2:为什么 refund 要限制最多 20 次?

A: 这是渠道限制 + 业务保护的双重考虑。

渠道侧: 微信/支付宝的退款接口有调用频次限制(微信是同一商户每秒 10 次左右), 多次退款会挤占正常支付的接口配额。

业务侧: 分 20 次退款意味着一个订单可能被拆成 20 笔退款单, 对账复杂度、财务凭证、客服处理成本都是指数上升。

所以代码里做了三层保护:

java
// 1. 次数上限
if (size >= 20) return null;            // idempotentRefundTrading

// 2. 金额上限
if (refund > tradingAmount) throw;      // checkRefundTrading

// 3. 每笔退款单有唯一 refundNo(雪花 ID)
//    作为微信的 out_refund_no 幂等键,保证不会重复退

追问:20 这个数字是怎么定的? 业务和财务一起定的。要平衡"客户可能需要多次部分退款" 和"对账复杂度"。 而且这个值应该是可配置的——不同企业的业务场景不一样。

Q3:为什么要做主动查单 + 定时对账?

A: 因为回调不可靠。

text
场景 1:用户支付后直接关掉了页面
  → 回调还是会到的(微信服务器主动调),所以这种情况还好

场景 2:网络问题导致我们的回调接口收不到,或处理失败
  → 用户那边显示"支付成功"(微信 App 里有记录),但我们的库里还是"待付款"
  → ★ 这就是"钱付了但单没更新",是最严重的对账差异

场景 3:我们主动发消息通知用户,但用户已经关了短信通道
  → 用户不知道要付钱

所以要做三层保障:

层次手段解决
用户侧轮询小程序/前端轮询 queryTrading用户不关页面时的快速感知
定时主动查tradingJob 查"付款中"超过 N 分钟的单,向微信查真实状态兜底,能发现漏掉的回调
日终对账拉微信对账单和本地库逐笔比对最终一致性保证,发现所有差异

tradingJob 的实现细节值得注意: 它查的是状态为 FKZ(付款中)且按 created ASC 排序的单, 每次处理一批,并对状态有变化的批量发消息。

为什么要按 created ASC? 因为"付款中"是先进先出的,早下单的应该先被查完。

追问:日终对账怎么做对不平的?

  • 差异分类:我们有它没有(本地超时关单但其实付了)/

它有我们没有(用户付了但我们没收到通知)/ 金额不一致;

  • 我们有的它没有:多为超时关单,需要人工确认后手动退款;
  • 它有我们没有:最危险,说明钱收了单丢了,必须自动补单 + 告警;
  • 对账文件要保留 2 年(合规要求)。

七、源码定位速查

文件行作用
trade/handler/HandlerFactory.java—渠道策略工厂(返回 null 的坑)
trade/service/impl/JsapiPayServiceImpl.java33-75JSAPI 下单(锁 + 幂等前置)
同上73⚠️ finally unlock() 在 if 外
trade/service/impl/NativePayServiceImpl.java54-102扫码支付 + 二维码生成
trade/service/impl/NotifyServiceImpl.java53-72微信回调(验签 + 解密)
同上74-103updateTrading 状态短路(幂等核心)
同上130⚠️ finally unlock() 在 if 外
trade/service/impl/BasicPayServiceImpl.java84-128退款(@Transactional 本地事务)
同上126⚠️ 同上
trade/handler/impl/BeforePayHandlerImpl.java106-128退款幂等(20 次上限)
同上130-155退款前置校验(状态 + 金额)
trade/constant/TradingConstant.java32⚠️ WECHAT_REFUND_ABNORMAL = "TRADE_CLOSED"
同上—TRADING_CHANNEL_CASH_PAY / CREDIT_PAY(无 Handler 的幽灵渠道)
trade/constant/TradingCacheConstant.java—锁 key 常量 + REDIS_WAIT_TIME=5
trade/handler/wechat/WeChatBasicPayHandler.java39-91主动查单 + 2 小时自动关单
同上72⚠️ 用退款常量判断支付状态
同上116-157微信退款(out_refund_no 幂等键)
trade/job/TradeJob.java47, 94tradingJob / refundJob(分片广播 + 批量发消息)
trade/service/impl/PayChannelServiceImpl.java39-47⚠️ 渠道配置查询(//TODO 缓存)
ms-domain/.../trade/enums/TradingStateEnum.java—7 个状态(无合法流转定义)
oms/mq/MQListener.java88-113消费支付/退款消息(靠 update 幂等)