diff --git a/src/main/java/com/yolo/keyborad/controller/AppleReceiptController.java b/src/main/java/com/yolo/keyborad/controller/AppleReceiptController.java index 914a5a1..c42b9bf 100644 --- a/src/main/java/com/yolo/keyborad/controller/AppleReceiptController.java +++ b/src/main/java/com/yolo/keyborad/controller/AppleReceiptController.java @@ -1,6 +1,8 @@ package com.yolo.keyborad.controller; import cn.dev33.satoken.stp.StpUtil; +import com.apple.itunes.storekit.model.JWSTransactionDecodedPayload; +import com.apple.itunes.storekit.verification.SignedDataVerifier; import com.yolo.keyborad.common.BaseResponse; import com.yolo.keyborad.common.ErrorCode; import com.yolo.keyborad.common.ResultUtils; @@ -10,13 +12,15 @@ import com.yolo.keyborad.model.dto.AppleServerNotification; import com.yolo.keyborad.service.ApplePurchaseService; import com.yolo.keyborad.service.AppleReceiptService; import jakarta.servlet.http.HttpServletRequest; -import com.yolo.keyborad.utils.JwtParser; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import lombok.extern.slf4j.Slf4j; +import java.time.Instant; +import java.util.Base64; +import java.util.Locale; import java.util.Map; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; @@ -26,23 +30,37 @@ import com.fasterxml.jackson.databind.ObjectMapper; @Slf4j public class AppleReceiptController { + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + private final AppleReceiptService appleReceiptService; private final ApplePurchaseService applePurchaseService; + private final SignedDataVerifier signedDataVerifier; public AppleReceiptController(AppleReceiptService appleReceiptService, - ApplePurchaseService applePurchaseService) { + ApplePurchaseService applePurchaseService, + SignedDataVerifier signedDataVerifier) { this.appleReceiptService = appleReceiptService; this.applePurchaseService = applePurchaseService; + this.signedDataVerifier = signedDataVerifier; } @PostMapping("/receipt") public AppleReceiptValidationResult validateReceipt(@RequestBody Map body) { + if (body == null) { + throw new BusinessException(ErrorCode.PARAMS_ERROR, "body 不能为空"); + } String receipt = body.get("receipt"); + if (receipt == null || receipt.isBlank()) { + throw new BusinessException(ErrorCode.PARAMS_ERROR, "receipt 不能为空"); + } return appleReceiptService.validateReceipt(receipt); } @PostMapping("/validate-receipt") public BaseResponse handlePurchase(@RequestBody Map body) { + if (body == null) { + throw new BusinessException(ErrorCode.PARAMS_ERROR, "body 不能为空"); + } String receipt = body.get("receipt"); if (receipt == null || receipt.isBlank()) { throw new BusinessException(ErrorCode.PARAMS_ERROR, "receipt 不能为空"); @@ -65,9 +83,11 @@ public class AppleReceiptController { */ @PostMapping("/notification") public BaseResponse receiveNotification(@RequestBody Map body, HttpServletRequest request) { + if (body == null) { + throw new BusinessException(ErrorCode.PARAMS_ERROR, "body 不能为空"); + } // 从请求体中获取 Apple 签名的载荷 String signedPayload = body.get("signedPayload"); - log.warn(body.toString()); // 校验 signedPayload 是否为空 if (signedPayload == null || signedPayload.isBlank()) { @@ -76,11 +96,19 @@ public class AppleReceiptController { // 解码签名载荷,获取通知详情 AppleServerNotification notification = decodeSignedPayload(signedPayload); - log.info("Apple server notification decoded: {}, query: {}", notification, request.getQueryString()); + log.info("Apple server notification decoded, type={}, env={}, query={}", + notification.getNotificationType(), + notification.getEnvironment(), + request != null ? request.getQueryString() : null); // 判断是否为续订相关通知,如果是则进行处理 - if (notification != null && notification.getNotificationType() != null - && notification.getNotificationType().toUpperCase().contains("RENEW")) { + String type = notification.getNotificationType(); + if (type != null + && type.toUpperCase(Locale.ROOT).contains("RENEW") + && notification.getSignedTransactionInfo() != null + && !notification.getSignedTransactionInfo().isBlank() + && notification.getOriginalTransactionId() != null + && !notification.getOriginalTransactionId().isBlank()) { applePurchaseService.processRenewNotification(notification); } @@ -98,49 +126,90 @@ public class AppleReceiptController { * @return AppleServerNotification 对象,包含解析后的通知详情 * @throws BusinessException 当参数无效或解析失败时抛出 */ + private AppleServerNotification decodeSignedPayload(String signedPayload) { try { - // 解析外层 JWT 载荷 - JsonNode root = JwtParser.parsePayload(signedPayload); + // 外层 notification 的 signedPayload 仅用于取出 signedTransactionInfo; + // 实际交易相关字段以 SignedDataVerifier 验签后的 transaction payload 为准,避免信任未验签的数据。 + JsonNode root = parseJwtPayloadWithoutVerification(signedPayload); AppleServerNotification notification = new AppleServerNotification(); - // 获取通知类型(支持驼峰和下划线两种命名格式) + // 从 JWT payload 根节点获取通知类型(支持驼峰和下划线两种命名格式) notification.setNotificationType(text(root, "notificationType", "notification_type")); - // 解析 data 节点中的基本信息 + // 解析 data 节点中的基本信息(环境和签名的交易信息) JsonNode data = root.get("data"); if (data != null && !data.isNull()) { + // 提取运行环境(Sandbox 或 Production) notification.setEnvironment(text(data, "environment")); - notification.setProductId(text(data, "productId", "product_id")); - notification.setOriginalTransactionId(text(data, "originalTransactionId", "original_transaction_id")); + // 提取签名的交易信息 JWT 字符串 notification.setSignedTransactionInfo(text(data, "signedTransactionInfo", "signed_transaction_info")); } - // 如果存在签名的交易信息,进一步解析嵌套的 JWT - if (notification.getSignedTransactionInfo() != null) { - JsonNode txNode = JwtParser.parsePayload(notification.getSignedTransactionInfo()); - - // 从交易信息中提取详细字段 - notification.setTransactionId(text(txNode, "transactionId", "transaction_id")); - - // 优先使用外层的值,如果为空则使用交易信息中的值 - notification.setProductId(firstNonBlank(notification.getProductId(), text(txNode, "productId", "product_id"))); - notification.setOriginalTransactionId(firstNonBlank(notification.getOriginalTransactionId(), text(txNode, "originalTransactionId", "original_transaction_id"))); - - // 将时间戳转换为 ISO 8601 格式 - notification.setPurchaseDate(epochToIso(txNode, "purchaseDate", "purchase_date")); - notification.setExpiresDate(epochToIso(txNode, "expiresDate", "expires_date")); + // 如果存在签名的交易信息,使用官方 SignedDataVerifier 进行验签并解码 + String signedTransactionInfo = notification.getSignedTransactionInfo(); + if (signedTransactionInfo != null && !signedTransactionInfo.isBlank()) { + // 调用 Apple 官方 SDK 验证签名并解码交易载荷 + JWSTransactionDecodedPayload txPayload = + signedDataVerifier.verifyAndDecodeTransaction(signedTransactionInfo); + + // 设置交易 ID(当前交易的唯一标识) + notification.setTransactionId(txPayload.getTransactionId()); + // 设置产品 ID(购买的商品标识符) + notification.setProductId(txPayload.getProductId()); + // 设置原始交易 ID(用于标识订阅组或首次购买) + notification.setOriginalTransactionId(txPayload.getOriginalTransactionId()); + + // 如果存在购买日期,将时间戳转换为 ISO 8601 格式字符串 + if (txPayload.getPurchaseDate() != null) { + notification.setPurchaseDate(Instant.ofEpochMilli(txPayload.getPurchaseDate()).toString()); + } + // 如果存在过期日期(订阅类商品),将时间戳转换为 ISO 8601 格式字符串 + if (txPayload.getExpiresDate() != null) { + notification.setExpiresDate(Instant.ofEpochMilli(txPayload.getExpiresDate()).toString()); + } + + // 如果外层未提供环境信息,则从交易载荷中获取并设置 + if ((notification.getEnvironment() == null || notification.getEnvironment().isBlank()) + && txPayload.getEnvironment() != null) { + notification.setEnvironment(txPayload.getEnvironment().name()); + } } return notification; } catch (IllegalArgumentException e) { + // 捕获参数校验异常,包装后抛出业务异常 throw new BusinessException(ErrorCode.PARAMS_ERROR, e.getMessage()); } catch (Exception e) { + // 捕获其他异常(如验签失败、解析失败等),记录日志并抛出操作异常 log.error("Failed to decode signedPayload", e); throw new BusinessException(ErrorCode.OPERATION_ERROR, "signedPayload 解析失败"); } } + + /** + * 解析 JWT 载荷内容(无验签) + * 仅用于从 JWT 中提取 payload 部分的 JSON 数据,不进行签名验证 + * 注意:此方法不验证 JWT 签名,仅用于快速解析结构,实际交易数据需通过 SignedDataVerifier 验签 + * + * @param jwt JWT 字符串,格式为 header.payload.signature + * @return 解析后的 JSON 节点,包含 payload 中的数据 + * @throws IllegalArgumentException 当 JWT 格式无效时抛出 + * @throws Exception 当 Base64 解码或 JSON 解析失败时抛出 + */ + private JsonNode parseJwtPayloadWithoutVerification(String jwt) throws Exception { + // 按点号分割 JWT,标准 JWT 包含三部分:header.payload.signature + String[] parts = jwt.split("\\."); + if (parts.length < 2) { + throw new IllegalArgumentException("Invalid JWT format"); + } + // 对 payload 部分进行 Base64 URL 解码 + byte[] payloadBytes = Base64.getUrlDecoder().decode(parts[1]); + // 将解码后的字节数组解析为 JSON 树结构 + return OBJECT_MAPPER.readTree(payloadBytes); + } + /** * 从 JSON 节点中提取文本值 * 支持多个候选键名,按顺序尝试获取第一个非空值 @@ -158,42 +227,4 @@ public class AppleReceiptController { return null; } - /** - * 返回第一个非空白的字符串 - * 用于在多个可选值中选择优先级更高的非空值 - * - * @param a 第一优先级的字符串 - * @param b 第二优先级的字符串 - * @return 第一个非空白的字符串,如果都为空则返回第一个参数 - */ - private String firstNonBlank(String a, String b) { - if (a != null && !a.isBlank()) { - return a; - } - return (b != null && !b.isBlank()) ? b : a; - } - - /** - * 将 Unix 时间戳(毫秒)转换为 ISO 8601 格式字符串 - * 从 JSON 节点中提取时间戳并转换为标准 ISO 格式 - * - * @param node JSON 节点 - * @param keys 候选的时间戳字段键名 - * @return ISO 8601 格式的时间字符串,如果解析失败则返回原始值 - */ - private String epochToIso(JsonNode node, String... keys) { - String val = text(node, keys); - if (val == null) { - return null; - } - try { - // 解析时间戳并转换为 ISO 格式 - long epochMillis = Long.parseLong(val); - return java.time.Instant.ofEpochMilli(epochMillis).toString(); - } catch (NumberFormatException e) { - // 如果不是有效的数字,直接返回原值 - return val; - } - } - }