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
+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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user