🎯 核心挑战 — 支付系统的5道难关
挑战1:支付渠道对接 — 支付宝/微信/银联各有各的SDK
类比:你是一个翻译,要同时跟说英语、法语、日语的人交流。每种语言的语法、词汇都不一样,但你要把他们都翻译成统一的中文让后方理解。支付渠道适配器就是这个翻译。
三大渠道的差异:
| 维度 | 支付宝 | 微信支付 | 银联 |
| 签名算法 | RSA2 | MD5/HMAC-SHA256 | SHA256+证书 |
| 回调格式 | form-urlencoded | XML/JSON | JSON |
| 退款接口 | alipay.trade.refund | secapi_pay/refund | /refund |
| 证书要求 | 公钥+私钥 | 商户证书+key | 双向SSL证书 |
| 沙箱环境 | 有 | 有 | 有 |
用策略模式统一支付接口:
// ========== 1. 统一支付接口 ==========
public interface PaymentChannel {
/**
* 创建支付单(统一下单)
* @param request 统一支付请求
* @return 统一支付响应(包含支付参数/二维码等)
*/
PaymentResponse createPayment(PaymentRequest request);
/**
* 处理渠道回调
*/
PaymentCallbackResult handleCallback(Map<String, String> params);
/**
* 查询支付状态
*/
PaymentQueryResult queryPayment(String outTradeNo);
/**
* 发起退款
*/
RefundResponse refund(RefundRequest request);
/**
* 获取渠道类型
*/
ChannelType getChannelType();
}
// ========== 2. 支付宝实现 ==========
@Service
public class AlipayChannel implements PaymentChannel {
@Override
public PaymentResponse createPayment(PaymentRequest request) {
AlipayTradePagePayRequest alipayRequest = new AlipayTradePagePayRequest();
alipayRequest.setNotifyUrl(callbackUrl + "/alipay");
alipayRequest.setReturnUrl(returnUrl);
JSONObject bizContent = new JSONObject();
bizContent.put("out_trade_no", request.getOutTradeNo());
bizContent.put("total_amount",
request.getAmount().setScale(2).toString());
bizContent.put("subject", request.getSubject());
bizContent.put("product_code", "FAST_INSTANT_TRADE_PAY");
alipayRequest.setBizContent(bizContent.toString());
AlipayTradePagePayResponse response =
alipayClient.pageExecute(alipayRequest);
return PaymentResponse.builder()
.payForm(response.getBody()) // 返回HTML表单
.channelType(ALIPAY)
.build();
}
@Override
public PaymentCallbackResult handleCallback(
Map<String, String> params) {
// 验签
boolean verified = AlipaySignature.rsaCheckV1(
params, alipayPublicKey, charset, signType, publicKey);
if (!verified) {
throw new SecurityException("支付宝回调验签失败");
}
return PaymentCallbackResult.builder()
.outTradeNo(params.get("out_trade_no"))
.tradeNo(params.get("trade_no"))
.tradeStatus(params.get("trade_status"))
.success("TRADE_SUCCESS".equals(
params.get("trade_status")))
.build();
}
@Override
public ChannelType getChannelType() {
return ALIPAY;
}
}
// ========== 3. 微信支付实现 ==========
@Service
public class WechatPayChannel implements PaymentChannel {
@Override
public PaymentResponse createPayment(PaymentRequest request) {
Map<String, String> params = new HashMap<>();
params.put("appid", appId);
params.put("mch_id", mchId);
params.put("out_trade_no", request.getOutTradeNo());
params.put("total_fee",
String.valueOf(request.getAmount()
.multiply(BigDecimal.valueOf(100)).intValue())); // 微信要分
params.put("notify_url", callbackUrl + "/wechat");
// ...签名、调接口
return PaymentResponse.builder()
.payParams(generatePayParams(params))
.channelType(WECHAT)
.build();
}
@Override
public ChannelType getChannelType() {
return WECHAT;
}
}
// ========== 4. 渠道路由:根据渠道类型选适配器 ==========
@Service
public class PaymentChannelFactory {
@Autowired
private List<PaymentChannel> channels; // Spring自动注入所有实现
private final Map<ChannelType, PaymentChannel> channelMap = new HashMap<>();
@PostConstruct
public void init() {
for (PaymentChannel channel : channels) {
channelMap.put(channel.getChannelType(), channel);
}
}
public PaymentChannel getChannel(ChannelType type) {
PaymentChannel channel = channelMap.get(type);
if (channel == null) {
throw new BusinessException("不支持的支付渠道: " + type);
}
return channel;
}
}
✅ 核心思想:策略模式 + 工厂模式。上层只跟 PaymentChannel 接口打交道,不关心是支付宝还是微信。新增渠道(如Apple Pay)只需新增一个实现类,无需改任何已有代码。
挑战2:对账 — 内部账本和渠道账单一分钱都不能差
类比:收银台每天打烊要数钱,跟银行对账单核对。如果对不上——多钱叫"长款"(多了不知哪来的),少钱叫"短款"(少了说不过去),都必须查清楚。
T+1对账流程:
T日23:59
→
T+1日凌晨
下载渠道账单
→
下载内部账单
→
逐笔比对
逐笔比对
→
匹配成功 ✅
/
差账待处理 ❌
/**
* T+1对账核心逻辑
*/
@Service
public class ReconciliationService {
/**
* 执行对账
* @param reconcileDate 对账日期(T+1日)
*/
public ReconciliationResult reconcile(LocalDate reconcileDate) {
// 1. 下载渠道账单(支付宝/微信/银联)
List<ChannelBill> channelBills = downloadChannelBills(reconcileDate);
// 2. 查询内部支付记录
List<InternalPayment> internalPayments =
paymentMapper.selectByDate(reconcileDate);
// 3. 构建Map便于查找(key=商户订单号)
Map<String, InternalPayment> internalMap = internalPayments.stream()
.collect(Collectors.toMap(
InternalPayment::getOutTradeNo, p -> p));
Map<String, ChannelBill> channelMap = channelBills.stream()
.collect(Collectors.toMap(
ChannelBill::getOutTradeNo, b -> b));
// 4. 逐笔比对
List<ReconciliationDiff> diffs = new ArrayList<>();
// 4a. 以内部账为基准,找渠道账中有没有
for (InternalPayment internal : internalPayments) {
ChannelBill bill = channelMap.get(internal.getOutTradeNo());
if (bill == null) {
// 内部有,渠道没有 → 短款(可能是渠道延迟)
diffs.add(ReconciliationDiff.shortage(internal));
} else if (internal.getAmount().compareTo(bill.getAmount()) != 0) {
// 金额不一致
diffs.add(ReconciliationDiff.amountMismatch(internal, bill));
}
}
// 4b. 以渠道账为基准,找内部账中有没有
for (ChannelBill bill : channelBills) {
InternalPayment internal = internalMap.get(bill.getOutTradeNo());
if (internal == null) {
// 渠道有,内部没有 → 长款(可能是回调丢失)
diffs.add(ReconciliationDiff.surplus(bill));
}
}
// 5. 保存对账结果
saveReconciliationResult(reconcileDate, diffs);
return new ReconciliationResult(
reconcileDate,
internalPayments.size(),
channelBills.size(),
diffs.size(),
diffs
);
}
}
差账处理:
| 类型 | 含义 | 可能原因 | 处理方式 |
| 长款 |
渠道有记录,内部没有 |
支付回调丢失、内部记账失败 |
人工核实后补记内部账 |
| 短款 |
内部有记录,渠道没有 |
渠道结算延迟、用户未实际付款 |
T+2再核对,仍短则人工处理 |
| 金额不一致 |
两边都有但金额不同 |
精度问题、部分退款 |
核实具体差异后调账 |
挑战3:退款 — 原路退回、部分退款、重复退款防护
类比:你在商店买东西要退货,店员不会从自己口袋掏钱给你,而是通过原来的支付方式退回——微信付的退到微信,信用卡付的退到信用卡。这就叫"原路退回"。
退款状态机:
退款申请
→
退款中
→
退款成功 ✅
退款中
→
退款失败 ❌
→
重新申请
@Service
public class RefundService {
@Autowired
private PaymentChannelFactory channelFactory;
@Autowired
private StringRedisTemplate redisTemplate;
/**
* 发起退款
* @param request 退款请求
*/
@Transactional
public RefundOrder applyRefund(RefundApplyRequest request) {
// 1. 查询原始支付单
PaymentOrder payOrder = paymentMapper
.selectByOutTradeNo(request.getOutTradeNo());
if (payOrder == null) {
throw new BusinessException("支付单不存在");
}
// 2. 校验退款金额(不能超过实付金额 - 已退金额)
BigDecimal alreadyRefunded = refundMapper
.sumRefundedAmount(payOrder.getId());
BigDecimal maxRefundable = payOrder.getActualAmount()
.subtract(alreadyRefunded != null ? alreadyRefunded : BigDecimal.ZERO);
if (request.getRefundAmount().compareTo(maxRefundable) > 0) {
throw new BusinessException(
"退款金额超出可退额度,最多可退: " + maxRefundable);
}
// 3. 幂等校验(防止重复退款)
String dedupeKey = "refund:apply:" + request.getOutTradeNo()
+ ":" + request.getRefundAmount().toPlainString();
Boolean isFirst = redisTemplate.opsForValue()
.setIfAbsent(dedupeKey, "1", 1, TimeUnit.HOURS);
if (!isFirst) {
throw new BusinessException("请勿重复发起退款");
}
// 4. 创建退款单
RefundOrder refund = new RefundOrder();
refund.setRefundNo(generateRefundNo());
refund.setOutTradeNo(payOrder.getOutTradeNo());
refund.setChannelTradeNo(payOrder.getChannelTradeNo());
refund.setRefundAmount(request.getRefundAmount());
refund.setTotalAmount(payOrder.getActualAmount());
refund.setChannelType(payOrder.getChannelType());
refund.setStatus(REFUND_PROCESSING);
refund.setReason(request.getReason());
refundOrderMapper.insert(refund);
// 5. 调用渠道退款接口
PaymentChannel channel = channelFactory
.getChannel(payOrder.getChannelType());
RefundRequest channelRequest = RefundRequest.builder()
.outTradeNo(payOrder.getOutTradeNo())
.outRefundNo(refund.getRefundNo())
.totalAmount(payOrder.getActualAmount())
.refundAmount(request.getRefundAmount())
.reason(request.getReason())
.build();
try {
RefundResponse response = channel.refund(channelRequest);
if (response.isSuccess()) {
refund.setStatus(REFUND_SUCCESS);
refund.setChannelRefundNo(response.getChannelRefundNo());
} else {
refund.setStatus(REFUND_FAILED);
refund.setFailReason(response.getFailReason());
}
} catch (Exception e) {
refund.setStatus(REFUND_FAILED);
refund.setFailReason(e.getMessage());
}
refundOrderMapper.updateById(refund);
return refund;
}
}
挑战4:资金安全 — 一分钱都不能错
⚠️ 这是支付系统最核心的原则:钱的事,一分钱都不能错。不是"差不多就行",而是"必须精确到分"。
第一个最常见的坑——double精度丢失:
// ❌ 致命错误:用double存金额
double a = 0.1;
double b = 0.2;
double c = a + b;
System.out.println(c);
// 输出: 0.30000000000000004 ← 不是0.3!!
// 更恐怖的例子:
double price = 19.9;
double quantity = 3;
double total = price * quantity;
System.out.println(total);
// 输出: 59.699999999999996 ← 不是59.7!!
// ✅ 正确做法:用BigDecimal
BigDecimal price = new BigDecimal("19.90"); // 用String构造!
BigDecimal quantity = new BigDecimal("3");
BigDecimal total = price.multiply(quantity);
System.out.println(total);
// 输出: 59.70 ← 精确!
// ❌ BigDecimal的坑:用double构造也不行!
BigDecimal wrong = new BigDecimal(0.1);
System.out.println(wrong);
// 输出: 0.1000000000000000055511151231257827021181583404541015625
// ✅ 正确:必须用String构造
BigDecimal right = new BigDecimal("0.1");
// 或者用valueOf
BigDecimal alsoRight = BigDecimal.valueOf(0.1);
⚠️ 黄金规则:金额字段在Java中用 BigDecimal,在数据库中用 DECIMAL(12,2),永远不用 double/float。构造BigDecimal必须用String参数或BigDecimal.valueOf()。
资金安全的其他规则:
- 金额比较:用 compareTo() 不用 equals()(1.0 和 1.00 equals为false但compareTo为0)
- 数据库字段:DECIMAL(12,2),2位小数精确到分
- 统一单位:内部统一用"分"(Long),显示时转成"元"。避免小数运算。
- 负数校验:任何金额操作后都要检查是否为负
- 溢出校验:大额操作要检查是否溢出
挑战5:支付回调幂等 — 渠道可能多次推送回调
类比:你跟同事说"帮我订个会议室",同事答应了。但你怕他没听见,又说了3遍。好的同事只会订1次,不会订4次。支付渠道就像那个"你",可能因为网络超时多次推送同一个回调,我们的系统必须只处理1次。
幂等表设计:
CREATE TABLE t_payment_idempotent (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
out_trade_no VARCHAR(64) NOT NULL COMMENT '商户订单号',
channel_trade_no VARCHAR(64) NOT NULL COMMENT '渠道交易号',
channel_type VARCHAR(16) NOT NULL COMMENT '渠道类型',
status TINYINT NOT NULL COMMENT '处理状态 0=处理中 1=成功 2=失败',
request_data TEXT COMMENT '原始请求数据',
process_time DATETIME COMMENT '处理时间',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_out_trade_channel (out_trade_no, channel_type)
) COMMENT '支付幂等记录表';
// 完整的回调幂等处理
@Service
public class PaymentCallbackService {
@Transactional
public CallbackResult handleCallback(CallbackRequest request) {
// 1. 查幂等表:这个订单是否已经处理过?
PaymentIdempotent record = idempotentMapper
.selectByOutTradeNo(request.getOutTradeNo(), request.getChannelType());
if (record != null) {
// 已经处理过了
if (record.getStatus() == 1) {
// 已成功,直接返回成功
log.info("重复回调(已成功),忽略: {}",
request.getOutTradeNo());
return CallbackResult.alreadySuccess();
}
if (record.getStatus() == 0) {
// 还在处理中(可能是上一次还没处理完),等一会再查
log.warn("重复回调(处理中),稍后重试: {}",
request.getOutTradeNo());
return CallbackResult.processing();
}
// 失败的可以重新处理
}
// 2. 插入幂等记录(状态=处理中)
PaymentIdempotent newRecord = new PaymentIdempotent();
newRecord.setOutTradeNo(request.getOutTradeNo());
newRecord.setChannelTradeNo(request.getChannelTradeNo());
newRecord.setChannelType(request.getChannelType());
newRecord.setStatus(0); // 处理中
newRecord.setRequestData(request.getRawData());
try {
idempotentMapper.insert(newRecord);
} catch (DuplicateKeyException e) {
// 唯一索引冲突,说明另一个线程已经在处理了
log.warn("并发回调,唯一索引拦截: {}",
request.getOutTradeNo());
return CallbackResult.alreadyProcessing();
}
// 3. 执行业务逻辑(更新支付状态、记账等)
try {
paymentOrderService.paySuccess(
request.getOutTradeNo(),
request.getChannelTradeNo(),
request.getChannelType()
);
// 4a. 更新幂等记录为成功
newRecord.setStatus(1);
newRecord.setProcessTime(LocalDateTime.now());
idempotentMapper.updateById(newRecord);
} catch (Exception e) {
// 4b. 更新幂等记录为失败
newRecord.setStatus(2);
idempotentMapper.updateById(newRecord);
throw e; // 抛出异常让事务回滚
}
return CallbackResult.success();
}
}
🔄 关键流程详解
流程1:统一下单API
@RestController
@RequestMapping("/api/payment")
public class PaymentController {
@Autowired
private PaymentService paymentService;
/**
* 统一下单接口
* 所有业务方(订单、充值等)都调这个接口创建支付单
*/
@PostMapping("/create")
public Result<PaymentCreateVO> createPayment(
@RequestBody PaymentCreateDTO dto) {
// 1. 参数校验
if (dto.getAmount() == null
|| dto.getAmount().compareTo(BigDecimal.ZERO) <= 0) {
throw new BusinessException("支付金额必须大于0");
}
if (dto.getChannelType() == null) {
throw new BusinessException("请选择支付渠道");
}
// 2. 生成支付单号(规则:P + 日期 + 6位序号)
String outTradeNo = generatePaymentNo();
// 3. 创建支付单
PaymentOrder payment = new PaymentOrder();
payment.setOutTradeNo(outTradeNo);
payment.setBizOrderNo(dto.getBizOrderNo());
payment.setAmount(dto.getAmount());
payment.setChannelType(dto.getChannelType());
payment.setStatus(PAYMENT_INIT);
payment.setSubject(dto.getSubject());
payment.setTimeoutMinutes(dto.getTimeoutMinutes() != null ?
dto.getTimeoutMinutes() : 30);
paymentOrderMapper.insert(payment);
// 4. 调用渠道统一下单
PaymentChannel channel = channelFactory
.getChannel(dto.getChannelType());
PaymentRequest channelRequest = PaymentRequest.builder()
.outTradeNo(outTradeNo)
.amount(dto.getAmount())
.subject(dto.getSubject())
.build();
PaymentResponse response = channel.createPayment(channelRequest);
// 5. 更新支付单状态
payment.setStatus(PAYMENT_PENDING);
paymentOrderMapper.updateById(payment);
// 6. 返回支付参数给前端
return Result.success(PaymentCreateVO.builder()
.outTradeNo(outTradeNo)
.payForm(response.getPayForm())
.payParams(response.getPayParams())
.channelType(dto.getChannelType())
.build());
}
}
流程2:支付回调处理
/**
* 通用支付回调入口
* 每个渠道的回调URL都指向这个接口,通过路径参数区分渠道
*/
@PostMapping("/callback/{channelType}")
public String handleCallback(
@PathVariable String channelType,
@RequestBody String rawBody,
@RequestHeader HttpHeaders headers) {
log.info("收到[{}]支付回调: {}", channelType, rawBody);
try {
// 1. 获取对应渠道的适配器
ChannelType type = ChannelType.valueOf(channelType.toUpperCase());
PaymentChannel channel = channelFactory.getChannel(type);
// 2. 解析并验签
Map<String, String> params = parseCallbackParams(rawBody, type);
PaymentCallbackResult result = channel.handleCallback(params);
if (!result.isSuccess()) {
log.warn("回调支付失败: outTradeNo={}, status={}",
result.getOutTradeNo(), result.getTradeStatus());
return channel.successResponse(); // 也要返回成功,防止渠道重试
}
// 3. 幂等处理 + 业务更新
paymentCallbackService.handleCallback(CallbackRequest.builder()
.outTradeNo(result.getOutTradeNo())
.channelTradeNo(result.getTradeNo())
.channelType(type)
.rawData(rawBody)
.build());
} catch (Exception e) {
log.error("支付回调处理异常: channelType={}", channelType, e);
return "fail"; // 返回失败,让渠道重试
}
return "success";
}
流程3:T+1对账批处理
/**
* 每天凌晨2点执行T+1对账
*/
@Scheduled(cron = "0 0 2 * * ?")
public void dailyReconciliation() {
LocalDate yesterday = LocalDate.now().minusDays(1);
log.info("开始T+1对账,对账日期: {}", yesterday);
// 对每个渠道分别对账
for (ChannelType channel : ChannelType.values()) {
try {
// 1. 下载渠道账单
List<ChannelBill> bills = downloadBill(channel, yesterday);
// 2. 执行对账
ReconciliationResult result =
reconciliationService.reconcile(channel, yesterday, bills);
// 3. 如果有差账,发告警
if (result.hasDiff()) {
alertService.sendReconciliationAlert(channel, result);
}
log.info("[{}]对账完成: 内部{}笔, 渠道{}笔, 差异{}笔",
channel, result.getInternalCount(),
result.getChannelCount(), result.getDiffCount());
} catch (Exception e) {
log.error("[{}]对账失败", channel, e);
alertService.sendErrorAlert("对账执行失败", channel, e);
}
}
}
/**
* 下载渠道账单
*/
private List<ChannelBill> downloadBill(ChannelType channel, LocalDate date) {
PaymentChannel channelAdapter = channelFactory.getChannel(channel);
// 不同渠道的账单格式不同,由适配器统一解析
String billContent = channelAdapter.downloadBill(date);
// 解析账单(支付宝是CSV,微信是CSV,银联是固定宽度)
return billParser.parse(channel, billContent);
}
💥 踩坑实录 — 血泪教训
坑1:double精度丢失 — 0.1 + 0.2 ≠ 0.3
场景:某电商大促,用double存金额。用户买了3件19.9元的商品,应付59.7元,但系统算出来是59.699999999999996元。虽然看起来差一点点,但对账时就会出现"短款",引发大量客诉。
// ❌ 致命错误
public class PaymentCalculator {
private double totalAmount; // 用double存金额!
public void add(double amount) {
totalAmount += amount; // 精度丢失!
}
}
// ✅ 正确做法
public class PaymentCalculator {
private BigDecimal totalAmount = BigDecimal.ZERO;
public void add(BigDecimal amount) {
totalAmount = totalAmount.add(amount); // 精确!
}
public void add(String amountStr) {
totalAmount = totalAmount.add(new BigDecimal(amountStr));
}
}
修复方案:全局禁用double/float存金额,用BigDecimal(String构造)或Long(分为单位)。SonarQube扫描 + Code Review强制检查。
坑2:支付回调没做幂等 — 用户被扣两次钱
场景:支付宝回调因为网络超时重试了3次。第一次处理成功把订单改为"已支付",第二次又处理了一次,系统又给用户发了一次"支付成功"通知,用户看到了2条扣款短信。
修复方案:
1. 回调处理前先查幂等表
2. 幂等表用唯一索引保证并发安全
3. 处理完更新幂等记录状态
4. 返回success后渠道不再重试(但要保证处理真的成功了)
坑3:对账差1分钱 — 四舍五入差异
场景:对账发现内部账是100.00元,渠道账是99.99元,差了1分钱。查了半天发现是:内部用ROUND_HALF_UP(四舍五入),渠道用ROUND_DOWN(向下取整),同一个金额0.005元,内部进位成0.01,渠道截断成0.00。
// ❌ 不同舍入模式导致差异
BigDecimal a = new BigDecimal("1.005");
BigDecimal r1 = a.setScale(2, RoundingMode.HALF_UP); // = 1.01
BigDecimal r2 = a.setScale(2, RoundingMode.DOWN); // = 1.00
// 差了0.01元!
// ✅ 统一舍入模式
// 全系统统一使用 ROUND_HALF_UP,与支付渠道保持一致
public static final RoundingMode DEFAULT_ROUNDING = RoundingMode.HALF_UP;
public static final int SCALE = 2; // 统一2位小数
修复方案:全系统统一舍入模式为ROUND_HALF_UP,统一精度为2位小数。Code规范明确禁止其他舍入模式。
坑4:退款到已注销的银行卡
场景:用户3个月前用银行卡支付,现在申请退款,但银行卡已经注销了。退款接口返回"退款账户不存在",退款失败了。
解决方案:
1. 退款失败后,引导用户提供新的退款账户
2. 部分渠道支持"退到余额"——先退到平台余额,用户再提现
3. 退款单状态设为"退款失败-待处理",运营人工介入
4. 设置退款有效期提醒,超期未处理的自动发告警