feat: 集成Google OAuth认证和完善Stripe支付系统
- 新增Google OAuth 2.0认证模块 - 实现Google Strategy和AuthController - 添加Google平台适配器和用户统一管理 - 支持OAuth回调和token交换机制 - 完善Stripe支付集成 - 新增Stripe支付适配器和控制器 - 实现webhook事件处理和支付确认 - 支持多币种和国际化支付 - 扩展平台类型支持 - 在PlatformType中新增GOOGLE和STRIPE - 更新适配器工厂注册机制 - 完善跨平台用户身份管理 - 增强依赖和配置 - 添加@nestjs/passport和passport-google-oauth20 - 更新支付方法枚举和货币支持 - 完善订单号生成和回调URL构建
This commit is contained in:
28
Dockerfile
28
Dockerfile
@@ -14,20 +14,20 @@ ENV BUILD_TIME=$BUILD_TIME
|
||||
# 设置工作目录
|
||||
WORKDIR /app
|
||||
|
||||
# 复制 package.json 和 package-lock.json (如果存在)
|
||||
COPY package*.json ./
|
||||
# 复制 package.json 和 pnpm-lock.yaml
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
|
||||
# 安装依赖
|
||||
RUN npm install --only=production && npm cache clean --force
|
||||
# 安装 pnpm 并安装生产依赖
|
||||
RUN npm install -g pnpm && pnpm install --prod --frozen-lockfile
|
||||
|
||||
# 构建阶段
|
||||
FROM base AS build
|
||||
# 安装所有依赖(包括开发依赖)
|
||||
RUN npm install
|
||||
RUN pnpm install --frozen-lockfile
|
||||
# 复制源代码
|
||||
COPY . .
|
||||
# 构建应用
|
||||
RUN npm run build
|
||||
RUN pnpm run build
|
||||
|
||||
# 生产阶段
|
||||
FROM node:22-alpine AS production
|
||||
@@ -38,18 +38,18 @@ RUN addgroup -g 1001 -S nodejs
|
||||
RUN adduser -S nestjs -u 1001
|
||||
# 设置工作目录
|
||||
WORKDIR /app
|
||||
# 复制 package.json
|
||||
COPY package*.json ./
|
||||
# 安装生产依赖
|
||||
RUN npm install --only=production && npm cache clean --force
|
||||
# 复制 package.json 和 pnpm-lock.yaml
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
# 安装 pnpm 并安装生产依赖
|
||||
RUN npm install -g pnpm && pnpm install --prod --frozen-lockfile
|
||||
# 从构建阶段复制构建产物
|
||||
COPY --from=build --chown=nestjs:nodejs /app/dist ./dist
|
||||
# 切换到非root用户
|
||||
USER nestjs
|
||||
# 暴露端口
|
||||
EXPOSE 3000
|
||||
EXPOSE 3003
|
||||
# 健康检查
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD curl -f http://localhost:3000/health || exit 1
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:3003/health || exit 1
|
||||
# 启动应用
|
||||
CMD ["node", "dist/src/main"]
|
||||
CMD ["node", "dist/main"]
|
||||
|
||||
342
STRIPE_INTEGRATION_GUIDE.md
Normal file
342
STRIPE_INTEGRATION_GUIDE.md
Normal file
@@ -0,0 +1,342 @@
|
||||
# Stripe国际支付集成指南
|
||||
|
||||
## 概述
|
||||
|
||||
本项目已成功集成Stripe国际信用卡支付功能,支持多种国际货币(USD、EUR、GBP、JPY等)的在线支付。Stripe适配器已完整实现并集成到现有的统一支付架构中。
|
||||
|
||||
## 功能特性
|
||||
|
||||
### ✅ 已实现功能
|
||||
|
||||
1. **支付订单管理**
|
||||
- 创建Stripe PaymentIntent
|
||||
- 查询支付状态
|
||||
- 支持多种国际货币
|
||||
|
||||
2. **Webhook处理**
|
||||
- 安全的签名验证
|
||||
- 异步支付结果通知
|
||||
- 订单状态同步
|
||||
|
||||
3. **退款管理**
|
||||
- 申请退款
|
||||
- 查询退款状态
|
||||
- 部分退款支持
|
||||
|
||||
4. **安全特性**
|
||||
- Webhook签名验证
|
||||
- 环境变量配置管理
|
||||
- 错误处理和日志记录
|
||||
|
||||
5. **API接口**
|
||||
- RESTful API设计
|
||||
- Swagger文档自动生成
|
||||
- 完整的DTO验证
|
||||
|
||||
## 环境配置
|
||||
|
||||
### 必需的环境变量
|
||||
|
||||
在`.env`文件中添加以下Stripe配置:
|
||||
|
||||
```bash
|
||||
# Stripe配置
|
||||
STRIPE_SECRET_KEY=sk_test_your_secret_key_here
|
||||
STRIPE_PUBLISHABLE_KEY=pk_test_your_publishable_key_here
|
||||
STRIPE_WEBHOOK_SECRET=whsec_your_webhook_secret_here
|
||||
|
||||
# 服务器配置(用于回调URL)
|
||||
SERVER_URL=https://your-domain.com
|
||||
```
|
||||
|
||||
### 获取Stripe密钥
|
||||
|
||||
1. **注册Stripe账号**
|
||||
- 访问 [stripe.com](https://stripe.com)
|
||||
- 创建账号并完成验证
|
||||
|
||||
2. **获取API密钥**
|
||||
- 登录Stripe Dashboard
|
||||
- 进入"开发者" -> "API密钥"
|
||||
- 复制可发布密钥和密钥
|
||||
|
||||
3. **设置Webhook**
|
||||
- 进入"开发者" -> "Webhook"
|
||||
- 添加端点:`https://your-domain.com/api/v1/payment/stripe/webhook`
|
||||
- 选择事件:`payment_intent.succeeded`
|
||||
- 复制签名密钥
|
||||
|
||||
## API使用指南
|
||||
|
||||
### 1. 创建支付订单
|
||||
|
||||
```bash
|
||||
POST /api/v1/payment/stripe/create-order
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"userId": "user_123",
|
||||
"businessType": "credit_purchase",
|
||||
"businessId": "credits_500",
|
||||
"amount": 4800,
|
||||
"currency": "USD",
|
||||
"description": "购买500积分包",
|
||||
"customerEmail": "user@example.com"
|
||||
}
|
||||
```
|
||||
|
||||
**响应示例:**
|
||||
```json
|
||||
{
|
||||
"orderId": "order_123",
|
||||
"paymentIntentId": "pi_test_123",
|
||||
"clientSecret": "pi_test_123_secret_abc",
|
||||
"publishableKey": "pk_test_123",
|
||||
"amount": 4800,
|
||||
"currency": "USD",
|
||||
"status": "pending",
|
||||
"createdAt": "2024-01-01T00:00:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 查询订单状态
|
||||
|
||||
```bash
|
||||
GET /api/v1/payment/stripe/order/{orderId}
|
||||
```
|
||||
|
||||
### 3. 申请退款
|
||||
|
||||
```bash
|
||||
POST /api/v1/payment/stripe/refund
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"orderId": "order_123",
|
||||
"refundAmount": 2400,
|
||||
"reason": "用户申请退款"
|
||||
}
|
||||
```
|
||||
|
||||
### 4. 查询退款状态
|
||||
|
||||
```bash
|
||||
GET /api/v1/payment/stripe/refund/{refundId}
|
||||
```
|
||||
|
||||
### 5. 获取支持的货币
|
||||
|
||||
```bash
|
||||
GET /api/v1/payment/stripe/supported-currencies
|
||||
```
|
||||
|
||||
## 前端集成示例
|
||||
|
||||
### React + Stripe Elements
|
||||
|
||||
```javascript
|
||||
import { loadStripe } from '@stripe/stripe-js';
|
||||
import {
|
||||
Elements,
|
||||
CardElement,
|
||||
useStripe,
|
||||
useElements
|
||||
} from '@stripe/react-stripe-js';
|
||||
|
||||
const stripePromise = loadStripe('pk_test_your_publishable_key');
|
||||
|
||||
function PaymentForm({ clientSecret }) {
|
||||
const stripe = useStripe();
|
||||
const elements = useElements();
|
||||
|
||||
const handleSubmit = async (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
if (!stripe || !elements) return;
|
||||
|
||||
const cardElement = elements.getElement(CardElement);
|
||||
|
||||
const { error, paymentIntent } = await stripe.confirmCardPayment(
|
||||
clientSecret,
|
||||
{
|
||||
payment_method: {
|
||||
card: cardElement,
|
||||
billing_details: {
|
||||
email: 'user@example.com',
|
||||
},
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
if (error) {
|
||||
console.error('支付失败:', error);
|
||||
} else {
|
||||
console.log('支付成功:', paymentIntent);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<CardElement />
|
||||
<button type="submit" disabled={!stripe}>
|
||||
支付
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [clientSecret, setClientSecret] = useState('');
|
||||
|
||||
// 创建支付订单获取clientSecret
|
||||
useEffect(() => {
|
||||
fetch('/api/v1/payment/stripe/create-order', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
userId: 'user_123',
|
||||
businessType: 'credit_purchase',
|
||||
businessId: 'credits_500',
|
||||
amount: 4800,
|
||||
currency: 'USD'
|
||||
})
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(data => setClientSecret(data.clientSecret));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Elements stripe={stripePromise}>
|
||||
<PaymentForm clientSecret={clientSecret} />
|
||||
</Elements>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## 支持的货币
|
||||
|
||||
| 货币代码 | 货币名称 | 符号 | 最小单位 |
|
||||
|---------|---------|------|---------|
|
||||
| USD | 美元 | $ | 2位小数 |
|
||||
| EUR | 欧元 | € | 2位小数 |
|
||||
| GBP | 英镑 | £ | 2位小数 |
|
||||
| JPY | 日元 | ¥ | 0位小数 |
|
||||
| AUD | 澳元 | A$ | 2位小数 |
|
||||
| CAD | 加元 | C$ | 2位小数 |
|
||||
| SGD | 新加坡元 | S$ | 2位小数 |
|
||||
| HKD | 港币 | HK$ | 2位小数 |
|
||||
|
||||
## 测试
|
||||
|
||||
### 运行单元测试
|
||||
|
||||
```bash
|
||||
# 运行Stripe适配器测试
|
||||
npm test stripe-payment.adapter.spec.ts
|
||||
|
||||
# 运行所有支付相关测试
|
||||
npm test payment
|
||||
```
|
||||
|
||||
### 测试用卡号
|
||||
|
||||
Stripe提供以下测试卡号:
|
||||
|
||||
- **成功支付**: 4242 4242 4242 4242
|
||||
- **需要验证**: 4000 0025 0000 3155
|
||||
- **被拒绝**: 4000 0000 0000 0002
|
||||
- **余额不足**: 4000 0000 0000 9995
|
||||
|
||||
使用任意未来日期作为过期时间,任意3位数字作为CVC。
|
||||
|
||||
## 监控和日志
|
||||
|
||||
### 日志级别
|
||||
|
||||
- **INFO**: 正常的支付流程日志
|
||||
- **WARN**: 配置问题和可恢复错误
|
||||
- **ERROR**: 支付失败和系统错误
|
||||
|
||||
### 关键监控指标
|
||||
|
||||
1. **支付成功率**
|
||||
2. **响应时间**
|
||||
3. **错误率**
|
||||
4. **退款处理时间**
|
||||
|
||||
## 安全注意事项
|
||||
|
||||
1. **密钥管理**
|
||||
- 绝不在客户端暴露密钥
|
||||
- 使用环境变量存储敏感信息
|
||||
- 定期轮换API密钥
|
||||
|
||||
2. **Webhook安全**
|
||||
- 始终验证Webhook签名
|
||||
- 使用HTTPS端点
|
||||
- 实现幂等性处理
|
||||
|
||||
3. **金额处理**
|
||||
- 服务端验证所有金额
|
||||
- 使用整数避免浮点精度问题
|
||||
- 实现金额限制和验证
|
||||
|
||||
## 故障排查
|
||||
|
||||
### 常见问题
|
||||
|
||||
1. **支付失败**
|
||||
- 检查Stripe密钥配置
|
||||
- 验证货币代码格式
|
||||
- 确认金额范围有效
|
||||
|
||||
2. **Webhook失败**
|
||||
- 验证端点URL可访问
|
||||
- 检查签名密钥配置
|
||||
- 确认事件类型选择
|
||||
|
||||
3. **退款问题**
|
||||
- 确认订单支付成功
|
||||
- 检查退款金额限制
|
||||
- 验证Charge ID存在
|
||||
|
||||
### 调试步骤
|
||||
|
||||
1. 查看应用日志
|
||||
2. 检查Stripe Dashboard事件
|
||||
3. 验证Webhook配置
|
||||
4. 测试API端点连通性
|
||||
|
||||
## 部署注意事项
|
||||
|
||||
### 生产环境
|
||||
|
||||
1. 使用生产环境Stripe密钥
|
||||
2. 配置HTTPS回调地址
|
||||
3. 设置适当的日志级别
|
||||
4. 配置监控和告警
|
||||
|
||||
### 性能优化
|
||||
|
||||
1. 使用数据库连接池
|
||||
2. 实现适当的缓存策略
|
||||
3. 配置合理的超时时间
|
||||
4. 监控内存和CPU使用
|
||||
|
||||
## 更新日志
|
||||
|
||||
- **v1.0.0**: 初始Stripe集成实现
|
||||
- 支付订单创建
|
||||
- Webhook处理
|
||||
- 退款功能
|
||||
- 多货币支持
|
||||
- 完整测试覆盖
|
||||
|
||||
---
|
||||
|
||||
## 技术支持
|
||||
|
||||
如有问题,请参考:
|
||||
1. [Stripe官方文档](https://stripe.com/docs)
|
||||
2. [项目API文档](/docs)
|
||||
3. 查看应用日志和错误信息
|
||||
63
auth.md
Normal file
63
auth.md
Normal file
@@ -0,0 +1,63 @@
|
||||
当前项目架构分析
|
||||
|
||||
现有认证系统:
|
||||
- 基于适配器模式的多平台用户认证系统
|
||||
- 支持微信、字节跳动等小程序平台登录
|
||||
- 统一用户实体(UserEntity)和平台用户实体(PlatformUserEntity)
|
||||
- 无Passport.js依赖,采用自定义适配器架构
|
||||
|
||||
缺失依赖:
|
||||
- 项目未安装@nestjs/passport和passport-google-oauth20
|
||||
- 需要安装Google OAuth相关依赖
|
||||
|
||||
Google登录集成实施方案
|
||||
|
||||
第一阶段:依赖安装
|
||||
|
||||
1. 安装必要依赖包
|
||||
pnpm add @nestjs/passport passport passport-google-oauth20 @types/passport-google-oauth20
|
||||
|
||||
第二阶段:平台类型扩展
|
||||
|
||||
2. 更新PlatformType枚举 (src/entities/platform-user.entity.ts:29)
|
||||
- 添加GOOGLE = 'google'选项
|
||||
|
||||
第三阶段:Google OAuth适配器实现
|
||||
|
||||
3. 创建Google适配器 (src/platform/adapters/google.adapter.ts)
|
||||
- 继承BaseAdapter基类
|
||||
- 实现Google OAuth 2.0认证流程
|
||||
- 支持访问令牌和刷新令牌管理
|
||||
4. 创建Google Strategy (src/auth/strategies/google.strategy.ts)
|
||||
- 实现Passport Google OAuth2.0策略
|
||||
- 与适配器系统集成
|
||||
|
||||
第四阶段:认证模块构建
|
||||
|
||||
5. 创建Auth模块 (src/auth/)
|
||||
- auth.module.ts: 配置Passport和Google Strategy
|
||||
- auth.controller.ts: Google OAuth路由端点
|
||||
- auth.guard.ts: Google认证守卫
|
||||
|
||||
第五阶段:系统集成
|
||||
|
||||
6. 更新PlatformModule (src/platform/platform.module.ts:66)
|
||||
- 注册GoogleAdapter到适配器工厂
|
||||
7. 更新AppModule (src/app.module.ts:58)
|
||||
- 导入新建的AuthModule
|
||||
|
||||
第六阶段:环境配置
|
||||
|
||||
8. 配置环境变量
|
||||
- GOOGLE_CLIENT_ID: Google OAuth客户端ID
|
||||
- GOOGLE_CLIENT_SECRET: Google OAuth客户端密钥
|
||||
- GOOGLE_CALLBACK_URL: OAuth回调URL
|
||||
|
||||
第七阶段:API端点
|
||||
|
||||
9. 实现Google登录端点
|
||||
- GET /auth/google: 启动Google OAuth流程
|
||||
- GET /auth/google/callback: 处理OAuth回调
|
||||
- POST /auth/google/token: 处理移动端token交换
|
||||
|
||||
这个方案将Google登录无缝集成到现有的多平台认证架构中,保持系统的一致性和可扩展性。
|
||||
@@ -35,14 +35,18 @@
|
||||
"@nestjs/config": "^4.0.2",
|
||||
"@nestjs/core": "^11.0.1",
|
||||
"@nestjs/jwt": "^11.0.0",
|
||||
"@nestjs/passport": "^11.0.5",
|
||||
"@nestjs/platform-express": "^11.0.1",
|
||||
"@nestjs/swagger": "^11.2.0",
|
||||
"@nestjs/typeorm": "^11.0.0",
|
||||
"@types/passport-google-oauth20": "^2.0.16",
|
||||
"axios": "^1.11.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.2",
|
||||
"form-data": "^4.0.4",
|
||||
"mysql2": "^3.9.7",
|
||||
"passport": "^0.7.0",
|
||||
"passport-google-oauth20": "^2.0.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
"sharp": "^0.34.4",
|
||||
|
||||
118
pnpm-lock.yaml
generated
118
pnpm-lock.yaml
generated
@@ -23,6 +23,9 @@ importers:
|
||||
'@nestjs/jwt':
|
||||
specifier: ^11.0.0
|
||||
version: 11.0.0(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))
|
||||
'@nestjs/passport':
|
||||
specifier: ^11.0.5
|
||||
version: 11.0.5(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0)
|
||||
'@nestjs/platform-express':
|
||||
specifier: ^11.0.1
|
||||
version: 11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6)
|
||||
@@ -32,6 +35,9 @@ importers:
|
||||
'@nestjs/typeorm':
|
||||
specifier: ^11.0.0
|
||||
version: 11.0.0(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.26(mysql2@3.14.4)(reflect-metadata@0.2.2)(ts-node@10.9.2(@types/node@22.18.0)(typescript@5.9.2)))
|
||||
'@types/passport-google-oauth20':
|
||||
specifier: ^2.0.16
|
||||
version: 2.0.16
|
||||
axios:
|
||||
specifier: ^1.11.0
|
||||
version: 1.11.0
|
||||
@@ -47,6 +53,12 @@ importers:
|
||||
mysql2:
|
||||
specifier: ^3.9.7
|
||||
version: 3.14.4
|
||||
passport:
|
||||
specifier: ^0.7.0
|
||||
version: 0.7.0
|
||||
passport-google-oauth20:
|
||||
specifier: ^2.0.0
|
||||
version: 2.0.0
|
||||
reflect-metadata:
|
||||
specifier: ^0.2.2
|
||||
version: 0.2.2
|
||||
@@ -888,6 +900,12 @@ packages:
|
||||
class-validator:
|
||||
optional: true
|
||||
|
||||
'@nestjs/passport@11.0.5':
|
||||
resolution: {integrity: sha512-ulQX6mbjlws92PIM15Naes4F4p2JoxGnIJuUsdXQPT+Oo2sqQmENEZXM7eYuimocfHnKlcfZOuyzbA33LwUlOQ==}
|
||||
peerDependencies:
|
||||
'@nestjs/common': ^10.0.0 || ^11.0.0
|
||||
passport: ^0.5.0 || ^0.6.0 || ^0.7.0
|
||||
|
||||
'@nestjs/platform-express@11.1.6':
|
||||
resolution: {integrity: sha512-HErwPmKnk+loTq8qzu1up+k7FC6Kqa8x6lJ4cDw77KnTxLzsCaPt+jBvOq6UfICmfqcqCCf3dKXg+aObQp+kIQ==}
|
||||
peerDependencies:
|
||||
@@ -1073,6 +1091,18 @@ packages:
|
||||
'@types/node@22.18.0':
|
||||
resolution: {integrity: sha512-m5ObIqwsUp6BZzyiy4RdZpzWGub9bqLJMvZDD0QMXhxjqMHMENlj+SqF5QxoUwaQNFe+8kz8XM8ZQhqkQPTgMQ==}
|
||||
|
||||
'@types/oauth@0.9.6':
|
||||
resolution: {integrity: sha512-H9TRCVKBNOhZZmyHLqFt9drPM9l+ShWiqqJijU1B8P3DX3ub84NjxDuy+Hjrz+fEca5Kwip3qPMKNyiLgNJtIA==}
|
||||
|
||||
'@types/passport-google-oauth20@2.0.16':
|
||||
resolution: {integrity: sha512-ayXK2CJ7uVieqhYOc6k/pIr5pcQxOLB6kBev+QUGS7oEZeTgIs1odDobXRqgfBPvXzl0wXCQHftV5220czZCPA==}
|
||||
|
||||
'@types/passport-oauth2@1.8.0':
|
||||
resolution: {integrity: sha512-6//z+4orIOy/g3zx17HyQ71GSRK4bs7Sb+zFasRoc2xzlv7ZCJ+vkDBYFci8U6HY+or6Zy7ajf4mz4rK7nsWJQ==}
|
||||
|
||||
'@types/passport@1.0.17':
|
||||
resolution: {integrity: sha512-aciLyx+wDwT2t2/kJGJR2AEeBz0nJU4WuRX04Wu9Dqc5lSUtwu0WERPHYsLhF9PtseiAMPBGNUOtFjxZ56prsg==}
|
||||
|
||||
'@types/qs@6.14.0':
|
||||
resolution: {integrity: sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==}
|
||||
|
||||
@@ -1486,6 +1516,10 @@ packages:
|
||||
base64-js@1.5.1:
|
||||
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
|
||||
|
||||
base64url@3.0.1:
|
||||
resolution: {integrity: sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
|
||||
bl@4.1.0:
|
||||
resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==}
|
||||
|
||||
@@ -2727,6 +2761,9 @@ packages:
|
||||
resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
oauth@0.10.2:
|
||||
resolution: {integrity: sha512-JtFnB+8nxDEXgNyniwz573xxbKSOu3R8D40xQKqcjwJ2CDkYqUDI53o6IuzDJBx60Z8VKCm271+t8iFjakrl8Q==}
|
||||
|
||||
object-assign@4.1.1:
|
||||
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -2789,6 +2826,22 @@ packages:
|
||||
resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
passport-google-oauth20@2.0.0:
|
||||
resolution: {integrity: sha512-KSk6IJ15RoxuGq7D1UKK/8qKhNfzbLeLrG3gkLZ7p4A6DBCcv7xpyQwuXtWdpyR0+E0mwkpjY1VfPOhxQrKzdQ==}
|
||||
engines: {node: '>= 0.4.0'}
|
||||
|
||||
passport-oauth2@1.8.0:
|
||||
resolution: {integrity: sha512-cjsQbOrXIDE4P8nNb3FQRCCmJJ/utnFKEz2NX209f7KOHPoX18gF7gBzBbLLsj2/je4KrgiwLLGjf0lm9rtTBA==}
|
||||
engines: {node: '>= 0.4.0'}
|
||||
|
||||
passport-strategy@1.0.0:
|
||||
resolution: {integrity: sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA==}
|
||||
engines: {node: '>= 0.4.0'}
|
||||
|
||||
passport@0.7.0:
|
||||
resolution: {integrity: sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==}
|
||||
engines: {node: '>= 0.4.0'}
|
||||
|
||||
path-exists@4.0.0:
|
||||
resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -2817,6 +2870,9 @@ packages:
|
||||
resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
pause@0.0.1:
|
||||
resolution: {integrity: sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==}
|
||||
|
||||
picocolors@1.1.1:
|
||||
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
||||
|
||||
@@ -3394,6 +3450,9 @@ packages:
|
||||
engines: {node: '>=0.8.0'}
|
||||
hasBin: true
|
||||
|
||||
uid2@0.0.4:
|
||||
resolution: {integrity: sha512-IevTus0SbGwQzYh3+fRsAMTVVPOoIVufzacXcHPmdlle1jUpq7BRL+mw3dgeLanvGZdwwbWhRV6XrcFNdBmjWA==}
|
||||
|
||||
uid@2.0.2:
|
||||
resolution: {integrity: sha512-u3xV3X7uzvi5b1MncmZo3i2Aw222Zk1keqLA1YkHldREkAhAqi65wuPfe7lHx8H/Wzy+8CE7S7uS3jekIM5s8g==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -3428,6 +3487,10 @@ packages:
|
||||
util-deprecate@1.0.2:
|
||||
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
||||
|
||||
utils-merge@1.0.1:
|
||||
resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==}
|
||||
engines: {node: '>= 0.4.0'}
|
||||
|
||||
uuid@11.1.0:
|
||||
resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==}
|
||||
hasBin: true
|
||||
@@ -4401,6 +4464,11 @@ snapshots:
|
||||
class-transformer: 0.5.1
|
||||
class-validator: 0.14.2
|
||||
|
||||
'@nestjs/passport@11.0.5(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0)':
|
||||
dependencies:
|
||||
'@nestjs/common': 11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
passport: 0.7.0
|
||||
|
||||
'@nestjs/platform-express@11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6)':
|
||||
dependencies:
|
||||
'@nestjs/common': 11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
@@ -4618,6 +4686,26 @@ snapshots:
|
||||
dependencies:
|
||||
undici-types: 6.21.0
|
||||
|
||||
'@types/oauth@0.9.6':
|
||||
dependencies:
|
||||
'@types/node': 22.18.0
|
||||
|
||||
'@types/passport-google-oauth20@2.0.16':
|
||||
dependencies:
|
||||
'@types/express': 5.0.3
|
||||
'@types/passport': 1.0.17
|
||||
'@types/passport-oauth2': 1.8.0
|
||||
|
||||
'@types/passport-oauth2@1.8.0':
|
||||
dependencies:
|
||||
'@types/express': 5.0.3
|
||||
'@types/oauth': 0.9.6
|
||||
'@types/passport': 1.0.17
|
||||
|
||||
'@types/passport@1.0.17':
|
||||
dependencies:
|
||||
'@types/express': 5.0.3
|
||||
|
||||
'@types/qs@6.14.0': {}
|
||||
|
||||
'@types/range-parser@1.2.7': {}
|
||||
@@ -5060,6 +5148,8 @@ snapshots:
|
||||
|
||||
base64-js@1.5.1: {}
|
||||
|
||||
base64url@3.0.1: {}
|
||||
|
||||
bl@4.1.0:
|
||||
dependencies:
|
||||
buffer: 5.7.1
|
||||
@@ -6465,6 +6555,8 @@ snapshots:
|
||||
dependencies:
|
||||
path-key: 3.1.1
|
||||
|
||||
oauth@0.10.2: {}
|
||||
|
||||
object-assign@4.1.1: {}
|
||||
|
||||
object-inspect@1.13.4: {}
|
||||
@@ -6535,6 +6627,26 @@ snapshots:
|
||||
|
||||
parseurl@1.3.3: {}
|
||||
|
||||
passport-google-oauth20@2.0.0:
|
||||
dependencies:
|
||||
passport-oauth2: 1.8.0
|
||||
|
||||
passport-oauth2@1.8.0:
|
||||
dependencies:
|
||||
base64url: 3.0.1
|
||||
oauth: 0.10.2
|
||||
passport-strategy: 1.0.0
|
||||
uid2: 0.0.4
|
||||
utils-merge: 1.0.1
|
||||
|
||||
passport-strategy@1.0.0: {}
|
||||
|
||||
passport@0.7.0:
|
||||
dependencies:
|
||||
passport-strategy: 1.0.0
|
||||
pause: 0.0.1
|
||||
utils-merge: 1.0.1
|
||||
|
||||
path-exists@4.0.0: {}
|
||||
|
||||
path-is-absolute@1.0.1: {}
|
||||
@@ -6555,6 +6667,8 @@ snapshots:
|
||||
|
||||
path-type@4.0.0: {}
|
||||
|
||||
pause@0.0.1: {}
|
||||
|
||||
picocolors@1.1.1: {}
|
||||
|
||||
picomatch@2.3.1: {}
|
||||
@@ -7112,6 +7226,8 @@ snapshots:
|
||||
uglify-js@3.19.3:
|
||||
optional: true
|
||||
|
||||
uid2@0.0.4: {}
|
||||
|
||||
uid@2.0.2:
|
||||
dependencies:
|
||||
'@lukeed/csprng': 1.1.0
|
||||
@@ -7160,6 +7276,8 @@ snapshots:
|
||||
|
||||
util-deprecate@1.0.2: {}
|
||||
|
||||
utils-merge@1.0.1: {}
|
||||
|
||||
uuid@11.1.0: {}
|
||||
|
||||
v8-compile-cache-lib@3.0.1: {}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Body, Controller, Post } from '@nestjs/common';
|
||||
import { Body, Controller, Post, Get } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Repository, DataSource } from 'typeorm';
|
||||
import {
|
||||
TemplateExecutionEntity,
|
||||
ExecutionStatus,
|
||||
@@ -12,7 +12,25 @@ export class AppController {
|
||||
constructor(
|
||||
@InjectRepository(TemplateExecutionEntity)
|
||||
private readonly executionRepository: Repository<TemplateExecutionEntity>,
|
||||
private readonly dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
@Get('health')
|
||||
async health(): Promise<ApiResponse<{ status: string; database: string; timestamp: string }>> {
|
||||
try {
|
||||
// 检查数据库连接
|
||||
await this.dataSource.query('SELECT 1');
|
||||
|
||||
return ResponseUtil.success({
|
||||
status: 'healthy',
|
||||
database: 'connected',
|
||||
timestamp: new Date().toISOString(),
|
||||
}, '服务健康');
|
||||
} catch (error) {
|
||||
return ResponseUtil.error(`健康检查失败: ${error.message}`, 500);
|
||||
}
|
||||
}
|
||||
|
||||
@Post('callback')
|
||||
async callback(@Body() body: any): Promise<ApiResponse<string | null>> {
|
||||
console.log(`🚀 [回调] 开始执行回调`);
|
||||
|
||||
@@ -23,6 +23,7 @@ import { PlatformModule } from './platform/platform.module';
|
||||
import { ContentModerationModule } from './content-moderation/content-moderation.module';
|
||||
import { PaymentModule } from './payment/payment.module';
|
||||
import { PaymentTestController } from './controllers/payment-test.controller';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -56,6 +57,7 @@ import { PaymentTestController } from './controllers/payment-test.controller';
|
||||
PlatformModule,
|
||||
ContentModerationModule,
|
||||
PaymentModule,
|
||||
AuthModule,
|
||||
],
|
||||
controllers: [AppController, TemplateController, ImageCompositionController, PaymentTestController],
|
||||
providers: [
|
||||
|
||||
81
src/auth/auth.controller.ts
Normal file
81
src/auth/auth.controller.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { Controller, Get, Post, Body, UseGuards, Req, Res, HttpCode, HttpStatus } from '@nestjs/common';
|
||||
import { Response } from 'express';
|
||||
import { ApiTags, ApiOperation, ApiResponse, ApiBody } from '@nestjs/swagger';
|
||||
import { GoogleAuthGuard } from './guards/google.guard';
|
||||
import { UnifiedUserService } from '../platform/services/unified-user.service';
|
||||
import { PlatformType } from '../entities/platform-user.entity';
|
||||
|
||||
class GoogleTokenDto {
|
||||
accessToken: string;
|
||||
refreshToken?: string;
|
||||
idToken?: string;
|
||||
}
|
||||
|
||||
@ApiTags('认证')
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(private readonly unifiedUserService: UnifiedUserService) {}
|
||||
|
||||
@Get('google')
|
||||
@UseGuards(GoogleAuthGuard)
|
||||
@ApiOperation({ summary: '启动Google OAuth登录流程' })
|
||||
@ApiResponse({ status: 302, description: '重定向到Google OAuth授权页面' })
|
||||
async googleAuth(@Req() req) {
|
||||
// Google OAuth重定向
|
||||
}
|
||||
|
||||
@Get('google/callback')
|
||||
@UseGuards(GoogleAuthGuard)
|
||||
@ApiOperation({ summary: 'Google OAuth回调处理' })
|
||||
@ApiResponse({ status: 200, description: '认证成功,返回用户信息和JWT令牌' })
|
||||
@ApiResponse({ status: 401, description: '认证失败' })
|
||||
async googleAuthCallback(@Req() req, @Res() res: Response) {
|
||||
const authResult = req.user;
|
||||
|
||||
// 重定向到前端应用,携带认证信息
|
||||
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:4333/api/auth/callback/google';
|
||||
const redirectUrl = `${frontendUrl}/auth/callback?success=true&token=${encodeURIComponent(authResult.tokens.accessToken)}&user=${encodeURIComponent(JSON.stringify(authResult.user))}`;
|
||||
|
||||
return res.redirect(redirectUrl);
|
||||
}
|
||||
|
||||
@Post('google/token')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Google令牌交换' })
|
||||
@ApiBody({ type: GoogleTokenDto })
|
||||
@ApiResponse({ status: 200, description: '令牌交换成功,返回用户信息和JWT令牌' })
|
||||
@ApiResponse({ status: 400, description: '令牌无效或交换失败' })
|
||||
async googleTokenExchange(@Body() tokenDto: GoogleTokenDto) {
|
||||
try {
|
||||
// 使用访问令牌获取用户信息并登录
|
||||
const loginData = {
|
||||
code: tokenDto.accessToken,
|
||||
};
|
||||
|
||||
const authResult = await this.unifiedUserService.login(PlatformType.GOOGLE, loginData);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: authResult,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@Get('google/profile')
|
||||
@ApiOperation({ summary: '获取Google用户资料' })
|
||||
@ApiResponse({ status: 200, description: '成功获取用户资料' })
|
||||
@ApiResponse({ status: 401, description: '未授权' })
|
||||
async getGoogleProfile(@Req() req) {
|
||||
// 这里可以从JWT令牌中解析用户信息
|
||||
// 或者使用Google API获取最新的用户信息
|
||||
return {
|
||||
message: '需要实现JWT验证逻辑',
|
||||
user: req.user,
|
||||
};
|
||||
}
|
||||
}
|
||||
23
src/auth/auth.module.ts
Normal file
23
src/auth/auth.module.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { GoogleStrategy } from './strategies/google.strategy';
|
||||
import { GoogleAuthGuard } from './guards/google.guard';
|
||||
import { PlatformModule } from '../platform/platform.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
PassportModule,
|
||||
PlatformModule,
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [
|
||||
GoogleStrategy,
|
||||
GoogleAuthGuard,
|
||||
],
|
||||
exports: [
|
||||
GoogleStrategy,
|
||||
GoogleAuthGuard,
|
||||
],
|
||||
})
|
||||
export class AuthModule {}
|
||||
5
src/auth/guards/google.guard.ts
Normal file
5
src/auth/guards/google.guard.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
@Injectable()
|
||||
export class GoogleAuthGuard extends AuthGuard('google') {}
|
||||
57
src/auth/strategies/google.strategy.ts
Normal file
57
src/auth/strategies/google.strategy.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { Strategy, VerifyCallback } from 'passport-google-oauth20';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { UnifiedUserService } from '../../platform/services/unified-user.service';
|
||||
import { PlatformType } from '../../entities/platform-user.entity';
|
||||
|
||||
@Injectable()
|
||||
export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
|
||||
constructor(
|
||||
private configService: ConfigService,
|
||||
private unifiedUserService: UnifiedUserService,
|
||||
) {
|
||||
super({
|
||||
clientID: configService.get<string>('GOOGLE_CLIENT_ID') || '',
|
||||
clientSecret: configService.get<string>('GOOGLE_CLIENT_SECRET') || '',
|
||||
callbackURL: configService.get<string>('GOOGLE_CALLBACK_URL') || 'http://localhost:3005/api/v1/auth/google/callback',
|
||||
scope: ['email', 'profile'],
|
||||
});
|
||||
console.log({
|
||||
clientID: configService.get<string>('GOOGLE_CLIENT_ID') || '',
|
||||
clientSecret: configService.get<string>('GOOGLE_CLIENT_SECRET') || '',
|
||||
callbackURL: configService.get<string>('GOOGLE_CALLBACK_URL') || 'http://localhost:3005/api/v1/auth/google/callback',
|
||||
scope: ['email', 'profile'],
|
||||
})
|
||||
}
|
||||
|
||||
async validate(
|
||||
accessToken: string,
|
||||
refreshToken: string,
|
||||
profile: any,
|
||||
done: VerifyCallback,
|
||||
): Promise<any> {
|
||||
try {
|
||||
// 构造登录数据,直接使用已经获取的用户信息
|
||||
const loginData = {
|
||||
code: 'google_oauth_direct', // 标识这是通过Passport直接获取的
|
||||
userInfo: {
|
||||
id: profile.id,
|
||||
email: profile.emails?.[0]?.value,
|
||||
name: profile.displayName,
|
||||
picture: profile.photos?.[0]?.value,
|
||||
accessToken,
|
||||
refreshToken,
|
||||
},
|
||||
};
|
||||
|
||||
// 使用统一用户服务进行登录
|
||||
const authResult = await this.unifiedUserService.login(PlatformType.GOOGLE, loginData);
|
||||
|
||||
// 返回完整的认证结果给Passport
|
||||
return done(null, authResult);
|
||||
} catch (error) {
|
||||
return done(error, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,8 @@ export enum PlatformType {
|
||||
KUAISHOU = 'kuaishou', // 快手小程序 - 快手短视频生态
|
||||
H5 = 'h5', // H5网页版 - 浏览器端应用
|
||||
RN = 'rn', // React Native应用 - 原生移动应用
|
||||
STRIPE = 'stripe', // Stripe支付平台 - 国际信用卡支付
|
||||
GOOGLE = 'google', // Google OAuth - Google账号登录
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
541
src/payment/adapters/__tests__/stripe-payment.adapter.spec.ts
Normal file
541
src/payment/adapters/__tests__/stripe-payment.adapter.spec.ts
Normal file
@@ -0,0 +1,541 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { HttpService } from '@nestjs/axios';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Repository } from 'typeorm';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import { StripePaymentAdapter } from '../stripe-payment.adapter';
|
||||
import {
|
||||
PaymentOrderEntity,
|
||||
PaymentTransactionEntity,
|
||||
RefundRecordEntity,
|
||||
} from '../../entities';
|
||||
import {
|
||||
CreatePaymentOrderData,
|
||||
PaymentBusinessType,
|
||||
PaymentOrderStatus,
|
||||
RefundStatus,
|
||||
} from '../../interfaces/payment.interface';
|
||||
import { PlatformType } from '../../../entities/platform-user.entity';
|
||||
import Stripe from 'stripe';
|
||||
|
||||
// Mock Stripe
|
||||
jest.mock('stripe');
|
||||
|
||||
describe('StripePaymentAdapter', () => {
|
||||
let adapter: StripePaymentAdapter;
|
||||
let httpService: HttpService;
|
||||
let configService: ConfigService;
|
||||
let paymentOrderRepository: Repository<PaymentOrderEntity>;
|
||||
let paymentTransactionRepository: Repository<PaymentTransactionEntity>;
|
||||
let refundRecordRepository: Repository<RefundRecordEntity>;
|
||||
let mockStripe: jest.Mocked<Stripe>;
|
||||
|
||||
const mockPaymentOrder = {
|
||||
id: 'order_123',
|
||||
orderNo: 'ST1640995200ABC123',
|
||||
userId: 'user_123',
|
||||
platform: PlatformType.STRIPE,
|
||||
thirdPartyOrderId: 'pi_test_123',
|
||||
amount: 4800,
|
||||
currency: 'USD',
|
||||
description: '购买500积分包',
|
||||
status: PaymentOrderStatus.PENDING,
|
||||
businessType: PaymentBusinessType.CREDIT_PURCHASE,
|
||||
businessId: 'credits_500',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
canRefund: jest.fn().mockReturnValue(true),
|
||||
getRefundableAmount: jest.fn().mockReturnValue(4800),
|
||||
};
|
||||
|
||||
const mockPaymentIntent = {
|
||||
id: 'pi_test_123',
|
||||
client_secret: 'pi_test_123_secret_abc',
|
||||
status: 'succeeded' as Stripe.PaymentIntent.Status,
|
||||
amount: 4800,
|
||||
amount_received: 4800,
|
||||
currency: 'usd',
|
||||
charges: {
|
||||
data: [
|
||||
{
|
||||
id: 'ch_test_123',
|
||||
created: 1640995200,
|
||||
},
|
||||
],
|
||||
},
|
||||
metadata: {
|
||||
orderNo: 'ST1640995200ABC123',
|
||||
userId: 'user_123',
|
||||
businessType: 'credit_purchase',
|
||||
businessId: 'credits_500',
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
StripePaymentAdapter,
|
||||
{
|
||||
provide: HttpService,
|
||||
useValue: {
|
||||
get: jest.fn(),
|
||||
post: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: ConfigService,
|
||||
useValue: {
|
||||
get: jest.fn((key: string) => {
|
||||
switch (key) {
|
||||
case 'STRIPE_SECRET_KEY':
|
||||
return 'sk_test_123456';
|
||||
case 'STRIPE_WEBHOOK_SECRET':
|
||||
return 'whsec_test_123';
|
||||
case 'STRIPE_PUBLISHABLE_KEY':
|
||||
return 'pk_test_123456';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(PaymentOrderEntity),
|
||||
useValue: {
|
||||
findOne: jest.fn(),
|
||||
save: jest.fn(),
|
||||
update: jest.fn(),
|
||||
create: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(PaymentTransactionEntity),
|
||||
useValue: {
|
||||
save: jest.fn(),
|
||||
update: jest.fn(),
|
||||
create: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(RefundRecordEntity),
|
||||
useValue: {
|
||||
findOne: jest.fn(),
|
||||
save: jest.fn(),
|
||||
update: jest.fn(),
|
||||
create: jest.fn(),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
adapter = module.get<StripePaymentAdapter>(StripePaymentAdapter);
|
||||
httpService = module.get<HttpService>(HttpService);
|
||||
configService = module.get<ConfigService>(ConfigService);
|
||||
paymentOrderRepository = module.get<Repository<PaymentOrderEntity>>(
|
||||
getRepositoryToken(PaymentOrderEntity),
|
||||
);
|
||||
paymentTransactionRepository = module.get<Repository<PaymentTransactionEntity>>(
|
||||
getRepositoryToken(PaymentTransactionEntity),
|
||||
);
|
||||
refundRecordRepository = module.get<Repository<RefundRecordEntity>>(
|
||||
getRepositoryToken(RefundRecordEntity),
|
||||
);
|
||||
|
||||
// Mock Stripe instance
|
||||
mockStripe = {
|
||||
paymentIntents: {
|
||||
create: jest.fn() as jest.MockedFunction<any>,
|
||||
retrieve: jest.fn() as jest.MockedFunction<any>,
|
||||
},
|
||||
refunds: {
|
||||
create: jest.fn() as jest.MockedFunction<any>,
|
||||
retrieve: jest.fn() as jest.MockedFunction<any>,
|
||||
},
|
||||
webhooks: {
|
||||
constructEvent: jest.fn() as jest.MockedFunction<any>,
|
||||
},
|
||||
reporting: {
|
||||
reportRuns: {
|
||||
create: jest.fn() as jest.MockedFunction<any>,
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
|
||||
// Replace the stripe instance
|
||||
(adapter as any).stripe = mockStripe;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('createPaymentOrder', () => {
|
||||
it('应该成功创建支付订单', async () => {
|
||||
// Arrange
|
||||
const orderData: CreatePaymentOrderData = {
|
||||
orderNo: '',
|
||||
amount: 4800,
|
||||
currency: 'USD',
|
||||
description: '购买500积分包',
|
||||
userId: 'user_123',
|
||||
platformUserId: 'stripe_user_123',
|
||||
businessType: PaymentBusinessType.CREDIT_PURCHASE,
|
||||
businessId: 'credits_500',
|
||||
notifyUrl: 'http://localhost:3000/webhook',
|
||||
};
|
||||
|
||||
(mockStripe.paymentIntents.create as jest.MockedFunction<any>).mockResolvedValue(mockPaymentIntent);
|
||||
jest.spyOn(adapter as any, 'savePaymentOrder').mockResolvedValue({
|
||||
...mockPaymentOrder,
|
||||
id: 'order_123',
|
||||
});
|
||||
jest.spyOn(adapter as any, 'savePaymentTransaction').mockResolvedValue({});
|
||||
|
||||
// Act
|
||||
const result = await adapter.createPaymentOrder(orderData);
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual({
|
||||
orderId: 'order_123',
|
||||
thirdPartyOrderId: 'pi_test_123',
|
||||
paymentParams: {
|
||||
paymentIntentId: 'pi_test_123',
|
||||
clientSecret: 'pi_test_123_secret_abc',
|
||||
publishableKey: 'pk_test_123456',
|
||||
},
|
||||
status: PaymentOrderStatus.PENDING,
|
||||
paymentMethod: adapter.paymentMethod,
|
||||
createdAt: expect.any(Date),
|
||||
});
|
||||
|
||||
expect(mockStripe.paymentIntents.create).toHaveBeenCalledWith({
|
||||
amount: 4800,
|
||||
currency: 'usd',
|
||||
description: '购买500积分包',
|
||||
metadata: expect.objectContaining({
|
||||
userId: 'user_123',
|
||||
businessType: PaymentBusinessType.CREDIT_PURCHASE,
|
||||
businessId: 'credits_500',
|
||||
}),
|
||||
automatic_payment_methods: {
|
||||
enabled: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('应该在金额无效时抛出错误', async () => {
|
||||
// Arrange
|
||||
const orderData: CreatePaymentOrderData = {
|
||||
orderNo: '',
|
||||
amount: -100,
|
||||
currency: 'USD',
|
||||
description: '购买500积分包',
|
||||
userId: 'user_123',
|
||||
platformUserId: 'stripe_user_123',
|
||||
businessType: PaymentBusinessType.CREDIT_PURCHASE,
|
||||
businessId: 'credits_500',
|
||||
notifyUrl: 'http://localhost:3000/webhook',
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
await expect(adapter.createPaymentOrder(orderData)).rejects.toThrow(
|
||||
'Stripe支付创建支付订单失败: 订单金额无效',
|
||||
);
|
||||
});
|
||||
|
||||
it('应该在货币类型无效时抛出错误', async () => {
|
||||
// Arrange
|
||||
const orderData: CreatePaymentOrderData = {
|
||||
orderNo: '',
|
||||
amount: 4800,
|
||||
currency: 'INVALID',
|
||||
description: '购买500积分包',
|
||||
userId: 'user_123',
|
||||
platformUserId: 'stripe_user_123',
|
||||
businessType: PaymentBusinessType.CREDIT_PURCHASE,
|
||||
businessId: 'credits_500',
|
||||
notifyUrl: 'http://localhost:3000/webhook',
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
await expect(adapter.createPaymentOrder(orderData)).rejects.toThrow(
|
||||
'Stripe支付创建支付订单失败: 货币类型无效',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('queryPaymentOrder', () => {
|
||||
it('应该成功查询支付订单状态', async () => {
|
||||
// Arrange
|
||||
jest.spyOn(paymentOrderRepository, 'findOne').mockResolvedValue(mockPaymentOrder as any);
|
||||
(mockStripe.paymentIntents.retrieve as jest.MockedFunction<any>).mockResolvedValue(mockPaymentIntent as any);
|
||||
jest.spyOn(adapter as any, 'updatePaymentOrderStatus').mockResolvedValue(undefined);
|
||||
|
||||
// Act
|
||||
const result = await adapter.queryPaymentOrder('order_123');
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual({
|
||||
orderId: 'order_123',
|
||||
thirdPartyOrderId: 'pi_test_123',
|
||||
status: PaymentOrderStatus.PAID,
|
||||
amount: 4800,
|
||||
paidAmount: 4800,
|
||||
paidAt: new Date(1640995200000),
|
||||
thirdPartyTransactionId: 'ch_test_123',
|
||||
rawData: mockPaymentIntent,
|
||||
});
|
||||
|
||||
expect(mockStripe.paymentIntents.retrieve).toHaveBeenCalledWith('pi_test_123');
|
||||
});
|
||||
|
||||
it('应该在订单不存在时抛出错误', async () => {
|
||||
// Arrange
|
||||
jest.spyOn(paymentOrderRepository, 'findOne').mockResolvedValue(null);
|
||||
|
||||
// Act & Assert
|
||||
await expect(adapter.queryPaymentOrder('nonexistent_order')).rejects.toThrow(
|
||||
'Stripe支付查询支付订单失败: 订单不存在',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handlePaymentCallback', () => {
|
||||
it('应该成功处理支付成功的Webhook', async () => {
|
||||
// Arrange
|
||||
const callbackData = {
|
||||
signature: 'test_signature',
|
||||
event: {
|
||||
type: 'payment_intent.succeeded',
|
||||
data: {
|
||||
object: mockPaymentIntent,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
jest.spyOn(adapter, 'verifySignature').mockResolvedValue(true);
|
||||
jest.spyOn(paymentOrderRepository, 'findOne').mockResolvedValue(mockPaymentOrder as any);
|
||||
jest.spyOn(adapter as any, 'updatePaymentOrderStatus').mockResolvedValue(undefined);
|
||||
jest.spyOn(adapter as any, 'savePaymentTransaction').mockResolvedValue({});
|
||||
|
||||
// Act
|
||||
const result = await adapter.handlePaymentCallback(callbackData);
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
orderId: 'order_123',
|
||||
status: PaymentOrderStatus.PAID,
|
||||
paidAt: new Date(1640995200000),
|
||||
thirdPartyTransactionId: 'ch_test_123',
|
||||
responseData: { received: true },
|
||||
});
|
||||
});
|
||||
|
||||
it('应该在签名验证失败时返回失败结果', async () => {
|
||||
// Arrange
|
||||
const callbackData = {
|
||||
signature: 'invalid_signature',
|
||||
event: null,
|
||||
};
|
||||
|
||||
jest.spyOn(adapter, 'verifySignature').mockResolvedValue(false);
|
||||
|
||||
// Act
|
||||
const result = await adapter.handlePaymentCallback(callbackData);
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
orderId: '',
|
||||
status: PaymentOrderStatus.FAILED,
|
||||
errorMessage: 'Webhook签名验证失败',
|
||||
responseData: { received: false },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('refundPayment', () => {
|
||||
it('应该成功申请退款', async () => {
|
||||
// Arrange
|
||||
const refundData = {
|
||||
orderId: 'order_123',
|
||||
refundAmount: 2400,
|
||||
reason: '用户申请退款',
|
||||
refundNo: 'STR1640995200ABC123',
|
||||
thirdPartyOrderId: 'pi_test_123',
|
||||
thirdPartyTransactionId: 'ch_test_123',
|
||||
};
|
||||
|
||||
const mockRefundRecord = {
|
||||
id: 'refund_123',
|
||||
refundNo: 'STR1640995200ABC123',
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
const mockRefund = {
|
||||
id: 're_test_123',
|
||||
status: 'succeeded',
|
||||
failure_reason: null,
|
||||
amount: 2400,
|
||||
};
|
||||
|
||||
jest.spyOn(paymentOrderRepository, 'findOne').mockResolvedValue(mockPaymentOrder as any);
|
||||
jest.spyOn(adapter as any, 'validateOrderOperation').mockReturnValue({ valid: true });
|
||||
jest.spyOn(adapter as any, 'saveRefundRecord').mockResolvedValue(mockRefundRecord);
|
||||
(mockStripe.paymentIntents.retrieve as jest.MockedFunction<any>).mockResolvedValue(mockPaymentIntent as any);
|
||||
(mockStripe.refunds.create as jest.MockedFunction<any>).mockResolvedValue(mockRefund as any);
|
||||
jest.spyOn(adapter as any, 'updateRefundRecord').mockResolvedValue(undefined);
|
||||
|
||||
// Act
|
||||
const result = await adapter.refundPayment(refundData);
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual({
|
||||
refundId: 'refund_123',
|
||||
thirdPartyRefundId: 're_test_123',
|
||||
status: RefundStatus.SUCCESS,
|
||||
refundAmount: 2400,
|
||||
createdAt: mockRefundRecord.createdAt,
|
||||
estimatedArrivalTime: expect.any(Date),
|
||||
});
|
||||
|
||||
expect(mockStripe.refunds.create).toHaveBeenCalledWith({
|
||||
charge: 'ch_test_123',
|
||||
amount: 2400,
|
||||
reason: 'requested_by_customer',
|
||||
metadata: {
|
||||
refundNo: 'STR1640995200ABC123',
|
||||
orderId: 'order_123',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('应该在原订单不存在时抛出错误', async () => {
|
||||
// Arrange
|
||||
const refundData = {
|
||||
orderId: 'nonexistent_order',
|
||||
refundAmount: 2400,
|
||||
reason: '用户申请退款',
|
||||
refundNo: 'STR1640995200ABC123',
|
||||
thirdPartyOrderId: 'pi_test_123',
|
||||
thirdPartyTransactionId: 'ch_test_123',
|
||||
};
|
||||
|
||||
jest.spyOn(paymentOrderRepository, 'findOne').mockResolvedValue(null);
|
||||
|
||||
// Act & Assert
|
||||
await expect(adapter.refundPayment(refundData)).rejects.toThrow(
|
||||
'Stripe支付申请退款失败: 原订单不存在',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifySignature', () => {
|
||||
it('应该成功验证Webhook签名', async () => {
|
||||
// Arrange
|
||||
const callbackData = {
|
||||
body: 'test_body',
|
||||
};
|
||||
const signature = 'test_signature';
|
||||
|
||||
const mockEvent = {
|
||||
type: 'payment_intent.succeeded',
|
||||
data: { object: mockPaymentIntent },
|
||||
};
|
||||
|
||||
(mockStripe.webhooks.constructEvent as jest.MockedFunction<any>).mockReturnValue(mockEvent as any);
|
||||
|
||||
// Act
|
||||
const result = await adapter.verifySignature(callbackData, signature);
|
||||
|
||||
// Assert
|
||||
expect(result).toBe(true);
|
||||
expect(mockStripe.webhooks.constructEvent).toHaveBeenCalledWith(
|
||||
'test_body',
|
||||
'test_signature',
|
||||
'whsec_test_123',
|
||||
);
|
||||
expect(callbackData).toEqual({
|
||||
body: 'test_body',
|
||||
event: mockEvent,
|
||||
});
|
||||
});
|
||||
|
||||
it('应该在签名无效时返回false', async () => {
|
||||
// Arrange
|
||||
const callbackData = {
|
||||
body: 'test_body',
|
||||
};
|
||||
const signature = 'invalid_signature';
|
||||
|
||||
(mockStripe.webhooks.constructEvent as jest.MockedFunction<any>).mockImplementation(() => {
|
||||
throw new Error('Invalid signature');
|
||||
});
|
||||
|
||||
// Act
|
||||
const result = await adapter.verifySignature(callbackData, signature);
|
||||
|
||||
// Assert
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('convertToStripeAmount', () => {
|
||||
it('应该正确转换JPY等零小数位货币', () => {
|
||||
// Arrange & Act
|
||||
const result = (adapter as any).convertToStripeAmount(10000, 'JPY');
|
||||
|
||||
// Assert
|
||||
expect(result).toBe(100); // 10000分 = 100元
|
||||
});
|
||||
|
||||
it('应该保持USD等标准货币的分单位', () => {
|
||||
// Arrange & Act
|
||||
const result = (adapter as any).convertToStripeAmount(4800, 'USD');
|
||||
|
||||
// Assert
|
||||
expect(result).toBe(4800); // 保持4800分
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseStripePaymentStatus', () => {
|
||||
it('应该正确解析各种Stripe支付状态', () => {
|
||||
expect((adapter as any).parseStripePaymentStatus('succeeded')).toBe(
|
||||
PaymentOrderStatus.PAID,
|
||||
);
|
||||
expect((adapter as any).parseStripePaymentStatus('canceled')).toBe(
|
||||
PaymentOrderStatus.CANCELLED,
|
||||
);
|
||||
expect((adapter as any).parseStripePaymentStatus('processing')).toBe(
|
||||
PaymentOrderStatus.PENDING,
|
||||
);
|
||||
expect((adapter as any).parseStripePaymentStatus('requires_payment_method')).toBe(
|
||||
PaymentOrderStatus.PENDING,
|
||||
);
|
||||
expect((adapter as any).parseStripePaymentStatus('unknown_status')).toBe(
|
||||
PaymentOrderStatus.FAILED,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseStripeRefundStatus', () => {
|
||||
it('应该正确解析各种Stripe退款状态', () => {
|
||||
expect((adapter as any).parseStripeRefundStatus('succeeded')).toBe(
|
||||
RefundStatus.SUCCESS,
|
||||
);
|
||||
expect((adapter as any).parseStripeRefundStatus('failed')).toBe(
|
||||
RefundStatus.FAILED,
|
||||
);
|
||||
expect((adapter as any).parseStripeRefundStatus('pending')).toBe(
|
||||
RefundStatus.PROCESSING,
|
||||
);
|
||||
expect((adapter as any).parseStripeRefundStatus('canceled')).toBe(
|
||||
RefundStatus.FAILED,
|
||||
);
|
||||
expect((adapter as any).parseStripeRefundStatus('unknown_status')).toBe(
|
||||
RefundStatus.PENDING,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -57,7 +57,20 @@ export abstract class BasePaymentAdapter implements IPaymentAdapter {
|
||||
protected generateOrderNo(): string {
|
||||
const timestamp = Date.now();
|
||||
const random = Math.random().toString(36).substr(2, 9);
|
||||
const platformPrefix = this.platform === PlatformType.WECHAT ? 'WX' : 'DY';
|
||||
let platformPrefix: string;
|
||||
switch (this.platform) {
|
||||
case PlatformType.WECHAT:
|
||||
platformPrefix = 'WX';
|
||||
break;
|
||||
case PlatformType.BYTEDANCE:
|
||||
platformPrefix = 'DY';
|
||||
break;
|
||||
case PlatformType.STRIPE:
|
||||
platformPrefix = 'ST';
|
||||
break;
|
||||
default:
|
||||
platformPrefix = 'UN'; // Unknown
|
||||
}
|
||||
return `${platformPrefix}${timestamp}${random.toUpperCase()}`;
|
||||
}
|
||||
|
||||
@@ -68,8 +81,20 @@ export abstract class BasePaymentAdapter implements IPaymentAdapter {
|
||||
protected generateRefundNo(): string {
|
||||
const timestamp = Date.now();
|
||||
const random = Math.random().toString(36).substr(2, 9);
|
||||
const platformPrefix =
|
||||
this.platform === PlatformType.WECHAT ? 'WXR' : 'DYR';
|
||||
let platformPrefix: string;
|
||||
switch (this.platform) {
|
||||
case PlatformType.WECHAT:
|
||||
platformPrefix = 'WXR';
|
||||
break;
|
||||
case PlatformType.BYTEDANCE:
|
||||
platformPrefix = 'DYR';
|
||||
break;
|
||||
case PlatformType.STRIPE:
|
||||
platformPrefix = 'STR';
|
||||
break;
|
||||
default:
|
||||
platformPrefix = 'UNR'; // Unknown Refund
|
||||
}
|
||||
return `${platformPrefix}${timestamp}${random.toUpperCase()}`;
|
||||
}
|
||||
|
||||
@@ -310,7 +335,7 @@ export abstract class BasePaymentAdapter implements IPaymentAdapter {
|
||||
* @returns 是否有效
|
||||
*/
|
||||
protected validateCurrency(currency: string): boolean {
|
||||
return ['CNY', 'USD'].includes(currency);
|
||||
return ['CNY', 'USD', 'EUR', 'GBP', 'JPY', 'AUD', 'CAD', 'SGD', 'HKD'].includes(currency);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export { BasePaymentAdapter } from './base-payment.adapter';
|
||||
export { WechatPaymentAdapter } from './wechat-payment.adapter';
|
||||
export { DouyinPaymentAdapter } from './douyin-payment.adapter';
|
||||
export { StripePaymentAdapter } from './stripe-payment.adapter';
|
||||
|
||||
660
src/payment/adapters/stripe-payment.adapter.ts
Normal file
660
src/payment/adapters/stripe-payment.adapter.ts
Normal file
@@ -0,0 +1,660 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { HttpService } from '@nestjs/axios';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Repository } from 'typeorm';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import Stripe from 'stripe';
|
||||
import {
|
||||
CreatePaymentOrderData,
|
||||
PaymentOrderResult,
|
||||
PaymentOrderQueryResult,
|
||||
PaymentCallbackResult,
|
||||
RefundRequestData,
|
||||
RefundResult,
|
||||
RefundQueryResult,
|
||||
BillData,
|
||||
PaymentMethod,
|
||||
PaymentOrderStatus,
|
||||
RefundStatus,
|
||||
} from '../interfaces/payment.interface';
|
||||
|
||||
// 扩展的 PaymentIntent 类型,包含展开的 charges 属性
|
||||
interface ExpandedPaymentIntent extends Stripe.PaymentIntent {
|
||||
charges?: {
|
||||
data: Stripe.Charge[];
|
||||
};
|
||||
}
|
||||
import { PlatformType } from '../../entities/platform-user.entity';
|
||||
import { BasePaymentAdapter } from './base-payment.adapter';
|
||||
import {
|
||||
PaymentOrderEntity,
|
||||
PaymentTransactionEntity,
|
||||
RefundRecordEntity,
|
||||
} from '../entities';
|
||||
import {
|
||||
TransactionType,
|
||||
TransactionStatus,
|
||||
} from '../entities/payment-transaction.entity';
|
||||
|
||||
/**
|
||||
* Stripe支付适配器
|
||||
* 实现Stripe国际信用卡支付的完整流程
|
||||
* 支持创建支付意图、查询状态、处理Webhook、申请退款等功能
|
||||
* 遵循Stripe API规范和最佳实践
|
||||
*/
|
||||
@Injectable()
|
||||
export class StripePaymentAdapter extends BasePaymentAdapter {
|
||||
platform = PlatformType.STRIPE;
|
||||
paymentMethod = PaymentMethod.STRIPE_CARD;
|
||||
|
||||
private readonly stripe: Stripe;
|
||||
private readonly webhookSecret: string;
|
||||
|
||||
constructor(
|
||||
httpService: HttpService,
|
||||
configService: ConfigService,
|
||||
@InjectRepository(PaymentOrderEntity)
|
||||
paymentOrderRepository: Repository<PaymentOrderEntity>,
|
||||
@InjectRepository(PaymentTransactionEntity)
|
||||
paymentTransactionRepository: Repository<PaymentTransactionEntity>,
|
||||
@InjectRepository(RefundRecordEntity)
|
||||
refundRecordRepository: Repository<RefundRecordEntity>,
|
||||
) {
|
||||
super(
|
||||
httpService,
|
||||
configService,
|
||||
paymentOrderRepository,
|
||||
paymentTransactionRepository,
|
||||
refundRecordRepository,
|
||||
);
|
||||
|
||||
// 初始化Stripe客户端
|
||||
const secretKey = this.configService.get('STRIPE_SECRET_KEY') || '';
|
||||
this.webhookSecret = this.configService.get('STRIPE_WEBHOOK_SECRET') || '';
|
||||
|
||||
if (!secretKey) {
|
||||
this.logger.warn(
|
||||
'Stripe密钥未配置,相关功能将不可用。请配置环境变量: STRIPE_SECRET_KEY',
|
||||
);
|
||||
// 使用占位符密钥避免初始化错误
|
||||
this.stripe = new Stripe('sk_test_placeholder', {
|
||||
apiVersion: '2025-08-27.basil',
|
||||
typescript: true,
|
||||
});
|
||||
} else {
|
||||
this.stripe = new Stripe(secretKey, {
|
||||
apiVersion: '2025-08-27.basil',
|
||||
typescript: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建支付订单
|
||||
* 创建Stripe Payment Intent
|
||||
* @param orderData 订单数据
|
||||
* @returns 支付订单结果
|
||||
*/
|
||||
async createPaymentOrder(
|
||||
orderData: CreatePaymentOrderData,
|
||||
): Promise<PaymentOrderResult> {
|
||||
try {
|
||||
// 0. 检查Stripe配置
|
||||
const secretKey = this.configService.get('STRIPE_SECRET_KEY');
|
||||
if (!secretKey) {
|
||||
throw new Error('Stripe配置未完整,请配置STRIPE_SECRET_KEY环境变量');
|
||||
}
|
||||
|
||||
// 1. 参数验证
|
||||
if (!this.validateAmount(orderData.amount)) {
|
||||
throw new Error('订单金额无效');
|
||||
}
|
||||
|
||||
if (!this.validateCurrency(orderData.currency)) {
|
||||
throw new Error('货币类型无效');
|
||||
}
|
||||
|
||||
// 2. 生成订单号
|
||||
const orderNo = this.generateOrderNo();
|
||||
const orderDataWithNo = { ...orderData, orderNo };
|
||||
|
||||
// 3. 转换金额单位(Stripe使用最小货币单位)
|
||||
const amount = this.convertToStripeAmount(
|
||||
orderData.amount,
|
||||
orderData.currency,
|
||||
);
|
||||
|
||||
// 4. 创建Stripe PaymentIntent
|
||||
const paymentIntent = await this.stripe.paymentIntents.create({
|
||||
amount,
|
||||
currency: orderData.currency.toLowerCase(),
|
||||
description: orderData.description,
|
||||
metadata: {
|
||||
orderNo,
|
||||
userId: orderData.userId,
|
||||
businessType: orderData.businessType,
|
||||
businessId: orderData.businessId,
|
||||
},
|
||||
automatic_payment_methods: {
|
||||
enabled: true,
|
||||
},
|
||||
});
|
||||
|
||||
// 5. 生成客户端支付参数
|
||||
if (!paymentIntent.client_secret) {
|
||||
throw new Error('PaymentIntent创建失败:缺少client_secret');
|
||||
}
|
||||
|
||||
const paymentParams = {
|
||||
paymentIntentId: paymentIntent.id,
|
||||
clientSecret: paymentIntent.client_secret,
|
||||
publishableKey: this.configService.get('STRIPE_PUBLISHABLE_KEY'),
|
||||
};
|
||||
|
||||
// 6. 保存订单到数据库
|
||||
const paymentOrder = await this.savePaymentOrder(
|
||||
orderDataWithNo,
|
||||
paymentIntent.id,
|
||||
paymentParams,
|
||||
);
|
||||
|
||||
// 7. 记录交易日志
|
||||
await this.savePaymentTransaction(
|
||||
paymentOrder.id,
|
||||
TransactionType.PAYMENT,
|
||||
orderData.amount,
|
||||
{ paymentIntentId: paymentIntent.id },
|
||||
paymentIntent,
|
||||
TransactionStatus.PENDING,
|
||||
);
|
||||
|
||||
return {
|
||||
orderId: paymentOrder.id,
|
||||
thirdPartyOrderId: paymentIntent.id,
|
||||
paymentParams,
|
||||
status: PaymentOrderStatus.PENDING,
|
||||
paymentMethod: this.paymentMethod,
|
||||
createdAt: paymentOrder.createdAt,
|
||||
};
|
||||
} catch (error) {
|
||||
throw this.handleError(error, '创建Stripe支付订单');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询支付订单状态
|
||||
* 查询Stripe PaymentIntent状态
|
||||
* @param orderId 本地订单ID
|
||||
* @param thirdPartyOrderId 第三方订单ID
|
||||
* @returns 订单查询结果
|
||||
*/
|
||||
async queryPaymentOrder(
|
||||
orderId: string,
|
||||
thirdPartyOrderId?: string,
|
||||
): Promise<PaymentOrderQueryResult> {
|
||||
try {
|
||||
// 1. 获取本地订单信息
|
||||
const paymentOrder = await this.paymentOrderRepository.findOne({
|
||||
where: { id: orderId },
|
||||
});
|
||||
|
||||
if (!paymentOrder) {
|
||||
throw new Error('订单不存在');
|
||||
}
|
||||
|
||||
// 2. 获取PaymentIntent ID
|
||||
const paymentIntentId =
|
||||
thirdPartyOrderId || paymentOrder.thirdPartyOrderId;
|
||||
if (!paymentIntentId) {
|
||||
throw new Error('缺少Stripe PaymentIntent ID');
|
||||
}
|
||||
|
||||
// 3. 查询Stripe PaymentIntent
|
||||
const paymentIntent = await this.stripe.paymentIntents.retrieve(
|
||||
paymentIntentId,
|
||||
{ expand: ['charges'] }
|
||||
) as ExpandedPaymentIntent;
|
||||
|
||||
// 4. 解析支付状态
|
||||
const status = this.parseStripePaymentStatus(paymentIntent.status);
|
||||
const charges = this.isValidChargesList(paymentIntent.charges) ? paymentIntent.charges : null;
|
||||
const paidAt =
|
||||
paymentIntent.status === 'succeeded' && charges?.data?.[0]
|
||||
? new Date(charges.data[0].created * 1000)
|
||||
: undefined;
|
||||
|
||||
// 5. 更新本地订单状态
|
||||
if (status !== paymentOrder.status) {
|
||||
await this.updatePaymentOrderStatus(
|
||||
orderId,
|
||||
status,
|
||||
paymentIntent.amount_received,
|
||||
paidAt,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
orderId,
|
||||
thirdPartyOrderId: paymentIntentId,
|
||||
status,
|
||||
amount: paymentOrder.amount,
|
||||
paidAmount: paymentIntent.amount_received,
|
||||
paidAt,
|
||||
thirdPartyTransactionId: charges?.data?.[0]?.id,
|
||||
rawData: paymentIntent,
|
||||
};
|
||||
} catch (error) {
|
||||
throw this.handleError(error, '查询Stripe支付订单');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理支付回调
|
||||
* 处理Stripe Webhook事件
|
||||
* @param callbackData Webhook数据
|
||||
* @returns 回调处理结果
|
||||
*/
|
||||
async handlePaymentCallback(
|
||||
callbackData: any,
|
||||
): Promise<PaymentCallbackResult> {
|
||||
try {
|
||||
// 1. 验证Webhook签名
|
||||
const signature = callbackData.signature;
|
||||
if (!(await this.verifySignature(callbackData, signature))) {
|
||||
return {
|
||||
success: false,
|
||||
orderId: '',
|
||||
status: PaymentOrderStatus.FAILED,
|
||||
errorMessage: 'Webhook签名验证失败',
|
||||
responseData: { received: false },
|
||||
};
|
||||
}
|
||||
|
||||
// 2. 解析Webhook事件
|
||||
const event = callbackData.event;
|
||||
if (event.type !== 'payment_intent.succeeded') {
|
||||
return {
|
||||
success: true,
|
||||
orderId: '',
|
||||
status: PaymentOrderStatus.PENDING,
|
||||
responseData: { received: true },
|
||||
};
|
||||
}
|
||||
|
||||
const paymentIntent = event.data.object as ExpandedPaymentIntent;
|
||||
const orderNo = paymentIntent.metadata.orderNo;
|
||||
|
||||
// 3. 查找本地订单
|
||||
const paymentOrder = await this.paymentOrderRepository.findOne({
|
||||
where: { orderNo },
|
||||
});
|
||||
|
||||
if (!paymentOrder) {
|
||||
return {
|
||||
success: false,
|
||||
orderId: '',
|
||||
status: PaymentOrderStatus.FAILED,
|
||||
errorMessage: '订单不存在',
|
||||
responseData: { received: false },
|
||||
};
|
||||
}
|
||||
|
||||
// 4. 处理支付成功
|
||||
const status = PaymentOrderStatus.PAID;
|
||||
const charges = this.isValidChargesList(paymentIntent.charges) ? paymentIntent.charges : null;
|
||||
const paidAt = charges?.data?.[0]?.created
|
||||
? new Date(charges.data[0].created * 1000)
|
||||
: new Date();
|
||||
|
||||
// 5. 更新订单状态
|
||||
await this.updatePaymentOrderStatus(
|
||||
paymentOrder.id,
|
||||
status,
|
||||
paymentIntent.amount_received,
|
||||
paidAt,
|
||||
);
|
||||
|
||||
// 6. 记录回调日志
|
||||
await this.savePaymentTransaction(
|
||||
paymentOrder.id,
|
||||
TransactionType.CALLBACK,
|
||||
paymentIntent.amount_received || 0,
|
||||
callbackData,
|
||||
paymentIntent,
|
||||
TransactionStatus.SUCCESS,
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
orderId: paymentOrder.id,
|
||||
status,
|
||||
paidAt,
|
||||
thirdPartyTransactionId: charges?.data?.[0]?.id,
|
||||
responseData: { received: true },
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error('处理Stripe Webhook失败:', error);
|
||||
return {
|
||||
success: false,
|
||||
orderId: '',
|
||||
status: PaymentOrderStatus.FAILED,
|
||||
errorMessage: error.message,
|
||||
responseData: { received: false },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 申请退款
|
||||
* 创建Stripe Refund
|
||||
* @param refundData 退款数据
|
||||
* @returns 退款结果
|
||||
*/
|
||||
async refundPayment(refundData: RefundRequestData): Promise<RefundResult> {
|
||||
try {
|
||||
// 1. 获取原订单信息
|
||||
const paymentOrder = await this.paymentOrderRepository.findOne({
|
||||
where: { id: refundData.orderId },
|
||||
});
|
||||
|
||||
if (!paymentOrder) {
|
||||
throw new Error('原订单不存在');
|
||||
}
|
||||
|
||||
// 2. 验证订单状态
|
||||
const validation = this.validateOrderOperation(paymentOrder, 'refund');
|
||||
if (!validation.valid) {
|
||||
throw new Error(validation.message);
|
||||
}
|
||||
|
||||
// 3. 保存退款记录
|
||||
const refundRecord = await this.saveRefundRecord(
|
||||
refundData,
|
||||
paymentOrder,
|
||||
);
|
||||
|
||||
// 4. 获取PaymentIntent的Charge ID
|
||||
if (!paymentOrder.thirdPartyOrderId) {
|
||||
throw new Error('缺少第三方订单ID');
|
||||
}
|
||||
|
||||
const paymentIntent = await this.stripe.paymentIntents.retrieve(
|
||||
paymentOrder.thirdPartyOrderId,
|
||||
{ expand: ['charges'] }
|
||||
) as ExpandedPaymentIntent;
|
||||
const charges = this.isValidChargesList(paymentIntent.charges) ? paymentIntent.charges : null;
|
||||
const chargeId = charges?.data?.[0]?.id;
|
||||
if (!chargeId) {
|
||||
throw new Error('未找到有效的支付记录');
|
||||
}
|
||||
|
||||
// 5. 转换退款金额
|
||||
const refundAmount = this.convertToStripeAmount(
|
||||
refundData.refundAmount,
|
||||
paymentOrder.currency,
|
||||
);
|
||||
|
||||
// 6. 创建Stripe退款
|
||||
const refund = await this.stripe.refunds.create({
|
||||
charge: chargeId,
|
||||
amount: refundAmount,
|
||||
reason: 'requested_by_customer',
|
||||
metadata: {
|
||||
refundNo: refundRecord.refundNo,
|
||||
orderId: refundData.orderId,
|
||||
},
|
||||
});
|
||||
|
||||
// 7. 更新退款记录
|
||||
const status = this.parseStripeRefundStatus(refund.status || 'failed');
|
||||
await this.updateRefundRecord(
|
||||
refundRecord.id,
|
||||
status,
|
||||
refund.id,
|
||||
status === RefundStatus.SUCCESS ? new Date() : undefined,
|
||||
);
|
||||
|
||||
return {
|
||||
refundId: refundRecord.id,
|
||||
thirdPartyRefundId: refund.id,
|
||||
status,
|
||||
refundAmount: refundData.refundAmount,
|
||||
createdAt: refundRecord.createdAt,
|
||||
estimatedArrivalTime: this.calculateRefundArrivalTime(),
|
||||
};
|
||||
} catch (error) {
|
||||
throw this.handleError(error, '申请Stripe退款');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询退款状态
|
||||
* 查询Stripe退款状态
|
||||
* @param refundId 退款ID
|
||||
* @returns 退款查询结果
|
||||
*/
|
||||
async queryRefundStatus(refundId: string): Promise<RefundQueryResult> {
|
||||
try {
|
||||
// 1. 获取退款记录
|
||||
const refundRecord = await this.refundRecordRepository.findOne({
|
||||
where: { id: refundId },
|
||||
});
|
||||
|
||||
if (!refundRecord) {
|
||||
throw new Error('退款记录不存在');
|
||||
}
|
||||
|
||||
// 2. 查询Stripe退款
|
||||
if (!refundRecord.thirdPartyRefundId) {
|
||||
throw new Error('缺少第三方退款ID');
|
||||
}
|
||||
|
||||
const refund = await this.stripe.refunds.retrieve(
|
||||
refundRecord.thirdPartyRefundId,
|
||||
);
|
||||
|
||||
// 3. 解析退款状态
|
||||
const status = this.parseStripeRefundStatus(refund.status || 'failed');
|
||||
const refundedAt =
|
||||
refund.status === 'succeeded' ? new Date() : undefined;
|
||||
|
||||
// 4. 更新退款记录
|
||||
if (status !== refundRecord.status) {
|
||||
await this.updateRefundRecord(
|
||||
refundRecord.id,
|
||||
status,
|
||||
undefined,
|
||||
refundedAt,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
refundId,
|
||||
thirdPartyRefundId: refund.id,
|
||||
status,
|
||||
refundAmount: refundRecord.refundAmount,
|
||||
originalAmount: refundRecord.originalAmount,
|
||||
reason: refundRecord.reason,
|
||||
refundedAt,
|
||||
arrivedAt: refund.status === 'succeeded' ? new Date() : undefined,
|
||||
failureReason:
|
||||
refund.status === 'failed' ? refund.failure_reason : undefined,
|
||||
createdAt: refundRecord.createdAt,
|
||||
updatedAt: refundRecord.updatedAt,
|
||||
};
|
||||
} catch (error) {
|
||||
throw this.handleError(error, '查询Stripe退款状态');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证Webhook签名
|
||||
* 使用Stripe提供的签名验证
|
||||
* @param callbackData 回调数据
|
||||
* @param signature 签名
|
||||
* @returns 验证结果
|
||||
*/
|
||||
async verifySignature(
|
||||
callbackData: any,
|
||||
signature: string,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
if (!this.webhookSecret) {
|
||||
this.logger.warn('Stripe Webhook密钥未配置,跳过签名验证');
|
||||
return true;
|
||||
}
|
||||
|
||||
// 使用Stripe SDK验证Webhook签名
|
||||
const event = this.stripe.webhooks.constructEvent(
|
||||
callbackData.body,
|
||||
signature,
|
||||
this.webhookSecret,
|
||||
);
|
||||
|
||||
callbackData.event = event;
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.logger.error('Stripe Webhook签名验证失败:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载对账单
|
||||
* 创建Stripe Balance Transaction Report
|
||||
* @param date 对账日期
|
||||
* @returns 对账单数据
|
||||
*/
|
||||
async downloadBill(date: string): Promise<BillData> {
|
||||
try {
|
||||
// Stripe对账单通过Reporting API获取
|
||||
const reportRun = await this.stripe.reporting.reportRuns.create({
|
||||
report_type: 'balance.summary.1',
|
||||
parameters: {
|
||||
interval_start: Math.floor(new Date(date).getTime() / 1000),
|
||||
interval_end: Math.floor(
|
||||
new Date(date + 'T23:59:59').getTime() / 1000,
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
// 等待报告生成完成
|
||||
// 注意:实际使用中应该通过轮询或Webhook来检查报告状态
|
||||
return {
|
||||
billDate: date,
|
||||
totalCount: 0,
|
||||
totalAmount: 0,
|
||||
records: [],
|
||||
rawData: JSON.stringify(reportRun),
|
||||
};
|
||||
} catch (error) {
|
||||
throw this.handleError(error, '下载Stripe对账单');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换金额到Stripe格式
|
||||
* 不同货币有不同的最小单位
|
||||
* @param amount 金额(分)
|
||||
* @param currency 货币类型
|
||||
* @returns Stripe金额
|
||||
*/
|
||||
private convertToStripeAmount(amount: number, currency: string): number {
|
||||
const zeroCurrencies = ['JPY', 'KRW']; // 零小数位货币
|
||||
|
||||
if (zeroCurrencies.includes(currency.toUpperCase())) {
|
||||
// 对于JPY等货币,不需要转换
|
||||
return Math.round(amount / 100); // 从分转为元
|
||||
}
|
||||
|
||||
// 对于大部分货币(USD, EUR等),Stripe使用最小单位(分/cent)
|
||||
return amount;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析Stripe支付状态
|
||||
* @param stripeStatus Stripe支付状态
|
||||
* @returns 系统支付状态
|
||||
*/
|
||||
private parseStripePaymentStatus(
|
||||
stripeStatus: Stripe.PaymentIntent.Status,
|
||||
): PaymentOrderStatus {
|
||||
switch (stripeStatus) {
|
||||
case 'succeeded':
|
||||
return PaymentOrderStatus.PAID;
|
||||
case 'canceled':
|
||||
return PaymentOrderStatus.CANCELLED;
|
||||
case 'processing':
|
||||
return PaymentOrderStatus.PENDING;
|
||||
case 'requires_payment_method':
|
||||
case 'requires_confirmation':
|
||||
case 'requires_action':
|
||||
return PaymentOrderStatus.PENDING;
|
||||
default:
|
||||
return PaymentOrderStatus.FAILED;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析Stripe退款状态
|
||||
* @param stripeStatus Stripe退款状态
|
||||
* @returns 系统退款状态
|
||||
*/
|
||||
private parseStripeRefundStatus(
|
||||
stripeStatus: string,
|
||||
): RefundStatus {
|
||||
switch (stripeStatus) {
|
||||
case 'succeeded':
|
||||
return RefundStatus.SUCCESS;
|
||||
case 'failed':
|
||||
return RefundStatus.FAILED;
|
||||
case 'pending':
|
||||
return RefundStatus.PROCESSING;
|
||||
case 'canceled':
|
||||
return RefundStatus.FAILED;
|
||||
default:
|
||||
return RefundStatus.PENDING;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 类型保护函数:检查 charges 是否为有效的包含 charge 数据的对象
|
||||
* @param charges 待检查的 charges 对象
|
||||
* @returns 类型保护结果
|
||||
*/
|
||||
private isValidChargesList(charges: any): charges is { data: Stripe.Charge[] } {
|
||||
return charges &&
|
||||
typeof charges === 'object' &&
|
||||
'data' in charges &&
|
||||
Array.isArray(charges.data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算退款到账时间
|
||||
* Stripe退款通常5-10个工作日
|
||||
* @returns 预计到账时间
|
||||
*/
|
||||
private calculateRefundArrivalTime(): Date {
|
||||
const arrivalTime = new Date();
|
||||
arrivalTime.setDate(arrivalTime.getDate() + 7); // 7天后
|
||||
return arrivalTime;
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理错误
|
||||
* @param error 错误对象
|
||||
* @param context 错误上下文
|
||||
* @returns 标准化错误
|
||||
*/
|
||||
protected handleError(error: any, context: string): Error {
|
||||
this.logger.error(`${context} 错误:`, error);
|
||||
|
||||
if (error.type && error.type.startsWith('Stripe')) {
|
||||
return new Error(
|
||||
`Stripe支付错误: ${error.message || '未知错误'}`,
|
||||
);
|
||||
}
|
||||
|
||||
return new Error(`Stripe支付${context}失败: ${error.message}`);
|
||||
}
|
||||
}
|
||||
@@ -1 +1,2 @@
|
||||
export { PaymentController } from './payment.controller';
|
||||
export { StripePaymentController } from './stripe-payment.controller';
|
||||
|
||||
459
src/payment/controllers/stripe-payment.controller.ts
Normal file
459
src/payment/controllers/stripe-payment.controller.ts
Normal file
@@ -0,0 +1,459 @@
|
||||
import {
|
||||
Controller,
|
||||
Post,
|
||||
Get,
|
||||
Body,
|
||||
Param,
|
||||
Headers,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
BadRequestException,
|
||||
NotFoundException,
|
||||
RawBodyRequest,
|
||||
Req,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
ApiResponse,
|
||||
ApiParam,
|
||||
ApiHeader,
|
||||
ApiBearerAuth,
|
||||
} from '@nestjs/swagger';
|
||||
import { Request } from 'express';
|
||||
import { UnifiedPaymentService } from '../services/unified-payment.service';
|
||||
import { PlatformType } from '../../entities/platform-user.entity';
|
||||
import {
|
||||
CreateStripePaymentOrderDto,
|
||||
StripePaymentOrderResponseDto,
|
||||
StripeWebhookEventDto,
|
||||
CreateStripeRefundDto,
|
||||
StripeRefundResponseDto,
|
||||
} from '../dto';
|
||||
import {
|
||||
PaymentOrderStatus,
|
||||
PaymentBusinessType,
|
||||
} from '../interfaces/payment.interface';
|
||||
|
||||
/**
|
||||
* Stripe支付控制器
|
||||
* 处理Stripe国际信用卡支付相关的API请求
|
||||
* 包括创建支付订单、处理Webhook回调、申请退款等功能
|
||||
*/
|
||||
@ApiTags('Stripe支付')
|
||||
@Controller('api/v1/payment/stripe')
|
||||
export class StripePaymentController {
|
||||
private readonly logger = new Logger(StripePaymentController.name);
|
||||
|
||||
constructor(
|
||||
private readonly unifiedPaymentService: UnifiedPaymentService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 创建Stripe支付订单
|
||||
* 创建PaymentIntent并返回客户端支付参数
|
||||
*/
|
||||
@Post('create-order')
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({
|
||||
summary: '创建Stripe支付订单',
|
||||
description: '创建Stripe PaymentIntent,用于国际信用卡支付',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 201,
|
||||
description: '支付订单创建成功',
|
||||
type: StripePaymentOrderResponseDto,
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 400,
|
||||
description: '请求参数错误',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 401,
|
||||
description: '未授权',
|
||||
})
|
||||
async createPaymentOrder(
|
||||
@Body() createOrderDto: CreateStripePaymentOrderDto,
|
||||
): Promise<StripePaymentOrderResponseDto> {
|
||||
try {
|
||||
this.logger.log(
|
||||
`创建Stripe支付订单: 用户=${createOrderDto.userId}, 金额=${createOrderDto.amount}, 币种=${createOrderDto.currency}`,
|
||||
);
|
||||
|
||||
// 生成虚拟的platformUserId(Stripe支付不需要特定的平台用户ID)
|
||||
const platformUserId = `stripe_${createOrderDto.userId}`;
|
||||
|
||||
const result = await this.unifiedPaymentService.createPaymentOrder(
|
||||
PlatformType.STRIPE,
|
||||
createOrderDto.userId,
|
||||
createOrderDto.businessType,
|
||||
createOrderDto.businessId,
|
||||
platformUserId,
|
||||
createOrderDto.currency,
|
||||
createOrderDto.amount,
|
||||
);
|
||||
|
||||
const response: StripePaymentOrderResponseDto = {
|
||||
orderId: result.orderId,
|
||||
paymentIntentId: result.thirdPartyOrderId,
|
||||
clientSecret: result.paymentParams.clientSecret,
|
||||
publishableKey: result.paymentParams.publishableKey,
|
||||
amount: createOrderDto.amount,
|
||||
currency: createOrderDto.currency,
|
||||
status: result.status,
|
||||
createdAt: result.createdAt,
|
||||
};
|
||||
|
||||
this.logger.log(
|
||||
`Stripe支付订单创建成功: 订单ID=${result.orderId}`,
|
||||
);
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
this.logger.error(`创建Stripe支付订单失败: ${error.message}`, error.stack);
|
||||
throw new BadRequestException(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询Stripe支付订单状态
|
||||
*/
|
||||
@Get('order/:orderId')
|
||||
@ApiOperation({
|
||||
summary: '查询Stripe支付订单状态',
|
||||
description: '查询指定订单的最新支付状态',
|
||||
})
|
||||
@ApiParam({
|
||||
name: 'orderId',
|
||||
description: '订单ID',
|
||||
example: 'ord_123456789',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: '查询成功',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 404,
|
||||
description: '订单不存在',
|
||||
})
|
||||
async queryPaymentOrder(@Param('orderId') orderId: string) {
|
||||
try {
|
||||
const result = await this.unifiedPaymentService.queryPaymentOrder(orderId);
|
||||
|
||||
this.logger.log(
|
||||
`查询Stripe订单状态: 订单ID=${orderId}, 状态=${result.status}`,
|
||||
);
|
||||
|
||||
return {
|
||||
orderId: result.orderId,
|
||||
paymentIntentId: result.thirdPartyOrderId,
|
||||
status: result.status,
|
||||
amount: result.amount,
|
||||
paidAmount: result.paidAmount,
|
||||
paidAt: result.paidAt,
|
||||
currency: result.rawData?.currency?.toUpperCase(),
|
||||
lastUpdated: new Date(),
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error(`查询Stripe订单失败: ${error.message}`, error.stack);
|
||||
if (error.message.includes('不存在')) {
|
||||
throw new NotFoundException(error.message);
|
||||
}
|
||||
throw new BadRequestException(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理Stripe Webhook回调
|
||||
* 验证签名并处理支付事件
|
||||
*/
|
||||
@Post('webhook')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
summary: 'Stripe Webhook回调',
|
||||
description: '处理Stripe发送的Webhook事件通知',
|
||||
})
|
||||
@ApiHeader({
|
||||
name: 'stripe-signature',
|
||||
description: 'Stripe签名头',
|
||||
required: true,
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Webhook处理成功',
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
received: { type: 'boolean' },
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 400,
|
||||
description: '签名验证失败',
|
||||
})
|
||||
async handleWebhook(
|
||||
@Req() request: RawBodyRequest<Request>,
|
||||
@Headers('stripe-signature') signature: string,
|
||||
) {
|
||||
try {
|
||||
if (!signature) {
|
||||
throw new BadRequestException('缺少Stripe签名头');
|
||||
}
|
||||
|
||||
this.logger.log('收到Stripe Webhook回调');
|
||||
|
||||
// 构建回调数据
|
||||
const callbackData = {
|
||||
body: request.rawBody,
|
||||
signature,
|
||||
event: null, // 将在适配器中解析
|
||||
};
|
||||
|
||||
const result = await this.unifiedPaymentService.handlePaymentCallback(
|
||||
PlatformType.STRIPE,
|
||||
callbackData,
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
this.logger.log(
|
||||
`Stripe Webhook处理成功: 订单ID=${result.orderId}, 状态=${result.status}`,
|
||||
);
|
||||
} else {
|
||||
this.logger.warn(
|
||||
`Stripe Webhook处理失败: ${result.errorMessage}`,
|
||||
);
|
||||
}
|
||||
|
||||
return result.responseData || { received: true };
|
||||
} catch (error) {
|
||||
this.logger.error(`处理Stripe Webhook失败: ${error.message}`, error.stack);
|
||||
throw new BadRequestException(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 申请Stripe退款
|
||||
*/
|
||||
@Post('refund')
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({
|
||||
summary: '申请Stripe退款',
|
||||
description: '对已成功的支付申请退款',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 201,
|
||||
description: '退款申请成功',
|
||||
type: StripeRefundResponseDto,
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 400,
|
||||
description: '退款申请失败',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 404,
|
||||
description: '原订单不存在',
|
||||
})
|
||||
async createRefund(
|
||||
@Body() refundDto: CreateStripeRefundDto,
|
||||
): Promise<StripeRefundResponseDto> {
|
||||
try {
|
||||
this.logger.log(
|
||||
`申请Stripe退款: 订单ID=${refundDto.orderId}, 金额=${refundDto.refundAmount}`,
|
||||
);
|
||||
|
||||
const result = await this.unifiedPaymentService.refundPayment(
|
||||
refundDto.orderId,
|
||||
refundDto.refundAmount,
|
||||
refundDto.reason,
|
||||
);
|
||||
|
||||
const response: StripeRefundResponseDto = {
|
||||
refundId: result.refundId,
|
||||
stripeRefundId: result.thirdPartyRefundId,
|
||||
status: result.status,
|
||||
refundAmount: result.refundAmount,
|
||||
estimatedArrivalTime: result.estimatedArrivalTime,
|
||||
createdAt: result.createdAt,
|
||||
};
|
||||
|
||||
this.logger.log(
|
||||
`Stripe退款申请成功: 退款ID=${result.refundId}`,
|
||||
);
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
this.logger.error(`申请Stripe退款失败: ${error.message}`, error.stack);
|
||||
if (error.message.includes('不存在')) {
|
||||
throw new NotFoundException(error.message);
|
||||
}
|
||||
throw new BadRequestException(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询Stripe退款状态
|
||||
*/
|
||||
@Get('refund/:refundId')
|
||||
@ApiOperation({
|
||||
summary: '查询Stripe退款状态',
|
||||
description: '查询退款处理进度和状态',
|
||||
})
|
||||
@ApiParam({
|
||||
name: 'refundId',
|
||||
description: '退款记录ID',
|
||||
example: 'ref_123456789',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: '查询成功',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 404,
|
||||
description: '退款记录不存在',
|
||||
})
|
||||
async queryRefundStatus(@Param('refundId') refundId: string) {
|
||||
try {
|
||||
const result = await this.unifiedPaymentService.queryRefundStatus(refundId);
|
||||
|
||||
this.logger.log(
|
||||
`查询Stripe退款状态: 退款ID=${refundId}, 状态=${result.status}`,
|
||||
);
|
||||
|
||||
return {
|
||||
refundId: result.refundId,
|
||||
stripeRefundId: result.thirdPartyRefundId,
|
||||
status: result.status,
|
||||
refundAmount: result.refundAmount,
|
||||
originalAmount: result.originalAmount,
|
||||
reason: result.reason,
|
||||
refundedAt: result.refundedAt,
|
||||
arrivedAt: result.arrivedAt,
|
||||
failureReason: result.failureReason,
|
||||
createdAt: result.createdAt,
|
||||
updatedAt: result.updatedAt,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error(`查询Stripe退款状态失败: ${error.message}`, error.stack);
|
||||
if (error.message.includes('不存在')) {
|
||||
throw new NotFoundException(error.message);
|
||||
}
|
||||
throw new BadRequestException(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取支持的货币列表
|
||||
*/
|
||||
@Get('supported-currencies')
|
||||
@ApiOperation({
|
||||
summary: '获取支持的货币列表',
|
||||
description: '获取Stripe支付支持的货币类型',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: '获取成功',
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
currencies: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
code: { type: 'string' },
|
||||
name: { type: 'string' },
|
||||
symbol: { type: 'string' },
|
||||
minorUnit: { type: 'number' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
async getSupportedCurrencies() {
|
||||
const currencies = [
|
||||
{
|
||||
code: 'USD',
|
||||
name: 'US Dollar',
|
||||
symbol: '$',
|
||||
minorUnit: 2,
|
||||
},
|
||||
{
|
||||
code: 'EUR',
|
||||
name: 'Euro',
|
||||
symbol: '€',
|
||||
minorUnit: 2,
|
||||
},
|
||||
{
|
||||
code: 'GBP',
|
||||
name: 'British Pound',
|
||||
symbol: '£',
|
||||
minorUnit: 2,
|
||||
},
|
||||
{
|
||||
code: 'JPY',
|
||||
name: 'Japanese Yen',
|
||||
symbol: '¥',
|
||||
minorUnit: 0,
|
||||
},
|
||||
{
|
||||
code: 'AUD',
|
||||
name: 'Australian Dollar',
|
||||
symbol: 'A$',
|
||||
minorUnit: 2,
|
||||
},
|
||||
{
|
||||
code: 'CAD',
|
||||
name: 'Canadian Dollar',
|
||||
symbol: 'C$',
|
||||
minorUnit: 2,
|
||||
},
|
||||
{
|
||||
code: 'SGD',
|
||||
name: 'Singapore Dollar',
|
||||
symbol: 'S$',
|
||||
minorUnit: 2,
|
||||
},
|
||||
{
|
||||
code: 'HKD',
|
||||
name: 'Hong Kong Dollar',
|
||||
symbol: 'HK$',
|
||||
minorUnit: 2,
|
||||
},
|
||||
];
|
||||
|
||||
return { currencies };
|
||||
}
|
||||
|
||||
/**
|
||||
* 健康检查
|
||||
*/
|
||||
@Get('health')
|
||||
@ApiOperation({
|
||||
summary: 'Stripe服务健康检查',
|
||||
description: '检查Stripe支付服务是否正常运行',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: '服务正常',
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
status: { type: 'string' },
|
||||
timestamp: { type: 'string' },
|
||||
service: { type: 'string' },
|
||||
},
|
||||
},
|
||||
})
|
||||
async healthCheck() {
|
||||
return {
|
||||
status: 'healthy',
|
||||
timestamp: new Date().toISOString(),
|
||||
service: 'stripe-payment',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -8,3 +8,14 @@ export {
|
||||
RefundResponseDto,
|
||||
RefundQueryResponseDto,
|
||||
} from './refund-request.dto';
|
||||
export {
|
||||
CreateStripePaymentOrderDto,
|
||||
StripePaymentOrderResponseDto,
|
||||
StripeWebhookEventDto,
|
||||
ConfirmStripePaymentDto,
|
||||
CreateStripeRefundDto,
|
||||
StripeRefundResponseDto,
|
||||
StripePaymentMethodDto,
|
||||
StripeCustomerDto,
|
||||
StripeCurrency,
|
||||
} from './stripe-payment.dto';
|
||||
|
||||
330
src/payment/dto/stripe-payment.dto.ts
Normal file
330
src/payment/dto/stripe-payment.dto.ts
Normal file
@@ -0,0 +1,330 @@
|
||||
import { IsString, IsNumber, IsOptional, IsEnum, Min, Max } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { PaymentBusinessType } from '../interfaces/payment.interface';
|
||||
|
||||
/**
|
||||
* Stripe支付货币枚举
|
||||
*/
|
||||
export enum StripeCurrency {
|
||||
USD = 'USD',
|
||||
EUR = 'EUR',
|
||||
GBP = 'GBP',
|
||||
JPY = 'JPY',
|
||||
AUD = 'AUD',
|
||||
CAD = 'CAD',
|
||||
SGD = 'SGD',
|
||||
HKD = 'HKD',
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建Stripe支付订单DTO
|
||||
*/
|
||||
export class CreateStripePaymentOrderDto {
|
||||
@ApiProperty({
|
||||
description: '用户ID',
|
||||
example: 'user_123456789',
|
||||
})
|
||||
@IsString()
|
||||
userId: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '业务类型',
|
||||
enum: PaymentBusinessType,
|
||||
example: PaymentBusinessType.CREDIT_PURCHASE,
|
||||
})
|
||||
@IsEnum(PaymentBusinessType)
|
||||
businessType: PaymentBusinessType;
|
||||
|
||||
@ApiProperty({
|
||||
description: '业务关联ID',
|
||||
example: 'credits_500',
|
||||
})
|
||||
@IsString()
|
||||
businessId: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '支付金额(分)',
|
||||
example: 4800,
|
||||
minimum: 1,
|
||||
maximum: 100000000,
|
||||
})
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
@Max(100000000)
|
||||
@Type(() => Number)
|
||||
amount: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: '货币类型',
|
||||
enum: StripeCurrency,
|
||||
example: StripeCurrency.USD,
|
||||
})
|
||||
@IsEnum(StripeCurrency)
|
||||
currency: StripeCurrency;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '商品描述',
|
||||
example: '购买500积分包',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '用户邮箱(用于Stripe客户信息)',
|
||||
example: 'user@example.com',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
customerEmail?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '附加数据',
|
||||
example: { source: 'web', campaign: 'holiday_sale' },
|
||||
})
|
||||
@IsOptional()
|
||||
metadata?: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stripe支付订单响应DTO
|
||||
*/
|
||||
export class StripePaymentOrderResponseDto {
|
||||
@ApiProperty({
|
||||
description: '本地订单ID',
|
||||
example: 'ord_123456789',
|
||||
})
|
||||
orderId: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Stripe PaymentIntent ID',
|
||||
example: 'pi_1234567890abcdef',
|
||||
})
|
||||
paymentIntentId: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '客户端密钥',
|
||||
example: 'pi_1234567890abcdef_secret_xyz',
|
||||
})
|
||||
clientSecret: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Stripe可发布密钥',
|
||||
example: 'pk_test_1234567890abcdef',
|
||||
})
|
||||
publishableKey: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '支付金额(分)',
|
||||
example: 4800,
|
||||
})
|
||||
amount: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: '货币类型',
|
||||
example: 'USD',
|
||||
})
|
||||
currency: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '订单状态',
|
||||
example: 'pending',
|
||||
})
|
||||
status: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '创建时间',
|
||||
example: '2024-01-01T00:00:00.000Z',
|
||||
})
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stripe Webhook事件DTO
|
||||
*/
|
||||
export class StripeWebhookEventDto {
|
||||
@ApiProperty({
|
||||
description: '事件ID',
|
||||
example: 'evt_1234567890abcdef',
|
||||
})
|
||||
@IsString()
|
||||
id: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '事件类型',
|
||||
example: 'payment_intent.succeeded',
|
||||
})
|
||||
@IsString()
|
||||
type: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '事件数据',
|
||||
})
|
||||
data: {
|
||||
object: any;
|
||||
};
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '创建时间戳',
|
||||
example: 1640995200,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
created?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 确认Stripe支付DTO
|
||||
*/
|
||||
export class ConfirmStripePaymentDto {
|
||||
@ApiProperty({
|
||||
description: 'PaymentIntent ID',
|
||||
example: 'pi_1234567890abcdef',
|
||||
})
|
||||
@IsString()
|
||||
paymentIntentId: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '支付方法ID',
|
||||
example: 'pm_1234567890abcdef',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
paymentMethodId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stripe退款申请DTO
|
||||
*/
|
||||
export class CreateStripeRefundDto {
|
||||
@ApiProperty({
|
||||
description: '原订单ID',
|
||||
example: 'ord_123456789',
|
||||
})
|
||||
@IsString()
|
||||
orderId: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '退款金额(分)',
|
||||
example: 2400,
|
||||
minimum: 1,
|
||||
})
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
@Type(() => Number)
|
||||
refundAmount: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: '退款原因',
|
||||
example: '用户申请退款',
|
||||
})
|
||||
@IsString()
|
||||
reason: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stripe退款响应DTO
|
||||
*/
|
||||
export class StripeRefundResponseDto {
|
||||
@ApiProperty({
|
||||
description: '退款记录ID',
|
||||
example: 'ref_123456789',
|
||||
})
|
||||
refundId: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Stripe退款ID',
|
||||
example: 're_1234567890abcdef',
|
||||
})
|
||||
stripeRefundId: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '退款状态',
|
||||
example: 'pending',
|
||||
})
|
||||
status: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '退款金额(分)',
|
||||
example: 2400,
|
||||
})
|
||||
refundAmount: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '预计到账时间',
|
||||
example: '2024-01-08T00:00:00.000Z',
|
||||
})
|
||||
estimatedArrivalTime?: Date;
|
||||
|
||||
@ApiProperty({
|
||||
description: '创建时间',
|
||||
example: '2024-01-01T00:00:00.000Z',
|
||||
})
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stripe支付方法DTO
|
||||
*/
|
||||
export class StripePaymentMethodDto {
|
||||
@ApiProperty({
|
||||
description: '支付方法ID',
|
||||
example: 'pm_1234567890abcdef',
|
||||
})
|
||||
@IsString()
|
||||
id: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '支付方法类型',
|
||||
example: 'card',
|
||||
})
|
||||
@IsString()
|
||||
type: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '卡片信息',
|
||||
})
|
||||
@IsOptional()
|
||||
card?: {
|
||||
brand: string;
|
||||
last4: string;
|
||||
exp_month: number;
|
||||
exp_year: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Stripe客户信息DTO
|
||||
*/
|
||||
export class StripeCustomerDto {
|
||||
@ApiPropertyOptional({
|
||||
description: '客户邮箱',
|
||||
example: 'customer@example.com',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
email?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '客户姓名',
|
||||
example: 'John Doe',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
name?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '客户描述',
|
||||
example: 'Premium user from web platform',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '客户元数据',
|
||||
})
|
||||
@IsOptional()
|
||||
metadata?: Record<string, string>;
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { PlatformType } from '../../entities/platform-user.entity';
|
||||
export enum PaymentMethod {
|
||||
WECHAT_MINIPROGRAM = 'wechat_miniprogram', // 微信小程序支付
|
||||
BYTEDANCE_MINIPROGRAM = 'bytedance_miniprogram', // 字节跳动小程序支付
|
||||
STRIPE_CARD = 'stripe_card', // Stripe信用卡支付
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -16,7 +16,7 @@ import { UserSubscriptionEntity } from '../entities/user-subscription.entity';
|
||||
import { UserCreditEntity } from '../entities/user-credit.entity';
|
||||
|
||||
// 适配器
|
||||
import { WechatPaymentAdapter, DouyinPaymentAdapter } from './adapters';
|
||||
import { WechatPaymentAdapter, DouyinPaymentAdapter, StripePaymentAdapter } from './adapters';
|
||||
|
||||
// 服务
|
||||
import {
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
} from './services';
|
||||
|
||||
// 控制器
|
||||
import { PaymentController } from './controllers';
|
||||
import { PaymentController, StripePaymentController } from './controllers';
|
||||
|
||||
/**
|
||||
* 支付模块
|
||||
@@ -73,6 +73,7 @@ import { PaymentController } from './controllers';
|
||||
// 支付适配器
|
||||
WechatPaymentAdapter,
|
||||
DouyinPaymentAdapter,
|
||||
StripePaymentAdapter,
|
||||
|
||||
// 核心服务
|
||||
PaymentAdapterFactory,
|
||||
@@ -86,6 +87,7 @@ import { PaymentController } from './controllers';
|
||||
factory: PaymentAdapterFactory,
|
||||
wechatAdapter: WechatPaymentAdapter,
|
||||
douyinAdapter: DouyinPaymentAdapter,
|
||||
stripeAdapter: StripePaymentAdapter,
|
||||
) => {
|
||||
// 注册微信支付适配器
|
||||
factory.registerAdapter(
|
||||
@@ -101,17 +103,25 @@ import { PaymentController } from './controllers';
|
||||
douyinAdapter,
|
||||
);
|
||||
|
||||
// 注册Stripe支付适配器
|
||||
factory.registerAdapter(
|
||||
stripeAdapter.platform,
|
||||
stripeAdapter.paymentMethod,
|
||||
stripeAdapter,
|
||||
);
|
||||
|
||||
return factory;
|
||||
},
|
||||
inject: [
|
||||
PaymentAdapterFactory,
|
||||
WechatPaymentAdapter,
|
||||
DouyinPaymentAdapter,
|
||||
StripePaymentAdapter,
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
controllers: [PaymentController],
|
||||
controllers: [PaymentController, StripePaymentController],
|
||||
|
||||
exports: [
|
||||
// 导出核心服务供其他模块使用
|
||||
|
||||
@@ -108,6 +108,8 @@ export class UnifiedPaymentService {
|
||||
* @param businessType 业务类型
|
||||
* @param businessId 业务ID
|
||||
* @param platformUserId 平台用户ID
|
||||
* @param currency 货币类型(可选,默认CNY)
|
||||
* @param customAmount 自定义金额(可选,覆盖配置价格)
|
||||
* @returns 支付订单结果
|
||||
*/
|
||||
async createPaymentOrder(
|
||||
@@ -116,6 +118,8 @@ export class UnifiedPaymentService {
|
||||
businessType: PaymentBusinessType,
|
||||
businessId: string,
|
||||
platformUserId: string,
|
||||
currency: string = 'CNY',
|
||||
customAmount?: number,
|
||||
): Promise<PaymentOrderResult> {
|
||||
try {
|
||||
this.logger.log(
|
||||
@@ -146,8 +150,8 @@ export class UnifiedPaymentService {
|
||||
// 5. 构建订单数据
|
||||
const orderData: CreatePaymentOrderData = {
|
||||
orderNo: '', // 由适配器生成
|
||||
amount: businessConfig.unitPrice,
|
||||
currency: 'CNY',
|
||||
amount: customAmount || businessConfig.unitPrice,
|
||||
currency: currency,
|
||||
description: businessConfig.description,
|
||||
userId,
|
||||
platformUserId,
|
||||
@@ -157,8 +161,9 @@ export class UnifiedPaymentService {
|
||||
metadata: {
|
||||
businessName: businessConfig.name,
|
||||
platform,
|
||||
currency,
|
||||
},
|
||||
notifyUrl: `${this.configService.get('SERVER_URL')}/api/v1/payment/${platform.toLowerCase()}/callback`,
|
||||
notifyUrl: this.buildNotifyUrl(platform),
|
||||
};
|
||||
|
||||
// 6. 获取支付适配器并创建订单
|
||||
@@ -618,6 +623,8 @@ export class UnifiedPaymentService {
|
||||
return PaymentMethod.WECHAT_MINIPROGRAM;
|
||||
case PlatformType.BYTEDANCE:
|
||||
return PaymentMethod.BYTEDANCE_MINIPROGRAM;
|
||||
case PlatformType.STRIPE:
|
||||
return PaymentMethod.STRIPE_CARD;
|
||||
default:
|
||||
throw new BadRequestException('不支持的支付平台');
|
||||
}
|
||||
@@ -759,4 +766,24 @@ export class UnifiedPaymentService {
|
||||
const random = Math.random().toString(36).substr(2, 9);
|
||||
return `RF${timestamp}${random.toUpperCase()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建回调通知URL
|
||||
* @param platform 支付平台
|
||||
* @returns 回调URL
|
||||
*/
|
||||
private buildNotifyUrl(platform: PlatformType): string {
|
||||
const baseUrl = this.configService.get('SERVER_URL') || 'http://localhost:3000';
|
||||
|
||||
switch (platform) {
|
||||
case PlatformType.WECHAT:
|
||||
return `${baseUrl}/api/v1/payment/wechat/callback`;
|
||||
case PlatformType.BYTEDANCE:
|
||||
return `${baseUrl}/api/v1/payment/bytedance/callback`;
|
||||
case PlatformType.STRIPE:
|
||||
return `${baseUrl}/api/v1/payment/stripe/webhook`;
|
||||
default:
|
||||
return `${baseUrl}/api/v1/payment/${platform.toLowerCase()}/callback`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
252
src/platform/adapters/google.adapter.ts
Normal file
252
src/platform/adapters/google.adapter.ts
Normal file
@@ -0,0 +1,252 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { HttpService } from '@nestjs/axios';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Repository } from 'typeorm';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { UserEntity } from '../../entities/user.entity';
|
||||
import { PlatformUserEntity, PlatformType } from '../../entities/platform-user.entity';
|
||||
import { BaseAdapter } from './base.adapter';
|
||||
import {
|
||||
PlatformLoginData,
|
||||
PlatformRegisterData,
|
||||
UserAuthResult,
|
||||
PlatformUserInfo,
|
||||
TokenRefreshResult,
|
||||
} from '../interfaces/platform.interface';
|
||||
|
||||
@Injectable()
|
||||
export class GoogleAdapter extends BaseAdapter {
|
||||
platform = PlatformType.GOOGLE;
|
||||
|
||||
constructor(
|
||||
protected readonly httpService: HttpService,
|
||||
protected readonly configService: ConfigService,
|
||||
@InjectRepository(UserEntity)
|
||||
protected readonly userRepository: Repository<UserEntity>,
|
||||
@InjectRepository(PlatformUserEntity)
|
||||
protected readonly platformUserRepository: Repository<PlatformUserEntity>,
|
||||
protected readonly jwtService: JwtService,
|
||||
) {
|
||||
super(httpService, configService, userRepository, platformUserRepository, jwtService);
|
||||
}
|
||||
|
||||
async login(loginData: PlatformLoginData): Promise<UserAuthResult> {
|
||||
try {
|
||||
const { code, userInfo } = loginData;
|
||||
|
||||
let googleUserInfo: any;
|
||||
let tokenData: any = {};
|
||||
|
||||
if (code === 'google_oauth_direct' && userInfo) {
|
||||
// 通过Passport Strategy直接获取的用户信息
|
||||
googleUserInfo = {
|
||||
id: userInfo.id,
|
||||
name: userInfo.name,
|
||||
email: userInfo.email,
|
||||
picture: userInfo.picture,
|
||||
};
|
||||
tokenData = {
|
||||
access_token: userInfo.accessToken,
|
||||
refresh_token: userInfo.refreshToken,
|
||||
};
|
||||
} else {
|
||||
// 传统的授权码交换流程(用于移动端或直接API调用)
|
||||
tokenData = await this.exchangeCodeForTokens(code);
|
||||
googleUserInfo = await this.getUserInfoFromGoogle(tokenData.access_token);
|
||||
}
|
||||
|
||||
// 转换为平台用户信息格式
|
||||
const platformUserInfo: PlatformUserInfo = {
|
||||
platformUserId: googleUserInfo.id,
|
||||
nickname: googleUserInfo.name,
|
||||
avatarUrl: googleUserInfo.picture || '',
|
||||
email: googleUserInfo.email,
|
||||
phone: undefined,
|
||||
};
|
||||
|
||||
// 查找或创建统一用户
|
||||
const user = await this.findOrCreateUnifiedUser(platformUserInfo);
|
||||
|
||||
// 更新平台用户数据
|
||||
await this.updatePlatformUserData(user.id, tokenData, googleUserInfo);
|
||||
|
||||
// 生成JWT令牌
|
||||
const tokens = await this.generateTokens(user, this.platform);
|
||||
|
||||
return {
|
||||
user: this.formatUnifiedUserInfo(user),
|
||||
tokens,
|
||||
platformData: googleUserInfo,
|
||||
};
|
||||
} catch (error) {
|
||||
throw this.handlePlatformError(error, 'Google');
|
||||
}
|
||||
}
|
||||
|
||||
async register(registerData: PlatformRegisterData): Promise<UserAuthResult> {
|
||||
return this.login(registerData);
|
||||
}
|
||||
|
||||
async getUserInfo(token: string, platformUserId: string): Promise<PlatformUserInfo> {
|
||||
try {
|
||||
const response = await this.httpService
|
||||
.get(`https://www.googleapis.com/oauth2/v2/userinfo`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
})
|
||||
.toPromise();
|
||||
|
||||
if (!response) {
|
||||
throw new Error('Failed to get user info from Google');
|
||||
}
|
||||
|
||||
const googleUserInfo = response.data;
|
||||
|
||||
return {
|
||||
platformUserId: googleUserInfo.id,
|
||||
nickname: googleUserInfo.name,
|
||||
avatarUrl: googleUserInfo.picture || '',
|
||||
email: googleUserInfo.email,
|
||||
};
|
||||
} catch (error) {
|
||||
throw this.handlePlatformError(error, 'Google');
|
||||
}
|
||||
}
|
||||
|
||||
async refreshToken(refreshToken: string): Promise<TokenRefreshResult> {
|
||||
try {
|
||||
const response = await this.httpService
|
||||
.post('https://oauth2.googleapis.com/token', {
|
||||
client_id: this.configService.get('GOOGLE_CLIENT_ID'),
|
||||
client_secret: this.configService.get('GOOGLE_CLIENT_SECRET'),
|
||||
refresh_token: refreshToken,
|
||||
grant_type: 'refresh_token',
|
||||
})
|
||||
.toPromise();
|
||||
|
||||
if (!response) {
|
||||
throw new Error('Failed to refresh token from Google');
|
||||
}
|
||||
|
||||
const tokenData = response.data;
|
||||
const expiresAt = new Date(Date.now() + tokenData.expires_in * 1000);
|
||||
|
||||
return {
|
||||
accessToken: tokenData.access_token,
|
||||
refreshToken: tokenData.refresh_token || refreshToken,
|
||||
expiresAt,
|
||||
};
|
||||
} catch (error) {
|
||||
throw this.handlePlatformError(error, 'Google');
|
||||
}
|
||||
}
|
||||
|
||||
async updateUserInfo(userId: string, updateData: Partial<PlatformUserInfo>): Promise<void> {
|
||||
const platformUser = await this.platformUserRepository.findOne({
|
||||
where: { userId, platform: this.platform },
|
||||
});
|
||||
|
||||
if (platformUser) {
|
||||
platformUser.platformData = {
|
||||
...platformUser.platformData,
|
||||
...updateData,
|
||||
};
|
||||
await this.platformUserRepository.save(platformUser);
|
||||
}
|
||||
}
|
||||
|
||||
async validateToken(token: string): Promise<boolean> {
|
||||
try {
|
||||
const response = await this.httpService
|
||||
.get(`https://www.googleapis.com/oauth2/v2/tokeninfo?access_token=${token}`)
|
||||
.toPromise();
|
||||
|
||||
return !!(response && response.data.user_id);
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async revokeToken(token: string): Promise<void> {
|
||||
try {
|
||||
await this.httpService
|
||||
.post(`https://oauth2.googleapis.com/revoke?token=${token}`)
|
||||
.toPromise();
|
||||
} catch (error) {
|
||||
// 忽略撤销令牌的错误
|
||||
console.warn('Google令牌撤销失败:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用授权码交换访问令牌
|
||||
*/
|
||||
private async exchangeCodeForTokens(code: string) {
|
||||
const response = await this.httpService
|
||||
.post('https://oauth2.googleapis.com/token', {
|
||||
client_id: this.configService.get('GOOGLE_CLIENT_ID'),
|
||||
client_secret: this.configService.get('GOOGLE_CLIENT_SECRET'),
|
||||
code,
|
||||
grant_type: 'authorization_code',
|
||||
redirect_uri: this.configService.get('GOOGLE_CALLBACK_URL'),
|
||||
})
|
||||
.toPromise();
|
||||
|
||||
if (!response) {
|
||||
throw new Error('Failed to exchange code for tokens');
|
||||
}
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从Google获取用户信息
|
||||
*/
|
||||
private async getUserInfoFromGoogle(accessToken: string) {
|
||||
const response = await this.httpService
|
||||
.get('https://www.googleapis.com/oauth2/v2/userinfo', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
},
|
||||
})
|
||||
.toPromise();
|
||||
|
||||
if (!response) {
|
||||
throw new Error('Failed to get user info from Google');
|
||||
}
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 重写更新平台用户数据方法以支持Google OAuth令牌
|
||||
*/
|
||||
protected async updatePlatformUserData(
|
||||
userId: string,
|
||||
authData: any,
|
||||
userInfo?: any,
|
||||
): Promise<void> {
|
||||
const platformUser = await this.platformUserRepository.findOne({
|
||||
where: { userId, platform: this.platform },
|
||||
});
|
||||
|
||||
if (platformUser) {
|
||||
platformUser.accessToken = authData.access_token;
|
||||
platformUser.refreshToken = authData.refresh_token;
|
||||
platformUser.expiresAt = authData.expires_in
|
||||
? new Date(Date.now() + authData.expires_in * 1000)
|
||||
: null;
|
||||
|
||||
if (userInfo) {
|
||||
platformUser.platformData = {
|
||||
...platformUser.platformData,
|
||||
...userInfo,
|
||||
};
|
||||
}
|
||||
|
||||
await this.platformUserRepository.save(platformUser);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './base.adapter';
|
||||
export * from './wechat.adapter';
|
||||
export * from './bytedance.adapter';
|
||||
export * from './google.adapter';
|
||||
|
||||
@@ -12,6 +12,7 @@ import { ExtensionDataEntity } from '../entities/extension-data.entity';
|
||||
// 适配器
|
||||
import { WechatAdapter } from './adapters/wechat.adapter';
|
||||
import { BytedanceAdapter } from './adapters/bytedance.adapter';
|
||||
import { GoogleAdapter } from './adapters/google.adapter';
|
||||
|
||||
// 服务
|
||||
import { PlatformAdapterFactory } from './services/platform-adapter.factory';
|
||||
@@ -46,6 +47,7 @@ import { PlatformAuthGuard } from './guards/platform-auth.guard';
|
||||
// 适配器实现
|
||||
WechatAdapter,
|
||||
BytedanceAdapter,
|
||||
GoogleAdapter,
|
||||
|
||||
// 工厂和服务
|
||||
PlatformAdapterFactory,
|
||||
@@ -61,12 +63,14 @@ import { PlatformAuthGuard } from './guards/platform-auth.guard';
|
||||
factory: PlatformAdapterFactory,
|
||||
wechatAdapter: WechatAdapter,
|
||||
bytedanceAdapter: BytedanceAdapter,
|
||||
googleAdapter: GoogleAdapter,
|
||||
) => {
|
||||
factory.registerAdapter(wechatAdapter.platform, wechatAdapter);
|
||||
factory.registerAdapter(bytedanceAdapter.platform, bytedanceAdapter);
|
||||
factory.registerAdapter(googleAdapter.platform, googleAdapter);
|
||||
return factory;
|
||||
},
|
||||
inject: [PlatformAdapterFactory, WechatAdapter, BytedanceAdapter],
|
||||
inject: [PlatformAdapterFactory, WechatAdapter, BytedanceAdapter, GoogleAdapter],
|
||||
},
|
||||
],
|
||||
controllers: [UnifiedUserController],
|
||||
|
||||
Reference in New Issue
Block a user