apple苹果订阅内购极速入门-V2接口

极速入门版,更多的内容可以参考给出的参考链接

准备工作

苹果根证书,下载地址

应用证书,准备以下内容,参数的含义参考com.apple.itunes.storekit.client.AppStoreServerAPIClient

java 复制代码
@ConfigurationProperties(prefix = "apple.pay")
public class AppleProperties {
    private String keyId;
    private Long appAppleId;
    private String bundleId;
    private String issuerId;
    private String certPath;
    private String certName;
}

配置证书

这里最好把测试环境的证书也添加上,主要是苹果审核生产APP可能是用的还是测试环境账号,生产证书解析不了签名,导致购买失败,就会审核不通过

java 复制代码
     @Bean
     public AppStoreServerAPIClient appStoreServerAPIClient() throws Exception {
         String p8;
         String certUri=appleProperties.getCertPath()+appleProperties.getCertName();

         if (FileUtil.exist(certUri)) {
             p8 = FileUtil.readString(FileUtil.file(certUri), StandardCharsets.UTF_8);
         }else{
             p8 = FileUtil.readString(FileUtil.file("classpath:apple-test.p8"), StandardCharsets.UTF_8);
         }

         AppStoreServerAPIClient client = new AppStoreServerAPIClient(p8, appleProperties.getKeyId(), appleProperties.getIssuerId(), appleProperties.getBundleId(), Environment.PRODUCTION);

         return client;
     }

    @Bean
    public SignedDataVerifier signedDataVerifier() throws Exception {
        String certPath = appleProperties.getCertPath();
        String certUri= certPath +appleProperties.getCertName();
        HashSet<InputStream> files = new HashSet<>(4);
        if (FileUtil.exist(certUri)) {
            log.info("加载服务器证书");
            files.add(new FileInputStream(FileUtil.file(certPath+"AppleComputerRootCertificate.cer")));
            files.add(new FileInputStream(FileUtil.file(certPath+"AppleRootCA-G3.cer")));
            files.add(new FileInputStream(FileUtil.file(certPath+"AppleRootCA-G2.cer")));
            files.add(new FileInputStream(FileUtil.file(certPath+"AppleIncRootCertificate.cer")));
        }else{
            log.info("加载内置证书");
            files.add(new FileInputStream(FileUtil.file("classpath:AppleComputerRootCertificate.cer")));
            files.add(new FileInputStream(FileUtil.file("classpath:AppleRootCA-G3.cer")));
            files.add(new FileInputStream(FileUtil.file("classpath:AppleRootCA-G2.cer")));
            files.add(new FileInputStream(FileUtil.file("classpath:AppleIncRootCertificate.cer")));
        }
        SignedDataVerifier verifier = new SignedDataVerifier(files, appleProperties.getBundleId(), appleProperties.getAppAppleId(), Environment.PRODUCTION, true);
        return verifier;
    }

	@Bean(name = "sandBoxClient")
    public AppStoreServerAPIClient sandBoxApiClient() throws Exception {
		// ...省略
		AppStoreServerAPIClient client = new AppStoreServerAPIClient(p8, appleProperties.getKeyId(), appleProperties.getIssuerId(), appleProperties.getBundleId(), Environment.SANDBOX);
	}
	@Bean(name = "sandBoxVerify")
    public SignedDataVerifier sandBoxSignedDataVerifier() throws Exception {
		// ...省略
		SignedDataVerifier verifier = new SignedDataVerifier(files, appleProperties.getBundleId(), appleProperties.getAppAppleId(), Environment.SANDBOX, true);
	}
    @Bean
    public ReceiptUtility receiptUtil() {
		// 票据验签工具类
        return new ReceiptUtility();
    }

解析签名

回调签名验证(订阅)

java 复制代码
	ResponseBodyV2DecodedPayload responsePayload = null;
	boolean sandBoxFlag = false;
	try {
		responsePayload = signedDataVerifier.verifyAndDecodeNotification(req.getSignedPayload());
	} catch (VerificationException e) {
		if (e.getStatus().equals(VerificationStatus.INVALID_ENVIRONMENT)) {
			log.error("=======[生产签名解析失败,转测试验证]========");
			responsePayload = sandBoxDataVerifier.verifyAndDecodeNotification(req.getSignedPayload());
			sandBoxFlag = true;
		} else {
			throw new RuntimeException(e);
		}
	}

	JWSRenewalInfoDecodedPayload renewalPayload = null;
	if (sandBoxFlag) {
		renewalPayload = sandBoxDataVerifier.verifyAndDecodeRenewalInfo(responsePayload.getData().getSignedRenewalInfo());
	} else {
		renewalPayload = signedDataVerifier.verifyAndDecodeRenewalInfo(responsePayload.getData().getSignedRenewalInfo());
	}

	JWSTransactionDecodedPayload jwsPayload = null;
	if (sandBoxFlag) {
		jwsPayload = sandBoxDataVerifier.verifyAndDecodeTransaction(responsePayload.getData().getSignedTransactionInfo());
	} else {
		jwsPayload = signedDataVerifier.verifyAndDecodeTransaction(responsePayload.getData().getSignedTransactionInfo());
	}
	// 有了这三个ResponseBodyV2DecodedPayload、JWSRenewalInfoDecodedPayload、JWSTransactionDecodedPayload的内容,就可以提取自己需要的数据进行业务校验

客户端发起验证(内购/订阅)

可以要求客户端传入签名 或者transactionId

通过签名得到transactionId

String transactionId = receiptUtility.extractTransactionIdFromAppReceipt(req.getSignedPayload()); TransactionInfoResponse transactionInfo = appStoreServerAPIClient.getTransactionInfo(transactionId);

通过transactionInfo得到票据具体内容

JWSTransactionDecodedPayload jwsPayload = sandBoxDataVerifier.verifyAndDecodeTransaction(transactionInfo.getSignedTransactionInfo());

这里和上面逻辑一样,transactionInfo/jwsPayload解析失败的时候,转测试验证.

一些提示事项

java 复制代码
// 判断内购和订阅
switch (jwsPayload.getType()) {
	case CONSUMABLE -> {
		// 内购,内购是没有回调的
	}
	case AUTO_RENEWABLE_SUBSCRIPTION -> {
		// 订阅
	}
}
// 判断通知类型,续订\取消订阅\订阅到期\退款等状态
switch (responsePayload.getNotificationType()) {
}
//0 自动续订已关闭。客户已关闭订阅的自动续订功能,当前订阅期结束时不会续订。
//1 自动续订已开启。订阅将在当前订阅期结束时续订。
//JWSRenewalInfoDecodedPayload -> autoRenewStatus

/**
* 1 自动续订订阅已激活。
* 2 自动续订订阅已过期。
* 3 自动续订订阅处于计费重试期。
* 4 自动续订订阅处于计费宽限期。
* 5 自动续订订阅已被撤销。
*/
//ResponseBodyV2DecodedPayload -> status

一些注意事项

  1. 收到客户端订阅票据的时候,苹果也会回调接口.只需要有一个生效就可以了
  2. 发生订阅恢复购买后对其他设备的处理逻辑
  3. 退款和撤销退款
  4. 内购是没有回调的

MAVEN依赖

xml 复制代码
<dependency>
    <groupId>com.apple.itunes.storekit</groupId>
    <artifactId>app-store-server-library</artifactId>
    <version>1.0.0</version>
</dependency>

参考代码

结合文档和自己业务整理的,凑合凑合参考

ini 复制代码
private void processPurchaseType(AppleNotificationDto notificationDto,ResponseBodyV2DecodedPayload responsePayload) {

    if (responsePayload != null) {
       switch (responsePayload.getNotificationType()) {
          case SUBSCRIBED:
          case OFFER_REDEEMED:
          case RENEWAL_EXTENDED:
          case RENEWAL_EXTENSION:
             // 开通订阅
             notificationDto.setPurchaseType(IEnumConst.PurchaseTypeEnum.SUBSCRIBE_VIP.code);
             if (responsePayload.getSubtype() != null) {

                if (responsePayload.getSubtype() == Subtype.INITIAL_BUY) {
                   notificationDto.setPurchaseSubType(IEnumConst.PurchaseSubTypeEnum.INITIAL_BUY.code);
                } else if (responsePayload.getSubtype() == Subtype.UPGRADE) {
                   notificationDto.setPurchaseSubType(IEnumConst.PurchaseSubTypeEnum.UPGRADE.code);
                } else if (responsePayload.getSubtype() == Subtype.DOWNGRADE) {
                   notificationDto.setPurchaseSubType(IEnumConst.PurchaseSubTypeEnum.DOWNGRADE.code);
                } else if (responsePayload.getSubtype() == Subtype.RESUBSCRIBE) {
                   notificationDto.setPurchaseSubType(IEnumConst.PurchaseSubTypeEnum.RENEW_BUY.code);
                }
             }
             break;
          case DID_FAIL_TO_RENEW:
          case REVOKE:
             // 取消订阅;
             notificationDto.setPurchaseType(IEnumConst.PurchaseTypeEnum.CANCEL_VIP.code);
             notificationDto.setPurchaseSubType(IEnumConst.PurchaseSubTypeEnum.VOLUNTARY.code);
             if (responsePayload.getSubtype() != null && Subtype.GRACE_PERIOD == responsePayload.getSubtype()) {
                notificationDto.setPurchaseSubType(IEnumConst.PurchaseSubTypeEnum.GRACE_PERIOD.code);
             }
             break;
          case EXPIRED:
          case GRACE_PERIOD_EXPIRED:
             // 订阅到期
             notificationDto.setPurchaseType(IEnumConst.PurchaseTypeEnum.EXPIRED_VIP.code);
             if (responsePayload.getSubtype() != null) {
                if (Subtype.VOLUNTARY == responsePayload.getSubtype()) {
                   notificationDto.setPurchaseSubType(IEnumConst.PurchaseSubTypeEnum.VOLUNTARY.code);
                } else if (Subtype.BILLING_RETRY == responsePayload.getSubtype()) {
                   notificationDto.setPurchaseSubType(IEnumConst.PurchaseSubTypeEnum.BILLING_RETRY.code);
                } else if (Subtype.PRICE_INCREASE == responsePayload.getSubtype()) {
                   notificationDto.setPurchaseSubType(IEnumConst.PurchaseSubTypeEnum.PRICE_INCREASE.code);
                } else if (Subtype.PRODUCT_NOT_FOR_SALE == responsePayload.getSubtype()) {
                   notificationDto.setPurchaseSubType(IEnumConst.PurchaseSubTypeEnum.PRODUCT_NOT_FOR_SALE.code);
                }
             }

             break;
          case REFUND:
             // 退款
             notificationDto.setIfRefund(1);
             notificationDto.setPurchaseType(IEnumConst.PurchaseTypeEnum.REFUND.code);
             break;
          case REFUND_REVERSED:
             // 撤销退款;
             notificationDto.setPurchaseType(IEnumConst.PurchaseTypeEnum.REFUND_REVERSED.code);
             break;
          case DID_RENEW:
             notificationDto.setPurchaseType(IEnumConst.PurchaseTypeEnum.RENEW_VIP.code);
             notificationDto.setPurchaseSubType(IEnumConst.PurchaseSubTypeEnum.RENEW_BUY.code);
             break;
          case DID_CHANGE_RENEWAL_PREF:
             notificationDto.setPurchaseType(IEnumConst.PurchaseTypeEnum.RENEW_VIP.code);
             if (responsePayload.getSubtype() != null && Subtype.UPGRADE == responsePayload.getSubtype()) {
                notificationDto.setPurchaseSubType(IEnumConst.PurchaseSubTypeEnum.UPGRADE.code);
             } else if (responsePayload.getSubtype() != null && Subtype.DOWNGRADE == responsePayload.getSubtype()) {
                notificationDto.setPurchaseSubType(IEnumConst.PurchaseSubTypeEnum.DOWNGRADE.code);
             }
             break;
          case DID_CHANGE_RENEWAL_STATUS:
             notificationDto.setPurchaseType(IEnumConst.PurchaseTypeEnum.CANCEL_VIP.code);
             notificationDto.setPurchaseSubType(IEnumConst.PurchaseSubTypeEnum.VOLUNTARY.code);
             break;
          case CONSUMPTION_REQUEST:
             notificationDto.setPurchaseType(PurchaseTypeEnum.REFUND_REQUEST.code);
             notificationDto.setPurchaseSubType(IEnumConst.PurchaseSubTypeEnum.CONSUMPTION_REQUEST.code);
             // return "充值";
             break;
          // case PRICE_INCREASE:
          case REFUND_DECLINED:
             notificationDto.setPurchaseType(PurchaseTypeEnum.REFUND_REJECTED.code);
             if (responsePayload.getSubtype() != null && (Subtype.AUTO_RENEW_DISABLED == responsePayload.getSubtype()
                || Subtype.VOLUNTARY == responsePayload.getSubtype())) {
                notificationDto.setPurchaseType(PurchaseTypeEnum.CANCEL_VIP.code);
                notificationDto.setPurchaseSubType(PurchaseSubTypeEnum.VOLUNTARY.code);
             }
             break;
          case TEST:
             // return "其他事件";
             break;
          default:
             // return "未知事件";

       }

       if ("dev".equals(enableLog)) {
          log.info("==========[order info start]=========");
          log.info(JSONUtil.toJsonPrettyStr(notificationDto));
          log.info("==========[order info end]=========");
       }
    }
}
rust 复制代码
private static boolean isVipFlag(ResponseBodyV2DecodedPayload payload) {
    boolean vipFlag = true;
    switch (payload.getNotificationType()) {
       case SUBSCRIBED -> {
          // 表示用户订阅了产品。
          // 如果 subtype 为 INITIAL_BUY,则用户首次通过家人共享购买或接收了对订阅的访问权限。
          // 如果 subtype 是 RESUBSCRIBE,则用户通过家人共享重新订阅或接收对同一订阅或同一订阅组内的另一个订阅的访问权限。
       }
       case DID_CHANGE_RENEWAL_PREF -> {
          // 如果 subtype 是 UPGRADE,则用户升级其订阅,或交叉分级为具有相同持续时间的订阅。 升级立即生效,开始新的计费周期,用户将收到上一周期未使用部分的按比例退款。
          // 如果 subtype 是 DOWNGRADE,则用户降级其订阅或交叉分级为具有不同持续时间的订阅。 降级将在下一个续订日期生效,并且不会影响当前有效的计划。
          // 如果 subtype 为空,则用户将其续订首选项更改回当前订阅,从而有效地取消降级。 请参阅组内订阅排名。
       }
       case DID_CHANGE_RENEWAL_STATUS -> {
          // 如果 subtype 为 AUTO_RENEW_ENABLED,则用户重新启用订阅自动续订。
          // 如果 subtype 为 AUTO_RENEW_DISABLED,则用户禁用了订阅自动续订,或者用户申请退款后 App Store 禁用了订阅自动续费。
          // ! 只是禁用订阅
          // vipFlag = false;
       }
       case OFFER_REDEEMED -> {
          // 表示用户兑换了促销优惠或优惠代码。
          // 如果 subtype 为 INITIAL_BUY,则用户兑换了首次购买的优惠。
          // 如果 subtype 为 RESUBSCRIBE,则用户兑换了要重新订阅非活动订阅的优惠。
          // 如果 subtype 是 UPGRADE,则用户兑换了升级其活动订阅的优惠,该优惠立即生效。
          // 如果 subtype 为 DOWNGRADE,则用户兑换了降级其有效订阅的优惠,该优惠将在下一个续订日期生效。
       }
       case DID_RENEW -> {
          // 订阅成功续订。
          // 如果 subtype 是 BILLING_RECOVERY,则先前未能续订的过期订阅已成功续订。
          // 如果 subtype 为空,则活动订阅已成功自动续订新的交易周期。 为客户提供订阅内容或服务的访问。
       }
       case EXPIRED -> {
          // 表示订阅已过期。
          // 如果 subtype 是 VOLUNTARY,则在用户禁用订阅续订后订阅过期。
          // 如果 subtype 是 BILLING_RETRY,则订阅到期,因为计费重试期结束而没有成功的计费交易。
          // 如果 subtype 为 PRICE_INCREASE,则订阅过期,因为用户未同意需要用户同意的价格上涨。
          // 如果 subtype 为 PRODUCT_NOT_FOR_SALE,则订阅过期,因为在订阅试图续订时无法购买产品。
          // 没有 subtype 的通知表明订阅出于某些其他原因而过期。
          vipFlag = false;
       }
       case DID_FAIL_TO_RENEW -> {
          // 表示订阅由于计费问题而未能续订。 订阅进入计费重试期。
          // 如果 subtype 为 GRACE_PERIOD,请在整个宽限期继续提供服务。
          // 如果 subtype 为空,则订阅不在宽限期,您可以停止提供订阅服务。
          // 向用户介绍其计费信息可能存在问题。 App Store 继续重试 60 天,或直到用户解决其计费问题或取消其订阅,以先到者为准。
          if (!Subtype.GRACE_PERIOD.equals(payload.getSubtype())) {
             vipFlag = false;
          }
       }
       case GRACE_PERIOD_EXPIRED -> {
          // 表明计费宽限期已经结束而不续订订阅,因此您可以关闭对服务或内容的访问。 通知用户他们的计费信息可能存在问题。 App Store 继续重试 60 天,或直到用户解决其计费问题或取消其订阅,以先到者为准。
          vipFlag = false;
       }
       case PRICE_INCREASE -> {
          // 表明系统已通知用户自动续订订阅价格上涨。
          // 如果涨价需要用户同意,则如果用户未响应涨价,则 subtype 为 PENDING;如果用户同意涨价,则 subtype 为 ACCEPTED。
          // 如果价格上涨不需要用户同意 ,则 subtype 是 Accepted
       }
       case REFUND -> {
          // 表示 App Store 已成功对消费品应用内购买、非消费品应用内购买、自动续订订阅或非续订订阅的交易进行退款。reservationDate 包含退款交易的时间戳。 OriginalTransactionId 和 ProductId 标识原始交易和产品。 reservationReason 包含原因。
          vipFlag = false;
       }
       case REFUND_DECLINED -> {
          // 表示 App Store 拒绝了应用开发者使用以下任一方法发起的退款请求: beginRefundRequest(for:in:), beginRefundRequest(in:), beginRefundRequest(for:in:), beginRefundRequest(in:), 和 refundRequestSheet(for:isPresented:onDismiss:).
       }
       case CONSUMPTION_REQUEST -> {
          // 表明客户发起了应用内消费品购买的退款请求,并且 App Store 要求您提供消费数据。 有关更多信息,请参见发送消费信息。
       }
       case RENEWAL_EXTENDED -> {
          // 表示 App Store 延长了特定订阅的订阅续订日期。 您可以通过调用 App Store Server API 中的 延长订阅续订日期 或 为所有活跃订阅者延长订阅续订日期 来请求订阅续订日期延期。
       }
       case REVOKE -> {
          // 表明用户有权通过家庭共享获得的应用内购买,不再通过共享获得。当购买者禁用产品的家庭共享、购买者(或家庭成员)离开家庭群组或者购买者要求并收到退款时,App Store 会发送此通知。您的应用程序还会收到 paymentQueue (_:didRevokeEntitlementsForProductIdentifiers:) 调用。家庭共享适用于非消耗性应用内购买和自动续订订阅
          vipFlag = false;
       }
       case TEST -> {
          // 当您通过调用 Request a Test Notification 时,App Store Server 发送的通知类型。 调用该端点来测试您的服务器是否正在接收通知。仅当您提出请求时,您才会收到此通知
       }
       case RENEWAL_EXTENSION -> {
          // 一种通知类型及其 subtype 型,表明 App Store 正在尝试延长订阅续订日期,因为您调用了 延长所有活跃订阅者的订阅续订日期 。
          // 如果 subtype 为 SUMMARY,则 App Store 已完成为所有符合条件的订阅者延长续订日期。有关详细信息,请参阅 responseBodyV2DecodedPayload 中的摘要。
          // 如果 subtype 为 FAILURE,则特定订阅的续订日期延期未成功。

       }
       case REFUND_REVERSED -> {
          // 表明 App Store 由于客户提出的争议而撤销了之前授予的退款。如果您的应用因相关退款而撤销了内容或服务,则需要恢复它们。此通知类型可适用于任何应用内购买类型:消耗型、非消耗型、非续订订阅和自动续订订阅。 对于自动续订订阅,当 App Store 撤销退款时,续订日期保持不变。
          // 充值配置信息
       }
    }
    return vipFlag;
}

参考

APPLE官方示例,让你事半功倍

APPLE官方通知V2 API文档

APPLE官方订阅接口API文档

App Store 服务端通知 V2 文档翻译整理

掘金博主Ztfiso苹果内购相关

自动续订订阅类型总结

退款处理

相关推荐
愚农搬码4 分钟前
LangChain 调用不同类型的多MCP服务
人工智能·后端
我会冲击波5 分钟前
推荐一款让代码命名变得轻松高效的idea插件
后端
楽码10 分钟前
安装和编写grpc协议文件
服务器·后端·grpc
码农之王12 分钟前
(二)TypeScript前置编译配置
前端·后端·typescript
一眼万年0420 分钟前
Kafka LogManager 深度解析
后端·kafka
天行健的回响21 分钟前
一次多线程改造实践:基于ExecutorService + CompletionService的并发处理优化
后端
盖世英雄酱581361 小时前
🚀不改SQL,也能让SQL的执行效率提升100倍
java·数据库·后端
陈随易1 小时前
Bun v1.2.16发布,内存优化,兼容提升,体验增强
前端·后端·程序员
GetcharZp1 小时前
「Golang黑科技」RobotGo自动化神器,鼠标键盘控制、屏幕截图、全局监听全解析!
后端·go
程序员岳焱1 小时前
Java 与 MySQL 性能优化:Linux服务器上MySQL性能指标解读与监控方法
linux·后端·mysql