diff --git a/docs/api-phase1.md b/docs/api-phase1.md index d8c3031..03e812d 100644 --- a/docs/api-phase1.md +++ b/docs/api-phase1.md @@ -256,6 +256,10 @@ POST /app/auth/register 重复/并发提交由数据库兜底;票据审计写 `store_receipt`(唯一索引 `uk_store_receipt(platform, transaction_id)`), B1-2 的退款/拒付复验依赖它。服务端凭据(`packageName` + service account JSON)走配置/环境变量,不入库。 +**恢复购买**:客户端换设备后重新提交同一个 `purchaseToken`(或调用 `GET /app/orders` 拉已购列表), +服务端按 `(platform, storeTransactionId)` 识别"权益已发过",**返回既有订单、不重复发币**; +若商店侧有购买而本地没有订单(例如换机前没同步),会**按校验结果补单落库**再发放,不需要人工介入。 + ### 1.9 创作者(下一批) | 方法 | 路径 | 说明 | @@ -417,6 +421,23 @@ B1-2 的退款/拒付复验依赖它。服务端凭据(`packageName` + service | POST | `/admin/wallets/coins` | `wallet:update` | 人工补币/扣回,必须带幂等键,先校验用户存在 | | POST | `/admin/reconcile/run` · GET `/admin/reconcile/page` | `reconcile:run` / `order:list` | 手动对账与历史(默认每天 04:20 自动跑前一天) | +**对账口径(B1-2 起生效,字段名不变,新增两个字段)** + +| 字段 | 含义 | +| --- | --- | +| `totalCount` | 当期本地有效订单数(排除 `CREATED`/`CLOSED`) | +| `matchedCount` | 其中能在 `store_receipt` 找到同一 `(platform, transaction_id)` 的笔数 | +| `diffCount` | 差异笔数 = 本地缺票据 + 票据无本地订单 + **商店已作废但本地无单** | +| `amountSum` | 本地订单金额合计(**本地口径**,不是商店结算口径) | +| `storeAmountSum` | 商店侧结算金额。Developer API 不提供(要向 Play Console 财务报告取)→ **无数据源时为 `null`** | +| `notes` | 说明差异构成、本次退款回收情况、以及"缺哪些数据源"。**契约强制:取不到的指标用 `null` + `notes` 说明,不许用 0 冒充** | +| `status` | 商店侧调用失败时为 `FAILED`(不会写成 `SUCCESS` + 一堆 0) | + +`POST /admin/reconcile/run` 同时会**拉取商店已作废交易并回收权益与金币**(退款/拒付回收), +回收是幂等的:同一笔作废交易重复出现在时间窗里,只回收一次。 +口径细节:Google 一次性商品的作废是**整笔**,拿不到部分金额,因此按整单回收; +找不到本地订单的作废交易会计入 `diffCount` 并在 `notes` 里点名。 + ### 2.7 创作者与内容审核(已实现) | 方法 | 路径 | 权限点 | 说明 | diff --git a/fruitshorts-admin-api/src/test/java/com/fruitshorts/admin/controller/ReconcileAdminControllerContractTest.java b/fruitshorts-admin-api/src/test/java/com/fruitshorts/admin/controller/ReconcileAdminControllerContractTest.java new file mode 100644 index 0000000..9cd210b --- /dev/null +++ b/fruitshorts-admin-api/src/test/java/com/fruitshorts/admin/controller/ReconcileAdminControllerContractTest.java @@ -0,0 +1,99 @@ +// Author: fruit.com.cn + +package com.fruitshorts.admin.controller; + +import com.fruitshorts.admin.support.ControllerContractSupport; +import com.fruitshorts.commerce.dal.entity.ReconcileTask; +import com.fruitshorts.commerce.service.ReconcileService; +import com.fruitshorts.common.pojo.PageResult; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.test.web.servlet.MockMvc; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +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; + +/** + * `/admin/reconcile/*` 接入层契约(api-phase1.md §2.6 已冻结):路由、参数绑定、统一响应体, + * 以及"取不到的指标是 null 而不是 0"这条硬口径在 JSON 里确实体现出来。 + */ +class ReconcileAdminControllerContractTest { + + private ReconcileService reconcileService; + private MockMvc mockMvc; + + @BeforeEach + void setUp() { + reconcileService = mock(ReconcileService.class); + mockMvc = ControllerContractSupport.mockMvcOf(new ReconcileAdminController(reconcileService)); + } + + @Test + void should_pass_date_and_platform_when_manual_reconcile_is_triggered() throws Exception { + given(reconcileService.run(eq(LocalDate.of(2026, 10, 15)), eq("GOOGLE"))) + .willReturn(task()); + + mockMvc.perform(post("/admin/reconcile/run") + .param("bizDate", "2026-10-15") + .param("platform", "GOOGLE")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.bizDate").value("2026-10-15")) + .andExpect(jsonPath("$.data.platform").value("GOOGLE")) + .andExpect(jsonPath("$.data.diffCount").value(2)) + // 契约强制:商店侧结算金额无数据源 → null(不是 0) + .andExpect(jsonPath("$.data.storeAmountSum").value(org.hamcrest.Matchers.nullValue())) + .andExpect(jsonPath("$.data.notes").value(org.hamcrest.Matchers.containsString("财务报告"))); + + verify(reconcileService).run(LocalDate.of(2026, 10, 15), "GOOGLE"); + } + + @Test + void should_default_to_yesterday_when_biz_date_is_absent() throws Exception { + given(reconcileService.run(any(), any())).willReturn(task()); + + mockMvc.perform(post("/admin/reconcile/run")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)); + + // 不传 bizDate → 传 null 给 service(由 service 决定"默认前一天") + verify(reconcileService).run(eq(null), eq(null)); + } + + @Test + void should_return_paged_history_when_page_is_requested() throws Exception { + given(reconcileService.page(1, 10)).willReturn(new PageResult<>(1L, List.of(task()))); + + mockMvc.perform(get("/admin/reconcile/page").param("pageIndex", "1").param("pageSize", "10")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.total").value(1)) + .andExpect(jsonPath("$.data.list[0].status").value("SUCCESS")); + } + + private ReconcileTask task() { + ReconcileTask task = new ReconcileTask(); + task.setId(1L); + task.setBizDate(LocalDate.of(2026, 10, 15)); + task.setPlatform("GOOGLE"); + task.setTotalCount(3); + task.setMatchedCount(1); + task.setDiffCount(2); + task.setAmountSum(new BigDecimal("14.97")); + task.setStoreAmountSum(null); + task.setStatus(ReconcileService.STATUS_SUCCESS); + task.setNotes("商店结算金额/手续费/税需从 Play Console 财务报告获取,故 storeAmountSum 为 null。"); + return task; + } +} diff --git a/fruitshorts-admin-api/src/test/java/com/fruitshorts/admin/support/ControllerContractSupport.java b/fruitshorts-admin-api/src/test/java/com/fruitshorts/admin/support/ControllerContractSupport.java new file mode 100644 index 0000000..1a39d5c --- /dev/null +++ b/fruitshorts-admin-api/src/test/java/com/fruitshorts/admin/support/ControllerContractSupport.java @@ -0,0 +1,43 @@ +// Author: fruit.com.cn +package com.fruitshorts.admin.support; + +import com.fruitshorts.framework.log.AccessDeniedRecorder; +import com.fruitshorts.framework.web.GlobalExceptionHandler; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.databind.json.JsonMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.mockito.Mockito; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +/** + * 控制器契约测试的公共装配:真控制器 + 真全局异常处理器 + 真校验器,**不启 Spring 容器**。 + * + *

为什么不用 {@code @WebMvcTest}:{@code AppApiApplication} 上挂着 + * {@code @MapperScan("com.fruitshorts.**.dal.mapper")},而切片测试不装配 DataSource / MyBatis, + * 扫描出的 Mapper 工厂会因为缺少 {@code SqlSessionFactory} 直接报错;要绕开就得再加一套测试专用的 + * 引导配置。这里的 standaloneSetup 同样只装 Web 层(路由、参数绑定、消息转换、异常处理), + * 覆盖面不变而更轻、更稳。 + */ +public final class ControllerContractSupport { + + private ControllerContractSupport() { + } + + /** 组装一个只含指定控制器与全局异常处理的 MockMvc。 */ + @SuppressWarnings("unchecked") + public static MockMvc mockMvcOf(Object controller) { + AccessDeniedRecorder recorder = new AccessDeniedRecorder(Mockito.mock(ObjectProvider.class)); + return MockMvcBuilders.standaloneSetup(controller) + .setControllerAdvice(new GlobalExceptionHandler(recorder)) + // 与 Spring Boot 的 Jackson 配置对齐:时间按 ISO 字符串输出而不是数组, + // 否则契约测试断言 LocalDate/LocalDateTime 时会看到 [2026,10,15] 这种形态。 + .setMessageConverters(new MappingJackson2HttpMessageConverter(JsonMapper.builder() + .addModule(new JavaTimeModule()) + .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) + .build())) + .build(); + } +} diff --git a/fruitshorts-app-api/src/test/java/com/fruitshorts/app/support/ControllerContractSupport.java b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/support/ControllerContractSupport.java index 674f493..7ab4da5 100644 --- a/fruitshorts-app-api/src/test/java/com/fruitshorts/app/support/ControllerContractSupport.java +++ b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/support/ControllerContractSupport.java @@ -3,8 +3,12 @@ package com.fruitshorts.app.support; import com.fruitshorts.framework.log.AccessDeniedRecorder; import com.fruitshorts.framework.web.GlobalExceptionHandler; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.databind.json.JsonMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import org.mockito.Mockito; import org.springframework.beans.factory.ObjectProvider; +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.setup.MockMvcBuilders; @@ -28,6 +32,12 @@ public final class ControllerContractSupport { AccessDeniedRecorder recorder = new AccessDeniedRecorder(Mockito.mock(ObjectProvider.class)); return MockMvcBuilders.standaloneSetup(controller) .setControllerAdvice(new GlobalExceptionHandler(recorder)) + // 与 Spring Boot 的 Jackson 配置对齐:时间按 ISO 字符串输出而不是数组, + // 否则契约测试断言 LocalDate/LocalDateTime 时会看到 [2026,10,15] 这种形态。 + .setMessageConverters(new MappingJackson2HttpMessageConverter(JsonMapper.builder() + .addModule(new JavaTimeModule()) + .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) + .build())) .build(); } } diff --git a/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/dal/entity/ReconcileTask.java b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/dal/entity/ReconcileTask.java index 960afc4..f2a34d5 100644 --- a/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/dal/entity/ReconcileTask.java +++ b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/dal/entity/ReconcileTask.java @@ -24,6 +24,10 @@ public class ReconcileTask { private Integer matchedCount; private Integer diffCount; private BigDecimal amountSum; + /** 商店侧结算金额;**无数据源时为 null**(绝不用 0 冒充)。 */ + private BigDecimal storeAmountSum; + /** 对账说明:缺哪些数据源、差异构成、本次退款回收情况。 */ + private String notes; /** RUNNING/SUCCESS/FAILED */ private String status; private String detail; 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 a070d4c..2122b53 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 @@ -301,10 +301,38 @@ public class OrderService { if (STATUS_REFUNDED.equals(order.getStatus())) { throw new ServiceException(ErrorCode.OPERATION_NOT_ALLOWED, "该订单已退款"); } + return refundInternal(order, reason, operatorId, "ADMIN"); + } + + /** + * 商店回调(退款 / 拒付 / 撤销)触发的回收,**幂等**: + * 订单已退款就直接返回,不二次回收权益、不二次扣币、不写第二条流水。 + * + *

与人工 {@link #refund} 的区别只在"已退款"的处理:人工入口继续报错(保持既有契约与前端提示), + * 商店回调必须容忍重复(Google 的作废列表会反复出现在时间窗里)。 + */ + @Transactional(rollbackFor = Exception.class) + public Orders refundByStoreCallback(String orderNo, String reason) { + Orders order = getExisting(orderNo); + if (STATUS_REFUNDED.equals(order.getStatus())) { + log.info("订单 {} 已退款,忽略重复的商店回调", orderNo); + return order; + } + if (!STATUS_PAID.equals(order.getStatus()) && !STATUS_DELIVERED.equals(order.getStatus())) { + // 未支付/已关闭的订单没什么可回收的,记录下来即可,不当成异常 + log.warn("订单 {} 状态为 {},商店回调无可回收内容", orderNo, order.getStatus()); + return order; + } + return refundInternal(order, reason, null, "STORE"); + } + + /** 退款主体:人工与商店回调共用,只有 source 不同。 */ + private Orders refundInternal(Orders order, String reason, Long operatorId, String source) { if (!STATUS_PAID.equals(order.getStatus()) && !STATUS_DELIVERED.equals(order.getStatus())) { throw new ServiceException(ErrorCode.OPERATION_NOT_ALLOWED, "只有已支付或已交付的订单可以退款,当前状态:" + order.getStatus()); } + String orderNo = order.getOrderNo(); String from = order.getStatus(); int revoked = entitlementService.revokeByOrderNo(orderNo, reason); String clawback = "未涉及金币"; @@ -323,7 +351,7 @@ public class OrderService { order.setRefundAt(LocalDateTime.now()); order.setRefundReason(reason == null ? "" : reason); ordersMapper.updateById(order); - recordEvent(order, "REFUND", from, STATUS_REFUNDED, "ADMIN", operatorId, + recordEvent(order, "REFUND", from, STATUS_REFUNDED, source, operatorId, "回收权益 " + revoked + " 条;" + clawback); return order; } diff --git a/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/service/ReconcileService.java b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/service/ReconcileService.java index 61758cc..e3d305a 100644 --- a/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/service/ReconcileService.java +++ b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/service/ReconcileService.java @@ -12,26 +12,35 @@ import com.fruitshorts.commerce.dal.mapper.OrdersMapper; import com.fruitshorts.commerce.dal.mapper.ReconcileTaskMapper; import com.fruitshorts.commerce.dal.mapper.StoreReceiptMapper; import com.fruitshorts.common.pojo.PageResult; -import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.ObjectProvider; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; import java.math.BigDecimal; import java.time.LocalDate; import java.time.LocalDateTime; +import java.util.HashSet; import java.util.List; +import java.util.Set; /** - * 对账:把本地订单与商店票据比对,差异进人工处理队列。 + * 对账:把本地订单与商店侧回执比对,差异进人工处理队列,并顺带执行退款回收。 * - * 第一阶段商店未接入,票据表是空的,所以差异必然等于订单数 —— - * 这正是要暴露的:接入商店后这个数字必须归零,否则就是在错账。 + *

口径(与 `docs/api-phase1.md` §2.6 一致,改口径必须同步契约): + *

*/ @Slf4j @Service -@RequiredArgsConstructor public class ReconcileService { public static final String STATUS_SUCCESS = "SUCCESS"; @@ -40,11 +49,22 @@ public class ReconcileService { private final ReconcileTaskMapper reconcileTaskMapper; private final OrdersMapper ordersMapper; private final StoreReceiptMapper storeReceiptMapper; + /** 只有配了 Google 才有退款回收能力;用 ObjectProvider 让两种配置都能启动。 */ + private final ObjectProvider storeRefundServiceProvider; + + public ReconcileService(ReconcileTaskMapper reconcileTaskMapper, OrdersMapper ordersMapper, + StoreReceiptMapper storeReceiptMapper, + ObjectProvider storeRefundServiceProvider) { + this.reconcileTaskMapper = reconcileTaskMapper; + this.ordersMapper = ordersMapper; + this.storeReceiptMapper = storeReceiptMapper; + this.storeRefundServiceProvider = storeRefundServiceProvider; + } @Transactional(rollbackFor = Exception.class) public ReconcileTask run(LocalDate bizDate, String platform) { LocalDate date = bizDate == null ? LocalDate.now().minusDays(1) : bizDate; - String scope = platform == null || platform.isBlank() ? "ALL" : platform; + String scope = StringUtils.hasText(platform) ? platform.trim().toUpperCase() : "ALL"; LocalDateTime from = date.atStartOfDay(); LocalDateTime to = date.plusDays(1).atStartOfDay(); @@ -57,34 +77,101 @@ public class ReconcileService { .lt(StoreReceipt::getCreatedAt, to) .eq(!"ALL".equals(scope), StoreReceipt::getPlatform, scope)); + int effectiveCount = 0; BigDecimal amountSum = BigDecimal.ZERO; - int paidCount = 0; + Set orderTxns = new HashSet<>(); for (Orders order : orders) { - if (order.getAmount() != null && !OrderService.STATUS_CREATED.equals(order.getStatus()) - && !OrderService.STATUS_CLOSED.equals(order.getStatus())) { + boolean effective = !OrderService.STATUS_CREATED.equals(order.getStatus()) + && !OrderService.STATUS_CLOSED.equals(order.getStatus()); + if (!effective) { + continue; + } + effectiveCount++; + if (order.getAmount() != null) { amountSum = amountSum.add(order.getAmount()); - paidCount++; + } + if (StringUtils.hasText(order.getStoreTransactionId())) { + orderTxns.add(order.getStoreTransactionId()); } } - int diff = Math.abs(paidCount - receipts.size()); + Set receiptTxns = new HashSet<>(); + for (StoreReceipt receipt : receipts) { + if (StringUtils.hasText(receipt.getTransactionId())) { + receiptTxns.add(receipt.getTransactionId()); + } + } + Set matched = new HashSet<>(orderTxns); + matched.retainAll(receiptTxns); + Set orderOnly = new HashSet<>(orderTxns); + orderOnly.removeAll(receiptTxns); + Set receiptOnly = new HashSet<>(receiptTxns); + receiptOnly.removeAll(orderTxns); + + // 商店侧:拉已作废交易并回收权益(未配置商店时为 null,绝不写 0 冒充) + StoreRefundService refundService = storeRefundServiceProvider.getIfAvailable(); + StoreRefundService.RecoverResult recover = null; + boolean storeConfigured = refundService != null; + String failure = ""; + if (storeConfigured) { + try { + recover = refundService.recoverVoidedPurchases(scope, date); + } catch (Exception ex) { + failure = ex.getMessage() == null ? ex.getClass().getSimpleName() : ex.getMessage(); + log.error("对账期间拉取商店退款失败:{}", failure); + } + } + int unmatchedVoided = recover == null ? 0 : Math.max(0, recover.unmatched()); + int diff = orderOnly.size() + receiptOnly.size() + unmatchedVoided; + ReconcileTask task = new ReconcileTask(); task.setBizDate(date); task.setPlatform(scope); - task.setTotalCount(paidCount); - task.setMatchedCount(Math.min(paidCount, receipts.size())); + task.setTotalCount(effectiveCount); + task.setMatchedCount(matched.size()); task.setDiffCount(diff); task.setAmountSum(amountSum); - task.setStatus(STATUS_SUCCESS); - task.setDetail("本地有效订单 " + paidCount + " 笔 / 商店票据 " + receipts.size() - + " 张,差异 " + diff + " 笔。" - + (diff > 0 ? "商店尚未接入时差异属预期,接入后必须归零。" : "")); + // 商店结算金额要向 Play Console 财务报告取,Developer API 不提供 → null + notes + task.setStoreAmountSum(null); + task.setStatus(StringUtils.hasText(failure) ? STATUS_FAILED : STATUS_SUCCESS); + task.setDetail("本地有效订单 " + effectiveCount + " 笔、金额 " + amountSum + + ";票据 " + receipts.size() + " 张;一致 " + matched.size() + " 笔;差异 " + diff + " 笔"); + task.setNotes(buildNotes(storeConfigured, recover, orderOnly.size(), receiptOnly.size(), + unmatchedVoided, failure)); task.setStartedAt(LocalDateTime.now()); task.setFinishedAt(LocalDateTime.now()); reconcileTaskMapper.insert(task); - log.info("对账完成 {} {}:订单 {} 票据 {} 差异 {}", scope, date, paidCount, receipts.size(), diff); + log.info("对账完成 {} {}:订单 {} 票据 {} 一致 {} 差异 {} 状态 {}", + scope, date, effectiveCount, receipts.size(), matched.size(), diff, task.getStatus()); return task; } + /** 把"缺什么数据源"写清楚,这是契约要求的 notes(不是可选装饰)。 */ + private String buildNotes(boolean storeConfigured, StoreRefundService.RecoverResult recover, + int orderOnly, int receiptOnly, int unmatchedVoided, String failure) { + StringBuilder notes = new StringBuilder(); + notes.append("差异构成:本地缺票据 ").append(orderOnly) + .append(" 笔、票据无本地订单 ").append(receiptOnly).append(" 笔"); + if (unmatchedVoided > 0) { + notes.append("、商店已作废但本地无单 ").append(unmatchedVoided).append(" 笔"); + } + notes.append("。"); + if (!storeConfigured) { + notes.append("商店侧数据源未配置(store.verifier=disabled),本次只做本地票据一致性核对;") + .append("商店结算金额与退款列表均无数据源。"); + } else if (StringUtils.hasText(failure)) { + notes.append("商店侧调用失败:").append(failure).append(";本次商店相关指标不可信,请重跑。"); + } else if (recover == null) { + notes.append("商店凭据不完整(缺 packageName),未拉取退款列表。"); + } else { + notes.append("商店侧已作废交易 ").append(recover.voidedTotal()).append(" 笔:回收 ") + .append(recover.recovered()).append(" 笔、已退款跳过 ") + .append(recover.skipped()).append(" 笔。"); + } + notes.append("商店结算金额/手续费/税需从 Play Console 财务报告(或 Cloud Storage 报表)获取,") + .append("Developer API 不提供,故 storeAmountSum 为 null。"); + return notes.toString(); + } + /** 每天 04:20 自动对前一天,避免人工忘记。 */ @Scheduled(cron = "${fruitshorts.commerce.reconcile-cron:0 20 4 * * *}") public void scheduled() { diff --git a/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/service/StoreRefundService.java b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/service/StoreRefundService.java new file mode 100644 index 0000000..73c9a9c --- /dev/null +++ b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/service/StoreRefundService.java @@ -0,0 +1,119 @@ +// Author: fruit.com.cn + +package com.fruitshorts.commerce.service; + +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.fruitshorts.commerce.config.StoreProperties; +import com.fruitshorts.commerce.dal.entity.Orders; +import com.fruitshorts.commerce.dal.mapper.OrdersMapper; +import com.fruitshorts.commerce.service.store.GooglePlayPurchaseClient; +import com.fruitshorts.commerce.service.store.StorePlatforms; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.List; + +/** + * 商店退款 / 拒付回收。 + * + *

由对账(每日 04:20 定时 + 后台手动 run)驱动:拉取 Google 的"已作废交易", + * 按 `(platform, orderId)` 找回本地订单并**幂等回收**权益与金币。 + * + *

口径说明(写进回执与契约文档): + *

    + *
  • 一次性商品的作废是**整笔**,Google 不返回部分金额 → 本轮按整单回收;
  • + *
  • 找不到本地订单的作废交易属于**差异**,会计入对账的 diffCount,不静默丢掉;
  • + *
  • 未配置 Google 凭据时**不调用商店**,返回 null 交由对账写 notes 说明"缺数据源"。
  • + *
+ */ +@Slf4j +@Service +@RequiredArgsConstructor +@ConditionalOnProperty(name = "fruitshorts.commerce.store.verifier", havingValue = "google-play") +public class StoreRefundService { + + private final StoreProperties properties; + private final GooglePlayPurchaseClient purchaseClient; + private final OrdersMapper ordersMapper; + private final OrderService orderService; + + /** 回收结果:回收了几笔、跳过几笔(已退款)、几笔在本地找不到(差异)。 */ + public record RecoverResult(int recovered, int skipped, int unmatched, int voidedTotal) { + + public static RecoverResult unavailable() { + return new RecoverResult(-1, -1, -1, -1); + } + } + + /** + * 拉取并回收指定日期(本地时区整天)的已作废交易。 + * + * @return null 表示"没有可用数据源"(未配置凭据),调用方必须用 null + notes,不能当 0 + */ + public RecoverResult recoverVoidedPurchases(String platform, LocalDate bizDate) { + if (!StorePlatforms.isGoogle(platform) && !"ALL".equalsIgnoreCase(platform)) { + // Apple 等其它渠道本阶段没有退款数据源 + return null; + } + if (!StringUtils.hasText(properties.getGooglePlayPackageName())) { + log.info("未配置 Google Play packageName,跳过退款回收"); + return null; + } + ZoneId zone = ZoneId.systemDefault(); + long start = bizDate.atStartOfDay(zone).toInstant().toEpochMilli(); + long end = bizDate.plusDays(1).atStartOfDay(zone).toInstant().toEpochMilli() - 1; + List voided = + purchaseClient.listVoidedPurchases(properties.getGooglePlayPackageName(), start, end); + + int recovered = 0; + int skipped = 0; + int unmatched = 0; + for (GooglePlayPurchaseClient.VoidedPurchase item : voided) { + Orders order = findOrder(item.orderId()); + if (order == null) { + unmatched++; + log.warn("商店已作废但本地没有对应订单:orderId={}", item.orderId()); + continue; + } + String before = order.getStatus(); + Orders after = orderService.refundByStoreCallback(order.getOrderNo(), reasonOf(item)); + if (before.equals(after.getStatus())) { + skipped++; + } else { + recovered++; + } + } + log.info("退款回收 {} {}:作废 {} 笔,回收 {} 笔,已退款跳过 {} 笔,本地无单 {} 笔", + platform, bizDate, voided.size(), recovered, skipped, unmatched); + return new RecoverResult(recovered, skipped, unmatched, voided.size()); + } + + private Orders findOrder(String storeTransactionId) { + if (!StringUtils.hasText(storeTransactionId)) { + return null; + } + return ordersMapper.selectOne(Wrappers.lambdaQuery() + .eq(Orders::getPlatform, StorePlatforms.GOOGLE) + .eq(Orders::getStoreTransactionId, storeTransactionId) + .last("limit 1")); + } + + /** 把 Google 的原因码翻成可读的中文,写进订单的退款原因与流水。 */ + private String reasonOf(GooglePlayPurchaseClient.VoidedPurchase item) { + String source = switch (item.voidedSource()) { + case 0 -> "用户退款"; + case 1 -> "系统作废"; + case 2 -> "开发者撤销"; + default -> "商店作废"; + }; + LocalDateTime at = item.voidedTime(); + return source + "(Google reason=" + item.voidedReason() + ",时间=" + + (at == null ? "未知" : at.toString()) + ")"; + } +} 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 index 526e3a1..28eef2b 100644 --- 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 @@ -3,6 +3,7 @@ package com.fruitshorts.commerce.service.store; import java.time.LocalDateTime; +import java.util.List; /** * Google Play Developer API 的取数端口。 @@ -19,6 +20,35 @@ public interface GooglePlayPurchaseClient { */ ProductPurchase fetchProductPurchase(String packageName, String productId, String purchaseToken); + /** + * 拉取"已作废(退款/撤销/拒付)"的交易,用于退款回收与对账。 + * + *

对应 Google 的 `purchases.voidedpurchases.list`:一次性商品的作废是**整笔**, + * 拿不到部分金额,所以本层只表达"这笔作废了"。 + * + * @param startMillis 起始时间(含),epoch 毫秒 + * @param endMillis 结束时间(含),epoch 毫秒 + */ + List listVoidedPurchases(String packageName, long startMillis, long endMillis); + + /** + * Google `purchases.voidedpurchases.list` 的单条记录。 + * + * @param voidedSource 0=用户主动退款,1=系统/风控,2=开发者撤销 + * @param voidedReason 具体原因码(Google 定义,原样保留便于排查) + */ + record VoidedPurchase(String orderId, String purchaseToken, long voidedTimeMillis, + int voidedSource, int voidedReason, String purchaseType) { + + /** 作废时间(本地时区);时间戳为 0 时返回 null。 */ + public LocalDateTime voidedTime() { + return voidedTimeMillis <= 0 + ? null + : LocalDateTime.ofInstant(java.time.Instant.ofEpochMilli(voidedTimeMillis), + java.time.ZoneId.systemDefault()); + } + } + /** * Google `purchases.products.get` 的返回里我们需要的字段。 * 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 index 28d3aa5..41f96ac 100644 --- 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 @@ -39,7 +39,10 @@ public class GooglePlayStoreVerifier implements StoreVerifier { @Override public StoreReceiptResult verify(String platform, String productId, String receipt) { if (!StorePlatforms.isGoogle(platform)) { - return StoreReceiptResult.failed("当前校验实现只支持 Google Play,收到的平台是:" + platform); + String hint = StorePlatforms.APPLE.equals(StorePlatforms.normalize(platform)) + ? "Apple IAP 尚未实现(印尼 iOS 占比低,契约已预留端口,B2 再实现)" + : "当前校验实现只支持 Google Play,收到的平台是:" + platform; + return StoreReceiptResult.failed(hint); } String packageName = properties.getGooglePlayPackageName(); if (!StringUtils.hasText(packageName)) { 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 index 41dd2d6..3dcfb74 100644 --- 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 @@ -27,7 +27,9 @@ import java.security.Signature; import java.security.spec.PKCS8EncodedKeySpec; import java.time.Duration; import java.time.Instant; +import java.util.ArrayList; import java.util.Base64; +import java.util.List; /** * 用 JDK 自带 {@link HttpClient} 调 Google Play Developer API,**不引入 Google SDK 依赖**。 @@ -47,8 +49,12 @@ 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 VOIDED_ENDPOINT = + "https://androidpublisher.googleapis.com/androidpublisher/v3/applications/%s/purchases/voidedpurchases"; private static final String SCOPE = "https://www.googleapis.com/auth/androidpublisher"; private static final long TOKEN_SKEW_SECONDS = 60; + /** 单页最多 1000 条(Google 上限),超过就翻页,避免只对了一半还以为对完了。 */ + private static final int VOIDED_PAGE_SIZE = 1000; private final StoreProperties properties; private final ObjectMapper objectMapper = new ObjectMapper(); @@ -64,6 +70,40 @@ public class HttpGooglePlayPurchaseClient implements GooglePlayPurchaseClient { .build(); } + @Override + public List listVoidedPurchases(String packageName, long startMillis, + long endMillis) { + List result = new ArrayList<>(); + String pageToken = ""; + do { + StringBuilder url = new StringBuilder(String.format(VOIDED_ENDPOINT, encode(packageName))) + .append("?startTime=").append(startMillis) + .append("&endTime=").append(endMillis) + .append("&maxResults=").append(VOIDED_PAGE_SIZE); + if (StringUtils.hasText(pageToken)) { + url.append("&token=").append(encode(pageToken)); + } + HttpRequest request = HttpRequest.newBuilder(URI.create(url.toString())) + .timeout(Duration.ofSeconds(properties.getGooglePlayTimeoutSeconds())) + .header("Authorization", "Bearer " + accessToken()) + .header("Accept", "application/json") + .GET() + .build(); + JsonNode body = send(request, "查询 Google Play 已作废交易失败"); + for (JsonNode item : body.path("voidedPurchases")) { + result.add(new VoidedPurchase( + text(item, "orderId"), + text(item, "purchaseToken"), + item.path("voidedTimeMillis").asLong(0L), + item.path("voidedSource").asInt(-1), + item.path("voidedReason").asInt(-1), + text(item, "purchaseType"))); + } + pageToken = body.path("tokenPagination").path("nextPageToken").asText(""); + } while (StringUtils.hasText(pageToken)); + return result; + } + @Override public ProductPurchase fetchProductPurchase(String packageName, String productId, String purchaseToken) { diff --git a/fruitshorts-module-commerce/src/test/java/com/fruitshorts/commerce/service/OrderServiceRefundTest.java b/fruitshorts-module-commerce/src/test/java/com/fruitshorts/commerce/service/OrderServiceRefundTest.java new file mode 100644 index 0000000..4629985 --- /dev/null +++ b/fruitshorts-module-commerce/src/test/java/com/fruitshorts/commerce/service/OrderServiceRefundTest.java @@ -0,0 +1,133 @@ +// 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.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.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 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.anyBoolean; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +/** + * 商店驱动的退款/拒付回收:回收一次、重复回调不二次回收,人工入口的既有契约不变。 + */ +class OrderServiceRefundTest { + + private static final String ORDER_NO = "FS202610161200000001"; + + private OrdersMapper ordersMapper; + private OrderEventMapper orderEventMapper; + private WalletService walletService; + private EntitlementService entitlementService; + private OrderService orderService; + + @BeforeEach + void setUp() { + ordersMapper = mock(OrdersMapper.class); + orderEventMapper = mock(OrderEventMapper.class); + walletService = mock(WalletService.class); + entitlementService = mock(EntitlementService.class); + orderService = new OrderService(ordersMapper, orderEventMapper, mock(ProductMapper.class), + mock(StoreReceiptMapper.class), mock(StoreVerifier.class), walletService, + entitlementService); + given(entitlementService.revokeByOrderNo(anyString(), any())).willReturn(1); + } + + @Test + void should_revoke_entitlement_and_claw_back_coins_when_store_reports_a_refund() { + given(ordersMapper.selectOne(any())).willReturn(deliveredOrder()); + + Orders order = orderService.refundByStoreCallback(ORDER_NO, "用户退款(Google reason=1)"); + + assertThat(order.getStatus()).isEqualTo(OrderService.STATUS_REFUNDED); + assertThat(order.getRefundAt()).isNotNull(); + assertThat(order.getRefundReason()).contains("用户退款"); + verify(entitlementService).revokeByOrderNo(eq(ORDER_NO), anyString()); + verify(walletService).change(eq(1001L), eq(-550L), eq(WalletService.BIZ_REFUND), + eq(ORDER_NO), eq("order:" + ORDER_NO + ":refund"), anyString(), eq(false), isNull()); + // 流水要写,而且来源标成 STORE,人工与商店回调在证据链上可区分 + ArgumentCaptor event = ArgumentCaptor.forClass(OrderEvent.class); + verify(orderEventMapper).insert(event.capture()); + assertThat(event.getValue().getEvent()).isEqualTo("REFUND"); + assertThat(event.getValue().getSource()).isEqualTo("STORE"); + assertThat(event.getValue().getPayload()).contains("回收权益 1 条"); + } + + @Test + void should_ignore_repeat_when_the_same_refund_callback_arrives_twice() { + Orders refunded = deliveredOrder(); + refunded.setStatus(OrderService.STATUS_REFUNDED); + given(ordersMapper.selectOne(any())).willReturn(refunded); + + Orders first = orderService.refundByStoreCallback(ORDER_NO, "用户退款"); + Orders second = orderService.refundByStoreCallback(ORDER_NO, "用户退款"); + + assertThat(first.getStatus()).isEqualTo(OrderService.STATUS_REFUNDED); + assertThat(second.getStatus()).isEqualTo(OrderService.STATUS_REFUNDED); + // 幂等的关键:不二次回收权益、不二次扣币、不写第二条流水、不改订单 + verify(entitlementService, never()).revokeByOrderNo(anyString(), any()); + verify(walletService, never()).change(any(), anyLong(), anyString(), anyString(), + anyString(), anyString(), anyBoolean(), any()); + verify(orderEventMapper, never()).insert(any(OrderEvent.class)); + verify(ordersMapper, never()).updateById(any(Orders.class)); + } + + @Test + void should_keep_the_manual_refund_contract_when_order_is_already_refunded() { + Orders refunded = deliveredOrder(); + refunded.setStatus(OrderService.STATUS_REFUNDED); + given(ordersMapper.selectOne(any())).willReturn(refunded); + + // 人工入口保持原契约:已退款要报错(前端据此提示),不能被"幂等"改成静默 + assertThatThrownBy(() -> orderService.refund(ORDER_NO, "重复操作", 1L)) + .isInstanceOf(ServiceException.class) + .satisfies(ex -> assertThat(((ServiceException) ex).getCode()) + .isEqualTo(ErrorCode.OPERATION_NOT_ALLOWED.getCode())) + .hasMessage("该订单已退款"); + } + + @Test + void should_do_nothing_when_a_voided_purchase_maps_to_an_unpaid_order() { + Orders created = deliveredOrder(); + created.setStatus(OrderService.STATUS_CREATED); + created.setCoinAmount(0L); + given(ordersMapper.selectOne(any())).willReturn(created); + + Orders order = orderService.refundByStoreCallback(ORDER_NO, "用户退款"); + + assertThat(order.getStatus()).isEqualTo(OrderService.STATUS_CREATED); + verify(entitlementService, never()).revokeByOrderNo(anyString(), any()); + verify(orderEventMapper, never()).insert(any(OrderEvent.class)); + } + + private Orders deliveredOrder() { + Orders order = new Orders(); + order.setId(1L); + order.setOrderNo(ORDER_NO); + order.setUserId(1001L); + order.setPlatform("GOOGLE"); + order.setStoreTransactionId("GPA.3311-00001"); + order.setCoinAmount(550L); + order.setStatus(OrderService.STATUS_DELIVERED); + return order; + } +} 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 index 88076c1..61dd368 100644 --- 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 @@ -179,6 +179,25 @@ class OrderServiceVerifyTest { .hasMessageContaining("不支持的商店平台"); } + @Test + void should_keep_the_original_delivery_when_the_purchase_is_restored_on_another_device() { + // 恢复购买:换设备重新提交同一票据(purchaseToken 不变 → 同一个 Google orderId) + Orders delivered = deliveredOrder(USER); + delivered.setDeliveredAt(LocalDateTime.of(2026, 10, 15, 9, 30)); + given(ordersMapper.selectOne(any())).willReturn(delivered); + verifier.result = okResult(); + + Orders restored = orderService.verifyAndDeliver(USER, "GOOGLE_PLAY", STORE_PRODUCT, TOKEN); + + assertThat(restored.getOrderNo()).isEqualTo(delivered.getOrderNo()); + assertThat(restored.getStatus()).isEqualTo(OrderService.STATUS_DELIVERED); + // 原有交付时间不被改写,说明没有二次发货 + assertThat(restored.getDeliveredAt()).isEqualTo(LocalDateTime.of(2026, 10, 15, 9, 30)); + verify(ordersMapper, never()).updateById(any(Orders.class)); + verify(storeReceiptMapper, never()).insert(any(StoreReceipt.class)); + verify(orderEventMapper, never()).insert(any(OrderEvent.class)); + } + private AtomicReference captureInsertedOrder() { AtomicReference saved = new AtomicReference<>(); given(ordersMapper.insert(any(Orders.class))).willAnswer(invocation -> { diff --git a/fruitshorts-module-commerce/src/test/java/com/fruitshorts/commerce/service/ReconcileServiceTest.java b/fruitshorts-module-commerce/src/test/java/com/fruitshorts/commerce/service/ReconcileServiceTest.java new file mode 100644 index 0000000..8e27200 --- /dev/null +++ b/fruitshorts-module-commerce/src/test/java/com/fruitshorts/commerce/service/ReconcileServiceTest.java @@ -0,0 +1,145 @@ +// Author: fruit.com.cn + +package com.fruitshorts.commerce.service; + +import com.fruitshorts.commerce.dal.entity.Orders; +import com.fruitshorts.commerce.dal.entity.ReconcileTask; +import com.fruitshorts.commerce.dal.entity.StoreReceipt; +import com.fruitshorts.commerce.dal.mapper.OrdersMapper; +import com.fruitshorts.commerce.dal.mapper.ReconcileTaskMapper; +import com.fruitshorts.commerce.dal.mapper.StoreReceiptMapper; +import com.fruitshorts.common.exception.ServiceException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.ObjectProvider; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; + +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; + +/** + * 对账口径:集合比对差异 + **取不到的指标用 null 并在 notes 说明**(契约强制,不许用 0 冒充)。 + */ +class ReconcileServiceTest { + + private static final LocalDate DATE = LocalDate.of(2026, 10, 15); + + private OrdersMapper ordersMapper; + private StoreReceiptMapper storeReceiptMapper; + private ReconcileTaskMapper reconcileTaskMapper; + private ObjectProvider provider; + private ReconcileService reconcileService; + private AtomicReference saved; + + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() { + ordersMapper = mock(OrdersMapper.class); + storeReceiptMapper = mock(StoreReceiptMapper.class); + reconcileTaskMapper = mock(ReconcileTaskMapper.class); + provider = mock(ObjectProvider.class); + reconcileService = new ReconcileService(reconcileTaskMapper, ordersMapper, + storeReceiptMapper, provider); + saved = new AtomicReference<>(); + given(reconcileTaskMapper.insert(any(ReconcileTask.class))).willAnswer(invocation -> { + saved.set(invocation.getArgument(0)); + return 1; + }); + } + + @Test + void should_return_null_store_amount_and_explain_the_missing_source_when_store_is_not_configured() { + given(provider.getIfAvailable()).willReturn(null); + given(ordersMapper.selectList(any())).willReturn(List.of(order("T1", "4.99"))); + given(storeReceiptMapper.selectList(any())).willReturn(List.of(receipt("T1"))); + + ReconcileTask task = reconcileService.run(DATE, "ALL"); + + assertThat(task.getTotalCount()).isEqualTo(1); + assertThat(task.getMatchedCount()).isEqualTo(1); + assertThat(task.getDiffCount()).isZero(); + assertThat(task.getAmountSum()).isEqualByComparingTo(new BigDecimal("4.99")); + assertThat(task.getStatus()).isEqualTo(ReconcileService.STATUS_SUCCESS); + // 契约强制:没有商店数据源 → null,不是 0 + assertThat(task.getStoreAmountSum()).isNull(); + assertThat(task.getNotes()).contains("数据源未配置"); + assertThat(task.getNotes()).contains("财务报告"); + } + + @Test + void should_count_differences_on_both_sides_when_sets_do_not_line_up() { + given(provider.getIfAvailable()).willReturn(null); + given(ordersMapper.selectList(any())).willReturn(List.of(order("T1", "4.99"))); + given(storeReceiptMapper.selectList(any())).willReturn(List.of(receipt("T2"))); + + ReconcileTask task = reconcileService.run(DATE, "ALL"); + + // 本地缺票据 1 笔 + 票据无本地订单 1 笔 + assertThat(task.getDiffCount()).isEqualTo(2); + assertThat(task.getMatchedCount()).isZero(); + assertThat(task.getNotes()).contains("本地缺票据 1 笔").contains("票据无本地订单 1 笔"); + } + + @Test + void should_recover_voided_purchases_and_report_them_in_notes_when_store_is_configured() { + StoreRefundService refundService = mock(StoreRefundService.class); + given(provider.getIfAvailable()).willReturn(refundService); + given(refundService.recoverVoidedPurchases("ALL", DATE)) + .willReturn(new StoreRefundService.RecoverResult(1, 0, 1, 2)); + given(ordersMapper.selectList(any())).willReturn(List.of(order("T1", "4.99"))); + given(storeReceiptMapper.selectList(any())).willReturn(List.of(receipt("T1"))); + + ReconcileTask task = reconcileService.run(DATE, "ALL"); + + // 回收 1 笔;另有 1 笔商店说作废、本地无单 → 计入差异 + assertThat(task.getDiffCount()).isEqualTo(1); + assertThat(task.getStoreAmountSum()).isNull(); + assertThat(task.getNotes()).contains("已作废交易 2 笔").contains("回收 1 笔") + .contains("商店已作废但本地无单 1 笔"); + } + + @Test + void should_mark_task_failed_and_keep_notes_when_the_store_call_throws() { + StoreRefundService refundService = mock(StoreRefundService.class); + given(provider.getIfAvailable()).willReturn(refundService); + given(refundService.recoverVoidedPurchases("ALL", DATE)) + .willThrow(new ServiceException("换取 Google access_token 失败")); + given(ordersMapper.selectList(any())).willReturn(List.of(order("T1", "4.99"))); + given(storeReceiptMapper.selectList(any())).willReturn(List.of(receipt("T1"))); + + ReconcileTask task = reconcileService.run(DATE, "ALL"); + + // 调商店失败必须显性失败,不能写成 SUCCESS + 一堆 0 + assertThat(task.getStatus()).isEqualTo(ReconcileService.STATUS_FAILED); + assertThat(task.getNotes()).contains("商店侧调用失败").contains("access_token"); + assertThat(task.getStoreAmountSum()).isNull(); + assertThat(task.getDiffCount()).isZero(); + } + + private Orders order(String transactionId, String amount) { + Orders order = new Orders(); + order.setId(1L); + order.setOrderNo("FS202610151200000001"); + order.setUserId(1001L); + order.setPlatform("GOOGLE"); + order.setStoreTransactionId(transactionId); + order.setAmount(new BigDecimal(amount)); + order.setStatus(OrderService.STATUS_DELIVERED); + return order; + } + + private StoreReceipt receipt(String transactionId) { + StoreReceipt receipt = new StoreReceipt(); + receipt.setId(1L); + receipt.setPlatform("GOOGLE"); + receipt.setTransactionId(transactionId); + receipt.setOrderNo("FS202610151200000001"); + return receipt; + } +} diff --git a/fruitshorts-module-commerce/src/test/java/com/fruitshorts/commerce/service/StoreRefundServiceTest.java b/fruitshorts-module-commerce/src/test/java/com/fruitshorts/commerce/service/StoreRefundServiceTest.java new file mode 100644 index 0000000..28b2370 --- /dev/null +++ b/fruitshorts-module-commerce/src/test/java/com/fruitshorts/commerce/service/StoreRefundServiceTest.java @@ -0,0 +1,142 @@ +// Author: fruit.com.cn + +package com.fruitshorts.commerce.service; + +import com.fruitshorts.commerce.config.StoreProperties; +import com.fruitshorts.commerce.dal.entity.Orders; +import com.fruitshorts.commerce.dal.mapper.OrdersMapper; +import com.fruitshorts.commerce.service.store.GooglePlayPurchaseClient; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +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; + +/** + * 退款回收:拉商店作废列表 → 幂等回收本地订单;找不到本地单要计入差异; + * 未配置商店时**返回 null**(由对账写 notes),不能假装"回收了 0 笔"。 + */ +class StoreRefundServiceTest { + + private static final LocalDate DATE = LocalDate.of(2026, 10, 15); + private static final String PACKAGE_NAME = "com.stormg.fruitshorts"; + + private StoreProperties properties; + private StubPurchaseClient client; + private OrdersMapper ordersMapper; + private OrderService orderService; + private StoreRefundService refundService; + + @BeforeEach + void setUp() { + properties = new StoreProperties(); + properties.setGooglePlayPackageName(PACKAGE_NAME); + client = new StubPurchaseClient(); + ordersMapper = mock(OrdersMapper.class); + orderService = mock(OrderService.class); + refundService = new StoreRefundService(properties, client, ordersMapper, orderService); + } + + @Test + void should_recover_matched_order_and_report_unmatched_when_store_lists_voided_purchases() { + client.voided = List.of( + new GooglePlayPurchaseClient.VoidedPurchase("GPA.3311-00001", "token-1", + 1760000000000L, 0, 1, "0"), + new GooglePlayPurchaseClient.VoidedPurchase("GPA.3311-99999", "token-9", + 1760000000000L, 1, 2, "0")); + Orders matched = order("GPA.3311-00001"); + given(ordersMapper.selectOne(any())).willReturn(matched, (Orders) null); + given(orderService.refundByStoreCallback(anyString(), anyString())).willAnswer(invocation -> { + matched.setStatus(OrderService.STATUS_REFUNDED); + return matched; + }); + + StoreRefundService.RecoverResult result = refundService.recoverVoidedPurchases("GOOGLE", DATE); + + assertThat(result.voidedTotal()).isEqualTo(2); + assertThat(result.recovered()).isEqualTo(1); + assertThat(result.skipped()).isZero(); + // 商店说有作废、本地没订单 → 必须计入差异,不能静默 + assertThat(result.unmatched()).isEqualTo(1); + // 回收原因要带可读来源与 Google 原因码,方便客服解释 + verify(orderService).refundByStoreCallback(eq(matched.getOrderNo()), + org.mockito.ArgumentMatchers.contains("用户退款")); + } + + @Test + void should_count_skipped_when_the_order_was_already_refunded() { + client.voided = List.of(new GooglePlayPurchaseClient.VoidedPurchase("GPA.3311-00001", + "token-1", 1760000000000L, 0, 1, "0")); + Orders refunded = order("GPA.3311-00001"); + refunded.setStatus(OrderService.STATUS_REFUNDED); + given(ordersMapper.selectOne(any())).willReturn(refunded); + // 已退款时 service 原样返回(状态不变) + given(orderService.refundByStoreCallback(anyString(), anyString())).willReturn(refunded); + + StoreRefundService.RecoverResult result = refundService.recoverVoidedPurchases("GOOGLE", DATE); + + assertThat(result.skipped()).isEqualTo(1); + assertThat(result.recovered()).isZero(); + assertThat(result.unmatched()).isZero(); + } + + @Test + void should_return_null_when_google_play_is_not_configured() { + properties.setGooglePlayPackageName(""); + + StoreRefundService.RecoverResult result = refundService.recoverVoidedPurchases("GOOGLE", DATE); + + assertThat(result).isNull(); + // 没配凭据就不该发请求 + assertThat(client.called).isFalse(); + } + + @Test + void should_return_null_when_platform_is_not_google() { + StoreRefundService.RecoverResult result = refundService.recoverVoidedPurchases("APPLE", DATE); + + assertThat(result).isNull(); + assertThat(client.called).isFalse(); + verify(ordersMapper, never()).selectOne(any()); + } + + private Orders order(String transactionId) { + Orders order = new Orders(); + order.setId(1L); + order.setOrderNo("FS202610151200000001"); + order.setUserId(1001L); + order.setPlatform("GOOGLE"); + order.setStoreTransactionId(transactionId); + order.setStatus(OrderService.STATUS_DELIVERED); + return order; + } + + /** 替身商店客户端:只回预设的作废列表,并记录有没有被调用过。 */ + private static final class StubPurchaseClient implements GooglePlayPurchaseClient { + private List voided = List.of(); + private boolean called; + + @Override + public List listVoidedPurchases(String packageName, long startMillis, + long endMillis) { + called = true; + return voided; + } + + @Override + public ProductPurchase fetchProductPurchase(String packageName, String productId, + String purchaseToken) { + throw new UnsupportedOperationException("本测试不校验购买"); + } + } +} 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 index 0bd32b3..2bb41e6 100644 --- 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 @@ -85,9 +85,18 @@ class GooglePlayStoreVerifierTest { } @Test - void should_reject_when_platform_is_not_google() { + void should_reject_with_apple_hint_when_platform_is_apple() { + // Apple 只留端口:给客户端的提示要说清"尚未实现",而不是含糊地报参数错 StoreVerifier.StoreReceiptResult result = verifier.verify("APPLE", PRODUCT_ID, TOKEN); + assertThat(result.ok()).isFalse(); + assertThat(result.message()).contains("Apple IAP 尚未实现"); + } + + @Test + void should_reject_when_platform_is_unknown() { + StoreVerifier.StoreReceiptResult result = verifier.verify("NINTENDO", PRODUCT_ID, TOKEN); + assertThat(result.ok()).isFalse(); assertThat(result.message()).contains("只支持 Google Play"); } @@ -111,5 +120,12 @@ class GooglePlayStoreVerifierTest { requestedProductId = productId; return purchase; } + + /** 本组测试只关心购买校验,作废列表用不到。 */ + @Override + public java.util.List listVoidedPurchases(String packageName, long startMillis, + long endMillis) { + return java.util.List.of(); + } } } diff --git a/sql/schema-phase1.sql b/sql/schema-phase1.sql index a03f5ca..fac5c1b 100644 --- a/sql/schema-phase1.sql +++ b/sql/schema-phase1.sql @@ -933,3 +933,14 @@ COMMENT ON COLUMN sys_admin_user.must_change_password IS '1 = 首登强制改密 -- 默认 1(超管照旧看到),置 0 表示"不给超管显示"。 ALTER TABLE sys_menu ADD COLUMN IF NOT EXISTS super_admin_visible SMALLINT NOT NULL DEFAULT 1; COMMENT ON COLUMN sys_menu.super_admin_visible IS '0 = 不给超管显示(角色专属菜单用;超管的菜单是全量兜底,不走 sys_role_menu)'; + +-- ============================================================ +-- 十、B1-2 对账:商店侧指标与说明 +-- ============================================================ +-- 对账要区分"本地口径"和"商店口径",而商店的结算金额/手续费/税只能从 Play Console 财务报告取, +-- Developer API 不提供。契约强制:**取不到的指标一律 null 并在 notes 说明缺什么,不许用 0 冒充**, +-- 所以这两列必须可空(0 与 null 在对账语境里含义完全不同:0 是"确实是 0",null 是"没有数据源")。 +ALTER TABLE reconcile_task ADD COLUMN IF NOT EXISTS store_amount_sum NUMERIC(14,2); +ALTER TABLE reconcile_task ADD COLUMN IF NOT EXISTS notes TEXT; +COMMENT ON COLUMN reconcile_task.store_amount_sum IS '商店侧结算金额;无数据源时为 NULL(不要用 0 冒充)'; +COMMENT ON COLUMN reconcile_task.notes IS '对账说明:差异构成、缺哪些数据源、本次退款回收情况';