1. 从一个真实需求说起:为什么我要啃微信支付集成这块硬骨头
做移动端和Web端项目的朋友,迟早都会撞上“支付”这堵墙。尤其是国内的项目,微信支付几乎是绕不开的选项。我最早接触微信支付是在一个电商类的小程序项目里,当时想得特别简单——不就是调个API、传个订单号、等回调吗?结果真正上手才发现,从统一下单到签名验证,从证书管理到回调幂等,每一步都有坑,而且坑坑不一样。
这篇文章我想聊的不是那种“复制粘贴官方文档”的教程,而是从一个实际开发者的角度,把微信支付集成这件事拆开揉碎。我会重点讲清楚工具包的设计思路、核心模块的实现细节、以及我在真实项目中踩过的那些坑。不管你是刚接触支付集成的新手,还是已经做过几套支付系统的老手,应该都能从中找到一些有用的东西。
先明确一下范围:这里讨论的“工具包”指的是我们自己封装的一套支付集成中间层,不是微信官方的SDK本身。官方SDK提供了基础的签名、验签、请求发送能力,但真正在业务系统里用起来,还需要做大量的适配和封装工作。这套工具包要解决的问题包括:统一的下单接口、回调的幂等处理、证书的自动管理、多商户配置的隔离、以及异常情况的兜底策略。
适合谁看?如果你正在做或者准备做微信支付集成,不管是小程序、公众号、H5还是Native支付,这篇文章里的思路和代码结构都可以直接参考。如果你已经集成完了但总觉得不够稳,也可以看看我在“常见问题与排查”那部分总结的经验,说不定能帮你省下几个通宵。
2. 工具包整体设计与核心思路拆解
2.1 为什么不直接用官方SDK
官方SDK能用吗?能用。但直接用的后果就是,你的业务代码里会散落着大量的签名逻辑、HTTP请求、XML解析、证书加载代码。我见过一个项目,光是处理支付回调的代码就写了八百多行,里面混杂着业务逻辑、验签、日志、异常处理,维护起来简直是噩梦。
封装工具包的核心目的就三个字:隔离性。把支付相关的技术细节全部收拢到一个独立的模块里,业务层只需要调用payService.createOrder()和payService.handleCallback()两个方法,剩下的签名、请求、验签、解密、幂等全部由工具包内部消化。这样做的好处是,当微信支付API升级或者需要切换支付渠道时,只需要改工具包这一层,业务代码几乎不用动。
另一个考虑是可测试性。支付这东西没法在本地环境真实调用,必须有一套完整的Mock机制。工具包里我们把微信支付的HTTP客户端抽象成了一个接口,测试环境下注入Mock实现,可以模拟各种成功、失败、超时的场景,不用真的去调微信的接口就能跑完整个支付流程的单元测试。
2.2 分层架构设计
工具包整体分为四层,从下往上依次是:
- 基础层:负责HTTP通信、签名算法、XML/JSON序列化、证书加载。这一层不包含任何业务逻辑,纯粹是技术工具。
- 协议层:封装微信支付的各个API接口,比如统一下单、订单查询、退款、关闭订单。每个接口对应一个Request和Response对象,参数校验和签名在这一层完成。
- 服务层:提供面向业务的门面接口,比如
createOrder、queryOrder、refund。这一层负责编排多个协议层调用,处理重试和降级。 - 回调层:专门处理微信服务器的异步通知,包括验签、解密、幂等检查、业务分发。
这样分层的好处是职责清晰。比如要新增一个“分账”功能,只需要在协议层加一个接口,服务层加一个方法,回调层加一个通知类型,不会影响到已有的代码。
2.3 核心配置项的设计
配置管理是支付工具包里最容易被忽视但最重要的部分。我见过太多项目把商户号、API密钥、证书路径硬编码在代码里,换一个环境就要改代码重新打包。正确的做法是把所有配置项抽出来,支持多环境、多商户的动态加载。
工具包里我们定义了这样一个配置结构:
wxpay: merchants: - merchantId: "1000001" appId: "wx1234567890" apiV3Key: "your-api-v3-key" privateKeyPath: "classpath:cert/apiclient_key.pem" certSerialNo: "your-cert-serial" notifyUrl: "https://your-domain.com/pay/callback" - merchantId: "1000002" appId: "wx0987654321" apiV3Key: "another-api-v3-key" privateKeyPath: "classpath:cert/apiclient_key_2.pem" certSerialNo: "another-cert-serial" notifyUrl: "https://your-domain.com/pay/callback"关键点在于merchants是一个列表,支持配置多个商户。每个商户有独立的密钥和证书,互不干扰。业务层调用时通过merchantId来指定使用哪个商户的配置。这个设计在服务商模式下特别有用,一个系统可以同时为多个子商户处理支付。
注意:APIv3密钥和证书私钥绝对不能提交到代码仓库。我们用的是配置中心加密存储,本地开发环境通过环境变量注入。这一点后面在“避坑经验”里还会详细说。
3. 核心模块深入解析与实操要点
3.1 签名与验签:支付安全的第一道门
微信支付的签名机制是整个集成里最核心也最容易出错的部分。V3版本用的是SHA256withRSA算法,签名串的构造规则是:HTTP方法 + URL路径 + 时间戳 + 随机串 + 请求体。这五个部分用换行符拼接,然后用商户私钥签名,最后Base64编码放到Authorization头里。
我见过最常见的错误是URL路径拼错了。比如请求的完整URL是https://api.mch.weixin.qq.com/v3/pay/transactions/jsapi,签名时用的路径必须是/v3/pay/transactions/jsapi,不能带域名,也不能带查询参数。这个细节官方文档里写了,但很多人第一次做的时候还是会搞错。
工具包里我们把签名逻辑封装成了一个独立的Signer类:
public class WxPaySigner { private final PrivateKey privateKey; private final String certSerialNo; public String sign(String method, String urlPath, String timestamp, String nonceStr, String body) { String message = method + "\n" + urlPath + "\n" + timestamp + "\n" + nonceStr + "\n" + body + "\n"; Signature sign = Signature.getInstance("SHA256withRSA"); sign.initSign(privateKey); sign.update(message.getBytes(StandardCharsets.UTF_8)); byte[] signature = sign.sign(); return Base64.getEncoder().encodeToString(signature); } public String buildAuthorizationHeader(String method, String urlPath, String body) { String timestamp = String.valueOf(System.currentTimeMillis() / 1000); String nonceStr = UUID.randomUUID().toString().replace("-", ""); String signature = sign(method, urlPath, timestamp, nonceStr, body); return "WECHATPAY2-SHA256-RSA2048 mchid=\"" + merchantId + "\"," + "nonce_str=\"" + nonceStr + "\"," + "timestamp=\"" + timestamp + "\"," + "serial_no=\"" + certSerialNo + "\"," + "signature=\"" + signature + "\""; } }验签则是反过来,用微信平台的公钥对回调通知里的签名进行验证。这里有个关键点:微信平台证书是会定期更换的,所以不能把证书写死在代码里,必须实现自动下载和更新机制。工具包里我们用一个定时任务每天检查一次平台证书,发现序列号变化就重新下载。
3.2 统一下单:从参数校验到请求发送
统一下单是支付流程的起点,也是最复杂的接口之一。以JSAPI支付为例,需要传入的参数包括:appid、mchid、description、out_trade_no、notify_url、amount、payer等。每个参数都有格式要求,比如out_trade_no只能是数字、字母、下划线、横线,长度6到32位。
工具包里我们在协议层做了严格的参数校验,用注解的方式声明每个字段的约束:
public class JsapiOrderRequest { @NotBlank @Size(min = 6, max = 32) @Pattern(regexp = "^[0-9A-Za-z_-]+$") private String outTradeNo; @NotNull @Min(1) private Integer totalAmount; @NotBlank @Size(max = 127) private String description; @NotBlank private String notifyUrl; @NotBlank private String openid; // ... 省略getter/setter }校验通过后,序列化成JSON,调用签名器生成Authorization头,然后通过HTTP客户端发送POST请求。这里有个细节:微信支付的APIv3接口要求请求头里必须带Accept: application/json和Content-Type: application/json,少一个都可能返回400错误。
请求发送后,拿到响应要判断HTTP状态码。200表示成功,其他状态码要根据响应体里的code字段来判断具体错误。比如PARAM_ERROR表示参数错误,SIGN_ERROR表示签名错误,FREQUENCY_LIMITED表示频率限制。工具包里我们把常见的错误码映射成了枚举,方便业务层做针对性处理。
3.3 回调处理:幂等、验签、解密三件套
支付回调是整个流程里最考验系统稳定性的环节。微信服务器会在支付成功后多次推送通知,直到收到成功响应为止。所以回调处理必须做到幂等——同一笔订单的回调处理多次,结果要一致。
工具包里的回调处理流程是这样的:
- 验签:从请求头里取出
Wechatpay-Signature、Wechatpay-Timestamp、Wechatpay-Nonce、Wechatpay-Serial,用平台公钥验证签名。验签失败直接返回失败,不处理业务。 - 解密:回调通知的
resource字段是加密的,用APIv3密钥进行AES-256-GCM解密,得到明文的通知内容。 - 幂等检查:根据解密后的
out_trade_no查询本地订单状态,如果已经是“已支付”,直接返回成功,不重复处理。 - 业务处理:更新订单状态、记录支付流水、触发后续业务逻辑(比如发货、通知用户)。
- 返回响应:处理成功返回
{"code": "SUCCESS"},失败返回{"code": "FAIL", "message": "..."}。
幂等检查这里有个坑:并发情况下,两个回调同时到达,都查到订单是“待支付”,然后都去更新订单状态,就会导致重复处理。解决办法是用数据库的行锁或者分布式锁。工具包里我们用的是SELECT ... FOR UPDATE,在事务里锁定订单行,确保只有一个线程能处理。
@Transactional public void handleCallback(String outTradeNo, String transactionId) { Order order = orderMapper.selectForUpdate(outTradeNo); if (order == null) { throw new BizException("订单不存在"); } if (OrderStatus.PAID.equals(order.getStatus())) { return; // 幂等,直接返回 } order.setStatus(OrderStatus.PAID); order.setTransactionId(transactionId); orderMapper.updateById(order); // 触发后续业务 eventPublisher.publishEvent(new OrderPaidEvent(order)); }提示:回调接口的URL必须是HTTPS的,而且不能带查询参数。微信服务器会校验域名是否在商户平台配置过,没配置的域名会直接拒绝。
3.4 证书管理与自动更新
微信支付的证书体系分两类:商户私钥和平台证书。商户私钥是自己生成的,用于签名;平台证书是微信提供的,用于验签。商户私钥相对简单,生成一次就行,但平台证书会定期更换,必须实现自动更新。
工具包里我们用一个CertManager来管理证书:
@Component public class CertManager { private volatile Map<String, X509Certificate> platformCerts = new ConcurrentHashMap<>(); @Scheduled(cron = "0 0 3 * * ?") // 每天凌晨3点更新 public void refreshPlatformCerts() { List<CertificateInfo> certs = wxPayClient.downloadPlatformCerts(); Map<String, X509Certificate> newCerts = new HashMap<>(); for (CertificateInfo cert : certs) { newCerts.put(cert.getSerialNo(), parseCert(cert.getCertPem())); } this.platformCerts = newCerts; } public X509Certificate getCert(String serialNo) { X509Certificate cert = platformCerts.get(serialNo); if (cert == null) { refreshPlatformCerts(); // 没找到就立即刷新一次 cert = platformCerts.get(serialNo); } return cert; } }这里的关键是volatile和ConcurrentHashMap,保证多线程环境下证书切换的可见性和线程安全。另外,当验签时发现序列号在本地缓存里找不到,要立即触发一次刷新,而不是直接报错。因为微信可能在你还没到定时任务时间就换了证书。
4. 完整实操流程与核心环节实现
4.1 环境准备与依赖引入
先说一下技术栈。工具包是基于Java 8+和Spring Boot 2.x开发的,HTTP客户端用的是OkHttp,JSON处理用的是Jackson,XML处理用的是Jackson的XML扩展。如果你用的是其他语言,思路是一样的,只是具体实现不同。
Maven依赖大概是这样:
<dependency> <groupId>com.squareup.okhttp3</groupId> <artifactId>okhttp</artifactId> <version>4.9.3</version> </dependency> <dependency> <groupId>com.fasterxml.jackson.dataformat</groupId> <artifactId>jackson-dataformat-xml</artifactId> <version>2.13.0</version> </dependency> <dependency> <groupId>org.bouncycastle</groupId> <artifactId>bcprov-jdk15on</artifactId> <version>1.70</version> </dependency>BouncyCastle是用来处理证书和加密的,Java自带的加密库在某些场景下不够用,比如加载PKCS#8格式的私钥。这个依赖加上之后,记得在代码里注册Provider:
static { Security.addProvider(new BouncyCastleProvider()); }4.2 商户私钥的生成与配置
商户私钥的生成有两种方式:用微信提供的工具生成,或者用OpenSSL自己生成。我推荐用OpenSSL,因为更灵活,而且生成的私钥格式更标准。
# 生成私钥 openssl genrsa -out apiclient_key.pem 2048 # 从私钥导出公钥 openssl rsa -in apiclient_key.pem -pubout -out apiclient_pub.pem # 查看私钥的序列号(用于配置certSerialNo) openssl x509 -in apiclient_cert.pem -noout -serial生成的私钥文件apiclient_key.pem要放到项目的资源目录下,但不要提交到Git。我们用的是.gitignore排除,然后在部署时通过配置中心或者环境变量注入。
加载私钥的代码:
public PrivateKey loadPrivateKey(String keyPath) throws Exception { String content = new String(Files.readAllBytes(Paths.get(keyPath))); content = content.replace("-----BEGIN PRIVATE KEY-----", "") .replace("-----END PRIVATE KEY-----", "") .replaceAll("\\s", ""); byte[] keyBytes = Base64.getDecoder().decode(content); PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(keyBytes); KeyFactory factory = KeyFactory.getInstance("RSA"); return factory.generatePrivate(spec); }4.3 统一下单的完整调用链
以JSAPI支付为例,从业务层发起支付到拿到前端调起支付所需的参数,完整流程如下:
第一步:业务层创建订单
@PostMapping("/order/create") public Result<PayParams> createOrder(@RequestBody CreateOrderRequest request) { // 1. 创建本地订单 Order order = orderService.createOrder(request); // 2. 调用支付工具包统一下单 JsapiOrderRequest payRequest = new JsapiOrderRequest(); payRequest.setOutTradeNo(order.getOrderNo()); payRequest.setTotalAmount(order.getAmount()); payRequest.setDescription(order.getSubject()); payRequest.setNotifyUrl(wxPayConfig.getNotifyUrl()); payRequest.setOpenid(request.getOpenid()); JsapiOrderResponse payResponse = wxPayService.createJsapiOrder(payRequest); // 3. 组装前端调起支付所需的参数 PayParams params = new PayParams(); params.setAppId(wxPayConfig.getAppId()); params.setTimeStamp(String.valueOf(System.currentTimeMillis() / 1000)); params.setNonceStr(UUID.randomUUID().toString().replace("-", "")); params.setPackageValue("prepay_id=" + payResponse.getPrepayId()); params.setSignType("RSA"); params.setPaySign(wxPaySigner.sign(params)); return Result.success(params); }第二步:工具包内部处理
工具包收到JsapiOrderRequest后,先做参数校验,然后构造请求体:
{ "appid": "wx1234567890", "mchid": "1000001", "description": "测试商品", "out_trade_no": "ORDER202401010001", "notify_url": "https://your-domain.com/pay/callback", "amount": { "total": 100, "currency": "CNY" }, "payer": { "openid": "oUpF8uMuAJO_M2pxb1Q9zNjWeS6o" } }然后生成签名,发送POST请求到https://api.mch.weixin.qq.com/v3/pay/transactions/jsapi。微信返回的响应里包含prepay_id,这个就是前端调起支付的关键参数。
第三步:前端调起支付
前端拿到PayParams后,调用wx.chooseWXPay:
wx.chooseWXPay({ appId: params.appId, timeStamp: params.timeStamp, nonceStr: params.nonceStr, package: params.packageValue, signType: params.signType, paySign: params.paySign, success: function(res) { // 支付成功,等待回调 }, fail: function(res) { // 支付失败 } });4.4 回调接口的实现与部署
回调接口必须是一个公网可访问的HTTPS接口。我们用的是Spring Boot的@RestController:
@PostMapping("/pay/callback") public String handleCallback(@RequestBody String body, @RequestHeader("Wechatpay-Signature") String signature, @RequestHeader("Wechatpay-Timestamp") String timestamp, @RequestHeader("Wechatpay-Nonce") String nonce, @RequestHeader("Wechatpay-Serial") String serial) { try { // 1. 验签 boolean verified = wxPaySigner.verifyCallback(signature, timestamp, nonce, body, serial); if (!verified) { log.error("回调验签失败"); return "{\"code\":\"FAIL\",\"message\":\"验签失败\"}"; } // 2. 解密 CallbackResource resource = wxPayDecryptor.decrypt(body); // 3. 幂等处理 orderService.handlePayCallback(resource.getOutTradeNo(), resource.getTransactionId()); // 4. 返回成功 return "{\"code\":\"SUCCESS\"}"; } catch (Exception e) { log.error("回调处理异常", e); return "{\"code\":\"FAIL\",\"message\":\"" + e.getMessage() + "\"}"; } }部署的时候有几个注意点:回调接口不能有鉴权拦截器,否则微信服务器访问不了;接口的响应时间要控制在5秒以内,超时微信会重试;接口必须支持POST方法,而且请求体是原始JSON字符串,不能被框架提前解析。
4.5 订单查询与退款实现
订单查询和退款是支付集成的配套功能。订单查询比较简单,调用GET /v3/pay/transactions/out-trade-no/{out_trade_no}?mchid={mchid}就行。退款稍微复杂一点,需要传入退款单号、原订单号、退款金额等参数。
退款接口的调用示例:
public RefundResponse refund(String outTradeNo, String outRefundNo, Integer refundAmount, Integer totalAmount) { RefundRequest request = new RefundRequest(); request.setOutTradeNo(outTradeNo); request.setOutRefundNo(outRefundNo); request.setAmount(new RefundAmount(totalAmount, refundAmount, "CNY")); String urlPath = "/v3/refund/domestic/refunds"; String body = objectMapper.writeValueAsString(request); String authorization = wxPaySigner.buildAuthorizationHeader("POST", urlPath, body); Request httpRequest = new Request.Builder() .url("https://api.mch.weixin.qq.com" + urlPath) .post(RequestBody.create(body, MediaType.parse("application/json"))) .header("Authorization", authorization) .header("Accept", "application/json") .header("Content-Type", "application/json") .build(); try (Response response = httpClient.newCall(httpRequest).execute()) { String responseBody = response.body().string(); if (response.isSuccessful()) { return objectMapper.readValue(responseBody, RefundResponse.class); } else { throw new WxPayException("退款失败: " + responseBody); } } }退款是异步的,调用成功后微信会返回一个退款单号,实际的退款结果通过退款回调通知。退款回调的处理逻辑和支付回调类似,也需要验签、解密、幂等处理。
5. 常见问题与排查技巧实录
5.1 签名错误排查指南
签名错误是集成过程中最高频的问题,没有之一。微信返回的错误信息通常是SIGN_ERROR,但不会告诉你具体哪里错了。我总结了一个排查清单:
| 排查项 | 常见错误 | 解决方法 |
|---|---|---|
| URL路径 | 带了域名或查询参数 | 只保留路径部分,如/v3/pay/transactions/jsapi |
| 请求体 | 序列化后的JSON和签名时用的不一致 | 签名和发送用同一个字符串,不要重新序列化 |
| 时间戳 | 单位搞错,用了毫秒 | 必须是秒级时间戳,10位数字 |
| 私钥 | 格式不对,或者加载错了 | 确认是PKCS#8格式,且和证书序列号匹配 |
| 换行符 | 拼接时少了或多了换行 | 每个部分后面都要有\n,包括最后一行 |
我遇到过一次特别隐蔽的签名错误:请求体里的中文被URL编码了,但签名时用的是原始中文,导致签名不一致。后来统一用UTF-8编码,问题解决。
5.2 回调收不到或重复回调
回调收不到的原因通常有三个:回调URL配置错误、服务器防火墙拦截、接口返回了非200状态码。排查的时候先在微信商户平台查看回调记录,确认微信是否发送了通知。如果发送了但你没收到,检查服务器的访问日志,看看有没有请求进来。
重复回调是正常现象,微信在收到成功响应之前会一直重试。但如果你的接口处理时间过长,微信可能会认为超时,然后重试。解决办法是把业务处理异步化,回调接口只做验签、解密、落库,然后立即返回成功,后续业务逻辑通过消息队列异步处理。
@PostMapping("/pay/callback") public String handleCallback(@RequestBody String body, ...) { // 验签、解密 CallbackResource resource = wxPayDecryptor.decrypt(body); // 快速落库 callbackLogMapper.insert(new CallbackLog(resource)); // 发送消息队列,异步处理 messageQueue.send(new PayCallbackMessage(resource.getOutTradeNo())); // 立即返回成功 return "{\"code\":\"SUCCESS\"}"; }5.3 证书过期与自动更新失败
平台证书过期会导致验签失败,所有回调都处理不了。工具包里虽然做了自动更新,但更新本身也可能失败,比如网络问题、权限问题。所以需要加一个告警机制:当证书距离过期还有7天时,发送告警通知;当验签失败率超过阈值时,也发送告警。
另外,平台证书下载接口本身也需要签名,用的是商户私钥。如果商户私钥也过期了,那就彻底没法自动更新了。所以商户私钥的过期时间也要监控,提前一个月告警。
5.4 金额精度与货币单位
微信支付的金额单位是分,不是元。这个看起来简单,但实际开发中很容易搞错。比如商品价格是9.9元,传给微信的必须是990。我见过一个项目,前端传的是元,后端没转换直接传给微信,结果用户付了9.9分,也就是不到一毛钱。
工具包里我们在参数校验阶段就做了检查:如果金额小于1或者大于100000000(100万元),直接抛出异常。同时,所有金额相关的字段都用Integer类型,避免浮点数精度问题。
5.5 多商户配置隔离问题
服务商模式下,一个系统要处理多个商户的支付。如果配置隔离没做好,可能会出现A商户的订单用了B商户的证书签名,导致签名错误。工具包里的做法是:每个商户有独立的WxPayService实例,通过merchantId来路由。业务层调用时必须显式传入merchantId,不允许使用默认值。
@Service public class WxPayServiceFactory { private Map<String, WxPayService> services = new ConcurrentHashMap<>(); public WxPayService getService(String merchantId) { WxPayService service = services.get(merchantId); if (service == null) { throw new BizException("未配置的商户: " + merchantId); } return service; } }5.6 网络超时与重试策略
微信支付的API调用偶尔会超时,尤其是网络波动的时候。工具包里配置了超时时间和重试策略:连接超时5秒,读取超时10秒,重试3次,每次间隔1秒。但要注意,统一下单接口不能盲目重试,因为重试可能导致重复下单。正确的做法是:超时后先调用订单查询接口确认订单状态,如果订单已存在就直接返回,不存在再重新下单。
public JsapiOrderResponse createOrderWithRetry(JsapiOrderRequest request) { try { return doCreateOrder(request); } catch (TimeoutException e) { // 超时后查询订单 QueryOrderResponse query = queryOrder(request.getOutTradeNo()); if (query != null && "SUCCESS".equals(query.getTradeState())) { return convertToOrderResponse(query); } // 订单不存在,重新下单 return doCreateOrder(request); } }6. 一些实战中攒下来的避坑经验
6.1 日志脱敏与安全审计
支付相关的日志绝对不能包含完整的请求体和响应体,因为里面可能有用户的openid、订单金额等敏感信息。工具包里我们做了日志脱敏:只记录订单号、金额、状态码,不记录完整的JSON。如果需要排查问题,可以通过订单号去数据库里查详细的请求记录,但数据库里的敏感字段也是加密存储的。
另外,所有支付相关的操作都要记录审计日志,包括谁在什么时间发起了退款、修改了配置等。这个在合规审计的时候特别重要。
6.2 测试环境的Mock方案
微信支付没有沙箱环境,所有测试都必须用真实的小额支付。这在开发阶段很不方便。我们的做法是在工具包里内置一个Mock模式,通过配置开关切换。Mock模式下,统一下单直接返回一个假的prepay_id,回调接口模拟微信发送通知,这样可以完整地跑通整个支付流程,不用花一分钱。
@Profile("dev") @Configuration public class MockWxPayConfig { @Bean public WxPayClient mockWxPayClient() { return new MockWxPayClient(); } }Mock客户端的行为可以配置:比如模拟支付成功、模拟支付失败、模拟回调延迟等。这样测试用例可以覆盖各种异常场景。
6.3 对账与异常订单处理
每天凌晨要跑一次对账任务,下载微信的账单,和本地的订单流水做比对。不一致的订单要标记出来人工处理。常见的不一致情况包括:本地订单已支付但微信账单没有、微信账单有但本地订单状态不对、金额不一致等。
对账文件的下载接口是GET /v3/bill/tradebill?bill_date=2024-01-01&bill_type=ALL,返回的是一个CSV文件的下载链接。下载后解析CSV,逐行比对。这个任务比较耗时,建议用异步任务处理,不要阻塞主线程。
6.4 版本升级与兼容性
微信支付的API版本从V2升级到V3之后,签名算法、数据格式、证书体系都变了。工具包里我们同时支持V2和V3,通过配置来切换。但新项目建议直接用V3,V2迟早会下线。
升级的时候要注意:V3的证书序列号是必须的,V2用的是MD5密钥;V3的请求体是JSON,V2是XML;V3的回调是加密的,V2是明文的。这些差异在工具包里都做了适配,业务层不需要关心。
6.5 性能优化与并发处理
支付接口的QPS通常不会太高,但回调接口可能会在短时间内收到大量通知,比如秒杀活动结束后。工具包里对回调接口做了限流和队列化处理:用信号量控制并发处理数,超过阈值的请求放入队列等待,避免把数据库打挂。
private final Semaphore callbackSemaphore = new Semaphore(50); @PostMapping("/pay/callback") public String handleCallback(@RequestBody String body, ...) { if (!callbackSemaphore.tryAcquire(3, TimeUnit.SECONDS)) { return "{\"code\":\"FAIL\",\"message\":\"系统繁忙\"}"; } try { // 处理回调 } finally { callbackSemaphore.release(); } }这个信号量的值要根据数据库的连接池大小和机器的处理能力来调整。太小会导致回调处理慢,微信重试;太大会把数据库打挂。我们一般设置为数据库连接池最大连接数的80%。
6.6 一个小技巧:用订单号反查支付状态
有时候业务系统出了问题,订单状态不对,但又不确定微信那边到底扣没扣钱。这时候可以用订单号去微信查一下支付状态。工具包里提供了一个queryOrder方法,传入商户订单号就能查到微信侧的支付状态。如果微信显示已支付但本地是待支付,说明回调丢了,可以手动触发一次补单。
public void repairOrder(String outTradeNo) { QueryOrderResponse response = wxPayService.queryOrder(outTradeNo); if ("SUCCESS".equals(response.getTradeState())) { orderService.handlePayCallback(outTradeNo, response.getTransactionId()); } }这个补单接口在运维的时候特别有用,建议做成一个后台功能,方便客服人员处理用户投诉。
7. 最后聊几句个人体会
这套工具包从第一版到现在,前后迭代了两年多,踩过的坑不计其数。最大的体会是:支付集成这件事,技术难度其实不算特别高,但细节特别多,而且每个细节都关系到真金白银。一个签名错误可能导致所有支付失败,一个幂等没做好可能导致重复发货,一个证书过期可能导致回调全部丢失。
所以我的建议是:不要急着写业务代码,先把工具包的基础设施搭好。签名、验签、证书管理、幂等处理、日志脱敏、对账这些看似“外围”的功能,其实才是支付系统稳定的关键。工具包做好之后,业务层的代码会变得非常薄,维护成本也会低很多。
另外,一定要重视测试。支付流程的测试用例要覆盖各种异常场景:签名失败、网络超时、回调重复、证书过期、金额非法等等。这些场景在真实环境中可能几个月才遇到一次,但一旦遇到就是生产事故。提前用Mock把这些场景跑通,比出了问题再救火要划算得多。
最后再分享一个小技巧:把微信支付的官方文档加入书签,经常翻一翻。微信的文档更新比较频繁,有时候接口参数变了或者新增了功能,文档里会有说明。我习惯每个月花半小时过一遍更新日志,看看有没有影响现有集成的变更。这个习惯帮我避免了好几次潜在的故障。