
我最早接支付的时候只做了支付宝代码里全是支付宝 SDK 的类名和字段名。后来老板一句话“再加个微信支付”我就被迫把整个下单模块翻出来重构了一轮。那次经历给我的教训很直接支付接入最贵的不是第一个渠道而是从单渠道走向多渠道时那些被写死的数据结构、状态判断和金额单位。这篇指南就是把“支付宝 微信双渠道”这套生产级实践完整梳理一遍重点放在密钥与证书体系、统一渠道抽象、下单回调主链路、退款对账和上线检查这几个维度。适合正在准备接微信支付、或者打算把支付模块重构成多渠道路径的 Java 后端。为了避免读者被官方 Demo 带偏我会先讲“为什么这么设计”再给可以直接参考的代码骨架和关键检查点。1. 为什么先定渠道边界再写代码1.1 支付宝与微信在接入姿势上的本质差异很多新手习惯拿官方 Demo 先跑通一个渠道跑通之后再补另一个。这种顺序本身没毛病真正的问题是如果你没有在第一天把渠道差异隔离掉业务层很快会长出大量if (channel ALIPAY)这种上帝分支后面每加一个字段都要两边同步改。要理解为什么需要隔离先看清两个渠道在设计上的差别维度支付宝微信支付服务端接入入口开放平台网关 官方 Java SDK商户平台 APIv3 REST 接口签名体系RSA2SHA256withRSA应用私钥签名平台公钥/证书验签商户私钥签名携带商户证书序列号用微信支付平台证书验签请求/响应格式表单参数与 JSON 混合老接口大量 KVAPIv3 基本全部 JSON异步通知表单参数 Map验签后看 trade_statusJSON 格式resource 字段用 AES-GCM 解密看 trade_state金额单位total_amount 是字符串“元”total 是整型“分”主要状态WAIT_BUYER_PAY / TRADE_CLOSED / TRADE_SUCCESS / TRADE_FINISHEDNOTPAY / SUCCESS / CLOSED / REFUND / REVOKED两边连“支付成功”的状态字段都不一样支付宝要认TRADE_SUCCESS微信要认SUCCESS。字段名、状态枚举、签名方式、金额单位全都不一样如果业务代码直接依赖渠道 SDK 的请求响应对象支付流水表就会被渠道字段污染后续的统计、查询、退款、对账全部要跟着渠道走。所以抽象这件事不是设计洁癖而是后面所有功能的成本问题。1.2 用统一接口先把渠道差异关进盒子里我采用的方案是经典的渠道适配器模式。业务侧只面对一个PaymentChannelAdapter接口支付宝和微信各自实现一个 adapterSDK 相关代码全部被关在 adapter 内部。public interface PaymentChannelAdapter { ChannelEnum channel(); PrepayResult prepay(PrepayRequest request); PayStatus query(String outTradeNo); RefundResult refund(RefundRequest request); RefundStatus queryRefund(String outTradeNo, String outRefundNo); boolean verifyNotify(MapString, String params); NotifyParseResult parseNotify(MapString, String params); String successResp(); String failureResp(); }PrepayRequest里的字段要提前在业务侧定死outTradeNo全局唯一订单号、subject商品描述、totalFeeCents金额统一用分、channel渠道枚举、openid微信 JSAPI/小程序必传、clientIp等。PrepayResult里放的是已经生成好的、客户端可以直接拿来拉起收银台的参数不同渠道的具体结构不同但对外统一包装。在 Spring 环境里把两个 adapter 注册成 Bean然后通过构造器注入组装成一个 MapService public class UnifiedPaymentServiceImpl implements UnifiedPaymentService { private final MapChannelEnum, PaymentChannelAdapter adapterMap; public UnifiedPaymentServiceImpl(ListPaymentChannelAdapter adapters) { this.adapterMap adapters.stream() .collect(Collectors.toMap(PaymentChannelAdapter::channel, Function.identity())); } Override public PrepayResult createOrder(PrepayRequest request) { PaymentChannelAdapter adapter adapterMap.get(request.getChannel()); if (adapter null) { throw new UnsupportedChannelException(request.getChannel()); } return adapter.prepay(request); } }这个封装的价值我自己的体感是支付模块 90% 以上的改动都是“新增一个渠道实现类”而不是去改老的业务逻辑。渠道 SDK 的升级、字段调整都被挡在 adapter 内部不会引起业务层连锁修改。2. 环境与密钥生产环境最先埋雷的位置2.1 支付宝的密钥体系公钥模式与证书模式怎么选支付宝的签名验签有公钥模式和证书模式。开发阶段用公钥模式最方便但生产环境我建议直接上证书模式证书模式下验签用的是支付宝公钥证书证书到期有明确提醒比手工维护一把公钥字符串更可控。用官方 SDK 初始化客户端时两种模式的配置差别就在AlipayConfig的构造上AlipayConfig alipayConfig new AlipayConfig(); // 沙箱网关和正式网关完全不同上线前必须确认 alipayConfig.setServerUrl(https://openapi-sandbox.dl.alipaydev.com); // 正式环境: https://openapi.alipay.com/gateway.do alipayConfig.setAppId(2021002...); alipayConfig.setPrivateKey(MII...); // 应用私钥PKCS8 格式 alipayConfig.setFormat(AlipayConfig.FORMAT_JSON); alipayConfig.setSignType(AlipayConfig.SIGN_TYPE_RSA2); // 证书模式需要三个证书 // 应用公钥证书、支付宝根证书、支付宝公钥证书 alipayConfig.setCertPath(new FileSystemResource(/certs/appCertPublicKey.crt)); alipayConfig.setRootCertPath(new FileSystemResource(/certs/alipayRootCert.crt)); alipayConfig.setAlipayPublicCertPath(new FileSystemResource(/certs/alipayPublicCert.crt)); AlipayClient alipayClient new DefaultAlipayClient(alipayConfig);新手最容易在这里踩的坑是沙箱和正式环境混配。沙箱 appid、沙箱网关、沙箱商户全部是独立体系经常有人拿着沙箱 appid 去调正式网关或者反过来结果全是各种签名异常。2.2 微信支付 APIv3 的证书和签名链路微信支付走 APIv3 时签名用的是商户私钥验签用的是微信支付平台证书。请求头Authorization里要带上商户证书序列号、随机串、时间戳和签名格式是固定的Authorization: WECHATPAY2-SHA256-RSA2048 mchid..., nonce_str..., timestamp..., serial_no..., signature...官方 Java SDK 把这些拼装细节都封装好了核心配置就是四样东西商户号mchid、商户私钥、商户证书序列号、APIv3 密钥。我一般这样组织// 以官方 SDK 的 Config Builder 为例不同版本类名略有差异 Config config new Config.Builder() .merchantId(1603...) .privateKey(loadPrivateKey(merchantPrivateKeyPem)) .merchantSerialNumber(4D9...) .apiV3Key(apiV3Key) .build();这里有个容易忽略的点APIv3 密钥不是用来做 RSA 签名的它主要用在两件事上一是回调通知里resource字段的 AES-GCM 解密二是下载账单时解密文件的密钥。所以配置管理上要把“商户私钥”和“APIv3 密钥”当成两份独立的敏感信息对待分开存储、分开轮换。2.3 配置落库前必须想清楚的几个细节密钥和证书这类配置在写第一行代码之前就得定好管理方式。我的建议是私钥和证书文件不进 Git不落数据库放到配置中心或环境变量里日志和异常信息里不要打印私钥内容。金额单位在业务层统一用“分”存储渠道层各自换算支付宝那边向外传字符串元微信那边传整型分。回调地址必须使用 HTTPS 且外网可达生产配置中心切到正式值后要确认没有旧值缓存残留。给证书和私钥做有效期监控。支付宝证书到期、微信平台证书轮换都是线上支付突然开始报错的高发原因。3. 从创建订单到支付成功主链路逐段打通3.1 服务端生成预支付单下单链路的第一步永远不是直接调支付接口而是在自己的订单表里先创建一条待支付记录拿到全局唯一的out_trade_no再交给渠道 adapter 去预约支付。支付宝 App 支付的服务端下单逻辑大概是这样的JSONObject bizContent new JSONObject(); bizContent.put(out_trade_no, outTradeNo); bizContent.put(total_amount, centsToYuan(totalFeeCents)); // 1.00 bizContent.put(subject, subject); bizContent.put(timeout_express, 30m); AlipayTradeAppPayRequest request new AlipayTradeAppPayRequest(); request.setNotifyUrl(alipayNotifyUrl); request.setBizContent(bizContent.toString()); AlipayTradeAppPayResponse response alipayClient.execute(request); // response.getBody() 里是客户端 App 直接可用的 orderStr微信 App 支付走 APIv3 是发一个 POST 到/v3/pay/transactions/appMapString, Object body new HashMap(); body.put(appid, appid); body.put(mchid, mchid); body.put(description, subject); body.put(out_trade_no, outTradeNo); body.put(notify_url, notifyUrl); MapString, Object amount new HashMap(); amount.put(total, totalFeeCents); // 整型, 单位分 amount.put(currency, CNY); body.put(amount, amount); HttpPost post new HttpPost(https://api.mch.weixin.qq.com/v3/pay/transactions/app); post.setEntity(new StringEntity(JSON.toJSONString(body), ContentType.APPLICATION_JSON));这里注意一个差异支付宝那边total_amount是字符串元微信这边amount.total是整型分。两边转换逻辑不要散落到业务代码里统一收敛到一个工具类。3.2 App 拉起支付的参数是怎么来的支付宝这边处理最简单服务端下单成功后拿到orderStr客户端直接调官方 SDK 的支付方法即可服务端不用再做二次加工。微信 App 支付则要分两段。第一段刚才说了先用/v3/pay/transactions/app拿到prepay_id第二段服务端要用prepay_id拼一个固定顺序的签名串String raw String.join(\n, appId, mchId, prepayId, SignWXPay, nonceStr, timestamp); String paySign rsaSign(raw, merchantPrivateKey);最后返回给客户端的是appid、partnerid商户号、prepayid、package固定值SignWXPay、noncestr、timestamp、sign这七个参数。很多同学卡在“为什么微信不直接返回签名”原因是拉起重收银台的是客户端客户端没法持有私钥做 RSA 签名所以签名必须由服务端完成。理解了这层边界就明白为什么要做第二次签名。微信小程序支付或 JSAPI 支付则类似只是下单时要求多带一个用户的openid调用的是/v3/pay/transactions/jsapi。3.3 回调验签、幂等与状态落库回调是整个支付链路里最需要认真对待的一段因为渠道会重试而且重试可能来得晚、来得乱。我的回调处理器模板长这样// 第一步验签验不过直接返回失败 if (!adapter.verifyNotify(params)) { return adapter.failureResp(); } // 第二步解析业务数据 NotifyParseResult data adapter.parseNotify(params); if (data.getPayStatus() ! PayStatus.PAID) { return adapter.successResp(); // 非成功状态无需处理 } // 第三步查本地订单幂等判断 PaymentOrder order paymentOrderMapper.selectByOutTradeNo(data.getOutTradeNo()); if (order null || order.getStatus() PayStatus.PAID) { return adapter.successResp(); } // 第四步金额一致性校验不一致必须告警 if (!Objects.equals(order.getTotalFeeCents(), data.getTotalFeeCents())) { alertService.amountMismatch(data); return adapter.failureResp(); } // 第五步状态落库并发布事件 paymentOrderMapper.updateStatusToPaid(order.getId(), data.getTransactionId(), data.getPaidAt()); eventPublisher.publish(new PaidEvent(order.getId())); return adapter.successResp();支付宝的回调成功响应是纯文本success/failure微信 APIv3 回调要求 HTTP 200且响应体是{code:SUCCESS,message:成功}这样的 JSON如果返回非 200微信会继续重试。3.4 主动查询兜底与关单回调再可靠也存在丢失或延迟的极端情况。我的做法是同时接上“回调处理”和“主动查询”两个通道定时任务每 5 分钟扫一遍状态仍然是待支付、且创建时间超过 10 分钟的订单用out_trade_no去对应渠道查询查到支付成功就补处理查到已关闭就同步关闭本地订单。支付宝查询走alipay.trade.query微信查询是GET /v3/pay/transactions/out-trade-no/{out_trade_no}?mchidxxx。主动查询的逻辑同样要幂等如果本地订单已经变成已支付状态查询结果就只做日志记录不能再触发重复的后续流程。关单是对超时未支付订单的回收。支付宝有alipay.trade.close微信有/v3/pay/transactions/out-trade-no/{out_trade_no}/close。两个渠道的关单接口都要求幂等对终态订单调用会返回特定错误码这个错误码要明确捕获并忽略不能当成致命异常上报。4. 退款、账单与对账低频高危操作要单独设计4.1 退款的三要素幂等、金额校验、双号关联退款接口调用频次低但一出问题就是资损级别。我的退款规范有三条硬要求第一退款单号必须业务侧生成并全局唯一。支付宝侧叫out_request_no微信侧叫out_refund_no。同一个订单做部分退款时可以有多条退款记录但每个退款单号只能对应一次退款退款表要对这个单号建唯一索引。第二退款前必须先校验可退金额。逻辑上要保证“本次退款金额 订单总额 - 已退总额”而且要在数据库层面做行锁或乐观锁避免并发退款把可退金额算错。第三退款成功后要查退款状态。支付宝同步响应里不一定能拿到最终结果需要用alipay.trade.fastpay.refund.query去查微信可以用GET /v3/refund/domestic/refunds/{out_refund_no}查询退款单当前状态。只有把退款状态推进到“退款成功”或“退款关闭”才能认为这笔退款流程结束。支付宝退款请求的简化写法JSONObject bizContent new JSONObject(); bizContent.put(out_trade_no, outTradeNo); bizContent.put(refund_amount, centsToYuan(refundFeeCents)); bizContent.put(out_request_no, outRefundNo); AlipayTradeRefundRequest request new AlipayTradeRefundRequest(); request.setBizContent(bizContent.toString());微信 APIv3 退款请求则是 POST/v3/refund/domestic/refundsbody 里带out_trade_no、out_refund_no、amount.refund、amount.total、amount.currency。4.2 账单下载和对账任务的落地思路渠道账单是对账的最硬证据。支付宝通过账单下载地址查询接口拿下载链接微信的账单下载接口返回的一般是 GZIP 压缩文件解密需要用到 APIv3 密钥。对账任务每天固定时间跑处理 T-1 的账单核心流程是下载当天渠道账单解析出每一笔交易的商户单号、渠道流水号、金额、时间。按out_trade_no和本地支付流水表关联。把匹配结果分成四类匹配一致、金额不一致、渠道有而本端无、本端有而渠道无。这四类里最需要关注的是“渠道有而本端无”。这个情况最常见的原因是回调丢了但用户实际支付成功走补单流程即可“本端有而渠道无”则要反向检查是不是调了沙箱数据没清干净或者渠道侧订单被撤销。对账脚本宁可跑慢一点也不能错配因为它直接关联资金安全。5. 生产环境里反复出现的那几个坑5.1 回调重复、乱序、延迟的应对支付宝和微信都有回调重试机制一次支付可能收到多次内容相同的通知也可能因为重试导致不同时间点的通知先后到达。我总结下来一条铁律回调处理绝不能做成“收到就 insert”。回调处理器只做状态扳机处理前必须先查一次本地订单当前状态。如果已经是终态就直接返回成功不再重复处理。乱序问题通常出现在“先支付后立刻退款”的场景退款回调可能比支付回调更早到达。状态机设计要允许待支付 - 已支付 - 已退款这个方向推进如果顺序异常比如从“待支付”直接收到退款成功通知要拒绝处理并告警。另外我建议加一张notify_log表把每次回调的原始报文完整落库。这张表只用来兜底排查不参与业务判定但真出了资损争议时原始报文就是最重要的证据。5.2 签名验签失败的典型原因签名验签失败在生产里绕不开常见原因有这么几类沙箱密钥和正式密钥混用。最典型的是配置中心切了正式变量但本地缓存没刷新。服务器时间偏差过大。微信验签时对时间戳有校验服务器时间不对会直接拒掉。支付宝验签用错了公钥。应该用支付宝公钥/证书而不是自己的应用公钥或应用证书。微信平台证书轮换后本地缓存的证书没更新。微信二次签名拼串顺序不对七个字段少一个或者顺序换一下就验签失败。定位这类问题最快的办法是抓原始请求头和原始报文体只看摘要信息多半会被误导。我后来把验签逻辑单独抽成 service只负责返回 true/false 和失败原因不掺任何业务代码排查效率高很多。5.3 金额单位与类型转换的坑支付宝的total_amount是“1.00”这种字符串元微信 APIv3 的total是整型 100。很多资损事故就出在金额转换上。业务库一律用BIGINT存分默认值 0渠道层再做转换public static long yuanToCents(String yuan) { return new BigDecimal(yuan) .movePointRight(2) .setScale(0, RoundingMode.HALF_UP) .longValueExact(); } public static String centsToYuan(long cents) { return BigDecimal.valueOf(cents) .movePointLeft(2) .toPlainString(); }不要用Double.parseDouble或Float去处理金额精度会出问题。这个工具类至少要覆盖0.10、9.99、100.01这几类用例才能放心上线。6. 上线前检查清单与实践体会6.1 能跑不等于能上生产能跑通官方 Demo 和有信心上生产是两码事。上生产前我习惯对着下面这张表逐项过分类检查项密钥生产网关、正式 appid、正式密钥/证书配置中心已切换且无旧值缓存回调HTTPS 外网可访问验签通过重复回调幂等失败重试逻辑正确订单out_trade_no 全局唯一状态机覆盖待支付、已支付、已退款、已关闭监控支付成功率、回调成功率、主动查询补单率、退款失败率都有告警对账T-1 对账任务已跑通差异单有明确处理人日志密钥和订单敏感字段已脱敏全链路 traceId 贯通“生产级”和“Demo 级”的差距基本都落在异常路径上回调延迟、重复回调、金额不一致、证书过期、超时未付、退款失败。上线前至少要把这六条异常路径在测试环境全部演练一遍。6.2 从接入到稳定时间到底花在哪以我最近一次双渠道接入为例写两个 adapter 本身只花了一周剩下的两周基本都花在幂等、对账和监控上。事后总结最大的收益来自最开始坚持统一接口和金额单位否则后面的隐患会成倍放大。我的另一个建议是先在沙箱环境把两个渠道的下单、回调、退款、关单全部自动化成集成测试每天跑一遍第二阶段用 1% 流量灰度观察支付成功率和回调失败率稳定后再全量放开。支付系统没有“最后一步”每上一个新渠道都是一次止血训练把流程沉淀成清单和测试用例才是这套代码真正能复用的地方。