如何使用 Firebase Firestore 创建幂等可调用函数?

How can I make an Idempotent Callable Function with Firebase Firestore?

有时我会从如下所示的可调用函数中获取重复的文档:

const { default: Big } = require('big.js');
const { firestore } = require('firebase-admin');
const functions = require('firebase-functions');
const { createLog } = require('./utils/createLog');
const { payCart } = require('./utils/payCart');
const { unlockCart } = require('./utils/unlockCart');

exports.completeRechargedTransaction = functions.https.onCall(
  async (data, context) => {
    try {
      if (!context.auth) {
        throw new functions.https.HttpsError(
          'unauthenticated',
          'unauthenticated'
        );
      }

      const requiredProperties = [
        'foo',
        'bar',
        'etc'
      ];

      const isDataValid = requiredProperties.every(prop => {
        return Object.keys(data).includes(prop);
      });

      if (!isDataValid) {
        throw new functions.https.HttpsError(
          'failed-precondition',
          'failed-precondition'
        );
      }

      const transactionRef = firestore()
        .collection('transactions')
        .doc(data.transactionID);

      const userRef = firestore().collection('users').doc(data.paidBy.userID);

      let currentTransaction = null;

      await firestore().runTransaction(async transaction => {
        try {
          const transactionSnap = await transaction.get(transactionRef);

          if (!transactionSnap.exists) {
            throw new functions.https.HttpsError(
              'not-found',
              'not-found'
            );
          }

          const transactionData = transactionSnap.data();

          if (transactionData.status !== 'recharged') {
            throw new functions.https.HttpsError(
              'invalid-argument',
              'invalid-argument'
            );
          }

          if (transactionData.type !== 'recharge') {
            throw new functions.https.HttpsError(
              'invalid-argument',
              'invalid-argument'
            );
          }

          if (transactionData.paidBy === null) {
            throw new functions.https.HttpsError(
              'invalid-argument',
              'invalid-argument',
            );
          }

          const userSnap = await transaction.get(userRef);

          if (!userSnap.exists) {
            throw new functions.https.HttpsError(
              'not-found',
              'not-found',
            );
          }

          const userData = userSnap.data();

          const newUserPoints = new Big(userData.points).plus(data.points);

          if (!data.isGoldUser) {
            transaction.update(userRef, {
              points: parseFloat(newUserPoints.toFixed(2))
            });
          }

          currentTransaction = {
            ...data,
            remainingBalance: parseFloat(newUserPoints.toFixed(2)),
            status: 'completed'
          };

          transaction.update(transactionRef, currentTransaction);
        } catch (error) {
          console.error(error);
          throw error;
        }
      });

      const { paymentMethod } = data.rechargeDetails;

      let cashAmount = 0;

      if (paymentMethod && paymentMethod.paymentMethod === 'cash') {
        cashAmount = data.points;
      }

      let cartResponse = null;

      if (
        data.rechargeDetails.isProcessingCart &&
        Boolean(data.paidBy.userID) &&
        !data.isGoldUser
      ) {
        cartResponse = await payCart(context, data.paidBy.userID, cashAmount); 
        // This is the function that does all the writes and for some reason it is getting
        // called twice or thrice in some rare cases, and I'm pretty much sure that 
        // The Angular Client is only calling this function "completeRechargedTransaction " once.
      }

      await createLog({
        message: 'Success',
        createdAt: new Date(),
        type: 'activity',
        collectionName: 'transactions',
        callerID: context.auth.uid || null,
        docID: transactionRef.id
      });

      return {
        code: 200,
        message: 'Success',
        transaction: currentTransaction,
        cartResponse
      };
    } catch (error) {
      console.error(error);

      await unlockCart(data.paidBy.userID);

      await createLog({
        message: error.message,
        createdAt: new Date(),
        type: 'error',
        collectionName: 'transactions',
        callerID: context.auth.uid || null,
        docID: data.transactionID,
        errorSource:
          'completeRechargedTransaction'
      });

      throw error;
    }
  }
);

我阅读了很多 firebase 文档,但我找不到在我的可调用函数上实现幂等性的解决方案,可调用函数中的上下文参数与后台函数和触发器有很大不同,可调用上下文看起来像这样:

https://firebase.google.com/docs/reference/functions/providers_https_.callablecontext

我确实找到了一篇有用的博文来使用 firebase 触发器实现幂等性:

Cloud Functions pro tips: Building idempotent functions

但我不完全理解这种方法,因为我认为它假设文档写入是在客户端(即前端应用程序)上进行的,我真的不认为这是一个好方法,因为它太依赖了在客户端上,我也担心安全问题。

所以,是的,我想知道是否有一种方法可以在可调用函数上实现幂等性,我需要类似 EventID 的东西,但要让可调用函数在我的应用程序和第三方 API 上安全地实现支付,比如条纹。

如果您能给我任何帮助或提示,我将不胜感激。

幂等函数的使用主要适用于响应a file uploaded to Cloud Storage or document added to Firestore. In these cases, the event triggers the function to be executed, and if the function succeeds, all is well. However, if the function fails, it will get retried automatically which leads to the problems discussed in the blog post you linked.

等事件的自动触发的Cloud Functions

对于用户触发的云函数(HTTPS Event or Callable 云函数),这些不会自动重试。这些函数的调用者可以选择处理任何错误以及是否由客户端再次调用该函数来重试。

由于这些用户触发的函数仅由您的客户端代码执行,因此您应该检查以确保 completeRechargedTransaction() 未被多次调用。测试此方法的一种方法是在调用函数之前为事件 ID 提供您自己的值,如下所示:

// using a firebase push ID as a UUID
// could also use someFirestoreCollectionReference.doc().id or uuid()
const eventId = firebase.database.ref().push().key;

completeRechargedTransaction({
  eventId,
  /* ... other data ... */
})
  .then(console.log.bind(null, "Successfully completed recharged transaction:"))
  .catch(console.error.bind(null, "Failed to complete recharged transaction:"));

注意:函数被客户端调用两次的最常见方式之一是重新呈现,您已更新状态以显示“正在加载”消息,并且然后您将第二次调用该函数。作为 React 的示例,您将确保您的数据库调用包含在它自己的 useEffect() 调用中。