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
+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) {
|
||||
|
||||
Reference in new issue
Block a user