拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Java接入ChinaPay支付网关:证书签名、报文组装与回调验签实战

Java接入ChinaPay支付网关:证书签名、报文组装与回调验签实战

简介:面向需要对接银联在线支付/ChinaPay网关的Java Web开发者,这份Java版支付接口示例工程可直接导入Eclipse查阅,覆盖从下单请求到异步通知、验签与回执处理的典型链路。压缩包共72个文件,约5.05MB,以17个Java源文件与17个编译后的Class文件为主,配合15个JAR依赖、10个JSP页面及properties、xml配置文件,便于对照源码理解支付对接中的参数组装、签名校验和页面跳转。已有431人学习下载,适合刚接触支付渠道接入或需要参考前后端联调细节的开发者。资料保留了工程目录与Eclipse配置,源码、类文件、依赖库分层清楚,可在本地环境快速还原运行骨架,重点观察配置文件与JSP中关于接口地址、商户号、证书等参数的接入方式,帮助减少对接中的通用踩坑。

1. 别把 chinapay 当普通 HTTP 接口:Java 接入前先认识它的签名体系

入行第三年,我第一次接 chinapay 支付,以为跟支付宝一样拿 appId 和密钥就能调通,结果证书、私钥、签名串、Base64 换行这些概念砸过来,一个签名错误就能磨一下午。这套 chinapay-java-new 就是我从那个项目里拆出来的 Java 封装,它不是官方 SDK,而是把 chinapay 网关从证书加载、报文签名、HTTP 发送到回调验签整条链路整理成了一个能直接跑的工程。核心价值在于它把老网关那些文档里含糊的边界都处理干净了:签名字段怎么排序、验签用哪把公钥、Linux 下证书路径怎么配。如果你正在看 chinapay 的 Java 接入,或者手头有一个跑了好几年但没人敢动的老支付模块,这份代码能让你少走很多弯路。它是给有 Java 基础、想快速落地联调的人准备的,不需要你重新发明签名轮子。

2. 从 jar 到可运行:chinapay-java-new 的项目结构与核心配置

2.1 项目里都有什么:五个模块的分工

先别急着跑,花两分钟看清项目结构。这个工程是标准的 Maven 多模块布局,主模块chinapay-java-new下挂了五个子模块,每个模块的职责都很单一,这是我拆项目时有意保持的,避免你为了查一个回调验签把整个工程的代码翻一遍。

模块路径职责
corechinapay-core封装了商户信息、订单状态、交易返回码等核心领域对象
cryptochinapay-crypto负责证书加载、私钥签名、公钥验签,是整个项目的安全地基
httpchinapay-http封装 HTTP 发送逻辑,内置超时、重试和日志钩子
servicechinapay-service对上层暴露消费、退款、查询、对账下载四类业务方法
demochinapay-demo可独立运行的示例小程序,方便你在没有业务系统时先本地验证

依赖关系是从上往下的单向依赖:demo 依赖 service,service 依赖 http 和 crypto,crypto 只依赖 core。如果你只想看签名是怎么做的,直接打开 crypto 模块即可;要是你只想跑通一笔查询交易,demo 模块里有一个QueryDemo的 main 方法,改一下配置就能跑。

我一般建议你先跑 demo,而不是先读全部源码。因为 demo 里已经把配置加载、签名、发送、验签这条链路串起来了,你看到请求能发出去并拿到返回码,说明环境和证书都没问题,后面再改自己的业务逻辑会轻松很多。

2.2 第一次启动:配置文件里的商户号、证书路径与网关地址

工程里默认的配置文件是src/main/resources/chinapay.properties,内容长这样:

# chinapay 商户配置 chinapay.merchant.id=620000000000001 chinapay.pfx.path=/etc/pay/chinapay/merchant.pfx chinapay.pfx.password=your-password chinapay.gateway.url=https://gateway.chinapay.com/pay/gateway chinapay.public.cert.path=/etc/pay/chinapay/pg_public.cer chinapay.charset=UTF-8 chinapay.timeout.ms=15000 chinapay.sign.type=SHA1withRSA

这里每一项都对应网关侧的真实要求,缺一个启动时就会报配置错。merchant.id是商户号,联调环境和生产环境通常不同;pfx.path是商户私钥证书,大多数情况是银行或银联提供给你的 PFX 文件,密码单独记一份,别写在代码里;gateway.url是支付网关地址,联调用测试地址,上线前切到生产地址,很多新人在这一步容易漏掉而一直连不上;public.cert.path是银联的平台公钥证书,验签时会用到。

配置文件加载是用 Spring 的@Value做的,但为了让你在非 Spring 环境也能跑,demo 里保留了一个ChinapayProperties类的load()方法,用原生 Java Properties 读取。如果你要把这套代码嵌到自己项目里,我建议把它注册成 Spring Bean,然后用@ConfigurationProperties(prefix = "chinapay")来绑定,这样 IDE 能自动提示,少打错几个参数名。

2.3 用一条命令验证环境:跑通预授权或查询接口

配置完别急着下单,先用查询接口验证环境。因为查询接口不涉及真实资金,参数最少,报错也最容易定位。demo 里的QueryDemo类提供了入口:

mvn clean package -DskipTests java -jar chinapay-demo/target/chinapay-demo-1.0-SNAPSHOT.jar --action=query --order-id=20250118001

运行后,项目会加载配置、读取证书、发起查询请求并打印响应和验签结果。看到日志里出现validate result: true和response code: 0000,说明整条链路是通的。

如果报签名错误,先检查平台公钥证书有没有加载成功,再看报文里中文是否变成了乱码。如果报证书没找到,就把pfx.path从相对路径改成绝对路径,并确认当前用户对文件有读权限,这是 Linux 部署最常见的坑。

3. 核心交易流程:下单、签名、发送与回调验签的实现细节

3.1 报文组装与签名:为什么 chinapay 用的是证书私钥而非 API Key

老一代网关做安全校验时,通用做法是「证书私钥签名 + 平台公钥验签」,而不是现在流行的 API Key 加签。chinapay 沿用了这套 PKI 体系,所以你会看到所有请求报文在发送前都要经过一个 SHA1withRSA 签名的过程。签名的输入不是整个 JSON,而是把请求报文中的关键字段按固定顺序拼接出来的一个明文字符串。

拼接规则是每个接口自己的协议定的,比如消费接口通常是merchantId|orderId|txnAmount|txnTime|...|这样的竖线分隔串。这个顺序错了,哪怕漏一个字段,网关验签都会失败。所以我建议你第一时间把接口文档里的「签名原串举例」找出来,用真实测试数据拼一遍,再拿项目里的签名工具去对比结果。

crypto 模块里提供了一个现成的签名组件:

import java.security.KeyStore; import java.security.PrivateKey; import java.security.Signature; import java.util.Base64; public class ChinapaySigner { private final PrivateKey privateKey; public ChinapaySigner(String pfxPath, String password) throws Exception { KeyStore keyStore = KeyStore.getInstance("PKCS12"); try (var in = new FileInputStream(pfxPath)) { keyStore.load(in, password.toCharArray()); } String alias = keyStore.aliases().nextElement(); this.privateKey = (PrivateKey) keyStore.getKey(alias, password.toCharArray()); } public String sign(String plainText) throws Exception { Signature signature = Signature.getInstance("SHA1withRSA"); signature.initSign(privateKey); signature.update(plainText.getBytes("UTF-8")); // 去掉换行是因为 Base64 默认可能会带 \r\n,网关验签不认 return Base64.getEncoder().encodeToString(signature.sign()) .replaceAll("\r", "") .replaceAll("\n", ""); } }

这段代码里有两个细节值得注意。第一,KeyStore.getInstance("PKCS12")不要写成"JKS",银联下发的商户证书基本都是 PFX/P12 格式,JKS 加载会报格式不识别。第二,签名后的 Base64 字符串一定要去掉换行符,不同 JDK 版本、不同操作系统在Base64.getEncoder()输出上可能带\r\n,网关验签是按原始字符串解 Base64 的,不剔除换行就报验签失败。

3.2 同步响应与异步回调:两个容易搞混的验签入口

chinapay 的交易结果有两种返回方式:同步返回是 HTTP 请求的即时响应,异步回调是网关在交易最终处理后主动向你的 notify URL 发起的通知。两个响应里都带sign字段,但验签用的公钥是不同的,极易混用。

同步响应的验签应该用银联平台公钥,也就是配置里public.cert.path指向的那个.cer文件。异步回调的验签,很多场景下需要用商户私钥对应的证书公钥去验——但更严谨的规则要看网关版本,有的版本回调字段和同步字段用的都是平台公钥,有的则不是。我见过两个项目因此互相甩锅,最后查到的问题都是把同步的验签逻辑直接复制到回调里。

项目里抽象了一个ChinapayVerifier类:

public boolean verify(String plainText, String sign, X509Certificate certificate) { try { Signature signature = Signature.getInstance("SHA1withRSA"); signature.initVerify(certificate.getPublicKey()); signature.update(plainText.getBytes("UTF-8")); return signature.verify(Base64.getDecoder().decode(sign)); } catch (Exception e) { log.error("chinapay verify failed, plainText={}, sign={}", plainText, sign, e); return false; } }

注意,这里传入的certificate不是从请求里拿的,而是从配置的证书文件加载出来的X509Certificate对象。请求报文里的sign只是待验签的值,它自己不是证书。调用时你需要先组装验签原串,再用ChinapayCertLoader加载对应的证书对象:

X509Certificate cert = ChinapayCertLoader.loadCert("platform.cer"); boolean ok = verifier.verify(plainText, response.getSign(), cert);

组装验签原串的规则和请求签名原串完全一致,只不过字段值要取自响应或回调报文。这里最容易漏的是:同步响应里有些字段是可选的,如果网关没返回,拼接时就不能占位,必须按网关实际返回的字段拼。项目里用了一个SignatureFieldBuilder,它会根据响应报文里真实存在的键值动态拼接,而不是直接拼一个模板。

3.3 退款与对账接口的参数差异

退款和对账是除了消费之外最常用的两个接口,但它们的参数组织和消费接口不一样。退款接口需要原订单号origOrderId,部分退款时还需要填退款金额,金额单位是「分」,并且退款金额不能大于原订单金额,否则网关直接拒绝。对账接口则按文件对账日期settleDate拉取商户对账单,不涉及签名金额,参数更少。

参数消费退款对账下载
merchantId是是是
orderId是否,用 origOrderId否
txnAmount是是(部分退款)否
txnTime是是否
origOrderId否是否
settleDate否否是(yyyyMMdd)
fileType否否是(如 00 代表汇总文件)

退款接口有两个点容易翻车。第一是txnTime,它必须传原交易的时间,而不是发起退款的时间,传错了网关会提示原交易不存在。第二是退款接口的签名原串里包含origOrderId,这个字段在报文中会同时存在orderId和origOrderId,拼签名时很多人会看错顺序。

对账下载的响应体不是 JSON,而是一个文件流。service 模块里对下载接口单独做了处理,没有走通用的 JSON 解析,所以你在调用时别直接用parseResponse去解析,否则会得到一堆乱码。项目返回的是一个InputStream,你把它写到本地文件后按行解析即可。

4. 避坑指南:chinapay 接入中我踩过的五个坑

4.1 坑一:证书密码含特殊字符导致 PKCS12 加载失败

现象:在本地 Windows 跑得好好的,部署到 Linux 服务器后,启动时一直报keystore password was incorrect。

原因:证书密码里有$和!这类特殊字符,在配置文件 properties 里被当成转义符或环境变量插值了,实际读到的密码已经变了。

解决:properties 文件里对特殊字符用反斜杠转义,或者改用启动环境变量注入密码。我后来直接把密码改成只包含数字和字母,彻底避免这个麻烦。如果密码不能改,就在 properties 里写成chinapay.pfx.password=pass\\$word这样的双反斜杠,并确认 Spring 的占位符解析没有把它吞掉。

4.2 坑二:报文参数顺序不一致导致签名验证不通过

现象:请求发到网关后返回5202签名错误,但用文档里的示例数据自测签名又是正确的。

原因:集成时照着某个博客的示例把merchantId和orderId顺序写反了。网关拼接签名原串的顺序是merchantId|orderId|txnTime|...,你比对的却是支付宝式的appId+orderId顺序。

解决:严格按 chinapay 接口文档最下方给的「签名原串示例」逐字比对。项目里提供了一个printSignSource的调试开关,开启后会在日志里打印实际参与签名的原串:

-Dchinapay.debug=true

打开后,你把这串原串和文档示例放在一起 diff,哪个字段顺序不对立刻就能看出来。

4.3 坑三:回调验签用错公钥,把商户公钥当成平台公钥

现象:同步响应验签全部通过,但异步回调在 demo 里永远验签失败,日志里全是verify: false。

原因:异步回调报文里的certId或签名算法倾向用平台证书验签,但我写回调处理时偷懒,直接复用了同步验签代码,同步验签用的又是商户公钥证书,两把证书的公钥不一致,自然验不过。

解决:查看网关文档中「异步回调验签」章节,确认是使用平台公钥还是商户公钥。如果文档没写清楚,把回调报文的sign字段分别用两把证书验一遍,看哪把能验过。项目里的verifyByMerchant和verifyByPlatform两个方法就是为这种不确定场景准备的,建议保留两者并在日志中标注验签公钥来源。

4.4 坑四:中文订单描述在 Linux 下变成乱码,网关返回非法请求

现象:本地开发发起消费请求正常,测试服务器上报4002 参数错误,查看日志发现请求报文里的商品名变成了???。

原因:服务器默认字符集是GBK或ISO-8859-1,发送请求时没有指定 UTF-8 编码,中文被转换成了乱码。

解决:在所有请求发送和响应解析的代码里显式指定字符集:

byte[] body = requestText.getBytes("UTF-8"); conn.setRequestProperty("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8");

同时建议在 JVM 启动参数里加上-Dfile.encoding=UTF-8,并在代码中统一使用Charset.forName("UTF-8")而不是依赖系统默认字符集。

4.5 坑五:同步结果为 0000,但异步回调始终没收到

现象:消费接口同步返回成功,业务上也发了货,但等待回调对账时却一直没有 notify 请求落地,前后台对不上账。

原因:异步回调通知地址notifyUrl不是在线请求中传给网关的,而是配置在商户后台的,且回调地址必须支持公网访问。当时联调环境拿的是内网 IP,网关无法回连。

解决:把商户后台的 notify 地址改成公网可访问的 HTTPS 地址,并且确保回调接口处理时间不超过网关超时时间。如果回调逻辑里做了同步数据库更新,建议改为先落库、再异步处理,避免网关在 5 秒内没得到success响应就重发。项目里NotifyController返回固定success字符串,任何异常都不能吞掉,一定要在入口 catch 后记录日志,再决定是否抛出。

5. 把项目改造成自己的:自定义加密、动态证书与多商户扩展

5.1 从写死参数到动态配置:改造一个多商户版本

接手这套代码后,你可能最先遇到的需求是多个商户号共用同一个工程。现在的配置里chinapay.merchant.id是单例写死的,要支持多商户,得把证书和商户号从ChinapayProperties里拆出来,做成商户维度的一个缓存 Map。我一般会这样改:

@Component public class MerchantRegistry { private final Map<String, MerchantConfig> merchantMap = new ConcurrentHashMap<>(); public void register(MerchantConfig config) { merchantMap.put(config.getMerchantId(), config); } public MerchantConfig get(String merchantId) { MerchantConfig config = merchantMap.get(merchantId); if (config == null) { throw new IllegalArgumentException("unregistered merchant: " + merchantId); } return config; } }

MerchantConfig里保存的是每个商户自己的私钥路径、回调地址和平台证书路径。改造的核心是让ChinapaySigner和ChinapayVerifier不再持有全局单例证书,而是每次从MerchantRegistry里取得对应商户的证书对象再初始化签名器。

多商户还有一个隐藏问题:私钥加载是 IO 密集操作,如果每个请求都重新 load PFX,性能会非常难看。我在注册表里加了缓存,按商户 ID 缓存PrivateKey和X509Certificate,并用 concurrentMap 防止并发加载重复读文件。如果你追求更高的可用性,可以再加一个定时刷新机制,每天凌晨重新加载一次证书,这样证书到期更换时不用发版。

5.2 用拦截器统一处理签名与日志

原项目里签名、验签和日志散落在各个 service 方法中,换一个接口就要复制一段代码,很容易漏。我建议你在改造时把这些横切逻辑抽到一个 HandlerInterceptor 或 Spring AOP 切面里,统一处理出参入参的签名与日志。以 AOP 为例:

@Aspect @Component public class PayLogAspect { @Around("@annotation(payLog)") public Object around(ProceedingJoinPoint joinPoint, PayLog payLog) throws Throwable { Object[] args = joinPoint.getArgs(); String requestJson = args.length > 0 ? toJson(args[0]) : "[]"; long start = System.currentTimeMillis(); try { Object result = joinPoint.proceed(); log.info("pay request={}, response={}, cost={}ms", requestJson, toJson(result), System.currentTimeMillis() - start); return result; } catch (Exception e) { log.error("pay request={}, error={}", requestJson, e.getMessage(), e); throw e; } } }

这个切面的价值不只是打日志,它可以帮你把签名环节统一到切面里。比如你可以定义一个@SignRequired注解,标注在需要签名的 service 方法上,切面在方法执行前自动赋值报文sign字段,方法执行后再自动验签。这样业务代码里看不到任何签名逻辑,将来换加密算法时只需要改切面一处。

但要注意,AOP 切面不适合处理异步回调的验签,因为回调入口是 Spring MVC 的 Controller,建议在 Controller 层单独保留一个verifyNotify方法,不要强行用切面兜底。

5.3 测试与仿真:没有真实商户号怎么模拟网关

很多初学者卡在第一步:没有商户号、没有证书,连环境都搭不起来。这时候你可以用 WireMock 启动一个本地假网关,模拟签名和回包,让代码的签名逻辑先跑起来。

WireMock 的配置很简单,启动一个桩服务:

WireMockServer wireMockServer = new WireMockServer(options().port(8089)); wireMockServer.start(); stubFor(post(urlEqualTo("/pay/gateway")) .willReturn(okJson("{\"merchantId\":\"620000000000001\",\"orderId\":\"20250118001\",\"respCode\":\"0000\",\"sign\":\"mockSign\"}")));

然后把配置文件里的chinapay.gateway.url指向http://localhost:8089/pay/gateway,再临时生成一对测试用的 RSA 密钥,替代真实的 PFX 证书。代码层面签名验签逻辑你不用改,重点是先把「报文组装 → 请求发送 → 响应解析 → 验签入口」这条路走通。等你拿到真实商户号时,只需要替换证书和网关地址,业务代码一行都不用动。

仿真阶段还有一个好处:你可以顺手把每个接口的参数边界测一遍,比如空订单号、超长商户名、退款金额超过原单金额。这些边界在真实网关里大部分会被拒绝,但在仿真环境里你可以主动触发,提前发现字段拼装的问题。我会把这类测试固化成 JUnit 用例,让后续改代码时不至于回归。

6. 最后一个技巧:把 chinapay 的证书加载从 JKS 换成 PEM,以及我养成的验证习惯

如果你公司的安全规范要求私钥以 PEM 文件保管,而不是 PFX 双证书格式,你需要额外引入 BouncyCastle 来加载 PEM 格式的私钥。crypto 模块里预留了PemPrivateKeyLoader,核心代码是这样:

private PrivateKey loadPemPrivateKey(String pemPath, String password) throws Exception { try (PEMParser parser = new PEMParser(new FileReader(pemPath))) { Object object = parser.readObject(); if (object instanceof PEMKeyPair) { PEMKeyPair keyPair = (PEMKeyPair) object; JcaPEMKeyConverter converter = new JcaPEMKeyConverter(); return converter.getKeyPair(keyPair).getPrivate(); } if (object instanceof PKCS8EncryptedPrivateKeyInfo) { PKCS8EncryptedPrivateKeyInfo encryptedInfo = (PKCS8EncryptedPrivateKeyInfo) object; JceOpenSSLPKCS8DecryptorProviderBuilder decryptBuilder = new JceOpenSSLPKCS8DecryptorProviderBuilder().setProvider("BC"); InputDecryptorProvider decProv = decryptBuilder.build(password.toCharArray()); return new JcaPEMKeyConverter().getPrivateKey(encryptedInfo.decryptPrivateKeyInfo(decProv)); } throw new IllegalArgumentException("unsupported PEM format: " + pemPath); } }

PEM 的坑主要在密文格式。有的 PEM 文件是BEGIN RSA PRIVATE KEY,有的带BEGIN ENCRYPTED PRIVATE KEY,密码保护方式也不一样。我建议你拿到 PEM 文件后先执行openssl rsa -in merchant.pem -check -noout验证文件完整性,再决定用哪个分支解析。加载后的PrivateKey对象与 PFX 加载出来的没有区别,直接塞回ChinapaySigner即可。

讲完这个技巧,顺便说一个我踩出来的习惯。自从那次因为证书密码特殊字符在 Linux 上折腾了整整半天后,我要求自己每次接任何支付渠道,都要先写一个PaymentChannelSmokeTest,里面只做三件事:加载证书、用一个固定报文签名、再用平台公钥验签。这个测试不依赖网络,纯本地执行,任何环境变动导致证书加载失败时,一跑就能定位是文件问题还是代码问题。从那以后我每次接入 chinapay 或类似网关项目,都强制先跑一遍这个自测,再去做业务联调,至少省掉了一半的排错时间。希望帮到你。

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

返回列表