EN

Apple 内购配置

本指南介绍如何使用 StoreKit 为 iOS 和 macOS 应用配置 Apple 应用内购买(IAP)。

前置条件

  • Apple 开发者账号($99/年)
  • 已在 App Store Connect 中注册的应用
  • 可访问 App Store Connect
  • 已完成付费应用协议

步骤一:完成协议

  1. 前往 App Store Connect
  2. 点击 协议、税务和银行业务
  3. 完成 付费应用 协议
  4. 添加银行账户和税务信息

重要提示: 协议完成前无法测试购买。

步骤二:创建应用内购买商品

  1. 在 App Store Connect 中打开您的应用
  2. 导航到 功能 > 应用内购买
  3. 点击 + 添加新商品

商品类型

类型 说明 使用场景
消耗型 可多次购买 虚拟货币、生命值
非消耗型 一次性购买 高级解锁、去除广告
自动续期订阅 周期性扣费 月度/年度会员
非续期订阅 固定期限访问 赛季通行证

创建订阅

  1. 选择 自动续期订阅
  2. 创建订阅组:
  • 组名:"Pro Plans"
  • 参考名称:内部标识符
  1. 添加订阅详情:
字段 说明 示例
参考名称 内部名称 Pro Monthly
商品 ID 唯一标识符 com.app.pro.monthly
订阅期限 计费周期 1 个月
价格 订阅价格 $9.99
  1. 添加本地化信息:
  • 显示名称
  • 描述
  1. 点击 保存

配置订阅功能

  1. 免费试用:可选的免费试用期
  2. 推介优惠:首期折扣价
  3. 促销优惠:为现有用户提供的特殊定价
  4. 优惠代码:可兑换免费访问的代码

步骤三:配置服务器通知

App Store 服务器通知 V2:

  1. 在 App Store Connect 中,前往 应用信息
  2. 找到 App Store 服务器通知
  3. 输入您的服务器 URL:
https://yourdomain.com/v1/payment/webhook/apple
  1. 选择 版本 2(推荐)
  2. 保存更改

通知类型

类型 说明
SUBSCRIBED 新订阅
DID_RENEW 订阅已续费
DIDCHANGERENEWAL_PREF 已安排方案变更
EXPIRED 订阅已过期
DIDFAILTO_RENEW 付款失败
REFUND 购买已退款

步骤四:设置共享密钥

用于收据验证:

  1. 在 App Store Connect 中打开您的应用
  2. 导航到 应用信息
  3. 找到 应用专用共享密钥
  4. 点击 管理 并生成密钥
  5. 复制并安全保存共享密钥

步骤五:在 OpenDev 中配置

  1. 登录 OpenDev 平台
  2. 前往应用的 支付配置
  3. 添加 Apple 配置:
{
  "platform": "apple",
  "enabled": true,
  "config": {
    "bundleId": "com.yourcompany.myapp",
    "sharedSecret": "your_shared_secret",
    "environment": "production",
    "notificationUrl": "https://yourdomain.com/v1/payment/webhook/apple"
  }
}

配置字段

字段 必填 说明
Bundle ID 应用的 Bundle 标识符
共享密钥 应用专用共享密钥
环境 sandbox 或 production
通知 URL 服务器通知端点

步骤六:配置商品档位

关联 Apple 商品 ID:

  1. 前往 OpenDev 中的 商品档位
  2. 为每个档位添加 Apple 商品 ID:
{
  "productId": "pro_monthly",
  "name": "Pro Monthly",
  "platformProductIds": {
    "apple": "com.yourcompany.myapp.pro.monthly"
  }
}

步骤七:实现 StoreKit 2

添加 StoreKit 能力

  1. 打开 Xcode 项目
  2. 选择目标 > Signing & Capabilities
  3. 点击 + Capability
  4. 添加 In-App Purchase

获取商品

import StoreKit

class StoreManager: ObservableObject {
    @Published var products: [Product] = []
    
    func fetchProducts() async {
        do {
            let productIds = ["com.app.pro.monthly", "com.app.pro.yearly"]
            products = try await Product.products(for: productIds)
        } catch {
            print("获取商品失败: \(error)")
        }
    }
}

购买商品

func purchase(_ product: Product) async throws -> Transaction? {
    let result = try await product.purchase()
    
    switch result {
    case .success(let verification):
        let transaction = try checkVerified(verification)
        
        // 发送到服务器验证
        await sendToServer(transaction: transaction)
        
        // 完成交易
        await transaction.finish()
        
        return transaction
        
    case .pending:
        // 交易待处理(如"请求购买")
        return nil
        
    case .userCancelled:
        return nil
        
    @unknown default:
        return nil
    }
}

func checkVerified<T>(_ result: VerificationResult<T>) throws -> T {
    switch result {
    case .unverified:
        throw StoreError.failedVerification
    case .verified(let signedType):
        return signedType
    }
}

处理交易

func listenForTransactions() -> Task<Void, Error> {
    return Task.detached {
        for await result in Transaction.updates {
            do {
                let transaction = try self.checkVerified(result)
                
                // 更新权益
                await self.updateEntitlements(transaction)
                
                await transaction.finish()
            } catch {
                print("交易验证失败")
            }
        }
    }
}

恢复购买

func restorePurchases() async {
    for await result in Transaction.currentEntitlements {
        if case .verified(let transaction) = result {
            // 恢复权益
            await updateEntitlements(transaction)
        }
    }
}

步骤八:服务器端验证

收据验证(传统方式)

const axios = require('axios');

async function verifyReceipt(receiptData, isProduction = true) {
    const url = isProduction
        ? 'https://buy.itunes.apple.com/verifyReceipt'
        : 'https://sandbox.itunes.apple.com/verifyReceipt';
    
    const response = await axios.post(url, {
        'receipt-data': receiptData,
        'password': process.env.APPLE_SHARED_SECRET,
    });
    
    return response.data;
}

使用 App Store Server API 验证(推荐)

const { SignedDataVerifier } = require('@apple/app-store-server-library');

async function verifyTransaction(signedTransaction) {
    const verifier = new SignedDataVerifier(
        appleRootCAs,
        true,
        Environment.PRODUCTION,
        bundleId
    );
    
    const transaction = await verifier.verifyAndDecodeTransaction(signedTransaction);
    return transaction;
}

步骤九:处理通知

const { SignedDataVerifier } = require('@apple/app-store-server-library');

app.post('/webhook/apple', async (req, res) => {
    try {
        const verifier = new SignedDataVerifier(/*...*/);
        
        const notification = await verifier.verifyAndDecodeNotification(
            req.body.signedPayload
        );
        
        switch (notification.notificationType) {
            case 'SUBSCRIBED':
                await handleNewSubscription(notification);
                break;
            case 'DID_RENEW':
                await handleRenewal(notification);
                break;
            case 'EXPIRED':
            case 'DID_FAIL_TO_RENEW':
                await handleExpiration(notification);
                break;
            case 'REFUND':
                await handleRefund(notification);
                break;
        }
        
        res.status(200).send();
    } catch (error) {
        res.status(400).send();
    }
});

步骤十:测试集成

沙盒测试

  1. 在 App Store Connect 中创建沙盒测试员:
  • 前往 用户和访问 > 沙盒 > 测试员
  • 添加测试账号
  1. 在设备上:
  • 退出正式 App Store 账号
  • 使用沙盒账号进行测试
  • 沙盒中购买免费

测试场景

场景 测试方法
新购买 使用沙盒账号购买
订阅续费 沙盒续费速度更快(见下表)
付款失败 中断交易
恢复购买 删除并重新安装应用

沙盒时间加速

实际时长 沙盒时长
1 周 3 分钟
1 个月 5 分钟
2 个月 10 分钟
1 年 1 小时

故障排查

商品未加载

解决方案:

  • 确保付费应用协议已完成
  • 商品必须处于"准备提交"状态
  • Bundle ID 必须完全匹配
  • 创建商品后等待 15-30 分钟

购买未完成

解决方案:

  • 检查沙盒账号是否有效
  • 验证是否已启用应用内购买能力
  • 检查是否有家长控制或限制

服务器未收到通知

解决方案:

  • 验证 URL 是否为 HTTPS 且可公网访问
  • 检查 URL 是否返回 200 状态码
  • 验证是否使用了正确的通知版本

安全最佳实践

  1. 服务器端验证 — 不要仅依赖客户端验证
  2. 使用服务器通知 — 获取可靠的订阅状态
  3. 存储交易 ID — 防止重复处理
  4. 处理宽限期 — 不要立即撤销访问权限
  5. 保护共享密钥 — 切勿在客户端代码中暴露

相关文档