refactor(apple-purchase): 重构苹果购买服务,增强可读性和健壮性
This commit is contained in:
@@ -51,7 +51,13 @@ public enum ErrorCode {
|
|||||||
THEME_NOT_FOUND(40410, "主题不存在"),
|
THEME_NOT_FOUND(40410, "主题不存在"),
|
||||||
THEME_ALREADY_PURCHASED(50014, "主题已购买"),
|
THEME_ALREADY_PURCHASED(50014, "主题已购买"),
|
||||||
THEME_NOT_AVAILABLE(50015, "主题不可购买"),
|
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, "商品不存在");
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 状态码
|
* 状态码
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -53,29 +53,61 @@ public class AppleReceiptController {
|
|||||||
return ResultUtils.success(Boolean.TRUE);
|
return ResultUtils.success(Boolean.TRUE);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 接收 Apple 服务器通知
|
||||||
|
* 处理来自 Apple 的服务器到服务器通知,主要用于订阅续订等事件
|
||||||
|
*
|
||||||
|
* @param body 请求体,包含 signedPayload 字段
|
||||||
|
* @param request HTTP 请求对象
|
||||||
|
* @return 处理结果
|
||||||
|
* @throws BusinessException 当 signedPayload 为空或解析失败时抛出
|
||||||
|
*/
|
||||||
@PostMapping("/notification")
|
@PostMapping("/notification")
|
||||||
public BaseResponse<Boolean> receiveNotification(@RequestBody Map<String, String> body, HttpServletRequest request) {
|
public BaseResponse<Boolean> receiveNotification(@RequestBody Map<String, String> body, HttpServletRequest request) {
|
||||||
|
// 从请求体中获取 Apple 签名的载荷
|
||||||
String signedPayload = body.get("signedPayload");
|
String signedPayload = body.get("signedPayload");
|
||||||
log.warn(body.toString());
|
log.warn(body.toString());
|
||||||
|
|
||||||
|
// 校验 signedPayload 是否为空
|
||||||
if (signedPayload == null || signedPayload.isBlank()) {
|
if (signedPayload == null || signedPayload.isBlank()) {
|
||||||
throw new BusinessException(ErrorCode.PARAMS_ERROR, "signedPayload 不能为空");
|
throw new BusinessException(ErrorCode.PARAMS_ERROR, "signedPayload 不能为空");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 解码签名载荷,获取通知详情
|
||||||
AppleServerNotification notification = decodeSignedPayload(signedPayload);
|
AppleServerNotification notification = decodeSignedPayload(signedPayload);
|
||||||
log.info("Apple server notification decoded: {}, query: {}", notification, request.getQueryString());
|
log.info("Apple server notification decoded: {}, query: {}", notification, request.getQueryString());
|
||||||
|
|
||||||
|
// 判断是否为续订相关通知,如果是则进行处理
|
||||||
if (notification != null && notification.getNotificationType() != null
|
if (notification != null && notification.getNotificationType() != null
|
||||||
&& notification.getNotificationType().toUpperCase().contains("RENEW")) {
|
&& notification.getNotificationType().toUpperCase().contains("RENEW")) {
|
||||||
applePurchaseService.processRenewNotification(notification);
|
applePurchaseService.processRenewNotification(notification);
|
||||||
}
|
}
|
||||||
|
|
||||||
return ResultUtils.success(Boolean.TRUE);
|
return ResultUtils.success(Boolean.TRUE);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解码 Apple 签名的 JWT 载荷
|
||||||
|
* 从 signedPayload 中解析出服务器通知的详细信息,包括通知类型、环境、产品ID、交易信息等
|
||||||
|
*
|
||||||
|
* @param signedPayload Apple 服务器发送的签名载荷(JWT 格式)
|
||||||
|
* @return AppleServerNotification 对象,包含解析后的通知详情
|
||||||
|
* @throws BusinessException 当参数无效或解析失败时抛出
|
||||||
|
*/
|
||||||
private AppleServerNotification decodeSignedPayload(String signedPayload) {
|
private AppleServerNotification decodeSignedPayload(String signedPayload) {
|
||||||
try {
|
try {
|
||||||
|
// 解析外层 JWT 载荷
|
||||||
JsonNode root = JwtParser.parsePayload(signedPayload);
|
JsonNode root = JwtParser.parsePayload(signedPayload);
|
||||||
AppleServerNotification notification = new AppleServerNotification();
|
AppleServerNotification notification = new AppleServerNotification();
|
||||||
|
|
||||||
|
// 获取通知类型(支持驼峰和下划线两种命名格式)
|
||||||
notification.setNotificationType(text(root, "notificationType", "notification_type"));
|
notification.setNotificationType(text(root, "notificationType", "notification_type"));
|
||||||
|
|
||||||
|
// 解析 data 节点中的基本信息
|
||||||
JsonNode data = root.get("data");
|
JsonNode data = root.get("data");
|
||||||
if (data != null && !data.isNull()) {
|
if (data != null && !data.isNull()) {
|
||||||
notification.setEnvironment(text(data, "environment"));
|
notification.setEnvironment(text(data, "environment"));
|
||||||
@@ -83,14 +115,23 @@ public class AppleReceiptController {
|
|||||||
notification.setOriginalTransactionId(text(data, "originalTransactionId", "original_transaction_id"));
|
notification.setOriginalTransactionId(text(data, "originalTransactionId", "original_transaction_id"));
|
||||||
notification.setSignedTransactionInfo(text(data, "signedTransactionInfo", "signed_transaction_info"));
|
notification.setSignedTransactionInfo(text(data, "signedTransactionInfo", "signed_transaction_info"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 如果存在签名的交易信息,进一步解析嵌套的 JWT
|
||||||
if (notification.getSignedTransactionInfo() != null) {
|
if (notification.getSignedTransactionInfo() != null) {
|
||||||
JsonNode txNode = JwtParser.parsePayload(notification.getSignedTransactionInfo());
|
JsonNode txNode = JwtParser.parsePayload(notification.getSignedTransactionInfo());
|
||||||
|
|
||||||
|
// 从交易信息中提取详细字段
|
||||||
notification.setTransactionId(text(txNode, "transactionId", "transaction_id"));
|
notification.setTransactionId(text(txNode, "transactionId", "transaction_id"));
|
||||||
|
|
||||||
|
// 优先使用外层的值,如果为空则使用交易信息中的值
|
||||||
notification.setProductId(firstNonBlank(notification.getProductId(), text(txNode, "productId", "product_id")));
|
notification.setProductId(firstNonBlank(notification.getProductId(), text(txNode, "productId", "product_id")));
|
||||||
notification.setOriginalTransactionId(firstNonBlank(notification.getOriginalTransactionId(), text(txNode, "originalTransactionId", "original_transaction_id")));
|
notification.setOriginalTransactionId(firstNonBlank(notification.getOriginalTransactionId(), text(txNode, "originalTransactionId", "original_transaction_id")));
|
||||||
|
|
||||||
|
// 将时间戳转换为 ISO 8601 格式
|
||||||
notification.setPurchaseDate(epochToIso(txNode, "purchaseDate", "purchase_date"));
|
notification.setPurchaseDate(epochToIso(txNode, "purchaseDate", "purchase_date"));
|
||||||
notification.setExpiresDate(epochToIso(txNode, "expiresDate", "expires_date"));
|
notification.setExpiresDate(epochToIso(txNode, "expiresDate", "expires_date"));
|
||||||
}
|
}
|
||||||
|
|
||||||
return notification;
|
return notification;
|
||||||
} catch (IllegalArgumentException e) {
|
} catch (IllegalArgumentException e) {
|
||||||
throw new BusinessException(ErrorCode.PARAMS_ERROR, e.getMessage());
|
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) {
|
private String text(JsonNode node, String... keys) {
|
||||||
for (String k : keys) {
|
for (String k : keys) {
|
||||||
if (node != null && node.has(k) && !node.get(k).isNull()) {
|
if (node != null && node.has(k) && !node.get(k).isNull()) {
|
||||||
@@ -109,6 +158,14 @@ public class AppleReceiptController {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 返回第一个非空白的字符串
|
||||||
|
* 用于在多个可选值中选择优先级更高的非空值
|
||||||
|
*
|
||||||
|
* @param a 第一优先级的字符串
|
||||||
|
* @param b 第二优先级的字符串
|
||||||
|
* @return 第一个非空白的字符串,如果都为空则返回第一个参数
|
||||||
|
*/
|
||||||
private String firstNonBlank(String a, String b) {
|
private String firstNonBlank(String a, String b) {
|
||||||
if (a != null && !a.isBlank()) {
|
if (a != null && !a.isBlank()) {
|
||||||
return a;
|
return a;
|
||||||
@@ -116,15 +173,25 @@ public class AppleReceiptController {
|
|||||||
return (b != null && !b.isBlank()) ? b : a;
|
return (b != null && !b.isBlank()) ? b : a;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 Unix 时间戳(毫秒)转换为 ISO 8601 格式字符串
|
||||||
|
* 从 JSON 节点中提取时间戳并转换为标准 ISO 格式
|
||||||
|
*
|
||||||
|
* @param node JSON 节点
|
||||||
|
* @param keys 候选的时间戳字段键名
|
||||||
|
* @return ISO 8601 格式的时间字符串,如果解析失败则返回原始值
|
||||||
|
*/
|
||||||
private String epochToIso(JsonNode node, String... keys) {
|
private String epochToIso(JsonNode node, String... keys) {
|
||||||
String val = text(node, keys);
|
String val = text(node, keys);
|
||||||
if (val == null) {
|
if (val == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
// 解析时间戳并转换为 ISO 格式
|
||||||
long epochMillis = Long.parseLong(val);
|
long epochMillis = Long.parseLong(val);
|
||||||
return java.time.Instant.ofEpochMilli(epochMillis).toString();
|
return java.time.Instant.ofEpochMilli(epochMillis).toString();
|
||||||
} catch (NumberFormatException e) {
|
} catch (NumberFormatException e) {
|
||||||
|
// 如果不是有效的数字,直接返回原值
|
||||||
return val;
|
return val;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,15 +51,33 @@ public class ApplePurchaseServiceImpl implements ApplePurchaseService {
|
|||||||
@Resource
|
@Resource
|
||||||
private UserService userService;
|
private UserService userService;
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理苹果购买(订阅或内购)
|
||||||
|
* 主要流程:
|
||||||
|
* 1. 校验收据有效性
|
||||||
|
* 2. 幂等性检查,避免重复处理同一笔交易
|
||||||
|
* 3. 查询商品信息
|
||||||
|
* 4. 保存购买记录
|
||||||
|
* 5. 根据商品类型执行对应逻辑(订阅延期或钱包充值)
|
||||||
|
*
|
||||||
|
* @param userId 用户ID
|
||||||
|
* @param validationResult 苹果收据验证结果,包含交易ID、商品ID、过期时间等信息
|
||||||
|
* @throws BusinessException 当收据无效、商品不存在或商品类型未知时抛出
|
||||||
|
*/
|
||||||
@Override
|
@Override
|
||||||
@Transactional(rollbackFor = Exception.class)
|
@Transactional(rollbackFor = Exception.class)
|
||||||
public void processPurchase(Long userId, AppleReceiptValidationResult validationResult) {
|
public void processPurchase(Long userId, AppleReceiptValidationResult validationResult) {
|
||||||
|
// 1. 校验收据有效性
|
||||||
if (validationResult == null || !validationResult.isValid()) {
|
if (validationResult == null || !validationResult.isValid()) {
|
||||||
log.error("Apple receipt validation failed.{}", validationResult.getReason());
|
|
||||||
throw new BusinessException(ErrorCode.RECEIPT_INVALID);
|
throw new BusinessException(ErrorCode.RECEIPT_INVALID);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 2. 解析商品ID
|
||||||
String productId = resolveProductId(validationResult.getProductIds());
|
String productId = resolveProductId(validationResult.getProductIds());
|
||||||
// 幂等:transactionId 已经处理过则跳过
|
|
||||||
|
// 3. 幂等性检查:根据交易ID判断该笔交易是否已经处理过
|
||||||
|
// 防止同一笔购买被重复处理,导致用户多次获得权益
|
||||||
boolean handled = purchaseRecordsService.lambdaQuery()
|
boolean handled = purchaseRecordsService.lambdaQuery()
|
||||||
.eq(KeyboardUserPurchaseRecords::getTransactionId, validationResult.getTransactionId())
|
.eq(KeyboardUserPurchaseRecords::getTransactionId, validationResult.getTransactionId())
|
||||||
.eq(KeyboardUserPurchaseRecords::getStatus, "PAID")
|
.eq(KeyboardUserPurchaseRecords::getStatus, "PAID")
|
||||||
@@ -69,49 +87,72 @@ public class ApplePurchaseServiceImpl implements ApplePurchaseService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 4. 查询商品信息
|
||||||
KeyboardProductItems product = productItemsService.getProductEntityByProductId(productId);
|
KeyboardProductItems product = productItemsService.getProductEntityByProductId(productId);
|
||||||
if (product == null) {
|
if (product == null) {
|
||||||
log.error("Apple purchase not found, productId={}", productId);
|
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);
|
KeyboardUserPurchaseRecords purchaseRecord = buildPurchaseRecord(userId, validationResult, product);
|
||||||
purchaseRecordsService.save(purchaseRecord);
|
purchaseRecordsService.save(purchaseRecord);
|
||||||
|
|
||||||
|
// 6. 根据商品类型执行相应的业务逻辑
|
||||||
if ("subscription".equalsIgnoreCase(product.getType())) {
|
if ("subscription".equalsIgnoreCase(product.getType())) {
|
||||||
|
// 订阅类商品:延长用户VIP有效期
|
||||||
handleSubscription(userId, product, validationResult);
|
handleSubscription(userId, product, validationResult);
|
||||||
} else if ("in-app-purchase".equalsIgnoreCase(product.getType())) {
|
} else if ("in-app-purchase".equalsIgnoreCase(product.getType())) {
|
||||||
|
// 内购类商品:为用户钱包充值
|
||||||
handleInAppPurchase(userId, product, purchaseRecord.getId());
|
handleInAppPurchase(userId, product, purchaseRecord.getId());
|
||||||
} else {
|
} 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
|
@Override
|
||||||
@Transactional(rollbackFor = Exception.class)
|
@Transactional(rollbackFor = Exception.class)
|
||||||
public void processRenewNotification(AppleServerNotification notification) {
|
public void processRenewNotification(AppleServerNotification notification) {
|
||||||
|
// 参数校验:确保通知对象和原始交易ID不为空
|
||||||
if (notification == null || notification.getOriginalTransactionId() == null) {
|
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<KeyboardUserPurchaseRecords> records = purchaseRecordsService.lambdaQuery()
|
List<KeyboardUserPurchaseRecords> records = purchaseRecordsService.lambdaQuery()
|
||||||
.eq(KeyboardUserPurchaseRecords::getOriginalTransactionId, notification.getOriginalTransactionId())
|
.eq(KeyboardUserPurchaseRecords::getOriginalTransactionId, notification.getOriginalTransactionId())
|
||||||
.orderByDesc(KeyboardUserPurchaseRecords::getId)
|
.orderByDesc(KeyboardUserPurchaseRecords::getId)
|
||||||
|
.last("LIMIT 1")
|
||||||
.list();
|
.list();
|
||||||
|
|
||||||
|
// 如果找不到匹配的购买记录,记录警告并返回
|
||||||
if (records == null || records.isEmpty()) {
|
if (records == null || records.isEmpty()) {
|
||||||
log.warn("Renewal notification without matching purchase record, originalTransactionId={}", notification.getOriginalTransactionId());
|
log.warn("Renewal notification without matching purchase record, originalTransactionId={}", notification.getOriginalTransactionId());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
KeyboardUserPurchaseRecords record = records.get(0);
|
KeyboardUserPurchaseRecords record = records.get(0);
|
||||||
|
|
||||||
|
// 根据商品ID获取商品信息
|
||||||
KeyboardProductItems product = productItemsService.getProductEntityByProductId(notification.getProductId());
|
KeyboardProductItems product = productItemsService.getProductEntityByProductId(notification.getProductId());
|
||||||
|
|
||||||
|
// 校验商品是否存在且类型为订阅类型
|
||||||
if (product == null || !"subscription".equalsIgnoreCase(product.getType())) {
|
if (product == null || !"subscription".equalsIgnoreCase(product.getType())) {
|
||||||
log.warn("Renewal notification ignored, product not subscription or not found. productId={}", notification.getProductId());
|
log.warn("Renewal notification ignored, product not subscription or not found. productId={}", notification.getProductId());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 写一条续订记录
|
// 构建续订购买记录对象
|
||||||
KeyboardUserPurchaseRecords renewRecord = new KeyboardUserPurchaseRecords();
|
KeyboardUserPurchaseRecords renewRecord = new KeyboardUserPurchaseRecords();
|
||||||
renewRecord.setUserId(record.getUserId());
|
renewRecord.setUserId(record.getUserId());
|
||||||
renewRecord.setProductId(product.getProductId());
|
renewRecord.setProductId(product.getProductId());
|
||||||
@@ -120,184 +161,366 @@ public class ApplePurchaseServiceImpl implements ApplePurchaseService {
|
|||||||
renewRecord.setCurrency(product.getCurrency());
|
renewRecord.setCurrency(product.getCurrency());
|
||||||
renewRecord.setPurchaseTime(toDate(parseInstant(notification.getPurchaseDate())));
|
renewRecord.setPurchaseTime(toDate(parseInstant(notification.getPurchaseDate())));
|
||||||
renewRecord.setPurchaseType(product.getType());
|
renewRecord.setPurchaseType(product.getType());
|
||||||
renewRecord.setStatus("PAID");
|
renewRecord.setStatus("PAID"); // 续订状态默认为已支付
|
||||||
renewRecord.setPaymentMethod("APPLE");
|
renewRecord.setPaymentMethod("APPLE");
|
||||||
renewRecord.setTransactionId(notification.getTransactionId());
|
renewRecord.setTransactionId(notification.getTransactionId()); // 新的交易ID
|
||||||
renewRecord.setOriginalTransactionId(notification.getOriginalTransactionId());
|
renewRecord.setOriginalTransactionId(notification.getOriginalTransactionId()); // 原始交易ID
|
||||||
renewRecord.setProductIds(new String[]{product.getProductId()});
|
renewRecord.setProductIds(new String[]{product.getProductId()});
|
||||||
|
|
||||||
|
// 解析并设置订阅过期时间
|
||||||
Instant expiresInstant = parseInstant(notification.getExpiresDate());
|
Instant expiresInstant = parseInstant(notification.getExpiresDate());
|
||||||
if (expiresInstant != null) {
|
if (expiresInstant != null) {
|
||||||
renewRecord.setExpiresDate(Date.from(expiresInstant));
|
renewRecord.setExpiresDate(Date.from(expiresInstant));
|
||||||
}
|
}
|
||||||
renewRecord.setEnvironment(notification.getEnvironment());
|
renewRecord.setEnvironment(notification.getEnvironment());
|
||||||
renewRecord.setPurchaseDate(toDate(parseInstant(notification.getPurchaseDate())));
|
renewRecord.setPurchaseDate(toDate(parseInstant(notification.getPurchaseDate())));
|
||||||
|
|
||||||
|
// 保存续订记录到数据库
|
||||||
purchaseRecordsService.save(renewRecord);
|
purchaseRecordsService.save(renewRecord);
|
||||||
|
|
||||||
|
// 延长用户VIP有效期
|
||||||
extendVip(record.getUserId().longValue(), product, expiresInstant);
|
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) {
|
private void handleSubscription(Long userId, KeyboardProductItems product, AppleReceiptValidationResult validationResult) {
|
||||||
|
// 1. 查询用户信息
|
||||||
KeyboardUser user = userService.getById(userId);
|
KeyboardUser user = userService.getById(userId);
|
||||||
if (user == null) {
|
if (user == null) {
|
||||||
throw new BusinessException(ErrorCode.USER_NOT_FOUND);
|
throw new BusinessException(ErrorCode.USER_NOT_FOUND);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 2. 确定VIP延期的基准时间
|
||||||
|
// 如果用户当前VIP未过期,则基于当前过期时间延长;否则基于当前时间延长
|
||||||
Instant base = resolveBaseExpiry(user.getVipExpiry());
|
Instant base = resolveBaseExpiry(user.getVipExpiry());
|
||||||
|
|
||||||
|
// 3. 计算新的VIP过期时间
|
||||||
|
// 根据商品配置的时长(天数)进行延期
|
||||||
Instant newExpiry = base.plus(resolveDurationDays(product), ChronoUnit.DAYS);
|
Instant newExpiry = base.plus(resolveDurationDays(product), ChronoUnit.DAYS);
|
||||||
|
|
||||||
|
// 4. 更新用户VIP状态
|
||||||
user.setIsVip(true);
|
user.setIsVip(true);
|
||||||
user.setVipExpiry(Date.from(newExpiry));
|
user.setVipExpiry(Date.from(newExpiry));
|
||||||
|
|
||||||
|
// 5. 保存用户信息到数据库
|
||||||
boolean updated = userService.updateById(user);
|
boolean updated = userService.updateById(user);
|
||||||
if (!updated) {
|
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);
|
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) {
|
private void handleInAppPurchase(Long userId, KeyboardProductItems product, Integer purchaseRecordId) {
|
||||||
|
// 1. 查询用户钱包信息
|
||||||
KeyboardUserWallet wallet = walletService.lambdaQuery()
|
KeyboardUserWallet wallet = walletService.lambdaQuery()
|
||||||
.eq(KeyboardUserWallet::getUserId, userId)
|
.eq(KeyboardUserWallet::getUserId, userId)
|
||||||
.one();
|
.one();
|
||||||
|
|
||||||
|
// 2. 如果用户钱包不存在,创建新钱包
|
||||||
if (wallet == null) {
|
if (wallet == null) {
|
||||||
wallet = new KeyboardUserWallet();
|
wallet = new KeyboardUserWallet();
|
||||||
wallet.setUserId(userId);
|
wallet.setUserId(userId);
|
||||||
wallet.setBalance(BigDecimal.ZERO);
|
wallet.setBalance(BigDecimal.ZERO);
|
||||||
wallet.setStatus((short) 1);
|
wallet.setStatus((short) 1); // 状态:1-正常
|
||||||
wallet.setVersion(0);
|
wallet.setVersion(0); // 乐观锁版本号
|
||||||
wallet.setCreatedAt(new Date());
|
wallet.setCreatedAt(new Date());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 3. 解析商品的充值额度
|
||||||
BigDecimal credit = resolveCreditAmount(product);
|
BigDecimal credit = resolveCreditAmount(product);
|
||||||
|
|
||||||
|
// 4. 校验充值额度是否有效
|
||||||
if (credit.compareTo(BigDecimal.ZERO) <= 0) {
|
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 before = wallet.getBalance() == null ? BigDecimal.ZERO : wallet.getBalance();
|
||||||
BigDecimal after = before.add(credit);
|
BigDecimal after = before.add(credit);
|
||||||
|
|
||||||
|
// 6. 更新钱包余额
|
||||||
wallet.setBalance(after);
|
wallet.setBalance(after);
|
||||||
wallet.setUpdatedAt(new Date());
|
wallet.setUpdatedAt(new Date());
|
||||||
walletService.saveOrUpdate(wallet);
|
walletService.saveOrUpdate(wallet);
|
||||||
|
|
||||||
|
// 7. 创建钱包交易记录,用于财务对账和历史查询
|
||||||
KeyboardWalletTransaction tx = walletTransactionService.createTransaction(
|
KeyboardWalletTransaction tx = walletTransactionService.createTransaction(
|
||||||
userId,
|
userId,
|
||||||
purchaseRecordId == null ? null : purchaseRecordId.longValue(),
|
purchaseRecordId == null ? null : purchaseRecordId.longValue(),
|
||||||
credit,
|
credit,
|
||||||
(short) 2, // 2: 苹果内购充值
|
(short) 2, // 交易类型:2-苹果内购充值
|
||||||
before,
|
before,
|
||||||
after,
|
after,
|
||||||
"Apple 充值: " + product.getProductId()
|
"Apple 充值: " + product.getProductId()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 8. 记录充值成功日志
|
||||||
log.info("Wallet recharge success, user={}, credit={}, txId={}", userId, credit, tx.getId());
|
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) {
|
private KeyboardUserPurchaseRecords buildPurchaseRecord(Long userId, AppleReceiptValidationResult validationResult, KeyboardProductItems product) {
|
||||||
KeyboardUserPurchaseRecords record = new KeyboardUserPurchaseRecords();
|
KeyboardUserPurchaseRecords record = new KeyboardUserPurchaseRecords();
|
||||||
|
|
||||||
|
// 设置用户ID(转换为Integer类型)
|
||||||
record.setUserId(userId.intValue());
|
record.setUserId(userId.intValue());
|
||||||
|
|
||||||
|
// 设置商品相关信息
|
||||||
record.setProductId(product.getProductId());
|
record.setProductId(product.getProductId());
|
||||||
record.setPurchaseQuantity(product.getDurationValue());
|
record.setPurchaseQuantity(product.getDurationValue()); // 购买数量/时长
|
||||||
record.setPrice(product.getPrice());
|
record.setPrice(product.getPrice()); // 商品价格
|
||||||
record.setCurrency(product.getCurrency());
|
record.setCurrency(product.getCurrency()); // 货币类型
|
||||||
|
record.setPurchaseType(product.getType()); // 商品类型:subscription 或 in-app-purchase
|
||||||
|
|
||||||
|
// 设置购买时间,如果验证结果中没有则使用当前时间
|
||||||
record.setPurchaseTime(Date.from(Objects.requireNonNullElseGet(validationResult.getPurchaseDate(), Instant::now)));
|
record.setPurchaseTime(Date.from(Objects.requireNonNullElseGet(validationResult.getPurchaseDate(), Instant::now)));
|
||||||
record.setPurchaseType(product.getType());
|
|
||||||
record.setStatus("PAID");
|
// 设置交易状态和支付方式
|
||||||
record.setPaymentMethod("APPLE");
|
record.setStatus("PAID"); // 状态:已支付
|
||||||
record.setTransactionId(validationResult.getTransactionId());
|
record.setPaymentMethod("APPLE"); // 支付方式:苹果支付
|
||||||
record.setOriginalTransactionId(validationResult.getOriginalTransactionId());
|
|
||||||
record.setProductIds(new String[]{String.join(",", validationResult.getProductIds())});
|
// 设置苹果交易相关信息
|
||||||
|
record.setTransactionId(validationResult.getTransactionId()); // 当前交易ID
|
||||||
|
record.setOriginalTransactionId(validationResult.getOriginalTransactionId()); // 原始交易ID(用于续订关联)
|
||||||
|
record.setProductIds(new String[]{String.join(",", validationResult.getProductIds())}); // 商品ID列表(逗号分隔)
|
||||||
|
|
||||||
|
// 设置订阅过期时间(仅订阅类商品有此字段)
|
||||||
if (validationResult.getExpiresDate() != null) {
|
if (validationResult.getExpiresDate() != null) {
|
||||||
record.setExpiresDate(Date.from(validationResult.getExpiresDate()));
|
record.setExpiresDate(Date.from(validationResult.getExpiresDate()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 设置交易环境:PRODUCTION(生产环境)或 SANDBOX(沙盒环境)
|
||||||
record.setEnvironment(validationResult.getEnvironment() == null ? null : validationResult.getEnvironment().name());
|
record.setEnvironment(validationResult.getEnvironment() == null ? null : validationResult.getEnvironment().name());
|
||||||
|
|
||||||
|
// 设置购买日期(与purchaseTime可能有细微差别,保留原始数据)
|
||||||
record.setPurchaseDate(validationResult.getPurchaseDate() == null ? null : Date.from(validationResult.getPurchaseDate()));
|
record.setPurchaseDate(validationResult.getPurchaseDate() == null ? null : Date.from(validationResult.getPurchaseDate()));
|
||||||
|
|
||||||
return record;
|
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) {
|
private void extendVip(Long userId, KeyboardProductItems product, Instant targetExpiry) {
|
||||||
|
// 1. 查询用户信息
|
||||||
KeyboardUser user = userService.getById(userId);
|
KeyboardUser user = userService.getById(userId);
|
||||||
if (user == null) {
|
if (user == null) {
|
||||||
throw new BusinessException(ErrorCode.USER_NOT_FOUND);
|
throw new BusinessException(ErrorCode.USER_NOT_FOUND);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 2. 解析商品配置的订阅时长(天数)
|
||||||
long durationDays = resolveDurationDays(product);
|
long durationDays = resolveDurationDays(product);
|
||||||
|
|
||||||
|
// 3. 确定VIP延期的基准时间
|
||||||
|
// 如果用户当前VIP未过期,则基于当前过期时间延长;否则基于当前时间延长
|
||||||
Instant base = resolveBaseExpiry(user.getVipExpiry());
|
Instant base = resolveBaseExpiry(user.getVipExpiry());
|
||||||
|
|
||||||
|
// 4. 计算新的VIP过期时间
|
||||||
|
// 优先使用目标时间(如续订通知中的过期时间),否则基于基准时间加上订阅时长
|
||||||
Instant newExpiry = targetExpiry != null ? targetExpiry : base.plus(durationDays, ChronoUnit.DAYS);
|
Instant newExpiry = targetExpiry != null ? targetExpiry : base.plus(durationDays, ChronoUnit.DAYS);
|
||||||
// 如果目标时间早于当前,则基于当前时间加时长
|
|
||||||
|
// 5. 防御性检查:如果计算出的过期时间早于当前时间(异常情况),则基于当前时间重新计算
|
||||||
|
// 这种情况可能发生在时间不同步或数据异常时,确保VIP不会立即过期
|
||||||
if (newExpiry.isBefore(Instant.now())) {
|
if (newExpiry.isBefore(Instant.now())) {
|
||||||
newExpiry = Instant.now().plus(durationDays, ChronoUnit.DAYS);
|
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);
|
boolean updated = userService.updateById(user);
|
||||||
if (!updated) {
|
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);
|
log.info("Extend VIP by notification, user {} to {}", userId, newExpiry);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析商品ID列表,提取第一个商品ID
|
||||||
|
*
|
||||||
|
* @param productIds 商品ID列表,通常来自苹果收据验证结果
|
||||||
|
* @return 第一个商品ID
|
||||||
|
* @throws BusinessException 当商品ID列表为空时抛出
|
||||||
|
*/
|
||||||
private String resolveProductId(List<String> productIds) {
|
private String resolveProductId(List<String> productIds) {
|
||||||
|
// 校验商品ID列表不为空
|
||||||
if (productIds == null || productIds.isEmpty()) {
|
if (productIds == null || productIds.isEmpty()) {
|
||||||
throw new BusinessException(ErrorCode.PARAMS_ERROR, "productId 缺失");
|
throw new BusinessException(ErrorCode.PARAMS_ERROR, "productId 缺失");
|
||||||
}
|
}
|
||||||
|
// 返回列表中的第一个商品ID
|
||||||
return productIds.get(0);
|
return productIds.get(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析VIP延期的基准时间
|
||||||
|
* 如果用户当前VIP未过期,则基于当前过期时间延长;否则基于当前时间延长
|
||||||
|
*
|
||||||
|
* @param currentExpiry 用户当前的VIP过期时间
|
||||||
|
* @return VIP延期的基准时间点
|
||||||
|
*/
|
||||||
private Instant resolveBaseExpiry(Date currentExpiry) {
|
private Instant resolveBaseExpiry(Date currentExpiry) {
|
||||||
|
// 如果当前过期时间为空,返回当前时间作为基准
|
||||||
if (currentExpiry == null) {
|
if (currentExpiry == null) {
|
||||||
return Instant.now();
|
return Instant.now();
|
||||||
}
|
}
|
||||||
|
// 将Date转换为Instant
|
||||||
Instant current = currentExpiry.toInstant();
|
Instant current = currentExpiry.toInstant();
|
||||||
|
// 如果VIP未过期,使用当前过期时间作为基准;否则使用当前时间
|
||||||
return current.isAfter(Instant.now()) ? current : Instant.now();
|
return current.isAfter(Instant.now()) ? current : Instant.now();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析商品的订阅时长(转换为天数)
|
||||||
|
* 支持多种配置方式:
|
||||||
|
* 1. 直接配置的durationDays字段
|
||||||
|
* 2. 通过durationValue和durationUnit组合计算(支持天/月单位)
|
||||||
|
*
|
||||||
|
* @param product 商品信息,包含时长相关配置
|
||||||
|
* @return 订阅时长(天数),如果无法解析则返回0
|
||||||
|
*/
|
||||||
private long resolveDurationDays(KeyboardProductItems product) {
|
private long resolveDurationDays(KeyboardProductItems product) {
|
||||||
|
// 优先使用直接配置的天数字段
|
||||||
if (product.getDurationDays() != null && product.getDurationDays() > 0) {
|
if (product.getDurationDays() != null && product.getDurationDays() > 0) {
|
||||||
return product.getDurationDays();
|
return product.getDurationDays();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 解析时长数值和单位
|
||||||
Integer value = product.getDurationValue();
|
Integer value = product.getDurationValue();
|
||||||
String unit = product.getDurationUnit() == null ? "" : product.getDurationUnit().toLowerCase();
|
String unit = product.getDurationUnit() == null ? "" : product.getDurationUnit().toLowerCase();
|
||||||
|
|
||||||
|
// 校验时长数值是否有效
|
||||||
if (value == null || value <= 0) {
|
if (value == null || value <= 0) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 根据单位进行转换
|
||||||
|
// 如果单位是"天",直接返回数值
|
||||||
if (unit.contains("day") || unit.contains("天")) {
|
if (unit.contains("day") || unit.contains("天")) {
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
// 如果单位是"月",按30天/月进行转换
|
||||||
if (unit.contains("month") || unit.contains("月")) {
|
if (unit.contains("month") || unit.contains("月")) {
|
||||||
return (long) value * 30;
|
return (long) value * 30;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 默认返回原始数值(当单位未知时)
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析商品的充值额度
|
||||||
|
* 优先从商品名称中提取数值,如果提取失败则使用durationValue字段
|
||||||
|
*
|
||||||
|
* @param product 商品信息,包含name和durationValue字段
|
||||||
|
* @return 充值额度,如果无法解析则返回0
|
||||||
|
*/
|
||||||
private BigDecimal resolveCreditAmount(KeyboardProductItems product) {
|
private BigDecimal resolveCreditAmount(KeyboardProductItems product) {
|
||||||
// 优先使用 name 中的数值,否则退回 durationValue
|
// 优先尝试从商品名称中解析数值(如"100金币"中的100)
|
||||||
BigDecimal fromName = parseNumber(product.getName());
|
BigDecimal fromName = parseNumber(product.getName());
|
||||||
if (fromName != null) {
|
if (fromName != null) {
|
||||||
return fromName;
|
return fromName;
|
||||||
}
|
}
|
||||||
|
// 如果名称中没有数值,使用durationValue字段作为备选
|
||||||
if (product.getDurationValue() != null) {
|
if (product.getDurationValue() != null) {
|
||||||
return BigDecimal.valueOf(product.getDurationValue());
|
return BigDecimal.valueOf(product.getDurationValue());
|
||||||
}
|
}
|
||||||
|
// 如果都没有,返回0
|
||||||
return BigDecimal.ZERO;
|
return BigDecimal.ZERO;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从字符串中提取数值
|
||||||
|
* 通过正则表达式移除所有非数字和小数点字符,然后转换为BigDecimal
|
||||||
|
*
|
||||||
|
* @param raw 原始字符串,可能包含数字和其他字符(如"100金币")
|
||||||
|
* @return 提取出的数值,如果解析失败则返回null
|
||||||
|
*/
|
||||||
private BigDecimal parseNumber(String raw) {
|
private BigDecimal parseNumber(String raw) {
|
||||||
|
// 空值检查
|
||||||
if (raw == null) {
|
if (raw == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
// 移除所有非数字和非小数点字符,然后转换为BigDecimal
|
||||||
return new BigDecimal(raw.replaceAll("[^\\d.]", ""));
|
return new BigDecimal(raw.replaceAll("[^\\d.]", ""));
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
|
// 解析失败时返回null(如字符串中完全没有数字)
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析ISO 8601格式的时间字符串为Instant对象
|
||||||
|
*
|
||||||
|
* @param iso ISO 8601格式的时间字符串(如"2024-01-01T00:00:00Z")
|
||||||
|
* @return 解析后的Instant对象,解析失败或输入为空时返回null
|
||||||
|
*/
|
||||||
private Instant parseInstant(String iso) {
|
private Instant parseInstant(String iso) {
|
||||||
|
// 空值或空白字符串检查
|
||||||
if (iso == null || iso.isBlank()) {
|
if (iso == null || iso.isBlank()) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
// 解析ISO 8601格式的时间字符串
|
||||||
return Instant.parse(iso);
|
return Instant.parse(iso);
|
||||||
} catch (DateTimeParseException e) {
|
} catch (DateTimeParseException e) {
|
||||||
|
// 解析失败时记录警告日志并返回null
|
||||||
log.warn("Failed to parse expiresDate: {}", iso);
|
log.warn("Failed to parse expiresDate: {}", iso);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将Instant对象转换为Date对象
|
||||||
|
*
|
||||||
|
* @param instant Instant时间对象
|
||||||
|
* @return 转换后的Date对象,输入为null时返回null
|
||||||
|
*/
|
||||||
private Date toDate(Instant instant) {
|
private Date toDate(Instant instant) {
|
||||||
return instant == null ? null : Date.from(instant);
|
return instant == null ? null : Date.from(instant);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user