简介:面向Java开发者的银联在线支付(ChinaPay)接口集成示例项目,适合需要快速接入银联支付网关、理解支付回调与页面跳转逻辑的Web开发人员。压缩包约5.05MB,共72个文件,涵盖17个Java源码与对应class文件、15个jar依赖库、10个JSP页面,以及properties、xml等配置文件,基本构成一个可直接部署运行的支付Demo工程。目录中既有src核心业务代码,也有WebContent下的前端页面与template模板,并包含test测试代码和build构建产物,结构清晰便于按模块学习。已有431人学习浏览。通过该项目可了解银联支付接口的请求参数构造、签名验证、支付结果通知处理,以及JSP页面与后端逻辑的配合方式;同时可复用其中封装好的支付工具类与配置项,减少从零排查接口文档的成本。对于刚接触第三方支付对接的开发者,这是一份不错的实战参考。
1. 第一次用 chinapay-java-new 接银联支付,我把签名和回调拆开重写了一遍
做支付后端的第一天,我被银联官方 Java Demo 上了一课:代码还是几年前的 Servlet 风格,依赖靠手动塞 lib,证书路径写死,日志打不出来。后来在团队内部交付物里看到一套按银联在线接口规范重写的新封装 chinapay-java-new,把签名、验签、下单、退款、主动查询这些动作拆成了可以单独测试的 Java 类,才真正把支付模块从黑匣子变成看得见的代码。这篇文章我按自己的落地顺序写:先讲银联收单链路里 Java 要负责哪几件事,再给出一套能跑通的最小配置和核心代码,最后把我在测试环境和生产环境踩过的五个坑原样记录下来。适合正在用 Java 接银联通道、或者准备把老 SDK 换掉的团队。
2. 银联支付链路里 Java 负责什么:四个阶段先拆清楚再写业务
支付网关对接最大的障碍,是很多人把“下单接口”当成支付模块的全部。你只调一个接口当然能支付成功,但线上会出问题的恰恰是回调、退款和对账这些侧面。我习惯把链路拆成四个阶段,每个阶段对应一组 Java 类型,也让后来的同事知道出了问题去哪个类里找。
2.1 下单、回调、退款、对账四个阶段,拆成四组职责
第一个阶段是下单参数组装与签名。商户号、订单号、交易时间、交易金额、订单描述、前台跳转地址和后台通知地址,这些字段被封装成请求参数对象。组装完成后,参数按 key 做字典序排序,拼成key=value&key=value这样的待签名字符串,再用商户私钥做 RSA 签名,最后把签名值作为独立参数一起提交给网关。这个阶段容易出现的误解是把签名当成加密,以为签过名参数就保密了;实际签名的目的是防篡改,参数本身在报文里是明文,传输安全靠网关侧的 HTTPS 保证。
第二个阶段是支付跳转和等待回调。网关验签通过后会展示收银台,用户完成支付后,银联网关会返回两个结果:一个同步的前端跳转,让浏览器回到 frontUrl;一个异步的后台通知。后台通知会发送到 notifyUrl。很多项目只处理了前端跳转,用户付完款浏览器直接关掉,前端跳转根本发生不了,系统就永远停留在待支付。所以支付模块稳定性的第一原则是:以异步通知为准,前端跳转只是一个用户体验辅助。
第三个阶段是异步通知处理。这里包含两件必须做对的事:验签和幂等。通知到达后,要先把除 sign 外的参数按同样规则拼成待签名字符串,用银联公钥验签,验签失败的一律拒绝;验签通过后,按订单号把订单从待支付状态改成已支付。注意同样一笔通知可能被银联重发,也可能因为负载均衡被分到不同节点,因此状态更新必须是原子的,常见的做法是update orders set status = 'PAID' where order_id = ? and status = 'WAIT_PAY',行数影响为 0 就不算重复入账。这层 Java 数据一致性设计,直接决定了支付系统会不会出现重复扣款这种事故。
第四个阶段是对账、退款和主动查询。银联的异步通知是尽力而为,网络抖动、应用重启、回调处理抛异常都会丢通知。所以支付模块里要放一个定时任务,扫描超过一定时间仍处于待支付的订单,调用订单查询接口主动确认结果;对已经成功的订单,如果用户申请退款,要用原交易的查询流水号而不是商户订单号;每天还要拉银联的对账文件,和本地订单表逐笔比对。很多团队在上线初期不看对账文件,等到月末财务对账时才发现少了一笔钱,那就是血泪教训。
如果把这四个阶段映射到代码,我一般会分成四组类型:OrderParamBuilder 负责组装参数和过滤空值;SignatureService 负责签名和验签的算法细节;GatewayClient 负责与网关的 HTTP 交互,包括超时和重试;NotifyController 负责接收回调、验签和幂等更新。这套 new 封装里,最让我放心的一点就是参数组装和签名算法分离,证书和密钥靠配置注入,而不是封死在工具类静态方法里。
2.2 新版封装和老 SDK 的选型:三个硬边界加一个兜底策略
换掉老 SDK 这件事,我一开始也犹豫过,毕竟官方有现成的 jar。但老 SDK 的维护状态和工程质量摆在那里:依赖老,有的还把 commons-httpclient 带进依赖树,跟 Spring Boot 3 这种换了 Jakarta 命名空间的项目直接冲突;证书加载方式不透明,测试环境切换证书要改 classpath;异常处理要么吞掉要么直接抛给业务层,根本没法链路追踪;日志不结构化,线上查一次支付失败得靠肉眼翻字符串。新项目直接搬老 SDK,往往上线第一个月就会开始返工。
我判断要不要换新封装,会先看三个硬边界。第一是 Java 版本和依赖树:老 SDK 如果只支持 Java 6/7,连编译都过不了,新封装至少要能在 Java 8 和 Java 17 上平滑运行,依赖里尽量只有 HTTP 客户端和日志门面。第二是签名算法的可配置性:支付网关一旦升级签名算法,硬编码的签名类就是事故源头;新封装应该把签名实现抽象成接口,RSA、SHA256withRSA 这些算法可以通过配置切换。第三是回调幂等支持:老 SDK 大多只给解析工具,不背幂等责任;新封装会在回调处理链里内置去重逻辑,或者至少提供一套清晰的扩展点,让业务方把去重写进标准流程。
除了这三个硬边界,我还要看源码是不是能改、能单测。支付通道的接入文档更新很频繁,老 SDK 停更后,新增字段往往要自己拼报文,这时候封装里如果全是私有静态方法,改起来就痛苦。反而是一个结构清晰、只依赖基础类库的封装,出了问题能直接定位到签名拼接那一行。
还有一条容易被忽略的兜底策略:不要把支付 SDK 当黑盒。拿到新封装的第一件事,不是跑 Demo,而是先把它的签名拼接规则和回调验签逻辑读一遍。ChinaPay 这类网关的坑大多藏在签名规范和字符集里,这两块读透了,后面写业务代码会顺手很多。这也回答了一个常见问题:老 SDK 和自研封装之间,其实还有一条中间路线,就是在老 SDK 外面套一层代理,把超时、日志、幂等补上,这样至少能争取到迁移的缓冲时间。
2.3 证书与密钥管理:Java 侧最容易失守的边界
银联商户证书通常是一张 PFX(PKCS#12)格式的证书文件,里面存着商户私钥;验签用的银联公钥或者公钥证书是另外下发的。Java 侧要把 PFX 加载成 KeyStore,再通过 KeyStore 拿到 PrivateKey 做签名。这里最容易翻车的是两件事:一是把测试证书和生产证书放错位置,二是证书到期没有预警。
证书加载的位置,我一般会避免放进 classpath,更不要提交到 Git 仓库。常见做法是把证书路径配置在 application.yml 里,或者挂载到部署目录,由运维单独管理。new 封装里如果提供了证书热加载机制,那更好,证书轮换时不用重启应用。另外一个我吃过亏的细节是:证书密码不要跟前端配置共用一套,也不要用支付网关管理后台的登录密码,单独设置并放到配置中心。
证书到期是个缓慢发生的故障,但后果非常剧烈。私钥过期后,签名请求会被网关直接拒绝,而且日志往往只显示“验签失败”或者“签名错误”,不会直接告诉你证书过期。我的习惯是在配置里加一个证书有效期检查,应用启动时读取证书的 notAfter 时间,提前三十天输出告警日志。这个检查代码非常简单,却在生产环境帮我躲过了一次通道长时间不能用的事故。
3. 用 chinapay-java-new 在本地跑通第一笔下单:三步走
3.1 环境与配置:Java 环境变量、PFX 证书、网关地址三件套
先说明一点,接入银联支付必须先有商户号和证书,这个过程是在银联商户门户里完成的,拿到的东西包括:商户号、PFX 证书文件、证书密码、网关测试地址和正式地址、银联验签公钥。下面的示例配置里网关地址只是占位符,以开通资料里的实际地址为准。
环境准备这一块,老生常谈但必须写,因为很多翻车现场就是 Java 环境变量没配好,SDK 起不来。以 Linux 服务器为例,我一般把 Java 环境变量写进/etc/profile.d/java.sh,内容很简单:
# JDK 11 环境变量配置,按实际安装路径调整 JAVA_HOME cat > /etc/profile.d/java.sh <<'EOF' export JAVA_HOME=/usr/lib/jvm/java-11-openjdk-amd64 export PATH=$JAVA_HOME/bin:$PATH EOF source /etc/profile.d/java.sh java -version这段脚本做了三件事:设置 JAVA_HOME、把 Java 的 bin 目录加进 PATH、让java -version能直接执行。这里要强调的是,source只对当前会话生效,如果你用的是 systemd 启动 Spring Boot 应用,还要在 systemd unit 文件里也补上 Environment 变量,否则应用起来后还是找不到 Java。这个坑我给过一个判断标准:服务启动日志里看到java: command not found,多半就是 daemon 环境没有加载 profile。
接下来是配置文件。以 Spring Boot 的 application.yml 为例,我会把银联相关配置单独归一个前缀,避免和业务配置混在一起:
chinapay: merchant-id: 808000000000001 sign-type: RSA cert: path: /app/ssl/merchant-test.pfx password: change-me type: PKCS12 gateway: pay-url: https://pay-gateway.test.example.com/acquire query-url: https://pay-gateway.test.example.com/query refund-url: https://pay-gateway.test.example.com/refund connect-timeout-ms: 3000 read-timeout-ms: 10000 callback: notify-url: https://api.example.com/pay/notify front-url: https://www.example.com/pay/result提示:网关地址、商户号、证书文件的准确值,一律以银联开通邮件和商户门户下载到的资料为准,不要把示例里的占位符照抄进生产配置。
这些参数对应的含义是:merchant-id 是开通商户号,签名类型指定用 RSA,证书路径指向 PFX 文件,type 固定是 PKCS12。connect-timeout-ms 是建立连接的超时,read-timeout-ms 是等待网关响应的超时。银联网关在支付高峰期偶尔会慢,read-timeout 设到 10 秒以上比较稳,太短会误判失败,太长又会让线程池被慢请求占满。回调地址 notify-url 必须是公网可以访问的 HTTPS 地址,否则异步通知发不进来。
3.2 下单的核心流程:组装参数、签名、提交网关
用一个典型的新封装,我会把下单逻辑封装成 OrderPaymentService 方法,代码分三步:组装参数、签名、发送网关。下面这段就是简化的核心,注释里标出了银联规范和最容易错的地方:
@Component public class OrderPaymentService { private final ChinapayConfig config; private final SignatureService signatureService; private final GatewayClient gatewayClient; public String createOrder(String orderId, long amountInCents) { // 1. 组装下单参数。LinkedHashMap 只是保证插入顺序,签名时还会再排序 Map<String, String> params = new LinkedHashMap<>(); params.put("merId", config.getMerchantId()); params.put("orderId", orderId); params.put("txnTime", LocalDateTime.now() .format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss"))); params.put("txnAmt", String.valueOf(amountInCents)); params.put("orderDesc", "product order " + orderId); params.put("notifyUrl", config.getCallback().getNotifyUrl()); params.put("frontUrl", config.getCallback().getFrontUrl()); // 2. 待签名字符串:按 key 字典序排序,用 & 连接键值对 String signContent = params.entrySet().stream() .sorted(Map.Entry.comparingByKey()) .map(e -> e.getKey() + "=" + e.getValue()) .collect(Collectors.joining("&")); params.put("sign", signatureService.sign(signContent)); // 3. 通过网关客户端提交表单,返回内容里通常包含支付跳转地址 String response = gatewayClient.postForm(config.getGateway().getPayUrl(), params); return parsePayUrl(response); } }这段代码的逻辑是:第一步构造下单请求参数,金额单位必须是分,我用 long 类型避免浮点误差;交易时间格式是yyyyMMddHHmmss这种紧凑时间戳,这是银联网关定义的格式,换成年-月-日格式会直接解析失败。第二步做签名,顺序是先把参数按 key 字典序升序排序,再把每个键值对拼成key=value,用&连接。这一步跟很多支付平台的规则几乎一致,但有一个容易忽略的点:值里出现了特殊字符时不建议先做 URL 编码再签名,按银联文档要求的原文拼接,否则回调验签也会因为编码后的差异而失败。
第三步把参数以表单方式提交到网关地址,返回内容通常是一个页面或者一段 JSON,里面有一个用于跳转的支付地址。注意 HTTP 客户端要单独设置连接超时和读超时,这个超时不要复用业务接口的默认值。发送失败时要区分是网络层失败还是网关业务失败,网络层失败可以做一次重试,但重试时订单号必须保持同一个,防止重复下单。
3.3 回调验签和幂等更新:这一段是支付模块的命门
异步通知接口是整个支付模块里最容易出事故的一段。我见过太多项目把验签这一步省掉,直接信任通知里的支付结果,结果被伪造通知刷单。正确的处理顺序是先验签再改单。下面是一个典型的 controller 实现:
@RestController public class PayNotifyController { @PostMapping("/pay/notify") public String handleNotify(HttpServletRequest request) throws IOException { // 1. 从原始输入流解析表单参数,不依赖 request.getParameter() Map<String, String> params = FormBodyParser.parse(request.getInputStream()); // 2. 验签:除 sign 字段外,其它字段按同样规则拼接 String sign = params.remove("sign"); String content = params.entrySet().stream() .sorted(Map.Entry.comparingByKey()) .map(e -> e.getKey() + "=" + e.getValue()) .collect(Collectors.joining("&")); if (!signatureService.verify(content, sign)) { log.warn("银联回调验签失败, orderId={}", params.get("orderId")); return "fail"; } // 3. 幂等更新:只有待支付状态能改成已支付,行数为 0 说明已被处理 boolean success = orderService.markPaidIfWaiting( params.get("orderId"), Long.parseLong(params.get("txnAmt"))); return success ? "success" : "fail"; } }这段代码的关键有两点。第一,解析参数必须从request.getInputStream()自己读,因为容器可能已经按默认字符集消费过 body,或者 Filter 里已经读了一次,二次解析拿到的字节流就会是空或者乱码;如果你用request.getParameter()去取参,回调的 Content-Type 一旦不是严格带charset=UTF-8的格式,编码问题就会从这里钻进来。第二,验签成功后返回给网关的应答字符串,银联逻辑是收到success就不再重发,收到其他字符串会继续重试。所以这里不能提前返回 success,必须先落库成功再回 success,否则服务重启后同一通知还会再来。
幂等更新的 SQL 可以用UPDATE orders SET status = 'PAID', pay_time = NOW() WHERE order_id = ? AND status = 'WAIT_PAY',影响行数 1 表示这次通知是第一次处理,0 表示之前已经处理过。这是我在支付模块里最依赖的一条规则:状态机比应用层锁要可靠得多。
4. 中国银联 Java 对接避坑清单:五个高频现场与排查顺序
这一节是这篇文章里最实用的部分。以下五条现象都是我在这个对接方向里真实遇到过的,每一条按“现象、原因、解决”三行来写,方便你出了问题直接对照。
4.1 现场一:下单请求被拒,网关返回签名校验失败
现象:用 chinapay-java-new 发起下单,网关返回类似sign check fail的提示,连银联收银台页面都到不了,下单日志里只有一串参数和一个非成功状态码。
原因:签名串拼接规则不对,这是概率最高的一条。银联要求待签名字符串按 key 字典序升序排列,空值参数不参与拼接,签名字段本身也不能参与拼接。我在代码评审里见过的典型错误,是从别的支付平台迁移过来的同事保留了 URL 编码再拼接的旧习惯,导致网关用自己验签逻辑还原出来的字符串和服务端不一致。第二个常见原因是测试证书与生产证书混用,签名用的私钥和网关后台配置的公钥不是同一对,也会报验签失败。
解决:先把待签名字符串完整打印出来,和银联文档提供的示例报文逐字符对比;再用银联提供的验签工具对同一组参数做校验,判断问题出在拼接规则还是证书本身。如果确认是证书问题,核对当前代码加载的 PFX 是不是与环境匹配。这套排查顺序能覆盖掉九成以上的首次验签失败,我建议把它写进团队内部的操作手册。
4.2 现场二:回调报文中文乱码,订单描述变成问号
现象:异步通知接口收到的订单描述字段变成???或乱码,严重时连带验签都过不了,订单一直被判定为支付失败。
原因:编码不一致。银联网关的回调报文在不同历史时期使用过不同的字符集,一些老接口的默认编码是 GBK 或 ISO-8859-1,而新封装默认按 UTF-8 解析,解析出来的字节流自然就是乱码。另一个隐蔽来源是 Spring Boot 的 CharacterEncodingFilter 只处理响应的默认编码,如果请求的 Content-Type 没有带charset=UTF-8,容器会按平台默认编码解析参数,导致进入业务代码之前就被错误解码。乱码一旦发生,验签拼接时用的值和网关签名时用的值就不是同一份字节,验签必然失败。
解决:对客户端,先在配置里统一server.servlet.encoding.charset=UTF-8和force=true;对网关回调,不要用request.getParameter(),而是解析原始输入流并手动指定 UTF-8 解码。如果确认网关约定是 GBK,就在解析处先按 GBK 解码再把字符串转成 UTF-8,保证验签和解码用的是同一编码。我的习惯是新封装里所有字符解码都显式指定 Charset,不依赖环境默认值。
4.3 现场三:退款时提示交易不存在或原交易不可退
现象:用户申请退款,后端拿着商户订单号去调退款接口,网关返回“交易不存在”或者“原交易不可退”。
原因:银联的退款接口要求传原交易流水号(queryId),而不是商户订单号。下单成功或者异步通知到达时,业务系统如果没有把 queryId 保存到订单流水表,退款时就没有凭证。另外,退款金额与原交易金额不一致,或者退款的次数频率超出限制,也会被网关拒绝。
解决:在支付通知或主动查询返回结果时,把 queryId、原交易金额、清算币种这几个字段落到订单流水表;退款前先按订单号主动查询一次原交易,确保拿到正确的 queryId 之后再调退款接口。退款接口也要先做本地验签,并对同一笔退款请求做幂等控制,避免用户多点几次退款按钮就产生多笔退款单。这个坑的代价是资金和用户体验,比前面几个更值得提前设计。
4.4 现场四:通知丢了,用户扣款成功但订单一直停在待支付
现象:用户已经付款成功,系统里订单状态还是待支付,用户投诉电话进来,查不到任何失败日志。
原因:异步通知不保证一定送达,这是所有支付网关的共性。网络闪断、回调接口阻塞超时、应用恰好重启,都会让通知丢失。项目如果只有回调一条路更新订单状态,就会漏单。
解决:增加主动对账兜底。我在项目中用 Spring 的@Scheduled定时任务做一个扫描器,每 10 分钟拉取支付中且超过 15 分钟的订单,逐个调订单查询接口确认状态;命中已支付就按幂等更新方式补单,命中失败就触发自动退款或转人工。这个定时任务框架里要特别注意竞态:扫描任务和异步通知可能同时到达,所以补单也必须走和回调相同的update ... where status='WAIT_PAY'幂等逻辑。每天再拉一次对账文件做全量核对,双重兜底之后,漏单就属于极小概率事件了。
4.5 现场五:多节点部署时重复回调把订单更新两遍
现象:同一笔支付的通知在集群两个节点上同时被处理,订单余额被重复入账,或者订单状态被第二次更新成退款时状态,账目对不上。
原因:应用做了负载均衡,网关的异步通知被分发到不同节点,两边同时验签成功同时执行更新,应用内的本地锁失效,最终两条线程都执行了状态更新。
解决:不要依赖应用层锁,改用数据库唯一约束或乐观锁。最简洁的方案就是在状态更新 SQL 里加上前置状态条件,UPDATE orders SET status='PAID' WHERE order_id=? AND status='WAIT_PAY',数据库行锁会保证只有一条线程更新成功;如果确认并发量高有性能顾虑,可以在订单支付流水表建唯一索引,插入流水时抢唯一约束。这个做法和我前面讲的数据一致性原则是同一套:支付模块的状态迁移必须靠数据库保证,而不是靠 synchronized 或 Redis 分布式锁的完美运行。
以上五条现象,按 4.1 到 4.5 的顺序就是我的排查路径:先确认签名串没拼错,再确认回调报文没有乱码,接着检查退款是不是缺了 queryId,最后回头看主动对账和幂等有没有兜底。签名串比对这一步走踏实了,后面四个场景至少能少一半。
5. 中国银联 Java 模块的验证技巧:把签名自检做成启动检查
上线前最后一道保障,我会把签名验签的自检做到应用启动流程里。具体做法是写一个启动检查器,应用上下文刷新完成后,用固定的测试参数做一次 sign,再用同一组参数做 verify。测试环境里如果证书路径配置错、密码不对、签名字符串拼接有误,应用启动就会快速失败,而不是等到商户真实请求打进网关才报错。这个习惯帮我过滤掉了大量和证书相关的低级故障。
第二个技巧是回调接口的回归测试。支付网关的真实回调无法在单元测试里触发,所以我会用 MockMvc 构造一个与银联报文结构一致的表单请求,把待签名字符串生成正确签名后发给本地接口,验证第一次返回 success、第二次返回 fail。这样幂等逻辑在每次版本迭代时都能被守住:
@Test void notify_should_be_idempotent() throws Exception { Map<String, String> params = buildNotifyParams("123456"); params.put("sign", signatureService.sign(buildSignContent(params))); mockMvc.perform(post("/pay/notify") .contentType(MediaType.APPLICATION_FORM_URLENCODED) .content(encode(params))) .andExpect(status().isOk()) .andExpect(content().string("success")); mockMvc.perform(post("/pay/notify") .contentType(MediaType.APPLICATION_FORM_URLENCODED) .content(encode(params))) .andExpect(content().string("fail")); }这段测试用同一个签名后的参数组连续请求两次,第一次走完整验签和状态更新,第二次因为订单状态已经不再是待支付,幂等更新影响行数为 0,接口返回 fail。它能同时保住验签和幂等两条底线。
第三个技巧是证书到期预警。我会在配置里读 PFX 证书的 notAfter,启动时打印距离到期天数,并在提前三十天时输出告警日志。证书到期是一个缓慢失效的过程,但一旦失效,支付通道会突然不可用,事后恢复又必须走证书重新下发的流程,所以我在生产上吃过一次亏之后就把它固化成了启动检查。
我现在的习惯是每个月在测试环境把这三条验证过一遍,再跑一次对账脚本,确保定时任务和幂等逻辑没有被后来上线的功能改坏。支付模块最怕的不是业务复杂度,而是“没出事的时候不知道怎么坏,出了事又不知道从哪查”。这一套把签名自检、回调回归、证书预警落到代码里的思路,希望帮到你。
本文还有配套的精品资源,点击获取