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:
imeepos
2025-09-26 15:26:37 +08:00
parent cec66085dc
commit 489756b7a0
26 changed files with 3065 additions and 27 deletions

View File

@@ -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
View 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
View 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登录无缝集成到现有的多平台认证架构中保持系统的一致性和可扩展性。

View File

@@ -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
View File

@@ -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: {}

View File

@@ -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(`🚀 [回调] 开始执行回调`);

View File

@@ -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: [

View 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
View 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 {}

View File

@@ -0,0 +1,5 @@
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class GoogleAuthGuard extends AuthGuard('google') {}

View 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);
}
}
}

View File

@@ -26,6 +26,8 @@ export enum PlatformType {
KUAISHOU = 'kuaishou', // 快手小程序 - 快手短视频生态
H5 = 'h5', // H5网页版 - 浏览器端应用
RN = 'rn', // React Native应用 - 原生移动应用
STRIPE = 'stripe', // Stripe支付平台 - 国际信用卡支付
GOOGLE = 'google', // Google OAuth - Google账号登录
}
/**

View 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,
);
});
});
});

View File

@@ -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);
}
/**

View File

@@ -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';

View 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}`);
}
}

View File

@@ -1 +1,2 @@
export { PaymentController } from './payment.controller';
export { StripePaymentController } from './stripe-payment.controller';

View 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}`,
);
// 生成虚拟的platformUserIdStripe支付不需要特定的平台用户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',
};
}
}

View File

@@ -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';

View 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>;
}

View File

@@ -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信用卡支付
}
/**

View File

@@ -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: [
// 导出核心服务供其他模块使用

View File

@@ -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`;
}
}
}

View 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);
}
}
}

View File

@@ -1,3 +1,4 @@
export * from './base.adapter';
export * from './wechat.adapter';
export * from './bytedance.adapter';
export * from './google.adapter';

View File

@@ -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],