在服务端管理应用内购买
Manage in-app purchases on your server
2021年6月8日
一句话判断
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 调用。