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