订阅架构设计指南
Architecting for subscriptions
2020年6月25日
一句话判断
做订阅制 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}")
最佳实践
- 服务端做权限判断,客户端只做 UI 展示。永远不要只依赖客户端的
receipt判断来解锁功能。用户可以在系统时间上做手脚绕过本地检查。客户端从服务端 API 拿到「有权限 / 无权限」的布尔值即可。 - 为每个订阅用户记录完整的交易历史。
original_transaction_id是用户订阅的唯一标识,transaction_id是每次续期的新 ID。保存所有transaction_id,在处理退款和纠纷时需要完整的交易链。 - 设置合理的客户端缓存过期时间。建议 1-4 小时。太短会增加 API 请求量,太长会导致续期后用户看不到最新状态。可以在 app 从后台回到前台时强制刷新一次。
还有什么值得关注
- 家庭共享订阅的权限判断更复杂,需要检查
in_app_ownership_type字段,不是购买者的家庭成员可能有延迟。 SKPaymentQueue的finishTransaction一定要在服务端确认后才调用,不要一收到购买成功就 finish。- App Store Connect 的订阅数据有 2-3 天延迟,做实时数据展示时用你自己的服务端数据而不是苹果的报告。