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:
1 parent
db897d50d6
commit
1dc97b4bf6
17 files changed
+971
-21
No files matched your search
+99
@@ -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;
|
||||
}
|
||||
}
|
||||
+43
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user