Compare commits

...

2 Commits

12 changed files with 210 additions and 20 deletions

View File

@@ -7,6 +7,7 @@ import com.yolo.keyborad.common.ResultUtils;
import com.yolo.keyborad.exception.BusinessException; import com.yolo.keyborad.exception.BusinessException;
import com.yolo.keyborad.model.dto.AppleReceiptValidationResult; import com.yolo.keyborad.model.dto.AppleReceiptValidationResult;
import com.yolo.keyborad.service.ApplePurchaseService; import com.yolo.keyborad.service.ApplePurchaseService;
import com.yolo.keyborad.service.KeyboardUserPurchaseRecordsService;
import com.yolo.keyborad.service.AppleReceiptService; import com.yolo.keyborad.service.AppleReceiptService;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestBody;
@@ -23,11 +24,14 @@ public class AppleReceiptController {
private final AppleReceiptService appleReceiptService; private final AppleReceiptService appleReceiptService;
private final ApplePurchaseService applePurchaseService; private final ApplePurchaseService applePurchaseService;
private final KeyboardUserPurchaseRecordsService purchaseRecordsService;
public AppleReceiptController(AppleReceiptService appleReceiptService, public AppleReceiptController(AppleReceiptService appleReceiptService,
ApplePurchaseService applePurchaseService) { ApplePurchaseService applePurchaseService,
KeyboardUserPurchaseRecordsService purchaseRecordsService) {
this.appleReceiptService = appleReceiptService; this.appleReceiptService = appleReceiptService;
this.applePurchaseService = applePurchaseService; this.applePurchaseService = applePurchaseService;
this.purchaseRecordsService = purchaseRecordsService;
} }
@PostMapping("/receipt") @PostMapping("/receipt")
@@ -85,4 +89,26 @@ public class AppleReceiptController {
return ResultUtils.success(Boolean.TRUE); return ResultUtils.success(Boolean.TRUE);
} }
/**
* 检查购买记录是否存在
* 根据 transactionId 和 originalTransactionId 查询购买记录
*
* @param body 请求体,包含 transactionId 和 originalTransactionId
* @return 存在返回 true不存在返回 false
*/
@PostMapping("/check-purchase")
public BaseResponse<Boolean> checkPurchaseExists(@RequestBody Map<String, String> body) {
if (body == null) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "body 不能为空");
}
String transactionId = body.get("transactionId");
String originalTransactionId = body.get("originalTransactionId");
if ((transactionId == null || transactionId.isBlank())
&& (originalTransactionId == null || originalTransactionId.isBlank())) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "transactionId 和 originalTransactionId 不能同时为空");
}
boolean exists = purchaseRecordsService.checkPurchaseExists(transactionId, originalTransactionId);
return ResultUtils.success(exists);
}
} }

View File

@@ -132,4 +132,8 @@ public class KeyboardUser {
@TableField(value = "vip_level") @TableField(value = "vip_level")
@Schema(description = "vip等级") @Schema(description = "vip等级")
private Integer vipLevel; private Integer vipLevel;
@TableField(value = "uuid")
@Schema(description = "uuid")
private String uuid;
} }

View File

@@ -57,4 +57,6 @@ public class KeyboardUserInfoRespVO {
@Schema(description = "vip等级") @Schema(description = "vip等级")
private Integer vipLevel; private Integer vipLevel;
@Schema(description = "uuid")
private String uuid;
} }

View File

@@ -59,4 +59,8 @@ public class KeyboardUserRespVO {
@TableField(value = "vip_level") @TableField(value = "vip_level")
@Schema(description = "vip等级") @Schema(description = "vip等级")
private Integer vipLevel; private Integer vipLevel;
@TableField(value = "uuid")
@Schema(description = "uuid")
private String uuid;
} }

View File

@@ -43,4 +43,11 @@ public interface ApplePurchaseService {
* @param notification 解码后的通知载荷 * @param notification 解码后的通知载荷
*/ */
void handleConsumptionRequest(ResponseBodyV2DecodedPayload notification); void handleConsumptionRequest(ResponseBodyV2DecodedPayload notification);
/**
* 处理一次性购买通知ONE_TIME_CHARGE
*
* @param notification 解码后的通知载荷
*/
void handleOneTimeChargeNotification(ResponseBodyV2DecodedPayload notification);
} }

View File

@@ -8,4 +8,13 @@ import com.baomidou.mybatisplus.extension.service.IService;
*/ */
public interface KeyboardUserPurchaseRecordsService extends IService<KeyboardUserPurchaseRecords>{ public interface KeyboardUserPurchaseRecordsService extends IService<KeyboardUserPurchaseRecords>{
/**
* 检查购买记录是否存在
*
* @param transactionId 交易 ID
* @param originalTransactionId 原始交易 ID
* @return 存在返回 true不存在返回 false
*/
boolean checkPurchaseExists(String transactionId, String originalTransactionId);
} }

View File

@@ -38,4 +38,5 @@ public interface UserService extends IService<KeyboardUser> {
*/ */
Boolean cancelAccount(long userId); Boolean cancelAccount(long userId);
Long selectUserByUUid(String uuid);
} }

View File

@@ -1,5 +1,6 @@
package com.yolo.keyborad.service.impl; package com.yolo.keyborad.service.impl;
import cn.dev33.satoken.stp.StpUtil;
import com.apple.itunes.storekit.model.JWSRenewalInfoDecodedPayload; import com.apple.itunes.storekit.model.JWSRenewalInfoDecodedPayload;
import com.apple.itunes.storekit.model.JWSTransactionDecodedPayload; import com.apple.itunes.storekit.model.JWSTransactionDecodedPayload;
import com.apple.itunes.storekit.model.NotificationTypeV2; import com.apple.itunes.storekit.model.NotificationTypeV2;
@@ -34,6 +35,7 @@ import java.time.format.DateTimeParseException;
import java.util.Date; import java.util.Date;
import java.util.List; import java.util.List;
import java.util.Objects; import java.util.Objects;
import java.util.UUID;
/** /**
* 苹果购买后置处理:订阅续期 / 内购充值 + 记录落库 * 苹果购买后置处理:订阅续期 / 内购充值 + 记录落库
@@ -152,23 +154,10 @@ public class ApplePurchaseServiceImpl implements ApplePurchaseService {
JWSTransactionDecodedPayload transaction = JWSTransactionDecodedPayload transaction =
signedDataVerifier.verifyAndDecodeTransaction(signedTransactionInfo); signedDataVerifier.verifyAndDecodeTransaction(signedTransactionInfo);
String originalTransactionId = transaction.getOriginalTransactionId();
String productId = transaction.getProductId(); String productId = transaction.getProductId();
// 根据原始交易ID查询用户购买记录 UUID appAccountToken = transaction.getAppAccountToken();
List<KeyboardUserPurchaseRecords> records = purchaseRecordsService.lambdaQuery() Long userId = userService.selectUserByUUid(appAccountToken.toString());
.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); KeyboardProductItems product = productItemsService.getProductEntityByProductId(productId);
@@ -354,6 +343,127 @@ public class ApplePurchaseServiceImpl implements ApplePurchaseService {
// 提供用户消费状态、交付状态等信息,帮助 Apple 评估退款请求 // 提供用户消费状态、交付状态等信息,帮助 Apple 评估退款请求
} }
/**
* 处理一次性购买通知ONE_TIME_CHARGE
* 流程参考 processPurchase
* 1. 验证并解码交易信息
* 2. 幂等性检查,避免重复处理
* 3. 通过 originalTransactionId 查找用户
* 4. 查询商品信息
* 5. 保存购买记录
* 6. 根据商品类型执行对应逻辑(订阅延期或钱包充值)
*
* @param notification 解码后的通知载荷
*/
@Override
@Transactional(rollbackFor = Exception.class)
public void handleOneTimeChargeNotification(ResponseBodyV2DecodedPayload notification) {
if (notification == null || notification.getData() == null) {
log.warn("ONE_TIME_CHARGE notification data is null");
return;
}
try {
// 1. 解码交易信息
String signedTransactionInfo = notification.getData().getSignedTransactionInfo();
if (signedTransactionInfo == null || signedTransactionInfo.isBlank()) {
log.warn("No signed transaction info in ONE_TIME_CHARGE notification");
return;
}
JWSTransactionDecodedPayload transaction =
signedDataVerifier.verifyAndDecodeTransaction(signedTransactionInfo);
String transactionId = transaction.getTransactionId();
String originalTransactionId = transaction.getOriginalTransactionId();
String productId = transaction.getProductId();
UUID appAccountToken = transaction.getAppAccountToken();
Long userId = userService.selectUserByUUid(appAccountToken.toString());
log.info("Processing ONE_TIME_CHARGE: transactionId={}, productId={}", transactionId, productId);
// 2. 幂等性检查根据交易ID判断是否已处理
boolean handled = purchaseRecordsService.lambdaQuery()
.eq(KeyboardUserPurchaseRecords::getTransactionId, transactionId)
.eq(KeyboardUserPurchaseRecords::getStatus, "PAID")
.exists();
if (handled) {
log.info("ONE_TIME_CHARGE already handled, transactionId={}", transactionId);
return;
}
// // 3. 通过 originalTransactionId 查找关联的用户购买记录以获取 userId
// 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 ONE_TIME_CHARGE, originalTransactionId={}", originalTransactionId);
// return;
// }
// Long userId = records.get(0).getUserId().longValue();
// 4. 查询商品信息
KeyboardProductItems product = productItemsService.getProductEntityByProductId(productId);
if (product == null) {
log.error("Product not found for ONE_TIME_CHARGE, productId={}", productId);
return;
}
// 5. 构建并保存购买记录
KeyboardUserPurchaseRecords purchaseRecord = new KeyboardUserPurchaseRecords();
purchaseRecord.setUserId(userId.intValue());
purchaseRecord.setProductId(productId);
purchaseRecord.setPurchaseQuantity(product.getDurationValue());
purchaseRecord.setPrice(product.getPrice());
purchaseRecord.setCurrency(product.getCurrency());
purchaseRecord.setPurchaseType(product.getType());
purchaseRecord.setStatus("PAID");
purchaseRecord.setPaymentMethod("APPLE");
purchaseRecord.setTransactionId(transactionId);
purchaseRecord.setOriginalTransactionId(originalTransactionId);
purchaseRecord.setProductIds(new String[]{productId});
if (transaction.getPurchaseDate() != null) {
purchaseRecord.setPurchaseTime(Date.from(Instant.ofEpochMilli(transaction.getPurchaseDate())));
purchaseRecord.setPurchaseDate(Date.from(Instant.ofEpochMilli(transaction.getPurchaseDate())));
} else {
purchaseRecord.setPurchaseTime(new Date());
}
if (transaction.getExpiresDate() != null) {
purchaseRecord.setExpiresDate(Date.from(Instant.ofEpochMilli(transaction.getExpiresDate())));
}
if (transaction.getEnvironment() != null) {
purchaseRecord.setEnvironment(transaction.getEnvironment().name());
}
purchaseRecordsService.save(purchaseRecord);
// 6. 根据商品类型执行对应的业务逻辑
if ("subscription".equalsIgnoreCase(product.getType())) {
Instant expiresInstant = transaction.getExpiresDate() != null
? Instant.ofEpochMilli(transaction.getExpiresDate()) : null;
extendVip(userId, product, expiresInstant);
} else if ("in-app-purchase".equalsIgnoreCase(product.getType())) {
handleInAppPurchase(userId, product, purchaseRecord.getId());
} else {
log.warn("未知商品类型, type={}, productId={}", product.getType(), productId);
}
log.info("ONE_TIME_CHARGE processed successfully: userId={}, transactionId={}", userId, transactionId);
} catch (VerificationException e) {
log.error("Failed to verify transaction in ONE_TIME_CHARGE notification", e);
} catch (Exception e) {
log.error("Error processing ONE_TIME_CHARGE notification", e);
throw new BusinessException(ErrorCode.OPERATION_ERROR, "处理一次性购买通知失败");
}
}
/** /**
* 处理成功的续订 * 处理成功的续订
* 创建新的购买记录并延长VIP有效期 * 创建新的购买记录并延长VIP有效期

View File

@@ -155,6 +155,7 @@ public class AppleReceiptServiceImpl implements AppleReceiptService {
// 续订偏好变更通知 // 续订偏好变更通知
case DID_CHANGE_RENEWAL_PREF: case DID_CHANGE_RENEWAL_PREF:
case DID_CHANGE_RENEWAL_STATUS: case DID_CHANGE_RENEWAL_STATUS:
case PRICE_INCREASE: case PRICE_INCREASE:
applePurchaseService.handleRenewalPreferenceChange(notification); applePurchaseService.handleRenewalPreferenceChange(notification);
@@ -165,9 +166,14 @@ public class AppleReceiptServiceImpl implements AppleReceiptService {
applePurchaseService.handleConsumptionRequest(notification); applePurchaseService.handleConsumptionRequest(notification);
break; break;
// 一次性购买通知
case ONE_TIME_CHARGE:
applePurchaseService.handleOneTimeChargeNotification(notification);
break;
// 其他通知类型(记录但不处理) // 其他通知类型(记录但不处理)
case EXTERNAL_PURCHASE_TOKEN: case EXTERNAL_PURCHASE_TOKEN:
case ONE_TIME_CHARGE:
case REVOKE: case REVOKE:
case TEST: case TEST:
log.info("Received notification type {} - no action required", type); log.info("Received notification type {} - no action required", type);

View File

@@ -1,8 +1,7 @@
package com.yolo.keyborad.service.impl; package com.yolo.keyborad.service.impl;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.beans.factory.annotation.Autowired; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import java.util.List;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.yolo.keyborad.mapper.KeyboardUserPurchaseRecordsMapper; import com.yolo.keyborad.mapper.KeyboardUserPurchaseRecordsMapper;
import com.yolo.keyborad.model.entity.KeyboardUserPurchaseRecords; import com.yolo.keyborad.model.entity.KeyboardUserPurchaseRecords;
@@ -15,4 +14,15 @@ import com.yolo.keyborad.service.KeyboardUserPurchaseRecordsService;
@Service @Service
public class KeyboardUserPurchaseRecordsServiceImpl extends ServiceImpl<KeyboardUserPurchaseRecordsMapper, KeyboardUserPurchaseRecords> implements KeyboardUserPurchaseRecordsService{ public class KeyboardUserPurchaseRecordsServiceImpl extends ServiceImpl<KeyboardUserPurchaseRecordsMapper, KeyboardUserPurchaseRecords> implements KeyboardUserPurchaseRecordsService{
@Override
public boolean checkPurchaseExists(String transactionId, String originalTransactionId) {
LambdaQueryWrapper<KeyboardUserPurchaseRecords> queryWrapper = new LambdaQueryWrapper<>();
if (transactionId != null && !transactionId.isBlank()) {
queryWrapper.eq(KeyboardUserPurchaseRecords::getTransactionId, transactionId);
}
if (originalTransactionId != null && !originalTransactionId.isBlank()) {
queryWrapper.eq(KeyboardUserPurchaseRecords::getOriginalTransactionId, originalTransactionId);
}
return exists(queryWrapper);
}
} }

View File

@@ -2,6 +2,7 @@ package com.yolo.keyborad.service.impl;
import cn.dev33.satoken.stp.StpUtil; import cn.dev33.satoken.stp.StpUtil;
import cn.hutool.core.bean.BeanUtil; import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.lang.UUID;
import cn.hutool.core.util.IdUtil; import cn.hutool.core.util.IdUtil;
import cn.hutool.core.util.RandomUtil; import cn.hutool.core.util.RandomUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
@@ -198,11 +199,20 @@ public class UserServiceImpl extends ServiceImpl<KeyboardUserMapper, KeyboardUse
return true; return true;
} }
@Override
public Long selectUserByUUid(String uuid) {
KeyboardUser keyboardUserDB = keyboardUserMapper.selectOne(
new LambdaQueryWrapper<KeyboardUser>()
.eq(KeyboardUser::getUuid, uuid));
return keyboardUserDB.getId();
}
private KeyboardUser buildNewUserWithSubjectId(String sub) { private KeyboardUser buildNewUserWithSubjectId(String sub) {
KeyboardUser keyboardUser = new KeyboardUser(); KeyboardUser keyboardUser = new KeyboardUser();
keyboardUser.setSubjectId(sub); keyboardUser.setSubjectId(sub);
keyboardUser.setUid(IdUtil.getSnowflake().nextId()); keyboardUser.setUid(IdUtil.getSnowflake().nextId());
keyboardUser.setNickName("User_" + RandomUtil.randomString(6)); keyboardUser.setNickName("User_" + RandomUtil.randomString(6));
keyboardUser.setUuid(IdUtil.randomUUID());
return keyboardUser; return keyboardUser;
} }

View File

@@ -118,6 +118,7 @@ public class UserRegistrationHandler {
keyboardUser.setEmail(userRegisterDTO.getMailAddress()); keyboardUser.setEmail(userRegisterDTO.getMailAddress());
keyboardUser.setGender(userRegisterDTO.getGender()); keyboardUser.setGender(userRegisterDTO.getGender());
keyboardUser.setEmailVerified(true); keyboardUser.setEmailVerified(true);
keyboardUser.setUuid(IdUtil.randomUUID());
return keyboardUser; return keyboardUser;
} }