如何使用 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,type: 'error',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 Storagedocument added to Firestore等事件的自动触发的Cloud Functions。在这些情况下,事件触发要执行的函数,如果函数成功,则一切正常。但是,如果该函数失败,则会自动重试,从而导致blog post you linked 中讨论的问题。

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

由于这些用户触发的函数仅由您的客户端代码执行,您应该检查以确保 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() 调用中。