← 返回业务系统实战目录

🏦 支付系统实战 — 资金安全的每一环

对账 退款 幂等 资金安全 支付渠道

像银行柜台一样理解支付系统:收银台=支付渠道、柜台记账=内部账本、对账=银行日终对账、退款=退回原路

🏦 业务全景 — 支付系统做什么

想象你去银行柜台办理业务的整个过程:

银行柜台类比

银行场景支付系统说明
你到银行柜台用户发起支付用户在APP上点击"立即付款"
柜员问你要哪种方式选择支付渠道支付宝、微信、银行卡等
柜员操作扣款调用渠道支付接口系统调起支付宝/微信SDK
柜员给你回单支付回调通知渠道通知支付结果
银行内部记账内部账本记录系统记录这笔资金流动
下班后对账T+1对账每天核对内部账和渠道账
你要求退回退款原路退回到用户账户

支付全流程:

创建支付单 调起支付渠道 用户付款 渠道回调
渠道回调 更新订单状态 内部记账 通知业务方
通知业务方 T+1对账 账务核对完成 ✅

支付系统是电商的"金库",任何一分钱的差错都是严重事故。它需要处理的核心问题包括:对接多种支付渠道、确保资金安全、每日对账、退款处理、防止重复扣款等。

⚠️ 资金安全是支付系统的底线:每一分钱都必须可追溯、可对账、可解释。double精度丢失、并发重复扣款、对账差一分钱——这些都不是小问题,而是P0级事故。

🎯 核心挑战 — 支付系统的5道难关

挑战1:支付渠道对接 — 支付宝/微信/银联各有各的SDK

类比:你是一个翻译,要同时跟说英语、法语、日语的人交流。每种语言的语法、词汇都不一样,但你要把他们都翻译成统一的中文让后方理解。支付渠道适配器就是这个翻译。

三大渠道的差异:

维度支付宝微信支付银联
签名算法RSA2MD5/HMAC-SHA256SHA256+证书
回调格式form-urlencodedXML/JSONJSON
退款接口alipay.trade.refundsecapi_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(); } }

🏗️ 架构设计 — 支付系统架构

支付网关
API网关(鉴权+限流) 签名校验 请求日志
路由引擎
渠道路由(选哪个渠道) 费率计算 渠道路由策略
渠道适配器
AlipayAdapter WechatAdapter UnionPayAdapter 统一回调处理
账务核心
内部记账(复式记账) 余额管理 流水记录
对账引擎
账单下载 逐笔对账 差账处理 对账报表

关键设计决策:

  • 支付网关统一入口:所有支付请求都走网关,网关负责鉴权(确保是合法的商户请求)、限流(防止恶意刷接口)、签名校验(防篡改)。就像银行大门,所有人都从正门进出。
  • 路由引擎自动选渠道:根据金额、用户偏好、渠道费率、渠道可用性自动选择最优支付渠道。就像银行柜台的"请到3号窗口"。
  • 渠道适配器隔离差异:不同渠道的SDK差异全部封装在适配器里,上层只调用统一接口。就像万能充电器,不管什么手机都能充。
  • 账务核心复式记账:每笔资金流动都有"借"和"贷"两条记录,确保账目永远平衡。就像会计的"借贷必相等"原则。
复式记账示例:用户付了100元 → 内部账记两笔:
借:应收渠道款 100元(渠道欠我们100元)
贷:用户预付款 100元(我们欠用户100元的货)
借贷相等,账目平衡。如果对不上,说明有错。

🔄 关键流程详解

流程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. 设置退款有效期提醒,超期未处理的自动发告警

🔍 排查思路 — 出了问题先看什么

问题1:支付回调未收到 — 订单一直"待支付"

1
查回调日志:grep订单号,看是否收到过回调
2
主动查询渠道:调渠道查询接口,看用户是否真的付了
3
检查回调URL:是否在渠道后台正确配置了回调地址
4
检查防火墙/网关:是否拦截了渠道的回调请求
5
手动补单:确认用户已支付,手动触发回调处理

问题2:对账出现差账

1
确认差账类型:长款(渠道有内部没有)还是短款(内部有渠道没有)
2
查交易时间:是否跨日交易导致对账日期不一致
3
查金额差异:是否舍入模式不同
4
查支付状态:内部是否已记录但状态未更新
5
人工核实并调账

问题3:退款失败

1
查退款单状态和失败原因
2
查原始支付单:渠道交易号是否正确
3
查渠道返回的错误码:如"ACCOUNT_NOT_EXIST"等
4
联系渠道客服确认退款限制
5
引导用户换卡或退款到余额

排查命令速查

# 查询支付单状态 $ mysql -e "SELECT * FROM t_payment_order WHERE out_trade_no='P202406130001'" # 查幂等记录 $ mysql -e "SELECT * FROM t_payment_idempotent WHERE out_trade_no='P202406130001'" # 查退款单 $ mysql -e "SELECT * FROM t_refund_order WHERE out_trade_no='P202406130001'" # 搜索回调日志 $ grep "P202406130001" /var/log/payment-callback.log | tail -30 # 主动查询渠道支付状态 $ curl -X POST "https://openapi.alipay.com/gateway.do" \ -d "method=alipay.trade.query&out_trade_no=P202406130001" # 查对账差异 $ mysql -e "SELECT * FROM t_reconciliation_diff WHERE reconcile_date='2024-06-12'" # 统计各渠道支付成功笔数 $ mysql -e "SELECT channel_type, COUNT(*), SUM(amount) FROM t_payment_order WHERE status=2 AND DATE(create_time)='2024-06-12' GROUP BY channel_type"

📝 面试高频 — 5道必考题

Q1:支付回调为什么要做幂等?怎么做?

参考答案:支付渠道(支付宝/微信)为了保证通知送达,会多次推送同一个支付结果回调(一般推4-5次)。如果不做幂等,会导致:
1. 订单状态被重复更新
2. 用户收到多条扣款短信
3. 业务方收到多条"支付成功"通知
实现方式
1. 幂等表:以(订单号+渠道类型)为唯一索引,处理前先插入幂等记录
2. Redis去重:SETNX以订单号为key,设置24小时过期
3. 状态判断:处理前先检查订单是否已经是"已支付"状态
三种方式结合使用,数据库唯一索引做最终兜底。

Q2:为什么金额不能用double?BigDecimal有哪些坑?

参考答案:double用二进制浮点数表示,无法精确表示0.1等十进制小数。0.1+0.2=0.30000000000000004,这在支付系统中是不可接受的。
BigDecimal的坑:
1. 构造函数坑:new BigDecimal(0.1) 仍然不精确,必须用 new BigDecimal("0.1") 或 BigDecimal.valueOf(0.1)
2. equals坑:new BigDecimal("1.0").equals(new BigDecimal("1.00")) 返回false(scale不同),但 compareTo 返回0。金额比较用 compareTo。
3. 除法坑:1/3会抛ArithmeticException(无限循环小数),必须指定scale和roundingMode。
最佳实践:金额用Long存分,或BigDecimal(String构造),数据库用DECIMAL(12,2)。

Q3:支付系统如何做对账?

参考答案:T+1对账,即每天凌晨核对前一天的账——
1. 下载账单:从各渠道下载T日交易账单(支付宝/微信后台提供API下载)
2. 逐笔比对:以(商户订单号)为关联键,比对内部账和渠道账
3. 差异处理
- 长款(渠道有内部没有):通常是回调丢失,补记内部账
- 短款(内部有渠道没有):通常是跨日交易,T+2再核对
- 金额不一致:查舍入模式、部分退款等
4. 告警机制:差账超阈值自动告警,人工介入处理

Q4:如何设计一个支持多渠道的支付系统?

参考答案:核心是策略模式+工厂模式——
1. 定义统一接口 PaymentChannel(createPayment/handleCallback/refund/queryPayment)
2. 每个渠道一个实现类(AlipayChannel/WechatChannel/UnionPayChannel)
3. PaymentChannelFactory 根据渠道类型路由到具体实现
4. 上层代码只跟接口打交道,不关心具体渠道
5. 新增渠道(如Apple Pay)只需加一个实现类+注册到工厂
另外还需要:渠道路由引擎(根据费率/可用性选渠道)、统一的回调入口(/callback/{channelType})、统一的对账流程。

Q5:退款流程有哪些注意点?

参考答案
1. 原路退回:必须退到原支付方式,不能退到其他账户(合规要求)
2. 部分退款:支持一笔订单多次部分退款,但要校验累计退款金额不能超过实付金额
3. 退款幂等:以退款单号为唯一标识,防止重复退款(最严重的资金事故)
4. 退款超时:渠道退款可能延迟,需要设置超时查询机制
5. 退款失败处理:银行卡注销、账户冻结等,需要人工介入或退到余额
6. 退款记账:退款也要记复式账,确保借贷平衡

业务系统实战 · 订单系统 · 支付系统 · 秒杀系统

返回目录