Files
whale-town-end/src/core/utils/verification/verification.service.ts
moyin eb7a022f5b feat:添加验证码服务
- 实现验证码生成、验证和管理功能
- 支持多种验证码类型(邮箱验证、密码重置、短信验证)
- 集成Redis缓存存储验证码
- 实现防刷机制:发送频率限制和每小时限制
- 支持验证码过期管理和尝试次数限制
- 包含完整的单元测试
2025-12-17 20:21:30 +08:00

317 lines
8.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 验证码管理服务
*
* 功能描述:
* - 生成和管理各种类型的验证码
* - 使用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,
};
}
}