同一个商户号,V2 免密代扣和 V3 分账的证书怎么共存?

一个很别扭的需求

我们 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,哪边用对称签名哪边用非对称,这些细节一个都不能错。

相关推荐
估值探索者1 小时前
【Python量化技术指标实战 04】布林带通道突破,怎么知道是真突破还是假突破?
java·开发语言·python·接口·股票数据api接口
captain3761 小时前
Spring Boot的配置文件
java·spring boot·后端
captain3761 小时前
Spring Boot日志
java·数据库·spring boot
Rabitebla1 小时前
【Linux】 权限篇(三):深入理解 umask 位运算与粘滞位的设计思想
java·linux·服务器·c++·笔记·学习
IT_陈寒2 小时前
为什么你应该学习React?
前端·人工智能·后端
每天一道题2 小时前
Kafka 为什么会重复消费?从 Producer ACK、Offset 到消息可靠性
后端·微服务
Pin_Qing2 小时前
缓存能活多久,现在是个要算的参数
java·大数据·开发语言·人工智能·程序人生
jyOverQ2 小时前
从零开始学 RocketMQ(四):集群部署、消息存储与高可用【保姆级教程】
java·rocketmq·java-rocketmq
小裕哥略帅2 小时前
行级数据权限控制@DataPermission
java·开发语言