前言 iOS 内购分为四种商品类型:
消耗品
非消耗品
自动续期订阅
非续期订阅
内购(In-App Purchase) IAP
全称:In-App Purchase
,是指苹果 App Store
的应用内购买,是苹果为 App
内购买 虚拟商品或服务
提供的一套交易系统。
适用范围:在 App
内需要付费使用的产品功能或虚拟商品/服务,如游戏道具、电子书、音乐、视频、订阅会员、App的高级功能等需要使用 IAP
,而在 App
内购买实体商品(如淘宝购买手机)或者不在 App
内使用的虚拟商品(如充话费)或服务(如滴滴叫车)则不适用于 IAP
。
简而言之,苹果规定:适用范围内的 虚拟商品或服务
,必须使用 IAP
进行购买支付,不允许使用支付宝、微信支付等其它第三方支付方式(包括Apple Pay),也不允许以任何方式(包括跳出App、提示文案等)引导用户通过应用外部渠道购买。
内购前准备 APP内集成IAP代码之前需要先去开发账号的 ITunes Connect
进行以下三步操作:
后台填写 银行账户、税务
信息;
配置商品信息,包括产品ID,产品价格等;
配置用于测试IAP支付功能的沙箱账户。
填写银行账户信息一般交由产品管理人员负责,开发者不需要关注,开发者需要关注的是第二步和第三步。
银行账户、税务信息填写 关于如何去 Itunes Connect
后台填写账户信息,本文不做讨论
配置内购商品 IAP
是一套商品交易系统,而非简单的支付系统,每一个购买项目都需要在开发者后台的 Itunes Connect后台
为 App
创建一个对应的商品,提交给苹果审核通过后,购买项目才会生效。内购商品有四种类型:
消耗型项目:只可使用一次的产品,使用之后即失效,必须再次购买,如:游戏币、一次性虚拟道具等;
非消耗型项目:只需购买一次,不会过期或随着使用而减少的产品。如:电子书;
自动续期订阅:允许用户在固定时间段内购买动态内容的产品。除非用户选择取消,否则此类订阅会自动续期,如:Apple Music这类按月订阅的商品;
非续期订阅:允许用户购买有时限性服务的产品,此 App
内购买项目的内容可以是静态的。此类订阅不会自动续期。
配置商品信息需要注意产品ID和产品价格
产品 ID
具有唯一性,建议使用项目的 Bundle Identidier
作为前缀后面拼接自定义的唯一的商品名或者 ID
(字母、数字),这里有个坑:一旦新建一个内购商品,它的产品ID将永远被占用,即使该商品已经被删除,已创建的内购商品除了产品 ID
之外的所有信息都可以修改,如果删除了一个内购商品,将无法再创建一个相同产品 ID
的商品,也意味着该产品 ID
永久失效。
在创建IAP项目的时候,需要设定价格,产品价格只能从苹果提供的价格等级去选择,这个价格等级是固定的,同一价格等级会对应各个国家的货币,比如等级1对应1美元、6元人民币,等级2对应2美元、12元人民币……最高等级87对应999.99美元、6498元人民币。另外可能是为了照顾某些货币区的开发者和用户,还有一些特殊的等级,比如备用等级A对应1美元、1元人民币,备用等级B对应1美元、3元人民币这样。除此之外,IAP项目不能定一个9.9元人民币这样不符合任何等级的价格。详细价格等级表可以看苹果的官方价格等级文档。苹果的价格等级表通常是不会调整的,但也不排除在某些货币汇率发生巨大变化的情况下,对该货币的定价进行调整,调整前苹果会发邮件通知开发者。
商品分成,App Store上的付费App和App内购,苹果与开发者默认是 3/7
分成。但实际上,在某些地区苹果与开发者分成之前需要先扣除交易税,开发者的实际分成不一定是70%。从2015年10月开始,苹果对中国地区的App Store购买扣除了2%的交易税,对于中国区帐号购买的IAP,开发者的实际分成在68%~69%之间,而且中国以外不同地区的交易税标准也存在差异。
配置沙箱测试账号
新的内购产品上线之前,测试人员一般需要对内购产品进行测试,但是内购涉及到钱,所以苹果为内购测试提供了 沙箱测试账号
的功能,Apple Pay
推出之后 沙箱测试账号
也可以用于 Apple Pay
支付的测试,沙箱测试账号
简单理解就是:只能用于 内购
和 Apple Pay
测试功能的 Apple ID
,它并不是真实的 Apple ID
。
填写沙箱测试账号信息需要注意以下几点:
电子邮件不能是别人已经注册过 AppleID
的邮箱;
电子邮箱可以不是真实的邮箱,但是必须符合邮箱格式;
App Store
地区的选择,测试的时候弹出的提示框以及结算的价格会按照沙箱账号选择的地区来,建议测试的时候新建几个不同地区的账号进行测试。
沙箱账号测试的使用:
首先沙箱测试账号必须在真机环境下进行测试,并且是 adhoc
证书或者 develop
证书签名的安装包,沙盒账号不支持直接从 App Store
下载的安装包;
去真机的 App Store
退出真实的 Apple ID
账号,退出之后并不需要在App Store 里面登录沙箱测试账号;
然后去 App
里面测试购买商品,会弹出登录框,选择使用现有的 Apple ID
,然后登录沙箱测试账号,登录成功之后会弹出购买提示框,点击购买,然后会弹出提示框完成购买。
内购流程
获取内购产品列表(从App内读取或从自己服务器读取),向用户展示内购列表
用户选择某个内购产品后,先 请求可用的内购产品的本地化信息列表
,此次调用Apple的 StoreKit
库的代码
得到内购产品的本地化信息后,根据用户选择的内购 产品的ID
得到 内购产品
根据内购产品 发起IAP购买请求
,收到购买完成的回调
购买流程结束后, 向自己服务器发起 验证凭证
以及支付结果的请求
自己的服务器接收iOS端发过来的 购买凭证
,判断凭证是否已经存在或验证过,然后存储该凭证。将该凭证发送到苹果的服务器验证,并将验证结果返回给客户端
自己的服务器将支付结果信息返回给前端并发放虚拟产品
自动续期订阅和非续期订阅 IAP
去开发者网站配置 税务
账号
去开发者网站配置 银行
账号
去开发者网站配置 价格
信息
在开发者账号中勾选内购权限
项目中封装一个 FJYDIAPTool
类,实现内购买功能
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 #import <Foundation/Foundation.h> #import "FJYDMeVIPModel.h" NS_ASSUME_NONNULL_BEGIN @protocol FJYDIAPToolDelegate <NSObject> // 购买并验证成功的回调 - (void)FJYDIAPToolBuyProductSucceedWithInfoReceiptModel:(FJYDBuyReceiptModel *)receiptModel; // 验证凭证结束上传地理位置的回调 - (void)FJYDIAPToolVerificationEndPostLocationWithRectiptModel:(FJYDBuyReceiptModel *)receiptModel; @end @interface FJYDIAPTool : NSObject /** 代理 */ @property (nonatomic,weak)id<FJYDIAPToolDelegate>delegate; //是否在购买中 @property (nonatomic,assign)BOOL isBuying; /** 单例 @return FJYDIAPTool */ + (FJYDIAPTool *)shareTool; /** 初始化侦听 */ - (void)setUp; /** 用户决定购买商品 @param productID 商品ID */ - (void)buyProductWithProductID:(NSString *)productID; /** 恢复商品 */ - (void)restorePurchase; @end
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 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 #import "FJYDIAPTool.h" #import <iconv.h> #import "FJYDVIPHttps.h" #import "FJYDReceiptData.h" @interface FJYDIAPTool ()<SKPaymentTransactionObserver,SKProductsRequestDelegate> @property(nonatomic, strong)SKProductsRequest * request; @property(nonatomic,copy)NSString * productID; @end @implementation FJYDIAPTool // 单例 static FJYDIAPTool * shareTool; + (FJYDIAPTool *)shareTool{ if (!shareTool) { shareTool = [FJYDIAPTool new]; } return shareTool; } #pragma mark -setUp - (void)setUp{ // 设置购买队列的监听器 [[SKPaymentQueue defaultQueue] addTransactionObserver:self]; } #pragma mark -用户决定购买商品 - (void)buyProductWithProductID:(NSString *)productID{ if ([SKPaymentQueue canMakePayments]) { [self requestProductData:productID]; self.productID = productID; }else{ FJYDLog(@"不允许程序内付费"); } } #pragma mark -去苹果服务器请求商品 - (void)requestProductData:(NSString *)productID{ NSArray * product = [[NSArray alloc] initWithObjects:productID,nil]; NSSet * nsset = [NSSet setWithArray:product]; _request = [[SKProductsRequest alloc]initWithProductIdentifiers:nsset]; _request.delegate = self; [_request start]; } #pragma mark -收到产品返回信息 - (void)productsRequest:(SKProductsRequest *)request didReceiveResponse:(SKProductsResponse *)response{ NSLog(@"-------------请求对应的产品信息----------------"); dispatch_async(dispatch_get_main_queue(), ^{ [MBProgressHUDExt showProgressLoading:@"" inView:kWindow]; }); NSArray *product = response.products; //没有商品 if (product.count == 0) { dispatch_async(dispatch_get_main_queue(), ^{ [MBProgressHUDExt showMessage:@"没有商品" inView:kWindow afterDelayTime:kHUD_Time]; }); return; } // 验证商品ID SKProduct *prod = nil; for (SKProduct *pro in product) { NSLog(@"%@", [pro description]); NSLog(@"%@", [pro localizedTitle]); NSLog(@"%@", [pro localizedDescription]); NSLog(@"%@", [pro price]); NSLog(@"%@", [pro productIdentifier]); if ([pro.productIdentifier isEqualToString:self.productID]) { prod = pro; } } // 发送购买请求 if (prod != nil) { SKPayment *payment = [SKPayment paymentWithProduct:prod]; [[SKPaymentQueue defaultQueue]addPayment:payment]; } } // 失败回调 - (void)request:(SKRequest *)request didFailWithError:(NSError *)error{ //隐藏 [MBProgressHUDExt hidden]; //购买失败 [MBProgressHUDExt showMessage:@"购买失败" inView:kWindow afterDelayTime:kHUD_Time]; } // 支付后的反馈信息 - (void)requestDidFinish:(SKRequest *)request{ FJYDLog(@"反馈信息结束"); //隐藏 [MBProgressHUDExt hidden]; } #pragma mark -SKPaymentTransactionObserver( //监听购买队列的变化,购买队列状态变化,判断购买状态是否成功(只有在重新启动中才能再次执行) - (void)paymentQueue:(SKPaymentQueue *)queue updatedTransactions:(NSArray<SKPaymentTransaction *> *)transactions{ //处理结果 for (SKPaymentTransaction * transaction in transactions) { NSLog(@"队列状态变化 %@",transaction); // 小票状态 switch (transaction.transactionState) { // 购买完成 case SKPaymentTransactionStatePurchased: { // 订阅特殊处理(自动订阅第二次扣费:自动扣费) if(transaction.originalTransaction){ FJYDLog(@"自动订阅第二次扣费:自动扣费成功"); //购买支付成功后,向自己的服务器发送凭证进行验证 [self sendReceiptToServerWithTransaction:transaction]; } // 普通购买以及第一次购买 else{ FJYDLog(@"普通购买(非自动订阅,每次购买都执行)以及第一次购买(自动订阅)"); // 购买支付成功后,向自己的服务器发送凭证进行验证 [self sendReceiptToServerWithTransaction:transaction]; } } break; //恢复成功 case SKPaymentTransactionStateRestored: { NSLog(@"恢复成功 :%@", transaction.payment.productIdentifier); [MBProgressHUDExt showMessage:@"恢复购买成功" inView:kWindow afterDelayTime:kHUD_Time]; // 将交易从交易队列中删除 [[SKPaymentQueue defaultQueue] finishTransaction:transaction]; } break; // 交易失败 case SKPaymentTransactionStateFailed: { if (transaction.error.code != SKErrorPaymentCancelled) { [MBProgressHUDExt showMessage:@"购买失败" inView:kWindow afterDelayTime:kHUD_Time]; }else{ [MBProgressHUDExt showMessage:@"用户取消交易" inView:kWindow afterDelayTime:kHUD_Time]; } // 将交易从交易队列中删除 [[SKPaymentQueue defaultQueue] finishTransaction:transaction]; } break; //商品已经添加进列表 case SKPaymentTransactionStatePurchasing: { NSLog(@"商品已经添加进列表"); [MBProgressHUDExt showProgressLoading:@"" inView:kWindow]; } break; case SKPaymentTransactionStateDeferred: { NSLog(@"等待确认,儿童模式需要询问家长同意"); } default: break; } } } #pragma mark -购买成功后,向服务器发送凭证,并验证凭证 - (void)sendReceiptToServerWithTransaction:(SKPaymentTransaction *)transaction{ // 验证凭据,获取到苹果返回的交易凭据 // appStoreReceiptURL iOS7.0增加的,购买交易完成后,会将凭据存放在该地址 NSURL * receiptURL = [[NSBundle mainBundle] appStoreReceiptURL]; // 从沙盒中获取到购买凭据 NSData * receiptData = [NSData dataWithContentsOfURL:receiptURL]; // 加密成Base64形式的NSString NSString * receiptStr = [receiptData base64EncodedStringWithOptions:NSDataBase64EncodingEndLineWithLineFeed]; // FJYDLog(@"receiptStr:%@",receiptStr); // 存储凭证和token [FJYDReceiptData saveOrderReceiptWithReceipt:receiptStr]; // 如果是在支付页面 if (self.isBuying == YES) { //获取用户信息 NSString * tokenStr = [kUserDefault objectForKey:kUserToken]; //加载态 [MBProgressHUDExt showProgressLoading:@"支付确认中..." inView:kWindow]; //如果用户信息和凭证均有值 if (tokenStr.length > 0 && receiptStr.length > 0) { //发送凭证 [self sendRecwiptToServerWithReceipt:receiptStr Token:tokenStr Transaction:transaction]; } } // 如果不是在支付页面 else { //获取凭证 NSMutableArray * receiptArr = [FJYDReceiptData getOrderReceipt]; // 如果有凭证 if (receiptArr.count > 0) { // 向服务器验证凭证 for (NSInteger i = 0; i < receiptArr.count; i ++) { NSMutableDictionary * dic = [receiptArr objectAtIndex:i]; NSString * tokenStr = [dic objectForKey:@"token"]; NSString * receiptStr = [dic objectForKey:@"receipt"]; //发送凭证 [self sendRecwiptToServerWithReceipt:receiptStr Token:tokenStr Transaction:transaction]; } } } } //发送凭证 - (void)sendRecwiptToServerWithReceipt:(NSString *)receipt Token:(NSString *)token Transaction:(SKPaymentTransaction *)transaction{ //发送给服务器凭证 token = [NSString stringWithFormat:@"authorization%@",token]; [FJYDVIPHttps sendAndValidationReceipt:receipt Token:token Succeed:^(FJYDBaseModel * _Nonnull model) { //隐藏 [MBProgressHUDExt hidden]; //解析 FJYDBuyReceiptModel * receiptModel = [FJYDBuyReceiptModel mj_objectWithKeyValues:model.data]; //如果请求成功,并返回数据 if (receiptModel != nil) { FJYDLog(@"凭证验证成功"); // 从列表中移除当前凭证 [FJYDReceiptData removeOrderReceiptWithReceipt:receipt]; // 如果将交易从交易队列中删除 [[SKPaymentQueue defaultQueue] finishTransaction:transaction]; // 在支付页面需要跳转支付成功页面 if (self.isBuying == YES) { //验证结束上传地理位置的回调 if (self.delegate && [self.delegate respondsToSelector:@selector(FJYDIAPToolVerificationEndPostLocationWithRectiptModel:)]) { [self.delegate FJYDIAPToolVerificationEndPostLocationWithRectiptModel:receiptModel]; } // 购买成功并验证凭证成功的回调 if (self.delegate && [self.delegate respondsToSelector:@selector(FJYDIAPToolBuyProductSucceedWithInfoReceiptModel:)]) { [self.delegate FJYDIAPToolBuyProductSucceedWithInfoReceiptModel:receiptModel]; } } } // 如果请求成功,但是返回数据失败 else { FJYDLog(@"凭证验证失败"); } } Failure:^(NSError * _Nonnull error) { [MBProgressHUDExt hidden]; FJYDLog(@"凭证验证失败"); }]; } #pragma mark -恢复商品 - (void)restorePurchase{ // 恢复已经完成的所有交易.(仅限永久有效商品) [[SKPaymentQueue defaultQueue] restoreCompletedTransactions]; } #pragma mark -dealloc -(void)dealloc{ // 解除监听 [[SKPaymentQueue defaultQueue] removeTransactionObserver:self]; } @end
自动续期订阅 自动续期订阅需要增加一个参数 password
,秘钥
在APP内购买项目处创建。服务器提供URL用以接收苹果服务器通知,包含订阅状态变更或App内购买项目退款等。
丢单及其他问题处理
1、发起支付
2、扣费成功
3、得到receipt(支付凭据)
4、去后台验证凭据获取商品交易状态
5、返回数据,验证成功前端刷新数据
漏单情况一:2到3环节出问题属于苹果的问题,目前没做处理。
漏单情况二:3到4的时候出问题,比如断网。此时 移动端
会把支付凭据 持久化存储
下来,如果期间用户卸载APP此单在前端就真漏了,如果没有协助,下次重新打开app进入购买页会先判断有无未成功的支付,有就提示用户,用户选择找回,重走4,5流程。这一步看产品需求怎么做,可以让用户自主选择是否恢复未成功的支付也可以前端默默恢复就行。
漏单情况三:4到5的时候出问题。此时后台其实已经成功,只是 移动端
没获取到数据,当漏单处理,下次进入的时候先刷新数据即可。