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
+31
-1
@@ -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 创作者(下一批)
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|
||||
+11
-3
@@ -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
|
||||
|
||||
+177
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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