如何在 flutter 中获取用户当前订阅状态并验证有效性?
结论
不要在 Flutter 客户端直接调用 Google Play Developer API。建议按下面的方式处理:
- Flutter 完成购买后,将
purchaseToken发送给可信服务端。 - 服务端通过 Google Play Developer API 验证购买,并将
purchaseToken与 Firebaseuid绑定。 - Real-time developer notifications(RTDN)到达 Cloud Pub/Sub 后,由 Cloud Functions 或 Cloud Run 再次查询 Google Play,并更新 Firestore 中的订阅权益。
- Flutter 只读取 Firestore 中由服务端生成的权益状态,不在客户端判断订阅是否有效。
Cloud Firestore 只是数据库,不能直接调用 Google Play API。验证逻辑需要由 Cloud Functions、Cloud Run 或其他可信服务端执行。
新项目优先使用:
purchases.subscriptionsv2.get
不要使用旧版:
purchases.subscriptions.get
subscriptionsv2.get 返回的订阅生命周期状态更完整,查询时也不需要额外提供 subscriptionId。
推荐的数据流程
Flutter 购买
↓
把 purchaseToken 发送给 Cloud Functions
↓
Cloud Functions 调用 subscriptionsv2.get
↓
验证 packageName、状态、到期时间
↓
将 token 绑定到 Firebase uid
↓
更新 Firestore 用户权益
后续发生续订、取消、宽限期、账号保留或到期时:
Google Play
↓ RTDN
Cloud Pub/Sub
↓
Cloud Function
↓
使用 purchaseToken 调用 subscriptionsv2.get
↓
重新计算并更新 Firestore 权益
↓
Flutter 实时读取最新结果
RTDN 只表示订阅发生了变化,不能作为最终的订阅状态。收到通知后,仍要查询 Google Play Developer API。
服务端调用 Google Play Developer API
下面是 Node.js 示例。Cloud Functions 的运行账号需要在 Google Play Console 中获得读取订单和订阅数据的权限,同时还要启用 Android Publisher API。
const { google } = require("googleapis");
const PACKAGE_NAME = "com.example.app";
async function getGooglePlaySubscription(purchaseToken) {
const auth = new google.auth.GoogleAuth({
scopes: ["https://www.googleapis.com/auth/androidpublisher"],
});
const androidPublisher = google.androidpublisher({
version: "v3",
auth,
});
const response =
await androidPublisher.purchases.subscriptionsv2.get({
packageName: PACKAGE_NAME,
token: purchaseToken,
});
return response.data;
}
在 Cloud Functions 或 Cloud Run 中,建议使用 Application Default Credentials。不要把服务账号私钥放进 Flutter 应用,也不要将其提交到代码仓库。
如何判断订阅是否有效
subscriptionsv2.get 返回的主要字段包括:
subscriptionState
acknowledgementState
lineItems[].expiryTime
lineItems[].productId
可以按照业务规则判断用户是否拥有付费权益:
function calculateEntitlement(subscription) {
const state = subscription.subscriptionState;
const allowedStates = new Set([
"SUBSCRIPTION_STATE_ACTIVE",
"SUBSCRIPTION_STATE_IN_GRACE_PERIOD",
"SUBSCRIPTION_STATE_CANCELED",
]);
const latestExpiry = (subscription.lineItems ?? [])
.map((item) => item.expiryTime)
.filter(Boolean)
.sort()
.at(-1);
const expiresAt = latestExpiry ? new Date(latestExpiry) : null;
const notExpired =
expiresAt !== null && expiresAt.getTime() > Date.now();
return {
active: allowedStates.has(state) && notExpired,
state,
expiresAt,
productIds: (subscription.lineItems ?? [])
.map((item) => item.productId)
.filter(Boolean),
};
}
示例将 SUBSCRIPTION_STATE_CANCELED 视为可能仍有权益,因为用户关闭自动续订后,通常还能使用到当前计费周期结束。宽限期内是否继续开放付费功能取决于业务策略,这个示例选择继续开放。
以下状态通常不应授予权益:
SUBSCRIPTION_STATE_PENDING
SUBSCRIPTION_STATE_PAUSED
SUBSCRIPTION_STATE_ON_HOLD
SUBSCRIPTION_STATE_EXPIRED
不能只检查 expiryTime。订阅处于暂停或账号保留等状态时,到期时间无法完整反映当前权益。
处理首次购买
在 Android 上,可以从 Flutter 的 PurchaseDetails.verificationData.serverVerificationData 取得需要交给服务端验证的数据。不能只根据客户端的 PurchaseStatus.purchased 开放永久权益。
void listenToPurchases(InAppPurchase inAppPurchase) {
inAppPurchase.purchaseStream.listen((purchases) async {
for (final purchase in purchases) {
if (purchase.status == PurchaseStatus.purchased ||
purchase.status == PurchaseStatus.restored) {
final purchaseToken =
purchase.verificationData.serverVerificationData;
final verified = await verifyOnBackend(
purchaseToken: purchaseToken,
productId: purchase.productID,
);
if (!verified) {
// 不授予本地付费权益,等待服务端状态。
continue;
}
}
if (purchase.pendingCompletePurchase) {
await inAppPurchase.completePurchase(purchase);
}
}
});
}
verifyOnBackend 应调用要求 Firebase Authentication 的 HTTPS Callable Function 或普通 HTTPS API:
Future<bool> verifyOnBackend({
required String purchaseToken,
required String productId,
}) async {
final callable =
FirebaseFunctions.instance.httpsCallable("verifyPlaySubscription");
final result = await callable.call({
"purchaseToken": purchaseToken,
"productId": productId,
});
return result.data["active"] == true;
}
服务端必须从 Firebase 身份令牌中取得 uid,不能接受客户端自行提交的 uid。
首次验证并写入 Firestore
const { onCall, HttpsError } =
require("firebase-functions/v2/https");
const admin = require("firebase-admin");
admin.initializeApp();
exports.verifyPlaySubscription = onCall(async (request) => {
if (!request.auth) {
throw new HttpsError(
"unauthenticated",
"Authentication is required."
);
}
const purchaseToken = request.data?.purchaseToken;
if (typeof purchaseToken !== "string" || !purchaseToken) {
throw new HttpsError(
"invalid-argument",
"purchaseToken is required."
);
}
let subscription;
try {
subscription =
await getGooglePlaySubscription(purchaseToken);
} catch (error) {
console.error("Google Play verification failed", error);
throw new HttpsError(
"failed-precondition",
"The subscription could not be verified."
);
}
const entitlement = calculateEntitlement(subscription);
const uid = request.auth.uid;
await admin.firestore()
.collection("users")
.doc(uid)
.collection("entitlements")
.doc("premium")
.set({
platform: "google_play",
active: entitlement.active,
subscriptionState: entitlement.state,
expiresAt: entitlement.expiresAt
? admin.firestore.Timestamp.fromDate(
entitlement.expiresAt
)
: null,
productIds: entitlement.productIds,
purchaseToken,
updatedAt:
admin.firestore.FieldValue.serverTimestamp(),
}, { merge: true });
// 单独维护 token 到用户的映射,供 RTDN 使用。
await admin.firestore()
.collection("playPurchaseTokens")
.doc(purchaseToken)
.set({
uid,
updatedAt:
admin.firestore.FieldValue.serverTimestamp(),
}, { merge: true });
return {
active: entitlement.active,
state: entitlement.state,
expiresAt:
entitlement.expiresAt?.toISOString() ?? null,
};
});
在生产环境中,建议先对原始 purchaseToken 做不可逆哈希,再将结果用作文档 ID,以减少敏感标识暴露。
还要防止同一个 purchaseToken 被绑定到多个 Firebase 用户。绑定操作最好放在 Firestore transaction 中完成。
处理 Pub/Sub 的实时通知
RTDN 的 Pub/Sub 消息是经过 Base64 编码的 JSON。取得订阅通知中的 purchaseToken 后,用它重新查询订阅:
const { onMessagePublished } =
require("firebase-functions/v2/pubsub");
exports.handlePlaySubscriptionNotification =
onMessagePublished("play-subscription-events", async (event) => {
const encodedData = event.data.message.data;
const payload = JSON.parse(
Buffer.from(encodedData, "base64").toString("utf8")
);
const notification = payload.subscriptionNotification;
if (!notification?.purchaseToken) {
// 可能是测试通知或其他商品类型的通知。
return;
}
if (payload.packageName !== PACKAGE_NAME) {
throw new Error("Unexpected package name");
}
const purchaseToken = notification.purchaseToken;
const tokenDocument = await admin.firestore()
.collection("playPurchaseTokens")
.doc(purchaseToken)
.get();
if (!tokenDocument.exists) {
console.warn("No user is associated with this token");
return;
}
const { uid } = tokenDocument.data();
const subscription =
await getGooglePlaySubscription(purchaseToken);
const entitlement =
calculateEntitlement(subscription);
await admin.firestore()
.collection("users")
.doc(uid)
.collection("entitlements")
.doc("premium")
.set({
platform: "google_play",
active: entitlement.active,
subscriptionState: entitlement.state,
expiresAt: entitlement.expiresAt
? admin.firestore.Timestamp.fromDate(
entitlement.expiresAt
)
: null,
productIds: entitlement.productIds,
lastNotificationType:
notification.notificationType ?? null,
updatedAt:
admin.firestore.FieldValue.serverTimestamp(),
}, { merge: true });
});
Pub/Sub 可能重复投递消息,消息也可能延迟或乱序到达,所以处理函数必须保持幂等。不要根据 notificationType 对现有状态做简单的增减修改。每次收到通知后直接查询 Google Play,再用查询结果覆盖缓存状态更可靠。
Flutter 读取订阅权益
Flutter 不必反复调用 Google Play,只需监听服务端维护的权益文档:
Stream<bool> watchPremiumEntitlement(String uid) {
return FirebaseFirestore.instance
.collection("users")
.doc(uid)
.collection("entitlements")
.doc("premium")
.snapshots()
.map((snapshot) {
final data = snapshot.data();
return data?["active"] == true;
});
}
Firestore Security Rules 应禁止客户端写入权益字段:
match /users/{userId}/entitlements/{entitlementId} {
allow read: if request.auth != null
&& request.auth.uid == userId;
allow write: if false;
}
服务端 Admin SDK 不受客户端 Security Rules 限制,因此仍可更新这些文档。
如果必须使用旧接口
旧接口的调用方式如下:
const response =
await androidPublisher.purchases.subscriptions.get({
packageName: PACKAGE_NAME,
subscriptionId: productId,
token: purchaseToken,
});
它会返回 expiryTimeMillis、paymentState、autoRenewing 等字段。对于新订阅系统、基础方案和优惠,subscriptionsv2.get 通常更合适。
无论使用哪个接口,都必须由服务端发起调用。Google Play API 凭据绝不能放在 Flutter 客户端中。
还需要注意的事项
- RTDN 不能替代首次购买验证。首次购买时也要立即将 token 发送到服务端。
- RTDN 不会告诉你用户是谁,因此首次购买时必须可信地绑定
purchaseToken与 Firebaseuid。 - 取消自动续订不代表订阅立即失效,需要结合
subscriptionState和expiryTime判断。 - 不要信任客户端提交的价格、到期时间、订阅状态或用户标识。
- 购买确认和订阅验证是两件不同的事。应通过
completePurchase或服务端确认购买,避免购买因未及时确认而被退款。 - 建议增加定期校验任务,重新检查仍标记为有效或近期到期的订阅,以弥补极少数通知丢失、配置错误或处理失败的情况。
- Firestore 保存的是验证后的缓存状态,Android 订阅状态仍以 Google Play Developer API 为准。
备注:内容仅供参考。
