diff --git a/docs/api-phase1.md b/docs/api-phase1.md index 17f11c0..d8c3031 100644 --- a/docs/api-phase1.md +++ b/docs/api-phase1.md @@ -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": "" } +``` + +| 字段 | 说明 | +| --- | --- | +| `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 创作者(下一批) | 方法 | 路径 | 说明 | diff --git a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppCommerceController.java b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppCommerceController.java index 17d0b81..c5254b9 100644 --- a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppCommerceController.java +++ b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppCommerceController.java @@ -62,11 +62,19 @@ public class AppCommerceController { return Result.ok(orderService.listByUser(SecurityUtils.getAppUserId())); } - /** 提交商店票据。第一阶段商店未接入,会返回明确的"尚未配置"。 */ - @Operation(summary = "提交商店票据(第一阶段商店未接入,返回明确提示)") + /** + * 提交商店票据:服务端校验 → 建单 → 发权益。 + * + *

body:`platform`(GOOGLE/GOOGLE_PLAY)、`productId`(商店商品 id)、`receipt`(purchaseToken)。 + * 同一票据重复提交只交付一次;票据已绑定其他账号、或校验不通过时返回明确业务码且不产生订单。 + * 商店未配置(默认)时返回 `200003 尚未配置`,与第一阶段行为一致。 + */ + @Operation(summary = "提交商店票据(校验后建单并发权益,重复提交幂等)") @PostMapping("/orders/verify") public Result verify(@RequestBody Map body) { return Result.ok(orderService.verifyAndDeliver(SecurityUtils.getAppUserId(), - body.getOrDefault("platform", ""), body.getOrDefault("receipt", ""))); + body.getOrDefault("platform", ""), + body.getOrDefault("productId", ""), + body.getOrDefault("receipt", ""))); } } diff --git a/fruitshorts-app-api/src/main/resources/application.yml b/fruitshorts-app-api/src/main/resources/application.yml index e146d1d..354e44b 100644 --- a/fruitshorts-app-api/src/main/resources/application.yml +++ b/fruitshorts-app-api/src/main/resources/application.yml @@ -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 diff --git a/fruitshorts-app-api/src/test/java/com/fruitshorts/app/controller/AppOrdersVerifyContractTest.java b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/controller/AppOrdersVerifyContractTest.java new file mode 100644 index 0000000..5098e68 --- /dev/null +++ b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/controller/AppOrdersVerifyContractTest.java @@ -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 行为不变"。 + * + *

业务规则在 `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; + } + } +} diff --git a/fruitshorts-module-commerce/pom.xml b/fruitshorts-module-commerce/pom.xml index e7ac890..e39d3df 100644 --- a/fruitshorts-module-commerce/pom.xml +++ b/fruitshorts-module-commerce/pom.xml @@ -34,5 +34,11 @@ com.fruitshorts fruitshorts-module-playback + + + org.springframework.boot + spring-boot-starter-test + test + diff --git a/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/config/StoreProperties.java b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/config/StoreProperties.java new file mode 100644 index 0000000..e11dcdd --- /dev/null +++ b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/config/StoreProperties.java @@ -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; + +/** + * 商店票据校验配置。 + * + *

凭据只从配置/环境变量读,**不入库、不提交到仓库、不打进镜像**: + *

+ * 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
+ * 
+ * `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; +} diff --git a/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/service/DisabledStoreVerifier.java b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/service/DisabledStoreVerifier.java index 3b59a92..41eed01 100644 --- a/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/service/DisabledStoreVerifier.java +++ b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/service/DisabledStoreVerifier.java @@ -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; /** * 未接入商店时的默认实现:明确报"未配置",而不是静默放行或假装成功。 * 这样客户端拿到的是可理解的业务提示,也不会产生假订单。 + * + *

由 `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 商店校验尚未配置,第一阶段仅预留接口"); } diff --git a/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/service/OrderService.java b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/service/OrderService.java index 65c54df..a070d4c 100644 --- a/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/service/OrderService.java +++ b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/service/OrderService.java @@ -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 会抛出明确异常,不会产生假订单 —— 这是刻意的: - * 宁可不收钱,也不能在支付链路上给出"看起来成功"的假象。 + * 客户端提交商店票据:**校验 → 建单 → 发权益**,一步都不能省。 + * + *

幂等与防重放: + *

*/ @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.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.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 products = productMapper.selectList(Wrappers.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)); } /** 后台补发:把已支付未交付的订单重新交付;已交付时幂等返回。 */ diff --git a/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/service/StoreVerifier.java b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/service/StoreVerifier.java index 1792b31..24d1cf1 100644 --- a/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/service/StoreVerifier.java +++ b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/service/StoreVerifier.java @@ -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 即可, - * 订单与权益链路不需要改动。 + *

实现由 `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); } } } diff --git a/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/service/store/GooglePlayPurchaseClient.java b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/service/store/GooglePlayPurchaseClient.java new file mode 100644 index 0000000..526e3a1 --- /dev/null +++ b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/service/store/GooglePlayPurchaseClient.java @@ -0,0 +1,40 @@ +// Author: fruit.com.cn + +package com.fruitshorts.commerce.service.store; + +import java.time.LocalDateTime; + +/** + * Google Play Developer API 的取数端口。 + * + *

抽成端口是为了可测:单测用替身直接喂"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()); + } + } +} diff --git a/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/service/store/GooglePlayStoreVerifier.java b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/service/store/GooglePlayStoreVerifier.java new file mode 100644 index 0000000..28d3aa5 --- /dev/null +++ b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/service/store/GooglePlayStoreVerifier.java @@ -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 票据校验实现。 + * + *

只有 `fruitshorts.commerce.store.verifier=google-play` 时才装配(默认 disabled,保持原有对外行为)。 + * 校验口径: + *

    + *
  • platform 必须是 Google(`GOOGLE` / `GOOGLE_PLAY` 都会归一化);
  • + *
  • Google 返回 `purchaseState == 0`(0=已购买,1=已取消,2=待处理);
  • + *
  • 返回的 `productId` 必须与请求一致 —— 防止"拿 A 商品的票据冒充 B 商品"。
  • + *
+ * 结果里的 `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 校验通过"); + } +} diff --git a/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/service/store/HttpGooglePlayPurchaseClient.java b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/service/store/HttpGooglePlayPurchaseClient.java new file mode 100644 index 0000000..41dd2d6 --- /dev/null +++ b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/service/store/HttpGooglePlayPurchaseClient.java @@ -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 依赖**。 + * + *

两步走: + *

    + *
  1. 服务账号私钥签 RS256 的 JWT,向 {@code oauth2.googleapis.com/token} 换 access_token;
  2. + *
  3. `GET .../purchases/products/{productId}/tokens/{token}` 查购买状态。
  4. + *
+ * 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 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("") : ""; + } +} diff --git a/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/service/store/StorePlatforms.java b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/service/store/StorePlatforms.java new file mode 100644 index 0000000..80d31cd --- /dev/null +++ b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/service/store/StorePlatforms.java @@ -0,0 +1,39 @@ +// Author: fruit.com.cn + +package com.fruitshorts.commerce.service.store; + +import java.util.Locale; + +/** + * 商店平台取值。 + * + *

入库口径按建表注释:`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)); + } +} diff --git a/fruitshorts-module-commerce/src/test/java/com/fruitshorts/commerce/service/OrderServiceVerifyTest.java b/fruitshorts-module-commerce/src/test/java/com/fruitshorts/commerce/service/OrderServiceVerifyTest.java new file mode 100644 index 0000000..88076c1 --- /dev/null +++ b/fruitshorts-module-commerce/src/test/java/com/fruitshorts/commerce/service/OrderServiceVerifyTest.java @@ -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; + +/** + * 票据校验闭环:**校验 → 建单 → 发权益**,以及幂等与防重放。 + * + *

用替身 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 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 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 captureInsertedOrder() { + AtomicReference 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; + } + } +} diff --git a/fruitshorts-module-commerce/src/test/java/com/fruitshorts/commerce/service/store/GooglePlayStoreVerifierTest.java b/fruitshorts-module-commerce/src/test/java/com/fruitshorts/commerce/service/store/GooglePlayStoreVerifierTest.java new file mode 100644 index 0000000..0bd32b3 --- /dev/null +++ b/fruitshorts-module-commerce/src/test/java/com/fruitshorts/commerce/service/store/GooglePlayStoreVerifierTest.java @@ -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 返回之后怎么判定",用替身客户端喂数据,不联网。 + * + *

真调 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; + } + } +}