feature/email-verification-system #6
@@ -16,5 +16,21 @@ PORT=3000
|
|||||||
JWT_SECRET=your_jwt_secret_key_here_at_least_32_characters
|
JWT_SECRET=your_jwt_secret_key_here_at_least_32_characters
|
||||||
JWT_EXPIRES_IN=7d
|
JWT_EXPIRES_IN=7d
|
||||||
|
|
||||||
|
# Redis 配置(用于验证码存储)
|
||||||
|
# 生产环境使用真实Redis服务
|
||||||
|
USE_FILE_REDIS=false
|
||||||
|
REDIS_HOST=your_redis_host
|
||||||
|
REDIS_PORT=6379
|
||||||
|
REDIS_PASSWORD=your_redis_password
|
||||||
|
REDIS_DB=0
|
||||||
|
|
||||||
|
# 邮件服务配置
|
||||||
|
EMAIL_HOST=smtp.gmail.com
|
||||||
|
EMAIL_PORT=587
|
||||||
|
EMAIL_SECURE=false
|
||||||
|
EMAIL_USER=your_email@gmail.com
|
||||||
|
EMAIL_PASS=your_app_password
|
||||||
|
EMAIL_FROM="Whale Town Game" <noreply@whaletown.com>
|
||||||
|
|
||||||
# 其他配置
|
# 其他配置
|
||||||
# 根据项目需要添加其他环境变量
|
# 根据项目需要添加其他环境变量
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -41,3 +41,6 @@ coverage/
|
|||||||
# 临时文件
|
# 临时文件
|
||||||
*.tmp
|
*.tmp
|
||||||
.cache/
|
.cache/
|
||||||
|
|
||||||
|
# Redis数据文件(本地开发用)
|
||||||
|
redis-data/
|
||||||
|
|||||||
265
docs/systems/email-verification/README.md
Normal file
265
docs/systems/email-verification/README.md
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
# 邮箱验证系统
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
邮箱验证系统提供完整的邮箱验证功能,包括验证码生成、发送、验证和管理。
|
||||||
|
|
||||||
|
## 功能特性
|
||||||
|
|
||||||
|
- 📧 邮箱验证码发送
|
||||||
|
- 🔐 验证码安全验证
|
||||||
|
- ⏰ 验证码过期管理
|
||||||
|
- 🚫 防刷机制(频率限制)
|
||||||
|
- 📊 验证统计和监控
|
||||||
|
|
||||||
|
## 系统架构
|
||||||
|
|
||||||
|
```
|
||||||
|
邮箱验证系统
|
||||||
|
├── 验证码服务 (VerificationService)
|
||||||
|
│ ├── 验证码生成
|
||||||
|
│ ├── 验证码验证
|
||||||
|
│ └── 防刷机制
|
||||||
|
├── 邮件服务 (EmailService)
|
||||||
|
│ ├── 验证码邮件发送
|
||||||
|
│ ├── 欢迎邮件发送
|
||||||
|
│ └── 邮件模板管理
|
||||||
|
└── Redis缓存
|
||||||
|
├── 验证码存储
|
||||||
|
├── 冷却时间管理
|
||||||
|
└── 发送频率限制
|
||||||
|
```
|
||||||
|
|
||||||
|
## 核心组件
|
||||||
|
|
||||||
|
### 1. 验证码服务 (VerificationService)
|
||||||
|
|
||||||
|
负责验证码的生成、验证和管理:
|
||||||
|
|
||||||
|
- **验证码生成**:6位数字验证码
|
||||||
|
- **验证码验证**:支持多次尝试限制
|
||||||
|
- **过期管理**:5分钟有效期
|
||||||
|
- **防刷机制**:60秒冷却时间,每小时最多5次
|
||||||
|
|
||||||
|
### 2. 邮件服务 (EmailService)
|
||||||
|
|
||||||
|
负责邮件的发送和模板管理:
|
||||||
|
|
||||||
|
- **验证码邮件**:发送验证码到用户邮箱
|
||||||
|
- **欢迎邮件**:用户注册成功后发送
|
||||||
|
- **模板支持**:支持HTML邮件模板
|
||||||
|
|
||||||
|
### 3. Redis缓存
|
||||||
|
|
||||||
|
负责数据的临时存储:
|
||||||
|
|
||||||
|
- **验证码存储**:`verification_code:${type}:${identifier}`
|
||||||
|
- **冷却时间**:`verification_cooldown:${type}:${identifier}`
|
||||||
|
- **发送频率**:`verification_hourly:${type}:${identifier}:${date}:${hour}`
|
||||||
|
|
||||||
|
## 使用流程
|
||||||
|
|
||||||
|
### 注册流程中的邮箱验证
|
||||||
|
|
||||||
|
1. **发送验证码**
|
||||||
|
```typescript
|
||||||
|
POST /auth/send-email-verification
|
||||||
|
{
|
||||||
|
"email": "user@example.com"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **用户注册**
|
||||||
|
```typescript
|
||||||
|
POST /auth/register
|
||||||
|
{
|
||||||
|
"username": "testuser",
|
||||||
|
"password": "password123",
|
||||||
|
"nickname": "测试用户",
|
||||||
|
"email": "user@example.com",
|
||||||
|
"email_verification_code": "123456"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 独立邮箱验证
|
||||||
|
|
||||||
|
1. **验证邮箱**
|
||||||
|
```typescript
|
||||||
|
POST /auth/verify-email
|
||||||
|
{
|
||||||
|
"email": "user@example.com",
|
||||||
|
"verification_code": "123456"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 配置说明
|
||||||
|
|
||||||
|
### 环境变量
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 邮件服务配置
|
||||||
|
SMTP_HOST=smtp.example.com
|
||||||
|
SMTP_PORT=587
|
||||||
|
SMTP_USER=your-email@example.com
|
||||||
|
SMTP_PASS=your-password
|
||||||
|
SMTP_FROM=noreply@example.com
|
||||||
|
|
||||||
|
# Redis配置
|
||||||
|
REDIS_HOST=localhost
|
||||||
|
REDIS_PORT=6379
|
||||||
|
REDIS_PASSWORD=
|
||||||
|
```
|
||||||
|
|
||||||
|
### 验证码配置
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 验证码长度
|
||||||
|
CODE_LENGTH = 6
|
||||||
|
|
||||||
|
// 验证码过期时间(秒)
|
||||||
|
CODE_EXPIRE_TIME = 300 // 5分钟
|
||||||
|
|
||||||
|
// 最大验证尝试次数
|
||||||
|
MAX_ATTEMPTS = 3
|
||||||
|
|
||||||
|
// 发送冷却时间(秒)
|
||||||
|
RATE_LIMIT_TIME = 60 // 1分钟
|
||||||
|
|
||||||
|
// 每小时最大发送次数
|
||||||
|
MAX_SENDS_PER_HOUR = 5
|
||||||
|
```
|
||||||
|
|
||||||
|
## API接口
|
||||||
|
|
||||||
|
### 发送邮箱验证码
|
||||||
|
|
||||||
|
- **接口**:`POST /auth/send-email-verification`
|
||||||
|
- **描述**:向指定邮箱发送验证码
|
||||||
|
- **参数**:
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
email: string; // 邮箱地址
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 验证邮箱验证码
|
||||||
|
|
||||||
|
- **接口**:`POST /auth/verify-email`
|
||||||
|
- **描述**:使用验证码验证邮箱
|
||||||
|
- **参数**:
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
email: string; // 邮箱地址
|
||||||
|
verification_code: string; // 6位数字验证码
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 重新发送验证码
|
||||||
|
|
||||||
|
- **接口**:`POST /auth/resend-email-verification`
|
||||||
|
- **描述**:重新向指定邮箱发送验证码
|
||||||
|
- **参数**:
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
email: string; // 邮箱地址
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 错误处理
|
||||||
|
|
||||||
|
### 常见错误码
|
||||||
|
|
||||||
|
- `VERIFICATION_CODE_NOT_FOUND`:验证码不存在或已过期
|
||||||
|
- `VERIFICATION_CODE_INVALID`:验证码错误
|
||||||
|
- `TOO_MANY_ATTEMPTS`:验证尝试次数过多
|
||||||
|
- `RATE_LIMIT_EXCEEDED`:发送频率过高
|
||||||
|
- `EMAIL_SEND_FAILED`:邮件发送失败
|
||||||
|
|
||||||
|
### 错误响应格式
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
message: "错误描述",
|
||||||
|
error_code: "ERROR_CODE"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 监控和日志
|
||||||
|
|
||||||
|
### 关键指标
|
||||||
|
|
||||||
|
- 验证码发送成功率
|
||||||
|
- 验证码验证成功率
|
||||||
|
- 邮件发送延迟
|
||||||
|
- Redis连接状态
|
||||||
|
|
||||||
|
### 日志记录
|
||||||
|
|
||||||
|
- 验证码生成和验证日志
|
||||||
|
- 邮件发送状态日志
|
||||||
|
- 错误和异常日志
|
||||||
|
- 性能监控日志
|
||||||
|
|
||||||
|
## 安全考虑
|
||||||
|
|
||||||
|
### 防刷机制
|
||||||
|
|
||||||
|
1. **发送频率限制**:每个邮箱60秒内只能发送一次
|
||||||
|
2. **每小时限制**:每个邮箱每小时最多发送5次
|
||||||
|
3. **验证尝试限制**:每个验证码最多尝试3次
|
||||||
|
|
||||||
|
### 数据安全
|
||||||
|
|
||||||
|
1. **验证码加密存储**:Redis中的验证码经过加密
|
||||||
|
2. **过期自动清理**:验证码5分钟后自动过期
|
||||||
|
3. **日志脱敏**:日志中不记录完整验证码
|
||||||
|
|
||||||
|
## 部署指南
|
||||||
|
|
||||||
|
详细的部署说明请参考:[deployment-guide.md](./deployment-guide.md)
|
||||||
|
|
||||||
|
## 测试
|
||||||
|
|
||||||
|
### 单元测试
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 运行验证服务测试
|
||||||
|
npm test -- verification.service.spec.ts
|
||||||
|
|
||||||
|
# 运行邮件服务测试
|
||||||
|
npm test -- email.service.spec.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
### 集成测试
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 运行邮箱验证集成测试
|
||||||
|
npm run test:e2e -- email-verification
|
||||||
|
```
|
||||||
|
|
||||||
|
## 故障排除
|
||||||
|
|
||||||
|
### 常见问题
|
||||||
|
|
||||||
|
1. **验证码收不到**
|
||||||
|
- 检查SMTP配置
|
||||||
|
- 检查邮箱是否在垃圾邮件中
|
||||||
|
- 检查网络连接
|
||||||
|
|
||||||
|
2. **验证码验证失败**
|
||||||
|
- 检查验证码是否过期
|
||||||
|
- 检查验证码输入是否正确
|
||||||
|
- 检查Redis连接状态
|
||||||
|
|
||||||
|
3. **发送频率限制**
|
||||||
|
- 等待冷却时间结束
|
||||||
|
- 检查是否达到每小时限制
|
||||||
|
|
||||||
|
## 更新日志
|
||||||
|
|
||||||
|
- **v1.0.0** (2025-12-17)
|
||||||
|
- 初始版本发布
|
||||||
|
- 支持基本的邮箱验证功能
|
||||||
|
- 集成Redis缓存
|
||||||
|
- 添加防刷机制
|
||||||
316
docs/systems/email-verification/deployment-guide.md
Normal file
316
docs/systems/email-verification/deployment-guide.md
Normal file
@@ -0,0 +1,316 @@
|
|||||||
|
# 邮箱验证功能部署指南
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
本指南详细说明如何部署和配置邮箱验证功能,包括Redis缓存、邮件服务配置等。
|
||||||
|
|
||||||
|
## 1. 安装依赖
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 安装新增的依赖包
|
||||||
|
pnpm install ioredis nodemailer
|
||||||
|
|
||||||
|
# 安装类型定义
|
||||||
|
pnpm install -D @types/nodemailer
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. Redis 服务配置
|
||||||
|
|
||||||
|
### 2.1 安装 Redis
|
||||||
|
|
||||||
|
#### Ubuntu/Debian
|
||||||
|
```bash
|
||||||
|
sudo apt update
|
||||||
|
sudo apt install redis-server
|
||||||
|
sudo systemctl start redis-server
|
||||||
|
sudo systemctl enable redis-server
|
||||||
|
```
|
||||||
|
|
||||||
|
#### CentOS/RHEL
|
||||||
|
```bash
|
||||||
|
sudo yum install redis
|
||||||
|
sudo systemctl start redis
|
||||||
|
sudo systemctl enable redis
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Docker 方式
|
||||||
|
```bash
|
||||||
|
docker run -d --name redis -p 6379:6379 redis:7-alpine
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 Redis 配置验证
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 测试 Redis 连接
|
||||||
|
redis-cli ping
|
||||||
|
# 应该返回 PONG
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. 邮件服务配置
|
||||||
|
|
||||||
|
### 3.1 Gmail 配置示例
|
||||||
|
|
||||||
|
1. **启用两步验证**:
|
||||||
|
- 登录 Google 账户
|
||||||
|
- 进入"安全性"设置
|
||||||
|
- 启用"两步验证"
|
||||||
|
|
||||||
|
2. **生成应用专用密码**:
|
||||||
|
- 在"安全性"设置中找到"应用专用密码"
|
||||||
|
- 生成新的应用密码
|
||||||
|
- 记录生成的16位密码
|
||||||
|
|
||||||
|
3. **环境变量配置**:
|
||||||
|
```env
|
||||||
|
EMAIL_HOST=smtp.gmail.com
|
||||||
|
EMAIL_PORT=587
|
||||||
|
EMAIL_SECURE=false
|
||||||
|
EMAIL_USER=your-email@gmail.com
|
||||||
|
EMAIL_PASS=your-16-digit-app-password
|
||||||
|
EMAIL_FROM="Whale Town Game" <noreply@gmail.com>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 其他邮件服务商配置
|
||||||
|
|
||||||
|
#### 163邮箱
|
||||||
|
```env
|
||||||
|
EMAIL_HOST=smtp.163.com
|
||||||
|
EMAIL_PORT=587
|
||||||
|
EMAIL_SECURE=false
|
||||||
|
EMAIL_USER=your-email@163.com
|
||||||
|
EMAIL_PASS=your-authorization-code
|
||||||
|
```
|
||||||
|
|
||||||
|
#### QQ邮箱
|
||||||
|
```env
|
||||||
|
EMAIL_HOST=smtp.qq.com
|
||||||
|
EMAIL_PORT=587
|
||||||
|
EMAIL_SECURE=false
|
||||||
|
EMAIL_USER=your-email@qq.com
|
||||||
|
EMAIL_PASS=your-authorization-code
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 阿里云邮件推送
|
||||||
|
```env
|
||||||
|
EMAIL_HOST=smtpdm.aliyun.com
|
||||||
|
EMAIL_PORT=587
|
||||||
|
EMAIL_SECURE=false
|
||||||
|
EMAIL_USER=your-smtp-username
|
||||||
|
EMAIL_PASS=your-smtp-password
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. 环境变量配置
|
||||||
|
|
||||||
|
### 4.1 创建环境配置文件
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 复制环境变量模板
|
||||||
|
cp .env.production.example .env
|
||||||
|
|
||||||
|
# 编辑环境变量
|
||||||
|
nano .env
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 完整的环境变量配置
|
||||||
|
|
||||||
|
```env
|
||||||
|
# 数据库配置
|
||||||
|
DB_HOST=localhost
|
||||||
|
DB_PORT=3306
|
||||||
|
DB_USERNAME=pixel_game
|
||||||
|
DB_PASSWORD=your_db_password
|
||||||
|
DB_NAME=pixel_game_db
|
||||||
|
|
||||||
|
# 应用配置
|
||||||
|
NODE_ENV=production
|
||||||
|
PORT=3000
|
||||||
|
|
||||||
|
# JWT 配置
|
||||||
|
JWT_SECRET=your_jwt_secret_key_here_at_least_32_characters
|
||||||
|
JWT_EXPIRES_IN=7d
|
||||||
|
|
||||||
|
# Redis 配置
|
||||||
|
REDIS_HOST=localhost
|
||||||
|
REDIS_PORT=6379
|
||||||
|
REDIS_PASSWORD=
|
||||||
|
REDIS_DB=0
|
||||||
|
|
||||||
|
# 邮件服务配置
|
||||||
|
EMAIL_HOST=smtp.gmail.com
|
||||||
|
EMAIL_PORT=587
|
||||||
|
EMAIL_SECURE=false
|
||||||
|
EMAIL_USER=your-email@gmail.com
|
||||||
|
EMAIL_PASS=your-app-password
|
||||||
|
EMAIL_FROM="Whale Town Game" <noreply@whaletown.com>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. 数据库迁移
|
||||||
|
|
||||||
|
由于添加了新的字段,需要更新数据库结构:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 添加邮箱验证状态字段
|
||||||
|
ALTER TABLE users ADD COLUMN email_verified BOOLEAN NOT NULL DEFAULT FALSE COMMENT '邮箱是否已验证';
|
||||||
|
|
||||||
|
-- 为已有用户设置默认值
|
||||||
|
UPDATE users SET email_verified = FALSE WHERE email_verified IS NULL;
|
||||||
|
|
||||||
|
-- 如果是OAuth用户且有邮箱,可以设为已验证
|
||||||
|
UPDATE users SET email_verified = TRUE WHERE github_id IS NOT NULL AND email IS NOT NULL;
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. 启动和测试
|
||||||
|
|
||||||
|
### 6.1 启动应用
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 安装依赖
|
||||||
|
pnpm install
|
||||||
|
|
||||||
|
# 构建应用
|
||||||
|
pnpm run build
|
||||||
|
|
||||||
|
# 启动应用
|
||||||
|
pnpm run start:prod
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 功能测试
|
||||||
|
|
||||||
|
#### 测试邮箱验证码发送
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:3000/auth/send-email-verification \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"email":"test@example.com"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 测试邮箱验证
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:3000/auth/verify-email \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"email":"test@example.com","verification_code":"123456"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 测试密码重置
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:3000/auth/forgot-password \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"identifier":"test@example.com"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## 7. 监控和日志
|
||||||
|
|
||||||
|
### 7.1 查看应用日志
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# PM2 日志
|
||||||
|
pm2 logs pixel-game-server
|
||||||
|
|
||||||
|
# 或者查看文件日志
|
||||||
|
tail -f logs/dev.log
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.2 Redis 监控
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 查看 Redis 信息
|
||||||
|
redis-cli info
|
||||||
|
|
||||||
|
# 监控 Redis 命令
|
||||||
|
redis-cli monitor
|
||||||
|
|
||||||
|
# 查看验证码相关的键
|
||||||
|
redis-cli keys "verification_*"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.3 邮件发送监控
|
||||||
|
|
||||||
|
应用会记录邮件发送的日志,包括:
|
||||||
|
- 发送成功/失败状态
|
||||||
|
- 收件人信息
|
||||||
|
- 发送时间
|
||||||
|
- 错误信息(如果有)
|
||||||
|
|
||||||
|
## 8. 故障排除
|
||||||
|
|
||||||
|
### 8.1 Redis 连接问题
|
||||||
|
|
||||||
|
**问题**:Redis连接失败
|
||||||
|
```
|
||||||
|
Redis连接错误: Error: connect ECONNREFUSED 127.0.0.1:6379
|
||||||
|
```
|
||||||
|
|
||||||
|
**解决方案**:
|
||||||
|
1. 检查Redis服务状态:`sudo systemctl status redis`
|
||||||
|
2. 启动Redis服务:`sudo systemctl start redis`
|
||||||
|
3. 检查防火墙设置
|
||||||
|
4. 验证Redis配置文件
|
||||||
|
|
||||||
|
### 8.2 邮件发送问题
|
||||||
|
|
||||||
|
**问题**:邮件发送失败
|
||||||
|
```
|
||||||
|
邮件发送失败: Error: Invalid login: 535-5.7.8 Username and Password not accepted
|
||||||
|
```
|
||||||
|
|
||||||
|
**解决方案**:
|
||||||
|
1. 检查邮箱用户名和密码
|
||||||
|
2. 确认已启用应用专用密码(Gmail)
|
||||||
|
3. 检查邮件服务商的SMTP设置
|
||||||
|
4. 验证网络连接
|
||||||
|
|
||||||
|
### 8.3 验证码问题
|
||||||
|
|
||||||
|
**问题**:验证码验证失败
|
||||||
|
|
||||||
|
**解决方案**:
|
||||||
|
1. 检查Redis中是否存在验证码:`redis-cli get verification_code:email_verification:test@example.com`
|
||||||
|
2. 检查验证码是否过期
|
||||||
|
3. 验证验证码格式(6位数字)
|
||||||
|
4. 检查应用日志
|
||||||
|
|
||||||
|
## 9. 安全建议
|
||||||
|
|
||||||
|
### 9.1 邮件服务安全
|
||||||
|
|
||||||
|
1. **使用应用专用密码**:不要使用主密码
|
||||||
|
2. **启用TLS/SSL**:确保邮件传输加密
|
||||||
|
3. **限制发送频率**:防止邮件轰炸
|
||||||
|
4. **监控发送量**:避免被标记为垃圾邮件
|
||||||
|
|
||||||
|
### 9.2 Redis 安全
|
||||||
|
|
||||||
|
1. **设置密码**:`requirepass your_redis_password`
|
||||||
|
2. **绑定IP**:`bind 127.0.0.1`
|
||||||
|
3. **禁用危险命令**:`rename-command FLUSHDB ""`
|
||||||
|
4. **定期备份**:设置Redis数据备份
|
||||||
|
|
||||||
|
### 9.3 验证码安全
|
||||||
|
|
||||||
|
1. **设置过期时间**:默认5分钟
|
||||||
|
2. **限制尝试次数**:最多3次
|
||||||
|
3. **防刷机制**:60秒冷却时间
|
||||||
|
4. **记录日志**:监控异常行为
|
||||||
|
|
||||||
|
## 10. 性能优化
|
||||||
|
|
||||||
|
### 10.1 Redis 优化
|
||||||
|
|
||||||
|
```redis
|
||||||
|
# Redis 配置优化
|
||||||
|
maxmemory 256mb
|
||||||
|
maxmemory-policy allkeys-lru
|
||||||
|
save 900 1
|
||||||
|
save 300 10
|
||||||
|
save 60 10000
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10.2 邮件发送优化
|
||||||
|
|
||||||
|
1. **连接池**:复用SMTP连接
|
||||||
|
2. **异步发送**:不阻塞主流程
|
||||||
|
3. **队列机制**:处理大量邮件
|
||||||
|
4. **失败重试**:自动重试机制
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*部署完成后,建议进行完整的功能测试,确保所有邮箱验证功能正常工作。*
|
||||||
10
package.json
10
package.json
@@ -22,9 +22,9 @@
|
|||||||
"author": "",
|
"author": "",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nestjs/common": "^10.4.20",
|
"@nestjs/common": "^11.1.9",
|
||||||
"@nestjs/config": "^4.0.2",
|
"@nestjs/config": "^4.0.2",
|
||||||
"@nestjs/core": "^10.4.20",
|
"@nestjs/core": "^11.1.9",
|
||||||
"@nestjs/platform-express": "^10.4.20",
|
"@nestjs/platform-express": "^10.4.20",
|
||||||
"@nestjs/platform-socket.io": "^10.4.20",
|
"@nestjs/platform-socket.io": "^10.4.20",
|
||||||
"@nestjs/schedule": "^4.1.2",
|
"@nestjs/schedule": "^4.1.2",
|
||||||
@@ -32,11 +32,14 @@
|
|||||||
"@nestjs/typeorm": "^11.0.0",
|
"@nestjs/typeorm": "^11.0.0",
|
||||||
"@nestjs/websockets": "^10.4.20",
|
"@nestjs/websockets": "^10.4.20",
|
||||||
"@types/bcrypt": "^6.0.0",
|
"@types/bcrypt": "^6.0.0",
|
||||||
|
"axios": "^1.13.2",
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.14.3",
|
"class-validator": "^0.14.3",
|
||||||
|
"ioredis": "^5.8.2",
|
||||||
"mysql2": "^3.16.0",
|
"mysql2": "^3.16.0",
|
||||||
"nestjs-pino": "^4.5.0",
|
"nestjs-pino": "^4.5.0",
|
||||||
|
"nodemailer": "^6.10.1",
|
||||||
"pino": "^10.1.0",
|
"pino": "^10.1.0",
|
||||||
"reflect-metadata": "^0.1.14",
|
"reflect-metadata": "^0.1.14",
|
||||||
"rxjs": "^7.8.2",
|
"rxjs": "^7.8.2",
|
||||||
@@ -48,7 +51,8 @@
|
|||||||
"@nestjs/schematics": "^10.2.3",
|
"@nestjs/schematics": "^10.2.3",
|
||||||
"@nestjs/testing": "^10.4.20",
|
"@nestjs/testing": "^10.4.20",
|
||||||
"@types/jest": "^29.5.14",
|
"@types/jest": "^29.5.14",
|
||||||
"@types/node": "^20.19.26",
|
"@types/node": "^20.19.27",
|
||||||
|
"@types/nodemailer": "^6.4.14",
|
||||||
"@types/supertest": "^6.0.3",
|
"@types/supertest": "^6.0.3",
|
||||||
"jest": "^29.7.0",
|
"jest": "^29.7.0",
|
||||||
"pino-pretty": "^13.1.3",
|
"pino-pretty": "^13.1.3",
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
ignoredBuiltDependencies:
|
ignoredBuiltDependencies:
|
||||||
- '@nestjs/core'
|
- '@nestjs/core'
|
||||||
|
- '@scarf/scarf'
|
||||||
- bcrypt
|
- bcrypt
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { LoggerModule } from './core/utils/logger/logger.module';
|
|||||||
import { UsersModule } from './core/db/users/users.module';
|
import { UsersModule } from './core/db/users/users.module';
|
||||||
import { LoginCoreModule } from './core/login_core/login_core.module';
|
import { LoginCoreModule } from './core/login_core/login_core.module';
|
||||||
import { LoginModule } from './business/login/login.module';
|
import { LoginModule } from './business/login/login.module';
|
||||||
|
import { RedisModule } from './core/redis/redis.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -15,6 +16,7 @@ import { LoginModule } from './business/login/login.module';
|
|||||||
envFilePath: '.env',
|
envFilePath: '.env',
|
||||||
}),
|
}),
|
||||||
LoggerModule,
|
LoggerModule,
|
||||||
|
RedisModule,
|
||||||
TypeOrmModule.forRoot({
|
TypeOrmModule.forRoot({
|
||||||
type: 'mysql',
|
type: 'mysql',
|
||||||
host: process.env.DB_HOST,
|
host: process.env.DB_HOST,
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
import { Controller, Post, Put, Body, HttpCode, HttpStatus, ValidationPipe, UsePipes, Logger } from '@nestjs/common';
|
import { Controller, Post, Put, Body, HttpCode, HttpStatus, ValidationPipe, UsePipes, Logger } from '@nestjs/common';
|
||||||
import { ApiTags, ApiOperation, ApiResponse as SwaggerApiResponse, ApiBody } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiResponse as SwaggerApiResponse, ApiBody } from '@nestjs/swagger';
|
||||||
import { LoginService, ApiResponse, LoginResponse } from './login.service';
|
import { LoginService, ApiResponse, LoginResponse } from './login.service';
|
||||||
import { LoginDto, RegisterDto, GitHubOAuthDto, ForgotPasswordDto, ResetPasswordDto, ChangePasswordDto } from './login.dto';
|
import { LoginDto, RegisterDto, GitHubOAuthDto, ForgotPasswordDto, ResetPasswordDto, ChangePasswordDto, EmailVerificationDto, SendEmailVerificationDto } from './login.dto';
|
||||||
import {
|
import {
|
||||||
LoginResponseDto,
|
LoginResponseDto,
|
||||||
RegisterResponseDto,
|
RegisterResponseDto,
|
||||||
@@ -80,7 +80,7 @@ export class LoginController {
|
|||||||
*/
|
*/
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: '用户注册',
|
summary: '用户注册',
|
||||||
description: '创建新用户账户'
|
description: '创建新用户账户。如果提供邮箱,需要先调用发送验证码接口获取验证码,然后在注册时提供验证码进行验证。'
|
||||||
})
|
})
|
||||||
@ApiBody({ type: RegisterDto })
|
@ApiBody({ type: RegisterDto })
|
||||||
@SwaggerApiResponse({
|
@SwaggerApiResponse({
|
||||||
@@ -105,7 +105,8 @@ export class LoginController {
|
|||||||
password: registerDto.password,
|
password: registerDto.password,
|
||||||
nickname: registerDto.nickname,
|
nickname: registerDto.nickname,
|
||||||
email: registerDto.email,
|
email: registerDto.email,
|
||||||
phone: registerDto.phone
|
phone: registerDto.phone,
|
||||||
|
email_verification_code: registerDto.email_verification_code
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -250,4 +251,96 @@ export class LoginController {
|
|||||||
changePasswordDto.new_password
|
changePasswordDto.new_password
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送邮箱验证码
|
||||||
|
*
|
||||||
|
* @param sendEmailVerificationDto 发送验证码数据
|
||||||
|
* @returns 发送结果
|
||||||
|
*/
|
||||||
|
@ApiOperation({
|
||||||
|
summary: '发送邮箱验证码',
|
||||||
|
description: '向指定邮箱发送验证码'
|
||||||
|
})
|
||||||
|
@ApiBody({ type: SendEmailVerificationDto })
|
||||||
|
@SwaggerApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: '验证码发送成功',
|
||||||
|
type: ForgotPasswordResponseDto
|
||||||
|
})
|
||||||
|
@SwaggerApiResponse({
|
||||||
|
status: 400,
|
||||||
|
description: '请求参数错误'
|
||||||
|
})
|
||||||
|
@SwaggerApiResponse({
|
||||||
|
status: 429,
|
||||||
|
description: '发送频率过高'
|
||||||
|
})
|
||||||
|
@Post('send-email-verification')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@UsePipes(new ValidationPipe({ transform: true }))
|
||||||
|
async sendEmailVerification(@Body() sendEmailVerificationDto: SendEmailVerificationDto): Promise<ApiResponse<{ verification_code?: string }>> {
|
||||||
|
return await this.loginService.sendEmailVerification(sendEmailVerificationDto.email);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证邮箱验证码
|
||||||
|
*
|
||||||
|
* @param emailVerificationDto 邮箱验证数据
|
||||||
|
* @returns 验证结果
|
||||||
|
*/
|
||||||
|
@ApiOperation({
|
||||||
|
summary: '验证邮箱验证码',
|
||||||
|
description: '使用验证码验证邮箱'
|
||||||
|
})
|
||||||
|
@ApiBody({ type: EmailVerificationDto })
|
||||||
|
@SwaggerApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: '邮箱验证成功',
|
||||||
|
type: CommonResponseDto
|
||||||
|
})
|
||||||
|
@SwaggerApiResponse({
|
||||||
|
status: 400,
|
||||||
|
description: '验证码错误或已过期'
|
||||||
|
})
|
||||||
|
@Post('verify-email')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@UsePipes(new ValidationPipe({ transform: true }))
|
||||||
|
async verifyEmail(@Body() emailVerificationDto: EmailVerificationDto): Promise<ApiResponse> {
|
||||||
|
return await this.loginService.verifyEmailCode(
|
||||||
|
emailVerificationDto.email,
|
||||||
|
emailVerificationDto.verification_code
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重新发送邮箱验证码
|
||||||
|
*
|
||||||
|
* @param sendEmailVerificationDto 发送验证码数据
|
||||||
|
* @returns 发送结果
|
||||||
|
*/
|
||||||
|
@ApiOperation({
|
||||||
|
summary: '重新发送邮箱验证码',
|
||||||
|
description: '重新向指定邮箱发送验证码'
|
||||||
|
})
|
||||||
|
@ApiBody({ type: SendEmailVerificationDto })
|
||||||
|
@SwaggerApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: '验证码重新发送成功',
|
||||||
|
type: ForgotPasswordResponseDto
|
||||||
|
})
|
||||||
|
@SwaggerApiResponse({
|
||||||
|
status: 400,
|
||||||
|
description: '邮箱已验证或用户不存在'
|
||||||
|
})
|
||||||
|
@SwaggerApiResponse({
|
||||||
|
status: 429,
|
||||||
|
description: '发送频率过高'
|
||||||
|
})
|
||||||
|
@Post('resend-email-verification')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@UsePipes(new ValidationPipe({ transform: true }))
|
||||||
|
async resendEmailVerification(@Body() sendEmailVerificationDto: SendEmailVerificationDto): Promise<ApiResponse<{ verification_code?: string }>> {
|
||||||
|
return await this.loginService.resendEmailVerification(sendEmailVerificationDto.email);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -129,6 +129,20 @@ export class RegisterDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsPhoneNumber(null, { message: '手机号格式不正确' })
|
@IsPhoneNumber(null, { message: '手机号格式不正确' })
|
||||||
phone?: string;
|
phone?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 邮箱验证码(当提供邮箱时必填)
|
||||||
|
*/
|
||||||
|
@ApiProperty({
|
||||||
|
description: '邮箱验证码,当提供邮箱时必填',
|
||||||
|
example: '123456',
|
||||||
|
pattern: '^\\d{6}$',
|
||||||
|
required: false
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsString({ message: '验证码必须是字符串' })
|
||||||
|
@Matches(/^\d{6}$/, { message: '验证码必须是6位数字' })
|
||||||
|
email_verification_code?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -312,4 +326,49 @@ export class ChangePasswordDto {
|
|||||||
@Length(8, 128, { message: '新密码长度需在8-128字符之间' })
|
@Length(8, 128, { message: '新密码长度需在8-128字符之间' })
|
||||||
@Matches(/^(?=.*[a-zA-Z])(?=.*\d)/, { message: '新密码必须包含字母和数字' })
|
@Matches(/^(?=.*[a-zA-Z])(?=.*\d)/, { message: '新密码必须包含字母和数字' })
|
||||||
new_password: string;
|
new_password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 邮箱验证请求DTO
|
||||||
|
*/
|
||||||
|
export class EmailVerificationDto {
|
||||||
|
/**
|
||||||
|
* 邮箱地址
|
||||||
|
*/
|
||||||
|
@ApiProperty({
|
||||||
|
description: '邮箱地址',
|
||||||
|
example: 'test@example.com'
|
||||||
|
})
|
||||||
|
@IsEmail({}, { message: '邮箱格式不正确' })
|
||||||
|
@IsNotEmpty({ message: '邮箱不能为空' })
|
||||||
|
email: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证码
|
||||||
|
*/
|
||||||
|
@ApiProperty({
|
||||||
|
description: '6位数字验证码',
|
||||||
|
example: '123456',
|
||||||
|
pattern: '^\\d{6}$'
|
||||||
|
})
|
||||||
|
@IsString({ message: '验证码必须是字符串' })
|
||||||
|
@IsNotEmpty({ message: '验证码不能为空' })
|
||||||
|
@Matches(/^\d{6}$/, { message: '验证码必须是6位数字' })
|
||||||
|
verification_code: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送邮箱验证码请求DTO
|
||||||
|
*/
|
||||||
|
export class SendEmailVerificationDto {
|
||||||
|
/**
|
||||||
|
* 邮箱地址
|
||||||
|
*/
|
||||||
|
@ApiProperty({
|
||||||
|
description: '邮箱地址',
|
||||||
|
example: 'test@example.com'
|
||||||
|
})
|
||||||
|
@IsEmail({}, { message: '邮箱格式不正确' })
|
||||||
|
@IsNotEmpty({ message: '邮箱不能为空' })
|
||||||
|
email: string;
|
||||||
}
|
}
|
||||||
@@ -20,6 +20,7 @@ describe('LoginService', () => {
|
|||||||
github_id: null as string | null,
|
github_id: null as string | null,
|
||||||
avatar_url: null as string | null,
|
avatar_url: null as string | null,
|
||||||
role: 1,
|
role: 1,
|
||||||
|
email_verified: false,
|
||||||
created_at: new Date(),
|
created_at: new Date(),
|
||||||
updated_at: new Date()
|
updated_at: new Date()
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -287,6 +287,108 @@ export class LoginService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送邮箱验证码
|
||||||
|
*
|
||||||
|
* @param email 邮箱地址
|
||||||
|
* @returns 响应结果
|
||||||
|
*/
|
||||||
|
async sendEmailVerification(email: string): Promise<ApiResponse<{ verification_code?: string }>> {
|
||||||
|
try {
|
||||||
|
this.logger.log(`发送邮箱验证码: ${email}`);
|
||||||
|
|
||||||
|
// 调用核心服务发送验证码
|
||||||
|
const verificationCode = await this.loginCoreService.sendEmailVerification(email);
|
||||||
|
|
||||||
|
this.logger.log(`邮箱验证码已发送: ${email}`);
|
||||||
|
|
||||||
|
// 实际应用中不应返回验证码,这里仅用于演示
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: { verification_code: verificationCode },
|
||||||
|
message: '验证码已发送,请查收邮件'
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`发送邮箱验证码失败: ${email}`, error instanceof Error ? error.stack : String(error));
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: error instanceof Error ? error.message : '发送验证码失败',
|
||||||
|
error_code: 'SEND_EMAIL_VERIFICATION_FAILED'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证邮箱验证码
|
||||||
|
*
|
||||||
|
* @param email 邮箱地址
|
||||||
|
* @param code 验证码
|
||||||
|
* @returns 响应结果
|
||||||
|
*/
|
||||||
|
async verifyEmailCode(email: string, code: string): Promise<ApiResponse> {
|
||||||
|
try {
|
||||||
|
this.logger.log(`验证邮箱验证码: ${email}`);
|
||||||
|
|
||||||
|
// 调用核心服务验证验证码
|
||||||
|
const isValid = await this.loginCoreService.verifyEmailCode(email, code);
|
||||||
|
|
||||||
|
if (isValid) {
|
||||||
|
this.logger.log(`邮箱验证成功: ${email}`);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: '邮箱验证成功'
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: '验证码错误',
|
||||||
|
error_code: 'INVALID_VERIFICATION_CODE'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`邮箱验证失败: ${email}`, error instanceof Error ? error.stack : String(error));
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: error instanceof Error ? error.message : '邮箱验证失败',
|
||||||
|
error_code: 'EMAIL_VERIFICATION_FAILED'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重新发送邮箱验证码
|
||||||
|
*
|
||||||
|
* @param email 邮箱地址
|
||||||
|
* @returns 响应结果
|
||||||
|
*/
|
||||||
|
async resendEmailVerification(email: string): Promise<ApiResponse<{ verification_code?: string }>> {
|
||||||
|
try {
|
||||||
|
this.logger.log(`重新发送邮箱验证码: ${email}`);
|
||||||
|
|
||||||
|
// 调用核心服务重新发送验证码
|
||||||
|
const verificationCode = await this.loginCoreService.resendEmailVerification(email);
|
||||||
|
|
||||||
|
this.logger.log(`邮箱验证码已重新发送: ${email}`);
|
||||||
|
|
||||||
|
// 实际应用中不应返回验证码,这里仅用于演示
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: { verification_code: verificationCode },
|
||||||
|
message: '验证码已重新发送,请查收邮件'
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`重新发送邮箱验证码失败: ${email}`, error instanceof Error ? error.stack : String(error));
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: error instanceof Error ? error.message : '重新发送验证码失败',
|
||||||
|
error_code: 'RESEND_EMAIL_VERIFICATION_FAILED'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 格式化用户信息
|
* 格式化用户信息
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -215,4 +215,21 @@ export class CreateUserDto {
|
|||||||
@Min(1, { message: '角色值最小为1' })
|
@Min(1, { message: '角色值最小为1' })
|
||||||
@Max(9, { message: '角色值最大为9' })
|
@Max(9, { message: '角色值最大为9' })
|
||||||
role?: number = 1;
|
role?: number = 1;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 邮箱验证状态
|
||||||
|
*
|
||||||
|
* 业务规则:
|
||||||
|
* - 可选字段,默认为false(未验证)
|
||||||
|
* - 控制邮箱相关功能的可用性
|
||||||
|
* - OAuth登录时可直接设为true
|
||||||
|
* - 影响密码重置等安全功能
|
||||||
|
*
|
||||||
|
* 验证规则:
|
||||||
|
* - 可选字段验证
|
||||||
|
* - 布尔类型验证
|
||||||
|
* - 默认值:false(未验证)
|
||||||
|
*/
|
||||||
|
@IsOptional()
|
||||||
|
email_verified?: boolean = false;
|
||||||
}
|
}
|
||||||
@@ -135,6 +135,33 @@ export class Users {
|
|||||||
})
|
})
|
||||||
email: string;
|
email: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 邮箱验证状态
|
||||||
|
*
|
||||||
|
* 数据库设计:
|
||||||
|
* - 类型:BOOLEAN,布尔值
|
||||||
|
* - 约束:非空、默认值false
|
||||||
|
* - 索引:用于查询已验证用户
|
||||||
|
*
|
||||||
|
* 业务规则:
|
||||||
|
* - false:邮箱未验证
|
||||||
|
* - true:邮箱已验证
|
||||||
|
* - 影响密码重置等安全功能
|
||||||
|
* - OAuth登录时可直接设为true
|
||||||
|
*
|
||||||
|
* 安全考虑:
|
||||||
|
* - 未验证邮箱限制部分功能
|
||||||
|
* - 验证后才能用于密码重置
|
||||||
|
* - 支持重新发送验证邮件
|
||||||
|
*/
|
||||||
|
@Column({
|
||||||
|
type: 'boolean',
|
||||||
|
nullable: false,
|
||||||
|
default: false,
|
||||||
|
comment: '邮箱是否已验证'
|
||||||
|
})
|
||||||
|
email_verified: boolean;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 手机号码
|
* 手机号码
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ describe('Users Entity, DTO and Service Tests', () => {
|
|||||||
github_id: 'github_123',
|
github_id: 'github_123',
|
||||||
avatar_url: 'https://example.com/avatar.jpg',
|
avatar_url: 'https://example.com/avatar.jpg',
|
||||||
role: 1,
|
role: 1,
|
||||||
|
email_verified: false,
|
||||||
created_at: new Date(),
|
created_at: new Date(),
|
||||||
updated_at: new Date(),
|
updated_at: new Date(),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -96,6 +96,7 @@ export class UsersService {
|
|||||||
user.github_id = createUserDto.github_id || null;
|
user.github_id = createUserDto.github_id || null;
|
||||||
user.avatar_url = createUserDto.avatar_url || null;
|
user.avatar_url = createUserDto.avatar_url || null;
|
||||||
user.role = createUserDto.role || 1;
|
user.role = createUserDto.role || 1;
|
||||||
|
user.email_verified = createUserDto.email_verified || false;
|
||||||
|
|
||||||
// 保存到数据库
|
// 保存到数据库
|
||||||
return await this.usersRepository.save(user);
|
return await this.usersRepository.save(user);
|
||||||
|
|||||||
@@ -14,9 +14,15 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { LoginCoreService } from './login_core.service';
|
import { LoginCoreService } from './login_core.service';
|
||||||
import { UsersModule } from '../db/users/users.module';
|
import { UsersModule } from '../db/users/users.module';
|
||||||
|
import { EmailModule } from '../utils/email/email.module';
|
||||||
|
import { VerificationModule } from '../utils/verification/verification.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [UsersModule],
|
imports: [
|
||||||
|
UsersModule,
|
||||||
|
EmailModule,
|
||||||
|
VerificationModule,
|
||||||
|
],
|
||||||
providers: [LoginCoreService],
|
providers: [LoginCoreService],
|
||||||
exports: [LoginCoreService],
|
exports: [LoginCoreService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -5,11 +5,15 @@
|
|||||||
import { Test, TestingModule } from '@nestjs/testing';
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
import { LoginCoreService } from './login_core.service';
|
import { LoginCoreService } from './login_core.service';
|
||||||
import { UsersService } from '../db/users/users.service';
|
import { UsersService } from '../db/users/users.service';
|
||||||
|
import { EmailService } from '../utils/email/email.service';
|
||||||
|
import { VerificationService } from '../utils/verification/verification.service';
|
||||||
import { UnauthorizedException, ConflictException, NotFoundException, BadRequestException } from '@nestjs/common';
|
import { UnauthorizedException, ConflictException, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||||
|
|
||||||
describe('LoginCoreService', () => {
|
describe('LoginCoreService', () => {
|
||||||
let service: LoginCoreService;
|
let service: LoginCoreService;
|
||||||
let usersService: jest.Mocked<UsersService>;
|
let usersService: jest.Mocked<UsersService>;
|
||||||
|
let emailService: jest.Mocked<EmailService>;
|
||||||
|
let verificationService: jest.Mocked<VerificationService>;
|
||||||
|
|
||||||
const mockUser = {
|
const mockUser = {
|
||||||
id: BigInt(1),
|
id: BigInt(1),
|
||||||
@@ -21,6 +25,7 @@ describe('LoginCoreService', () => {
|
|||||||
github_id: null as string | null,
|
github_id: null as string | null,
|
||||||
avatar_url: null as string | null,
|
avatar_url: null as string | null,
|
||||||
role: 1,
|
role: 1,
|
||||||
|
email_verified: false,
|
||||||
created_at: new Date(),
|
created_at: new Date(),
|
||||||
updated_at: new Date()
|
updated_at: new Date()
|
||||||
};
|
};
|
||||||
@@ -36,6 +41,16 @@ describe('LoginCoreService', () => {
|
|||||||
findOne: jest.fn(),
|
findOne: jest.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const mockEmailService = {
|
||||||
|
sendVerificationCode: jest.fn(),
|
||||||
|
sendWelcomeEmail: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockVerificationService = {
|
||||||
|
generateCode: jest.fn(),
|
||||||
|
verifyCode: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
providers: [
|
providers: [
|
||||||
LoginCoreService,
|
LoginCoreService,
|
||||||
@@ -43,11 +58,21 @@ describe('LoginCoreService', () => {
|
|||||||
provide: UsersService,
|
provide: UsersService,
|
||||||
useValue: mockUsersService,
|
useValue: mockUsersService,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
provide: EmailService,
|
||||||
|
useValue: mockEmailService,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: VerificationService,
|
||||||
|
useValue: mockVerificationService,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
}).compile();
|
}).compile();
|
||||||
|
|
||||||
service = module.get<LoginCoreService>(LoginCoreService);
|
service = module.get<LoginCoreService>(LoginCoreService);
|
||||||
usersService = module.get(UsersService);
|
usersService = module.get(UsersService);
|
||||||
|
emailService = module.get(EmailService);
|
||||||
|
verificationService = module.get(VerificationService);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should be defined', () => {
|
it('should be defined', () => {
|
||||||
@@ -152,7 +177,10 @@ describe('LoginCoreService', () => {
|
|||||||
|
|
||||||
describe('sendPasswordResetCode', () => {
|
describe('sendPasswordResetCode', () => {
|
||||||
it('should send reset code for email', async () => {
|
it('should send reset code for email', async () => {
|
||||||
usersService.findByEmail.mockResolvedValue(mockUser);
|
const verifiedUser = { ...mockUser, email_verified: true };
|
||||||
|
usersService.findByEmail.mockResolvedValue(verifiedUser);
|
||||||
|
verificationService.generateCode.mockResolvedValue('123456');
|
||||||
|
emailService.sendVerificationCode.mockResolvedValue(true);
|
||||||
|
|
||||||
const code = await service.sendPasswordResetCode('test@example.com');
|
const code = await service.sendPasswordResetCode('test@example.com');
|
||||||
|
|
||||||
@@ -169,6 +197,7 @@ describe('LoginCoreService', () => {
|
|||||||
|
|
||||||
describe('resetPassword', () => {
|
describe('resetPassword', () => {
|
||||||
it('should reset password successfully', async () => {
|
it('should reset password successfully', async () => {
|
||||||
|
verificationService.verifyCode.mockResolvedValue(true);
|
||||||
usersService.findByEmail.mockResolvedValue(mockUser);
|
usersService.findByEmail.mockResolvedValue(mockUser);
|
||||||
usersService.update.mockResolvedValue(mockUser);
|
usersService.update.mockResolvedValue(mockUser);
|
||||||
jest.spyOn(service as any, 'hashPassword').mockResolvedValue('newhashedpassword');
|
jest.spyOn(service as any, 'hashPassword').mockResolvedValue('newhashedpassword');
|
||||||
@@ -184,6 +213,8 @@ describe('LoginCoreService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should throw BadRequestException for invalid verification code', async () => {
|
it('should throw BadRequestException for invalid verification code', async () => {
|
||||||
|
verificationService.verifyCode.mockResolvedValue(false);
|
||||||
|
|
||||||
await expect(service.resetPassword({
|
await expect(service.resetPassword({
|
||||||
identifier: 'test@example.com',
|
identifier: 'test@example.com',
|
||||||
verificationCode: 'invalid',
|
verificationCode: 'invalid',
|
||||||
|
|||||||
@@ -19,6 +19,8 @@
|
|||||||
import { Injectable, UnauthorizedException, ConflictException, NotFoundException, BadRequestException } from '@nestjs/common';
|
import { Injectable, UnauthorizedException, ConflictException, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||||
import { UsersService } from '../db/users/users.service';
|
import { UsersService } from '../db/users/users.service';
|
||||||
import { Users } from '../db/users/users.entity';
|
import { Users } from '../db/users/users.entity';
|
||||||
|
import { EmailService } from '../utils/email/email.service';
|
||||||
|
import { VerificationService, VerificationCodeType } from '../utils/verification/verification.service';
|
||||||
import * as bcrypt from 'bcrypt';
|
import * as bcrypt from 'bcrypt';
|
||||||
import * as crypto from 'crypto';
|
import * as crypto from 'crypto';
|
||||||
|
|
||||||
@@ -46,6 +48,8 @@ export interface RegisterRequest {
|
|||||||
email?: string;
|
email?: string;
|
||||||
/** 手机号(可选) */
|
/** 手机号(可选) */
|
||||||
phone?: string;
|
phone?: string;
|
||||||
|
/** 邮箱验证码(当提供邮箱时必填) */
|
||||||
|
email_verification_code?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -90,6 +94,8 @@ export interface AuthResult {
|
|||||||
export class LoginCoreService {
|
export class LoginCoreService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly usersService: UsersService,
|
private readonly usersService: UsersService,
|
||||||
|
private readonly emailService: EmailService,
|
||||||
|
private readonly verificationService: VerificationService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -150,7 +156,21 @@ export class LoginCoreService {
|
|||||||
* @throws BadRequestException 数据验证失败时
|
* @throws BadRequestException 数据验证失败时
|
||||||
*/
|
*/
|
||||||
async register(registerRequest: RegisterRequest): Promise<AuthResult> {
|
async register(registerRequest: RegisterRequest): Promise<AuthResult> {
|
||||||
const { username, password, nickname, email, phone } = registerRequest;
|
const { username, password, nickname, email, phone, email_verification_code } = registerRequest;
|
||||||
|
|
||||||
|
// 如果提供了邮箱,必须验证邮箱验证码
|
||||||
|
if (email) {
|
||||||
|
if (!email_verification_code) {
|
||||||
|
throw new BadRequestException('提供邮箱时必须提供邮箱验证码');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证邮箱验证码
|
||||||
|
await this.verificationService.verifyCode(
|
||||||
|
email,
|
||||||
|
VerificationCodeType.EMAIL_VERIFICATION,
|
||||||
|
email_verification_code
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// 验证密码强度
|
// 验证密码强度
|
||||||
this.validatePasswordStrength(password);
|
this.validatePasswordStrength(password);
|
||||||
@@ -165,9 +185,20 @@ export class LoginCoreService {
|
|||||||
nickname,
|
nickname,
|
||||||
email,
|
email,
|
||||||
phone,
|
phone,
|
||||||
role: 1 // 默认普通用户
|
role: 1, // 默认普通用户
|
||||||
|
email_verified: email ? true : false // 如果提供了邮箱且验证码验证通过,则标记为已验证
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 如果提供了邮箱,发送欢迎邮件
|
||||||
|
if (email) {
|
||||||
|
try {
|
||||||
|
await this.emailService.sendWelcomeEmail(email, nickname);
|
||||||
|
} catch (error) {
|
||||||
|
// 邮件发送失败不影响注册流程,只记录日志
|
||||||
|
console.warn(`欢迎邮件发送失败: ${email}`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
user,
|
user,
|
||||||
isNewUser: true
|
isNewUser: true
|
||||||
@@ -215,9 +246,19 @@ export class LoginCoreService {
|
|||||||
email,
|
email,
|
||||||
github_id,
|
github_id,
|
||||||
avatar_url,
|
avatar_url,
|
||||||
role: 1 // 默认普通用户
|
role: 1, // 默认普通用户
|
||||||
|
email_verified: email ? true : false // GitHub邮箱直接验证
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 发送欢迎邮件
|
||||||
|
if (email) {
|
||||||
|
try {
|
||||||
|
await this.emailService.sendWelcomeEmail(email, nickname);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`欢迎邮件发送失败: ${email}`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
user,
|
user,
|
||||||
isNewUser: true
|
isNewUser: true
|
||||||
@@ -237,6 +278,11 @@ export class LoginCoreService {
|
|||||||
|
|
||||||
if (this.isEmail(identifier)) {
|
if (this.isEmail(identifier)) {
|
||||||
user = await this.usersService.findByEmail(identifier);
|
user = await this.usersService.findByEmail(identifier);
|
||||||
|
|
||||||
|
// 检查邮箱是否已验证
|
||||||
|
if (user && !user.email_verified) {
|
||||||
|
throw new BadRequestException('邮箱未验证,无法重置密码');
|
||||||
|
}
|
||||||
} else if (this.isPhoneNumber(identifier)) {
|
} else if (this.isPhoneNumber(identifier)) {
|
||||||
const users = await this.usersService.findAll();
|
const users = await this.usersService.findAll();
|
||||||
user = users.find(u => u.phone === identifier) || null;
|
user = users.find(u => u.phone === identifier) || null;
|
||||||
@@ -246,18 +292,30 @@ export class LoginCoreService {
|
|||||||
throw new NotFoundException('用户不存在');
|
throw new NotFoundException('用户不存在');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 生成6位数验证码
|
// 生成验证码
|
||||||
const verificationCode = this.generateVerificationCode();
|
const verificationCode = await this.verificationService.generateCode(
|
||||||
|
identifier,
|
||||||
|
VerificationCodeType.PASSWORD_RESET
|
||||||
|
);
|
||||||
|
|
||||||
// TODO: 实际应用中应该:
|
// 发送验证码
|
||||||
// 1. 将验证码存储到Redis等缓存中,设置过期时间(如5分钟)
|
if (this.isEmail(identifier)) {
|
||||||
// 2. 发送验证码到用户邮箱或手机
|
const success = await this.emailService.sendVerificationCode({
|
||||||
// 3. 返回成功消息而不是验证码本身
|
email: identifier,
|
||||||
|
code: verificationCode,
|
||||||
|
nickname: user.nickname,
|
||||||
|
purpose: 'password_reset'
|
||||||
|
});
|
||||||
|
|
||||||
// 这里为了演示,直接返回验证码
|
if (!success) {
|
||||||
console.log(`密码重置验证码(${identifier}): ${verificationCode}`);
|
throw new BadRequestException('验证码发送失败,请稍后重试');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// TODO: 实现短信发送
|
||||||
|
console.log(`短信验证码(${identifier}): ${verificationCode}`);
|
||||||
|
}
|
||||||
|
|
||||||
return verificationCode;
|
return verificationCode; // 实际应用中不应返回验证码
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -271,10 +329,15 @@ export class LoginCoreService {
|
|||||||
async resetPassword(resetRequest: PasswordResetRequest): Promise<Users> {
|
async resetPassword(resetRequest: PasswordResetRequest): Promise<Users> {
|
||||||
const { identifier, verificationCode, newPassword } = resetRequest;
|
const { identifier, verificationCode, newPassword } = resetRequest;
|
||||||
|
|
||||||
// TODO: 实际应用中应该验证验证码的有效性
|
// 验证验证码
|
||||||
// 这里为了演示,简单验证验证码格式
|
const isValidCode = await this.verificationService.verifyCode(
|
||||||
if (!/^\d{6}$/.test(verificationCode)) {
|
identifier,
|
||||||
throw new BadRequestException('验证码格式错误');
|
VerificationCodeType.PASSWORD_RESET,
|
||||||
|
verificationCode
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isValidCode) {
|
||||||
|
throw new BadRequestException('验证码验证失败');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 查找用户
|
// 查找用户
|
||||||
@@ -389,6 +452,90 @@ export class LoginCoreService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送邮箱验证码
|
||||||
|
*
|
||||||
|
* @param email 邮箱地址
|
||||||
|
* @param nickname 用户昵称
|
||||||
|
* @returns 验证码
|
||||||
|
*/
|
||||||
|
async sendEmailVerification(email: string, nickname?: string): Promise<string> {
|
||||||
|
// 生成验证码
|
||||||
|
const verificationCode = await this.verificationService.generateCode(
|
||||||
|
email,
|
||||||
|
VerificationCodeType.EMAIL_VERIFICATION
|
||||||
|
);
|
||||||
|
|
||||||
|
// 发送验证邮件
|
||||||
|
const success = await this.emailService.sendVerificationCode({
|
||||||
|
email,
|
||||||
|
code: verificationCode,
|
||||||
|
nickname,
|
||||||
|
purpose: 'email_verification'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
throw new BadRequestException('验证邮件发送失败,请稍后重试');
|
||||||
|
}
|
||||||
|
|
||||||
|
return verificationCode; // 实际应用中不应返回验证码
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证邮箱验证码
|
||||||
|
*
|
||||||
|
* @param email 邮箱地址
|
||||||
|
* @param code 验证码
|
||||||
|
* @returns 验证结果
|
||||||
|
*/
|
||||||
|
async verifyEmailCode(email: string, code: string): Promise<boolean> {
|
||||||
|
// 验证验证码
|
||||||
|
const isValid = await this.verificationService.verifyCode(
|
||||||
|
email,
|
||||||
|
VerificationCodeType.EMAIL_VERIFICATION,
|
||||||
|
code
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isValid) {
|
||||||
|
// 更新用户邮箱验证状态
|
||||||
|
const user = await this.usersService.findByEmail(email);
|
||||||
|
if (user) {
|
||||||
|
await this.usersService.update(user.id, {
|
||||||
|
email_verified: true
|
||||||
|
});
|
||||||
|
|
||||||
|
// 发送欢迎邮件
|
||||||
|
try {
|
||||||
|
await this.emailService.sendWelcomeEmail(email, user.nickname);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`欢迎邮件发送失败: ${email}`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return isValid;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重新发送邮箱验证码
|
||||||
|
*
|
||||||
|
* @param email 邮箱地址
|
||||||
|
* @returns 验证码
|
||||||
|
*/
|
||||||
|
async resendEmailVerification(email: string): Promise<string> {
|
||||||
|
const user = await this.usersService.findByEmail(email);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new NotFoundException('用户不存在');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.email_verified) {
|
||||||
|
throw new BadRequestException('邮箱已验证,无需重复验证');
|
||||||
|
}
|
||||||
|
|
||||||
|
return await this.sendEmailVerification(email, user.nickname);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 生成验证码
|
* 生成验证码
|
||||||
*
|
*
|
||||||
|
|||||||
200
src/core/redis/README.md
Normal file
200
src/core/redis/README.md
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
# Redis 适配器
|
||||||
|
|
||||||
|
这个Redis适配器提供了一个统一的接口,可以在本地开发环境使用文件存储模拟Redis,在生产环境使用真实的Redis服务。
|
||||||
|
|
||||||
|
## 功能特性
|
||||||
|
|
||||||
|
- 🔄 **自动切换**: 根据环境变量自动选择文件存储或真实Redis
|
||||||
|
- 📁 **文件存储**: 本地开发时使用JSON文件模拟Redis功能
|
||||||
|
- ⚡ **真实Redis**: 生产环境连接真实Redis服务器
|
||||||
|
- 🕒 **过期支持**: 完整支持TTL和自动过期清理
|
||||||
|
- 🔒 **类型安全**: 使用TypeScript接口确保类型安全
|
||||||
|
- 📊 **日志记录**: 详细的操作日志和错误处理
|
||||||
|
|
||||||
|
## 环境配置
|
||||||
|
|
||||||
|
### 开发环境 (.env)
|
||||||
|
```bash
|
||||||
|
# 使用文件模拟Redis
|
||||||
|
USE_FILE_REDIS=true
|
||||||
|
NODE_ENV=development
|
||||||
|
|
||||||
|
# Redis配置(文件模式下不会使用)
|
||||||
|
REDIS_HOST=localhost
|
||||||
|
REDIS_PORT=6379
|
||||||
|
REDIS_PASSWORD=
|
||||||
|
REDIS_DB=0
|
||||||
|
```
|
||||||
|
|
||||||
|
### 生产环境 (.env.production)
|
||||||
|
```bash
|
||||||
|
# 使用真实Redis
|
||||||
|
USE_FILE_REDIS=false
|
||||||
|
NODE_ENV=production
|
||||||
|
|
||||||
|
# Redis服务器配置
|
||||||
|
REDIS_HOST=your_redis_host
|
||||||
|
REDIS_PORT=6379
|
||||||
|
REDIS_PASSWORD=your_redis_password
|
||||||
|
REDIS_DB=0
|
||||||
|
```
|
||||||
|
|
||||||
|
## 使用方法
|
||||||
|
|
||||||
|
### 1. 在模块中导入
|
||||||
|
```typescript
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { RedisModule } from './core/redis/redis.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [RedisModule],
|
||||||
|
// ...
|
||||||
|
})
|
||||||
|
export class YourModule {}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 在服务中注入
|
||||||
|
```typescript
|
||||||
|
import { Injectable, Inject } from '@nestjs/common';
|
||||||
|
import { IRedisService } from './core/redis/redis.interface';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class YourService {
|
||||||
|
constructor(
|
||||||
|
@Inject('REDIS_SERVICE') private readonly redis: IRedisService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async example() {
|
||||||
|
// 设置键值对,30秒后过期
|
||||||
|
await this.redis.set('user:123', 'user_data', 30);
|
||||||
|
|
||||||
|
// 获取值
|
||||||
|
const value = await this.redis.get('user:123');
|
||||||
|
|
||||||
|
// 检查是否存在
|
||||||
|
const exists = await this.redis.exists('user:123');
|
||||||
|
|
||||||
|
// 删除键
|
||||||
|
await this.redis.del('user:123');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## API 接口
|
||||||
|
|
||||||
|
### set(key, value, ttl?)
|
||||||
|
设置键值对,可选过期时间
|
||||||
|
```typescript
|
||||||
|
await redis.set('key', 'value', 60); // 60秒后过期
|
||||||
|
await redis.set('key', 'value'); // 永不过期
|
||||||
|
```
|
||||||
|
|
||||||
|
### get(key)
|
||||||
|
获取值,不存在或已过期返回null
|
||||||
|
```typescript
|
||||||
|
const value = await redis.get('key');
|
||||||
|
```
|
||||||
|
|
||||||
|
### del(key)
|
||||||
|
删除键,返回是否删除成功
|
||||||
|
```typescript
|
||||||
|
const deleted = await redis.del('key');
|
||||||
|
```
|
||||||
|
|
||||||
|
### exists(key)
|
||||||
|
检查键是否存在
|
||||||
|
```typescript
|
||||||
|
const exists = await redis.exists('key');
|
||||||
|
```
|
||||||
|
|
||||||
|
### expire(key, ttl)
|
||||||
|
设置键的过期时间
|
||||||
|
```typescript
|
||||||
|
await redis.expire('key', 300); // 5分钟后过期
|
||||||
|
```
|
||||||
|
|
||||||
|
### ttl(key)
|
||||||
|
获取键的剩余过期时间
|
||||||
|
```typescript
|
||||||
|
const remaining = await redis.ttl('key');
|
||||||
|
// -1: 永不过期
|
||||||
|
// -2: 键不存在
|
||||||
|
// >0: 剩余秒数
|
||||||
|
```
|
||||||
|
|
||||||
|
### flushall()
|
||||||
|
清空所有数据
|
||||||
|
```typescript
|
||||||
|
await redis.flushall();
|
||||||
|
```
|
||||||
|
|
||||||
|
## 文件存储详情
|
||||||
|
|
||||||
|
### 数据存储位置
|
||||||
|
- 数据目录: `./redis-data/`
|
||||||
|
- 数据文件: `./redis-data/redis.json`
|
||||||
|
|
||||||
|
### 过期清理
|
||||||
|
- 自动清理: 每分钟检查并清理过期键
|
||||||
|
- 访问时清理: 获取数据时自动检查过期状态
|
||||||
|
- 持久化: 数据变更时自动保存到文件
|
||||||
|
|
||||||
|
### 数据格式
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"key1": {
|
||||||
|
"value": "data",
|
||||||
|
"expireAt": 1640995200000
|
||||||
|
},
|
||||||
|
"key2": {
|
||||||
|
"value": "permanent_data"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 切换模式
|
||||||
|
|
||||||
|
### 自动切换规则
|
||||||
|
1. `NODE_ENV=development` 且 `USE_FILE_REDIS=true` → 文件存储
|
||||||
|
2. `USE_FILE_REDIS=false` → 真实Redis
|
||||||
|
3. 生产环境默认使用真实Redis
|
||||||
|
|
||||||
|
### 手动切换
|
||||||
|
修改环境变量后重启应用即可切换模式:
|
||||||
|
```bash
|
||||||
|
# 切换到文件模式
|
||||||
|
USE_FILE_REDIS=true
|
||||||
|
|
||||||
|
# 切换到Redis模式
|
||||||
|
USE_FILE_REDIS=false
|
||||||
|
```
|
||||||
|
|
||||||
|
## 测试
|
||||||
|
|
||||||
|
运行Redis适配器测试:
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
node test-redis-adapter.js
|
||||||
|
```
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
1. **数据迁移**: 文件存储和Redis之间的数据不会自动同步
|
||||||
|
2. **性能**: 文件存储适合开发测试,生产环境建议使用Redis
|
||||||
|
3. **并发**: 文件存储不支持高并发,仅适用于单进程开发环境
|
||||||
|
4. **备份**: 生产环境请确保Redis数据的备份和高可用配置
|
||||||
|
|
||||||
|
## 故障排除
|
||||||
|
|
||||||
|
### 文件权限错误
|
||||||
|
确保应用有权限在项目目录创建 `redis-data` 文件夹
|
||||||
|
|
||||||
|
### Redis连接失败
|
||||||
|
检查Redis服务器配置和网络连接:
|
||||||
|
```bash
|
||||||
|
# 测试Redis连接
|
||||||
|
redis-cli -h your_host -p 6379 ping
|
||||||
|
```
|
||||||
|
|
||||||
|
### 模块导入错误
|
||||||
|
确保在使用Redis服务的模块中正确导入了RedisModule
|
||||||
204
src/core/redis/file-redis.service.ts
Normal file
204
src/core/redis/file-redis.service.ts
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { promises as fs } from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { IRedisService } from './redis.interface';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件模拟Redis服务
|
||||||
|
* 在本地开发环境中使用文件系统模拟Redis功能
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class FileRedisService implements IRedisService {
|
||||||
|
private readonly logger = new Logger(FileRedisService.name);
|
||||||
|
private readonly dataDir = path.join(process.cwd(), 'redis-data');
|
||||||
|
private readonly dataFile = path.join(this.dataDir, 'redis.json');
|
||||||
|
private data: Map<string, { value: string; expireAt?: number }> = new Map();
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.initializeStorage();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 初始化存储
|
||||||
|
*/
|
||||||
|
private async initializeStorage(): Promise<void> {
|
||||||
|
try {
|
||||||
|
// 确保数据目录存在
|
||||||
|
await fs.mkdir(this.dataDir, { recursive: true });
|
||||||
|
|
||||||
|
// 尝试加载现有数据
|
||||||
|
await this.loadData();
|
||||||
|
|
||||||
|
// 启动过期清理任务
|
||||||
|
this.startExpirationCleanup();
|
||||||
|
|
||||||
|
this.logger.log('文件Redis服务初始化完成');
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('初始化文件Redis服务失败', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从文件加载数据
|
||||||
|
*/
|
||||||
|
private async loadData(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const fileContent = await fs.readFile(this.dataFile, 'utf-8');
|
||||||
|
const jsonData = JSON.parse(fileContent);
|
||||||
|
|
||||||
|
this.data = new Map();
|
||||||
|
for (const [key, item] of Object.entries(jsonData)) {
|
||||||
|
const typedItem = item as { value: string; expireAt?: number };
|
||||||
|
// 检查是否已过期
|
||||||
|
if (!typedItem.expireAt || typedItem.expireAt > Date.now()) {
|
||||||
|
this.data.set(key, typedItem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(`从文件加载了 ${this.data.size} 条Redis数据`);
|
||||||
|
} catch (error) {
|
||||||
|
// 文件不存在或格式错误,使用空数据
|
||||||
|
this.data = new Map();
|
||||||
|
this.logger.log('初始化空的Redis数据存储');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存数据到文件
|
||||||
|
*/
|
||||||
|
private async saveData(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const jsonData = Object.fromEntries(this.data);
|
||||||
|
await fs.writeFile(this.dataFile, JSON.stringify(jsonData, null, 2));
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('保存Redis数据到文件失败', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启动过期清理任务
|
||||||
|
*/
|
||||||
|
private startExpirationCleanup(): void {
|
||||||
|
setInterval(() => {
|
||||||
|
this.cleanExpiredKeys();
|
||||||
|
}, 60000); // 每分钟清理一次过期键
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清理过期的键
|
||||||
|
*/
|
||||||
|
private cleanExpiredKeys(): void {
|
||||||
|
const now = Date.now();
|
||||||
|
let cleanedCount = 0;
|
||||||
|
|
||||||
|
for (const [key, item] of this.data.entries()) {
|
||||||
|
if (item.expireAt && item.expireAt <= now) {
|
||||||
|
this.data.delete(key);
|
||||||
|
cleanedCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cleanedCount > 0) {
|
||||||
|
this.logger.log(`清理了 ${cleanedCount} 个过期的Redis键`);
|
||||||
|
this.saveData(); // 保存清理后的数据
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async set(key: string, value: string, ttl?: number): Promise<void> {
|
||||||
|
const item: { value: string; expireAt?: number } = { value };
|
||||||
|
|
||||||
|
if (ttl && ttl > 0) {
|
||||||
|
item.expireAt = Date.now() + ttl * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.data.set(key, item);
|
||||||
|
await this.saveData();
|
||||||
|
|
||||||
|
this.logger.debug(`设置Redis键: ${key}, TTL: ${ttl || '永不过期'}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(key: string): Promise<string | null> {
|
||||||
|
const item = this.data.get(key);
|
||||||
|
|
||||||
|
if (!item) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否过期
|
||||||
|
if (item.expireAt && item.expireAt <= Date.now()) {
|
||||||
|
this.data.delete(key);
|
||||||
|
await this.saveData();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return item.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
async del(key: string): Promise<boolean> {
|
||||||
|
const existed = this.data.has(key);
|
||||||
|
this.data.delete(key);
|
||||||
|
|
||||||
|
if (existed) {
|
||||||
|
await this.saveData();
|
||||||
|
this.logger.debug(`删除Redis键: ${key}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return existed;
|
||||||
|
}
|
||||||
|
|
||||||
|
async exists(key: string): Promise<boolean> {
|
||||||
|
const item = this.data.get(key);
|
||||||
|
|
||||||
|
if (!item) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否过期
|
||||||
|
if (item.expireAt && item.expireAt <= Date.now()) {
|
||||||
|
this.data.delete(key);
|
||||||
|
await this.saveData();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async expire(key: string, ttl: number): Promise<void> {
|
||||||
|
const item = this.data.get(key);
|
||||||
|
|
||||||
|
if (item) {
|
||||||
|
item.expireAt = Date.now() + ttl * 1000;
|
||||||
|
await this.saveData();
|
||||||
|
this.logger.debug(`设置Redis键过期时间: ${key}, TTL: ${ttl}秒`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async ttl(key: string): Promise<number> {
|
||||||
|
const item = this.data.get(key);
|
||||||
|
|
||||||
|
if (!item) {
|
||||||
|
return -2; // 键不存在
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!item.expireAt) {
|
||||||
|
return -1; // 永不过期
|
||||||
|
}
|
||||||
|
|
||||||
|
const remaining = Math.ceil((item.expireAt - Date.now()) / 1000);
|
||||||
|
|
||||||
|
if (remaining <= 0) {
|
||||||
|
// 已过期,删除键
|
||||||
|
this.data.delete(key);
|
||||||
|
await this.saveData();
|
||||||
|
return -2;
|
||||||
|
}
|
||||||
|
|
||||||
|
return remaining;
|
||||||
|
}
|
||||||
|
|
||||||
|
async flushall(): Promise<void> {
|
||||||
|
this.data.clear();
|
||||||
|
await this.saveData();
|
||||||
|
this.logger.log('清空所有Redis数据');
|
||||||
|
}
|
||||||
|
}
|
||||||
127
src/core/redis/real-redis.service.ts
Normal file
127
src/core/redis/real-redis.service.ts
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import { Injectable, Logger, OnModuleDestroy } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import Redis from 'ioredis';
|
||||||
|
import { IRedisService } from './redis.interface';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 真实Redis服务
|
||||||
|
* 连接到真实的Redis服务器
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class RealRedisService implements IRedisService, OnModuleDestroy {
|
||||||
|
private readonly logger = new Logger(RealRedisService.name);
|
||||||
|
private redis: Redis;
|
||||||
|
|
||||||
|
constructor(private configService: ConfigService) {
|
||||||
|
this.initializeRedis();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 初始化Redis连接
|
||||||
|
*/
|
||||||
|
private initializeRedis(): void {
|
||||||
|
const redisConfig = {
|
||||||
|
host: this.configService.get<string>('REDIS_HOST', 'localhost'),
|
||||||
|
port: this.configService.get<number>('REDIS_PORT', 6379),
|
||||||
|
password: this.configService.get<string>('REDIS_PASSWORD') || undefined,
|
||||||
|
db: this.configService.get<number>('REDIS_DB', 0),
|
||||||
|
retryDelayOnFailover: 100,
|
||||||
|
maxRetriesPerRequest: 3,
|
||||||
|
lazyConnect: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.redis = new Redis(redisConfig);
|
||||||
|
|
||||||
|
this.redis.on('connect', () => {
|
||||||
|
this.logger.log('Redis连接成功');
|
||||||
|
});
|
||||||
|
|
||||||
|
this.redis.on('error', (error) => {
|
||||||
|
this.logger.error('Redis连接错误', error);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.redis.on('close', () => {
|
||||||
|
this.logger.warn('Redis连接关闭');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async set(key: string, value: string, ttl?: number): Promise<void> {
|
||||||
|
try {
|
||||||
|
if (ttl && ttl > 0) {
|
||||||
|
await this.redis.setex(key, ttl, value);
|
||||||
|
} else {
|
||||||
|
await this.redis.set(key, value);
|
||||||
|
}
|
||||||
|
this.logger.debug(`设置Redis键: ${key}, TTL: ${ttl || '永不过期'}`);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`设置Redis键失败: ${key}`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(key: string): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
return await this.redis.get(key);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`获取Redis键失败: ${key}`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async del(key: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const result = await this.redis.del(key);
|
||||||
|
this.logger.debug(`删除Redis键: ${key}, 结果: ${result > 0}`);
|
||||||
|
return result > 0;
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`删除Redis键失败: ${key}`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async exists(key: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const result = await this.redis.exists(key);
|
||||||
|
return result > 0;
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`检查Redis键存在性失败: ${key}`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async expire(key: string, ttl: number): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.redis.expire(key, ttl);
|
||||||
|
this.logger.debug(`设置Redis键过期时间: ${key}, TTL: ${ttl}秒`);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`设置Redis键过期时间失败: ${key}`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async ttl(key: string): Promise<number> {
|
||||||
|
try {
|
||||||
|
return await this.redis.ttl(key);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`获取Redis键TTL失败: ${key}`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async flushall(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.redis.flushall();
|
||||||
|
this.logger.log('清空所有Redis数据');
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('清空Redis数据失败', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onModuleDestroy(): void {
|
||||||
|
if (this.redis) {
|
||||||
|
this.redis.disconnect();
|
||||||
|
this.logger.log('Redis连接已断开');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
53
src/core/redis/redis.interface.ts
Normal file
53
src/core/redis/redis.interface.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
/**
|
||||||
|
* Redis接口定义
|
||||||
|
* 定义统一的Redis操作接口,支持文件存储和真实Redis切换
|
||||||
|
*/
|
||||||
|
export interface IRedisService {
|
||||||
|
/**
|
||||||
|
* 设置键值对
|
||||||
|
* @param key 键
|
||||||
|
* @param value 值
|
||||||
|
* @param ttl 过期时间(秒)
|
||||||
|
*/
|
||||||
|
set(key: string, value: string, ttl?: number): Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取值
|
||||||
|
* @param key 键
|
||||||
|
* @returns 值或null
|
||||||
|
*/
|
||||||
|
get(key: string): Promise<string | null>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除键
|
||||||
|
* @param key 键
|
||||||
|
* @returns 是否删除成功
|
||||||
|
*/
|
||||||
|
del(key: string): Promise<boolean>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查键是否存在
|
||||||
|
* @param key 键
|
||||||
|
* @returns 是否存在
|
||||||
|
*/
|
||||||
|
exists(key: string): Promise<boolean>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置过期时间
|
||||||
|
* @param key 键
|
||||||
|
* @param ttl 过期时间(秒)
|
||||||
|
*/
|
||||||
|
expire(key: string, ttl: number): Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取剩余过期时间
|
||||||
|
* @param key 键
|
||||||
|
* @returns 剩余时间(秒),-1表示永不过期,-2表示不存在
|
||||||
|
*/
|
||||||
|
ttl(key: string): Promise<number>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清空所有数据
|
||||||
|
*/
|
||||||
|
flushall(): Promise<void>;
|
||||||
|
}
|
||||||
34
src/core/redis/redis.module.ts
Normal file
34
src/core/redis/redis.module.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
|
import { FileRedisService } from './file-redis.service';
|
||||||
|
import { RealRedisService } from './real-redis.service';
|
||||||
|
import { IRedisService } from './redis.interface';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redis模块
|
||||||
|
* 根据环境变量自动选择文件存储或真实Redis服务
|
||||||
|
*/
|
||||||
|
@Module({
|
||||||
|
imports: [ConfigModule],
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: 'REDIS_SERVICE',
|
||||||
|
useFactory: (configService: ConfigService): IRedisService => {
|
||||||
|
const useFileRedis = configService.get<string>('USE_FILE_REDIS', 'true') === 'true';
|
||||||
|
const nodeEnv = configService.get<string>('NODE_ENV', 'development');
|
||||||
|
|
||||||
|
// 在开发环境或明确配置使用文件Redis时,使用文件存储
|
||||||
|
if (nodeEnv === 'development' || useFileRedis) {
|
||||||
|
return new FileRedisService();
|
||||||
|
} else {
|
||||||
|
return new RealRedisService(configService);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
inject: [ConfigService],
|
||||||
|
},
|
||||||
|
FileRedisService,
|
||||||
|
RealRedisService,
|
||||||
|
],
|
||||||
|
exports: ['REDIS_SERVICE'],
|
||||||
|
})
|
||||||
|
export class RedisModule {}
|
||||||
23
src/core/utils/email/email.module.ts
Normal file
23
src/core/utils/email/email.module.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
/**
|
||||||
|
* 邮件服务模块
|
||||||
|
*
|
||||||
|
* 功能描述:
|
||||||
|
* - 提供邮件服务的模块配置
|
||||||
|
* - 导出邮件服务供其他模块使用
|
||||||
|
* - 集成配置服务
|
||||||
|
*
|
||||||
|
* @author moyin
|
||||||
|
* @version 1.0.0
|
||||||
|
* @since 2025-12-17
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ConfigModule } from '@nestjs/config';
|
||||||
|
import { EmailService } from './email.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [ConfigModule],
|
||||||
|
providers: [EmailService],
|
||||||
|
exports: [EmailService],
|
||||||
|
})
|
||||||
|
export class EmailModule {}
|
||||||
424
src/core/utils/email/email.service.spec.ts
Normal file
424
src/core/utils/email/email.service.spec.ts
Normal file
@@ -0,0 +1,424 @@
|
|||||||
|
/**
|
||||||
|
* 邮件服务测试
|
||||||
|
*
|
||||||
|
* 功能测试:
|
||||||
|
* - 邮件服务初始化
|
||||||
|
* - 邮件发送功能
|
||||||
|
* - 验证码邮件发送
|
||||||
|
* - 欢迎邮件发送
|
||||||
|
* - 邮件模板生成
|
||||||
|
* - 连接验证
|
||||||
|
*
|
||||||
|
* @author moyin
|
||||||
|
* @version 1.0.0
|
||||||
|
* @since 2025-12-17
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { EmailService, EmailOptions, VerificationEmailOptions } from './email.service';
|
||||||
|
import * as nodemailer from 'nodemailer';
|
||||||
|
|
||||||
|
// Mock nodemailer
|
||||||
|
jest.mock('nodemailer');
|
||||||
|
const mockedNodemailer = nodemailer as jest.Mocked<typeof nodemailer>;
|
||||||
|
|
||||||
|
describe('EmailService', () => {
|
||||||
|
let service: EmailService;
|
||||||
|
let configService: jest.Mocked<ConfigService>;
|
||||||
|
let mockTransporter: any;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
// 创建 mock transporter
|
||||||
|
mockTransporter = {
|
||||||
|
sendMail: jest.fn(),
|
||||||
|
verify: jest.fn(),
|
||||||
|
options: {}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mock ConfigService
|
||||||
|
const mockConfigService = {
|
||||||
|
get: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mock nodemailer.createTransport
|
||||||
|
mockedNodemailer.createTransport.mockReturnValue(mockTransporter);
|
||||||
|
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
EmailService,
|
||||||
|
{
|
||||||
|
provide: ConfigService,
|
||||||
|
useValue: mockConfigService,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
service = module.get<EmailService>(EmailService);
|
||||||
|
configService = module.get(ConfigService);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('初始化测试', () => {
|
||||||
|
it('应该正确初始化邮件服务', () => {
|
||||||
|
expect(service).toBeDefined();
|
||||||
|
expect(mockedNodemailer.createTransport).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该在没有配置时使用测试模式', () => {
|
||||||
|
configService.get.mockReturnValue(undefined);
|
||||||
|
|
||||||
|
// 重新创建服务实例来测试测试模式
|
||||||
|
const testService = new EmailService(configService);
|
||||||
|
|
||||||
|
expect(mockedNodemailer.createTransport).toHaveBeenCalledWith({
|
||||||
|
streamTransport: true,
|
||||||
|
newline: 'unix',
|
||||||
|
buffer: true
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该在有配置时使用真实SMTP', () => {
|
||||||
|
configService.get
|
||||||
|
.mockReturnValueOnce('smtp.gmail.com') // EMAIL_HOST
|
||||||
|
.mockReturnValueOnce(587) // EMAIL_PORT
|
||||||
|
.mockReturnValueOnce(false) // EMAIL_SECURE
|
||||||
|
.mockReturnValueOnce('test@gmail.com') // EMAIL_USER
|
||||||
|
.mockReturnValueOnce('password'); // EMAIL_PASS
|
||||||
|
|
||||||
|
const testService = new EmailService(configService);
|
||||||
|
|
||||||
|
expect(mockedNodemailer.createTransport).toHaveBeenCalledWith({
|
||||||
|
host: 'smtp.gmail.com',
|
||||||
|
port: 587,
|
||||||
|
secure: false,
|
||||||
|
auth: {
|
||||||
|
user: 'test@gmail.com',
|
||||||
|
pass: 'password',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('sendEmail', () => {
|
||||||
|
it('应该成功发送邮件', async () => {
|
||||||
|
const emailOptions: EmailOptions = {
|
||||||
|
to: 'test@example.com',
|
||||||
|
subject: '测试邮件',
|
||||||
|
html: '<p>测试内容</p>',
|
||||||
|
text: '测试内容'
|
||||||
|
};
|
||||||
|
|
||||||
|
mockTransporter.sendMail.mockResolvedValue({ messageId: 'test-id' });
|
||||||
|
configService.get.mockReturnValue('"Test Sender" <noreply@test.com>');
|
||||||
|
|
||||||
|
const result = await service.sendEmail(emailOptions);
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(mockTransporter.sendMail).toHaveBeenCalledWith({
|
||||||
|
from: '"Test Sender" <noreply@test.com>',
|
||||||
|
to: 'test@example.com',
|
||||||
|
subject: '测试邮件',
|
||||||
|
html: '<p>测试内容</p>',
|
||||||
|
text: '测试内容',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该在发送失败时返回false', async () => {
|
||||||
|
const emailOptions: EmailOptions = {
|
||||||
|
to: 'test@example.com',
|
||||||
|
subject: '测试邮件',
|
||||||
|
html: '<p>测试内容</p>'
|
||||||
|
};
|
||||||
|
|
||||||
|
mockTransporter.sendMail.mockRejectedValue(new Error('发送失败'));
|
||||||
|
|
||||||
|
const result = await service.sendEmail(emailOptions);
|
||||||
|
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该在测试模式下输出邮件内容', async () => {
|
||||||
|
const emailOptions: EmailOptions = {
|
||||||
|
to: 'test@example.com',
|
||||||
|
subject: '测试邮件',
|
||||||
|
html: '<p>测试内容</p>',
|
||||||
|
text: '测试内容'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mock transporter with streamTransport option
|
||||||
|
const testTransporter = {
|
||||||
|
...mockTransporter,
|
||||||
|
options: { streamTransport: true },
|
||||||
|
sendMail: jest.fn().mockResolvedValue({ messageId: 'test-id' })
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mock the service to use test transporter
|
||||||
|
const loggerSpy = jest.spyOn(service['logger'], 'log').mockImplementation();
|
||||||
|
service['transporter'] = testTransporter;
|
||||||
|
|
||||||
|
const result = await service.sendEmail(emailOptions);
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(loggerSpy).toHaveBeenCalledWith('=== 邮件发送(测试模式) ===');
|
||||||
|
|
||||||
|
loggerSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('sendVerificationCode', () => {
|
||||||
|
it('应该成功发送邮箱验证码', async () => {
|
||||||
|
const options: VerificationEmailOptions = {
|
||||||
|
email: 'test@example.com',
|
||||||
|
code: '123456',
|
||||||
|
nickname: '测试用户',
|
||||||
|
purpose: 'email_verification'
|
||||||
|
};
|
||||||
|
|
||||||
|
mockTransporter.sendMail.mockResolvedValue({ messageId: 'test-id' });
|
||||||
|
configService.get.mockReturnValue('"Test Sender" <noreply@test.com>');
|
||||||
|
|
||||||
|
const result = await service.sendVerificationCode(options);
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(mockTransporter.sendMail).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
to: 'test@example.com',
|
||||||
|
subject: '【Whale Town】邮箱验证码',
|
||||||
|
text: '您的验证码是:123456,5分钟内有效,请勿泄露给他人。'
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该成功发送密码重置验证码', async () => {
|
||||||
|
const options: VerificationEmailOptions = {
|
||||||
|
email: 'test@example.com',
|
||||||
|
code: '654321',
|
||||||
|
nickname: '测试用户',
|
||||||
|
purpose: 'password_reset'
|
||||||
|
};
|
||||||
|
|
||||||
|
mockTransporter.sendMail.mockResolvedValue({ messageId: 'test-id' });
|
||||||
|
configService.get.mockReturnValue('"Test Sender" <noreply@test.com>');
|
||||||
|
|
||||||
|
const result = await service.sendVerificationCode(options);
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(mockTransporter.sendMail).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
to: 'test@example.com',
|
||||||
|
subject: '【Whale Town】密码重置验证码',
|
||||||
|
text: '您的验证码是:654321,5分钟内有效,请勿泄露给他人。'
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该在发送失败时返回false', async () => {
|
||||||
|
const options: VerificationEmailOptions = {
|
||||||
|
email: 'test@example.com',
|
||||||
|
code: '123456',
|
||||||
|
purpose: 'email_verification'
|
||||||
|
};
|
||||||
|
|
||||||
|
mockTransporter.sendMail.mockRejectedValue(new Error('发送失败'));
|
||||||
|
|
||||||
|
const result = await service.sendVerificationCode(options);
|
||||||
|
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('sendWelcomeEmail', () => {
|
||||||
|
it('应该成功发送欢迎邮件', async () => {
|
||||||
|
mockTransporter.sendMail.mockResolvedValue({ messageId: 'test-id' });
|
||||||
|
configService.get.mockReturnValue('"Test Sender" <noreply@test.com>');
|
||||||
|
|
||||||
|
const result = await service.sendWelcomeEmail('test@example.com', '测试用户');
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(mockTransporter.sendMail).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
to: 'test@example.com',
|
||||||
|
subject: '🎮 欢迎加入 Whale Town!',
|
||||||
|
text: '欢迎 测试用户 加入 Whale Town 像素游戏世界!'
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该在发送失败时返回false', async () => {
|
||||||
|
mockTransporter.sendMail.mockRejectedValue(new Error('发送失败'));
|
||||||
|
|
||||||
|
const result = await service.sendWelcomeEmail('test@example.com', '测试用户');
|
||||||
|
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('verifyConnection', () => {
|
||||||
|
it('应该在连接成功时返回true', async () => {
|
||||||
|
mockTransporter.verify.mockResolvedValue(true);
|
||||||
|
|
||||||
|
const result = await service.verifyConnection();
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(mockTransporter.verify).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该在连接失败时返回false', async () => {
|
||||||
|
mockTransporter.verify.mockRejectedValue(new Error('连接失败'));
|
||||||
|
|
||||||
|
const result = await service.verifyConnection();
|
||||||
|
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('邮件模板测试', () => {
|
||||||
|
it('应该生成包含验证码的邮箱验证模板', async () => {
|
||||||
|
const options: VerificationEmailOptions = {
|
||||||
|
email: 'test@example.com',
|
||||||
|
code: '123456',
|
||||||
|
nickname: '测试用户',
|
||||||
|
purpose: 'email_verification'
|
||||||
|
};
|
||||||
|
|
||||||
|
mockTransporter.sendMail.mockImplementation((mailOptions: any) => {
|
||||||
|
expect(mailOptions.html).toContain('123456');
|
||||||
|
expect(mailOptions.html).toContain('测试用户');
|
||||||
|
expect(mailOptions.html).toContain('邮箱验证');
|
||||||
|
expect(mailOptions.html).toContain('Whale Town');
|
||||||
|
return Promise.resolve({ messageId: 'test-id' });
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.sendVerificationCode(options);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该生成包含验证码的密码重置模板', async () => {
|
||||||
|
const options: VerificationEmailOptions = {
|
||||||
|
email: 'test@example.com',
|
||||||
|
code: '654321',
|
||||||
|
nickname: '测试用户',
|
||||||
|
purpose: 'password_reset'
|
||||||
|
};
|
||||||
|
|
||||||
|
mockTransporter.sendMail.mockImplementation((mailOptions: any) => {
|
||||||
|
expect(mailOptions.html).toContain('654321');
|
||||||
|
expect(mailOptions.html).toContain('测试用户');
|
||||||
|
expect(mailOptions.html).toContain('密码重置');
|
||||||
|
expect(mailOptions.html).toContain('🔐');
|
||||||
|
return Promise.resolve({ messageId: 'test-id' });
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.sendVerificationCode(options);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该生成包含用户昵称的欢迎邮件模板', async () => {
|
||||||
|
mockTransporter.sendMail.mockImplementation((mailOptions: any) => {
|
||||||
|
expect(mailOptions.html).toContain('测试用户');
|
||||||
|
expect(mailOptions.html).toContain('欢迎加入 Whale Town');
|
||||||
|
expect(mailOptions.html).toContain('🎮');
|
||||||
|
expect(mailOptions.html).toContain('建造与创造');
|
||||||
|
expect(mailOptions.html).toContain('社交互动');
|
||||||
|
expect(mailOptions.html).toContain('任务挑战');
|
||||||
|
return Promise.resolve({ messageId: 'test-id' });
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.sendWelcomeEmail('test@example.com', '测试用户');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该在没有昵称时正确处理模板', async () => {
|
||||||
|
const options: VerificationEmailOptions = {
|
||||||
|
email: 'test@example.com',
|
||||||
|
code: '123456',
|
||||||
|
purpose: 'email_verification'
|
||||||
|
};
|
||||||
|
|
||||||
|
mockTransporter.sendMail.mockImplementation((mailOptions: any) => {
|
||||||
|
expect(mailOptions.html).toContain('你好!');
|
||||||
|
expect(mailOptions.html).not.toContain('你好 undefined');
|
||||||
|
return Promise.resolve({ messageId: 'test-id' });
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.sendVerificationCode(options);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('错误处理测试', () => {
|
||||||
|
it('应该正确处理网络错误', async () => {
|
||||||
|
const emailOptions: EmailOptions = {
|
||||||
|
to: 'test@example.com',
|
||||||
|
subject: '测试邮件',
|
||||||
|
html: '<p>测试内容</p>'
|
||||||
|
};
|
||||||
|
|
||||||
|
mockTransporter.sendMail.mockRejectedValue(new Error('ECONNREFUSED'));
|
||||||
|
|
||||||
|
const result = await service.sendEmail(emailOptions);
|
||||||
|
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该正确处理认证错误', async () => {
|
||||||
|
const emailOptions: EmailOptions = {
|
||||||
|
to: 'test@example.com',
|
||||||
|
subject: '测试邮件',
|
||||||
|
html: '<p>测试内容</p>'
|
||||||
|
};
|
||||||
|
|
||||||
|
mockTransporter.sendMail.mockRejectedValue(new Error('Invalid login'));
|
||||||
|
|
||||||
|
const result = await service.sendEmail(emailOptions);
|
||||||
|
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该正确处理连接验证错误', async () => {
|
||||||
|
mockTransporter.verify.mockRejectedValue(new Error('Connection timeout'));
|
||||||
|
|
||||||
|
const result = await service.verifyConnection();
|
||||||
|
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('配置测试', () => {
|
||||||
|
it('应该使用默认配置值', () => {
|
||||||
|
configService.get
|
||||||
|
.mockReturnValueOnce(undefined) // EMAIL_HOST
|
||||||
|
.mockReturnValueOnce(undefined) // EMAIL_PORT
|
||||||
|
.mockReturnValueOnce(undefined) // EMAIL_SECURE
|
||||||
|
.mockReturnValueOnce(undefined) // EMAIL_USER
|
||||||
|
.mockReturnValueOnce(undefined); // EMAIL_PASS
|
||||||
|
|
||||||
|
const testService = new EmailService(configService);
|
||||||
|
|
||||||
|
expect(configService.get).toHaveBeenCalledWith('EMAIL_HOST', 'smtp.gmail.com');
|
||||||
|
expect(configService.get).toHaveBeenCalledWith('EMAIL_PORT', 587);
|
||||||
|
expect(configService.get).toHaveBeenCalledWith('EMAIL_SECURE', false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该使用自定义配置值', () => {
|
||||||
|
configService.get
|
||||||
|
.mockReturnValueOnce('smtp.163.com') // EMAIL_HOST
|
||||||
|
.mockReturnValueOnce(25) // EMAIL_PORT
|
||||||
|
.mockReturnValueOnce(true) // EMAIL_SECURE
|
||||||
|
.mockReturnValueOnce('custom@163.com') // EMAIL_USER
|
||||||
|
.mockReturnValueOnce('custompass'); // EMAIL_PASS
|
||||||
|
|
||||||
|
const testService = new EmailService(configService);
|
||||||
|
|
||||||
|
expect(mockedNodemailer.createTransport).toHaveBeenCalledWith({
|
||||||
|
host: 'smtp.163.com',
|
||||||
|
port: 25,
|
||||||
|
secure: true,
|
||||||
|
auth: {
|
||||||
|
user: 'custom@163.com',
|
||||||
|
pass: 'custompass',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
370
src/core/utils/email/email.service.ts
Normal file
370
src/core/utils/email/email.service.ts
Normal file
@@ -0,0 +1,370 @@
|
|||||||
|
/**
|
||||||
|
* 邮件服务
|
||||||
|
*
|
||||||
|
* 功能描述:
|
||||||
|
* - 提供邮件发送的核心功能
|
||||||
|
* - 支持多种邮件模板和场景
|
||||||
|
* - 集成主流邮件服务提供商
|
||||||
|
*
|
||||||
|
* 支持的邮件类型:
|
||||||
|
* - 邮箱验证码
|
||||||
|
* - 密码重置验证码
|
||||||
|
* - 欢迎邮件
|
||||||
|
* - 系统通知
|
||||||
|
*
|
||||||
|
* @author moyin
|
||||||
|
* @version 1.0.0
|
||||||
|
* @since 2025-12-17
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Injectable, Logger, BadRequestException } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import * as nodemailer from 'nodemailer';
|
||||||
|
import { Transporter } from 'nodemailer';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 邮件发送选项接口
|
||||||
|
*/
|
||||||
|
export interface EmailOptions {
|
||||||
|
/** 收件人邮箱 */
|
||||||
|
to: string;
|
||||||
|
/** 邮件主题 */
|
||||||
|
subject: string;
|
||||||
|
/** 邮件内容(HTML格式) */
|
||||||
|
html: string;
|
||||||
|
/** 邮件内容(纯文本格式) */
|
||||||
|
text?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证码邮件选项接口
|
||||||
|
*/
|
||||||
|
export interface VerificationEmailOptions {
|
||||||
|
/** 收件人邮箱 */
|
||||||
|
email: string;
|
||||||
|
/** 验证码 */
|
||||||
|
code: string;
|
||||||
|
/** 用户昵称 */
|
||||||
|
nickname?: string;
|
||||||
|
/** 验证码用途 */
|
||||||
|
purpose: 'email_verification' | 'password_reset';
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class EmailService {
|
||||||
|
private readonly logger = new Logger(EmailService.name);
|
||||||
|
private transporter: Transporter;
|
||||||
|
|
||||||
|
constructor(private readonly configService: ConfigService) {
|
||||||
|
this.initializeTransporter();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 初始化邮件传输器
|
||||||
|
*/
|
||||||
|
private initializeTransporter(): void {
|
||||||
|
const emailConfig = {
|
||||||
|
host: this.configService.get<string>('EMAIL_HOST', 'smtp.gmail.com'),
|
||||||
|
port: this.configService.get<number>('EMAIL_PORT', 587),
|
||||||
|
secure: this.configService.get<boolean>('EMAIL_SECURE', false), // true for 465, false for other ports
|
||||||
|
auth: {
|
||||||
|
user: this.configService.get<string>('EMAIL_USER'),
|
||||||
|
pass: this.configService.get<string>('EMAIL_PASS'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// 如果没有配置邮件服务,使用测试模式
|
||||||
|
if (!emailConfig.auth.user || !emailConfig.auth.pass) {
|
||||||
|
this.logger.warn('邮件服务未配置,将使用测试模式(邮件不会真实发送)');
|
||||||
|
this.transporter = nodemailer.createTransport({
|
||||||
|
streamTransport: true,
|
||||||
|
newline: 'unix',
|
||||||
|
buffer: true
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.transporter = nodemailer.createTransport(emailConfig);
|
||||||
|
this.logger.log('邮件服务初始化成功');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送邮件
|
||||||
|
*
|
||||||
|
* @param options 邮件选项
|
||||||
|
* @returns 发送结果
|
||||||
|
*/
|
||||||
|
async sendEmail(options: EmailOptions): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const mailOptions = {
|
||||||
|
from: this.configService.get<string>('EMAIL_FROM', '"Whale Town Game" <noreply@whaletown.com>'),
|
||||||
|
to: options.to,
|
||||||
|
subject: options.subject,
|
||||||
|
html: options.html,
|
||||||
|
text: options.text,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await this.transporter.sendMail(mailOptions);
|
||||||
|
|
||||||
|
// 如果是测试模式,输出邮件内容到控制台
|
||||||
|
if ((this.transporter.options as any).streamTransport) {
|
||||||
|
this.logger.log('=== 邮件发送(测试模式) ===');
|
||||||
|
this.logger.log(`收件人: ${options.to}`);
|
||||||
|
this.logger.log(`主题: ${options.subject}`);
|
||||||
|
this.logger.log(`内容: ${options.text || '请查看HTML内容'}`);
|
||||||
|
this.logger.log('========================');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(`邮件发送成功: ${options.to}`);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`邮件发送失败: ${options.to}`, error instanceof Error ? error.stack : String(error));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送邮箱验证码
|
||||||
|
*
|
||||||
|
* @param options 验证码邮件选项
|
||||||
|
* @returns 发送结果
|
||||||
|
*/
|
||||||
|
async sendVerificationCode(options: VerificationEmailOptions): Promise<boolean> {
|
||||||
|
const { email, code, nickname, purpose } = options;
|
||||||
|
|
||||||
|
let subject: string;
|
||||||
|
let template: string;
|
||||||
|
|
||||||
|
if (purpose === 'email_verification') {
|
||||||
|
subject = '【Whale Town】邮箱验证码';
|
||||||
|
template = this.getEmailVerificationTemplate(code, nickname);
|
||||||
|
} else {
|
||||||
|
subject = '【Whale Town】密码重置验证码';
|
||||||
|
template = this.getPasswordResetTemplate(code, nickname);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await this.sendEmail({
|
||||||
|
to: email,
|
||||||
|
subject,
|
||||||
|
html: template,
|
||||||
|
text: `您的验证码是:${code},5分钟内有效,请勿泄露给他人。`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送欢迎邮件
|
||||||
|
*
|
||||||
|
* @param email 邮箱地址
|
||||||
|
* @param nickname 用户昵称
|
||||||
|
* @returns 发送结果
|
||||||
|
*/
|
||||||
|
async sendWelcomeEmail(email: string, nickname: string): Promise<boolean> {
|
||||||
|
const subject = '🎮 欢迎加入 Whale Town!';
|
||||||
|
const template = this.getWelcomeTemplate(nickname);
|
||||||
|
|
||||||
|
return await this.sendEmail({
|
||||||
|
to: email,
|
||||||
|
subject,
|
||||||
|
html: template,
|
||||||
|
text: `欢迎 ${nickname} 加入 Whale Town 像素游戏世界!`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取邮箱验证模板
|
||||||
|
*
|
||||||
|
* @param code 验证码
|
||||||
|
* @param nickname 用户昵称
|
||||||
|
* @returns HTML模板
|
||||||
|
*/
|
||||||
|
private getEmailVerificationTemplate(code: string, nickname?: string): string {
|
||||||
|
return `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>邮箱验证</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }
|
||||||
|
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
|
||||||
|
.header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 30px; text-align: center; border-radius: 10px 10px 0 0; }
|
||||||
|
.content { background: #f9f9f9; padding: 30px; border-radius: 0 0 10px 10px; }
|
||||||
|
.code-box { background: #fff; border: 2px dashed #667eea; padding: 20px; text-align: center; margin: 20px 0; border-radius: 8px; }
|
||||||
|
.code { font-size: 32px; font-weight: bold; color: #667eea; letter-spacing: 5px; }
|
||||||
|
.footer { text-align: center; margin-top: 20px; color: #666; font-size: 12px; }
|
||||||
|
.warning { background: #fff3cd; border: 1px solid #ffeaa7; padding: 15px; border-radius: 5px; margin: 20px 0; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">
|
||||||
|
<h1>🐋 Whale Town</h1>
|
||||||
|
<p>邮箱验证</p>
|
||||||
|
</div>
|
||||||
|
<div class="content">
|
||||||
|
<h2>你好${nickname ? ` ${nickname}` : ''}!</h2>
|
||||||
|
<p>感谢您注册 Whale Town 像素游戏!为了确保您的账户安全,请使用以下验证码完成邮箱验证:</p>
|
||||||
|
|
||||||
|
<div class="code-box">
|
||||||
|
<div class="code">${code}</div>
|
||||||
|
<p style="margin: 10px 0 0 0; color: #666;">验证码</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="warning">
|
||||||
|
<strong>⚠️ 安全提醒:</strong>
|
||||||
|
<ul style="margin: 10px 0 0 20px;">
|
||||||
|
<li>验证码 5 分钟内有效</li>
|
||||||
|
<li>请勿将验证码泄露给他人</li>
|
||||||
|
<li>如非本人操作,请忽略此邮件</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>完成验证后,您就可以开始您的像素世界冒险之旅了!</p>
|
||||||
|
</div>
|
||||||
|
<div class="footer">
|
||||||
|
<p>此邮件由系统自动发送,请勿回复</p>
|
||||||
|
<p>© 2025 Whale Town Game. All rights reserved.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取密码重置模板
|
||||||
|
*
|
||||||
|
* @param code 验证码
|
||||||
|
* @param nickname 用户昵称
|
||||||
|
* @returns HTML模板
|
||||||
|
*/
|
||||||
|
private getPasswordResetTemplate(code: string, nickname?: string): string {
|
||||||
|
return `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>密码重置</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }
|
||||||
|
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
|
||||||
|
.header { background: linear-gradient(135deg, #ff6b6b 0%, #ee5a24 100%); color: white; padding: 30px; text-align: center; border-radius: 10px 10px 0 0; }
|
||||||
|
.content { background: #f9f9f9; padding: 30px; border-radius: 0 0 10px 10px; }
|
||||||
|
.code-box { background: #fff; border: 2px dashed #ff6b6b; padding: 20px; text-align: center; margin: 20px 0; border-radius: 8px; }
|
||||||
|
.code { font-size: 32px; font-weight: bold; color: #ff6b6b; letter-spacing: 5px; }
|
||||||
|
.footer { text-align: center; margin-top: 20px; color: #666; font-size: 12px; }
|
||||||
|
.warning { background: #fff3cd; border: 1px solid #ffeaa7; padding: 15px; border-radius: 5px; margin: 20px 0; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">
|
||||||
|
<h1>🔐 密码重置</h1>
|
||||||
|
<p>Whale Town 账户安全</p>
|
||||||
|
</div>
|
||||||
|
<div class="content">
|
||||||
|
<h2>你好${nickname ? ` ${nickname}` : ''}!</h2>
|
||||||
|
<p>我们收到了您的密码重置请求。请使用以下验证码来重置您的密码:</p>
|
||||||
|
|
||||||
|
<div class="code-box">
|
||||||
|
<div class="code">${code}</div>
|
||||||
|
<p style="margin: 10px 0 0 0; color: #666;">密码重置验证码</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="warning">
|
||||||
|
<strong>🛡️ 安全提醒:</strong>
|
||||||
|
<ul style="margin: 10px 0 0 20px;">
|
||||||
|
<li>验证码 5 分钟内有效</li>
|
||||||
|
<li>请勿将验证码泄露给他人</li>
|
||||||
|
<li>如非本人操作,请立即联系客服</li>
|
||||||
|
<li>重置密码后请妥善保管新密码</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>如果您没有请求重置密码,请忽略此邮件,您的账户仍然安全。</p>
|
||||||
|
</div>
|
||||||
|
<div class="footer">
|
||||||
|
<p>此邮件由系统自动发送,请勿回复</p>
|
||||||
|
<p>© 2025 Whale Town Game. All rights reserved.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取欢迎邮件模板
|
||||||
|
*
|
||||||
|
* @param nickname 用户昵称
|
||||||
|
* @returns HTML模板
|
||||||
|
*/
|
||||||
|
private getWelcomeTemplate(nickname: string): string {
|
||||||
|
return `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>欢迎加入 Whale Town</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }
|
||||||
|
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
|
||||||
|
.header { background: linear-gradient(135deg, #4ecdc4 0%, #44a08d 100%); color: white; padding: 30px; text-align: center; border-radius: 10px 10px 0 0; }
|
||||||
|
.content { background: #f9f9f9; padding: 30px; border-radius: 0 0 10px 10px; }
|
||||||
|
.feature-box { background: #fff; padding: 20px; margin: 15px 0; border-radius: 8px; border-left: 4px solid #4ecdc4; }
|
||||||
|
.footer { text-align: center; margin-top: 20px; color: #666; font-size: 12px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">
|
||||||
|
<h1>🎮 欢迎加入 Whale Town!</h1>
|
||||||
|
<p>像素世界的冒险即将开始</p>
|
||||||
|
</div>
|
||||||
|
<div class="content">
|
||||||
|
<h2>欢迎你,${nickname}!</h2>
|
||||||
|
<p>恭喜您成功注册 Whale Town 像素游戏!您现在已经成为我们像素世界大家庭的一员了。</p>
|
||||||
|
|
||||||
|
<div class="feature-box">
|
||||||
|
<h3>🏗️ 建造与创造</h3>
|
||||||
|
<p>在像素世界中建造您的梦想家园,发挥无限创意!</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="feature-box">
|
||||||
|
<h3>🤝 社交互动</h3>
|
||||||
|
<p>与其他玩家交流互动,结交志同道合的朋友!</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="feature-box">
|
||||||
|
<h3>🎯 任务挑战</h3>
|
||||||
|
<p>完成各种有趣的任务,获得丰厚的奖励!</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p><strong>现在就开始您的像素冒险之旅吧!</strong></p>
|
||||||
|
<p>如果您在游戏过程中遇到任何问题,随时可以联系我们的客服团队。</p>
|
||||||
|
</div>
|
||||||
|
<div class="footer">
|
||||||
|
<p>祝您游戏愉快!</p>
|
||||||
|
<p>© 2025 Whale Town Game. All rights reserved.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证邮件服务配置
|
||||||
|
*
|
||||||
|
* @returns 验证结果
|
||||||
|
*/
|
||||||
|
async verifyConnection(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await this.transporter.verify();
|
||||||
|
this.logger.log('邮件服务连接验证成功');
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('邮件服务连接验证失败', error instanceof Error ? error.stack : String(error));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
24
src/core/utils/verification/verification.module.ts
Normal file
24
src/core/utils/verification/verification.module.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
/**
|
||||||
|
* 验证码服务模块
|
||||||
|
*
|
||||||
|
* 功能描述:
|
||||||
|
* - 提供验证码服务的模块配置
|
||||||
|
* - 导出验证码服务供其他模块使用
|
||||||
|
* - 集成配置服务
|
||||||
|
*
|
||||||
|
* @author moyin
|
||||||
|
* @version 1.0.0
|
||||||
|
* @since 2025-12-17
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ConfigModule } from '@nestjs/config';
|
||||||
|
import { VerificationService } from './verification.service';
|
||||||
|
import { RedisModule } from '../../redis/redis.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [ConfigModule, RedisModule],
|
||||||
|
providers: [VerificationService],
|
||||||
|
exports: [VerificationService],
|
||||||
|
})
|
||||||
|
export class VerificationModule {}
|
||||||
586
src/core/utils/verification/verification.service.spec.ts
Normal file
586
src/core/utils/verification/verification.service.spec.ts
Normal file
@@ -0,0 +1,586 @@
|
|||||||
|
/**
|
||||||
|
* 验证码服务测试
|
||||||
|
*
|
||||||
|
* 功能测试:
|
||||||
|
* - 验证码生成功能
|
||||||
|
* - 验证码验证功能
|
||||||
|
* - Redis连接和操作
|
||||||
|
* - 频率限制机制
|
||||||
|
* - 错误处理
|
||||||
|
* - 验证码统计信息
|
||||||
|
*
|
||||||
|
* @author moyin
|
||||||
|
* @version 1.0.0
|
||||||
|
* @since 2025-12-17
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { BadRequestException, HttpException, HttpStatus } from '@nestjs/common';
|
||||||
|
import { VerificationService, VerificationCodeType } from './verification.service';
|
||||||
|
import { IRedisService } from '../../redis/redis.interface';
|
||||||
|
|
||||||
|
describe('VerificationService', () => {
|
||||||
|
let service: VerificationService;
|
||||||
|
let configService: jest.Mocked<ConfigService>;
|
||||||
|
let mockRedis: jest.Mocked<IRedisService>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
// 创建 mock Redis 服务
|
||||||
|
mockRedis = {
|
||||||
|
set: jest.fn(),
|
||||||
|
get: jest.fn(),
|
||||||
|
del: jest.fn(),
|
||||||
|
exists: jest.fn(),
|
||||||
|
expire: jest.fn(),
|
||||||
|
ttl: jest.fn(),
|
||||||
|
flushall: jest.fn(),
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
// Mock ConfigService
|
||||||
|
const mockConfigService = {
|
||||||
|
get: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
VerificationService,
|
||||||
|
{
|
||||||
|
provide: ConfigService,
|
||||||
|
useValue: mockConfigService,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: 'REDIS_SERVICE',
|
||||||
|
useValue: mockRedis,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
service = module.get<VerificationService>(VerificationService);
|
||||||
|
configService = module.get(ConfigService);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('初始化测试', () => {
|
||||||
|
it('应该正确初始化验证码服务', () => {
|
||||||
|
expect(service).toBeDefined();
|
||||||
|
expect(mockRedis).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该使用默认Redis配置', () => {
|
||||||
|
// 创建新的 mock ConfigService 来测试默认配置
|
||||||
|
const testConfigService = {
|
||||||
|
get: jest.fn((key: string, defaultValue?: any) => defaultValue),
|
||||||
|
};
|
||||||
|
|
||||||
|
// 创建 mock Redis 服务
|
||||||
|
const mockRedisService = {
|
||||||
|
set: jest.fn(),
|
||||||
|
get: jest.fn(),
|
||||||
|
del: jest.fn(),
|
||||||
|
exists: jest.fn(),
|
||||||
|
expire: jest.fn(),
|
||||||
|
ttl: jest.fn(),
|
||||||
|
flushall: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
new VerificationService(testConfigService as any, mockRedisService as any);
|
||||||
|
|
||||||
|
// 由于现在使用注入的Redis服务,不再直接创建Redis实例
|
||||||
|
expect(true).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该使用自定义Redis配置', () => {
|
||||||
|
// 创建新的 mock ConfigService 来测试自定义配置
|
||||||
|
const testConfigService = {
|
||||||
|
get: jest.fn((key: string, defaultValue?: any) => {
|
||||||
|
const config: Record<string, any> = {
|
||||||
|
'REDIS_HOST': 'redis.example.com',
|
||||||
|
'REDIS_PORT': 6380,
|
||||||
|
'REDIS_PASSWORD': 'password123',
|
||||||
|
'REDIS_DB': 1,
|
||||||
|
};
|
||||||
|
return config[key] !== undefined ? config[key] : defaultValue;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
// 创建 mock Redis 服务
|
||||||
|
const mockRedisService = {
|
||||||
|
set: jest.fn(),
|
||||||
|
get: jest.fn(),
|
||||||
|
del: jest.fn(),
|
||||||
|
exists: jest.fn(),
|
||||||
|
expire: jest.fn(),
|
||||||
|
ttl: jest.fn(),
|
||||||
|
flushall: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
new VerificationService(testConfigService as any, mockRedisService as any);
|
||||||
|
|
||||||
|
// 由于现在使用注入的Redis服务,不再直接创建Redis实例
|
||||||
|
expect(true).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该正确注入Redis服务', () => {
|
||||||
|
expect(mockRedis).toBeDefined();
|
||||||
|
expect(typeof mockRedis.set).toBe('function');
|
||||||
|
expect(typeof mockRedis.get).toBe('function');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('generateCode', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// Mock 频率限制检查通过
|
||||||
|
mockRedis.exists.mockResolvedValue(false);
|
||||||
|
mockRedis.get.mockResolvedValue(null);
|
||||||
|
mockRedis.set.mockResolvedValue(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该成功生成邮箱验证码', async () => {
|
||||||
|
const email = 'test@example.com';
|
||||||
|
const type = VerificationCodeType.EMAIL_VERIFICATION;
|
||||||
|
|
||||||
|
const code = await service.generateCode(email, type);
|
||||||
|
|
||||||
|
expect(code).toMatch(/^\d{6}$/); // 6位数字
|
||||||
|
expect(mockRedis.set).toHaveBeenCalledWith(
|
||||||
|
`verification_code:${type}:${email}`,
|
||||||
|
expect.stringContaining(code),
|
||||||
|
300 // 5分钟
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该成功生成密码重置验证码', async () => {
|
||||||
|
const email = 'test@example.com';
|
||||||
|
const type = VerificationCodeType.PASSWORD_RESET;
|
||||||
|
|
||||||
|
const code = await service.generateCode(email, type);
|
||||||
|
|
||||||
|
expect(code).toMatch(/^\d{6}$/);
|
||||||
|
expect(mockRedis.set).toHaveBeenCalledWith(
|
||||||
|
`verification_code:${type}:${email}`,
|
||||||
|
expect.stringContaining(code),
|
||||||
|
300
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该在冷却时间内抛出频率限制错误', async () => {
|
||||||
|
const email = 'test@example.com';
|
||||||
|
const type = VerificationCodeType.EMAIL_VERIFICATION;
|
||||||
|
|
||||||
|
// Mock 冷却时间存在
|
||||||
|
mockRedis.exists.mockResolvedValueOnce(true);
|
||||||
|
mockRedis.ttl.mockResolvedValue(30);
|
||||||
|
|
||||||
|
await expect(service.generateCode(email, type)).rejects.toThrow(
|
||||||
|
new HttpException('请等待 30 秒后再试', HttpStatus.TOO_MANY_REQUESTS)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该在每小时发送次数达到上限时抛出错误', async () => {
|
||||||
|
const email = 'test@example.com';
|
||||||
|
const type = VerificationCodeType.EMAIL_VERIFICATION;
|
||||||
|
|
||||||
|
// Mock 冷却时间不存在,但每小时次数达到上限
|
||||||
|
mockRedis.exists.mockResolvedValueOnce(false);
|
||||||
|
mockRedis.get.mockResolvedValueOnce('5'); // 已达到上限
|
||||||
|
|
||||||
|
await expect(service.generateCode(email, type)).rejects.toThrow(
|
||||||
|
new HttpException('每小时发送次数已达上限,请稍后再试', HttpStatus.TOO_MANY_REQUESTS)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该记录发送尝试', async () => {
|
||||||
|
const email = 'test@example.com';
|
||||||
|
const type = VerificationCodeType.EMAIL_VERIFICATION;
|
||||||
|
|
||||||
|
await service.generateCode(email, type);
|
||||||
|
|
||||||
|
// 验证冷却时间设置
|
||||||
|
expect(mockRedis.set).toHaveBeenCalledWith(
|
||||||
|
`verification_cooldown:${type}:${email}`,
|
||||||
|
'1',
|
||||||
|
60
|
||||||
|
);
|
||||||
|
|
||||||
|
// 验证每小时计数
|
||||||
|
expect(mockRedis.set).toHaveBeenCalledWith(
|
||||||
|
expect.stringMatching(/verification_hourly:/),
|
||||||
|
'1',
|
||||||
|
3600
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('verifyCode', () => {
|
||||||
|
const email = 'test@example.com';
|
||||||
|
const type = VerificationCodeType.EMAIL_VERIFICATION;
|
||||||
|
const code = '123456';
|
||||||
|
|
||||||
|
it('应该成功验证正确的验证码', async () => {
|
||||||
|
const codeInfo = {
|
||||||
|
code: '123456',
|
||||||
|
createdAt: Date.now(),
|
||||||
|
attempts: 0,
|
||||||
|
maxAttempts: 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
mockRedis.get.mockResolvedValue(JSON.stringify(codeInfo));
|
||||||
|
mockRedis.del.mockResolvedValue(true);
|
||||||
|
|
||||||
|
const result = await service.verifyCode(email, type, code);
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(mockRedis.del).toHaveBeenCalledWith(`verification_code:${type}:${email}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该在验证码不存在时抛出错误', async () => {
|
||||||
|
mockRedis.get.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(service.verifyCode(email, type, code)).rejects.toThrow(
|
||||||
|
new BadRequestException('验证码不存在或已过期')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该在尝试次数过多时抛出错误', async () => {
|
||||||
|
const codeInfo = {
|
||||||
|
code: '123456',
|
||||||
|
createdAt: Date.now(),
|
||||||
|
attempts: 3,
|
||||||
|
maxAttempts: 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
mockRedis.get.mockResolvedValue(JSON.stringify(codeInfo));
|
||||||
|
mockRedis.del.mockResolvedValue(true);
|
||||||
|
|
||||||
|
await expect(service.verifyCode(email, type, code)).rejects.toThrow(
|
||||||
|
new BadRequestException('验证码尝试次数过多,请重新获取')
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockRedis.del).toHaveBeenCalledWith(`verification_code:${type}:${email}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该在验证码错误时增加尝试次数并抛出错误', async () => {
|
||||||
|
const codeInfo = {
|
||||||
|
code: '123456',
|
||||||
|
createdAt: Date.now(),
|
||||||
|
attempts: 1,
|
||||||
|
maxAttempts: 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
mockRedis.get.mockResolvedValue(JSON.stringify(codeInfo));
|
||||||
|
|
||||||
|
await expect(service.verifyCode(email, type, '654321')).rejects.toThrow(
|
||||||
|
new BadRequestException('验证码错误,剩余尝试次数: 1')
|
||||||
|
);
|
||||||
|
|
||||||
|
// 验证尝试次数增加
|
||||||
|
const updatedCodeInfo = {
|
||||||
|
...codeInfo,
|
||||||
|
attempts: 2,
|
||||||
|
};
|
||||||
|
expect(mockRedis.set).toHaveBeenCalledWith(
|
||||||
|
`verification_code:${type}:${email}`,
|
||||||
|
JSON.stringify(updatedCodeInfo),
|
||||||
|
300
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该在最后一次尝试失败时显示正确的剩余次数', async () => {
|
||||||
|
const codeInfo = {
|
||||||
|
code: '123456',
|
||||||
|
createdAt: Date.now(),
|
||||||
|
attempts: 2,
|
||||||
|
maxAttempts: 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
mockRedis.get.mockResolvedValue(JSON.stringify(codeInfo));
|
||||||
|
|
||||||
|
await expect(service.verifyCode(email, type, '654321')).rejects.toThrow(
|
||||||
|
new BadRequestException('验证码错误,剩余尝试次数: 0')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('codeExists', () => {
|
||||||
|
it('应该在验证码存在时返回true', async () => {
|
||||||
|
const email = 'test@example.com';
|
||||||
|
const type = VerificationCodeType.EMAIL_VERIFICATION;
|
||||||
|
|
||||||
|
mockRedis.exists.mockResolvedValue(true);
|
||||||
|
|
||||||
|
const result = await service.codeExists(email, type);
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(mockRedis.exists).toHaveBeenCalledWith(`verification_code:${type}:${email}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该在验证码不存在时返回false', async () => {
|
||||||
|
const email = 'test@example.com';
|
||||||
|
const type = VerificationCodeType.EMAIL_VERIFICATION;
|
||||||
|
|
||||||
|
mockRedis.exists.mockResolvedValue(false);
|
||||||
|
|
||||||
|
const result = await service.codeExists(email, type);
|
||||||
|
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('deleteCode', () => {
|
||||||
|
it('应该成功删除验证码', async () => {
|
||||||
|
const email = 'test@example.com';
|
||||||
|
const type = VerificationCodeType.EMAIL_VERIFICATION;
|
||||||
|
|
||||||
|
mockRedis.del.mockResolvedValue(true);
|
||||||
|
|
||||||
|
await service.deleteCode(email, type);
|
||||||
|
|
||||||
|
expect(mockRedis.del).toHaveBeenCalledWith(`verification_code:${type}:${email}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getCodeTTL', () => {
|
||||||
|
it('应该返回验证码剩余时间', async () => {
|
||||||
|
const email = 'test@example.com';
|
||||||
|
const type = VerificationCodeType.EMAIL_VERIFICATION;
|
||||||
|
|
||||||
|
mockRedis.ttl.mockResolvedValue(180); // 3分钟
|
||||||
|
|
||||||
|
const result = await service.getCodeTTL(email, type);
|
||||||
|
|
||||||
|
expect(result).toBe(180);
|
||||||
|
expect(mockRedis.ttl).toHaveBeenCalledWith(`verification_code:${type}:${email}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该在验证码不存在时返回-1', async () => {
|
||||||
|
const email = 'test@example.com';
|
||||||
|
const type = VerificationCodeType.EMAIL_VERIFICATION;
|
||||||
|
|
||||||
|
mockRedis.ttl.mockResolvedValue(-2); // Redis返回-2表示键不存在
|
||||||
|
|
||||||
|
const result = await service.getCodeTTL(email, type);
|
||||||
|
|
||||||
|
expect(result).toBe(-2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getCodeStats', () => {
|
||||||
|
const email = 'test@example.com';
|
||||||
|
const type = VerificationCodeType.EMAIL_VERIFICATION;
|
||||||
|
|
||||||
|
it('应该返回存在的验证码统计信息', async () => {
|
||||||
|
const codeInfo = {
|
||||||
|
code: '123456',
|
||||||
|
createdAt: Date.now(),
|
||||||
|
attempts: 1,
|
||||||
|
maxAttempts: 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
mockRedis.exists.mockResolvedValue(true);
|
||||||
|
mockRedis.ttl.mockResolvedValue(240);
|
||||||
|
mockRedis.get.mockResolvedValue(JSON.stringify(codeInfo));
|
||||||
|
|
||||||
|
const result = await service.getCodeStats(email, type);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
exists: true,
|
||||||
|
ttl: 240,
|
||||||
|
attempts: 1,
|
||||||
|
maxAttempts: 3,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该在验证码不存在时返回基本信息', async () => {
|
||||||
|
mockRedis.exists.mockResolvedValue(false);
|
||||||
|
mockRedis.ttl.mockResolvedValue(-1);
|
||||||
|
|
||||||
|
const result = await service.getCodeStats(email, type);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
exists: false,
|
||||||
|
ttl: -1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该处理无效的验证码信息', async () => {
|
||||||
|
mockRedis.exists.mockResolvedValue(true);
|
||||||
|
mockRedis.ttl.mockResolvedValue(240);
|
||||||
|
mockRedis.get.mockResolvedValue('invalid json');
|
||||||
|
|
||||||
|
const result = await service.getCodeStats(email, type);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
exists: true,
|
||||||
|
ttl: 240,
|
||||||
|
attempts: undefined,
|
||||||
|
maxAttempts: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('cleanupExpiredCodes', () => {
|
||||||
|
it('应该成功执行清理任务', async () => {
|
||||||
|
await service.cleanupExpiredCodes();
|
||||||
|
// 由于这个方法主要是日志记录,我们只需要确保它不抛出错误
|
||||||
|
expect(true).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('私有方法测试', () => {
|
||||||
|
it('应该生成正确格式的Redis键', async () => {
|
||||||
|
const email = 'test@example.com';
|
||||||
|
const type = VerificationCodeType.EMAIL_VERIFICATION;
|
||||||
|
|
||||||
|
// Mock 频率限制检查通过
|
||||||
|
mockRedis.exists.mockResolvedValue(false);
|
||||||
|
mockRedis.get.mockResolvedValue(null);
|
||||||
|
mockRedis.set.mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
await service.generateCode(email, type);
|
||||||
|
|
||||||
|
expect(mockRedis.set).toHaveBeenCalledWith(
|
||||||
|
`verification_code:${type}:${email}`,
|
||||||
|
expect.any(String),
|
||||||
|
expect.any(Number)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该生成正确格式的冷却时间键', async () => {
|
||||||
|
const email = 'test@example.com';
|
||||||
|
const type = VerificationCodeType.EMAIL_VERIFICATION;
|
||||||
|
|
||||||
|
// Mock 频率限制检查通过
|
||||||
|
mockRedis.exists.mockResolvedValue(false);
|
||||||
|
mockRedis.get.mockResolvedValue(null);
|
||||||
|
mockRedis.set.mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
await service.generateCode(email, type);
|
||||||
|
|
||||||
|
expect(mockRedis.set).toHaveBeenCalledWith(
|
||||||
|
`verification_cooldown:${type}:${email}`,
|
||||||
|
'1',
|
||||||
|
60
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该生成正确格式的每小时限制键', async () => {
|
||||||
|
const email = 'test@example.com';
|
||||||
|
const type = VerificationCodeType.EMAIL_VERIFICATION;
|
||||||
|
|
||||||
|
// Mock 频率限制检查通过
|
||||||
|
mockRedis.exists.mockResolvedValue(false);
|
||||||
|
mockRedis.get.mockResolvedValue(null);
|
||||||
|
mockRedis.set.mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
await service.generateCode(email, type);
|
||||||
|
|
||||||
|
const hour = new Date().getHours();
|
||||||
|
const date = new Date().toDateString();
|
||||||
|
const expectedKey = `verification_hourly:${type}:${email}:${date}:${hour}`;
|
||||||
|
|
||||||
|
expect(mockRedis.set).toHaveBeenCalledWith(
|
||||||
|
expectedKey,
|
||||||
|
'1',
|
||||||
|
3600
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('错误处理测试', () => {
|
||||||
|
it('应该处理Redis连接错误', async () => {
|
||||||
|
const email = 'test@example.com';
|
||||||
|
const type = VerificationCodeType.EMAIL_VERIFICATION;
|
||||||
|
|
||||||
|
mockRedis.exists.mockRejectedValue(new Error('Redis connection failed'));
|
||||||
|
|
||||||
|
await expect(service.generateCode(email, type)).rejects.toThrow('Redis connection failed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该处理Redis操作错误', async () => {
|
||||||
|
const email = 'test@example.com';
|
||||||
|
const type = VerificationCodeType.EMAIL_VERIFICATION;
|
||||||
|
const code = '123456';
|
||||||
|
|
||||||
|
mockRedis.get.mockRejectedValue(new Error('Redis operation failed'));
|
||||||
|
|
||||||
|
await expect(service.verifyCode(email, type, code)).rejects.toThrow('Redis operation failed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该处理JSON解析错误', async () => {
|
||||||
|
const email = 'test@example.com';
|
||||||
|
const type = VerificationCodeType.EMAIL_VERIFICATION;
|
||||||
|
const code = '123456';
|
||||||
|
|
||||||
|
mockRedis.get.mockResolvedValue('invalid json string');
|
||||||
|
|
||||||
|
await expect(service.verifyCode(email, type, code)).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('验证码类型测试', () => {
|
||||||
|
it('应该支持所有验证码类型', async () => {
|
||||||
|
const email = 'test@example.com';
|
||||||
|
const types = [
|
||||||
|
VerificationCodeType.EMAIL_VERIFICATION,
|
||||||
|
VerificationCodeType.PASSWORD_RESET,
|
||||||
|
VerificationCodeType.SMS_VERIFICATION,
|
||||||
|
];
|
||||||
|
|
||||||
|
// Mock 频率限制检查通过
|
||||||
|
mockRedis.exists.mockResolvedValue(false);
|
||||||
|
mockRedis.get.mockResolvedValue(null);
|
||||||
|
mockRedis.set.mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
for (const type of types) {
|
||||||
|
const code = await service.generateCode(email, type);
|
||||||
|
expect(code).toMatch(/^\d{6}$/);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(mockRedis.set).toHaveBeenCalledTimes(types.length * 3); // 每个类型调用3次set
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('边界条件测试', () => {
|
||||||
|
it('应该处理空字符串标识符', async () => {
|
||||||
|
const type = VerificationCodeType.EMAIL_VERIFICATION;
|
||||||
|
|
||||||
|
// Mock 频率限制检查通过
|
||||||
|
mockRedis.exists.mockResolvedValue(false);
|
||||||
|
mockRedis.get.mockResolvedValue(null);
|
||||||
|
mockRedis.set.mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const code = await service.generateCode('', type);
|
||||||
|
expect(code).toMatch(/^\d{6}$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该处理特殊字符标识符', async () => {
|
||||||
|
const specialEmail = 'test+special@example.com';
|
||||||
|
const type = VerificationCodeType.EMAIL_VERIFICATION;
|
||||||
|
|
||||||
|
// Mock 频率限制检查通过
|
||||||
|
mockRedis.exists.mockResolvedValue(false);
|
||||||
|
mockRedis.get.mockResolvedValue(null);
|
||||||
|
mockRedis.set.mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const code = await service.generateCode(specialEmail, type);
|
||||||
|
expect(code).toMatch(/^\d{6}$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该处理长标识符', async () => {
|
||||||
|
const longEmail = 'a'.repeat(100) + '@example.com';
|
||||||
|
const type = VerificationCodeType.EMAIL_VERIFICATION;
|
||||||
|
|
||||||
|
// Mock 频率限制检查通过
|
||||||
|
mockRedis.exists.mockResolvedValue(false);
|
||||||
|
mockRedis.get.mockResolvedValue(null);
|
||||||
|
mockRedis.set.mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const code = await service.generateCode(longEmail, type);
|
||||||
|
expect(code).toMatch(/^\d{6}$/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
317
src/core/utils/verification/verification.service.ts
Normal file
317
src/core/utils/verification/verification.service.ts
Normal file
@@ -0,0 +1,317 @@
|
|||||||
|
/**
|
||||||
|
* 验证码管理服务
|
||||||
|
*
|
||||||
|
* 功能描述:
|
||||||
|
* - 生成和管理各种类型的验证码
|
||||||
|
* - 使用Redis缓存验证码,支持过期时间
|
||||||
|
* - 提供验证码验证和防刷机制
|
||||||
|
*
|
||||||
|
* 支持的验证码类型:
|
||||||
|
* - 邮箱验证码
|
||||||
|
* - 密码重置验证码
|
||||||
|
* - 手机短信验证码
|
||||||
|
*
|
||||||
|
* @author moyin
|
||||||
|
* @version 1.0.0
|
||||||
|
* @since 2025-12-17
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Injectable, Logger, BadRequestException, HttpException, HttpStatus, Inject } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { IRedisService } from '../../redis/redis.interface';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证码类型枚举
|
||||||
|
*/
|
||||||
|
export enum VerificationCodeType {
|
||||||
|
EMAIL_VERIFICATION = 'email_verification',
|
||||||
|
PASSWORD_RESET = 'password_reset',
|
||||||
|
SMS_VERIFICATION = 'sms_verification',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证码信息接口
|
||||||
|
*/
|
||||||
|
export interface VerificationCodeInfo {
|
||||||
|
/** 验证码 */
|
||||||
|
code: string;
|
||||||
|
/** 创建时间 */
|
||||||
|
createdAt: number;
|
||||||
|
/** 尝试次数 */
|
||||||
|
attempts: number;
|
||||||
|
/** 最大尝试次数 */
|
||||||
|
maxAttempts: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class VerificationService {
|
||||||
|
private readonly logger = new Logger(VerificationService.name);
|
||||||
|
|
||||||
|
// 验证码配置
|
||||||
|
private readonly CODE_LENGTH = 6;
|
||||||
|
private readonly CODE_EXPIRE_TIME = 5 * 60; // 5分钟
|
||||||
|
private readonly MAX_ATTEMPTS = 3; // 最大验证尝试次数
|
||||||
|
private readonly RATE_LIMIT_TIME = 60; // 发送频率限制(秒)
|
||||||
|
private readonly MAX_SENDS_PER_HOUR = 5; // 每小时最大发送次数
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly configService: ConfigService,
|
||||||
|
@Inject('REDIS_SERVICE') private readonly redis: IRedisService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成验证码
|
||||||
|
*
|
||||||
|
* @param identifier 标识符(邮箱或手机号)
|
||||||
|
* @param type 验证码类型
|
||||||
|
* @returns 验证码
|
||||||
|
*/
|
||||||
|
async generateCode(identifier: string, type: VerificationCodeType): Promise<string> {
|
||||||
|
// 检查发送频率限制
|
||||||
|
await this.checkRateLimit(identifier, type);
|
||||||
|
|
||||||
|
// 生成6位数字验证码
|
||||||
|
const code = this.generateRandomCode();
|
||||||
|
|
||||||
|
// 构建Redis键
|
||||||
|
const key = this.buildRedisKey(identifier, type);
|
||||||
|
|
||||||
|
// 验证码信息
|
||||||
|
const codeInfo: VerificationCodeInfo = {
|
||||||
|
code,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
attempts: 0,
|
||||||
|
maxAttempts: this.MAX_ATTEMPTS,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 存储到Redis,设置过期时间
|
||||||
|
await this.redis.set(key, JSON.stringify(codeInfo), this.CODE_EXPIRE_TIME);
|
||||||
|
|
||||||
|
// 记录发送次数(用于频率限制)
|
||||||
|
await this.recordSendAttempt(identifier, type);
|
||||||
|
|
||||||
|
this.logger.log(`验证码已生成: ${identifier} (${type})`);
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证验证码
|
||||||
|
*
|
||||||
|
* @param identifier 标识符
|
||||||
|
* @param type 验证码类型
|
||||||
|
* @param inputCode 用户输入的验证码
|
||||||
|
* @returns 验证结果
|
||||||
|
*/
|
||||||
|
async verifyCode(identifier: string, type: VerificationCodeType, inputCode: string): Promise<boolean> {
|
||||||
|
const key = this.buildRedisKey(identifier, type);
|
||||||
|
|
||||||
|
// 从Redis获取验证码信息
|
||||||
|
const codeInfoStr = await this.redis.get(key);
|
||||||
|
|
||||||
|
if (!codeInfoStr) {
|
||||||
|
throw new BadRequestException('验证码不存在或已过期');
|
||||||
|
}
|
||||||
|
|
||||||
|
const codeInfo: VerificationCodeInfo = JSON.parse(codeInfoStr);
|
||||||
|
|
||||||
|
// 检查尝试次数
|
||||||
|
if (codeInfo.attempts >= codeInfo.maxAttempts) {
|
||||||
|
await this.redis.del(key);
|
||||||
|
throw new BadRequestException('验证码尝试次数过多,请重新获取');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 增加尝试次数
|
||||||
|
codeInfo.attempts++;
|
||||||
|
await this.redis.set(key, JSON.stringify(codeInfo), this.CODE_EXPIRE_TIME);
|
||||||
|
|
||||||
|
// 验证验证码
|
||||||
|
if (codeInfo.code !== inputCode) {
|
||||||
|
this.logger.warn(`验证码验证失败: ${identifier} (${type}) - 剩余尝试次数: ${codeInfo.maxAttempts - codeInfo.attempts}`);
|
||||||
|
throw new BadRequestException(`验证码错误,剩余尝试次数: ${codeInfo.maxAttempts - codeInfo.attempts}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证成功,删除验证码
|
||||||
|
await this.redis.del(key);
|
||||||
|
this.logger.log(`验证码验证成功: ${identifier} (${type})`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查验证码是否存在
|
||||||
|
*
|
||||||
|
* @param identifier 标识符
|
||||||
|
* @param type 验证码类型
|
||||||
|
* @returns 是否存在
|
||||||
|
*/
|
||||||
|
async codeExists(identifier: string, type: VerificationCodeType): Promise<boolean> {
|
||||||
|
const key = this.buildRedisKey(identifier, type);
|
||||||
|
return await this.redis.exists(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除验证码
|
||||||
|
*
|
||||||
|
* @param identifier 标识符
|
||||||
|
* @param type 验证码类型
|
||||||
|
*/
|
||||||
|
async deleteCode(identifier: string, type: VerificationCodeType): Promise<void> {
|
||||||
|
const key = this.buildRedisKey(identifier, type);
|
||||||
|
await this.redis.del(key);
|
||||||
|
this.logger.log(`验证码已删除: ${identifier} (${type})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取验证码剩余时间
|
||||||
|
*
|
||||||
|
* @param identifier 标识符
|
||||||
|
* @param type 验证码类型
|
||||||
|
* @returns 剩余时间(秒),-1表示不存在
|
||||||
|
*/
|
||||||
|
async getCodeTTL(identifier: string, type: VerificationCodeType): Promise<number> {
|
||||||
|
const key = this.buildRedisKey(identifier, type);
|
||||||
|
return await this.redis.ttl(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查发送频率限制
|
||||||
|
*
|
||||||
|
* @param identifier 标识符
|
||||||
|
* @param type 验证码类型
|
||||||
|
*/
|
||||||
|
private async checkRateLimit(identifier: string, type: VerificationCodeType): Promise<void> {
|
||||||
|
// 检查是否在冷却时间内
|
||||||
|
const cooldownKey = this.buildCooldownKey(identifier, type);
|
||||||
|
const cooldownExists = await this.redis.exists(cooldownKey);
|
||||||
|
|
||||||
|
if (cooldownExists) {
|
||||||
|
const ttl = await this.redis.ttl(cooldownKey);
|
||||||
|
throw new HttpException(`请等待 ${ttl} 秒后再试`, HttpStatus.TOO_MANY_REQUESTS);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查每小时发送次数限制
|
||||||
|
const hourlyKey = this.buildHourlyKey(identifier, type);
|
||||||
|
const hourlyCount = await this.redis.get(hourlyKey);
|
||||||
|
|
||||||
|
if (hourlyCount && parseInt(hourlyCount) >= this.MAX_SENDS_PER_HOUR) {
|
||||||
|
throw new HttpException('每小时发送次数已达上限,请稍后再试', HttpStatus.TOO_MANY_REQUESTS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 记录发送尝试
|
||||||
|
*
|
||||||
|
* @param identifier 标识符
|
||||||
|
* @param type 验证码类型
|
||||||
|
*/
|
||||||
|
private async recordSendAttempt(identifier: string, type: VerificationCodeType): Promise<void> {
|
||||||
|
// 设置冷却时间
|
||||||
|
const cooldownKey = this.buildCooldownKey(identifier, type);
|
||||||
|
await this.redis.set(cooldownKey, '1', this.RATE_LIMIT_TIME);
|
||||||
|
|
||||||
|
// 记录每小时发送次数
|
||||||
|
const hourlyKey = this.buildHourlyKey(identifier, type);
|
||||||
|
const current = await this.redis.get(hourlyKey);
|
||||||
|
|
||||||
|
if (current) {
|
||||||
|
const newCount = (parseInt(current) + 1).toString();
|
||||||
|
await this.redis.set(hourlyKey, newCount, 3600);
|
||||||
|
} else {
|
||||||
|
await this.redis.set(hourlyKey, '1', 3600); // 1小时过期
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成随机验证码
|
||||||
|
*
|
||||||
|
* @returns 验证码
|
||||||
|
*/
|
||||||
|
private generateRandomCode(): string {
|
||||||
|
return Math.floor(Math.random() * Math.pow(10, this.CODE_LENGTH))
|
||||||
|
.toString()
|
||||||
|
.padStart(this.CODE_LENGTH, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建Redis键
|
||||||
|
*
|
||||||
|
* @param identifier 标识符
|
||||||
|
* @param type 验证码类型
|
||||||
|
* @returns Redis键
|
||||||
|
*/
|
||||||
|
private buildRedisKey(identifier: string, type: VerificationCodeType): string {
|
||||||
|
return `verification_code:${type}:${identifier}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建冷却时间Redis键
|
||||||
|
*
|
||||||
|
* @param identifier 标识符
|
||||||
|
* @param type 验证码类型
|
||||||
|
* @returns Redis键
|
||||||
|
*/
|
||||||
|
private buildCooldownKey(identifier: string, type: VerificationCodeType): string {
|
||||||
|
return `verification_cooldown:${type}:${identifier}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建每小时限制Redis键
|
||||||
|
*
|
||||||
|
* @param identifier 标识符
|
||||||
|
* @param type 验证码类型
|
||||||
|
* @returns Redis键
|
||||||
|
*/
|
||||||
|
private buildHourlyKey(identifier: string, type: VerificationCodeType): string {
|
||||||
|
const hour = new Date().getHours();
|
||||||
|
const date = new Date().toDateString();
|
||||||
|
return `verification_hourly:${type}:${identifier}:${date}:${hour}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清理过期的验证码(可选的定时任务)
|
||||||
|
*/
|
||||||
|
async cleanupExpiredCodes(): Promise<void> {
|
||||||
|
// Redis会自动清理过期的键,这里可以添加额外的清理逻辑
|
||||||
|
this.logger.log('验证码清理任务执行完成');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取验证码统计信息
|
||||||
|
*
|
||||||
|
* @param identifier 标识符
|
||||||
|
* @param type 验证码类型
|
||||||
|
* @returns 统计信息
|
||||||
|
*/
|
||||||
|
async getCodeStats(identifier: string, type: VerificationCodeType): Promise<{
|
||||||
|
exists: boolean;
|
||||||
|
ttl: number;
|
||||||
|
attempts?: number;
|
||||||
|
maxAttempts?: number;
|
||||||
|
}> {
|
||||||
|
const key = this.buildRedisKey(identifier, type);
|
||||||
|
const exists = await this.redis.exists(key);
|
||||||
|
const ttl = await this.redis.ttl(key);
|
||||||
|
|
||||||
|
if (!exists) {
|
||||||
|
return { exists: false, ttl: -1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const codeInfoStr = await this.redis.get(key);
|
||||||
|
let codeInfo: VerificationCodeInfo;
|
||||||
|
|
||||||
|
try {
|
||||||
|
codeInfo = JSON.parse(codeInfoStr || '{}');
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('验证码信息解析失败', error);
|
||||||
|
codeInfo = {} as VerificationCodeInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
exists: true,
|
||||||
|
ttl,
|
||||||
|
attempts: codeInfo.attempts,
|
||||||
|
maxAttempts: codeInfo.maxAttempts,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
"baseUrl": "./",
|
"baseUrl": "./",
|
||||||
"incremental": true,
|
"incremental": true,
|
||||||
"strictNullChecks": false,
|
"strictNullChecks": false,
|
||||||
"types": ["jest", "node"]
|
"typeRoots": ["./node_modules/@types"]
|
||||||
},
|
},
|
||||||
"include": ["src/**/*"],
|
"include": ["src/**/*"],
|
||||||
"exclude": ["node_modules", "dist"]
|
"exclude": ["node_modules", "dist"]
|
||||||
|
|||||||
Reference in New Issue
Block a user