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

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

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

No files matched your search

@@ -0,0 +1,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;
}
}
}