feat(apple): 支持App Store Server V2通知全类型处理
- 新增订阅、退款、偏好变更、消费请求等通知处理器 - 统一使用ResponseBodyV2DecodedPayload验签与分发 - 移除控制器层JWT解析逻辑,下沉至服务层 - 增加幂等、状态回滚及权益撤销/恢复能力
This commit is contained in:
@@ -1,48 +1,33 @@
|
||||
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;
|
||||
import com.yolo.keyborad.exception.BusinessException;
|
||||
import com.yolo.keyborad.model.dto.AppleReceiptValidationResult;
|
||||
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 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;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/apple")
|
||||
@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,
|
||||
SignedDataVerifier signedDataVerifier) {
|
||||
ApplePurchaseService applePurchaseService) {
|
||||
this.appleReceiptService = appleReceiptService;
|
||||
this.applePurchaseService = applePurchaseService;
|
||||
this.signedDataVerifier = signedDataVerifier;
|
||||
}
|
||||
|
||||
@PostMapping("/receipt")
|
||||
@@ -75,158 +60,29 @@ public class AppleReceiptController {
|
||||
|
||||
/**
|
||||
* 接收 Apple 服务器通知
|
||||
* 处理来自 Apple 的服务器到服务器通知,主要用于订阅续订等事件
|
||||
* 处理来自 Apple 的服务器到服务器通知,包括订阅续订、退款等事件
|
||||
* 所有验证和处理逻辑都委托给 service 层
|
||||
*
|
||||
* @param body 请求体,包含 signedPayload 字段
|
||||
* @param request HTTP 请求对象
|
||||
* @return 处理结果
|
||||
* @throws BusinessException 当 signedPayload 为空或解析失败时抛出
|
||||
* @throws BusinessException 当 signedPayload 为空时抛出
|
||||
*/
|
||||
@PostMapping("/notification")
|
||||
public BaseResponse<Boolean> receiveNotification(@RequestBody Map<String, String> body, HttpServletRequest request) {
|
||||
|
||||
|
||||
public BaseResponse<Boolean> receiveNotification(@RequestBody Map<String, String> body) {
|
||||
// 参数校验
|
||||
if (body == null) {
|
||||
throw new BusinessException(ErrorCode.PARAMS_ERROR, "body 不能为空");
|
||||
}
|
||||
// 从请求体中获取 Apple 签名的载荷
|
||||
|
||||
String signedPayload = body.get("signedPayload");
|
||||
// 校验 signedPayload 是否为空
|
||||
if (signedPayload == null || signedPayload.isBlank()) {
|
||||
throw new BusinessException(ErrorCode.PARAMS_ERROR, "signedPayload 不能为空");
|
||||
}
|
||||
|
||||
// 解码签名载荷,获取通知详情
|
||||
AppleServerNotification notification = decodeSignedPayload(signedPayload);
|
||||
log.info("Apple server notification decoded, type={}, env={}, query={}",
|
||||
notification.getNotificationType(),
|
||||
notification.getEnvironment(),
|
||||
request != null ? request.getQueryString() : null);
|
||||
|
||||
// 判断是否为续订相关通知,如果是则进行处理
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
// 委托给 service 层处理所有通知逻辑
|
||||
appleReceiptService.processNotification(signedPayload);
|
||||
|
||||
return ResultUtils.success(Boolean.TRUE);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* 解码 Apple 签名的 JWT 载荷
|
||||
* 从 signedPayload 中解析出服务器通知的详细信息,包括通知类型、环境、产品ID、交易信息等
|
||||
*
|
||||
* @param signedPayload Apple 服务器发送的签名载荷(JWT 格式)
|
||||
* @return AppleServerNotification 对象,包含解析后的通知详情
|
||||
* @throws BusinessException 当参数无效或解析失败时抛出
|
||||
*/
|
||||
|
||||
private AppleServerNotification decodeSignedPayload(String signedPayload) {
|
||||
try {
|
||||
// 外层 notification 的 signedPayload 仅用于取出 signedTransactionInfo;
|
||||
// 实际交易相关字段以 SignedDataVerifier 验签后的 transaction payload 为准,避免信任未验签的数据。
|
||||
JsonNode root = parseJwtPayloadWithoutVerification(signedPayload);
|
||||
AppleServerNotification notification = new AppleServerNotification();
|
||||
|
||||
// 从 JWT payload 根节点获取通知类型(支持驼峰和下划线两种命名格式)
|
||||
notification.setNotificationType(text(root, "notificationType", "notification_type"));
|
||||
|
||||
// 解析 data 节点中的基本信息(环境和签名的交易信息)
|
||||
JsonNode data = root.get("data");
|
||||
if (data != null && !data.isNull()) {
|
||||
// 提取运行环境(Sandbox 或 Production)
|
||||
notification.setEnvironment(text(data, "environment"));
|
||||
// 提取签名的交易信息 JWT 字符串
|
||||
notification.setSignedTransactionInfo(text(data, "signedTransactionInfo", "signed_transaction_info"));
|
||||
}
|
||||
|
||||
// 如果存在签名的交易信息,使用官方 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 节点中提取文本值
|
||||
* 支持多个候选键名,按顺序尝试获取第一个非空值
|
||||
*
|
||||
* @param node JSON 节点
|
||||
* @param keys 候选的键名列表(支持驼峰和下划线命名)
|
||||
* @return 找到的第一个非空文本值,如果都不存在则返回 null
|
||||
*/
|
||||
private String text(JsonNode node, String... keys) {
|
||||
for (String k : keys) {
|
||||
if (node != null && node.has(k) && !node.get(k).isNull()) {
|
||||
return node.get(k).asText();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user