ios IAP

ios 所有虚拟商品的支付都不允许接第三方支付 实在是太烦了 只能自己接IAP 也是第一次接触 将过程记录下来 有任何问题 欢迎指教
官方文档:https://developer.apple.com/documentation/storekit/in-app_purchase?language=objc

https://help.apple.com/app-store-connect/#/devb57be10e7

相关参考:https://www.jianshu.com/p/7ae9654b85ee

具体操作

前期准备

  1. 在iTunes connect中完善你的银行,税务,联系人信息

  2. 在证书和project中开启in-app purchase

  3. 准备一个可以支付的沙盒测试账号

    image-20200322222445401

    这个账号必须是一个没有注册过apple id的账号 这样在你测试的时候 你只要输入这个账号的用户名密码就可以了

  4. 创建应用内购买项目

    在iTunes Connect中,我的app->功能,点击App内购买项目,加号就可以添加了 需要根据不同需要添加不同类型的商品

    image-20200322222344383

    image-20200322222723336

代码实现

  1. 获取Product Identifier 当用户点击对应商品时 将刚才创建的product id传进来

    1
    2
    3
    SKProductsRequest *request = [[SKProductsRequest alloc] initWithProductIdentifiers:[NSSet setWithArray:[NSArray arrayWithObjects:@"productID", nil]]];
    request.delegate = self;
    [request start];
  2. 发起购买

    1
    2
    3
    4
    - (void)productsRequest:(SKProductsRequest *)request didReceiveResponse:(SKProductsResponse *)response{
    SKMutablePayment *payment = [SKMutablePayment paymentWithProduct:response.products.firstObject];
    [[SKPaymentQueue defaultQueue] addPayment:payment];
    }
  3. 验证购买凭证(包括 ios 跟调用服务端接口)

    发起了购买后,我们要对返回进行后续的处理,因为苹果只负责支付这一个部分,他并不负责这个购买完成后的服务怎么提供,也就说我们需要与后台交互,完成我们自己的业务逻辑
    这个Observer的代码需要写在Appdelegate.m中,这是因为我们要处理app意外推出的情况,如果写在了购买页面,那么就没有办法去处理意外退出的情况

    对于交易成功的商品,我们需要校验transaction的receipt是否是真实的,这里一个可行的做法就是我们把这个receipt(自动续约还需要共享密钥)交给后台,由后台负责与苹果校验信息是否有效。这步OK了之后,就可以和用户说购买成功,然后提供你这个增值服务了。

    由于我们与后台校验的逻辑不能写在购买页面,我们只传个receipt给后台,后台同事可能会问,我怎么知道用户买的是什么,看苹果的文档,我们发现校验成功后,返回的json数据有所有我们需要的信息

    这里要注意的是在处理交易完成的各种情况时,要记得调用下面的方法,告诉苹果,我这个购买完成了,否则每次进app都会收到updatedTransactions的回调。

    defaultQueue] finishTransaction: transaction];//购买结束```
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95
    96
    97
    98
    99
    100
    101
    102
    103
    104
    105
    106
    107
    108
    109
    110
    111
    112
    113
    114

    ```objective-c
    - (void)request:(SKRequest *)request didFailWithError:(NSError *)error {
    [SVProgressHUD showErrorWithStatus:@"发起支付失败"];
    }
    - (void)paymentQueue:(SKPaymentQueue *)queue updatedTransactions:(NSArray *)transactions {
    for (SKPaymentTransaction *transaction in transactions)
    {
    switch (transaction.transactionState)
    {
    case SKPaymentTransactionStatePurchased://购买成功
    [self dl_completeTransaction:transaction];
    break;
    case SKPaymentTransactionStateFailed://购买失败
    [self dl_failedTransaction:transaction];
    break;
    case SKPaymentTransactionStateRestored://恢复购买
    [self dl_restoreTransaction:transaction];
    break;
    case SKPaymentTransactionStatePurchasing://正在处理
    break;
    default:
    break;
    }
    }

    }
    #pragma mark - PrivateMethod
    - (void)dl_completeTransaction:(SKPaymentTransaction *)transaction {
    NSString *productIdentifier = transaction.payment.productIdentifier;
    NSData *receiptData = [NSData dataWithContentsOfURL:[[NSBundle mainBundle] appStoreReceiptURL]];
    NSString *receipt = [receiptData base64EncodedStringWithOptions:NSDataBase64Encoding64CharacterLineLength];
    if ([receipt length] > 0 && [productIdentifier length] > 0) {
    [SVProgressHUD showSuccessWithStatus:@"支付成功"];
    /**
    可以将receipt发给服务器进行购买验证
    */
    [self dl_validateReceiptWiththeAppStore:receipt];
    }
    [[SKPaymentQueue defaultQueue] finishTransaction: transaction];//购买结束
    }

    - (void)dl_failedTransaction:(SKPaymentTransaction *)transaction {
    if(transaction.error.code != SKErrorPaymentCancelled) {
    [SVProgressHUD showErrorWithStatus:@"用户取消支付"];
    } else {
    [SVProgressHUD showErrorWithStatus:@"支付失败"];
    }
    [[SKPaymentQueue defaultQueue] finishTransaction: transaction];
    }


    - (void)dl_restoreTransaction:(SKPaymentTransaction *)transaction {
    [[SKPaymentQueue defaultQueue] finishTransaction: transaction];
    }

    -(void)dl_validateReceiptWiththeAppStore:(NSString *)receipt
    {
    NSError *error;
    NSDictionary *requestContents = @{@"receipt-data": receipt};
    NSData *requestData = [NSJSONSerialization dataWithJSONObject:requestContents options:0 error:&error];
    if (!requestData) {

    }else{

    }
    NSLog(@"%@", requestData);
    NSURL *storeURL;
    #ifdef DEBUG
    storeURL = [NSURL URLWithString:@"https://sandbox.itunes.apple.com/verifyReceipt"];
    #else
    storeURL = [NSURL URLWithString:@"https://buy.itunes.apple.com/verifyReceipt"];
    #endif

    NSMutableURLRequest *storeRequest = [NSMutableURLRequest requestWithURL:storeURL];
    [storeRequest setHTTPMethod:@"POST"];
    [storeRequest setHTTPBody:requestData];
    NSOperationQueue *queue = [[NSOperationQueue alloc] init];
    [NSURLConnection sendAsynchronousRequest:storeRequest queue:queue
    completionHandler:^(NSURLResponse *response, NSData *data, NSError *connectionError) {
    if (connectionError) {
    [SVProgressHUD showErrorWithStatus:@"网络繁忙, 请稍后再试"];
    } else {
    NSError *error;
    NSDictionary *jsonResponse = [NSJSONSerialization JSONObjectWithData:data options:0 error:&error];
    if (!jsonResponse) {
    /* 处理error */
    NSLog(@"%@", error);
    }else{
    /*服务端处理支付请求*/
    NSString *paidUrl = [API_SERVER stringByAppendingString:@"paid_url"];
    NSMutableDictionary *parameter = [NSMutableDictionary dictionary];
    parameter[@"token"] = @"xxx";//订单唯一标识
    parameter[@"pay_method"] = @"xxx";
    parameter[@"receipt"] = receipt;
    AFHTTPSessionManager * manager = [AFHTTPSessionManager manager];
    manager.requestSerializer = [AFJSONRequestSerializer serializer];
    manager.responseSerializer = [AFJSONResponseSerializer serializer];
    [manager POST:paidUrl parameters:parameter constructingBodyWithBlock:nil progress:nil success:^(NSURLSessionDataTask * _Nonnull task, id _Nullable responseObject) {
    NSNumber*code= [responseObject objectForKey:@"code"];
    if([code isEqualToNumber:[NSNumber numberWithInt:1000]]){
    NSLog(@"update order status success");
    }else{
    NSLog(@"update order status error");
    }
    } failure:^(NSURLSessionDataTask * _Nullable task, NSError * _Nonnull error) {
    NSLog(@"error==========%@", error);
    [SVProgressHUD showErrorWithStatus:@"网络繁忙, 请稍后再试"];
    }];
    }
    }
    }];

    }

ps: 在支付文件头部需要引入StoreKit

1
2
3
#import <StoreKit/StoreKit.h>
#import <StoreKit/SKPaymentTransaction.h>
@interface memberChoosePeriodViewController ()<SKProductsRequestDelegate,SKPaymentTransactionObserver>

服务器端之后的验证

如果是会自动续费的产品 我们就需要做自己的验证了

在APP启动时候要增加侦听:
[[SKPaymentQueue defaultQueue] addTransactionObserver:self];

  1. 在订阅url 中填入服务器地址 每次需要续费的时候 ios 会来请求

    image-20200322225205935

  2. 对于自动订阅的订单 每次支付的时候 我都会开一个30days之后的定时任务 去检查到期时扣款是否成功

    ps:注意上线跟测试时的地址是不同的

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    class IosRenewMemberWorker
    include Sidekiq::Worker
    sidekiq_options queue: :api_default

    def perform(order_id)
    order = ImprovedOrder.find order_id
    res = RestClient::Request.execute(
    method: :post,
    url: "https://sandbox.itunes.apple.com/verifyReceipt",
    # url: "https://buy.itunes.apple.com/verifyReceipt",
    timeout: 5,
    headers: {"content-type" => 'application/json'},
    payload: {"receipt-data": order.ios_newest_receipt.gsub("\r\n", ""),"password": "xxx"}.to_json #passtword 是App 专用共享密钥
    )
    r = JSON.parse res
    #订阅重试 r["pending_renewal_info"][0]["is_in_billing_retry_period"]
    #“1”- App Store 仍然尝试续期订阅,续期失败 “0”- App Store 已停止尝试续期订阅。
    if r['status'] == 0 && order.aasm_state == 'paid' && r["pending_renewal_info"][0]["is_in_billing_retry_period"] != "1"
    order.update(ios_newest_receipt: r["latest_receipt"])
    order.user.upgrade_membership!(30.days,"auto")
    #自动续期状态 r["pending_renewal_info"][0]["auto_renew_status"]
    #“1”- 订阅将在当前订阅时间段结束时续期。 “0”- 顾客已关闭自动续期订阅。
    IosRenewMemberWorker.perform_at(30.days, order.id) if r["pending_renewal_info"][0]["auto_renew_status"] == "1"
    #取消订阅 cancellation_date
    #注意: 取消的App内购买项目将无限期保留在收据中。仅在为非消耗型产品、自动续期订阅、 非续期订阅或免费订阅退款时适用。
    #续费产品 r["pending_renewal_info"][0]["product_id"]
    elsif r["pending_renewal_info"][0]["is_in_billing_retry_period"] == "1"
    order.ios_pay_fail!
    end
    end
    end
  3. 每次需要续费的时候 ios 会来请求url 更待订单状态 并支付虚拟商品

    image-20200322231906611
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    desc 'iOS IAP listen url'
    post 'IAP_listen' do
    order = ImprovedOrder.find_by(ios_newest_receipt: params[:unified_receipt])
    #CANCEL Apple客户支持取消了订阅。检查Cancellation Date以了解订阅取消的日期和时间。
    #RENEWAL 已过期订阅的自动续订成功。检查Subscription Expiration Date以确定下一个续订日期和时间。
    #INTERACTIVE_RENEWAL 客户通过使用应用程序界面或在App Store中的App Store中以交互方式续订订阅。服务立即可用。
    #DID_CHANGE_RENEWAL_PREF 客户更改了在下次续订时生效的计划。当前的有效计划不受影响。
    if params[:notification_type] == "CANCEL"
    order.cancel_auto_renew!
    elsif params[:notification_type] == "RENEWAL"
    order.restore_pay!
    IosRenewMemberWorker.perform_at(30.days, order.id) if order.plan == 'ios_wonder_cv_vip'
    elsif params[:notification_type] == "INTERACTIVE_RENEWAL"
    order.auto_restore_pay!
    IosRenewMemberWorker.perform_at(30.days, order.id) if order.plan == 'ios_wonder_cv_vip'
    #elsif params[:notification_type] == "DID_CHANGE_RENEWAL_PREF"
    end
    {
    status: 0,
    code: 1000,
    message: '订单状态更改成功'
    }
    end

测试

  • 测试拉取有效的IAP项目,和相关页面
  • 测试一个成功的交易
    购买你的IAP项目,然后登陆第一步中申请的sandbox测试员的账号,然后在paymentQueue:updatedTransactions:看这个transaction.status是不是SKPaymentTransactionStatePurchased
  • 测试校验收据
  • 测试一个被打断的交易
    打断点,在完成交易前终止程序重新run项目,看看能不能继续完成购买
    ,这个情况是模拟用户已经付款成功,但是App由于某种原因崩溃。

特殊点

1.sandbox怎么测试取消订阅?
答:自己模拟啊,模拟苹果的post请求,先看苹果的server-to-server(https://developer.apple.com/documentation/storekit/in-app_purchase/enabling_server-to-server_notifications?language=objc),其中notification_type 对应值为 CANCEL ,其它字段按需要改成之前订阅的数据,向你们服务器发送请求啊,就点拨到这了。

2.自己的服务器怎么处理苹果的续订?
答:

  • 首先用户第一次购买订阅,server需要把票据存储(最好把过期时间也记录一下,字段record_expires_date),苹果会通知我们的server的,其中notification_type 对应值为 INITIAL_BUY。
  • 服务器需要做个定期(每天)检测,检测目前已有的所有订阅订单是否过期,如果发现过期了,就去苹果服务器验证receipt,其中苹果返回的latest_receipt_info 字段,会告诉最新的订阅订单情况,你可以校验expires-date与当前时间比较,判断该订阅有没有续订成功,并同时更新上述让记录的record_expires_date字段.
  • 我们为什么做上述的处理?大家都知道苹果服务器会在订阅过期的前一天,对用户进行自动扣费,如果扣费成功了,苹果服务器并不会通知我们的服务器,这是重点。不过有个特例,如果苹果订阅过期前一天扣费失败了,苹果服务器后面几天还会尝试对用户自动扣费,如果后面扣费成功了(这时候用户实际状态是没有续订成功),苹果会通知我们的server的,其中notification_type 对应值为 RENEWAL,对于RENEWAL我们还是需要给用户更新为正在订阅的状态。
  • 正式环境下,用户主动取消订阅,苹果会通知我们的server的,其中notification_type 对应值为 CANCEL,我们需要更新用户订阅的状态为取消。
  • 总结,对于自动续订订阅,我们自己的服务器完全可以与apple server的交互应对用户的订阅状态,只需要确定客户端传来的用户第一次购买, user id 对应 original-transaction-id的关系。后面的续订,取消,变更套餐,完全不依赖于客户端传来的信息。