iOS开发基础99-iOS 内购问题与防范:越狱欺诈、重复订单、丢单与退款

iOS 内购问题与防范:越狱欺诈、重复订单、丢单与退款

内购(IAP)是 App 变现的核心,但实际开发中会遇到越狱欺诈、重复发放、丢单、退款等问题。本文从内购基本流程出发,深入解析四大核心问题(越狱欺诈、重复订单、丢单、退款)的底层原理和防范手段,补充订单状态机设计、服务器校验流程、客户端补偿机制等实战内容,给出 OC/Swift 代码示例和常见坑排查。


一、内购基本流程回顾

一句话原理

内购的核心是客户端发起支付 → 苹果扣款 → 客户端拿到收据 → 服务端向苹果验证收据 → 验证通过后发放商品,整个过程中服务端验证是安全的基石。

完整流程

客户端                          服务端                      苹果服务器
  │                              │                           │
  │── 1. 请求商品信息 ─────────────────────────────────────→│
  │←──── 商品列表 ─────────────────────────────────────────│
  │                              │                           │
  │── 2. 发起支付 ────────────────────────────────────────→│
  │                              │                    用户确认支付
  │←──── 3. 支付成功回调 ──────────────────────────────────│
  │     (拿到 receipt)         │                           │
  │                              │                           │
  │── 4. 上报 receipt ────────→│                           │
  │                              │── 5. 验证收据 ─────────→│
  │                              │←──── 验证结果 ──────────│
  │                              │                           │
  │                              │── 6. 发放商品             │
  │←──── 7. 通知客户端 ──────────│                           │
  │                              │                           │
  │── 8. finishTransaction      │                           │

需要考虑的特殊情况

情况 说明
网络中断 支付成功但上报收据时网络断了,导致丢单
支付失败重试 用户取消后重新购买,可能产生多笔交易
多设备登录 同一账号在不同设备购买,需要统一处理
越狱设备 伪造支付成功,试图免费获取商品
退款 用户购买后向苹果申请退款,需要追回商品

二、核心问题一:越狱欺诈与防范

一句话原理

越狱插件通过拦截 StoreKit 的支付回调,伪造支付成功,让客户端以为支付完成了。但只要服务端向苹果服务器验证收据,伪造的收据就会露馅——服务端验证是防越狱欺诈的基础。

越狱欺诈的原理

常见越狱插件(LocaliAPStore、IAPFree 等)的工作方式:

  1. Hook StoreKit 的支付方法,拦截支付请求。
  2. 不经过苹果服务器,直接伪造一个"支付成功"的回调给客户端。
  3. 如果客户端自己验证收据(不经过服务端),插件还会伪造验证成功的响应。

关键认知:越狱欺诈只能欺骗客户端,无法欺骗苹果服务器。只要服务端拿收据去苹果验证,伪造的收据要么验证失败(status ≠ 0),要么 in_app 为空。

正常订单 vs 越狱订单

// 正常订单:in_app 有真实交易记录
{
    "status": 0,
    "environment": "Production",
    "receipt": {
        "bundle_id": "com.example.app",
        "in_app": [
            {
                "product_id": "com.example.gold.100",
                "transaction_id": "1000000400000001",
                "original_transaction_id": "1000000400000001",
                "purchase_date_ms": "1484283892000",
                "quantity": "1"
            }
        ]
    }
}

// 越狱伪造订单:status 可能为 0,但 in_app 为空
{
    "status": 0,
    "environment": "Production",
    "receipt": {
        "bundle_id": "com.example.app",
        "in_app": []  // 空数组!
    }
}

防范手段(多层校验)

1. 必须服务端验证

不要在客户端直接验证收据。客户端的所有数据都不可信,必须由服务端向苹果服务器验证。

2. 校验 in_app 非空

in_app 为空的订单直接判定为无效:

def verify_receipt(receipt_data):
    result = request_apple_verify(receipt_data)
    if result['status'] != 0:
        return False, "验证失败"
    
    in_app = result['receipt'].get('in_app', [])
    if not in_app:
        return False, "in_app 为空,疑似越狱欺诈"
    
    return True, in_app

3. 校验 bundle_id

防止跨应用使用收据(A 应用的收据在 B 应用使用):

expected_bundle_id = "com.example.app"
if result['receipt']['bundle_id'] != expected_bundle_id:
    return False, "bundle_id 不匹配"

4. 校验 product_id

不要相信客户端传过来的商品 ID,以苹果返回的 product_id 为准:

for item in in_app:
    product_id = item['product_id']
    if product_id not in VALID_PRODUCT_IDS:
        return False, f"无效商品: {product_id}"

5. 校验 transaction_id 唯一性

每个交易有唯一的 transaction_id,用它去重(详见下一节)。

6. 校验购买时间

防止使用过期的旧收据重复提交:

purchase_time = int(item['purchase_date_ms']) / 1000
current_time = time.time()
if current_time - purchase_time > 86400 * 7:  # 超过 7 天
    # 可能是旧收据重用,根据业务决定是否拒绝
    pass

三、核心问题二:重复订单与防重

一句话原理

同一笔交易可能因为网络重试、App 重启、多设备上报等原因被多次提交到服务端,用 transaction_id 做唯一索引去重是最可靠的防重手段。

为什么会出现重复订单

场景 说明
网络重试 客户端上报收据超时,自动重试,服务端收到两次
App 重启 未完成的交易在启动后重新回调,再次上报
多设备 同一账号在 A 设备支付,B 设备也收到回调并上报
收据包含多笔交易 同一个 App Receipt 包含所有历史交易,每次验证都返回全部

用 transaction_id 防重

transaction_id 是每笔交易的唯一标识,同一笔交易的 transaction_id 永远不变。

数据库设计

-- transaction_id 设为唯一索引,从数据库层面保证不重复
CREATE TABLE orders (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    transaction_id VARCHAR(64) NOT NULL,
    original_transaction_id VARCHAR(64),
    product_id VARCHAR(128) NOT NULL,
    user_id VARCHAR(64) NOT NULL,
    status VARCHAR(32) NOT NULL DEFAULT 'pending',
    receipt_data TEXT,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    UNIQUE (transaction_id)
);

CREATE UNIQUE INDEX idx_transaction_id ON orders(transaction_id);

服务端校验流程

def handle_purchase(user_id, receipt_data):
    # 1. 向苹果验证
    result = request_apple_verify(receipt_data)
    if result['status'] != 0:
        return {'success': False, 'msg': '验证失败'}
    
    in_app = result['receipt'].get('in_app', [])
    if not in_app:
        return {'success': False, 'msg': '无效订单'}
    
    # 2. 遍历所有交易,逐个处理
    granted_items = []
    for item in in_app:
        transaction_id = item['transaction_id']
        product_id = item['product_id']
        
        # 3. 查重:transaction_id 已存在则跳过
        existing = db.find_one({'transaction_id': transaction_id})
        if existing:
            continue  # 已处理过,跳过
        
        # 4. 创建订单(唯一索引保证并发安全)
        try:
            db.insert_one({
                'transaction_id': transaction_id,
                'product_id': product_id,
                'user_id': user_id,
                'status': 'success'
            })
            # 5. 发放商品
            grant_product(user_id, product_id)
            granted_items.append(product_id)
        except DuplicateKeyError:
            # 并发情况下唯一索引冲突,说明已被其他请求处理
            continue
    
    return {'success': True, 'items': granted_items}

关键:用数据库唯一索引而不是"先查后插",因为"先查后插"在并发下有竞态条件(两个请求同时查到不存在,然后都插入)。

订单状态机

pending ──→ verifying ──→ success ──→ refunded
   │            │            │
   │            │            └──→ duplicate(重复订单)
   │            └──→ failed(验证失败)
   └──→ expired(超时)
状态 说明
pending 客户端已上报,等待验证
verifying 正在向苹果验证
success 验证成功,商品已发放
failed 验证失败
duplicate 重复订单,不重复发放
refunded 用户已退款

四、核心问题三:丢单与补偿

一句话原理

丢单是指用户付了钱但没收到商品,通常因为支付成功后网络中断或 App 崩溃导致收据没上报。解决思路是:客户端启动时重新检查未完成交易,服务端用 transaction_id 幂等处理。

丢单场景

  1. 用户支付成功,客户端准备上报收据时网络断开。
  2. 上报过程中 App 被系统杀死或崩溃。
  3. 服务端验证成功但返回响应时网络断开,客户端以为失败。

客户端策略

1. App 启动时注册交易观察者

// AppDelegate 中,App 启动时立刻注册
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
    [[SKPaymentQueue defaultQueue] addTransactionObserver:[IAPManager sharedManager]];
    return YES;
}

注册后,苹果会把所有未完成的交易(未调用 finishTransaction 的)通过 updatedTransactions: 回调回来,客户端重新上报。

2. 未完成交易的持久化(可选)

不建议用 NSUserDefaults 存收据(会被清除、不加密)。更可靠的方式是依赖 SKPaymentQueue 的未完成交易机制——只要不调用 finishTransaction,交易就一直在队列中,下次启动会重新回调。

如果确实需要本地持久化,用钥匙串(Keychain):

// 用钥匙串保存待上报的收据(比 NSUserDefaults 安全)
[SSKeychain setPassword:receiptString forService:@"com.example.iap" account:@"pendingReceipt"];

// 启动时读取
NSString *pendingReceipt = [SSKeychain passwordForService:@"com.example.iap" account:@"pendingReceipt"];
if (pendingReceipt) {
    [self verifyReceipt:pendingReceipt completion:^(BOOL success) {
        if (success) {
            [SSKeychain deletePasswordForService:@"com.example.iap" account:@"pendingReceipt"];
        }
    }];
}

3. 先验证再结束交易

- (void)handlePurchased:(SKPaymentTransaction *)transaction {
    // 1. 获取收据
    NSURL *receiptURL = [[NSBundle mainBundle] appStoreReceiptURL];
    NSData *receiptData = [NSData dataWithContentsOfURL:receiptURL];
    
    // 2. 上报服务端验证
    [self verifyReceipt:receiptData completion:^(BOOL success) {
        if (success) {
            // 3. 验证成功,发放商品后才结束交易
            [[SKPaymentQueue defaultQueue] finishTransaction:transaction];
        }
        // 验证失败不结束交易,下次启动还会重试
    }];
}

原则:验证成功并发放商品后才调用 finishTransaction。验证失败不要结束,留给下次启动重试。

服务端策略

1. 幂等处理

同一个 transaction_id 无论上报多少次,只发放一次商品(用唯一索引保证)。

2. 记录所有订单

无论验证成功还是失败,都保存收据和验证结果,方便审计和排查:

# 保存所有收到的验证请求
db.insert_one({
    'receipt_data': receipt_data,
    'user_id': user_id,
    'status': 'pending',
    'created_at': datetime.now()
})

3. 异步验证 + 轮询

客户端上报后,服务端可以异步验证,客户端轮询结果:

客户端上报收据 → 服务端返回 order_id → 客户端轮询 /api/order/status?order_id=xxx → 获取发放结果

五、核心问题四:退款处理

一句话原理

用户购买后可以向苹果申请退款,苹果通过 Server-to-Server 通知主动告知服务端,服务端收到退款通知后要追回商品或取消会员权限。

Server-to-Server 通知配置

  1. 在 App Store Connect → App 信息 → 通用 → 服务器到服务器通知中填写通知 URL。
  2. 苹果会在订阅状态变化、退款等事件发生时,向该 URL 发送 POST 请求。

通知类型

notification_type 说明
INITIAL_BUY 首次购买订阅
DID_RENEW 自动续费成功
DID_FAIL_TO_RENEW 续费失败(如余额不足)
CANCEL 用户取消订阅
REFUND 用户退款(消耗型/非消耗型/订阅)
REVOKE 家庭共享被撤销
PRICE_INCREASE 订阅涨价
GRACE_PERIOD 宽限期(续费失败后仍有效)

退款通知处理

@app.route('/apple/notification', methods=['POST'])
def apple_notification():
    data = request.json
    notification_type = data.get('notification_type')
    transaction_id = data.get('transaction_id')
    original_transaction_id = data.get('original_transaction_id')
    
    if notification_type == 'REFUND':
        # 1. 更新订单状态
        db.update_one(
            {'transaction_id': transaction_id},
            {'$set': {'status': 'refunded', 'refunded_at': datetime.now()}}
        )
        
        # 2. 追回商品(根据业务处理)
        user_id = get_user_by_transaction(transaction_id)
        revoke_product(user_id, transaction_id)
        
        # 3. 记录日志
        db.insert_one({'type': 'refund_log', 'transaction_id': transaction_id, 'data': data})
    
    elif notification_type == 'DID_RENEW':
        # 订阅续费成功,延长会员时间
        extend_subscription(original_transaction_id, data.get('expires_date_ms'))
    
    return 'OK', 200

退款的延迟性

苹果退款通知不是实时的,可能有几小时到几天的延迟。应对策略:

  1. 依赖 Server-to-Server 通知为主。
  2. 定期(如每天)主动向苹果验证活跃订阅的收据,确认是否已退款。
  3. 用户投诉时,用 transaction_id 主动查询苹果验证接口确认状态。

退款后的业务处理

商品类型 退款后处理
消耗型(金币) 难以追回已消耗的虚拟币,通常做封号或扣减处理
非消耗型(去广告) 撤销权限,下次启动时重新校验
订阅型(会员) 立即取消会员权限,记录到期时间

六、订单系统设计要点

数据库表设计

-- 订单表
CREATE TABLE orders (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    transaction_id VARCHAR(64) NOT NULL COMMENT '苹果交易ID,唯一',
    original_transaction_id VARCHAR(64) COMMENT '原始交易ID(订阅恢复用)',
    product_id VARCHAR(128) NOT NULL COMMENT '商品ID',
    user_id BIGINT NOT NULL COMMENT '用户ID',
    amount DECIMAL(10,2) COMMENT '金额',
    status VARCHAR(32) NOT NULL DEFAULT 'pending' COMMENT '状态',
    environment VARCHAR(16) COMMENT 'Sandbox/Production',
    receipt_data TEXT COMMENT '收据Base64',
    purchase_time BIGINT COMMENT '购买时间戳',
    expires_time BIGINT COMMENT '到期时间(订阅用)',
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    UNIQUE KEY uk_transaction (transaction_id),
    KEY idx_user (user_id),
    KEY idx_status (status)
);

-- 退款日志表
CREATE TABLE refund_logs (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    transaction_id VARCHAR(64) NOT NULL,
    notification_type VARCHAR(32),
    raw_data JSON COMMENT '苹果原始通知',
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

服务端校验完整流程

1. 接收客户端上报的 receipt_data + user_id
2. 先查 transaction_id 是否已存在(快速返回,避免重复验证)
3. 向苹果生产环境验证
   - status = 21007 → 转发到沙盒环境重新验证
   - status ≠ 0 → 验证失败,记录日志
4. 解析返回结果
   - in_app 为空 → 拒绝(疑似越狱)
   - bundle_id 不匹配 → 拒绝
   - 遍历 in_app,逐个处理
5. 对每个交易:
   - transaction_id 查重(唯一索引)
   - product_id 校验
   - 插入订单 + 发放商品(事务保证原子性)
6. 返回发放结果给客户端

七、客户端代码补充(OC)

// IAPManager.m 核心方法

- (void)paymentQueue:(SKPaymentQueue *)queue updatedTransactions:(NSArray<SKPaymentTransaction *> *)transactions {
    for (SKPaymentTransaction *transaction in transactions) {
        switch (transaction.transactionState) {
            case SKPaymentTransactionStatePurchased:
            case SKPaymentTransactionStateRestored:
                [self handlePurchased:transaction];
                break;
            case SKPaymentTransactionStateFailed:
                NSLog(@"支付失败:%@", transaction.error.localizedDescription);
                [[SKPaymentQueue defaultQueue] finishTransaction:transaction];
                break;
            default:
                break;
        }
    }
}

- (void)handlePurchased:(SKPaymentTransaction *)transaction {
    // 1. 获取收据
    NSURL *receiptURL = [[NSBundle mainBundle] appStoreReceiptURL];
    NSData *receiptData = [NSData dataWithContentsOfURL:receiptURL];
    
    // 收据可能为空(如真机直接 Xcode 安装),尝试刷新
    if (!receiptData) {
        SKReceiptRefreshRequest *refresh = [[SKReceiptRefreshRequest alloc] init];
        refresh.delegate = self;
        [refresh start];
        return;
    }
    
    NSString *receiptString = [receiptData base64EncodedStringWithOptions:0];
    
    // 2. 持久化待上报的收据(钥匙串)
    [SSKeychain setPassword:receiptString forService:@"com.example.iap" account:@"pending"];
    
    // 3. 上报服务端
    [self.api verifyReceipt:receiptString completion:^(BOOL success, NSArray *items) {
        if (success) {
            // 4. 发放商品(更新 UI)
            [self handleItemsGranted:items];
            
            // 5. 清除待上报收据
            [SSKeychain deletePasswordForService:@"com.example.iap" account:@"pending"];
            
            // 6. 结束交易
            [[SKPaymentQueue defaultQueue] finishTransaction:transaction];
        } else {
            NSLog(@"验证失败,不结束交易,下次启动重试");
        }
    }];
}

// App 启动时检查待上报的收据
- (void)checkPendingTransactions {
    NSString *pendingReceipt = [SSKeychain passwordForService:@"com.example.iap" account:@"pending"];
    if (pendingReceipt) {
        [self.api verifyReceipt:pendingReceipt completion:^(BOOL success, NSArray *items) {
            if (success) {
                [SSKeychain deletePasswordForService:@"com.example.iap" account:@"pending"];
            }
        }];
    }
}

八、Swift 版本对照

import StoreKit

class IAPManager: NSObject, SKPaymentTransactionObserver {
    static let shared = IAPManager()
    
    func start() {
        SKPaymentQueue.default().add(self)
        checkPendingReceipt()
    }
    
    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)
            default:
                break
            }
        }
    }
    
    private func handlePurchased(_ transaction: SKPaymentTransaction) {
        guard let receiptURL = Bundle.main.appStoreReceiptURL,
              let receiptData = try? Data(contentsOf: receiptURL) else {
            // 收据为空,刷新
            let refresh = SKReceiptRefreshRequest()
            refresh.delegate = self
            refresh.start()
            return
        }
        
        let receiptString = receiptData.base64EncodedString()
        
        // 钥匙串保存待上报收据
        savePendingReceipt(receiptString)
        
        // 上报服务端
        verifyReceipt(receiptString) { [weak self] success in
            if success {
                self?.clearPendingReceipt()
                SKPaymentQueue.default().finishTransaction(transaction)
            }
        }
    }
    
    private func checkPendingReceipt() {
        if let receipt = loadPendingReceipt() {
            verifyReceipt(receipt) { [weak self] success in
                if success { self?.clearPendingReceipt() }
            }
        }
    }
    
    // 钥匙串操作(用 KeychainAccess 或 Security framework)
    private func savePendingReceipt(_ receipt: String) { ... }
    private func loadPendingReceipt() -> String? { ... }
    private func clearPendingReceipt() { ... }
    
    private func verifyReceipt(_ receipt: String, completion: @escaping (Bool) -> Void) {
        // 调用服务端接口
    }
}

九、常见问题与坑

Q1:越狱插件真的能伪造通过苹果验证的收据吗?

不能。越狱插件只能欺骗客户端(拦截本地回调),无法伪造能通过苹果服务器验证的收据。服务端验证时,伪造的收据要么 status 非 0,要么 in_app 为空。所以服务端验证是防越狱的基础。

Q2:为什么不能用 receipt 的 MD5 去重?

同一个 App Receipt 包含所有历史交易,每次验证返回的收据可能相同,但里面可能包含多笔交易。用收据 MD5 去重会漏掉同一收据中的新交易。正确做法是遍历 in_app 数组,用每个交易的 transaction_id 去重。

Q3:finishTransaction 什么时候调用?

验证成功并发放商品后调用。验证失败不要调用,这样下次 App 启动时交易还会回调,可以重试。但如果是永久性失败(如收据无效),也应该调用 finishTransaction 避免无限重试。

Q4:沙盒环境测试需要注意什么?

  • 沙盒收据验证返回 status = 21007,服务端要转发到沙盒接口。
  • 苹果审核期间用的是沙盒环境,必须支持 21007 处理。
  • 沙盒环境的购买不会真实扣费。
  • 沙盒订阅的时间被压缩(几分钟相当于一个月),方便测试续费。

Q5:用户退款了怎么办?

  • 消耗型商品(金币):难以追回,通常记录在案,严重时封号。
  • 非消耗型(去广告):撤销权限,下次启动重新校验。
  • 订阅型:立即取消会员权限。
  • 依赖 Server-to-Server 通知,同时定期主动验证。

Q6:多设备购买同一商品怎么办?

  • 非消耗型和订阅型:可以恢复购买(restore),多设备共享。
  • 消耗型:不建议跨设备共享,用 user_id + transaction_id 绑定,防止一个交易给多个账号发放。

Q7:服务端验证苹果接口超时怎么办?

  • 重试机制(最多 3 次,指数退避)。
  • 超时后不要结束交易,客户端下次启动重试。
  • 苹果服务器偶尔不可用,要有容错和告警。

Q8:怎么防止用户用别人的账号充值?

  • 服务端记录 user_id 和 transaction_id 的绑定关系。
  • 同一个 transaction_id 只能给一个 user_id 发放。
  • 校验 receipt 中的 bundle_id 和 product_id。

Q9:订阅型商品和消耗型处理有什么不同?

对比 消耗型 订阅型
transaction_id 每次购买不同 续费的 original_transaction_id 相同
收据 in_app 包含所有未消耗交易 只包含未过期订阅
到期时间 无 有 expires_date_ms
退款通知 REFUND REFUND / CANCEL / DID_FAIL_TO_RENEW
恢复购买 不能恢复 可以恢复

Q10:内购被拒常见原因?

  • 没有恢复购买按钮(非消耗型和订阅必须有)。
  • 商品描述不清或价格不对应。
  • 用了非苹果支付(微信/支付宝买虚拟商品,游戏除外)。
  • 隐藏功能或引导外部支付。
  • 测试时用生产环境测试(应该用沙盒)。

十、总结

  • 四大核心问题:
    • 越狱欺诈:服务端验证 + in_app 非空 + bundle_id/product_id 校验,多层防御。
    • 重复订单:transaction_id 唯一索引去重,数据库层面保证幂等。
    • 丢单:客户端启动注册 observer 重试 + 钥匙串持久化 + 服务端幂等处理。
    • 退款:Server-to-Server 通知 + 定期主动验证 + 按商品类型追回。
  • 关键原则:
    • 客户端不可信,所有验证走服务端。
    • transaction_id 是防重的唯一可靠依据。
    • 先验证发放,再 finishTransaction。
    • 所有订单持久化,方便审计和排查。
  • 系统设计:订单状态机 + 唯一索引 + 异步验证 + 退款通知,构成完整的内购安全体系。

posted @ 2021-09-28 14:24  Mr.陳  阅读(5222)  评论(0)    收藏  举报