简介:面向需要接入中国农业银行缴费中心的Java开发人员,这份BRIDGE新版商户直连DEMO(V1.4)提供了完整的支付对接示例,解决商户系统与农行缴费中心之间订单处理、支付确认、退款、回调通知等接口联调问题。压缩包共133个文件,以57个Java源码、44个JSP页面为主,辅以接口调用所需的JAR依赖、XML配置、JS脚本、properties配置及CER/PFX安全证书文件,整体大小6.92MB,目录结构清晰,便于按模块查阅与复用。已有566人学习下载。Java文件覆盖核心业务逻辑与API调用,JSP呈现商户后台交互页面,XML与properties定义环境参数,证书文件用于联调环境的安全认证;配套的V1.4接口文档则详细说明URL、请求参数、响应格式与错误码,帮助开发者快速理解农行BRIDGE新版商户接入流程。通过学习和运行DEMO,可直接复用支付请求、结果验签、异常退款等关键代码,缩短真实业务系统与农行缴费中心的对接开发周期。
1. 中国农业银行缴费中心 BRIDGE 商户直连:JAVA 版 V1.4 DEMO 到底能帮你省哪些事
中国农业银行缴费中心的 BRIDGE 商户直连 DEMO(JAVA 版本,V1.4)应该是不少接缴费业务的团队最先拿到的参考工程,它配合那套接口文档,解决的是把自家业务系统对接到农行缴费中心这件事。无论是水电燃气、学费党费还是非税缴费,走商户直连都意味着用户不用跳出你的 App 或公众号去农行页面付款,支付结果由你的后端直接接收。这个 DEMO 把证书装载、报文组装、签名验签、HTTP 调用和回调解析串成了一条可运行的链路。但它不是拿来就能跑的玩具工程,我见过太多同事栽在证书格式和签名串上。这篇笔记给准备动手的人讲讲怎么把它读懂、跑通、避坑。
2. BRIDGE 直连与缴费接口模型:先看懂链路再碰代码
2.1 BRIDGE 在农行缴费体系里是什么角色
BRIDGE 这个名字在农行缴费中心的技术体系里指的是商户接入的桥接网关。农行内部的缴费主机、核心系统、分行业务系统之间链路很复杂,对商户来说不可能直接暴露内网接口,所以 BRIDGE 作为统一出入口,接收商户系统发来的 HTTPS 请求,转发给缴费中心,再把处理结果同步返回。
链路大致是:商户后台服务器 → HTTPS 加密通道 → BRIDGE 网关 → 农行缴费中心主机 → 返回同步应答。异步环节则是缴费完成后由缴费中心主动向商户的回调地址推送结果通知。所以对接时你实际要处理两种流量:一种是主动查询和缴费下单的请求-响应,另一种是被动接收的支付结果回调。
| 接入模式 | 是否需要证书 | 用户体验 | 开发工作量 | 适用场景 |
|---|---|---|---|---|
| 商户直连(BRIDGE 直连) | 需要,双向认证 | 支付全程在商户渠道内完成 | 较大,需处理签名、加密、回调 | 自有 App、公众号、小程序、PC 官网 |
| 跳转农行缴费页面 | 一般不需要 | 用户跳出商户渠道 | 较小,几乎零开发 | 无技术团队的轻量接入,或低频缴费 |
直连模式的核心价值在于渠道可控和体验可控,这也是为什么很多商户宁可多花两周开发时间也要接直连。而 BRIDGE 网关屏蔽了农行内部接口差异,商户只需要跟 BRIDGE 定义的报文格式打交道,不必关心农行主机侧的细节。这个 DEMO 就是围绕这套报文格式给出的 Java 参考实现。
2.2 DEMO V1.4 的工程结构和需要先读的两份文档
拿到 DEMO 压缩包后,第一件事不是急着在 IDE 里打开跑,而是先看目录结构。常见做法是里面会有 src 目录存 Java 源码、resources 目录存配置文件和证书样例、doc 目录存接口文档。V1.4 这批文档相比旧版,通常会把接口规范和 DEMO 使用说明拆得更细,内容也更多,乱翻很容易迷失。我给新人的建议是按下面这个顺序读。
| 文档或交付物 | 主要用途 | 建议阅读顺序 |
|---|---|---|
| DEMO 使用说明或 README | 讲工程怎么导入、配置怎么改、跑通最小流程 | 第 1 个读,20 分钟建立全局观 |
| 商户直连接口规范 V1.4 | 报文结构、字段定义、签名规则、错误码 | 第 2 个读,配合代码对照 |
| 证书与密钥说明 | 商户私钥、银行公钥的格式和加载方式 | 第 3 个读,否则证书坑够你踩一天 |
| 版本变更说明或升级记录 | 本次 V1.4 相比旧版的字段和接口变化 | 老项目迁移时才需要重点看 |
读文档的时候不要从头到尾啃,要先找到三样东西:报文结构图或字段表、签名流程说明、一个完整的请求报文样例。DEMO 代码是围绕这些文档实现的,你拿着样例报文去代码里找对应的字段组装,会很快建立感觉。
这个工程的 Java 部分通常是一个 Maven 工程,依赖农行提供的 SDK 工具包和常见 HTTP、JSON 库。IDE 导入后直接运行主函数往往会失败,因为配置文件里的商户号和证书路径是示例值,需要替换成农行给你的测试商户资料。V1.4 相比旧版常见差异是字段增补和报文样例更新,具体以包内变更说明为准,但证书加载和签名验签的核心逻辑一般不会大变。
3. 用 DEMO 跑通第一笔缴费请求:证书装载与测试环境配置
3.1 配置文件里要动的四个关键项
DEMO 工程的 resources 目录下基本都会有一个配置文件,可能是 properties 也可能是 yml,作用都是把和运行环境相关的参数抽出来。你要改的核心就是四样东西:商户号、BRIDGE 地址、证书路径、回调地址。下面这份配置是我按常见工程结构还原出来的模板,字段名可能和你的包内有差异,但含义一致。
merchant: id: "123456789012" # 农行分配的 12 位商户号,测试环境和生产环境不一样 name: "测试商户" bridge: url: "https://{环境域名}/brd/gateway.do" # 测试环境地址,由农行对接人员提供 connect-timeout-ms: 5000 # 连接超时,默认 5 秒够用,跨境或弱网可放宽到 10 秒 read-timeout-ms: 15000 # 读取超时,缴费下单接口一般不会超过 15 秒 security: pfx-path: "classpath:cert/merchant_test.pfx" # 商户私钥证书,PKCS12 格式 pfx-password: "${PFX_PASS}" # 证书密码,别硬编码到代码里,走环境变量 public-key-path: "classpath:cert/abc_public.cer" # 农行公钥证书,用于验签 sign-type: "SHA256withRSA" # 签名算法,以文档说明为准 callback: url: "https://{商户域名}/pay/callback/abc" # 农行异步通知的接收地址这里有个血泪经验:证书密码千万不要明文写在 yml 里提交到 Git 仓库。因为配了测试证书密码,一不小心就跟着代码一起进了版本库,后面生产证书密码如果复用,等于把生产通道的钥匙交出去了。我一般用环境变量注入,如上文的${PFX_PASS},部署时在服务器的环境变量里配置,既避免泄露也方便多环境切换。
改完配置别急着跑。先确认一件事:农行给你的测试证书是 PFX 还是 JKS 格式,公钥是 CER 文件还是 Base64 字符串。V1.4 的 DEMO 如果兼容多种格式,加载代码里一般会有一个 KeyLoader 之类的工具类,根据扩展名自动选择加载方式。如果你的证书格式和 DEMO 默认不一致,优先改配置文件而不是改代码。
3.2 从 Client 类跟到请求发送-验签-解析的完整链路
配置就位后,从 DEMO 的主入口或单元测试进入,你会看到一个封装好的 Client 或 Service 类,里面按顺序完成了五件事:组装报文、生成签名、发送请求、验签、解析应答。不要把这五步拆散,因为农行侧验签时要求收到的字段必须与签名时完全一致,你在哪一步多塞了个空字段或少带了字段,后面全是验签失败。
下面是一段按常见实现整理的伪码,结构参考了这类直连 DEMO 的标准写法。
// 1. 组装业务参数,用 TreeMap 保证 key 按 ASCII 升序排列 TreeMap<String, String> params = new TreeMap<>(); params.put("trxId", generateTrxId()); // 商户流水号,必须唯一 params.put("merId", config.getMerchantId()); params.put("orderAmt", fenToString(amount)); // 金额以“分”为单位转字符串 params.put("payType", "01"); // 缴费类型,用前端传值 params.put("billNo", bill.getBillNo()); // 账单号 params.put("callbackUrl", config.getCallbackUrl()); // 2. 用商户私钥对待签串签名,签名结果放回参数里 String signSrc = SignUtil.buildSignSrc(params); // 按“k=v&k=v”拼接,剔除空值 byte[] signBytes = SignUtil.sign(signSrc.getBytes(StandardCharsets.UTF_8), privateKey); params.put("sign", Base64.getEncoder().encodeToString(signBytes)); // 3. HTTPS 发送到 BRIDGE 网关 String respBody = HttpClientUtil.postForm(config.getBridgeUrl(), params); // 4. 解析响应,先验签再取业务数据 TreeMap<String, String> respMap = parseForm(respBody); boolean ok = SignUtil.verify( SignUtil.buildSignSrc(respMap), // 注意去掉响应里的 sign 字段再拼 respMap.get("sign"), publicKey); if (!ok) { throw new VerifyException("农行响应验签失败"); }这段代码里几个关键设计值得留意。第一,用TreeMap而不是HashMap,因为它天然按 key 做字典序排列,省得自己写比较器,签名串的排序规则通常就要求 ASCII 升序。第二,金额转成“分”字符串而不是用 double,避免浮点精度把一笔 0.1 元的订单变成 0.10000000001 元。第三,验签时要从响应 Map 里先把sign字段摘掉再拼接,否则等于带着签名去验签名。
参数层面的超时设置也在这里体现。connect-timeout管的是 TCP 连接建立,read-timeout管的是发完请求后等响应的时间。农行缴费中心偶尔会因为批处理任务变慢,把 read-timeout 设到 15 秒以上能减少误报失败的次数,但也别设太长,否则你的线程池容易被慢接口占满。失败重试要配在业务层,而不是 HTTP 层。
跑通流程后,你会收到同步应答,里面包含处理结果码、银行流水号和缴费状态。但千万别把同步应答当作最终结果,缴费类接口常常是异步确认的,最终状态要等回调通知或主动查询接口来确认。这就是下一章要说的签名验签细节,也是直连接入里最容易出问题的环节。
4. 签名验签与数据加解密:RSA 参数怎么设才不会跑不通
4.1 签名算法的选择与密钥格式:PFX、CER、Base64 之间是什么关系
农行缴费中心这类银企直连接口,签名算法常见的是 RSA 系,具体签名算法名在 DEMO 配置里写成SHA256withRSA,即 SHA-256 做摘要、RSA 做签名,密钥长度一般要求 2048 位。商户用自己的私钥对请求报文签名,农行用商户公钥验签;反过来,农行用自己的私钥对响应和通知签名,商户用农行公钥验签。全程是单向签名,不是双向加密。
这里有个常见的概念混淆,值得展开讲。PFX 文件里装的是商户私钥和商户证书,加载时要用 PKCS12 的 KeyStore 类型;CER 文件是农行公钥证书,用来验农行发来的内容。你签名需要用 PFX 里的私钥,你验签需要用 CER 里的公钥,两者不能搞混。我见过有人拿着商户的 CER 去验农行的通知,结果自然是消息认证码不匹配。
// 加载 PFX 中的商户私钥,这是签名用的钥匙 KeyStore keyStore = KeyStore.getInstance("PKCS12"); try (InputStream in = new FileInputStream(pfxFile)) { keyStore.load(in, password.toCharArray()); } String alias = keyStore.aliases().nextElement(); // 证书别名一般在证书里可见 PrivateKey privateKey = (PrivateKey) keyStore.getKey(alias, password.toCharArray()); // 加载农行公钥证书,这是验签用的钥匙 CertificateFactory cf = CertificateFactory.getInstance("X.509"); X509Certificate bankCert; try (InputStream in = new FileInputStream(cerFile)) { bankCert = (X509Certificate) cf.generateCertificate(in); } PublicKey bankPublicKey = bankCert.getPublicKey();代码里值得注意的点有两个。第一,KeyStore.getInstance("PKCS12")不要随手写成"JKS",那是 JDK 默认的老格式,用 JKS 加载 PFX 会直接抛异常或得到空别名。第二,从 KeyStore 取 key 时要传密码,这个密码和登录 KeyStore 的密码通常一致,但也不绝对,个别分行发的测试证书有过别名密码和整体密码不同的情况,真遇到报错keystore password was incorrect时先查这一点。
部分 V1.4 的包会附带国密分支,支持 SM2/SM3 的签名算法,算法名形如SM3withSM2,依赖里会多一个 BouncyCastle 的 provider。如果你的对接分行要求国密,DEMO 里一般会有单独的配置开关,把sign-type切过去即可。国密不是每家分行都启用,先看文档里有没有《国密算法说明》,没有就老老实实走 RSA,别自己加戏。
签名算法的参数里还有一项容易被忽略:字符集。农行侧对报文用的编码是 UTF-8,你组装待签串时用getBytes("UTF-8"),不要依赖平台默认字符集。代码里我会显式写StandardCharsets.UTF_8,而不是省事写getBytes(),否则在 Windows 本机是 GBK,服务器上是 UTF-8,同一段代码签名结果不同,验签必挂。
4.2 签名串的组装顺序和验签失败的“玄学”:90% 的问题出在待签串
我接触过不少接直连的团队,反馈验签失败时第一反应都是“密钥不对”“算法不对”,实际上九成案例的根因在待签串。待签串的组装规则是农行接口规范里写得最严格的部分,常见要求是:字段按 key 的 ASCII 码升序排列、值为空或 null 的字段不参与签名、拼接时用key=value并用&分隔、结尾不带&。
public static String buildSignSrc(Map<String, String> params) { // 去掉 sign 本身和所有空值,TreeMap 自动升序 TreeMap<String, String> sorted = new TreeMap<>(); for (Map.Entry<String, String> e : params.entrySet()) { if (e.getKey() == null || "sign".equals(e.getKey())) { continue; } String v = e.getValue(); if (v != null && !v.isEmpty()) { sorted.put(e.getKey(), v); } } StringBuilder sb = new StringBuilder(); for (Map.Entry<String, String> e : sorted.entrySet()) { sb.append(e.getKey()).append("=").append(e.getValue()).append("&"); } // 去掉末尾多出来的那个 &,这是最常见的翻车点 if (sb.length() > 0) { sb.deleteCharAt(sb.length() - 1); } return sb.toString(); } }这段代码里有一个隐藏问题:如果某个 value 本身含有&或=字符,拼接出来的待签串会被拆分后重新组装,导致农行侧还原不出来。解决办法是在文档允许的前提下对 value 做 URL 编码再拼接,且必须对 key 和 value 同时采用同一套编码规则。但注意,这不是绝对标准,农行各分行接口对编码处理并不完全一致,有的要求不编码直接拼,以你拿到的接口规范为准。代码里我会把这种处理做成常量开关,而不是写死在拼接逻辑里。
验签失败的排查路径,我会按照下面的顺序走一遍。第一步,把待签串原样打印出来,逐字符核对是否多了空格、换行或制表符;第二步,确认签名时用的密钥是商户私钥、验签时用的密钥是银行公钥;第三步,确认签名结果经过了 Base64 编码后再放入报文,有些 DEMO 返回的是 hex 字符串,格式错了验签必然失败。这三步走完,九成问题能定位。剩下的玄学问题,多半是复制报文时从 PDF 里带进了不可见字符,或 IDE 自动给文件加了 BOM 头。
至于报文体本身的加解密,这类直连接口里常见做法是业务字段明文加 RSA 整体签名,部分敏感字段如手机号、身份证号会在业务层做 AES 加密后放入报文。V1.4 的 DEMO 里如果带了加密工具类,八成是把 AES 密钥放在配置里,和 PFX 同级管理。这个密钥属于对称密钥,泄露比证书泄露后果更直接,建议走配置中心或云上密钥管理,不要和代码一起打包。
5. 常见问题排查:BRIDGE 直连 DEMO 调试里最容易翻车的 5 个点
5.1 证书加载阶段的三个坑:格式、别名、密码各说一次
现象 1:运行 DEMO 主程序,控制台直接抛java.io.IOException: keystore password was incorrect,但密码确认过是对的。
原因:PFX 文件的 KeyStore 密码和私钥条目密码不一致。多数证书工具导出 PFX 时会让你设置两级密码,第一级保护整个 KeyStore,第二级保护私钥条目,两个密码可以不同。DEMO 代码里通常只提供一个密码字段,用它 load 了 KeyStore 之后,再用同一个密码去 getKey 就会失败。
解决:先用 KeyStore Explorer 之类工具查看 PFX 的私钥条目密码是否与 KeyStore 登录密码一致。不一致时,把两个密码分别配置到 yml 的两个字段里,一个叫pfx-password,一个叫key-password,DEMO 若没支持,就在 KeyLoader 里小改一下。
现象 2:KeyStore 加载成功,但签名时抛出InvalidKeyException: IOException: ObjectIdentifier[] -- Invalid key。
原因:商户私钥可能不是 RSA 密钥,而是 EC 或 SM2 密钥,但你签名算法仍设成了 SHA256withRSA。V1.4 的包如果支持多种证书格式,pom 里会引入多个加密 provider,加载代码要按密钥类型选择算法。
解决:用工具查看 PFX 里的私钥算法类型。如果是 EC,签名算法改成SHA256withECDSA或文档指定的算法;如果是 SM2,用SM3withSM2并确认 BouncyCastle provider 已注册。
现象 3:自测时验签通过,连到农行测试环境后全部验签失败,连错误码都一样。
原因:测试环境和生产环境的商户号、证书是两套,DEMO 默认配置指向生产证书,但 BRIDGE 地址却改成了测试地址。农行测试网关持有的商户公钥和你本地私钥不匹配。
解决:核对三件套:商户号、证书对、BRIDGE 地址必须来自同一个环境。最稳的做法是在配置文件名上加环境后缀,application-test.yml和application-prod.yml彻底分开,避免手工改来改去改漏一个字段。
5.2 签名与回调阶段的两个坑:待签串里藏了看不见的字符
现象 4:代码逻辑和文档完全一致,但农行返回9999验签失败,把打印出来的待签串贴到文档示例里对比,肉眼看不到任何差异。
原因:肉眼看不见的字符在作怪,常见三种来源。一是从 PDF 接口文档里复制样例时带入了换行符\n或回车符\r;二是 IDE 自动给属性文件加了 UTF-8 BOM 头,BOM 字符\uFEFF混进了第一行配置的 value 里;三是 Windows 下getBytes()用了 GBK,中文字段名转出来的字节序列和 UTF-8 完全不同。
解决:把待签串用 Base64 编码后打印,转成可见字符串再检查开头和结尾。BOM 问题用十六进制查看器确认配置文件首字节是否为EF BB BF,是的话用file命令转成无 BOM 格式。字符集问题把代码里所有getBytes()都改成getBytes(StandardCharsets.UTF_8)。
现象 5:回调通知能收到,但验签失败,排查发现通知里的签名是用农行私钥生成的,你用商户公钥去验了。
原因:回调通知的签名方向是农行 → 商户,必须用农行公钥验签,而我们日常调试签名时用的是商户私钥签、农行公钥验。方向搞反的典型特征是:自己拼的请求验签通过,农行主动推的通知验签必挂。
解决:在代码里把验签入口拆成两个方法,一个叫verifyBankResponse,用配置里的农行公钥;一个叫verifyCallback,同样用农行公钥,但实现不同——回调通知里可能还带一个签名原文字段,是农行把报文按自己的规则拼好的,直接用那个字段拼接验签,不要再自己组装。另外把回调地址配到农行测试系统时,注意内外网地址映射,农行从外网访问不到你在本机的 localhost,得用内网穿透或部署到测试服务器上收通知,这不算技术难点,但很容易到联调当天才发现。
回调还有个幂等问题,农行通知机制大概率会重发,商户系统处理时要按trxId或银行流水号做去重,否则同一笔缴费用户被扣了两次确认。把去重表建好,用唯一索引兜底,这是生产上线前必须做的事。
6. 联调验证与生产上线的两个实用技巧:多留一分日志,少跑一夜对账
6.1 把 DEMO 的日志切到 debug 级,让签名串和验签结果直接可见
很多 DEMO 默认日志级别是 info,只打印请求返回码,签名串这种关键中间量全被藏掉了。联调阶段第一步就是改日志级别,把所有涉及签名验签的包切到 debug。
<logger name="com.yourcompany.pay.abc" level="DEBUG"/> <logger name="com.abchina.sdk" level="DEBUG"/>切到 debug 之后,每次请求至少能看到三行关键日志:组装好的待签串、签名后的 Base64 值、农行响应的待验串和验签结果。这三行日志留着,出问题时有后悔药可吃。我一般会在生产环境保留这个级别的日志但按天滚动,保留七天,占不了多少磁盘,换来的排查能力非常值。
6.2 上线前留一个按日对账的兜底任务
直连缴费最怕的不是接口报错,而是静默掉单——用户扣了钱,你的系统没收到通知。回调会重发,但重发也有间隔,极端情况网络故障超过重试窗口,单子就丢了。
所以在生产上线前,我会基于 DEMO 里的账单查询接口或交易流水查询接口做每日对账。每天凌晨拉取农行侧前一日全部缴费流水,和本地订单表逐笔比对,金额一致且状态一致的归档,有差异的进人工处理表。
// 定时任务示意,每天 01:30 执行 @Scheduled(cron = "0 30 1 * * ?") public void dailyReconcile() { List<BankBill> bankBills = demoClient.queryYesterdayBills(); for (BankBill bill : bankBills) { Order order = orderMapper.selectByTrxId(bill.getTrxId()); if (order == null || !order.getAmount().equals(bill.getAmount())) { reconcileMapper.insertProblem(bill); // 有差异,进人工池 } } }这个任务代码量不大,但能把最后的风险兜住。我第一次接这类直连项目时,就是漏了回调重试窗口这个问题,上线第二天就有三笔订单用户扣款成功而系统显示未支付,客服电话被打爆,后来补了对账任务才踏实。日志留足、对账兜底,这两件事比任何加密算法都更能保证生产安全。希望帮到你。
本文还有配套的精品资源,点击获取