PAYATHON 2026

在 Google Firebase 中集成 payment gateway

支付阿杰

结论

Firebase Function URL 公开可访问本身没有问题。真正要避免的是把支付页面跳转到 return URL 视为支付成功的证明。

支付流程可以分为三部分:

  1. 客户端调用受身份验证保护的 Firebase Function,由服务端创建支付订单。
  2. 支付网关通过服务器之间的 webhook/回调通知支付结果。Firebase Function 验证签名后再更新订单。
  3. return URL 只负责把用户带回 Web 页面或 App,并展示订单状态,不能直接把订单改为”已支付”。

如果支付网关不提供签名回调,Firebase Function 收到通知后必须使用服务端密钥查询网关的订单 API,核实支付状态。

为什么不能信任 return URL

return URL 只是浏览器或 App 的跳转目标。攻击者可以:

  • 手动访问这个 URL;
  • 修改查询参数,例如伪造 status=success;
  • 重放之前的成功请求;
  • 在支付尚未完成时关闭或拦截页面;
  • 支付完成后不再跳回应用。

所以下面的逻辑并不安全:

/payment-return?orderId=123&status=success
// 错误示例:信任客户端传来的状态
await updateOrder(orderId, { paid: true });

即使这个 URL 很难猜,也不能用它确认款项已经到账。支付结果必须来自经过密码学验证的 webhook,或由服务端向支付网关 API 查询得出。

推荐的支付流程

1. 由服务端创建支付订单

客户端只提交商品或购物车标识。商品价格、币种、折扣和最终金额都要由服务端重新计算,不能采用客户端传来的最终金额。

import { onCall, HttpsError } from "firebase-functions/v2/https";
import { defineSecret } from "firebase-functions/params";

const paymentApiKey = defineSecret("PAYMENT_API_KEY");

export const createPayment = onCall(
  {
    secrets: [paymentApiKey],
    enforceAppCheck: true,
  },
  async (request) => {
    if (!request.auth) {
      throw new HttpsError("unauthenticated", "Authentication required");
    }

    const { cartId } = request.data;

    // 从 Firestore 读取购物车,并在服务端计算价格。
    const order = await createPendingOrder({
      uid: request.auth.uid,
      cartId,
    });

    // 以下接口名称仅为示意,应替换为支付网关的官方 SDK/API。
    const payment = await paymentProvider.createPayment({
      merchantOrderId: order.id,
      amount: order.amount,
      currency: order.currency,
      returnUrl: `https://shop.example.com/payment/return?orderId=${order.id}`,
      webhookUrl: "https://REGION-PROJECT_ID.cloudfunctions.net/paymentWebhook",
    });

    await saveProviderPaymentId(order.id, payment.id);

    return {
      orderId: order.id,
      checkoutUrl: payment.checkoutUrl,
    };
  }
);

PAYMENT_API_KEY、webhook secret 等凭据应保存在 Secret Manager 或 Firebase Functions secrets 中,不能写入 Web 或移动端代码。

enforceAppCheck 能减少非官方客户端的滥用,但无法代替 Firebase Authentication、服务端金额校验和支付签名验证。

2. 用 webhook 接收支付结果

支付网关通常会向商户提供的 HTTPS 地址发送异步通知。这个地址可以是公开的 Firebase HTTP Function,因为支付网关需要访问它。

安全性取决于签名验证:

import { onRequest } from "firebase-functions/v2/https";
import { defineSecret } from "firebase-functions/params";

const webhookSecret = defineSecret("PAYMENT_WEBHOOK_SECRET");

export const paymentWebhook = onRequest(
  { secrets: [webhookSecret] },
  async (req, res) => {
    if (req.method !== "POST") {
      res.status(405).send("Method Not Allowed");
      return;
    }

    try {
      const signature = req.header("x-payment-signature");

      if (!signature) {
        res.status(400).send("Missing signature");
        return;
      }

      // 必须使用网关官方要求的原始请求体、签名算法和 SDK。
      // 不要自行假设签名字段或 JSON 序列化规则。
      const event = paymentProvider.verifyWebhook({
        rawBody: req.rawBody,
        signature,
        secret: webhookSecret.value(),
      });

      if (event.type === "payment.succeeded") {
        await markOrderPaidIdempotently({
          providerEventId: event.id,
          providerPaymentId: event.paymentId,
          merchantOrderId: event.merchantOrderId,
          paidAmount: event.amount,
          currency: event.currency,
        });
      }

      res.status(200).send("OK");
    } catch (error) {
      res.status(400).send("Invalid webhook");
    }
  }
);

不同支付网关的签名规范差别很大。有些使用 HMAC,有些使用公钥签名,还有些要求把时间戳和原始请求体纳入计算。实现时必须遵循支付网关的官方 SDK 或文档,不能直接把示例中的 header 名称用于生产环境。

如果网关没有可靠的 webhook 签名机制,可以在回调到达后主动核验:

const payment = await paymentProvider.getPayment(providerPaymentId);

if (
  payment.status === "paid" &&
  payment.merchantOrderId === expectedOrderId &&
  payment.amount === expectedAmount &&
  payment.currency === expectedCurrency
) {
  await markOrderPaidIdempotently(/* ... */);
}

查询所用的 API 密钥只能保存在服务端。

3. 将 return URL 用于页面跳转

Web 应用可以把 return URL 设为自己的支付结果页面。移动端可以根据支付网关提供的能力,使用 Universal Links、App Links 或自定义 URL scheme。

返回页面只读取订单状态:

const order = await getOrder(orderId);

switch (order.status) {
  case "paid":
    showSuccess();
    break;
  case "failed":
    showFailure();
    break;
  default:
    showProcessing();
}

用户跳回页面时,webhook 可能还没有到达。页面需要支持显示”处理中”,并通过 Firestore 实时监听、轮询受保护的订单查询接口,或者提示用户稍后刷新。

更新订单时必须校验的内容

签名正确,只能说明事件通过了签名验证,并不表示其中的业务数据符合预期。处理成功事件时还要检查:

  • merchantOrderId 是否对应本系统中的订单;
  • 支付网关的 payment ID 是否与创建订单时保存的一致;
  • 实付金额和币种是否完全匹配;
  • 订单是否属于正确的商户账户或环境;
  • 支付状态是否为最终成功状态,而不是 pending、authorized 等中间状态;
  • 事件是否已经处理过;
  • 已取消或已退款的订单是否允许进行这次状态转换。

金额最好使用最小货币单位的整数,例如分,不要使用浮点数:

const expectedAmount = 1999; // 19.99,单位为分

防止重复通知和并发问题

支付网关通常会重试 webhook,因此同一个事件可能被发送多次。处理逻辑必须具备幂等性。可以把网关事件 ID 保存到 Firestore,并在同一个事务中完成检查和更新。

await db.runTransaction(async (tx) => {
  const eventRef = db.collection("paymentEvents").doc(providerEventId);
  const orderRef = db.collection("orders").doc(merchantOrderId);

  const [eventSnapshot, orderSnapshot] = await Promise.all([
    tx.get(eventRef),
    tx.get(orderRef),
  ]);

  if (eventSnapshot.exists) {
    return;
  }

  if (!orderSnapshot.exists) {
    throw new Error("Order not found");
  }

  const order = orderSnapshot.data();

  if (
    order?.amount !== paidAmount ||
    order?.currency !== currency ||
    order?.providerPaymentId !== providerPaymentId
  ) {
    throw new Error("Payment data mismatch");
  }

  tx.create(eventRef, {
    receivedAt: new Date(),
    type: "payment.succeeded",
  });

  if (order.status !== "paid") {
    tx.update(orderRef, {
      status: "paid",
      paidAt: new Date(),
    });
  }
});

生产代码可以使用 Firestore 服务端时间戳,避免依赖函数实例的本地时间。

其他注意事项

  • 不要把支付网关私钥、API secret 或 webhook secret 放入 Firebase Remote Config、Web bundle 或移动端安装包。
  • 不要依靠 CORS 保护 webhook。CORS 是浏览器机制,无法阻止攻击者直接发送 HTTP 请求。
  • 不要要求 webhook 携带 Firebase 用户登录令牌。支付网关不是 Firebase 用户,通常无法生成这种令牌。
  • IP allowlist 可以作为附加措施,但不能代替签名验证,而且网关的出口 IP 可能变化。
  • webhook 应尽快返回成功响应。耗时的发货、通知或积分处理可以交给队列或其他后台任务。
  • 测试环境和生产环境应分别配置 API 密钥、webhook secret 与订单数据,避免混用。
  • 记录网关事件 ID、订单 ID、校验结果和状态变化,但不要记录银行卡信息、完整密钥或其他敏感支付数据。
  • 退款、拒付和支付撤销也应通过验证后的 webhook 更新,不能只处理支付成功事件。

最终原则很简单:客户端跳转只决定用户看到什么,经过服务端验证的支付事件才决定订单状态。

备注:内容仅供参考。