ARTICLE DETAIL

资讯详情

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

SpringBoot 接入 Apple Pay 回调:JWT 验签与幂等落库实战

SpringBoot 接入 Apple Pay 回调:JWT 验签与幂等落库实战 简介这份资源面向在SpringBoot后端集成iOS端Apple Pay的开发者重点解决支付令牌回调验证这一关键环节。包内共98个文件以70个xml配置、10个class字节码、8个java源码及3个properties配置为主另含jar依赖、mvnw构建脚本与md说明文档压缩包约93KB属于可直接导入运行的Maven工程结构。内容围绕商户信息配置、支付令牌解码、JWT签名验证、与Apple支付验证API通信、交易入库及异常处理等环节展开并给出沙箱与生产环境的测试切换思路。已有498人学习下载适合具备一定SpringBoot基础、需要落地Apple Pay服务端校验的开发者参考可帮助快速理解验证链路、复用工程骨架并排查签名与网络异常同时提醒遵循Apple指南与PCI DSS要求避免在服务端存储敏感支付信息。1. 拆开 apple-pay.rar一个 SpringBoot 接 Apple Pay 回调的完整骨架上周有个做 iOS 电商的朋友半夜找我说 Apple Pay 前端弹窗能起来但服务端回调一直 500日志里全是 JWT 解析异常。我让他把工程打包发我解压一看就是个典型的apple-pay.rar——Maven 骨架、pom.xml、src/main、src/test、.mvn/wrapper一应俱全连.idea下的compiler.xml、encodings.xml都没删。这种包最有价值的地方不是代码多完整而是它把「SpringBoot 怎么接住 Apple Pay 那串加密 Payment Token」的链路摆出来了。Apple Pay 的支付令牌本质是个 JWT里面裹着交易金额、商户 ID、设备签名服务端要做的是解码、验签、再拿解码结果去 Apple 的验证接口换一个确认状态。这份资源适合两类人一是 iOS 端已经调通、卡在服务端验签的移动开发二是做聚合支付、需要把 Apple Pay 接进现有 SpringBoot 订单系统的后端。下面我按「先跑起来、再抠参数、最后排坑」的顺序拆一遍。2. 工程结构与依赖从 pom.xml 看 Apple Pay 验证需要哪些库2.1 目录里哪些文件真正参与支付验证解压后先别急着mvn spring-boot:run花两分钟认清结构。src/main/java放业务代码src/main/resources放配置src/test是测试target和generated-sources、generated-test-sources都是构建产物可以直接忽略甚至删掉再重新生成。.mvn/wrapper和mvnw.cmd是 Maven Wrapper保证你本地没装 Maven 也能用固定版本构建这点在团队协作里很关键——支付项目最怕「我这儿能跑你那儿报错」Wrapper 把 Maven 版本锁死能省掉一类玄学问题。.idea下那堆misc.xml、modules.xml、workspace.xml是 IntelliJ 的工程元数据jarRepositories.xml记录了你用过的远程仓库地址这些都不影响运行但workspace.xml里可能残留别人的本地路径导入后如果索引异常直接删掉.idea重新导入更干净。HELP.md一般是 Spring Initializr 生成的占位说明没有实际业务价值。真正要盯的是pom.xml。Apple Pay 服务端验证绕不开三件事解析 JWT、做 RSA/EC 验签、发 HTTPS 请求给 Apple。所以依赖里通常会有 JWT 库jjwt 或 nimbus-jose-jwt、BouncyCastle处理 Apple 返回的证书链、以及 Spring 的spring-boot-starter-web。下面是我一般会检查的依赖片段dependencies !-- Web 层接收 iOS 端 POST 过来的 payment token -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency !-- JWT 解析Apple Pay token 是三层结构的 JWS -- dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-api/artifactId version0.11.5/version /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-impl/artifactId version0.11.5/version scoperuntime/scope /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-jackson/artifactId version0.11.5/version scoperuntime/scope /dependency !-- 处理 PKCS#7 / 证书链验签时会用到 -- dependency groupIdorg.bouncycastle/groupId artifactIdbcpkix-jdk18on/artifactId version1.77/version /dependency /dependencies逻辑说明jjwt拆成 api/impl/jackson 三块是它 0.11 之后的惯例api 编译期用impl 和 jackson 运行期才加载scope 写错会导致启动时NoClassDefFoundError。BouncyCastle 的版本号要和你 JDK 对齐jdk18on是给 JDK 1.8 及以上用的如果你项目还在 JDK 8别引成jdk15on的老包否则验签时算法提供者注册不上。参数上jjwt的版本不要低于 0.11早期 0.9.x 的 API 是Jwts.parser().setSigningKey()和现在完全不一样网上抄代码最容易在这儿翻车。2.2 用 Maven Wrapper 把工程跑起来确认依赖后用 Wrapper 构建别用系统全局的 mvn避免版本差异# Linux / macOS ./mvnw clean package -DskipTests # Windows mvnw.cmd clean package -DskipTestsclean清掉target里的旧产物package走到打包阶段-DskipTests先跳过测试——第一次跑先确认能编译通过测试留到后面单独跑。如果这一步报Could not resolve dependencies先看.mvn/wrapper/maven-wrapper.properties里的 distributionUrl 指向哪个版本再确认jarRepositories.xml里记录的仓库地址在你当前网络下能不能通。构建成功后target下会有 jarjava -jar起服务默认 8080。这一步的意义是先把「工程本身没问题」和「支付逻辑有问题」分开不然你会在依赖和业务之间反复横跳。3. 支付令牌解码与验签ApplePayVerificationService 怎么写3.1 Payment Token 的三段结构和字段含义iOS 端传过来的 payment token 是个 JWS形如header.payload.signature三段 Base64Url。header 里alg通常是ES256x5c是一串证书链payload 里才是业务数据关键字段有transactionId、applicationData哈希后的业务标识、amount最小货币单位比如人民币是分、currencyCode、merchantId、deviceManufacturerIdentifier等。很多人一上来就Jwts.parser().parseClaimsJws(token)结果抛SignatureException因为 Apple 的 token 不是用你的密钥签的而是用 Apple 自己的私钥签的验签要用x5c里的证书公钥且证书链要能追到 Apple Root CA。这就是为什么前面要引 BouncyCastle。下面是我在ApplePayVerificationService里常用的解码方法先只做解析不做验签确认字段能读出来public class ApplePayVerificationService { private static final ObjectMapper MAPPER new ObjectMapper(); /** * 解析 Apple Pay payment token返回 payload 中的业务字段 * 注意此方法只做 Base64 解码不做签名校验仅用于调试 */ public MapString, Object decodeToken(String paymentToken) throws IOException { String[] parts paymentToken.split(\\.); if (parts.length ! 3) { throw new IllegalArgumentException(payment token 不是合法的 JWS 三段结构); } // JWS 用的是 Base64Url不是标准 Base64末尾的 要补回来 byte[] payloadBytes Base64.getUrlDecoder().decode(padBase64(parts[1])); return MAPPER.readValue(payloadBytes, new TypeReferenceMapString, Object() {}); } private String padBase64(String raw) { int mod raw.length() % 4; if (mod 2) return raw ; if (mod 3) return raw ; return raw; } }逻辑说明split(\\.)按点切三段parts[1]是 payload。Base64Url 解码前要补因为 JWS 规范去掉了填充符JDK 的Base64.getUrlDecoder()对缺填充的串会抛IllegalArgumentException这是最常见的第一个坑。参数上paymentToken直接来自请求体别做 trim前后空格会破坏 Base64。返回的 Map 里amount是数字类型transactionId是字符串取的时候别一律toString()金额要按BigDecimal处理否则对账时精度丢失。3.2 用 x5c 证书链做 ES256 验签调试通了字段再把验签加上。Apple 的x5c是个证书数组第一个是叶子证书用它里面的公钥验签同时要校验证书链是否可信。完整实现偏长核心逻辑是这样public boolean verifySignature(String paymentToken) throws Exception { String[] parts paymentToken.split(\\.); String headerJson new String(Base64.getUrlDecoder().decode(padBase64(parts[0])), StandardCharsets.UTF_8); JsonNode header MAPPER.readTree(headerJson); // x5c 是证书链取第一个叶子证书 JsonNode x5c header.get(x5c); if (x5c null || x5c.isEmpty()) { throw new IllegalStateException(header 中缺少 x5c 证书链); } CertificateFactory cf CertificateFactory.getInstance(X.509, BC); X509Certificate leaf (X509Certificate) cf.generateCertificate( new ByteArrayInputStream(Base64.getDecoder().decode(x5c.get(0).asText()))); // 用叶子证书公钥验签签名算法 ES256 Signature sig Signature.getInstance(SHA256withECDSA, BC); sig.initVerify(leaf.getPublicKey()); sig.update((parts[0] . parts[1]).getBytes(StandardCharsets.US_ASCII)); byte[] signature Base64.getUrlDecoder().decode(padBase64(parts[2])); return sig.verify(signature); }逻辑说明验签的数据是header.payload拼接后的 ASCII 字节不是整个 token这点和普通 JWT 一致。Signature.getInstance(SHA256withECDSA, BC)第二个参数指定 BouncyCastle 提供者前提是你启动时注册了Security.addProvider(new BouncyCastleProvider())否则会走 JDK 默认实现某些 JDK 版本对 ES256 的 DER 编码处理不一致验签会莫名失败。参数上x5c.get(0)是叶子证书生产环境还应该继续校验x5c里后续证书能否链到 Apple Root CA以及证书有效期只验签名不验链等于没验。这一步做完verifySignature返回 false 就直接拒绝交易别往下走。3.3 调 Apple 验证接口拿最终状态本地验签只证明 token 没被篡改交易是否真实成功还得问 Apple。把解码后的 token 原样 POST 到 Apple 的验证地址沙箱和生产是两个不同域名配置里要能切换Value(${applepay.verify-url}) private String verifyUrl; public String confirmWithApple(String paymentToken) { HttpHeaders headers new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); MapString, String body Collections.singletonMap(paymentToken, paymentToken); ResponseEntityString resp new RestTemplate() .postForEntity(verifyUrl, new HttpEntity(body, headers), String.class); if (!resp.getStatusCode().is2xxSuccessful()) { throw new IllegalStateException(Apple 验证接口返回异常: resp.getStatusCode()); } return resp.getBody(); }逻辑说明请求体字段名就是paymentToken值传原始 JWS 串不要自己再包一层。verifyUrl从配置读沙箱和生产用不同 profile 切换别硬编码。返回体里通常有status字段只有明确成功才落库。参数上RestTemplate的超时一定要设默认无超时Apple 接口抖动时会把你的线程池拖垮生产环境建议换成带连接池的WebClient或配置SimpleClientHttpRequestFactory的 connectTimeout/readTimeout。4. 落库、幂等与订单状态机验证通过之后别急着发货4.1 交易表结构和幂等键设计验证通过只是「这笔支付是真的」不代表「这笔订单该发货」。我见过最惨的事故是回调重试导致同一笔交易扣了两次库存。Apple 的回调和你的前端重试都可能让同一个transactionId进来多次所以落库第一件事是给transaction_id加唯一索引插入冲突就当作重复回调直接返回成功。表结构大致这样CREATE TABLE apple_pay_transaction ( id BIGINT PRIMARY KEY AUTO_INCREMENT, transaction_id VARCHAR(64) NOT NULL, merchant_id VARCHAR(64) NOT NULL, order_no VARCHAR(64) NOT NULL, amount DECIMAL(18,2) NOT NULL, currency_code VARCHAR(8) NOT NULL, status VARCHAR(16) NOT NULL COMMENT VERIFIED/FAILED/REFUNDED, raw_token TEXT COMMENT 原始 token排查用注意脱敏, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_transaction (transaction_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;逻辑说明uk_transaction是幂等的核心重复插入会抛DuplicateKeyException在 service 里 catch 住返回「已处理」即可。amount用DECIMAL不用FLOAT支付金额绝不能用浮点。raw_token存原始串是为了出问题时能复现但里面含设备信息生产环境要么加密存要么只存哈希别裸奔。参数上transaction_id长度给 64 够用currency_code按 ISO 4217 三位码别存「人民币」这种中文。4.2 订单状态流转和并发控制交易落库后要更新订单状态这一步必须和订单表在同一个事务里且要防并发。常见做法是「先查订单当前状态再条件更新」Transactional public void markOrderPaid(String orderNo, String transactionId) { // 悲观锁或乐观锁二选一这里用条件更新模拟乐观锁 int updated orderMapper.updateStatusIfPending(orderNo, PAID, transactionId); if (updated 0) { // 订单不存在或已是 PAID查一下区分是重复回调还是异常 Order order orderMapper.selectByNo(orderNo); if (order ! null PAID.equals(order.getStatus())) { return; // 重复回调幂等返回 } throw new IllegalStateException(订单状态异常orderNo orderNo); } }逻辑说明updateStatusIfPending的 SQL 是UPDATE orders SET statusPAID WHERE order_no? AND statusPENDING靠数据库的行锁保证只有一个线程能改成功返回 0 就说明要么订单不存在要么已经被改过。参数上orderNo从 token 的applicationData里解出来别信前端单独传的订单号两者要对得上对不上直接拒绝——这是防篡改的关键一环。事务边界要包住「插交易 改订单」只包一半会出现交易记录有了订单没改的脏数据。5. 避坑与排查Apple Pay 回调验证最常见的五个翻车点5.1 现象验签一直 SignatureException换环境又好了原因JDK 版本差异导致 ES256 的 DER 签名编码处理不同或者没注册 BouncyCastle 提供者走了 JDK 默认实现。解决启动类里显式Security.addProvider(new BouncyCastleProvider())验签时Signature.getInstance(SHA256withECDSA, BC)指定提供者团队统一 JDK 版本写进pom.xml的maven.compiler.source/target。5.2 现象Base64 解码抛 IllegalArgumentException原因JWS 的 Base64Url 去掉了填充符直接Base64.getDecoder().decode()或getUrlDecoder().decode()对长度不是 4 的倍数会报错。解决解码前按长度补就是 3.1 里那个padBase64别用标准 Base64 解 URL 安全的串字符集不一样。5.3 现象沙箱能过生产验签失败原因沙箱和生产的证书链根不同或者merchantId配错。解决verifyUrl和商户证书按 profile 隔离生产环境的merchantId必须和 Apple Developer 后台注册的完全一致大小写敏感。上线前用生产证书在沙箱跑一遍验签逻辑确认代码不依赖沙箱特有字段。5.4 现象回调重复进来库存扣了两次原因没做幂等或者幂等键选错用了订单号而不是transactionId。解决transaction_id唯一索引 条件更新订单状态重复回调 catchDuplicateKeyException后直接返回成功别抛异常让 Apple 继续重试。5.5 现象Apple 验证接口偶发超时线程池打满原因RestTemplate默认无超时Apple 接口抖动时请求堆积。解决配置 connectTimeout 和 readTimeout一般 3s/5s超时后记录日志并返回可重试状态别在回调线程里同步等太久重活丢给消息队列异步处理。6. 进阶把验证逻辑抽成可测试的纯函数用沙箱数据回归支付代码最难测因为真实 token 拿不到。我的习惯是把「解码 验签 字段提取」抽成一个不依赖 Spring 上下文的纯函数类输入是 token 字符串输出是校验结果对象这样单元测试可以直接喂构造的 JWS。构造测试 token 时用 BouncyCastle 自己生成一对 EC 密钥按 JWS 格式拼三段签名用私钥验签用公钥就能覆盖正常和篡改两种路径Test void shouldRejectTamperedPayload() throws Exception { KeyPair kp generateEcKeyPair(); String token buildJws(kp.getPrivate(), {\amount\:100,\currencyCode\:\CNY\}); // 篡改 payload 后再验签必须失败 String tampered token.replace(eyJhbW91bnQi, eyJhbW91bnQiX); assertFalse(verifier.verifySignature(tampered)); }逻辑说明buildJws自己拼 header含 x5c 或直接放公钥、payload、签名三段generateEcKeyPair用KeyPairGenerator.getInstance(EC)生成 P-256 曲线密钥。这样测试不依赖网络和 Apple 环境CI 里能跑。参数上曲线必须用secp256r1即 P-256Apple 用的就是这条曲线换成别的曲线验签算法对不上。真实联调时把沙箱环境跑出来的 token 脱敏后存成 fixture每次改验签逻辑都拿它回归一遍比重新走一遍 iOS 端快得多。从那以后我每次接支付回调都强制先把「解码、验签、幂等」三件事拆成独立可测的单元再谈业务逻辑因为这三块一旦混在 Controller 里出问题时你连日志都定位不到。希望帮到你。本文还有配套的精品资源点击获取
返回列表