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 一致,改口径必须同步契约): + *
由对账(每日 04:20 定时 + 后台手动 run)驱动:拉取 Google 的"已作废交易", + * 按 `(platform, orderId)` 找回本地订单并**幂等回收**权益与金币。 + * + *
口径说明(写进回执与契约文档): + *
对应 Google 的 `purchases.voidedpurchases.list`:一次性商品的作废是**整笔**,
+ * 拿不到部分金额,所以本层只表达"这笔作废了"。
+ *
+ * @param startMillis 起始时间(含),epoch 毫秒
+ * @param endMillis 结束时间(含),epoch 毫秒
+ */
+ List