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);
}
服务器端处理:
-
iap_transactions表增加product_type字段:ALTER TABLE iap_transactions ADD COLUMN product_type VARCHAR(16) NOT NULL DEFAULT 'consumable'; -
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.csv中amount(钻石数)需固定,不随地区变化- 无论玩家在哪个国家 ¥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 被杀掉,客户端未能收到服务器验证结果,导致商品未发货。
机制:
ProcessPurchase返回PurchaseProcessingResult.Pending后,必须立即将 productId 和 receipt 持久化到本地存储(PlayerPrefs 或本地 SQLite)- 客户端重启时,在
IAPManager初始化完成后调用CheckPendingPurchases()遍历所有 Pending 商品 - 对每个 Pending 商品重新发送
C2S_IAPVerify给服务器 - 服务器收到后正常验证 + 防重(
transaction_id唯一约束保证不会重复发货) - 服务器返回成功后,客户端
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.csv的price列作为备用 / 默认价格(如网络异常时)
本地配置
IAPProduct.csv 字段说明:
productId— Apple 商品 ID(用于购买时传给 Unity IAP)name— 商品展示名称(可本地化)desc— 商品描述type— consumable / non_consumablerewardId— 奖励 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)对通知中的 signedTransactionInfo 和 signedRenewalInfo 进行签名。服务器必须验证 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. 测试指南
沙盒测试账号
- 在 App Store Connect → 用户和访问 → 沙盒测试员 中创建沙盒账号
- 沙盒账号使用虚构邮箱(如
test@example.com),无需真实 Apple ID - 设备上操作:
- iOS: 设置 → App Store → 拉到最底 → 沙盒账号 → 登录沙盒 Apple ID
- 注意:沙盒账号不能在正式 App Store 登录,只能在 App 内购买弹窗中使用
- 沙盒环境下购买不会实际扣款
退款测试
- 用沙盒账号完成一笔购买
- 在 App Store Connect → 你的 App → 交易历史 → 找到该笔交易
- 点击"发起退款",退款将在 24-48 小时内生效
- 退款生效后 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% |
监控建议
- 每日监控:检查前一日验证成功率、退款率
- 退款率告警:超过 5% 时触发运维告警,排查是否有利用漏洞恶意退款
- 验证延迟:Apple 验证接口平均延迟超过 2 秒时检查网络链路
- 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中记录开发者收入,仅记录rewardId和amount - 财务对账建议:按月导出 App Store Connect 销售报表,与服务器
iap_transactions表交叉核对
13. 待确认
- Server Notifications URL:
https://www.dapanz.com/iap/notify- 需服务器实现 POST 接口,接收 Apple 推送的退款/订阅通知
- 当前服务器未开发,先拟定此路径,开发时实现
所有内容仅供学习与交流,转载须标明链接。未经同意,禁止作为商业用途,有特殊需求请联系站长。
