Java支付网关对接:签名验签、证书加载与幂等回调实践
简介chinapay-java-new 是一份面向 Java Web 开发者的银联支付ChinaPay接口集成示例工程适合正在对接网关支付、需要参考请求报文组装与签名验签流程的初中级开发者用来解决支付通道接入时参数拼装、密钥配置和回调处理缺少完整样例的问题。压缩包共 72 个文件、约 5.05MB以 17 个 java 源码和 17 个编译后的 class 为主配合 15 个 jar 依赖、10 个 jsp 页面以及 properties、xml 配置和 Eclipse 工程元数据形成可直接导入 IDE 的 Web 项目结构源码层、页面层与依赖层划分清晰便于按模块定位逻辑。目前已有 432 人学习下载在支付对接场景中具备一定参考价值。读者可从 src 下的 chinapay 包入手结合 res 资源目录与 test 测试代码理解商户端下单、签名、返回验签的完整链路也可借助 build/classes 与 jar 包核对运行时依赖减少环境搭建阶段的试错成本适合作为银联支付接入时的对照样例与排错参考。1. 支付网关对接为什么总卡在签名这份 Java 包能省掉的活有个做电商后端的朋友下单接口本地跑得顺风顺水一上测试环境就返回「签名错误」两天没查出来最后发现对方网关要求签名原文用 GBK 编码而他一直拿 UTF-8 拼串。Java 支付网关对接这套东西难点从来不在业务逻辑而在报文拼接、证书加载、签名验签、结果解析、异步通知这五个环节里各自藏着的隐性约定文档通常只给一张字段表剩下的靠自己试。这份 Java 对接包工程名paygate-javanew分支对应的是重构后的版本做的事就是把上述约定固化成可直接调用的类签名器、证书加载器、报文构造器、交易客户端、对账解析器。适合正要接支付渠道的后端也适合接手了一坨祖传支付代码、想理清调用链的人。2. 报文与签名把「拼串 签名」拆成四步分别验证支付对接翻车最集中的地方就是签名。签名不过时很多人第一反应是换算法、换密钥、重启服务这种玄学操作十次有九次是白费。正确姿势是把「拼串 → 取私钥 → 签名 → 发送」拆成四个可以单独验证的步骤每一步都能打印中间结果去和网关侧对。这份包的PayForm/CertLoader/RsaSigner三个类就是按这个思路切的互相之间没有隐藏依赖你可以只拿其中一个类进自己的工程不必整套引入。2.1 报文组装排序规则、空值过滤、编码时机绝大多数支付网关的签名原文遵循同一套规则按参数名升序排列用keyvalue形式以连接排除sign字段本身排除空值。听起来简单三个细节能让人卡半天。第一升序是字节序还是字典序。TreeMapString, String默认走String.compareTo也就是按 UTF-16 code unit 比较对纯 ASCII 参数名来说等价于字典序但如果你自定义了忽略大小写的比较器orderId和OrderId的先后就变了签名原文跟着变。不要动这个比较器。第二空值的判定标准。有的网关要求null不拼、空串要拼有的要求两者都不拼。判断依据只能靠对方提供的签名示例拿一组含空字段的样例参数手工算出原文和你代码输出的原文逐字符比对。第三编码做在哪一步。签名原文必须用未经 URL 编码的原始值URL 编码只发生在构造 HTTP 请求体的时候而且只对 value 编码key 不编码。把编码提前做进签名原文是最常见的错误来源之一。public final class PayForm { private final TreeMapString, String params new TreeMap(); /** 入参统一走这里null 与空串一律不入包避免原文里多出 key */ public PayForm put(String key, String value) { if (key ! null value ! null !value.isEmpty()) { params.put(key, value); } return this; } /** 签名原文字典序升序、 连接、跳过 sign 字段value 保持原始形态 */ public String toSignSource() { StringBuilder sb new StringBuilder(256); for (Map.EntryString, String e : params.entrySet()) { if (sign.equals(e.getKey())) { continue; } if (sb.length() 0) { sb.append(); } sb.append(e.getKey()).append().append(e.getValue()); } return sb.toString(); } /** 传输体到这里才做 URL 编码charset 必须和签名时一致 */ public String toBody(String charset) throws Exception { StringBuilder sb new StringBuilder(256); for (Map.EntryString, String e : params.entrySet()) { if (sb.length() 0) { sb.append(); } sb.append(e.getKey()).append() .append(URLEncoder.encode(e.getValue(), charset)); } return sb.toString(); } public MapString, String snapshot() { return Collections.unmodifiableMap(params); } }toSignSource()和toBody()拆开的意义在于前者可以单独打日志后者可能包含大量编码字符混在一起看根本对不上。charset参数不要硬编码走配置项因为同一套代码可能要面对多个渠道UTF-8 和 GBK 都存在。snapshot()返回只读视图方便在异常日志里一次性把整包参数打出来——出问题时你会庆幸有这行日志。2.2 证书加载从 classpath 读别用绝对路径私钥通常以 PKCS12 格式.pfx/.p12下发里面装着私钥和证书链加载密码由渠道方提供。本地开发时用new FileInputStream(/Users/xxx/cert.pfx)完全没问题打成 jar 部署到服务器就必然炸因为 jar 内部的文件不是文件系统路径。正确做法是放进src/main/resources用getResourceAsStream读。public final class CertLoader { /** resourcePath 以 / 开头表示从 classpath 根开始找例如 /cert/pay.pfx */ public static KeyStore loadPkcs12(String resourcePath, char[] storePwd) throws Exception { KeyStore ks KeyStore.getInstance(PKCS12); try (InputStream in CertLoader.class.getResourceAsStream(resourcePath)) { if (in null) { throw new IllegalStateException(classpath 下找不到证书: resourcePath); } ks.load(in, storePwd); } return ks; } /** alias 是证书别名可用 keytool -list -keystore 查看 */ public static PrivateKey privateKey(KeyStore ks, String alias, char[] keyPwd) throws Exception { Key key ks.getKey(alias, keyPwd); if (!(key instanceof PrivateKey)) { throw new IllegalStateException(别名 alias 下不是私钥); } return (PrivateKey) key; } public static PublicKey publicKey(KeyStore ks, String alias) throws Exception { Certificate cert ks.getCertificate(alias); if (cert null) { throw new IllegalStateException(别名 alias 下没有证书); } return cert.getPublicKey(); } }两个参数容易搞混storePwd是打开整个密钥库的密码keyPwd是取出某个别名下私钥的密码PKCS12 格式下这两者经常相同但不要假设它们一定相同配置里分开写。alias必须显式指定虽然很多密钥库只有一个条目用ks.aliases()取第一个也能跑但换证书时就埋了雷。还有一个上线才暴露的问题.pfx文件进了 Git。这份包的做法是把证书目录加进忽略规则构建时通过环境变量注入路径本地开发各自放一份。密钥密码同理走环境变量或者配置中心不写进application.yml。2.3 签名与验签算法一致只是及格线算法名SHA256withRSA是常见选择也有渠道仍用SHA1withRSA以对方文档为准不要在没确认的情况下自作主张升级。真正决定成败的是原文的字节序列source.getBytes(charset)这一行的 charset 必须和 2.1 里拼串时用的完全一致。public final class RsaSigner { public static String sign(String source, PrivateKey key, String charset) throws Exception { Signature s Signature.getInstance(SHA256withRSA); s.initSign(key); s.update(source.getBytes(charset)); // 标准 Base64不是 URL-safe 版本签完的串若含 / 需在传输层再编码 return Base64.getEncoder().encodeToString(s.sign()); } public static boolean verify(String source, String signBase64, PublicKey key, String charset) throws Exception { Signature s Signature.getInstance(SHA256withRSA); s.initVerify(key); s.update(source.getBytes(charset)); try { return s.verify(Base64.getDecoder().decode(signBase64)); } catch (IllegalArgumentException e) { // Base64 本身不合法多半是传输过程中被 URL 解码吃掉了 号 return false; } } }verify里那个 catch 处理的是血泪经验签名结果里可能出现和/如果接收方直接把参数从 query string 里取出来不做还原会被解成空格Base64 解码失败。解决办法是发送方对签名值做一次 URL 编码接收方先 URL 解码再验签两侧都要对齐。验签失败时最有价值的动作不是重试是把「本地算出的原文」「本地算出的签名」「对方返回的签名」三样东西一起打进日志逐字符比对长度。原文长度不一致说明拼串规则有出入原文一致而签名不同说明密钥或算法不对。这一步能省掉大量猜谜时间。2.4 跑通一次下单从组装到解析的完整链路把上面三块串起来一次下单请求的代码大概长这样。注意超时是必填项没设超时的 HTTP 客户端在生产环境等于定时炸弹。public class PayGatewayClient { private final String gatewayUrl; // 网关下单地址走配置 private final String merchantId; // 商户号 private final KeyStore keyStore; private final String alias; private final char[] keyPwd; private final String charset; // UTF-8 或 GBK与渠道文档一致 private final HttpClient http; // 复用同一个实例内部有连接池 public PayGatewayClient(String gatewayUrl, String merchantId, KeyStore keyStore, String alias, char[] keyPwd, String charset) { this.gatewayUrl gatewayUrl; this.merchantId merchantId; this.keyStore keyStore; this.alias alias; this.keyPwd keyPwd; this.charset charset; this.http HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(3)) // 建连超时 .build(); } public String placeOrder(String orderNo, long amountFen, String subject) throws Exception { PayForm form new PayForm() .put(merchantId, merchantId) .put(orderNo, orderNo) .put(amount, String.valueOf(amountFen)) // 单位是分不是元 .put(subject, subject) .put(timestamp, String.valueOf(System.currentTimeMillis())); PrivateKey pk CertLoader.privateKey(keyStore, alias, keyPwd); String sign RsaSigner.sign(form.toSignSource(), pk, charset); form.put(sign, sign); HttpRequest req HttpRequest.newBuilder() .uri(URI.create(gatewayUrl)) .timeout(Duration.ofSeconds(8)) // 读超时短于网关侧超时 .header(Content-Type, application/x-www-form-urlencoded;charset charset) .POST(HttpRequest.BodyPublishers.ofString(form.toBody(charset), Charset.forName(charset))) .build(); HttpResponseString resp http.send(req, HttpResponse.BodyHandlers.ofString(Charset.forName(charset))); if (resp.statusCode() ! 200) { throw new IllegalStateException(网关返回非 200: resp.statusCode()); } return resp.body(); // 交给上层解析 验签见 3.1 } }几个参数值得单独说。connectTimeout给 3 秒timeout给 8 秒后者必须小于渠道方自己的超时常见是 10 到 15 秒否则会出现「本地超时、对方成功」的不一致状态。Content-Type里的 charset 和ofString的 charset 要一致这两处不一致会导致服务端按错误编码解析中文参数直接乱码。amount用 long 传分全程不碰浮点这一点在第五章还会展开。3. 交易全生命周期下单、查询、退款、对账怎么写一次成功支付只占系统实际要处理的场景的三成剩下的七成是超时、未知态、退款、对账差错。这份包把交易抽象成一个PayTrade接口下单/查询/退款/关单四个方法实现类里统一处理签名和 HTTP业务层只关心状态流转。这么切的原因是状态机逻辑最容易写错把它从网络代码里剥离出来才方便写单元测试。3.1 请求封装与超时重试哪些能重哪些不能支付接口的重试有个铁律下单和退款不能盲目重试查询可以。下单重试会产生重复订单退款重试可能造成重复退款除非渠道方支持幂等键——很多渠道用「商户订单号」做幂等同一单号重复提交返回首笔结果这种情况才敢重试。是否支持幂等必须翻文档确认不能靠猜。/** 只对查询类接口开放重试指数退避最多 3 次 */ public String queryWithRetry(String orderNo) throws Exception { long backoff 200L; Exception last null; for (int i 0; i 3; i) { try { return doQuery(orderNo); } catch (SocketTimeoutException | ConnectException e) { last e; // 网络类异常才重试 Thread.sleep(backoff); backoff * 2; // 200ms - 400ms - 800ms } catch (PayBizException e) { throw e; // 业务错误如签名失败重试无意义 } } throw new IllegalStateException(查询重试 3 次仍失败, last); }异常分类是这段代码的核心。SocketTimeoutException和ConnectException属于「不知道对方处理没处理」可以重试PayBizException是对方明确告诉你失败了重试只会浪费时间和配额。把这两类混在一起 catch是很多线上事故的起点。3.2 查询与退款未知态不是失败交易状态至少需要四个SUCCESS、FAILED、PROCESSING、UNKNOWN。新手最容易犯的错是把「查不到」当成「失败」然后给用户退款或者重新下单结果用户被扣了两次钱。查不到只说明这一刻还没有结果必须继续轮询或者挂起等回调。状态含义处理方式SUCCESS渠道明确返回成功发货、更新订单FAILED渠道明确返回失败关闭订单允许重下PROCESSING渠道明确表示处理中定时轮询间隔 5s 起逐步拉长UNKNOWN超时、网络异常、返回码不认识禁止当失败处理进人工队列或持续轮询public PayStatus poll(String orderNo, int maxTimes) throws Exception { long interval 5000L; for (int i 0; i maxTimes; i) { String body doQuery(orderNo); PayStatus st parseStatus(body); // 解析 验签见 3.4 if (st PayStatus.SUCCESS || st PayStatus.FAILED) { return st; } Thread.sleep(interval); interval Math.min(interval * 2, 60000L); // 最长 1 分钟一次 } return PayStatus.UNKNOWN; // 交给上层挂起不判失败 }退款同理退款单号必须由我方生成并持久化重复提交同一个退款单号渠道方通常返回首笔结果。退款金额要校验不超过原交易金额减去已退金额这个校验放在业务层别指望渠道方替你拦。3.3 对账文件下载、解析、差异定位对账是发现资金差错的最后一道防线。渠道方一般每天凌晨生成对账文件格式多为定长文本或 CSV通过 SFTP 或者 HTTP 下载。文件通常按行给出一笔交易的完整信息我方要做的是逐行解析与本地订单表比对差异按类型归类。/** 按行解析渠道对账文件字段以 | 分隔金额单位为分 */ public ListReconRecord parse(InputStream in, String charset) throws Exception { ListReconRecord list new ArrayList(); try (BufferedReader br new BufferedReader(new InputStreamReader(in, charset))) { String line; boolean first true; while ((line br.readLine()) ! null) { if (first) { first false; continue; } // 跳过表头 String[] f line.split(\\|, -1); // -1 保留尾部空字段 if (f.length 6) { continue; // 尾行汇总或空行跳过 } ReconRecord r new ReconRecord(); r.setOrderNo(f[0].trim()); r.setChannelTradeNo(f[1].trim()); r.setAmountFen(Long.parseLong(f[2].trim())); r.setFeeFen(Long.parseLong(f[3].trim())); r.setStatus(f[4].trim()); r.setTradeTime(f[5].trim()); list.add(r); } } return list; }split的第二个参数传-1是个细节不传的话行尾连续的空字段会被丢弃字段下标全部错位。差异比对建议分三类输出本地成功渠道无记录最严重通常是掉单、渠道成功本地无记录回调丢了、金额不一致少见但必须人工核。每天跑完把这个结果表推给相关负责人比事后翻日志高效得多。3.4 异步通知验签、幂等、返回格式异步通知是支付结果最可靠的来源也是被攻击面最大的入口。处理顺序必须是先验签再幂等最后返回渠道要求的成功标识。PostMapping(/pay/notify) public String notify(RequestParam MapString, String raw) { // 1. 用原始参数重建 PayForm顺序和空值规则与下单时一致 PayForm form new PayForm(); raw.forEach(form::put); String sign raw.getOrDefault(sign, ); // 2. 验签失败直接拒绝不写任何业务数据 boolean ok; try { ok RsaSigner.verify(form.toSignSource(), URLDecoder.decode(sign, StandardCharsets.UTF_8), CertLoader.publicKey(keyStore, alias), charset); } catch (Exception e) { return FAIL; } if (!ok) { return FAIL; } // 3. 幂等以渠道交易号建唯一索引重复插入捕获异常后直接返回成功 try { tradeService.markPaid(raw.get(orderNo), raw.get(channelTradeNo), Long.parseLong(raw.get(amount))); } catch (DuplicateKeyException e) { // 已经处理过返回成功让渠道停止重推 } return SUCCESS; }三个坑点一RequestParam Map拿到的是 URL 解码之后的值而签名原文需要原始值如果参数里有或中文直接拿解码后的值参与验签会失败稳妥做法是读原始请求体自行解析二幂等必须落在数据库唯一约束上靠「先查再插」在并发下一定漏三返回体内容必须严格照文档写有的渠道认SUCCESS有的认OK返回错了会被无限重推。4. 避坑与排查五个真金白银换来的记录下面五条都是可以在十分钟内定位的问题前提是你知道往哪看。每条按现象、原因、解决三步写能直接对着排查。4.1 本地验签通过线上一直签名错误现象同一份参数本地单测里verify返回 true部署到测试环境调真实网关返回「签名错误」。原因九成是编码不一致。本地默认字符集是 UTF-8服务器 JVM 启动参数没指定file.encoding某些精简镜像默认成了别的编码或者代码里一处用charset变量、另一处用了StandardCharsets.UTF_8硬编码。中文商品名一出现就暴露。解决把这个 charset 提成配置项在拼串、签名、序列化请求体三处都引用同一个值启动时打印一次Charset.defaultCharset()出问题时第一眼看这行日志。4.2 金额差了 100 倍现象用户付了 1 元渠道扣款 100 元或者反过来。原因单位不统一。网关文档写「金额单位为分」业务库里存的是BigDecimal的元中间用doubleValue() * 100转换浮点误差加上四舍五入规则不同就成了玄学。也有的是反过来代码传了分网关按元处理。解决统一用 long 存分从数据库字段到 DTO 到请求参数全程不出现 double入参转换用BigDecimal.movePointRight(2).longValueExact()一旦有小数位超过两位直接抛异常比悄悄截断安全得多。4.3 打成 jar 后启动报证书找不到现象IDE 里跑得好好的mvn package之后启动抛classpath 下找不到证书。原因证书放在了src/main/resources之外的目录或者构建插件把resources下的*.pfx排除了。有些团队的pom.xml里配了excludes过滤二进制文件证书首当其冲。解决把证书放到resources/cert/下检查打包插件的排除规则启动后加一个自检逻辑getResourceAsStream返回 null 就快速失败别等到第一次下单才发现。4.4 回调重复触发用户收到两次发货现象同一笔订单的异步通知来了三次业务系统发了三次货。原因只做了「查状态再更新」的软幂等两个回调并发进来都查到未支付都执行了更新。渠道方在没收到正确响应时会按固定间隔重推重复是常态不是异常。解决在交易表上对渠道交易号建唯一索引插入失败即视为已处理更完整的做法是把「更新订单状态」和「写流水」放在同一个本地事务里用数据库约束兜底。4.5 查询接口偶发读到旧状态现象刚支付成功立刻调查询接口返回还是处理中过几秒再查才成功。原因渠道侧的状态更新和查询接口之间存在同步延迟尤其是跨机房部署的渠道这个延迟可能到秒级。这不是 bug是设计如此。解决支付成功后不要同步依赖查询结果以异步通知为准查询只用于兜底和定时补偿且要容忍延迟把「查询返回处理中」当成正常分支处理不要当异常告警。5. 上线前我会走的那套自检从金额边界到回放把前面几章串起来之后还有一层东西是代码评审看不出来的只有在真实链路上跑一遍才会暴露。我现在每次接新渠道或者改签名逻辑都会固定走一遍这套自检顺序是从小到大、从静到动。先做静态验证不碰网络。拿渠道文档里的签名示例参数用PayForm拼出原文和文档给的原文逐字符比对长度不一样就先停别往下走。然后把原文传给RsaSigner.sign把结果和文档给的签名值比对这一步能同时验证密钥、算法、Base64 编码三个因素。这两步都过了才说明签名模块本身没问题后面出错就只可能是参数或者网络。再做金额边界。我会准备一组固定的测试金额1 分、99 分、100 分、99999999 分以及原交易金额恰好等于可退金额的退款场景。重点看两处BigDecimal转 long 时有没有被截断退款金额校验用的是不是减法后的余额。这一步踩过的坑最多因为大部分单测只测 100 元这种整数边界值从来没人写。然后是异常路径回放。把connectTimeout临时调到 100 毫秒人为制造超时观察订单最终落在哪个状态是否进入了 UNKNOWN 分支是否有告警。再手动重放一次异步通知确认幂等约束真的生效而不是「理论上生效」。这一步是我最看重的因为它验证的是系统在坏情况下的行为而不是好情况下的正确性。检查项判定标准常见失败原因签名原文比对与文档示例逐字符一致空值过滤规则不同签名值比对Base64 解码后字节一致算法或密钥别名错金额边界1 分与最大值均正确元分转换用了 double超时回放落 UNKNOWN 且不判失败catch 里默认返回 FAILED通知重放第二次不产生业务副作用软幂等无唯一约束这套流程走完大概四十分钟比上线后排查一晚上划算得多。从那以后我每次动签名或者金额相关的代码都强制把这三步跑一遍再提测哪怕是改一个字符。希望帮到你。本文还有配套的精品资源点击获取