iOS 内购注意点

公司的项目最近集成了iOS内购, 尽管网上有很多相当详细的内购集成教程, 但可能由于集成内购的应用比较少, 市场需求不大, 所以教程都比较旧, 而且有几个重点没有提及到, 以至于小弟我踩了不少的坑...所以在这里打算就内购的几个注意点作一个小小的补充, 希望可以一解大家在集成内购时所产生的困惑. 当然如果大家有好的做法也欢迎指正, 毕竟小弟也是第一次集成内购.

1. 漏单问题

交易状态变化回调方法是由系统进行回调的, 无论是正在购买, 购买失败, 购买成功等都会被调用, 我们只需要在此方法中进行相应的操作即可.

// 交易状态变化回调方法
- (void)paymentQueue:(SKPaymentQueue *)queue updatedTransactions:(NSArray<SKPaymentTransaction *> *)transactions NS_AVAILABLE_IOS(3_0);

一般来说, 对于消耗性商品, 我们用得最多的是在判断用户购买成功之后交给我们的服务器进行校验, 收到服务器的确认后把交易 finish 掉.

// finish 交易
[[SKPaymentQueue defaultQueue] finishTransaction:transactions];

如果不把交易 finish 掉的话, 在下次重新打开应用待代码执行到监听内购队列后此方法都会被回调, 直到被 finish 掉为止. 所以为了防止漏单, 建议将内购抽类做成单例对象, 并在程序入口启动内购类, 第一时间监听内购队列. 这样做的话, 即使用户在成功购买商品后由于各种原因没告知服务器就关闭了应用, 在下次打开应用时也能及时把交易补回, 这样就不会造成漏单问题了.

// 监听内购队列
[[SKPaymentQueue defaultQueue] addTransactionObserver:_inPurchaseManager];

但事与愿违, 在调试中, 我们发现如果在有多个成功交易未 finish 掉的情况下把应用关闭后再打开, 往往会把其中某些任务漏掉, 即回调方法少回调了, 这让我们非常郁闷. 既然官方的API不好使, 我们只能把这个重任交给后台的验证流程了, 具体的做法下面会讲到.

2. 验证问题

在确认用户成功支付后, 我们需要把验证密钥发送给服务器, 密钥的本身说白了其实就是一个文件, 我们需要把它转成 ns64 字符串再交给服务器, 服务器拿到我们的密钥后就可以去苹果的后台进行验证了. 可能大家会很好奇, 后台究竟是怎样进行验证的呢, 带着这个疑问, 我们不妨来模拟一下.

我们先把本地的密钥文件转成 ns64 字符串.

// 获取验证文件url
NSURL *pathUrl = [[NSBundle mainBundle] appStoreReceiptURL];
// 文件不存在 return
if (![[NSFileManager defaultManager] fileExistsAtPath:pathUrl.path]) return;        
// 把文件转成数据流
NSData *receiptData = [NSData dataWithContentsOfURL:pathUrl];
// 把数据流转成 ns64 字符串
NSString *baseString = [receiptData base64EncodedStringWithOptions:NSDataBase64EncodingEndLineWithLineFeed];

没错, 这个 baseString 就是我们所说的密钥. 什么, 你想看看它长什么样? 相信我, 你不会想看的, 它就是一个大小约为7k的一大串字符. 另外, 苹果的验证接口有2个, 分别是调试接口和发布接口.

调试: https://sandbox.itunes.apple.com/verifyReceipt
发布: https://buy.itunes.apple.com/verifyReceipt

接下来我们就来模仿服务器的验证流程.

// 设置请求参数(key是苹果规定的)
NSDictionary *param = @{@"receipt-data":baseString};
// 获取网络管理者
AFHTTPSessionManager *manager = [AFHTTPSessionManager manager];
// 设置请求格式为json
manager.requestSerializer = [AFJSONRequestSerializer serializer];
// 发出请求
[manager POST:@"https://sandbox.itunes.apple.com/verifyReceipt" parameters:param progress:nil success:^(NSURLSessionDataTask * _Nonnull task, id  _Nullable responseObject) {
      
    NSLog(@"responseObject = %@", responseObject);
        
} failure:^(NSURLSessionDataTask * _Nullable task, NSError * _Nonnull error) {
    
    NSLog(@"error = %@", error);
}];

这里我们访问网络用的是 AFNetworking 框架, 需要注意的是这里必须要设置请求格式告诉苹果后台这是 json 格式, 不然苹果会不认识这些数据. 并且由于我们用的是沙盒测试账号, 所以访问的也是苹果的调试接口.

程序跑起来后, 很有可能会打印出错误日志, 提示Request failed: unacceptable content-type: text/plain"等一大串信息, 这是由于 AFNetworking 解析格式缺失的问题, 只要进入到 AFURLResponseSerialization.m 的源文件里, 在所属类 AFJSONResponseSerializer 中的 init 方法内添加一个字段即可.

- (instancetype)init {
    self = [super init];
    if (!self) {
        return nil;
    }
    // 原来的样子
    // self.acceptableContentTypes = [NSSet setWithObjects:@"application/json", @"text/json", @"text/javascript", nil];
    // 添加后的样子
    self.acceptableContentTypes = [NSSet setWithObjects:@"application/json", @"text/json", @"text/javascript", @"text/plain", nil];

    return self;
}

现在再把程序跑起来就会看到如下的打印内容了.

responseObject = {
    environment = Sandbox;
    receipt =     {
        "adam_id" = 0;
        "app_item_id" = 0;
        "application_version" = "1.0.3.2";
        "bundle_id" = "**********";
        "download_id" = 0;
        "in_app" =         (
                        {
                "is_trial_period" = false;
                "original_purchase_date" = "2017-02-08 02:26:13 Etc/GMT";
                "original_purchase_date_ms" = 1486520773000;
                "original_purchase_date_pst" = "2017-02-07 18:26:13 America/Los_Angeles";
                "original_transaction_id" = 1000000271607744;
                "product_id" = "**********_06";
                "purchase_date" = "2017-02-08 02:26:13 Etc/GMT";
                "purchase_date_ms" = 1486520773000;
                "purchase_date_pst" = "2017-02-07 18:26:13 America/Los_Angeles";
                quantity = 1;
                "transaction_id" = 1000000271607744;
            },
                        {
                "is_trial_period" = false;
                "original_purchase_date" = "2017-02-25 05:59:35 Etc/GMT";
                "original_purchase_date_ms" = 1488002375000;
                "original_purchase_date_pst" = "2017-02-24 21:59:35 America/Los_Angeles";
                "original_transaction_id" = 1000000276891381;
                "product_id" = "**********_01";
                "purchase_date" = "2017-02-25 05:59:35 Etc/GMT";
                "purchase_date_ms" = 1488002375000;
                "purchase_date_pst" = "2017-02-24 21:59:35 America/Los_Angeles";
                quantity = 1;
                "transaction_id" = 1000000276891381;
            },
                        {
                "is_trial_period" = false;
                "original_purchase_date" = "2017-03-10 05:44:43 Etc/GMT";
                "original_purchase_date_ms" = 1489124683000;
                "original_purchase_date_pst" = "2017-03-09 21:44:43 America/Los_Angeles";
                "original_transaction_id" = 1000000280765165;
                "product_id" = "**********_01";
                "purchase_date" = "2017-03-10 05:44:43 Etc/GMT";
                "purchase_date_ms" = 1489124683000;
                "purchase_date_pst" = "2017-03-09 21:44:43 America/Los_Angeles";
                quantity = 1;
                "transaction_id" = 1000000280765165;
            }
        );
        "original_application_version" = "1.0";
        "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";
        "receipt_creation_date" = "2017-03-10 05:44:44 Etc/GMT";
        "receipt_creation_date_ms" = 1489124684000;
        "receipt_creation_date_pst" = "2017-03-09 21:44:44 America/Los_Angeles";
        "receipt_type" = ProductionSandbox;
        "request_date" = "2017-03-10 08:50:00 Etc/GMT";
        "request_date_ms" = 1489135800761;
        "request_date_pst" = "2017-03-10 00:50:00 America/Los_Angeles";
        "version_external_identifier" = 0;
    };
    status = 0;
}

安全起见, 这里我把一些不方便展示的内容用 * 代替了. 一开始看到这些可能会有点晕, 毕竟信息量有点大, 但其实有很多东西一般是用不上的. 这里面我们最关心的是 in_app 里的数组, 因为根据苹果的官方文档所示, 这些就是付款成功而未被 finish 掉的交易 (如下图所示, 此处苹果并没有说消耗性的商品会出现在列表里, 不过本人例子中的商品全都属于消耗性的, 有点不惑) , 而一般这个数组里只会存在一个元素, 这里会出现3个是因为这3个单子已经被苹果漏掉了, 是的, 这就是上面所提到的漏单情况, 回调方法是不会再走了, 恶心吧...


1.png

但生活还是得继续, 这里我们可以看到每个交易里都有一些很详细的信息, 一般我们只对 original_transaction_id (交易ID)product_id (商品ID) 感兴趣, 服务器也是凭此作为用户购买成功的依据, 那么问题来了, 这里好像并没有用户的ID, 是的, 服务器是不知道商品是谁买的, 所以我们要把用户的ID和交易ID也一起发给服务器, 让服务器与验证返回的数据进行匹对, 从而把买家和商品对应起来.

// 设置发送给服务器的参数
NSMutableDictionary *param = [NSMutableDictionary dictionary];
param[@"receipt"] = baseString;
param[@"userID"] = self.userID;
param[@"transactionID"] = transactions.transactionIdentifier;

来到这里, 刚才遗留的漏单问题是时候要拿出来解决了, 刚才也说到了, 回调方法有可能少走, 甚至还有可能在客户端启动后完全不走 (这个只是以防万一) , 我个人建议的做法是, 首先在服务端建立2个表, 一个黑一个白, 黑表是记录过往真正购买成功的历史信息, 白表是记录付款成功而未认领的交易信息. 在客户端启动后的10秒内 (时间可以自己定) 回调方法如果都没有走, 我们就主动把密钥上传给服务器, 当然最好把用户的一些信息, 包括账号ID, 手机型号, 系统版本等信息一并带上, 服务器拿到密钥后去苹果后台验证, 把得到的付款成功的交易信息全部写进白表里 (检测去重) . 以后如果有新交易产生, 客户端会把密钥和交易号等信息传给服务器, 服务器同样到苹果后台验证后写进白表, 接着在表里看看是否有客户端所给的交易号信息, 如果有再去黑表里检测是否存在, 黑表不存在则判断为成功购买并结算商品, 这时要在白表中删除对应数据和在黑表中添加新数据, 之后回馈给客户端, 客户端把交易 finish 掉这个购买流程就算是结束了. 这时候白表里记录着的很有可能就是一些被漏掉的单子, 为什么不是一定而是很有可能? 因为会存在已经记录在黑表中但未被客户端 finish 掉的单子, 此时再到黑表中滤一遍就知道是否是真正的漏单了, 这时候只能通过人工的方式去解决了, 比如可以主动跟这位用户沟通询问情况, 或者是在有用户反应漏单时, 可以在表中检测相关信息判断是否属实等等. 另外服务器可以定时检测两个表中的数据进行去重操作, 当然也可以在每次添加进白表前先在黑表中过滤, 不过这样比较耗性能. 目前想到的解决办法就是这样的, 如果有更好的想法希望大家可以给点思路.

好了, 调整一下心情咱们继续. 聪明的同学可能察觉到了, 上面说到苹果有2个验证的接口, 那后台应该访问哪个呢? 是这样的, 无论应用上线与否, 只要是用沙盒测试账号进行内购的, 就应该访问调试的接口, 相反, 如果是用普通账号进行内购的, 则要访问发布的接口, 当然了, 未上线的应用是不允许用普通账号进行内购的. 那么问题来了, 我们怎么知道用户是通过普通帐号还是沙盒测试账号来进行内购的呢? 别急, 苹果提供了相关的状态码来帮助我们解决这个问题.

21000    App Store 不能读取你提供的JSON对象
21002    receipt-data 域的数据有问题
21003    receipt 无法通过验证
21004    提供的 shared secret 不匹配你账号中的 shared secret
21005    receipt 服务器当前不可用
21006    receipt 合法, 但是订阅已过期. 服务器接收到这个状态码时, receipt 数据仍然会解码并一起发送
21007    receipt 是 Sandbox receipt, 但却发送至生产系统的验证服务
21008    receipt 是生产 receipt, 但却发送至 Sandbox 环境的验证服务

没错, 细心的朋友应该留意到了, 在刚刚那一大串的验证返回数据中有一个名为 status 的 key, 正常时值为0. 所以我们的做法是, 全部统一先访问发布接口, 在返回的数据中检测 status 的值, 如果为 21007 , 说明是通过沙盒测试账号进行内购的, 则再访问调试接口. 事实上苹果的官方推荐做法也是这样的.


2.png

3. 误充问题

关于这个问题还是挺有趣的, 因为存在这样的一种情况: 用户A登录后买了一样商品, 但与服务器交互失败了, 导致没有把交易信息告知服务器, 接着他退出了当前帐号, 这时候用户B来了, 一登录服务器, 我们就会用当前用户ID把上次没有走完的内购逻辑继续走下去, 接下来的事情相信大家都能想像到了, 用户B会发现他获得了一件商品, 是的, 用户A买的东西被充到了用户B的手上.

要解决这个问题必须要把交易和用户ID绑定起来, 要怎么做呢? 其实很简单, 我们只要在查询商品结果回调方法里, 在添加交易队列之前把用户ID设进去即可.

// 查询商品结果回调方法
- (void)productsRequest:(SKProductsRequest *)request didReceiveResponse:(SKProductsResponse *)response {

    // 遍历每一件商品
    for (SKProduct *product in response.products) {

        // 生成可变订单
        SKMutablePayment *payment = [SKMutablePayment paymentWithProduct:product];
        // 设置用户ID
        payment.applicationUsername = self.userID;
        // 添加进交易队列
        [[SKPaymentQueue defaultQueue] addPayment:payment];
    }
}

然后给服务器发送的参数就不再像之前那样写了.

// 设置发送给服务器的参数
NSMutableDictionary *param = [NSMutableDictionary dictionary];
param[@"receipt"] = baseString;
// 之前
// param[@"userID"] = self.userID;
// 现在
param[@"userID"] = transactions.payment.applicationUsername;
param[@"transactionID"] = transactions.transactionIdentifier;

这样就不会有误充的问题了.

最后附上小弟写的内购工具类的github地址
https://github.com/Veeco/WGInPurchaseController

小弟第一次做内购, 如再发现一些坑或者有一些更好的处理方法时会继续补充. 另外大家如果有更好的观点或意见也欢迎多多指教, 谢谢!
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 202,723评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,080评论 2 379
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 149,604评论 0 335
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,440评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,431评论 5 364
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,499评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,893评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,541评论 0 256
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,751评论 1 296
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,547评论 2 319
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,619评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,320评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,890评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,896评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,137评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,796评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,335评论 2 342

推荐阅读更多精彩内容

  • Spring Cloud为开发人员提供了快速构建分布式系统中一些常见模式的工具(例如配置管理,服务发现,断路器,智...
    卡卡罗2017阅读 134,579评论 18 139
  • 一.总说内购的内容 协议、税务和银行业务 信息填写 内购商品的添加 添加沙盒测试账号 内购代码的具体实现 内购的注...
    九洲仙人阅读 2,944评论 2 3
  • 国家电网公司企业标准(Q/GDW)- 面向对象的用电信息数据交换协议 - 报批稿:20170802 前言: 排版 ...
    庭说阅读 10,846评论 6 13
  • 第一周很快过去,每日一绘都有认真的画,希望能够有进步,静等老师点评。
    夜鱼非鱼阅读 171评论 8 3
  • 昨天傍晚下班就匆匆赶去看这部电影,满场只有四个观众,大感庆幸。 如此上座率,估计很快就下线了,其实真的很不...
    止末阅读 352评论 0 0