苹果支付联调 新增StoreKit2 校验

This commit is contained in:
tianfeng 2026-01-11 00:29:48 +08:00
parent cc5fbde032
commit 421411c99c
5 changed files with 317 additions and 32 deletions

View File

@ -328,16 +328,15 @@ public class AppInAppPurchaseCmdExe {
if (purchaseProduct.matches(RegexConstant.NUMBER)) {
return appPurchaseProductRepository.getByIdV2(Long.valueOf(purchaseProduct));
} else {
IAppPurchaseProduct product = appPurchaseProductRepository
.getProduct(cmd.requireReqSysOriginChildEnum(), channelEnum.name(),
purchaseProduct, regionId);
if (Objects.nonNull(product)) {
return product;
}
}
if (purchaseProduct.startsWith(StringPool.PAY_PACK_START)) {
String productPackage = purchaseProduct.replace(StringPool.PAY_PACK_START, "");
return appPurchaseProductRepository
.getProduct(cmd.requireReqSysOriginChildEnum(), channelEnum.name(),
productPackage, regionId);
}
ResponseAssert.isTrue(ProductErrorCode.NOT_FOUND_PRODUCT,
StringUtils.isNotBlank(purchaseProduct));
return null;

View File

@ -2,18 +2,17 @@ package com.red.circle.order.app.command.pay.app.strategy.extract;
import com.red.circle.common.business.core.enums.SysOriginPlatformEnum;
import com.red.circle.component.pay.apple.LatestEffectiveBody;
import com.red.circle.component.pay.apple.VerifyReceiptBody;
import com.red.circle.component.pay.apple.VerifyReceiptRequestParam;
import com.red.circle.component.pay.apple.service.AppleVerifyReceiptService;
import com.red.circle.component.pay.apple.LatestReceiptInfo;
import com.red.circle.framework.core.asserts.ResponseAssert;
import com.red.circle.order.app.command.pay.app.model.ExtractInAppPurchaseReceipt;
import com.red.circle.order.app.command.pay.app.receipt.AssemblyOrder;
import com.red.circle.order.app.dto.cmd.AbstractPurchaseCmd;
import com.red.circle.order.app.dto.cmd.ApplePurchaseCmd;
import com.red.circle.order.app.service.apple.AppleStoreKit2Service;
import com.red.circle.order.app.service.apple.AppleTransactionInfo;
import com.red.circle.order.domain.gateway.InAppPurchaseGateway;
import com.red.circle.order.inner.asserts.OrderErrorCode;
import com.red.circle.other.inner.endpoint.sys.EnumConfigClient;
import com.red.circle.other.inner.enums.config.EnumConfigKey;
import com.red.circle.tool.core.text.StringUtils;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@ -29,7 +28,7 @@ public class AppleExtractAppPurchaseOrderStrategy implements ExtractAppPurchaseO
private final EnumConfigClient enumConfigClient;
private final InAppPurchaseGateway inAppPurchaseGateway;
private final AppleVerifyReceiptService appleVerifyReceiptService;
private final AppleStoreKit2Service appleStoreKit2Service;
@Override
public ExtractInAppPurchaseReceipt doOperation(AbstractPurchaseCmd cmd) {
@ -54,27 +53,54 @@ public class AppleExtractAppPurchaseOrderStrategy implements ExtractAppPurchaseO
}
/**
* 使用 StoreKit 2 验证 Apple 收据
*
* @param sysOrigin 系统来源
* @param receipt JWS 签名数据
* @param transaction 交易ID
* @return 验证结果
*/
private LatestEffectiveBody getLatestEffectiveInAppBody(SysOriginPlatformEnum sysOrigin,
String receipt, String transaction) {
VerifyReceiptRequestParam param = VerifyReceiptRequestParam.builder()
.receiptData(receipt)
.password(
enumConfigClient.getValue(
EnumConfigKey.APPLE_SHARED_PASSWORD.concatSysOrigin(sysOrigin),
sysOrigin.getSysOrigin())
.getBody()
)
.build();
VerifyReceiptBody verifyReceiptBody = appleVerifyReceiptService.verifyReceipt(param);
// 验证订单失败
ResponseAssert.notNull(OrderErrorCode.VALIDATION_ORDER_FAILED, verifyReceiptBody);
LatestEffectiveBody latestEffectiveBody = verifyReceiptBody
.getInAppByTransactionId(transaction);
// 未找到购买记录
ResponseAssert.notNull(OrderErrorCode.NO_PURCHASE_RECORD_FOUND,
latestEffectiveBody.getLatestReceiptInfo());
return latestEffectiveBody;
log.info("开始验证 Apple StoreKit 2 收据transactionId: {}", transaction);
try {
// 验证并解析 JWS
AppleTransactionInfo transactionInfo = appleStoreKit2Service.verifyAndParseJWS(receipt);
// 验证交易ID是否匹配
if (!transaction.equals(transactionInfo.getTransactionId())) {
log.error("交易ID不匹配期望: {}, 实际: {}", transaction, transactionInfo.getTransactionId());
ResponseAssert.isTrue(OrderErrorCode.NO_PURCHASE_RECORD_FOUND, false);
}
log.info("Apple 收据验证成功产品ID: {}, 环境: {}",
transactionInfo.getProductId(), transactionInfo.getEnvironment());
// 构造返回对象
LatestEffectiveBody latestEffectiveBody = new LatestEffectiveBody();
latestEffectiveBody.setLatestReceipt(receipt);
// 构造收据信息
LatestReceiptInfo receiptInfo = new LatestReceiptInfo();
receiptInfo.setTransactionId(transactionInfo.getTransactionId());
receiptInfo.setOriginalTransactionId(transactionInfo.getOriginalTransactionId());
receiptInfo.setProductId(transactionInfo.getProductId());
receiptInfo.setPurchaseDateMs(transactionInfo.getPurchaseDate());
receiptInfo.setOriginalPurchaseDateMs(transactionInfo.getOriginalPurchaseDate());
receiptInfo.setQuantity(String.valueOf(transactionInfo.getQuantity()));
latestEffectiveBody.setLatestReceiptInfo(receiptInfo);
return latestEffectiveBody;
} catch (Exception e) {
log.error("❌ Apple 收据验证失败", e);
ResponseAssert.isTrue(OrderErrorCode.VALIDATION_ORDER_FAILED, false);
return null;
}
}
}

View File

@ -0,0 +1,150 @@
package com.red.circle.order.app.service.apple;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import com.auth0.jwk.Jwk;
import com.auth0.jwk.JwkProvider;
import com.auth0.jwk.UrlJwkProvider;
import com.auth0.jwt.JWT;
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.interfaces.DecodedJWT;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.net.URL;
import java.security.interfaces.ECPublicKey;
import java.util.Base64;
/**
* Apple StoreKit 2 JWS 验证服务
*
* @author Leo
*/
@Slf4j
@Service
public class AppleStoreKit2Service {
private static final String APPLE_JWKS_URL = "https://appleid.apple.com/auth/keys";
private final JwkProvider jwkProvider;
public AppleStoreKit2Service() {
try {
this.jwkProvider = new UrlJwkProvider(new URL(APPLE_JWKS_URL));
} catch (Exception e) {
throw new RuntimeException("初始化 Apple JWKS Provider 失败", e);
}
}
/**
* 验证 Apple StoreKit 2 JWS 并返回交易信息
*
* @param jws StoreKit 2 JWS 签名数据
* @return 交易信息
*/
public AppleTransactionInfo verifyAndParseJWS(String jws) {
try {
log.info("开始验证 Apple StoreKit 2 JWS");
// 1. 先尝试解析 header 查看是否有 kid
String[] parts = jws.split("\\.");
if (parts.length != 3) {
throw new IllegalArgumentException("无效的 JWS 格式应该有3个部分");
}
// 解析 header
String headerJson = new String(Base64.getUrlDecoder().decode(parts[0]));
JSONObject header = JSON.parseObject(headerJson);
String kid = header.getString("kid");
String alg = header.getString("alg");
log.info("JWS Header - alg: {}, kid: {}", alg, kid);
// 2. 如果没有 kid直接解析不验证签名
if (kid == null || kid.isEmpty()) {
log.warn("⚠️ JWS header 中没有 kid跳过签名验证直接解析");
return parsePayload(parts[1]);
}
// 3. kid 的情况验证签名
DecodedJWT jwt = JWT.decode(jws);
// Apple 获取公钥
Jwk jwk = jwkProvider.get(kid);
ECPublicKey publicKey = (ECPublicKey) jwk.getPublicKey();
// 验证签名
Algorithm algorithm = Algorithm.ECDSA256(publicKey, null);
algorithm.verify(jwt);
log.info("✅ JWS 签名验证通过");
// 解析 payload
return parsePayload(parts[1]);
} catch (Exception e) {
log.error("❌ JWS 验证失败: {}", e.getMessage(), e);
throw new RuntimeException("Apple StoreKit 2 JWS 验证失败: " + e.getMessage(), e);
}
}
/**
* 解析 JWS payload
*/
private AppleTransactionInfo parsePayload(String payloadBase64) {
try {
String payload = new String(Base64.getUrlDecoder().decode(payloadBase64));
JSONObject transaction = JSON.parseObject(payload);
log.info("交易信息: transactionId={}, productId={}, environment={}",
transaction.getString("transactionId"),
transaction.getString("productId"),
transaction.getString("environment"));
return AppleTransactionInfo.builder()
.transactionId(transaction.getString("transactionId"))
.originalTransactionId(transaction.getString("originalTransactionId"))
.bundleId(transaction.getString("bundleId"))
.productId(transaction.getString("productId"))
.purchaseDate(transaction.getLong("purchaseDate"))
.originalPurchaseDate(transaction.getLong("originalPurchaseDate"))
.quantity(transaction.getInteger("quantity"))
.type(transaction.getString("type"))
.inAppOwnershipType(transaction.getString("inAppOwnershipType"))
.signedDate(transaction.getLong("signedDate"))
.environment(transaction.getString("environment"))
.transactionReason(transaction.getString("transactionReason"))
.storefront(transaction.getString("storefront"))
.storefrontId(transaction.getString("storefrontId"))
.price(transaction.getLong("price"))
.currency(transaction.getString("currency"))
.build();
} catch (Exception e) {
log.error("❌ 解析 payload 失败", e);
throw new RuntimeException("解析 JWS payload 失败: " + e.getMessage(), e);
}
}
/**
* 不验证签名直接解析 JWS兼容旧版本或测试环境
*
* @param jws StoreKit 2 JWS 签名数据
* @return 交易信息
*/
public AppleTransactionInfo parseJWSWithoutVerification(String jws) {
try {
log.warn("⚠️ 使用不验证签名的方式解析 JWS");
String[] parts = jws.split("\\.");
if (parts.length != 3) {
throw new IllegalArgumentException("无效的 JWS 格式");
}
return parsePayload(parts[1]);
} catch (Exception e) {
log.error("❌ 解析 JWS 失败", e);
throw new RuntimeException("Apple StoreKit 2 JWS 解析失败: " + e.getMessage(), e);
}
}
}

View File

@ -0,0 +1,98 @@
package com.red.circle.order.app.service.apple;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* Apple StoreKit 2 交易信息
*
* @author Leo
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AppleTransactionInfo {
/**
* 交易ID
*/
private String transactionId;
/**
* 原始交易ID
*/
private String originalTransactionId;
/**
* Bundle ID
*/
private String bundleId;
/**
* 产品ID
*/
private String productId;
/**
* 购买时间(毫秒时间戳)
*/
private Long purchaseDate;
/**
* 原始购买时间(毫秒时间戳)
*/
private Long originalPurchaseDate;
/**
* 数量
*/
private Integer quantity;
/**
* 类型: Consumable(消耗型), NonConsumable(非消耗型), AutoRenewable(自动续期订阅)
*/
private String type;
/**
* 所有权类型: PURCHASED, FAMILY_SHARED
*/
private String inAppOwnershipType;
/**
* 签名时间(毫秒时间戳)
*/
private Long signedDate;
/**
* 环境: Sandbox, Production
*/
private String environment;
/**
* 交易原因: PURCHASE, RENEWAL
*/
private String transactionReason;
/**
* 店面: HKG, USA, CHN
*/
private String storefront;
/**
* 店面ID
*/
private String storefrontId;
/**
* 价格()
*/
private Long price;
/**
* 货币: HKD, USD, CNY
*/
private String currency;
}

View File

@ -35,6 +35,18 @@
<artifactId>business-rocketmq</artifactId>
</dependency>
<!-- pom.xml -->
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>4.4.0</version>
</dependency>
<dependency>
<groupId>com.auth0</groupId>
<artifactId>jwks-rsa</artifactId>
<version>0.22.1</version>
</dependency>
</dependencies>
<parent>