From d9a778f5aa12af7ec98af6f3471cdf10ca59e431 Mon Sep 17 00:00:00 2001 From: ziin Date: Mon, 15 Dec 2025 15:15:10 +0800 Subject: [PATCH] =?UTF-8?q?refactor(apple-purchase):=20=E9=87=8D=E6=9E=84?= =?UTF-8?q?=E8=8B=B9=E6=9E=9C=E8=B4=AD=E4=B9=B0=E6=9C=8D=E5=8A=A1=EF=BC=8C?= =?UTF-8?q?=E5=A2=9E=E5=BC=BA=E5=8F=AF=E8=AF=BB=E6=80=A7=E5=92=8C=E5=81=A5?= =?UTF-8?q?=E5=A3=AE=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/yolo/keyborad/common/ErrorCode.java | 8 +- .../controller/AppleReceiptController.java | 67 +++++ .../impl/ApplePurchaseServiceImpl.java | 283 ++++++++++++++++-- 3 files changed, 327 insertions(+), 31 deletions(-) diff --git a/src/main/java/com/yolo/keyborad/common/ErrorCode.java b/src/main/java/com/yolo/keyborad/common/ErrorCode.java index 82d8eb3..237e8ec 100644 --- a/src/main/java/com/yolo/keyborad/common/ErrorCode.java +++ b/src/main/java/com/yolo/keyborad/common/ErrorCode.java @@ -51,7 +51,13 @@ public enum ErrorCode { THEME_NOT_FOUND(40410, "主题不存在"), THEME_ALREADY_PURCHASED(50014, "主题已购买"), THEME_NOT_AVAILABLE(50015, "主题不可购买"), - RECEIPT_INVALID(50016, "收据无效"); + RECEIPT_INVALID(50016, "收据无效"), + UPDATE_USER_VIP_STATUS_ERROR(50017, "更新用户VIP状态失败"), + PRODUCT_QUOTA_NOT_SET(50018, "商品额度未配置"), + LACK_ORIGIN_TRANSACTION_ID_ERROR(50019, "缺少原始交易id"), + UNKNOWN_PRODUCT_TYPE(50020, "未知商品类型"), + PRODUCT_NOT_FOUND(50021, "商品不存在"); + /** * 状态码 */ diff --git a/src/main/java/com/yolo/keyborad/controller/AppleReceiptController.java b/src/main/java/com/yolo/keyborad/controller/AppleReceiptController.java index 999d8ff..914a5a1 100644 --- a/src/main/java/com/yolo/keyborad/controller/AppleReceiptController.java +++ b/src/main/java/com/yolo/keyborad/controller/AppleReceiptController.java @@ -53,29 +53,61 @@ public class AppleReceiptController { return ResultUtils.success(Boolean.TRUE); } + + /** + * 接收 Apple 服务器通知 + * 处理来自 Apple 的服务器到服务器通知,主要用于订阅续订等事件 + * + * @param body 请求体,包含 signedPayload 字段 + * @param request HTTP 请求对象 + * @return 处理结果 + * @throws BusinessException 当 signedPayload 为空或解析失败时抛出 + */ @PostMapping("/notification") public BaseResponse receiveNotification(@RequestBody Map body, HttpServletRequest request) { + // 从请求体中获取 Apple 签名的载荷 String signedPayload = body.get("signedPayload"); log.warn(body.toString()); + + // 校验 signedPayload 是否为空 if (signedPayload == null || signedPayload.isBlank()) { throw new BusinessException(ErrorCode.PARAMS_ERROR, "signedPayload 不能为空"); } + + // 解码签名载荷,获取通知详情 AppleServerNotification notification = decodeSignedPayload(signedPayload); log.info("Apple server notification decoded: {}, query: {}", notification, request.getQueryString()); + + // 判断是否为续订相关通知,如果是则进行处理 if (notification != null && notification.getNotificationType() != null && notification.getNotificationType().toUpperCase().contains("RENEW")) { applePurchaseService.processRenewNotification(notification); } + return ResultUtils.success(Boolean.TRUE); } + + /** + * 解码 Apple 签名的 JWT 载荷 + * 从 signedPayload 中解析出服务器通知的详细信息,包括通知类型、环境、产品ID、交易信息等 + * + * @param signedPayload Apple 服务器发送的签名载荷(JWT 格式) + * @return AppleServerNotification 对象,包含解析后的通知详情 + * @throws BusinessException 当参数无效或解析失败时抛出 + */ private AppleServerNotification decodeSignedPayload(String signedPayload) { try { + // 解析外层 JWT 载荷 JsonNode root = JwtParser.parsePayload(signedPayload); AppleServerNotification notification = new AppleServerNotification(); + + // 获取通知类型(支持驼峰和下划线两种命名格式) notification.setNotificationType(text(root, "notificationType", "notification_type")); + + // 解析 data 节点中的基本信息 JsonNode data = root.get("data"); if (data != null && !data.isNull()) { notification.setEnvironment(text(data, "environment")); @@ -83,14 +115,23 @@ public class AppleReceiptController { notification.setOriginalTransactionId(text(data, "originalTransactionId", "original_transaction_id")); 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")); } + return notification; } catch (IllegalArgumentException e) { throw new BusinessException(ErrorCode.PARAMS_ERROR, e.getMessage()); @@ -100,6 +141,14 @@ public class AppleReceiptController { } } + /** + * 从 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()) { @@ -109,6 +158,14 @@ public class AppleReceiptController { return null; } + /** + * 返回第一个非空白的字符串 + * 用于在多个可选值中选择优先级更高的非空值 + * + * @param a 第一优先级的字符串 + * @param b 第二优先级的字符串 + * @return 第一个非空白的字符串,如果都为空则返回第一个参数 + */ private String firstNonBlank(String a, String b) { if (a != null && !a.isBlank()) { return a; @@ -116,15 +173,25 @@ public class AppleReceiptController { 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; } } 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 8dad823..d3d4c17 100644 --- a/src/main/java/com/yolo/keyborad/service/impl/ApplePurchaseServiceImpl.java +++ b/src/main/java/com/yolo/keyborad/service/impl/ApplePurchaseServiceImpl.java @@ -51,15 +51,33 @@ public class ApplePurchaseServiceImpl implements ApplePurchaseService { @Resource private UserService userService; + + /** + * 处理苹果购买(订阅或内购) + * 主要流程: + * 1. 校验收据有效性 + * 2. 幂等性检查,避免重复处理同一笔交易 + * 3. 查询商品信息 + * 4. 保存购买记录 + * 5. 根据商品类型执行对应逻辑(订阅延期或钱包充值) + * + * @param userId 用户ID + * @param validationResult 苹果收据验证结果,包含交易ID、商品ID、过期时间等信息 + * @throws BusinessException 当收据无效、商品不存在或商品类型未知时抛出 + */ @Override @Transactional(rollbackFor = Exception.class) public void processPurchase(Long userId, AppleReceiptValidationResult validationResult) { + // 1. 校验收据有效性 if (validationResult == null || !validationResult.isValid()) { - log.error("Apple receipt validation failed.{}", validationResult.getReason()); throw new BusinessException(ErrorCode.RECEIPT_INVALID); } + + // 2. 解析商品ID String productId = resolveProductId(validationResult.getProductIds()); - // 幂等:transactionId 已经处理过则跳过 + + // 3. 幂等性检查:根据交易ID判断该笔交易是否已经处理过 + // 防止同一笔购买被重复处理,导致用户多次获得权益 boolean handled = purchaseRecordsService.lambdaQuery() .eq(KeyboardUserPurchaseRecords::getTransactionId, validationResult.getTransactionId()) .eq(KeyboardUserPurchaseRecords::getStatus, "PAID") @@ -69,49 +87,72 @@ public class ApplePurchaseServiceImpl implements ApplePurchaseService { return; } + // 4. 查询商品信息 KeyboardProductItems product = productItemsService.getProductEntityByProductId(productId); if (product == null) { log.error("Apple purchase not found, productId={}", productId); - throw new BusinessException(ErrorCode.NOT_FOUND_ERROR, "商品不存在: " + productId); + throw new BusinessException(ErrorCode.PRODUCT_NOT_FOUND); } - // 写购买记录 + // 5. 构建并保存购买记录到数据库 KeyboardUserPurchaseRecords purchaseRecord = buildPurchaseRecord(userId, validationResult, product); purchaseRecordsService.save(purchaseRecord); + // 6. 根据商品类型执行相应的业务逻辑 if ("subscription".equalsIgnoreCase(product.getType())) { + // 订阅类商品:延长用户VIP有效期 handleSubscription(userId, product, validationResult); } else if ("in-app-purchase".equalsIgnoreCase(product.getType())) { + // 内购类商品:为用户钱包充值 handleInAppPurchase(userId, product, purchaseRecord.getId()); } else { - throw new BusinessException(ErrorCode.OPERATION_ERROR, "未知商品类型: " + product.getType()); + // 未知商品类型,抛出异常 + log.error("未知商品类型, type={}", product.getType()); + throw new BusinessException(ErrorCode.UNKNOWN_PRODUCT_TYPE); } } + + /** + * 处理苹果订阅续期通知 + * 当用户的订阅自动续费时,苹果会发送通知到我们的服务器 + * + * @param notification 苹果服务器通知对象,包含续订交易信息 + * @throws BusinessException 当参数缺失、商品不存在或更新失败时抛出 + */ @Override @Transactional(rollbackFor = Exception.class) public void processRenewNotification(AppleServerNotification notification) { + // 参数校验:确保通知对象和原始交易ID不为空 if (notification == null || notification.getOriginalTransactionId() == null) { - throw new BusinessException(ErrorCode.PARAMS_ERROR, "缺少原始交易ID"); + throw new BusinessException(ErrorCode.LACK_ORIGIN_TRANSACTION_ID_ERROR); } - // 找到用户:原始交易ID可能对应多条记录,取最新的一条 + + // 根据原始交易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()); return; } - // 写一条续订记录 + // 构建续订购买记录对象 KeyboardUserPurchaseRecords renewRecord = new KeyboardUserPurchaseRecords(); renewRecord.setUserId(record.getUserId()); renewRecord.setProductId(product.getProductId()); @@ -120,184 +161,366 @@ public class ApplePurchaseServiceImpl implements ApplePurchaseService { 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()); - renewRecord.setOriginalTransactionId(notification.getOriginalTransactionId()); + renewRecord.setTransactionId(notification.getTransactionId()); // 新的交易ID + renewRecord.setOriginalTransactionId(notification.getOriginalTransactionId()); // 原始交易ID renewRecord.setProductIds(new String[]{product.getProductId()}); + + // 解析并设置订阅过期时间 Instant expiresInstant = parseInstant(notification.getExpiresDate()); if (expiresInstant != null) { renewRecord.setExpiresDate(Date.from(expiresInstant)); } renewRecord.setEnvironment(notification.getEnvironment()); renewRecord.setPurchaseDate(toDate(parseInstant(notification.getPurchaseDate()))); + + // 保存续订记录到数据库 purchaseRecordsService.save(renewRecord); + // 延长用户VIP有效期 extendVip(record.getUserId().longValue(), product, expiresInstant); } + + /** + * 处理订阅类商品的购买 + * 为用户延长VIP有效期 + * + * @param userId 用户ID + * @param product 商品信息,包含订阅时长等配置 + * @param validationResult 苹果收据验证结果(当前方法未直接使用,但保留以便扩展) + * @throws BusinessException 当用户不存在或更新失败时抛出 + */ private void handleSubscription(Long userId, KeyboardProductItems product, AppleReceiptValidationResult validationResult) { + // 1. 查询用户信息 KeyboardUser user = userService.getById(userId); if (user == null) { throw new BusinessException(ErrorCode.USER_NOT_FOUND); } + + // 2. 确定VIP延期的基准时间 + // 如果用户当前VIP未过期,则基于当前过期时间延长;否则基于当前时间延长 Instant base = resolveBaseExpiry(user.getVipExpiry()); + + // 3. 计算新的VIP过期时间 + // 根据商品配置的时长(天数)进行延期 Instant newExpiry = base.plus(resolveDurationDays(product), ChronoUnit.DAYS); + + // 4. 更新用户VIP状态 user.setIsVip(true); user.setVipExpiry(Date.from(newExpiry)); + + // 5. 保存用户信息到数据库 boolean updated = userService.updateById(user); if (!updated) { - throw new BusinessException(ErrorCode.OPERATION_ERROR, "更新用户VIP失败"); + throw new BusinessException(ErrorCode.UPDATE_USER_VIP_STATUS_ERROR); } + + // 6. 记录日志 log.info("Extend VIP for user {} to {}", userId, newExpiry); } + + /** + * 处理内购类商品的购买 + * 为用户钱包充值相应的额度 + * + * @param userId 用户ID + * @param product 商品信息,包含充值额度等配置 + * @param purchaseRecordId 购买记录ID,用于关联钱包交易记录 + * @throws BusinessException 当商品额度未配置或钱包操作失败时抛出 + */ private void handleInAppPurchase(Long userId, KeyboardProductItems product, Integer purchaseRecordId) { + // 1. 查询用户钱包信息 KeyboardUserWallet wallet = walletService.lambdaQuery() .eq(KeyboardUserWallet::getUserId, userId) .one(); + + // 2. 如果用户钱包不存在,创建新钱包 if (wallet == null) { wallet = new KeyboardUserWallet(); wallet.setUserId(userId); wallet.setBalance(BigDecimal.ZERO); - wallet.setStatus((short) 1); - wallet.setVersion(0); + wallet.setStatus((short) 1); // 状态:1-正常 + wallet.setVersion(0); // 乐观锁版本号 wallet.setCreatedAt(new Date()); } + // 3. 解析商品的充值额度 BigDecimal credit = resolveCreditAmount(product); + + // 4. 校验充值额度是否有效 if (credit.compareTo(BigDecimal.ZERO) <= 0) { - throw new BusinessException(ErrorCode.OPERATION_ERROR, "商品额度未配置"); + throw new BusinessException(ErrorCode.PRODUCT_QUOTA_NOT_SET); } + + // 5. 计算充值前后的余额 BigDecimal before = wallet.getBalance() == null ? BigDecimal.ZERO : wallet.getBalance(); BigDecimal after = before.add(credit); + + // 6. 更新钱包余额 wallet.setBalance(after); wallet.setUpdatedAt(new Date()); walletService.saveOrUpdate(wallet); + // 7. 创建钱包交易记录,用于财务对账和历史查询 KeyboardWalletTransaction tx = walletTransactionService.createTransaction( userId, purchaseRecordId == null ? null : purchaseRecordId.longValue(), credit, - (short) 2, // 2: 苹果内购充值 + (short) 2, // 交易类型:2-苹果内购充值 before, after, "Apple 充值: " + product.getProductId() ); + + // 8. 记录充值成功日志 log.info("Wallet recharge success, user={}, credit={}, txId={}", userId, credit, tx.getId()); } + + /** + * 构建购买记录对象 + * 将苹果收据验证结果和商品信息转换为购买记录实体 + * + * @param userId 用户ID + * @param validationResult 苹果收据验证结果,包含交易信息 + * @param product 商品信息,包含商品配置和定价 + * @return 构建完成的购买记录对象 + */ private KeyboardUserPurchaseRecords buildPurchaseRecord(Long userId, AppleReceiptValidationResult validationResult, KeyboardProductItems product) { KeyboardUserPurchaseRecords record = new KeyboardUserPurchaseRecords(); + + // 设置用户ID(转换为Integer类型) record.setUserId(userId.intValue()); + + // 设置商品相关信息 record.setProductId(product.getProductId()); - record.setPurchaseQuantity(product.getDurationValue()); - record.setPrice(product.getPrice()); - record.setCurrency(product.getCurrency()); + record.setPurchaseQuantity(product.getDurationValue()); // 购买数量/时长 + record.setPrice(product.getPrice()); // 商品价格 + record.setCurrency(product.getCurrency()); // 货币类型 + record.setPurchaseType(product.getType()); // 商品类型:subscription 或 in-app-purchase + + // 设置购买时间,如果验证结果中没有则使用当前时间 record.setPurchaseTime(Date.from(Objects.requireNonNullElseGet(validationResult.getPurchaseDate(), Instant::now))); - record.setPurchaseType(product.getType()); - record.setStatus("PAID"); - record.setPaymentMethod("APPLE"); - record.setTransactionId(validationResult.getTransactionId()); - record.setOriginalTransactionId(validationResult.getOriginalTransactionId()); - record.setProductIds(new String[]{String.join(",", validationResult.getProductIds())}); + + // 设置交易状态和支付方式 + record.setStatus("PAID"); // 状态:已支付 + record.setPaymentMethod("APPLE"); // 支付方式:苹果支付 + + // 设置苹果交易相关信息 + record.setTransactionId(validationResult.getTransactionId()); // 当前交易ID + record.setOriginalTransactionId(validationResult.getOriginalTransactionId()); // 原始交易ID(用于续订关联) + record.setProductIds(new String[]{String.join(",", validationResult.getProductIds())}); // 商品ID列表(逗号分隔) + + // 设置订阅过期时间(仅订阅类商品有此字段) if (validationResult.getExpiresDate() != null) { record.setExpiresDate(Date.from(validationResult.getExpiresDate())); } + + // 设置交易环境:PRODUCTION(生产环境)或 SANDBOX(沙盒环境) record.setEnvironment(validationResult.getEnvironment() == null ? null : validationResult.getEnvironment().name()); + + // 设置购买日期(与purchaseTime可能有细微差别,保留原始数据) record.setPurchaseDate(validationResult.getPurchaseDate() == null ? null : Date.from(validationResult.getPurchaseDate())); + return record; } + + /** + * 延长用户VIP有效期 + * 支持两种延期策略: + * 1. 如果提供了目标过期时间(targetExpiry),则直接使用该时间 + * 2. 如果未提供目标时间,则基于用户当前VIP过期时间延长指定时长 + * + * @param userId 用户ID + * @param product 商品信息,包含订阅时长配置 + * @param targetExpiry 目标过期时间(可选),通常来自苹果续订通知中的过期时间 + * @throws BusinessException 当用户不存在或更新失败时抛出 + */ private void extendVip(Long userId, KeyboardProductItems product, Instant targetExpiry) { + // 1. 查询用户信息 KeyboardUser user = userService.getById(userId); if (user == null) { throw new BusinessException(ErrorCode.USER_NOT_FOUND); } + + // 2. 解析商品配置的订阅时长(天数) long durationDays = resolveDurationDays(product); + + // 3. 确定VIP延期的基准时间 + // 如果用户当前VIP未过期,则基于当前过期时间延长;否则基于当前时间延长 Instant base = resolveBaseExpiry(user.getVipExpiry()); + + // 4. 计算新的VIP过期时间 + // 优先使用目标时间(如续订通知中的过期时间),否则基于基准时间加上订阅时长 Instant newExpiry = targetExpiry != null ? targetExpiry : base.plus(durationDays, ChronoUnit.DAYS); - // 如果目标时间早于当前,则基于当前时间加时长 + + // 5. 防御性检查:如果计算出的过期时间早于当前时间(异常情况),则基于当前时间重新计算 + // 这种情况可能发生在时间不同步或数据异常时,确保VIP不会立即过期 if (newExpiry.isBefore(Instant.now())) { newExpiry = Instant.now().plus(durationDays, ChronoUnit.DAYS); } - user.setIsVip(true); - user.setVipExpiry(Date.from(newExpiry)); + + // 6. 更新用户VIP状态 + user.setIsVip(true); // 设置为VIP用户 + user.setVipExpiry(Date.from(newExpiry)); // 更新VIP过期时间 + + // 7. 保存用户信息到数据库 boolean updated = userService.updateById(user); if (!updated) { - throw new BusinessException(ErrorCode.OPERATION_ERROR, "更新用户VIP失败"); + throw new BusinessException(ErrorCode.UPDATE_USER_VIP_STATUS_ERROR); } + + // 8. 记录延期成功日志 log.info("Extend VIP by notification, user {} to {}", userId, newExpiry); } + + /** + * 解析商品ID列表,提取第一个商品ID + * + * @param productIds 商品ID列表,通常来自苹果收据验证结果 + * @return 第一个商品ID + * @throws BusinessException 当商品ID列表为空时抛出 + */ private String resolveProductId(List productIds) { + // 校验商品ID列表不为空 if (productIds == null || productIds.isEmpty()) { throw new BusinessException(ErrorCode.PARAMS_ERROR, "productId 缺失"); } + // 返回列表中的第一个商品ID return productIds.get(0); } + /** + * 解析VIP延期的基准时间 + * 如果用户当前VIP未过期,则基于当前过期时间延长;否则基于当前时间延长 + * + * @param currentExpiry 用户当前的VIP过期时间 + * @return VIP延期的基准时间点 + */ private Instant resolveBaseExpiry(Date currentExpiry) { + // 如果当前过期时间为空,返回当前时间作为基准 if (currentExpiry == null) { return Instant.now(); } + // 将Date转换为Instant Instant current = currentExpiry.toInstant(); + // 如果VIP未过期,使用当前过期时间作为基准;否则使用当前时间 return current.isAfter(Instant.now()) ? current : Instant.now(); } + /** + * 解析商品的订阅时长(转换为天数) + * 支持多种配置方式: + * 1. 直接配置的durationDays字段 + * 2. 通过durationValue和durationUnit组合计算(支持天/月单位) + * + * @param product 商品信息,包含时长相关配置 + * @return 订阅时长(天数),如果无法解析则返回0 + */ private long resolveDurationDays(KeyboardProductItems product) { + // 优先使用直接配置的天数字段 if (product.getDurationDays() != null && product.getDurationDays() > 0) { return product.getDurationDays(); } + + // 解析时长数值和单位 Integer value = product.getDurationValue(); String unit = product.getDurationUnit() == null ? "" : product.getDurationUnit().toLowerCase(); + + // 校验时长数值是否有效 if (value == null || value <= 0) { return 0; } + + // 根据单位进行转换 + // 如果单位是"天",直接返回数值 if (unit.contains("day") || unit.contains("天")) { return value; } + // 如果单位是"月",按30天/月进行转换 if (unit.contains("month") || unit.contains("月")) { return (long) value * 30; } + + // 默认返回原始数值(当单位未知时) return value; } + + /** + * 解析商品的充值额度 + * 优先从商品名称中提取数值,如果提取失败则使用durationValue字段 + * + * @param product 商品信息,包含name和durationValue字段 + * @return 充值额度,如果无法解析则返回0 + */ private BigDecimal resolveCreditAmount(KeyboardProductItems product) { - // 优先使用 name 中的数值,否则退回 durationValue + // 优先尝试从商品名称中解析数值(如"100金币"中的100) BigDecimal fromName = parseNumber(product.getName()); if (fromName != null) { return fromName; } + // 如果名称中没有数值,使用durationValue字段作为备选 if (product.getDurationValue() != null) { return BigDecimal.valueOf(product.getDurationValue()); } + // 如果都没有,返回0 return BigDecimal.ZERO; } + /** + * 从字符串中提取数值 + * 通过正则表达式移除所有非数字和小数点字符,然后转换为BigDecimal + * + * @param raw 原始字符串,可能包含数字和其他字符(如"100金币") + * @return 提取出的数值,如果解析失败则返回null + */ private BigDecimal parseNumber(String raw) { + // 空值检查 if (raw == null) { return null; } try { + // 移除所有非数字和非小数点字符,然后转换为BigDecimal return new BigDecimal(raw.replaceAll("[^\\d.]", "")); } catch (Exception e) { + // 解析失败时返回null(如字符串中完全没有数字) return null; } } + /** + * 解析ISO 8601格式的时间字符串为Instant对象 + * + * @param iso ISO 8601格式的时间字符串(如"2024-01-01T00:00:00Z") + * @return 解析后的Instant对象,解析失败或输入为空时返回null + */ private Instant parseInstant(String iso) { + // 空值或空白字符串检查 if (iso == null || iso.isBlank()) { return null; } try { + // 解析ISO 8601格式的时间字符串 return Instant.parse(iso); } catch (DateTimeParseException e) { + // 解析失败时记录警告日志并返回null log.warn("Failed to parse expiresDate: {}", iso); return null; } } + /** + * 将Instant对象转换为Date对象 + * + * @param instant Instant时间对象 + * @return 转换后的Date对象,输入为null时返回null + */ private Date toDate(Instant instant) { return instant == null ? null : Date.from(instant); }