Files
whale-town-end/src/core/utils/verification/verification.service.ts
moyin ac989fe985 style(verification): 添加类注释,完善代码规范
范围:src/core/utils/verification/
- 为VerificationService添加完整的类注释,包含职责、主要方法和使用场景说明
- 为VerificationModule添加完整的类注释,包含模块职责和功能说明
- 更新文件修改记录和版本号(1.0.1  1.0.2)
- 更新@lastModified时间戳为2026-01-12
2026-01-12 19:21:30 +08:00

431 lines
13 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缓存验证码支持过期时间
* - 提供验证码验证和防刷机制
*
* 职责分离:
* - 验证码生成和存储管理
* - 验证码验证和尝试次数控制
* - 频率限制和防刷机制
*
* 支持的验证码类型:
* - 邮箱验证码
* - 密码重置验证码
* - 手机短信验证码
*
* 最近修改:
* - 2026-01-12: 代码规范优化 - 添加VerificationService类注释完善职责和方法说明 (修改者: moyin)
* - 2026-01-07: 代码规范优化 - 清理未使用的导入(ConfigService)和多余空行
* - 2026-01-07: 代码规范优化 - 完善文件头注释和修改记录规范
*
* @author moyin
* @version 1.0.2
* @since 2025-12-17
* @lastModified 2026-01-12
*/
import { Injectable, Logger, BadRequestException, HttpException, HttpStatus, Inject } from '@nestjs/common';
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;
}
/**
* 验证码管理服务
*
* 职责:
* - 生成和管理各种类型的验证码(邮箱、密码重置、短信)
* - 提供验证码验证和尝试次数控制机制
* - 实现防刷机制和频率限制功能
* - 管理Redis缓存中的验证码存储和过期
*
* 主要方法:
* - generateCode() - 生成指定类型的验证码
* - verifyCode() - 验证用户输入的验证码
* - codeExists() - 检查验证码是否存在
* - deleteCode() - 删除指定验证码
* - getCodeTTL() - 获取验证码剩余时间
* - clearCooldown() - 清除发送冷却时间
*
* 使用场景:
* - 用户注册时的邮箱验证
* - 密码重置流程的安全验证
* - 短信验证码的生成和校验
* - 防止验证码恶意刷取和暴力破解
*/
@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(
@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;
}
}