From f03fcdede52e34c9c18bd1fee60ecba9de0a089f Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Sat, 17 Oct 2026 02:35:36 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20B1=20Google=20Play=20Billing=20?= =?UTF-8?q?=E7=A5=A8=E6=8D=AE=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 GooglePlayVerifier(调 Google Play Developer API 二次校验票据) - StoreVerifier.verify 扩展 productId 参数,订单/控制器链路同步 - DisabledStoreVerifier 加条件装配,Google 启用后自动切换 - application.yml 增加 pay.google 配置(凭证经环境变量注入,默认关闭) --- .../app/controller/AppCommerceController.java | 4 +- .../src/main/resources/application.yml | 6 ++ fruitshorts-module-commerce/pom.xml | 11 +++ .../service/DisabledStoreVerifier.java | 7 +- .../commerce/service/GooglePlayVerifier.java | 85 +++++++++++++++++++ .../commerce/service/OrderService.java | 4 +- .../commerce/service/StoreVerifier.java | 9 +- 7 files changed, 121 insertions(+), 5 deletions(-) create mode 100644 fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/service/GooglePlayVerifier.java diff --git a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppCommerceController.java b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppCommerceController.java index 9ffbd13..a5e3283 100644 --- a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppCommerceController.java +++ b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppCommerceController.java @@ -67,6 +67,8 @@ public class AppCommerceController { @PostMapping("/orders/verify") public Result verify(@RequestBody Map body) { return Result.ok(orderService.verifyAndDeliver(SecurityUtils.getAppUserId(), - body.getOrDefault("platform", ""), body.getOrDefault("receipt", ""))); + body.getOrDefault("platform", ""), + body.getOrDefault("productId", ""), + body.getOrDefault("receipt", ""))); } } diff --git a/fruitshorts-app-api/src/main/resources/application.yml b/fruitshorts-app-api/src/main/resources/application.yml index 71f58aa..54c49ed 100644 --- a/fruitshorts-app-api/src/main/resources/application.yml +++ b/fruitshorts-app-api/src/main/resources/application.yml @@ -57,6 +57,12 @@ fruitshorts: admin-ttl-hours: 12 cors: allowed-origins: "*" + pay: + google: + # 接入 Google Play Billing 后置 true,并注入服务账号 JSON + enabled: false + package-name: com.nontonki + service-account-json: ${GOOGLE_PLAY_SERVICE_ACCOUNT_JSON:} logging: level: diff --git a/fruitshorts-module-commerce/pom.xml b/fruitshorts-module-commerce/pom.xml index e7ac890..4070f51 100644 --- a/fruitshorts-module-commerce/pom.xml +++ b/fruitshorts-module-commerce/pom.xml @@ -34,5 +34,16 @@ com.fruitshorts fruitshorts-module-playback + + + com.google.apis + google-api-services-androidpublisher + v3-rev20231030-2.0.0 + + + com.google.auth + google-auth-library-oauth2-http + 1.23.0 + diff --git a/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/service/DisabledStoreVerifier.java b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/service/DisabledStoreVerifier.java index 3b59a92..f6d3e79 100644 --- a/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/service/DisabledStoreVerifier.java +++ b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/service/DisabledStoreVerifier.java @@ -4,17 +4,22 @@ package com.fruitshorts.commerce.service; import com.fruitshorts.common.exception.ErrorCode; import com.fruitshorts.common.exception.ServiceException; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Component; /** * 未接入商店时的默认实现:明确报"未配置",而不是静默放行或假装成功。 * 这样客户端拿到的是可理解的业务提示,也不会产生假订单。 + * + * Google Play 校验启用后(fruitshorts.pay.google.enabled=true)自动失效。 */ @Component +@ConditionalOnProperty(name = "fruitshorts.pay.google.enabled", havingValue = "false", + matchIfMissing = true) public class DisabledStoreVerifier implements StoreVerifier { @Override - public StoreReceiptResult verify(String platform, String receipt) { + public StoreReceiptResult verify(String platform, String productId, String receipt) { throw new ServiceException(ErrorCode.OPERATION_NOT_ALLOWED, "Apple / Google 商店校验尚未配置,第一阶段仅预留接口"); } diff --git a/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/service/GooglePlayVerifier.java b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/service/GooglePlayVerifier.java new file mode 100644 index 0000000..1e195ff --- /dev/null +++ b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/service/GooglePlayVerifier.java @@ -0,0 +1,85 @@ +// Author: fruit.com.cn + +package com.fruitshorts.commerce.service; + +import com.google.api.client.googleapis.javanet.GoogleNetHttpTransport; +import com.google.api.client.json.gson.GsonFactory; +import com.google.api.services.androidpublisher.AndroidPublisher; +import com.google.api.services.androidpublisher.model.ProductPurchase; +import com.google.auth.http.HttpCredentialsAdapter; +import com.google.auth.oauth2.GoogleCredentials; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; + +/** + * Google Play Billing 票据校验(B1)。 + * + * 通过 Google Play Developer API 的 purchases.products.get 二次校验购买票据, + * 不信任客户端上报。凭证(服务账号 JSON)与包名通过配置注入,未启用时走 + * DisabledStoreVerifier。 + */ +@Slf4j +@Component +@ConditionalOnProperty(name = "fruitshorts.pay.google.enabled", havingValue = "true") +public class GooglePlayVerifier implements StoreVerifier { + + private static final String SCOPE = "https://www.googleapis.com/auth/androidpublisher"; + + private final String packageName; + private final AndroidPublisher publisher; + + public GooglePlayVerifier( + @Value("${fruitshorts.pay.google.package-name:}") String packageName, + @Value("${fruitshorts.pay.google.service-account-json:}") String serviceAccountJson) { + this.packageName = packageName; + this.publisher = buildPublisher(serviceAccountJson); + } + + @Override + public StoreReceiptResult verify(String platform, String productId, String receipt) { + if (!"GOOGLE".equalsIgnoreCase(platform)) { + return StoreReceiptResult.failed("不支持的平台:" + platform); + } + try { + AndroidPublisher.Purchases.Products.Get get = + publisher.purchases().products().get(packageName, productId, receipt); + ProductPurchase purchase = get.execute(); + Integer purchaseState = purchase.getPurchaseState(); + // 0 = 已购买;1 = 已取消;2 = 待处理 + if (purchaseState == null || purchaseState != 0) { + return StoreReceiptResult.failed("购买状态异常:purchaseState=" + purchaseState); + } + String orderId = purchase.getOrderId(); + if (orderId == null || orderId.isEmpty()) { + return StoreReceiptResult.failed("票据缺少订单号"); + } + return new StoreReceiptResult(true, orderId, orderId, productId, "ok"); + } catch (Exception e) { + log.warn("Google Play 票据校验失败:{}", e.getMessage()); + return StoreReceiptResult.failed("票据校验失败:" + e.getMessage()); + } + } + + private AndroidPublisher buildPublisher(String serviceAccountJson) { + try { + GoogleCredentials credentials = GoogleCredentials.fromStream( + new ByteArrayInputStream( + serviceAccountJson.getBytes(StandardCharsets.UTF_8))) + .createScoped(SCOPE); + return new AndroidPublisher.Builder( + GoogleNetHttpTransport.newTrustedTransport(), + GsonFactory.getDefaultInstance(), + new HttpCredentialsAdapter(credentials)) + .setApplicationName("FruitShorts") + .build(); + } catch (Exception e) { + throw new IllegalStateException( + "初始化 Google Play 校验失败:" + e.getMessage(), e); + } + } +} diff --git a/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/service/OrderService.java b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/service/OrderService.java index 65c54df..35c2e6b 100644 --- a/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/service/OrderService.java +++ b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/service/OrderService.java @@ -85,8 +85,8 @@ public class OrderService { * 宁可不收钱,也不能在支付链路上给出"看起来成功"的假象。 */ @Transactional(rollbackFor = Exception.class) - public Orders verifyAndDeliver(Long userId, String platform, String receipt) { - StoreVerifier.StoreReceiptResult result = storeVerifier.verify(platform, receipt); + public Orders verifyAndDeliver(Long userId, String platform, String productId, String receipt) { + StoreVerifier.StoreReceiptResult result = storeVerifier.verify(platform, productId, receipt); if (!result.ok()) { throw new ServiceException(ErrorCode.OPERATION_NOT_ALLOWED, result.message()); } diff --git a/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/service/StoreVerifier.java b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/service/StoreVerifier.java index 1792b31..be30e09 100644 --- a/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/service/StoreVerifier.java +++ b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/service/StoreVerifier.java @@ -11,7 +11,14 @@ package com.fruitshorts.commerce.service; */ public interface StoreVerifier { - StoreReceiptResult verify(String platform, String receipt); + /** + * 校验商店票据。 + * + * @param platform 平台标识(GOOGLE / APPLE) + * @param productId 商品 ID(Google Play 校验必需,Apple 可为空) + * @param receipt 票据:Google 为 purchaseToken,Apple 为 receipt data + */ + StoreReceiptResult verify(String platform, String productId, String receipt); /** 校验结果。ok=false 时 message 说明原因,用于人工排查。 */ record StoreReceiptResult(boolean ok, String transactionId, String originalTransactionId,