From cd6eca9cbb2e01efffcdf1d0464ee403689e05e7 Mon Sep 17 00:00:00 2001 From: ziin Date: Tue, 16 Dec 2025 15:50:35 +0800 Subject: [PATCH] =?UTF-8?q?feat(apple):=20=E6=94=AF=E6=8C=81App=20Store=20?= =?UTF-8?q?Server=20V2=E9=80=9A=E7=9F=A5=E5=85=A8=E7=B1=BB=E5=9E=8B?= =?UTF-8?q?=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增订阅、退款、偏好变更、消费请求等通知处理器 - 统一使用ResponseBodyV2DecodedPayload验签与分发 - 移除控制器层JWT解析逻辑,下沉至服务层 - 增加幂等、状态回滚及权益撤销/恢复能力 --- .../controller/AppleReceiptController.java | 166 +----- .../service/ApplePurchaseService.java | 28 +- .../keyborad/service/AppleReceiptService.java | 14 +- .../impl/ApplePurchaseServiceImpl.java | 493 ++++++++++++++++-- .../service/impl/AppleReceiptServiceImpl.java | 103 +++- 5 files changed, 593 insertions(+), 211 deletions(-) diff --git a/src/main/java/com/yolo/keyborad/controller/AppleReceiptController.java b/src/main/java/com/yolo/keyborad/controller/AppleReceiptController.java index a52175e..63a62ab 100644 --- a/src/main/java/com/yolo/keyborad/controller/AppleReceiptController.java +++ b/src/main/java/com/yolo/keyborad/controller/AppleReceiptController.java @@ -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 receiveNotification(@RequestBody Map body, HttpServletRequest request) { - - + public BaseResponse receiveNotification(@RequestBody Map 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; - } - } diff --git a/src/main/java/com/yolo/keyborad/service/ApplePurchaseService.java b/src/main/java/com/yolo/keyborad/service/ApplePurchaseService.java index b5598fd..5baa1a3 100644 --- a/src/main/java/com/yolo/keyborad/service/ApplePurchaseService.java +++ b/src/main/java/com/yolo/keyborad/service/ApplePurchaseService.java @@ -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); } diff --git a/src/main/java/com/yolo/keyborad/service/AppleReceiptService.java b/src/main/java/com/yolo/keyborad/service/AppleReceiptService.java index 4b9db24..c4f4943 100644 --- a/src/main/java/com/yolo/keyborad/service/AppleReceiptService.java +++ b/src/main/java/com/yolo/keyborad/service/AppleReceiptService.java @@ -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); } diff --git a/src/main/java/com/yolo/keyborad/service/impl/ApplePurchaseServiceImpl.java b/src/main/java/com/yolo/keyborad/service/impl/ApplePurchaseServiceImpl.java index ba870f8..584d88c 100644 --- a/src/main/java/com/yolo/keyborad/service/impl/ApplePurchaseServiceImpl.java +++ b/src/main/java/com/yolo/keyborad/service/impl/ApplePurchaseServiceImpl.java @@ -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 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 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); } diff --git a/src/main/java/com/yolo/keyborad/service/impl/AppleReceiptServiceImpl.java b/src/main/java/com/yolo/keyborad/service/impl/AppleReceiptServiceImpl.java index 4ebceee..d74bf48 100644 --- a/src/main/java/com/yolo/keyborad/service/impl/AppleReceiptServiceImpl.java +++ b/src/main/java/com/yolo/keyborad/service/impl/AppleReceiptServiceImpl.java @@ -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; + /** + * 苹果购买服务 + *

+ * 用于处理通知后的业务逻辑 + *

+ */ + private final ApplePurchaseService applePurchaseService; + /** * 构造函数 *

- * 通过构造函数注入签名数据验证器 + * 通过构造函数注入签名数据验证器和购买服务 *

* @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 服务器通知 + *

+ * 验证通知签名并根据通知类型分发到相应的处理逻辑 + * 支持的通知类型包括:订阅、续订、过期、退款、偏好变更等 + *

+ * @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); + } + } + /** * 构建苹果收据验证结果对象 *