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:
1 parent
27e689c771
commit
db897d50d6
15 files changed
+1187
-24
No files matched your search
@@ -34,5 +34,11 @@
|
||||
<groupId>com.fruitshorts</groupId>
|
||||
<artifactId>fruitshorts-module-playback</artifactId>
|
||||
</dependency>
|
||||
<!-- 票据校验与订单交付的单测用:JUnit5 + Mockito,test scope,不进运行态依赖 -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
+36
@@ -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;
|
||||
}
|
||||
+7
-1
@@ -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 商店校验尚未配置,第一阶段仅预留接口");
|
||||
}
|
||||
|
||||
+176
-12
@@ -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));
|
||||
}
|
||||
|
||||
/** 后台补发:把已支付未交付的订单重新交付;已交付时幂等返回。 */
|
||||
|
||||
+24
-7
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+40
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
+71
@@ -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 校验通过");
|
||||
}
|
||||
}
|
||||
+210
@@ -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("") : "";
|
||||
}
|
||||
}
|
||||
+39
@@ -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));
|
||||
}
|
||||
}
|
||||
+234
@@ -0,0 +1,234 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
package com.fruitshorts.commerce.service;
|
||||
|
||||
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.common.exception.ErrorCode;
|
||||
import com.fruitshorts.common.exception.ServiceException;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.BDDMockito.given;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
||||
/**
|
||||
* 票据校验闭环:**校验 → 建单 → 发权益**,以及幂等与防重放。
|
||||
*
|
||||
* <p>用替身 verifier(`StubStoreVerifier`)喂"商店的判定结果",Mapper 与钱包/权益层用替身,
|
||||
* 不连库、不联网。四类必须覆盖:通过→发权益、重复→幂等、无效→拒绝、跨用户→拒绝。
|
||||
*/
|
||||
class OrderServiceVerifyTest {
|
||||
|
||||
private static final Long USER = 1001L;
|
||||
private static final String TOKEN = "purchase-token-1";
|
||||
private static final String STORE_PRODUCT = "coin_550";
|
||||
private static final String TXN = "GPA.3311-0000-0000-00001";
|
||||
|
||||
private OrdersMapper ordersMapper;
|
||||
private OrderEventMapper orderEventMapper;
|
||||
private ProductMapper productMapper;
|
||||
private StoreReceiptMapper storeReceiptMapper;
|
||||
private StubStoreVerifier verifier;
|
||||
private WalletService walletService;
|
||||
private EntitlementService entitlementService;
|
||||
private OrderService orderService;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
ordersMapper = mock(OrdersMapper.class);
|
||||
orderEventMapper = mock(OrderEventMapper.class);
|
||||
productMapper = mock(ProductMapper.class);
|
||||
storeReceiptMapper = mock(StoreReceiptMapper.class);
|
||||
verifier = new StubStoreVerifier();
|
||||
walletService = mock(WalletService.class);
|
||||
entitlementService = mock(EntitlementService.class);
|
||||
orderService = new OrderService(ordersMapper, orderEventMapper, productMapper,
|
||||
storeReceiptMapper, verifier, walletService, entitlementService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_deliver_coins_when_receipt_is_valid() {
|
||||
given(ordersMapper.selectOne(any())).willReturn(null);
|
||||
given(productMapper.selectList(any())).willReturn(List.of(coinPack()));
|
||||
verifier.result = okResult();
|
||||
AtomicReference<Orders> saved = captureInsertedOrder();
|
||||
|
||||
Orders order = orderService.verifyAndDeliver(USER, "GOOGLE_PLAY", STORE_PRODUCT, TOKEN);
|
||||
|
||||
assertThat(order.getStatus()).isEqualTo(OrderService.STATUS_DELIVERED);
|
||||
// 500 基础 + 50 赠送
|
||||
assertThat(order.getCoinAmount()).isEqualTo(550L);
|
||||
assertThat(order.getPlatform()).isEqualTo(StorePlatforms.GOOGLE);
|
||||
assertThat(order.getStoreTransactionId()).isEqualTo(TXN);
|
||||
assertThat(order.getProductCode()).isEqualTo("coin_550");
|
||||
// Google 给的微单位金额换算成元:990000 → 0.99
|
||||
assertThat(order.getAmount()).isEqualByComparingTo(new BigDecimal("0.99"));
|
||||
assertThat(order.getCurrency()).isEqualTo("IDR");
|
||||
assertThat(order.getDeliveredAt()).isNotNull();
|
||||
assertThat(saved.get().getOrderNo()).startsWith("FS");
|
||||
|
||||
// 发权益:钱包只调一次,幂等键按订单固定
|
||||
verify(walletService).change(eq(USER), eq(550L), eq(WalletService.BIZ_RECHARGE),
|
||||
eq(order.getOrderNo()), eq("order:" + order.getOrderNo() + ":coins"),
|
||||
anyString(), eq(true), eq(null));
|
||||
// 票据审计行 + 两条状态流水(VERIFY_OK / DELIVER)
|
||||
verify(storeReceiptMapper).insert(any(StoreReceipt.class));
|
||||
verify(orderEventMapper, org.mockito.Mockito.times(2)).insert(any(OrderEvent.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_return_existing_order_without_delivering_again_when_receipt_is_submitted_twice() {
|
||||
Orders delivered = deliveredOrder(USER);
|
||||
given(ordersMapper.selectOne(any())).willReturn(delivered);
|
||||
verifier.result = okResult();
|
||||
|
||||
Orders first = orderService.verifyAndDeliver(USER, "GOOGLE", STORE_PRODUCT, TOKEN);
|
||||
Orders second = orderService.verifyAndDeliver(USER, "GOOGLE", STORE_PRODUCT, TOKEN);
|
||||
|
||||
assertThat(first.getOrderNo()).isEqualTo(delivered.getOrderNo());
|
||||
assertThat(second.getOrderNo()).isEqualTo(delivered.getOrderNo());
|
||||
// 重复提交:不再发币、不再建单、不再写票据
|
||||
verify(walletService, never()).change(any(), anyLong(), anyString(), anyString(),
|
||||
anyString(), anyString(), org.mockito.ArgumentMatchers.anyBoolean(), any());
|
||||
verify(ordersMapper, never()).insert(any(Orders.class));
|
||||
verify(storeReceiptMapper, never()).insert(any(StoreReceipt.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_reject_and_create_nothing_when_receipt_is_invalid() {
|
||||
verifier.result = StoreVerifier.StoreReceiptResult.failed("Google Play 购买状态不是已购买");
|
||||
|
||||
assertThatThrownBy(() -> orderService.verifyAndDeliver(USER, "GOOGLE", STORE_PRODUCT, TOKEN))
|
||||
.isInstanceOf(ServiceException.class)
|
||||
.satisfies(ex -> assertThat(((ServiceException) ex).getCode())
|
||||
.isEqualTo(ErrorCode.OPERATION_NOT_ALLOWED.getCode()))
|
||||
.hasMessageContaining("购买状态不是已购买");
|
||||
// 关键:不产生假订单、不写票据、不发币
|
||||
verify(ordersMapper, never()).insert(any(Orders.class));
|
||||
verify(storeReceiptMapper, never()).insert(any(StoreReceipt.class));
|
||||
verify(walletService, never()).change(any(), anyLong(), anyString(), anyString(),
|
||||
anyString(), anyString(), org.mockito.ArgumentMatchers.anyBoolean(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_reject_when_receipt_belongs_to_another_user() {
|
||||
given(ordersMapper.selectOne(any())).willReturn(deliveredOrder(2002L));
|
||||
verifier.result = okResult();
|
||||
|
||||
assertThatThrownBy(() -> orderService.verifyAndDeliver(USER, "GOOGLE", STORE_PRODUCT, TOKEN))
|
||||
.isInstanceOf(ServiceException.class)
|
||||
.satisfies(ex -> assertThat(((ServiceException) ex).getCode())
|
||||
.isEqualTo(ErrorCode.FORBIDDEN.getCode()))
|
||||
.hasMessageContaining("已绑定其他账号");
|
||||
verify(walletService, never()).change(any(), anyLong(), anyString(), anyString(),
|
||||
anyString(), anyString(), org.mockito.ArgumentMatchers.anyBoolean(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_reject_when_product_is_not_configured() {
|
||||
given(ordersMapper.selectOne(any())).willReturn(null);
|
||||
given(productMapper.selectList(any())).willReturn(List.of());
|
||||
verifier.result = okResult();
|
||||
|
||||
assertThatThrownBy(() -> orderService.verifyAndDeliver(USER, "GOOGLE", STORE_PRODUCT, TOKEN))
|
||||
.isInstanceOf(ServiceException.class)
|
||||
.satisfies(ex -> assertThat(((ServiceException) ex).getCode())
|
||||
.isEqualTo(ErrorCode.DATA_NOT_FOUND.getCode()));
|
||||
verify(ordersMapper, never()).insert(any(Orders.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_fall_back_to_configured_price_when_store_omits_amount() {
|
||||
given(ordersMapper.selectOne(any())).willReturn(null);
|
||||
given(productMapper.selectList(any())).willReturn(List.of(coinPack()));
|
||||
verifier.result = new StoreVerifier.StoreReceiptResult(true, TXN, "", STORE_PRODUCT,
|
||||
null, "", LocalDateTime.now(), "Google Play 校验通过");
|
||||
AtomicReference<Orders> saved = captureInsertedOrder();
|
||||
|
||||
orderService.verifyAndDeliver(USER, "GOOGLE", STORE_PRODUCT, TOKEN);
|
||||
|
||||
assertThat(saved.get().getAmount()).isEqualByComparingTo(new BigDecimal("4.99"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_reject_when_platform_is_unknown() {
|
||||
assertThatThrownBy(() -> orderService.verifyAndDeliver(USER, "NINTENDO", STORE_PRODUCT, TOKEN))
|
||||
.isInstanceOf(ServiceException.class)
|
||||
.hasMessageContaining("不支持的商店平台");
|
||||
}
|
||||
|
||||
private AtomicReference<Orders> captureInsertedOrder() {
|
||||
AtomicReference<Orders> saved = new AtomicReference<>();
|
||||
given(ordersMapper.insert(any(Orders.class))).willAnswer(invocation -> {
|
||||
saved.set(invocation.getArgument(0));
|
||||
return 1;
|
||||
});
|
||||
return saved;
|
||||
}
|
||||
|
||||
private Product coinPack() {
|
||||
Product product = new Product();
|
||||
product.setId(9L);
|
||||
product.setCode("coin_550");
|
||||
product.setName("550 金币");
|
||||
product.setType("COIN_PACK");
|
||||
product.setPlatform("ALL");
|
||||
product.setStoreProductId(STORE_PRODUCT);
|
||||
product.setCoinAmount(500L);
|
||||
product.setBonusCoin(50L);
|
||||
product.setPriceAmount(new BigDecimal("4.99"));
|
||||
product.setCurrency("IDR");
|
||||
product.setVipDays(0);
|
||||
product.setStatus(1);
|
||||
return product;
|
||||
}
|
||||
|
||||
private Orders deliveredOrder(Long userId) {
|
||||
Orders order = new Orders();
|
||||
order.setId(1L);
|
||||
order.setOrderNo("FS202610161200000001");
|
||||
order.setUserId(userId);
|
||||
order.setPlatform(StorePlatforms.GOOGLE);
|
||||
order.setStoreTransactionId(TXN);
|
||||
order.setCoinAmount(550L);
|
||||
order.setStatus(OrderService.STATUS_DELIVERED);
|
||||
return order;
|
||||
}
|
||||
|
||||
private StoreVerifier.StoreReceiptResult okResult() {
|
||||
return new StoreVerifier.StoreReceiptResult(true, TXN, "", STORE_PRODUCT,
|
||||
990000L, "IDR", LocalDateTime.now(), "Google Play 校验通过");
|
||||
}
|
||||
|
||||
/** 替身 verifier:喂什么结果就返回什么,供四类分支断言。 */
|
||||
private static final class StubStoreVerifier implements StoreVerifier {
|
||||
private StoreReceiptResult result = StoreReceiptResult.failed("未设置");
|
||||
|
||||
@Override
|
||||
public StoreReceiptResult verify(String platform, String productId, String receipt) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
package com.fruitshorts.commerce.service.store;
|
||||
|
||||
import com.fruitshorts.commerce.config.StoreProperties;
|
||||
import com.fruitshorts.commerce.service.StoreVerifier;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* Google Play 校验分支:只测"拿到 Google 返回之后怎么判定",用替身客户端喂数据,不联网。
|
||||
*
|
||||
* <p>真调 Google API 需要 service account 凭据(属付费密钥,见回执里的清单),
|
||||
* 所以这里把判定逻辑钉死,凭据到位后再做一次真实联调。
|
||||
*/
|
||||
class GooglePlayStoreVerifierTest {
|
||||
|
||||
private static final String PACKAGE_NAME = "com.stormg.fruitshorts";
|
||||
private static final String PRODUCT_ID = "coin_550";
|
||||
private static final String TOKEN = "purchase-token-1";
|
||||
|
||||
private StoreProperties properties;
|
||||
private StubPurchaseClient client;
|
||||
private GooglePlayStoreVerifier verifier;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
properties = new StoreProperties();
|
||||
properties.setGooglePlayPackageName(PACKAGE_NAME);
|
||||
client = new StubPurchaseClient();
|
||||
verifier = new GooglePlayStoreVerifier(properties, client);
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_return_transaction_when_google_confirms_the_purchase() {
|
||||
client.purchase = new GooglePlayPurchaseClient.ProductPurchase("GPA.3311-00001", PRODUCT_ID,
|
||||
0, 1, 1760000000000L, 990000L, "IDR", "0");
|
||||
|
||||
StoreVerifier.StoreReceiptResult result = verifier.verify("GOOGLE_PLAY", PRODUCT_ID, TOKEN);
|
||||
|
||||
assertThat(result.ok()).isTrue();
|
||||
assertThat(result.transactionId()).isEqualTo("GPA.3311-00001");
|
||||
assertThat(result.productId()).isEqualTo(PRODUCT_ID);
|
||||
assertThat(result.amountMicros()).isEqualTo(990000L);
|
||||
assertThat(result.currency()).isEqualTo("IDR");
|
||||
assertThat(result.purchaseTime()).isNotNull();
|
||||
assertThat(client.requestedPackageName).isEqualTo(PACKAGE_NAME);
|
||||
assertThat(client.requestedProductId).isEqualTo(PRODUCT_ID);
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_reject_when_purchase_state_is_not_purchased() {
|
||||
client.purchase = new GooglePlayPurchaseClient.ProductPurchase("GPA.3311-00002", PRODUCT_ID,
|
||||
1, 1, 1760000000000L, 990000L, "IDR", "0");
|
||||
|
||||
StoreVerifier.StoreReceiptResult result = verifier.verify("GOOGLE", PRODUCT_ID, TOKEN);
|
||||
|
||||
assertThat(result.ok()).isFalse();
|
||||
assertThat(result.message()).contains("购买状态");
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_reject_when_google_returns_a_different_product() {
|
||||
client.purchase = new GooglePlayPurchaseClient.ProductPurchase("GPA.3311-00003", "coin_100",
|
||||
0, 1, 1760000000000L, 100000L, "IDR", "0");
|
||||
|
||||
StoreVerifier.StoreReceiptResult result = verifier.verify("GOOGLE", PRODUCT_ID, TOKEN);
|
||||
|
||||
assertThat(result.ok()).isFalse();
|
||||
assertThat(result.message()).contains("商品与请求不一致");
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_reject_when_order_id_is_missing() {
|
||||
// 没有 orderId 就没有幂等键:宁可不发权益,也不能冒重复发货的风险
|
||||
client.purchase = new GooglePlayPurchaseClient.ProductPurchase("", PRODUCT_ID,
|
||||
0, 1, 1760000000000L, 990000L, "IDR", "0");
|
||||
|
||||
StoreVerifier.StoreReceiptResult result = verifier.verify("GOOGLE", PRODUCT_ID, TOKEN);
|
||||
|
||||
assertThat(result.ok()).isFalse();
|
||||
assertThat(result.message()).contains("orderId");
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_reject_when_platform_is_not_google() {
|
||||
StoreVerifier.StoreReceiptResult result = verifier.verify("APPLE", PRODUCT_ID, TOKEN);
|
||||
|
||||
assertThat(result.ok()).isFalse();
|
||||
assertThat(result.message()).contains("只支持 Google Play");
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_reject_when_product_id_or_token_is_blank() {
|
||||
assertThat(verifier.verify("GOOGLE", "", TOKEN).ok()).isFalse();
|
||||
assertThat(verifier.verify("GOOGLE", PRODUCT_ID, "").ok()).isFalse();
|
||||
}
|
||||
|
||||
/** 替身客户端:记录被请求的 packageName/productId,返回预设购买信息。 */
|
||||
private static final class StubPurchaseClient implements GooglePlayPurchaseClient {
|
||||
private ProductPurchase purchase;
|
||||
private String requestedPackageName = "";
|
||||
private String requestedProductId = "";
|
||||
|
||||
@Override
|
||||
public ProductPurchase fetchProductPurchase(String packageName, String productId,
|
||||
String purchaseToken) {
|
||||
requestedPackageName = packageName;
|
||||
requestedProductId = productId;
|
||||
return purchase;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user