尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

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

发布时间:2026/9/29 19:14:06

资讯中心
01
ARTICLE

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 依赖 serviceservice 依赖 http 和 cryptocrypto 只依赖 core。如果你只想看签名是怎么做的直接打开 crypto 模块即可要是你只想跑通一笔查询交易demo 模块里有一个QueryDemo的 main 方法改一下配置就能跑。我一般建议你先跑 demo而不是先读全部源码。因为 demo 里已经把配置加载、签名、发送、验签这条链路串起来了你看到请求能发出去并拿到返回码说明环境和证书都没问题后面再改自己的业务逻辑会轻松很多。2.2 第一次启动配置文件里的商户号、证书路径与网关地址工程里默认的配置文件是src/main/resources/chinapay.properties内容长这样# chinapay 商户配置 chinapay.merchant.id620000000000001 chinapay.pfx.path/etc/pay/chinapay/merchant.pfx chinapay.pfx.passwordyour-password chinapay.gateway.urlhttps://gateway.chinapay.com/pay/gateway chinapay.public.cert.path/etc/pay/chinapay/pg_public.cer chinapay.charsetUTF-8 chinapay.timeout.ms15000 chinapay.sign.typeSHA1withRSA这里每一项都对应网关侧的真实要求缺一个启动时就会报配置错。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 --actionquery --order-id20250118001运行后项目会加载配置、读取证书、发起查询请求并打印响应和验签结果。看到日志里出现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否否是yyyyMMddfileType否否是如 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.passwordpass\\$word这样的双反斜杠并确认 Spring 的占位符解析没有把它吞掉。4.2 坑二报文参数顺序不一致导致签名验证不通过现象请求发到网关后返回5202签名错误但用文档里的示例数据自测签名又是正确的。原因集成时照着某个博客的示例把merchantId和orderId顺序写反了。网关拼接签名原串的顺序是merchantId|orderId|txnTime|...你比对的却是支付宝式的appIdorderId顺序。解决严格按 chinapay 接口文档最下方给的「签名原串示例」逐字比对。项目里提供了一个printSignSource的调试开关开启后会在日志里打印实际参与签名的原串-Dchinapay.debugtrue打开后你把这串原串和文档示例放在一起 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; charsetUTF-8);同时建议在 JVM 启动参数里加上-Dfile.encodingUTF-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 MapString, 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 或类似网关项目都强制先跑一遍这个自测再去做业务联调至少省掉了一半的排错时间。希望帮到你。本文还有配套的精品资源点击获取
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

◈

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

◐

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

▲

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。