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

@@ -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) {