feat: B1 Google Play Billing 票据校验
- 新增 GooglePlayVerifier(调 Google Play Developer API 二次校验票据) - StoreVerifier.verify 扩展 productId 参数,订单/控制器链路同步 - DisabledStoreVerifier 加条件装配,Google 启用后自动切换 - application.yml 增加 pay.google 配置(凭证经环境变量注入,默认关闭)
This commit is contained in:
1 parent
bbf46b3445
commit
f03fcdede5
7 files changed
+121
-5
No files matched your search
+3
-1
@@ -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:
|
||||
|
||||
@@ -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>
|
||||
+6
-1
@@ -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 商店校验尚未配置,第一阶段仅预留接口");
|
||||
}
|
||||
|
||||
+85
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -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());
|
||||
}
|
||||
|
||||
+8
-1
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user