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
@@ -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 创作者与内容审核(已实现)
|
||||
|
||||
| 方法 | 路径 | 权限点 | 说明 |
|
||||
|
||||
+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();
|
||||
}
|
||||
}
|
||||
+10
@@ -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();
|
||||
}
|
||||
}
|
||||
+4
@@ -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;
|
||||
|
||||
+29
-1
@@ -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;
|
||||
}
|
||||
|
||||
+105
-18
@@ -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() {
|
||||
|
||||
+119
@@ -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()) + ")";
|
||||
}
|
||||
}
|
||||
+30
@@ -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` 的返回里我们需要的字段。
|
||||
*
|
||||
|
||||
+4
-1
@@ -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)) {
|
||||
|
||||
+40
@@ -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) {
|
||||
|
||||
+133
@@ -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;
|
||||
}
|
||||
}
|
||||
+19
@@ -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 -> {
|
||||
|
||||
+145
@@ -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;
|
||||
}
|
||||
}
|
||||
+142
@@ -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("本测试不校验购买");
|
||||
}
|
||||
}
|
||||
}
|
||||
+17
-1
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 '对账说明:差异构成、缺哪些数据源、本次退款回收情况';
|
||||
Reference in new issue
Block a user