diff --git a/.gitignore b/.gitignore index 47f4c10..7fbcb53 100644 --- a/.gitignore +++ b/.gitignore @@ -51,3 +51,5 @@ build/ /src/main/resources/static/ws-test.html /.omc/ /logs/ +/src/main/resources/sql/google-play-iap.sql +/docs/google-play-iap-integration.md diff --git a/src/main/java/com/yolo/keyborad/MyApplication.java b/src/main/java/com/yolo/keyborad/MyApplication.java index 70c49ac..1a77a5e 100644 --- a/src/main/java/com/yolo/keyborad/MyApplication.java +++ b/src/main/java/com/yolo/keyborad/MyApplication.java @@ -2,6 +2,7 @@ package com.yolo.keyborad; import com.yolo.keyborad.common.xfile.ByteFileWrapperAdapter; import com.yolo.keyborad.config.AppleAppStoreProperties; +import com.yolo.keyborad.config.GooglePlayProperties; import lombok.extern.slf4j.Slf4j; import org.dromara.x.file.storage.core.tika.ContentTypeDetect; import org.dromara.x.file.storage.spring.EnableFileStorage; @@ -14,7 +15,7 @@ import org.springframework.context.annotation.Bean; @Slf4j @SpringBootApplication -@EnableConfigurationProperties(AppleAppStoreProperties.class) +@EnableConfigurationProperties({AppleAppStoreProperties.class, GooglePlayProperties.class}) @EnableFileStorage public class MyApplication { public static void main(String[] args) { diff --git a/src/main/java/com/yolo/keyborad/common/ErrorCode.java b/src/main/java/com/yolo/keyborad/common/ErrorCode.java index e91836f..d7edd52 100644 --- a/src/main/java/com/yolo/keyborad/common/ErrorCode.java +++ b/src/main/java/com/yolo/keyborad/common/ErrorCode.java @@ -76,6 +76,12 @@ public enum ErrorCode { AUDIO_FILE_TOO_LARGE(40017, "音频文件过大"), AUDIO_FORMAT_NOT_SUPPORTED(40018, "音频格式不支持"), STT_SERVICE_ERROR(50031, "语音转文字服务异常"), + GOOGLE_PLAY_NOT_ENABLED(50032, "Google Play 服务未启用"), + GOOGLE_PLAY_PACKAGE_NAME_MISSING(50033, "Google Play packageName 未配置"), + GOOGLE_PLAY_PURCHASE_MISMATCH(50034, "Google Play 购买信息不匹配"), + GOOGLE_PLAY_WEBHOOK_UNAUTHORIZED(50035, "Google Play RTDN 来源校验失败"), + GOOGLE_PLAY_RTDN_PAYLOAD_INVALID(50036, "Google Play RTDN payload 非法"), + GOOGLE_PLAY_STATE_INVALID(50037, "Google Play 购买状态非法"), REPORT_TYPE_INVALID(40020, "举报类型无效"), REPORT_COMPANION_ID_EMPTY(40021, "被举报的AI角色ID不能为空"), REPORT_TYPE_EMPTY(40022, "举报类型不能为空"), @@ -104,4 +110,4 @@ public enum ErrorCode { public String getCodeAsString() { return String.valueOf(code); } -} \ No newline at end of file +} diff --git a/src/main/java/com/yolo/keyborad/config/GooglePlayHttpConfig.java b/src/main/java/com/yolo/keyborad/config/GooglePlayHttpConfig.java new file mode 100644 index 0000000..b066cb0 --- /dev/null +++ b/src/main/java/com/yolo/keyborad/config/GooglePlayHttpConfig.java @@ -0,0 +1,18 @@ +package com.yolo.keyborad.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.net.http.HttpClient; +import java.time.Duration; + +@Configuration +public class GooglePlayHttpConfig { + + @Bean + public HttpClient googlePlayHttpClient() { + return HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(10)) + .build(); + } +} diff --git a/src/main/java/com/yolo/keyborad/config/GooglePlayProperties.java b/src/main/java/com/yolo/keyborad/config/GooglePlayProperties.java new file mode 100644 index 0000000..15c2d0a --- /dev/null +++ b/src/main/java/com/yolo/keyborad/config/GooglePlayProperties.java @@ -0,0 +1,35 @@ +package com.yolo.keyborad.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@Data +@ConfigurationProperties(prefix = "google.play") +public class GooglePlayProperties { + + private boolean enabled; + + private String packageName; + + private String serviceAccountKeyPath; + + private String oauthTokenUri = "https://oauth2.googleapis.com/token"; + + private String androidPublisherScope = "https://www.googleapis.com/auth/androidpublisher"; + + private String pubsubTokenInfoUri = "https://oauth2.googleapis.com/tokeninfo"; + + private boolean validatePubsubJwt = true; + + private boolean requireObfuscatedAccountId = false; + + private Pubsub pubsub = new Pubsub(); + + @Data + public static class Pubsub { + private String expectedTopic; + private String expectedSubscription; + private String audience; + private String serviceAccountEmail; + } +} diff --git a/src/main/java/com/yolo/keyborad/config/SaTokenConfigure.java b/src/main/java/com/yolo/keyborad/config/SaTokenConfigure.java index b41c546..87d6482 100644 --- a/src/main/java/com/yolo/keyborad/config/SaTokenConfigure.java +++ b/src/main/java/com/yolo/keyborad/config/SaTokenConfigure.java @@ -86,6 +86,8 @@ public class SaTokenConfigure implements WebMvcConfigurer { "/character/listByTagWithNotLogin", "/ai-companion/report", "/apple/notification", + "/google-play/rtdn", + "/appVersions/checkUpdate" "/appVersions/checkUpdate", "/character/detailWithNotLogin" }; diff --git a/src/main/java/com/yolo/keyborad/controller/GooglePlayController.java b/src/main/java/com/yolo/keyborad/controller/GooglePlayController.java new file mode 100644 index 0000000..756efbf --- /dev/null +++ b/src/main/java/com/yolo/keyborad/controller/GooglePlayController.java @@ -0,0 +1,36 @@ +package com.yolo.keyborad.controller; + +import cn.dev33.satoken.stp.StpUtil; +import com.yolo.keyborad.common.BaseResponse; +import com.yolo.keyborad.common.ResultUtils; +import com.yolo.keyborad.model.dto.googleplay.GooglePlayPubSubPushRequest; +import com.yolo.keyborad.model.dto.googleplay.GooglePlayPurchaseVerifyReq; +import com.yolo.keyborad.model.vo.googleplay.GooglePlayPurchaseVerifyResp; +import com.yolo.keyborad.service.GooglePlayBillingService; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +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; + +@RestController +@RequestMapping("/google-play") +@RequiredArgsConstructor +public class GooglePlayController { + + private final GooglePlayBillingService googlePlayBillingService; + + @PostMapping("/purchases/verify") + public BaseResponse verify(@RequestBody GooglePlayPurchaseVerifyReq req) { + Long userId = StpUtil.getLoginIdAsLong(); + return ResultUtils.success(googlePlayBillingService.verifyPurchase(userId, req)); + } + + @PostMapping("/rtdn") + public BaseResponse handleRtdn(HttpServletRequest request, + @RequestBody GooglePlayPubSubPushRequest pushRequest) { + googlePlayBillingService.handleRtdn(request, pushRequest); + return ResultUtils.success(Boolean.TRUE); + } +} diff --git a/src/main/java/com/yolo/keyborad/googleplay/GooglePlayApiClient.java b/src/main/java/com/yolo/keyborad/googleplay/GooglePlayApiClient.java new file mode 100644 index 0000000..bc98e26 --- /dev/null +++ b/src/main/java/com/yolo/keyborad/googleplay/GooglePlayApiClient.java @@ -0,0 +1,292 @@ +package com.yolo.keyborad.googleplay; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yolo.keyborad.config.GooglePlayProperties; +import com.yolo.keyborad.googleplay.model.GooglePlayPurchaseSnapshot; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.Date; + +@Slf4j +@Component +@RequiredArgsConstructor +public class GooglePlayApiClient { + + private static final String API_BASE = "https://androidpublisher.googleapis.com/androidpublisher/v3/applications"; + private static final int MAX_RETRY = 3; + private static final long RETRY_INTERVAL_MILLIS = 300L; + + private final GooglePlayServiceAccountTokenProvider tokenProvider; + private final HttpClient googlePlayHttpClient; + private final ObjectMapper objectMapper; + private final GooglePlayProperties properties; + + public GooglePlayPurchaseSnapshot getSubscription(String packageName, String purchaseToken) { + String url = API_BASE + "/" + encode(packageName) + "/purchases/subscriptionsv2/tokens/" + encode(purchaseToken); + JsonNode jsonNode = executeJson("GET_SUBSCRIPTION", url, null, true); + return mapSubscriptionSnapshot(packageName, purchaseToken, jsonNode); + } + + public GooglePlayPurchaseSnapshot getOneTimeProduct(String packageName, String purchaseToken) { + String url = API_BASE + "/" + encode(packageName) + "/purchases/productsv2/tokens/" + encode(purchaseToken); + JsonNode jsonNode = executeJson("GET_ONE_TIME", url, null, true); + return mapOneTimeSnapshot(packageName, purchaseToken, jsonNode); + } + + public void acknowledgeSubscription(String packageName, String productId, String purchaseToken) { + String url = API_BASE + "/" + encode(packageName) + "/purchases/subscriptions/" + + encode(productId) + "/tokens/" + encode(purchaseToken) + ":acknowledge"; + executeJson("ACK_SUBSCRIPTION", url, "{}", true); + } + + public void acknowledgeProduct(String packageName, String productId, String purchaseToken) { + String url = API_BASE + "/" + encode(packageName) + "/purchases/products/" + + encode(productId) + "/tokens/" + encode(purchaseToken) + ":acknowledge"; + executeJson("ACK_PRODUCT", url, "{}", true); + } + + public void consumeProduct(String packageName, String productId, String purchaseToken) { + String url = API_BASE + "/" + encode(packageName) + "/purchases/products/" + + encode(productId) + "/tokens/" + encode(purchaseToken) + ":consume"; + executeJson("CONSUME_PRODUCT", url, "{}", true); + } + + public JsonNode verifyPubSubJwt(String bearerToken) { + String url = properties.getPubsubTokenInfoUri() + "?id_token=" + encode(bearerToken); + return executeJson("VERIFY_PUBSUB_JWT", url, null, false); + } + + private JsonNode executeJson(String operation, String url, String body, boolean withAuthorization) { + for (int attempt = 1; attempt <= MAX_RETRY; attempt++) { + try { + HttpRequest request = buildRequest(url, body, withAuthorization); + HttpResponse response = googlePlayHttpClient.send(request, HttpResponse.BodyHandlers.ofString()); + if (response.statusCode() / 100 != 2) { + throw new GooglePlayApiException(response.statusCode(), response.body()); + } + String responseBody = response.body(); + return responseBody == null || responseBody.isBlank() + ? objectMapper.createObjectNode() + : objectMapper.readTree(responseBody); + } catch (GooglePlayApiException e) { + if (attempt == MAX_RETRY || e.getStatusCode() == 404) { + throw e; + } + sleep(attempt, operation, e); + } catch (Exception e) { + if (attempt == MAX_RETRY) { + throw new GooglePlayApiException("Google Play 请求失败: " + operation, e); + } + sleep(attempt, operation, e); + } + } + throw new IllegalStateException("unreachable"); + } + + private HttpRequest buildRequest(String url, String body, boolean withAuthorization) { + HttpRequest.Builder builder = HttpRequest.newBuilder(URI.create(url)) + .header("Accept", "application/json"); + if (withAuthorization) { + builder.header("Authorization", "Bearer " + tokenProvider.getAccessToken()); + } + if (body == null) { + return builder.GET().build(); + } + return builder.header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(body)) + .build(); + } + + private GooglePlayPurchaseSnapshot mapSubscriptionSnapshot(String packageName, String purchaseToken, JsonNode root) { + JsonNode lineItem = firstLineItem(root); + String state = mapSubscriptionState(text(root, "subscriptionState")); + String googleOrderId = firstNonBlank(text(lineItem, "latestSuccessfulOrderId"), text(root, "latestOrderId")); + return GooglePlayPurchaseSnapshot.builder() + .packageName(packageName) + .productId(text(lineItem, "productId")) + .productType(GooglePlayConstants.PRODUCT_TYPE_SUBSCRIPTION) + .purchaseToken(purchaseToken) + .orderKey(resolveOrderKey(googleOrderId, purchaseToken)) + .googleOrderId(googleOrderId) + .linkedPurchaseToken(text(root, "linkedPurchaseToken")) + .state(state) + .acknowledgementState(mapAcknowledgementState(text(root, "acknowledgementState"))) + .consumptionState(GooglePlayConstants.CONSUMPTION_NOT_APPLICABLE) + .quantity(1) + .refundableQuantity(null) + .autoRenewEnabled(bool(lineItem.path("autoRenewingPlan"), "autoRenewEnabled")) + .accessGranted(isSubscriptionAccessGranted(state)) + .externalAccountId(text(root.path("externalAccountIdentifiers"), "obfuscatedExternalAccountId")) + .externalProfileId(text(root.path("externalAccountIdentifiers"), "obfuscatedExternalProfileId")) + .regionCode(text(root, "regionCode")) + .canceledStateReason(resolveCanceledReason(root.path("canceledStateContext"))) + .startTime(parseTime(firstNonBlank(text(root, "startTime"), text(lineItem, "startTime")))) + .expiryTime(parseTime(text(lineItem, "expiryTime"))) + .autoResumeTime(parseTime(text(root.path("pausedStateContext"), "autoResumeTime"))) + .lastSyncedAt(new Date()) + .rawResponse(writeJson(root)) + .build(); + } + + private GooglePlayPurchaseSnapshot mapOneTimeSnapshot(String packageName, String purchaseToken, JsonNode root) { + JsonNode lineItem = root.path("productLineItem"); + String state = mapOneTimeState(text(root.path("purchaseStateContext"), "purchaseState")); + String googleOrderId = text(root, "latestOrderId"); + return GooglePlayPurchaseSnapshot.builder() + .packageName(packageName) + .productId(text(lineItem, "productId")) + .productType(GooglePlayConstants.PRODUCT_TYPE_ONE_TIME) + .purchaseToken(purchaseToken) + .orderKey(resolveOrderKey(googleOrderId, purchaseToken)) + .googleOrderId(googleOrderId) + .linkedPurchaseToken(null) + .state(state) + .acknowledgementState(mapAcknowledgementState(text(root, "acknowledgementState"))) + .consumptionState(mapConsumptionState(text(root, "consumptionState"))) + .quantity(number(lineItem, "quantity")) + .refundableQuantity(number(root, "refundableQuantity")) + .autoRenewEnabled(false) + .accessGranted(GooglePlayConstants.STATE_ACTIVE.equals(state)) + .externalAccountId(text(root.path("externalAccountIdentifiers"), "obfuscatedExternalAccountId")) + .externalProfileId(text(root.path("externalAccountIdentifiers"), "obfuscatedExternalProfileId")) + .regionCode(text(root, "regionCode")) + .canceledStateReason(null) + .startTime(parseTime(text(root, "purchaseCompletionTime"))) + .expiryTime(null) + .autoResumeTime(null) + .lastSyncedAt(new Date()) + .rawResponse(writeJson(root)) + .build(); + } + + private JsonNode firstLineItem(JsonNode root) { + JsonNode lineItems = root.path("lineItems"); + return lineItems.isArray() && !lineItems.isEmpty() ? lineItems.get(0) : objectMapper.createObjectNode(); + } + + private String resolveOrderKey(String googleOrderId, String purchaseToken) { + return firstNonBlank(googleOrderId, "TOKEN:" + purchaseToken); + } + + private String resolveCanceledReason(JsonNode node) { + if (node == null || node.isMissingNode()) { + return null; + } + if (node.hasNonNull("userInitiatedCancellation")) { + return "USER"; + } + if (node.hasNonNull("systemInitiatedCancellation")) { + return "SYSTEM"; + } + if (node.hasNonNull("developerInitiatedCancellation")) { + return "DEVELOPER"; + } + if (node.hasNonNull("replacementCancellation")) { + return "REPLACEMENT"; + } + return null; + } + + private String mapSubscriptionState(String state) { + return switch (state) { + case "SUBSCRIPTION_STATE_ACTIVE" -> GooglePlayConstants.STATE_ACTIVE; + case "SUBSCRIPTION_STATE_CANCELED" -> GooglePlayConstants.STATE_CANCELED; + case "SUBSCRIPTION_STATE_IN_GRACE_PERIOD" -> GooglePlayConstants.STATE_IN_GRACE_PERIOD; + case "SUBSCRIPTION_STATE_ON_HOLD" -> GooglePlayConstants.STATE_ON_HOLD; + case "SUBSCRIPTION_STATE_PAUSED" -> GooglePlayConstants.STATE_PAUSED; + case "SUBSCRIPTION_STATE_EXPIRED" -> GooglePlayConstants.STATE_EXPIRED; + case "SUBSCRIPTION_STATE_PENDING" -> GooglePlayConstants.STATE_PENDING; + case "SUBSCRIPTION_STATE_PENDING_PURCHASE_CANCELED" -> GooglePlayConstants.STATE_PENDING_PURCHASE_CANCELED; + default -> GooglePlayConstants.STATE_UNKNOWN; + }; + } + + private boolean isSubscriptionAccessGranted(String state) { + return GooglePlayConstants.STATE_ACTIVE.equals(state) + || GooglePlayConstants.STATE_CANCELED.equals(state) + || GooglePlayConstants.STATE_IN_GRACE_PERIOD.equals(state); + } + + private String mapOneTimeState(String state) { + return switch (state) { + case "PURCHASE_STATE_PURCHASED" -> GooglePlayConstants.STATE_ACTIVE; + case "PURCHASE_STATE_PENDING" -> GooglePlayConstants.STATE_PENDING; + case "PURCHASE_STATE_CANCELLED" -> GooglePlayConstants.STATE_CANCELED; + default -> GooglePlayConstants.STATE_UNKNOWN; + }; + } + + private String mapAcknowledgementState(String state) { + return "ACKNOWLEDGEMENT_STATE_ACKNOWLEDGED".equals(state) + ? GooglePlayConstants.ACK_ACKNOWLEDGED + : GooglePlayConstants.ACK_PENDING; + } + + private String mapConsumptionState(String state) { + if ("CONSUMPTION_STATE_CONSUMED".equals(state)) { + return GooglePlayConstants.CONSUMPTION_CONSUMED; + } + if ("CONSUMPTION_STATE_NO_NEED_TO_CONSUME".equals(state)) { + return GooglePlayConstants.CONSUMPTION_NOT_APPLICABLE; + } + return GooglePlayConstants.CONSUMPTION_PENDING; + } + + private String text(JsonNode node, String field) { + JsonNode child = node == null ? null : node.get(field); + return child == null || child.isNull() ? null : child.asText(); + } + + private Boolean bool(JsonNode node, String field) { + JsonNode child = node == null ? null : node.get(field); + return child == null || child.isNull() ? null : child.asBoolean(); + } + + private Integer number(JsonNode node, String field) { + JsonNode child = node == null ? null : node.get(field); + return child == null || child.isNull() ? null : child.asInt(); + } + + private Date parseTime(String value) { + if (value == null || value.isBlank()) { + return null; + } + return Date.from(Instant.parse(value)); + } + + private String firstNonBlank(String first, String second) { + return first != null && !first.isBlank() ? first : second; + } + + private String encode(String raw) { + return URLEncoder.encode(raw, StandardCharsets.UTF_8); + } + + private String writeJson(JsonNode jsonNode) { + try { + return objectMapper.writeValueAsString(jsonNode); + } catch (Exception e) { + return "{}"; + } + } + + private void sleep(int attempt, String operation, Exception e) { + log.warn("Google Play API retry, operation={}, attempt={}", operation, attempt, e); + try { + Thread.sleep(RETRY_INTERVAL_MILLIS * attempt); + } catch (InterruptedException interruptedException) { + Thread.currentThread().interrupt(); + throw new GooglePlayApiException("Google Play 请求被中断", interruptedException); + } + } +} diff --git a/src/main/java/com/yolo/keyborad/googleplay/GooglePlayApiException.java b/src/main/java/com/yolo/keyborad/googleplay/GooglePlayApiException.java new file mode 100644 index 0000000..773fd56 --- /dev/null +++ b/src/main/java/com/yolo/keyborad/googleplay/GooglePlayApiException.java @@ -0,0 +1,23 @@ +package com.yolo.keyborad.googleplay; + +import lombok.Getter; + +@Getter +public class GooglePlayApiException extends RuntimeException { + + private final int statusCode; + + private final String responseBody; + + public GooglePlayApiException(int statusCode, String responseBody) { + super("Google Play API request failed, status=" + statusCode); + this.statusCode = statusCode; + this.responseBody = responseBody; + } + + public GooglePlayApiException(String message, Throwable cause) { + super(message, cause); + this.statusCode = -1; + this.responseBody = null; + } +} diff --git a/src/main/java/com/yolo/keyborad/googleplay/GooglePlayConstants.java b/src/main/java/com/yolo/keyborad/googleplay/GooglePlayConstants.java new file mode 100644 index 0000000..ae86684 --- /dev/null +++ b/src/main/java/com/yolo/keyborad/googleplay/GooglePlayConstants.java @@ -0,0 +1,52 @@ +package com.yolo.keyborad.googleplay; + +public final class GooglePlayConstants { + + public static final String PRODUCT_TYPE_SUBSCRIPTION = "SUBSCRIPTION"; + public static final String PRODUCT_TYPE_ONE_TIME = "ONE_TIME"; + + public static final String STATE_PENDING = "PENDING"; + public static final String STATE_ACTIVE = "ACTIVE"; + public static final String STATE_CANCELED = "CANCELED"; + public static final String STATE_IN_GRACE_PERIOD = "IN_GRACE_PERIOD"; + public static final String STATE_ON_HOLD = "ON_HOLD"; + public static final String STATE_PAUSED = "PAUSED"; + public static final String STATE_EXPIRED = "EXPIRED"; + public static final String STATE_REVOKED = "REVOKED"; + public static final String STATE_REFUNDED = "REFUNDED"; + public static final String STATE_PENDING_PURCHASE_CANCELED = "PENDING_PURCHASE_CANCELED"; + public static final String STATE_UNKNOWN = "UNKNOWN"; + + public static final String ACK_PENDING = "PENDING"; + public static final String ACK_ACKNOWLEDGED = "ACKNOWLEDGED"; + + public static final String CONSUMPTION_PENDING = "PENDING"; + public static final String CONSUMPTION_CONSUMED = "CONSUMED"; + public static final String CONSUMPTION_NOT_APPLICABLE = "NOT_APPLICABLE"; + + public static final String DELIVERY_PENDING = "PENDING"; + public static final String DELIVERY_DELIVERED = "DELIVERED"; + public static final String DELIVERY_REVOKED = "REVOKED"; + public static final String DELIVERY_NOT_REQUIRED = "NOT_REQUIRED"; + public static final String DELIVERY_MANUAL_REVIEW = "MANUAL_REVIEW"; + + public static final String ENTITLEMENT_VIP_SUBSCRIPTION = "VIP_SUBSCRIPTION"; + public static final String ENTITLEMENT_VIP_ONE_TIME = "VIP_ONE_TIME"; + public static final String ENTITLEMENT_WALLET_TOP_UP = "WALLET_TOP_UP"; + public static final String ENTITLEMENT_NON_CONSUMABLE = "NON_CONSUMABLE"; + + public static final String EVENT_RECEIVED = "RECEIVED"; + public static final String EVENT_PROCESSED = "PROCESSED"; + public static final String EVENT_FAILED = "FAILED"; + public static final String EVENT_IGNORED = "IGNORED"; + + public static final String EVENT_TYPE_SUBSCRIPTION = "SUBSCRIPTION"; + public static final String EVENT_TYPE_ONE_TIME = "ONE_TIME"; + public static final String EVENT_TYPE_VOIDED = "VOIDED"; + public static final String EVENT_TYPE_TEST = "TEST"; + + public static final String PAYMENT_METHOD_GOOGLE_PLAY = "GOOGLE_PLAY"; + + private GooglePlayConstants() { + } +} diff --git a/src/main/java/com/yolo/keyborad/googleplay/GooglePlayEntitlementApplier.java b/src/main/java/com/yolo/keyborad/googleplay/GooglePlayEntitlementApplier.java new file mode 100644 index 0000000..fb727b6 --- /dev/null +++ b/src/main/java/com/yolo/keyborad/googleplay/GooglePlayEntitlementApplier.java @@ -0,0 +1,292 @@ +package com.yolo.keyborad.googleplay; + +import com.yolo.keyborad.common.ErrorCode; +import com.yolo.keyborad.exception.BusinessException; +import com.yolo.keyborad.googleplay.model.GooglePlayPurchaseSnapshot; +import com.yolo.keyborad.mapper.GooglePlayUserEntitlementMapper; +import com.yolo.keyborad.model.entity.KeyboardProductItems; +import com.yolo.keyborad.model.entity.googleplay.GooglePlayOrder; +import com.yolo.keyborad.model.entity.googleplay.GooglePlayUserEntitlement; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.math.BigDecimal; +import java.util.Date; + +@Slf4j +@Component +@RequiredArgsConstructor +public class GooglePlayEntitlementApplier { + + private final GooglePlayUserEntitlementMapper entitlementMapper; + private final GooglePlayVipBenefitService vipBenefitService; + private final GooglePlayWalletBenefitService walletBenefitService; + + public GooglePlayUserEntitlement apply(Long userId, + KeyboardProductItems product, + GooglePlayPurchaseSnapshot snapshot, + GooglePlayOrder order) { + String benefitType = resolveBenefitType(product, snapshot); + String entitlementKey = resolveEntitlementKey(benefitType, product.getProductId()); + GooglePlayUserEntitlement entitlement = loadEntitlement(snapshot.getPurchaseToken(), entitlementKey); + if (entitlement == null) { + entitlement = new GooglePlayUserEntitlement(); + entitlement.setCreatedAt(new Date()); + } + 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); + } + saveEntitlement(entitlement); + return entitlement; + } + + private void fillCommonFields(GooglePlayUserEntitlement entitlement, + Long userId, + KeyboardProductItems product, + GooglePlayPurchaseSnapshot snapshot, + GooglePlayOrder order, + String benefitType, + String entitlementKey) { + Date now = new Date(); + entitlement.setUserId(userId); + entitlement.setEntitlementKey(entitlementKey); + entitlement.setProductId(product.getProductId()); + entitlement.setProductType(snapshot.getProductType()); + entitlement.setSourcePurchaseToken(snapshot.getPurchaseToken()); + entitlement.setCurrentOrderKey(order.getOrderKey()); + entitlement.setBenefitType(benefitType); + entitlement.setState(snapshot.getState()); + entitlement.setUpdatedAt(now); + if (entitlement.getQuantity() == null) { + entitlement.setQuantity(BigDecimal.ZERO); + } + } + + private void applySubscriptionVip(Long userId, + KeyboardProductItems product, + GooglePlayPurchaseSnapshot snapshot, + GooglePlayOrder order, + GooglePlayUserEntitlement entitlement) { + Date expiryTime = snapshot.getExpiryTime(); + if (snapshot.getAccessGranted() && expiryTime == null) { + throw new BusinessException(ErrorCode.GOOGLE_PLAY_STATE_INVALID, "订阅缺少 expiryTime"); + } + entitlement.setActive(Boolean.TRUE.equals(snapshot.getAccessGranted())); + entitlement.setStartTime(snapshot.getStartTime()); + entitlement.setEndTime(expiryTime); + order.setDeliveryStatus(GooglePlayConstants.DELIVERY_NOT_REQUIRED); + order.setGrantedQuantity(BigDecimal.ZERO); + if (Boolean.TRUE.equals(snapshot.getAccessGranted())) { + entitlement.setLastGrantedAt(new Date()); + vipBenefitService.activate(userId, product, expiryTime); + return; + } + entitlement.setLastRevokedAt(new Date()); + vipBenefitService.deactivate(userId, expiryTime); + } + + private void applyOneTimeVip(Long userId, + KeyboardProductItems product, + GooglePlayPurchaseSnapshot snapshot, + GooglePlayOrder order, + GooglePlayUserEntitlement entitlement) { + if (GooglePlayConstants.STATE_ACTIVE.equals(snapshot.getState())) { + grantOneTimeVip(userId, product, order, entitlement); + return; + } + revokeVipEntitlement(userId, order, entitlement); + } + + private void applyWalletTopUp(Long userId, + KeyboardProductItems product, + GooglePlayPurchaseSnapshot snapshot, + GooglePlayOrder order, + GooglePlayUserEntitlement entitlement) { + 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); + return; + } + revokeWalletTopUp(userId, order, entitlement, amount); + } + + private void applyNonConsumable(GooglePlayPurchaseSnapshot snapshot, + GooglePlayOrder order, + GooglePlayUserEntitlement entitlement) { + boolean active = GooglePlayConstants.STATE_ACTIVE.equals(snapshot.getState()) + || GooglePlayConstants.STATE_CANCELED.equals(snapshot.getState()); + entitlement.setActive(active); + entitlement.setStartTime(snapshot.getStartTime()); + entitlement.setEndTime(snapshot.getExpiryTime()); + entitlement.setQuantity(BigDecimal.ONE); + order.setGrantedQuantity(BigDecimal.ONE); + order.setDeliveryStatus(active ? GooglePlayConstants.DELIVERY_DELIVERED : GooglePlayConstants.DELIVERY_REVOKED); + if (active) { + entitlement.setLastGrantedAt(new Date()); + return; + } + entitlement.setLastRevokedAt(new Date()); + } + + private void grantOneTimeVip(Long userId, + KeyboardProductItems product, + GooglePlayOrder order, + GooglePlayUserEntitlement entitlement) { + if (GooglePlayConstants.DELIVERY_DELIVERED.equals(order.getDeliveryStatus())) { + entitlement.setActive(true); + return; + } + Date expiry = resolveOneTimeVipExpiry(product); + vipBenefitService.activate(userId, product, expiry); + entitlement.setActive(true); + entitlement.setQuantity(BigDecimal.ONE); + entitlement.setStartTime(new Date()); + entitlement.setEndTime(expiry); + entitlement.setLastGrantedAt(new Date()); + order.setDeliveryStatus(GooglePlayConstants.DELIVERY_DELIVERED); + order.setGrantedQuantity(BigDecimal.ONE); + } + + private void revokeVipEntitlement(Long userId, + GooglePlayOrder order, + GooglePlayUserEntitlement entitlement) { + entitlement.setActive(false); + entitlement.setLastRevokedAt(new Date()); + if (!GooglePlayConstants.DELIVERY_DELIVERED.equals(order.getDeliveryStatus())) { + order.setDeliveryStatus(GooglePlayConstants.DELIVERY_REVOKED); + return; + } + vipBenefitService.deactivate(userId, entitlement.getEndTime()); + order.setDeliveryStatus(GooglePlayConstants.DELIVERY_REVOKED); + } + + private void grantWalletTopUp(Long userId, + KeyboardProductItems product, + GooglePlayOrder order, + GooglePlayUserEntitlement entitlement, + BigDecimal amount) { + if (GooglePlayConstants.DELIVERY_DELIVERED.equals(order.getDeliveryStatus())) { + entitlement.setActive(true); + return; + } + walletBenefitService.grant(userId, order.getId(), product.getProductId(), amount); + entitlement.setActive(true); + entitlement.setQuantity(amount); + entitlement.setStartTime(new Date()); + entitlement.setLastGrantedAt(new Date()); + order.setDeliveryStatus(GooglePlayConstants.DELIVERY_DELIVERED); + order.setGrantedQuantity(amount); + } + + private void revokeWalletTopUp(Long userId, + GooglePlayOrder order, + GooglePlayUserEntitlement entitlement, + BigDecimal amount) { + entitlement.setActive(false); + entitlement.setLastRevokedAt(new Date()); + if (!GooglePlayConstants.DELIVERY_DELIVERED.equals(order.getDeliveryStatus())) { + order.setDeliveryStatus(GooglePlayConstants.DELIVERY_REVOKED); + return; + } + boolean revoked = walletBenefitService.revoke(userId, order.getId(), amount); + if (!revoked) { + order.setDeliveryStatus(GooglePlayConstants.DELIVERY_MANUAL_REVIEW); + return; + } + order.setDeliveryStatus(GooglePlayConstants.DELIVERY_REVOKED); + } + + private Date resolveOneTimeVipExpiry(KeyboardProductItems product) { + if (product.getDurationDays() == null || product.getDurationDays() <= 0) { + return null; + } + long millis = (long) product.getDurationDays() * 24 * 60 * 60 * 1000; + return new Date(System.currentTimeMillis() + millis); + } + + private String resolveBenefitType(KeyboardProductItems product, GooglePlayPurchaseSnapshot snapshot) { + if (GooglePlayConstants.PRODUCT_TYPE_SUBSCRIPTION.equals(snapshot.getProductType())) { + return GooglePlayConstants.ENTITLEMENT_VIP_SUBSCRIPTION; + } + if (looksLikeVipProduct(product)) { + return GooglePlayConstants.ENTITLEMENT_VIP_ONE_TIME; + } + if (looksLikeWalletProduct(product)) { + return GooglePlayConstants.ENTITLEMENT_WALLET_TOP_UP; + } + return GooglePlayConstants.ENTITLEMENT_NON_CONSUMABLE; + } + + private boolean looksLikeVipProduct(KeyboardProductItems product) { + String unit = product.getUnit() == null ? "" : product.getUnit().toLowerCase(); + return product.getDurationDays() != null && product.getDurationDays() > 0 + || unit.contains("vip") + || unit.contains("member") + || unit.contains("会员"); + } + + private boolean looksLikeWalletProduct(KeyboardProductItems product) { + String unit = product.getUnit() == null ? "" : product.getUnit().toLowerCase(); + return unit.contains("coin") + || unit.contains("quota") + || unit.contains("credit") + || unit.contains("金币") + || unit.contains("次数") + || resolveWalletAmount(product).compareTo(BigDecimal.ZERO) > 0; + } + + private BigDecimal resolveWalletAmount(KeyboardProductItems product) { + BigDecimal fromName = parseNumber(product.getName()); + if (fromName != null) { + return fromName; + } + if (product.getDurationValue() != null) { + return BigDecimal.valueOf(product.getDurationValue()); + } + return BigDecimal.ZERO; + } + + private BigDecimal parseNumber(String raw) { + if (raw == null || raw.isBlank()) { + return null; + } + try { + return new BigDecimal(raw.replaceAll("[^\\d.]", "")); + } catch (Exception e) { + return null; + } + } + + private String resolveEntitlementKey(String benefitType, String productId) { + if (GooglePlayConstants.ENTITLEMENT_NON_CONSUMABLE.equals(benefitType)) { + return "PRODUCT:" + productId; + } + if (GooglePlayConstants.ENTITLEMENT_WALLET_TOP_UP.equals(benefitType)) { + return "WALLET"; + } + return "VIP"; + } + + private GooglePlayUserEntitlement loadEntitlement(String purchaseToken, String entitlementKey) { + return entitlementMapper.selectOne(com.baomidou.mybatisplus.core.toolkit.Wrappers.lambdaQuery() + .eq(GooglePlayUserEntitlement::getSourcePurchaseToken, purchaseToken) + .eq(GooglePlayUserEntitlement::getEntitlementKey, entitlementKey) + .last("LIMIT 1")); + } + + private void saveEntitlement(GooglePlayUserEntitlement entitlement) { + if (entitlement.getId() == null) { + entitlementMapper.insert(entitlement); + return; + } + entitlementMapper.updateById(entitlement); + } +} diff --git a/src/main/java/com/yolo/keyborad/googleplay/GooglePlayNotificationSupport.java b/src/main/java/com/yolo/keyborad/googleplay/GooglePlayNotificationSupport.java new file mode 100644 index 0000000..d5e1f5a --- /dev/null +++ b/src/main/java/com/yolo/keyborad/googleplay/GooglePlayNotificationSupport.java @@ -0,0 +1,191 @@ +package com.yolo.keyborad.googleplay; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yolo.keyborad.common.ErrorCode; +import com.yolo.keyborad.exception.BusinessException; +import com.yolo.keyborad.googleplay.model.GooglePlaySyncCommand; +import com.yolo.keyborad.model.dto.googleplay.GooglePlayPubSubPushRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.Base64; +import java.util.Date; + +@Component +@RequiredArgsConstructor +public class GooglePlayNotificationSupport { + + private static final int VOIDED_PRODUCT_TYPE_SUBSCRIPTION = 1; + + private final ObjectMapper objectMapper; + + public GooglePlayPubSubPushRequest.DeveloperNotification decode(GooglePlayPubSubPushRequest pushRequest) { + try { + byte[] decoded = Base64.getDecoder().decode(pushRequest.getMessage().getData()); + return objectMapper.readValue(decoded, GooglePlayPubSubPushRequest.DeveloperNotification.class); + } catch (Exception e) { + throw new BusinessException(ErrorCode.GOOGLE_PLAY_RTDN_PAYLOAD_INVALID, "RTDN payload 解析失败"); + } + } + + public GooglePlaySyncCommand buildCommand(GooglePlayPubSubPushRequest pushRequest, + GooglePlayPubSubPushRequest.DeveloperNotification notification, + String packageName) { + if (notification.getSubscriptionNotification() != null) { + return buildSubscriptionCommand(pushRequest, notification, packageName); + } + if (notification.getOneTimeProductNotification() != null) { + return buildOneTimeCommand(pushRequest, notification, packageName); + } + return buildVoidedCommand(pushRequest, notification, packageName); + } + + public String resolveEventType(GooglePlayPubSubPushRequest.DeveloperNotification notification) { + if (notification.getSubscriptionNotification() != null) { + return GooglePlayConstants.EVENT_TYPE_SUBSCRIPTION; + } + if (notification.getOneTimeProductNotification() != null) { + return GooglePlayConstants.EVENT_TYPE_ONE_TIME; + } + if (notification.getVoidedPurchaseNotification() != null) { + return GooglePlayConstants.EVENT_TYPE_VOIDED; + } + return GooglePlayConstants.EVENT_TYPE_TEST; + } + + public Integer resolveNotificationType(GooglePlayPubSubPushRequest.DeveloperNotification notification) { + if (notification.getSubscriptionNotification() != null) { + return notification.getSubscriptionNotification().getNotificationType(); + } + if (notification.getOneTimeProductNotification() != null) { + return notification.getOneTimeProductNotification().getNotificationType(); + } + return notification.getVoidedPurchaseNotification() == null ? null : notification.getVoidedPurchaseNotification().getRefundType(); + } + + public String resolveNotificationName(GooglePlayPubSubPushRequest.DeveloperNotification notification) { + if (notification.getSubscriptionNotification() != null) { + return resolveSubscriptionName(notification.getSubscriptionNotification().getNotificationType()); + } + if (notification.getOneTimeProductNotification() != null) { + return resolveOneTimeName(notification.getOneTimeProductNotification().getNotificationType()); + } + return notification.getVoidedPurchaseNotification() == null ? "TEST_NOTIFICATION" : "VOIDED_PURCHASE"; + } + + public String resolvePurchaseToken(GooglePlayPubSubPushRequest.DeveloperNotification notification) { + if (notification.getSubscriptionNotification() != null) { + return notification.getSubscriptionNotification().getPurchaseToken(); + } + if (notification.getOneTimeProductNotification() != null) { + return notification.getOneTimeProductNotification().getPurchaseToken(); + } + return notification.getVoidedPurchaseNotification() == null ? null : notification.getVoidedPurchaseNotification().getPurchaseToken(); + } + + public String resolveProductId(GooglePlayPubSubPushRequest.DeveloperNotification notification) { + if (notification.getOneTimeProductNotification() != null) { + return notification.getOneTimeProductNotification().getSku(); + } + return null; + } + + public String normalizeVoidedProductType(Integer productType) { + return VOIDED_PRODUCT_TYPE_SUBSCRIPTION == productType + ? GooglePlayConstants.PRODUCT_TYPE_SUBSCRIPTION + : GooglePlayConstants.PRODUCT_TYPE_ONE_TIME; + } + + public Date resolveEventTime(String eventTimeMillis) { + if (eventTimeMillis == null || eventTimeMillis.isBlank()) { + return new Date(); + } + return new Date(Long.parseLong(eventTimeMillis)); + } + + private GooglePlaySyncCommand buildSubscriptionCommand(GooglePlayPubSubPushRequest pushRequest, + GooglePlayPubSubPushRequest.DeveloperNotification notification, + String packageName) { + int type = notification.getSubscriptionNotification().getNotificationType(); + return GooglePlaySyncCommand.builder() + .packageName(packageName) + .productType(GooglePlayConstants.PRODUCT_TYPE_SUBSCRIPTION) + .purchaseToken(notification.getSubscriptionNotification().getPurchaseToken()) + .source("RTDN") + .eventType(GooglePlayConstants.EVENT_TYPE_SUBSCRIPTION) + .notificationType(type) + .notificationName(resolveSubscriptionName(type)) + .messageId(pushRequest.getMessage().getMessageId()) + .subscriptionName(pushRequest.getSubscription()) + .eventTime(resolveEventTime(notification.getEventTimeMillis())) + .build(); + } + + private GooglePlaySyncCommand buildOneTimeCommand(GooglePlayPubSubPushRequest pushRequest, + GooglePlayPubSubPushRequest.DeveloperNotification notification, + String packageName) { + int type = notification.getOneTimeProductNotification().getNotificationType(); + return GooglePlaySyncCommand.builder() + .packageName(packageName) + .productId(notification.getOneTimeProductNotification().getSku()) + .productType(GooglePlayConstants.PRODUCT_TYPE_ONE_TIME) + .purchaseToken(notification.getOneTimeProductNotification().getPurchaseToken()) + .source("RTDN") + .eventType(GooglePlayConstants.EVENT_TYPE_ONE_TIME) + .notificationType(type) + .notificationName(resolveOneTimeName(type)) + .messageId(pushRequest.getMessage().getMessageId()) + .subscriptionName(pushRequest.getSubscription()) + .eventTime(resolveEventTime(notification.getEventTimeMillis())) + .build(); + } + + private GooglePlaySyncCommand buildVoidedCommand(GooglePlayPubSubPushRequest pushRequest, + GooglePlayPubSubPushRequest.DeveloperNotification notification, + String packageName) { + GooglePlayPubSubPushRequest.VoidedPurchaseNotification voided = notification.getVoidedPurchaseNotification(); + return GooglePlaySyncCommand.builder() + .packageName(packageName) + .productType(normalizeVoidedProductType(voided.getProductType())) + .purchaseToken(voided.getPurchaseToken()) + .source("RTDN") + .eventType(GooglePlayConstants.EVENT_TYPE_VOIDED) + .notificationType(voided.getRefundType()) + .notificationName("VOIDED_PURCHASE") + .messageId(pushRequest.getMessage().getMessageId()) + .subscriptionName(pushRequest.getSubscription()) + .eventTime(resolveEventTime(notification.getEventTimeMillis())) + .build(); + } + + private String resolveSubscriptionName(Integer type) { + return switch (type) { + case 1 -> "SUBSCRIPTION_RECOVERED"; + case 2 -> "SUBSCRIPTION_RENEWED"; + case 3 -> "SUBSCRIPTION_CANCELED"; + case 4 -> "SUBSCRIPTION_PURCHASED"; + case 5 -> "SUBSCRIPTION_ON_HOLD"; + case 6 -> "SUBSCRIPTION_IN_GRACE_PERIOD"; + case 7 -> "SUBSCRIPTION_RESTARTED"; + case 9 -> "SUBSCRIPTION_DEFERRED"; + case 10 -> "SUBSCRIPTION_PAUSED"; + case 11 -> "SUBSCRIPTION_PAUSE_SCHEDULE_CHANGED"; + case 12 -> "SUBSCRIPTION_REVOKED"; + case 13 -> "SUBSCRIPTION_EXPIRED"; + case 17 -> "SUBSCRIPTION_ITEMS_CHANGED"; + case 18 -> "SUBSCRIPTION_CANCELLATION_SCHEDULED"; + case 19 -> "SUBSCRIPTION_PRICE_CHANGE_UPDATED"; + case 20 -> "SUBSCRIPTION_PENDING_PURCHASE_CANCELED"; + case 22 -> "SUBSCRIPTION_PRICE_STEP_UP_CONSENT_UPDATED"; + default -> "SUBSCRIPTION_UNKNOWN"; + }; + } + + private String resolveOneTimeName(Integer type) { + return switch (type) { + case 1 -> "ONE_TIME_PRODUCT_PURCHASED"; + case 2 -> "ONE_TIME_PRODUCT_CANCELED"; + default -> "ONE_TIME_UNKNOWN"; + }; + } +} diff --git a/src/main/java/com/yolo/keyborad/googleplay/GooglePlayPubSubAuthService.java b/src/main/java/com/yolo/keyborad/googleplay/GooglePlayPubSubAuthService.java new file mode 100644 index 0000000..b003011 --- /dev/null +++ b/src/main/java/com/yolo/keyborad/googleplay/GooglePlayPubSubAuthService.java @@ -0,0 +1,91 @@ +package com.yolo.keyborad.googleplay; + +import com.fasterxml.jackson.databind.JsonNode; +import com.yolo.keyborad.common.ErrorCode; +import com.yolo.keyborad.config.GooglePlayProperties; +import com.yolo.keyborad.exception.BusinessException; +import com.yolo.keyborad.model.dto.googleplay.GooglePlayPubSubPushRequest; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class GooglePlayPubSubAuthService { + + private final GooglePlayProperties properties; + private final GooglePlayApiClient apiClient; + + public void verify(HttpServletRequest request, GooglePlayPubSubPushRequest pushRequest) { + verifyTopic(request); + verifySubscription(pushRequest); + if (!properties.isValidatePubsubJwt()) { + return; + } + String bearerToken = resolveBearerToken(request); + JsonNode tokenInfo = apiClient.verifyPubSubJwt(bearerToken); + verifyAudience(tokenInfo); + verifyEmail(tokenInfo); + verifyIssuer(tokenInfo); + } + + private void verifyTopic(HttpServletRequest request) { + String expectedTopic = properties.getPubsub().getExpectedTopic(); + if (expectedTopic == null || expectedTopic.isBlank()) { + return; + } + String currentTopic = request.getHeader("X-Goog-Topic"); + if (!expectedTopic.equals(currentTopic)) { + throw new BusinessException(ErrorCode.GOOGLE_PLAY_WEBHOOK_UNAUTHORIZED, "Pub/Sub topic 不匹配"); + } + } + + private void verifySubscription(GooglePlayPubSubPushRequest pushRequest) { + String expectedSubscription = properties.getPubsub().getExpectedSubscription(); + if (expectedSubscription == null || expectedSubscription.isBlank()) { + return; + } + if (!expectedSubscription.equals(pushRequest.getSubscription())) { + throw new BusinessException(ErrorCode.GOOGLE_PLAY_WEBHOOK_UNAUTHORIZED, "Pub/Sub subscription 不匹配"); + } + } + + private String resolveBearerToken(HttpServletRequest request) { + String authorization = request.getHeader("Authorization"); + if (authorization == null || !authorization.startsWith("Bearer ")) { + throw new BusinessException(ErrorCode.GOOGLE_PLAY_WEBHOOK_UNAUTHORIZED, "缺少 Pub/Sub Bearer Token"); + } + return authorization.substring("Bearer ".length()); + } + + private void verifyAudience(JsonNode tokenInfo) { + String expectedAudience = properties.getPubsub().getAudience(); + if (expectedAudience == null || expectedAudience.isBlank()) { + return; + } + if (!expectedAudience.equalsIgnoreCase(tokenInfo.path("aud").asText())) { + throw new BusinessException(ErrorCode.GOOGLE_PLAY_WEBHOOK_UNAUTHORIZED, "Pub/Sub audience 不匹配"); + } + } + + private void verifyEmail(JsonNode tokenInfo) { + if (!tokenInfo.path("email_verified").asBoolean(false)) { + throw new BusinessException(ErrorCode.GOOGLE_PLAY_WEBHOOK_UNAUTHORIZED, "Pub/Sub 邮箱未验证"); + } + String expectedEmail = properties.getPubsub().getServiceAccountEmail(); + if (expectedEmail == null || expectedEmail.isBlank()) { + return; + } + if (!expectedEmail.equalsIgnoreCase(tokenInfo.path("email").asText())) { + throw new BusinessException(ErrorCode.GOOGLE_PLAY_WEBHOOK_UNAUTHORIZED, "Pub/Sub service account 不匹配"); + } + } + + private void verifyIssuer(JsonNode tokenInfo) { + String issuer = tokenInfo.path("iss").asText(); + boolean valid = "https://accounts.google.com".equals(issuer) || "accounts.google.com".equals(issuer); + if (!valid) { + throw new BusinessException(ErrorCode.GOOGLE_PLAY_WEBHOOK_UNAUTHORIZED, "Pub/Sub issuer 非法"); + } + } +} diff --git a/src/main/java/com/yolo/keyborad/googleplay/GooglePlayRtdnEventService.java b/src/main/java/com/yolo/keyborad/googleplay/GooglePlayRtdnEventService.java new file mode 100644 index 0000000..ba58079 --- /dev/null +++ b/src/main/java/com/yolo/keyborad/googleplay/GooglePlayRtdnEventService.java @@ -0,0 +1,99 @@ +package com.yolo.keyborad.googleplay; + +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yolo.keyborad.common.ErrorCode; +import com.yolo.keyborad.exception.BusinessException; +import com.yolo.keyborad.mapper.GooglePlayRtdnEventMapper; +import com.yolo.keyborad.model.dto.googleplay.GooglePlayPubSubPushRequest; +import com.yolo.keyborad.model.entity.googleplay.GooglePlayRtdnEvent; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.Date; + +@Component +@RequiredArgsConstructor +public class GooglePlayRtdnEventService { + + private final GooglePlayRtdnEventMapper rtdnEventMapper; + private final GooglePlayNotificationSupport notificationSupport; + private final ObjectMapper objectMapper; + + public GooglePlayRtdnEvent upsertEvent(GooglePlayPubSubPushRequest pushRequest, + GooglePlayPubSubPushRequest.DeveloperNotification notification) { + String messageId = pushRequest.getMessage().getMessageId(); + GooglePlayRtdnEvent event = rtdnEventMapper.selectOne(Wrappers.lambdaQuery() + .eq(GooglePlayRtdnEvent::getMessageId, messageId) + .last("LIMIT 1")); + if (event == null) { + event = new GooglePlayRtdnEvent(); + event.setMessageId(messageId); + event.setRetryCount(0); + event.setCreatedAt(new Date()); + } else if (GooglePlayConstants.EVENT_PROCESSED.equals(event.getStatus())) { + return event; + } else { + event.setRetryCount(event.getRetryCount() == null ? 1 : event.getRetryCount() + 1); + } + fillEvent(event, pushRequest, notification); + saveEvent(event); + return event; + } + + public void markProcessed(GooglePlayRtdnEvent event) { + mark(event, GooglePlayConstants.EVENT_PROCESSED, null, true); + } + + public void markIgnored(GooglePlayRtdnEvent event) { + mark(event, GooglePlayConstants.EVENT_IGNORED, null, true); + } + + public void markFailed(GooglePlayRtdnEvent event, Exception e) { + mark(event, GooglePlayConstants.EVENT_FAILED, e.getMessage(), false); + } + + private void fillEvent(GooglePlayRtdnEvent event, + GooglePlayPubSubPushRequest pushRequest, + GooglePlayPubSubPushRequest.DeveloperNotification notification) { + event.setSubscriptionName(pushRequest.getSubscription()); + event.setPackageName(notification.getPackageName()); + event.setEventType(notificationSupport.resolveEventType(notification)); + event.setNotificationType(notificationSupport.resolveNotificationType(notification)); + event.setNotificationName(notificationSupport.resolveNotificationName(notification)); + event.setPurchaseToken(notificationSupport.resolvePurchaseToken(notification)); + event.setProductId(notificationSupport.resolveProductId(notification)); + event.setOrderId(notification.getVoidedPurchaseNotification() == null ? null : notification.getVoidedPurchaseNotification().getOrderId()); + event.setEventTime(notificationSupport.resolveEventTime(notification.getEventTimeMillis())); + event.setStatus(GooglePlayConstants.EVENT_RECEIVED); + event.setRawEnvelope(writeJson(pushRequest)); + event.setRawPayload(writeJson(notification)); + event.setErrorMessage(null); + event.setProcessedAt(null); + event.setUpdatedAt(new Date()); + } + + private void mark(GooglePlayRtdnEvent event, String status, String errorMessage, boolean processed) { + event.setStatus(status); + event.setErrorMessage(errorMessage); + event.setProcessedAt(processed ? new Date() : null); + event.setUpdatedAt(new Date()); + saveEvent(event); + } + + private void saveEvent(GooglePlayRtdnEvent event) { + if (event.getId() == null) { + rtdnEventMapper.insert(event); + return; + } + rtdnEventMapper.updateById(event); + } + + private String writeJson(Object value) { + try { + return objectMapper.writeValueAsString(value); + } catch (Exception e) { + throw new BusinessException(ErrorCode.OPERATION_ERROR, "JSON 序列化失败"); + } + } +} diff --git a/src/main/java/com/yolo/keyborad/googleplay/GooglePlayServiceAccountTokenProvider.java b/src/main/java/com/yolo/keyborad/googleplay/GooglePlayServiceAccountTokenProvider.java new file mode 100644 index 0000000..62679f6 --- /dev/null +++ b/src/main/java/com/yolo/keyborad/googleplay/GooglePlayServiceAccountTokenProvider.java @@ -0,0 +1,145 @@ +package com.yolo.keyborad.googleplay; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yolo.keyborad.config.GooglePlayProperties; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.io.Resource; +import org.springframework.core.io.ResourceLoader; +import org.springframework.stereotype.Component; + +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.security.KeyFactory; +import java.security.PrivateKey; +import java.security.spec.PKCS8EncodedKeySpec; +import java.time.Instant; +import java.util.Base64; +import java.util.Date; + +@Slf4j +@Component +@RequiredArgsConstructor +public class GooglePlayServiceAccountTokenProvider { + + private static final long ASSERTION_EXPIRE_SECONDS = 3600; + private static final long REFRESH_EARLY_SECONDS = 300; + + private final GooglePlayProperties properties; + private final ResourceLoader resourceLoader; + private final ObjectMapper objectMapper; + private final HttpClient googlePlayHttpClient; + + private volatile CachedToken cachedToken; + private volatile ServiceAccountCredentials credentials; + + public String getAccessToken() { + CachedToken token = cachedToken; + if (token != null && token.expiresAt().isAfter(Instant.now().plusSeconds(REFRESH_EARLY_SECONDS))) { + return token.token(); + } + synchronized (this) { + token = cachedToken; + if (token != null && token.expiresAt().isAfter(Instant.now().plusSeconds(REFRESH_EARLY_SECONDS))) { + return token.token(); + } + cachedToken = fetchAccessToken(loadCredentials()); + return cachedToken.token(); + } + } + + private CachedToken fetchAccessToken(ServiceAccountCredentials account) { + try { + String assertion = buildAssertion(account); + String body = "grant_type=" + encode("urn:ietf:params:oauth:grant-type:jwt-bearer") + + "&assertion=" + encode(assertion); + HttpRequest request = HttpRequest.newBuilder(URI.create(properties.getOauthTokenUri())) + .header("Content-Type", "application/x-www-form-urlencoded") + .POST(HttpRequest.BodyPublishers.ofString(body)) + .build(); + HttpResponse response = googlePlayHttpClient.send(request, HttpResponse.BodyHandlers.ofString()); + if (response.statusCode() / 100 != 2) { + throw new GooglePlayApiException(response.statusCode(), response.body()); + } + JsonNode jsonNode = objectMapper.readTree(response.body()); + String accessToken = jsonNode.path("access_token").asText(); + long expiresIn = jsonNode.path("expires_in").asLong(ASSERTION_EXPIRE_SECONDS); + return new CachedToken(accessToken, Instant.now().plusSeconds(expiresIn)); + } catch (GooglePlayApiException e) { + throw e; + } catch (Exception e) { + throw new GooglePlayApiException("获取 Google Play access token 失败", e); + } + } + + private String buildAssertion(ServiceAccountCredentials account) { + Instant now = Instant.now(); + return Jwts.builder() + .setHeaderParam("kid", account.privateKeyId()) + .setIssuer(account.clientEmail()) + .setAudience(properties.getOauthTokenUri()) + .claim("scope", properties.getAndroidPublisherScope()) + .setIssuedAt(Date.from(now)) + .setExpiration(Date.from(now.plusSeconds(ASSERTION_EXPIRE_SECONDS))) + .signWith(account.privateKey(), SignatureAlgorithm.RS256) + .compact(); + } + + private ServiceAccountCredentials loadCredentials() { + ServiceAccountCredentials account = credentials; + if (account != null) { + return account; + } + synchronized (this) { + account = credentials; + if (account != null) { + return account; + } + credentials = readCredentials(); + return credentials; + } + } + + private ServiceAccountCredentials readCredentials() { + if (properties.getServiceAccountKeyPath() == null || properties.getServiceAccountKeyPath().isBlank()) { + throw new IllegalStateException("google.play.service-account-key-path 未配置"); + } + try { + Resource resource = resourceLoader.getResource(properties.getServiceAccountKeyPath()); + JsonNode jsonNode = objectMapper.readTree(resource.getInputStream()); + String clientEmail = jsonNode.path("client_email").asText(); + String privateKeyId = jsonNode.path("private_key_id").asText(); + String privateKeyPem = jsonNode.path("private_key").asText(); + return new ServiceAccountCredentials(clientEmail, privateKeyId, parsePrivateKey(privateKeyPem)); + } catch (Exception e) { + throw new GooglePlayApiException("读取 Google Play service account 失败", e); + } + } + + private PrivateKey parsePrivateKey(String privateKeyPem) throws Exception { + String sanitized = privateKeyPem + .replace("-----BEGIN PRIVATE KEY-----", "") + .replace("-----END PRIVATE KEY-----", "") + .replaceAll("\\s+", ""); + byte[] decoded = Base64.getDecoder().decode(sanitized); + PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(decoded); + return KeyFactory.getInstance("RSA").generatePrivate(spec); + } + + private String encode(String raw) { + return URLEncoder.encode(raw, StandardCharsets.UTF_8); + } + + private record CachedToken(String token, Instant expiresAt) { + } + + private record ServiceAccountCredentials(String clientEmail, String privateKeyId, PrivateKey privateKey) { + } +} diff --git a/src/main/java/com/yolo/keyborad/googleplay/GooglePlayStateService.java b/src/main/java/com/yolo/keyborad/googleplay/GooglePlayStateService.java new file mode 100644 index 0000000..3378677 --- /dev/null +++ b/src/main/java/com/yolo/keyborad/googleplay/GooglePlayStateService.java @@ -0,0 +1,226 @@ +package com.yolo.keyborad.googleplay; + +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.yolo.keyborad.common.ErrorCode; +import com.yolo.keyborad.exception.BusinessException; +import com.yolo.keyborad.googleplay.model.GooglePlayPurchaseSnapshot; +import com.yolo.keyborad.googleplay.model.GooglePlaySyncCommand; +import com.yolo.keyborad.googleplay.model.GooglePlaySyncResult; +import com.yolo.keyborad.mapper.GooglePlayOrderMapper; +import com.yolo.keyborad.mapper.GooglePlayPurchaseTokenMapper; +import com.yolo.keyborad.model.entity.KeyboardProductItems; +import com.yolo.keyborad.model.entity.googleplay.GooglePlayOrder; +import com.yolo.keyborad.model.entity.googleplay.GooglePlayPurchaseToken; +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.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.util.Date; + +@Component +@RequiredArgsConstructor +public class GooglePlayStateService { + + private final GooglePlayOrderMapper orderMapper; + private final GooglePlayPurchaseTokenMapper purchaseTokenMapper; + private final KeyboardProductItemsService productItemsService; + private final GooglePlayEntitlementApplier entitlementApplier; + + @Transactional(rollbackFor = Exception.class) + public GooglePlaySyncResult sync(GooglePlaySyncCommand command, GooglePlayPurchaseSnapshot snapshot) { + KeyboardProductItems product = loadProduct(snapshot.getProductId()); + GooglePlayOrder order = buildOrder(command, snapshot); + GooglePlayPurchaseToken token = buildToken(command, snapshot); + GooglePlayUserEntitlement entitlement = null; + if (command.getUserId() != null) { + entitlement = entitlementApplier.apply(command.getUserId(), product, snapshot, order); + } + saveOrder(order); + token.setLatestOrderKey(order.getOrderKey()); + token.setLatestOrderId(order.getGoogleOrderId()); + saveToken(token); + return GooglePlaySyncResult.builder() + .order(order) + .token(token) + .entitlement(entitlement) + .acknowledgeRequired(requiresAcknowledge(snapshot, command.getUserId())) + .consumeRequired(requiresConsume(snapshot, entitlement, command.getUserId())) + .linkedPurchaseTokenToSync(resolveLinkedToken(snapshot)) + .build(); + } + + @Transactional(rollbackFor = Exception.class) + public void markAcknowledged(String purchaseToken, String orderKey) { + updateAckState(purchaseToken, orderKey, GooglePlayConstants.ACK_ACKNOWLEDGED); + } + + @Transactional(rollbackFor = Exception.class) + public void markConsumed(String purchaseToken, String orderKey) { + updateConsumptionState(purchaseToken, orderKey, GooglePlayConstants.CONSUMPTION_CONSUMED); + } + + public GooglePlayPurchaseToken findToken(String purchaseToken) { + return purchaseTokenMapper.selectOne(Wrappers.lambdaQuery() + .eq(GooglePlayPurchaseToken::getPurchaseToken, purchaseToken) + .last("LIMIT 1")); + } + + private KeyboardProductItems loadProduct(String productId) { + KeyboardProductItems product = productItemsService.getProductEntityByProductId(productId); + if (product == null) { + throw new BusinessException(ErrorCode.PRODUCT_NOT_FOUND, "Google Play 商品不存在: " + productId); + } + return product; + } + + private GooglePlayOrder buildOrder(GooglePlaySyncCommand command, GooglePlayPurchaseSnapshot snapshot) { + GooglePlayOrder order = orderMapper.selectOne(Wrappers.lambdaQuery() + .eq(GooglePlayOrder::getOrderKey, snapshot.getOrderKey()) + .last("LIMIT 1")); + if (order == null) { + order = new GooglePlayOrder(); + order.setCreatedAt(new Date()); + order.setDeliveryStatus(defaultDeliveryStatus(snapshot)); + order.setGrantedQuantity(BigDecimal.ZERO); + } + order.setUserId(command.getUserId()); + order.setPackageName(snapshot.getPackageName()); + order.setProductId(snapshot.getProductId()); + order.setProductType(snapshot.getProductType()); + order.setPurchaseToken(snapshot.getPurchaseToken()); + order.setOrderKey(snapshot.getOrderKey()); + order.setGoogleOrderId(snapshot.getGoogleOrderId()); + order.setLinkedPurchaseToken(snapshot.getLinkedPurchaseToken()); + order.setOrderState(snapshot.getState()); + order.setAcknowledgementState(snapshot.getAcknowledgementState()); + order.setConsumptionState(snapshot.getConsumptionState()); + order.setQuantity(snapshot.getQuantity()); + order.setRefundableQuantity(snapshot.getRefundableQuantity()); + order.setEntitlementStartTime(snapshot.getStartTime()); + order.setEntitlementEndTime(snapshot.getExpiryTime()); + order.setLastEventTime(command.getEventTime()); + order.setLastSyncedAt(snapshot.getLastSyncedAt()); + order.setRawResponse(snapshot.getRawResponse()); + order.setUpdatedAt(new Date()); + return order; + } + + private GooglePlayPurchaseToken buildToken(GooglePlaySyncCommand command, GooglePlayPurchaseSnapshot snapshot) { + GooglePlayPurchaseToken token = findToken(snapshot.getPurchaseToken()); + if (token == null) { + token = new GooglePlayPurchaseToken(); + token.setCreatedAt(new Date()); + } + token.setPurchaseToken(snapshot.getPurchaseToken()); + token.setLinkedPurchaseToken(snapshot.getLinkedPurchaseToken()); + token.setUserId(command.getUserId()); + token.setPackageName(snapshot.getPackageName()); + token.setProductId(snapshot.getProductId()); + token.setProductType(snapshot.getProductType()); + token.setTokenState(snapshot.getState()); + token.setAcknowledgementState(snapshot.getAcknowledgementState()); + token.setConsumptionState(snapshot.getConsumptionState()); + token.setAutoRenewEnabled(snapshot.getAutoRenewEnabled()); + token.setExternalAccountId(snapshot.getExternalAccountId()); + token.setExternalProfileId(snapshot.getExternalProfileId()); + token.setRegionCode(snapshot.getRegionCode()); + token.setStartTime(snapshot.getStartTime()); + token.setExpiryTime(snapshot.getExpiryTime()); + token.setAutoResumeTime(snapshot.getAutoResumeTime()); + token.setCanceledStateReason(snapshot.getCanceledStateReason()); + token.setLastEventType(command.getNotificationName()); + token.setLastEventTime(command.getEventTime()); + token.setLastSyncedAt(snapshot.getLastSyncedAt()); + token.setRawResponse(snapshot.getRawResponse()); + token.setUpdatedAt(new Date()); + 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 requiresConsume(GooglePlayPurchaseSnapshot snapshot, + GooglePlayUserEntitlement entitlement, + Long userId) { + if (userId == null || entitlement == null) { + return false; + } + boolean oneTime = GooglePlayConstants.PRODUCT_TYPE_ONE_TIME.equals(snapshot.getProductType()); + 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; + } + + private String resolveLinkedToken(GooglePlayPurchaseSnapshot snapshot) { + if (!GooglePlayConstants.STATE_PENDING_PURCHASE_CANCELED.equals(snapshot.getState())) { + return null; + } + return snapshot.getLinkedPurchaseToken(); + } + + private void saveOrder(GooglePlayOrder order) { + if (order.getId() == null) { + orderMapper.insert(order); + return; + } + orderMapper.updateById(order); + } + + private void saveToken(GooglePlayPurchaseToken token) { + if (token.getId() == null) { + purchaseTokenMapper.insert(token); + return; + } + purchaseTokenMapper.updateById(token); + } + + private void updateAckState(String purchaseToken, String orderKey, String state) { + GooglePlayPurchaseToken token = findToken(purchaseToken); + if (token != null) { + token.setAcknowledgementState(state); + token.setUpdatedAt(new Date()); + purchaseTokenMapper.updateById(token); + } + GooglePlayOrder order = orderMapper.selectOne(Wrappers.lambdaQuery() + .eq(GooglePlayOrder::getOrderKey, orderKey) + .last("LIMIT 1")); + if (order == null) { + return; + } + order.setAcknowledgementState(state); + order.setUpdatedAt(new Date()); + orderMapper.updateById(order); + } + + private void updateConsumptionState(String purchaseToken, String orderKey, String state) { + GooglePlayPurchaseToken token = findToken(purchaseToken); + if (token != null) { + token.setConsumptionState(state); + token.setUpdatedAt(new Date()); + purchaseTokenMapper.updateById(token); + } + GooglePlayOrder order = orderMapper.selectOne(Wrappers.lambdaQuery() + .eq(GooglePlayOrder::getOrderKey, orderKey) + .last("LIMIT 1")); + if (order == null) { + return; + } + order.setConsumptionState(state); + order.setUpdatedAt(new Date()); + orderMapper.updateById(order); + } + + private String defaultDeliveryStatus(GooglePlayPurchaseSnapshot snapshot) { + if (GooglePlayConstants.PRODUCT_TYPE_SUBSCRIPTION.equals(snapshot.getProductType())) { + return GooglePlayConstants.DELIVERY_NOT_REQUIRED; + } + return GooglePlayConstants.DELIVERY_PENDING; + } +} diff --git a/src/main/java/com/yolo/keyborad/googleplay/GooglePlayVipBenefitService.java b/src/main/java/com/yolo/keyborad/googleplay/GooglePlayVipBenefitService.java new file mode 100644 index 0000000..888084f --- /dev/null +++ b/src/main/java/com/yolo/keyborad/googleplay/GooglePlayVipBenefitService.java @@ -0,0 +1,45 @@ +package com.yolo.keyborad.googleplay; + +import com.yolo.keyborad.common.ErrorCode; +import com.yolo.keyborad.exception.BusinessException; +import com.yolo.keyborad.model.entity.KeyboardProductItems; +import com.yolo.keyborad.model.entity.KeyboardUser; +import com.yolo.keyborad.service.UserService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.Date; + +@Component +@RequiredArgsConstructor +public class GooglePlayVipBenefitService { + + private final UserService userService; + + public void activate(Long userId, KeyboardProductItems product, Date expiry) { + KeyboardUser user = requireUser(userId); + user.setIsVip(true); + user.setVipLevel(product.getLevel()); + user.setVipExpiry(expiry); + if (!userService.updateById(user)) { + throw new BusinessException(ErrorCode.UPDATE_USER_VIP_STATUS_ERROR); + } + } + + public void deactivate(Long userId, Date expiry) { + KeyboardUser user = requireUser(userId); + user.setIsVip(false); + user.setVipExpiry(expiry); + if (!userService.updateById(user)) { + throw new BusinessException(ErrorCode.UPDATE_USER_VIP_STATUS_ERROR); + } + } + + private KeyboardUser requireUser(Long userId) { + KeyboardUser user = userService.getById(userId); + if (user == null) { + throw new BusinessException(ErrorCode.USER_NOT_FOUND); + } + return user; + } +} diff --git a/src/main/java/com/yolo/keyborad/googleplay/GooglePlayWalletBenefitService.java b/src/main/java/com/yolo/keyborad/googleplay/GooglePlayWalletBenefitService.java new file mode 100644 index 0000000..5bd0ab3 --- /dev/null +++ b/src/main/java/com/yolo/keyborad/googleplay/GooglePlayWalletBenefitService.java @@ -0,0 +1,72 @@ +package com.yolo.keyborad.googleplay; + +import com.yolo.keyborad.model.entity.KeyboardUserWallet; +import com.yolo.keyborad.service.KeyboardUserWalletService; +import com.yolo.keyborad.service.KeyboardWalletTransactionService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.math.BigDecimal; +import java.util.Date; + +@Slf4j +@Component +@RequiredArgsConstructor +public class GooglePlayWalletBenefitService { + + private static final short GOOGLE_PLAY_WALLET_TX_TYPE = 3; + + private final KeyboardUserWalletService walletService; + private final KeyboardWalletTransactionService walletTransactionService; + + public void grant(Long userId, Long orderId, String productId, BigDecimal amount) { + KeyboardUserWallet wallet = getOrCreateWallet(userId); + BigDecimal before = defaultBalance(wallet.getBalance()); + BigDecimal after = before.add(amount); + wallet.setBalance(after); + wallet.setUpdatedAt(new Date()); + walletService.saveOrUpdate(wallet); + walletTransactionService.createTransaction(userId, orderId, amount, GOOGLE_PLAY_WALLET_TX_TYPE, + before, after, "Google Play 充值: " + productId); + } + + public boolean revoke(Long userId, Long orderId, BigDecimal amount) { + KeyboardUserWallet wallet = walletService.lambdaQuery() + .eq(KeyboardUserWallet::getUserId, userId) + .one(); + BigDecimal before = wallet == null ? BigDecimal.ZERO : defaultBalance(wallet.getBalance()); + if (before.compareTo(amount) < 0) { + log.error("Google Play 退款回滚失败,余额不足, userId={}, orderId={}", userId, orderId); + return false; + } + BigDecimal after = before.subtract(amount); + wallet.setBalance(after); + wallet.setUpdatedAt(new Date()); + walletService.updateById(wallet); + walletTransactionService.createTransaction(userId, orderId, amount.negate(), GOOGLE_PLAY_WALLET_TX_TYPE, + before, after, "Google Play 退款回滚"); + return true; + } + + private KeyboardUserWallet getOrCreateWallet(Long userId) { + KeyboardUserWallet wallet = walletService.lambdaQuery() + .eq(KeyboardUserWallet::getUserId, userId) + .one(); + if (wallet != null) { + return wallet; + } + KeyboardUserWallet created = new KeyboardUserWallet(); + created.setUserId(userId); + created.setBalance(BigDecimal.ZERO); + created.setStatus((short) 1); + created.setVersion(0); + created.setCreatedAt(new Date()); + walletService.save(created); + return created; + } + + private BigDecimal defaultBalance(BigDecimal balance) { + return balance == null ? BigDecimal.ZERO : balance; + } +} diff --git a/src/main/java/com/yolo/keyborad/googleplay/model/GooglePlayPurchaseSnapshot.java b/src/main/java/com/yolo/keyborad/googleplay/model/GooglePlayPurchaseSnapshot.java new file mode 100644 index 0000000..6b1c1b4 --- /dev/null +++ b/src/main/java/com/yolo/keyborad/googleplay/model/GooglePlayPurchaseSnapshot.java @@ -0,0 +1,57 @@ +package com.yolo.keyborad.googleplay.model; + +import lombok.Builder; +import lombok.Data; + +import java.util.Date; + +@Data +@Builder(toBuilder = true) +public class GooglePlayPurchaseSnapshot { + + private String packageName; + + private String productId; + + private String productType; + + private String purchaseToken; + + private String orderKey; + + private String googleOrderId; + + private String linkedPurchaseToken; + + private String state; + + private String acknowledgementState; + + private String consumptionState; + + private Integer quantity; + + private Integer refundableQuantity; + + private Boolean autoRenewEnabled; + + private Boolean accessGranted; + + private String externalAccountId; + + private String externalProfileId; + + private String regionCode; + + private String canceledStateReason; + + private Date startTime; + + private Date expiryTime; + + private Date autoResumeTime; + + private Date lastSyncedAt; + + private String rawResponse; +} diff --git a/src/main/java/com/yolo/keyborad/googleplay/model/GooglePlaySyncCommand.java b/src/main/java/com/yolo/keyborad/googleplay/model/GooglePlaySyncCommand.java new file mode 100644 index 0000000..d7a7f84 --- /dev/null +++ b/src/main/java/com/yolo/keyborad/googleplay/model/GooglePlaySyncCommand.java @@ -0,0 +1,35 @@ +package com.yolo.keyborad.googleplay.model; + +import lombok.Builder; +import lombok.Data; + +import java.util.Date; + +@Data +@Builder(toBuilder = true) +public class GooglePlaySyncCommand { + + private Long userId; + + private String packageName; + + private String productId; + + private String productType; + + private String purchaseToken; + + private String source; + + private String eventType; + + private Integer notificationType; + + private String notificationName; + + private String messageId; + + private String subscriptionName; + + private Date eventTime; +} diff --git a/src/main/java/com/yolo/keyborad/googleplay/model/GooglePlaySyncResult.java b/src/main/java/com/yolo/keyborad/googleplay/model/GooglePlaySyncResult.java new file mode 100644 index 0000000..773fb92 --- /dev/null +++ b/src/main/java/com/yolo/keyborad/googleplay/model/GooglePlaySyncResult.java @@ -0,0 +1,24 @@ +package com.yolo.keyborad.googleplay.model; + +import com.yolo.keyborad.model.entity.googleplay.GooglePlayOrder; +import com.yolo.keyborad.model.entity.googleplay.GooglePlayPurchaseToken; +import com.yolo.keyborad.model.entity.googleplay.GooglePlayUserEntitlement; +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +public class GooglePlaySyncResult { + + private GooglePlayOrder order; + + private GooglePlayPurchaseToken token; + + private GooglePlayUserEntitlement entitlement; + + private boolean acknowledgeRequired; + + private boolean consumeRequired; + + private String linkedPurchaseTokenToSync; +} diff --git a/src/main/java/com/yolo/keyborad/mapper/GooglePlayOrderMapper.java b/src/main/java/com/yolo/keyborad/mapper/GooglePlayOrderMapper.java new file mode 100644 index 0000000..7f11fc1 --- /dev/null +++ b/src/main/java/com/yolo/keyborad/mapper/GooglePlayOrderMapper.java @@ -0,0 +1,7 @@ +package com.yolo.keyborad.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.yolo.keyborad.model.entity.googleplay.GooglePlayOrder; + +public interface GooglePlayOrderMapper extends BaseMapper { +} diff --git a/src/main/java/com/yolo/keyborad/mapper/GooglePlayPurchaseTokenMapper.java b/src/main/java/com/yolo/keyborad/mapper/GooglePlayPurchaseTokenMapper.java new file mode 100644 index 0000000..68e4e1d --- /dev/null +++ b/src/main/java/com/yolo/keyborad/mapper/GooglePlayPurchaseTokenMapper.java @@ -0,0 +1,7 @@ +package com.yolo.keyborad.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.yolo.keyborad.model.entity.googleplay.GooglePlayPurchaseToken; + +public interface GooglePlayPurchaseTokenMapper extends BaseMapper { +} diff --git a/src/main/java/com/yolo/keyborad/mapper/GooglePlayRtdnEventMapper.java b/src/main/java/com/yolo/keyborad/mapper/GooglePlayRtdnEventMapper.java new file mode 100644 index 0000000..3d306b8 --- /dev/null +++ b/src/main/java/com/yolo/keyborad/mapper/GooglePlayRtdnEventMapper.java @@ -0,0 +1,7 @@ +package com.yolo.keyborad.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.yolo.keyborad.model.entity.googleplay.GooglePlayRtdnEvent; + +public interface GooglePlayRtdnEventMapper extends BaseMapper { +} diff --git a/src/main/java/com/yolo/keyborad/mapper/GooglePlayUserEntitlementMapper.java b/src/main/java/com/yolo/keyborad/mapper/GooglePlayUserEntitlementMapper.java new file mode 100644 index 0000000..141ee76 --- /dev/null +++ b/src/main/java/com/yolo/keyborad/mapper/GooglePlayUserEntitlementMapper.java @@ -0,0 +1,7 @@ +package com.yolo.keyborad.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.yolo.keyborad.model.entity.googleplay.GooglePlayUserEntitlement; + +public interface GooglePlayUserEntitlementMapper extends BaseMapper { +} diff --git a/src/main/java/com/yolo/keyborad/model/dto/googleplay/GooglePlayPubSubPushRequest.java b/src/main/java/com/yolo/keyborad/model/dto/googleplay/GooglePlayPubSubPushRequest.java new file mode 100644 index 0000000..2d3165d --- /dev/null +++ b/src/main/java/com/yolo/keyborad/model/dto/googleplay/GooglePlayPubSubPushRequest.java @@ -0,0 +1,60 @@ +package com.yolo.keyborad.model.dto.googleplay; + +import lombok.Data; + +import java.util.Map; + +@Data +public class GooglePlayPubSubPushRequest { + + private Message message; + + private String subscription; + + @Data + public static class Message { + private Map attributes; + private String data; + private String messageId; + private String publishTime; + } + + @Data + public static class DeveloperNotification { + private String version; + private String packageName; + private String eventTimeMillis; + private SubscriptionNotification subscriptionNotification; + private OneTimeProductNotification oneTimeProductNotification; + private VoidedPurchaseNotification voidedPurchaseNotification; + private TestNotification testNotification; + } + + @Data + public static class SubscriptionNotification { + private String version; + private Integer notificationType; + private String purchaseToken; + } + + @Data + public static class OneTimeProductNotification { + private String version; + private Integer notificationType; + private String purchaseToken; + private String sku; + } + + @Data + public static class VoidedPurchaseNotification { + private String purchaseToken; + private String orderId; + private Integer productType; + private Integer refundType; + } + + @Data + public static class TestNotification { + private String version; + } +} diff --git a/src/main/java/com/yolo/keyborad/model/dto/googleplay/GooglePlayPurchaseVerifyReq.java b/src/main/java/com/yolo/keyborad/model/dto/googleplay/GooglePlayPurchaseVerifyReq.java new file mode 100644 index 0000000..9e2bdb4 --- /dev/null +++ b/src/main/java/com/yolo/keyborad/model/dto/googleplay/GooglePlayPurchaseVerifyReq.java @@ -0,0 +1,15 @@ +package com.yolo.keyborad.model.dto.googleplay; + +import lombok.Data; + +@Data +public class GooglePlayPurchaseVerifyReq { + + private String packageName; + + private String productId; + + private String productType; + + private String purchaseToken; +} 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 new file mode 100644 index 0000000..e39e40f --- /dev/null +++ b/src/main/java/com/yolo/keyborad/model/entity/googleplay/GooglePlayOrder.java @@ -0,0 +1,84 @@ +package com.yolo.keyborad.model.entity.googleplay; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.math.BigDecimal; +import java.util.Date; + +@Data +@TableName("google_play_order") +public class GooglePlayOrder { + + @TableId(value = "id", type = IdType.AUTO) + private Long id; + + @TableField("user_id") + private Long userId; + + @TableField("package_name") + private String packageName; + + @TableField("product_id") + private String productId; + + @TableField("product_type") + private String productType; + + @TableField("purchase_token") + private String purchaseToken; + + @TableField("order_key") + private String orderKey; + + @TableField("google_order_id") + private String googleOrderId; + + @TableField("linked_purchase_token") + private String linkedPurchaseToken; + + @TableField("order_state") + private String orderState; + + @TableField("acknowledgement_state") + private String acknowledgementState; + + @TableField("consumption_state") + private String consumptionState; + + @TableField("quantity") + private Integer quantity; + + @TableField("refundable_quantity") + private Integer refundableQuantity; + + @TableField("delivery_status") + private String deliveryStatus; + + @TableField("granted_quantity") + private BigDecimal grantedQuantity; + + @TableField("entitlement_start_time") + private Date entitlementStartTime; + + @TableField("entitlement_end_time") + private Date entitlementEndTime; + + @TableField("last_event_time") + private Date lastEventTime; + + @TableField("last_synced_at") + private Date lastSyncedAt; + + @TableField("raw_response") + private String rawResponse; + + @TableField("created_at") + private Date createdAt; + + @TableField("updated_at") + private Date updatedAt; +} diff --git a/src/main/java/com/yolo/keyborad/model/entity/googleplay/GooglePlayPurchaseToken.java b/src/main/java/com/yolo/keyborad/model/entity/googleplay/GooglePlayPurchaseToken.java new file mode 100644 index 0000000..60b04b5 --- /dev/null +++ b/src/main/java/com/yolo/keyborad/model/entity/googleplay/GooglePlayPurchaseToken.java @@ -0,0 +1,92 @@ +package com.yolo.keyborad.model.entity.googleplay; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.util.Date; + +@Data +@TableName("google_play_purchase_token") +public class GooglePlayPurchaseToken { + + @TableId(value = "id", type = IdType.AUTO) + private Long id; + + @TableField("purchase_token") + private String purchaseToken; + + @TableField("linked_purchase_token") + private String linkedPurchaseToken; + + @TableField("user_id") + private Long userId; + + @TableField("package_name") + private String packageName; + + @TableField("product_id") + private String productId; + + @TableField("product_type") + private String productType; + + @TableField("latest_order_key") + private String latestOrderKey; + + @TableField("latest_order_id") + private String latestOrderId; + + @TableField("token_state") + private String tokenState; + + @TableField("acknowledgement_state") + private String acknowledgementState; + + @TableField("consumption_state") + private String consumptionState; + + @TableField("auto_renew_enabled") + private Boolean autoRenewEnabled; + + @TableField("external_account_id") + private String externalAccountId; + + @TableField("external_profile_id") + private String externalProfileId; + + @TableField("region_code") + private String regionCode; + + @TableField("start_time") + private Date startTime; + + @TableField("expiry_time") + private Date expiryTime; + + @TableField("auto_resume_time") + private Date autoResumeTime; + + @TableField("canceled_state_reason") + private String canceledStateReason; + + @TableField("last_event_type") + private String lastEventType; + + @TableField("last_event_time") + private Date lastEventTime; + + @TableField("last_synced_at") + private Date lastSyncedAt; + + @TableField("raw_response") + private String rawResponse; + + @TableField("created_at") + private Date createdAt; + + @TableField("updated_at") + private Date updatedAt; +} diff --git a/src/main/java/com/yolo/keyborad/model/entity/googleplay/GooglePlayRtdnEvent.java b/src/main/java/com/yolo/keyborad/model/entity/googleplay/GooglePlayRtdnEvent.java new file mode 100644 index 0000000..e9442e5 --- /dev/null +++ b/src/main/java/com/yolo/keyborad/model/entity/googleplay/GooglePlayRtdnEvent.java @@ -0,0 +1,71 @@ +package com.yolo.keyborad.model.entity.googleplay; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.util.Date; + +@Data +@TableName("google_play_rtdn_event") +public class GooglePlayRtdnEvent { + + @TableId(value = "id", type = IdType.AUTO) + private Long id; + + @TableField("message_id") + private String messageId; + + @TableField("subscription_name") + private String subscriptionName; + + @TableField("package_name") + private String packageName; + + @TableField("event_type") + private String eventType; + + @TableField("notification_type") + private Integer notificationType; + + @TableField("notification_name") + private String notificationName; + + @TableField("purchase_token") + private String purchaseToken; + + @TableField("product_id") + private String productId; + + @TableField("order_id") + private String orderId; + + @TableField("event_time") + private Date eventTime; + + @TableField("status") + private String status; + + @TableField("retry_count") + private Integer retryCount; + + @TableField("raw_envelope") + private String rawEnvelope; + + @TableField("raw_payload") + private String rawPayload; + + @TableField("error_message") + private String errorMessage; + + @TableField("processed_at") + private Date processedAt; + + @TableField("created_at") + private Date createdAt; + + @TableField("updated_at") + private Date updatedAt; +} diff --git a/src/main/java/com/yolo/keyborad/model/entity/googleplay/GooglePlayUserEntitlement.java b/src/main/java/com/yolo/keyborad/model/entity/googleplay/GooglePlayUserEntitlement.java new file mode 100644 index 0000000..b9dbb80 --- /dev/null +++ b/src/main/java/com/yolo/keyborad/model/entity/googleplay/GooglePlayUserEntitlement.java @@ -0,0 +1,69 @@ +package com.yolo.keyborad.model.entity.googleplay; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.math.BigDecimal; +import java.util.Date; + +@Data +@TableName("google_play_user_entitlement") +public class GooglePlayUserEntitlement { + + @TableId(value = "id", type = IdType.AUTO) + private Long id; + + @TableField("user_id") + private Long userId; + + @TableField("entitlement_key") + private String entitlementKey; + + @TableField("product_id") + private String productId; + + @TableField("product_type") + private String productType; + + @TableField("source_purchase_token") + private String sourcePurchaseToken; + + @TableField("current_order_key") + private String currentOrderKey; + + @TableField("benefit_type") + private String benefitType; + + @TableField("state") + private String state; + + @TableField("active") + private Boolean active; + + @TableField("quantity") + private BigDecimal quantity; + + @TableField("start_time") + private Date startTime; + + @TableField("end_time") + private Date endTime; + + @TableField("last_granted_at") + private Date lastGrantedAt; + + @TableField("last_revoked_at") + private Date lastRevokedAt; + + @TableField("metadata") + private String metadata; + + @TableField("created_at") + private Date createdAt; + + @TableField("updated_at") + private Date updatedAt; +} diff --git a/src/main/java/com/yolo/keyborad/model/vo/googleplay/GooglePlayPurchaseVerifyResp.java b/src/main/java/com/yolo/keyborad/model/vo/googleplay/GooglePlayPurchaseVerifyResp.java new file mode 100644 index 0000000..1e64fe0 --- /dev/null +++ b/src/main/java/com/yolo/keyborad/model/vo/googleplay/GooglePlayPurchaseVerifyResp.java @@ -0,0 +1,35 @@ +package com.yolo.keyborad.model.vo.googleplay; + +import lombok.Data; + +import java.util.Date; + +@Data +public class GooglePlayPurchaseVerifyResp { + + private Long userId; + + private String productId; + + private String productType; + + private String purchaseToken; + + private String orderId; + + private String orderState; + + private String entitlementState; + + private String deliveryStatus; + + private Boolean accessGranted; + + private Boolean acknowledged; + + private Boolean consumed; + + private Date expiryTime; + + private Date lastSyncedAt; +} diff --git a/src/main/java/com/yolo/keyborad/service/GooglePlayBillingService.java b/src/main/java/com/yolo/keyborad/service/GooglePlayBillingService.java new file mode 100644 index 0000000..11285ae --- /dev/null +++ b/src/main/java/com/yolo/keyborad/service/GooglePlayBillingService.java @@ -0,0 +1,13 @@ +package com.yolo.keyborad.service; + +import com.yolo.keyborad.model.dto.googleplay.GooglePlayPubSubPushRequest; +import com.yolo.keyborad.model.dto.googleplay.GooglePlayPurchaseVerifyReq; +import com.yolo.keyborad.model.vo.googleplay.GooglePlayPurchaseVerifyResp; +import jakarta.servlet.http.HttpServletRequest; + +public interface GooglePlayBillingService { + + GooglePlayPurchaseVerifyResp verifyPurchase(Long userId, GooglePlayPurchaseVerifyReq req); + + void handleRtdn(HttpServletRequest request, GooglePlayPubSubPushRequest pushRequest); +} diff --git a/src/main/java/com/yolo/keyborad/service/impl/GooglePlayBillingServiceImpl.java b/src/main/java/com/yolo/keyborad/service/impl/GooglePlayBillingServiceImpl.java new file mode 100644 index 0000000..9405b20 --- /dev/null +++ b/src/main/java/com/yolo/keyborad/service/impl/GooglePlayBillingServiceImpl.java @@ -0,0 +1,293 @@ +package com.yolo.keyborad.service.impl; + +import com.yolo.keyborad.common.ErrorCode; +import com.yolo.keyborad.config.GooglePlayProperties; +import com.yolo.keyborad.exception.BusinessException; +import com.yolo.keyborad.googleplay.GooglePlayApiClient; +import com.yolo.keyborad.googleplay.GooglePlayApiException; +import com.yolo.keyborad.googleplay.GooglePlayConstants; +import com.yolo.keyborad.googleplay.GooglePlayNotificationSupport; +import com.yolo.keyborad.googleplay.GooglePlayPubSubAuthService; +import com.yolo.keyborad.googleplay.GooglePlayRtdnEventService; +import com.yolo.keyborad.googleplay.GooglePlayStateService; +import com.yolo.keyborad.googleplay.model.GooglePlayPurchaseSnapshot; +import com.yolo.keyborad.googleplay.model.GooglePlaySyncCommand; +import com.yolo.keyborad.googleplay.model.GooglePlaySyncResult; +import com.yolo.keyborad.model.dto.googleplay.GooglePlayPubSubPushRequest; +import com.yolo.keyborad.model.dto.googleplay.GooglePlayPurchaseVerifyReq; +import com.yolo.keyborad.model.entity.googleplay.GooglePlayPurchaseToken; +import com.yolo.keyborad.model.entity.googleplay.GooglePlayRtdnEvent; +import com.yolo.keyborad.model.vo.googleplay.GooglePlayPurchaseVerifyResp; +import com.yolo.keyborad.service.GooglePlayBillingService; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.Date; +import java.util.Locale; + +@Slf4j +@Service +@RequiredArgsConstructor +public class GooglePlayBillingServiceImpl implements GooglePlayBillingService { + + private final GooglePlayProperties properties; + private final GooglePlayApiClient apiClient; + private final GooglePlayPubSubAuthService pubSubAuthService; + private final GooglePlayStateService stateService; + private final GooglePlayNotificationSupport notificationSupport; + private final GooglePlayRtdnEventService rtdnEventService; + + @Override + public GooglePlayPurchaseVerifyResp verifyPurchase(Long userId, GooglePlayPurchaseVerifyReq req) { + ensureEnabled(); + validateVerifyRequest(req); + String packageName = resolvePackageName(req.getPackageName()); + String productType = normalizeProductType(req.getProductType()); + GooglePlayPurchaseSnapshot snapshot = fetchSnapshot(packageName, productType, req.getPurchaseToken()); + validateProduct(snapshot, req.getProductId()); + verifyExternalAccount(userId, snapshot); + GooglePlaySyncCommand command = GooglePlaySyncCommand.builder() + .userId(userId) + .packageName(packageName) + .productId(snapshot.getProductId()) + .productType(productType) + .purchaseToken(req.getPurchaseToken()) + .source("CLIENT") + .eventType("CLIENT_VERIFY") + .notificationName("CLIENT_VERIFY") + .eventTime(new Date()) + .build(); + GooglePlaySyncResult result = stateService.sync(command, snapshot); + finalizePurchase(snapshot, result); + return buildResponse(result); + } + + @Override + public void handleRtdn(HttpServletRequest request, GooglePlayPubSubPushRequest pushRequest) { + ensureEnabled(); + validatePushRequest(pushRequest); + pubSubAuthService.verify(request, pushRequest); + GooglePlayPubSubPushRequest.DeveloperNotification notification = notificationSupport.decode(pushRequest); + GooglePlayRtdnEvent event = rtdnEventService.upsertEvent(pushRequest, notification); + if (GooglePlayConstants.EVENT_PROCESSED.equals(event.getStatus())) { + return; + } + if (notification.getTestNotification() != null) { + rtdnEventService.markIgnored(event); + return; + } + GooglePlaySyncCommand command = notificationSupport.buildCommand( + pushRequest, + notification, + resolvePackageName(notification.getPackageName()) + ); + try { + GooglePlayPurchaseSnapshot snapshot = fetchSnapshotForNotification(command); + GooglePlayPurchaseSnapshot effectiveSnapshot = applyVoidedStateIfNeeded(snapshot, notification); + Long userId = resolveUserId(command.getPurchaseToken(), effectiveSnapshot); + GooglePlaySyncResult result = stateService.sync(command.toBuilder().userId(userId).build(), effectiveSnapshot); + finalizePurchase(effectiveSnapshot, result); + syncLinkedTokenIfNeeded(command, result, effectiveSnapshot); + rtdnEventService.markProcessed(event); + } catch (Exception e) { + rtdnEventService.markFailed(event, e); + throw e; + } + } + + private void ensureEnabled() { + if (!properties.isEnabled()) { + throw new BusinessException(ErrorCode.GOOGLE_PLAY_NOT_ENABLED); + } + } + + private void validateVerifyRequest(GooglePlayPurchaseVerifyReq req) { + if (req == null || req.getPurchaseToken() == null || req.getPurchaseToken().isBlank()) { + throw new BusinessException(ErrorCode.PARAMS_ERROR, "purchaseToken 不能为空"); + } + if (req.getProductType() == null || req.getProductType().isBlank()) { + throw new BusinessException(ErrorCode.PARAMS_ERROR, "productType 不能为空"); + } + } + + private void validatePushRequest(GooglePlayPubSubPushRequest pushRequest) { + if (pushRequest == null || pushRequest.getMessage() == null) { + throw new BusinessException(ErrorCode.PARAMS_ERROR, "Pub/Sub body 不能为空"); + } + if (pushRequest.getMessage().getMessageId() == null || pushRequest.getMessage().getMessageId().isBlank()) { + throw new BusinessException(ErrorCode.PARAMS_ERROR, "messageId 不能为空"); + } + if (pushRequest.getMessage().getData() == null || pushRequest.getMessage().getData().isBlank()) { + throw new BusinessException(ErrorCode.PARAMS_ERROR, "data 不能为空"); + } + } + + private GooglePlayPurchaseSnapshot fetchSnapshot(String packageName, String productType, String purchaseToken) { + if (GooglePlayConstants.PRODUCT_TYPE_SUBSCRIPTION.equals(productType)) { + return apiClient.getSubscription(packageName, purchaseToken); + } + return apiClient.getOneTimeProduct(packageName, purchaseToken); + } + + private GooglePlayPurchaseSnapshot fetchSnapshotForNotification(GooglePlaySyncCommand command) { + try { + return fetchSnapshot(command.getPackageName(), command.getProductType(), command.getPurchaseToken()); + } catch (GooglePlayApiException e) { + if (e.getStatusCode() != 404 || !GooglePlayConstants.EVENT_TYPE_VOIDED.equals(command.getEventType())) { + throw e; + } + GooglePlayPurchaseToken localToken = stateService.findToken(command.getPurchaseToken()); + return GooglePlayPurchaseSnapshot.builder() + .packageName(command.getPackageName()) + .productId(localToken == null ? command.getProductId() : localToken.getProductId()) + .productType(localToken == null ? command.getProductType() : localToken.getProductType()) + .purchaseToken(command.getPurchaseToken()) + .orderKey(localToken == null || localToken.getLatestOrderKey() == null + ? "TOKEN:" + command.getPurchaseToken() + : localToken.getLatestOrderKey()) + .googleOrderId(localToken == null ? null : localToken.getLatestOrderId()) + .state(GooglePlayConstants.STATE_UNKNOWN) + .acknowledgementState(GooglePlayConstants.ACK_PENDING) + .consumptionState(GooglePlayConstants.CONSUMPTION_PENDING) + .accessGranted(false) + .externalAccountId(localToken == null ? null : localToken.getExternalAccountId()) + .lastSyncedAt(new Date()) + .rawResponse("{}") + .build(); + } + } + + private void validateProduct(GooglePlayPurchaseSnapshot snapshot, String requestProductId) { + if (requestProductId == null || requestProductId.isBlank()) { + return; + } + if (!requestProductId.equals(snapshot.getProductId())) { + throw new BusinessException(ErrorCode.GOOGLE_PLAY_PURCHASE_MISMATCH, "productId 与 Google 返回不一致"); + } + } + + private void verifyExternalAccount(Long userId, GooglePlayPurchaseSnapshot snapshot) { + String externalAccountId = snapshot.getExternalAccountId(); + if ((externalAccountId == null || externalAccountId.isBlank()) && properties.isRequireObfuscatedAccountId()) { + throw new BusinessException(ErrorCode.GOOGLE_PLAY_PURCHASE_MISMATCH, "缺少 obfuscatedExternalAccountId"); + } + if (externalAccountId == null || externalAccountId.isBlank()) { + return; + } + if (!String.valueOf(userId).equals(externalAccountId)) { + throw new BusinessException(ErrorCode.GOOGLE_PLAY_PURCHASE_MISMATCH, "购买用户与当前登录用户不一致"); + } + } + + private GooglePlayPurchaseVerifyResp buildResponse(GooglePlaySyncResult result) { + GooglePlayPurchaseVerifyResp resp = new GooglePlayPurchaseVerifyResp(); + resp.setUserId(result.getOrder().getUserId()); + resp.setProductId(result.getOrder().getProductId()); + resp.setProductType(result.getOrder().getProductType()); + resp.setPurchaseToken(result.getOrder().getPurchaseToken()); + resp.setOrderId(result.getOrder().getGoogleOrderId()); + resp.setOrderState(result.getOrder().getOrderState()); + resp.setEntitlementState(result.getEntitlement() == null ? null : result.getEntitlement().getState()); + resp.setDeliveryStatus(result.getOrder().getDeliveryStatus()); + resp.setAccessGranted(result.getEntitlement() != null && Boolean.TRUE.equals(result.getEntitlement().getActive())); + resp.setAcknowledged(GooglePlayConstants.ACK_ACKNOWLEDGED.equals(result.getOrder().getAcknowledgementState())); + resp.setConsumed(GooglePlayConstants.CONSUMPTION_CONSUMED.equals(result.getOrder().getConsumptionState())); + resp.setExpiryTime(result.getOrder().getEntitlementEndTime()); + resp.setLastSyncedAt(result.getOrder().getLastSyncedAt()); + return resp; + } + + private GooglePlayPurchaseSnapshot applyVoidedStateIfNeeded(GooglePlayPurchaseSnapshot snapshot, + GooglePlayPubSubPushRequest.DeveloperNotification notification) { + if (notification.getVoidedPurchaseNotification() == null) { + return snapshot; + } + String productType = notificationSupport.normalizeVoidedProductType( + notification.getVoidedPurchaseNotification().getProductType() + ); + String state = GooglePlayConstants.PRODUCT_TYPE_SUBSCRIPTION.equals(productType) + ? GooglePlayConstants.STATE_REVOKED + : GooglePlayConstants.STATE_REFUNDED; + return snapshot.toBuilder().state(state).accessGranted(false).build(); + } + + private void finalizePurchase(GooglePlayPurchaseSnapshot snapshot, GooglePlaySyncResult result) { + if (result.isConsumeRequired()) { + try { + apiClient.consumeProduct(snapshot.getPackageName(), snapshot.getProductId(), snapshot.getPurchaseToken()); + stateService.markAcknowledged(snapshot.getPurchaseToken(), snapshot.getOrderKey()); + stateService.markConsumed(snapshot.getPurchaseToken(), snapshot.getOrderKey()); + } catch (Exception e) { + log.warn("Google Play consume retry pending, orderKey={}", snapshot.getOrderKey(), e); + } + return; + } + if (!result.isAcknowledgeRequired()) { + return; + } + try { + acknowledge(snapshot); + stateService.markAcknowledged(snapshot.getPurchaseToken(), snapshot.getOrderKey()); + } catch (Exception e) { + log.warn("Google Play acknowledge retry pending, orderKey={}", snapshot.getOrderKey(), e); + } + } + + private void acknowledge(GooglePlayPurchaseSnapshot snapshot) { + if (GooglePlayConstants.PRODUCT_TYPE_SUBSCRIPTION.equals(snapshot.getProductType())) { + apiClient.acknowledgeSubscription(snapshot.getPackageName(), snapshot.getProductId(), snapshot.getPurchaseToken()); + return; + } + apiClient.acknowledgeProduct(snapshot.getPackageName(), snapshot.getProductId(), snapshot.getPurchaseToken()); + } + + private void syncLinkedTokenIfNeeded(GooglePlaySyncCommand command, + GooglePlaySyncResult result, + GooglePlayPurchaseSnapshot snapshot) { + if (result.getLinkedPurchaseTokenToSync() == null || result.getLinkedPurchaseTokenToSync().isBlank()) { + return; + } + GooglePlayPurchaseSnapshot linkedSnapshot = apiClient.getSubscription(command.getPackageName(), result.getLinkedPurchaseTokenToSync()); + GooglePlaySyncCommand linkedCommand = command.toBuilder() + .purchaseToken(result.getLinkedPurchaseTokenToSync()) + .productType(GooglePlayConstants.PRODUCT_TYPE_SUBSCRIPTION) + .productId(linkedSnapshot.getProductId()) + .userId(resolveUserId(result.getLinkedPurchaseTokenToSync(), linkedSnapshot)) + .notificationName("LINKED_TOKEN_SYNC") + .build(); + GooglePlaySyncResult linkedResult = stateService.sync(linkedCommand, linkedSnapshot); + finalizePurchase(linkedSnapshot, linkedResult); + } + + private Long resolveUserId(String purchaseToken, GooglePlayPurchaseSnapshot snapshot) { + GooglePlayPurchaseToken localToken = stateService.findToken(purchaseToken); + if (localToken != null && localToken.getUserId() != null) { + return localToken.getUserId(); + } + String externalAccountId = snapshot.getExternalAccountId(); + if (externalAccountId == null || !externalAccountId.matches("\\d+")) { + return null; + } + return Long.parseLong(externalAccountId); + } + + private String resolvePackageName(String packageName) { + if (packageName != null && !packageName.isBlank()) { + return packageName; + } + if (properties.getPackageName() == null || properties.getPackageName().isBlank()) { + throw new BusinessException(ErrorCode.GOOGLE_PLAY_PACKAGE_NAME_MISSING); + } + return properties.getPackageName(); + } + + private String normalizeProductType(String rawType) { + String lowerCase = rawType.toLowerCase(Locale.ROOT); + if (lowerCase.contains("sub")) { + return GooglePlayConstants.PRODUCT_TYPE_SUBSCRIPTION; + } + return GooglePlayConstants.PRODUCT_TYPE_ONE_TIME; + } +} diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index abe5162..f0a33be 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -52,6 +52,21 @@ apple: - "classpath:AppleRootCA-G2.cer" - "classpath:AppleRootCA-G3.cer" +google: + play: + enabled: false + package-name: "com.boshan.key.of.love" + service-account-key-path: "classpath:keyboard-490601-ee503a425cc4.json" + oauth-token-uri: "https://oauth2.googleapis.com/token" + android-publisher-scope: "https://www.googleapis.com/auth/androidpublisher" + pubsub-token-info-uri: "https://oauth2.googleapis.com/tokeninfo" + validate-pubsub-jwt: true + require-obfuscated-account-id: true + pubsub: + expected-topic: "projects/keyboard-490601/topics/keyboard_topic" + expected-subscription: "projects/keyboard-490601/subscriptions/keyboard_topic-sub" + service-account-email: "id-220@keyboard-490601.iam.gserviceaccount.com" + dromara: x-file-storage: #文件存储配置 default-platform: cloudflare-r2 #默认使用的存储平台 @@ -112,4 +127,4 @@ deepgram: model: nova-2 language: en smart-format: true - punctuate: true \ No newline at end of file + punctuate: true diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 8583eb9..7ccbea1 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -50,6 +50,21 @@ apple: - "classpath:AppleRootCA-G2.cer" - "classpath:AppleRootCA-G3.cer" +google: + play: + enabled: true + package-name: "com.boshan.key.of.love" + service-account-key-path: "classpath:keyboard-490601-ee503a425cc4.json" + oauth-token-uri: "https://oauth2.googleapis.com/token" + android-publisher-scope: "https://www.googleapis.com/auth/androidpublisher" + pubsub-token-info-uri: "https://oauth2.googleapis.com/tokeninfo" + validate-pubsub-jwt: true + require-obfuscated-account-id: true + pubsub: + expected-topic: "projects/keyboard-490601/topics/keyboard_topic" + expected-subscription: "projects/keyboard-490601/subscriptions/keyboard_topic-sub" + service-account-email: "id-220@keyboard-490601.iam.gserviceaccount.com" + nacos: config: server-addr: 127.0.0.1:8848 diff --git a/src/main/resources/keyboard-490601-ee503a425cc4.json b/src/main/resources/keyboard-490601-ee503a425cc4.json new file mode 100644 index 0000000..5d09ff8 --- /dev/null +++ b/src/main/resources/keyboard-490601-ee503a425cc4.json @@ -0,0 +1,13 @@ +{ + "type": "service_account", + "project_id": "keyboard-490601", + "private_key_id": "ee503a425cc4eca9024352f5f588a93b2280c2d6", + "private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCezXyzVJQht9FE\n/Ghrf7uo45kzSL5UrznBYN7k85gTca997kNKzdj5vb0Y6DMwN9SXBjA9D8GQVLcM\nt4WhqIC26/T1UPDkXT7Zwk+3o/doYXeg0RIKFWN0D528lWxR4qafE0xz31HxcYpR\nmo1n7gpM1LKCHBEdylamsGUYZCJjE4UvSOZGUMiJRRponf/xLxNC8nwIEpygEfUr\n1QGnVe84jfLhnFCSkxzQNLkbDf7i2fWyI0HK1hM/r4SMCJuWV8Z9JqzpijOnPWBt\nc4VMOmHpWZKxkHL91K5nx4yedrqaMctHMvQD7hcutWVxM4aTzCQQ1477u1EN8Ud1\n5xBg1zS1AgMBAAECggEAD9+sgmSG9iPguEueyHgZMxWlH7o6xE5LsKfVP/+ViQQJ\nLcZeVaDj+nrb5xx22XALQRluQvxLNfkx3wSNSA6G50II12jC26DygmPpAgtS1M1B\nXwLnEbj3mwdglhQ9oqXMUARm1QJSt5bI48VWMzhZMTrlqRnTIC40oS7qvBhuU4bc\nrNjr49y+THCs34eBRgbyQl5JeZoOsO1iShkJsIwRPq6nUcYoXOS0UvgeJSNnyyW2\nyERPSKsvWtCCNkRFCM898axE7pbfxHXbOq4I507nrOqj8fc0+WeDA4ipEse/amlm\ngyQsB3ossHeEaRtSMiX/iJL+ogkQHAcB624UO75TYQKBgQDN4teFg4xDr8XlDg2n\noAXFz1NvC8pvfPusJD5x0+9yp4HE7DfifvleDdwTCrnw/09jR/S57UmLm1fJ5Vg4\nOISBfjhzrM87Y+ziyARTMmpVcji3qvxxhTJE3/z1lT64to+HiYhoC1aIkKixzGun\nkySjqVWwa1l16+iRU6y5ep3R/QKBgQDFdMmCVlcsIqbmQHvqNqhpf1LGzkQtzU7S\nmr47+wjxJWRC4z3QyR2B92+fQROAA5rAwxSjuphKmiYPV4mIaMueruP2JVSISpj+\nHR62F8nB/VlUJNGOUtO2WyNP+Zg1s00x3G7UM29+4gmKjF4TO0ODbhdBqiWDjjyH\n7Sj2gDVvGQKBgGZhD8T/piceZ+y/8UBSjaxQrW+B0HdiEhAGsdqOhfpgm2IeCKgj\ndcM0ZyQ86DuT6Zk39dTizviSFbR6zESgrhtqdY3n9+DTjr8ysRvh7QhyVQvYBdI5\nZsbjDvnb7iWR+otuc5sxUCV2sbxAJ6RbwhN0J/0jVIgT+EET98b/1yzNAoGAeant\nI8cJbWNojQT8lSLagC54lZCwh7wyLig0wQZ7ywIsFd6ozwWsjdElUO4rEryl1NIe\n3IdzoLv8aYWZc5iGph7CzX7Q6C12uVS+AJsFsObm7KbHDDfSxVDAoF19QNFa5jcZ\nfty2fWrPUDQPHzBr+AaUg9xPwxCYEXS6wa/bvLkCgYEAsZXYI4u53tpawcqazYzZ\nitFqEXvKbTuFneVhqOtbLIo2Iqr9AmfPo2YfUjO2QJVQDd+a1BQNFnnHkZoO4Sj1\nXynMcA6hu81qi4IZ9mQJq6F7BSpDddQRssgYVjqlYaV4V4+4Bwj3OJLOHSl33nV9\n4t67bLfWuMHCLQWIqnGrRhY=\n-----END PRIVATE KEY-----\n", + "client_email": "id-220@keyboard-490601.iam.gserviceaccount.com", + "client_id": "103246842098216114468", + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://oauth2.googleapis.com/token", + "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", + "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/id-220%40keyboard-490601.iam.gserviceaccount.com", + "universe_domain": "googleapis.com" +}