简介这是一份面向Java Web开发者的ChinaPay中国银联支付接口对接示例工程采用Eclipse标准结构组织适合需要快速熟悉银联在线支付流程或进行二次集成的读者。资源包共72个文件约5.05MB由17个Java源码、17个Class文件与15个Jar依赖组成辅以10个JSP页面和若干properties、XML配置覆盖从页面发起支付到后端处理通知的完整链路。JSP与Java文件可对照阅读便于理解支付参数组装、加密签名和服务端验签等关键环节Jar包则提供了接口调用的基础支撑。工程保留完整的Eclipse配置及src、build、WebContent等目录结构导入开发环境即可直接运行调试。目前已有431人学习使用适合初中级工程师作为入门chinapay支付集成的参考模板。1. chinapay-java-new老网关的 Java 集成为什么值得用新工程重做一遍chinapay-java-new 并不是某个官方发布的新版 SDK而是一类重新封装 ChinaPay 支付网关的 Java 工程方案。很多团队在对接时都有同样的体验官方资料停在一个连 Maven 坐标都要手敲的年代下载下来的压缩包里还混杂着上一代框架的依赖拿到新 JDK 上直接跑不起来。所谓“new”本质是用现代 Java 的 HttpClient、配置化密钥管理和清晰的模块边界把报文组装、签名、验签、回调解析收拢成一层可以被单元测试覆盖的代码而不是让业务代码里到处散落 Base64 和 Signature 拼接。它解决的是老 SDK 在新环境里的存活问题也解决“出了问题不知道是字段顺序错还是编码错”的排查难题。这套笔记面向需要亲自把 ChinaPay 网关接进商户系统的开发者和架构师。你会先弄清楚网关的报文规矩再拿到一套能直接抄走的工程骨架和核心代码最后看到几个真实高频的线上踩坑点。新手可以按章节把最小流程跑通熟手可以跳过基础直接看最后一章的回归测试思路。2. 拆开 chinapay-java-new 的报文链路为什么 Base64 之外还要签名2.1 网关报文的三层封装明文、签名和 XML 外壳接入 ChinaPay 这类历史较长的网关第一件事是忘掉 JSON 传参的习惯。网关侧传过来的报文通常长这样先有一段“明文串”里面是所有业务字段按固定顺序拼接的结果这段明文用商户私钥做签名生成一段 Base64 签名最后把明文和签名一起包进 XML 结构整个 XML 再整体做一次 Base64才作为 HTTP POST 的 body。换句话说一个请求体经历了明文拼接、签名为、XML 封装、Base64 传输四步。这里有一个新手常问的疑问报文都 Base64 了为什么还要签名答案很简单Base64 只是编码不加密也不防篡改。把 Base64 解码后谁都能读所以签名才是安全性的全部。网关收到报文后用商户公钥验签确认这段报文确实来自该商户网关返回的报文则用网关私钥签名商户侧必须用网关公钥验签防止有人伪造回调通知。整套机制里签名原文的字段顺序是唯一的“契约”顺序错了一切都白搭。以常见的交易请求为例参与签名原文的字段大致是这一组字段名示例值说明MerId123456789012345商户号网关开户时分配OrderId20250214A00001商户订单号需唯一TransDate20250214交易日期格式 yyyyMMddTransAmt1000交易金额单位是分CuryId156币种156 表示人民币TransType0001交易类型不同接口有对应值Version20101008接口版本号参数说明TransAmt 必须用整数“分”传10.00 元写成 1000不能带小数点TransDate 和 OrderId 的组合在很多网关注册里承担幂等作用同一日内订单号重复会被拦截。这里的重点是不同接口类型的字段集合并不完全相同退款、查询、代付都有各自的签名原文规则所以核心代码里不能只写一个拼接方法而是要为每个接口单独维护拼装顺序。2.2 签名算法的选择SHA1WithRSA 优先SM2 按需切换存量支付网关里RSA 签名仍然是绝对主流算法名通常写作 SHA1WithRSA 或 SHA256WithRSA。新项目接入时我的建议是第一版走通道默认支持的 RSA先让业务流程完整跑通不要一上来就追求国密 SM2。原因很直接SM2 的密钥格式、证书解析、签名长度都和 RSA 不同且签名结果长度不固定如果数据库里的签名字段按 RSA 定长预留切换时会有存储问题。等接口通了、测试环境能持续产出正确签名再考虑评估国密切换。SM2 替换时不需要更改网关 URL但商户私钥载体、签名算法和验签参数全部要换。密钥管理上比较稳的做法是商户私钥放在独立的密钥服务或文件系统里配合环境变量注入口令不要把密码写到配置文件再打进 JAR。网关公钥一般以 .cer 证书形式下发启动时读取一次缓存到内存即可。关于密钥生命周期有一个很多人忽视的点证书会过期。接入 chinapay-java-new 时我会在工程里加一个启动检查任务扫描商户证书和网关证书的到期时间小于 30 天就打印告警日志。线上环境最容易发生的故障不是代码问题而是证书过期后所有验签失败且报错信息没有任何提示性。2.3 工程骨架模块拆分与配置文件设计我一般会把方案拆成两个 Maven 模块。sdk-core 负责纯粹的技术封装包括 Base64 编解码、签名与验签、HTTP 调用、XML 解析sdk-spring-boot-starter 负责自动装配向业务层暴露 PayService、NotifyService 等门面接口。这样做的理由是如果后续要支持多个支付通道core 可以复用starter 只是配置和装配的壳。配置文件集中在 application.yml核心配置如下chinapay: merchant-id: 123456789012345 private-key: path: file:/etc/chinapay/merchant_private.p12 store-password: ${CHINAPAY_STORE_PASSWORD} key-password: ${CHINAPAY_KEY_PASSWORD} public-key: path: file:/etc/chinapay/gateway_public.cer gateway: pay-url: ${CHINAPAY_PAY_URL} query-url: ${CHINAPAY_QUERY_URL}参数说明private-key.path 指向商户私钥支持 p12 和 jks 两种格式store-password 和 key-password 用环境变量占位符防止明文泄露gateway 下的两个 URL 在联调环境和生产环境是不同的放进配置中心可以按环境切换。这里不建议把证书放在 classpath 下因为打成 JAR 后路径很难控制也容易被别的组件扫描到。启动装配的核心逻辑是读取证书文件并缓存两个密钥对象。密钥对象在 core 里定义为统一的Signer和Verifier接口底层实现可以是 RSA 或 SM2业务层只依赖接口。这样将来切换算法时业务代码一行都不用改。3. 把核心流程写成能跑通的代码下单、回调验签、退款与查询3.1 下单报文组装字段顺序是最大的坑下单是整个接入里最典型、也最适合作为模板的流程。我写了一个完整的报文构建方法你先看代码再看后面的参数说明public String buildPayRequest(PayOrder order) throws Exception { // 1. 按网关要求的顺序拼接签名原文 String plainText String.join(, order.getMerchantId(), // 商户号 order.getOrderId(), // 订单号 order.getTransDate(), // 交易日期 yyyyMMdd order.getTransAmt(), // 金额单位分 order.getCuryId(), // 币种 156 order.getTransType(), // 交易类型 order.getVersion()); // 版本号 // 2. 用商户私钥签名得到 Base64 字符串 byte[] signature sign(plainText.getBytes(StandardCharsets.UTF_8)); // 3. 组装 XML 报文再整体 Base64 String xml buildXml(plainText, signature); return Base64.getEncoder().encodeToString( xml.getBytes(StandardCharsets.UTF_8)); }逻辑说明第 1 步把业务字段用 连接成签名原文顺序严格按网关文档来第 2 步签名时直接操作 UTF-8 字节不要经过系统默认编码转换否则换一台机器结果就变了第 3 步的 buildXml 方法将明文和签名放进 CDATA 区域避免 XML 转义问题。这里有个容易误用的点有的开发者把整个 XML 报文拿去签名这是错的网关侧验证的是固定字段拼出的明文不是 XML 原文。参数说明PayOrder 里的 transAmt 在 Web 层传入时通常是 BigDecimal这里必须转换为 Long 类型的分transDate 可以从订单创建时间格式化但要注意时区统一用 Asia/ShanghaiorderId 建议用“日期随机序列”的格式避免跨日重复。签名算法默认是 SHA1WithRSA在 sign 方法内部通过 PrivateKey 初始化 Signature 实例完成。3.2 回调验签不验签等于给攻击者留了后门回调地址是商户服务器开放给外网的接口如果只判断订单状态就更新业务库任何一个猜到 URL 的人都可以伪造“支付成功”把订单置为已支付。验签必须放在状态更新之前而且要放在任何数据库写入之前。public boolean verifyNotify(MapString, String params) { // 1. 从回调参数中取出签名 String signature params.get(Signature); if (signature null || signature.isEmpty()) { log.warn(回调报文缺少 Signature); return false; } // 2. 按约定顺序还原签名原文 String plain String.join(, params.get(MerId), params.get(OrderId), params.get(TransAmt), params.get(TransDate), params.get(CuryId)); // 3. 使用网关公钥验签 return rsaVerify(plain.getBytes(StandardCharsets.UTF_8), signature); }逻辑说明第 2 步的拼装顺序和下单时的规则一致但字段集合不同回调报文里通常还包含支付结果码、渠道流水号等额外字段。那些字段原本就不在签名原文内千万不要拼进去。第 3 步 rsaVerify 方法内部用网关公钥初始化 Verification验签时把 Base64 签名解码为字节后传入。参数说明params 是 HttpServletRequest 反序列化后的参数 Map网关走的是 Form POST直接用原生的 request.getParameterMap 即可注意签名参数名的大小写有些网关环境解析后变成小写 signature后端在取参数时要做兼容处理最保险的做法是包装一个忽略大小写的 Map 获取方法。验签失败时不要静默吞掉要记录完整参数快照供排错但日志里要去掉持卡人手机号、身份证号等敏感字段。3.3 退款和查询复用签名服务但必须处理幂等退款接口的报文结构和下单高度相似差别主要在 TransType 值和金额语义。退款要带上原订单号有的网关还要求原交易日期用于定位原始交易。查询接口负责异步对账通常在每日固定时间跑一轮把本地订单状态与网关侧实际状态对齐。这一层最容易被忽略的是幂等。退款请求必须传我方系统自己生成的退款流水号不能直接用原订单号。原因很简单如果某次退款请求超时你发起重试网关按原订单号判断业务已经处理过可能直接返回“重复退款”也可能因为两套系统的判断逻辑差异导致重复退款。常见做法是引入退款流水表在数据库里对退款流水号增加唯一约束重试前先查询流水是否存在存在则直接返回原结果不重复提交。查询接口则更简单入参是订单号和交易日期出参是网关侧的状态和金额。这里有一个经验查询接口的响应报文里也可能带签名不要因为是内部调用就跳过验签。只要数据来自外网都要走同一套验签逻辑代码路径越统一越不容易漏。4. chinapay-java-new 接入避坑清单五个反复出现的线上问题4.1 验签偶发失败先怀疑编码再怀疑证书现象同一套代码在测试环境全通上线后偶尔报验签失败重试几次又能成功。原因最常见的不是算法问题而是编码不一致。开发环境用 Windows 默认的 GBK 读取参数线上是 UTF-8拼出来的字节序列完全不同。另一个可能原因是网关返回的明文里包含换行符而拼接时没有做清理。解决全局统一 UTF-8在工程启动时设置file.encodingUTF-8同时所有拼接操作都显式指定字符集。如果怀疑换行符在拼接前对每个字段做 trim并把 \r\n 替换为空。4.2 证书加载报错别把密钥放在 classpath 里现象启动时打印“文件不存在”但证书明明已经打进 JAR 包了。原因把 .p12 证书放进了 src/main/resources打成 JAR 后路径变成了 classpath:/xxx.p12普通 File 对象读不到 JAR 内部的资源。解决把证书路径改成外部配置项用file:/etc/chinapay/...这样的绝对路径启动。同时加一个启动自检校验密钥库密码是否正确、证书是否在有效期内有问题就 fail fast而不是等到第一笔交易失败才暴露。4.3 金额单位混乱分和元在退款场景翻车现象退款金额比支付金额多了 100 倍用户只付了 10 元却收到 1000 元退款。原因下单接口里金额单位是分退款接口的开发人员误以为网关统一用元直接把页面传入的元金额提交了网关按分解析后金额被放大百倍。解决在 core 层统一使用 Long 类型表示“分”禁止在业务层出现 double 金额字段。在退款方法的入口加断言金额值小于 0 或大于单笔上限时直接抛异常。这个校验成本很低但能挡住最贵的一类失误。4.4 回调报文里取不到 Signature 字段现象本地模拟回调一切正常生产环境偶发验签空指针。原因网关对参数名大小写敏感且不同环节传递过程中 Map 的 key 可能被小写化。代码里直接params.get(Signature)一旦 key 变成 signature就取不到值。解决封装一个忽略大小写的参数获取方法内部遍历 keySet 做 equalsIgnoreCase 匹配。这个方法同样适用于验签原文拼接避免因为参数名大小写不一致导致验签失败。4.5 超时重试导致重复退款现象用户反馈一笔订单收到了两笔退款数据库里也只有一条退款流水。原因退款请求超时后业务系统发起重试。由于退款流水号是随机生成的两次请求被网关当作两笔独立退款处理。解决为每笔退款生成业务流水号并落库数据库加唯一约束。重试前先查流水表如果流水已存在直接返回上一次的结果不再提交网关。同时在网关侧也要尽量使用支持幂等的接口字段。5. 用固定向量把签名服务锁死让支付代码可以被回归验证支付模块最怕的是“改了一行不知道影响了什么”。签名、验签这类逻辑其实非常适合做回归测试因为私钥和证书都是固定资源同样的明文输入一定产生同样的签名输出。如果你的代码里是每次调SecureRandom辅助随机填充那么 PKCS1 v1.5 签名仍是确定性的可以放心断言。我习惯在测试资源里维护一个 golden vector从联调环境抓取一次成功交易把签名原文、证书别名、期望签名 Base64 字符串存成 JSON fixture。SignerTest里加载同一份明文和私钥运行签名方法后断言输出与 fixture 一致。任何字段顺序调整、编码错误或者证书加载逻辑变化都会立刻让测试变红。这个文件不随业务代码改动只在网关调整签名规则时更新。回调验签同样可以做回归。把网关在测试环境推送的真实回调记录保存为 XML 文件在测试里模拟 Spring MVC 请求让控制器完整走一遍验签、业务更新和应答返回的流程。这样你可以放心地改底层 HTTP 工具库改完跑一遍测试就知道自己的改动是否破坏了验签链路。这套做法比传统的 Mock 更有价值因为 Mock 的返回数据常常是自己构造的容易和真实报文存在细微差异。我最初对接这类老网关时也走过弯路直接从网上下了一段“通用工具类”签名、验签、加密全放在一个类里结果上线第一天就遇到回调验签失败。后来把签名原文构造抽成独立服务用黄金向量做回归心里才真正踏实下来。支付对接不是写出来就完事能验证、能回归的代码才值得交给下一个接手的人。希望帮到你。本文还有配套的精品资源点击获取