forked from xiaohei/taiji-AI-PAD
604 lines
15 KiB
Markdown
604 lines
15 KiB
Markdown
# PayPal 支付集成 - 前端实施文档
|
||
|
||
## 1. 概述
|
||
|
||
### 1.1 需求背景
|
||
用户在使用平台 Agent 时,当 EU 余额不足时可以通过 PayPal 在线充值。前端需要集成 PayPal JS SDK,显示支付按钮并处理支付流程。
|
||
|
||
### 1.2 核心概念
|
||
- **EU(执行单元)**:系统计费单位,1 EU = 1 USD
|
||
- **PayPal JS SDK**:PayPal 官方前端 SDK,用于显示支付按钮和处理支付弹窗
|
||
|
||
### 1.3 PayPal Client ID
|
||
- **环境**:Sandbox(测试环境)
|
||
- **Client ID**:`AWJcBVeccSgDDhcZcYEbf4SJKxq9Uk_qVNlvk9mCewzl9o1Cp0onPzOD-v26-Mye9F1cKF6SzipuTtQZ`
|
||
|
||
> **注意**:Client ID 是公开的,可以安全地放在前端代码中。Secret 只在后端使用。
|
||
|
||
---
|
||
|
||
## 2. 后端 API 接口
|
||
|
||
前端需要调用以下后端接口:
|
||
|
||
### 2.1 创建订单
|
||
|
||
**请求**
|
||
```http
|
||
POST /api/user/billing/paypal/create-order
|
||
Authorization: Bearer <token>
|
||
Content-Type: application/json
|
||
|
||
{
|
||
"amount": 10.00,
|
||
"currency": "USD"
|
||
}
|
||
```
|
||
|
||
**响应**
|
||
```json
|
||
{
|
||
"success": true,
|
||
"data": {
|
||
"orderId": "5O190127TN364715T",
|
||
"status": "CREATED",
|
||
"amount": 10.00,
|
||
"currency": "USD",
|
||
"euAmount": 10.00
|
||
}
|
||
}
|
||
```
|
||
|
||
### 2.2 捕获支付
|
||
|
||
**请求**
|
||
```http
|
||
POST /api/user/billing/paypal/capture-order
|
||
Authorization: Bearer <token>
|
||
Content-Type: application/json
|
||
|
||
{
|
||
"orderId": "5O190127TN364715T"
|
||
}
|
||
```
|
||
|
||
**响应**
|
||
```json
|
||
{
|
||
"success": true,
|
||
"data": {
|
||
"orderId": "5O190127TN364715T",
|
||
"status": "COMPLETED",
|
||
"amount": 10.00,
|
||
"euAmount": 10.00,
|
||
"newBalance": 110.00,
|
||
"captureId": "3C679366HH908993F",
|
||
"payerEmail": "buyer@example.com"
|
||
},
|
||
"message": "充值成功,已增加 10.00 EU"
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 3. 支付流程
|
||
|
||
```mermaid
|
||
sequenceDiagram
|
||
participant U as 用户
|
||
participant F as 前端
|
||
participant B as 后端
|
||
participant P as PayPal
|
||
|
||
U->>F: 1. 输入充值金额
|
||
U->>F: 2. 点击 PayPal 按钮
|
||
F->>B: 3. POST /create-order
|
||
B-->>F: 4. 返回 orderId
|
||
F->>P: 5. PayPal SDK 弹出支付窗口
|
||
U->>P: 6. 登录 PayPal 并确认支付
|
||
P-->>F: 7. 支付成功回调
|
||
F->>B: 8. POST /capture-order
|
||
B-->>F: 9. 返回充值结果
|
||
F-->>U: 10. 显示成功,更新余额
|
||
```
|
||
|
||
---
|
||
|
||
## 4. 实施步骤
|
||
|
||
### 4.1 安装依赖
|
||
|
||
```bash
|
||
# React 项目
|
||
npm install @paypal/react-paypal-js
|
||
|
||
# 或 Vue 项目
|
||
npm install @paypal/paypal-js
|
||
```
|
||
|
||
### 4.2 创建 PayPal 充值组件 (React)
|
||
|
||
```tsx
|
||
// components/PayPalRecharge.tsx
|
||
|
||
import { PayPalScriptProvider, PayPalButtons } from "@paypal/react-paypal-js";
|
||
import { useState } from "react";
|
||
import { message } from "antd";
|
||
|
||
// Sandbox Client ID(生产环境需要替换为 Live Client ID)
|
||
const PAYPAL_CLIENT_ID = "AWJcBVeccSgDDhcZcYEbf4SJKxq9Uk_qVNlvk9mCewzl9o1Cp0onPzOD-v26-Mye9F1cKF6SzipuTtQZ";
|
||
|
||
interface PayPalRechargeProps {
|
||
amount: number;
|
||
onSuccess: (data: {
|
||
orderId: string;
|
||
amount: number;
|
||
euAmount: number;
|
||
newBalance: number;
|
||
}) => void;
|
||
onError: (error: Error) => void;
|
||
}
|
||
|
||
export function PayPalRecharge({ amount, onSuccess, onError }: PayPalRechargeProps) {
|
||
const [loading, setLoading] = useState(false);
|
||
|
||
// 获取 token(根据你的项目实际情况调整)
|
||
const getToken = () => {
|
||
return localStorage.getItem("token") || sessionStorage.getItem("token");
|
||
};
|
||
|
||
return (
|
||
<PayPalScriptProvider
|
||
options={{
|
||
clientId: PAYPAL_CLIENT_ID,
|
||
currency: "USD",
|
||
intent: "capture",
|
||
}}
|
||
>
|
||
<PayPalButtons
|
||
style={{
|
||
layout: "vertical",
|
||
color: "blue",
|
||
shape: "rect",
|
||
label: "paypal",
|
||
}}
|
||
disabled={loading || amount <= 0}
|
||
|
||
// 创建订单
|
||
createOrder={async () => {
|
||
setLoading(true);
|
||
try {
|
||
const response = await fetch("/api/user/billing/paypal/create-order", {
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
Authorization: `Bearer ${getToken()}`,
|
||
},
|
||
body: JSON.stringify({
|
||
amount: amount,
|
||
currency: "USD",
|
||
}),
|
||
});
|
||
|
||
const data = await response.json();
|
||
|
||
if (!response.ok || !data.success) {
|
||
throw new Error(data.message || data.detail || "创建订单失败");
|
||
}
|
||
|
||
return data.data.orderId;
|
||
} catch (error) {
|
||
setLoading(false);
|
||
onError(error as Error);
|
||
throw error;
|
||
}
|
||
}}
|
||
|
||
// 支付成功后捕获订单
|
||
onApprove={async (data) => {
|
||
try {
|
||
const response = await fetch("/api/user/billing/paypal/capture-order", {
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
Authorization: `Bearer ${getToken()}`,
|
||
},
|
||
body: JSON.stringify({
|
||
orderId: data.orderID,
|
||
}),
|
||
});
|
||
|
||
const result = await response.json();
|
||
setLoading(false);
|
||
|
||
if (!response.ok || !result.success) {
|
||
throw new Error(result.message || result.detail || "支付验证失败");
|
||
}
|
||
|
||
message.success(`充值成功!已增加 ${result.data.euAmount} EU`);
|
||
onSuccess(result.data);
|
||
|
||
} catch (error) {
|
||
setLoading(false);
|
||
onError(error as Error);
|
||
}
|
||
}}
|
||
|
||
// 支付错误
|
||
onError={(error) => {
|
||
setLoading(false);
|
||
message.error("支付失败,请重试");
|
||
onError(new Error(String(error)));
|
||
}}
|
||
|
||
// 用户取消支付
|
||
onCancel={() => {
|
||
setLoading(false);
|
||
message.info("支付已取消");
|
||
}}
|
||
/>
|
||
</PayPalScriptProvider>
|
||
);
|
||
}
|
||
```
|
||
|
||
### 4.3 创建充值页面 (React)
|
||
|
||
```tsx
|
||
// pages/Recharge.tsx
|
||
|
||
import { useState, useEffect } from "react";
|
||
import { Card, InputNumber, Button, Space, Typography, Statistic, Spin } from "antd";
|
||
import { PayPalRecharge } from "../components/PayPalRecharge";
|
||
|
||
const { Title, Text } = Typography;
|
||
|
||
// 预设金额选项
|
||
const PRESET_AMOUNTS = [10, 50, 100, 500];
|
||
|
||
export function RechargePage() {
|
||
const [amount, setAmount] = useState<number>(10);
|
||
const [balance, setBalance] = useState<number>(0);
|
||
const [loading, setLoading] = useState(true);
|
||
|
||
// 获取当前余额
|
||
useEffect(() => {
|
||
fetchBalance();
|
||
}, []);
|
||
|
||
const fetchBalance = async () => {
|
||
try {
|
||
const response = await fetch("/api/user/billing/balance", {
|
||
headers: {
|
||
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
||
},
|
||
});
|
||
const data = await response.json();
|
||
if (data.success) {
|
||
setBalance(data.data.euBalance || 0);
|
||
}
|
||
} catch (error) {
|
||
console.error("获取余额失败:", error);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
const handleSuccess = (data: { newBalance: number }) => {
|
||
// 更新余额显示
|
||
setBalance(data.newBalance);
|
||
};
|
||
|
||
const handleError = (error: Error) => {
|
||
console.error("支付错误:", error);
|
||
};
|
||
|
||
if (loading) {
|
||
return (
|
||
<div style={{ textAlign: "center", padding: 50 }}>
|
||
<Spin size="large" />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div style={{ maxWidth: 600, margin: "0 auto", padding: 24 }}>
|
||
<Card>
|
||
<Title level={3}>账户充值</Title>
|
||
|
||
{/* 当前余额 */}
|
||
<Statistic
|
||
title="当前余额"
|
||
value={balance}
|
||
suffix="EU"
|
||
precision={2}
|
||
style={{ marginBottom: 24 }}
|
||
/>
|
||
|
||
{/* 预设金额选择 */}
|
||
<div style={{ marginBottom: 24 }}>
|
||
<Text>选择充值金额 (USD)</Text>
|
||
<Space style={{ marginTop: 8, display: "flex", flexWrap: "wrap" }}>
|
||
{PRESET_AMOUNTS.map((preset) => (
|
||
<Button
|
||
key={preset}
|
||
type={amount === preset ? "primary" : "default"}
|
||
onClick={() => setAmount(preset)}
|
||
>
|
||
${preset}
|
||
</Button>
|
||
))}
|
||
</Space>
|
||
</div>
|
||
|
||
{/* 自定义金额输入 */}
|
||
<div style={{ marginBottom: 24 }}>
|
||
<Text>或输入自定义金额</Text>
|
||
<InputNumber
|
||
style={{ width: "100%", marginTop: 8 }}
|
||
min={1}
|
||
max={10000}
|
||
value={amount}
|
||
onChange={(value) => setAmount(value || 0)}
|
||
prefix="$"
|
||
precision={2}
|
||
placeholder="输入充值金额"
|
||
/>
|
||
</div>
|
||
|
||
{/* 充值说明 */}
|
||
<div style={{ marginBottom: 16, padding: 12, background: "#f5f5f5", borderRadius: 4 }}>
|
||
<Text type="secondary">
|
||
充值 <Text strong>${amount.toFixed(2)} USD</Text> = <Text strong>{amount.toFixed(2)} EU</Text>
|
||
</Text>
|
||
</div>
|
||
|
||
{/* PayPal 支付按钮 */}
|
||
<PayPalRecharge
|
||
amount={amount}
|
||
onSuccess={handleSuccess}
|
||
onError={handleError}
|
||
/>
|
||
|
||
{/* 说明文字 */}
|
||
<div style={{ marginTop: 16 }}>
|
||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||
· 1 USD = 1 EU(执行单元)<br />
|
||
· 最小充值金额:$1.00<br />
|
||
· 最大充值金额:$10,000.00<br />
|
||
· 支付完成后余额立即到账
|
||
</Text>
|
||
</div>
|
||
</Card>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
### 4.4 Vue 集成示例
|
||
|
||
```vue
|
||
<!-- components/PayPalRecharge.vue -->
|
||
|
||
<template>
|
||
<div ref="paypalContainer" id="paypal-button-container"></div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, onMounted, watch } from 'vue';
|
||
import { loadScript } from '@paypal/paypal-js';
|
||
import { message } from 'ant-design-vue';
|
||
|
||
const props = defineProps<{
|
||
amount: number;
|
||
}>();
|
||
|
||
const emit = defineEmits<{
|
||
(e: 'success', data: any): void;
|
||
(e: 'error', error: Error): void;
|
||
}>();
|
||
|
||
const paypalContainer = ref<HTMLElement | null>(null);
|
||
|
||
const PAYPAL_CLIENT_ID = 'AWJcBVeccSgDDhcZcYEbf4SJKxq9Uk_qVNlvk9mCewzl9o1Cp0onPzOD-v26-Mye9F1cKF6SzipuTtQZ';
|
||
|
||
const getToken = () => {
|
||
return localStorage.getItem('token') || '';
|
||
};
|
||
|
||
onMounted(async () => {
|
||
try {
|
||
const paypal = await loadScript({
|
||
clientId: PAYPAL_CLIENT_ID,
|
||
currency: 'USD',
|
||
});
|
||
|
||
if (paypal && paypal.Buttons) {
|
||
paypal.Buttons({
|
||
createOrder: async () => {
|
||
const response = await fetch('/api/user/billing/paypal/create-order', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
Authorization: `Bearer ${getToken()}`,
|
||
},
|
||
body: JSON.stringify({
|
||
amount: props.amount,
|
||
currency: 'USD',
|
||
}),
|
||
});
|
||
|
||
const data = await response.json();
|
||
if (!data.success) {
|
||
throw new Error(data.message || '创建订单失败');
|
||
}
|
||
return data.data.orderId;
|
||
},
|
||
|
||
onApprove: async (data: { orderID: string }) => {
|
||
const response = await fetch('/api/user/billing/paypal/capture-order', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
Authorization: `Bearer ${getToken()}`,
|
||
},
|
||
body: JSON.stringify({
|
||
orderId: data.orderID,
|
||
}),
|
||
});
|
||
|
||
const result = await response.json();
|
||
if (result.success) {
|
||
message.success(`充值成功!已增加 ${result.data.euAmount} EU`);
|
||
emit('success', result.data);
|
||
} else {
|
||
throw new Error(result.message || '支付验证失败');
|
||
}
|
||
},
|
||
|
||
onError: (err: any) => {
|
||
message.error('支付失败,请重试');
|
||
emit('error', new Error(String(err)));
|
||
},
|
||
|
||
onCancel: () => {
|
||
message.info('支付已取消');
|
||
},
|
||
}).render('#paypal-button-container');
|
||
}
|
||
} catch (error) {
|
||
console.error('加载 PayPal SDK 失败:', error);
|
||
}
|
||
});
|
||
</script>
|
||
```
|
||
|
||
---
|
||
|
||
## 5. 环境配置
|
||
|
||
### 5.1 Sandbox 测试环境
|
||
|
||
```typescript
|
||
// config/paypal.ts
|
||
|
||
export const PAYPAL_CONFIG = {
|
||
// Sandbox Client ID
|
||
clientId: "AWJcBVeccSgDDhcZcYEbf4SJKxq9Uk_qVNlvk9mCewzl9o1Cp0onPzOD-v26-Mye9F1cKF6SzipuTtQZ",
|
||
currency: "USD",
|
||
intent: "capture",
|
||
};
|
||
```
|
||
|
||
### 5.2 生产环境切换
|
||
|
||
生产环境需要:
|
||
|
||
1. **获取 Live Client ID**
|
||
- 登录 https://developer.paypal.com
|
||
- 进入 Dashboard → My Apps & Credentials
|
||
- 切换到 "Live" 标签
|
||
- 获取 Live Client ID
|
||
|
||
2. **更新配置**
|
||
```typescript
|
||
// 使用环境变量
|
||
export const PAYPAL_CONFIG = {
|
||
clientId: process.env.REACT_APP_PAYPAL_CLIENT_ID || "sandbox_client_id",
|
||
currency: "USD",
|
||
intent: "capture",
|
||
};
|
||
```
|
||
|
||
3. **环境变量文件**
|
||
```bash
|
||
# .env.production
|
||
REACT_APP_PAYPAL_CLIENT_ID=<Live Client ID>
|
||
```
|
||
|
||
---
|
||
|
||
## 6. 测试指南
|
||
|
||
### 6.1 Sandbox 测试账号
|
||
|
||
1. 登录 https://developer.paypal.com/dashboard/accounts
|
||
2. 使用 Personal 类型的测试账号进行支付
|
||
3. 默认测试账号密码通常是 `12345678`
|
||
|
||
### 6.2 测试流程
|
||
|
||
1. 启动前端开发服务器
|
||
2. 打开充值页面
|
||
3. 输入充值金额(如 $10)
|
||
4. 点击 PayPal 按钮
|
||
5. 在弹出窗口中使用 Sandbox 测试账号登录
|
||
6. 确认支付
|
||
7. 验证余额是否增加
|
||
|
||
### 6.3 常见测试场景
|
||
|
||
| 场景 | 操作 | 预期结果 |
|
||
|------|------|----------|
|
||
| 正常支付 | 完成支付流程 | 余额增加,显示成功提示 |
|
||
| 取消支付 | 在 PayPal 窗口点击取消 | 显示"支付已取消"提示 |
|
||
| 金额为 0 | 输入 0 或负数 | PayPal 按钮禁用 |
|
||
| 超过最大金额 | 输入超过 10000 | 输入框限制最大值 |
|
||
| 网络错误 | 断开网络 | 显示错误提示 |
|
||
|
||
---
|
||
|
||
## 7. 错误处理
|
||
|
||
### 7.1 常见错误码
|
||
|
||
| 错误 | 原因 | 处理方式 |
|
||
|------|------|----------|
|
||
| `INSTRUMENT_DECLINED` | 支付方式被拒绝 | 提示用户更换支付方式 |
|
||
| `PAYER_ACTION_REQUIRED` | 需要用户操作 | 引导用户完成 PayPal 验证 |
|
||
| `ORDER_NOT_APPROVED` | 订单未批准 | 提示用户重新支付 |
|
||
| `INVALID_RESOURCE_ID` | 订单 ID 无效 | 刷新页面重试 |
|
||
|
||
### 7.2 错误处理示例
|
||
|
||
```typescript
|
||
const handleError = (error: Error) => {
|
||
console.error("支付错误:", error);
|
||
|
||
const errorMessage = error.message || String(error);
|
||
|
||
if (errorMessage.includes("INSTRUMENT_DECLINED")) {
|
||
message.error("支付方式被拒绝,请更换支付方式");
|
||
} else if (errorMessage.includes("ORDER_NOT_APPROVED")) {
|
||
message.error("订单未批准,请重新支付");
|
||
} else if (errorMessage.includes("network")) {
|
||
message.error("网络错误,请检查网络连接");
|
||
} else {
|
||
message.error("支付失败,请重试");
|
||
}
|
||
};
|
||
```
|
||
|
||
---
|
||
|
||
## 8. 任务清单
|
||
|
||
- [ ] 安装 `@paypal/react-paypal-js` 或 `@paypal/paypal-js` 依赖
|
||
- [ ] 创建 PayPal 配置文件
|
||
- [ ] 创建 `PayPalRecharge` 组件
|
||
- [ ] 创建充值页面
|
||
- [ ] 添加路由配置
|
||
- [ ] 处理支付成功/失败回调
|
||
- [ ] 更新余额显示
|
||
- [ ] 使用 Sandbox 账号测试
|
||
- [ ] 配置生产环境 Client ID
|
||
|
||
---
|
||
|
||
## 9. 参考资料
|
||
|
||
- [PayPal React SDK 文档](https://www.npmjs.com/package/@paypal/react-paypal-js)
|
||
- [PayPal JS SDK 文档](https://developer.paypal.com/sdk/js/)
|
||
- [PayPal Sandbox 测试指南](https://developer.paypal.com/tools/sandbox/)
|
||
- [PayPal 按钮样式配置](https://developer.paypal.com/sdk/js/configuration/#link-style)
|