一个很别扭的需求
我们 ETC 项目要上一个新功能:通行费代扣成功之后,要给渠道方分账。
代扣走的是微信支付的免密代扣 (V2 接口,委托代扣协议),分账走的是服务商分账(V3 接口)。
问题是:分账只有 V3 有。 微信从来没在 V2 里做过分账能力,这块一开始就是 V3 专属。
于是我们的系统里就出现了这么一个尴尬的局面------同一个商户号,同一个业务链路,前面用 V2 扣钱,后面用 V3 分钱。两套 SDK、两套证书、两套签名算法,还得在同一个 Spring 应用里和平共处。
这篇文章记录一下我当时踩的坑。如果你也遇到类似"新旧版本 SDK 混用"的场景,应该能少走点弯路。
先把两套东西的区别摆清楚
| 维度 | V2(免密代扣) | V3(服务商分账) |
|---|---|---|
| 证书文件 | apiclient_cert.p12 |
apiclient_key.pem + 微信支付公钥 |
| 签名算法 | MD5 / HMAC-SHA256(拼 API key) | RSA SHA256(私钥签名) |
| 验签方式 | MD5 校验(应答 sign 字段) | RSA 公钥验签 |
| 请求体 | XML | JSON |
| 通信协议 | 自己拼 HTTP | 用 SDK 封装 |
| 官方 SDK | wechatpay-apache-httpclient(V3 的) / 老的 WXPayUtil |
wechatpay-java |
看到这张表应该能理解了:这不是"新旧版本"的关系,这是两套完全不同的协议。
V2 的签名是对称的------用商户 API 密钥算 MD5 或者 HMAC-SHA256,谁有密钥谁都能签也能验。
V3 的签名是非对称的------你用私钥签名,对方用你上传到微信平台的证书(或者微信支付公钥)来验。
这两个东西在概念上就没有共用空间。
坑一:证书文件不是一套
我们运维那边一开始只给了一份证书,就是老的 apiclient_cert.p12。
我拿去配 V3,直接报错。V3 要的是私钥文件 (apiclient_key.pem),而且不是从 p12 里随便导出来的那种------必须是从微信商户平台下载的那一份,因为要和你在平台上上传的公钥/证书配对。
正确姿势是去商户平台「API 安全」里下载:
- 商户 API 证书:
apiclient_cert.pem+apiclient_key.pem - 微信支付公钥:
pub_key.pem+ 公钥 ID(形如PUB_KEY_ID_xxxxx)
然后在配置里:
yaml
wx:
v2:
app-id: wx1234567890abcdef
mch-id: "1900000001"
api-key: "32位API密钥"
cert-path: /opt/certs/apiclient_cert.p12 # 老的,别删
cert-password: "1900000001"
v3:
mch-id: "1900000001"
private-key-path: /opt/certs/apiclient_key.pem
merchant-serial-no: "4A6B7C8D..." # 商户证书序列号
public-key-path: /opt/certs/pub_key.pem
public-key-id: "PUB_KEY_ID_0119010..." # 公钥ID,不是证书序列号!
这里有个特别容易搞混的点:merchant-serial-no 和 public-key-id 是两个完全不同的东西。
merchant-serial-no是你自己那张证书的序列号,用来告诉微信"这条请求是我签的"。public-key-id是微信支付公钥的 ID,用来告诉 SDK"拿这个公钥去验微信返回的签名"。
我一开始把公钥 ID 填成了证书序列号,报的错是「签名验证失败」,看起来像是签名写错了,实际上只是 ID 填错了。这个错查了我一个下午。
顺带说一句,微信支付公钥(也就是公钥模式)是替代老的「平台证书」方案的。老方案有个很烦的地方:平台证书会定期轮换,你得写个定时任务去下载更新。公钥模式基本上一劳永逸,新项目建议直接用公钥模式。
坑二:SDK 依赖打架
V2 我们用的是老的 WXPayUtil 那一套(自己封装的 HTTP 调用 + WXPayXmlUtil)。
V3 我用的是官方的 wechatpay-java:
xml
<dependency>
<groupId>com.github.wechatpay-apiv3</groupId>
<artifactId>wechatpay-java</artifactId>
<version>0.2.17</version>
</dependency>
这个 SDK 内部依赖了 OkHttp 和一些 Jackson 组件。而 V2 那套顺手引入的 wechatpay-apache-httpclient 依赖的是 Apache HttpClient 4.x。
两套 HTTP 客户端共存本身没问题,但它们各自的 HttpClient 实例如果都用 @Bean 注册,会因为类型不明确导致注入失败。
我一开始想省事,把 V2 的 WXPayClient 也做成 @Bean,结果启动报:
vbnet
NoUniqueBeanDefinitionException:
expected single matching bean but found 2
后来我干脆把 V2 的实现改成用静态工厂创建,不进 Spring 容器:
java
@Configuration
public class WxV2Config {
@Value("${wx.v2.cert-path}")
private String certPath;
@Value("${wx.v2.cert-password}")
private String certPassword;
@Value("${wx.v2.api-key}")
private String apiKey;
@Bean("wxV2PayService")
public WxV2PayService wxV2PayService() {
// 只注册业务服务,HttpClient 内部自己 new
return new WxV2PayServiceImpl(apiKey, certPath, certPassword);
}
}
V3 那边标准写法:
java
@Configuration
public class WxV3Config {
@Bean("wxV3Config")
public Config wxV3Config(
@Value("${wx.v3.mch-id}") String mchId,
@Value("${wx.v3.private-key-path}") String keyPath,
@Value("${wx.v3.merchant-serial-no}") String serialNo,
@Value("${wx.v3.public-key-path}") String pubKeyPath,
@Value("${wx.v3.public-key-id}") String pubKeyId) throws IOException {
PrivateKey privateKey = PemUtil.loadPrivateKey(new FileInputStream(keyPath));
PublicKey publicKey = PemUtil.loadPublicKey(new FileInputStream(pubKeyPath));
return new RSAPublicKeyConfig.Builder()
.merchantId(mchId)
.privateKey(privateKey)
.merchantSerialNumber(serialNo)
.publicKey(publicKey)
.publicKeyId(pubKeyId)
.apiV3Key(apiV3Key)
.build();
}
@Bean("wxV3ProfitSharingService")
public ProfitSharingService profitSharingService(@Qualifier("wxV3Config") Config config) {
return new ProfitSharingService.Builder().config(config).build();
}
}
注意每个 Bean 都给了明确的名字,注入的时候用 @Qualifier。混用场景下,Bean 名字就是你的安全绳,能命名就别偷懒。
坑三:V2 的 XML 和 V3 的 JSON 在同一个 DTO 上打架
因为两个接口其实是同一个业务(代扣 → 分账),我一开始想复用同一个请求对象。
想得太美了。
V2 是 XML,字段名全大写下划线(out_trade_no、total_fee)。 V3 是 JSON,字段名camelCase(outOrderNo、amount.total)。
硬要用一个类,就得挂两套序列化注解,代码会变得极其难看。我最后的做法是:彻底分开,各用各的 DTO。
arduino
dto/
v2/
WxV2WithholdRequest.java // XML 字段
v3/
WxV3ProfitSharingRequest.java // JSON 字段
从 v2 的类型反射成 v3 的类型,写一个显式的转换器:
java
public class WxV3ProfitSharingRequest convert(WxV2WithholdResult v2Result, Integer profitAmount) {
WxV3ProfitSharingRequest req = new WxV3ProfitSharingRequest();
req.setAppid(v2Result.getAppid());
req.setTransactionId(v2Result.getTransactionId()); // 注意:V3 要用微信侧交易号,不是商户单号
req.setOutOrderNo("PS" + v2Result.getOutTradeNo()); // 分账单号自己生成
req.setAmount(...);
return req;
}
这里有一个特别容易踩的坑:V3 的 transactionId 是指微信支付侧返回的交易号,不是你自己生成的 out_trade_no。
分账接口要求传的是 transactionId。我当时顺手把商户单号填进去了,结果微信返回:
json
{
"code": "PARAM_ERROR",
"message": "transaction_id格式错误"
}
所以代扣的时候一定要把微信返回的 transaction_id 落库,不然分账的时候你拿不到。
坑四:回调验签是两套
我们代扣成功的回调是 V2 的(XML + MD5 验签),分账结果的通知是 V3 的(JSON + RSA 验签)。
两个回调接口,两套验签逻辑,千万别写混。
V2 验签:
java
Map<String, String> params = WXPayUtil.xmlToMap(xmlBody);
if (!WXPayUtil.isSignatureValid(params, apiKey)) {
// 验签失败
}
V3 验签(用 wechatpay-java 的 NotificationParser):
java
NotificationRequest request = new NotificationRequest.Builder()
.serialNumber(serialNo) // 我从 header 里取的 Wechatpay-Serial
.nonce(nonce)
.timestamp(timestamp)
.signature(signature)
.body(body)
.build();
TransactionNotification notification = notificationParser.parse(request, TransactionNotification.class);
V3 的回调验签一定要从 HTTP header 里取 Wechatpay-Serial、Wechatpay-Nonce、Wechatpay-Timestamp、Wechatpay-Signature 这四个值,而且这四个值必须原封不动地传给 parser,不能做任何加工(比如 trim、转大小写)。
我踩过一次:网关层对 header 做了统一的小写转换,把 Wechatpay-Signature 变成了 wechatpay-signature,然后我按原样取不到值,验签直接失败。排查了半小时才发现是网关干的。
结论:这几个 header 的原始值要尽早抓出来,最好用一个前置 Filter 把它们存到一个 ThreadLocal 或者请求属性里,别等到业务层再从 request 里翻。
我的整体建议
如果你也遇到"同一个商户号混用 V2 和 V3"的场景,我的建议按重要性排序:
1. 物理隔离。 配置文件分两段(wx.v2.* / wx.v3.*),Java 包分两层(wx.v2 / wx.v3),Bean 名字带版本号。不要试图"抽象"出一个统一的支付服务,这两个协议的差异大到抽象出来只会更乱。
2. 证书文件分开存、分开管。 V2 的 p12 和 V3 的 pem 放不同目录,配置项名字写清楚用途,别叫 wx.cert 这种含糊的名字。
3. 交易号一定要落库。 不管是 V2 的 transaction_id 还是 V3 的 transaction_id,代扣成功后就存下来。分账、退款、查询都靠它。
4. 两套回调分开写,别复用。 验签逻辑、报文格式都不一样,复用的收益极低,风险极高。
5. 早点上公钥模式。 如果 V3 你还在用平台证书,考虑换成微信支付公钥。少一个定时下载任务,少一堆轮换期间的诡异问题。
最后说一句感想。很多"技术选型"的难题,本质是"历史遗留"的难题。 你没法重来一遍,只能在两套体系中间修一座桥。修桥的关键不是桥造得多漂亮,而是把两边的规矩都摸清楚------哪边是 XML 哪边是 JSON,哪边用对称签名哪边用非对称,这些细节一个都不能错。