尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

农行网银支付Java接口对接:证书签名与回调验签实战

农行网银支付Java接口对接:证书签名与回调验签实战 简介面向农行网银支付网页端对接的 Java 接口资源包内含一百四十七个文件、压缩包约五点一兆字节适合需要在电商平台或在线服务系统中集成农行支付能力的后端开发者。包内以编译后的接口类、动态页面示例与依赖库为主体另配有多份配置文件、数字证书及信任库覆盖支付请求构造、数字签名、发送请求、响应解析和证书校验等环节整体文件组织便于直接引入项目使用。示例程序集中演示了交易请求、签名验证、银行响应处理、异常处理与回调通知等关键流程能够帮助开发者快速定位商户配置、签名服务、数据验签等模块的用法减少查阅资料与联调排错的时间。已有两千余人学习下载适合具备 Java Web 基础、希望借助现成接口封装快速完成农行网银支付集成的开发者。1. 农行web端网银支付Java接口拿到文件和demo后先别急着写业务代码对接农行web端网银支付Java工程师最常见的第一道工序是收到一个压缩包里面一份接口规范PDF、一个证书文件、一个JSP年代的demo工程。这套东西看起来陈旧却是生产环境里唯一能信任的对接依据。农行web端网银支付Java接口走的是标准HTTPS加报文签名核心难点不在银行页面的交互而在证书初始化、签名串拼接和回调验签这三件事。文章按实际对接顺序写从解压文件包到跑通一笔支付再到处理异步通知给出可抄的代码片段和参数表适合第一次接银行支付的Java工程师也适合被老代码折磨到想重写对接的熟手。读完你至少能回答三个问题demo里该用哪部分、下单请求怎么发、回调怎么验才安全。2. 解压出的文件怎么用接口包结构、支付链路和证书初始化2.1 农行接口文件包的结构PDF、demo和证书各司其职拿到手的包通常会按这样的目录组织doc目录放接口规范PDFdemo目录是一个可直接扔进Tomcat的web项目conf目录里是商户号、终端号、证书路径的参数文件根目录还有一个PFX格式的商户证书。先别急着把demo导入IDE跑起来先把接口规范PDF里“交易流程”“字段说明”“签名机制”三章读完整。这个动作决定你后面是顺风顺水还是反复返工。demo工程大多是JSP加Servlet的结构页面做得很全有下单页、回调展示页、查询页、退款页。但它的定位是“能演示”不是“能上线”。里面会存在证书密码写死在常量类、签名工具类与业务逻辑耦合、异常分支没处理等情况。它可以作为报文组织和签名算法的参考实现但不要指望原封不动部署到生产环境。常见做法是把它当作一座矿抽取它构造请求、组织签名串、发起HTTP请求的工具方法然后按自身业务重写成Spring Boot服务。另外需要留意的是不同时期拿到的接口包版本不一样有的包同时包含B2C和B2B两套文档有的包把测试证书和生产证书放在不同子目录。目录名可以变但核心的东西不变一份接口规范、一个可运行的demo、一个用于签名的商户证书。把这三样认清楚后面所有代码都是围绕它们展开。2.2 支付链路从商户下单到农行收银台的报文流转农行web端网银支付的链路和绝大多数银行网关一致。商户后台先校验库存并锁定订单然后拼装支付参数用商户私钥做签名把请求POST到农行网关地址。农行网关校验签名通过后返回一个收银台HTML页面浏览器跳转到农行的网银页面让用户完成支付。支付完成后农行会同时发起两条通知一条是浏览器跳转到商户的ReturnUrl同步通知另一条是服务器直连商户的NotifyUrl异步通知。同步通知和异步通知都可能延迟、重复甚至丢失所以订单状态必须以异步通知为准。这条链路里有几个容易被低估的点。返回给你的收银台是HTML不是JSON所以对接代码的准备工作从一开始就不是“解析JSON响应”而是“正确渲染并转发一个表单页面”。第二点是几乎所有银行接口都要求商户端先做证书签名农行也一样意味着你的服务必须能在启动时加载商户私钥而不是每次请求临时读文件。第三点网关地址、证书路径、证书密码这些配置不能在代码里写死要抽出来放到环境配置里否则从测试环境切生产环境时到处找密码会让人崩溃。另外测试环境通常配的是一个独立的测试网关和测试证书返回的收银台页面也会明确标注“测试环境”字样。这个环节最容易犯的错误是用测试账号去请求生产网关结果当然是证书验签失败。建议把网关地址做成一个配置项测试和生产走同一套代码只是配置不同。2.3 证书转换把PFX转成JKS并确认别名没有踩空农行给的商户证书常常是PKCS12格式也就是.pfx文件。JSSE是支持PKCS12的但实际使用中不少老版本JDK、部分Java EE容器和工具链对PFX支持得并不好。常见做法是把它转成JKS格式这样后续无论是Spring Boot配置server.ssl还是自定义KeyStore对象都要省心得多。keytool -importkeystore \ -srckeystore merchant.pfx \ -srcstoretype PKCS12 \ -srcstorepass 证书初始密码 \ -destkeystore merchant.jks \ -deststoretype JKS \ -deststorepass 你自己设置的jks密码参数说明-srckeystore是农行发来的PFX文件-srcstorepass是商户证书的初始密码这个密码通常写在银行发来的函件或邮件里不是你自己设的-deststorepass是转换后JKS的密码建议设置成与源密码不同并将它存到配置中心或环境变量。转换完成后用下面这条命令确认证书确实被正确导入keytool -list -v -keystore merchant.jks -storepass 你的jks密码执行后能看到证书的owner、签发者、有效期和别名。这里有个常见的坑有些PFX文件里不止一张证书可能包含私钥证书和CA证书keytool导入后会出现多个别名。Java里加载时如果直接取第一个别名可能拿到的是CA证书而不是带私钥的商户证书后续签名就会报“Private key not found”。所以代码里取别名时一定要用isKeyEntry()判断是否含有私钥再取真正的私钥条目。证书初始化这件事别指望一次成功。我遇到过测试环境一切正常、切生产时启动失败的情况最后发现是生产证书的文件名和测试证书不一样代码里按测试证书名硬编码读取路径导致文件找不到。因此证书路径、库密码、别名都要作为配置项处理启动时做一次自检打印出证书的有效期和商户号有问题早暴露。3. 从demo到能跑的Spring Boot下单服务3.1 把demo里的签名逻辑抽成独立工具类demo工程里通常会有一个支付工具类或者签名工具类它的核心工作就是两件事加载商户私钥、对参数串做RSA签名。不要把这个工具类搬进业务代码里就完事要把它整理成一个无状态的组件只接收参数不依赖Servlet容器。public class AbcPaySigner { private final PrivateKey privateKey; public AbcPaySigner(String pfxPath, String pfxPassword) throws Exception { KeyStore ks KeyStore.getInstance(PKCS12); try (InputStream in new FileInputStream(pfxPath)) { ks.load(in, pfxPassword.toCharArray()); } String alias null; EnumerationString aliases ks.aliases(); while (aliases.hasMoreElements()) { String a aliases.nextElement(); if (ks.isKeyEntry(a)) { alias a; break; } } if (alias null) { throw new IllegalStateException(证书库中不存在私钥条目); } this.privateKey (PrivateKey) ks.getKey(alias, pfxPassword.toCharArray()); } public String sign(MapString, String params) throws Exception { String plain buildPlainText(params); Signature signature Signature.getInstance(SHA1withRSA); signature.initSign(privateKey); signature.update(plain.getBytes(GBK)); return Base64.getEncoder().encodeToString(signature.sign()); } private String buildPlainText(MapString, String params) { return params.entrySet().stream() .filter(e - e.getValue() ! null !e.getValue().isEmpty()) .map(e - e.getKey() e.getValue()) .collect(Collectors.joining()); } }逻辑说明构造函数加载PKCS12证书库遍历别名找到带私钥的条目避免误用CA证书。sign方法先按“keyvaluekeyvalue”拼出待签名串再用私钥做SHA1withRSA签名最后Base64编码。这段代码的核心是buildPlainText的过滤逻辑值为空的参数不能进签名串否则验签时银行端拼出来的串和你这边不一致结果就是验签失败。参数说明算法名SHA1withRSA是农行接口文档里最常见的签名算法但也有极少数版本要求MD5withRSA以你手上文档为准。编码GBK这里很关键商户号、订单号这类纯ASCII字符不受影响但商品名含中文时编码不一致会直接导致签名对不上。3.2 下单请求的参数清单是抄作业的第一步把签名工具准备好之后下一步是组装下单请求。农行的web支付下单一般需要下面这些字段具体名称以文档为准但基本绕不开这些。建一个TreeMap来组装天然按字典序排序能省掉很多签名串排序的麻烦。public String buildOrderRequest(PayOrder order, String gatewayUrl) throws Exception { MapString, String params new TreeMap(); params.put(MerchantID, merchantConfig.getMerchantId()); params.put(TerminalID, merchantConfig.getTerminalId()); params.put(OrderNo, order.getOrderNo()); params.put(OrderDate, order.getOrderDate()); params.put(OrderAmount, String.valueOf(order.getAmountFen())); params.put(OrderCurrency, CNY); params.put(ReturnUrl, merchantConfig.getReturnUrl()); params.put(NotifyUrl, merchantConfig.getNotifyUrl()); params.put(ProductName, order.getProductName()); String sign paySigner.sign(params); params.put(Signature, sign); return postForm(gatewayUrl, params); }逻辑说明MerchantID是农行分配给商户的编号TerminalID是终端号这两个值只有开通支付功能后才会拿到。OrderAmount必须换算成“分”农行的金额单位不是元直接用元提交会被拒绝常见错误是把金额做成Double再传精度丢失的后果更严重。ProductName是商品描述长度有上限超过限制会报字段超长建议在业务层做一次截断。参数说明ReturnUrl和NotifyUrl必须是外网可访问的地址。测试阶段可以用内网穿透工具临时暴露本地服务但生产环境必须使用HTTPS域名。农行对这两个地址的校验比较严格如果地址不可达支付成功后订单状态就一直是“已支付待通知”这是很多联调事故的根源。postForm方法实际是一个用HttpURLConnection实现表单提交的工具关键点是提交参数的编码格式要和签名时保持一致private String postForm(String gatewayUrl, MapString, String params) throws IOException { String body params.entrySet().stream() .map(e - e.getKey() URLEncoder.encode(e.getValue(), GBK)) .collect(Collectors.joining()); HttpURLConnection conn (HttpURLConnection) new URL(gatewayUrl).openConnection(); conn.setRequestMethod(POST); conn.setDoOutput(true); conn.setConnectTimeout(5000); conn.setReadTimeout(15000); conn.getOutputStream().write(body.getBytes(GBK)); try (BufferedReader reader new BufferedReader( new InputStreamReader(conn.getInputStream(), GBK))) { return reader.lines().collect(Collectors.joining(\n)); } }逻辑说明这里返回的内容是农行收银台的HTML页面不是JSON。得到这段HTML之后业务系统通常有两种处理方式一种是直接把HTML作为响应返回给前端浏览器让浏览器自动执行表单跳转另一种是解析HTML中的表单字段由前端拼接后再跳转。第一种更简单也更符合农行的设计意图推荐用第一种。注意URLEncoder.encode这一步如果参数里已经包含了转账字符二次编码反而会让银行解析出错所以这里只对value做编码不对整体串重复编码。3.3 金额转换和中文编码是两处最容易翻车的地雷金额单位的坑几乎所有接银行支付的人都踩过。下单时金额传100代表1元传100.00在某些版本里会被拒绝最稳妥的做法是在服务端用BigDecimal计算并转成整数分避免在前端用浮点数做任何运算。public static String toFen(BigDecimal amount) { return amount.multiply(BigDecimal.valueOf(100)) .setScale(0, RoundingMode.HALF_UP) .toBigInteger() .toString(); }说明入参金额单位是元返回的是字符串格式的分。HALF_UP表示四舍五入银行侧计费基本都这么做不要用BigDecimal默认的舍入方式。接着是中文编码问题农行的报文字段在HTTP层普遍使用GBK这不同于一般互联网接口的UTF-8。如果业务数据库和接口层的编码不一致需要在组装报文的边界做一次显式转换。最简单的做法是在读取和输出流的地方统一指定GBK而不是依赖JVM默认字符集否则JVM跑在Linux上默认UTF-8签名和解析就会对不上。4. 回调处理支付结果的黑匣子要用验签和幂等打开4.1 同步通知和异步通知的职责不能混农行web网银支付的回调有两套很多新手只处理了同步的ReturnUrl发现支付成功后订单状态没更新就开始排查前面的下单流程方向从一开始就错了。同步通知是浏览器重定向用户看到“支付成功”页面之后浏览器才发起这次请求如果用户支付成功后立刻关闭浏览器或者支付过程中断网同步通知根本不会到达。异步通知是农行服务器主动发起的请求不依赖浏览器这才是订单状态更新的主通道。两个通知的到达顺序也没有保证。有时候异步先到有时候同步先到偶尔还会出现重复。处理时遵循一个原则同步通知只做展示不做状态更新异步通知做状态更新但必须配合幂等处理。同步通知里如果查询到订单已经支付成功就展示成功页否则展示“处理中”让用户等一会儿再刷新。GetMapping(/pay/return) public String returnPage(String OrderNo) { PayOrder order orderService.getByOrderNo(OrderNo); if (order ! null PAID.equals(order.getStatus())) { return success; } return processing; }逻辑说明ReturnUrl只做查询展示不直接依赖回调参数里的支付结果字段因为同步回调里的参数理论上可以被伪造。页面上的结果要以自己数据库的状态为准数据库状态则来源于异步通知的更新。4.2 验签逻辑先把签名摘出来再按原规则重拼异步通知到达时农行会POST一批参数到NotifyUrl其中有一个Signature字段。验签流程是先把Signature从参数里取出来剩下的参数去掉空值按同样的规则拼成待验签字符串然后用农行提供的公钥证书做验签。注意顺序问题不同接口版本对参数排序的要求不同有的是按字典序有的是按文档指定的字段顺序一律以文档为准。public boolean verifyNotify(MapString, String notifyParams, String bankPublicKeyPath) throws Exception { String sign notifyParams.remove(Signature); if (sign null || sign.isEmpty()) { return false; } String plain notifyParams.entrySet().stream() .filter(e - e.getValue() ! null !e.getValue().isEmpty()) .sorted(Map.Entry.comparingByKey()) .map(e - e.getKey() e.getValue()) .collect(Collectors.joining()); CertificateFactory cf CertificateFactory.getInstance(X.509); try (InputStream in new FileInputStream(bankPublicKeyPath)) { X509Certificate cert (X509Certificate) cf.generateCertificate(in); Signature verifier Signature.getInstance(SHA1withRSA); verifier.initVerify(cert.getPublicKey()); verifier.update(plain.getBytes(GBK)); return verifier.verify(Base64.getDecoder().decode(sign)); } }逻辑说明第一步从参数里移除Signature因为它本身不参与签名串。第二步按公钥证书里给定的规则重拼待验签串。这段代码没有复用下单时的buildPlainText因为两个环节的参数集合不一样但拼接规则必须一致。验签用的公钥证书是农行在商户开通时随接口资料一起提供的不是商户自己的PFX私钥证书很多人在这一步用错证书导致验签永远失败。参数说明公钥证书文件建议也做成配置项并且测试环境和生产环境各放一份。测试环境的公钥证书和生产的不一样混用会得到干净的false。验签失败时优先打印出拼接后的字符串和收到的Signature与银行方的测试报文逐字对比不要直接怀疑算法。4.3 幂等更新重复通知不是Bug是银行接口的常态异步通知可能因为网络超时、银行侧重试而重复投递。农行一般会隔一段时间重发直到收到明确成功响应。如果业务侧不做幂等就会出现一笔订单被入账两次、库存被扣两次的严重事故。幂等的实现可以简单直接在订单表上对order_no建唯一索引状态更新时带上旧状态条件。Transactional public PayNotifyResult handleNotify(PayNotifyReq req) { String orderNo req.getOrderNo(); PayOrder order orderService.getByOrderNo(orderNo); if (order null) { return PayNotifyResult.fail(订单不存在请重新通知); } if (PAID.equals(order.getStatus())) { // 已处理过的重复通知直接返回成功不再重复入账 return PayNotifyResult.success(); } if (!verifyNotify(req.getParams(), bankPublicKeyPath)) { return PayNotifyResult.fail(验签失败); } int updated orderService.markPaid(orderNo, req.getAmountFen(), req.getBankSerialNo()); if (updated 0) { return PayNotifyResult.fail(并发更新冲突请重新通知); } // 发订单支付成功事件触发后续发货流程 eventPublisher.publish(new OrderPaidEvent(orderNo)); return PayNotifyResult.success(); }逻辑说明markPaid内部执行的是带条件的更新语句例如UPDATE pay_order SET statusPAID, pay_timenow(), bank_serial#serialNo WHERE order_no#orderNo AND statusUNPAID。更新影响行数为0说明此前已经处理过直接返回成功。之所以先判断PAID再验签是为了让重复通知尽快结束避免每次重复通知都做一次验签计算。但从安全角度第一次收到通知时必须先验签业务校验放后面。返回给银行的结果也不是随便写的。农行通过响应内容判断是否需要重发通常约定返回明确的成功标识。处理失败时不要返回成功否则银行以为你收到了就不再重发最终会造成订单已扣款但商户侧未知的后果。5. 农行网银支付Java接口避坑五类翻车现场都是真金白银换来的5.1 现象签名服务启动时报“keystore password was incorrect”原因这个报错信息很有迷惑性。多数情况下不是密码输入错误而是把“证书库密码”和“私钥密码”搞混了。PFX格式的证书库密码和私钥密码通常相同但转成JKS之后可以分别设置如果你用JKS的storepass去读private key就会报password was incorrect。还有一种情况是密码本身带了不可见字符从邮件或函件复制时把换行符也复制进去了肉眼看不出来。解决先用keytool -list -v -keystore merchant.jks -storepass 密码单独验证storepass再用Java代码单独读取private key把两个环节拆开排查。读取私钥时设置keyPassword参数不要默认复用storepass。如果是从邮件复制的密码建议先粘贴到十六进制编辑器或者od -c查看一下末尾有没有\r或\n。5.2 现象下单成功但收银台页面显示的商品名是乱码原因农行的收银台页面是按GBK编码解析参数的业务系统在组装请求时如果用了UTF-8编码或者Java服务运行在默认UTF-8环境下ProductName这个字段就会变成乱码签名也很大概率验不过。乱码属于最表层的表现真正的问题是编码链路从参数拼装到HTTP输出没有统一。常见做法是下单接口从Controller层就强制指定produces和consumes的字符集并且在HTTP客户端里固定GBK。解决把所有跟农行交互的边界统一用GBK。组装参数时统一用getBytes(GBK)接收返回值时统一用new InputStreamReader(conn.getInputStream(), GBK)不要依赖JVM默认字符集。如果业务库本身是UTF-8就在放入Map之前把ProductName做一次new String(name.getBytes(UTF-8), GBK)转换保证进入报文的是GBK字节。5.3 现象支付成功了订单却一直处于待支付状态原因最常见的是只接了同步通知没接异步通知。用户支付完成后浏览器确实跳回了同步页但如果用户中途关闭浏览器、切后台、或者银行侧异步通知发到生产环境的外网地址失败订单状态就不会更新。另一个常见原因是异步通知地址配置成了内网地址测试服务器在局域网内生产环境部署在云服务器上NotifyUrl填的是内网IP银行服务器根本访问不到。解决先确认NotifyUrl是否从外网可访问直接在浏览器里打开这个地址看返回信息。再确认同步和异步两套回调都部署上了并且异步通知的逻辑不依赖HTTP Session因为银行服务器发请求时不会有你的Session。最后在订单表中增加一个notify_count字段每次收到通知加一排查时一眼能看出银行重发了多少次、你的服务实际收到了多少次。5.4 现象验签总是不通过代码怎么改都不生效原因这是典型的“签名串拼接规则不一致”问题。下单和验签虽然都是拼keyvaluekeyvalue但参与字段的范围可能不同。有些版本的验签要求把所有参数都参与包括空值参数有些要求过滤掉空值还有的要求先按文档写的字段顺序排而不是字典序。只要有一点不一致验签结果就是false而且这个错不会给你任何提示性日志。公共证书用错也常发生用商户私钥证书去验银行发来的签名结果必然是失败而且报错信息看起来像“算法不匹配”让人误以为是加密算法问题。解决用接口文档里的报文示例做离线验证。把文档里的请求参数和签名结果抄下来用自己写的工具类重新拼一遍逐个比对拼接串的字节。把过滤空值的规则、排序规则、签名字段是否参与这三件事用注释写进代码里以免后面接手的人改坏。验签失败时把收到的原始参数和拼接结果打到日志跟文档示例横向对比。5.5 现象异步通知重复到达订单被重复入账和重复发货原因银行网关在网络抖动、响应超时后会按策略重发通知这是接口设计的正常行为不是bug。业务系统在第一次处理时如果因为某个异常没有返回成功标识银行会继续重发如果第一次处理已成功但响应在网络上丢失银行同样会重发。处理端没有幂等保护第二次通知来了就会再次走一遍入账逻辑。解决订单状态更新必须用条件更新同时为银行交易流水号bank_serial_no建唯一索引。另外在发货逻辑入口再查一次订单状态状态不是PAID就不触发双保险。处理成功但想减少重复通知的流量可以在响应体里明确返回成功标识并保证响应内容足够短避免网络分片导致响应丢失。6. 联调期的最后一关把黑匣子变成白盒的验证技巧6.1 先验证证书和网关连通性再写业务代码证书能不能用、网关通不通这两件事不该等到业务代码写完再验证。常见做法是先手工签一个最简单的报文直接打到农行的查询接口看返回报文是成功还是证书错误。openssl pkcs12 -in merchant.pfx -clcerts -nokeys -out cert.pem openssl pkcs12 -in merchant.pfx -nocerts -nodes -out key.pem执行后得到cert.pem和key.pem再用curl携带证书发起一次GET请求curl --cert cert.pem --key key.pem --cacert ca.pem https://网关地址/查询类接口说明把PFX拆成PEM格式是为了让curl能直接使用--cacert指向农行给的服务端证书链。这样能在不写Java代码的情况下确认证书链是否完整、网关是否能访问。如果这一关都过不了后面所有Java代码都是白写。6.2 用一份验收清单收口联调联调结束前按下面这份清单走一遍能覆盖大部分生产事故场景。第一用0.01元测试下单确认返回HTML页面正常页面能跳到农行测试收银台。第二支付成功后同步通知返回成功页异步通知更新订单状态且两端数据一致。第三手动重发异步通知订单不会重复入账。第四中文商品名和商户订单号在银行侧显示正常。第五关闭NotifyUrl断点模拟服务不可用恢复后确认银行重发通知并成功处理。前四项是联调常规动作第五项很多人忽略但它恰恰验证重发机制的可靠性真正上线后帮你挡住一大半丢单事故。6.3 让日志成为排障的第一现场联调阶段在银行交互的每个边界打印日志请求参数、签名前字符串、签名后值、响应HTML长度、回调原始参数、验签结果、订单更新影响行数。日志格式统一成keyvalue风格方便grep。银行侧报“无此订单”时多半是测试商户号和正式商户号混用报“验签失败”时日志里拼接串就是和银行掰扯的最有力证据。整套流程跑通之后把网关地址、证书路径、商户号和终端号整理成环境配置说明这是你留给下一个接手人最实用的东西。我个人习惯在配置类里留一段启动自检代码启动时打印证书有效期和商户号发现过期能提前预警而不是等到支付失败才回头查。希望这套流程能帮你少走几趟弯路对接支付顺利上线。本文还有配套的精品资源点击获取
返回列表