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

Node.js中支付成功后关联JWT用户ID与PayPal Custom ID的方法

PayPal订阅与用户ID关联的实现方案

一、从PayPal Webhook获取并处理custom_id

你当前代码的核心问题是事件类型选择不准确,需调整并确认custom_id的提取逻辑:

  • 订阅场景下,PayPal核心触发的事件是BILLING.SUBSCRIPTION.ACTIVATED(订阅激活成功)或BILLING.SUBSCRIPTION.CREATED(订阅创建完成),而非用于单次支付的PAYMENT.SALE.COMPLETED。这些订阅类事件中,custom_id就是你创建订阅时传入的用户ID,提取路径webhookEvent.resource.custom_id是正确的。
  • 必须保留Webhook签名验证逻辑,这是防止恶意请求的关键。建议将获取PayPal Access Token的逻辑抽成公共函数,避免重复代码。

二、关联用户ID与PayPal标识的最佳实践

1. 数据库设计优化

不要直接在User模型中存储subscriptionId,建议单独创建Subscription模型,支持多订阅管理与后续扩展:

// models/Subscription.js
const mongoose = require('mongoose');

const subscriptionSchema = new mongoose.Schema({
  userId: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true },
  paypalSubscriptionId: { type: String, required: true, unique: true },
  status: { type: String, enum: ['ACTIVE', 'CANCELLED', 'EXPIRED'], default: 'ACTIVE' },
  createdAt: { type: Date, default: Date.now },
  updatedAt: { type: Date, default: Date.now }
});

module.exports = mongoose.model('Subscription', subscriptionSchema);

2. 幂等性处理

PayPal Webhook可能因网络问题重复发送事件,需避免重复处理:

  • 新增EventLog模型记录已处理的事件ID,处理前先检查是否已存在:
// models/EventLog.js
const mongoose = require('mongoose');

const eventLogSchema = new mongoose.Schema({
  paypalEventId: { type: String, required: true, unique: true },
  status: { type: String, enum: ['SUCCESS', 'FAILED', 'UNHANDLED'], required: true },
  processedAt: { type: Date, default: Date.now }
});

module.exports = mongoose.model('EventLog', eventLogSchema);

3. 错误边界处理

  • 处理用户不存在的情况:通过custom_id查询用户时,若不存在需记录日志并返回对应状态码。
  • 确保Webhook始终返回200给PayPal,否则PayPal会持续重试未确认的请求。

三、修正后的关键代码示例

1. 公共工具函数:获取PayPal Access Token

// utils/paypal.js
const axios = require('axios');

async function getPayPalAccessToken() {
  const params = new URLSearchParams();
  params.append("grant_type", "client_credentials");
  const authResponse = await axios.post(
    "https://api-m.sandbox.paypal.com/v1/oauth2/token",
    params,
    {
      headers: { "Content-Type": "application/x-www-form-urlencoded" },
      auth: {
        username: process.env.PAYPAL_CLIENT_ID,
        password: process.env.PAYPAL_SECRET,
      },
    }
  );
  return authResponse.data.access_token;
}

module.exports = { getPayPalAccessToken };

2. 优化后的Webhook处理逻辑

// routes/paypal.js
const { getPayPalAccessToken } = require('../utils/paypal');
const Subscription = require('../models/Subscription');
const EventLog = require('../models/EventLog');
const User = require('../models/User');

paypalRouter.post("/subscriptions/webhook", async (req, res) => {
  try {
    const webhookEvent = req.body;

    // 检查事件是否已处理
    const existingEvent = await EventLog.findOne({ paypalEventId: webhookEvent.id });
    if (existingEvent) {
      return res.status(200).send('Event already processed');
    }

    // 验证Webhook签名
    const verification = {
      auth_algo: req.headers['paypal-auth-algo'],
      cert_url: req.headers['paypal-cert-url'],
      transmission_id: req.headers['paypal-transmission-id'],
      transmission_sig: req.headers['paypal-transmission-sig'],
      transmission_time: req.headers['paypal-transmission-time'],
      webhook_id: process.env.Webhook_ID,
      webhook_event: webhookEvent
    };

    const accessToken = await getPayPalAccessToken();
    const verifyResponse = await axios.post(
      "https://api-m.sandbox.paypal.com/v1/notifications/verify-webhook-signature",
      verification,
      { headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' } }
    );

    if (verifyResponse.data.verification_status !== "SUCCESS") {
      console.log("Webhook verification failed:", verifyResponse.data);
      await EventLog.create({ paypalEventId: webhookEvent.id, status: 'FAILED' });
      return res.status(401).send('Webhook signature verification failed');
    }

    // 处理订阅激活事件
    if (webhookEvent.event_type === "BILLING.SUBSCRIPTION.ACTIVATED") {
      const userId = webhookEvent.resource.custom_id;
      const paypalSubscriptionId = webhookEvent.resource.id;

      // 检查用户是否存在
      const user = await User.findById(userId);
      if (!user) {
        console.error(`User not found: ${userId}`);
        await EventLog.create({ paypalEventId: webhookEvent.id, status: 'FAILED' });
        return res.status(404).send('User not found');
      }

      // 创建或更新订阅记录
      await Subscription.findOneAndUpdate(
        { userId },
        { paypalSubscriptionId, status: 'ACTIVE', updatedAt: new Date() },
        { upsert: true, new: true }
      );

      await EventLog.create({ paypalEventId: webhookEvent.id, status: 'SUCCESS' });
      console.log("Subscription linked to user successfully");
      return res.status(200).send('Event processed');
    }

    // 处理订阅取消事件(可选)
    if (webhookEvent.event_type === "BILLING.SUBSCRIPTION.CANCELLED") {
      const paypalSubscriptionId = webhookEvent.resource.id;
      await Subscription.findOneAndUpdate(
        { paypalSubscriptionId },
        { status: 'CANCELLED', updatedAt: new Date() }
      );
      await EventLog.create({ paypalEventId: webhookEvent.id, status: 'SUCCESS' });
      return res.status(200).send('Event processed');
    }

    // 未处理的事件类型
    await EventLog.create({ paypalEventId: webhookEvent.id, status: 'UNHANDLED' });
    return res.status(200).send('Unhandled event type');

  } catch (error) {
    console.error("Webhook error:", error.response ? error.response.data : error.message);
    // 确保返回200,避免PayPal重试
    await EventLog.create({ paypalEventId: webhookEvent.id, status: 'FAILED' });
    return res.status(200).send('Error processing event');
  }
});

四、额外注意事项

  • 环境变量安全:所有PayPal密钥、JWT密钥需存储在环境变量中,禁止硬编码。
  • Sandbox测试:在PayPal沙箱中创建测试订阅,触发Webhook事件验证custom_id传递是否正确。
  • 日志记录:对所有Webhook事件、数据库操作进行日志记录,便于问题排查。

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.06.27 02:35:55