diff --git a/src/core/utils/verification/verification.module.ts b/src/core/utils/verification/verification.module.ts new file mode 100644 index 0000000..9c0a16e --- /dev/null +++ b/src/core/utils/verification/verification.module.ts @@ -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 {} \ No newline at end of file diff --git a/src/core/utils/verification/verification.service.spec.ts b/src/core/utils/verification/verification.service.spec.ts new file mode 100644 index 0000000..298f21a --- /dev/null +++ b/src/core/utils/verification/verification.service.spec.ts @@ -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; + let mockRedis: jest.Mocked; + + 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); + 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 = { + '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}$/); + }); + }); +}); \ No newline at end of file diff --git a/src/core/utils/verification/verification.service.ts b/src/core/utils/verification/verification.service.ts new file mode 100644 index 0000000..b9f0f12 --- /dev/null +++ b/src/core/utils/verification/verification.service.ts @@ -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 { + // 检查发送频率限制 + 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 { + 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 { + const key = this.buildRedisKey(identifier, type); + return await this.redis.exists(key); + } + + /** + * 删除验证码 + * + * @param identifier 标识符 + * @param type 验证码类型 + */ + async deleteCode(identifier: string, type: VerificationCodeType): Promise { + 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 { + const key = this.buildRedisKey(identifier, type); + return await this.redis.ttl(key); + } + + /** + * 检查发送频率限制 + * + * @param identifier 标识符 + * @param type 验证码类型 + */ + private async checkRateLimit(identifier: string, type: VerificationCodeType): Promise { + // 检查是否在冷却时间内 + 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 { + // 设置冷却时间 + 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 { + // 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, + }; + } +} \ No newline at end of file