亮点 8 · 支付回调会重投,退款能分 20 次
业务问题: 用户点了支付,微信/支付宝会多次给我们的回调接口发通知。 第一次说"付了 15 块",第二次说"付了 15 块",第三次还是"付了 15 块"。 我们必须保证:订单只被标记为已支付一次,消息只发一次。
除此之外还有两个业务难点: 退款可以分多次退(最多 20 次),以及用户可能一直不付,要自动关单。
一、业务场景
1.1 支付回调为什么一定会重复
| 重投原因 | 频率 | 说明 |
|---|---|---|
| HTTP 响应没在 5 秒内返回 | 高 | 支付平台会重投,最多 8 次 |
| 我们返回了 500 | 中 | 平台认为是失败,会重投 |
| 网络抖动但实际已处理 | 中 | 我们收到了,平台没收到响应 |
| 人工重放 | 低 | 运维排障 |
关键点:回调是"至少一次"投递,不是"恰好一次"。 所以幂等不是优化,是必需品。
而且我们返回 500 反而更糟:
// NotifyController
// 微信:成功返回 200 + 空 body,失败返回 500 → 微信会一直重投
// 支付宝:成功返回 "success",失败返回 500 → 支付宝最多重试 25 小时所以失败时返回 500 是刻意的——让平台重投, 比我们自己重试更可靠(因为重投有官方背书的顺序保证)。
1.2 三种支付方式的差异
| 方式 | 用户交互 | 服务端职责 |
|---|---|---|
| Native 扫码支付 | 出二维码 → 微信/支付宝扫 → 轮询查状态 | 生成二维码 + 轮询兜底(用户不扫也要能查到已付) |
| JSAPI 小程序支付 | 直接调起微信支付 | 下单拿 prepay_id → 前端调起 → 必须靠回调 |
| Basic 基础支付 | 管理端手动退款 | 主要做退款和对账 |
二、方案设计
2.1 策略模式 + 工厂:多渠道怎么统一
// 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 用注解声明自己负责哪个渠道:
@PayChannel(PayChannelEnum.WECHAT_PAY)
public class WeChatJsapiPayHandler implements JsapiPayHandler { ... }
@PayChannel(PayChannelEnum.ALI_PAY)
public class AliJsapiPayHandler implements JsapiPayHandler { ... }这个设计的价值: 新增一个支付渠道(比如加"云闪付")只需要: 写一个实现类 + 加一个注解,不需要改任何调用方代码。
而且 getBeansOfType 是从 Spring 容器里拿的,所以自动支持了 Spring 的生命周期管理。
2.2 创建交易:锁 + 幂等前置检查
// 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 支付回调:锁 + 状态短路(幂等的关键)
// 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 次的业务约束
// 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;
}// BasicPayServiceImpl#refundTrading
if (null == refundRecord) {
return false; // ← 退款次数超限
}
...
// BasicPayController
if (!result) {
throw new CCException(TradingEnum.BASIC_REFUND_COUNT_OUT_FAIL); // "退款次数超出限制,最多20次"
}退款流程:
@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;
}
...
}退款前置校验(两个):
// BeforePayHandlerImpl#checkRefundTrading
// 1. 必须已结算
if (tradingState != TradingStateEnum.YJS) → 拒绝
// 2. 累计退款金额不能超过支付金额
if (refund > tradingAmount) → 拒绝微信退款的幂等键设计(值得讲):
// 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 实现多次部分退款:
// AliBasicPayHandler#refundTrading
Factory.Payment.Common()
.optional("out_request_no", refundNo)
.refund(tradingOrderNo, refundAmount);微信和支付宝在多次部分退款上的模型不同:
- 微信:
out_refund_no每笔唯一,天然支持多次退款;- 支付宝:必须传同一个
out_request_no才认为是同一笔,换句话说支付宝的
out_request_no是幂等键而不是单号。这个差异很容易踩坑,属于要主动讲出来加分的地方。
2.5 主动查单 + 自动关单
用户支付后可能直接关掉页面不回跳,所以必须有主动查询:
// 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 分片对账任务
// 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,并且多个执行器并行。
批量发送消息而不是一条一条发:
// 把所有状态有变化的交易单组装成一个数组,一次性发
mqFeign.sendMsg(Exchanges.TRADE, RoutingKeys.TRADE_UPDATE_STATUS, msg);
// msg 是 List<TradeStatusMsg> 的 JSON这个优化很关键: 如果有 100 笔需要更新,发 1 条消息 vs 发 100 条消息, MQ 的吞吐、DB 的压力、日志的噪音,差 100 倍。
而且消费侧也要能处理数组:
// 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 |
怎么解(三层一起用):
// 第 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:多渠道的抽象边界
难点在哪: 微信和支付宝的接口形态差异很大:
| 维度 | 微信 | 支付宝 |
|---|---|---|
| 签名算法 | SHA256withRSA | RSA2 |
| 证书管理 | 平台证书(需定期下载)+ 商户证书 | 应用公私钥 |
| 回调加解密 | AES-GCM 解密 | RSA2 解密 + 验签 |
| 金额单位 | 分(×100) | 元(字符串) |
| 多次退款 | out_refund_no 每笔唯一 | out_request_no 需一致 |
| 查单接口 | /v3/pay/transactions/out-trade-no/{no} | alipay.trade.query |
如果不做抽象:
// 不抽象的写法
public TradingEntity createJsapiTrading(TradingEntity e) {
if (WECHAT.equals(e.getChannel())) {
// 微信的逻辑
} else if (ALI.equals(e.getChannel())) {
// 支付宝的逻辑
}
}每加一个渠道,所有方法都要改一遍,而且 if-else 会越写越长。
怎么解(当前做法 + 改进):
当前做法是策略模式 + 注解 + 工厂,方向是对的。但还有两个可以改进的点:
// 改进 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;
}// 改进 2:抽象层次再抽一层,把"渠道差异"和"业务流程"分离
// 签名/加解密这种纯技术差异,抽到 SDK 层或适配器层
// Handler 只表达业务流程
public interface JsapiPayHandler {
/** 创建预支付交易,返回前端调起支付所需的参数 */
JsapiPayParam createJsapiTrading(TradingEntity trading);
/** 验签并解密回调,返回统一的支付结果 */
NotifyResult verifyAndParseNotify(HttpServletRequest request);
}改进 2 的价值在于: verifyAndParseNotify 抽出来之后, 上层就完全不知道渠道差异了,返回的是统一的 NotifyResult。
难点 3:支付状态机的完整性
难点在哪: TradingStateEnum 有 7 个状态:
DFK(1, 待付款) FKZ(2, 付款中) FKSB(3, 付款失败)
YJS(4, 已结算) QXDD(5, 取消订单) MD(6, 免单) GZ(7, 挂账)但代码里没有任何一处定义"哪些状态可以流转到哪些状态"。 所有地方都是 setTradingState(...) 直接改。
后果:理论上可以写出 QXDD → YJS(已取消变已支付)这种荒谬的状态。
怎么解(状态机显式化):
// 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, 当前状态)—— 这是乐观锁式的条件更新,一行代码同时实现了:
- 状态流转合法性校验(间接)
- 并发保护(CAS)
- 幂等(重复调用时 WHERE 不匹配,affected rows = 0)
一石三鸟。这是我最想推荐的做法。
四、实际遇到的困难与踩坑
坑 1:WECHAT_REFUND_ABNORMAL 常量值写错
// 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") 去判断支付状态——值恰好相同所以能跑对,但语义完全错了。"
同一文件里的另一个问题:
// 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
public static <T> T get(PayChannelEnum payChannel, Class<T> handler) {
...
return null; // 找不到渠道返回 null
}三个调用点全是裸调:
JsapiPayHandler jsapiPayHandler = HandlerFactory.get(channel, JsapiPayHandler.class);
jsapiPayHandler.createJsapiTrading(tradingEntity); // ← null 会 NPEBasicPayHandler handler = HandlerFactory.get(channel, BasicPayHandler.class);
Boolean result = handler.refundTrading(refundRecord); // ← NPE为什么会返回 null?
tradingChannel传了一个没有 Handler 的渠道;- 代码里定义了两个"幽灵渠道":
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:支付渠道查询没有缓存
// 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 常量已经定义好了:
public static final String PAY_CHANNEL_LIST = "trading:pay_channel_list&ttl=-1";定义了但零引用。
这个查询在什么场景被调用? 每次创建交易单都要查支付渠道配置(商户号、证书、密钥)。 配置数据几乎不变,但每次下单都要查一次 MySQL。
修复(按贴进度):
@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:回调处理没有"验签失败"和"业务失败"的区分
// 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。
应该做的是:
// 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 消费侧同样可能重复
// 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 积分"),就会重复发。
通用的解法(消费侧幂等):
// 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 → 已经付过了 → 忽略五、如果重做,我会怎么改
| 优先级 | 改动 | 收益 |
|---|---|---|
| P0 | 4 处 finally unlock() 改条件解锁 | 消除异常被覆盖 |
| P0 | 状态变更改条件更新(WHERE status = 当前状态) | 幂等从应用逻辑下沉到数据约束 |
| P0 | HandlerFactory.get() 找不到时抛业务异常 | 消除 NPE |
| P1 | 交易状态机显式化(枚举里定义合法流转) | 消除 QXDD → YJS 这类非法流转 |
| P1 | 修 WECHAT_REFUND_ABNORMAL 常量值 + 清理死渠道常量 | 消除定时炸弹 |
| P1 | 回调处理区分验签失败/配置错误/业务失败 | 消除无效重投和告警噪音 |
| P1 | 支付渠道配置加本地缓存(常量已定义,方法上 TODO) | 下单链路去掉一次 MySQL |
| P2 | 微信/支付宝的签名、加解密抽到适配器层 | 降低渠道接入成本 |
| P2 | 消费者加条件更新幂等 | 从"侥幸幂等"变成"设计幂等" |
| P2 | 2 小时关单时长改为运营可配 | 业务规则不该硬编码 |
关于"条件更新"这条,值得再展开
这个技巧值得单独讲,因为它是最实用的幂等手段:
-- 普通写法(不幂等)
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: 两层:
第一层:分布式锁(按 tradingOrderNo)
→ 保证同一笔交易的并发回调只有一个在处理
第二层:状态短路
if (tradingState == 已结算) return; ← ★ 这是关键
→ 保证重复投递(先后到达)被挡住为什么两层都要: 锁防的是"同时来",状态短路防的是"先后��"。 只加锁的话,第二次回调拿到锁后状态已经是 YJS 了, 还是会重复处理和重复发消息。
如果重做,我会再加一层:把状态变更改成条件更新
UPDATE cc_trading SET trading_state = 'YJS'
WHERE trading_order_no = ? AND trading_state <> 'YJS'条件更新把幂等性从应用逻辑下沉到了数据库约束。
追问:为什么这里用状态判断而不是唯一索引? 因为支付的"重复"不是重复插入,而是重复更新。 UPDATE 天然不会插入新行,唯一索引帮不上忙。 只有当操作是 INSERT 时,唯一索引才是最好的幂等手段。
Q2:为什么 refund 要限制最多 20 次?
A: 这是渠道限制 + 业务保护的双重考虑。
渠道侧: 微信/支付宝的退款接口有调用频次限制(微信是同一商户每秒 10 次左右), 多次退款会挤占正常支付的接口配额。
业务侧: 分 20 次退款意味着一个订单可能被拆成 20 笔退款单, 对账复杂度、财务凭证、客服处理成本都是指数上升。
所以代码里做了三层保护:
// 1. 次数上限
if (size >= 20) return null; // idempotentRefundTrading
// 2. 金额上限
if (refund > tradingAmount) throw; // checkRefundTrading
// 3. 每笔退款单有唯一 refundNo(雪花 ID)
// 作为微信的 out_refund_no 幂等键,保证不会重复退追问:20 这个数字是怎么定的? 业务和财务一起定的。要平衡"客户可能需要多次部分退款" 和"对账复杂度"。 而且这个值应该是可配置的——不同企业的业务场景不一样。
Q3:为什么要做主动查单 + 定时对账?
A: 因为回调不可靠。
场景 1:用户支付后直接关掉了页面
→ 回调还是会到的(微信服务器主动调),所以这种情况还好
场景 2:网络问题导致我们的回调接口收不到,或处理失败
→ 用户那边显示"支付成功"(微信 App 里有记录),但我们的库里还是"待付款"
→ ★ 这就是"钱付了但单没更新",是最严重的对账差异
场景 3:我们主动发消息通知用户,但用户已经关了短信通道
→ 用户不知道要付钱所以要做三层保障:
| 层次 | 手段 | 解决 |
|---|---|---|
| 用户侧轮询 | 小程序/前端轮询 queryTrading | 用户不关页面时的快速感知 |
| 定时主动查 | tradingJob 查"付款中"超过 N 分钟的单,向微信查真实状态 | 兜底,能发现漏掉的回调 |
| 日终对账 | 拉微信对账单和本地库逐笔比对 | 最终一致性保证,发现所有差异 |
tradingJob 的实现细节值得注意: 它查的是状态为 FKZ(付款中)且按 created ASC 排序的单, 每次处理一批,并对状态有变化的批量发消息。
为什么要按 created ASC? 因为"付款中"是先进先出的,早下单的应该先被查完。
追问:日终对账怎么做对不平的?
- 差异分类:我们有它没有(本地超时关单但其实付了)/
它有我们没有(用户付了但我们没收到通知)/ 金额不一致;
- 我们有的它没有:多为超时关单,需要人工确认后手动退款;
- 它有我们没有:最危险,说明钱收了单丢了,必须自动补单 + 告警;
- 对账文件要保留 2 年(合规要求)。
七、源码定位速查
| 文件 | 行 | 作用 |
|---|---|---|
trade/handler/HandlerFactory.java | — | 渠道策略工厂(返回 null 的坑) |
trade/service/impl/JsapiPayServiceImpl.java | 33-75 | JSAPI 下单(锁 + 幂等前置) |
| 同上 | 73 | ⚠️ finally unlock() 在 if 外 |
trade/service/impl/NativePayServiceImpl.java | 54-102 | 扫码支付 + 二维码生成 |
trade/service/impl/NotifyServiceImpl.java | 53-72 | 微信回调(验签 + 解密) |
| 同上 | 74-103 | updateTrading 状态短路(幂等核心) |
| 同上 | 130 | ⚠️ finally unlock() 在 if 外 |
trade/service/impl/BasicPayServiceImpl.java | 84-128 | 退款(@Transactional 本地事务) |
| 同上 | 126 | ⚠️ 同上 |
trade/handler/impl/BeforePayHandlerImpl.java | 106-128 | 退款幂等(20 次上限) |
| 同上 | 130-155 | 退款前置校验(状态 + 金额) |
trade/constant/TradingConstant.java | 32 | ⚠️ 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.java | 39-91 | 主动查单 + 2 小时自动关单 |
| 同上 | 72 | ⚠️ 用退款常量判断支付状态 |
| 同上 | 116-157 | 微信退款(out_refund_no 幂等键) |
trade/job/TradeJob.java | 47, 94 | tradingJob / refundJob(分片广播 + 批量发消息) |
trade/service/impl/PayChannelServiceImpl.java | 39-47 | ⚠️ 渠道配置查询(//TODO 缓存) |
ms-domain/.../trade/enums/TradingStateEnum.java | — | 7 个状态(无合法流转定义) |
oms/mq/MQListener.java | 88-113 | 消费支付/退款消息(靠 update 幂等) |