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_idtransaction_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 崩溃,客户端没收到支付回调,商品没发放。

解决方案

  1. App 启动时注册 SKPaymentTransactionObserver(如上面代码 init 中所示)。
  2. 未完成的交易会在启动后自动触发 updatedTransactions: 回调。
  3. 在回调中重新验证收据、发放商品、结束交易。
  4. 服务端用 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、系统自动验证,新项目推荐。

posted @ 2019-06-06 16:23  Mr.陳  阅读(4354)  评论(0)    收藏  举报