关于内购
内购的收益是三七分成。
关于内购产品
内购产品可以是内容(比如数字内容)、功能(比如去除广告)或服务(比如语音编辑),不能用于真实物品与服务、不合适的内容(比如色情作品等)。
内购产品类型有:自动续期订阅、非续期订阅、非消耗型项目、消耗型项目。
区别 | 自动续期订阅 | 非续期订阅 | 非消耗型项目 | 消耗型项目 |
---|---|---|---|---|
用户可购买次数 | 多次 | 多次 | 多次 | 一次 |
凭据中购买记录信息 | 一直 | 一直 | 一直 | 一次 |
跨设备同步 | 系统控制 | App控制 | 系统控制 | 不存在 |
恢复 | 系统控制 | App控制 | 系统控制 | 不存在 |
关于自动续期订阅
Apple会在订阅到期前10天检查续订可行性,会在自动续期订阅到期前24小时开始尝试续订,如果续订失败,AppStore可能还会继续尝试,连续续订多次后就不会再次尝试,从时间上最多尝试60天。
在测试环境中自动续期订阅会比正常的要快,而且每天最多续订6次。
内购产品UI设计
- 只有用户能购买的时候才展示商店
- 很自然的过渡到产品界面
- 产品有组织化能方便用户找到
- 向用户传达你的产品的价值
- 产品价格清楚显示,根据AppStore返回的地区和价格单位来显示
关于凭据
iTunes Connect 配置
配置协议、税务银行业务
配置用户和职能,在其中配置测试账号
- 测试账号不要在正式环境登陆,已登陆就会永久失效
内购产品的配置
- 内购产品可以删除,但是删除后的内购产品ID依然不可再次使用
- 内购产品不完整会显示元数据丢失
- 内购产品跨平台不能使用,比如iOS上买的,在macOS不可用
场景
在应用中购买内购产品
- 根据服务器或本地存储的内购产品ID构造
SKProductsRequest
请求,使用SKProductsRequestDelegate
获取产品的信息SKProduct
,比如产品的描述、产品的价格- 以Apple返回的产品列表为准,因为有的产品可能会变成无效
- 使用
[SKPaymentQueue canMakePayments]
判断用户是否启用程序内购买 - 根据
SKProduct
构造SKPayment
,使用-[SKPaymentQueue addPayment:]
开始购买,使用SKPaymentTransactionObserver
监听交易结果- 如果购买成功,那么记录交易情况,发送产品(比如下载附件),然后结束交易,结束监听
- 如果购买失败,结束交易,结束监听
- 如果已购买,那么需要做 恢复购买 处理,然后结束监听
在AppStore购买内购产品
- 使用
SKProductStorePromotionController
控制 Promoted Products 的可见性和显示顺序(本地存储)- 使用
-[SKProductStorePromotionController fetchStorePromotionVisibilityForProduct:completionHandler:]
读取可见性 - 使用
-[SKProductStorePromotionController updateStorePromotionVisibility:forProduct:]
设置可见性 - 使用
-[SKProductStorePromotionController fetchStorePromotionOrderCompletionHandler:]
读取显示顺序 - 使用
-[SKProductStorePromotionController updateStorePromotionOrder:completionHandler:]
设置显示顺序- 传进来的数组中的产品显示在列表的开头,然后才是剩余的内购产品
- 如果传进来的是空数组,那么就表示默认显示顺序
- 使用
- 使用
-[SKPaymentTransactionObserver paymentQueue:shouldAddStorePayment:forProduct:]
延迟或取消购买 - 使用
-[SKPaymentQueue addPayment:]
开始购买,使用SKPaymentTransactionObserver
监听交易结果,后续操作参考 在应用中购买内购产品 - 使用
itms-services://?action=purchaseIntent&bundleId=<com.example.app>&productIdentifier=<product_name>
来测试
从AppStore下载内购产品的附件
- 使用
-[SKPaymentQueue startDownloads:]
下载附件 - 使用
-[SKPaymentTransactionObserver paymentQueue:updatedDownloads:]
监听下载过程 - 使用
pauseDownloads:
、resumeDownloads:
和cancelDownloads:
来操作下载 - 使用
SKDownload
的contentURL
、contentURLForProductID:
、deleteContentForProductID:
来读取或删除下载文件
自动续订订阅的服务器到服务器通知
要接收状态更新通知,请在App Store Connect中为您的应用配置通知URL。App Store将通过HTTP POST将JSON对象传送到您的服务器。
字段 | 类型 | 备注 |
---|---|---|
environment | string | Sandbox-沙盒环境 PROD-正式环境 |
notification_type | string | INITIAL_BUY-订阅初次购买 CANCEL-取消交易 RENEWAL-已过期的订阅续订成功 INTERACTIVE_RENEWAL-续订被延迟的订阅 DID_CHANGE_RENEWAL_PREF-用户修改了订阅计划,会影响下次的订阅 |
password | string | 内购产品密钥 |
original_transaction_id | string | 初次交易的id |
cancellation_date | string, interpreted as an RFC 3339 date | 取消交易的时间,仅当通知类型为CANCEL时返回 |
web_order_line_item_id | string | 用于区分订阅内购,仅当通知类型为CANCEL时返回 |
latest_receipt | string | 最新的交易凭据,仅当通知类型为RENEWAL或INTERACTIVE_RENEWAL时返回 |
latest_receipt_info | [] | 最新的交易凭据中的信息,当通知类型为CANCEL时不返回,其他情况会返回 |
latest_expired_receipt | string | 最新的过交易凭据,仅当有过期交易凭据时返回 |
latest_expired_receipt_info | string | 最新的交易凭据中的信息,仅当通知类型为RENEWAL或CANCEL或订阅过期且续订失败时返回 |
auto_renew_status | string | 是否自动续订 true-是 false-否 |
auto_renew_adam_id | string | 内购产品的数字序号 |
auto_renew_product_id | string | 内购产品的id |
expiration_intent | string | 过期原因,仅当通知类型为RENEWAL或INTERACTIVE_RENEWAL时返回 |
从字段备注上来看,这里比较混乱,最好以实际返回的结果为准
我方服务器应返回HTTP状态代码200。如果您的服务器发送50x或40x HTTP代码,App Store将重试该通知。 App Store会在一段时间内多次尝试重试通知,但如果尝试失败次数过多,最终会停止。
AppStore会在自动续期订阅到期前24小时开始尝试续订,如果续订成功,将没有通知;如果续订失败,AppStore可能还会继续尝试,连续续订多次后就不会再次尝试,如果继续尝试成功了,而此时订阅已经过期,那么就会发送续订成功的通知。
管理订阅
通过页面 https://buy.itunes.apple.com/WebObjects/MZFinance.woa/wa/manageSubscriptions 来管理订阅。
监听交易
添加持久的交易监听器,就能在app启动或从后台唤醒的时候收到续订通知。
刷新凭据
使用SKReceiptRefreshRequest
来刷新凭据
- 成功购买了内购
- 调用了SKReceiptRefreshRequest
- 调用了restoreCompletedTransactions
恢复已完成的交易
恢复已完成的交易,是恢复自动续期订阅、免费订阅或恢复非消耗型项目。
需要的恢复购买的场景有:
- Apple用户在其他的设备上登陆,然后安装应用
- 内购对应的应用被卸载了,重新安装应用
调用 restoreCompletedTransactions 来进行恢复,恢复结果中没有任何项目的可能原因有:
- 有未完成的交易
- 根本就没有购买过自动续期订阅、免费订阅或恢复非消耗型项目
- 在恢复不具备恢复能力的项目,比如非续期订阅、消耗型项目
- 版本号 CFBundleVersion 不规范
恢复交易不会创建新的交易。
取消交易
取消交易,是针对自动续期订阅、非续期订阅、非消耗型项目。
cancellation_date字段,目前只有自动续期订阅这种取消交易才会有,而非续期订阅、非消耗型项目取消交易的情况,除非Apple专门操作,否者默认是没有的。
验证凭据
获取凭据
- 通过 NSBundle 的 appStoreReceiptURL 获取凭据
- 在老版本macOS,可以通过 /Contents/_MASReceipt/receipt 路径获取凭据
- 在老版本iOS,可以通过 SKPaymentTransaction 的 transactionReceipt 属性获取凭据
本地验证
可以在 main 方法中,在调用 NSApplicationMain 方法之前进行验证,或者出于一些特别的安全要求,可以在程序运行之前进行验证。
asn1c
可以通过 asn1c 工具生成解析凭据中的信息的程序,此工具可以在 MacPorts 和 SourceForge下载。下载后通过命令 asn1c -fnative-types filename
生成解析代码。
计算GUID的哈希值
- 在macOS上,参考Get the GUID in macOS
- 在iOS上,以 UIDevice 的 identifierForVendor 作为GUID
将获得的GUID跟包名、type为4的属性的值联系起来,直接使用凭据的原始字节数据,不做任何UTF-8的字符串转化,然后就算拼接起来的字节数据的 SHA-1 值。
验证步骤
- 凭据是否有数据
- 凭据是否是Apple签名的
- 凭据中的包名是否匹配Info.plist中的 CFBundleIdentifier
- 凭据中的版本号是否匹配Info.plist中的 CFBundleShortVersionString(macOS)或 CFBundleVersion(iOS)
- 计算GUID的哈希值是否正确
凭据中包含的信息为:
ASN.1 Field Type | ASN.1 Field Value | 备注 |
---|---|---|
2 | UTF8STRING | 应用的BundleID |
3 | UTF8STRING | 应用的版本号 |
4 | A series of bytes | 计算用于验证的哈希值时会用到 |
5 | 20-byte SHA-1 digest | 用于验证凭据 |
17 | [17_object] | 一组内购信息 |
18 | UTF8STRING | 首次购买时的应用版本号,沙盒环境下始终是 1.0 |
12 | IA5STRING, interpreted as an RFC 3339 date | 凭据创建时间,用于验证,避免本地时间有问题 |
21 | IA5STRING, interpreted as an RFC 3339 date | 过期时间,只有通过Volume Purchase Program购买的才会有这个字段 |
17_object
ASN.1 Field Type | ASN.1 Field Value | 备注 |
---|---|---|
1701 | INTEGER | 内购的数量,跟 SKPayment 的 quantity 呼应 |
1702 | UTF8STRING | 内购的项目ID,跟 SKPayment 的 productIdentifier 呼应 |
1703 | UTF8STRING | 内购的交易ID,跟 transactionIdentifier 呼应 |
1704 | IA5STRING, interpreted as an RFC 3339 date | 内购的购买时间或更新时间 |
1705 | UTF8STRING | 内购的初始交易ID |
1706 | IA5STRING, interpreted as an RFC 3339 date | 内购的初始购买时间 |
1708 | IA5STRING, interpreted as an RFC 3339 date | 订阅的过期时间,只有自动续期订阅有 |
1719 | INTEGER | 是否处于介绍价格期,只有自动续期订阅有,1-是 0-否 |
1712 | IA5STRING, interpreted as an RFC 3339 date | 取消交易的时间,由Apple客户支持来取消交易 |
1711 | INTEGER | 用于区分跨设备的购买事件,包括订阅续订事件 |
验证失败处理
- 在macOS上验证失败码,需要调用
exit(173)
,系统会自动尝试申请新凭据、提示错误信息(前提是系统版本不小于10.6.6) - 在iOS上验证失败,需要调用 SKReceiptRefreshRequest 类来刷新凭据
保护验证过程
- 内联验证代码,而不是使用系统提供的API
- 代码强化技术,比如混淆
远程验证
请求
- URL
- 沙盒环境:测试和审核的时候,用 https://sandbox.itunes.apple.com/verifyReceipt 接口进行验证
- 正式环境:在AppStore销售的时候,用 https://buy.itunes.apple.com/verifyReceipt 接口进行验证
- Method
- POST
- Header
- JSON
- Body
字段 | 类型 | 备注 |
---|---|---|
receipt-data | string | base64编码的字符串 |
password | string | 内购密钥 |
exclude-old-transactions | string,枚举 | 用于自动续期订阅、非续期订阅。true,表示返回数据只是最新交易信息;false,表示返回数据包含旧交易信息 |
响应
字段 | 类型 | 备注 |
---|---|---|
status | 整数 | 参考 StatusCode |
receipt | receipt | 凭据信息 |
latest_receipt | string | 最新的base64编码的字符串 |
latest_receipt_info | [latest_receipt_info] | |
latest_expired_receipt_info | 只有iOS 6交易版本的自动续期订阅会出现 | |
pending_renewal_info | 只有iOS 7交易版本的自动续期订阅会出现,存放的是尝试续订的结果信息 | |
is-retryable | 只有状态码为21000-21199时会出现 |
receipt
字段 | 类型 | 备注 |
---|---|---|
bundle_id | string | 应用的BundleID |
application_version | string | 应用的版本号 |
in_app | [in_app] | 一组内购信息 |
original_application_version | string | 首次购买时的应用版本号,沙盒环境下始终是 1.0 |
receipt_creation_date | IA5STRING, interpreted as an RFC 3339 date | 凭据创建时间,用于验证,避免本地时间有问题 |
expiration_date | IA5STRING, interpreted as an RFC 3339 date | 过期时间,只有通过Volume Purchase Program购买的才会有这个字段 |
latest_receipt_info
in_app
字段 | 类型 | 备注 |
---|---|---|
quantity | string | 内购的数量,跟 SKPayment 的 quantity 呼应 |
product_id | string | 内购的项目ID,跟 SKPayment 的 productIdentifier 呼应 |
transaction_id | string | 内购的交易ID,跟 transactionIdentifier 呼应 |
original_transaction_id | string | 内购的初始交易ID |
purchase_date | string, interpreted as an RFC 3339 date | 内购的购买时间或更新时间 |
original_purchase_date | string, interpreted as an RFC 3339 date | 内购的初始购买时间 |
expires_date | string, interpreted as an RFC 3339 date | 订阅的过期时间,只有自动续期订阅有 |
expiration_intent | string | 过期原因,只有自动续期订阅有,-用户取消 2-账单错误,比如用户支付信息不再有效 3-用户不同意最近的价格上涨 4-续订后产品不可用 5-未知错误 |
is_in_billing_retry_period | string | 只有自动续期订阅有,1-仍在尝试续订 0-停止尝试续订 |
is_trial_period | string | 是否处于试用期,只有自动续期订阅有,true-是 false-否 |
is_in_intro_offer_period | string | 是否处于介绍价格期,只有自动续期订阅有,true-是 false-否 |
cancellation_date | string, interpreted as an RFC 3339 date | 取消交易的时间,由Apple客户支持来取消交易 |
cancellation_reason | string | 取消交易的理由,1-app有问题 0-其他原因,比如客户意外购买 |
app_item_id | string | 用于区分应用的,沙盒环境不会出现 |
version_external_identifier | string | 应用的版本,沙盒环境不会出现 |
web_order_line_item_id | string | 用于区分跨设备的购买事件,包括订阅续订事件 |
auto_renew_status | string | 1-将自动续订 0-不会自动续订 |
auto_renew_product_id | string | 内购项目id,只有自动续期订阅有 |
price_consent_status | string | 只有自动续期订阅有,1-用户同意价格上涨 0-用户没有做任何动作,那么订阅将不会续订 |
StatusCode
字段 | 备注 |
---|---|
21000 | 上传数据无法json解析 |
21002 | 上传数据中 receipt-data 无法解析 |
21003 | 凭据无法验证 |
21004 | 密钥不匹配 |
21005 | 凭据服务器暂时不可用 |
21006 | 凭据有效,但是订阅过期(iOS 6之前会这样),凭据信息也会返回 |
21007 | 凭据来自沙盒环境,而当前是正式环境 |
21008 | 凭据来自正式环境,而当前是沙盒环境 |
21010 | 当作购买没有发生来处理 |
21100-21199 | 其他的数据错误 |
可以始终用正式环境的接口进行验证,如果接口返回21007状态码,那么就表示你所传的凭据是沙盒环境的凭据,然后再用沙盒环境的接口去进行验证;如果接口返回0状态码,表示验证成功。
如果购买成功,调用接口返回的 in_app 为空数组,那么就需要更新凭据,通过 SKReceiptRefreshRequest 类来更新凭据。
如果是自动续期订阅、非续期订阅、非消耗型项目,那么它们的信息会一直保留在凭据中(之前买的信息都会在);如果是消耗型项目,那么它的信息将一直保留到你完成交易,在完成交易后,信息在下次凭据更新就会被删除掉,比如有新的购买或者强制刷新。
丢失凭据
在有些情况下,凭据会丢失,appStoreReceiptURL 会返回为nil。当这种情况发生的时候,调用SKReceiptRefreshRequest来更新凭据,此更新可能会成功,也可能失败,成功会执行 requestDidFinish ,失败会执行 didFailWithError 。
限制
每个开发者账户可在该账户的所有App中创建至多10000个App内购项目产品。
兼容性
- 自动续订型订阅:iOS >= 4.2, macOS >= 10.9
安全
反信用欺诈
- SKPayment类的applicationUsername属性:用自己服务器的账号名哈希后的字符串来填充
#import <CommonCrypto/CommonCrypto.h>
// Custom method to calculate the SHA-256 hash using Common Crypto
- (NSString *)hashedValueForAccountName:(NSString*)userAccountName
{
const int HASH_SIZE = 32;
unsigned char hashedChars[HASH_SIZE];
const char *accountName = [userAccountName UTF8String];
size_t accountNameLen = strlen(accountName);
// Confirm that the length of the user name is small enough
// to be recast when calling the hash function.
if (accountNameLen > UINT32_MAX) {
NSLog(@"Account name too long to hash: %@", userAccountName);
return nil;
}
CC_SHA256(accountName, (CC_LONG)accountNameLen, hashedChars);
// Convert the array of bytes into a string showing its hex representation.
NSMutableString *userAccountHash = [[NSMutableString alloc] init];
for (int i = 0; i < HASH_SIZE; i++) {
// Add a dash every four bytes, for readability.
if (i != 0 && i%4 == 0) {
[userAccountHash appendString:@"-"];
}
[userAccountHash appendFormat:@"%02x", hashedChars[i]];
}
return userAccountHash;
}
// product is an SKProduct object.
SKMutablePayment *payment = [SKMutablePayment paymentWithProduct:product];
//Populate applicationUsername with your customer's username on your server.
payment.applicationUsername = [self hashedValueForAccountName:@"userNameOnYourServer"];
// Submit payment request.
[[SKPaymentQueue defaultQueue] addPayment:payment];
参考
- In-App Purchase FAQ
- App 内购买项目配置流程
- Receipt Validation Programming Guide
- In-App Purchase Programming Guide
- App Store Connect Help
- iOS内购IAP