Compare commits

...

1 Commits

View File

@@ -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<String, String> 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<Boolean> handlePurchase(@RequestBody Map<String, String> 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<Boolean> receiveNotification(@RequestBody Map<String, String> 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;
}
}
}