PAYATHON 2026

如何在 flutter 中获取用户当前订阅状态并验证有效性?

支付老李

结论

不要在 Flutter 客户端直接调用 Google Play Developer API。建议按下面的方式处理:

  1. Flutter 完成购买后,将 purchaseToken 发送给可信服务端。
  2. 服务端通过 Google Play Developer API 验证购买,并将 purchaseToken 与 Firebase uid 绑定。
  3. Real-time developer notifications(RTDN)到达 Cloud Pub/Sub 后,由 Cloud Functions 或 Cloud Run 再次查询 Google Play,并更新 Firestore 中的订阅权益。
  4. 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 与 Firebase uid。
  • 取消自动续订不代表订阅立即失效,需要结合 subscriptionState 和 expiryTime 判断。
  • 不要信任客户端提交的价格、到期时间、订阅状态或用户标识。
  • 购买确认和订阅验证是两件不同的事。应通过 completePurchase 或服务端确认购买,避免购买因未及时确认而被退款。
  • 建议增加定期校验任务,重新检查仍标记为有效或近期到期的订阅,以弥补极少数通知丢失、配置错误或处理失败的情况。
  • Firestore 保存的是验证后的缓存状态,Android 订阅状态仍以 Google Play Developer API 为准。

备注:内容仅供参考。