阅读量:0
使用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发送。 |
21002 | receipt-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; }
具体的业务处理这里就不过多赘述了,每个公司的业务不同,因此处理逻辑也不同,根据实际情况自行处理即可。