From 489756b7a076209cc8aa3fbfc0e7e16224938751 Mon Sep 17 00:00:00 2001 From: imeepos Date: Fri, 26 Sep 2025 15:26:37 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=9B=86=E6=88=90Google=20OAuth?= =?UTF-8?q?=E8=AE=A4=E8=AF=81=E5=92=8C=E5=AE=8C=E5=96=84Stripe=E6=94=AF?= =?UTF-8?q?=E4=BB=98=E7=B3=BB=E7=BB=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增Google OAuth 2.0认证模块 - 实现Google Strategy和AuthController - 添加Google平台适配器和用户统一管理 - 支持OAuth回调和token交换机制 - 完善Stripe支付集成 - 新增Stripe支付适配器和控制器 - 实现webhook事件处理和支付确认 - 支持多币种和国际化支付 - 扩展平台类型支持 - 在PlatformType中新增GOOGLE和STRIPE - 更新适配器工厂注册机制 - 完善跨平台用户身份管理 - 增强依赖和配置 - 添加@nestjs/passport和passport-google-oauth20 - 更新支付方法枚举和货币支持 - 完善订单号生成和回调URL构建 --- Dockerfile | 28 +- STRIPE_INTEGRATION_GUIDE.md | 342 +++++++++ auth.md | 63 ++ package.json | 4 + pnpm-lock.yaml | 118 ++++ src/app.controller.ts | 22 +- src/app.module.ts | 2 + src/auth/auth.controller.ts | 81 +++ src/auth/auth.module.ts | 23 + src/auth/guards/google.guard.ts | 5 + src/auth/strategies/google.strategy.ts | 57 ++ src/entities/platform-user.entity.ts | 2 + .../__tests__/stripe-payment.adapter.spec.ts | 541 ++++++++++++++ src/payment/adapters/base-payment.adapter.ts | 33 +- src/payment/adapters/index.ts | 1 + .../adapters/stripe-payment.adapter.ts | 660 ++++++++++++++++++ src/payment/controllers/index.ts | 1 + .../controllers/stripe-payment.controller.ts | 459 ++++++++++++ src/payment/dto/index.ts | 11 + src/payment/dto/stripe-payment.dto.ts | 330 +++++++++ src/payment/interfaces/payment.interface.ts | 1 + src/payment/payment.module.ts | 16 +- .../services/unified-payment.service.ts | 33 +- src/platform/adapters/google.adapter.ts | 252 +++++++ src/platform/adapters/index.ts | 1 + src/platform/platform.module.ts | 6 +- 26 files changed, 3065 insertions(+), 27 deletions(-) create mode 100644 STRIPE_INTEGRATION_GUIDE.md create mode 100644 auth.md create mode 100644 src/auth/auth.controller.ts create mode 100644 src/auth/auth.module.ts create mode 100644 src/auth/guards/google.guard.ts create mode 100644 src/auth/strategies/google.strategy.ts create mode 100644 src/payment/adapters/__tests__/stripe-payment.adapter.spec.ts create mode 100644 src/payment/adapters/stripe-payment.adapter.ts create mode 100644 src/payment/controllers/stripe-payment.controller.ts create mode 100644 src/payment/dto/stripe-payment.dto.ts create mode 100644 src/platform/adapters/google.adapter.ts diff --git a/Dockerfile b/Dockerfile index e014299..fd1b7e3 100644 --- a/Dockerfile +++ b/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"] diff --git a/STRIPE_INTEGRATION_GUIDE.md b/STRIPE_INTEGRATION_GUIDE.md new file mode 100644 index 0000000..3617297 --- /dev/null +++ b/STRIPE_INTEGRATION_GUIDE.md @@ -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 ( +
+ + + + ); +} + +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 ( + + + + ); +} +``` + +## 支持的货币 + +| 货币代码 | 货币名称 | 符号 | 最小单位 | +|---------|---------|------|---------| +| 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. 查看应用日志和错误信息 \ No newline at end of file diff --git a/auth.md b/auth.md new file mode 100644 index 0000000..d2449c5 --- /dev/null +++ b/auth.md @@ -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登录无缝集成到现有的多平台认证架构中,保持系统的一致性和可扩展性。 \ No newline at end of file diff --git a/package.json b/package.json index 6b97541..5b371bb 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bcbda19..f5ad42c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: {} diff --git a/src/app.controller.ts b/src/app.controller.ts index e3004e2..18ca8f9 100644 --- a/src/app.controller.ts +++ b/src/app.controller.ts @@ -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, + private readonly dataSource: DataSource, ) {} + + @Get('health') + async health(): Promise> { + 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> { console.log(`🚀 [回调] 开始执行回调`); diff --git a/src/app.module.ts b/src/app.module.ts index a226814..43db443 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -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: [ diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts new file mode 100644 index 0000000..75b8e47 --- /dev/null +++ b/src/auth/auth.controller.ts @@ -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, + }; + } +} \ No newline at end of file diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts new file mode 100644 index 0000000..1a8b966 --- /dev/null +++ b/src/auth/auth.module.ts @@ -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 {} \ No newline at end of file diff --git a/src/auth/guards/google.guard.ts b/src/auth/guards/google.guard.ts new file mode 100644 index 0000000..9fd68e6 --- /dev/null +++ b/src/auth/guards/google.guard.ts @@ -0,0 +1,5 @@ +import { Injectable } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; + +@Injectable() +export class GoogleAuthGuard extends AuthGuard('google') {} \ No newline at end of file diff --git a/src/auth/strategies/google.strategy.ts b/src/auth/strategies/google.strategy.ts new file mode 100644 index 0000000..f1545b7 --- /dev/null +++ b/src/auth/strategies/google.strategy.ts @@ -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('GOOGLE_CLIENT_ID') || '', + clientSecret: configService.get('GOOGLE_CLIENT_SECRET') || '', + callbackURL: configService.get('GOOGLE_CALLBACK_URL') || 'http://localhost:3005/api/v1/auth/google/callback', + scope: ['email', 'profile'], + }); + console.log({ + clientID: configService.get('GOOGLE_CLIENT_ID') || '', + clientSecret: configService.get('GOOGLE_CLIENT_SECRET') || '', + callbackURL: configService.get('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 { + 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); + } + } +} \ No newline at end of file diff --git a/src/entities/platform-user.entity.ts b/src/entities/platform-user.entity.ts index a3e3a93..1b17ac1 100644 --- a/src/entities/platform-user.entity.ts +++ b/src/entities/platform-user.entity.ts @@ -26,6 +26,8 @@ export enum PlatformType { KUAISHOU = 'kuaishou', // 快手小程序 - 快手短视频生态 H5 = 'h5', // H5网页版 - 浏览器端应用 RN = 'rn', // React Native应用 - 原生移动应用 + STRIPE = 'stripe', // Stripe支付平台 - 国际信用卡支付 + GOOGLE = 'google', // Google OAuth - Google账号登录 } /** diff --git a/src/payment/adapters/__tests__/stripe-payment.adapter.spec.ts b/src/payment/adapters/__tests__/stripe-payment.adapter.spec.ts new file mode 100644 index 0000000..b7d7a00 --- /dev/null +++ b/src/payment/adapters/__tests__/stripe-payment.adapter.spec.ts @@ -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; + let paymentTransactionRepository: Repository; + let refundRecordRepository: Repository; + let mockStripe: jest.Mocked; + + 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); + httpService = module.get(HttpService); + configService = module.get(ConfigService); + paymentOrderRepository = module.get>( + getRepositoryToken(PaymentOrderEntity), + ); + paymentTransactionRepository = module.get>( + getRepositoryToken(PaymentTransactionEntity), + ); + refundRecordRepository = module.get>( + getRepositoryToken(RefundRecordEntity), + ); + + // Mock Stripe instance + mockStripe = { + paymentIntents: { + create: jest.fn() as jest.MockedFunction, + retrieve: jest.fn() as jest.MockedFunction, + }, + refunds: { + create: jest.fn() as jest.MockedFunction, + retrieve: jest.fn() as jest.MockedFunction, + }, + webhooks: { + constructEvent: jest.fn() as jest.MockedFunction, + }, + reporting: { + reportRuns: { + create: jest.fn() as jest.MockedFunction, + }, + }, + } 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).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).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).mockResolvedValue(mockPaymentIntent as any); + (mockStripe.refunds.create as jest.MockedFunction).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).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).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, + ); + }); + }); +}); \ No newline at end of file diff --git a/src/payment/adapters/base-payment.adapter.ts b/src/payment/adapters/base-payment.adapter.ts index 60d2a77..493ea4e 100644 --- a/src/payment/adapters/base-payment.adapter.ts +++ b/src/payment/adapters/base-payment.adapter.ts @@ -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); } /** diff --git a/src/payment/adapters/index.ts b/src/payment/adapters/index.ts index ec9a1ca..c7b88f6 100644 --- a/src/payment/adapters/index.ts +++ b/src/payment/adapters/index.ts @@ -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'; diff --git a/src/payment/adapters/stripe-payment.adapter.ts b/src/payment/adapters/stripe-payment.adapter.ts new file mode 100644 index 0000000..7c6b694 --- /dev/null +++ b/src/payment/adapters/stripe-payment.adapter.ts @@ -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, + @InjectRepository(PaymentTransactionEntity) + paymentTransactionRepository: Repository, + @InjectRepository(RefundRecordEntity) + refundRecordRepository: Repository, + ) { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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}`); + } +} \ No newline at end of file diff --git a/src/payment/controllers/index.ts b/src/payment/controllers/index.ts index 2e8a22d..45513be 100644 --- a/src/payment/controllers/index.ts +++ b/src/payment/controllers/index.ts @@ -1 +1,2 @@ export { PaymentController } from './payment.controller'; +export { StripePaymentController } from './stripe-payment.controller'; diff --git a/src/payment/controllers/stripe-payment.controller.ts b/src/payment/controllers/stripe-payment.controller.ts new file mode 100644 index 0000000..d443627 --- /dev/null +++ b/src/payment/controllers/stripe-payment.controller.ts @@ -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 { + 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, + @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 { + 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', + }; + } +} \ No newline at end of file diff --git a/src/payment/dto/index.ts b/src/payment/dto/index.ts index 60a74e6..5a10c45 100644 --- a/src/payment/dto/index.ts +++ b/src/payment/dto/index.ts @@ -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'; diff --git a/src/payment/dto/stripe-payment.dto.ts b/src/payment/dto/stripe-payment.dto.ts new file mode 100644 index 0000000..72a2661 --- /dev/null +++ b/src/payment/dto/stripe-payment.dto.ts @@ -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; +} \ No newline at end of file diff --git a/src/payment/interfaces/payment.interface.ts b/src/payment/interfaces/payment.interface.ts index 9268691..a012772 100644 --- a/src/payment/interfaces/payment.interface.ts +++ b/src/payment/interfaces/payment.interface.ts @@ -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信用卡支付 } /** diff --git a/src/payment/payment.module.ts b/src/payment/payment.module.ts index 0ea6846..1622af4 100644 --- a/src/payment/payment.module.ts +++ b/src/payment/payment.module.ts @@ -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: [ // 导出核心服务供其他模块使用 diff --git a/src/payment/services/unified-payment.service.ts b/src/payment/services/unified-payment.service.ts index bdd27bc..1149055 100644 --- a/src/payment/services/unified-payment.service.ts +++ b/src/payment/services/unified-payment.service.ts @@ -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 { 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`; + } + } } diff --git a/src/platform/adapters/google.adapter.ts b/src/platform/adapters/google.adapter.ts new file mode 100644 index 0000000..779698c --- /dev/null +++ b/src/platform/adapters/google.adapter.ts @@ -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, + @InjectRepository(PlatformUserEntity) + protected readonly platformUserRepository: Repository, + protected readonly jwtService: JwtService, + ) { + super(httpService, configService, userRepository, platformUserRepository, jwtService); + } + + async login(loginData: PlatformLoginData): Promise { + 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 { + return this.login(registerData); + } + + async getUserInfo(token: string, platformUserId: string): Promise { + 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 { + 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): Promise { + 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 { + 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 { + 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 { + 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); + } + } +} \ No newline at end of file diff --git a/src/platform/adapters/index.ts b/src/platform/adapters/index.ts index 5f48533..413aa14 100644 --- a/src/platform/adapters/index.ts +++ b/src/platform/adapters/index.ts @@ -1,3 +1,4 @@ export * from './base.adapter'; export * from './wechat.adapter'; export * from './bytedance.adapter'; +export * from './google.adapter'; diff --git a/src/platform/platform.module.ts b/src/platform/platform.module.ts index e7753a2..5a52175 100644 --- a/src/platform/platform.module.ts +++ b/src/platform/platform.module.ts @@ -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],