Apple 内购配置
本指南介绍如何使用 StoreKit 为 iOS 和 macOS 应用配置 Apple 应用内购买(IAP)。
前置条件
- Apple 开发者账号($99/年)
- 已在 App Store Connect 中注册的应用
- 可访问 App Store Connect
- 已完成付费应用协议
步骤一:完成协议
- 前往 App Store Connect
- 点击 协议、税务和银行业务
- 完成 付费应用 协议
- 添加银行账户和税务信息
重要提示: 协议完成前无法测试购买。
步骤二:创建应用内购买商品
- 在 App Store Connect 中打开您的应用
- 导航到 功能 > 应用内购买
- 点击 + 添加新商品
商品类型
| 类型 | 说明 | 使用场景 |
|---|---|---|
| 消耗型 | 可多次购买 | 虚拟货币、生命值 |
| 非消耗型 | 一次性购买 | 高级解锁、去除广告 |
| 自动续期订阅 | 周期性扣费 | 月度/年度会员 |
| 非续期订阅 | 固定期限访问 | 赛季通行证 |
创建订阅
- 选择 自动续期订阅
- 创建订阅组:
- 组名:"Pro Plans"
- 参考名称:内部标识符
- 添加订阅详情:
| 字段 | 说明 | 示例 |
|---|---|---|
| 参考名称 | 内部名称 | Pro Monthly |
| 商品 ID | 唯一标识符 | com.app.pro.monthly |
| 订阅期限 | 计费周期 | 1 个月 |
| 价格 | 订阅价格 | $9.99 |
- 添加本地化信息:
- 显示名称
- 描述
- 点击 保存
配置订阅功能
- 免费试用:可选的免费试用期
- 推介优惠:首期折扣价
- 促销优惠:为现有用户提供的特殊定价
- 优惠代码:可兑换免费访问的代码
步骤三:配置服务器通知
App Store 服务器通知 V2:
- 在 App Store Connect 中,前往 应用信息
- 找到 App Store 服务器通知
- 输入您的服务器 URL:
https://yourdomain.com/v1/payment/webhook/apple
- 选择 版本 2(推荐)
- 保存更改
通知类型
| 类型 | 说明 |
|---|---|
| SUBSCRIBED | 新订阅 |
| DID_RENEW | 订阅已续费 |
| DIDCHANGERENEWAL_PREF | 已安排方案变更 |
| EXPIRED | 订阅已过期 |
| DIDFAILTO_RENEW | 付款失败 |
| REFUND | 购买已退款 |
步骤四:设置共享密钥
用于收据验证:
- 在 App Store Connect 中打开您的应用
- 导航到 应用信息
- 找到 应用专用共享密钥
- 点击 管理 并生成密钥
- 复制并安全保存共享密钥
步骤五:在 OpenDev 中配置
- 登录 OpenDev 平台
- 前往应用的 支付配置
- 添加 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:
- 前往 OpenDev 中的 商品档位
- 为每个档位添加 Apple 商品 ID:
{
"productId": "pro_monthly",
"name": "Pro Monthly",
"platformProductIds": {
"apple": "com.yourcompany.myapp.pro.monthly"
}
}
步骤七:实现 StoreKit 2
添加 StoreKit 能力
- 打开 Xcode 项目
- 选择目标 > Signing & Capabilities
- 点击 + Capability
- 添加 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();
}
});
步骤十:测试集成
沙盒测试
- 在 App Store Connect 中创建沙盒测试员:
- 前往 用户和访问 > 沙盒 > 测试员
- 添加测试账号
- 在设备上:
- 退出正式 App Store 账号
- 使用沙盒账号进行测试
- 沙盒中购买免费
测试场景
| 场景 | 测试方法 |
|---|---|
| 新购买 | 使用沙盒账号购买 |
| 订阅续费 | 沙盒续费速度更快(见下表) |
| 付款失败 | 中断交易 |
| 恢复购买 | 删除并重新安装应用 |
沙盒时间加速
| 实际时长 | 沙盒时长 |
|---|---|
| 1 周 | 3 分钟 |
| 1 个月 | 5 分钟 |
| 2 个月 | 10 分钟 |
| 1 年 | 1 小时 |
故障排查
商品未加载
解决方案:
- 确保付费应用协议已完成
- 商品必须处于"准备提交"状态
- Bundle ID 必须完全匹配
- 创建商品后等待 15-30 分钟
购买未完成
解决方案:
- 检查沙盒账号是否有效
- 验证是否已启用应用内购买能力
- 检查是否有家长控制或限制
服务器未收到通知
解决方案:
- 验证 URL 是否为 HTTPS 且可公网访问
- 检查 URL 是否返回 200 状态码
- 验证是否使用了正确的通知版本
安全最佳实践
- 服务器端验证 — 不要仅依赖客户端验证
- 使用服务器通知 — 获取可靠的订阅状态
- 存储交易 ID — 防止重复处理
- 处理宽限期 — 不要立即撤销访问权限
- 保护共享密钥 — 切勿在客户端代码中暴露