Files
whale-town-end/src/core/utils/verification/verification.service.ts
moyin a78df48101 feat:添加验证码冷却时间清除功能
新增 clearCooldown 方法,用于在用户成功完成操作后
清除验证码冷却时间,提升用户体验:
- 注册成功后清除邮箱验证码冷却时间
- 密码重置成功后清除重置验证码冷却时间
- 验证码登录成功后清除登录验证码冷却时间
2025-12-25 20:48:15 +08:00

401 lines
12 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) {
this.logger.warn(`验证码不存在或已过期: ${identifier} (${type})`);
throw new BadRequestException('验证码不存在或已过期');
}
let codeInfo: VerificationCodeInfo;
try {
codeInfo = JSON.parse(codeInfoStr);
} catch (error) {
this.logger.error(`验证码数据解析失败: ${identifier} (${type})`, error);
await this.redis.del(key);
throw new BadRequestException('验证码数据异常,请重新获取');
}
// 检查尝试次数
if (codeInfo.attempts >= codeInfo.maxAttempts) {
this.logger.warn(`验证码尝试次数已达上限: ${identifier} (${type}), 尝试次数: ${codeInfo.attempts}`);
await this.redis.del(key);
throw new BadRequestException('验证码尝试次数过多,请重新获取');
}
// 获取当前TTL
const currentTTL = await this.redis.ttl(key);
this.logger.debug(`验证码当前TTL: ${identifier} (${type}), TTL: ${currentTTL}`);
// 验证验证码
if (codeInfo.code !== inputCode) {
// 增加尝试次数
codeInfo.attempts++;
// 保持原有的TTL不重置过期时间
if (currentTTL > 0) {
// 使用剩余的TTL时间
await this.redis.set(key, JSON.stringify(codeInfo), currentTTL);
this.logger.warn(`验证码验证失败: ${identifier} (${type}) - 剩余尝试次数: ${codeInfo.maxAttempts - codeInfo.attempts}, 剩余时间: ${currentTTL}`);
} else if (currentTTL === -1) {
// 永不过期的情况,保持永不过期
await this.redis.set(key, JSON.stringify(codeInfo));
this.logger.warn(`验证码验证失败: ${identifier} (${type}) - 剩余尝试次数: ${codeInfo.maxAttempts - codeInfo.attempts}, 永不过期`);
} else {
// TTL为-2表示键不存在这种情况理论上不应该发生
this.logger.error(`验证码TTL异常: ${identifier} (${type}), TTL: ${currentTTL}`);
throw new BadRequestException('验证码状态异常,请重新获取');
}
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}`;
}
/**
* 清除验证码冷却时间
*
* @param identifier 标识符
* @param type 验证码类型
*/
async clearCooldown(identifier: string, type: VerificationCodeType): Promise<void> {
const cooldownKey = this.buildCooldownKey(identifier, type);
await this.redis.del(cooldownKey);
this.logger.log(`验证码冷却时间已清除: ${identifier} (${type})`);
}
/**
* 清理过期的验证码(可选的定时任务)
*/
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;
code?: string;
createdAt?: number;
}> {
const key = this.buildRedisKey(identifier, type);
const exists = await this.redis.exists(key);
const ttl = await this.redis.ttl(key);
if (!exists) {
this.logger.debug(`验证码不存在: ${identifier} (${type})`);
return { exists: false, ttl: -2 };
}
const codeInfoStr = await this.redis.get(key);
let codeInfo: VerificationCodeInfo;
try {
codeInfo = JSON.parse(codeInfoStr || '{}');
} catch (error) {
this.logger.error('验证码信息解析失败', error);
codeInfo = {} as VerificationCodeInfo;
}
this.logger.debug(`验证码统计: ${identifier} (${type}), TTL: ${ttl}, 尝试次数: ${codeInfo.attempts}/${codeInfo.maxAttempts}`);
return {
exists: true,
ttl,
attempts: codeInfo.attempts,
maxAttempts: codeInfo.maxAttempts,
code: codeInfo.code, // 仅用于调试,生产环境应该移除
createdAt: codeInfo.createdAt,
};
}
/**
* 调试方法:获取验证码详细信息
* 仅用于开发和调试,生产环境应该移除或限制访问
*
* @param identifier 标识符
* @param type 验证码类型
* @returns 详细信息
*/
async debugCodeInfo(identifier: string, type: VerificationCodeType): Promise<any> {
const key = this.buildRedisKey(identifier, type);
const [exists, ttl, rawData] = await Promise.all([
this.redis.exists(key),
this.redis.ttl(key),
this.redis.get(key)
]);
const result = {
key,
exists,
ttl,
rawData,
parsedData: null as any,
currentTime: Date.now(),
timeFormatted: new Date().toISOString()
};
if (rawData) {
try {
result.parsedData = JSON.parse(rawData);
} catch (error) {
result.parsedData = { error: 'JSON解析失败', raw: rawData };
}
}
this.logger.debug(`调试验证码信息: ${JSON.stringify(result, null, 2)}`);
return result;
}
}