PAYATHON 2026

移动应用支付成功后如何发送 Push notification

支付小周

明确结论

支付成功后的 Push notification 应由服务端发送,不能由移动应用根据客户端的支付结果直接触发。

正确的流程是:支付平台通过异步回调(Webhook)通知业务服务端。服务端验证签名、核对金额并进行幂等处理,将订单状态更新为“已支付”,然后通过消息队列或后台任务调用 FCM、APNs 等推送服务。

为什么不能由客户端直接发送

客户端显示“支付成功”,不等于服务端已经确认到账,可能存在以下情况:

  • 支付完成后网络中断,结果未能同步到服务端。
  • 客户端回调可能遭到篡改或伪造。
  • 支付平台可能延迟确认交易。
  • 同一个支付回调可能被重复发送。
  • 将推送服务密钥放进移动应用会带来严重的安全风险。

订单状态应以支付平台的服务端通知为准。客户端返回的结果只适合用来更新界面,例如显示“正在确认支付”。

推荐处理流程

用户发起支付
    ↓
支付平台处理交易
    ↓
支付平台调用服务端 Webhook
    ↓
服务端验证签名、订单号、金额和币种
    ↓
以幂等方式更新订单状态
    ↓
写入推送任务或消息队列
    ↓
推送服务发送通知
    ↓
用户点击通知后进入订单详情页

具体步骤如下:

  1. 用户登录应用时,将设备的 FCM registration token 或 APNs device token 上传到服务端,并与用户账号绑定。
  2. 创建支付交易时,由服务端保存业务订单号、支付平台交易号、金额、币种和初始状态。
  3. 配置支付平台的 Webhook 地址。
  4. 收到回调后,使用支付平台提供的 SDK 或官方方法验证签名。
  5. 查询并锁定订单,检查订单号、金额、币种和支付状态。
  6. 只有在订单第一次从未支付状态变为已支付时,才创建推送任务。
  7. 由后台任务调用 FCM 或 APNs 发送通知。
  8. 保存推送结果,并清理已经失效的设备 token。

服务端示例

以下示例使用 Node.js、Express 和 Firebase Admin SDK。不同支付平台的回调验签方式不同,因此 verifyPaymentWebhook() 只是一个接口占位,必须替换为对应平台的官方实现。

import express from "express";
import admin from "firebase-admin";

const app = express();

// 某些支付平台要求使用原始请求体进行验签。
// 是否能使用 express.json(),应以支付平台的 Webhook 文档为准。
app.use(express.json());

async function verifyPaymentWebhook(req) {
  // 必须使用支付平台提供的签名验证机制。
  // 验证失败时应抛出异常或返回 false。
  throw new Error("Implement verification with the payment provider SDK");
}

app.post("/webhooks/payment", async (req, res) => {
  let event;

  try {
    event = await verifyPaymentWebhook(req);
  } catch (error) {
    return res.status(400).send("Invalid webhook signature");
  }

  if (event.type !== "payment.succeeded") {
    return res.sendStatus(200);
  }

  const {
    transactionId,
    orderId,
    amount,
    currency
  } = event.data;

  try {
    const notificationJob = await database.transaction(async (tx) => {
      const order = await tx.orders.findForUpdate(orderId);

      if (!order) {
        throw new Error("Order not found");
      }

      if (order.amount !== amount || order.currency !== currency) {
        throw new Error("Payment data does not match the order");
      }

      // 支付平台可能重复发送同一事件。
      if (order.status === "PAID") {
        return null;
      }

      await tx.orders.update(orderId, {
        status: "PAID",
        transactionId,
        paidAt: new Date()
      });

      // 建议使用 Outbox Pattern,保证订单更新和消息创建处于同一事务。
      return tx.notificationJobs.create({
        userId: order.userId,
        orderId: order.id,
        type: "PAYMENT_SUCCEEDED",
        status: "PENDING"
      });
    });

    // Webhook 应尽快返回成功。
    // 实际项目中通常由队列消费者处理 notificationJob。
    if (notificationJob) {
      await enqueueNotification(notificationJob.id);
    }

    return res.sendStatus(200);
  } catch (error) {
    console.error("Payment webhook processing failed", error);
    return res.sendStatus(500);
  }
});

发送 FCM Push notification

后台任务获取用户仍然有效的设备 token 后,可以通过 Firebase Admin SDK 发送通知:

async function sendPaymentNotification(job) {
  const order = await database.orders.findById(job.orderId);
  const tokens = await database.deviceTokens.findActiveByUserId(job.userId);

  if (tokens.length === 0) {
    await database.notificationJobs.markCompleted(job.id);
    return;
  }

  const message = {
    notification: {
      title: "支付成功",
      body: `订单 ${order.id} 已支付成功`
    },
    data: {
      type: "PAYMENT_SUCCEEDED",
      orderId: String(order.id)
    },
    tokens
  };

  const result = await admin.messaging().sendEachForMulticast(message);

  for (let index = 0; index < result.responses.length; index += 1) {
    const response = result.responses[index];

    if (!response.success) {
      const code = response.error?.code;

      if (
        code === "messaging/registration-token-not-registered" ||
        code === "messaging/invalid-registration-token"
      ) {
        await database.deviceTokens.disable(tokens[index]);
      }
    }
  }

  await database.notificationJobs.markCompleted(job.id);
}

初始化 Firebase Admin SDK 时,应在服务端安全加载凭据:

admin.initializeApp({
  credential: admin.credential.applicationDefault()
});

不要将 Firebase 服务账号私钥、APNs signing key 或其他服务端凭据打包进移动应用。

移动端需要做什么

移动端主要负责申请通知权限、获取设备 token、将 token 上传到服务端,以及在用户点击通知后跳转到相应页面。

上传 token 时,还要记录平台和应用环境:

{
  "token": "DEVICE_PUSH_TOKEN",
  "platform": "ios",
  "environment": "production"
}

重新安装应用、恢复设备数据或推送服务刷新,都可能导致设备 token 发生变化。应用获取新 token 后,应及时更新服务端记录。用户退出登录时,也要解除当前 token 与用户账号的绑定,避免后续登录该设备的其他账号收到错误通知。

通知内容建议

Push notification 可以用来提示支付结果,但不能将其视为支付凭证或唯一的通知渠道。通知内容宜保持简洁:

{
  "title": "支付成功",
  "body": "您的订单已支付成功,点击查看详情。",
  "data": {
    "type": "PAYMENT_SUCCEEDED",
    "orderId": "ORDER_123"
  }
}

为保护用户隐私,锁屏通知中不要包含完整银行卡号、身份证号、详细商品信息或其他敏感数据。是否展示金额,应根据业务场景和用户的隐私设置决定。

需要特别注意的问题

Webhook 必须验签

不能只根据请求体中的 status: "success" 更新订单,还要验证:

  • Webhook 签名是否合法。
  • 事件是否来自预期的支付账户。
  • 订单号是否存在。
  • 金额和币种是否一致。
  • 交易是否确实处于成功状态。
  • 交易号是否已经处理过。

保证幂等性

支付平台可能多次发送同一个回调,网络重试也可能造成重复处理。可以将支付平台的事件 ID 或交易 ID 设为唯一键,同时限制订单只能发生一次有效的状态转换。

不要让推送阻塞支付回调

如果直接在 Webhook 请求中调用 FCM 或 APNs,推送服务一旦超时,支付平台可能会重复发送回调。更稳妥的做法是先提交数据库事务,再将通知任务交给消息队列处理。

正确处理失败和重试

遇到网络错误、限流或临时服务异常时,可以进行有限次数的指数退避重试。对于无效或已经注销的设备 token,不应反复重试,而应及时禁用或删除。

客户端仍要主动查询订单

用户可能关闭通知,设备也可能处于离线状态。省电策略或 token 失效同样会导致 Push notification 无法送达。因此,应用回到前台或打开订单页时,仍要调用服务端接口查询最新的订单状态,不能依赖 Push notification 更新核心业务数据。

备注:内容仅供参考。