在对接Spring Boot 微信支付接口这块,我前后踩了小半年,从申请商户号到Native下单、回调验签、退款、对账全部走通。这期间最折磨人的不是业务代码,而是微信支付那套文档和签名机制,尤其是在联调阶段,明明照着文档写了,却一直报签名错误、验签失败、证书加载异常。很多朋友在群里问“为什么按照官方Demo还是不行”,大部分问题其实都出在几个固定的地方。这篇文章我就把从零到上线积累的经验拆开讲,不会去堆API大全,而是把真正决定成败的细节抠清楚。
整套体系涉及的东西其实不少:商户号、APIv3密钥、商户API证书、平台证书、回调验签、AES-GCM解密、幂等处理。单独拎出每一项来读文档都能看懂,但串在一起就是个无底洞。准备接微信支付的开发人员,无论是新手还是有Spring Boot基础的,我建议先把我这篇文章的框架过一遍,至少知道钱走哪条路、谁的钥匙开哪扇门,再去写代码,会比对着文档硬怼舒服很多。
1. 开工前的账房:申请支付能力前,先把五样材料备齐
这一步藏着一个很大的坑。很多人的误区是只要拿到商户号,找到一个支付接口文档就能开始写代码,结果写到一半发现还缺证书、缺AppID关联,来回折腾好几次。微信支付和普通的第三方支付SDK不一样,它不是提供一个token就能调通的,权限体系分了好几层,每一层对应的材料和用途都不一样。
1.1 微信支付体系的身份凭证有哪些
需要先明确一个概念,微信支付有两种主流接入模式:JSAPI支付(公众号/小程序内部拉起)、App支付、H5支付、Native支付(扫码)、小程序支付。无论哪一种,它们共享同一套商户平台和密钥体系,只有下单参数中的trade_type不一样。所以只要密钥体系准备好,所有场景都是同一个套路。
备用材料清单如下:
- 商户号mchid:商户平台主页就能看到,一个营业执照可以申请一个或多个商户号。这个号会在后续所有接口请求中出现。
- AppID:来源于你注册的公众号或小程序平台,是用户在微信端的“应用标识”,需要登录商户平台在“产品中心-AppID账号管理”里完成绑定,把AppID和商户号关联起来。这也是很多人忽略的一步,不关联,JSAPI下单必报“appid与商户号不匹配”。
- APIv3密钥(APIV3 Key):这是加解密用的对称密钥,在商户平台“账户中心-API安全-设置APIv3密钥”里自行设置。它不是证书,而是32字节的字符串,用于回调通知的AES-GCM解密,以及微信支付敏感信息(如退款回调中的用户信息)的加解密。
- 商户API证书(商户私钥+证书序列号):在商户平台申请,包含apiclient_key.pem(私钥)和apiclient_cert.pem(公钥证书)。这里的私钥用来给请求签名,证书序列号(有效期)会放在请求头里告诉服务端用哪个证书做验签。证书申请后可以下载两次,丢失就得重置,非常麻烦。
- 微信支付平台证书(平台公钥):这是微信支付服务端的公钥证书,用来验证回调通知是不是微信官方发的。V3版本有些接口响应会返回平台证书序列号,需要根据证书序列号去拉取最新的平台证书。
很多人不理解为什么需要“商户证书”和“平台证书”两套。通俗讲,商户证书相当于你作为收款方的公章盖在每个请求上,微信服务器读过你的公章确认请求确实来自你;平台证书则是微信的工号牌,微信发回调通知时你核对工号牌,确认确实是官方推的,而不是别人伪造的退款通知。
注意:APIV3密钥是你在商户平台手动设置的一个随机32字节字符串,不是商户证书密码,别混淆。很多人把这两个东西搞混,导致后面解密回调通知时要么Key短了要么解出来是乱码。
1.2 证书和密钥的保存位置
我推荐把敏感信息放在Spring Boot的外部配置中,比如application-prod.yml中通过环境变量注入,而不要把pem文件塞进src/main/resources里打打进jar包。pem文件放到服务器上的一个独立目录,只允许java进程用户访问。项目里通过@Value("${wechatpay.privateKeyPath}")这种形式读路径,然后启动时加载字节流。
一个推荐目录结构:
/data/wechatpay/ ├── apiclient_key.pem ├── apiclient_cert.pem └── wechatpay_platform_cert.pem强烈建议在代码里对私钥做一个校验,确保真的能通过PKCS8格式读取。很多情况下从商户平台下载的是PKCS1格式,需要转换。我用OpenSSL转换过,这个后面再讲。实际项目中遇到最多的报错就是“Invalid key format”,根源就是私钥格式不匹配。
2. 从Request到Response:手写一个带微信签名的HTTP客户端
微信支付V3接口的所有请求都要求客户端对请求内容进行签名,服务端返回的内容也可能带签名。很多Spring Boot项目已经引入了RestTemplate或OkHttp,但直接用它们发起微信支付请求时会发现,签名头的构造是大家普遍卡住的地方。
2.1 微信支付V3签名机制剥开看
以JSAPI下单请求为例,完整链路是:你的服务器按照一定规则构造签名串,用商户私钥对签名串做SHA256withRSA签名,形成signature字段;然后把一系列元信息打包成Authorization请求头;微信服务器收到后,根据请求头里的序列号找到你这张商户证书的公钥,验签该请求就是真实发起的。
签名串的构造规则如下(以JSAPI为例,Native下单同样适用):
HTTP方法\n URL路径\n 请求时间戳\n 请求随机串\n 请求报文主体\n逐项说清楚:HTTP方法就是POST;URL路径指的是接口地址中的pathname部分,不包含query和域名,比如“/v3/pay/transactions/jsapi”;请求时间戳就是当时秒级Unix时间戳;随机串一般是UUID或工具类生成的32位随机字符串;请求体就是实际发送的JSON字符串。如果请求体为空字符串,则签名串第五行为空行,最后一行的换行符仍然保留。
签名代码片段(Java 11以上可以用java.nio和java.security):
/** * 生成微信支付V3请求签名 */ public static String sign(String httpMethod, String urlPath, String timestamp, String nonce, String body, PrivateKey privateKey) throws Exception { String message = httpMethod + "\n" + urlPath + "\n" + timestamp + "\n" + nonce + "\n" + body + "\n"; Signature sign = Signature.getInstance("SHA256withRSA"); sign.initSign(privateKey); sign.update(message.getBytes(StandardCharsets.UTF_8)); return Base64.getEncoder().encodeToString(sign.sign()); }2.2 Authorization请求头应该长什么样
有了签名,还不能直接拿去做请求。需要把它拼到Authorization头里面,格式固定:
WECHATPAY3-SHA256-RSA2048 mchid="商户号",nonce_str="随机串",timestamp="时间戳",serial_no="证书序列号",signature="签名值"这里serial_no值得单独强调一遍,它是商户API证书的序列号,不是平台证书的序列号。有的朋友图省事直接把平台证书序列号拿过来用了,会一直报“serial no mismatch”或“证书序列号不存在”。序列号查看有两种方式:OpenSSL命令openssl x509 -in apiclient_cert.pem -noout -serial可以读取,Java侧也可以直接从X509Certificate对象获取。
我封装了一个核心请求方法,为了简化展示用原生HttpClient:
public class WechatPayClient { private final String mchId; private final String privateKeyPath; private final String certSerialNo; private final String apiV3Key; private final String appId; private final HttpClient httpClient; public WechatPayClient(String mchId, String privateKeyPath, String certSerialNo, String apiV3Key, String appId) { this.mchId = mchId; this.privateKeyPath = privateKeyPath; this.certSerialNo = certSerialNo; this.apiV3Key = apiV3Key; this.appId = appId; this.httpClient = HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(10)) .build(); } public String post(String urlPath, String body) throws Exception { String timestamp = String.valueOf(Instant.now().getEpochSecond()); String nonce = UUID.randomUUID().toString().replace("-", ""); String signature = sign("POST", urlPath, timestamp, nonce, body, loadPrivateKey()); HttpRequest request = HttpRequest.newBuilder() .uri(URI.create("https://api.mch.weixin.qq.com" + urlPath)) .timeout(Duration.ofSeconds(30)) .header("Authorization", buildAuthHeader(timestamp, nonce, signature)) .header("Content-Type", "application/json") .header("Accept", "application/json") .header("User-Agent", "spring-boot-project/1.0") .POST(HttpRequest.BodyPublishers.ofString(body)) .build(); HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); if (response.statusCode() == 200 || response.statusCode() == 204) { return response.body(); } throw new RuntimeException("微信支付接口调用失败,HTTP状态码: " + response.statusCode() + ", 响应内容: " + response.body()); } }这个类会贯穿所有接口——下单、查单、关单、申请退款都用它。好处是整个项目只需要管理一套签名逻辑,包括后续如果对接其他接口只需要换URL和请求体。
提示:不要每次请求都从硬盘读pem文件并解析私钥。第一次加载后缓存到内存里的PrivateKey对象,否则高并发下性能明显下降,而且文件读取失败的概率也会放大。我自己的做法是应用启动时一次性加载,配合Spring的@ConfigurationProperties把加载好的PrivateKey放进容器里。
3. Native下单链路:从“生成二维码”到“用户扫一扫付款”
这是Spring Boot项目最常见的对接场景——PC网页端生成支付二维码,用户用微信扫码完成支付。整个链路不长,但很多项目就在这一步生成二维码的方式上劈叉了:有的返回了code_url但没转换成二维码图片,有的把code_url直接当支付地址给了前端,这些理解偏差都会导致实际流程跑不通。
3.1 组装Native下单请求体
Native下单的核心是POST到/v3/pay/transactions/native。RequestBody长这样(JSON):
{ "appid": "wx1234567890abcdef", "mchid": "1230000109", "description": "商品描述", "out_trade_no": "订单号20250101120000", "time_expire": "2025-01-01T13:05:00+08:00", "notify_url": "https://yourdomain.com/api/pay/notify/native", "amount": { "total": 100, "currency": "CNY" } }特别注意amount.total的单位是分,不是元。一笔100元的订单,total要传10000。这个单位坑,几乎每个第一次对接的人都踩过,因为文档虽写了“单位为分”,但示例数据里给的又像是元,容易混淆。我后来在订单创建的地方写好转换工具注释,避免后面维护的人改错。
time_expire字段建议必须设置。它表示订单在微信侧的最晚支付时间,超过后用户扫码会出现“订单已过期”,这个时间从下单开始最短5分钟最长2小时。我一般设置比订单有效时间稍微长一些,比如订单30分钟有效,time_expire设置成35分钟,避免因为提前过期导致用户付不了款而我们还没收到通知。
接口响应会返回code_url:
{ "code_url": "weixin://wxpay/bizpayurl?pr=xxxxxx" }这个code_url就是WeChat Native支付的专用二维码内容。前端拿到这个字符串需要自己生成二维码图片。项目里如果用了zxing,直接把code_url塞给二维码生成工具即可;如果没有,可以用一个现成的GO库或服务。注意不要把code_url直接“302重定向”,那不是浏览器能直接跳转的地址,必须扫码才能唤起支付。
3.2 订单号和重复下单的防御
支付场景下,订单号out_trade_no是全局唯一的。如果你不小心在同一个订单号上重复调下单接口,微信会返回同一个code_url,甚至直接报“订单已存在”。这反而给了我们一个保障:可以利用这个特性做幂等,但更科学的做法还是自己控制好状态机逻辑。
实际开发时我建议:
- 下单前先查订单状态:如果本地订单已经是待支付或已支付,就不应该再调用微信下单接口;
- 下游唯一键:数据库给订单号加唯一索引,支付回调来时用订单号做持久化;
- 超时重试:如果HTTP请求超时但订单可能已创建,不要盲目重新下单,先调用查单接口确认状态,再决定下一步。
这一步看起来像是业务琐事,但一旦并发上来,没有唯一键和状态判断的支付对账系统会乱成一锅粥,甚至出现财务侧重复计款的问题。
在Spring Boot中建议用@Transactional包裹“创建本地支付单”这段逻辑,先插本地订单表再发起远程请求。极端情况下如果远程下单成功但本地事务回滚了,订单号可能已经被微信侧占用,此时会用另一个新单号重试,对业务影响不大,但后续对账时要把这种“孤儿单”排查出来。
讲解一个查询接口的基本用法,后面也会用到:
GET /v3/pay/transactions/out-trade-no/{out_trade_no}?mchid={mchid}返回的trade_state字段有以下几种结果:SUCCESS(支付成功)、REFUND(转入退款)、NOTPAY(未支付)、CLOSED(已关闭)、REVOKED(已撤销)、USERPAYING(用户支付中)、PAYERROR(支付失败)。如果查到是USERPAYING,别急着关单,过几秒再查一次或直接等回调推送。处理轮询时要注意频率,别写成死循环,建议sleep 3到5秒,最多查3次。
4. 回调通知的验签与解密:这是最多人放水的地方
支付成功以后,微信服务器会向你的notify_url发起POST通知,内容不是普通JSON。很多项目在联调时报“回调能通但解密失败”或者干脆“验签不通过”。顺着来,先验签,再解密,最后处理业务,这个顺序一步都不能乱,每步的操作细节还略有讲究。
4.1 HTTP头里藏着验签的必要参数
微信支付回调通知的请求头里会带这些字段:
Wechatpay-Timestamp // 通知时间戳 Wechatpay-Nonce // 通知随机串 Wechatpay-Signature // 平台签名 Wechatpay-Serial // 平台证书序列号同时请求体是加密的密文,形如:
{ "id": "EV-支付通知ID", "create_time": "2025-01-01T12:00:00+08:00", "resource_type": "encrypt-resource", "event_type": "TRANSACTION.SUCCESS", "summary": "支付成功", "resource": { "original_type": "transaction", "algorithm": "AEAD_AES_256_GCM", "ciphertext": "密文", "associated_data": "关联数据", "nonce": "加密随机串" } }验签时,用请求头里的Wechatpay-Timestamp、Wechatpay-Nonce以及原始请求体字符串(注意是接收到的最原始的字节流,不是解析后的map再序列化),拼出验签串:
Wechatpay-Timestamp\n Wechatpay-Nonce\n 原始请求体\n最后加一个换行。然后用平台证书的公钥对Wechatpay-Signature做SHA256withRSA验签。验签通过之后才能去做AES-GCM解密,解读出resource里的明文。
4.2 拿到明文后如何防重放和防重复处理
验签和解密通过后,明文数据结构是:
{ "appid": "wx1234567890abcdef", "mchid": "1230000109", "out_trade_no": "订单号", "transaction_id": "微信支付订单号", "trade_type": "NATIVE", "trade_state": "SUCCESS", "success_time": "2025-01-01T12:00:00+08:00", "amount": { "total": 100, "payer_total": 100, "currency": "CNY" } }这里有两个关键判断:
- 金额校验:把明文里的amount.total和你本地订单表里存的需求金额比较,如果不一致,说明有异常或者说业务错乱,此时不能更新订单为已支付,而是记录告警并返回失败响应(这样微信会后续继续重试通知)。
- 幂等处理:支付回调可能因为网络问题重发,所以必须用transaction_id或out_trade_no做唯一检查。在Spring Boot里我习惯用redis setnx锁或者数据库状态机,防止并发下同一笔订单被更新两次。
处理完成的响应:HTTP 200,body为{"code":"SUCCESS"}或"SUCCESS"。 处理失败的响应:HTTP 5xx或4xx,body建议为错误信息,微信会隔一段时间重试。重要的一点:不要一收到回调就立刻返回SUCCESS,哪怕业务代码还没处理完也强制返回成功,那样微信侧认为你已经处理成功,不会再推送,但如果本地处理失败,这笔钱就永远没入账记录,会变成对账时的黑洞。正确做法是处理好业务并落库后再返回成功,如果处理出现问题,直接返回非200让微信重推。
AES-256-GCM解密的Java代码这里贴核心逻辑:
public static String decryptData(String apiV3Key, String nonce, String associatedData, String ciphertext) throws Exception { byte[] keyBytes = apiV3Key.getBytes(StandardCharsets.UTF_8); SecretKeySpec key = new SecretKeySpec(keyBytes, "AES"); byte[] cipherData = Base64.getDecoder().decode(ciphertext); byte[] nonceBytes = nonce.getBytes(StandardCharsets.UTF_8); byte[] associatedDataBytes = associatedData.getBytes(StandardCharsets.UTF_8); Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); GCMParameterSpec spec = new GCMParameterSpec(128, nonceBytes); cipher.init(Cipher.DECRYPT_MODE, key, spec); cipher.updateAAD(associatedDataBytes); byte[] plainText = cipher.doFinal(cipherData); return new String(plainText, StandardCharsets.UTF_8); }注意APIv3密钥必须是32字节,字符串本身可以直接作为key,不需要再做MD5或者hex转换。这里我踩过一个大坑:以为要把密钥转成十六进制格式,结果解密出来的全是乱码,浪费了半天才检查出来是字节处理错了。
5. 回到业务代码之前:设计一个声明式支付Service
如果你直接用Controller去写这些逻辑,过两个月自己都会改吐。我的经验是单独抽一个WechatPayService,对上层业务屏蔽“签名、请求、验签、解密”这些脏活。这样业务代码只需要关注订单状态流转,而且测试也更好写。
5.1 核心Service的设计思路
Service类里维护的依赖包括:配置类(读取商户号、密钥等属性)、下单客户端(上文封装的WechatPayClient)、订单Repository(数据库操作)、RedisTemplate(幂等标记)。核心方法可以设计为几个:
createNativeOrder(OrderDTO order):组装下单参数,发起请求,返回code_urlhandleNotify(String requestBody, HttpServletRequest request):验签、解密、核对金额、更新订单queryOrder(String outTradeNo):查单,用于对账和超时处理refund(String outTradeNo, RefundDTO refundDTO):申请退款queryRefund(String refundId):查询退款状态
这里要注意,支付回调是主动推送,但推送可能因为各种原因延迟甚至丢失。所以除了回调驱动,还要写一个定时任务,每隔一段时间扫描“待支付超时”的订单,主动调用查单接口把最新状态拉回来更新。尤其在做自动关单或停单功能时,定时查单是很必要的兜底手段。
5.2 配置绑定:用@ConfigurationProperties管理微信支付参数
Spring Boot的配置管理大家已经很熟了,我习惯用@ConfigurationProperties(prefix = "wechat.pay")绑定一个属性类:
@ConfigurationProperties(prefix = "wechat.pay") public class WechatPayProperties { private String appId; private String mchId; private String apiV3Key; private String privateKeyPath; private String privateKeySerial; private String platformCertPath; private String notifyUrl; // getters/setters }application.yml里相应的段:
wechat: pay: app-id: wx1234567890abcdef mch-id: 1230000109 api-v3-key: ${WECHAT_PAY_API_V3_KEY} private-key-path: /data/wechatpay/apiclient_key.pem private-key-serial: ${WECHAT_PAY_CERT_SERIAL} platform-cert-path: /data/wechatpay/wechatpay_platform_cert.pem notify-url: https://yourdomain.com/api/pay/notify/native生产环境的api-v3-key和证书序列号这样敏感字段一定不要明文写在git仓库里,走环境变量或配置中心。
另外两个小技巧:
- 本地串联测试:微信支付有“沙箱环境”吗?目前V3没有真正的官方沙箱支付模拟,但有测试商户号。最方便的自测方法是下单成功后,用查询接口确认下单成功,然后手动把本地订单改成已支付状态来联调后面的退款流程,回调逻辑需要等真钱打进去才能跑通。我自己会用1分钱真实支付测通全链路,成本可以接受。
- 回调地址需要HTTPS:微信支付要求notify_url必须为公网可访问的HTTPS地址(开发环境可以临时用内网穿透,但生产千万别用)。这个要求其实很合理,涉及资金安全,传输层的身份认证不能省。
6. 退款、对账与生产环境期的运维梳理
支付跑通只是第一步。真正上生产后,退款和对账才是财务关心的重点。我遇到的不少项目,支付上线后才发现退款流程没做,用户的退款需求只能靠人工在商户平台手动操作,效率低且容易漏。
6.1 退款接口的几个要点
微信支付V3的退款接口是POST /v3/refund/domestic/refunds,请求体示例:
{ "out_trade_no": "订单号", "out_refund_no": "退款单号", "amount": { "refund": 100, "total": 100, "currency": "CNY" }, "notify_url": "https://yourdomain.com/api/pay/notify/refund" }退款单号out_refund_no是商家自己生成的退款请求唯一标识,也要做幂等。接口响应会立刻返回退款单信息,但真正退款成功或失败的结果是靠回调异步通知(refund.status)或者查询接口轮询获取的。要注意:退款金额不能大于原订单的实付金额,微信侧会校验。如果有部分退款,请求体里还有有个这样的字段组合存在,总值校验同样严格。
退款回调的验签和支付回调完全一致,只是event_type不同(退款成功一般为REFUND.SUCCESS),解密后的resource里会有refund_status字段,常见取值:SUCCESS、CLOSED、PROCESSING、ABNORMAL。PROCESSING别急着置为退款失败,要等最终状态。
6.2 对账单和隔日核对怎么做
微信支付商户平台提供“账单下载”能力,可以下载交易账单和退款账单,按天粒度。一般在公司财务需要做对账时,定时任务每天凌晨拉取前一天的账单,和本地支付表、退款表做比对,有落差就告警。
代码层面可以封装一个下载对账单的接口,比如日账单:
GET /v3/bill/tradebill?bill_date=2025-01-01&bill_type=ALL返回的内容有两种格式,新接口返回的是账单下载URL,需要再用URL去下载。注意对账单是文本文件,字段分隔符习惯是逗号,但里面某些文本字段可能包含换行,解析时建议按官方字段顺序逐列分析,不要简单split(",")完事。这一步细节多,我用过一次后就不太建议手写解析,有现成工具类可以借鉴但自己要多验证。
6.3 证书过期与替换的运维常态
这是个容易在半夜三点惊醒你的问题。微信支付平台证书有过期时间,通常是5年,但不是说一次下载永久使用。平台证书轮换时,新证书会在到期前大约一个月生效,老证书可能仍然有效一段时间。万一你本地只缓存了旧证书,新回调的签名验证就会报“证书序列号不存在”。
应对策略有几种:
- 自动下载平台证书:启动时检查本地证书失效日期,失效前20天自动调用下载平台证书接口换成新证书;
- 注册证书更新钩子:微信支付提供的下载接口可以根据
Wechatpay-Serial动态下载指定的证书,把它加到回调处理流程中,如果本地没有对应序列号就在验签失败前自动拉取一次,拉取后校验签名,做成“懒加载”模式。这个方式更稳,因为有的时候你根本不知道证书是什么时候被替换的。
我采用的就是第二种“回调带序列号自动补证书”的方案。代码里逻辑是这样的:验签时根据请求头Wechatpay-Serial去证书缓存里找,找不到就调用下载接口拉取该序列号的证书,放到本地缓存和磁盘文件里,再重新执行验签。只要网络正常,几乎不会因为证书轮换导致验签失败。
还有商户API证书过期。商户证书一般有效期也是一段比较长的时间,但到期前需要在商户平台重新下载并安装,这个过程可能要重新生成私钥。如果私钥换了,所有机器上的配置都要同步更新,不然请求发出去微信一验签对不上就拒绝。所以证书即将到期时,要提前准备换证窗口,在低流量时段切换。
这里顺带提醒一下:私钥文件权限需要控制好。Linux服务器上用chmod 600,并且不要让java进程以root用户跑,避免一次服务器被攻破就导致签名私钥泄露。私钥泄露意味着攻击者能伪造你的下单和退款请求,属于资金级事故。
6.4 日志打印的边界:哪些能打哪些不能打
联调阶段大家喜欢把请求和响应body都打到日志里,这本身没问题,但上生产前必须清理。请求体里的description、金额字段可以打,但请求签名不可打;回调原文密文也不建议打,因为虽然加了密,但属于无用日志还会膨胀存储;解密后的明文不要打完整信息,真要排查就脱敏打后四位。
一个比较稳妥的做法:
log.info("支付回调处理成功, out_trade_no={}, transaction_id={}, amount={}", outTradeNo, txId, total);不要把解密后的JSON整体打一行。以前看到一些日志文件里把整个支付通知明细都打出来,包括用户的支付账户信息,虽然微信支付这边不涉及银行卡号这类敏感字段,但会涉及个人支付行为隐私,多一事不如少一事。
6.5 关于定时任务和状态机
支付模块的状态流转最后的落地是靠数据库。我习惯在订单表里增加一个pay_status字段,取值大概为:
- 0:待支付
- 1:支付中(已经下单但未收到回调)
- 2:支付成功
- 3:退款中
- 4:已退款
- 5:支付失败/已关闭
状态流转要尽量收敛到同一个Service方法里,并配合乐观锁update ... where pay_status = old来防止并发更新。回调处理和定时查单两个路径可能同时更新同一个订单,如果没有乐观锁,就可能出现“退款状态覆盖成支付成功”这种幽灵问题。实际写SQL时:
update pay_order set pay_status = 2, transaction_id = ?, paid_at = ? where order_no = ? and pay_status = 0如果更新行数为0,说明已经被其他线程处理过了,跳过去即可。这种写法比纯代码判断状态再更新更稳,数据库的原子性替我们挡住了并发间隙。
支付系统没有银弹,但把这些边界想清楚,做出来的东西就能让人睡得踏实。对接微信支付这件事,前期的密钥配置、证书管理和回调逻辑是最花时间的,一旦把基础设施搭成能复制复用的Service,后面接各种新支付场景都很快。我后来在项目里又在同一套签名机制上接了C2M付款、商家转账等接口,只改URL和请求体就可以,边际成本很低。
最后再分享一个我在生产上用的习惯:每次支付、退款、回调都打印一个带有traceId的日志链路,filter里把请求头或服务端的UUID塞进MDC里,日志就自动带上了。出问题时,顺着一个orderNo或traceId把全链路串起来看,比在日志文件里猜快太多了。对接支付这种真金白银的功能,不怕复杂,怕的是出问题时两眼一抹黑。