
做支付系统这些年我听到最多的一句话就是“接入支付宝微信不就是调两个接口吗”单说调通接口确实一天就能搞定——文档一翻、SDK一引、下单调通、回调验签跑通一个 demo 真不难。但一旦上了生产环境你会发现自己整天在纠结的根本不是接口怎么调而是订单状态怎么保证、回调丢了怎么办、对账差异怎么处理、两个渠道的异常怎么统一收口。这些才是“生产级”和“demo 级”的分水岭。这篇文章不聊云里雾里的架构概念只讲一个实际落地的 Java 项目用 Spring Boot 把支付宝、微信支付双渠道完整接进去覆盖下单、回调、退款、查询、关单、对账这些关键流程。重点会拆解渠道抽象层的设计思路、两个平台各自的参数差异和坑点、生产环境必须做的幂等与一致性保障。适合正在做支付开发、或者准备接手支付模块的同学参考有经验的老手也可以直接跳到问题排查那部分看看。1. 整体设计先把渠道抽象层做好否则后面全是苦日子刚接手支付需求的时候很多人第一反应是写两个 Service一个 AlipayService一个 WechatPayService然后在 Controller 里 if else 判断走哪个。这个方案在只有一个支付场景、且不打算扩展渠道的时候勉强能用。但只要后续加上第 3 个渠道银联、云闪付、钱包余额或者支付形式从网页支付变成 App 支付、小程序支付这段代码就会变成一锅粥。我自己的做法是先定义一个统一接口把两个渠道“插件化”。业务层完全不感知支付宝和微信的差异只依赖这个抽象接口。1.1 渠道抽象层把支付宝和微信变成可替换的“插件”我当时定义的支付网关接口大致长这样public interface PayGateway { // 渠道标识alipay / wechat String channel(); // 创建支付单 PayResult createPayment(PayRequest request); // 主动查询订单 PayResult queryPayment(String outTradeNo); // 发起退款 RefundResult refund(RefundRequest request); // 解析渠道回调返回统一通知结果 PayNotification parseNotification(String body, MapString, String headers); }这个接口有两个关键点值得展开说。第一是返回值必须“统一”。PayRequest、PayResult 这些对象里不能直接放支付宝的 AlipayTradeCreateResponse也不能放微信的 Transaction 对象而是要把两个渠道的参数收敛成我们自己的领域模型。比如 PayRequest 就包含这几样内部订单号、渠道、金额、商品描述、回调地址、附加参数。渠道特有的字段微信要 openid、支付宝要 product_code放到一个 Map 类型的 extra 参数里由各渠道的自己解析。这样业务层永远只操作自己的对象不会因为渠道 SDK 版本升级而需要大改。第二是实现类要做成 Spring Bean通过渠道标识路由。具体路由逻辑可以用一个简单的 MapService public class PayGatewayRouter { private final MapString, PayGateway gatewayMap; public PayGatewayRouter(ListPayGateway gatewayList) { gatewayMap gatewayList.stream() .collect(Collectors.toMap(PayGateway::channel, g - g)); } public PayGateway route(String channel) { PayGateway gateway gatewayMap.get(channel); if (gateway null) { throw new IllegalArgumentException(unsupported channel: channel); } return gateway; } }Spring 会自动把两个实现类AlipayGateway、WechatPayGateway注入到 List 里后续新增渠道只需要加一个实现类Router 里的逻辑一行都不用改。这个模式其实就是策略模式在支付这种多渠道场景下非常好用我看过不少项目最终就是从这里开始腐坏的——渠道判断散落在 Service、Controller 各个角落最后新渠道上线像扫雷一样。1.2 订单状态机别让支付状态“裸奔”渠道抽象是横向的统一状态管理则是纵向的一致性保障。支付订单的状态如果只有一个 status 字段然后到处 set那出问题是迟早的事。我习惯用一个状态机来硬约束状态流转。先定义订单状态枚举public enum OrderStatus { CREATED, // 已创建等待支付 PAYING, // 支付中下单成功未收到回调 PAID, // 支付成功 CLOSED, // 已关闭超时未支付 REFUNDING, // 退款中 REFUNDED // 已退款 }状态机允许的流转路径在一个 Map 里写死private static final MapOrderStatus, SetOrderStatus ALLOWED_TRANSITIONS new EnumMap(OrderStatus.class); static { ALLOWED_TRANSITIONS.put(OrderStatus.CREATED, new HashSet(Arrays.asList( OrderStatus.PAYING, OrderStatus.PAID, OrderStatus.CLOSED))); ALLOWED_TRANSITIONS.put(OrderStatus.PAYING, new HashSet(Arrays.asList( OrderStatus.PAID, OrderStatus.CLOSED))); ALLOWED_TRANSITIONS.put(OrderStatus.PAID, new HashSet(Arrays.asList( OrderStatus.REFUNDING, OrderStatus.REFUNDED))); ALLOWED_TRANSITIONS.put(OrderStatus.REFUNDING, new HashSet(Collections.singletonList( OrderStatus.REFUNDED))); }每次状态变更都走统一的方法先校验流转是否被允许再更新状态同时往订单状态历史表里插一条记录。这么做最大的好处是不会出现“已退款订单还能被支付回调改成支付成功”的荒唐事。我在生产环境遇到过一次极其诡异的问题排查了整整一天最后发现是回调处理逻辑里没有状态判断支付成功回调和退款成功回调并发到达时后写的一方把状态覆盖了。有了状态机约束这种问题在代码层面就被拦截了。状态历史表也别省就是简简单单几列订单号、旧状态、新状态、操作人、操作时间、变更原因。这个表平时看着没用出了问题查数据时它就是救命稻草。比如用户说“我明明支付成功了怎么订单显示关闭”有状态历史就能一眼看出是关单任务先执行了还是支付回调没到。2. 支付宝接入实战参数、回调、验签与本地联调支付宝的文档在所有支付渠道里算是写得很全的但是有个特点内容太多而且一些关键细节分散在好几个页面里。这里我把从创建应用到最后跑通全流程的关键步骤重新捋一遍。2.1 准备工作应用创建、密钥生成与沙箱环境接入支付宝前要先在开放平台创建应用签约你要用的产品。支付类产品常用的有手机网站支付wap、APP 支付、电脑网站支付page、当面付扫码支付。不同产品的签约条件略有差异但接入逻辑基本一致核心参数是三个appId、应用私钥、支付宝公钥。密钥生成是第一个容易踩坑的地方。支付宝推荐 RSA2 签名也就是 SHA256withRSA生成密钥建议用 openssl 而不是直接用什么在线工具。我的实际做法是# 生成 2048 位 RSA 私钥 openssl genrsa -out app_private_key.pem 2048 # 从私钥导出公钥 openssl rsa -in app_private_key.pem -pubout -out app_public_key.pem然后把 app_public_key.pem 的内容上传到开放平台的“应用公钥”里平台会生成对应的支付宝公钥这个公钥要填到我们服务端的配置里。注意应用私钥自始至终只存在于自己的服务器绝不能提交到代码仓库。我见过有人把私钥直接写到 application.yml 里提交到 Git后果不堪设想。沙箱环境是另一个重点。支付宝的沙箱支持模拟真实支付流程gateway 地址和正式环境不一样。正式环境是 openapi.alipay.com沙箱环境是 openapi.alipaydev.com。我在本地开发时直接用沙箱环境跑通了整个下单回调流程但一定要记住沙箱应用和正式应用的 appId、密钥完全独立切换环境时必须把整套配置一起换掉否则就会出现“正式环境验签一直失败”的诡异问题。2.2 下单与回调一次完整的支付闭环支付下单的流程大同小异后端调用支付宝的创建订单接口拿回一段用于唤起支付的信息支付宝返回的表单 HTML 或者支付串前端拿到后唤起支付宝收银台。用支付宝官方 SDK 的 AlipayTradeService 时核心代码如下AlipayTradeWapPayRequest request new AlipayTradeWapPayRequest(); request.setNotifyUrl(notifyUrl); request.setBizContent({ \out_trade_no\:\ outTradeNo \, \total_amount\:\ amount \, \subject\:\ subject \, \product_code\:\QUICK_WAP_WAY\ }); AlipayTradeWapPayResponse response alipayClient.pageExecute(request);有几个细节必须抠清楚。out_trade_no 是商户订单号只能是数字、字母和下划线而且同一商户下不能重复。很多同学直接拿数据库自增主键当订单号这在单体阶段问题不大但如果后面做了分库分表或者有多个业务线共用一套支付网关订单号就可能撞车。我建议用“业务线编码 日期 随机序列”的方式生成比如 PAY20250307153000123456。再看 total_amount。支付宝金额单位是“元”字符串类型这背后有一个重要原因如果把它当成 double 来传可能出现 49.99 变成 49.990000000000002 之类的精度问题。所以永远不要用浮点数直接给支付宝传参金额在数据库里要存“分”这个整数单位对外传输时再转换为“元”的字符串。回调是支付闭环里最重要的一环。支付宝的异步通知会以 POST 请求发送到我们配置的 notifyUrl按文档约定收到通知后第一步就是验签。验签的目的是确认这笔通知确实来自支付宝而不是有人伪造请求。用支付宝 SDK 验签的方法// request 为支付宝通知请求 boolean signVerified AlipaySignature.rsaCheckV1( request.getParameterMap(), config.getAlipayPublicKey(), UTF-8, RSA2); if (!signVerified) { log.warn(支付宝验签失败outTradeNo {}, outTradeNo); return failure; }验签通过后还要核对 out_trade_no 和 total_amount 这两项必须和本地订单一致。这里有一个容易忽略的点支付宝会发送多笔不同状态的异步通知包括 WAIT_BUYER_PAY、TRADE_SUCCESS、TRADE_FINISHED。真正代表支付成功的是 TRADE_SUCCESSTRADE_FINISHED 表示交易已完成且不可退款。所以不要一收到通知就更新订单状态而是先判断 trade_status。收到通知后要返回“success”给支付宝如果不返回或者返回其他内容支付宝会认为通知发送失败然后按照间隔递增的策略反复重发最长可达 24 小时。所以即使这笔通知我们已经处理过了也要返回 success否则会被重复调用好多次。2.3 退款与查询别漏掉这两个基础能力退款接口的调用和下单类似但有一个细节退款金额 refund_amount 不能大于原订单金额而且退款可以分多笔。所以每次退款都要先校验“累计已退款金额 本次退款金额 原订单金额”这个逻辑要写在数据库事务里靠行锁或者乐观锁控制并发。另外支付宝退款成功是同步返回的也就是说调用接口就能知道结果不像支付那样依赖回调。主动查询接口则是对账和补偿的基础。比如用户支付成功但我们没收到回调时需要用 out_trade_no 调用查询接口确认订单状态。查询接口比回调更可靠因为它是“拉”的模式不受通知丢失影响这也是后面生产环境补偿机制的重要组成。3. 微信支付接入实战APIv3、证书体系与回调解密如果说支付宝是“参数多但文档清晰”那么微信支付就是“文档要理解很久但理顺了逻辑也清爽”。支付宝用公钥验签微信走的是 APIv3 协议整个安全模型和支付宝不一样这是很多从支付宝转过来的人最不适应的地方。3.1 APIv3 与证书体系两种安全模型对比微信支付的 APIv3 体系里有几个概念必须先搞清楚商户号 mchid微信支付商户平台的唯一商户标识。APIv3 密钥回调数据解密的对称密钥在商户平台手动设置。商户 API 私钥商户自己生成的 RSA 私钥用于请求签名。商户证书序列号商户证书对应的序列号请求时用来告诉微信“我是谁”。微信支付公钥微信侧的公钥用于验签。和支付宝“用应用私钥签名、用支付宝公钥验签”不同微信支付要求请求方使用商户 API 私钥对待签名串签名并把签名放在请求头里。微信服务器收到后通过商户证书找到对应公钥验签。回调验签则反过来微信用自己的私钥签名我们拿微信支付公钥去验。用官方 SDK wechatpay-java 时核心配置是配置商户号和私钥路径private static String buildWechatPayClient() throws IOException { PrivateKey merchantPrivateKey PemUtil.loadPrivateKey( new FileInputStream(/path/to/apiclient_key.pem)); WechatPayHttpClientBuilder builder WechatPayHttpClientBuilder.create() .withMerchant(mchId) // 商户号 .withPrivateKey(merchantPrivateKey) // 商户 API 私钥 .withMerchantSerialNumber(serialNo) // 商户证书序列号 .withWechatPayPublicKey(wechatPublicKey); // 微信支付公钥 return builder.build(); }两家的参数对比我整理成了一张表方便大家对照着准备维度支付宝微信支付商户标识appId应用IDmchid商户号 appid公众号/小程序ID请求签名应用私钥签名商户 API 私钥签名验签公钥支付宝公钥微信支付公钥回调数据加密明文签名AES-256-GCM 加密金额单位元字符串分整数这里要特别提醒微信支付的商户证书和 APIv3 密钥是两套东西很多同学分不清。申请 APIv3 密钥后它只用于回调解密的 AES 密钥派生不用于请求签名。请求签名用的是证书私钥这点别搞混否则解密回调数据时用私钥去解死活解不出来。3.2 下单、回调与解密微信支付核心流程微信支付的 JSAPI 下单适用于公众号/小程序内支付需要传 openid而 App 支付不需要。以 JSAPI 为例请求参数大致如下{ appid: 公众号或小程序的appid, mchid: 商户号, description: 商品描述, out_trade_no: 商户订单号, notify_url: https://api.xxx.com/pay/wechat/notify, amount: { total: 100, currency: CNY }, payer: { openid: 用户的openid } }注意这里的 total 是“分”整数类型。比如人民币 100 元这里要传 10000。这个单位坑我见过不止一次有人按支付宝的习惯传“100”结果用户支付了 1 块钱或者有人把 double 金额直接乘 100 再强转 int遇到 0.29 元就变成 28.99 之类的精度问题。回调是微信支付和支付宝差异最大的地方。微信回调请求体是加密的外层是 JSON里面 transaction 字段resource用 AES-256-GCM 加密。处理回调的第一步是验签即验证微信支付公钥对通知请求体的签名boolean signatureValid verifier.verify( headers.get(Wechatpay-Timestamp), headers.get(Wechatpay-Nonce), body, headers.get(Wechatpay-Signature));验签通过后再解密 resource拿到的明文里包含 out_trade_no、transaction_id、trade_state 等关键字段。解密逻辑用 SDK 的 NotificationRequest 就可以核心参数是 APIv3 密钥。解密后要做什么和支付宝一样核对订单号、金额、状态。微信支付回调里 trade_state 为 SUCCESS 才代表支付成功其他状态如 CLOSED、REFUND也要处理但核心逻辑要围绕 SUCCESS 展开。微信回调还有个细节处理成功后一定要返回 HTTP 200 且响应体中包含“成功”字样具体是空 JSON 的 200如果返回非 200微信会按照间隔递增的策略重试通知。我见过一个同学在处理回调后手动抛了个异常结果微信每 15 秒重发一次回调日志刷了上千条。3.3 微信支付特有的几个坑微信开放能力里openid 的获取是一个高频问题。openid 是用户在一个公众号/小程序下的唯一标识获取方式是通过授权流程换取的 code 再调用接口。很多苦于用户没有关注公众号的场景其实用静默授权 snsapi_base 也可以拿到 openid用户无感知。如果是 App 支付则不需要 openid直接用 appid 和 partnerid 唤起微信客户端。另一个坑是回调 body 里如果用了中转参数spbill_create_ip 之类或者业务系统做了链路追踪前缀可能在排查问题时对不上账单。我的做法是在创建订单时就把 wx 返回的 prepay_id 和我们的订单号关联存储后续所有查询都以 prepay_id 作为关联键之一这样查日志、对账都方便。还有一个很多人忽略的点微信支付的法务和结算要求回调域名必须是 HTTPS 且已在商户平台配置而且回调路径要与配置完全一致。我在测试环境踩过坑本地联调时用内网穿透工具映射了一个临时域名结果回调一直不触发后来发现是商户平台没配置该域名。4. 生产环境稳定性保障幂等、超时关单、对账与监控接口调通只是第一步。生产环境拼的是稳定性和数据一致性。这部分我按“钱不能错、单不能乱、问题要能查”的原则来讲。4.1 幂等设计重复回调是常态不是异常回调通知天然就是重复的——渠道会按策略重发我们自己的重试机制也可能重复提交。所以回调处理必须幂等。我的处理模型是“验签 - 查本地单 - 校验金额与状态 - 更新状态每一步都要有防重保障。查本地单这一步要加锁。我的做法是对订单记录加悲观锁或者用乐观锁版本号更新时带上条件“where order_status ?”更新影响行数为 0 则说明状态已变更直接返回成功。这样即使同一笔回调并发到达两次也只会有一次真正生效。另外要为每笔渠道流水建立一个唯一索引字段是 channel channel_transaction_id微信的 transaction_id、支付宝的 trade_no。回调处理时先尝试插入流水如果主键冲突说明重复通知直接返回成功即可。这个表也是对账时的重要依据。4.2 超时关单与主动补偿用户下单后可能一直不支付需要有一个任务把超时订单关闭。常见方案是两种基于延时消息RocketMQ 延时消息、Redis 过期监听等和基于定时扫描。从生产稳定性角度我更推荐定时扫描为主、延时消息为辅。延时消息在分布式中可能因为消费端积压而延迟执行定时扫描则可以通过数据库索引控制扫描范围稳定性更强。关单流程要注意和支付回调竞态。用户刚好在关单那一刻支付成功了如果关单任务先把订单置为关闭随后支付回调到达按照状态机校验会发现不允许从 CLOSED 变更为 PAID然后怎么办我建议处理方式是如果订单已关闭但渠道返回支付成功需要拉起一笔退款把用户的钱原路退回。这个场景在真实支付中比例极低但必须有兜底逻辑否则就成了“用户付了钱但你订单关闭”的事故。主动补偿的核心是定时扫描订单表找出“下单超过 N 分钟仍处于 CREATED/PAYING 状态”的订单调用渠道查询接口确认真实状态。如果查询结果已经支付成功但本地还是未支付就需要靠这笔查询结果触发状态更新。另外支付成功缺回调还可能是通知 URL 配置错了所以日志里要把回调到达率和支付成功率作为核心指标监控起来。4.3 对账机制资金安全的最后防线对账是支付类系统最不讨人喜欢但绝对不能少的环节。支付宝和微信都提供 T1 的对账单下载功能即第二天可以下载前一天的交易明细。对账任务一般是每天凌晨跑逻辑分三步第一步从渠道下载账单文件解析成标准格式的流水记录第二步把渠道流水和本地订单表做双向比对找出两边不一致的记录第三步对差异进行分类处理。常见的差异包括本地有支付单但渠道没有可能是订单没真正提交成功、渠道有支付记录但本地没有可能是回调丢失导致本地状态未更新、金额不一致这种情况要立即人工介入。这个对账任务最好做成独立模块和主交易链路隔离避免因为对账逻辑报错影响线上交易。我的经验是对账操作要放到独立的定时任务服务里用独立的数据库账号只有读权限避免误操作。4.4 日志与监控问题要能“追得回”日志打得好不好直接决定线上问题排查效率。我习惯在以下几个节点打结构化日志创建订单包含渠道、金额、订单号、渠道调用返回包含返回码、耗时、回调接收包含渠道、完整原始报文摘要、状态变更旧状态、新状态、触发类型。日志里必须带上全局链路 IDtraceId方便从用户请求一路串到回调处理。监控方面支付系统至少要有三个核心指标支付下单成功率、回调到达率、支付最终成功率。支付下单成功率下降通常是签名配置或下单参数问题回调到达率下降说明回调接口可能出错了支付最终成功率异常往往是对账或退款链路出了问题。这些指标通过定时统计状态表数据就能拿到不需要额外埋点。5. 常见问题与排查实录最后这部分是实战高频问题的速查。很多问题都有固定的排查路径知道套路以后五分钟能定位不知道就会像无头苍蝇。5.1 金额精度丢失现象订单金额显示 0.9999999或者回调金额和本地金额差几分钱。原因基本都在浮点数。方案就一条数据库金额一律用分成整数类型存储对外传输金额一律用字符串计算一律用 BigDecimal。5.2 回调验签一直失败先确认环境没混正式环境是不是用了沙箱公钥沙箱环境是不是用了正式公钥。再确认参数构造有没有用到原始报文特别是微信回调如果自己拼报文空格、换行、大小写都可能导致验签失败。一般我会先在日志里把收到的报文原样打出来和验签方法接收到的字符串做对比很快就能看出问题。5.3 重复支付同一笔订单用户发起多次支付渠道返回两个不同的支付流水。这里的处理原则是订单状态机兜底只要有一个渠道流水把订单置为 PAID后续其他支付请求一律拒绝或自动退款。这里要警惕并发如果两个请求同时通过状态校验就一定要靠数据库行锁来解决。5.4 沙箱与正式环境切换出错支付宝沙箱网关地址、appId、密钥不同微信支付几乎没有沙箱环境微信支付有测试商户号但功能有限大部分测试要依赖真实环境。我觉得最稳妥的做法是把渠道配置放到配置中心按环境隔离本地开发一律走支付宝沙箱微信支付则通过完全独立的一套测试商户号完成上线前做一次配置核对。5.5 问题排查速查表问题现象可能原因排查路径回调不触发回调 URL 未配置、HTTPS 不通过检查商户平台回调地址配置测试临时域名是否实现回调验签失败公钥/证书配置错误对比验签原始报文与请求体微信回调解密失败APIv3 密钥错误、数据非完整报文核对密钥确保拿到原始字符串订单状态被覆盖缺少状态机约束查状态历史表定位状态变更来源用户已支付但订单未更新回调丢失查流水表、主动查询接口状态退款迟迟不成功余额不足、原订单非成功态查退款接口返回、确认订单状态最后再说一个排查技巧。如果线上出现疑似支付异常先不要急着改代码下去查通道侧数据支付宝的商家中心交易查询、微信商户平台交易查询都提供了订单实时状态界面。先用这个页面确认渠道侧的真实状态再回本地查数据库两边一对比就能很快定位问题在哪个环节。交付过几个支付项目之后我最大的体会是支付系统的复杂度不在任何一个接口里而藏在状态流转、异常处理、数据一致性这些“边角料”里。把回调幂等、对账、日志这些基础功夫做扎实了生产环境会少掉很多头发。最后分享一个小技巧上线前把支付宝沙箱环境和微信测试商户各跑一遍完整流程但正式环境的鉴权配置一定要在配置中心单独维护一套别在本地和沙箱混着测这个坑我替你们踩过了。