iOS 内购系统设计文档

iOS内购系统设计文档:客户端通过本地CSV秒出商品列表,Unity IAP异步获取价格(含币种),服务器验证Receipt后发放钻石,支持Server Notifications V2退款通知。

最后编辑时间:瑶华

iOS 内购系统设计文档

1. 概述

设计目标

  • 玩家通过 iOS 内购购买钻石(或其他奖励)
  • 服务器验证支付凭证,防止客户端伪造
  • 支持退款通知处理

涉及系统

  • Apple App Store Connect(商品配置)
  • Unity IAP(客户端支付)
  • 游戏服务器(验证 + 发货)
  • Apple Server Notifications V2(退款/订阅通知)

2. 商品配置

IAPProduct.csv

productId,name,desc,type,rewardId,price
com.ninjafall.gem_pack_1,小宝石包,立刻获得60钻石,consumable,2001,6
com.ninjafall.gem_pack_2,中宝石包,立刻获得360钻石,consumable,2002,30
com.ninjafall.gem_pack_3,大宝石包,立刻获得1200钻石,consumable,2003,98
com.ninjafall.gem_pack_4,超大宝石包,立刻获得4000钻石,consumable,2004,328

reward.csv(新增)

2001,diamond,0,0,60,60,,小宝石包(60钻)
2002,diamond,0,0,360,360,,中宝石包(360钻)
2003,diamond,0,0,1200,1200,,大宝石包(1200钻)
2004,diamond,0,0,4000,4000,,超大宝石包(4000钻)

数据同步说明

Unity IAP 通过 Apple StoreKit 获取商品列表(价格、名称、本地化信息), 客户端初始化时由 Unity IAP 自动填充,无需手动维护。 但 productId、rewardId、type 等业务字段仍需本地配置(IAPProduct.csv), 服务器验证时也需要 CSV 中的 productId → rewardId 映射关系。

Apple App Store Connect 配置

  • 每个 productId 必须与 App Store Connect 中的商品ID完全一致
  • Bundle ID 必须与游戏一致
  • 需要沙盒测试账号用于测试

非消耗品(Non-Consumable)支持

月卡/永久卡等一次性购买的商品属于 ProductType.NonConsumable,与消耗型钻石包有本质区别。

客户端判断

// IAPProduct.csv 扩展:增加 productType 列
// productId,name,productType,rewardId,price
// com.ninjafall.monthly_card,月卡,non_consumable,3001,30
// com.ninjafall.permanent_card,永久卡,non_consumable,3002,198

public enum IAPProductType {
    consumable,
    non_consumable
}

// 初始化时区分类型(商品列表从本地 IAPProduct.csv 配置获取)
foreach (var product in _cachedProducts) {
    var type = product.Type == "non_consumable" 
        ? ProductType.NonConsumable 
        : ProductType.Consumable;
    builder.AddProduct(product.ProductId, type);
}

服务器端处理

  1. iap_transactions 表增加 product_type 字段:

    ALTER TABLE iap_transactions ADD COLUMN product_type VARCHAR(16) NOT NULL DEFAULT 'consumable';
  2. Non-consumable 发货前检查用户是否已有未过期的同类商品:

    
    // 月卡:检查是否已有有效月卡
    var existingCard = await db.IapTransactions
     .Where(t => t.UserId == userId 
         && t.ProductType == "non_consumable"
         && t.ProductId == productId
         && t.ExpiresAt > DateTime.UtcNow)
     .FirstOrDefaultAsync();

if (existingCard != null) { return Error("该商品已购买且未过期"); }


3. Non-consumable 增加 `expires_at` 字段用于过期判断:
```sql
ALTER TABLE iap_transactions ADD COLUMN expires_at DATETIME DEFAULT NULL;
-- expires_at = NULL 表示永久有效(永久卡)

区域定价说明

  • Apple 自动根据用户 App Store 地区转换货币,无需为每个国家单独配置商品价格
  • 开发者只需在 App Store Connect 中设置一个基准价格(如 ¥6),Apple 会自动换算为 USD、EUR 等
  • reward.csvamount(钻石数)需固定,不随地区变化
    • 无论玩家在哪个国家 ¥6 购买,都获得 60 钻石
    • 避免玩家通过切换低价区获取更多奖励

3. 客户端(Unity IAP)

导入 Unity IAP

Window → Package Manager → Unity IAP → Install

初始化

using UnityEngine;
using UnityEngine.Purchasing;

public class IAPManager : MonoBehaviour, IStoreListener {
    private IStoreController _controller;

    void Start() {
        var module = StandardPurchasingModule.Instance();
        var builder = ConfigurationBuilder.Instance(module);

        // 商品列表从本地配置(IAPProduct.csv)或 Unity IAP 自动从 Apple Store 获取
        // builder.AddProduct() 在初始化前执行,productId 和 type 来自本地配置

        UnityPurchasing.Initialize(this, builder);
    }

    public void OnInitialized(IStoreController controller, IExtensionProvider extensions) {
        _controller = controller;
    }

    // 购买商品
    public void BuyProduct(string productId) {
        _controller.InitiatePurchase(productId);
    }

    // 购买成功 → 发送 receipt 给服务器
    public PurchaseProcessingResult ProcessPurchase(PurchaseEventArgs e) {
        string receipt = e.purchasedProduct.receipt;
        WebSocketService.Instance.RequestIAPVerify(receipt, e.purchasedProduct.definition.id);
        return PurchaseProcessingResult.Pending; // 等服务器返回后再 Complete
    }
}

客户端流程

1. IAPManager 初始化,Unity IAP 自动从 Apple Store 获取商品列表
2. 玩家点击购买 → IAPManager.BuyProduct(productId)
3. Apple 支付弹窗
4. 支付成功 → 收到 receipt(base64)
5. 发给服务器验证 → 等待结果
6. 服务器返回成功 → IAPManager.CompletePurchase()
7. 服务器返回失败 → 提示玩家

Proto 消息

// C2S_IAPVerify(请求验证)
message C2S_IAPVerify {
    option (msgid) = 50;
    string receipt = 1;       // Apple 返回的 receipt(base64)
    string product_id = 2;    // 商品ID
}

// S2C_IAPVerify(验证结果)
message S2C_IAPVerify {
    option (msgid) = 51;
    int32 code = 1;           // 0=成功
    string msg = 2;
    int32 reward_id = 3;       // 发给的 rewardId
}

购买中断恢复

场景:玩家支付成功(Apple 扣款),但之后网络超时或 App 被杀掉,客户端未能收到服务器验证结果,导致商品未发货。

机制

  1. ProcessPurchase 返回 PurchaseProcessingResult.Pending 后,必须立即将 productId 和 receipt 持久化到本地存储(PlayerPrefs 或本地 SQLite)
  2. 客户端重启时,在 IAPManager 初始化完成后调用 CheckPendingPurchases() 遍历所有 Pending 商品
  3. 对每个 Pending 商品重新发送 C2S_IAPVerify 给服务器
  4. 服务器收到后正常验证 + 防重(transaction_id 唯一约束保证不会重复发货)
  5. 服务器返回成功后,客户端 CompletePurchase() 并清除本地 Pending 记录

Android 端(UnityPlayerActivity.java)

// 在 onResume / onCreate 中注册 IAP 恢复回调
@Override
protected void onResume() {
    super.onResume();
    // Unity 侧通过 UnitySendMessage 触发 IAPManager.CheckPendingPurchases()
    UnityPlayer.UnitySendMessage("IAPManager", "CheckPendingPurchases", "");
}

iOS 端(UnityAppController)

// 在 applicationDidBecomeActive 中触发恢复
- (void)applicationDidBecomeActive:(UIApplication *)application {
    // Unity 侧通过 UnitySendMessage 触发恢复
    UnitySendMessage("IAPManager", "CheckPendingPurchases", "");
}

C# 实现示例

// 持久化 Pending 商品
private void SavePendingPurchase(string productId, string receipt) {
    PlayerPrefs.SetString($"iap_pending_{productId}", receipt);
    PlayerPrefs.Save();
}

// 启动时恢复所有 Pending 购买
public void CheckPendingPurchases() {
    foreach (var product in IAPProductConfig.Products) {
        string receipt = PlayerPrefs.GetString($"iap_pending_{product.productId}", "");
        if (!string.IsNullOrEmpty(receipt)) {
            WebSocketService.Instance.RequestIAPVerify(receipt, product.productId);
        }
    }
}

// 服务器验证成功后
private void OnVerifySuccess(string productId) {
    _controller.ConfirmPendingPurchase(
        _controller.products.WithID(productId)
    );
    PlayerPrefs.DeleteKey($"iap_pending_{productId}");
    PlayerPrefs.Save();
}

错误处理

网络失败

  • 弹窗提示:"网络不佳,请重试"
  • 自动重试 3 次,间隔 5 秒
  • 3 次失败后提示用户检查网络并手动重试
private IEnumerator RetryVerify(string receipt, string productId, int retryCount = 0) {
    if (retryCount >= 3) {
        ShowErrorDialog("网络不佳,请重试");
        yield break;
    }

    yield return new WaitForSeconds(5);
    WebSocketService.Instance.RequestIAPVerify(receipt, productId);
}

Apple 商店不可用

  • IStoreListener.OnInitialized 回调未触发时,通过 OnInitializeFailed 判断原因:
InitializationFailureReason 含义 处理
NoProductsAvailable 没有可用商品 提示"商店暂无商品",检查 App Store Connect 配置
PurchasingUnavailable / PurchaserDisabled 购买不可用(家长控制等) 提示"内购功能暂不可用,请在设置中检查购买限制"
Unknown 未知原因 提示"商店初始化失败,请重试"

用户取消购买

  • 不弹窗,不发送任何服务器请求
  • 轻量日志记录即可(用于分析转化率)
public void OnPurchaseFailed(Product product, PurchaseFailureReason reason) {
    if (reason == PurchaseFailureReason.UserCancelled) {
        Debug.Log($"[IAP] 用户取消购买: {product.definition.id}");
        return; // 静默处理,不弹窗
    }
    // 其他失败原因 → 弹窗提示
}

沙盒 vs 生产自动切换

  • 客户端无需处理沙盒切换 —— receipt 始终发给服务器,由服务器端根据 Apple 返回的 status=21007 自动切换验证地址
  • 玩家无论在沙盒还是生产环境购买,体验一致

4. 商品列表显示

客户端商品列表来源

客户端通过读取本地 IAPProduct.csv 渲染商品列表(ms 级响应)。无需调用任何网络接口。

Proto 消息

无需服务器接口。客户端直接使用 Unity IAP 的商品数据渲染列表。

服务器角色

服务器不提供商品列表,不涉及 S2C_IAPProducts 接口。 服务器仅负责验证 Receipt 并发放奖励。

客户端调用时机

点击购买时,客户端直接读取本地 IAPProduct.csv 渲染商品列表(ms 级响应),展示商品名称和描述。

客户端示例

public void ShowShopUI() {
    // 1. 直接从本地 CSV 读取,ms 级,展示名称和描述
    var products = IAPProductConfig.LoadFromCSV();
    foreach (var p in products) {
        var item = new ShopItem {
            ProductId = p.productId,
            Name = p.name,          // 来自 CSV,名称可本地化
            Description = p.desc,   // 来自 CSV
            PriceDisplay = "加载中...", // 价格异步加载,初始显示占位
            PriceReady = false,
        };
        _shopItems.Add(item);
    }

    // 2. 后台异步调 Unity IAP 拉取 Apple 真实价格
    LoadPricesFromUnityIAP();
}

private async void LoadPricesFromUnityIAP() {
    // 等待 Unity IAP 初始化完成(若未初始化)
    await WaitForIAPInitialized();

    foreach (var product in _controller.products) {
        // product.metadata.localizedPriceString — Apple 返回的本地化价格(含货币符号,如"¥6"或"$0.99")
        var item = FindItem(product.definition.id);
        if (item != null) {
            item.PriceDisplay = product.metadata.localizedPriceString;
            item.PriceReady = true;
        }
    }
}

购买按钮状态

状态 按钮文案 可点击
价格加载中 "加载中..."
价格已加载 "¥6 购买"

客户端购买流程

1. 点击"¥6 购买"
      ↓
2. 拉起 Apple 支付弹窗
      ↓
3. Apple 支付完成(扣款)
      ↓
4. 显示"支付成功" → 按钮置灰(不可取消)
      ↓
5. 发送 C2S_IAPVerify(含 receipt)给服务器
      ↓
6. 显示"等待服务器验证..."
      ↓
7. 服务器验证成功 → 发放钻石
      ↓
8. 服务器推送 S2C_IAPVerify(含 code=0)
      ↓
9. 客户端更新钻石余额 → 显示"购买成功"(含具体钻石数,如"+60 钻石")

异常处理:
- 服务器返回失败 → 显示"购买失败,请重试"
- 网络超时 → 保留 pending,重启后 CheckPendingPurchases 重发

客户端状态示例

public enum IAPShopState {
    PriceLoading,   // 价格加载中
    ReadyToBuy,      // 价格已加载,可购买
    Paying,          // Apple 支付中(不可取消)
    Verifying,       // 服务器验证中
    Success,         // 购买成功
    Failed           // 失败
}

// 状态转换
void OnApplePaymentSuccess() {
    ShowUI("支付成功");
    SetShopState(IAPShopState.Paying);
    // 发 receipt 给服务器
}

void OnServerVerifySuccess(int rewardAmount) {
    PlayerData.Diamonds += rewardAmount;
    ShowUI($"购买成功 +{rewardAmount} 钻石");
    SetShopState(IAPShopState.Success);
}

void OnServerVerifyFailed(string msg) {
    ShowUI($"购买失败:{msg}");
    SetShopState(IAPShopState.Failed);
}

价格说明

  • 商品名称、描述从 IAPProduct.csv 读取(ms 级响应)
  • 价格从 Unity IAP 获取(异步,返回后更新按钮文案)
  • IAPProduct.csvprice 列作为备用 / 默认价格(如网络异常时)

本地配置

IAPProduct.csv 字段说明:

  • productId — Apple 商品 ID(用于购买时传给 Unity IAP)
  • name — 商品展示名称(可本地化)
  • desc — 商品描述
  • type — consumable / non_consumable
  • rewardId — 奖励 ID(服务器验证时用)
  • price — 备用价格(元),仅在 Apple 价格获取失败时使用

5. 服务器验证

C2S_IAPVerify 处理

1. 接收 receipt + productId
2. 查 IAPProduct.csv 验证 productId 是否有效
3. 调 Apple 验证接口:
   POST https://buy.itunes.apple.com/verifyReceipt
   Body: {"receipt-data": "<receipt>"}
4. Apple 返回 JSON:
   {
     "status": 0,
     "receipt": {
       "in_app": [{
         "product_id": "com.ninjafall.gem_pack_1",
         "transaction_id": "xxx",
         "quantity": "1"
       }],
       "bundle_id": "com.xxx.xxx"
     }
   }
   - status=0:验证成功
   - status=21007:沙盒 receipt → 调沙盒地址重试
   - status!=0:验证失败
5. 防重复:查 iap_transactions 表,transaction_id 是否已处理
6. 验证通过:
   - 查 rewardId → 查 reward.csv → 发放奖励
   - 记录 transaction_id 到 iap_transactions 表
   - 返回成功

Apple 验证地址

生产环境:https://buy.itunes.apple.com/verifyReceipt
沙盒环境:https://sandbox.itunes.apple.com/verifyReceipt

沙盒自动切换逻辑

Apple 返回 status=21007 表示沙盒环境的 receipt 被发送到了生产验证接口。服务器需要自动切换到沙盒地址重试。

处理规则

  • 检测到 status == 21007 → 自动使用沙盒地址重试
  • 最多重试 1 次,防止循环
  • 两次请求使用相同的 receipt-data

6. 订单查询接口

用途

客户端可查询玩家历史购买记录,用于展示购买历史或客服查单。

Proto 消息

// C2S_IAPHistory(查询购买记录)
message C2S_IAPHistory {
    option (msgid) = 52;
}

// S2C_IAPHistory
message S2C_IAPHistory {
    option (msgid) = 53;
    repeated IAPRecord records = 1;
}

message IAPRecord {
    string transaction_id = 1;
    string product_id = 2;
    int32 amount = 3;
    int64 created_at = 4;
    int64 refunded_at = 5;  // 0 表示未退款
}

服务器处理

public async Task<IAPHistoryResponse> GetHistory(long userId) {
    var records = await db.IapTransactions
        .Where(t => t.UserId == userId)
        .OrderByDescending(t => t.CreatedAt)
        .Select(t => new IAPRecord {
            TransactionId = t.TransactionId,
            ProductId = t.ProductId,
            Amount = t.Amount,
            CreatedAt = new DateTimeOffset(t.CreatedAt).ToUnixTimeSeconds(),
            RefundedAt = t.RefundedAt.HasValue 
                ? new DateTimeOffset(t.RefundedAt.Value).ToUnixTimeSeconds() 
                : 0
        })
        .ToListAsync();

    return new IAPHistoryResponse { Records = records };
}

安全提示:仅返回当前登录用户的记录,不允许跨用户查询。


7. Server Notifications V2

用途

Apple 主动推送交易通知(退款/订阅状态变更)

配置

在 App Store Connect → 你的 App → Server Notifications V2 中配置通知 URL:

https://你的服务器域名/iap/notify

支持的通知类型

类型 说明
REFUND 用户退款
SUBSCRIBED 订阅成功
DID_RENEW 订阅续费
EXPIRED 订阅到期
DID_CHANGE_RENEWAL_PREF 用户更改订阅计划

通知格式

{
  "notificationType": "REFUND",
  "notificationUUID": "xxx",
  "timestamp": "1234567890",
  "data": {
    "signedTransactionInfo": "<JWT>"
  }
}

签名验证

Apple 使用 JWT(RS256, RSASSA-PKCS1-v1_5 + SHA-256)对通知中的 signedTransactionInfosignedRenewalInfo 进行签名。服务器必须验证 JWT 签名后再处理业务逻辑。

获取 Apple 公钥

Apple 公钥可从以下地址获取:

https://finance.itunes.apple.com/iap/v1/keys

返回格式:

{
  "keys": [
    {
      "kty": "RSA",
      "kid": "xxx",
      "use": "sig",
      "alg": "RS256",
      "n": "...",
      "e": "..."
    }
  ]
}

缓存策略

  • 本地缓存 RSA 公钥(内存或文件),有效期 24 小时
  • 签名验证时先用缓存公钥,验证失败则重新拉取公钥并再次验证
  • 避免每次通知都请求公钥端点
public class AppleKeyCache {
    private static List<JsonWebKey> _cachedKeys;
    private static DateTime _lastFetch = DateTime.MinValue;
    private static readonly TimeSpan CacheDuration = TimeSpan.FromHours(24);

    public static async Task<List<JsonWebKey>> GetKeys() {
        if (_cachedKeys != null && DateTime.UtcNow - _lastFetch < CacheDuration) {
            return _cachedKeys;
        }

        using var http = new HttpClient();
        var json = await http.GetStringAsync(
            "https://finance.itunes.apple.com/iap/v1/keys");
        var response = JsonConvert.DeserializeObject<AppleKeysResponse>(json);
        _cachedKeys = response.Keys;
        _lastFetch = DateTime.UtcNow;
        return _cachedKeys;
    }

    public static void Invalidate() {
        _cachedKeys = null;
    }
}

JWT 验证流程

public async Task<bool> VerifyJwt(string jwt) {
    var keys = await AppleKeyCache.GetKeys();

    try {
        var handler = new JwtSecurityTokenHandler();
        var validationParams = new TokenValidationParameters {
            ValidateIssuer = true,
            ValidIssuer = "Apple",
            ValidateAudience = true,
            ValidAudience = BundleId, // 你的 App Bundle ID
            ValidateLifetime = true,
            IssuerSigningKeys = keys.Select(k => new RsaSecurityKey(k.ToRSA())),
            ValidateIssuerSigningKey = true
        };

        handler.ValidateToken(jwt, validationParams, out _);
        return true;
    }
    catch (SecurityTokenSignatureKeyNotFoundException) {
        // 公钥过期/不匹配,刷新缓存后重试一次
        AppleKeyCache.Invalidate();
        var freshKeys = await AppleKeyCache.GetKeys();
        // ... 用 freshKeys 再次验证
    }

    return false;
}

幂等性处理

Apple 可能因网络原因重复发送同一通知。服务器必须保证幂等。

notificationUUID 作为唯一键

CREATE TABLE iap_notifications (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    notification_uuid VARCHAR(64) NOT NULL UNIQUE,
    notification_type VARCHAR(32) NOT NULL,
    raw_payload TEXT NOT NULL,
    processed_at DATETIME DEFAULT CURRENT_TIMESTAMP,
    INDEX idx_uuid (notification_uuid)
);

处理逻辑

public async Task<IActionResult> HandleNotification(NotificationPayload payload) {
    // 幂等检查
    var exists = await db.IapNotifications
        .AnyAsync(n => n.NotificationUuid == payload.NotificationUUID);

    if (exists) {
        // 已处理过,直接返回 200
        return Ok();
    }

    // 记录通知(先插入,防止并发重复处理)
    try {
        db.IapNotifications.Add(new IapNotification {
            NotificationUuid = payload.NotificationUUID,
            NotificationType = payload.NotificationType,
            RawPayload = JsonConvert.SerializeObject(payload)
        });
        await db.SaveChangesAsync();
    }
    catch (DbUpdateException) {
        // 并发下已插入,幂等返回
        return Ok();
    }

    // 处理业务逻辑(退款扣奖等)
    await ProcessNotification(payload);

    return Ok();
}

为什么要幂等?

  • Apple 保证至少投递一次(at-least-once),不保证精确一次
  • 服务器网络波动可能导致返回 200 但 Apple 未收到,触发重试
  • 重复扣奖励会导致玩家损失,必须防止

服务器接收逻辑

1. 接收 POST JSON
2. 验证 JWT 签名(Apple 公钥从 https://finance.itunes.apple.com/公钥获取)
3. 解析 transactionInfo
4. 根据 notificationType 处理:
   - REFUND → 扣回已发货的 reward(查 iap_transactions)
   - SUBSCRIBED → 记录订阅状态
   - EXPIRED → 更新订阅状态
5. 返回 200 OK(Apple 要求 10 秒内响应)

8. 数据库设计

iap_transactions(已处理交易记录)

CREATE TABLE iap_transactions (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    user_id BIGINT NOT NULL,
    transaction_id VARCHAR(64) NOT NULL UNIQUE,
    product_id VARCHAR(64) NOT NULL,
    reward_id INT NOT NULL,
    amount INT NOT NULL,
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
    refunded_at DATETIME DEFAULT NULL,
    INDEX idx_user (user_id),
    INDEX idx_transaction (transaction_id)
);

字段说明

字段 说明
transaction_id Apple 交易ID,防重复发货
reward_id 发放的 rewardId
amount 发放数量(钻石数)
refunded_at 非空表示已退款

9. 防作弊关键点

作弊方式 防御
伪造 receipt 服务器调 Apple 验证,不信任客户端
重放旧 receipt transaction_id 唯一约束,不可重复
篡改 productId 服务器查 IAPProduct.csv,不信任客户端
退款后继续发货 Server Notifications V2 推送 REFUND,扣回奖励

退款时限说明

  • Apple 默认提供 90 天内的退款窗口,用户在 App Store 购买历史中发起退款
  • REFUND 通知可能延迟送达(Apple 不保证实时性),通常几分钟到几小时内到达
  • 服务器应记录每笔退款通知的 timestamp,与交易创建时间对比,用于数据核对
  • 建议在 iap_transactions 表增加 refund_notification_timestamp 字段:
    ALTER TABLE iap_transactions ADD COLUMN refund_notification_timestamp BIGINT DEFAULT NULL;
  • 定期核对:每日检查差异超过 24 小时的退款通知,人工排查可能的数据丢失

10. 测试指南

沙盒测试账号

  1. App Store Connect → 用户和访问 → 沙盒测试员 中创建沙盒账号
  2. 沙盒账号使用虚构邮箱(如 test@example.com),无需真实 Apple ID
  3. 设备上操作
    • iOS: 设置 → App Store → 拉到最底 → 沙盒账号 → 登录沙盒 Apple ID
    • 注意:沙盒账号不能在正式 App Store 登录,只能在 App 内购买弹窗中使用
  4. 沙盒环境下购买不会实际扣款

退款测试

  1. 用沙盒账号完成一笔购买
  2. 在 App Store Connect → 你的 App → 交易历史 → 找到该笔交易
  3. 点击"发起退款",退款将在 24-48 小时内生效
  4. 退款生效后 Apple 会发送 REFUND 通知,检查服务器是否正确处理

沙盒 vs 生产验证

场景 购买环境 验证地址 status
沙盒购买 → 生产验证 沙盒 buy.itunes.apple.com 21007
沙盒购买 → 沙盒验证 沙盒 sandbox.itunes.apple.com 0
生产购买 → 生产验证 生产 buy.itunes.apple.com 0
生产购买 → 沙盒验证 生产 sandbox.itunes.apple.com 21008

测试要点

  • 用沙盒账号购买,确认 receipt 发生产接口返回 21007 后自动切换沙盒重试
  • 整套流程客户端无感知,无需手动切换验证地址

11. 运营监控

关键指标

指标 计算公式 建议阈值
IAP 验证成功率 (验证成功数 / 验证请求数) × 100% > 95%
Apple 验证平均延迟 Apple 接口响应时间平均值 < 2000ms
退款率 (REFUND 通知数 / 总交易数) × 100% < 5% 告警
Pending 恢复率 (恢复成功数 / Pending 购买数) × 100% > 90%

监控建议

  1. 每日监控:检查前一日验证成功率、退款率
  2. 退款率告警:超过 5% 时触发运维告警,排查是否有利用漏洞恶意退款
  3. 验证延迟:Apple 验证接口平均延迟超过 2 秒时检查网络链路
  4. Pending 积压:Pending 购买超过 24 小时未恢复时人工介入

日志建议

[IAP] VerifyRequest | userId=xxx | productId=xxx | timestamp=xxx
[IAP] VerifyResult | userId=xxx | status=0 | latency=523ms
[IAP] VerifyError | userId=xxx | status=21007 | retry=sandbox
[IAP] Refund | userId=xxx | transactionId=xxx | amount=60
[IAP] DuplicateRequest | userId=xxx | transactionId=xxx (已处理)

12. 财务说明

Apple 分成规则

  • Apple 标准抽成:30%(中国区相同)
  • 小型企业计划(年收入 < $1M):15%
  • 订阅类商品第二年起:15%

定价示例

定价(¥) Apple 抽成 开发者实际收入(¥) 发放钻石
6 30% (1.8) 4.2 60
30 30% (9) 21 360
98 30% (29.4) 68.6 1200
328 30% (98.4) 229.6 4000

注意:Apple 抽成后收入为人民币,实际到账由 Apple 按月结算并按汇率折算。钻石发放数固定,不与 Apple 抽成精确对应。

价格分层策略

  • 本游戏采用 App Store 定价,Apple 结算后按汇率折算
  • 无需在 IAPProduct.csv 中记录开发者收入,仅记录 rewardIdamount
  • 财务对账建议:按月导出 App Store Connect 销售报表,与服务器 iap_transactions 表交叉核对

13. 待确认

  1. Server Notifications URLhttps://www.dapanz.com/iap/notify
    • 需服务器实现 POST 接口,接收 Apple 推送的退款/订阅通知
    • 当前服务器未开发,先拟定此路径,开发时实现
  • 留下精彩的评论吧~(0条)

所有内容仅供学习与交流,转载须标明链接。未经同意,禁止作为商业用途,有特殊需求请联系站长。