You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

iOS应用集成PayPal Payouts与Firebase云函数报错排查

PayPal Payouts集成Firebase Cloud Functions报错排查

我正在开发一款iOS应用,需通过PayPal向玩家发放奖金,目前通过Firebase Cloud Functions集成PayPal Payouts功能,但发起提现时应用提示“Unexpected error occurred.”,云函数日志仅显示存在问题但无明确解决方案。以下是客户端与服务端代码,同时咨询三个问题:

  1. 请求payload或云函数的调用方式是否存在错误?
  2. 使用GTMSessionFetcher进行此类请求有哪些已知问题或最佳实践?
  3. 该“Unexpected error occurred.”提示的可能原因是什么?

客户端代码(Swift + GTMSessionFetcher)

import UIKit
import GTMSessionFetcherCore
import FirebaseAuth
import FirebaseFirestore
import FirebaseFunctions

class WinningsViewController: UIViewController {
    
    var balanceLabel: UILabel!
    var withdrawPaypalButton: UIButton!
    var withdrawVenmoButton: UIButton!
    var balanceListener: ListenerRegistration?
    lazy var functions = Functions.functions()
    var currentFetcher: GTMSessionFetcher?
    
    override func viewDidLoad() {
        super.viewDidLoad()
        setupUI()
        setupBalanceListener()
    }
    
    deinit {
        balanceListener?.remove()
    }
    
    func setupUI() {
        // UI setup code...
    }
    
    func setupBalanceListener() {
        guard let user = Auth.auth().currentUser else { return }
        let userUID = user.uid
        let db = Firestore.firestore()
        balanceListener = db.collection("playerBalances").document(userUID).addSnapshotListener { [weak self] (document, error) in
            if let document = document, document.exists, let balance = document.data()?["balance"] as? Double {
                self?.balanceLabel.text = String(format: "Balance: $%.2f", balance)
                self?.updateButtonState(balance: balance)
            }
        }
    }
    
    func updateButtonState(balance: Double) {
        let isEnabled = balance > 0.0
        withdrawPaypalButton.isEnabled = isEnabled
        withdrawVenmoButton.isEnabled = isEnabled
    }
    
    @objc func withdrawPaypalButtonTapped() {
        let alertController = UIAlertController(title: "Withdraw with PayPal", message: "Please enter your PayPal email address", preferredStyle: .alert)
        alertController.addTextField { textField in
            textField.placeholder = "PayPal email"
        }
        let withdrawAction = UIAlertAction(title: "Withdraw", style: .default) { [weak self] _ in
            guard let email = alertController.textFields?.first?.text, !email.isEmpty else { return }
            self?.initiatePaypalPayout(recipientEmail: email)
        }
        alertController.addAction(withdrawAction)
        alertController.addAction(UIAlertAction(title: "Cancel", style: .cancel, handler: nil))
        present(alertController, animated: true, completion: nil)
    }
    
    func initiatePaypalPayout(recipientEmail: String) {
        guard let user = Auth.auth().currentUser else { return }
        currentFetcher?.stopFetching()
        let db = Firestore.firestore()
        let userUID = user.uid
        db.collection("playerBalances").document(userUID).getDocument { [weak self] (document, error) in
            if let document = document, document.exists, let balance = document.data()?["balance"] as? Double {
                guard let url = URL(string: "https://us-central1-your-project-id.cloudfunctions.net/withdrawPayPal") else { return }
                var request = URLRequest(url: url)
                request.httpMethod = "POST"
                request.setValue("application/json", forHTTPHeaderField: "Content-Type")
                let payload: [String: Any] = ["data": ["recipientEmail": recipientEmail, "amount": balance]]
                do {
                    request.httpBody = try JSONSerialization.data(withJSONObject: payload, options: [])
                } catch {
                    return
                }
                self?.currentFetcher = GTMSessionFetcher(request: request)
                self?.currentFetcher?.beginFetch { data, error in
                    self?.currentFetcher = nil
                    if let error = error as NSError? {
                        self?.showPaymentErrorAlert(error: error.localizedDescription)
                    } else if let data = data,
                              let result = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any],
                              let success = result["success"] as? Bool, success {
                        self?.showPaymentSuccessAlert()
                    } else {
                        self?.showPaymentErrorAlert(error: "Unexpected error occurred.")
                    }
                }
            }
        }
    }
    
    func showPaymentSuccessAlert() {
        let alertController = UIAlertController(title: "Payment Successful", message: "Your payout has been successfully initiated!", preferredStyle: .alert)
        alertController.addAction(UIAlertAction(title: "OK", style: .default, handler: nil))
        present(alertController, animated: true, completion: nil)
    }
    
    func showPaymentErrorAlert(error: String) {
        let alertController = UIAlertController(title: "Payment Error", message: "There was an error initiating your payout: \(error)", preferredStyle: .alert)
        alertController.addAction(UIAlertAction(title: "OK", style: .default, handler: nil))
        present(alertController, animated: true, completion: nil)
    }
}

服务端代码

index.js

const functions = require('firebase-functions');
const admin = require('firebase-admin');
const cors = require('cors')({ origin: true });
const { withdrawPayPal } = require('./paypalPayout');

admin.initializeApp();
const db = admin.firestore();

exports.withdrawPayPal = functions.https.onCall(async (data, context) => {
    const response = {
        statusCode: 200,
        headers: {
            "Access-Control-Allow-Origin": "*",
            "Access-Control-Allow-Methods": "GET, POST, OPTIONS, PUT, DELETE",
            "Access-Control-Allow-Headers": "Content-Type, Authorization"
        },
        body: null
    };

    try {
        const result = await withdrawPayPal(data, context);
        response.body = JSON.stringify(result);
        return response;
    } catch (error) {
        response.statusCode = 500;
        response.body = JSON.stringify({ error: error.message });
        return response;
    }
});

paypalPayout.js

const functions = require('firebase-functions');
const admin = require('firebase-admin');
const paypal = require('@paypal/payouts-sdk');

const clientId = 'YOUR_PAYPAL_CLIENT_ID';
const clientSecret = 'YOUR_PAYPAL_CLIENT_SECRET';
const environment = new paypal.core.SandboxEnvironment(clientId, clientSecret);
const client = new paypal.core.PayPalHttpClient(environment);

const getPayPalAccessToken = async () => {
    try {
        const response = await client.fetchAccessToken();
        return response.result.access_token;
    } catch (error) {
        console.error('Error getting PayPal access token:', error);
        throw new functions.https.HttpsError('unknown', 'Failed to get PayPal access token.');
    }
};

const createPayout = async (recipientEmail, amount) => {
    const requestBody = {
        "sender_batch_header": {
            "sender_batch_id": `Payouts_${new Date().getTime()}`,
            "email_subject": "You have a payout!",
            "email_message": "You have received a payout! Thanks for using our service!"
        },
        "items": [{
            "recipient_type": "EMAIL",
            "amount": {
                "value": amount.toFixed(2),
                "currency": "USD"
            },
            "receiver": recipientEmail,
            "note": "Thank you for your patronage!",
            "sender_item_id": `Item_${new Date().getTime()}`
        }]
    };

    let request = new paypal.payouts.PayoutsPostRequest();
    request.requestBody(requestBody);

    try {
        const response = await client.execute(request);
        return response.result;
    } catch (error) {
        console.error("PayPal Payout Error:", error);
        throw new functions.https.HttpsError('unknown', 'Error creating payout', error);
    }
};

exports.withdrawPayPal = functions.https.onCall(async (data, context) => {
    if (!context.auth) {
        throw new functions.https.HttpsError('unauthenticated', 'The function must be called while authenticated.');
    }

    const { recipientEmail, amount } = data.data;  // Adjusted to retrieve from data object
    if (!recipientEmail || !amount) {
        throw new functions.https.HttpsError('invalid-argument', 'The function must be called with two arguments: recipientEmail and amount.');
    }

    const userId = context.auth.uid;
    const db = admin.firestore();
    const userBalanceRef = db.collection('playerBalances').doc(userId);
    const userBalanceDoc = await userBalanceRef.get();

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

    const userBalance = parseFloat(userBalanceDoc.data().balance);
    const payoutAmount = parseFloat(amount);

    if (userBalance < payoutAmount) {
        throw new functions.https.HttpsError('failed-precondition', 'Insufficient balance');
    }

    try {
        const payoutResponse = await createPayout(recipientEmail, payoutAmount);

        const batchStatus = payoutResponse.batch_header.batch_status;
        const batchId = payoutResponse.batch_header.payout_batch_id;

        if (batchStatus === 'PENDING' || batchStatus === 'SUCCESS') {
            await userBalanceRef.update({
                balance: userBalance - payoutAmount
            });
            return { success: true, message: 'Payout processed successfully!', batchId, batchStatus };
        } else {
            throw new functions.https.HttpsError('unknown', 'Payout failed with status: ' + batchStatus);
        }
    } catch (error) {
        throw new functions.https.HttpsError('unknown', 'Payout failed.', error.message);
    }
});

问题解答

1. 请求payload与云函数调用方式的错误

核心问题:HTTP请求调用onCall函数的格式不匹配

Firebase的functions.https.onCall是专用Callable函数,它有固定的请求/响应规范,你的代码存在两处关键错误:

  • 客户端:手动封装的{"data": {...}}payload是多余的——Callable函数会自动将请求体的顶层字段解析为data参数,不需要额外嵌套
  • 服务端index.js:返回了类似HTTP响应的{statusCode, headers, body}结构,这完全不符合Callable函数的要求,它应该直接返回JSON对象,Firebase会自动处理响应格式

修复步骤:

  • 客户端:修改payload结构,去掉外层data键,建议直接用Firebase Functions SDK调用(更适配Callable函数):
    func initiatePaypalPayout(recipientEmail: String) {
        guard let user = Auth.auth().currentUser else { return }
        let db = Firestore.firestore()
        let userUID = user.uid
        db.collection("playerBalances").document(userUID).getDocument { [weak self] (document, error) in
            if let document = document, document.exists, let balance = document.data()?["balance"] as? Double {
                self?.functions.httpsCallable("withdrawPayPal").call(["recipientEmail": recipientEmail, "amount": balance]) { result, error in
                    if let error = error as NSError? {
                        self?.showPaymentErrorAlert(error: error.localizedDescription)
                        return
                    }
                    if let resultData = result?.data as? [String: Any], let success = resultData["success"] as? Bool, success {
                        self?.showPaymentSuccessAlert()
                    } else {
                        self?.showPaymentErrorAlert(error: "Unexpected error occurred.")
                    }
                }
            }
        }
    }
    
  • 服务端index.js:删除HTTP响应封装,直接返回结果或抛出错误:
    exports.withdrawPayPal = functions.https.onCall(async (data, context) => {
        try {
            return await withdrawPayPal(data, context);
        } catch (error) {
            throw new functions.https.HttpsError('unknown', error.message);
        }
    });
    
  • 服务端paypalPayout.js:修正data取值,去掉多余的.data:
    const { recipientEmail, amount } = data;
    

2. GTMSessionFetcher的已知问题与最佳实践

如果坚持用GTMSessionFetcher调用Callable函数,需注意以下几点:

  • 必须遵循Callable函数规范:
    • 请求头要带Authorization: Bearer <Firebase ID Token>,否则服务端context.auth会为null
    • 请求体必须是{"data": {...}}格式(这是Callable函数的要求)
  • 已知问题:
    • 默认不自动处理身份验证,需手动获取并附加ID Token
    • 错误处理需要自己解析Firebase返回的特定错误格式,不如SDK直观
  • 最佳实践:
    • 优先用Firebase Functions SDK,避免手动处理请求格式和身份验证
    • 若必须用GTMSessionFetcher,先获取ID Token并附加到请求头:
      Auth.auth().currentUser?.getIDToken(completion: { token, error in
          guard let token = token else { return }
          request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
      })
      
    • 开启日志调试:
      self?.currentFetcher?.logger = GTMLogger.shared()
      

3. “Unexpected error occurred.”的可能原因

这个提示触发的条件是:请求无网络错误,但返回的JSON无法解析出success: true,常见原因包括:

  • 服务端返回格式错误:比如index.js中返回的{statusCode, body}结构,客户端解析时找不到success字段
  • 身份验证失败:服务端context.auth为null,抛出unauthenticated错误,但客户端未正确解析该错误,进入else分支
  • PayPal API调用失败:比如客户端ID/密钥错误、沙箱环境配置问题、金额格式错误(amount.toFixed(2)转数字时出错),但服务端错误未正确传递到客户端
  • 数据解析失败:服务端返回的JSON格式异常,客户端无法解析为[String: Any]

内容的提问来源于stack exchange,提问作者TheGuyWhoCantCode123

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.20 14:07:33