forked from datawhale/whale-town-end
- 实现验证码生成、验证和管理功能 - 支持多种验证码类型(邮箱验证、密码重置、短信验证) - 集成Redis缓存存储验证码 - 实现防刷机制:发送频率限制和每小时限制 - 支持验证码过期管理和尝试次数限制 - 包含完整的单元测试
317 lines
8.9 KiB
TypeScript
317 lines
8.9 KiB
TypeScript
/**
|
||
* 验证码管理服务
|
||
*
|
||
* 功能描述:
|
||
* - 生成和管理各种类型的验证码
|
||
* - 使用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,
|
||
};
|
||
}
|
||
} |