iOS应用集成PayPal Payouts与Firebase云函数报错排查
PayPal Payouts集成Firebase Cloud Functions报错排查
我正在开发一款iOS应用,需通过PayPal向玩家发放奖金,目前通过Firebase Cloud Functions集成PayPal Payouts功能,但发起提现时应用提示“Unexpected error occurred.”,云函数日志仅显示存在问题但无明确解决方案。以下是客户端与服务端代码,同时咨询三个问题:
- 请求payload或云函数的调用方式是否存在错误?
- 使用GTMSessionFetcher进行此类请求有哪些已知问题或最佳实践?
- 该“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
相关产品推荐
相关产品推荐

