文章目录
简介
记录一下 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发送。 |
21002 | receipt-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
- 回调通知最新版本为v2;v1已废弃。
官方文档:https://developer.apple.com/documentation/appstoreservernotifications/app_store_server_notifications_v2 - 支付票据(receipt)中 收据列表(in_app),v1版本最新测试:订阅类商品的原始票据位于最后一位;续订的票据信息从第一位开始往后,也就是说多次续订,最新的票据位于倒数第二位。
- 票据验证最新版本v2;v1已废弃。
官方文档:https://developer.apple.com/documentation/appstorereceipts/validating_receipts_on_the_device
参考文档
https://juejin.cn/post/7046969127205863438
苹果简体中文文档: https://developer.apple.com/cn/documentation/