From e657a22b10e1dff438083a14a9d123feed8d57e9 Mon Sep 17 00:00:00 2001 From: ziin Date: Fri, 10 Apr 2026 17:39:41 +0800 Subject: [PATCH] =?UTF-8?q?refactor(googleplay):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E5=B9=B6=E5=8F=91=E5=8F=91=E8=B4=A7=E9=98=B2=E6=8A=A4=E5=B9=B6?= =?UTF-8?q?=E4=BC=98=E5=8C=96=E8=AE=A2=E5=8D=95=E7=8A=B6=E6=80=81=E6=B5=81?= =?UTF-8?q?=E8=BD=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../googleplay/GooglePlayConstants.java | 1 + .../GooglePlayEntitlementApplier.java | 41 ++++++---- .../GooglePlayOrderDeliveryGuard.java | 67 ++++++++++++++++ .../googleplay/GooglePlayStateService.java | 79 ++++++++++++++++--- .../mapper/GooglePlayOrderMapper.java | 17 ++++ .../entity/googleplay/GooglePlayOrder.java | 6 ++ 6 files changed, 185 insertions(+), 26 deletions(-) create mode 100644 src/main/java/com/yolo/keyborad/googleplay/GooglePlayOrderDeliveryGuard.java diff --git a/src/main/java/com/yolo/keyborad/googleplay/GooglePlayConstants.java b/src/main/java/com/yolo/keyborad/googleplay/GooglePlayConstants.java index ae86684..9ae699c 100644 --- a/src/main/java/com/yolo/keyborad/googleplay/GooglePlayConstants.java +++ b/src/main/java/com/yolo/keyborad/googleplay/GooglePlayConstants.java @@ -25,6 +25,7 @@ public final class GooglePlayConstants { public static final String CONSUMPTION_NOT_APPLICABLE = "NOT_APPLICABLE"; public static final String DELIVERY_PENDING = "PENDING"; + public static final String DELIVERY_PROCESSING = "PROCESSING"; public static final String DELIVERY_DELIVERED = "DELIVERED"; public static final String DELIVERY_REVOKED = "REVOKED"; public static final String DELIVERY_NOT_REQUIRED = "NOT_REQUIRED"; diff --git a/src/main/java/com/yolo/keyborad/googleplay/GooglePlayEntitlementApplier.java b/src/main/java/com/yolo/keyborad/googleplay/GooglePlayEntitlementApplier.java index b7662aa..de1a76b 100644 --- a/src/main/java/com/yolo/keyborad/googleplay/GooglePlayEntitlementApplier.java +++ b/src/main/java/com/yolo/keyborad/googleplay/GooglePlayEntitlementApplier.java @@ -22,6 +22,7 @@ public class GooglePlayEntitlementApplier { private final GooglePlayUserEntitlementMapper entitlementMapper; private final GooglePlayVipBenefitService vipBenefitService; private final GooglePlayWalletBenefitService walletBenefitService; + private final GooglePlayOrderDeliveryGuard orderDeliveryGuard; public GooglePlayUserEntitlement apply(Long userId, KeyboardProductItems product, @@ -29,6 +30,7 @@ public class GooglePlayEntitlementApplier { GooglePlayOrder order) { String benefitType = resolveBenefitType(product, snapshot); String entitlementKey = resolveEntitlementKey(benefitType, product.getProductId()); + boolean grantOwned = orderDeliveryGuard.prepareGrant(benefitType, snapshot, order); GooglePlayUserEntitlement entitlement = loadEntitlement(snapshot.getPurchaseToken(), entitlementKey); if (entitlement == null) { entitlement = new GooglePlayUserEntitlement(); @@ -37,9 +39,11 @@ public class GooglePlayEntitlementApplier { fillCommonFields(entitlement, userId, product, snapshot, order, benefitType, entitlementKey); switch (benefitType) { case GooglePlayConstants.ENTITLEMENT_VIP_SUBSCRIPTION -> applySubscriptionVip(userId, product, snapshot, order, entitlement); - case GooglePlayConstants.ENTITLEMENT_VIP_ONE_TIME -> applyOneTimeVip(userId, product, snapshot, order, entitlement); - case GooglePlayConstants.ENTITLEMENT_WALLET_TOP_UP -> applyWalletTopUp(userId, product, snapshot, order, entitlement); - default -> applyNonConsumable(snapshot, order, entitlement); + case GooglePlayConstants.ENTITLEMENT_VIP_ONE_TIME -> + applyOneTimeVip(userId, product, snapshot, order, entitlement, grantOwned); + case GooglePlayConstants.ENTITLEMENT_WALLET_TOP_UP -> + applyWalletTopUp(userId, product, snapshot, order, entitlement, grantOwned); + default -> applyNonConsumable(snapshot, order, entitlement, grantOwned); } saveEntitlement(entitlement); return entitlement; @@ -94,9 +98,10 @@ public class GooglePlayEntitlementApplier { KeyboardProductItems product, GooglePlayPurchaseSnapshot snapshot, GooglePlayOrder order, - GooglePlayUserEntitlement entitlement) { + GooglePlayUserEntitlement entitlement, + boolean grantOwned) { if (GooglePlayConstants.STATE_ACTIVE.equals(snapshot.getState())) { - grantOneTimeVip(userId, product, order, entitlement); + grantOneTimeVip(userId, product, order, entitlement, grantOwned); return; } revokeVipEntitlement(userId, order, entitlement); @@ -106,13 +111,14 @@ public class GooglePlayEntitlementApplier { KeyboardProductItems product, GooglePlayPurchaseSnapshot snapshot, GooglePlayOrder order, - GooglePlayUserEntitlement entitlement) { + GooglePlayUserEntitlement entitlement, + boolean grantOwned) { BigDecimal amount = resolveWalletAmount(product); if (amount.compareTo(BigDecimal.ZERO) <= 0) { throw new BusinessException(ErrorCode.PRODUCT_QUOTA_NOT_SET); } if (GooglePlayConstants.STATE_ACTIVE.equals(snapshot.getState())) { - grantWalletTopUp(userId, product, order, entitlement, amount); + grantWalletTopUp(userId, product, order, entitlement, amount, grantOwned); return; } revokeWalletTopUp(userId, order, entitlement, amount); @@ -120,9 +126,14 @@ public class GooglePlayEntitlementApplier { private void applyNonConsumable(GooglePlayPurchaseSnapshot snapshot, GooglePlayOrder order, - GooglePlayUserEntitlement entitlement) { + GooglePlayUserEntitlement entitlement, + boolean grantOwned) { boolean active = GooglePlayConstants.STATE_ACTIVE.equals(snapshot.getState()) || GooglePlayConstants.STATE_CANCELED.equals(snapshot.getState()); + if (!grantOwned && active) { + entitlement.setActive(GooglePlayConstants.DELIVERY_DELIVERED.equals(order.getDeliveryStatus())); + return; + } entitlement.setActive(active); entitlement.setStartTime(snapshot.getStartTime()); entitlement.setEndTime(snapshot.getExpiryTime()); @@ -139,9 +150,10 @@ public class GooglePlayEntitlementApplier { private void grantOneTimeVip(Long userId, KeyboardProductItems product, GooglePlayOrder order, - GooglePlayUserEntitlement entitlement) { - if (GooglePlayConstants.DELIVERY_DELIVERED.equals(order.getDeliveryStatus())) { - entitlement.setActive(true); + GooglePlayUserEntitlement entitlement, + boolean grantOwned) { + if (!grantOwned || GooglePlayConstants.DELIVERY_DELIVERED.equals(order.getDeliveryStatus())) { + entitlement.setActive(GooglePlayConstants.DELIVERY_DELIVERED.equals(order.getDeliveryStatus())); return; } Date expiry = resolveOneTimeVipExpiry(product); @@ -172,9 +184,10 @@ public class GooglePlayEntitlementApplier { KeyboardProductItems product, GooglePlayOrder order, GooglePlayUserEntitlement entitlement, - BigDecimal amount) { - if (GooglePlayConstants.DELIVERY_DELIVERED.equals(order.getDeliveryStatus())) { - entitlement.setActive(true); + BigDecimal amount, + boolean grantOwned) { + if (!grantOwned || GooglePlayConstants.DELIVERY_DELIVERED.equals(order.getDeliveryStatus())) { + entitlement.setActive(GooglePlayConstants.DELIVERY_DELIVERED.equals(order.getDeliveryStatus())); return; } walletBenefitService.grant(userId, order.getId(), product.getName(), amount); diff --git a/src/main/java/com/yolo/keyborad/googleplay/GooglePlayOrderDeliveryGuard.java b/src/main/java/com/yolo/keyborad/googleplay/GooglePlayOrderDeliveryGuard.java new file mode 100644 index 0000000..baaa66a --- /dev/null +++ b/src/main/java/com/yolo/keyborad/googleplay/GooglePlayOrderDeliveryGuard.java @@ -0,0 +1,67 @@ +package com.yolo.keyborad.googleplay; + +import com.yolo.keyborad.googleplay.model.GooglePlayPurchaseSnapshot; +import com.yolo.keyborad.mapper.GooglePlayOrderMapper; +import com.yolo.keyborad.model.entity.googleplay.GooglePlayOrder; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.Date; + +@Component +@RequiredArgsConstructor +public class GooglePlayOrderDeliveryGuard { + + private final GooglePlayOrderMapper orderMapper; + + /** + * 一次性商品在真正发权益前先抢占处理资格,避免并发请求重复发货。 + */ + public boolean prepareGrant(String benefitType, GooglePlayPurchaseSnapshot snapshot, GooglePlayOrder order) { + if (!requiresGrantGuard(benefitType, snapshot) || order.getId() == null) { + order.setDeliveryOwnershipGranted(true); + return true; + } + if (GooglePlayConstants.DELIVERY_DELIVERED.equals(order.getDeliveryStatus())) { + order.setDeliveryOwnershipGranted(false); + return false; + } + Date now = new Date(); + int updated = orderMapper.updateDeliveryStatusIfMatch( + order.getId(), + GooglePlayConstants.DELIVERY_PENDING, + GooglePlayConstants.DELIVERY_PROCESSING, + now + ); + if (updated == 1) { + order.setDeliveryStatus(GooglePlayConstants.DELIVERY_PROCESSING); + order.setDeliveryOwnershipGranted(true); + order.setUpdatedAt(now); + return true; + } + refreshDeliveryState(order); + order.setDeliveryOwnershipGranted(false); + return false; + } + + private boolean requiresGrantGuard(String benefitType, GooglePlayPurchaseSnapshot snapshot) { + if (!GooglePlayConstants.PRODUCT_TYPE_ONE_TIME.equals(snapshot.getProductType())) { + return false; + } + if (GooglePlayConstants.ENTITLEMENT_NON_CONSUMABLE.equals(benefitType)) { + return GooglePlayConstants.STATE_ACTIVE.equals(snapshot.getState()) + || GooglePlayConstants.STATE_CANCELED.equals(snapshot.getState()); + } + return GooglePlayConstants.STATE_ACTIVE.equals(snapshot.getState()); + } + + private void refreshDeliveryState(GooglePlayOrder order) { + GooglePlayOrder latestOrder = orderMapper.selectById(order.getId()); + if (latestOrder == null) { + return; + } + order.setDeliveryStatus(latestOrder.getDeliveryStatus()); + order.setGrantedQuantity(latestOrder.getGrantedQuantity()); + order.setUpdatedAt(latestOrder.getUpdatedAt()); + } +} diff --git a/src/main/java/com/yolo/keyborad/googleplay/GooglePlayStateService.java b/src/main/java/com/yolo/keyborad/googleplay/GooglePlayStateService.java index d62d76f..fc3dc44 100644 --- a/src/main/java/com/yolo/keyborad/googleplay/GooglePlayStateService.java +++ b/src/main/java/com/yolo/keyborad/googleplay/GooglePlayStateService.java @@ -15,6 +15,7 @@ import com.yolo.keyborad.model.entity.googleplay.GooglePlayUserEntitlement; import com.yolo.keyborad.service.KeyboardProductItemsService; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; +import org.springframework.dao.DuplicateKeyException; import org.springframework.transaction.annotation.Transactional; import java.math.BigDecimal; @@ -38,8 +39,7 @@ public class GooglePlayStateService { KeyboardProductItems product = loadProduct(productId); GooglePlayOrder order = buildOrder(command, snapshot); GooglePlayPurchaseToken token = buildToken(command, snapshot); - // 先保存订单以确保 order.id 已生成,钱包充值等权益分发依赖 order.id 写入交易流水 - saveOrder(order); + persistOrderIfNew(order); GooglePlayUserEntitlement entitlement = null; if (command.getUserId() != null) { entitlement = entitlementApplier.apply(command.getUserId(), product, snapshot, order); @@ -54,8 +54,8 @@ public class GooglePlayStateService { .order(order) .token(token) .entitlement(entitlement) - .acknowledgeRequired(requiresAcknowledge(snapshot, command.getUserId())) - .consumeRequired(requiresConsume(snapshot, entitlement, command.getUserId())) + .acknowledgeRequired(requiresAcknowledge(snapshot, command.getUserId(), order)) + .consumeRequired(requiresConsume(snapshot, entitlement, command.getUserId(), order)) .linkedPurchaseTokenToSync(resolveLinkedToken(snapshot)) .build(); } @@ -147,15 +147,22 @@ public class GooglePlayStateService { return token; } - private boolean requiresAcknowledge(GooglePlayPurchaseSnapshot snapshot, Long userId) { - return userId != null - && GooglePlayConstants.ACK_PENDING.equals(snapshot.getAcknowledgementState()) - && GooglePlayConstants.STATE_ACTIVE.equals(snapshot.getState()); + private boolean requiresAcknowledge(GooglePlayPurchaseSnapshot snapshot, Long userId, GooglePlayOrder order) { + if (userId == null + || !GooglePlayConstants.ACK_PENDING.equals(snapshot.getAcknowledgementState()) + || !GooglePlayConstants.STATE_ACTIVE.equals(snapshot.getState())) { + return false; + } + if (!GooglePlayConstants.PRODUCT_TYPE_ONE_TIME.equals(snapshot.getProductType())) { + return true; + } + return Boolean.TRUE.equals(order.getDeliveryOwnershipGranted()); } private boolean requiresConsume(GooglePlayPurchaseSnapshot snapshot, GooglePlayUserEntitlement entitlement, - Long userId) { + Long userId, + GooglePlayOrder order) { if (userId == null || entitlement == null) { return false; } @@ -163,7 +170,8 @@ public class GooglePlayStateService { boolean wallet = GooglePlayConstants.ENTITLEMENT_WALLET_TOP_UP.equals(entitlement.getBenefitType()); boolean active = GooglePlayConstants.STATE_ACTIVE.equals(snapshot.getState()); boolean pending = GooglePlayConstants.CONSUMPTION_PENDING.equals(snapshot.getConsumptionState()); - return oneTime && wallet && active && pending; + boolean owned = Boolean.TRUE.equals(order.getDeliveryOwnershipGranted()); + return oneTime && wallet && active && pending && owned; } private String resolveLinkedToken(GooglePlayPurchaseSnapshot snapshot) { @@ -175,20 +183,67 @@ public class GooglePlayStateService { private void saveOrder(GooglePlayOrder order) { if (order.getId() == null) { - orderMapper.insert(order); + insertOrder(order); return; } orderMapper.updateById(order); } + /** + * 仅在新订单场景预落库,避免并发请求用旧快照把发货状态回写成 PENDING。 + */ + private void persistOrderIfNew(GooglePlayOrder order) { + if (order.getId() == null) { + saveOrder(order); + } + } + private void saveToken(GooglePlayPurchaseToken token) { if (token.getId() == null) { - purchaseTokenMapper.insert(token); + insertToken(token); return; } purchaseTokenMapper.updateById(token); } + /** + * 并发首写同一订单时,唯一键冲突后回读已存在记录继续流程。 + */ + private void insertOrder(GooglePlayOrder order) { + try { + orderMapper.insert(order); + } catch (DuplicateKeyException e) { + GooglePlayOrder existingOrder = orderMapper.selectOne(Wrappers.lambdaQuery() + .eq(GooglePlayOrder::getOrderKey, order.getOrderKey()) + .last("LIMIT 1")); + if (existingOrder == null) { + throw e; + } + order.setId(existingOrder.getId()); + order.setCreatedAt(existingOrder.getCreatedAt()); + order.setDeliveryStatus(existingOrder.getDeliveryStatus()); + order.setGrantedQuantity(existingOrder.getGrantedQuantity()); + orderMapper.updateById(order); + } + } + + /** + * purchaseToken 首次并发写入时回读已有记录,避免重复插入直接失败。 + */ + private void insertToken(GooglePlayPurchaseToken token) { + try { + purchaseTokenMapper.insert(token); + } catch (DuplicateKeyException e) { + GooglePlayPurchaseToken existingToken = findToken(token.getPurchaseToken()); + if (existingToken == null) { + throw e; + } + token.setId(existingToken.getId()); + token.setCreatedAt(existingToken.getCreatedAt()); + purchaseTokenMapper.updateById(token); + } + } + private void updateAckState(String purchaseToken, String orderKey, String state) { GooglePlayPurchaseToken token = findToken(purchaseToken); if (token != null) { diff --git a/src/main/java/com/yolo/keyborad/mapper/GooglePlayOrderMapper.java b/src/main/java/com/yolo/keyborad/mapper/GooglePlayOrderMapper.java index 7f11fc1..4540c14 100644 --- a/src/main/java/com/yolo/keyborad/mapper/GooglePlayOrderMapper.java +++ b/src/main/java/com/yolo/keyborad/mapper/GooglePlayOrderMapper.java @@ -2,6 +2,23 @@ package com.yolo.keyborad.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.yolo.keyborad.model.entity.googleplay.GooglePlayOrder; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Update; public interface GooglePlayOrderMapper extends BaseMapper { + + /** + * 原子抢占发货资格,只有当前状态匹配时才允许进入处理中。 + */ + @Update(""" + UPDATE google_play_order + SET delivery_status = #{targetStatus}, + updated_at = #{updatedAt} + WHERE id = #{orderId} + AND delivery_status = #{expectedStatus} + """) + int updateDeliveryStatusIfMatch(@Param("orderId") Long orderId, + @Param("expectedStatus") String expectedStatus, + @Param("targetStatus") String targetStatus, + @Param("updatedAt") java.util.Date updatedAt); } diff --git a/src/main/java/com/yolo/keyborad/model/entity/googleplay/GooglePlayOrder.java b/src/main/java/com/yolo/keyborad/model/entity/googleplay/GooglePlayOrder.java index e39e40f..f5e83f3 100644 --- a/src/main/java/com/yolo/keyborad/model/entity/googleplay/GooglePlayOrder.java +++ b/src/main/java/com/yolo/keyborad/model/entity/googleplay/GooglePlayOrder.java @@ -81,4 +81,10 @@ public class GooglePlayOrder { @TableField("updated_at") private Date updatedAt; + + /** + * 当前线程是否拿到了本次发货资格,仅用于本次请求内控制幂等,不落库。 + */ + @TableField(exist = false) + private Boolean deliveryOwnershipGranted; }