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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.yolo.keyborad.service;
|
||||
|
||||
import com.apple.itunes.storekit.model.ResponseBodyV2DecodedPayload;
|
||||
import com.yolo.keyborad.model.dto.AppleReceiptValidationResult;
|
||||
|
||||
/**
|
||||
@@ -16,9 +17,30 @@ public interface ApplePurchaseService {
|
||||
void processPurchase(Long userId, AppleReceiptValidationResult validationResult);
|
||||
|
||||
/**
|
||||
* 处理苹果服务器续订通知(无收据,仅基于原始交易号和商品信息)
|
||||
* 处理订阅相关通知(新订阅、续订、续订失败、过期等)
|
||||
*
|
||||
* @param notification 通知内容
|
||||
* @param notification 解码后的通知载荷
|
||||
*/
|
||||
void processRenewNotification(com.yolo.keyborad.model.dto.AppleServerNotification notification);
|
||||
void handleSubscriptionNotification(ResponseBodyV2DecodedPayload notification);
|
||||
|
||||
/**
|
||||
* 处理退款相关通知(退款、退款拒绝、退款撤销)
|
||||
*
|
||||
* @param notification 解码后的通知载荷
|
||||
*/
|
||||
void handleRefundNotification(ResponseBodyV2DecodedPayload notification);
|
||||
|
||||
/**
|
||||
* 处理续订偏好变更通知
|
||||
*
|
||||
* @param notification 解码后的通知载荷
|
||||
*/
|
||||
void handleRenewalPreferenceChange(ResponseBodyV2DecodedPayload notification);
|
||||
|
||||
/**
|
||||
* 处理消费请求通知
|
||||
*
|
||||
* @param notification 解码后的通知载荷
|
||||
*/
|
||||
void handleConsumptionRequest(ResponseBodyV2DecodedPayload notification);
|
||||
}
|
||||
|
||||
@@ -5,9 +5,17 @@ import com.yolo.keyborad.model.dto.AppleReceiptValidationResult;
|
||||
public interface AppleReceiptService {
|
||||
|
||||
/**
|
||||
* 验证 base64 app receipt 是否有效,并返回解析结果。
|
||||
* 验证 JWS 交易数据是否有效,并返回解析结果。
|
||||
*
|
||||
* @param signedPayload Base64 的 app receipt(以 MI... 开头那串)
|
||||
* @param signedTransaction JWS 格式的签名交易数据
|
||||
*/
|
||||
AppleReceiptValidationResult validateReceipt(String signedPayload);
|
||||
AppleReceiptValidationResult validateReceipt(String signedTransaction);
|
||||
|
||||
/**
|
||||
* 处理 Apple 服务器通知
|
||||
* 验证通知签名并根据通知类型分发到相应的处理逻辑
|
||||
*
|
||||
* @param signedPayload Apple 服务器发送的签名载荷(JWT 格式)
|
||||
*/
|
||||
void processNotification(String signedPayload);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
package com.yolo.keyborad.service.impl;
|
||||
|
||||
import com.apple.itunes.storekit.model.JWSRenewalInfoDecodedPayload;
|
||||
import com.apple.itunes.storekit.model.JWSTransactionDecodedPayload;
|
||||
import com.apple.itunes.storekit.model.NotificationTypeV2;
|
||||
import com.apple.itunes.storekit.model.ResponseBodyV2DecodedPayload;
|
||||
import com.apple.itunes.storekit.verification.SignedDataVerifier;
|
||||
import com.apple.itunes.storekit.verification.VerificationException;
|
||||
import com.yolo.keyborad.common.ErrorCode;
|
||||
import com.yolo.keyborad.exception.BusinessException;
|
||||
import com.yolo.keyborad.model.dto.AppleReceiptValidationResult;
|
||||
@@ -51,6 +57,9 @@ public class ApplePurchaseServiceImpl implements ApplePurchaseService {
|
||||
@Resource
|
||||
private UserService userService;
|
||||
|
||||
@Resource
|
||||
private SignedDataVerifier signedDataVerifier;
|
||||
|
||||
|
||||
/**
|
||||
* 处理苹果购买(订阅或内购)
|
||||
@@ -115,72 +124,460 @@ public class ApplePurchaseServiceImpl implements ApplePurchaseService {
|
||||
|
||||
|
||||
/**
|
||||
* 处理苹果订阅续期通知
|
||||
* 当用户的订阅自动续费时,苹果会发送通知到我们的服务器
|
||||
*
|
||||
* @param notification 苹果服务器通知对象,包含续订交易信息
|
||||
* @throws BusinessException 当参数缺失、商品不存在或更新失败时抛出
|
||||
* 处理订阅相关通知
|
||||
* 包括:新订阅、续订、续订失败、过期、宽限期过期、优惠兑换、续订延长等
|
||||
*
|
||||
* @param notification 解码后的通知载荷
|
||||
*/
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void processRenewNotification(AppleServerNotification notification) {
|
||||
// 参数校验:确保通知对象和原始交易ID不为空
|
||||
if (notification == null || notification.getOriginalTransactionId() == null) {
|
||||
throw new BusinessException(ErrorCode.LACK_ORIGIN_TRANSACTION_ID_ERROR);
|
||||
}
|
||||
|
||||
// 根据原始交易ID查询历史购买记录
|
||||
// 原始交易ID可能对应多条记录(首次购买 + 多次续订),取最新的一条
|
||||
List<KeyboardUserPurchaseRecords> records = purchaseRecordsService.lambdaQuery()
|
||||
.eq(KeyboardUserPurchaseRecords::getOriginalTransactionId, notification.getOriginalTransactionId())
|
||||
.orderByDesc(KeyboardUserPurchaseRecords::getId)
|
||||
.last("LIMIT 1")
|
||||
.list();
|
||||
|
||||
// 如果找不到匹配的购买记录,记录警告并返回
|
||||
if (records == null || records.isEmpty()) {
|
||||
log.warn("Renewal notification without matching purchase record, originalTransactionId={}", notification.getOriginalTransactionId());
|
||||
return;
|
||||
}
|
||||
KeyboardUserPurchaseRecords record = records.get(0);
|
||||
|
||||
// 根据商品ID获取商品信息
|
||||
KeyboardProductItems product = productItemsService.getProductEntityByProductId(notification.getProductId());
|
||||
|
||||
// 校验商品是否存在且类型为订阅类型
|
||||
if (product == null || !"subscription".equalsIgnoreCase(product.getType())) {
|
||||
log.warn("Renewal notification ignored, product not subscription or not found. productId={}", notification.getProductId());
|
||||
public void handleSubscriptionNotification(ResponseBodyV2DecodedPayload notification) {
|
||||
if (notification == null || notification.getData() == null) {
|
||||
log.warn("Subscription notification data is null");
|
||||
return;
|
||||
}
|
||||
|
||||
// 构建续订购买记录对象
|
||||
NotificationTypeV2 type = notification.getNotificationType();
|
||||
log.info("Processing subscription notification: type={}, subtype={}",
|
||||
type, notification.getSubtype());
|
||||
|
||||
try {
|
||||
// 解码交易信息
|
||||
String signedTransactionInfo = notification.getData().getSignedTransactionInfo();
|
||||
if (signedTransactionInfo == null || signedTransactionInfo.isBlank()) {
|
||||
log.warn("No signed transaction info in notification");
|
||||
return;
|
||||
}
|
||||
|
||||
JWSTransactionDecodedPayload transaction =
|
||||
signedDataVerifier.verifyAndDecodeTransaction(signedTransactionInfo);
|
||||
|
||||
String originalTransactionId = transaction.getOriginalTransactionId();
|
||||
String productId = transaction.getProductId();
|
||||
|
||||
// 根据原始交易ID查询用户购买记录
|
||||
List<KeyboardUserPurchaseRecords> records = purchaseRecordsService.lambdaQuery()
|
||||
.eq(KeyboardUserPurchaseRecords::getOriginalTransactionId, originalTransactionId)
|
||||
.orderByDesc(KeyboardUserPurchaseRecords::getId)
|
||||
.last("LIMIT 1")
|
||||
.list();
|
||||
|
||||
if (records == null || records.isEmpty()) {
|
||||
log.warn("No purchase record found for originalTransactionId={}", originalTransactionId);
|
||||
return;
|
||||
}
|
||||
|
||||
KeyboardUserPurchaseRecords existingRecord = records.get(0);
|
||||
Long userId = existingRecord.getUserId().longValue();
|
||||
|
||||
// 查询商品信息
|
||||
KeyboardProductItems product = productItemsService.getProductEntityByProductId(productId);
|
||||
if (product == null || !"subscription".equalsIgnoreCase(product.getType())) {
|
||||
log.warn("Product not found or not subscription type: productId={}", productId);
|
||||
return;
|
||||
}
|
||||
|
||||
// 根据通知类型处理
|
||||
switch (type) {
|
||||
case SUBSCRIBED:
|
||||
case DID_RENEW:
|
||||
case OFFER_REDEEMED:
|
||||
// 续订成功:创建新的购买记录并延长VIP
|
||||
handleSuccessfulRenewal(userId, product, transaction);
|
||||
break;
|
||||
|
||||
case DID_FAIL_TO_RENEW:
|
||||
// 续订失败:记录日志,不修改VIP状态
|
||||
log.warn("Subscription renewal failed for user={}, productId={}", userId, productId);
|
||||
break;
|
||||
|
||||
case EXPIRED:
|
||||
handleSubscriptionExpired(userId);
|
||||
break;
|
||||
case GRACE_PERIOD_EXPIRED:
|
||||
// 订阅过期:更新用户VIP状态
|
||||
handleSubscriptionExpired(userId);
|
||||
break;
|
||||
|
||||
case RENEWAL_EXTENDED:
|
||||
case RENEWAL_EXTENSION:
|
||||
// 续订延长:延长VIP有效期
|
||||
Instant expiresDate = transaction.getExpiresDate() != null
|
||||
? Instant.ofEpochMilli(transaction.getExpiresDate())
|
||||
: null;
|
||||
extendVip(userId, product, expiresDate);
|
||||
break;
|
||||
|
||||
default:
|
||||
log.info("Subscription notification type {} - no specific action", type);
|
||||
}
|
||||
|
||||
} catch (VerificationException e) {
|
||||
log.error("Failed to verify transaction in notification", e);
|
||||
} catch (Exception e) {
|
||||
log.error("Error processing subscription notification", e);
|
||||
throw new BusinessException(ErrorCode.OPERATION_ERROR, "处理订阅通知失败");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理退款相关通知
|
||||
* 包括:退款、退款拒绝、退款撤销
|
||||
*
|
||||
* @param notification 解码后的通知载荷
|
||||
*/
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void handleRefundNotification(ResponseBodyV2DecodedPayload notification) {
|
||||
if (notification == null || notification.getData() == null) {
|
||||
log.warn("Refund notification data is null");
|
||||
return;
|
||||
}
|
||||
|
||||
NotificationTypeV2 type = notification.getNotificationType();
|
||||
log.info("Processing refund notification: type={}, subtype={}",
|
||||
type, notification.getSubtype());
|
||||
|
||||
try {
|
||||
String signedTransactionInfo = notification.getData().getSignedTransactionInfo();
|
||||
if (signedTransactionInfo == null || signedTransactionInfo.isBlank()) {
|
||||
log.warn("No signed transaction info in refund notification");
|
||||
return;
|
||||
}
|
||||
|
||||
JWSTransactionDecodedPayload transaction =
|
||||
signedDataVerifier.verifyAndDecodeTransaction(signedTransactionInfo);
|
||||
|
||||
String transactionId = transaction.getTransactionId();
|
||||
|
||||
// 查询购买记录
|
||||
KeyboardUserPurchaseRecords record = purchaseRecordsService.lambdaQuery()
|
||||
.eq(KeyboardUserPurchaseRecords::getTransactionId, transactionId)
|
||||
.one();
|
||||
|
||||
if (record == null) {
|
||||
log.warn("No purchase record found for transactionId={}", transactionId);
|
||||
return;
|
||||
}
|
||||
|
||||
Long userId = record.getUserId().longValue();
|
||||
KeyboardProductItems product = productItemsService.getProductEntityByProductId(record.getProductId());
|
||||
|
||||
if (product == null) {
|
||||
log.warn("Product not found: productId={}", record.getProductId());
|
||||
return;
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case REFUND:
|
||||
// 退款:撤销用户权益
|
||||
handleRefund(userId, product, record);
|
||||
break;
|
||||
|
||||
case REFUND_DECLINED:
|
||||
// 退款拒绝:无需操作
|
||||
log.info("Refund declined for user={}, transactionId={}", userId, transactionId);
|
||||
break;
|
||||
|
||||
case REFUND_REVERSED:
|
||||
// 退款撤销:恢复用户权益
|
||||
handleRefundReversed(userId, product, record);
|
||||
break;
|
||||
|
||||
default:
|
||||
log.info("Refund notification type {} - no specific action", type);
|
||||
}
|
||||
|
||||
} catch (VerificationException e) {
|
||||
log.error("Failed to verify transaction in refund notification", e);
|
||||
} catch (Exception e) {
|
||||
log.error("Error processing refund notification", e);
|
||||
throw new BusinessException(ErrorCode.OPERATION_ERROR, "处理退款通知失败");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理续订偏好变更通知
|
||||
* 包括:续订偏好变更、续订状态变更、价格上涨
|
||||
*
|
||||
* @param notification 解码后的通知载荷
|
||||
*/
|
||||
@Override
|
||||
public void handleRenewalPreferenceChange(ResponseBodyV2DecodedPayload notification) {
|
||||
if (notification == null || notification.getData() == null) {
|
||||
log.warn("Renewal preference notification data is null");
|
||||
return;
|
||||
}
|
||||
|
||||
NotificationTypeV2 type = notification.getNotificationType();
|
||||
log.info("Processing renewal preference notification: type={}, subtype={}",
|
||||
type, notification.getSubtype());
|
||||
|
||||
try {
|
||||
String signedRenewalInfo = notification.getData().getSignedRenewalInfo();
|
||||
if (signedRenewalInfo != null && !signedRenewalInfo.isBlank()) {
|
||||
JWSRenewalInfoDecodedPayload renewalInfo =
|
||||
signedDataVerifier.verifyAndDecodeRenewalInfo(signedRenewalInfo);
|
||||
|
||||
log.info("Renewal preference changed: autoRenewStatus={}, productId={}",
|
||||
renewalInfo.getAutoRenewStatus(),
|
||||
renewalInfo.getAutoRenewProductId());
|
||||
}
|
||||
|
||||
// 记录偏好变更,但不修改用户状态
|
||||
// 实际的续订状态会在下次续订时通过 DID_RENEW 或 DID_FAIL_TO_RENEW 通知
|
||||
|
||||
} catch (VerificationException e) {
|
||||
log.error("Failed to verify renewal info in notification", e);
|
||||
} catch (Exception e) {
|
||||
log.error("Error processing renewal preference notification", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理消费请求通知
|
||||
* Apple 请求提供消费数据以评估退款请求
|
||||
*
|
||||
* @param notification 解码后的通知载荷
|
||||
*/
|
||||
@Override
|
||||
public void handleConsumptionRequest(ResponseBodyV2DecodedPayload notification) {
|
||||
if (notification == null || notification.getData() == null) {
|
||||
log.warn("Consumption request notification data is null");
|
||||
return;
|
||||
}
|
||||
|
||||
log.info("Received consumption request notification - manual review may be required");
|
||||
|
||||
// TODO: 实现消费数据上报逻辑
|
||||
// 可以调用 AppStoreServerAPIClient.sendConsumptionData() 方法
|
||||
// 提供用户消费状态、交付状态等信息,帮助 Apple 评估退款请求
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理成功的续订
|
||||
* 创建新的购买记录并延长VIP有效期
|
||||
*/
|
||||
private void handleSuccessfulRenewal(Long userId, KeyboardProductItems product,
|
||||
JWSTransactionDecodedPayload transaction) {
|
||||
// 幂等性检查
|
||||
boolean exists = purchaseRecordsService.lambdaQuery()
|
||||
.eq(KeyboardUserPurchaseRecords::getTransactionId, transaction.getTransactionId())
|
||||
.exists();
|
||||
|
||||
if (exists) {
|
||||
log.info("Renewal already processed: transactionId={}", transaction.getTransactionId());
|
||||
return;
|
||||
}
|
||||
|
||||
// 创建续订购买记录
|
||||
KeyboardUserPurchaseRecords renewRecord = new KeyboardUserPurchaseRecords();
|
||||
renewRecord.setUserId(record.getUserId());
|
||||
renewRecord.setUserId(userId.intValue());
|
||||
renewRecord.setProductId(product.getProductId());
|
||||
renewRecord.setPurchaseQuantity(product.getDurationValue());
|
||||
renewRecord.setPrice(product.getPrice());
|
||||
renewRecord.setCurrency(product.getCurrency());
|
||||
renewRecord.setPurchaseTime(toDate(parseInstant(notification.getPurchaseDate())));
|
||||
renewRecord.setPurchaseType(product.getType());
|
||||
renewRecord.setStatus("PAID"); // 续订状态默认为已支付
|
||||
renewRecord.setStatus("PAID");
|
||||
renewRecord.setPaymentMethod("APPLE");
|
||||
renewRecord.setTransactionId(notification.getTransactionId()); // 新的交易ID
|
||||
renewRecord.setOriginalTransactionId(notification.getOriginalTransactionId()); // 原始交易ID
|
||||
renewRecord.setTransactionId(transaction.getTransactionId());
|
||||
renewRecord.setOriginalTransactionId(transaction.getOriginalTransactionId());
|
||||
renewRecord.setProductIds(new String[]{product.getProductId()});
|
||||
|
||||
// 解析并设置订阅过期时间
|
||||
Instant expiresInstant = parseInstant(notification.getExpiresDate());
|
||||
if (expiresInstant != null) {
|
||||
|
||||
if (transaction.getPurchaseDate() != null) {
|
||||
renewRecord.setPurchaseTime(Date.from(Instant.ofEpochMilli(transaction.getPurchaseDate())));
|
||||
renewRecord.setPurchaseDate(Date.from(Instant.ofEpochMilli(transaction.getPurchaseDate())));
|
||||
}
|
||||
|
||||
Instant expiresInstant = null;
|
||||
if (transaction.getExpiresDate() != null) {
|
||||
expiresInstant = Instant.ofEpochMilli(transaction.getExpiresDate());
|
||||
renewRecord.setExpiresDate(Date.from(expiresInstant));
|
||||
}
|
||||
renewRecord.setEnvironment(notification.getEnvironment());
|
||||
renewRecord.setPurchaseDate(toDate(parseInstant(notification.getPurchaseDate())));
|
||||
|
||||
// 保存续订记录到数据库
|
||||
|
||||
if (transaction.getEnvironment() != null) {
|
||||
renewRecord.setEnvironment(transaction.getEnvironment().name());
|
||||
}
|
||||
|
||||
purchaseRecordsService.save(renewRecord);
|
||||
|
||||
// 延长用户VIP有效期
|
||||
extendVip(record.getUserId().longValue(), product, expiresInstant);
|
||||
// 延长VIP有效期
|
||||
extendVip(userId, product, expiresInstant);
|
||||
|
||||
log.info("Renewal processed successfully: userId={}, transactionId={}",
|
||||
userId, transaction.getTransactionId());
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理订阅过期
|
||||
* 检查用户VIP状态,如果已过期则更新为非VIP
|
||||
*/
|
||||
private void handleSubscriptionExpired(Long userId) {
|
||||
KeyboardUser user = userService.getById(userId);
|
||||
if (user == null) {
|
||||
log.warn("User not found: userId={}", userId);
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查VIP是否已过期
|
||||
if (user.getVipExpiry() != null && user.getVipExpiry().toInstant().isBefore(Instant.now())) {
|
||||
user.setIsVip(false);
|
||||
userService.updateById(user);
|
||||
log.info("User VIP expired: userId={}", userId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理退款
|
||||
* 撤销用户权益(订阅或钱包余额)
|
||||
*/
|
||||
private void handleRefund(Long userId, KeyboardProductItems product,
|
||||
KeyboardUserPurchaseRecords record) {
|
||||
if ("subscription".equalsIgnoreCase(product.getType())) {
|
||||
// 订阅退款:倒扣会员时间
|
||||
KeyboardUser user = userService.getById(userId);
|
||||
if (user == null) {
|
||||
log.warn("User not found for refund: userId={}", userId);
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取当前VIP过期时间
|
||||
Date currentVipExpiry = user.getVipExpiry();
|
||||
if (currentVipExpiry == null) {
|
||||
log.info("User has no VIP expiry, no need to deduct: userId={}", userId);
|
||||
} else {
|
||||
// 计算需要倒扣的天数
|
||||
long durationDays = resolveDurationDays(product);
|
||||
|
||||
// 从当前过期时间倒扣
|
||||
Instant currentExpiry = currentVipExpiry.toInstant();
|
||||
Instant newExpiry = currentExpiry.minus(durationDays, ChronoUnit.DAYS);
|
||||
|
||||
// 判断倒扣后的时间是否仍大于当前时间
|
||||
Instant now = Instant.now();
|
||||
if (newExpiry.isAfter(now)) {
|
||||
// 倒扣后仍然是VIP
|
||||
user.setIsVip(true);
|
||||
user.setVipExpiry(Date.from(newExpiry));
|
||||
log.info("Subscription refunded, VIP time deducted: userId={}, oldExpiry={}, newExpiry={}",
|
||||
userId, currentExpiry, newExpiry);
|
||||
} else {
|
||||
// 倒扣后已过期,取消VIP
|
||||
user.setIsVip(false);
|
||||
user.setVipExpiry(Date.from(newExpiry)); // 保留倒扣后的时间,而不是设为null
|
||||
log.info("Subscription refunded, VIP expired after deduction: userId={}, newExpiry={}",
|
||||
userId, newExpiry);
|
||||
}
|
||||
|
||||
userService.updateById(user);
|
||||
}
|
||||
} else if ("in-app-purchase".equalsIgnoreCase(product.getType())) {
|
||||
// 内购退款:扣除钱包余额
|
||||
BigDecimal refundAmount = resolveCreditAmount(product);
|
||||
KeyboardUserWallet wallet = walletService.lambdaQuery()
|
||||
.eq(KeyboardUserWallet::getUserId, userId)
|
||||
.one();
|
||||
|
||||
if (wallet != null && wallet.getBalance().compareTo(refundAmount) >= 0) {
|
||||
BigDecimal before = wallet.getBalance();
|
||||
BigDecimal after = before.subtract(refundAmount);
|
||||
wallet.setBalance(after);
|
||||
walletService.updateById(wallet);
|
||||
|
||||
// 创建退款交易记录
|
||||
walletTransactionService.createTransaction(
|
||||
userId,
|
||||
record.getId().longValue(),
|
||||
refundAmount.negate(),
|
||||
(short) 3, // 交易类型:3-退款
|
||||
before,
|
||||
after,
|
||||
"Apple 退款: " + product.getProductId()
|
||||
);
|
||||
|
||||
log.info("In-app purchase refunded: userId={}, amount={}", userId, refundAmount);
|
||||
} else {
|
||||
log.warn("Insufficient balance for refund: userId={}, required={}", userId, refundAmount);
|
||||
}
|
||||
}
|
||||
|
||||
// 更新购买记录状态
|
||||
record.setStatus("REFUNDED");
|
||||
purchaseRecordsService.updateById(record);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理退款撤销
|
||||
* 恢复用户权益(加回之前倒扣的时间)
|
||||
*/
|
||||
private void handleRefundReversed(Long userId, KeyboardProductItems product,
|
||||
KeyboardUserPurchaseRecords record) {
|
||||
if ("subscription".equalsIgnoreCase(product.getType())) {
|
||||
// 订阅退款撤销:加回之前倒扣的会员时间
|
||||
KeyboardUser user = userService.getById(userId);
|
||||
if (user == null) {
|
||||
log.warn("User not found for refund reversal: userId={}", userId);
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取当前VIP过期时间
|
||||
Date currentVipExpiry = user.getVipExpiry();
|
||||
if (currentVipExpiry == null) {
|
||||
// 如果当前没有过期时间,使用当前时间作为基准
|
||||
currentVipExpiry = Date.from(Instant.now());
|
||||
}
|
||||
|
||||
// 计算需要加回的天数
|
||||
long durationDays = resolveDurationDays(product);
|
||||
|
||||
// 加回会员时间
|
||||
Instant currentExpiry = currentVipExpiry.toInstant();
|
||||
Instant newExpiry = currentExpiry.plus(durationDays, ChronoUnit.DAYS);
|
||||
|
||||
// 判断加回后的时间是否大于当前时间
|
||||
Instant now = Instant.now();
|
||||
if (newExpiry.isAfter(now)) {
|
||||
user.setIsVip(true);
|
||||
} else {
|
||||
user.setIsVip(false);
|
||||
}
|
||||
|
||||
user.setVipExpiry(Date.from(newExpiry));
|
||||
userService.updateById(user);
|
||||
|
||||
log.info("Refund reversed, VIP time restored: userId={}, oldExpiry={}, newExpiry={}, isVip={}",
|
||||
userId, currentExpiry, newExpiry, user.getIsVip());
|
||||
|
||||
} else if ("in-app-purchase".equalsIgnoreCase(product.getType())) {
|
||||
// 内购退款撤销:恢复钱包余额
|
||||
BigDecimal creditAmount = resolveCreditAmount(product);
|
||||
KeyboardUserWallet wallet = walletService.lambdaQuery()
|
||||
.eq(KeyboardUserWallet::getUserId, userId)
|
||||
.one();
|
||||
|
||||
if (wallet != null) {
|
||||
BigDecimal before = wallet.getBalance();
|
||||
BigDecimal after = before.add(creditAmount);
|
||||
wallet.setBalance(after);
|
||||
walletService.updateById(wallet);
|
||||
|
||||
walletTransactionService.createTransaction(
|
||||
userId,
|
||||
record.getId().longValue(),
|
||||
creditAmount,
|
||||
(short) 4, // 交易类型:4-退款撤销
|
||||
before,
|
||||
after,
|
||||
"Apple 退款撤销: " + product.getProductId()
|
||||
);
|
||||
|
||||
log.info("Refund reversed: userId={}, amount={}", userId, creditAmount);
|
||||
}
|
||||
}
|
||||
|
||||
// 更新购买记录状态
|
||||
record.setStatus("PAID");
|
||||
purchaseRecordsService.updateById(record);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -2,9 +2,12 @@ package com.yolo.keyborad.service.impl;
|
||||
|
||||
import com.apple.itunes.storekit.model.Environment;
|
||||
import com.apple.itunes.storekit.model.JWSTransactionDecodedPayload;
|
||||
import com.apple.itunes.storekit.model.NotificationTypeV2;
|
||||
import com.apple.itunes.storekit.model.ResponseBodyV2DecodedPayload;
|
||||
import com.apple.itunes.storekit.verification.SignedDataVerifier;
|
||||
import com.apple.itunes.storekit.verification.VerificationException;
|
||||
import com.yolo.keyborad.model.dto.AppleReceiptValidationResult;
|
||||
import com.yolo.keyborad.service.ApplePurchaseService;
|
||||
import com.yolo.keyborad.service.AppleReceiptService;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -31,15 +34,26 @@ public class AppleReceiptServiceImpl implements AppleReceiptService {
|
||||
*/
|
||||
private final SignedDataVerifier signedDataVerifier;
|
||||
|
||||
/**
|
||||
* 苹果购买服务
|
||||
* <p>
|
||||
* 用于处理通知后的业务逻辑
|
||||
* </p>
|
||||
*/
|
||||
private final ApplePurchaseService applePurchaseService;
|
||||
|
||||
/**
|
||||
* 构造函数
|
||||
* <p>
|
||||
* 通过构造函数注入签名数据验证器
|
||||
* 通过构造函数注入签名数据验证器和购买服务
|
||||
* </p>
|
||||
* @param signedDataVerifier 签名数据验证器
|
||||
* @param applePurchaseService 苹果购买服务
|
||||
*/
|
||||
public AppleReceiptServiceImpl(SignedDataVerifier signedDataVerifier) {
|
||||
public AppleReceiptServiceImpl(SignedDataVerifier signedDataVerifier,
|
||||
ApplePurchaseService applePurchaseService) {
|
||||
this.signedDataVerifier = signedDataVerifier;
|
||||
this.applePurchaseService = applePurchaseService;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -87,6 +101,91 @@ public class AppleReceiptServiceImpl implements AppleReceiptService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理 Apple 服务器通知
|
||||
* <p>
|
||||
* 验证通知签名并根据通知类型分发到相应的处理逻辑
|
||||
* 支持的通知类型包括:订阅、续订、过期、退款、偏好变更等
|
||||
* </p>
|
||||
* @param signedPayload Apple 服务器发送的签名载荷(JWT 格式)
|
||||
*/
|
||||
@Override
|
||||
public void processNotification(String signedPayload) {
|
||||
if (signedPayload == null || signedPayload.isBlank()) {
|
||||
log.warn("Received empty notification payload");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. 验证并解码通知载荷
|
||||
ResponseBodyV2DecodedPayload notification =
|
||||
signedDataVerifier.verifyAndDecodeNotification(signedPayload);
|
||||
|
||||
NotificationTypeV2 type = notification.getNotificationType();
|
||||
log.info("Received Apple notification: type={}, subtype={}, environment={}",
|
||||
type,
|
||||
notification.getSubtype(),
|
||||
notification.getData() != null ? notification.getData().getEnvironment() : "unknown");
|
||||
|
||||
// 2. 根据通知类型分发处理
|
||||
if (type == null) {
|
||||
log.warn("Notification type is null, ignoring");
|
||||
return;
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
// 订阅相关通知
|
||||
case SUBSCRIBED:
|
||||
case DID_RENEW:
|
||||
case DID_FAIL_TO_RENEW:
|
||||
case EXPIRED:
|
||||
case GRACE_PERIOD_EXPIRED:
|
||||
case OFFER_REDEEMED:
|
||||
case RENEWAL_EXTENDED:
|
||||
case RENEWAL_EXTENSION:
|
||||
applePurchaseService.handleSubscriptionNotification(notification);
|
||||
break;
|
||||
|
||||
// 退款相关通知
|
||||
case REFUND:
|
||||
case REFUND_DECLINED:
|
||||
case REFUND_REVERSED:
|
||||
applePurchaseService.handleRefundNotification(notification);
|
||||
break;
|
||||
|
||||
// 续订偏好变更通知
|
||||
case DID_CHANGE_RENEWAL_PREF:
|
||||
case DID_CHANGE_RENEWAL_STATUS:
|
||||
case PRICE_INCREASE:
|
||||
applePurchaseService.handleRenewalPreferenceChange(notification);
|
||||
break;
|
||||
|
||||
// 消费请求通知
|
||||
case CONSUMPTION_REQUEST:
|
||||
applePurchaseService.handleConsumptionRequest(notification);
|
||||
break;
|
||||
|
||||
// 其他通知类型(记录但不处理)
|
||||
case EXTERNAL_PURCHASE_TOKEN:
|
||||
case ONE_TIME_CHARGE:
|
||||
case REVOKE:
|
||||
case TEST:
|
||||
log.info("Received notification type {} - no action required", type);
|
||||
break;
|
||||
|
||||
default:
|
||||
log.warn("Unknown notification type: {}", type);
|
||||
}
|
||||
|
||||
} catch (VerificationException e) {
|
||||
// 验证异常处理:签名无效、证书链问题等
|
||||
log.error("Apple notification verification failed: status={}", e.getStatus(), e);
|
||||
} catch (Exception e) {
|
||||
// 其他未预期异常处理
|
||||
log.error("Unexpected error when processing Apple notification", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建苹果收据验证结果对象
|
||||
* <p>
|
||||
|
||||
Reference in New Issue
Block a user