Bootstrap

Java服务端接入苹果内购。实现票据二次校验、自动续期订阅

简介

记录一下 Java 服务端接入苹果内购。

1. 商品类型

苹果规定在 APP Store上架的 APP 使用苹果自己的支付方式(IAP内购),并且苹果会抽30%的税。
上架商品包括:消耗性,非消耗性,自动续期订阅,非续期订阅。上架商品可在 APP Store后台配置。

2. 获取支付票据

由用户完成付款操作后,苹果返回 票据 给 IOS 客户端,再由客户端返回给服务端进行业务处理。服务端需要携带 票据 信息向苹果进行二次 票据验证,验证成功后可继续进行剩余的业务逻辑。

3. 票据验证

票据是苹果将支付的相关信息,整理成了一个json返回给我们。里面包含比较常用的一些数据段是商品ID、支付时间、苹果的订单ID(transactionId),以及自动订阅商品的优惠政策、过期时间、续订时间等。
苹果有两个票据校验的接口,一个是沙盒环境,一个是正式环境。在测试阶段和上线后需要用不同的接口去校验。正式票据到沙盒环境校验会报 (21007) 的错误码。

注意: 自动订阅模式需要传输 ”共享密钥” 参数,可在APP Store中获取。
官方文接口文档:
最新版V2:
https://developer.apple.com/documentation/appstorereceipts/validating_receipts_on_the_device
https://developer.apple.com/documentation/appstoreservernotifications/app_store_server_notifications_v2
文章内容为V1版(目前已废弃。目前仍在用,但有风险):
https://developer.apple.com/documentation/appstorereceipts/verifyreceipt
https://developer.apple.com/documentation/appstoreservernotifications/app_store_server_notifications_version_1

4. 服务端验证票据

4.1 服务端逻辑

 public void verifyReceipt(AppleRequestProtocol request) {
		// 票据
        String receipt = request.getReceipt();	
		// 服务端自己的订单号,可用做后续业务逻辑
        String orderId = request.getOrderNumber();
		// 注意,有的票据在客户端接收时 加号 可能会被转换为 空格
        String data = receipt.replace(" ", "+");
		// 请求苹果服务器进行票据验证
        String result = AppleVerifyUtil.verifyApple(data, 1, orderId);
        JSONObject receiptData = JSONObject.parseObject(result);
        // 解析票据
        if(result == null){
            // 解析票据失败 或 网络问题
			log.error("[ verify receipt error]");
            return ;
        }else {
            // 支付环境是否正确
            int status = receiptData.getInteger("status");
            if(21007 == status){
                // 验证失败21007 走沙箱环境
                result = AppleVerifyUtil.verifyApple(data, 0, orderId);
                if(result == null){
                    //  解析票据失败
					log.error("[ verify receipt error]");
                    return ;
                }
                receiptData = JSONObject.parseObject(result);
                status = receiptData.getInteger("status");
            }

			if(0 == status){
				JSONObject receiptInfo = receiptData.getJSONObject("receipt");
				JSONArray inAppList = receiptInfo.getJSONArray("in_app");
				if(!CollectionUtils.isEmpty(inAppList)){
					JSONObject inApp = inAppList.getJSONObject(inAppList.size() - 1);
					// 票据ID
					String transactionId = inApp.getString("transaction_id");
					// 购买时间
					Long purchaseDateMs = inApp.getLong("purchase_date_ms");
					// 商品ID 与在APP Store 后台配置的一致
					String productId = inApp.getString("product_id");

					// 剩余业务逻辑
				
				}else{
					// 获取in_app支付列表失败
					log.error("[receipt error]");
				}
			}
        }
    }

4.2 苹果内购验证工具类

/**
 * 苹果内购验证工具类
 */
@Slf4j
public class AppleVerifyUtil {
    /**
     * 苹果内购沙盒环境
     */
    private static final String url_sandbox = "https://sandbox.itunes.apple.com/verifyReceipt";
    /**
     * 苹果内购正式环境
     */
    private static final String url_verify = "https://buy.itunes.apple.com/verifyReceipt";
    /**
     * 秘钥 (自动订阅服务需要秘钥)
     */
    private static final String KEY = "需要到APP Store后台获取";


    /**
     * 苹果服务器内购验证票据
     * @param receipt 验证收据
     * @param type  环境  (0 开发)
     * @return
     */
    public static String verifyApple(String receipt, int type) {
        String url = "";
        //环境判断 线上/开发环境用不同的请求链接
        if(type == 0){
            url =  url_sandbox;
        }else{
            url = url_verify;
        }

        try {
            SSLContext sc = SSLContext.getInstance("SSL");
            sc.init(null, new TrustManager[] { new TrustAnyTrustManager() }, new java.security.SecureRandom());
            URL console = new URL(url);

            JSONObject jsonObject = new JSONObject();
            jsonObject.put("receipt-data", receipt);
            jsonObject.put("password", KEY);

            OkHttpClient okHttpClient = new OkHttpClient.Builder()
                    .connectTimeout(10, TimeUnit.SECONDS)
                    .readTimeout(10, TimeUnit.SECONDS)
                    .build();
            MediaType mediaType=MediaType.Companion.parse("application/json;charset=utf-8");
            RequestBody stringBody=RequestBody.Companion.create(jsonObject.toString(),mediaType);
            Request request=new Request
                    .Builder()
                    .url(console)
                    .post(stringBody)
                    .build();

            String result = okHttpClient.newCall(request).execute().body().string();
            return result;
        } catch (Exception e) {
            log.error("[ios verify error]");
            return null;
        }
    }

    private static class TrustAnyTrustManager implements X509TrustManager {

        @Override
        public void checkClientTrusted(X509Certificate[] chain, String authType) throws CertificateException {
        }

        @Override
        public void checkServerTrusted(X509Certificate[] chain, String authType) throws CertificateException {
        }

        @Override
        public X509Certificate[] getAcceptedIssuers() {
            return new X509Certificate[] {};
        }
    }
}

4.3 票据信息

服务端请求苹果验证接口后,苹果返回解析后的票据信息。重要部分在receipt

"receipt": {
        "in_app": [
            {
                "product_id": "202201",                             //  商品ID
                "quantity": "1",                                    //  购买商品数量
                "transaction_id": "2000000026612777",               //票据ID
                "original_transaction_id": "2000000026612777",      //原始购买票据ID
                "purchase_date": "2022-04-06 01:54:59 Etc/GMT",     //购买时间
                "purchase_date_ms": "1649210099000",                //购买时间戳
                "purchase_date_pst": "2022-04-05 18:54:59 America/Los_Angeles",         // 购买时间(美国)no

                "original_purchase_date": "2022-04-06 01:55:00 Etc/GMT",                    //原始购买时间
                "original_purchase_date_ms": "1649210100000",                               //原始购买时间戳
                "original_purchase_date_pst": "2022-04-05 18:55:00 America/Los_Angeles",    //原始购买时间(美国)no

                "expires_date": "2022-04-06 01:59:59 Etc/GMT",                  //订阅到期时间
                "expires_date_ms": "1649210399000",                             //订阅到期时间戳
                "expires_date_pst": "2022-04-05 18:59:59 America/Los_Angeles",  //订阅到期时间(美国) no

                "is_in_intro_offer_period": "false",            //是否在享受优惠价格期间
                "is_trial_period": "false",                     //是否享受免费试用
                "web_order_line_item_id": "2000000002007193",   //跨设备购买事件(包括订阅更新事件)的唯一标识符。此值是识别订阅购买的主键
                "in_app_ownership_type": "PURCHASED",
            }
        ],

4.4 错误码

状态码 -详情
0校验成功
21000未使用HTTP POST请求方法向App Store发送请求。
21001此状态代码不再由App Store发送。
21002receipt-data属性中的数据格式错误或丢失。
21003收据无法认证。
21004您提供的共享密码与您帐户的文件共享密码不匹配。
21005收据服务器当前不可用。
21006该收据有效,但订阅已过期。当此状态代码返回到您的服务器时,收据数据也会被解码并作为响应的一部分返回。仅针对自动续订的iOS 6样式的交易收据返回。
21007该收据来自测试环境,但已发送到生产环境以进行验证。
21008该收据来自生产环境,但是已发送到测试环境以进行验证。
21009内部数据访问错误。稍后再试。
21010找不到或删除了该用户帐户。

5. 续订

针对自动续期订阅类型,App Store会在订阅时间快到期之前,自动扣费帮助用户续订该服务。
server to server的校验方式,也是苹果推荐的校验方式 ,由苹果主动告知我们状态。 服务器需要接收苹果服务器发送过来的回调消息,根据消息类型进行续订,取消订阅,退订等操作。

5.1 配置接收通知地址

需要在App Store connect后台配置订阅状态URL ,用于接收 App Store 服务器回调通知的网址
官方文档: https://help.apple.com/app-store-connect/#/dev0067a330b

5.2 接收通知

苹果服务器通过HTTP POST将JSON对象传递给您的服务器,解析JSON获取responsebody,根据参数 notification_type 通知类型来执行不同的操作。

官方文档
responsebody: https://developer.apple.com/documentation/appstoreservernotifications/responsebodyv1
notification_type: https://developer.apple.com/documentation/appstoreservernotifications/notification_type

public void renewal(JSONObject object) {
    // 原始transaction_id
    String originalTransactionId = object.getString("original_transaction_id");
    // 获取订阅通知类型
    String notification_type = object.getString("notification_type");
    log.info("renewal notify: [ original_transaction_id: {} ], [ notification_type: {} ]", originalTransactionId, notification_type);

    // 回调收据信息
    JSONObject unifiedReceipt = object.getJSONObject("unified_receipt");
    JSONArray latestReceiptInfo = unifiedReceipt.getJSONArray("latest_receipt_info");
    if(!CollectionUtils.isEmpty(latestReceiptInfo)) {
        JSONObject receipt = latestReceiptInfo.getJSONObject(0);
        String productId = receipt.getString("product_id");
        String transactionId = receipt.getString("transaction_id");

        // 处理自动续订成功
        if("DID_RENEW".equals(notification_type)){
           // 业务逻辑
        }

        // 退款
        if("CANCEL".equals(notification_type)){
            if(!CollectionUtils.isEmpty(latestReceiptInfo)){
                // 业务逻辑
            }
        }
    }
}

更新

2022-10-16

请添加图片描述
实践中遇到了 消耗性票据 获取不正确的问题,记录一下。
支付票据(receipt)中 收据列表(in_app)会保留所有 订阅类商品非消耗性商品 信息,且会依次进入列表(最后一位是最新的一次购买记录)。消耗性商品 信息只在未向苹果服务器进行校验时存在,且只存在列表第一项(再次购买 消耗性商品 会替换票据信息)。

2023-01-03

  1. 回调通知最新版本为v2;v1已废弃。
    官方文档:https://developer.apple.com/documentation/appstoreservernotifications/app_store_server_notifications_v2
  2. 支付票据(receipt)中 收据列表(in_app),v1版本最新测试:订阅类商品原始票据位于最后一位;续订的票据信息从第一位开始往后,也就是说多次续订,最新的票据位于倒数第二位。
  3. 票据验证最新版本v2;v1已废弃。
    官方文档:https://developer.apple.com/documentation/appstorereceipts/validating_receipts_on_the_device

参考文档

https://juejin.cn/post/7046969127205863438
苹果简体中文文档: https://developer.apple.com/cn/documentation/

;