feat(commerce): B1-2 恢复购买/退款拒付回收/对账真实比对 + Apple 端口预留

为什么:风险清单 R-03 把"票据校验 + 对账打通"列为上线阻塞项。勘察发现 ReconcileService 与
/admin/reconcile 两个接口 B1 之前就已实现,真缺口是:对账没跟商店比对、差额用 0 兜底(违反
"取不到就用 null"的契约)、退款回收只有人工入口且不幂等。按实际缺口做,不重复造。

- 恢复购买:补专项测试(换设备重提同一票据 → 同一订单、原交付时间不改写、不新增流水);
  补单能力沿用 B1-1 的服务端建单路径,恢复列表沿用 GET /app/orders,不新增接口
- 退款/拒付回收:GooglePlayPurchaseClient 增加 listVoidedPurchases(purchases.voidedpurchases,
  带分页);新增 StoreRefundService 拉取并按 (platform, orderId) 幂等回收,本地无单计入差异;
  OrderService 增加 refundByStoreCallback(已退款直接返回,不二次回收/不写第二条流水),
  人工 refund() 的既有契约(已退款报错)保持不变
- 对账:reconcile_task 增 store_amount_sum(可空)与 notes 两列(幂等 ALTER);
  run() 改为集合比对(本地缺票据 + 票据无本地单 + 商店已作废但本地无单),并在同一次 run 里
  顺带执行退款回收;storeAmountSum 恒为 null 并在 notes 说明需从 Play Console 财务报告获取;
  商店调用失败置 status=FAILED 且不写 SUCCESS+0
- Apple IAP:只留端口/枚举与明确提示,不实现真调用(B2 再实现)
- 契约同步 docs/api-phase1.md §1.8/§2.6;新增测试 17 条(commerce 14 + admin-api 3)
- 实测:mvn test 71 条全过(commerce 27 + admin-api 3 + app-api 41,B1-1 的 54 条未被打红);
  两个入口 package 通过;端到端手动对账返回 storeAmountSum=null + notes 说明缺什么
This commit is contained in:
jianghaifeng committed 2026-10-16 11:52:10 +08:00
1 parent db897d50d6
commit 1dc97b4bf6
17 files changed
+971 -21

No files matched your search

@@ -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;
}
}
@@ -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 容器**。
*
* <p>为什么不用 {@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();
}
}