iOS开发基础97-iOS 内购(IAP):从支付流程到收据验证与安全防护
iOS 内购(IAP):从支付流程到收据验证与安全防护
应用内购(In-App Purchase, IAP)是 iOS 应用变现的核心方式,而支付安全是内购的生命线。本文从 StoreKit 完整支付流程讲起,深入解析收据验证的两种方式、苹果验证接口、status 码含义,详细分析 6 种常见攻击手段与防护策略,补充丢单处理、恢复购买、订阅型商品等实际问题,给出完整 OC 代码、Swift/StoreKit 2 版本对照和常见坑排查。
一、内购概述
一句话原理
内购就是用户在 App 内通过苹果支付购买虚拟商品,收据(Receipt)是苹果开具的"购买凭证",验证收据的真实性就是确认用户真的付了钱,然后才能发放商品。
什么是收据(Receipt)
收据是苹果签名的一个加密文件,记录了用户的购买信息:
- App 的 bundle_id
- 所有内购交易记录(product_id、transaction_id、购买时间等)
- 应用版本信息
收据存储在 App 的沙盒中,通过 [[NSBundle mainBundle] appStoreReceiptURL] 获取。
内购商品类型
| 类型 | 说明 | 示例 |
|---|---|---|
| 消耗型 | 购买一次消耗一次,可重复购买 | 金币、钻石、体力 |
| 非消耗型 | 购买一次永久拥有,可恢复 | 去广告、解锁关卡 |
| 自动续订订阅 | 自动续费,可取消 | 会员、视频 VIP |
| 非自动续订订阅 | 到期后不自动续费 | 季度会员 |
二、两种验证方式对比
一句话原理
客户端验证不安全(客户端数据可被篡改),服务端验证才是正解(服务端可信,由服务端向苹果验证后发放商品)。
对比
| 对比 | 客户端直接验证 | 服务端验证(推荐) |
|---|---|---|
| 验证方 | 客户端直接请求苹果服务器 | 客户端把收据发给自己的服务端,服务端请求苹果 |
| 安全性 | ❌ 低(客户端可被篡改、伪造响应) | ✅ 高(服务端可信) |
| 防重复 | ❌ 难以控制 | ✅ 服务端记录 transaction_id |
| 商品发放 | 客户端自行发放 | 服务端验证后发放 |
| 适用场景 | 仅个人项目/测试 | 所有正式上线项目 |
核心原则:客户端的所有信息都是不可信的,商品发放必须由服务端控制。
三、完整支付流程(StoreKit)
一句话原理
支付流程分 6 步:请求商品信息 → 发起支付 → 监听支付结果 → 获取收据 → 服务端验证 → 发放商品并结束交易。
流程图
客户端 服务端 苹果服务器
│ │ │
│── 1. 请求商品信息 ─────────────────────────────────────→│
│←──── 商品列表 ─────────────────────────────────────────│
│ │ │
│── 2. 发起支付(SKPayment)────────────────────────────→│
│ │ 弹出苹果支付框
│←──── 3. 支付结果回调 ──────────────────────────────────│
│ (SKPaymentTransaction) │ │
│ │ │
│── 4. 获取收据 ──────────┐ │ │
│ │ │ │
│── 5. 发送收据给服务端 ──→│ │ │
│ │── 6. 验证收据 ─────────────→│
│ │←──── 验证结果 ──────────────│
│ │ │ │
│←── 7. 发放商品 ─────────│ │ │
│ │ │
│── 8. 结束交易(finishTransaction)│ │
关键注意点
- 必须调用
finishTransaction:不结束交易,下次启动 App 还会收到回调,可能导致重复发放。 - 先验证再发放再结束交易:验证失败不要结束交易(或根据业务决定),验证成功发放商品后再结束。
- App 启动时注册 observer:处理上次未完成的交易(丢单恢复)。
四、收据验证详解
1. 苹果验证接口
| 环境 | URL |
|---|---|
| 生产环境 | https://buy.itunes.apple.com/verifyReceipt |
| 沙盒环境 | https://sandbox.itunes.apple.com/verifyReceipt |
推荐策略:先发到生产接口,如果返回 status = 21007(沙盒收据),再转发到沙盒接口。这样一套代码同时支持生产和沙盒(审核期间用的是沙盒环境)。
2. 请求格式
{
"receipt-data": "收据的Base64编码",
"password": "共享密钥(订阅型商品必填)"
}
3. status 码大全
| status | 含义 | 处理方式 |
|---|---|---|
| 0 | 验证成功 | 解析 in_app 发放商品 |
| 21000 | App Store 无法读取你提供的 JSON 数据 | 检查请求格式 |
| 21002 | receipt-data 字段数据格式错误 | 检查收据编码 |
| 21003 | 收据无法验证 | 收据无效,拒绝发放 |
| 21004 | 共享密钥不匹配 | 检查共享密钥 |
| 21005 | 收据服务器不可用 | 稍后重试 |
| 21006 | 收据有效但订阅已过期 | 订阅到期处理 |
| 21007 | 收据是沙盒收据,应发到沙盒服务器 | 转发到沙盒接口重新验证 |
| 21008 | 收据是生产收据,应发到生产服务器 | 转发到生产接口 |
| 21009 | 内部数据访问错误 | 稍后重试 |
| 21010 | 用户账号不存在或被删除 | 拒绝发放 |
4. 返回字段解析
验证成功后返回的关键字段:
| 字段 | 说明 |
|---|---|
status |
状态码,0 表示成功 |
environment |
环境(Production/Sandbox) |
receipt.bundle_id |
App 的 Bundle ID,必须校验 |
receipt.in_app |
所有内购交易记录数组 |
in_app[].product_id |
商品 ID,必须校验 |
in_app[].transaction_id |
交易唯一 ID,用于去重 |
in_app[].original_transaction_id |
原始交易 ID(订阅恢复时相同) |
in_app[].purchase_date_ms |
购买时间(毫秒时间戳) |
in_app[].quantity |
购买数量 |
in_app[].is_trial_period |
是否试用期(订阅型) |
重要:
status = 0只表示收据整体合法,具体购买了什么商品要解析in_app数组,不能只看 status 就发放商品。
五、常见攻击与防护
1. 劫持苹果服务器攻击(DNS 污染)
攻击方式:攻击者通过 DNS 污染让客户端连接到伪造的苹果服务器,返回虚假的验证成功响应。
防护:
- 不要用客户端直接验证(客户端的 DNS 可被污染)。
- 用服务端验证(服务端网络环境可信)。
- 服务端可配置 SSL Pinning,确保连接的是真实的苹果服务器。
2. 重复验证攻击(重复充值)
攻击方式:同一个收据被多次提交,如果服务端没有防重机制,会导致多次发放商品。
防护:
- 用
transaction_id去重(不是收据 MD5,见下方说明)。 - 服务端记录所有已处理的
transaction_id,每次验证前检查是否已处理过。 - 数据库中
transaction_id设为唯一索引。
常见错误做法:用收据的 MD5 去重。不准确,因为同一个 App Receipt 包含所有历史交易,每次验证返回的收据可能相同,但里面可能有多个新交易。正确做法是遍历 in_app 数组,用每个交易的 transaction_id 去重。
3. 跨应用攻击
攻击方式:攻击者在 A 应用购买商品,拿到 A 应用的收据,然后在 B 应用中提交这个收据。
防护:
- 校验验证返回的
bundle_id,必须和当前应用的 Bundle ID 一致。 - 不一致则拒绝发放。
4. 替换价格攻击(低价买高价)
攻击方式:攻击者用低价商品的收据,声称购买了高价商品。
防护:
- 不要相信客户端传过来的 product_id。
- 以苹果验证返回的
in_app[].product_id为准,根据这个 product_id 发放对应商品。 - 客户端只传收据,不传商品信息。
5. 歧义攻击(iOS 6 vs iOS 7+)
攻击方式:iOS 6 时代 status = 0 就表示支付成功;iOS 7 以后 status = 0 只表示收据整体合法,具体购买详情在 in_app 字段中。如果只看 status 不解析 in_app,可能给没有购买的用户发放商品。
防护:
status = 0后,必须遍历in_app数组,找到对应商品的交易记录。- 检查
product_id、transaction_id、购买时间等。 - 没有对应交易记录则拒绝发放。
6. 中间人攻击
攻击方式:攻击者在客户端和服务端之间截取、篡改收据数据。
防护:
- 客户端和服务端之间用 HTTPS(必须)。
- 高安全需求可加 SSL Pinning(证书绑定)。
- 收据本身是苹果签名的,篡改后苹果验证会失败,所以核心还是服务端验证。
- 给每个用户绑定收据(记录用户 ID 和 transaction_id 的关系),防止 A 用户的收据给 B 用户用。
六、客户端代码示例(OC)
1. 配置内购工具类
// IAPManager.h
#import <StoreKit/StoreKit.h>
@interface IAPManager : NSObject <SKPaymentTransactionObserver, SKProductsRequestDelegate>
+ (instancetype)sharedManager;
// 请求商品信息
- (void)requestProductsWithIdentifiers:(NSSet *)identifiers;
// 发起购买
- (void)buyProduct:(SKProduct *)product;
// 恢复购买
- (void)restorePurchase;
@end
// IAPManager.m
#import "IAPManager.h"
@implementation IAPManager
+ (instancetype)sharedManager {
static IAPManager *instance = nil;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
instance = [[self alloc] init];
});
return instance;
}
- (instancetype)init {
if (self = [super init]) {
// App 启动时注册交易观察者,处理丢单
[[SKPaymentQueue defaultQueue] addTransactionObserver:self];
}
return self;
}
#pragma mark - 请求商品信息
- (void)requestProductsWithIdentifiers:(NSSet *)identifiers {
if ([SKPaymentQueue canMakePayments]) {
SKProductsRequest *request = [[SKProductsRequest alloc] initWithProductIdentifiers:identifiers];
request.delegate = self;
[request start];
} else {
NSLog(@"用户禁止了内购");
}
}
- (void)productsRequest:(SKProductsRequest *)request didReceiveResponse:(SKProductsResponse *)response {
NSLog(@"有效商品:%@", response.products);
NSLog(@"无效商品 ID:%@", response.invalidProductIdentifiers);
// 在这里更新 UI,展示商品列表
}
#pragma mark - 发起购买
- (void)buyProduct:(SKProduct *)product {
SKPayment *payment = [SKPayment paymentWithProduct:product];
[[SKPaymentQueue defaultQueue] addPayment:payment];
}
#pragma mark - 监听支付结果
- (void)paymentQueue:(SKPaymentQueue *)queue updatedTransactions:(NSArray<SKPaymentTransaction *> *)transactions {
for (SKPaymentTransaction *transaction in transactions) {
switch (transaction.transactionState) {
case SKPaymentTransactionStatePurchased: {
NSLog(@"购买成功");
[self handlePurchased:transaction];
break;
}
case SKPaymentTransactionStateFailed: {
NSLog(@"购买失败:%@", transaction.error.localizedDescription);
[[SKPaymentQueue defaultQueue] finishTransaction:transaction];
break;
}
case SKPaymentTransactionStateRestored: {
NSLog(@"恢复购买成功");
[self handlePurchased:transaction];
break;
}
case SKPaymentTransactionStatePurchasing:
NSLog(@"购买中...");
break;
case SKPaymentTransactionStateDeferred:
NSLog(@"等待家长确认...");
break;
}
}
}
#pragma mark - 购买成功处理
- (void)handlePurchased:(SKPaymentTransaction *)transaction {
// 1. 获取收据
NSURL *receiptURL = [[NSBundle mainBundle] appStoreReceiptURL];
NSData *receiptData = [NSData dataWithContentsOfURL:receiptURL];
NSString *receiptString = [receiptData base64EncodedStringWithOptions:0];
// 2. 发送给服务端验证
[self verifyReceipt:receiptString completion:^(BOOL success, NSArray *inAppList) {
if (success) {
// 3. 发放商品(服务端发放,客户端只更新 UI)
NSLog(@"验证成功,发放商品");
// 4. 结束交易(必须调用)
[[SKPaymentQueue defaultQueue] finishTransaction:transaction];
} else {
NSLog(@"验证失败");
// 根据业务决定是否结束交易
// [[SKPaymentQueue defaultQueue] finishTransaction:transaction];
}
}];
}
#pragma mark - 服务端验证
- (void)verifyReceipt:(NSString *)receiptString completion:(void (^)(BOOL, NSArray *))completion {
// 这里调用自己的服务端接口,由服务端向苹果验证
// 服务端返回验证结果和 in_app 列表
// 以下为伪代码
NSDictionary *params = @{@"receipt": receiptString};
// [self POST:@"/api/verifyReceipt" parameters:params completion:completion];
}
#pragma mark - 恢复购买
- (void)restorePurchase {
[[SKPaymentQueue defaultQueue] restoreCompletedTransactions];
}
- (void)paymentQueueRestoreCompletedTransactionsFinished:(SKPaymentQueue *)queue {
NSLog(@"恢复购买完成");
}
- (void)paymentQueue:(SKPaymentQueue *)queue restoreCompletedTransactionsFailedWithError:(NSError *)error {
NSLog(@"恢复购买失败:%@", error.localizedDescription);
}
#pragma mark - 生命周期
- (void)dealloc {
[[SKPaymentQueue defaultQueue] removeTransactionObserver:self];
}
@end
七、丢单与恢复购买
丢单问题
场景:用户支付成功,但网络中断或 App 崩溃,客户端没收到支付回调,商品没发放。
解决方案:
- App 启动时注册
SKPaymentTransactionObserver(如上面代码 init 中所示)。 - 未完成的交易会在启动后自动触发
updatedTransactions:回调。 - 在回调中重新验证收据、发放商品、结束交易。
- 服务端用
transaction_id去重,不会重复发放。
恢复购买(restore)
场景:用户换手机、重装 App,需要恢复之前购买的非消耗型商品和订阅。
// 用户点击"恢复购买"按钮
- (IBAction)restoreButtonTapped:(id)sender {
[[SKPaymentQueue defaultQueue] restoreCompletedTransactions];
}
- 恢复购买会触发
SKPaymentTransactionStateRestored状态。 - 恢复的交易
original_transaction_id和原始购买相同。 - 消耗型商品不能恢复(购买即消耗)。
八、订阅型商品特殊处理
自动续订订阅的特点
- 订阅会自动续费,用户可以在系统设置中取消。
- 收据中
in_app只包含未过期的订阅(过期后会被移除)。 - 需要用
latest_receipt_info字段获取最新的订阅状态。 - 验证时需要共享密钥(password 字段)。
订阅状态判断
| 字段 | 说明 |
|---|---|
expires_date_ms |
订阅到期时间 |
is_trial_period |
是否试用期 |
is_in_intro_offer_period |
是否 introductory 优惠期 |
cancellation_date |
取消时间(用户退款) |
latest_receipt_info |
最新的订阅交易记录 |
服务器到服务器通知(Server-to-Server Notifications)
苹果提供了服务器通知,订阅状态变化时(续费、取消、退款等)苹果会主动 POST 通知到你配置的 URL:
INITIAL_BUY:首次购买DID_RENEW:自动续费成功DID_FAIL_TO_RENEW:续费失败CANCEL:用户取消订阅REFUND:用户退款REVOKE:家庭共享撤销
九、StoreKit 2 简介(iOS 15+)
一句话原理
StoreKit 2 是苹果在 iOS 15 推出的全新内购框架,Swift 原生、基于 async/await、API 更简洁,自动处理收据验证,不需要自己解析 JSON。
主要改进
| 对比 | StoreKit 1(旧) | StoreKit 2(新) |
|---|---|---|
| 语言 | Objective-C 为主 | Swift 原生 |
| 异步模式 | 代理回调 | async/await |
| 收据验证 | 手动请求苹果服务器 | 系统自动验证(Transaction 已验证) |
| API 复杂度 | 繁琐(SKPaymentQueue、代理) | 简洁(Product、Purchase 模型) |
| 最低系统 | iOS 3+ | iOS 15+ |
StoreKit 2 购买示例(Swift)
import StoreKit
// 请求商品
let products = try await Product.products(for: ["com.example.gold.1"])
// 发起购买
if let product = products.first {
let result = try await product.purchase()
switch result {
case .success(let verification):
// 验证交易
if case .verified(let transaction) = verification {
// 发放商品
await transaction.finish()
}
case .userCancelled:
print("用户取消")
case .pending:
print("等待中")
@unknown default:
break
}
}
StoreKit 2 的
Transaction已经经过系统验证,不需要自己请求苹果服务器,但服务端仍然需要记录 transaction_id 防重复,商品发放逻辑仍建议走服务端。
十、Swift 版本对照(StoreKit 1)
import StoreKit
class IAPManager: NSObject, SKPaymentTransactionObserver, SKProductsRequestDelegate {
static let shared = IAPManager()
override init() {
super.init()
SKPaymentQueue.default().add(self)
}
// 请求商品
func requestProducts(identifiers: Set<String>) {
let request = SKProductsRequest(productIdentifiers: identifiers)
request.delegate = self
request.start()
}
func productsRequest(_ request: SKProductsRequest, didReceive response: SKProductsResponse) {
print("商品:\(response.products)")
}
// 购买
func buy(_ product: SKProduct) {
let payment = SKPayment(product: product)
SKPaymentQueue.default().add(payment)
}
// 监听交易
func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) {
for transaction in transactions {
switch transaction.transactionState {
case .purchased, .restored:
handlePurchased(transaction)
case .failed:
SKPaymentQueue.default().finishTransaction(transaction)
case .purchasing, .deferred:
break
@unknown default:
break
}
}
}
func handlePurchased(_ transaction: SKPaymentTransaction) {
// 获取收据
guard let receiptURL = Bundle.main.appStoreReceiptURL,
let receiptData = try? Data(contentsOf: receiptURL) else { return }
let receiptString = receiptData.base64EncodedString()
// 发给服务端验证
verifyReceipt(receiptString) { success in
if success {
SKPaymentQueue.default().finishTransaction(transaction)
}
}
}
func verifyReceipt(_ receipt: String, completion: @escaping (Bool) -> Void) {
// 调用服务端接口
}
// 恢复购买
func restore() {
SKPaymentQueue.default().restoreCompletedTransactions()
}
}
十一、常见问题与坑
Q1:为什么必须调用 finishTransaction?
不调用 finishTransaction 的交易会一直留在队列中,下次 App 启动还会触发回调,可能导致重复发放商品。验证成功并发放商品后必须调用。
Q2:沙盒环境怎么测试?
- 在 App Store Connect 中创建沙盒测试账号。
- 真机设置 → App Store → 沙盒账号 → 登录测试账号。
- 沙盒环境购买不会真正扣费,密码输入后直接成功。
- 审核期间苹果审核员用的也是沙盒环境,所以必须处理
status = 21007。
Q3:收据为空怎么办?
appStoreReceiptURL 可能返回 nil(如从 Xcode 直接安装的测试包没有收据)。解决方法:
- 用
SKReceiptRefreshRequest刷新收据。 - 真机通过 TestFlight 或 App Store 安装的包才有收据。
Q4:消耗型商品和非消耗型商品的区别?
| 类型 | 可重复购买 | 可恢复 | 示例 |
|---|---|---|---|
| 消耗型 | ✅ 是 | ❌ 否 | 金币、钻石 |
| 非消耗型 | ❌ 否 | ✅ 是 | 去广告、解锁 |
| 订阅 | 续费 | ✅ 是 | 会员 |
Q5:怎么防止用户用别人的收据充值?
- 服务端记录用户 ID 和 transaction_id 的绑定关系。
- 同一个 transaction_id 只能给一个用户发放。
- 校验 bundle_id 和 product_id。
Q6:内购被拒常见原因?
- 没有恢复购买按钮(非消耗型和订阅必须有)。
- 商品描述不清晰。
- 用了自己的支付方式(如微信支付、支付宝购买虚拟商品,游戏除外)。
- 没有用苹果的支付体系。
- 商品价格和苹果档位不匹配。
Q7:服务端验证时苹果服务器超时怎么办?
- 重试机制(最多 3 次,指数退避)。
- 超时后不要立刻结束交易,等下次启动时重新处理。
- 苹果服务器偶尔不可用,要有容错。
Q8:能不能用客户端验证省掉服务端?
个人项目或测试可以,但正式上线强烈不推荐。客户端验证可以被工具(如 LocaliAPStore)破解,免费购买所有商品。服务端验证是最低安全要求。
Q9:订阅到期后怎么处理?
- 不要只看客户端的收据,订阅状态以服务端验证为准。
- 用
expires_date_ms判断是否在有效期内。 - 建议配置服务器到服务器通知,实时更新订阅状态。
- 到期后移除会员权限,但不要删除用户数据。
Q10:StoreKit 1 和 StoreKit 2 怎么选?
- 最低支持 iOS 15+:用 StoreKit 2,API 更简洁。
- 需要支持 iOS 14 及以下:用 StoreKit 1。
- 可以两者共存:iOS 15+ 用 StoreKit 2,旧系统用 StoreKit 1。
十二、总结
- 核心原则:客户端不可信,商品发放必须由服务端控制。
- 支付流程:请求商品 → 发起支付 → 监听结果 → 获取收据 → 服务端验证 → 发放商品 → 结束交易。
- 验证方式:服务端验证(推荐),先生产后沙盒(处理 21007)。
- 安全防护:
- 防重复:用 transaction_id 去重(不是收据 MD5)。
- 防跨应用:校验 bundle_id。
- 防替换价格:以苹果返回的 product_id 为准。
- 防歧义:status=0 后必须解析 in_app。
- 防中间人:HTTPS + SSL Pinning。
- 丢单处理:App 启动注册 observer,未完成交易自动回调,重新验证发放。
- 订阅型:需要共享密钥、latest_receipt_info、服务器通知。
- StoreKit 2(iOS 15+):Swift 原生、async/await、系统自动验证,新项目推荐。

浙公网安备 33010602011771号