使用Java接入苹果内购流程(附主要代码)

一、苹果内购介绍

在App Store上架的 APP ,如果订购的商品类型是虚拟的,非实物的,类似:虚拟币等,需要使用苹果自己的支付方式,即:IAP内购,并且苹果会抽30%的税。
上架商品可在 APP Store后台配置。

二、服务端验证支付票据

2.1 服务端逻辑

@Override
    public boolean verifyIosPayReceipt(Long userId, AppIosPayReceiptVerifyParam param) {
        String receipt = param.getReceipt();
        String orderNo = param.getOrderNo();

        // 校验订单号的有效性
        boolean flag = IDUtil.validateOrderNo(orderNo);
        if (!flag) {
            throw new CustomizeException("订单号格式错误");
        }

        // 校验订单号是否存在
        Orders orders = ordersRepository.getByOrderNo(orderNo);
        if (null == orders) {
            throw new CustomizeException("订单号不存在");
        }

        // 注意,有的票据在客户端接收时 加号 可能会被转换为 空格
        String data = receipt.replace(" ", "+");
        // 请求苹果服务器进行票据验证
        String result = AppleVerifyUtil.verifyApple(data, 1);
        JSONObject receiptData = JSONObject.parseObject(result);

        // 解析票据
        if (result == null) {
            // 解析票据失败 或 网络问题
            log.error("[ verify receipt error]");
            return false;
        }

        // 支付环境是否正确
        int status = receiptData.getInteger("status");
        if (21007 == status) {
            // 验证失败21007 走沙箱环境
            result = AppleVerifyUtil.verifyApple(data, 0);
            if (result == null) {
                //  解析票据失败
                log.error("[ verify receipt error]");
                return false;
            }
            receiptData = JSONObject.parseObject(result);
            status = receiptData.getInteger("status");
        }

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

            // todo 判断product_id,看返回的product_id与实际的充值金额是不是一致,防止骗单


            // todo 剩余业务逻辑
            return true;
        }
        return false;
    }

2.2 苹果内购验证工具类

package xxx.xxx.xxx.util;

import com.alibaba.fastjson.JSONObject;
import lombok.extern.slf4j.Slf4j;
import okhttp3.MediaType;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.RequestBody;

import javax.net.ssl.SSLContext;
import javax.net.ssl.TrustManager;
import javax.net.ssl.X509TrustManager;
import java.net.URL;
import java.security.cert.X509Certificate;
import java.util.Objects;
import java.util.concurrent.TimeUnit;

/**
 * 苹果内购验证工具类
 */
@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    环境  (1 生产;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.parse("application/json;charset=utf-8");
            RequestBody stringBody = RequestBody.create(mediaType, jsonObject.toString());
            Request request = new Request
                    .Builder()
                    .url(console)
                    .post(stringBody)
                    .build();

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

    private static class TrustAnyTrustManager implements X509TrustManager {

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

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

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

2.3 票据信息的数据结构

服务端携带票据请求苹果服务端验证成功后,苹果会返回解析后的票据信息。我们需要的信息都在receipt中。但是经过测试发现,出现了两种数据结构,分别是IOS7之前和IOS7之后。

  • IOS7之前的数据格式
{
    "receipt": {
        "original_purchase_date_pst": "2016-12-03 01:11:01 America/Los_Angeles", 
        "purchase_date_ms": "1480756261254", 
        "unique_identifier": "96f51b28f628493709966f33a1fe7ba", 
        "original_transaction_id": "1000000255766", 
        "bvrs": "82", 
        "transaction_id": "1000000255766", 
        "quantity": "1", 
        "unique_vendor_identifier": "FE358-1362-40FD-870F-DF788AC5", 
        "item_id": "11822945", 
        "product_id": "rjkf_itemid_1", 
        "purchase_date": "2016-12-03 09:11:01 Etc/GMT", 
        "original_purchase_date": "2016-12-03 09:11:01 Etc/GMT", 
        "purchase_date_pst": "2016-12-03 01:11:01 America/Los_Angeles", 
        "bid": "com.xxx.xxx", 
        "original_purchase_date_ms": "1480756261254"
    }, 
    "status": 0
}
  • IOS7之后的数据结构
{
    "status": 0, 
    "environment": "Sandbox", 
    "receipt": {
        "receipt_type": "ProductionSandbox", 
        "adam_id": 0, 
        "app_item_id": 0, 
        "bundle_id": "com.xxx.xxx", 
        "application_version": "84", 
        "download_id": 0, 
        "version_external_identifier": 0, 
        "receipt_creation_date": "2016-12-05 08:41:57 Etc/GMT", 
        "receipt_creation_date_ms": "1480927317000", 
        "receipt_creation_date_pst": "2016-12-05 00:41:57 America/Los_Angeles", 
        "request_date": "2016-12-05 08:41:59 Etc/GMT", 
        "request_date_ms": "1480927319441", 
        "request_date_pst": "2016-12-05 00:41:59 America/Los_Angeles", 
        "original_purchase_date": "2013-08-01 07:00:00 Etc/GMT", 
        "original_purchase_date_ms": "1375340400000", 
        "original_purchase_date_pst": "2013-08-01 00:00:00 America/Los_Angeles", 
        "original_application_version": "1.0", 
        "in_app": [
            {
                "quantity": "1", 
                "product_id": "rjkf_itemid_1", 
                "transaction_id": "10000003970", 
                "original_transaction_id": "10000003970", 
                "purchase_date": "2016-12-05 08:41:57 Etc/GMT", 
                "purchase_date_ms": "1480927317000", 
                "purchase_date_pst": "2016-12-05 00:41:57 America/Los_Angeles", 
                "original_purchase_date": "2016-12-05 08:41:57 Etc/GMT", 
                "original_purchase_date_ms": "1480927317000", 
                "original_purchase_date_pst": "2016-12-05 00:41:57 America/Los_Angeles", 
                "is_trial_period": "false"
            }
        ]
    }
}

2.4 错误码

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

三、接入IOS回调通知

3.1 配置回调通知url

打开应用配置,设置沙盒喝生产环境的服务器接口地址即可。 苹果官方配置页面
注意:最好选择V2版本通知,不建议使用V1版本,V1版本即将废弃。

3.2 通知类型

通知类型(notification_type):苹果官方文档地址
根据解析出来的notification_type字段来判断回调通知具体是什么场景,然后进行对应的业务逻辑处理。

  • DID_RENEW:表示客户的订阅已成功自动续订新的交易周期。为客户提供对订阅内容或服务的访问权限。
  • REFUND:表示 App Store 已成功对消耗性应用内购买、非消耗性应用内购买或非续订订阅的交易进行退款。包含退款交易的时间戳。并标识原始交易和产品。其中包含原因。

3.3 测试

官方提供的沙盒环境测试退款方法:苹果官方文档地址,需要在本地xcode跑StoreKit Test,需要ios开发人员支持。
这里提供一个免费的webhook网址,可以用于本地测试接收通知:https://webhook.site/

3.4 处理回调通知

  • 请求报文结构
{"signedPayload":"eyJhbGciOiJFUzI1NiIsIng1YyI6WyJNSUlFTURDQ0E3YWdBd0lCQWdJUWFQb1BsZHZwU29FSDBsQnJqRFB2OWpBS0JnZ3Foa2pPUFFRREF6QjFNVVF3UWdZRFZRUURERHRCY0hCc1pTQlhiM0pzWkhkcFpHVWdSR1YyWld4dmNHVnlJRkpsYkdGMGFXOXVjeUJEWlhKMGFXWnBZMkYwYVc5dUlFRjFkR2h2Y21sMGVURUxNQWtHQTFVRUN3d0NSell4RXpBUkJnTlZCQW9NQ2tGd2NHeGxJRWx1WXk0eEN6QUpCZ05WQkFZVEFsVlRNQjRYRFRJeE1EZ3lOVEF5TlRBek5Gb1hEVEl6TURreU5EQXlOVEF6TTFvd2daSXhRREErQm..."}
  • 验证签名,解析数据
	/**
     * 验证签名
     * @param jws
     * @return
     * @throws CertificateException
     */
    public static JSONObject verifyAndGet(String jws) throws CertificateException {
        DecodedJWT decodedJWT = JWT.decode(jws);
        // 拿到 header 中 x5c 数组中第一个
        String header = new String(java.util.Base64.getDecoder().decode(decodedJWT.getHeader()));
        String x5c = JSONObject.parseObject(header).getJSONArray("x5c").getString(0);

        // 获取公钥
        PublicKey publicKey = getPublicKeyByX5c(x5c);

        // 验证 token
        Algorithm algorithm = Algorithm.ECDSA256((ECPublicKey) publicKey, null);

        try {
            algorithm.verify(decodedJWT);
        } catch (SignatureVerificationException e) {
            throw new RuntimeException("签名验证失败");
        }
        // 解析数据
        return JSONObject.parseObject(new String(java.util.Base64.getDecoder().decode(decodedJWT.getPayload())));
    }


    /**
     * 获取公钥
     * @param x5c
     * @return
     * @throws CertificateException
     */
    private static PublicKey getPublicKeyByX5c(String x5c) throws CertificateException {
        byte[] x5c0Bytes = java.util.Base64.getDecoder().decode(x5c);
        CertificateFactory fact = CertificateFactory.getInstance("X.509");
        X509Certificate cer = (X509Certificate) fact.generateCertificate(new ByteArrayInputStream(x5c0Bytes));
        return cer.getPublicKey();
    }
  • 解析出来的JSON数据
{
  "notificationType": "REFUND",
  "notificationUUID": "334d1548-****-4ea9-****-e104731870b9",
  "data": {
    "appAppleId": 1617026651,
    "bundleId": "com.*****",
    "bundleVersion": "1",
    "environment": "Sandbox",
    "signedTransactionInfo": "eyJhbGciOiJFUzI1NiIsIng1YyI6WyJNSUlFTURDQ0E3YWdBd0lCQWdJUWFQb1BsZHZwU29FSDBsQnJqRFB2OWpBS0JnZ3Foa2pPUFFRREF6QjFNVVF3UWdZRFZRUURERHRCY0hCc1pTQlhiM0pzWkhkcFpHVWdSR1YyWld4dmNHVnlJRkpsYkdGMGFXOXVjeUJEWlhKMGFXWnBZMkYwYVc5dUlFRjFkR2h2Y21sMGVURUxNQWtHQTFVRUN3d0NSell4RXpBUkJnTlZCQW9NQ2tGd2NHeGxJRWx1WXk0eEN6QUpCZ05WQkFZVEFsVlRNQjRYRFRJeE1EZ3lOVEF5TlRBek5Gb1hEVEl6TURreU5EQXlOVEF6TTFvd2daSXhRREErQmdOVkJBTU1OMUJ5YjJRZ1JVTkRJRTFoWXlCQmNIQWdVM1J2Y21VZ1lXNWtJR2xVZFc1bGN5QlRkRzl5WlNCU1pXTmxhWEIwSUZOcFoyNXBibWN4TERBcUJnTlZCQXNNSTBGd2NHeGxJRmR2Y214a2QybGtaU0JFWlhabGJHOXdaWElnVW1Wc1lYUnBi..."
  },
  "version": "2.0",
  "signedDate": 1680778196476
}

data中的signedTransactionInfo依然是一个jws格式,且字段与主动查询的结果一致,用上面的解析代码再解码一次。

  • 处理回调通知接口Java代码
/**
 * IOS通知请求体
 */
@Data
public class IosNotificationRequest {
    private String signedPayLoad;
}
	/**
     * 苹果ios服务通知回调
     */
    @RequestMapping("/iosNotification")
    public boolean iosNotification(@RequestBody IosNotificationRequest request) {
        log.info("apple ios server notification come in, request:{}", JSONObject.toJSONString(request));
        String signedPayLoad = request.getSignedPayLoad();
        try {
            JSONObject payload = AppleVerifyUtil.verifyAndGet(signedPayLoad);

            String notificationType = payload.get("notificationType").toString();

            JSONObject data = payload.getJSONObject("data");
            log.info("apple ios server notification verify success, payload:{}, data:{}", payload, data);
            String signedTransactionInfo = data.get("signedTransactionInfo").toString();
            String environment = data.get("environment").toString();

            JSONObject transactionInfo = AppleVerifyUtil.verifyAndGet(signedTransactionInfo);
            String transactionId = transactionInfo.get("transactionId").toString();
            String originalTransactionId = transactionInfo.get("originalTransactionId").toString();
            String productId = transactionInfo.get("productId").toString();

            if ("DID_RENEW".equals(notificationType)) {
                // todo 处理订阅续期业务逻辑

            } else if ("REFUND".equals(notificationType)) {
                // todo 处理退款业务逻辑

            } else {
                log.error("notificationType:{}未处理", notificationType);
            }

        } catch (CertificateException e) {
            log.error("apple ios server notification verify error, signedPayLoad:{}", signedPayLoad, e);
            return false;
        }
        return true;
    }

具体的业务处理这里就不过多赘述了,每个公司的业务不同,因此处理逻辑也不同,根据实际情况自行处理即可。

  • 10
    点赞
  • 13
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 7
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 7
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

技术杠精

你的鼓励是我创作最大的动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值