Unity In-App Purchase:如何检测订阅是否已过期?
嘿,这个问题我之前帮好几个开发者踩过坑,Unity里处理订阅过期确实得兼顾本地体验和服务器安全,给你梳理一套靠谱的落地方案:
核心思路:双重验证确保订阅状态准确
只靠本地存储肯定不安全(很容易被篡改),所以必须结合「本地缓存+服务器端官方API验证」来实现,下面是具体步骤:
1. 用Unity IAP获取订阅收据并解析到期时间
首先确保你已经在Package Manager里导入了Unity Purchasing包,集成好基础的内购流程。当用户成功购买订阅后,在ProcessPurchase回调里可以拿到订阅的收据,从中解析出到期时间:
public PurchaseProcessingResult ProcessPurchase(PurchaseEventArgs args) { Product purchasedProduct = args.purchasedProduct; // 只处理订阅类型的产品 if (purchasedProduct.definition.type == ProductType.Subscription) { DateTime expirationDate = DateTime.MinValue; // 区分Android和iOS的收据解析逻辑 if (Application.platform == RuntimePlatform.Android) { // 解析Google Play收据 GooglePlayReceipt playReceipt = JsonUtility.FromJson<GooglePlayReceipt>(purchasedProduct.receipt); expirationDate = DateTimeOffset.FromUnixTimeSeconds(playReceipt.expirationTimestampSeconds).DateTime; } else if (Application.platform == RuntimePlatform.IPhonePlayer) { // 解析Apple App Store收据(Unity提供了AppleReceipt工具类) AppleReceipt appleReceipt = AppleReceipt.GetAppleReceiptFromBase64(purchasedProduct.receipt); foreach (var receiptItem in appleReceipt.ReceiptItems) { if (receiptItem.ProductID == purchasedProduct.definition.id) { expirationDate = receiptItem.ExpirationDate; break; } } } // 把到期时间临时存在PlayerPrefs(仅做缓存,不能作为唯一判断依据) if (expirationDate != DateTime.MinValue) { PlayerPrefs.SetString("SubExpiry", expirationDate.ToString("o")); // 用ISO格式避免时区问题 PlayerPrefs.Save(); } // 关键:把收据发送到你的服务器做官方验证,这一步不能省! StartCoroutine(SendReceiptToServer(purchasedProduct.receipt, purchasedProduct.definition.id)); } return PurchaseProcessingResult.Complete; } // 发送收据到服务器的协程示例 IEnumerator SendReceiptToServer(string receipt, string productId) { WWWForm form = new WWWForm(); form.AddField("receipt", receipt); form.AddField("product_id", productId); form.AddField("user_id", PlayerPrefs.GetString("UserID")); // 假设你有用户唯一标识 using (UnityWebRequest request = UnityWebRequest.Post("https://你的服务器地址/api/verify-sub", form)) { yield return request.SendWebRequest(); if (request.result != UnityWebRequest.Result.Success) { Debug.LogError("收据验证请求失败:" + request.error); // 这里可以加重试逻辑,比如隔5分钟再发送一次 } } }
2. 服务器端调用官方API验证订阅状态
这是确保订阅状态真实有效的核心步骤,本地数据随时可能被篡改,只有官方API返回的结果才可信。
针对Google Play的验证逻辑(伪代码示例)
你的服务器需要调用Google Play Developer API,传入订阅的token和产品ID,获取真实的到期时间、自动续费状态:
import requests import json def verify_google_subscription(receipt, product_id, package_name, service_account_key): # 从receipt里解析出Google Play的购买token receipt_json = json.loads(receipt) purchase_token = receipt_json["PurchaseToken"] # 获取OAuth2令牌(用服务账号密钥生成) auth_token = get_google_auth_token(service_account_key) # 调用Play API验证订阅 api_url = f"https://androidpublisher.googleapis.com/androidpublisher/v3/applications/{package_name}/purchases/subscriptions/{product_id}/tokens/{purchase_token}" headers = {"Authorization": f"Bearer {auth_token}"} response = requests.get(api_url, headers=headers) if response.status_code == 200: sub_data = response.json() return { "is_valid": True, "expiry_time": sub_data.get("expiryTimeMillis"), "auto_renew": sub_data.get("autoRenewing"), "cancel_reason": sub_data.get("cancelReason") } return {"is_valid": False}
针对Apple App Store的验证逻辑(伪代码示例)
调用苹果的App Store验证API,解析PKCS7格式的收据:
import requests def verify_apple_subscription(receipt, shared_secret): payload = { "receipt-data": receipt, "password": shared_secret, # 你的App Store共享密钥 "exclude-old-transactions": True } # 测试用沙盒地址:https://sandbox.itunes.apple.com/verifyReceipt,正式环境用生产地址 response = requests.post("https://buy.itunes.apple.com/verifyReceipt", json=payload) if response.status_code == 200: receipt_data = response.json() if receipt_data["status"] == 0: # 取最新的订阅交易记录 latest_transaction = receipt_data["latest_receipt_info"][-1] return { "is_valid": True, "expiry_time": latest_transaction["expires_date_ms"], "auto_renew": latest_transaction["is_auto_renewing"] == "true" } return {"is_valid": False}
服务器验证后,把用户的订阅状态(是否有效、到期时间)存在数据库里,供客户端查询。
3. 客户端定期校验订阅状态
客户端不能只依赖本地缓存,需要定期向服务器请求最新状态:
- 每次App启动时发起校验
- 可以设置定时器(比如每6小时一次)主动拉取状态
- 当用户切换账号、重新登录时也需要校验
// 从服务器校验订阅状态的协程 IEnumerator CheckSubscriptionStatusFromServer() { string userId = PlayerPrefs.GetString("UserID"); UnityWebRequest request = UnityWebRequest.Get($"https://你的服务器地址/api/check-sub?user_id={userId}"); yield return request.SendWebRequest(); if (request.result == UnityWebRequest.Result.Success) { SubscriptionStatus status = JsonUtility.FromJson<SubscriptionStatus>(request.downloadHandler.text); // 转换时间格式(服务器返回ISO字符串) DateTime expiryDate = DateTime.Parse(status.expirationDate); UpdateSubscriptionUI(status.isValid, expiryDate); // 根据状态开启/关闭付费功能 status.isValid ? EnablePremiumFeatures() : DisablePremiumFeatures(); } else { // 网络请求失败,临时用本地缓存的状态(提示用户检查网络) string expiryStr = PlayerPrefs.GetString("SubExpiry"); if (!string.IsNullOrEmpty(expiryStr) && DateTime.TryParse(expiryStr, out DateTime expiryDate)) { bool isStillValid = DateTime.Now < expiryDate; UpdateSubscriptionUI(isStillValid, expiryDate); isStillValid ? EnablePremiumFeatures() : DisablePremiumFeatures(); } else { UpdateSubscriptionUI(false, DateTime.MinValue); DisablePremiumFeatures(); } } } // 定义订阅状态的序列化类 [System.Serializable] public class SubscriptionStatus { public bool isValid; public string expirationDate; // 服务器返回ISO格式字符串 public bool autoRenew; } // 更新UI和功能的辅助方法 void UpdateSubscriptionUI(bool isActive, DateTime expiryDate) { if (isActive) { subStatusText.text = $"订阅有效,到期时间:{expiryDate.ToLocalTime():yyyy-MM-dd HH:mm}"; } else { subStatusText.text = "订阅已过期或未激活"; } }
4. 处理订阅更新/取消的实时通知
为了让状态更及时,你可以让服务器监听官方的实时通知:
- Google Play的Real-Time Developer Notifications:当订阅续费、取消、过期时,谷歌会主动通知你的服务器
- Apple的Server-to-Server Notifications:同样会推送订阅状态变化的事件
服务器收到通知后,立刻更新数据库里的用户订阅状态,这样客户端下次校验就能拿到最新结果。
避坑提醒
- 永远不要完全信任本地存储的到期时间,服务器验证是底线
- 处理时间时统一用UTC格式,避免时区差异导致的判断错误
- 测试时用官方的测试账号:谷歌用测试订阅计划,苹果用沙盒账号,不要产生真实扣费
- 对于订阅自动续费的情况,要提醒用户在对应平台的账号设置里管理订阅
内容的提问来源于stack exchange,提问作者Santosh




