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

+21
View File
@@ -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 创作者与内容审核(已实现)
| 方法 | 路径 | 权限点 | 说明 |
@@ -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();
}
}
@@ -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();
}
}
@@ -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;
@@ -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");
}
/**
* 商店回调(退款 / 拒付 / 撤销)触发的回收,**幂等**:
* 订单已退款就直接返回,不二次回收权益、不二次扣币、不写第二条流水。
*
* <p>与人工 {@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;
}
@@ -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;
/**
* 对账:把本地订单与商店票据比对,差异进人工处理队列。
* 对账:把本地订单与商店侧回执比对,差异进人工处理队列,并顺带执行退款回收。
*
* 第一阶段商店未接入,票据表是空的,所以差异必然等于订单数 ——
* 这正是要暴露的:接入商店后这个数字必须归零,否则就是在错账。
* <p>口径(与 `docs/api-phase1.md` §2.6 一致,改口径必须同步契约):
* <ul>
* <li>`totalCount` = 当期本地有效订单数(排除 CREATED/CLOSED);`matchedCount` = 其中能在票据表找到对应交易的笔数;
* `diffCount` = 本地缺票据 + 票据无本地单 + 商店已作废但本地未回收;`amountSum` = 本地金额合计(本地口径);</li>
* <li>`storeAmountSum` = 商店侧结算金额。**Developer API 不提供**(要向 Play Console 财务报告要),
* 所以**恒为 null**,并把缺什么写进 `notes` —— 契约强制"取不到就用 null,不许用 0 冒充";</li>
* <li>商店侧调用失败 → `status=FAILED` + `notes` 写明原因,**不写 SUCCESS 加一堆 0**;</li>
* <li>未配置商店(默认 verifier=disabled)→ 商店相关指标为 null,notes 注明"仅有本地一致性核对"。</li>
* </ul>
*/
@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<StoreRefundService> storeRefundServiceProvider;
public ReconcileService(ReconcileTaskMapper reconcileTaskMapper, OrdersMapper ordersMapper,
StoreReceiptMapper storeReceiptMapper,
ObjectProvider<StoreRefundService> 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<String> 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<String> receiptTxns = new HashSet<>();
for (StoreReceipt receipt : receipts) {
if (StringUtils.hasText(receipt.getTransactionId())) {
receiptTxns.add(receipt.getTransactionId());
}
}
Set<String> matched = new HashSet<>(orderTxns);
matched.retainAll(receiptTxns);
Set<String> orderOnly = new HashSet<>(orderTxns);
orderOnly.removeAll(receiptTxns);
Set<String> 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() {
@@ -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;
/**
* 商店退款 / 拒付回收。
*
* <p>由对账(每日 04:20 定时 + 后台手动 run)驱动:拉取 Google 的"已作废交易",
* 按 `(platform, orderId)` 找回本地订单并**幂等回收**权益与金币。
*
* <p>口径说明(写进回执与契约文档):
* <ul>
* <li>一次性商品的作废是**整笔**,Google 不返回部分金额 → 本轮按整单回收;</li>
* <li>找不到本地订单的作废交易属于**差异**,会计入对账的 diffCount,不静默丢掉;</li>
* <li>未配置 Google 凭据时**不调用商店**,返回 null 交由对账写 notes 说明"缺数据源"。</li>
* </ul>
*/
@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<GooglePlayPurchaseClient.VoidedPurchase> 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.<Orders>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()) + ")";
}
}
@@ -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);
/**
* 拉取"已作废(退款/撤销/拒付)"的交易,用于退款回收与对账。
*
* <p>对应 Google 的 `purchases.voidedpurchases.list`:一次性商品的作废是**整笔**,
* 拿不到部分金额,所以本层只表达"这笔作废了"。
*
* @param startMillis 起始时间(含),epoch 毫秒
* @param endMillis 结束时间(含),epoch 毫秒
*/
List<VoidedPurchase> 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` 的返回里我们需要的字段。
*
@@ -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)) {
@@ -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<VoidedPurchase> listVoidedPurchases(String packageName, long startMillis,
long endMillis) {
List<VoidedPurchase> 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) {
@@ -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<OrderEvent> 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;
}
}
@@ -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<Orders> captureInsertedOrder() {
AtomicReference<Orders> saved = new AtomicReference<>();
given(ordersMapper.insert(any(Orders.class))).willAnswer(invocation -> {
@@ -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<StoreRefundService> provider;
private ReconcileService reconcileService;
private AtomicReference<ReconcileTask> 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;
}
}
@@ -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<VoidedPurchase> voided = List.of();
private boolean called;
@Override
public List<VoidedPurchase> listVoidedPurchases(String packageName, long startMillis,
long endMillis) {
called = true;
return voided;
}
@Override
public ProductPurchase fetchProductPurchase(String packageName, String productId,
String purchaseToken) {
throw new UnsupportedOperationException("本测试不校验购买");
}
}
}
@@ -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<VoidedPurchase> listVoidedPurchases(String packageName, long startMillis,
long endMillis) {
return java.util.List.of();
}
}
}
+11
View File
@@ -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 '对账说明:差异构成、缺哪些数据源、本次退款回收情况';