Architecting for subscriptions
App Store & Distribution 高级 18m

订阅架构设计指南

Architecting for subscriptions

2020年6月25日

在 Apple 官方观看视频

一句话判断

做订阅制 app 不只是加个 StoreKit 调用——这场 Session 从客户端缓存策略、服务端验证、状态机设计到退款处理,给出了一个完整的订阅架构方案,是少有的「从 0 到 1 讲订阅」的深度内容。

这场 Session 讲了什么

这场 Session 不讲 StoreKit API 本身,而是讲如何设计一个健壮的订阅系统架构。核心议题是:客户端和服务端如何分工、订阅状态如何管理、如何处理各种边界情况(宽限期、计费重试、退款、家庭共享等)。

推荐的架构模式是「客户端负责 UI 展示 + 服务端负责权限判断」。客户端用 StoreKit 发起购买和展示订阅状态,服务端用 App Store Server Notifications 接收实时状态变更并做权限判断。客户端定时从服务端拉取权限状态。

Session 还详细讲解了订阅状态的完整生命周期:购买 -> 活跃 -> 到期/续期 -> 宽限期 -> 计费重试 -> 最终过期或恢复。每个状态都有对应的客户端和服务端处理逻辑。

值得深挖的点

服务端是权限的唯一真相来源

客户端的 receipt 缓存可能过期或被篡改,服务端收到的 Server Notification 才是订阅状态的可靠来源。Session 的架构建议:服务端维护一个订阅状态表(user_id, product_id, status, expires_date, transaction_id),每次收到 Server Notification 更新这个表,客户端通过 API 查询这个表来判断功能解锁。

计费重试和宽限期

用户订阅到期时,Apple 会尝试扣费。如果扣费失败(卡里没钱),Apple 会进入计费重试周期(最多重试 60 天)。在这期间,你可以选择给用户一个「宽限期」,允许他们继续使用订阅功能。Session 建议宽限期设为 6-16 天,太短用户来不及处理,太长成本太高。

代码片段

// 客户端:订阅状态查询和本地缓存
class SubscriptionManager {
    let userDefaults = UserDefaults.standard
    let apiClient = APIClient()
    
    // 检查当前订阅状态(优先从缓存读取,过期后从服务端刷新)
    func checkSubscriptionStatus(completion: @escaping (SubscriptionStatus) -> Void) {
        // 先检查本地缓存
        if let cached = getCachedStatus(), !cached.isExpired {
            completion(cached)
            return
        }
        
        // 缓存过期,从服务端拉取
        apiClient.getSubscriptionStatus { [weak self] status in
            self?.cacheStatus(status)
            completion(status)
        }
    }
    
    private func getCachedStatus() -> SubscriptionStatus? {
        guard let data = userDefaults.data(forKey: "subscription_cache"),
              let status = try? JSONDecoder().decode(SubscriptionStatus.self, from: data) else {
            return nil
        }
        return status
    }
    
    private func cacheStatus(_ status: SubscriptionStatus) {
        if let data = try? JSONEncoder().encode(status) {
            userDefaults.set(data, forKey: "subscription_cache")
        }
    }
}

struct SubscriptionStatus: Codable {
    let isActive: Bool
    let productId: String
    let expiresDate: Date
    let isInBillingRetry: Bool
    let isInGracePeriod: Bool
    
    var isExpired: Bool {
        return Date() > expiresDate.addingTimeInterval(3600)  // 1 小时缓存有效期
    }
}
# 服务端:处理 App Store Server Notifications(Python 示例)
# 这是订阅架构的核心 —— 服务端是权限的真相来源

from datetime import datetime, timedelta

def handle_server_notification(notification):
    """处理 App Store 服务端通知"""
    notification_type = notification['notificationType']
    original_tx_id = notification['originalTransactionId']
    
    if notification_type == 'DID_RENEW':
        # 订阅续期成功
        expires_date_ms = notification['expiresDate']
        expires_date = datetime.fromtimestamp(expires_date_ms / 1000)
        
        update_subscription(
            transaction_id=original_tx_id,
            status='active',
            expires_date=expires_date
        )
        
    elif notification_type == 'DID_FAIL_TO_RENEW':
        # 续期失败,进入计费重试
        update_subscription(
            transaction_id=original_tx_id,
            status='billing_retry',
            grace_period_end=datetime.now() + timedelta(days=14)
        )
        
    elif notification_type == 'DID_CHANGE_RENEWAL_STATUS':
        # 用户关闭了自动续期
        auto_renew = notification['autoRenewStatus'] == 1
        update_subscription(
            transaction_id=original_tx_id,
            auto_renew=auto_renew
        )
        
    elif notification_type == 'REFUND':
        # 用户退款
        update_subscription(
            transaction_id=original_tx_id,
            status='refunded',
            access_revoked=True
        )
        
    elif notification_type == 'EXPIRED':
        # 订阅最终过期
        update_subscription(
            transaction_id=original_tx_id,
            status='expired'
        )


def update_subscription(transaction_id, **kwargs):
    """更新数据库中的订阅记录"""
    # 构建 SQL UPDATE 语句
    updates = {k: v for k, v in kwargs.items()}
    updates['updated_at'] = datetime.now()
    # db.subscriptions.update(transaction_id, updates)
    print(f"更新订阅 {transaction_id}: {updates}")

最佳实践

  1. 服务端做权限判断,客户端只做 UI 展示。永远不要只依赖客户端的 receipt 判断来解锁功能。用户可以在系统时间上做手脚绕过本地检查。客户端从服务端 API 拿到「有权限 / 无权限」的布尔值即可。
  2. 为每个订阅用户记录完整的交易历史original_transaction_id 是用户订阅的唯一标识,transaction_id 是每次续期的新 ID。保存所有 transaction_id,在处理退款和纠纷时需要完整的交易链。
  3. 设置合理的客户端缓存过期时间。建议 1-4 小时。太短会增加 API 请求量,太长会导致续期后用户看不到最新状态。可以在 app 从后台回到前台时强制刷新一次。

还有什么值得关注

  • 家庭共享订阅的权限判断更复杂,需要检查 in_app_ownership_type 字段,不是购买者的家庭成员可能有延迟。
  • SKPaymentQueuefinishTransaction 一定要在服务端确认后才调用,不要一收到购买成功就 finish。
  • App Store Connect 的订阅数据有 2-3 天延迟,做实时数据展示时用你自己的服务端数据而不是苹果的报告。
WWDC 2020