知识库首页 知识库-世界 paypal-支付系统增强.md

paypal 支付系统增强

本地来源:Knowledge/World/项目/Practice/Saas功能文档/paypal-支付系统增强.md

推荐选择的事件 展开以下分类勾选:

必选(核心功能):

Billing subscription → 勾选: BILLING.SUBSCRIPTION.CREATED BILLING.SUBSCRIPTION.ACTIVATED BILLING.SUBSCRIPTION.CANCELLED BILLING.SUBSCRIPTION.EXPIRED BILLING.SUBSCRIPTION.PAYMENT.FAILED BILLING.SUBSCRIPTION.PAYMENT.SUCCEEDED

Checkout → 勾选:

CHECKOUT.ORDER.COMPLETED Payment → 勾选:

PAYMENT.CAPTURE.COMPLETED PAYMENT.SALE.COMPLETED

PRD:支付系统增强集成方案

项目:aiupscale.org 版本:v1.0 日期:2026-01-16 状态:待实施


🎯 预期结果(做完后能达成什么)

用户视角

场景 当前 做完后
用信用卡/借记卡付款 ✅ 通过 Stripe Checkout 跳转 ✅ 不变 + PayPal Card Fields 内嵌
用 PayPal 余额付款 ❌ 不支持 ✅ 点击 PayPal 按钮 → 授权 → 完成
用 Google Pay 付款 ❌ 不支持 ✅ Chrome/Android 自动显示按钮
用 Apple Pay 付款 ❌ 不支持 ✅ Safari/iOS 自动显示按钮
管理订阅(取消/查看) ✅ Stripe Customer Portal ✅ Stripe Portal + PayPal 账户页

技术视角

指标 预期值
新增代码文件 8 个(paypal.ts, paypal-utils.ts, webhook, create-order, capture-order, checkout 组件, 页面入口, CSP)
修改代码文件 9 个(types, index, website.tsx, price-plan, payment-page, check-status, searchParams, env-validation, next.config)
新增环境变量 15+ 个(PayPal 凭证 + 价格 ID)
构建是否通过 pnpm build 无报错
Webhook 幂等性 ✅ 重复事件不会重复发积分
安全 ✅ 签名验证 + 金额服务端计算 + CSP 放行

验收标准(打比方说明)

打比方:做完这个项目,就像你的店铺从"只收现金"升级成"现金、刷卡、微信、支付宝都能收"。

具体验收:

  1. 订阅购买:用 PayPal Sandbox 账户买一个月度订阅 - 跳转到 PayPal → 授权 → 回跳 → 显示"支付成功" - 数据库 payment 表有记录,status=active - 用户积分增加

  2. 积分包购买:用 PayPal Card Fields 直接输入测试卡号 - 不跳转,页面内完成支付 - 数据库 payment 表有记录,status=completed - 用户积分增加

  3. Google Pay(Stripe 侧):在 Chrome 浏览器打开结账页 - 如果设备支持,显示 Google Pay 按钮 - 点击 → 弹出 Google Pay 面板 → 确认 → 支付成功

  4. Apple Pay(Stripe 侧):在 Safari 打开结账页 - 如果设备支持,显示 Apple Pay 按钮 - 点击 → Face ID / Touch ID 确认 → 支付成功


TL;DR 快速总结

当前状态

  • Stripe:已完整集成(订阅、一次性支付、Webhook、积分系统)
  • Creem:已完整集成(作为备用支付供应商)
  • Google Pay / Apple Pay:未启用
  • PayPal:未集成

需要做的事情

功能 工作量 优先级 说明
Stripe + Google Pay 2小时 P0 仅需 Dashboard 开启 + 前端微调
Stripe + Apple Pay 4小时 P0 需域名验证 + Dashboard 配置
PayPal 集成 8-12小时 P1 新增支付供应商(含 Card Fields + Orders API)

核心架构变更

现有架构:
src/payment/
├── index.ts          # 支付工厂(管理 stripe/creem)
├── types.ts          # 类型定义
└── provider/
    ├── stripe.ts     # Stripe 实现 ✅
    └── creem.ts      # Creem 实现 ✅

增强后:
src/payment/
├── index.ts          # 支付工厂(新增 paypal)
├── types.ts          # 类型定义(新增 PaymentProviderName: 'paypal')
└── provider/
    ├── stripe.ts     # Stripe 实现(启用 Google Pay/Apple Pay)
    ├── creem.ts      # Creem 实现
    └── paypal.ts     # 【新增】PayPal 实现(订单/订阅/Webhook)

Cheatsheet 速查表

环境变量速查

# === Stripe(现有,需新增 Apple Pay 配置)===
STRIPE_SECRET_KEY="sk_live_xxx"
STRIPE_WEBHOOK_SECRET="whsec_xxx"
# 【新增】Apple Pay 域名验证文件(自动由 Stripe 提供)

# === PayPal(全新)===
NEXT_PUBLIC_PAYPAL_CLIENT_ID="AZDxxxxxxxYourClientID"  # 前端加载 SDK 用
PAYPAL_CLIENT_ID="AZDxxxxxxxYourClientID"             # 后端换 token 用
PAYPAL_CLIENT_SECRET="EGJxxxxxxYourSecret"
PAYPAL_WEBHOOK_ID="WH-xxxxxx"  # Webhook ID
PAYPAL_MODE="sandbox"  # sandbox | live
# 可选:Google Pay / Apple Pay 会用到
NEXT_PUBLIC_PAYPAL_MERCHANT_ID="YOUR_MERCHANT_ID"

# === 价格 ID 映射(PayPal)===
# 订阅(PayPal 必须预先创建 Plan)- Bronze/Silver/Gold/Platinum/Diamond 层级
NEXT_PUBLIC_PAYPAL_PRICE_BRONZE_MONTHLY="P-xxxxx"
NEXT_PUBLIC_PAYPAL_PRICE_BRONZE_YEARLY="P-xxxxx"
NEXT_PUBLIC_PAYPAL_PRICE_SILVER_MONTHLY="P-xxxxx"
NEXT_PUBLIC_PAYPAL_PRICE_SILVER_YEARLY="P-xxxxx"
NEXT_PUBLIC_PAYPAL_PRICE_GOLD_MONTHLY="P-xxxxx"
NEXT_PUBLIC_PAYPAL_PRICE_GOLD_YEARLY="P-xxxxx"
NEXT_PUBLIC_PAYPAL_PRICE_PLATINUM_MONTHLY="P-xxxxx"
NEXT_PUBLIC_PAYPAL_PRICE_PLATINUM_YEARLY="P-xxxxx"
NEXT_PUBLIC_PAYPAL_PRICE_DIAMOND_MONTHLY="P-xxxxx"
NEXT_PUBLIC_PAYPAL_PRICE_DIAMOND_YEARLY="P-xxxxx"
# 积分包(一次性,可选:不用 Plan 也能做动态金额)
NEXT_PUBLIC_PAYPAL_PRICE_CREDITS_BRONZE="P-xxxxx"
NEXT_PUBLIC_PAYPAL_PRICE_CREDITS_SILVER="P-xxxxx"
NEXT_PUBLIC_PAYPAL_PRICE_CREDITS_GOLD="P-xxxxx"
NEXT_PUBLIC_PAYPAL_PRICE_CREDITS_PLATINUM="P-xxxxx"
NEXT_PUBLIC_PAYPAL_PRICE_CREDITS_DIAMOND="P-xxxxx"

文件修改清单

文件 操作 说明
.env.example 不改 项目约定不动,新增变量写到 .env.local/部署环境
.env.local 修改 填入实际值
package.json 修改 新增 @paypal/react-paypal-js 依赖
src/payment/types.ts 修改 添加 'paypal' 到 PaymentProviderName
src/payment/index.ts 修改 注册 PayPal 供应商
src/payment/provider/paypal.ts 新建 PayPal 供应商实现
src/payment/provider/paypal-utils.ts 新建 PayPal 工具函数
src/components/payment/paypal-checkout.tsx 新建 PayPal 内嵌结账组件(卡片/钱包)
src/app/[locale]/(marketing)/checkout/paypal/page.tsx 新建 PayPal 结账页入口
src/app/api/webhooks/paypal/route.ts 新建 PayPal Webhook 端点
src/app/api/paypal/create-order/route.ts 新建 创建 PayPal 订单
src/app/api/paypal/capture-order/route.ts 新建 捕获 PayPal 付款
src/app/api/payment/check-status/route.ts 修改 兼容 PayPal 状态查询
src/components/payment/payment-page.tsx 修改 解析 PayPal 回跳参数
src/app/[locale]/(marketing)/payment/page.tsx 修改 searchParams 增加 token
src/config/website.tsx 修改 添加 PayPal 价格 ID 配置
src/lib/price-plan.ts 修改 修正 Provider 识别逻辑
src/lib/csp.ts 修改 放行 PayPal / Google Pay / Apple Pay 域名
src/lib/env-validation.ts 修改 加 PayPal 环境变量校验
next.config.ts 修改 Apple Pay 验证文件的 Content-Type
public/.well-known/apple-developer-merchantid-domain-association 新建 Apple Pay 域名验证文件(注意 Stripe/PayPal 冲突)

API 端点规划

现有:
POST /api/webhooks/stripe     # Stripe Webhook
POST /api/webhooks/creem      # Creem Webhook

新增:
POST /api/webhooks/paypal     # PayPal Webhook
POST /api/paypal/create-order # PayPal 创建订单(如果使用 JS SDK)
POST /api/paypal/capture-order # PayPal 捕获付款
GET  /api/payment/check-status?provider=paypal&order_id=... # 支付确认

默认配置检查清单 SOP

第一阶段:Stripe 增强(Google Pay / Apple Pay)

1.1 Google Pay 启用(Dashboard 配置)

  • [ ] 步骤 1:登录 Stripe Dashboard
  • [ ] 步骤 2:进入 SettingsPayment methods
  • [ ] 步骤 3:找到 Google Pay,点击 Turn on
  • [ ] 步骤 4:无需代码修改!Stripe Checkout 自动支持

原理说明

打比方:Google Pay 在 Stripe 里就像"自动挡"——你只要在 Dashboard 里打开开关,Stripe 的结账页面就会自动检测用户设备,如果支持 Google Pay 就显示按钮。你的代码一行都不用改。

1.2 Apple Pay 启用

  • [ ] 步骤 1:Stripe Dashboard → SettingsPayment methodsApple PayTurn on
  • [ ] 步骤 2:点击 Add new domain 添加你的域名(如 aiupscale.org
  • [ ] 步骤 3:下载域名验证文件(Stripe 自动生成)
  • [ ] 步骤 4:将文件放到 public/.well-known/apple-developer-merchantid-domain-association
  • [ ] 步骤 5:部署后点击 Verify 验证域名
  • [ ] 步骤 6:测试:在 Safari/iOS 设备上访问结账页面

文件放置

public/
└── .well-known/
    └── apple-developer-merchantid-domain-association  # 无扩展名!

Next.js 配置(确保静态文件正确服务):

// next.config.ts - 通常无需修改,Next.js 默认服务 public 目录
// 但需确保 .well-known 目录不被忽略

第二阶段:PayPal 集成

2.1 PayPal Developer 配置

  • [ ] 步骤 1:访问 https://developer.paypal.com/dashboard
  • [ ] 步骤 2:创建 REST API 应用
  • 路径:Apps & CredentialsCreate App
  • 记录 Client IDClient Secret
  • [ ] 步骤 2.5:开启能力开关(Card / Google Pay / Apple Pay)
  • 路径:Apps & Credentials → 选择应用 → Features
  • 勾选 Advanced Credit and Debit Card Payments
  • 勾选 Google Pay / Apple Pay
  • Apple Pay 需要下载 PayPal 的域名验证文件并放到 public/.well-known/
  • 如验证失败,在 next.config.ts 里给该文件加 Content-Type: application/octet-stream
  • [ ] 步骤 3:创建 Sandbox 测试账户
  • 路径:Testing ToolsSandbox Accounts
  • 创建 1 个 Business 账户(收款方)
  • 创建 1 个 Personal 账户(付款方)
  • [ ] 步骤 4:配置 Webhook
  • 路径:Apps & Credentials → 选择应用 → WebhooksAdd Webhook
  • URL:https://你的域名/api/webhooks/paypal
  • 订阅事件:
    • PAYMENT.CAPTURE.COMPLETED
    • CHECKOUT.ORDER.APPROVED
    • BILLING.SUBSCRIPTION.ACTIVATED
    • BILLING.SUBSCRIPTION.CANCELLED
    • BILLING.SUBSCRIPTION.EXPIRED

2.2 创建 PayPal 订阅计划(Subscription Plans)

为什么需要这一步?

打比方:Stripe 允许你"临时开价"(dynamic price_data),但 PayPal 要求你先在后台"挂牌"——必须提前创建好 Subscription Plan,才能让用户订阅。

  • [ ] 步骤 1:访问 https://developer.paypal.com/dashboard/subscriptions
  • [ ] 步骤 2:创建 Product(产品)
  • Name: AI Upscale Pro
  • Type: SERVICE
  • [ ] 步骤 3:为该 Product 创建 Plan(计划)- Bronze/Silver/Gold/Platinum/Diamond 层级
  • Bronze(青铜)Monthly $4.9 / Yearly $24
  • Silver(白银)Monthly $12.9 / Yearly $96
  • Gold(黄金)Monthly $29 / Yearly $240
  • Platinum(铂金)Monthly $89 / Yearly $600
  • Diamond(钻石)Monthly $199 / Yearly $960
  • 记录每个 Plan 的 Plan ID(格式:P-xxxxx
  • 如果暂时不卖某个档位,对应环境变量先留空
  • [ ] 步骤 4:将 Plan ID 填入环境变量

2.3 项目配置

  • [ ] 步骤 1:更新 .env.local / 生产环境变量(按项目约定不改 .env.example
  • [ ] 步骤 2:更新 src/config/website.tsx 中的 paypalPriceIds
  • [ ] 步骤 3:创建 PayPal 供应商文件
  • [ ] 步骤 4:注册供应商到工厂
  • [ ] 步骤 5:创建 Webhook 路由
  • [ ] 步骤 6:本地测试 + 部署

详细实施指南

一、环境变量配置

文件:.env.local / 线上环境变量

# ============================================
# PayPal 支付配置(新增)
# ============================================

# PayPal API 凭证
# 获取地址:https://developer.paypal.com/dashboard/applications
NEXT_PUBLIC_PAYPAL_CLIENT_ID=""
PAYPAL_CLIENT_ID=""
PAYPAL_CLIENT_SECRET=""

# PayPal Webhook 配置
# 创建地址:Dashboard → Apps → 选择应用 → Webhooks → Add Webhook
PAYPAL_WEBHOOK_ID=""

# PayPal 运行模式:sandbox(测试)或 live(生产)
PAYPAL_MODE="sandbox"
NEXT_PUBLIC_PAYPAL_MERCHANT_ID=""

# PayPal 订阅计划 Price ID (Bronze/Silver/Gold/Platinum/Diamond)
# 创建地址:https://developer.paypal.com/dashboard/subscriptions
# 格式:P-xxxxx
NEXT_PUBLIC_PAYPAL_PRICE_BRONZE_MONTHLY=""
NEXT_PUBLIC_PAYPAL_PRICE_BRONZE_YEARLY=""
NEXT_PUBLIC_PAYPAL_PRICE_SILVER_MONTHLY=""
NEXT_PUBLIC_PAYPAL_PRICE_SILVER_YEARLY=""
NEXT_PUBLIC_PAYPAL_PRICE_GOLD_MONTHLY=""
NEXT_PUBLIC_PAYPAL_PRICE_GOLD_YEARLY=""
NEXT_PUBLIC_PAYPAL_PRICE_PLATINUM_MONTHLY=""
NEXT_PUBLIC_PAYPAL_PRICE_PLATINUM_YEARLY=""
NEXT_PUBLIC_PAYPAL_PRICE_DIAMOND_MONTHLY=""
NEXT_PUBLIC_PAYPAL_PRICE_DIAMOND_YEARLY=""

# PayPal 积分包 Price ID(一次性支付)
NEXT_PUBLIC_PAYPAL_PRICE_CREDITS_BRONZE=""
NEXT_PUBLIC_PAYPAL_PRICE_CREDITS_SILVER=""
NEXT_PUBLIC_PAYPAL_PRICE_CREDITS_GOLD=""
NEXT_PUBLIC_PAYPAL_PRICE_CREDITS_PLATINUM=""
NEXT_PUBLIC_PAYPAL_PRICE_CREDITS_DIAMOND=""

# ============================================
# 支付供应商配置(更新)
# ============================================

# 默认支付供应商:stripe | creem | paypal
NEXT_PUBLIC_PAYMENT_DEFAULT_PROVIDER="stripe"

# 启用的支付供应商(逗号分隔)
# 可选值:stripe, creem, paypal
NEXT_PUBLIC_PAYMENT_ENABLED_PROVIDERS="stripe,paypal"

补充:环境变量校验 - 在 src/lib/env-validation.ts 增加 PayPal 相关项(PAYPAL_CLIENT_ID/SECRET/WEBHOOK_ID/PAYPAL_MODE) - 规则:设置了 PAYPAL_CLIENT_ID 就必须有 PAYPAL_CLIENT_SECRETPAYPAL_WEBHOOK_ID


二、类型定义更新

文件:src/payment/types.ts

// 【修改】把 paypal 加进 providers 列表
export const PAYMENT_PROVIDERS = ['stripe', 'creem', 'paypal'] as const
export type PaymentProviderName = (typeof PAYMENT_PROVIDERS)[number]

// 说明:PayPal 的 payload 类型建议放在 provider 文件里,不放到公共 types.ts

三、PayPal 供应商实现

文件:src/payment/provider/paypal.ts(新建)

/**
 * PayPal 支付供应商实现
 *
 * 支持功能:
 * - 订阅支付(Subscriptions)
 * - 一次性支付(Orders)
 * - Webhook 处理
 * - 客户门户(PayPal 自带管理)
 *
 * PayPal API 文档:https://developer.paypal.com/docs/api/overview/
 */

import { randomUUID } from 'crypto'
import { addCredits, addSubscriptionCredits } from '@/credits/credits'
import { CREDIT_TRANSACTION_TYPE } from '@/credits/types'
import { getCreditPackageById } from '@/credits/server'
import { getDb } from '@/db'
import { payment } from '@/db/schema'
import { logger } from '@/lib/safe-logger'
import { sendNotification } from '@/notification/notification'
import { eq } from 'drizzle-orm'
import type {
  CreateCheckoutParams,
  CreateCreditCheckoutParams,
  CreatePortalParams,
  PaymentProvider,
  Subscription,
  getSubscriptionsParams,
} from '../types'
import { PaymentTypes } from '../types'
import { fetchOrderDetails, paypalRequest } from './paypal-utils'

const portalUrl =
  process.env.PAYPAL_MODE === 'live'
    ? 'https://www.paypal.com/myaccount/autopay'
    : 'https://www.sandbox.paypal.com/myaccount/autopay'

const parseCustomId = (value?: string): Record<string, string> => {
  if (!value) return {}
  try {
    return JSON.parse(value)
  } catch {
    return {}
  }
}

const toCents = (value?: string): number | null => {
  if (!value) return null
  const amount = Number.parseFloat(value)
  if (!Number.isFinite(amount)) return null
  return Math.round(amount * 100)
}

/**
 * PayPal 支付供应商类
 */
export class PayPalProvider implements PaymentProvider {
  /**
   * 创建订阅结账会话
   *
   * PayPal 订阅流程:
   * 1. 调用 /v1/billing/subscriptions 创建订阅
   * 2. 返回 approve URL 让用户授权
   * 3. 用户授权后 PayPal 发送 Webhook
   */
  async createCheckout(params: CreateCheckoutParams) {
    const customId = JSON.stringify({
      userId: params.metadata?.userId,
      userName: params.metadata?.userName,
      planId: params.planId,
      priceId: params.priceId,
      provider: 'paypal',
      ...params.metadata,
    })

    const subscription = await paypalRequest<{
      id: string
      links: Array<{ href: string; rel: string }>
    }>('/v1/billing/subscriptions', {
      method: 'POST',
      headers: { 'PayPal-Request-Id': randomUUID() },
      body: JSON.stringify({
        plan_id: params.priceId, // PayPal Plan ID
        application_context: {
          brand_name: 'AI Upscale',
          locale: params.locale ?? 'en-US',
          shipping_preference: 'NO_SHIPPING',
          user_action: 'SUBSCRIBE_NOW',
          return_url: params.successUrl ?? '',
          cancel_url: params.cancelUrl ?? '',
        },
        custom_id: customId,
      }),
    })

    const approveLink = subscription.links.find((link) => link.rel === 'approve')
    if (!approveLink?.href) {
      throw new Error('PayPal 订阅创建失败:缺少授权链接')
    }

    return { url: approveLink.href, id: subscription.id, priceId: params.priceId }
  }

  /**
   * 创建积分包购买(一次性支付)
   *
   * PayPal 一次性支付流程:
   * 1. 调用 /v2/checkout/orders 创建订单
   * 2. 返回 approve URL 让用户授权
   * 3. 用户授权后调用 capture 端点完成付款
   */
  async createCreditCheckout(params: CreateCreditCheckoutParams) {
    const creditPackage = getCreditPackageById(params.packageId)
    if (!creditPackage) {
      throw new Error(`Credit package ${params.packageId} not found`)
    }

    const amount = (creditPackage.price.amount / 100).toFixed(2)
    const customId = JSON.stringify({
      userId: params.metadata?.userId,
      userName: params.metadata?.userName,
      packageId: params.packageId,
      priceId: params.priceId,
      credits: creditPackage.credits,
      type: 'credit_purchase',
      provider: 'paypal',
      ...params.metadata,
    })

    const order = await paypalRequest<{
      id: string
      links: Array<{ href: string; rel: string }>
    }>('/v2/checkout/orders', {
      method: 'POST',
      headers: { 'PayPal-Request-Id': randomUUID() },
      body: JSON.stringify({
        intent: 'CAPTURE',
        purchase_units: [
          {
            reference_id: params.packageId,
            description: `${creditPackage.credits} Credits`,
            custom_id: customId,
            amount: {
              currency_code: creditPackage.price.currency.toUpperCase(),
              value: amount,
            },
          },
        ],
        application_context: {
          brand_name: 'AI Upscale',
          shipping_preference: 'NO_SHIPPING',
          user_action: 'PAY_NOW',
          return_url: params.successUrl ?? '',
          cancel_url: params.cancelUrl ?? '',
        },
      }),
    })

    const approveLink = order.links.find((link) => link.rel === 'approve')
    if (!approveLink?.href) {
      throw new Error('PayPal 订单创建失败:缺少授权链接')
    }

    return { url: approveLink.href, id: order.id, priceId: params.priceId }
  }

  /**
   * 创建客户门户
   *
   * PayPal 不像 Stripe 有专门的 Customer Portal,
   * 而是直接跳转到 PayPal 账户的订阅管理页面
   */
  async createCustomerPortal(_: CreatePortalParams) {
    return { url: portalUrl }
  }

  /**
   * 获取用户订阅列表
   *
   * 注意:PayPal API 不支持按用户查询订阅,
   * 所以我们从本地数据库查询
   */
  async getSubscriptions(
    params: getSubscriptionsParams
  ): Promise<Subscription[]> {
    const db = await getDb()
    const records = await db
      .select()
      .from(payment)
      .where(eq(payment.userId, params.userId))

    return records
      .filter((item) => {
        const metadata = item.metadata as Record<string, string> | null
        return (
          metadata?.provider === 'paypal' &&
          item.type === PaymentTypes.SUBSCRIPTION
        )
      })
      .map((item) => ({
        id: item.subscriptionId ?? item.sessionId,
        customerId: item.customerId,
        status: item.status as Subscription['status'],
        priceId: item.priceId,
        type: PaymentTypes.SUBSCRIPTION,
        interval: item.interval ?? undefined,
        currentPeriodStart: item.periodStart ?? undefined,
        currentPeriodEnd: item.periodEnd ?? undefined,
        cancelAtPeriodEnd: item.cancelAtPeriodEnd ?? false,
        trialStartDate: item.trialStart ?? undefined,
        trialEndDate: item.trialEnd ?? undefined,
        createdAt: item.createdAt ?? new Date(),
      }))
  }

  /**
   * 处理 Webhook 事件
   */
  async handleWebhookEvent(
    payload: string,
    _signature: string
  ): Promise<void> {
    const event = JSON.parse(payload) as {
      event_type: string
      resource: Record<string, unknown>
    }

    switch (event.event_type) {
      case 'BILLING.SUBSCRIPTION.ACTIVATED':
        await this.handleSubscriptionActivated(event.resource)
        return
      case 'BILLING.SUBSCRIPTION.CANCELLED':
      case 'BILLING.SUBSCRIPTION.EXPIRED':
        await this.handleSubscriptionEnded(event.resource)
        return
      case 'PAYMENT.CAPTURE.COMPLETED':
        await this.handlePaymentCaptured(event.resource)
        return
      default:
        logger.log('[PayPal] 未处理事件', event.event_type)
    }
  }

  /**
   * 处理订阅激活
   */
  private async handleSubscriptionActivated(
    resource: Record<string, unknown>
  ): Promise<void> {
    const subscriptionId = resource.id as string
    const customData = parseCustomId(resource.custom_id as string)
    const userId = customData.userId
    if (!userId) {
      logger.error('[PayPal] 订阅缺少 userId', resource)
      return
    }

    const planId =
      customData.priceId || (resource.plan_id as string) || 'unknown'
    const customerId =
      (resource.subscriber as any)?.payer_id ||
      (resource.subscriber as any)?.email_address ||
      `paypal_${userId}`

    const startTime = new Date(resource.start_time as string)
    const nextBillingTime = (resource.billing_info as any)?.next_billing_time
      ? new Date((resource.billing_info as any).next_billing_time)
      : new Date(startTime.getTime() + 30 * 24 * 60 * 60 * 1000)

    const amountCents = toCents(
      (resource.billing_info as any)?.last_payment?.amount?.value
    )
    const currency = (resource.billing_info as any)?.last_payment?.amount?.currency_code?.toUpperCase()

    const metadata = {
      provider: 'paypal',
      ...customData,
    } as Record<string, string>
    if (amountCents !== null) {
      metadata.invoiceAmountPaid = String(amountCents)
      metadata.invoiceAmountUnit = 'minor_unit'
    }
    if (currency) {
      metadata.invoiceCurrency = currency
    }

    const db = await getDb()
    await db
      .insert(payment)
      .values({
        userId,
        customerId,
        priceId: planId,
        subscriptionId,
        sessionId: subscriptionId,
        paid: true,
        status: 'active',
        type: PaymentTypes.SUBSCRIPTION,
        periodStart: startTime,
        periodEnd: nextBillingTime,
        amountPaidCents: amountCents ?? undefined,
        currency,
        metadata,
      })
      .onConflictDoUpdate({
        target: payment.sessionId,
        set: {
          status: 'active',
          paid: true,
          periodStart: startTime,
          periodEnd: nextBillingTime,
          amountPaidCents: amountCents ?? undefined,
          currency,
          metadata,
        },
      })

    await addSubscriptionCredits(userId, planId)
    await sendNotification({
      type: 'payment_success',
      userId,
      data: { provider: 'paypal', subscriptionId },
    })
  }

  /**
   * 处理订阅结束(取消/过期)
   */
  private async handleSubscriptionEnded(
    resource: Record<string, unknown>
  ): Promise<void> {
    const subscriptionId = resource.id as string
    const db = await getDb()
    await db
      .update(payment)
      .set({ status: 'canceled', cancelAtPeriodEnd: true })
      .where(eq(payment.sessionId, subscriptionId))
  }

  /**
   * 处理一次性支付完成(积分包)
   */
  private async handlePaymentCaptured(
    resource: Record<string, unknown>
  ): Promise<void> {
    const captureId = resource.id as string
    const orderId =
      (resource.supplementary_data as any)?.related_ids?.order_id ||
      (resource as any)?.order_id

    let customData = parseCustomId(resource.custom_id as string)
    if (!customData.userId && orderId) {
      const orderDetails = await fetchOrderDetails(orderId)
      const customId = orderDetails?.purchase_units?.[0]?.custom_id
      customData = parseCustomId(customId)
    }

    const userId = customData.userId
    const credits = Number(customData.credits || 0)
    const packageId = customData.packageId
    const priceId = customData.priceId || packageId || orderId || captureId

    if (!userId || !credits) {
      logger.error('[PayPal] 积分支付缺少必要参数', {
        userId,
        credits,
        orderId,
        captureId,
      })
      return
    }

    const customerId =
      (resource.payer as any)?.payer_id || `paypal_${userId}`
    const amountCents = toCents((resource.amount as any)?.value)
    const currency = (resource.amount as any)?.currency_code?.toUpperCase()

    const metadata = {
      provider: 'paypal',
      captureId,
      ...customData,
    } as Record<string, string>
    if (amountCents !== null) {
      metadata.invoiceAmountPaid = String(amountCents)
      metadata.invoiceAmountUnit = 'minor_unit'
    }
    if (currency) {
      metadata.invoiceCurrency = currency
    }

    const db = await getDb()
    const [record] = await db
      .insert(payment)
      .values({
        userId,
        customerId,
        priceId,
        sessionId: orderId || captureId,
        paymentIntentId: captureId,
        paid: true,
        status: 'completed',
        type: PaymentTypes.ONE_TIME,
        amountPaidCents: amountCents ?? undefined,
        currency,
        metadata,
      })
      .onConflictDoUpdate({
        target: payment.sessionId,
        set: {
          paid: true,
          status: 'completed',
          paymentIntentId: captureId,
          amountPaidCents: amountCents ?? undefined,
          currency,
          metadata,
        },
      })
      .returning({ id: payment.id })

    const creditPackage = packageId
      ? getCreditPackageById(packageId)
      : undefined
    const expireDays = creditPackage?.expireDays ?? 365

    await addCredits({
      userId,
      amount: credits,
      type: CREDIT_TRANSACTION_TYPE.PURCHASE_PACKAGE,
      description: `PayPal 购买 ${credits} 积分`,
      paymentId: record?.id,
      expireDays,
    })

    await sendNotification({
      type: 'payment_success',
      userId,
      data: { provider: 'paypal', credits },
    })
  }
}

// 单例导出
let paypalProviderInstance: PayPalProvider | null = null

export function getPayPalProvider(): PayPalProvider {
  if (!paypalProviderInstance) {
    paypalProviderInstance = new PayPalProvider()
  }
  return paypalProviderInstance
}

字段对齐提醒(非常重要) - payment.customerId 不能为空:优先用 payer_id / subscriber.payer_id,兜底 paypal_${userId} - payment.sessionId:订单用 orderId,订阅用 subscriptionId - payment.paymentIntentId:建议放 captureId - 后台金额显示靠 metadata.invoiceAmountPaid / invoiceAmountUnit / invoiceCurrency


四、PayPal 工具函数

文件:src/payment/provider/paypal-utils.ts(新建)

/**
 * PayPal 工具函数
 *
 * 包含:
 * - Webhook 签名验证
 * - 订单捕获
 * - 订单详情查询
 * - 订阅管理
 */

import { randomUUID } from 'crypto'

const PAYPAL_API_BASE = process.env.PAYPAL_MODE === 'live'
  ? 'https://api-m.paypal.com'
  : 'https://api-m.sandbox.paypal.com'

let tokenCache: { token: string; expiresAt: number } | null = null

async function getAccessToken(): Promise<string> {
  if (tokenCache && Date.now() < tokenCache.expiresAt - 60_000) {
    return tokenCache.token
  }

  const clientId = process.env.PAYPAL_CLIENT_ID
  const clientSecret = process.env.PAYPAL_CLIENT_SECRET
  if (!clientId || !clientSecret) {
    throw new Error('Missing PAYPAL_CLIENT_ID/PAYPAL_CLIENT_SECRET')
  }

  const auth = Buffer.from(`${clientId}:${clientSecret}`).toString('base64')
  const response = await fetch(`${PAYPAL_API_BASE}/v1/oauth2/token`, {
    method: 'POST',
    headers: {
      Authorization: `Basic ${auth}`,
      'Content-Type': 'application/x-www-form-urlencoded',
    },
    body: 'grant_type=client_credentials',
  })

  if (!response.ok) {
    const error = await response.text()
    throw new Error(`PayPal OAuth 失败: ${error}`)
  }

  const data = await response.json()
  tokenCache = {
    token: data.access_token,
    expiresAt: Date.now() + data.expires_in * 1000,
  }
  return data.access_token
}

export async function paypalRequest<T>(
  endpoint: string,
  init: RequestInit = {}
): Promise<T> {
  const token = await getAccessToken()
  const response = await fetch(`${PAYPAL_API_BASE}${endpoint}`, {
    ...init,
    headers: {
      Authorization: `Bearer ${token}`,
      'Content-Type': 'application/json',
      ...(init.headers || {}),
    },
  })

  if (!response.ok) {
    const error = await response.text()
    throw new Error(`PayPal API 错误 [${endpoint}]: ${error}`)
  }

  if (response.status === 204) {
    return undefined as T
  }

  return response.json() as Promise<T>
}

/**
 * 验证 PayPal Webhook 签名
 *
 * PayPal 使用 CRC32 + 签名链验证,比较复杂。
 * 官方推荐直接调用 API 验证:
 * POST /v1/notifications/verify-webhook-signature
 *
 * @see https://developer.paypal.com/docs/api/webhooks/v1/#verify-webhook-signature
 */
export async function verifyWebhookSignature(
  headers: Headers,
  body: string
): Promise<boolean> {
  // 提取验证所需的头部
  const transmissionId = headers.get('paypal-transmission-id')
  const transmissionTime = headers.get('paypal-transmission-time')
  const certUrl = headers.get('paypal-cert-url')
  const authAlgo = headers.get('paypal-auth-algo')
  const transmissionSig = headers.get('paypal-transmission-sig')

  if (!transmissionId || !transmissionTime || !certUrl || !authAlgo || !transmissionSig) {
    console.error('[PayPal] 缺少验证头部')
    return false
  }

  // 调用验证 API
  const verifyResponse = await paypalRequest<{
    verification_status: string
  }>('/v1/notifications/verify-webhook-signature', {
    method: 'POST',
    body: JSON.stringify({
      auth_algo: authAlgo,
      cert_url: certUrl,
      transmission_id: transmissionId,
      transmission_sig: transmissionSig,
      transmission_time: transmissionTime,
      webhook_id: process.env.PAYPAL_WEBHOOK_ID,
      webhook_event: JSON.parse(body),
    }),
  })

  return verifyResponse.verification_status === 'SUCCESS'
}

/**
 * 捕获 PayPal 订单(完成付款)
 *
 * 用户授权后需要调用此方法完成扣款
 */
export async function captureOrder(orderId: string) {
  return paypalRequest(`/v2/checkout/orders/${orderId}/capture`, {
    method: 'POST',
    headers: { 'PayPal-Request-Id': randomUUID() },
  })
}

export async function fetchOrderDetails(orderId: string) {
  return paypalRequest(`/v2/checkout/orders/${orderId}`)
}

/**
 * 取消 PayPal 订阅
 */
export async function cancelSubscription(
  subscriptionId: string,
  reason: string = '用户请求取消'
): Promise<void> {
  await paypalRequest(`/v1/billing/subscriptions/${subscriptionId}/cancel`, {
    method: 'POST',
    body: JSON.stringify({ reason }),
  })
}

五、PayPal Orders API 路由(Card Fields / Google Pay / Apple Pay)

为什么要这俩接口? PayPal 的卡片直付、Google Pay、Apple Pay 是“内嵌收银台”,需要你先在后端“开小票”(create-order),再“刷卡扣款”(capture-order)。

订阅走法提醒 订阅继续走现有 createCheckoutAction -> PayPalProvider.createCheckout(跳转到 PayPal 授权页); 一次性/积分包才走 create-order / capture-order

5.1 创建订单

文件:src/app/api/paypal/create-order/route.ts

要点: - 必须带 custom_id,里面放 userId/priceId/packageId/credits,后面 Webhook 才能对账。 - 金额必须从服务端配置里算,别信前端传过来的价格(防篡改)。 - 建议带 PayPal-Request-Id 作为幂等键(防重复创建)。 - 建议先写一条 payment 记录(status=processing),方便支付页轮询。 - customerId 不能为空,先用 paypal_${userId} 兜底。

import { NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/server'
import { getDb } from '@/db'
import { payment } from '@/db/schema'
import { getCreditPackageById } from '@/credits/server'
import { buildDynamicPriceId } from '@/lib/price-plan'
import { paypalRequest } from '@/payment/provider/paypal-utils'

export async function POST(request: NextRequest) {
  const session = await getSession()
  if (!session?.user?.id) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
  }

  const { packageId, priceId } = await request.json()
  const creditPackage = packageId ? getCreditPackageById(packageId) : null
  if (!creditPackage) {
    return NextResponse.json({ error: 'Invalid package' }, { status: 400 })
  }

  const resolvedPriceId =
    priceId ||
    creditPackage.price.priceId ||
    buildDynamicPriceId(packageId, creditPackage.price)
  const customId = JSON.stringify({
    userId: session.user.id,
    packageId,
    priceId: resolvedPriceId,
    credits: creditPackage.credits,
    provider: 'paypal',
  })

  const order = await paypalRequest<{
    id: string
  }>('/v2/checkout/orders', {
    method: 'POST',
    body: JSON.stringify({
      intent: 'CAPTURE',
      purchase_units: [
        {
          custom_id: customId,
          amount: {
            currency_code: creditPackage.price.currency.toUpperCase(),
            value: (creditPackage.price.amount / 100).toFixed(2),
          },
        },
      ],
      application_context: {
        shipping_preference: 'NO_SHIPPING',
        return_url: `${process.env.NEXT_PUBLIC_BASE_URL}/payment?provider=paypal`,
        cancel_url: `${process.env.NEXT_PUBLIC_BASE_URL}/pricing?provider=paypal`,
      },
    }),
  })

  const db = await getDb()
  await db.insert(payment).values({
    userId: session.user.id,
    customerId: `paypal_${session.user.id}`,
    priceId: resolvedPriceId,
    sessionId: order.id,
    paid: false,
    status: 'processing',
    type: 'one_time',
    metadata: { provider: 'paypal', customId },
  })

  return NextResponse.json({ id: order.id })
}

5.2 捕获付款

文件:src/app/api/paypal/capture-order/route.ts

要点: - 只做“扣款 + 更新状态”,积分发放以 Webhook 为准,避免双发。

import { NextRequest, NextResponse } from 'next/server'
import { getDb } from '@/db'
import { payment } from '@/db/schema'
import { captureOrder } from '@/payment/provider/paypal-utils'
import { eq } from 'drizzle-orm'

export async function POST(request: NextRequest) {
  const { orderId } = await request.json()
  const capture = await captureOrder(orderId)
  const captureId = capture?.purchase_units?.[0]?.payments?.captures?.[0]?.id
  const amount = capture?.purchase_units?.[0]?.payments?.captures?.[0]?.amount

  const db = await getDb()
  await db
    .update(payment)
    .set({
      paid: true,
      status: 'completed',
      paymentIntentId: captureId,
      amountPaidCents: amount?.value
        ? Math.round(Number(amount.value) * 100)
        : undefined,
      currency: amount?.currency_code?.toUpperCase(),
    })
    .where(eq(payment.sessionId, orderId))

  return NextResponse.json({ status: capture.status })
}

5.3 前端结账组件(PayPal Buttons + Card Fields)

文件:src/components/payment/paypal-checkout.tsx

要点: - 先装依赖:pnpm add @paypal/react-paypal-js - 用 @paypal/react-paypal-jscomponents 一次性带齐:buttons,card-fields,googlepay,applepay - createOrder/api/paypal/create-order - onApprove/api/paypal/capture-order - Google Pay 还需加载 https://pay.google.com/gp/p/js/pay.js 并调用 paypal.Googlepay() - Apple Pay 需 paypal.Applepay() + 域名验证文件

'use client'

import {
  PayPalButtons,
  PayPalCardFieldsProvider,
  PayPalCardFieldsForm,
  PayPalScriptProvider,
} from '@paypal/react-paypal-js'

export function PayPalCheckout({
  packageId,
  priceId,
}: {
  packageId: string
  priceId: string
}) {
  return (
    <PayPalScriptProvider
      options={{
        clientId: process.env.NEXT_PUBLIC_PAYPAL_CLIENT_ID!,
        components: 'buttons,card-fields,googlepay,applepay',
        currency: 'USD',
        intent: 'capture',
      }}
    >
      <PayPalButtons
        createOrder={async () => {
          const res = await fetch('/api/paypal/create-order', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ packageId, priceId }),
          })
          const data = await res.json()
          return data.id
        }}
        onApprove={async (data) => {
          await fetch('/api/paypal/capture-order', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ orderId: data.orderID }),
          })
        }}
      />

      <PayPalCardFieldsProvider
        createOrder={async () => {
          const res = await fetch('/api/paypal/create-order', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ packageId, priceId }),
          })
          const data = await res.json()
          return data.id
        }}
      >
        <PayPalCardFieldsForm />
      </PayPalCardFieldsProvider>
    </PayPalScriptProvider>
  )
}

小提醒:PayPal 回跳会自动带 token(订单号)或 subscription_id,不用自己拼 {orderID}


六、Webhook 路由

文件:src/app/api/webhooks/paypal/route.ts(新建)

/**
 * PayPal Webhook 端点
 *
 * 接收并处理 PayPal 支付事件通知
 *
 * 配置地址:https://developer.paypal.com/dashboard/webhooks
 * 需要订阅的事件:
 * - PAYMENT.CAPTURE.COMPLETED(一次性支付完成)
 * - BILLING.SUBSCRIPTION.ACTIVATED(订阅激活)
 * - BILLING.SUBSCRIPTION.CANCELLED(订阅取消)
 * - BILLING.SUBSCRIPTION.EXPIRED(订阅过期)
 * - CHECKOUT.ORDER.APPROVED(可选,不处理也行)
 */

import { randomUUID } from 'crypto'
import { NextRequest, NextResponse } from 'next/server'
import { getDb } from '@/db'
import { webhookEvent } from '@/db/schema'
import { handleWebhookEvent } from '@/payment'
import { verifyWebhookSignature } from '@/payment/provider/paypal-utils'
import { eq } from 'drizzle-orm'

export async function POST(request: NextRequest) {
  try {
    // 1. 获取请求体
    const body = await request.text()
    const headers = request.headers

    // 2. 验证签名
    const isValid = await verifyWebhookSignature(headers, body)
    if (!isValid) {
      console.error('[PayPal Webhook] 签名验证失败')
      return NextResponse.json(
        { error: '签名验证失败' },
        { status: 401 }
      )
    }

    // 3. 解析事件
    const event = JSON.parse(body)
    const eventId = event.id as string
    const eventType = event.event_type as string

    console.log(`[PayPal Webhook] 收到事件: ${eventType} (${eventId})`)

    // 4. 幂等性检查
    const db = await getDb()
    const [existingEvent] = await db
      .select({ processedAt: webhookEvent.processedAt })
      .from(webhookEvent)
      .where(eq(webhookEvent.eventId, eventId))
      .limit(1)

    if (existingEvent?.processedAt) {
      console.log(`[PayPal Webhook] 事件已处理: ${eventId}`)
      return NextResponse.json({ received: true, duplicate: true })
    }

    // 5. 记录事件(标记为处理中)
    if (!existingEvent) {
      await db
        .insert(webhookEvent)
        .values({
          id: randomUUID(),
          eventId,
          source: 'paypal',
          eventType,
          processedAt: null,
        })
        .onConflictDoNothing({ target: webhookEvent.eventId })
    }

    // 6. 处理事件
    try {
      await handleWebhookEvent(
        'paypal',
        body,
        '' // PayPal 签名已在上面验证
      )

      // 7. 标记为已处理
      await db
        .update(webhookEvent)
        .set({ processedAt: new Date() })
        .where(eq(webhookEvent.eventId, eventId))

      console.log(`[PayPal Webhook] 处理完成: ${eventId}`)
      return NextResponse.json({ received: true })

    } catch (error) {
      // 处理失败,保留 processedAt=null 以便重试
      console.error(`[PayPal Webhook] 处理失败:`, error)
      return NextResponse.json(
        { error: '处理失败' },
        { status: 500 }
      )
    }

  } catch (error) {
    console.error('[PayPal Webhook] 请求处理错误:', error)
    return NextResponse.json(
      { error: '服务器错误' },
      { status: 500 }
    )
  }
}

// PayPal 可能发送 GET 请求验证端点可用性
export async function GET() {
  return NextResponse.json({ status: 'ok' })
}

七、支付工厂注册

文件:src/payment/index.ts

需要在现有代码基础上添加 PayPal 支持:

// 【新增导入】
import { PayPalProvider } from './provider/paypal'

// 【修改】在 createProviderInstance 中添加 PayPal case
const createProviderInstance = (
  provider: PaymentProviderName
): PaymentProvider => {
  switch (provider) {
    case 'stripe':
      return new StripeProvider()
    case 'creem':
      return new CreemProvider()
    case 'paypal': // 【新增】
      return new PayPalProvider()
    default:
      throw new Error(`Unsupported payment provider: ${provider}`)
  }
}

八、价格配置更新

文件:src/config/website.tsx

paymentConfig 中添加 PayPal 价格 ID:

// 【新增】PayPal 价格 ID 配置 (Bronze/Silver/Gold/Platinum/Diamond)
const paypalPriceIds = {
  // 订阅计划
  bronzeMonthly: process.env.NEXT_PUBLIC_PAYPAL_PRICE_BRONZE_MONTHLY || '',
  bronzeYearly: process.env.NEXT_PUBLIC_PAYPAL_PRICE_BRONZE_YEARLY || '',
  silverMonthly: process.env.NEXT_PUBLIC_PAYPAL_PRICE_SILVER_MONTHLY || '',
  silverYearly: process.env.NEXT_PUBLIC_PAYPAL_PRICE_SILVER_YEARLY || '',
  goldMonthly: process.env.NEXT_PUBLIC_PAYPAL_PRICE_GOLD_MONTHLY || '',
  goldYearly: process.env.NEXT_PUBLIC_PAYPAL_PRICE_GOLD_YEARLY || '',
  platinumMonthly: process.env.NEXT_PUBLIC_PAYPAL_PRICE_PLATINUM_MONTHLY || '',
  platinumYearly: process.env.NEXT_PUBLIC_PAYPAL_PRICE_PLATINUM_YEARLY || '',
  diamondMonthly: process.env.NEXT_PUBLIC_PAYPAL_PRICE_DIAMOND_MONTHLY || '',
  diamondYearly: process.env.NEXT_PUBLIC_PAYPAL_PRICE_DIAMOND_YEARLY || '',

  // 积分包
  creditsBronze: process.env.NEXT_PUBLIC_PAYPAL_PRICE_CREDITS_BRONZE || '',
  creditsSilver: process.env.NEXT_PUBLIC_PAYPAL_PRICE_CREDITS_SILVER || '',
  creditsGold: process.env.NEXT_PUBLIC_PAYPAL_PRICE_CREDITS_GOLD || '',
  creditsPlatinum: process.env.NEXT_PUBLIC_PAYPAL_PRICE_CREDITS_PLATINUM || '',
  creditsDiamond: process.env.NEXT_PUBLIC_PAYPAL_PRICE_CREDITS_DIAMOND || '',
}

// 在计划配置中添加 PayPal Price ID
// 示例:在 Silver 计划的 prices 数组中
buildProviderPrice(
  {
    stripe: stripePriceIds.silverMonthly,
    creem: creemPriceIds.silverMonthly,
    paypal: paypalPriceIds.silverMonthly, // 【新增】
  },
  {
    type: PaymentTypes.SUBSCRIPTION,
    amount: 1290, // $12.9/month
    currency: 'USD',
    interval: PlanIntervals.MONTH,
  }
)

补充:一次性支付的动态价格 - 如果不打算为积分包创建 PayPal Price ID,需要在 getPriceIdForProvider 里对 PayPal 的 one_time 放行 buildDynamicPriceId,否则按钮会是灰的。


九、价格检测逻辑更新

文件:src/lib/price-plan.ts

// 【修改】detectProviderByPriceId:优先检查 providerPriceIds,避免误判为默认 provider
export function detectProviderByPriceId(
  priceId: string
): PaymentProviderName | null {
  if (!priceId) return null

  const plans = getAllPricePlans()
  for (const plan of plans) {
    for (const price of plan.prices) {
      if (price.providerPriceIds) {
        const entry = Object.entries(price.providerPriceIds).find(
          ([, id]) => id === priceId
        )
        if (entry && isPaymentProviderName(entry[0])) {
          return entry[0]
        }
      }

      if (matchesPriceId(price, plan.id, priceId)) {
        return websiteConfig.payment.defaultProvider
      }
    }
  }

  return null
}

十、支付确认页与状态查询(PayPal)

文件:src/components/payment/payment-page.tsx 补上 PayPal 回跳参数解析(PayPal 会自动带 tokensubscription_id):

  • 同时在 src/app/[locale]/(marketing)/payment/page.tsxsearchParams 类型里加上 token?: string
// 新增:支持 PayPal 的 order_id / subscription_id
const providerName: PaymentProviderName = isPaymentProviderName(provider)
  ? provider
  : 'stripe'

const paypalOrderId = rawParams?.order_id ?? rawParams?.token
const paypalSubId = rawParams?.subscription_id

// 构造查询参数时加上 PayPal 的 ID
if (providerName === 'paypal') {
  if (paypalOrderId) params.append('order_id', paypalOrderId)
  if (paypalSubId) params.append('subscription_id', paypalSubId)
}

文件:src/app/api/payment/check-status/route.ts 新增 PayPal 分支:用 order_idpayment.sessionId,或用 subscription_idpayment.subscriptionId

if (provider === 'paypal') {
  const orderId = searchParams.get('order_id')
  const subscriptionId = searchParams.get('subscription_id')

  if (!orderId && !subscriptionId) {
    return NextResponse.json({ status: 'pending' })
  }

  const db = await getDb()
  const whereClause = orderId
    ? eq(payment.sessionId, orderId)
    : eq(payment.subscriptionId, subscriptionId as string)

  const record = await db
    .select({ id: payment.id, paid: payment.paid, status: payment.status })
    .from(payment)
    .where(and(whereClause, eq(payment.userId, currentUserId)))
    .limit(1)

  if (record[0]?.paid || record[0]?.status === 'active') {
    return NextResponse.json({ status: 'completed', paymentId: record[0].id })
  }

  return NextResponse.json({ status: 'pending' })
}

十一、前端入口(怎么让用户看到 PayPal)

当前定价页默认只走 默认 Provider。要让 PayPal 真正可用,有两种做法:

方案 A:直接把 PayPal 设为默认 - 改 NEXT_PUBLIC_PAYMENT_DEFAULT_PROVIDER=paypal - 适合想快速让 PayPal 成为主通道

方案 B:在 UI 里加 PayPal 按钮 - 在 PricingTable 里额外渲染一个按钮:

<CheckoutButton
  provider="paypal"
  userId={currentUserId}
  planId={selectedSubscription.planId}
  priceId={selectedSubscription.priceId}
>
  Pay with PayPal
</CheckoutButton>
  • 适合“Stripe + PayPal 并存”的场景
  • 如果要走内嵌卡片收款,按钮可先跳到 /checkout/paypal?packageId=...&priceId=...

十二、CSP 放行(必须)

文件:src/lib/csp.ts PayPal/Google Pay/Apple Pay 会走脚本、iframe、网络请求,CSP 不放行会直接黑屏。

建议补这些域名(按需加 sandbox/live):

  • script-src
  • https://www.paypal.com
  • https://www.sandbox.paypal.com
  • https://www.paypalobjects.com
  • https://pay.google.com(Google Pay JS)
  • frame-src
  • https://www.paypal.com
  • https://www.sandbox.paypal.com
  • connect-src
  • https://api-m.paypal.com
  • https://api-m.sandbox.paypal.com
  • https://pay.google.com
  • https://apple-pay-gateway.apple.com

测试清单

Stripe Google Pay / Apple Pay 测试

  • [ ] 在 Stripe Dashboard 确认 Google Pay 已启用
  • [ ] 在 Stripe Dashboard 确认 Apple Pay 已启用且域名已验证
  • [ ] 使用 Chrome 浏览器测试 Google Pay 按钮是否显示
  • [ ] 使用 Safari/iOS 设备测试 Apple Pay 按钮是否显示
  • [ ] 完成一笔 Google Pay 测试支付
  • [ ] 完成一笔 Apple Pay 测试支付
  • [ ] 确认 Webhook 正确处理支付完成事件
  • [ ] 确认积分正确分配

PayPal 测试

  • [ ] 使用 Sandbox Personal 账户完成订阅购买
  • [ ] 确认订阅激活 Webhook 正确处理
  • [ ] 确认积分正确分配
  • [ ] 使用 Sandbox Personal 账户完成积分包购买
  • [ ] 确认支付捕获 Webhook 正确处理
  • [ ] Card Fields(信用卡直付)跑一笔成功 + 一笔失败
  • [ ] Google Pay / Apple Pay(PayPal 侧)在支持设备上可见并完成支付
  • [ ] 测试订阅取消流程
  • [ ] 测试客户门户跳转

风险与注意事项

PayPal 特有限制

  1. 订阅不支持动态价格:必须在 Dashboard 预先创建 Subscription Plan(一次性订单可动态金额)
  2. 订阅修改限制:不能像 Stripe 那样随意升降级,需要取消后重新订阅
  3. 退款流程:需要在 PayPal Dashboard 手动处理
  4. 结账体验:只用 PayPal 跳转会略重;如果做 Card Fields 就是内嵌体验

安全考虑

  1. Webhook 签名验证:必须启用,防止伪造请求
  2. 幂等性处理:已实现,防止重复处理
  3. 环境隔离:Sandbox 和 Live 使用不同的凭证
  4. custom_id 丢失风险:部分事件里拿不到 custom_id,需要用 order_id 拉订单详情或在 create-order 先落库

运维考虑

  1. 监控:建议添加 PayPal API 调用的日志和告警
  2. 对账:定期比对 PayPal Dashboard 和本地数据库
  3. 客服:准备 PayPal 相关的常见问题解答

Apple Pay 冲突提醒

  • Stripe Apple Pay 和 PayPal Apple Pay 都需要 /.well-known/apple-developer-merchantid-domain-association 文件。
  • 同一个域名只能放一个文件版本,同时启用两家 Apple Pay 很容易冲突
  • 建议:选一个作为 Apple Pay 主渠道;或先只开 Stripe/PayPal 其中之一的 Apple Pay。

时间估算

任务 预估时间
Stripe Google Pay 配置 30分钟
Stripe Apple Pay 配置(含域名验证) 2小时
PayPal Developer 配置 1小时
PayPal 订阅计划创建 1小时
PayPal 供应商代码实现 4小时
Webhook 路由实现 1小时
工厂注册和配置更新 1小时
测试(Sandbox) 2小时
部署和生产测试 1小时
总计 约 13.5 小时

后续扩展

  1. PayPal 替代支付方式:如 Venmo(美国)、Pay Later(分期)
  2. PayPal Google Pay / Apple Pay:PayPal 本身也支持作为 Google Pay / Apple Pay 的处理器
  3. 多币种支持:当前按 USD 实现,后续可扩展其他货币
  4. 自动重试:Webhook 处理失败时的自动重试机制

✅ 最终 Checklist(查漏补缺)

完成所有开发后,逐项勾选确认没有遗漏:

一、Dashboard 配置(外部平台)

Stripe Dashboard

  • [ ] Google Pay 已开启(Settings → Payment methods → Google Pay → Turn on)
  • [ ] Apple Pay 已开启且域名已验证
  • [ ] Webhook 端点正常接收事件

PayPal Developer Dashboard

  • [ ] REST API 应用已创建
  • [ ] Client IDClient Secret 已记录
  • [ ] Advanced Credit and Debit Card Payments 已勾选
  • [ ] Google Pay 已勾选(可选)
  • [ ] Apple Pay 已勾选 + 域名验证文件已部署(可选)
  • [ ] Sandbox Business 账户已创建
  • [ ] Sandbox Personal 账户已创建(用于测试付款)
  • [ ] Webhook 已配置,订阅了以下事件:
  • [ ] PAYMENT.CAPTURE.COMPLETED
  • [ ] BILLING.SUBSCRIPTION.ACTIVATED
  • [ ] BILLING.SUBSCRIPTION.CANCELLED
  • [ ] BILLING.SUBSCRIPTION.EXPIRED
  • [ ] 订阅计划(Subscription Plans)已创建 (Bronze/Silver/Gold/Platinum/Diamond):
  • [ ] Bronze Monthly / Yearly
  • [ ] Silver Monthly / Yearly
  • [ ] Gold Monthly / Yearly
  • [ ] Platinum Monthly / Yearly
  • [ ] Diamond Monthly / Yearly
  • [ ] Plan ID 已记录并填入环境变量

二、环境变量配置

必填项

  • [ ] NEXT_PUBLIC_PAYPAL_CLIENT_ID 已填写
  • [ ] PAYPAL_CLIENT_ID 已填写
  • [ ] PAYPAL_CLIENT_SECRET 已填写
  • [ ] PAYPAL_WEBHOOK_ID 已填写
  • [ ] PAYPAL_MODE 已设置(sandbox / live)

价格 ID(订阅)(Bronze/Silver/Gold/Platinum/Diamond)

  • [ ] NEXT_PUBLIC_PAYPAL_PRICE_BRONZE_MONTHLY
  • [ ] NEXT_PUBLIC_PAYPAL_PRICE_BRONZE_YEARLY
  • [ ] NEXT_PUBLIC_PAYPAL_PRICE_SILVER_MONTHLY
  • [ ] NEXT_PUBLIC_PAYPAL_PRICE_SILVER_YEARLY
  • [ ] NEXT_PUBLIC_PAYPAL_PRICE_GOLD_MONTHLY
  • [ ] NEXT_PUBLIC_PAYPAL_PRICE_GOLD_YEARLY
  • [ ] NEXT_PUBLIC_PAYPAL_PRICE_PLATINUM_MONTHLY
  • [ ] NEXT_PUBLIC_PAYPAL_PRICE_PLATINUM_YEARLY
  • [ ] NEXT_PUBLIC_PAYPAL_PRICE_DIAMOND_MONTHLY
  • [ ] NEXT_PUBLIC_PAYPAL_PRICE_DIAMOND_YEARLY

价格 ID(积分包,可选)

  • [ ] NEXT_PUBLIC_PAYPAL_PRICE_CREDITS_BRONZE
  • [ ] NEXT_PUBLIC_PAYPAL_PRICE_CREDITS_SILVER
  • [ ] NEXT_PUBLIC_PAYPAL_PRICE_CREDITS_GOLD
  • [ ] NEXT_PUBLIC_PAYPAL_PRICE_CREDITS_PLATINUM
  • [ ] NEXT_PUBLIC_PAYPAL_PRICE_CREDITS_DIAMOND

供应商开关

  • [ ] NEXT_PUBLIC_PAYMENT_ENABLED_PROVIDERS 已包含 paypal

三、代码文件检查

新建文件(8 个)

  • [ ] src/payment/provider/paypal.ts — 供应商实现
  • [ ] src/payment/provider/paypal-utils.ts — 工具函数
  • [ ] src/app/api/webhooks/paypal/route.ts — Webhook 端点
  • [ ] src/app/api/paypal/create-order/route.ts — 创建订单
  • [ ] src/app/api/paypal/capture-order/route.ts — 捕获付款
  • [ ] src/components/payment/paypal-checkout.tsx — 前端结账组件
  • [ ] src/app/[locale]/(marketing)/checkout/paypal/page.tsx — 结账页入口
  • [ ] public/.well-known/apple-developer-merchantid-domain-association — Apple Pay 验证(可选)

修改文件(9 个)

  • [ ] package.json — 添加 @paypal/react-paypal-js
  • [ ] src/payment/types.ts — 添加 'paypal' 到 PAYMENT_PROVIDERS
  • [ ] src/payment/index.ts — 注册 PayPalProvider
  • [ ] src/config/website.tsx — 添加 paypalPriceIds
  • [ ] src/lib/price-plan.ts — 修正 detectProviderByPriceId
  • [ ] src/components/payment/payment-page.tsx — 解析 PayPal 回跳参数
  • [ ] src/app/[locale]/(marketing)/payment/page.tsx — searchParams 增加 token
  • [ ] src/app/api/payment/check-status/route.ts — 兼容 PayPal 查询
  • [ ] src/lib/csp.ts — 放行 PayPal / Google Pay / Apple Pay 域名

可选修改

  • [ ] src/lib/env-validation.ts — 添加 PayPal 环境变量校验
  • [ ] next.config.ts — Apple Pay 文件 Content-Type

四、构建与类型检查

  • [ ] pnpm install 成功(无报错)
  • [ ] pnpm build 成功(无 TypeScript 错误)
  • [ ] pnpm lint 通过(无 ESLint/Biome 错误)

五、功能测试

Stripe Google Pay / Apple Pay

  • [ ] Chrome 浏览器能看到 Google Pay 按钮(需设备支持)
  • [ ] Safari 浏览器能看到 Apple Pay 按钮(需设备支持)
  • [ ] 完成一笔 Google Pay 测试支付
  • [ ] 完成一笔 Apple Pay 测试支付
  • [ ] Webhook 正确处理,积分正确分配

PayPal 订阅

  • [ ] 点击订阅按钮 → 跳转 PayPal 授权页
  • [ ] 用 Sandbox Personal 账户登录并授权
  • [ ] 回跳后显示"支付成功"
  • [ ] 数据库 payment 表有记录(status=active
  • [ ] Webhook BILLING.SUBSCRIPTION.ACTIVATED 收到并处理
  • [ ] 用户积分正确增加

PayPal 积分包(一次性支付)

  • [ ] 点击积分包购买按钮 → 显示 PayPal Buttons
  • [ ] 用 Sandbox 账户完成支付
  • [ ] 数据库 payment 表有记录(status=completed
  • [ ] Webhook PAYMENT.CAPTURE.COMPLETED 收到并处理
  • [ ] 用户积分正确增加

PayPal Card Fields(内嵌信用卡)

  • [ ] 信用卡输入框正确渲染
  • [ ] 使用测试卡号 4111111111111111 完成支付
  • [ ] 使用失败卡号测试错误处理
  • [ ] 支付成功后积分正确分配

幂等性测试

  • [ ] 手动重放同一 Webhook 事件,不会重复发积分
  • [ ] webhookEvent 表正确记录事件

六、安全检查

  • [ ] Webhook 签名验证已启用(verifyWebhookSignature
  • [ ] 金额从服务端配置计算,不信前端传值
  • [ ] customerId 字段不为空(兜底 paypal_${userId}
  • [ ] CSP 已放行 PayPal / Google Pay / Apple Pay 域名
  • [ ] 敏感环境变量(PAYPAL_CLIENT_SECRET)不暴露给前端

七、上线前确认

  • [ ] 切换 PAYPAL_MODElive
  • [ ] 使用 Live 的 Client IDClient Secret
  • [ ] 更新 Webhook URL 为生产域名
  • [ ] 在 PayPal Dashboard 注册生产域名的 Apple Pay 验证(如需)
  • [ ] 完成至少 1 笔真实小额支付验证
  • [ ] 监控日志确认无异常

八、文档与交接

  • [ ] 环境变量清单已同步给运维/部署负责人
  • [ ] PayPal Dashboard 账户信息已记录(谁有权限)
  • [ ] 常见问题 FAQ 已准备(客服用)
  • [ ] 退款流程已告知客服(PayPal 需手动处理)

🚨 常见踩坑提醒

问题 原因 解决方案
PayPal 按钮不显示 CSP 拦截了脚本 检查 src/lib/csp.ts 是否放行 paypal.com
Webhook 返回 401 签名验证失败 检查 PAYPAL_WEBHOOK_ID 是否正确
积分没加上 custom_id 丢失 检查 fetchOrderDetails 兜底逻辑
数据库插入报错 customerId 为空 检查兜底逻辑 paypal_${userId}
Apple Pay 验证失败 文件 Content-Type 错误 next.config.ts 设置 application/octet-stream
订阅按钮灰色 PayPal Plan ID 未填 检查环境变量 NEXT_PUBLIC_PAYPAL_PRICE_*

文档版本: v1.1 最后更新: 2026-01-17 作者: Claude Code

PayPal Plan 填写模板(Bronze/Silver/Gold/Platinum/Diamond) 第一步:创建 1 个 Product 字段 值 Product name AI Upscale Subscription Product description AI image and video upscaling service - subscription plans Product ID aiupscale-subscription Product type SERVICE Industry category SOFTWARE Product page URL https://aiupscale.org/pricing Product image URL (留空) 第二步:在该 Product 下创建 10 个 Plan Bronze 青铜(300 credits/月) Plan Billing Cycle Price Bronze Monthly Every 1 Month $4.90 USD Bronze Yearly Every 1 Year $24.00 USD Plan description (Monthly): AI Upscale Bronze - 300 credits per month Plan description (Yearly): AI Upscale Bronze - Annual billing (Save 60%) Silver 白银(1,000 credits/月) Plan Billing Cycle Price Silver Monthly Every 1 Month $12.90 USD Silver Yearly Every 1 Year $96.00 USD Plan description (Monthly): AI Upscale Silver - 1,000 credits per month Plan description (Yearly): AI Upscale Silver - Annual billing (Save 38%) Gold 黄金(3,000 credits/月) Plan Billing Cycle Price Gold Monthly Every 1 Month $29.00 USD Gold Yearly Every 1 Year $240.00 USD Plan description (Monthly): AI Upscale Gold - 3,000 credits per month Plan description (Yearly): AI Upscale Gold - Annual billing (Save 31%) Platinum 铂金(10,000 credits/月) Plan Billing Cycle Price Platinum Monthly Every 1 Month $89.00 USD Platinum Yearly Every 1 Year $600.00 USD Plan description (Monthly): AI Upscale Platinum - 10,000 credits per month Plan description (Yearly): AI Upscale Platinum - Annual billing (Save 44%) Diamond 钻石(100,000 credits/月) Plan Billing Cycle Price Diamond Monthly Every 1 Month $199.00 USD Diamond Yearly Every 1 Year $960.00 USD Plan description (Monthly): AI Upscale Diamond - 100,000 credits per month Plan description (Yearly): AI Upscale Diamond - Annual billing (Save 60%) 第三步:填写 .env 创建完成后,你会得到 10 个 Plan ID(格式:P-XXXXXXXX),填入:

============================================

PayPal 订阅计划 (Bronze/Silver/Gold/Platinum/Diamond)

============================================

Bronze 计划 ($4.9/月, $24/年) - 青铜 300 credits

NEXT_PUBLIC_PAYPAL_PRICE_BRONZE_MONTHLY="P-xxxxx" NEXT_PUBLIC_PAYPAL_PRICE_BRONZE_YEARLY="P-xxxxx"

Silver 计划 ($12.9/月, $96/年) - 白银 1,000 credits

NEXT_PUBLIC_PAYPAL_PRICE_SILVER_MONTHLY="P-xxxxx" NEXT_PUBLIC_PAYPAL_PRICE_SILVER_YEARLY="P-xxxxx"

Gold 计划 ($29/月, $240/年) - 黄金 3,000 credits

NEXT_PUBLIC_PAYPAL_PRICE_GOLD_MONTHLY="P-xxxxx" NEXT_PUBLIC_PAYPAL_PRICE_GOLD_YEARLY="P-xxxxx"

Platinum 计划 ($89/月, $600/年) - 铂金 10,000 credits

NEXT_PUBLIC_PAYPAL_PRICE_PLATINUM_MONTHLY="P-xxxxx" NEXT_PUBLIC_PAYPAL_PRICE_PLATINUM_YEARLY="P-xxxxx"

Diamond 计划 ($199/月, $960/年) - 钻石 100,000 credits

NEXT_PUBLIC_PAYPAL_PRICE_DIAMOND_MONTHLY="P-xxxxx" NEXT_PUBLIC_PAYPAL_PRICE_DIAMOND_YEARLY="P-xxxxx" 关于积分包(一次性购买) 积分包不需要创建 Plan,代码使用 PayPal Orders API 动态生成金额,所以这些环境变量可以留空:

PayPal 积分包(使用动态金额,不需要 Plan ID)

NEXT_PUBLIC_PAYPAL_PRICE_CREDITS_BRONZE="" NEXT_PUBLIC_PAYPAL_PRICE_CREDITS_SILVER="" NEXT_PUBLIC_PAYPAL_PRICE_CREDITS_GOLD="" NEXT_PUBLIC_PAYPAL_PRICE_CREDITS_PLATINUM="" NEXT_PUBLIC_PAYPAL_PRICE_CREDITS_DIAMOND=""


======================================== PayPal Products 备份 ========================================

【Product】Bronze ID: aiupscale-starter Description: Bronze plan with 300 credits per month Type: SERVICE Category: SOFTWARE Home URL: https://aiupscale.org/pricing Created: 2026-01-17T12:54:15Z

【Product】Silver ID: aiupscale-lite Description: Silver plan with 1000 credits per month Type: SERVICE Category: SOFTWARE Home URL: https://aiupscale.org/pricing Created: 2026-01-17T12:55:07Z

【Product】Gold ID: aiupscale-basic Description: Gold plan with 3000 credits per month Type: SERVICE Category: SOFTWARE Home URL: https://aiupscale.org/pricing Created: 2026-01-17T12:55:35Z

【Product】Platinum ID: aiupscale-platinum Description: Platinum plan with 10000 credits per month Type: SERVICE Category: SOFTWARE Home URL: https://aiupscale.org/pricing Created: 2026-01-17T13:21:00Z

【Product】Diamond ID: aiupscale-diamond Description: Diamond plan with 100000 credits per month Type: SERVICE Category: SOFTWARE Home URL: https://aiupscale.org/pricing Created: 2026-01-17T13:21:01Z

======================================== PayPal Plans 备份 ========================================

【Plan】Bronze Monthly Plan Plan ID: P-88X49740GK256393GNFVY3ZQ Product ID: aiupscale-starter Description: Bronze Monthly - 300 credits Status: ACTIVE Price: $4.9 USD Interval: 1 MONTH Created: 2026-01-17T13:25:58Z

【Plan】Silver Monthly Plan ID: P-8VU00800GG884923ENFVY32A Product ID: aiupscale-lite Description: Silver Monthly - 1000 credits Status: ACTIVE Price: $12.9 USD Interval: 1 MONTH Created: 2026-01-17T13:26:00Z

【Plan】Gold Monthly Plan ID: P-5VR795656G886713MNFVY32I Product ID: aiupscale-basic Description: Gold Monthly - 3000 credits Status: ACTIVE Price: $29.0 USD Interval: 1 MONTH Created: 2026-01-17T13:26:01Z

【Plan】Platinum Monthly Plan ID: P-9KK543764B913001BNFVY32I Product ID: aiupscale-platinum Description: Platinum Monthly - 10000 credits Status: ACTIVE Price: $89.0 USD Interval: 1 MONTH Created: 2026-01-17T13:26:01Z

【Plan】Bronze Yearly Plan ID: P-92A04229TE505300UNFVY32Q Product ID: aiupscale-starter Description: Bronze Yearly - 3600 credits Status: ACTIVE Price: $24.0 USD Interval: 1 YEAR Created: 2026-01-17T13:26:02Z

【Plan】Diamond Monthly Plan ID: P-5LA45995PH5322040NFVY32Q Product ID: aiupscale-diamond Description: Diamond Monthly - 100000 credits Status: ACTIVE Price: $199.0 USD Interval: 1 MONTH Created: 2026-01-17T13:26:02Z

【Plan】Platinum Yearly Plan ID: P-6G727296D45014931NFVY32Y Product ID: aiupscale-platinum Description: Platinum Yearly - 120000 credits Status: ACTIVE Price: $600.0 USD Interval: 1 YEAR Created: 2026-01-17T13:26:03Z

【Plan】Gold Yearly Plan ID: P-36245082AH015294VNFVY32Y Product ID: aiupscale-basic Description: Gold Yearly - 36000 credits Status: ACTIVE Price: $240.0 USD Interval: 1 YEAR Created: 2026-01-17T13:26:03Z

【Plan】Silver Yearly Plan ID: P-1U8791799C502143CNFVY32Y Product ID: aiupscale-lite Description: Silver Yearly - 12000 credits Status: ACTIVE Price: $96.0 USD Interval: 1 YEAR Created: 2026-01-17T13:26:03Z

【Plan】Diamond Yearly Plan ID: P-6XH40987SF149090LNFVY33A Product ID: aiupscale-diamond Description: Diamond Yearly - 1200000 credits Status: ACTIVE Price: $960.0 USD Interval: 1 YEAR Created: 2026-01-17T13:26:04Z


.env 配置(复制用)

Monthly Plans

NEXT_PUBLIC_PAYPAL_PRICE_BRONZE_MONTHLY="P-88X49740GK256393GNFVY3ZQ" NEXT_PUBLIC_PAYPAL_PRICE_SILVER_MONTHLY="P-8VU00800GG884923ENFVY32A" NEXT_PUBLIC_PAYPAL_PRICE_GOLD_MONTHLY="P-5VR795656G886713MNFVY32I" NEXT_PUBLIC_PAYPAL_PRICE_PLATINUM_MONTHLY="P-9KK543764B913001BNFVY32I" NEXT_PUBLIC_PAYPAL_PRICE_DIAMOND_MONTHLY="P-5LA45995PH5322040NFVY32Q"

Yearly Plans

NEXT_PUBLIC_PAYPAL_PRICE_BRONZE_YEARLY="P-92A04229TE505300UNFVY32Q" NEXT_PUBLIC_PAYPAL_PRICE_SILVER_YEARLY="P-1U8791799C502143CNFVY32Y" NEXT_PUBLIC_PAYPAL_PRICE_GOLD_YEARLY="P-36245082AH015294VNFVY32Y" NEXT_PUBLIC_PAYPAL_PRICE_PLATINUM_YEARLY="P-6G727296D45014931NFVY32Y" NEXT_PUBLIC_PAYPAL_PRICE_DIAMOND_YEARLY="P-6XH40987SF149090LNFVY33A"


🐛 实施过程问题汇总(Troubleshooting Log)

问题 1:PayPal custom_id 超过 127 字符限制

日期:2026-01-18

现象: - 点击 PayPal 订阅按钮时报错 - 错误信息:INVALID_STRING_MAX_LENGTH - 字段:/custom_id - 描述:The value of a field is too long

根本原因: PayPal API 的 custom_id 字段有 127 字符限制,但我们原来使用完整的 JSON 格式:

{
  "userId": "xxx-xxx-xxx-xxx-xxx",
  "userName": "[email protected]",
  "planId": "bronze",
  "priceId": "P-88X49740GK256393GNFVY3ZQ",
  "provider": "paypal",
  "type": "subscription"
}

这个 JSON 字符串长度约 164 字符,超出限制。

解决方案: 使用紧凑格式的键名:

原始键名 紧凑键名
userId u
planId p
packageId k
credits c
priceId r

修改后的 JSON:

{"u":"xxx-xxx","p":"bronze","r":"P-88X49740GK256393GNFVY3ZQ"}

修改文件: - src/payment/provider/paypal.ts - createCheckout() 方法中的 customId 生成逻辑 - createCreditCheckout() 方法中的 customId 生成逻辑 - parseCustomId() 函数支持紧凑格式解析

代码示例

// parseCustomId 支持双格式
const parseCustomId = (value?: string): Record<string, string> => {
  if (!value) return {};
  try {
    const raw = JSON.parse(value);
    // 如果已经是完整格式,直接返回
    if (raw.userId) return raw;
    // 紧凑格式映射
    const keyMap: Record<string, string> = {
      u: 'userId',
      p: 'planId',
      k: 'packageId',
      c: 'credits',
      r: 'priceId',
    };
    const result: Record<string, string> = {};
    for (const [key, val] of Object.entries(raw)) {
      const fullKey = keyMap[key] || key;
      result[fullKey] = String(val);
    }
    return result;
  } catch {
    return {};
  }
};

// 订阅的 customId(紧凑格式)
const customId = JSON.stringify({
  u: params.metadata?.userId,
  p: params.planId,
  r: params.priceId,
});

// 积分包的 customId(紧凑格式)
const customId = JSON.stringify({
  u: params.metadata?.userId,
  k: params.packageId,
  c: creditPackage.credits,
  r: params.priceId,
});

提交记录a7f4f1e - fix: use compact custom_id format for PayPal 127 char limit


问题 2:/checkout/paypal 路由 404 错误

日期:2026-01-18

现象: - 访问 https://aiupscale.org/checkout/paypal?packageId=bronze&priceId=... 返回 404 - 但 /zh/checkout/paypal/en/checkout/paypal 正常工作 - Vercel 日志显示 GET /checkout/paypal → 404

根本原因: Next.js App Router 的路由组结构问题。

项目使用两套路由组: 1. src/app/[locale]/(marketing)/ - 带语言前缀的路由(如 /zh/checkout/paypal) 2. src/app/(default)/(marketing)/ - 不带语言前缀的路由(如 /checkout/paypal

PayPal 结账页只在 [locale] 路由组中创建:

src/app/[locale]/(marketing)/checkout/paypal/page.tsx ✅ 存在
src/app/(default)/(marketing)/checkout/paypal/page.tsx ❌ 不存在

当用户访问 /checkout/paypal(无语言前缀)时: 1. 请求匹配到 (default) 路由组 2. 没有对应的 checkout/paypal 路由 3. 被 src/app/[locale]/[...rest]/page.tsx catch-all 捕获 4. catch-all 调用 notFound() 返回 404

解决方案: 在 (default) 路由组中创建对应的 re-export 文件:

src/app/(default)/(marketing)/checkout/paypal/page.tsx

内容:

export { default } from '@/app/[locale]/(marketing)/checkout/paypal/page';

为什么订阅跳转正常但积分包 404?

订阅和积分包的支付流程不同: - 订阅:调用 createCheckout → PayPal 返回授权 URL(https://www.paypal.com/...)→ 直接跳转到 PayPal - 积分包:调用 createCreditCheckout → 返回本地 /checkout/paypal?... 页面 → 需要在本地渲染 PayPal Buttons

所以积分包需要访问本地的 /checkout/paypal 页面,而订阅直接跳转到 PayPal 不需要。

修改文件: - 新建 src/app/(default)/(marketing)/checkout/paypal/page.tsx

构建验证: 修复后 pnpm build 输出中同时包含两个路由:

├ /checkout/paypal
├ /[locale]/checkout/paypal

提交记录e0fd89f - fix: add checkout/paypal route to default locale path


问题 3:usePayPalScriptReducer Hook 上下文错误

日期:2026-01-18

现象: - 访问 /checkout/paypal 页面白屏 - 控制台报错:usePayPalScriptReducer must be used within a PayPalScriptProvider

根本原因: React Hook 规则违反。usePayPalScriptReducer 必须在 PayPalScriptProvider 内部使用,但原来的代码结构是:

// ❌ 错误的结构
function PayPalCheckout() {
  const [{ isResolved }] = usePayPalScriptReducer(); // 在 Provider 外调用!

  return (
    <PayPalScriptProvider>
      {/* 内容 */}
    </PayPalScriptProvider>
  );
}

Hook 在组件顶层被调用时,PayPalScriptProvider 还没有渲染,所以 Context 不存在。

解决方案: 将组件拆分为两层:外层 Provider + 内层 Content。

// ✅ 正确的结构
function PayPalCheckout() {
  return (
    <PayPalScriptProvider options={scriptOptions}>
      <PayPalCheckoutContent {...props} />
    </PayPalScriptProvider>
  );
}

function PayPalCheckoutContent() {
  const [{ isResolved }] = usePayPalScriptReducer(); // 在 Provider 内调用 ✓
  // ...
}

修改文件: - src/components/payment/paypal-checkout.tsx - 新增 PayPalCheckoutContentProps 接口 - 拆分 PayPalCheckout 为外层组件(只负责 Provider 包裹) - 新增 PayPalCheckoutContent 内层组件(包含所有 Hook 和逻辑)

提交记录fb18a3f - fix: resolve PayPalScriptProvider context error in checkout component


问题预防建议

  1. PayPal custom_id 设计: - 始终使用紧凑格式 - 只放必要字段(userId、priceId) - 非必要信息(userName、provider)不放入

  2. 新增页面路由时: - 检查是否需要同时在 [locale](default) 两个路由组中创建 - 对于需要直接访问的页面(不带语言前缀),必须在 (default) 中创建 re-export

  3. 路由组结构参考

src/app/
├── [locale]/           # 带语言前缀
│   └── (marketing)/
│       └── checkout/
│           └── paypal/page.tsx
└── (default)/          # 不带语言前缀(需要 re-export)
    └── (marketing)/
        └── checkout/
            └── paypal/page.tsx  ← 必须创建!
  1. React Context Hook 使用: - 使用 Context Hook(如 usePayPalScriptReducer)时,必须确保组件在对应 Provider 内部 - 如果组件本身要渲染 Provider,需要拆分为外层(Provider)+ 内层(使用 Hook 的内容)

本文档为站内渲染。原始文件本地路径:saas/source/knowledge-world/Knowledge-World-项目-Practice-Saas功能文档-paypal-支付系统增强-a3626f.md(仅本地保留,不入库不部署)