☰
支付对接实战:chinapay-java-new中的RSA2签名与AES-GCM加密解析
2026/10/15 10:30:03 网站建设 项目流程

简介:一份面向Java开发者的中国银联ChinaPay支付接口对接参考工程,适合承担Web支付功能开发或需要快速集成银联在线支付的技术人员,可协助解决网关下单、签名验签、异步回调等环节的典型问题,也适合已有一定Java Web基础、希望系统学习第三方支付集成的开发者。压缩包共72个文件,体积约5.05MB,主要由17个Java源码及对应class文件、15个jar依赖库、10个JSP页面、properties与XML配置文件等组成;Java源码承载支付核心逻辑,class文件便于直接运行比对,jar包提供第三方支付依赖,JSP页面负责下单与结果展示,properties和XML用于环境及组件配置。已有431人在CSDN浏览学习,可作为支付接口联调、项目结构拆解或二次开发的基础模板,特别适用于遇到回调验签或签名报错时对照源码排查问题。从内容预览看,工程遵循标准Eclipse Web项目布局,包括WebContent、src、res、build等目录,读者可依据清晰的分层结构快速定位支付核心逻辑、表单页面与配置项,借助自带的jar依赖直接部署测试,省去手动搭建环境和查阅接口文档的精力,适合边读源码边对照支付流程进行操作。

1. chinapay-java-new 是什么:从一次支付联调翻车说起

最近在替一个老系统做支付通道切换,接手时发现之前的 chinapay 对接代码散落在好几个私有工具包里,证书加载、报文拼接全靠个人习惯撑着。换一个人维护,整个链路就成了黑匣子。后来拿到 chinapay-java-new 这份资源,把新版 SDK 的证书初始化、RSA2 加签、AES-GCM 加密、回调验签完整梳理了一遍,才把过去频繁出现的签名失败、回调重放问题压下去。这篇不介绍概念,只写落地。适合正在对接或升级 chinapay 通道的 Java 开发,也适合想把老版 SDK 迁移到新协议的人,看完能直接套用。

2. 工程结构与核心加密选型:新版 SDK 为什么要换 RSA2 + AES-GCM

2.1 目录结构与三个核心包

chinapay-java-new 不是一个空壳示例,而是可以直接引入项目的代码结构。我拿到后第一件事是把目录整体扫了一遍,典型的 Maven 工程,资源文件里已经带了测试证书和配置文件。目录长这样:

chinapay-java-new/ ├── pom.xml ├── src/main/java/com/chinapay/demo/ │ ├── config/ │ │ └── MerchantConfig.java # 商户号、证书路径、密钥口令配置 │ ├── util/ │ │ ├── SecureKit.java # AES-GCM 加解密、随机 IV │ │ └── SignUtil.java # RSA2 签名串构造、加签验签 │ ├── core/ │ │ ├── PayClient.java # HTTP 请求封装、报文组装 │ │ └── NotifyVerifier.java # 异步回调验签与幂等校验 │ └── model/ │ ├── OrderReq.java # 下单请求参数 │ └── PayResponse.java # 支付响应解析 └── resources/ ├── merchant-private.p12 # 商户私钥证书 ├── platform-public.cer # 平台公钥证书 └── application.properties # 配置文件

三个核心包要分清:config 管初始化,util 管加密签名,core 管请求与回调。我通常会把 config 和 util 单独抽成公共模块,core 里的 PayClient 和 NotifyVerifier 按业务定制。新版 SDK 的 pom.xml 依赖也不需要额外引入太多东西,底层只依赖 Apache HttpClient 和 BouncyCastle,具体版本以仓库 release 为准。

<dependencies> <dependency> <groupId>org.apache.httpcomponents.client5</groupId> <artifactId>httpclient5</artifactId> <version>5.2</version> </dependency> <dependency> <groupId>org.bouncycastle</groupId> <artifactId>bcprov-jdk15on</artifactId> <version>1.70</version> </dependency> </dependencies>

引入 BouncyCastle 不是玄学,是因为新版 chinapay 的证书和加密算法有一部分依赖 JDK 之外的算法实现,尤其是高版本的 AES-GCM 密钥派生。HttpClient 5 用来发送 HTTPS 请求,底层连接池和超时控制比老式的 HttpURLConnection 稳得多。

2.2 旧版 RSA + AES-ECB 的坑,以及新版选型理由

老版 chinapay SDK 常见组合是 MD5 摘要加签 + AES-ECB 加密敏感字段,AES-ECB 最大的问题是相同明文永远得到相同密文,攻击者可以通过密文块对比推断字段内容。新版换成 RSA2(SHA256withRSA)加签 + AES-GCM 加密,加签强度从 1024 位 RSA 提升到 2048 位,加密模式从 ECB 换成带认证标签的 GCM。

项目老版方案新版方案
加签算法MD5 摘要SHA256withRSA
密钥长度RSA 1024RSA 2048
加密模式AES-ECBAES-GCM
回调验签无统一标准必须验签
报文格式自定义 KeyValueJSON + Base64

我给一个实际对比:老版签名时,只用商户号和金额拼一个摘要串,MD5 出来直接当签名。问题在于 MD5 存在长度扩展攻击风险,且没有私钥参与,平台无法确认请求真的来自商户。新版强制用 RSA2 私钥加签,平台端用商户公钥验签,身份可信度完全不一样。

SecureKit 里 AES-GCM 的实现方式是这样的:

public class SecureKit { private static final int IV_LENGTH = 12; private static final int TAG_BITS = 128; public static String encryptByAesGcm(String plainText, SecretKey key) throws Exception { byte[] iv = new byte[IV_LENGTH]; SecureRandom random = SecureRandom.getInstanceStrong(); random.nextBytes(iv); Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); cipher.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(TAG_BITS, iv)); byte[] encrypted = cipher.doFinal( plainText.getBytes(StandardCharsets.UTF_8) ); ByteBuffer buffer = ByteBuffer.allocate(iv.length + encrypted.length); buffer.put(iv); buffer.put(encrypted); return Base64.getEncoder().encodeToString(buffer.array()); } }

这段逻辑的核心在 IV 的随机生成和拼接。AES-GCM 模式下,IV 必须是每次加密随机生成的,而且解密时需要用到同一个 IV,所以我把它直接拼在密文前面一起做 Base64。TAG_BITS 固定 128 位,这是安全参数,不要改成 96 或 32。GCMParameterSpec 的第一个参数是认证标签长度,第二个是 IV。这里有个细节:SecureRandom.getInstanceStrong()在某些 Linux 服务器上可能阻塞,如果遇到性能问题,可以改成普通new SecureRandom(),但需要确认当前 JDK 版本的熵源配置。

3. 证书初始化与报文签名:从 p12 到加签验签的完整链路

3.1 加载商户私钥与平台公钥

支付对接的第一步不是写请求代码,而是把证书加载打通。chinapay-java-new 里默认使用的是 PKCS12 格式的商户私钥证书,文件后缀是 p12,里面既包含商户私钥,也包含对应的证书链。平台公钥是 X.509 格式的 cer 文件,用来验签平台返回的数据。

public class MerchantConfig { private String merchantId; private String p12Path; private String p12Password; private String platformPublicKeyPath; private String gatewayUrl; // getter / setter 省略 } public class CertService { public PrivateKey loadMerchantPrivateKey(String p12Path, String p12Password) throws Exception { KeyStore keyStore = KeyStore.getInstance("PKCS12"); try (InputStream in = Files.newInputStream(Paths.get(p12Path))) { keyStore.load(in, p12Password.toCharArray()); } Enumeration<String> aliases = keyStore.aliases(); String alias = null; while (aliases.hasMoreElements()) { String current = aliases.nextElement(); if (keyStore.isKeyEntry(current)) { alias = current; break; } } if (alias == null) { throw new IllegalStateException("keystore 中未找到密钥条目"); } return (PrivateKey) keyStore.getKey(alias, p12Password.toCharArray()); } public PublicKey loadPlatformPublicKey(String cerPath) throws Exception { CertificateFactory factory = CertificateFactory.getInstance("X.509"); try (InputStream in = Files.newInputStream(Paths.get(cerPath))) { X509Certificate certificate = (X509Certificate) factory.generateCertificate(in); return certificate.getPublicKey(); } } }

这里有个很容易踩的细节:KeyStore.getInstance("PKCS12")必须显式指定,不能依赖默认类型,否则在部分 JDK 上会默认成 JKS,然后报错"keystore password was incorrect"。而我手动遍历别名找密钥条目的原因更实际——部分 p12 文件里除了密钥条目,还会带一个 trustedCertEntry,如果直接用aliases().nextElement(),拿到的可能是证书条目而不是私钥条目。判断标准是keyStore.isKeyEntry(current)。

3.2 签名串构造与 SHA256withRSA 加签

证书加载完,下一步是构造签名串。chinapay 新版统一要求:把所有参数按键名 ASCII 升序排列,排除空值,按key=value&key=value格式拼接,最后去掉末尾的&,然后对拼接结果做 UTF-8 编码,用商户私钥加签。

public class SignUtil { public static String buildSignContent(Map<String, String> params) { TreeMap<String, String> sortedMap = new TreeMap<>(params); StringBuilder content = new StringBuilder(); for (Map.Entry<String, String> entry : sortedMap.entrySet()) { String value = entry.getValue(); if (value == null || value.isEmpty()) { continue; } if (content.length() > 0) { content.append('&'); } content.append(entry.getKey()).append('=').append(value); } return content.toString(); } public static String sign(String signContent, PrivateKey privateKey) throws Exception { Signature signature = Signature.getInstance("SHA256withRSA"); signature.initSign(privateKey); signature.update(signContent.getBytes(StandardCharsets.UTF_8)); byte[] signedBytes = signature.sign(); return Base64.getEncoder().encodeToString(signedBytes); } }

buildSignContent里的TreeMap替代了手动排序,这是最不容易写错的方式之一。为什么用 TreeMap?因为 ASCII 升序恰好是 TreeMap 的自然排序。EmptyValue 跳过逻辑非常关键:平台端拼接签名串时同样会跳过空值,如果商户端把空值也拼进去,签名串必然不一致。加签时Base64.getEncoder().encodeToString不带换行符,这是必需条件,因为有些 Base64 工具会默认每 76 个字符插入换行,一旦插入换行,平台端验签就会失败。

3.3 对平台响应做验签

平台返回的同步结果和异步通知,都需要验签。验签用的公钥是平台公钥,验签的签名串构造规则必须和加签时完全一致。很多人在调试时犯一个错误:从平台返回的报文里复制参数去拼接,结果把sign字段自己也拼进去了,验签永远不过。

public class NotifyVerifier { public boolean verify(Map<String, String> params, String sign, PublicKey platformPublicKey) throws Exception { Map<String, String> paramCopy = new HashMap<>(params); paramCopy.remove("sign"); String signContent = SignUtil.buildSignContent(paramCopy); Signature signature = Signature.getInstance("SHA256withRSA"); signature.initVerify(platformPublicKey); signature.update(signContent.getBytes(StandardCharsets.UTF_8)); return signature.verify(Base64.getDecoder().decode(sign)); } }

这段代码的核心逻辑是paramCopy.remove("sign")。验签必须先把签名本身剔除,再重新构建签名串。还有个容易被忽略的点:签名串有序性。平台异步通知的参数里通常会有sign_type这种字段,如果签名规则里没有要求包含该字段,那在验签时也要一起剔除或跳过。最稳妥的做法是把所有参与签名的字段名与平台文档对照一遍,多一个字段少一个字段都会导致验签失败。

验签失败还有一个常见场景:平台公钥证书过期。公钥证书文件有个 validity 时间范围,超出范围后generateCertificate仍然能成功,但验签必然失败。我在生产环境遇到过证书过期的问题,代码日志里完全没有提示,后来把证书详情打印出来才看到NotAfter时间早就过了。所以加载完证书后,建议加一行证书有效期检查。

4. 常见问题排查:证书、报文与回调的三个必踩点

4.1 证书解析失败:PKCS12 类型与别名选择

现象:本地测试证书加载正常,部署到服务器后启动报IOException: keystore password was incorrect,密码明明没有改。

原因:服务器的 JDK 把 p12 文件当作 JKS 解析。JKS 和 PKCS12 的密钥库格式不同,默认的KeyStore.getDefaultType()在 Oracle JDK 上是 JKS,在 OpenJDK 11 以后可能变了,但为了兼容老配置,很多调用还是没显式指定类型。

解决:统一显式指定KeyStore.getInstance("PKCS12")。另外,如果服务器上多个应用共用同一个 JDK,环境变量里的javax.net.ssl.keyStoreType可能被某个应用改过,这也会影响默认密钥库类型。排查步骤如下:

keytool -list -v -keystore merchant-private.p12 -storetype PKCS12 -storepass 123456

这个命令会打印 p12 文件里的所有条目详情。如果 keytool 能正常列出,说明文件本身没问题,问题出在代码的加载方式。如果 keytool 也报错,先检查文件是否被 Git 当作文本处理而损坏——常见于团队协作时.gitattributes配置不当,p12 文件被转换行结束符,字节流损坏。

另一个与别名相关的坑:文件里有多个条目时,aliases().nextElement()返回的第一个条目不一定是密钥条目。我建议用遍历方式匹配isKeyEntry。如果遍历后仍然找不到,用 keytool 查看条目类型,必要时可以用 keytool 重新生成 p12。

4.2 回调验签总失败:签名串里的空值与重放

现象:用平台给的签名示例数据验签能通过,但实际异步通知过来后验签失败,同一笔订单在测试环境偶尔能成功。

原因:异步通知参数中包含部分可空字段。例如商户自定义字段extra在没传时是空字符串,平台文档说空值不参与签名,但实际通知参数里可能有两种情况——字段完全不存在,或者字段存在但值为空。某些情况下,平台会在签名串里保留非空字段,但回调参数里多了个"",构造签名串时没有过滤干净。

解决:buildSignContent必须严格使用非空判断,过滤掉null和""。同时,验签前打印完整的签名串内容,与平台日志对照。我一般会在验签失败的分支加一段日志,把参数排序后的签名串打出来,然后让平台方提供他们验签使用的原串,两者逐字符对比。还有一个重放问题:即使验签通过,同一个通知可能被平台重发多次。如果业务逻辑没有幂等,会出现重复回调导致重复扣款记录。解决方式是校验回调参数里的平台时间戳:

public boolean checkTimestamp(String timestamp, long maxIntervalMillis) { try { SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); Date time = format.parse(timestamp); return Math.abs(System.currentTimeMillis() - time.getTime()) <= maxIntervalMillis; } catch (ParseException e) { return false; } }

时间戳校验不是可选项。chinapay 新版回调里会带平台生成的流水号和时间字段。校验时间间隔可以在一定程度上防止历史报文重放,但真正的核心是业务幂等键。把平台流水号作为唯一索引,或者用商户订单号加回调类型做去重,比单纯靠时间戳更可靠。

4.3 报文编码问题:中文乱码与 Base64 换行

现象:订单标题里包含中文时,平台返回的参数校验失败,签名错误;把中文改成纯英文就正常。用 Postman 手动测试没问题,程序跑起来就出问题。

原因:中文订单标题在程序里被转成 GBK 字节参与签名,而平台按 UTF-8 解码后得到一串乱码,签名自然不一致。Postman 默认用的是 UTF-8,所以测不出问题。另一个隐藏原因是 Base64 编码换行。JDK 原生Base64.getEncoder()不会换行,但如果代码里用了Apache Commons Codec的Base64.encodeBase64String,默认会每隔 76 个字符插一个换行符\n,平台验签时把换行符当作签名内容的一部分,导致验签失败。

解决:全链路统一 UTF-8。签名、加密、HTTP 请求体三处都要设置:

public class HttpRequestBuilder { public HttpPost buildJsonRequest(String url, String jsonBody) { HttpPost post = new HttpPost(url); post.setHeader("Content-Type", "application/json;charset=UTF-8"); post.setEntity(new StringEntity(jsonBody, StandardCharsets.UTF_8)); return post; } }

StringEntity构造时显式传StandardCharsets.UTF_8,不要依赖操作系统默认字符集。对于 Base64,统一使用java.util.Base64,避免混用不同库。我维护的团队规范里有一条硬性要求:所有字符串与字节数组互转,必须显式声明字符集,不许用默认编码。这个习惯救了不止一次。

5. 生产验证三板斧:沙箱对账、幂等键与退款用例

在正式上线前,我会用三组用例验证 chinapay-java-new 整个链路是否可靠。第一组是沙箱对账:配置环境为沙箱地址,用测试证书分别跑下单、支付查询、退款三个接口,拿返回值里的订单状态与沙箱后台记录比对。sdk 里带了application.properties的环境开关,我把gatewayUrl切到沙箱地址,证书换成测试 p12,跑一遍全流程。不是只看返回码,要对每笔订单的金额、状态、平台流水号做一次全字段对比,确认请求参数没有漏传或错传。

第二组是幂等键生成规则。我会给outTradeNo加上业务类型和日期前缀,例如RECHARGE_20250116_加雪花 ID。这样即使并发重复请求,也不会因为同一订单号走到不同渠道,退款和查单也都复用同一套订单号。幂等键不该用 UUID 裸奔,无业务含义的订单号会显著增加对账排查难度。

第三组是退款用例。退款比下单更适合用来验证签名和证书是否正常,因为退款接口需要先用商户私钥加签,再用平台公钥验签平台返回结果,一套流程把两个证书全测到位。我习惯在沙箱环境定期跑退款到原单的用例,如果退款能成功,说明加签验签链路没有松动。

从那以后,我每次对接新支付通道,都会先强制走一遍这三板斧。签名失败时先看签名串,证书报错时先看密钥库类型,回调异常时先查幂等和时间戳。支付通道的坑大多是配置和编码层面的细节,把这几关守住,能少熬很多夜。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询