feat(commerce): Google Play 票据校验闭环(校验→建单→发权益 + 幂等/防重放)

为什么:风险清单 R-03 把支付票据校验列为上线阻塞项;/app/orders/verify 此前只有 disabled
占位,且 verifyAndDeliver 要求订单事先存在——而 IAP 没有下单接口,接了校验也走不通。

- 新增 GooglePlayStoreVerifier(校验 purchaseState/productId/orderId)+ StorePlatforms(平台归一化)
- 新增 GooglePlayPurchaseClient 端口与 HttpGooglePlayPurchaseClient:JDK HttpClient + 服务账号
  RS256 JWT 换 token,不引入 Google SDK 依赖;token 按过期缓存
- StoreVerifier 实现由 fruitshorts.commerce.store.verifier 选择,默认 disabled,无凭据时对外行为不变
- OrderService 补齐闭环:校验通过后按 (platform, storeProductId) 找商品建单、写 store_receipt 审计、
  发金币并推进到 DELIVERED;幂等键 (platform, storeTransactionId) 与唯一索引双保险
- 防重放:重复提交同用户返回既有订单且不再发币;票据已被他人使用返回 403;校验失败不写任何数据
- 凭据只走配置/环境变量(packageName + service account JSON),不入库不提交
- 契约同步 docs/api-phase1.md §1.8;金额微单位换算与回退价有单测覆盖
- 实测:mvn test 54 条全过(commerce 13 + app-api 41,M2 的 37 条未被打红);
  两个入口 package 通过;默认 disabled 端到端返回 200003 且 orders 表零行
This commit is contained in:
Zhou Weidong committed 2026-10-16 11:03:33 +08:00
1 parent 27e689c771
commit db897d50d6
15 files changed
+1187 -24

No files matched your search

@@ -0,0 +1,36 @@
// Author: fruit.com.cn
package com.fruitshorts.commerce.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
/**
* 商店票据校验配置。
*
* <p>凭据只从配置/环境变量读,**不入库、不提交到仓库、不打进镜像**:
* <pre>
* fruitshorts.commerce.store.verifier: disabled # disabled(默认)| google-play
* fruitshorts.commerce.store.google-play-package-name: com.stormg.xxx
* fruitshorts.commerce.store.google-play-service-account-json: /etc/fruitshorts/play-sa.json
* </pre>
* `google-play-service-account-json` 支持两种写法:文件路径,或直接给 JSON 内容/base64(便于放环境变量)。
*/
@Data
@Component
@ConfigurationProperties(prefix = "fruitshorts.commerce.store")
public class StoreProperties {
/** 校验实现:disabled = 明确报"未配置"(默认);google-play = 接 Google Play Developer API。 */
private String verifier = "disabled";
/** Google Play 应用的 packageName(例如 com.stormg.fruitshorts)。 */
private String googlePlayPackageName = "";
/** 服务账号 JSON:文件路径,或 JSON 内容,或 base64 编码的 JSON。 */
private String googlePlayServiceAccountJson = "";
/** 调用 Google 接口的超时(秒)。 */
private int googlePlayTimeoutSeconds = 10;
}
@@ -4,17 +4,23 @@ 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;
/**
* 未接入商店时的默认实现:明确报"未配置",而不是静默放行或假装成功。
* 这样客户端拿到的是可理解的业务提示,也不会产生假订单。
*
* <p>由 `fruitshorts.commerce.store.verifier` 选择:缺省(或写成 disabled)时用本实现,
* 保证没有凭据的环境下对外行为与第一阶段一致。
*/
@Component
@ConditionalOnProperty(name = "fruitshorts.commerce.store.verifier", havingValue = "disabled",
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 商店校验尚未配置,第一阶段仅预留接口");
}
@@ -7,20 +7,30 @@ import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.fruitshorts.commerce.dal.entity.OrderEvent;
import com.fruitshorts.commerce.dal.entity.Orders;
import com.fruitshorts.commerce.dal.entity.Product;
import com.fruitshorts.commerce.dal.entity.StoreReceipt;
import com.fruitshorts.commerce.dal.mapper.OrderEventMapper;
import com.fruitshorts.commerce.dal.mapper.OrdersMapper;
import com.fruitshorts.commerce.dal.mapper.ProductMapper;
import com.fruitshorts.commerce.dal.mapper.StoreReceiptMapper;
import com.fruitshorts.commerce.service.store.StorePlatforms;
import com.fruitshorts.commerce.vo.OrderQueryReqVO;
import com.fruitshorts.common.exception.ErrorCode;
import com.fruitshorts.common.exception.ServiceException;
import com.fruitshorts.common.pojo.PageResult;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.concurrent.ThreadLocalRandom;
/**
* 订单:检索、补发、退款回收。
@@ -39,8 +49,13 @@ public class OrderService {
public static final String STATUS_REFUNDED = "REFUNDED";
public static final String STATUS_CLOSED = "CLOSED";
private static final String RECEIPT_OK = "OK";
private static final DateTimeFormatter ORDER_NO_TIME = DateTimeFormatter.ofPattern("yyyyMMddHHmmss");
private final OrdersMapper ordersMapper;
private final OrderEventMapper orderEventMapper;
private final ProductMapper productMapper;
private final StoreReceiptMapper storeReceiptMapper;
private final StoreVerifier storeVerifier;
private final WalletService walletService;
private final EntitlementService entitlementService;
@@ -80,24 +95,173 @@ public class OrderService {
}
/**
* 客户端提交商店票据。
* 商店未接入时 StoreVerifier 会抛出明确异常,不会产生假订单 —— 这是刻意的:
* 宁可不收钱,也不能在支付链路上给出"看起来成功"的假象。
* 客户端提交商店票据:**校验 → 建单 → 发权益**,一步都不能省。
*
* <p>幂等与防重放:
* <ul>
* <li>幂等键 `(platform, storeTransactionId)`(DB 唯一索引 `uk_orders_store_txn`);
* 同一票据重复提交直接返回既有订单,不会二次发币;</li>
* <li>票据已被**别人**用掉 → 拒绝(跨用户重放);</li>
* <li>校验不通过 → 抛业务码,**不写订单、不写票据**(宁可不收钱,也不能给"看起来成功"的假象);</li>
* <li>并发提交同一票据 → 唯一键兜底:读回既有订单走幂等分支,不会重复发货。</li>
* </ul>
*/
@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) {
if (userId == null) {
throw new ServiceException(ErrorCode.UNAUTHORIZED);
}
String store = StorePlatforms.normalize(platform);
if (!StringUtils.hasText(store)) {
throw new ServiceException(ErrorCode.BAD_REQUEST, "不支持的商店平台:" + platform);
}
StoreVerifier.StoreReceiptResult result = storeVerifier.verify(store, productId, receipt);
if (!result.ok()) {
throw new ServiceException(ErrorCode.OPERATION_NOT_ALLOWED, result.message());
}
Orders order = ordersMapper.selectOne(Wrappers.<Orders>lambdaQuery()
.eq(Orders::getPlatform, platform)
.eq(Orders::getStoreTransactionId, result.transactionId())
.last("limit 1"));
if (order == null) {
throw new ServiceException(ErrorCode.DATA_NOT_FOUND, "找不到对应的订单");
Orders existing = findOrderByStoreTransaction(store, result.transactionId());
if (existing != null) {
requireSameUser(existing, userId);
if (STATUS_DELIVERED.equals(existing.getStatus())) {
// 已交付过:直接返回既有订单,连钱包幂等调用都不再发一次
return existing;
}
return deliver(existing, "STORE", null, "票据重复提交,返回既有订单");
}
return deliver(order, "STORE", null, "票据校验通过");
Product product = findProduct(store, result.productId());
if (product == null) {
throw new ServiceException(ErrorCode.DATA_NOT_FOUND,
"该商品未在后台配置,请联系客服:" + result.productId());
}
if (product.getVipDays() != null && product.getVipDays() > 0) {
// 会员商品的发放链路属于 B1-2,先明确拒绝,避免"付了钱没发东西"
throw new ServiceException(ErrorCode.OPERATION_NOT_ALLOWED, "会员商品暂未开放购买");
}
Orders order = buildOrder(userId, store, product, result, receipt);
try {
ordersMapper.insert(order);
} catch (DuplicateKeyException ex) {
Orders raced = findOrderByStoreTransaction(store, result.transactionId());
if (raced == null) {
throw new ServiceException(ErrorCode.DATA_EXISTS, "该票据正在处理,请稍后重试");
}
requireSameUser(raced, userId);
if (STATUS_DELIVERED.equals(raced.getStatus())) {
return raced;
}
return deliver(raced, "STORE", null, "票据并发提交,返回既有订单");
}
recordReceipt(order, store, result, receipt);
recordEvent(order, "VERIFY_OK", "", STATUS_PAID, "STORE", null,
"票据校验通过:" + result.message());
return deliver(order, "STORE", null, "商店票据校验通过");
}
/** 按商店交易号查订单:这是幂等与防重放的唯一入口。 */
private Orders findOrderByStoreTransaction(String platform, String transactionId) {
if (!StringUtils.hasText(platform) || !StringUtils.hasText(transactionId)) {
return null;
}
return ordersMapper.selectOne(Wrappers.<Orders>lambdaQuery()
.eq(Orders::getPlatform, platform)
.eq(Orders::getStoreTransactionId, transactionId)
.last("limit 1"));
}
/** 票据已被别的账号用过 → 拒绝,防止"一张票据刷两个号"。 */
private void requireSameUser(Orders order, Long userId) {
if (!userId.equals(order.getUserId())) {
log.warn("票据重放被拒绝:订单 {} 属于用户 {},当前用户 {}",
order.getOrderNo(), order.getUserId(), userId);
throw new ServiceException(ErrorCode.FORBIDDEN, "该票据已绑定其他账号,不能重复使用");
}
}
/** 商品按 `(平台, 商店商品 id)` 找;`platform=ALL` 的通用商品也认。 */
private Product findProduct(String platform, String storeProductId) {
if (!StringUtils.hasText(storeProductId)) {
return null;
}
List<Product> products = productMapper.selectList(Wrappers.<Product>lambdaQuery()
.eq(Product::getStoreProductId, storeProductId)
.in(Product::getPlatform, List.of(platform, "ALL"))
.eq(Product::getStatus, 1));
return products.isEmpty() ? null : products.get(0);
}
/** 从校验结果建单:状态先记 PAID(商店已收款),交付由 deliver 推进到 DELIVERED。 */
private Orders buildOrder(Long userId, String platform, Product product,
StoreVerifier.StoreReceiptResult result, String receipt) {
LocalDateTime now = LocalDateTime.now();
Orders order = new Orders();
order.setOrderNo(newOrderNo());
order.setUserId(userId);
order.setProductId(product.getId());
order.setProductCode(product.getCode());
order.setProductName(product.getName());
order.setType(StringUtils.hasText(product.getType()) ? product.getType() : "COIN_PACK");
order.setPlatform(platform);
order.setStoreProductId(product.getStoreProductId());
order.setStoreTransactionId(result.transactionId());
order.setOriginalTransactionId(result.originalTransactionId() == null
? "" : result.originalTransactionId());
long coins = (product.getCoinAmount() == null ? 0L : product.getCoinAmount())
+ (product.getBonusCoin() == null ? 0L : product.getBonusCoin());
order.setCoinAmount(coins);
order.setQuantity(1);
order.setAmount(resolveAmount(product, result));
order.setCurrency(StringUtils.hasText(result.currency())
? result.currency() : product.getCurrency());
order.setRegionCode("ID");
order.setChannel("IAP");
order.setExperimentId("");
// 只留票据指纹,不落完整 purchaseToken(复验由 store_receipt 存)
order.setReceipt(receipt == null ? "" : receipt);
order.setStatus(STATUS_PAID);
order.setVerifyAt(now);
order.setPaidAt(now);
order.setFailReason("");
order.setRefundReason("");
return order;
}
/**
* 金额:商店返回微单位整数(1 元 = 1_000_000),落库是 NUMERIC(12,2) → 换算成元保留 2 位。
* 商店没给就用后台商品配置的价格。
*/
private BigDecimal resolveAmount(Product product, StoreVerifier.StoreReceiptResult result) {
if (result.amountMicros() != null && result.amountMicros() > 0) {
return BigDecimal.valueOf(result.amountMicros(), 6).setScale(2, RoundingMode.HALF_UP);
}
return product.getPriceAmount() == null ? BigDecimal.ZERO : product.getPriceAmount();
}
/** 票据审计行:退款/拒付(B1-2)要靠它复验,所以原始 token 必须留档。 */
private void recordReceipt(Orders order, String platform,
StoreVerifier.StoreReceiptResult result, String receipt) {
StoreReceipt record = new StoreReceipt();
record.setPlatform(platform);
record.setTransactionId(result.transactionId());
record.setOriginalTransactionId(result.originalTransactionId() == null
? "" : result.originalTransactionId());
record.setOrderNo(order.getOrderNo());
record.setUserId(order.getUserId());
record.setProductId(result.productId());
record.setPurchaseAt(result.purchaseTime());
record.setRawPayload(receipt == null ? "" : receipt);
record.setVerifyStatus(RECEIPT_OK);
record.setVerifyResponse(result.message());
record.setVerifyAt(LocalDateTime.now());
storeReceiptMapper.insert(record);
}
/** 订单号:FS + 时间 + 4 位随机;唯一索引 uk_orders_no 兜底。 */
private String newOrderNo() {
return "FS" + LocalDateTime.now().format(ORDER_NO_TIME)
+ String.format("%04d", ThreadLocalRandom.current().nextInt(10000));
}
/** 后台补发:把已支付未交付的订单重新交付;已交付时幂等返回。 */
@@ -2,22 +2,39 @@
package com.fruitshorts.commerce.service;
import java.time.LocalDateTime;
/**
* 商店票据校验端口。
*
* 第一阶段**只预留端口,不接真实 Apple / Google 商店**:接入时新增一个实现
* (Apple App Store Server API / Google Play Developer API)并替换 DisabledStoreVerifier 即可,
* 订单与权益链路不需要改动。
* <p>实现由 `fruitshorts.commerce.store.verifier` 配置选择:默认 {@code disabled}(明确报"未配置"),
* 配成 {@code google-play} 时走 Google Play Developer API。订单与权益链路不感知具体商店。
*/
public interface StoreVerifier {
StoreReceiptResult verify(String platform, String receipt);
/**
* 校验票据。
*
* @param platform 商店平台,取值见 {@link StorePlatforms}
* @param productId 商店商品 id(Google Play 的 {@code purchases.products.get} 必需)
* @param receipt 购买凭证:Google Play 是 purchaseToken
*/
StoreReceiptResult verify(String platform, String productId, String receipt);
/** 校验结果。ok=false 时 message 说明原因,用于人工排查。 */
/**
* 校验结果。ok=false 时 message 说明原因,用于人工排查,也可直接下发给客户端。
*
* @param transactionId 商店交易号:Google Play 是 orderId,作为本地幂等键之一
* @param amountMicros 金额(微单位,Google Play 原样返回);null 表示商店未给
* @param purchaseTime 购买时间;null 表示商店未给
*/
record StoreReceiptResult(boolean ok, String transactionId, String originalTransactionId,
String productId, String message) {
String productId, Long amountMicros, String currency,
LocalDateTime purchaseTime, String message) {
/** 校验失败:只带原因,不带任何商品信息,避免调用方拿半截数据建单。 */
public static StoreReceiptResult failed(String message) {
return new StoreReceiptResult(false, "", "", "", message);
return new StoreReceiptResult(false, "", "", "", null, "", null, message);
}
}
}
@@ -0,0 +1,40 @@
// Author: fruit.com.cn
package com.fruitshorts.commerce.service.store;
import java.time.LocalDateTime;
/**
* Google Play Developer API 的取数端口。
*
* <p>抽成端口是为了可测:单测用替身直接喂"Google 的返回",不必联网、也不需要真实服务账号。
* 生产实现见 {@link HttpGooglePlayPurchaseClient}。
*/
public interface GooglePlayPurchaseClient {
/**
* 查询一次性商品(金币包)的购买状态。
*
* @return Google 返回的商品购买信息;凭证不存在/已失效时实现方抛异常
*/
ProductPurchase fetchProductPurchase(String packageName, String productId, String purchaseToken);
/**
* Google `purchases.products.get` 的返回里我们需要的字段。
*
* @param purchaseState 0=已购买,1=已取消,2=待处理
* @param consumptionState 0=未消费,1=已消费
*/
record ProductPurchase(String orderId, String productId, int purchaseState, int consumptionState,
long purchaseTimeMillis, Long priceAmountMicros, String priceCurrencyCode,
String purchaseType) {
/** 购买时间(本地时区);时间戳为 0 时返回 null,交给上层决定怎么处理。 */
public LocalDateTime purchaseTime() {
return purchaseTimeMillis <= 0
? null
: LocalDateTime.ofInstant(java.time.Instant.ofEpochMilli(purchaseTimeMillis),
java.time.ZoneId.systemDefault());
}
}
}
@@ -0,0 +1,71 @@
// Author: fruit.com.cn
package com.fruitshorts.commerce.service.store;
import com.fruitshorts.commerce.config.StoreProperties;
import com.fruitshorts.commerce.service.StoreVerifier;
import com.fruitshorts.common.exception.ErrorCode;
import com.fruitshorts.common.exception.ServiceException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
/**
* Google Play 票据校验实现。
*
* <p>只有 `fruitshorts.commerce.store.verifier=google-play` 时才装配(默认 disabled,保持原有对外行为)。
* 校验口径:
* <ul>
* <li>platform 必须是 Google(`GOOGLE` / `GOOGLE_PLAY` 都会归一化);</li>
* <li>Google 返回 `purchaseState == 0`(0=已购买,1=已取消,2=待处理);</li>
* <li>返回的 `productId` 必须与请求一致 —— 防止"拿 A 商品的票据冒充 B 商品"。</li>
* </ul>
* 结果里的 `transactionId` 用 Google 的 `orderId`,它是本地幂等键 `(platform, transactionId)` 的一半。
*/
@Slf4j
@Component
@RequiredArgsConstructor
@ConditionalOnProperty(name = "fruitshorts.commerce.store.verifier", havingValue = "google-play")
public class GooglePlayStoreVerifier implements StoreVerifier {
/** Google 的 purchaseState:0=已购买。 */
private static final int STATE_PURCHASED = 0;
private final StoreProperties properties;
private final GooglePlayPurchaseClient purchaseClient;
@Override
public StoreReceiptResult verify(String platform, String productId, String receipt) {
if (!StorePlatforms.isGoogle(platform)) {
return StoreReceiptResult.failed("当前校验实现只支持 Google Play,收到的平台是:" + platform);
}
String packageName = properties.getGooglePlayPackageName();
if (!StringUtils.hasText(packageName)) {
throw new ServiceException(ErrorCode.OPERATION_NOT_ALLOWED,
"未配置 Google Play packageName(fruitshorts.commerce.store.google-play-package-name)");
}
if (!StringUtils.hasText(productId) || !StringUtils.hasText(receipt)) {
return StoreReceiptResult.failed("缺少 productId 或 purchaseToken");
}
GooglePlayPurchaseClient.ProductPurchase purchase =
purchaseClient.fetchProductPurchase(packageName, productId, receipt);
if (purchase.purchaseState() != STATE_PURCHASED) {
return StoreReceiptResult.failed("Google Play 购买状态不是已购买(purchaseState="
+ purchase.purchaseState() + ")");
}
if (StringUtils.hasText(purchase.productId()) && !productId.equals(purchase.productId())) {
log.warn("票据商品与请求商品不一致:请求={}, Google={}", productId, purchase.productId());
return StoreReceiptResult.failed("票据对应的商品与请求不一致");
}
if (!StringUtils.hasText(purchase.orderId())) {
// 没有 orderId 就没有幂等键,宁可拒绝也不能建单(否则重复提交必然重复发货)
return StoreReceiptResult.failed("Google Play 未返回 orderId,无法作为幂等键");
}
return new StoreReceiptResult(true, purchase.orderId(), "", purchase.productId(),
purchase.priceAmountMicros(), purchase.priceCurrencyCode(), purchase.purchaseTime(),
"Google Play 校验通过");
}
}
@@ -0,0 +1,210 @@
// Author: fruit.com.cn
package com.fruitshorts.commerce.service.store;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fruitshorts.commerce.config.StoreProperties;
import com.fruitshorts.common.exception.ErrorCode;
import com.fruitshorts.common.exception.ServiceException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import java.io.IOException;
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.nio.file.Files;
import java.nio.file.Path;
import java.security.KeyFactory;
import java.security.PrivateKey;
import java.security.Signature;
import java.security.spec.PKCS8EncodedKeySpec;
import java.time.Duration;
import java.time.Instant;
import java.util.Base64;
/**
* 用 JDK 自带 {@link HttpClient} 调 Google Play Developer API,**不引入 Google SDK 依赖**。
*
* <p>两步走:
* <ol>
* <li>服务账号私钥签 RS256 的 JWT,向 {@code oauth2.googleapis.com/token} 换 access_token;</li>
* <li>`GET .../purchases/products/{productId}/tokens/{token}` 查购买状态。</li>
* </ol>
* token 按过期时间缓存,避免每笔校验都换一次。
*/
@Slf4j
@Component
@ConditionalOnProperty(name = "fruitshorts.commerce.store.verifier", havingValue = "google-play")
public class HttpGooglePlayPurchaseClient implements GooglePlayPurchaseClient {
private static final String TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token";
private static final String PURCHASE_ENDPOINT =
"https://androidpublisher.googleapis.com/androidpublisher/v3/applications/%s/purchases/products/%s/tokens/%s";
private static final String SCOPE = "https://www.googleapis.com/auth/androidpublisher";
private static final long TOKEN_SKEW_SECONDS = 60;
private final StoreProperties properties;
private final ObjectMapper objectMapper = new ObjectMapper();
private final HttpClient httpClient;
private String cachedToken = "";
private Instant cachedTokenExpireAt = Instant.EPOCH;
public HttpGooglePlayPurchaseClient(StoreProperties properties) {
this.properties = properties;
this.httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(properties.getGooglePlayTimeoutSeconds()))
.build();
}
@Override
public ProductPurchase fetchProductPurchase(String packageName, String productId,
String purchaseToken) {
String url = String.format(PURCHASE_ENDPOINT, encode(packageName), encode(productId),
encode(purchaseToken));
HttpRequest request = HttpRequest.newBuilder(URI.create(url))
.timeout(Duration.ofSeconds(properties.getGooglePlayTimeoutSeconds()))
.header("Authorization", "Bearer " + accessToken())
.header("Accept", "application/json")
.GET()
.build();
JsonNode body = send(request, "查询 Google Play 购买状态失败");
return new ProductPurchase(
text(body, "orderId"),
text(body, "productId"),
body.path("purchaseState").asInt(-1),
body.path("consumptionState").asInt(-1),
body.path("purchaseTimeMillis").asLong(0L),
body.hasNonNull("priceAmountMicros") ? body.path("priceAmountMicros").asLong() : null,
text(body, "priceCurrencyCode"),
text(body, "purchaseType"));
}
/** 拿 access_token:未过期就用缓存,否则用服务账号重新换一次。 */
private synchronized String accessToken() {
if (StringUtils.hasText(cachedToken) && Instant.now().isBefore(cachedTokenExpireAt)) {
return cachedToken;
}
JsonNode account = serviceAccount();
String clientEmail = account.path("client_email").asText("");
String privateKeyPem = account.path("private_key").asText("");
if (!StringUtils.hasText(clientEmail) || !StringUtils.hasText(privateKeyPem)) {
throw new ServiceException(ErrorCode.OPERATION_NOT_ALLOWED,
"Google Play 服务账号 JSON 缺少 client_email 或 private_key");
}
long now = Instant.now().getEpochSecond();
String assertion = jwt(clientEmail, now, now + 3600, privateKeyPem);
String form = "grant_type=" + encode("urn:ietf:params:oauth:grant-type:jwt-bearer")
+ "&assertion=" + encode(assertion);
HttpRequest request = HttpRequest.newBuilder(URI.create(TOKEN_ENDPOINT))
.timeout(Duration.ofSeconds(properties.getGooglePlayTimeoutSeconds()))
.header("Content-Type", "application/x-www-form-urlencoded")
.POST(HttpRequest.BodyPublishers.ofString(form))
.build();
JsonNode body = send(request, "换取 Google access_token 失败");
cachedToken = body.path("access_token").asText("");
long expiresIn = body.path("expires_in").asLong(3600L);
cachedTokenExpireAt = Instant.now().plusSeconds(Math.max(60L, expiresIn - TOKEN_SKEW_SECONDS));
if (!StringUtils.hasText(cachedToken)) {
throw new ServiceException(ErrorCode.OPERATION_NOT_ALLOWED, "Google 未返回 access_token");
}
return cachedToken;
}
/** 服务账号 JSON 支持三种写法:文件路径 / JSON 内容 / base64 内容。 */
private JsonNode serviceAccount() {
String raw = properties.getGooglePlayServiceAccountJson();
if (!StringUtils.hasText(raw)) {
throw new ServiceException(ErrorCode.OPERATION_NOT_ALLOWED,
"未配置 Google Play 服务账号(fruitshorts.commerce.store.google-play-service-account-json)");
}
String json = raw.trim();
if (!json.startsWith("{")) {
Path path = Path.of(json);
if (Files.isReadable(path)) {
json = readFile(path);
} else {
json = new String(Base64.getDecoder().decode(json), StandardCharsets.UTF_8);
}
}
try {
return objectMapper.readTree(json);
} catch (IOException ex) {
throw new ServiceException(ErrorCode.OPERATION_NOT_ALLOWED, "Google Play 服务账号 JSON 解析失败");
}
}
private String readFile(Path path) {
try {
return Files.readString(path, StandardCharsets.UTF_8);
} catch (IOException ex) {
throw new ServiceException(ErrorCode.OPERATION_NOT_ALLOWED, "服务账号文件不可读:" + path);
}
}
/** RS256 签名的 service-account JWT。 */
private String jwt(String clientEmail, long issuedAt, long expiresAt, String privateKeyPem) {
String header = base64Url("{\"alg\":\"RS256\",\"typ\":\"JWT\"}");
String claims = base64Url("{\"iss\":\"" + clientEmail + "\",\"scope\":\"" + SCOPE
+ "\",\"aud\":\"" + TOKEN_ENDPOINT + "\",\"iat\":" + issuedAt + ",\"exp\":" + expiresAt + "}");
String signingInput = header + "." + claims;
try {
Signature signature = Signature.getInstance("SHA256withRSA");
signature.initSign(privateKey(privateKeyPem));
signature.update(signingInput.getBytes(StandardCharsets.UTF_8));
return signingInput + "." + Base64.getUrlEncoder().withoutPadding()
.encodeToString(signature.sign());
} catch (Exception ex) {
log.warn("服务账号私钥签名失败:{}", ex.getMessage());
throw new ServiceException(ErrorCode.OPERATION_NOT_ALLOWED, "服务账号私钥不可用");
}
}
private PrivateKey privateKey(String pem) throws Exception {
String base64 = pem.replace("-----BEGIN PRIVATE KEY-----", "")
.replace("-----END PRIVATE KEY-----", "")
.replaceAll("\\s", "");
byte[] der = Base64.getDecoder().decode(base64);
return KeyFactory.getInstance("RSA").generatePrivate(new PKCS8EncodedKeySpec(der));
}
private JsonNode send(HttpRequest request, String errorPrefix) {
try {
HttpResponse<String> response = httpClient.send(request,
HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
if (response.statusCode() / 100 != 2) {
log.warn("{}:HTTP {} {}", errorPrefix, response.statusCode(), response.body());
throw new ServiceException(ErrorCode.OPERATION_NOT_ALLOWED,
errorPrefix + "(HTTP " + response.statusCode() + ")");
}
return objectMapper.readTree(response.body());
} catch (ServiceException ex) {
throw ex;
} catch (IOException ex) {
throw new ServiceException(ErrorCode.OPERATION_NOT_ALLOWED, errorPrefix + ":网络异常");
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
throw new ServiceException(ErrorCode.OPERATION_NOT_ALLOWED, errorPrefix + ":调用被中断");
}
}
private static String base64Url(String value) {
return Base64.getUrlEncoder().withoutPadding()
.encodeToString(value.getBytes(StandardCharsets.UTF_8));
}
private static String encode(String value) {
return URLEncoder.encode(value == null ? "" : value, StandardCharsets.UTF_8);
}
private static String text(JsonNode node, String field) {
return node.hasNonNull(field) ? node.path(field).asText("") : "";
}
}
@@ -0,0 +1,39 @@
// Author: fruit.com.cn
package com.fruitshorts.commerce.service.store;
import java.util.Locale;
/**
* 商店平台取值。
*
* <p>入库口径按建表注释:`product.platform` / `orders.platform` 用 {@code ALL/APPLE/GOOGLE}。
* 客户端可能按商店名写成 `GOOGLE_PLAY`,所以这里同时接受别名并**归一化后入库**,
* 避免同一笔交易因为拼写不同被当成两个平台(那会绕过 `uk_orders_store_txn` 幂等键)。
*/
public final class StorePlatforms {
public static final String GOOGLE = "GOOGLE";
public static final String APPLE = "APPLE";
private StorePlatforms() {
}
/** 归一化平台名;无法识别时返回空串(由调用方决定怎么报错)。 */
public static String normalize(String raw) {
if (raw == null) {
return "";
}
String value = raw.trim().toUpperCase(Locale.ROOT);
return switch (value) {
case "GOOGLE", "GOOGLE_PLAY", "GOOGLEPLAY", "ANDROID" -> GOOGLE;
case "APPLE", "APP_STORE", "APPSTORE", "IOS" -> APPLE;
default -> "";
};
}
/** 是否 Google Play。 */
public static boolean isGoogle(String raw) {
return GOOGLE.equals(normalize(raw));
}
}