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

+31 -1
View File
@@ -221,11 +221,41 @@ POST /app/auth/register
| GET | `/app/wallet` | 金币余额 + 最近流水 |
| POST | `/app/episodes/{id}/unlock` | 金币解锁分集;幂等键 `unlock:{userId}:{episodeId}`,重复调用不重复扣币 |
| GET | `/app/orders` | 我的订单 |
| POST | `/app/orders/verify` | 提交 IAP / Google Play 票据。**本轮只留端口不接商店**,会返回 `200003 尚未配置` |
| POST | `/app/orders/verify` | 提交 Google Play 票据:服务端校验 → 建单 → 发权益(详见下方说明) |
解锁后播放入口与下载入口会同时放行 —— 权益判定由交易模块的 `EntitlementPort` 实现提供,
播放域与下载域都不需要改代码。金币变动一律带幂等键并写 `coin_ledger`,钱包余额与流水同事务。
**`POST /app/orders/verify`(B1-1:Google Play 票据校验闭环)**
请求体:
```json
{ "platform": "GOOGLE_PLAY", "productId": "coin_550", "receipt": "<purchaseToken>" }
```
| 字段 | 说明 |
| --- | --- |
| `platform` | `GOOGLE` / `GOOGLE_PLAY`(两者等价,入库统一记 `GOOGLE`);Apple 走 B1-2 |
| `productId` | 商店商品 id(Google Play 的 `purchases.products.get` 必需),与本平台 `product.store_product_id` 对得上才发权益 |
| `receipt` | Google Play 的 `purchaseToken` |
行为与错误码:
| 场景 | 返回 |
| --- | --- |
| 校验通过 | `code=0`,`data` 是订单(`status=DELIVERED`、`coinAmount`=商品金币+赠送、`storeTransactionId`=Google `orderId`) |
| 同一票据**重复**提交(同用户) | `code=0` 返回**既有订单**,不重复发币、不重复建单 |
| 票据已绑定**其他账号** | `403 该票据已绑定其他账号,不能重复使用`(防跨用户重放) |
| 票据无效 / 未购买 / 商品不一致 / 缺 orderId | `200003` + 具体原因;**不产生订单、不发权益** |
| 商品未在后台配置 | `200002 该商品未在后台配置` |
| 平台不在支持范围 | `400 不支持的商店平台` |
| 商店未配置(默认 `verifier=disabled`) | `200003 Apple / Google 商店校验尚未配置`(与 B1-1 之前的行为一致) |
幂等键是 `(platform, store_transaction_id)`(DB 唯一索引 `uk_orders_store_txn`),
重复/并发提交由数据库兜底;票据审计写 `store_receipt`(唯一索引 `uk_store_receipt(platform, transaction_id)`),
B1-2 的退款/拒付复验依赖它。服务端凭据(`packageName` + service account JSON)走配置/环境变量,不入库。
### 1.9 创作者(下一批)
| 方法 | 路径 | 说明 |
@@ -62,11 +62,19 @@ public class AppCommerceController {
return Result.ok(orderService.listByUser(SecurityUtils.getAppUserId()));
}
/** 提交商店票据。第一阶段商店未接入,会返回明确的"尚未配置"。 */
@Operation(summary = "提交商店票据(第一阶段商店未接入,返回明确提示)")
/**
* 提交商店票据:服务端校验 → 建单 → 发权益。
*
* <p>body:`platform`(GOOGLE/GOOGLE_PLAY)、`productId`(商店商品 id)、`receipt`(purchaseToken)。
* 同一票据重复提交只交付一次;票据已绑定其他账号、或校验不通过时返回明确业务码且不产生订单。
* 商店未配置(默认)时返回 `200003 尚未配置`,与第一阶段行为一致。
*/
@Operation(summary = "提交商店票据(校验后建单并发权益,重复提交幂等)")
@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", "")));
}
}
@@ -53,6 +53,16 @@ fruitshorts:
license-days: 30
wifi-only-default: true
max-quality: AUTO
commerce:
store:
# 商店票据校验实现:disabled = 明确报"未配置"(默认,无凭据时对外行为不变)
# google-play = 走 Google Play Developer API
verifier: ${STORE_VERIFIER:disabled}
# 下面两项属付费凭据:只从环境变量/配置注入,不入库、不提交、不打进镜像
google-play-package-name: ${GOOGLE_PLAY_PACKAGE_NAME:}
# 服务账号 JSON:文件路径 \| JSON 内容 \| base64(建议用环境变量给 base64)
google-play-service-account-json: ${GOOGLE_PLAY_SERVICE_ACCOUNT_JSON:}
google-play-timeout-seconds: 10
media:
root-dir: ./data/uploads
public-prefix: /files
@@ -0,0 +1,177 @@
// Author: fruit.com.cn
package com.fruitshorts.app.controller;
import com.fruitshorts.app.support.ControllerContractSupport;
import com.fruitshorts.commerce.dal.entity.Orders;
import com.fruitshorts.commerce.dal.entity.Product;
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.DisabledStoreVerifier;
import com.fruitshorts.commerce.service.EntitlementService;
import com.fruitshorts.commerce.service.OrderService;
import com.fruitshorts.commerce.service.StoreVerifier;
import com.fruitshorts.commerce.service.UnlockService;
import com.fruitshorts.commerce.service.WalletService;
import com.fruitshorts.common.exception.ErrorCode;
import com.fruitshorts.framework.security.LoginUser;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.test.web.servlet.MockMvc;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.time.LocalDateTime;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* `/app/orders/verify` 的接入层契约:统一响应体 + 错误码 + "默认 disabled 行为不变"。
*
* <p>业务规则在 `fruitshorts-module-commerce` 的 `OrderServiceVerifyTest` 里测;
* 这里只钉"客户端会看到的 HTTP 表现",用替身 verifier,不连库、不联网。
*/
class AppOrdersVerifyContractTest {
private static final Long USER = 1001L;
private static final String BODY = """
{"platform":"GOOGLE_PLAY","productId":"coin_550","receipt":"purchase-token-1"}""";
private OrdersMapper ordersMapper;
private ProductMapper productMapper;
private StoreReceiptMapper storeReceiptMapper;
private StubStoreVerifier verifier;
private MockMvc mockMvc;
@BeforeEach
void setUp() {
ordersMapper = mock(OrdersMapper.class);
productMapper = mock(ProductMapper.class);
storeReceiptMapper = mock(StoreReceiptMapper.class);
verifier = new StubStoreVerifier();
OrderService orderService = new OrderService(ordersMapper, mock(OrderEventMapper.class),
productMapper, storeReceiptMapper, verifier, mock(WalletService.class),
mock(EntitlementService.class));
mockMvc = ControllerContractSupport.mockMvcOf(new AppCommerceController(
mock(WalletService.class), mock(UnlockService.class), orderService));
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(
new LoginUser(USER, "app-user", "APP_USER", List.of()), null, List.of()));
}
@AfterEach
void tearDown() {
SecurityContextHolder.clearContext();
}
@Test
void should_return_delivered_order_when_receipt_is_valid() throws Exception {
given(ordersMapper.selectOne(any())).willReturn(null);
given(productMapper.selectList(any())).willReturn(List.of(coinPack()));
verifier.result = new StoreVerifier.StoreReceiptResult(true, "GPA.3311-00001", "", "coin_550",
990000L, "IDR", LocalDateTime.now(), "Google Play 校验通过");
mockMvc.perform(post("/app/orders/verify").contentType(MediaType.APPLICATION_JSON).content(BODY))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.status").value(OrderService.STATUS_DELIVERED))
.andExpect(jsonPath("$.data.coinAmount").value(550))
.andExpect(jsonPath("$.data.platform").value("GOOGLE"))
.andExpect(jsonPath("$.data.storeTransactionId").value("GPA.3311-00001"));
verify(ordersMapper).insert(any(Orders.class));
}
@Test
void should_reject_with_operation_not_allowed_when_receipt_is_invalid() throws Exception {
verifier.result = StoreVerifier.StoreReceiptResult.failed("Google Play 购买状态不是已购买");
mockMvc.perform(post("/app/orders/verify").contentType(MediaType.APPLICATION_JSON).content(BODY))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(ErrorCode.OPERATION_NOT_ALLOWED.getCode()))
.andExpect(jsonPath("$.message").value("Google Play 购买状态不是已购买"));
verify(ordersMapper, never()).insert(any(Orders.class));
}
@Test
void should_reject_with_forbidden_when_receipt_belongs_to_another_user() throws Exception {
Orders others = new Orders();
others.setId(1L);
others.setOrderNo("FS202610161200000001");
others.setUserId(2002L);
others.setPlatform("GOOGLE");
others.setStoreTransactionId("GPA.3311-00001");
others.setStatus(OrderService.STATUS_DELIVERED);
given(ordersMapper.selectOne(any())).willReturn(others);
verifier.result = new StoreVerifier.StoreReceiptResult(true, "GPA.3311-00001", "", "coin_550",
990000L, "IDR", LocalDateTime.now(), "Google Play 校验通过");
mockMvc.perform(post("/app/orders/verify").contentType(MediaType.APPLICATION_JSON).content(BODY))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(ErrorCode.FORBIDDEN.getCode()))
.andExpect(jsonPath("$.message").value("该票据已绑定其他账号,不能重复使用"));
}
@Test
void should_keep_disabled_behaviour_when_store_is_not_configured() throws Exception {
// 默认实现(未配凭据):对外仍是"尚未配置",不产生订单
OrderService disabled = new OrderService(ordersMapper, mock(OrderEventMapper.class),
productMapper, storeReceiptMapper, new DisabledStoreVerifier(),
mock(WalletService.class), mock(EntitlementService.class));
MockMvc disabledMvc = ControllerContractSupport.mockMvcOf(new AppCommerceController(
mock(WalletService.class), mock(UnlockService.class), disabled));
String body = disabledMvc.perform(post("/app/orders/verify")
.contentType(MediaType.APPLICATION_JSON).content(BODY))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(ErrorCode.OPERATION_NOT_ALLOWED.getCode()))
// MockMvc 的 getContentAsString() 在无 charset 时按 ISO-8859-1 解,中文会变乱码,
// 这里显式按 UTF-8 解码,断言才是真的在断言中文文案
.andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8);
assertThat(body).contains("尚未配置");
verify(ordersMapper, never()).insert(any(Orders.class));
}
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("coin_550");
product.setCoinAmount(500L);
product.setBonusCoin(50L);
product.setPriceAmount(new BigDecimal("4.99"));
product.setCurrency("IDR");
product.setVipDays(0);
product.setStatus(1);
return product;
}
/** 替身 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;
}
}
}
+6
View File
@@ -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>
@@ -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));
}
}
@@ -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;
}
}
}
@@ -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;
}
}
}