推荐选择的事件 展开以下分类勾选:
必选(核心功能):
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 放行 |
验收标准(打比方说明)
打比方:做完这个项目,就像你的店铺从"只收现金"升级成"现金、刷卡、微信、支付宝都能收"。
具体验收:
-
订阅购买:用 PayPal Sandbox 账户买一个月度订阅 - 跳转到 PayPal → 授权 → 回跳 → 显示"支付成功" - 数据库
payment表有记录,status=active- 用户积分增加 -
积分包购买:用 PayPal Card Fields 直接输入测试卡号 - 不跳转,页面内完成支付 - 数据库
payment表有记录,status=completed- 用户积分增加 -
Google Pay(Stripe 侧):在 Chrome 浏览器打开结账页 - 如果设备支持,显示 Google Pay 按钮 - 点击 → 弹出 Google Pay 面板 → 确认 → 支付成功
-
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:进入
Settings→Payment methods - [ ] 步骤 3:找到
Google Pay,点击Turn on - [ ] 步骤 4:无需代码修改!Stripe Checkout 自动支持
原理说明:
打比方:Google Pay 在 Stripe 里就像"自动挡"——你只要在 Dashboard 里打开开关,Stripe 的结账页面就会自动检测用户设备,如果支持 Google Pay 就显示按钮。你的代码一行都不用改。
1.2 Apple Pay 启用
- [ ] 步骤 1:Stripe Dashboard →
Settings→Payment methods→Apple Pay→Turn 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 & Credentials→Create App - 记录
Client ID和Client 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 Tools→Sandbox Accounts - 创建 1 个 Business 账户(收款方)
- 创建 1 个 Personal 账户(付款方)
- [ ] 步骤 4:配置 Webhook
- 路径:
Apps & Credentials→ 选择应用 →Webhooks→Add Webhook - URL:
https://你的域名/api/webhooks/paypal - 订阅事件:
PAYMENT.CAPTURE.COMPLETEDCHECKOUT.ORDER.APPROVEDBILLING.SUBSCRIPTION.ACTIVATEDBILLING.SUBSCRIPTION.CANCELLEDBILLING.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_SECRET 和 PAYPAL_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-js,components 一次性带齐: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 会自动带 token 或 subscription_id):
- 同时在
src/app/[locale]/(marketing)/payment/page.tsx的searchParams类型里加上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_id 查 payment.sessionId,或用 subscription_id 查 payment.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.comhttps://www.sandbox.paypal.comhttps://www.paypalobjects.comhttps://pay.google.com(Google Pay JS)frame-src:https://www.paypal.comhttps://www.sandbox.paypal.comconnect-src:https://api-m.paypal.comhttps://api-m.sandbox.paypal.comhttps://pay.google.comhttps://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 特有限制
- 订阅不支持动态价格:必须在 Dashboard 预先创建 Subscription Plan(一次性订单可动态金额)
- 订阅修改限制:不能像 Stripe 那样随意升降级,需要取消后重新订阅
- 退款流程:需要在 PayPal Dashboard 手动处理
- 结账体验:只用 PayPal 跳转会略重;如果做 Card Fields 就是内嵌体验
安全考虑
- Webhook 签名验证:必须启用,防止伪造请求
- 幂等性处理:已实现,防止重复处理
- 环境隔离:Sandbox 和 Live 使用不同的凭证
- custom_id 丢失风险:部分事件里拿不到
custom_id,需要用order_id拉订单详情或在 create-order 先落库
运维考虑
- 监控:建议添加 PayPal API 调用的日志和告警
- 对账:定期比对 PayPal Dashboard 和本地数据库
- 客服:准备 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 小时 |
后续扩展
- PayPal 替代支付方式:如 Venmo(美国)、Pay Later(分期)
- PayPal Google Pay / Apple Pay:PayPal 本身也支持作为 Google Pay / Apple Pay 的处理器
- 多币种支持:当前按 USD 实现,后续可扩展其他货币
- 自动重试:Webhook 处理失败时的自动重试机制
✅ 最终 Checklist(查漏补缺)
完成所有开发后,逐项勾选确认没有遗漏:
一、Dashboard 配置(外部平台)
Stripe Dashboard
- [ ] Google Pay 已开启(Settings → Payment methods → Google Pay → Turn on)
- [ ] Apple Pay 已开启且域名已验证
- [ ] Webhook 端点正常接收事件
PayPal Developer Dashboard
- [ ] REST API 应用已创建
- [ ]
Client ID和Client 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_MODE为live - [ ] 使用 Live 的
Client ID和Client 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
问题预防建议
-
PayPal
custom_id设计: - 始终使用紧凑格式 - 只放必要字段(userId、priceId) - 非必要信息(userName、provider)不放入 -
新增页面路由时: - 检查是否需要同时在
[locale]和(default)两个路由组中创建 - 对于需要直接访问的页面(不带语言前缀),必须在(default)中创建 re-export -
路由组结构参考:
src/app/
├── [locale]/ # 带语言前缀
│ └── (marketing)/
│ └── checkout/
│ └── paypal/page.tsx
└── (default)/ # 不带语言前缀(需要 re-export)
└── (marketing)/
└── checkout/
└── paypal/page.tsx ← 必须创建!
- React Context Hook 使用:
- 使用 Context Hook(如
usePayPalScriptReducer)时,必须确保组件在对应 Provider 内部 - 如果组件本身要渲染 Provider,需要拆分为外层(Provider)+ 内层(使用 Hook 的内容)
本文档为站内渲染。原始文件本地路径:saas/source/knowledge-world/Knowledge-World-项目-文档-paypal-paypal-支付系统增强-94021f.md(仅本地保留,不入库不部署)