ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

SpringBoot实现Apple Pay支付回调验证:从JWT验签到落库全流程解析

SpringBoot实现Apple Pay支付回调验证:从JWT验签到落库全流程解析 简介面向SpringBoot服务端开发者的一份Apple Pay回调验证示例工程专门解决iOS端苹果支付交易令牌的服务器端校验问题适用于正在接入苹果支付功能的后端项目。压缩包共98个文件以70个XML配置为主涵盖Maven工程配置、IDE模块设置等另含8个Java源码、10个编译后的class文件、3个properties配置及mvnw脚本整体仅93KB属于轻量级代码示例。目前已有499人学习下载。资源提供了可运行的SpringBoot工程结构核心实现包括商户信息配置、基于JWT的支付令牌解码、使用商户私钥验证签名、调用Apple支付验证API确认交易状态以及验证通过后的数据库落库与错误处理。同时包含测试目录与target编译产物方便开发者对照源码与字节码排查问题。适合需要快速理解苹果支付服务端验证流程的后端工程师能够帮助从沙箱测试平滑过渡到生产环境。1. 苹果支付回调验证一份SpringBoot源码包怎么落地拿到这个apple-pay.rar解压出来是一个标准的SpringBoot Maven工程src、pom.xml、mvnw齐全不是网上那种零散代码片段。它的目标很具体iOS端在iPhone、iPad或Apple Watch上完成Apple Pay支付后设备会生成一个加密的支付令牌Payment Token这个令牌要送到你的服务端做解码、验签、状态确认和落库。这份源码就是围绕这条链路搭的底稿。对已经有SpringBoot基础、正在接苹果支付但卡在服务端验证环节的人来说适合直接照着改。别轻信“验个签就完事”的说法支付令牌的解密、验签、幂等和沙箱切换每一处都能让人翻车一整天。2. 先拆支付令牌JWT三段式、证书配置与jjwt解码2.1 支付令牌不是普通JWT别急着丢进解析器Apple Pay支付令牌对外看是一个JWT格式的字符串分成header.payload.signature三段但里面装的东西跟OAuth的JWT差别很大。payload里除了常规字段还有paymentData它内部包含ephemeralPublicKey、encryptedData、publicKeyHash、version四个子字段这是实际卡片信息所在的位置。很多文章一句话带过“解码JWT”实际操作时你会发现在解码站上只能看到header和部分payloadpaymentData是加密的那层密文不是普通Base64能解出来的。我一般会先把令牌的三段拆开分别观察header里的alg、kid再看payload里的transactionIdentifier、paymentMethod。开发阶段直接看明文排查问题时比调试器好用得多。下面这段代码不需要引入任何JWT库就能拆适合放在单元测试或调试入口里用。import java.nio.charset.StandardCharsets; import java.util.Base64; import org.json.JSONObject; public class DevTokenReader { public static void inspect(String token) { // JWT 三段用 . 分隔header.payload.signature String[] chunks token.split(\\.); if (chunks.length ! 3) { throw new IllegalArgumentException(token 不是三段式结构先确认传输过程有没有被截断); } // 这三段的 Base64URL 解码结果都是纯文本 JSON可以直接打出来看 String header new String(Base64.getUrlDecoder().decode(chunks[0]), StandardCharsets.UTF_8); String payload new String(Base64.getUrlDecoder().decode(chunks[1]), StandardCharsets.UTF_8); JSONObject headerObj new JSONObject(header); JSONObject payloadObj new JSONObject(payload); System.out.println(alg headerObj.getString(alg)); System.out.println(kid headerObj.getString(kid)); System.out.println(transactionIdentifier payloadObj.getString(transactionIdentifier)); System.out.println(paymentData 是否明文可读 (payloadObj.get(paymentData) instanceof JSONObject)); } }这段代码的逻辑只有两件事按.拆分JWT拿Base64 URL解码器解出前两段并转成JSON。alg字段告诉你这套令牌用的是什么签名算法Apple目前走的是RS256体系transactionIdentifier是Apple为这笔支付生成的唯一交易号后面做幂等判断就靠它paymentData如果显示成JSON对象说明你拿到的还是完整令牌如果显示成字符串那多半是传输过程中被序列化过一次后续解析要用JSON字符串再转一层。2.2 Merchant ID与证书沙箱和生产记得配两套要在SpringBoot里做验证前置条件是在Apple Developer后台注册App并拿到Merchant ID同时为这个Merchant ID生成“付款处理证书”。这套证书分沙箱和生产两套申请时机、证书有效期都不同。最土但也最稳的做法是把两者列成一组四行配置需要哪个环境就切哪个。配置项沙箱环境生产环境Merchant IDmerchant.com.xxx.sandboxmerchant.com.xxx证书文件sandbox_payment.cerproduction_payment.cer证书别名apple-pay-sandboxapple-pay-prod根证书Apple Root CA - G3Apple Root CA - G3验证API地址https://validation-pay.sandbox.apple.com/paymentValidation/https://validation-pay.apple.com/paymentValidation/注意证书不是下载完就能直接用。从Apple后台下载的.cer文件要转成PKCS12格式再导入Java的KeyStore里。跟进项目时经常看到有人把.cer和.p12混着用最后程序报No such algorithm或者InvalidKeyException根源就是证书格式和私钥没对上。真正常见做法是先把.cer转成.pem再和商户私钥一起打包成.p12最后用keytool导入。密钥库的存储路径、密码、别名全部放进application.yml别写死在代码里。2.3 用jjwt解析下载包里的令牌从解码到读关键字段这个工程里已经引入了jjwt依赖pom.xml里能看到三件套jjwt-api、jjwt-impl、jjwt-jackson。jjwt主要负责两件事解析JWT的标准结构以及验签。下面的代码展示了拿到支付令牌后怎么不验签先读出业务字段这个阶段生产代码里可以用但确认是调试用途正式验证流程里不能跳过签名校验。import io.jsonwebtoken.Claims; import io.jsonwebtoken.Jws; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.security.Keys; import java.security.PublicKey; public class ApplePayTokenDecoder { private final PublicKey appleRootPublicKey; public ApplePayTokenDecoder(PublicKey appleRootPublicKey) { this.appleRootPublicKey appleRootPublicKey; } public ApplePayTokenPayload decode(String token) { // 注意这里传的是 Apple 根证书公钥不是商户私钥 JwsClaims jws Jwts.parserBuilder() .setSigningKey(appleRootPublicKey) .build() .parseClaimsJws(token); Claims body jws.getBody(); String txId body.get(transactionIdentifier, String.class); String merchantId body.get(merchantIdentifier, String.class); Object paymentData body.get(paymentData); return new ApplePayTokenPayload(txId, merchantId, paymentData); } }逻辑说明parseClaimsJws在jjwt里是“解析并验签”的组合动作如果签名不匹配这一行直接抛JwtException根本拿不到后面的字段。所以代码顺序是验签在前、取值在后。参数说明setSigningKey这里放的是Apple根证书公钥不是你的商户私钥——支付令牌的签名是Apple拿它的私钥签的服务端只能用Apple的公钥验证。很多初接Apple Pay的人在这里把商户私钥填进去结果怎么验都失败实际不是代码问题是方向反了。3. SpringBoot验证主流程验签、解密、Apple API对接与落库3.1 完整验证步骤按这个顺序写不会乱Apple Pay服务端验证的核心不是单个方法能搞定的事而是一个固定顺序的流程。我通常把它拆成五步先取出交易号查库做幂等再用Apple根证书验JWT签名再解密paymentData拿到网关需要的密文信息然后调Apple验证API或收单机构确认交易状态最后落库。顺序不能乱尤其是“先查重再做验签”这一步能省掉大量重复验签的CPU开销。Service public class ApplePayVerificationService { private final ApplePayTransactionRepository transactionRepository; private final ApplePayTokenDecoder tokenDecoder; private final ApplePayClient applePayClient; public VerificationResult verify(String paymentToken, String merchantId) { // 第一步解析出交易号先做幂等判断 Claims claims tokenDecoder.decode(paymentToken); String txId claims.get(transactionIdentifier, String.class); if (transactionRepository.existsByTransactionIdentifier(txId)) { return VerificationResult.duplicate(txId); } // 第二步验签失败直接抛异常由全局异常处理器接管 if (!tokenDecoder.isSignatureValid(paymentToken)) { return VerificationResult.failed(SIGNATURE_INVALID); } // 第三步解密 paymentData取出提交给收单机构的密文 PaymentData data tokenDecoder.decryptPaymentData(claims.get(paymentData, Map.class), merchantId); // 第四步调 Apple Pay 验证接口或你的支付网关确认 VerificationStatus status applePayClient.confirm(data, txId, merchantId); // 第五步按结果落库后续对账和退款都走这张表 ApplePayTransaction entity ApplePayTransaction.builder() .transactionIdentifier(txId) .merchantId(merchantId) .amount(data.amount()) .status(status.name()) .build(); transactionRepository.save(entity); return VerificationResult.success(txId, status); } }这套主流程把五件事按危险程度排好了序查重不涉及加解密最快验签是安全底线必须在解密之前解密放在验签后面避免无效签名浪费一次私钥运算网络请求放第四位因为耗时最长失败时前面几步的结论还能复用。VerificationResult是个简单的状态对象duplicate和failed都走同一个返回结构接口层拿到的永远是标准响应体不用为异常分支另写格式。3.2 与Apple验证API通信HTTP客户端这样写源码里的ApplePayClient走的是Spring自带的RestTemplate。在SpringBoot 3.x项目里RestTemplate默认没有注入Bean需要在配置类里建一个顺便设置连接超时和读取超时。支付场景下网络抖动常见不设超时的话线程会挂在连接上越积越多。import org.springframework.beans.factory.annotation.Value; import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Component; import org.springframework.web.client.RestTemplate; import java.util.Map; Component public class ApplePayClient { private final RestTemplate restTemplate; private final String applePayVerifyUrl; public ApplePayClient(RestTemplate restTemplate, Value(${apple.pay.verify-url}) String applePayVerifyUrl) { this.restTemplate restTemplate; this.applePayVerifyUrl applePayVerifyUrl; } public VerificationStatus confirm(PaymentData data, String txId, String merchantId) { MapString, Object requestBody Map.of( merchantIdentifier, merchantId, transactionIdentifier, txId, paymentData, data.encryptedData() ); ResponseEntityAppleVerificationResponse response restTemplate.postForEntity( applePayVerifyUrl, requestBody, AppleVerificationResponse.class); if (response.getStatusCode().is2xxSuccessful() response.getBody() ! null) { return response.getBody().getStatus(); } return VerificationStatus.UNKNOWN; } }参数说明applePayVerifyUrl来自配置文件的apple.pay.verify-url沙箱和生产两套环境靠它区分代码里不写死。merchantIdentifier和transactionIdentifier是Apple那边识别商户和交易的核心字段两个都不能漏。paymentData传的是encryptedData而不是整个paymentData对象这一点容易搞混传多了Apple那边会返回参数校验错误。实际项目里这一步有时候不直接调Apple而是转给收单机构处理因为Apple Pay的“验证通过”和“资金到账”是两件事下游网关才能确认最终结果。3.3 落库与幂等交易表必须加唯一约束验证通过之后交易信息要存下来。最容易被忽略的是幂等设计Apple的重试机制、客户端的重复提交、消息队列的重复消费都会让同一个transactionIdentifier走多次验证流程。如果表结构不设唯一约束第一次成功后第二次又插入一条重复订单对账时就会看到双倍金额。CREATE TABLE apple_pay_transaction ( id BIGINT AUTO_INCREMENT PRIMARY KEY, transaction_identifier VARCHAR(64) NOT NULL UNIQUE, merchant_id VARCHAR(64) NOT NULL, amount DECIMAL(10,2) NOT NULL, status VARCHAR(16) NOT NULL, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, INDEX idx_merchant_id (merchant_id) );这里的transaction_identifier字段加了UNIQUE约束是数据库层面的最后一道防线。idx_merchant_id索引是为了后续按商户维度查订单、做结算时不用全表扫。amount用DECIMAL(10,2)不用FLOAT货币计算不要引入浮点误差。落库的动作还要配上唯一键冲突的兜底处理当INSERT遇到DuplicateKeyException时直接查原记录返回“已处理”比先查再插更稳因为查和插之间永远存在并发窗口。4. 错误处理与状态机补齐支付边界不给翻车留口子4.1 交易状态不只成功和失败很多第一版实现里VerificationResult只给两个值成功、失败。实际接入Apple Pay后会发现状态至少要有六种初始化、验证中、成功、失败、重复请求、退款。特别是“重复请求”这个状态它不是业务失败只是同一个交易被再发了一次接口要返回200不能让客户端误以为支付失败又去引导用户重新下单。我一般会在ApplePayTransaction里维护一个状态字段状态的流转方向是INIT-VALIDATING-SUCCESS或FAILED重复请求直接短路。退款是另一个方向需要订单系统发起退款申请后手动或定时任务同步状态。这个状态机不复杂代码量不多但能防止“同一个订单既标记成成功又标记成失败”这类脏数据。4.2 异常类型与接口返回码映射全局异常处理器需要对几类已知异常做统一返回。下面这张表是核心映射关系状态码设计成业务码形式跟HTTP状态码解耦客户端拿到的是code而不是去看HTTP状态。异常场景触发位置业务码推荐HTTP状态令牌结构非法解码前检查40001400签名无效验签阶段40002401paymentData损坏解密阶段40003400Apple接口超时网络请求50001502重复交易幂等判断20001200商户证书缺失配置加载50002500这个映射表的逻辑是凡是业务参数问题HTTP状态用4xx凡是服务端依赖的外部系统出问题用5xx重复交易虽然带“重复”字样但它的语义是“这笔已经处理过了”对客户端来说不是错误所以返回200。注意不要把验签失败也返回200客户端会以为验签过了后续流程全乱。4.3 日志脱敏别让支付令牌出现在info日志里支付令牌里带着加密的卡片信息日志打出来就是合规风险。项目里踩过的坑是开发同学为了方便排查在入口处打了一行log.info(payment token: {}, token)结果测试环境日志全量进了ELK。我接手后强制加了规则日志里只打印transactionIdentifier的后六位和merchantId完整令牌只允许在DEBUG级别下输出而且生产环境日志级别不允许开到DEBUG。实现上不需要引入额外框架在打印前做一个脱敏方法就够public String maskToken(String token) { if (token null || token.length() 32) { return null-or-too-short; } String tail token.substring(token.length() - 8); return token....concat(tail); }maskToken保留末尾八位前面统一替换成固定前缀。保留末尾是方便用日志检索到具体令牌又不泄露完整内容。日志里永远不出现完整令牌这是支付类项目的红线。另外还需要检查一下全局过滤器里有没有打印request body有的话把Apple Pay回调接口加进排除列表。5. 避坑与常见问题苹果支付验证的五个真实翻车点5.1 用商户私钥验JWT签名越验越失败现象代码逻辑没报错但parseClaimsJws永远抛SignatureException换哪个商户密钥都一样。原因方向搞反了。Apple Pay支付令牌的JWT签名是Apple用Apple私钥签的服务端只能拿Apple根证书公钥验证。商户私钥的用途是解密paymentData不是验签。把商户私钥塞进setSigningKey那等于用一把无关的钥匙去开锁。解决把验签和解密拆成两个方法setSigningKey(appleRootPublicKey)验签decryptPaymentData里才用商户私钥。这个坑在源码里其实已经分好了在ApplePayTokenDecoder里两个方法各干各的照抄就行。5.2 Apple根证书没导入KeyStore生产环境必挂现象沙箱环境验签偶尔能过生产环境请求一到就抛JwtException错误信息里带Unable to find certificate之类字样。原因Apple根证书没有导入JVM的信任库或者导入了但不被代码里的KeyStore引用。沙箱环境之所以偶尔能过是因为Apple沙箱签名链里有些时候走的是另一个信任路径真上生产信任链必须完整。解决用keytool -importcert导入Apple Root CA - G3证书别导进cacerts导进应用自己的KeyStore文件然后在application.yml里通过server.ssl.key-store或自定义配置指定路径和别名。上线前写个启动自检加载证书失败直接fail-fast。5.3 只配了沙箱证书切生产时验证接口回调全失败现象测试环境全通过部署到生产环境客户端付款成功服务端永远收不到合法验证请求日志里全是merchantId mismatch。原因客户端的merchantIdentifier用的是沙箱商户号服务端却切到了生产验证地址和证书。Apple后端会根据商户号判断这个付款请求属于哪个环境两边对不上直接拒绝。解决把“环境”当作显式参数传递客户端请求头带X-Apple-Pay-Env: production服务端根据这个参数加载对应的证书、商户号、验证地址三件套。不要靠改配置文件来切换配置文件太容易被遗漏。5.4 重复通知导致同一笔交易被处理两次现象订单表出现两条相同transactionIdentifier的记录对账时金额翻倍。用户只付了一次款系统却生成了两笔订单。原因没有做幂等。Apple的重试机制、客户端网络超时后的重发、消息队列的重复消费都会把同一个令牌再次送到服务端。代码里如果先验签再落库两次都验签成功就产生了重复记录。解决transaction_identifier设唯一约束落库用INSERT ... ON DUPLICATE KEY UPDATE或先查再插。前者在高并发下更稳因为查和插之间永远有窗口期。注意ON DUPLICATE KEY UPDATE时不要更新交易金额和状态为成功以外的值避免把已成功的记录改成其他状态。5.5 验证接口超时后没有重试用户卡在付款中现象客户端显示付款成功服务端日志里调Apple接口超时订单状态停在VALIDATING用户等不到确认结果客服开始收到催单。原因RestTemplate没设超时默认连接超时可能长达数分钟超时后也没有重试机制直接抛异常返回失败订单卡在中间态。解决给RestTemplate设置连接超时和读取超时常见做法是3秒连接、5秒读取。超时后走重试最多三次重试间隔按1秒、2秒、4秒递增。三次都不行订单进入FAILED状态同时把这个transactionIdentifier记入待人工核对队列。支付类业务宁可标记失败让用户查也不能卡在未知状态不管。6. 沙箱到生产切换自检清单与通用验证器6.1 上线前强制走一遍环境对照表项目里踩过的坑集中于环境切换。我把五件事做成了上线对照表前后端联调前逐项核对。配置项不多但每一项错一处整条链路就断掉。检查项沙箱生产客户端 merchantIdentifiermerchant.com.xxx.sandboxmerchant.com.xxx服务端证书别名apple-pay-sandboxapple-pay-prod证书有效期检查过期测试会拖慢联调过期直接拒绝验证API地址validation-pay.sandbox.apple.comvalidation-pay.apple.com日志级别DEBUGINFO禁用完整令牌输出这份表每次发布前过一遍通常五分钟之内能核对完。以前凭感觉切配置翻过两次车后来就把表直接贴进发布文档里每项打勾才允许上线。6.2 一段可复用的环境配置封装既然证书、商户号、验证地址三者必须绑定干脆做成配置绑定类代码里不要散落定义字符串。下面这段配置类放在config包下application.yml中按环境写死一组值就行。apple: pay: merchant-id: merchant.com.xxx verify-url: https://validation-pay.apple.com/paymentValidation/ key-store: classpath:keystore/apple-pay.p12 key-store-password: ${APPLE_PAY_STORE_PASSWORD} cert-alias: apple-pay-prod root-ca-alias: apple-root-caAPPLE_PAY_STORE_PASSWORD用环境变量注入不写进配置文件本身。cert-alias和root-ca-alias分别指向KeyStore里的商户证书别名和Apple根证书别名加载后业务代码只面对ApplePayProperties这个对象不会再出现拼错字符串的低级问题。沙箱环境切换时只改merchant-id、verify-url和cert-alias三个值改完就是一套完整环境。6.3 上线后的自检命令SpringBoot应用起来之后写一个CommandLineRunner在启动阶段做三项自检证书能否正常加载、根证书能否校验成功、验证API地址是否通。前两项是本地检查最后一项是网络探测三项不过直接抛出异常阻止应用继续启动。支付应用启动时快一两分钟不如先发现配置错误总好过线上用户付了款才发现全链路是断的。第一次把沙箱切到生产时我只是改了配置文件里的地址忘了换merchant-id客户端报invalid merchant identifier查了两个小时。后来我把环境切换和自检写进了启动流程从那以后每次上线都会强制走一遍“环境对照表加启动自检”确认日志里没有任何脱敏异常才敢把流量放进去。支付验证这种东西玄学的地方越少越好用流程把每个变量钉死剩下的问题就都能用日志说话了。希望帮到你。本文还有配套的精品资源点击获取
返回列表