资讯详情

银联支付对接实战:chinapay-java-new证书验签与掉单补偿拆解

📅 2026/10/11 2:41:39 | 华诺云谱 👁 阅读
银联支付对接实战:chinapay-java-new证书验签与掉单补偿拆解
简介一份面向 Java 开发者的 Chinapay 支付接口相关示例工程适用场景是快速理解支付接入中的 Web 页面与后端逻辑组织方式也适合初中级开发者作为模板进行二次开发。压缩包内共 72 个文件大小 5.05MB主要包含 17 个 Java 源码、17 个编译生成的 class、15 个 jar 依赖、10 个 JSP 页面以及 properties、XML、CSS 和 Eclipse 项目元信息文件能够覆盖从前台展示到服务端处理的基本链路。工程内部按 WebContent、src、test、build、classes 等目录分层JSP 与静态资源放在 Web 端源码和测试代码独立存放便于对照页面入口与后端类之间的调用关系梳理一次支付请求的完整路径。已有 431 人学习下载适合导入开发工具后结合自身业务调整尤其利于教学演示或快速搭建支付功能原型。1. chinapay-java-new别把它当成普通 HTTP 客户端项目来用做支付对接的人看到 chinapay-java-new 这个名字第一反应通常是「这不就是个封装好的 Java SDK 吗直接调接口不就行了」。但真正动手拆过这套资源的人会告诉你银联支付网关的坑从来不在「能不能调通」而在证书体系、验签时机、掉单补偿和通知幂等这些看不见的地方。这个项目解决的就是「从拿到商户号到线上稳定跑单」中间那一整段工程化问题报文怎么组、签名怎么加、异步通知怎么验、掉单怎么查、退费怎么做到不重复。适合所有用 Java 做电商、收银台、SaaS 分账系统的后端开发者尤其是第一次对接银联、对证书和签名机制不熟的人——把这份资源里的约定吃透能少走很多弯路。2. 拆包之前先看懂 chinapay 协议里的三个关键点2.1 报文不是 JSON是键值对与签名域的混合体很多从微信、支付宝转过来的人第一反应是找 chinapay-java-new 里的「JSON 工具类」结果翻遍整个项目也没找到一个标准的 JSON 请求体。这不奇怪银联支付网关至今仍以「键值对表单」作为主要报文载体所有业务字段平铺在一层 Map 里HTTP 请求体是application/x-www-form-urlencoded格式不是 JSON也不是 XML。这个设计意味着三件事第一你没法用 Postman 的 JSON 模式直接调试得用表单模式或者干脆写个测试脚本来组请求第二字段顺序虽然不影响验签但每个字段名必须和官方文档完全一致比如txnAmt是交易金额单位分写成amount网关直接拒绝第三签名域是独立的signature字段它不参与业务逻辑但对整个报文做摘要。在这个项目里我一般会先把SDKConstants这类常量类打开看一遍里面通常会列出所有必传字段的 key。实际对接时你只需要关心四组字段version/encoding/signMethod这种基础参数、txnType/txnSubType/bizType这种交易类型参数、merId/orderId/txnTime/txnAmt这种业务参数以及certId这种签名标识参数。提示如果你在项目里看到某个工具方法叫createRequestData之类的名字它往往就是整个报文组的入口。先别急着改把它调用的字段和官方接口文档逐行对照一遍能避免后面一大堆排错时间。2.2 项目里的核心类与目录定位先找到入口再写代码chinapay-java-new 这类资源包一般不会只有一个孤零零的 SDK而是把「证书加载、报文组装、HTTP 发送、响应解析、验签」拆成了几个包。我拆项目有个习惯先看pom.xml或build.gradle确认依赖版本再看包结构里有没有core、service、model、util这种分层。典型的结构大致是这样chinapay-java-new/ ├── core/ │ ├── CertManager.java # 证书加载与密钥解析 │ ├── SignatureUtil.java # 签名与验签工具 │ └── HttpClientUtil.java # 报文发送封装 ├── model/ │ ├── OrderRequest.java # 下单请求模型 │ ├── QueryRequest.java # 查询请求模型 │ └── RefundRequest.java # 退费请求模型 ├── service/ │ ├── PayService.java # 聚合支付门面 │ ├── QueryService.java # 订单查询 │ └── RefundService.java # 退费处理 └── config/ └── ChinapayProperties.java # 配置项映射这个结构很典型CertManager是核心中的核心因为后面所有签名、验签操作都绕不开它。我一般会先去读CertManager的加载逻辑它用KeyStore.getInstance(PKCS12)加载商户证书还是直接读.cer公钥文件证书密码是写在配置里还是通过环境变量注入这两点决定了你部署到生产环境时需要额外准备什么东西。SignatureUtil是第二个要重点看的类。银联的签名算法是 SHA1WithRSA也有部分接口用 SHA256WithRSA但真正决定成败的是签名源串的构成规则——它要求把所有参与签名的字段按 key 的字典序升序排列然后以keyvaluekeyvalue的形式拼接最后把signature本身排除在外。这个过程没有任何玄学但写错一个字段名、多转义一个字符验签就会失败。2.3 证书体系与密钥用途商户证书签请求银联公钥验响应这是 chinapay-java-new 里最容易让人混淆的一块。很多人以为「我有商户私钥所以响应也用商户私钥验」这是完全错误的。银联的证书体系是双向的商户用「商户私钥」对请求报文签名银联用「商户公钥」验证请求的合法性反过来银联返回的响应和异步通知是用「银联私钥」签名的商户必须用「银联公钥」来验签。一份合格的资源包.p12后缀的证书文件是商户证书里面既包含商户公钥也包含商户私钥用于签名请求另外还会有一个银联公钥文件一般是.cer或.key后缀用于验证响应和通知。如果项目压缩包里只带了.p12而没有银联公钥你大概率需要去配置目录里找acp_test_verify.cer这种格式的文件。密钥用途可以这样记发出去的报文用自己兜里的私钥签收进来的报文拿对方给的公钥验。签名是「我证明这请求是我发的」验签是「我确认这响应确实来自银联」。任何一个方向用错证书程序表现出来的症状都一样——「验签失败」这个笼统的异常后面会让人排查到怀疑人生。3. 第一次跑通交易环境配置、下单请求与验签3.1 环境与参数配置商户号、网关地址、证书路径把 chinapay-java-new 落到真实项目里第一步不是写业务代码而是把配置项理清楚。银联支付网关分为测试环境和生产环境两个环境的网关地址、证书文件都不同混用的话轻则报错重则测试环境 700 错误码满天飞。常见的做法是引入一个独立的配置文件用 Spring Boot 的ConfigurationProperties做映射chinapay: gateway: front-url: https://gateway.test.95516.com/gateway/api/frontTransReq.do back-url: https://gateway.test.95516.com/gateway/api/backTransReq.do query-url: https://gateway.test.95516.com/gateway/api/queryTrans.do merchant: id: 700000000000001 # 测试商户号 cert-path: classpath:certs/acp_test_sign.p12 # 商户证书 cert-password: 000000 # 测试证书密码通常是 6 个 0 verify-path: classpath:certs/acp_test_verify.cer # 银联验签公钥 notify: return-url: https://your-domain.com/pay/return notify-url: https://your-domain.com/pay/notify参数说明front-url是前台跳转网关用于PC网页支付这类需要用户在浏览器里完成支付的场景back-url是后台交易网关用于退款、代付这类不需要用户交互的请求。merchant.id是 15 位数字商户号测试环境有专门的测试号别拿生产的往测试环境怼。cert-path指向.p12证书文件classpath 开头的路径会让 Spring 从 resources 目录下解析部署时注意打包有没有把证书带进去。verify-path是银联验签公钥也许最常见的问题就是这里配成了商户证书导致所有响应都验签失败。配置项的边界要提前划清楚网关地址只在请求发送时被读取证书文件在系统启动时就会加载到内存里所以如果你改了证书路径必须重启应用才生效——热加载在 SSL 证书这种资源上本身就是不安全的做法。3.2 发起一笔消费交易核心代码走一遍配置就绪之后第一笔消费交易建议先写成一个独立方法不要直接嵌进业务 Service。这样你可以单独调试、单独排查不会把订单流程和支付流程的问题混在一起。public MapString, String createConsumeOrder(Order order) { MapString, String requestData new HashMap(); // 基础参数 requestData.put(version, 5.1.0); requestData.put(encoding, UTF-8); requestData.put(signMethod, 01); requestData.put(txnType, 01); // 01消费 requestData.put(txnSubType, 01); // 01普通消费 requestData.put(bizType, 000201); // 000201B2C 网关支付 requestData.put(channelType, 07); // 07互联网 // 商户与订单参数 requestData.put(merId, config.getMerchantId()); requestData.put(orderId, order.getOrderNo()); requestData.put(txnTime, order.getCreateTime()); requestData.put(txnAmt, String.valueOf(order.getAmountInFen())); requestData.put(currencyCode, 156); // 人民币 // 页面跳转与异步通知地址 requestData.put(returnUrl, config.getReturnUrl()); requestData.put(notifyUrl, config.getNotifyUrl()); // 签名并返回完整报文 requestData.put(certId, certManager.getCertId()); String signature signatureUtil.sign(requestData, certManager.getMerchantPrivateKey()); requestData.put(signature, signature); return requestData; }逻辑说明这段代码的核心不是「怎么发送请求」而是「怎么组一个能被银联接受的报文」。所有支付接口第一步都是构造这样一个Map把交易类型、商户号、订单号、金额等字段塞进去然后调用签名工具对整个 Map 做签名最后把签名值作为signature字段放回同一个 Map。这样returnUrl和notifyUrl都会被包含在签名范围内如果后续在代码里手动拼接 URL 而不是用工具类很容易导致签名与原报文不一致。参数说明version不是随意的部分老接口用5.0.0新接入统一走5.1.0txnAmt单位是「分」如果你的订单金额用 BigDecimal 保存记得multiply(100).intValue()转换直接toString()会把一个小数点带进去银联网关直接当作非法金额拒绝。orderId不能重复使用同一商户号下重复订单号会报「订单号重复」的错误这在重试场景里需要特别注意。3.3 同步响应与异步通知的验签时机发送下单请求后银联有三种返回方式同步 HTTP 响应、前台跳转 URL、后台异步通知。最容易出事的不是同步响应而是异步通知。同步响应只是告诉你「报文收到了」真正的支付结果在异步通知里。public boolean verifyNotify(MapString, String notifyData) { if (notifyData null || notifyData.isEmpty()) { return false; } String signature notifyData.remove(signature); String signMethod notifyData.get(signMethod); boolean valid signatureUtil.verify( notifyData, signature, certManager.getUnionpayPublicKey(), signMethod ); // 验签通过后还业务校验 if (valid 00.equals(notifyData.get(respCode))) { // 继续核对订单号、金额、商户号 return checkOrderConsistency(notifyData); } return false; }逻辑说明这段代码的前两行有个容易被忽略的细节——先把signature取出来并移除否则验签工具会把签名值本身也当作被签名的字段参与验签结果必然失败。验签通过之后必须再用respCode 00判断交易是否成功respCode不是 00 的通知即使是合法的也不能当作支付成功处理。注意这里用的是字符串比较不是equals会有隐蔽问题原因后面讲。异步通知的验签时机原则上只有一条必须在更新本地订单状态之前完成。先验签、再校验金额、最后更新库表。如果通知里的txnAmt和本地订单金额不一致这条通知必须丢弃并记日志这可能是一次恶意构造的请求也可能是上游系统串单了。4. 支付落地避坑回调、掉单与证书的四个经典翻车现场4.1 异步通知验签一直失败但同步响应正常现象下单请求发送成功同步响应一切正常但异步通知进来后验签工具一直返回 false。日志里只看到「验签失败」没有更详细的错误信息。原因最常见的两种情况——第一种是通知里的charset或encoding和验签时用的字符集不一致银联通知用的 GBK系统默认 UTF-8字符串在字节层面就对不上第二种是signature字段没有被排除在签名源串之外工具类把签名值自己也加进了待验签字段。解决验签前先打印原始字段列表人工确认字符集。我在项目里一般会让验签工具接收一个encoding参数从通知报文里动态读取而不是用全局配置。排除签名值这一步有的 SDK 封装好了有的没封装写完后单独写个单元测试用「原报文字符串去掉 signature 后重新拼接」再跑一遍验签。4.2 掉单了订单状态是已支付本地却是待支付现象用户实际完成了支付银行侧扣款成功但本地订单状态还停在「待支付」直到用户投诉才被发现。原因异步通知没送达。银联的通知机制是「发送失败会重新通知间隔 15 秒、15 秒、30 秒、3 分钟、10 分钟、20 分钟……」最长持续 24 小时。如果你的notifyUrl在某个时刻响应超时银联会转去下一轮重试此时如果应用恰好重启或者网络有波动通知就被丢了。解决不要依赖异步通知作为唯一的结果来源。在下单流程里设计一个「主动查单」任务比如每分钟扫描一次超时未支付订单调用查询接口主动获取最新状态。一旦查询接口返回已支付就以查询结果为准更新本地状态同时把该订单从轮询队列里移除。配合记录通知到达日志掉单率能显著降低。从那以后我每做一个支付项目都会强制要求「查单任务」和「通知处理」双轨并行任何一条路径先确认支付成功就立即终态落库。4.3 证书文件更新后全部请求报「加密错误」或「验签失败」现象运维按年轮换证书后重启应用所有支付请求直接失败有些接口报「证书加载异常」有些报「签名无效」。原因证书更新不只是替换证书文件还需要同步更新商户号绑定关系、证书 IDcertId以及银联公钥。很多项目只替换了.p12文件但代码里certManager.getCertId()还是从旧的证书对象中读取或者加载证书的方式是缓存了旧对象。解决把证书加载逻辑做成启动时检测打印证书指纹和certId与银联后台配置逐项比对。证书轮换后需要同时更新三处merchant.id如果变更、.p12证书文件、验证用的银联公钥文件。证书密码如果也变了必须通过环境变量注入而不是写在配置里。另外生产证书密码不要明文放 git用${CHINAPAY_CERT_PWD}引用环境变量。4.4 并发下单导致重复通知订单表被更新两次现象同一订单收到多条异步通知每次通知处理都「成功」结果订单状态字段被反复更新甚至出现金额累计错误。原因银联的通知重试机制在某些链路下会发送重复报文比如应用处理成功但响应超时银联认为是失败又重新通知。如果通知处理没有做幂等就可能重复更新。解决数据库层面加幂等约束——对订单通知记录建一个唯一索引字段是orderId txnTime的组合代码层面先查通知流水表存在就直接返回成功不存在再继续处理订单。不要只靠if判断订单状态并发下两个线程同时读到「待支付」都会往下走。一个实用做法是先把通知原始报文落库拿到一个自增 ID再在通知流水上做去重这样入账逻辑即使重跑也不会覆盖终态。5. 查询与退费把只读接口做成工程化能力5.1 主动查询订单状态超时任务怎么设计查询接口是掉单补偿的最后一道防线。银联的查询交易接口用txnType00可以按订单号和交易时间查询一笔订单的当前状态。设计主动查询任务时要考虑「查询频率」和「查询范围」两个边界扫描太频繁会增加无谓的网关压力扫描太稀疏用户等到超时还没被补偿体验很差。一种稳妥的做法是分层扫描刚下单一小时内每 5 分钟扫一次超过一小时后退到每 15 分钟扫一次超过 2 小时仍未支付的订单转人工或者标记为异常。Scheduled(fixedDelay 300000) public void scanTimeoutOrders() { ListOrder pendingOrders orderMapper.listPendingPaid( LocalDateTime.now().minusMinutes(5), LocalDateTime.now().minusMinutes(120) ); for (Order order : pendingOrders) { QueryResult result queryService.queryOrder( order.getOrderNo(), order.getTxnTime() ); if (00.equals(result.getRespCode()) 0000.equals(result.getQueryResult())) { // 0000查询到交易成功 orderService.markPaid(order, result); } else if (0001.equals(result.getQueryResult()) || 2000.equals(result.getQueryResult())) { // 未查到交易或未支付保持待支付 continue; } } }逻辑说明查询接口不同于下单接口它有独立的应答码。respCode00表示查询请求本身处理成功但真正的交易状态要看queryResult0000是成功0001是失败2000是未支付。很多人在这一步混淆了「接口调用成功」和「交易成功」两个概念看到respCode00就直接更新订单状态最后把失败订单也标记成已支付。参数说明listPendingPaid方法的作用是只捞「超过 5 分钟但不到 2 小时」的待支付订单避免把刚下单的订单也拉进来扫描。查询频率 5 分钟一次是较保守的设置如果商户并发高可以缩短到 2 分钟但要留意查询接口的调用量配额别把自己限流了。5.2 退费接口的幂等与金额校验退费比下单更容易踩坑因为退费涉及资金流出一旦重复执行会造成资损。银联退费接口的txnType04但真正的问题是它不完全幂等——同一笔订单多次提交退费请求第一次会成功后续可能报「原交易不存在或状态不正确」也可能处理成功生成多笔退费记录。Transactional public RefundResult refund(RefundRequest request) { // 幂等检查退费流水是否存在 RefundRecord exist refundMapper.selectByRefundNo(request.getRefundNo()); if (exist ! null) { return RefundResult.alreadyProcessed(exist); } // 校验退款金额不超过原订单可退金额 BigDecimal available orderService.getAvailableRefundAmount(request.getOrderNo()); if (request.getAmount().compareTo(available) 0) { throw new RefundAmountExceedException(退款金额超过可退余额); } // 组装退费报文 MapString, String data new HashMap(); data.put(version, 5.1.0); data.put(encoding, UTF-8); data.put(signMethod, 01); data.put(txnType, 04); data.put(txnSubType, 00); data.put(bizType, 000201); data.put(channelType, 07); data.put(merId, config.getMerchantId()); data.put(orderId, request.getOrderNo()); data.put(origQryId, request.getOrigQryId()); data.put(txnTime, request.getTxnTime()); data.put(txnAmt, String.valueOf(request.getAmountInFen())); data.put(refundNo, request.getRefundNo()); // 签名并发送退费请求 RefundResponse resp httpClient.post(config.getBackUrl(), sign(data)); // 记录退费流水 refundMapper.insert(RefundRecord.of(request, resp)); return RefundResult.of(resp); }逻辑说明这段代码里三个动作顺序不能乱——先查去重再查可退金额最后发请求。如果先发请求再查幂等两个线程同时进来就会双双绕过检查产生两笔退费。origQryId是原消费交易的查询流水号必须在支付成功后的异步通知里保存下来退费时带上它银联才能锁定原交易。refundNo必须唯一它是你这一侧生成的退费请求号通常用「原订单号 时间戳 随机数」拼接。参数说明txnSubType00是退费bizType保持不变仍然是原交易的业务类型。退费金额以「分」为单位必须小于或等于原交易金额减去已退金额。注意银联退费接口的响应不是最终结果异步通知会再次推送退费结果处理和消费通知一样先验签再落库。6. 进阶把整套逻辑封装成 Spring Boot Starter 并压测验证当多个业务线都需要对接银联支付时直接把 chinapay-java-new 放在某个业务工程里会变成隐性负债。较实用的做法是把它封装成一个独立的支付 Starter对外暴露统一的PayTemplate业务方只需要注入一个门面类不需要关心证书加载和报文签名的细节。AutoConfiguration EnableConfigurationProperties(ChinapayProperties.class) public class ChinapayAutoConfiguration { Bean ConditionalOnMissingBean public CertManager certManager(ChinapayProperties props) { return new CertManager(props.getCertPath(), props.getCertPassword()); } Bean ConditionalOnMissingBean public SignatureUtil signatureUtil(CertManager certManager, ChinapayProperties props) { return new SignatureUtil(certManager, props.getVerifyPath()); } Bean public PayTemplate payTemplate(SignatureUtil signatureUtil, ChinapayProperties props) { return new PayTemplate(signatureUtil, props); } }对应的spring.factories里要注册自动配置类这样业务工程引入依赖后只要配好chinapay.*前缀的配置项PayTemplate就能直接注入。有了这一层封装业务代码调用支付下单只需要一次方法调用签名、验签、证书管理的变更都被隔离在 Starter 内部。封装完成后别急着上线先用 Mock 数据做一轮压测验证。压测重点不是吞吐量而是「相同的订单号重复提交」「并发退费」「异步通知乱序到达」这三个场景。我会写一个测试脚本模拟 100 个并发线程同时提交同一订单号的消费请求确认仅仅只有第一笔成功其余全部被幂等拦下再模拟退费接口重复调用查看退费流水表是否只有一条记录。通过这三个场景才能真正判断封装结果是否工程化。回头总结这套 chinapay-java-new 的拆解过程我的习惯是先看证书管理再看报文组装最后写幂等测试三步缺一不可。当初第一次对接银联时我在异步通知验签上翻车最狠后来强制自己把所有支付项目都按「先验签、再查单、最后落库」的固定顺序走了一遍从此再没有出现过掉单后靠人工补单的情况。希望这些拆包过程能帮到你省下几个查证书问题的通宵。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑