feat: B1 Google Play Billing 票据校验

- 新增 GooglePlayVerifier(调 Google Play Developer API 二次校验票据)
- StoreVerifier.verify 扩展 productId 参数,订单/控制器链路同步
- DisabledStoreVerifier 加条件装配,Google 启用后自动切换
- application.yml 增加 pay.google 配置(凭证经环境变量注入,默认关闭)
This commit is contained in:
Zhou Weidong committed 2026-10-17 02:35:36 +08:00
1 parent bbf46b3445
commit f03fcdede5
7 files changed
+121 -5

No files matched your search

@@ -67,6 +67,8 @@ public class AppCommerceController {
@PostMapping("/orders/verify")
public Result<Orders> verify(@RequestBody Map<String, String> body) {
return Result.ok(orderService.verifyAndDeliver(SecurityUtils.getAppUserId(),
body.getOrDefault("platform", ""), body.getOrDefault("receipt", "")));
body.getOrDefault("platform", ""),
body.getOrDefault("productId", ""),
body.getOrDefault("receipt", "")));
}
}
@@ -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:
+11
View File
@@ -34,5 +34,16 @@
<groupId>com.fruitshorts</groupId>
<artifactId>fruitshorts-module-playback</artifactId>
</dependency>
<!-- Google Play Billing 票据校验 -->
<dependency>
<groupId>com.google.apis</groupId>
<artifactId>google-api-services-androidpublisher</artifactId>
<version>v3-rev20231030-2.0.0</version>
</dependency>
<dependency>
<groupId>com.google.auth</groupId>
<artifactId>google-auth-library-oauth2-http</artifactId>
<version>1.23.0</version>
</dependency>
</dependencies>
</project>
@@ -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 商店校验尚未配置,第一阶段仅预留接口");
}
@@ -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);
}
}
}
@@ -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());
}
@@ -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,