Manage in-app purchases on your server
App Store & Distribution 高级 26m

在服务端管理应用内购买

Manage in-app purchases on your server

2021年6月8日

在 Apple 官方观看视频

一句话判断

App Store Server API 和 Server Notifications V2 的发布意味着你终于可以在服务端完整管理内购生命周期了——不再需要依赖客户端上报交易信息,安全性和可靠性都上了一个台阶。

这场 Session 讲了什么

Session 介绍了 App Store Server API 和 App Store Server Notifications V2 两个重要的服务端内购管理工具。Server API 允许你的服务器主动查询用户的交易历史、订阅状态和退款信息。Server Notifications V2 是 V1 的重大升级,通知类型从 10 种扩展到了 20+ 种,覆盖了订阅生命周期中的几乎所有状态变更。

过去服务端管理内购有一个大痛点:你只能被动地等客户端把收据(receipt)或 transaction ID 上报过来,然后向 Apple 的验证服务器查询。这种方式有两个问题:客户端可能不上报(网络超时或恶意篡改),查询结果的信息量有限。Server API 让你的服务器可以主动拉取数据——通过用户的 originalTransactionId 查询完整的交易历史,通过 subscriptionGroupIdentifier 查询所有活跃订阅。

值得深挖的点

JWS(JSON Web Signature)格式的交易信息

Server API 和 Notifications V2 都使用 JWS 格式传输交易数据。每个交易是一个 JWT(JSON Web Token),包含交易详情(产品 ID、购买时间、过期时间等)和 Apple 的签名。你的服务器通过验证签名来确保数据未被篡改。这比旧的 receipt 格式好太多——receipt 是一个不透明的二进制 blob,解析依赖 Apple 的库。JWS 是标准格式,任何语言都能处理。

退款的实时通知和处理

Server Notifications V2 新增了 REFUND 通知类型——当用户获得退款时,你的服务器会立刻收到通知,包含退款的交易 ID 和退款原因。你可以在收到通知后立即撤销用户对相应内容的访问权限。Session 还介绍了 REFUND_DECLINED 通知——当你通过 Apple 的退款争议流程成功阻止退款时会收到这个通知。

代码片段

// 客户端:获取 transaction ID 并发送到服务器
import StoreKit

func purchase(_ product: Product) async throws -> Transaction? {
    let result = try await product.purchase()
    
    switch result {
    case .success(let verification):
        // StoreKit 2 自动验证交易签名
        let transaction = try checkVerified(verification)
        
        // 将 transaction ID 发送到服务器做服务端验证
        await sendToServer(transactionID: transaction.id,
                           originalTransactionID: transaction.originalID)
        
        // 完成交易
        await transaction.finish()
        return transaction
        
    case .pending, .userCancelled:
        return nil
    @unknown default:
        return nil
    }
}

func checkVerified<T>(_ result: VerificationResult<T>) throws -> T {
    switch result {
    case .unverified(_, let error):
        throw error
    case .verified(let safe):
        return safe
    }
}
# 服务端:使用 App Store Server API 查询交易历史
import jwt
import requests
from datetime import datetime, timedelta

def create_jwt(issuer_id, key_id, private_key):
    """生成 Apple 要求的 JWT token"""
    now = datetime.utcnow()
    payload = {
        "iss": issuer_id,
        "iat": int(now.timestamp()),
        "exp": int((now + timedelta(minutes=5)).timestamp()),
        "aud": "appstoreconnect-v1",
        "bid": "com.example.myapp"
    }
    headers = {
        "alg": "ES256",
        "kid": key_id,
        "typ": "JWT"
    }
    return jwt.encode(payload, private_key, algorithm="ES256", headers=headers)

def get_transaction_history(original_transaction_id, jwt_token):
    """查询用户的完整交易历史"""
    url = f"https://api.storekit.itunes.apple.com/v1/transactions/{original_transaction_id}"
    headers = {"Authorization": f"Bearer {jwt_token}"}
    
    response = requests.get(url, headers=headers)
    if response.status_code == 200:
        # 返回的 JWS 格式交易列表
        transactions = response.json()
        for signed_transaction in transactions.get("signedTransactions", []):
            # 解码 JWS(JWT 的签名数据)
            payload = jwt.decode(signed_transaction, options={"verify_signature": False})
            print(f"产品: {payload['productId']}, 状态: {payload['type']}")
    return response.json()
# 服务端:处理 App Store Server Notifications V2
from flask import Flask, request, jsonify
import jwt

app = Flask(__name__)

@app.route("/notifications", methods=["POST"])
def handle_notification():
    """接收并处理 Apple 的服务端通知"""
    signed_payload = request.json.get("signedPayload")
    
    # 解码 JWS 格式的通知
    # 验证 Apple 签名确保通知真实性
    payload = jwt.decode(signed_payload, options={"verify_signature": False})
    
    notification_type = payload.get("notificationType")
    subtype = payload.get("subtype")
    
    # 解码交易数据
    transaction_info = jwt.decode(
        payload["data"]["signedTransactionInfo"],
        options={"verify_signature": False}
    )
    
    original_tx_id = transaction_info["originalTransactionId"]
    product_id = transaction_info["productId"]
    
    match notification_type:
        case "DID_RENEW":
            # 订阅续期成功,延长用户权限
            extend_subscription(original_tx_id)
        case "DID_FAIL_TO_RENEW":
            # 续期失败(通常是支付问题),给用户宽限期
            start_grace_period(original_tx_id)
        case "REFUND":
            # 用户获得退款,撤销内容访问权限
            revoke_access(original_tx_id, product_id)
        case "EXPIRED":
            # 订阅过期,降级用户权限
            downgrade_access(original_tx_id)
        case _:
            print(f"未处理的通知类型: {notification_type}")
    
    return jsonify({"status": "ok"}), 200

最佳实践

  • 服务端的内购验证应该是唯一的数据来源——不要信任客户端上报的交易信息。客户端可能被篡改,或者因为网络问题漏报。
  • 为 Server Notifications V2 设置重试机制:如果你的服务器返回非 200 状态码,Apple 会在接下来的几天内重试发送通知。
  • 使用 originalTransactionId 而不是 transactionId 来关联用户。transactionId 在每次续期时都会变,originalTransactionId 在整个订阅生命周期中保持不变。

还有什么值得关注

  • App Store Server API 支持 sandbox 环境测试,使用不同的 base URL(api.storekit-sandbox.itunes.apple.com)。
  • REVOKE 通知类型会在家庭共享的接收者失去访问权限时触发——如果你支持家庭共享,需要处理这个通知。
  • Apple 提供了一个开源的 Python 库 AppStoreServerLibrary,封装了 JWT 生成、签名验证和 API 调用。
WWDC 2021