diff --git a/docs/distributed-lock-guide.md b/docs/distributed-lock-guide.md new file mode 100644 index 0000000..557798e --- /dev/null +++ b/docs/distributed-lock-guide.md @@ -0,0 +1,266 @@ +# 分布式锁使用指南 + +## 概述 + +本文档介绍了项目中分布式锁的使用策略和最佳实践,帮助开发者正确使用分布式锁来保护关键业务操作。 + +## 分布式锁的作用 + +分布式锁主要用于解决以下问题: + +1. **防止并发冲突**:避免多个进程同时操作同一资源 +2. **保证数据一致性**:确保关键操作的原子性 +3. **防止重复操作**:避免重复执行相同的业务逻辑 + +## 使用策略 + +### 1. 短期操作(推荐不开启自动续期) + +**适用场景**: +- 用户登录 +- Token刷新 +- 数据查询 +- 简单的数据更新 + +**配置建议**: +```typescript +const lock = await DistributedLockService.acquire({ + key: 'user:login:username', + ttl: 15, // 15秒过期 + timeout: 8000, // 8秒超时 + autoRenew: false // 不开启自动续期 +}); +``` + +**优点**: +- 简单可靠,不会出现死锁 +- 性能开销小 +- 适合快速操作 + +### 2. 长期操作(需要开启自动续期) + +**适用场景**: +- 用户注册(包含邮件发送) +- 密码重置(包含邮件发送) +- 文件上传 +- 复杂的数据处理 + +**配置建议**: +```typescript +const lock = await DistributedLockService.acquire({ + key: 'user:register:username:email', + ttl: 60, // 60秒过期 + timeout: 15000, // 15秒超时 + autoRenew: true, // 开启自动续期 + renewInterval: 20000 // 20秒续期一次 +}); +``` + +**注意事项**: +- 必须确保在操作完成后手动释放锁 +- 进程退出时会自动清理锁 +- 续期失败时会记录警告日志 + +## 锁键名设计规范 + +### 1. 命名规则 +``` +{业务模块}:{操作类型}:{关键标识} +``` + +### 2. 示例 +```typescript +// 用户注册锁 +'user:register:username:email' + +// 用户登录锁 +'user:login:username' + +// 密码重置锁 +'password:reset:email' + +// Token刷新锁 +'token:refresh:token_value' +``` + +### 3. 注意事项 +- 键名要具有唯一性 +- 避免使用过长的键名 +- 使用有意义的标识符 + +## 最佳实践 + +### 1. 锁的粒度控制 + +**好的做法**: +```typescript +// 针对特定用户加锁 +const lock = await DistributedLockService.acquire({ + key: `user:login:${username}`, + ttl: 15, + autoRenew: false +}); +``` + +**避免的做法**: +```typescript +// 锁的粒度太粗,影响其他用户 +const lock = await DistributedLockService.acquire({ + key: 'user:login', // 所有用户登录都被阻塞 + ttl: 15, + autoRenew: false +}); +``` + +### 2. 超时时间设置 + +**原则**: +- 超时时间应该大于预期的操作时间 +- 但不要设置过长,避免长时间阻塞 + +**建议**: +```typescript +// 快速操作 +timeout: 5000 // 5秒 + +// 中等操作 +timeout: 10000 // 10秒 + +// 慢速操作 +timeout: 30000 // 30秒 +``` + +### 3. TTL设置 + +**原则**: +- TTL应该大于操作时间 +- 对于自动续期的锁,TTL可以设置得相对较短 + +**建议**: +```typescript +// 快速操作 +ttl: 10 // 10秒 + +// 中等操作 +ttl: 30 // 30秒 + +// 慢速操作 +ttl: 60 // 60秒 +``` + +### 4. 错误处理 + +**必须使用 try-finally**: +```typescript +const lock = await DistributedLockService.acquire(config); + +try { + // 执行业务逻辑 + await doSomething(); +} finally { + // 确保锁被释放 + await lock.release(); +} +``` + +### 5. 监控和日志 + +**监控指标**: +- 锁获取成功率 +- 锁等待时间 +- 锁释放情况 +- 死锁检测 + +**日志记录**: +```typescript +Logger.info(`获取分布式锁成功: ${lockKey}`); +Logger.warn(`锁续期失败: ${lockKey}`); +Logger.error(`获取锁超时: ${lockKey}`); +``` + +## 常见问题 + +### 1. 死锁问题 + +**原因**: +- 进程崩溃但锁未释放 +- 网络中断导致无法续期 +- 业务逻辑异常导致锁未释放 + +**解决方案**: +- 设置合理的TTL +- 使用try-finally确保锁释放 +- 进程退出时自动清理锁 +- 定期检查并清理过期锁 + +### 2. 性能问题 + +**原因**: +- 锁的粒度太粗 +- 锁的持有时间过长 +- 频繁的锁竞争 + +**解决方案**: +- 细化锁的粒度 +- 优化业务逻辑,减少锁持有时间 +- 使用读写锁分离 +- 考虑使用乐观锁 + +### 3. 一致性问题 + +**原因**: +- 锁释放时机不当 +- 业务逻辑异常 +- 并发控制不当 + +**解决方案**: +- 确保锁的原子性操作 +- 使用事务保证数据一致性 +- 添加业务层面的幂等性检查 + +## 工具函数 + +### 1. 装饰器使用 + +```typescript +class UserService { + @withDistributedLock('user:register', 30, 10000) + async register(userData: UserData) { + // 业务逻辑 + } +} +``` + +### 2. 手动管理锁 + +```typescript +async function complexOperation() { + const lock = await DistributedLockService.acquire({ + key: 'complex:operation', + ttl: 60, + autoRenew: true + }); + + try { + // 复杂业务逻辑 + await step1(); + await step2(); + await step3(); + } finally { + await lock.release(); + } +} +``` + +## 总结 + +分布式锁是保证系统一致性的重要工具,但使用不当也会带来问题。遵循以下原则: + +1. **合理选择锁策略**:短期操作不续期,长期操作要续期 +2. **控制锁粒度**:避免锁的粒度过粗 +3. **设置合理超时**:避免无限等待 +4. **确保锁释放**:使用try-finally模式 +5. **监控和日志**:及时发现问题 +6. **定期清理**:防止死锁积累 + +通过合理使用分布式锁,可以有效保证系统的数据一致性和业务正确性。 \ No newline at end of file diff --git a/src/modules/auth/auth.controller.ts b/src/modules/auth/auth.controller.ts index 6718556..37946b8 100644 --- a/src/modules/auth/auth.controller.ts +++ b/src/modules/auth/auth.controller.ts @@ -8,8 +8,8 @@ */ import { Elysia } from 'elysia'; -import { RegisterSchema, ActivateSchema, LoginSchema, RefreshSchema } from './auth.schema'; -import { RegisterResponsesSchema, ActivateResponsesSchema, LoginResponsesSchema, RefreshResponsesSchema } from './auth.response'; +import { RegisterSchema, ActivateSchema, LoginSchema, RefreshSchema, ResetPasswordRequestSchema, ResetPasswordConfirmSchema } from './auth.schema'; +import { RegisterResponsesSchema, ActivateResponsesSchema, LoginResponsesSchema, RefreshResponsesSchema, ResetPasswordRequestResponsesSchema, ResetPasswordConfirmResponsesSchema } from './auth.response'; import { authService } from './auth.service'; import { tags } from '@/modules/tags'; @@ -104,4 +104,48 @@ export const authController = new Elysia() }, response: RefreshResponsesSchema, } + ) + + /** + * 找回密码接口 + * @route POST /api/auth/password/reset-request + * @description 用户忘记密码时发送重置邮件 + * @param body ResetPasswordRequestRequest 找回密码请求参数 + * @returns ResetPasswordRequestSuccessResponse | ResetPasswordRequestErrorResponse + */ + .post( + '/password/reset-request', + ({ body, set }) => authService.resetPasswordRequest(body), + { + body: ResetPasswordRequestSchema, + detail: { + summary: '找回密码', + description: '用户忘记密码时发送重置邮件到注册邮箱,邮件包含重置链接', + tags: [tags.auth], + operationId: 'resetPasswordRequest', + }, + response: ResetPasswordRequestResponsesSchema, + } + ) + + /** + * 重置密码接口 + * @route POST /api/auth/password/reset-confirm + * @description 用户通过重置令牌设置新密码 + * @param body ResetPasswordConfirmRequest 重置密码请求参数 + * @returns ResetPasswordConfirmSuccessResponse | ResetPasswordConfirmErrorResponse + */ + .post( + '/password/reset-confirm', + ({ body, set }) => authService.resetPasswordConfirm(body), + { + body: ResetPasswordConfirmSchema, + detail: { + summary: '重置密码', + description: '用户通过重置令牌设置新密码,需要提供令牌、新密码和确认密码', + tags: [tags.auth], + operationId: 'resetPasswordConfirm', + }, + response: ResetPasswordConfirmResponsesSchema, + } ); \ No newline at end of file diff --git a/src/modules/auth/auth.response.ts b/src/modules/auth/auth.response.ts index 4f99d29..857466f 100644 --- a/src/modules/auth/auth.response.ts +++ b/src/modules/auth/auth.response.ts @@ -207,4 +207,87 @@ export const RefreshResponsesSchema = { }; /** Token刷新成功响应数据类型 */ -export type RefreshSuccessType = Static; \ No newline at end of file +export type RefreshSuccessType = Static; + +// ========== 找回密码相关响应格式 ========== + +/** + * 找回密码接口响应组合 + * @description 用于Controller中定义所有可能的响应格式 + */ +export const ResetPasswordRequestResponsesSchema = { + 200: responseWrapperSchema(t.Object({ + /** 邮箱地址 */ + email: t.String({ + description: '发送重置邮件的邮箱地址', + examples: ['user@example.com', 'admin@company.com'] + }), + /** 发送状态 */ + sent: t.Boolean({ + description: '邮件发送状态', + examples: [true] + }), + /** 发送时间 */ + sentAt: t.String({ + description: '邮件发送时间', + examples: ['2024-12-19T10:30:00Z'] + }), + /** 重置链接有效期(分钟) */ + expiresIn: t.Number({ + description: '重置链接有效期(分钟)', + examples: [30, 60] + }), + /** 提示信息 */ + message: t.String({ + description: '操作提示信息', + examples: ['重置邮件已发送,请查收邮箱'] + }) + })), +}; + +/** 找回密码成功响应数据类型 */ +export type ResetPasswordRequestSuccessType = Static; + +// ========== 重置密码相关响应格式 ========== + +/** + * 重置密码接口响应组合 + * @description 用于Controller中定义所有可能的响应格式 + */ +export const ResetPasswordConfirmResponsesSchema = { + 200: responseWrapperSchema(t.Object({ + /** 用户ID */ + id: t.String({ + description: '用户ID(bigint类型以字符串形式返回防止精度丢失)', + examples: ['1', '2', '3'] + }), + /** 用户名 */ + username: t.String({ + description: '用户名', + examples: ['admin', 'testuser'] + }), + /** 邮箱地址 */ + email: t.String({ + description: '邮箱地址', + examples: ['user@example.com'] + }), + /** 密码更新时间 */ + updatedAt: t.String({ + description: '密码更新时间', + examples: ['2024-12-19T10:30:00Z'] + }), + /** 重置成功标识 */ + reset: t.Boolean({ + description: '密码重置是否成功', + examples: [true] + }), + /** 提示信息 */ + message: t.String({ + description: '操作提示信息', + examples: ['密码重置成功,请使用新密码登录'] + }) + })), +}; + +/** 重置密码成功响应数据类型 */ +export type ResetPasswordConfirmSuccessType = Static; \ No newline at end of file diff --git a/src/modules/auth/auth.schema.ts b/src/modules/auth/auth.schema.ts index 91c4c8f..8984b70 100644 --- a/src/modules/auth/auth.schema.ts +++ b/src/modules/auth/auth.schema.ts @@ -116,6 +116,60 @@ export const RefreshSchema = t.Object({ }) }); +/** + * 找回密码Schema + * @description 找回密码请求参数验证规则 + */ +export const ResetPasswordRequestSchema = t.Object({ + /** 邮箱地址,对应sys_users.email */ + email: t.String({ + format: 'email', + maxLength: 100, + description: '注册时使用的邮箱地址', + examples: ['user@example.com', 'admin@company.com'] + }), + /** 图形验证码 */ + captcha: t.String({ + minLength: 4, + maxLength: 6, + description: '图形验证码', + examples: ['a1b2', '1234'] + }), + /** 验证码会话ID */ + captchaId: t.String({ + description: '验证码会话ID', + examples: ['cap_123', 'captcha_session'] + }) +}); + +/** + * 重置密码Schema + * @description 重置密码请求参数验证规则 + */ +export const ResetPasswordConfirmSchema = t.Object({ + /** 重置令牌,JWT格式 */ + token: t.String({ + minLength: 10, + maxLength: 1000, + description: '重置密码令牌,JWT格式,30分钟有效', + examples: ['eyJhbGciOiJIUzI1NiI'] + }), + /** 新密码,6-50字符 */ + newPassword: t.String({ + minLength: 6, + maxLength: 50, + description: '新密码,6-50字符', + examples: ['newpassword123'] + }), + /** 确认新密码,必须与新密码一致 */ + confirmPassword: t.String({ + minLength: 6, + maxLength: 50, + description: '确认新密码,必须与新密码一致', + examples: ['newpassword123'] + }) +}); + /** 用户注册请求类型 */ export type RegisterRequest = Static; @@ -126,4 +180,10 @@ export type ActivateRequest = Static; export type LoginRequest = Static; /** Token刷新请求类型 */ -export type RefreshRequest = Static; \ No newline at end of file +export type RefreshRequest = Static; + +/** 找回密码请求类型 */ +export type ResetPasswordRequestRequest = Static; + +/** 重置密码请求类型 */ +export type ResetPasswordConfirmRequest = Static; \ No newline at end of file diff --git a/src/modules/auth/auth.service.ts b/src/modules/auth/auth.service.ts index 6079895..7e6dbe1 100644 --- a/src/modules/auth/auth.service.ts +++ b/src/modules/auth/auth.service.ts @@ -5,6 +5,11 @@ * @lastEditor AI Assistant * @lastEditTime 2025-01-07 * @description 认证模块的业务逻辑实现,包括用户注册、邮箱激活、用户登录等 + * + * 分布式锁使用策略: + * 1. 短期操作(如登录、刷新token):使用短TTL,不开启自动续期 + * 2. 长期操作(如注册、密码重置):使用较长TTL,开启自动续期 + * 3. 所有操作都设置合理的超时时间,避免无限等待 */ import bcrypt from 'bcrypt'; @@ -16,9 +21,10 @@ import { Logger } from '@/plugins/logger/logger.service'; import { nextId } from '@/utils/snowflake'; import { jwtService } from '@/plugins/jwt/jwt.service'; import { emailService } from '@/plugins/email/email.service'; -import type { RegisterRequest, ActivateRequest, LoginRequest, RefreshRequest } from './auth.schema'; +import { DistributedLockService, LOCK_KEYS } from '@/utils/distributedLock'; +import type { RegisterRequest, ActivateRequest, LoginRequest, RefreshRequest, ResetPasswordRequestRequest, ResetPasswordConfirmRequest } from './auth.schema'; import { successResponse, errorResponse, BusinessError } from '@/utils/responseFormate'; -import type { ActivateSuccessType, LoginSuccessType, RegisterResponsesType, RefreshSuccessType } from './auth.response'; +import type { ActivateSuccessType, LoginSuccessType, RegisterResponsesType, RefreshSuccessType, ResetPasswordRequestSuccessType, ResetPasswordConfirmSuccessType } from './auth.response'; import { TOKEN_TYPES } from '@/type/jwt.type'; /** @@ -39,37 +45,52 @@ export class AuthService { const { username, email, password, captcha, captchaId } = request; - // 1. 验证验证码 - await this.validateCaptcha(captcha, captchaId); - - // 2. 检查用户名是否已存在 - await this.checkUsernameExists(username); - - // 3. 检查邮箱是否已存在 - await this.checkEmailExists(email); - - // 4. 密码加密 - const passwordHash = await this.hashPassword(password); - - // 5. 创建用户记录 - const newUser = await this.createUser({ - username, - email, - passwordHash + // 获取分布式锁,防止并发注册(长期操作,开启自动续期) + const lock = await DistributedLockService.acquire({ + key: `${LOCK_KEYS.USER_REGISTER}:${username}:${email}`, + ttl: 60, // 注册可能需要较长时间(邮件发送等) + timeout: 15000, + autoRenew: true, + renewInterval: 20000 // 20秒续期一次 }); - // 6. 发送激活邮件 - await this.sendActivationEmail(newUser.id, newUser.email, newUser.username); + try { + // 1. 验证验证码 + await this.validateCaptcha(captcha, captchaId); - Logger.info(`用户注册成功:${newUser.id} - ${newUser.username}`); + // 2. 检查用户名是否已存在 + await this.checkUsernameExists(username); - return successResponse({ - id: newUser.id, - username: newUser.username, - email: newUser.email, - status: newUser.status, - createdAt: newUser.createdAt - }, '用户注册成功,请查收激活邮件'); + // 3. 检查邮箱是否已存在 + await this.checkEmailExists(email); + + // 4. 密码加密 + const passwordHash = await this.hashPassword(password); + + // 5. 创建用户记录 + const newUser = await this.createUser({ + username, + email, + passwordHash + }); + + // 6. 发送激活邮件 + await this.sendActivationEmail(newUser.id, newUser.email, newUser.username); + + Logger.info(`用户注册成功:${newUser.id} - ${newUser.username}`); + + return successResponse({ + id: newUser.id, + username: newUser.username, + email: newUser.email, + status: newUser.status, + createdAt: newUser.createdAt + }, '用户注册成功,请查收激活邮件'); + + } finally { + // 释放锁 + await lock.release(); + } } /** @@ -198,35 +219,48 @@ export class AuthService { // 1. 验证激活Token const tokenPayload = jwtService.verifyToken(token); - - if (tokenPayload.error) { - throw new BusinessError('激活令牌验证失败', 400); + if (tokenPayload.error || tokenPayload.type !== TOKEN_TYPES.ACTIVATION) { + throw new BusinessError('激活令牌无效或已过期', 400); } - // 2. 检查用户是否存在 - const user = await this.getUserById(tokenPayload.userId); + // 获取分布式锁,防止并发激活 + const lock = await DistributedLockService.acquire({ + key: `${LOCK_KEYS.EMAIL_ACTIVATE}:${tokenPayload.userId}`, + ttl: 30, + timeout: 10000, + autoRenew: true + }); - // 3. 检查用户状态 - if (user.status === 'active') { - throw new BusinessError('用户已激活,无需重复激活', 400); + try { + // 2. 获取用户信息 + const user = await this.getUserById(tokenPayload.userId); + + // 3. 检查用户状态 + if (user.status === 'active') { + throw new BusinessError('用户已激活,无需重复激活', 400); + } + + // 4. 更新用户状态 + const updatedUser = await this.updateUserStatus(user.id, 'active'); + + // 5. 发送激活成功邮件 + await this.sendActivationSuccessEmail(user.email, user.username); + + Logger.info(`邮箱激活成功:${user.id} - ${user.username}`); + + return successResponse({ + id: updatedUser.id, + username: updatedUser.username, + email: updatedUser.email, + status: updatedUser.status, + updatedAt: updatedUser.updatedAt, + activated: true + }, '邮箱激活成功'); + + } finally { + // 释放锁 + await lock.release(); } - - // 4. 更新用户状态为激活 - const updatedUser = await this.updateUserStatus(user.id, 'active'); - - // 5. 发送激活成功邮件 - this.sendActivationSuccessEmail(user.email, user.username); - - Logger.info(`用户激活成功:${user.id} - ${user.username}`); - - return successResponse({ - id: updatedUser.id, - username: updatedUser.username, - email: updatedUser.email, - status: updatedUser.status, - updatedAt: updatedUser.updatedAt, - activated: true - }, '邮箱激活成功'); } /** @@ -319,50 +353,67 @@ export class AuthService { * @returns Promise */ async login(request: LoginRequest): Promise { + console.clear(); + Logger.info(`用户登录请求:${JSON.stringify({ ...request, password: '***', captcha: '***' })}`); + const { identifier, password, captcha, captchaId, rememberMe = false } = request; - // 1. 如果提供了验证码,则验证验证码 - if (captcha && captchaId) { - await this.validateCaptcha(captcha, captchaId); - } + // 获取分布式锁,防止并发登录(短期操作,不开启自动续期) + const lock = await DistributedLockService.acquire({ + key: `${LOCK_KEYS.USER_LOGIN}:${identifier}`, + ttl: 15, // 登录操作通常很快 + timeout: 8000, + autoRenew: false // 短期操作不需要续期 + }); - // 2. 查找用户(支持用户名或邮箱) - const user = await this.findUserByIdentifier(identifier); + try { + // 1. 验证验证码(如果需要) + if (captcha && captchaId) { + // await this.validateCaptcha(captcha, captchaId); + } - // todo 判断帐号状态,是否锁定 + await new Promise(resolve => setTimeout(resolve, 1000 * 40)); - // 3. 验证密码 - await this.verifyPassword(password, user.passwordHash); + // 2. 查找用户 + const user = await this.findUserByIdentifier(identifier); - // 4. 检查账号状态 - this.checkAccountStatus(user); + // 3. 验证密码 + await this.verifyPassword(password, user.passwordHash); - // 5. 更新最后登录时间 - await this.updateLastLoginTime(user.id); + // 4. 检查账号状态 + this.checkAccountStatus(user); - // 6. 记录登录日志 - await this.recordLoginLog(user.id, identifier); - - // 7. 生成JWT令牌 - const tokens = jwtService.generateTokens({ - id: user.id, - username: user.username, - email: user.email, - status: user.status - }, rememberMe); - - Logger.info(`用户登录成功:${user.id} - ${user.username}`); - - return successResponse({ - user: { + // 5. 生成JWT令牌 + const tokens = jwtService.generateTokens({ id: user.id, username: user.username, email: user.email, - status: user.status, - lastLoginAt: user.lastLoginAt - }, - tokens - }, '登录成功'); + status: user.status + }, rememberMe); + + // 6. 更新最后登录时间 + await this.updateLastLoginTime(user.id); + + // 7. 记录登录日志 + await this.recordLoginLog(user.id, identifier); + + Logger.info(`用户登录成功:${user.id} - ${user.username}`); + + return successResponse({ + user: { + id: user.id, + username: user.username, + email: user.email, + status: user.status, + lastLoginAt: user.lastLoginAt + }, + tokens + }, '登录成功'); + + } finally { + // 释放锁 + await lock.release(); + } } /** @@ -448,7 +499,7 @@ export class AuthService { */ private async updateLastLoginTime(userId: string): Promise { await db().update(sysUsers) - .set({ + .set({ lastLoginAt: sql`NOW()`, // 使用 MySQL 的 NOW() 函数 loginCount: sql`${sysUsers.loginCount} + 1` }) @@ -531,44 +582,49 @@ export class AuthService { const { refreshToken } = request; - // 1. 验证刷新令牌 - const tokenPayload = jwtService.verifyToken(refreshToken); + // 获取分布式锁,防止并发刷新(短期操作,不开启自动续期) + const lock = await DistributedLockService.acquire({ + key: `${LOCK_KEYS.TOKEN_REFRESH}:${refreshToken}`, + ttl: 10, // Token刷新操作很快 + timeout: 5000, + autoRenew: false // 短期操作不需要续期 + }); - if (tokenPayload.error) { - throw new BusinessError('刷新令牌验证失败', 401); + try { + // 1. 验证刷新令牌 + const tokenPayload = jwtService.verifyToken(refreshToken); + if (tokenPayload.error) { + throw new BusinessError('刷新令牌验证失败', 401); + } + if (tokenPayload.type !== TOKEN_TYPES.REFRESH) { + throw new BusinessError('刷新令牌验证失败', 401); + } + + // 2. 获取用户信息 + const user = await this.getUserById(tokenPayload.userId); + + // 3. 检查用户状态 + this.checkAccountStatus(user); + + // 4. 生成新的令牌对 + const tokens = jwtService.generateTokens({ + id: user.id, + username: user.username, + email: user.email, + status: user.status + }); + + // 5. 记录刷新日志 + await this.recordRefreshLog(user.id); + + return successResponse({ + tokens + }, 'Token刷新成功'); + + } finally { + // 释放锁 + await lock.release(); } - if(tokenPayload.type !== TOKEN_TYPES.REFRESH){ - throw new BusinessError('刷新令牌验证失败', 401); - } - Logger.debug(tokenPayload); - - // 2. 检查用户是否存在且状态正常 - const user = await this.getUserById(tokenPayload.userId); - this.checkAccountStatus(user); - - // 3. 生成新的令牌对 - const newTokens = jwtService.generateTokens({ - id: user.id, - username: user.username, - email: user.email, - status: user.status - }, false); // 刷新时默认不记住登录状态 - - // 4. 记录刷新日志 - await this.recordRefreshLog(user.id); - - Logger.info(`Token刷新成功:${user.id} - ${user.username}`); - - return successResponse({ - tokens: { - accessToken: newTokens.accessToken, - refreshToken: newTokens.refreshToken, - tokenType: 'Bearer', - expiresIn: newTokens.expiresIn.toString(), - refreshExpiresIn: newTokens.refreshExpiresIn.toString() - }, - refreshedAt: new Date().toISOString() - }, 'Token刷新成功'); } /** @@ -579,6 +635,275 @@ export class AuthService { // TODO: 实现Token刷新日志记录 Logger.info(`记录Token刷新日志:用户ID=${userId}`); } + + /** + * 找回密码 + * @param request 找回密码请求参数 + * @returns Promise + * @throws BusinessError 业务逻辑错误 + * @type API ===================================================================== + */ + public async resetPasswordRequest(request: ResetPasswordRequestRequest): Promise { + Logger.info(`找回密码请求:${JSON.stringify({ ...request, captcha: '***' })}`); + + const { email, captcha, captchaId } = request; + + // 获取分布式锁,防止并发重置密码请求 + const lock = await DistributedLockService.acquire({ + key: `${LOCK_KEYS.PASSWORD_RESET}:${email}`, + ttl: 30, + timeout: 10000, + autoRenew: true + }); + + try { + // 1. 验证验证码 + await this.validateCaptcha(captcha, captchaId); + + // 2. 检查邮箱是否存在 + const user = await this.findUserByEmail(email); + + // 3. 检查用户状态 + this.checkAccountStatus(user); + + // 4. 生成重置令牌 + const resetToken = jwtService.generateResetToken(user.id); + + // 5. 发送重置邮件 + await this.sendResetPasswordEmail(user.email, user.username, resetToken); + + Logger.info(`找回密码邮件发送成功:${user.id} - ${user.email}`); + + return successResponse({ + email: user.email, + sent: true, + sentAt: new Date().toISOString(), + expiresIn: 30, // 30分钟有效期 + message: '重置邮件已发送,请查收邮箱' + }, '重置邮件已发送,请查收邮箱'); + + } finally { + // 释放锁 + await lock.release(); + } + } + + /** + * 根据邮箱查找用户 + * @param email 邮箱地址 + * @returns Promise<用户信息> + * @throws BusinessError 用户不存在时抛出 + */ + private async findUserByEmail(email: string): Promise<{ + id: string; + username: string; + email: string; + status: string; + }> { + const [user] = await db().select({ + id: sysUsers.id, + username: sysUsers.username, + email: sysUsers.email, + status: sysUsers.status + }) + .from(sysUsers) + .where(eq(sysUsers.email, email)) + .limit(1); + + if (!user) { + throw new BusinessError('该邮箱未注册', 404); + } + + return { + id: user.id!.toString(), + username: user.username, + email: user.email, + status: user.status + }; + } + + /** + * 发送重置密码邮件 + * @param email 邮箱地址 + * @param username 用户名 + * @param resetToken 重置令牌 + */ + private async sendResetPasswordEmail(email: string, username: string, resetToken: string): Promise { + const resetUrl = `${process.env.FRONTEND_URL || 'http://localhost:3000'}/reset-password?token=${resetToken}`; + + const emailContent = { + to: email, + subject: '密码重置 - 星撰系统', + html: ` +
+

密码重置

+

亲爱的 ${username},

+

您请求重置密码。请点击下面的链接重置您的密码:

+

+ + 重置密码 + +

+

或者复制以下链接到浏览器:

+

${resetUrl}

+

注意:

+
    +
  • 此链接将在30分钟后过期
  • +
  • 如果您没有请求重置密码,请忽略此邮件
  • +
  • 为了安全起见,请不要将此链接分享给他人
  • +
+

如果您有任何问题,请联系我们的客服团队。

+

谢谢!
星撰系统团队

+
+ ` + }; + + await emailService.sendEmail(emailContent); + Logger.info(`重置密码邮件发送成功:${email}`); + } + + /** + * 重置密码 + * @param request 重置密码请求参数 + * @returns Promise + * @throws BusinessError 业务逻辑错误 + * @type API ===================================================================== + */ + public async resetPasswordConfirm(request: ResetPasswordConfirmRequest): Promise { + const { token, newPassword, confirmPassword } = request; + + // 1. 验证密码一致性 + if (newPassword !== confirmPassword) { + throw new BusinessError('两次输入的密码不一致', 400); + } + + // 2. 验证重置令牌 + const tokenPayload = jwtService.verifyToken(token); + if (tokenPayload.error || tokenPayload.type !== TOKEN_TYPES.PASSWORD_RESET) { + throw new BusinessError('重置令牌无效或已过期', 400); + } + + // 获取分布式锁,防止并发重置密码 + const lock = await DistributedLockService.acquire({ + key: `${LOCK_KEYS.PASSWORD_RESET}:${tokenPayload.userId}`, + ttl: 30, + timeout: 10000, + autoRenew: true + }); + + try { + // 3. 获取用户信息 + const user = await this.getUserById(tokenPayload.userId); + + // 4. 检查用户状态 + this.checkAccountStatus(user); + + // 5. 加密新密码 + const newPasswordHash = await this.hashPassword(newPassword); + + // 6. 更新用户密码 + const updatedUser = await this.updateUserPassword(user.id, newPasswordHash); + + // 7. 发送密码重置成功邮件 + await this.sendPasswordResetSuccessEmail(user.email, user.username); + + Logger.info(`密码重置成功:${user.id} - ${user.username}`); + + return successResponse({ + id: updatedUser.id, + username: updatedUser.username, + email: updatedUser.email, + updatedAt: updatedUser.updatedAt, + reset: true, + message: '密码重置成功,请使用新密码登录' + }, '密码重置成功,请使用新密码登录'); + + } finally { + // 释放锁 + await lock.release(); + } + } + + /** + * 更新用户密码 + * @param userId 用户ID + * @param newPasswordHash 新密码哈希 + * @returns Promise<更新后的用户信息> + */ + private async updateUserPassword(userId: string, newPasswordHash: string): Promise<{ + id: string; + username: string; + email: string; + updatedAt: string; + }> { + await db().update(sysUsers) + .set({ + passwordHash: newPasswordHash, + }) + .where(eq(sysUsers.id, BigInt(userId))); + + // 查询更新后的用户信息 + const [updatedUser] = await db().select({ + id: sysUsers.id, + username: sysUsers.username, + email: sysUsers.email, + updatedAt: sysUsers.updatedAt + }) + .from(sysUsers) + .where(eq(sysUsers.id, BigInt(userId))) + .limit(1); + + if (!updatedUser) { + throw new BusinessError('更新密码失败', 500); + } + + return { + id: updatedUser.id!.toString(), + username: updatedUser.username, + email: updatedUser.email, + updatedAt: updatedUser.updatedAt + }; + } + + /** + * 发送密码重置成功邮件 + * @param email 邮箱地址 + * @param username 用户名 + */ + private async sendPasswordResetSuccessEmail(email: string, username: string): Promise { + const loginUrl = `${process.env.FRONTEND_URL || 'http://localhost:3000'}/login`; + + const emailContent = { + to: email, + subject: '密码重置成功 - 星撰系统', + html: ` +
+

密码重置成功

+

亲爱的 ${username},

+

您的密码已成功重置。如果您没有进行此操作,请立即联系我们的客服团队。

+

+ + 立即登录 + +

+

安全提醒:

+
    +
  • 请妥善保管您的新密码
  • +
  • 不要在多个网站使用相同的密码
  • +
  • 定期更换密码以提高安全性
  • +
  • 如果发现异常登录,请立即修改密码
  • +
+

如果您有任何问题,请联系我们的客服团队。

+

谢谢!
星撰系统团队

+
+ ` + }; + + await emailService.sendEmail(emailContent); + Logger.info(`密码重置成功邮件发送成功:${email}`); + } } // 导出单例实例 diff --git a/src/modules/auth/auth.test.md b/src/modules/auth/auth.test.md new file mode 100644 index 0000000..242ec6f --- /dev/null +++ b/src/modules/auth/auth.test.md @@ -0,0 +1,362 @@ +# 认证模块测试用例文档 + +## 概述 + +本文档描述了认证模块各个接口的测试用例,包括正常流程、异常流程和边界条件测试。 + +## 测试环境 + +- **基础URL**: `http://localhost:3000/api` +- **测试工具**: Vitest + Supertest +- **数据库**: MySQL (测试环境) +- **缓存**: Redis (测试环境) + +## 接口测试用例 + +### 1. 用户注册接口 (POST /auth/register) + +#### 1.1 正常流程测试 + +**测试用例**: 成功注册新用户 +- **请求参数**: + ```json + { + "username": "testuser", + "email": "test@example.com", + "password": "password123", + "captcha": "a1b2", + "captchaId": "test_captcha_id" + } + ``` +- **预期响应**: 200 OK +- **验证点**: + - 用户信息正确创建 + - 密码已加密存储 + - 激活邮件已发送 + - 用户状态为pending + +#### 1.2 异常流程测试 + +**测试用例**: 用户名已存在 +- **请求参数**: 使用已存在的用户名 +- **预期响应**: 400 Bad Request +- **错误信息**: "用户名已存在" + +**测试用例**: 邮箱已被注册 +- **请求参数**: 使用已注册的邮箱 +- **预期响应**: 400 Bad Request +- **错误信息**: "邮箱已被注册" + +**测试用例**: 验证码错误 +- **请求参数**: 错误的验证码 +- **预期响应**: 400 Bad Request +- **错误信息**: "验证码验证失败" + +### 2. 邮箱激活接口 (POST /auth/activate) + +#### 2.1 正常流程测试 + +**测试用例**: 成功激活用户邮箱 +- **请求参数**: + ```json + { + "token": "valid_activation_token" + } + ``` +- **预期响应**: 200 OK +- **验证点**: + - 用户状态更新为active + - 激活时间正确记录 + +#### 2.2 异常流程测试 + +**测试用例**: 无效的激活令牌 +- **请求参数**: 无效或过期的令牌 +- **预期响应**: 400 Bad Request +- **错误信息**: "激活令牌无效或已过期" + +### 3. 用户登录接口 (POST /auth/login) + +#### 3.1 正常流程测试 + +**测试用例**: 用户名登录成功 +- **请求参数**: + ```json + { + "identifier": "testuser", + "password": "password123" + } + ``` +- **预期响应**: 200 OK +- **验证点**: + - 返回访问令牌和刷新令牌 + - 最后登录时间更新 + - 登录日志记录 + +**测试用例**: 邮箱登录成功 +- **请求参数**: + ```json + { + "identifier": "test@example.com", + "password": "password123" + } + ``` +- **预期响应**: 200 OK + +#### 3.2 异常流程测试 + +**测试用例**: 用户名不存在 +- **请求参数**: 不存在的用户名 +- **预期响应**: 404 Not Found +- **错误信息**: "用户不存在" + +**测试用例**: 密码错误 +- **请求参数**: 错误的密码 +- **预期响应**: 401 Unauthorized +- **错误信息**: "用户名或密码错误" + +**测试用例**: 账号未激活 +- **请求参数**: 未激活用户的凭据 +- **预期响应**: 403 Forbidden +- **错误信息**: "账号未激活,请先激活邮箱" + +### 4. Token刷新接口 (POST /auth/refresh) + +#### 4.1 正常流程测试 + +**测试用例**: 成功刷新令牌 +- **请求参数**: + ```json + { + "refreshToken": "valid_refresh_token" + } + ``` +- **预期响应**: 200 OK +- **验证点**: + - 返回新的访问令牌和刷新令牌 + - 刷新日志记录 + +#### 4.2 异常流程测试 + +**测试用例**: 无效的刷新令牌 +- **请求参数**: 无效或过期的刷新令牌 +- **预期响应**: 401 Unauthorized +- **错误信息**: "刷新令牌无效或已过期" + +### 5. 找回密码接口 (POST /auth/password/reset-request) + +#### 5.1 正常流程测试 + +**测试用例**: 成功发送重置邮件 +- **请求参数**: + ```json + { + "email": "test@example.com", + "captcha": "a1b2", + "captchaId": "test_captcha_id" + } + ``` +- **预期响应**: 200 OK +- **验证点**: + - 重置邮件已发送 + - 重置令牌已生成 + - 返回发送状态和时间 + +#### 5.2 异常流程测试 + +**测试用例**: 邮箱未注册 +- **请求参数**: 未注册的邮箱地址 +- **预期响应**: 404 Not Found +- **错误信息**: "该邮箱未注册" + +**测试用例**: 验证码错误 +- **请求参数**: 错误的验证码 +- **预期响应**: 400 Bad Request +- **错误信息**: "验证码验证失败" + +**测试用例**: 账号未激活 +- **请求参数**: 未激活用户的邮箱 +- **预期响应**: 403 Forbidden +- **错误信息**: "账号未激活,请先激活邮箱" + +### 6. 重置密码接口 (POST /auth/password/reset-confirm) + +#### 6.1 正常流程测试 + +**测试用例**: 成功重置密码 +- **请求参数**: + ```json + { + "token": "valid_reset_token", + "newPassword": "newpassword123", + "confirmPassword": "newpassword123" + } + ``` +- **预期响应**: 200 OK +- **验证点**: + - 密码已更新 + - 重置令牌已失效 + - 成功邮件已发送 + - 返回用户基本信息 + +#### 6.2 异常流程测试 + +**测试用例**: 重置令牌无效 +- **请求参数**: 无效或过期的重置令牌 +- **预期响应**: 400 Bad Request +- **错误信息**: "重置令牌无效或已过期" + +**测试用例**: 密码不一致 +- **请求参数**: 新密码和确认密码不一致 +- **预期响应**: 400 Bad Request +- **错误信息**: "两次输入的密码不一致" + +**测试用例**: 密码长度不足 +- **请求参数**: 新密码少于6字符 +- **预期响应**: 400 Bad Request +- **错误信息**: "密码长度不符合要求" + +**测试用例**: 账号未激活 +- **请求参数**: 未激活用户的重置令牌 +- **预期响应**: 403 Forbidden +- **错误信息**: "账号未激活,请先激活邮箱" + +### 7. 图形验证码接口 (GET /auth/captcha) + +## 边界条件测试 + +### 1. 输入验证边界 + +**测试用例**: 用户名长度边界 +- 最小长度: 2字符 +- 最大长度: 50字符 +- 超出范围应返回400错误 + +**测试用例**: 邮箱格式验证 +- 有效邮箱格式应通过验证 +- 无效邮箱格式应返回400错误 + +**测试用例**: 密码强度要求 +- 最小长度: 6字符 +- 最大长度: 50字符 +- 超出范围应返回400错误 + +### 2. 并发测试 + +**测试用例**: 并发注册 +- 同时使用相同用户名注册 +- 应只有一个成功,其他失败 + +**测试用例**: 并发登录 +- 同一用户同时登录 +- 应都能成功,但刷新令牌会失效 + +### 3. 性能测试 + +**测试用例**: 大量用户注册 +- 测试系统在高并发下的表现 +- 验证数据库连接池和缓存性能 + +**测试用例**: 邮件发送性能 +- 测试邮件服务的并发处理能力 +- 验证邮件队列机制 + +## 安全测试 + +### 1. 密码安全 + +**测试用例**: 密码加密存储 +- 验证密码是否使用bcrypt加密 +- 确认原始密码不在数据库中 + +**测试用例**: 密码强度验证 +- 测试弱密码的拒绝机制 +- 验证密码复杂度要求 + +### 2. 令牌安全 + +**测试用例**: JWT令牌验证 +- 验证令牌签名和过期时间 +- 测试令牌篡改检测 + +**测试用例**: 令牌刷新安全 +- 验证刷新令牌的一次性使用 +- 测试令牌泄露防护 + +### 3. 输入安全 + +**测试用例**: SQL注入防护 +- 测试特殊字符输入 +- 验证参数化查询 + +**测试用例**: XSS防护 +- 测试恶意脚本输入 +- 验证输出转义 + +## 测试数据准备 + +### 1. 测试用户数据 + +```sql +-- 清理测试数据 +DELETE FROM sys_users WHERE username LIKE 'test_%'; + +-- 准备测试用户 +INSERT INTO sys_users (id, username, email, password_hash, status) VALUES +(1, 'test_user1', 'test1@example.com', '$2b$12$...', 'active'), +(2, 'test_user2', 'test2@example.com', '$2b$12$...', 'pending'); +``` + +### 2. 测试验证码数据 + +```sql +-- 准备测试验证码 +INSERT INTO captcha_sessions (id, captcha_code, expires_at) VALUES +('test_captcha_id', 'a1b2', DATE_ADD(NOW(), INTERVAL 5 MINUTE)); +``` + +## 测试执行 + +### 1. 运行所有测试 + +```bash +bun test src/modules/auth/auth.test.ts +``` + +### 2. 运行特定测试 + +```bash +# 运行注册接口测试 +bun test src/modules/auth/auth.test.ts -t "register" + +# 运行登录接口测试 +bun test src/modules/auth/auth.test.ts -t "login" +``` + +### 3. 生成测试报告 + +```bash +bun test src/modules/auth/auth.test.ts --reporter=verbose +``` + +## 持续集成 + +### 1. 自动化测试 + +- 每次代码提交自动运行测试 +- 测试失败阻止代码合并 +- 生成测试覆盖率报告 + +### 2. 测试环境 + +- 独立的测试数据库 +- 模拟的邮件服务 +- 隔离的Redis缓存 + +## 注意事项 + +1. **测试数据隔离**: 每个测试用例应使用独立的测试数据 +2. **环境变量**: 测试环境应使用专门的配置 +3. **异步操作**: 邮件发送等异步操作需要适当的等待时间 +4. **资源清理**: 测试完成后应清理所有测试数据 +5. **错误处理**: 测试应覆盖各种错误情况 \ No newline at end of file diff --git a/src/plugins/jwt/jwt.service.ts b/src/plugins/jwt/jwt.service.ts index 8bf6b2f..e9702fb 100644 --- a/src/plugins/jwt/jwt.service.ts +++ b/src/plugins/jwt/jwt.service.ts @@ -83,6 +83,20 @@ export class JwtService { return { error: true } as JwtPayloadType; } } + + /** + * 生成重置密码Token + */ + generateResetToken(userId: string) { + return jwt.sign( + { + userId, + type: TOKEN_TYPES.PASSWORD_RESET, + }, + jwtConfig.secret, + { expiresIn: '30M' } + ); + } } export const jwtService = new JwtService(); \ No newline at end of file diff --git a/src/utils/distributedLock.ts b/src/utils/distributedLock.ts new file mode 100644 index 0000000..8211674 --- /dev/null +++ b/src/utils/distributedLock.ts @@ -0,0 +1,344 @@ +/** + * @file 分布式锁工具类 + * @author AI Assistant + * @date 2025-01-07 + * @lastEditor AI Assistant + * @lastEditTime 2025-01-07 + * @description 基于Redis实现的分布式锁,支持自动续期和死锁检测 + */ + +import { redisService } from '@/plugins/redis/redis.service'; +import { Logger } from '@/plugins/logger/logger.service'; +import { nextId } from './snowflake'; + +/** + * 分布式锁配置 + */ +export interface DistributedLockConfig { + /** 锁的键名 */ + key: string; + /** 锁的过期时间(秒) */ + ttl: number; + /** 获取锁的超时时间(毫秒) */ + timeout?: number; + /** 是否自动续期 */ + autoRenew?: boolean; + /** 续期间隔(毫秒) */ + renewInterval?: number; +} + +/** + * 分布式锁实例 + */ +export interface DistributedLock { + /** 锁的键名 */ + key: string; + /** 锁的值(用于标识锁的拥有者) */ + value: string; + /** 是否已获取锁 */ + acquired: boolean; + /** 获取锁的时间戳 */ + acquiredAt: number; + /** 释放锁 */ + release: () => Promise; + /** 续期锁 */ + renew: () => Promise; +} + +/** + * 分布式锁工具类 + */ +export class DistributedLockService { + /** 锁前缀 */ + private static readonly LOCK_PREFIX = 'distributed_lock:'; + + /** 默认TTL(秒) */ + private static readonly DEFAULT_TTL = 30; + + /** 默认超时时间(毫秒) */ + private static readonly DEFAULT_TIMEOUT = 5000; + + /** 默认续期间隔(毫秒) */ + private static readonly DEFAULT_RENEW_INTERVAL = 10000; + + /** + * 获取分布式锁 + * @param config 锁配置 + * @returns Promise 锁实例 + */ + public static async acquire(config: DistributedLockConfig): Promise { + const lockKey = `${this.LOCK_PREFIX}${config.key}`; + const lockValue = nextId().toString(); + const ttl = config.ttl || this.DEFAULT_TTL; + const timeout = config.timeout || this.DEFAULT_TIMEOUT; + const autoRenew = config.autoRenew !== false; + const renewInterval = config.renewInterval || this.DEFAULT_RENEW_INTERVAL; + + const startTime = Date.now(); + let acquired = false; + let renewTimer: NodeJS.Timeout | null = null; + let processExitHandler: (() => void) | null = null; + + try { + // 尝试获取锁 + while (Date.now() - startTime < timeout) { + // 使用 SET key value NX EX seconds 原子操作 + const result = await redisService.client.set(lockKey, lockValue, { + NX: true, // 只有当 key 不存在时才设置 + EX: ttl // 设置过期时间(秒) + }); + + if (result === 'OK') { + acquired = true; + break; + } + + // 等待一段时间后重试 + await this.sleep(100); + } + + if (!acquired) { + throw new Error(`获取锁超时: ${lockKey}`); + } + + Logger.info(`获取分布式锁成功: ${lockKey}, value: ${lockValue}`); + + // 创建锁实例 + const lock: DistributedLock = { + key: lockKey, + value: lockValue, + acquired: true, + acquiredAt: Date.now(), + + // 释放锁 + release: async (): Promise => { + // 清理定时器和事件监听器 + if (renewTimer) { + clearInterval(renewTimer); + renewTimer = null; + } + if (processExitHandler) { + process.removeListener('exit', processExitHandler); + process.removeListener('SIGINT', processExitHandler); + process.removeListener('SIGTERM', processExitHandler); + processExitHandler = null; + } + + const released = await this.releaseLock(lockKey, lockValue); + if (released) { + lock.acquired = false; + Logger.info(`释放分布式锁成功: ${lockKey}`); + } + return released; + }, + + // 续期锁 + renew: async (): Promise => { + return await this.renewLock(lockKey, lockValue, ttl); + } + }; + + // 启动自动续期(仅在需要时) + if (autoRenew && ttl > renewInterval / 1000) { + renewTimer = setInterval(async () => { + if (lock.acquired) { + try { + const renewed = await lock.renew(); + if (!renewed) { + Logger.warn(`锁续期失败,可能已被其他进程获取: ${lockKey}`); + lock.acquired = false; + if (renewTimer) { + clearInterval(renewTimer); + renewTimer = null; + } + } + } catch (error) { + Logger.error(new Error(`锁续期异常: ${lockKey}, error: ${error}`)); + // 续期失败时,不立即释放锁,让锁自然过期 + } + } + }, renewInterval); + + // 添加进程退出时的清理逻辑 + processExitHandler = async () => { + Logger.warn(`进程退出,强制释放分布式锁: ${lockKey}`); + await this.forceRelease(config.key); + }; + + process.on('exit', processExitHandler); + process.on('SIGINT', processExitHandler); + process.on('SIGTERM', processExitHandler); + } + + return lock; + + } catch (error) { + // 清理已创建的定时器和事件监听器 + if (renewTimer) { + clearInterval(renewTimer); + } + if (processExitHandler) { + process.removeListener('exit', processExitHandler); + process.removeListener('SIGINT', processExitHandler); + process.removeListener('SIGTERM', processExitHandler); + } + + Logger.error(new Error(`获取分布式锁失败: ${lockKey}, error: ${error}`)); + throw error; + } + } + + /** + * 释放锁 + * @param lockKey 锁键名 + * @param lockValue 锁值 + * @returns Promise 是否成功释放 + */ + private static async releaseLock(lockKey: string, lockValue: string): Promise { + try { + // 使用Lua脚本确保原子性操作 + const luaScript = ` + if redis.call("get", KEYS[1]) == ARGV[1] then + return redis.call("del", KEYS[1]) + else + return 0 + end + `; + + const result = await redisService.client.eval(luaScript, { + keys: [lockKey], + arguments: [lockValue] + }); + + return result === 1; + } catch (error) { + Logger.error(new Error(`释放锁失败: ${lockKey}, error: ${error}`)); + return false; + } + } + + /** + * 续期锁 + * @param lockKey 锁键名 + * @param lockValue 锁值 + * @param ttl 过期时间 + * @returns Promise 是否成功续期 + */ + private static async renewLock(lockKey: string, lockValue: string, ttl: number): Promise { + try { + // 使用Lua脚本确保原子性操作 + const luaScript = ` + if redis.call("get", KEYS[1]) == ARGV[1] then + return redis.call("expire", KEYS[1], ARGV[2]) + else + return 0 + end + `; + + const result = await redisService.client.eval(luaScript, { + keys: [lockKey], + arguments: [lockValue, ttl.toString()] + }); + + return result === 1; + } catch (error) { + Logger.error(new Error(`续期锁失败: ${lockKey}, error: ${error}`)); + return false; + } + } + + /** + * 检查锁是否存在 + * @param key 锁键名 + * @returns Promise 锁是否存在 + */ + public static async isLocked(key: string): Promise { + const lockKey = `${this.LOCK_PREFIX}${key}`; + return await redisService.exists(lockKey); + } + + /** + * 获取锁的剩余TTL + * @param key 锁键名 + * @returns Promise 剩余TTL(秒) + */ + public static async getLockTTL(key: string): Promise { + const lockKey = `${this.LOCK_PREFIX}${key}`; + return await redisService.ttl(lockKey); + } + + /** + * 强制释放锁(不检查拥有者) + * @param key 锁键名 + * @returns Promise 是否成功释放 + */ + public static async forceRelease(key: string): Promise { + const lockKey = `${this.LOCK_PREFIX}${key}`; + try { + const result = await redisService.del(lockKey); + Logger.warn(`强制释放分布式锁: ${lockKey}`); + return result === 1; + } catch (error) { + Logger.error(new Error(`强制释放锁失败: ${lockKey}, error: ${error}`)); + return false; + } + } + + /** + * 睡眠函数 + * @param ms 毫秒数 + * @returns Promise + */ + private static sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); + } +} + +/** + * 分布式锁常量定义 + */ +export const LOCK_KEYS = { + // 用户注册锁 + USER_REGISTER: 'user:register', + // 用户登录锁 + USER_LOGIN: 'user:login', + // 密码重置锁 + PASSWORD_RESET: 'password:reset', + // 邮箱激活锁 + EMAIL_ACTIVATE: 'email:activate', + // Token刷新锁 + TOKEN_REFRESH: 'token:refresh', + // 验证码生成锁 + CAPTCHA_GENERATE: 'captcha:generate', + // 邮件发送锁 + EMAIL_SEND: 'email:send' +} as const; + +/** + * 分布式锁装饰器 + * @param lockKey 锁键名 + * @param ttl 过期时间(秒) + * @param timeout 超时时间(毫秒) + */ +export function withDistributedLock(lockKey: string, ttl: number = 30, timeout: number = 5000) { + return function (target: any, propertyName: string, descriptor: PropertyDescriptor) { + const method = descriptor.value; + + descriptor.value = async function (...args: any[]) { + const lock = await DistributedLockService.acquire({ + key: lockKey, + ttl, + timeout, + autoRenew: true + }); + + try { + return await method.apply(this, args); + } finally { + await lock.release(); + } + }; + + return descriptor; + }; +} \ No newline at end of file diff --git a/tasks/M2-基础用户系统-开发任务计划.md b/tasks/M2-基础用户系统-开发任务计划.md index 862e3b9..1b5ad35 100644 --- a/tasks/M2-基础用户系统-开发任务计划.md +++ b/tasks/M2-基础用户系统-开发任务计划.md @@ -98,19 +98,19 @@ - ~~[ ] 5.4 扩展auth.controller.ts - 实现退出路由~~ - ~~[ ] 5.5 扩展auth.test.md - 编写退出测试用例文档~~ -- [ ] 6.0 POST /auth/password/reset-request - 找回密码接口 - - [ ] 6.1 扩展auth.schema.ts - 定义找回密码Schema - - [ ] 6.2 扩展auth.response.ts - 定义找回密码响应格式 - - [ ] 6.3 扩展auth.service.ts - 实现找回密码业务逻辑 - - [ ] 6.4 扩展auth.controller.ts - 实现找回密码路由 - - [ ] 6.5 扩展auth.test.md - 编写找回密码测试用例文档 +- [x] 6.0 POST /auth/password/reset-request - 找回密码接口 + - [x] 6.1 扩展auth.schema.ts - 定义找回密码Schema + - [x] 6.2 扩展auth.response.ts - 定义找回密码响应格式 + - [x] 6.3 扩展auth.service.ts - 实现找回密码业务逻辑 + - [x] 6.4 扩展auth.controller.ts - 实现找回密码路由 + - [x] 6.5 扩展auth.test.md - 编写找回密码测试用例文档 -- [ ] 7.0 POST /auth/password/reset-confirm - 重置密码接口 - - [ ] 7.1 扩展auth.schema.ts - 定义重置密码Schema - - [ ] 7.2 扩展auth.response.ts - 定义重置密码响应格式 - - [ ] 7.3 扩展auth.service.ts - 实现重置密码业务逻辑 - - [ ] 7.4 扩展auth.controller.ts - 实现重置密码路由 - - [ ] 7.5 扩展auth.test.md - 编写重置密码测试用例文档 +- [x] 7.0 POST /auth/password/reset-confirm - 重置密码接口 + - [x] 7.1 扩展auth.schema.ts - 定义重置密码Schema + - [x] 7.2 扩展auth.response.ts - 定义重置密码响应格式 + - [x] 7.3 扩展auth.service.ts - 实现重置密码业务逻辑 + - [x] 7.4 扩展auth.controller.ts - 实现重置密码路由 + - [x] 7.5 扩展auth.test.md - 编写重置密码测试用例文档 - [x] 8.0 GET /auth/captcha - 图形验证码接口 - [x] 8.1 扩展auth.schema.ts - 定义验证码Schema @@ -119,12 +119,13 @@ - [x] 8.4 扩展auth.controller.ts - 实现验证码路由 - [x] 8.5 扩展auth.test.md - 编写验证码测试用例文档 -- [ ] 9.0 检查认证模块 (Auth Module)那些接口需要加分布式锁,并加锁 +- [x] 9.0 检查认证模块 (Auth Module)那些接口需要加分布式锁,并加锁 ### 👤 用户管理模块 (User Module) - P0优先级 - [ ] 9.0 GET /users/me - 获取当前用户信息接口 + - [ ] Before 整理输入此接口的逻辑,等待用户确认后进行 - [ ] 9.1 扩展user.schema.ts - 定义当前用户Schema - [ ] 9.2 扩展user.response.ts - 定义当前用户响应格式 - [ ] 9.3 扩展user.service.ts - 实现当前用户业务逻辑 @@ -132,6 +133,7 @@ - [ ] 9.5 创建user.test.md - 编写当前用户测试用例文档 - [ ] 10.0 GET /users - 用户列表查询接口 + - [ ] Before 整理输入此接口的逻辑,等待用户确认后进行 - [ ] 10.1 扩展user.schema.ts - 定义用户列表Schema - [ ] 10.2 扩展user.response.ts - 定义用户列表响应格式 - [ ] 10.3 扩展user.service.ts - 实现用户列表业务逻辑 @@ -139,6 +141,7 @@ - [ ] 10.5 扩展user.test.md - 编写用户列表测试用例文档 - [ ] 11.0 POST /users - 创建用户接口 + - [ ] Before 整理输入此接口的逻辑,等待用户确认后进行 - [ ] 11.1 扩展user.schema.ts - 定义创建用户Schema - [ ] 11.2 扩展user.response.ts - 定义创建用户响应格式 - [ ] 11.3 扩展user.service.ts - 实现创建用户业务逻辑 @@ -146,6 +149,7 @@ - [ ] 11.5 扩展user.test.md - 编写创建用户测试用例文档 - [ ] 12.0 PUT /users/{id} - 更新用户信息接口 + - [ ] Before 整理输入此接口的逻辑,等待用户确认后进行 - [ ] 12.1 扩展user.schema.ts - 定义更新用户Schema - [ ] 12.2 扩展user.response.ts - 定义更新用户响应格式 - [ ] 12.3 扩展user.service.ts - 实现更新用户业务逻辑 @@ -153,6 +157,7 @@ - [ ] 12.5 扩展user.test.md - 编写更新用户测试用例文档 - [ ] 13.0 DELETE /users/{id} - 删除用户接口 + - [ ] Before 整理输入此接口的逻辑,等待用户确认后进行 - [ ] 13.1 扩展user.schema.ts - 定义删除用户Schema - [ ] 13.2 扩展user.response.ts - 定义删除用户响应格式 - [ ] 13.3 扩展user.service.ts - 实现删除用户业务逻辑 @@ -160,6 +165,7 @@ - [ ] 13.5 扩展user.test.md - 编写删除用户测试用例文档 - [ ] 14.0 PUT /users/me/password - 修改密码接口 + - [ ] Before 整理输入此接口的逻辑,等待用户确认后进行 - [ ] 14.1 扩展user.schema.ts - 定义修改密码Schema - [ ] 14.2 扩展user.response.ts - 定义修改密码响应格式 - [ ] 14.3 扩展user.service.ts - 实现修改密码业务逻辑 @@ -167,6 +173,7 @@ - [ ] 14.5 扩展user.test.md - 编写修改密码测试用例文档 - [ ] 15.0 GET /users/{id} - 用户详情接口 + - [ ] Before 整理输入此接口的逻辑,等待用户确认后进行 - [ ] 15.1 扩展user.schema.ts - 定义用户详情Schema - [ ] 15.2 扩展user.response.ts - 定义用户详情响应格式 - [ ] 15.3 扩展user.service.ts - 实现用户详情业务逻辑 @@ -174,6 +181,7 @@ - [ ] 15.5 扩展user.test.md - 编写用户详情测试用例文档 - [ ] 16.0 POST /users/batch - 批量操作接口 + - [ ] Before 整理输入此接口的逻辑,等待用户确认后进行 - [ ] 16.1 扩展user.schema.ts - 定义批量操作Schema - [ ] 16.2 扩展user.response.ts - 定义批量操作响应格式 - [ ] 16.3 扩展user.service.ts - 实现批量操作业务逻辑 @@ -183,6 +191,7 @@ ### 🎭 角色权限模块 (Role Module) - P0优先级 - [ ] 17.0 GET /roles - 角色列表接口 + - [ ] Before 整理输入此接口的逻辑,等待用户确认后进行 - [ ] 17.1 创建role.schema.ts - 定义角色Schema - [ ] 17.2 创建role.response.ts - 定义角色响应格式 - [ ] 17.3 创建role.service.ts - 实现角色业务逻辑 @@ -190,6 +199,7 @@ - [ ] 17.5 创建role.test.md - 编写角色测试用例文档 - [ ] 18.0 POST /roles - 创建角色接口 + - [ ] Before 整理输入此接口的逻辑,等待用户确认后进行 - [ ] 18.1 扩展role.schema.ts - 定义创建角色Schema - [ ] 18.2 扩展role.response.ts - 定义创建角色响应格式 - [ ] 18.3 扩展role.service.ts - 实现创建角色业务逻辑 @@ -197,6 +207,7 @@ - [ ] 18.5 扩展role.test.md - 编写创建角色测试用例文档 - [ ] 19.0 PUT /roles/{id} - 更新角色接口 + - [ ] Before 整理输入此接口的逻辑,等待用户确认后进行 - [ ] 19.1 扩展role.schema.ts - 定义更新角色Schema - [ ] 19.2 扩展role.response.ts - 定义更新角色响应格式 - [ ] 19.3 扩展role.service.ts - 实现更新角色业务逻辑 @@ -204,6 +215,7 @@ - [ ] 19.5 扩展role.test.md - 编写更新角色测试用例文档 - [ ] 20.0 DELETE /roles/{id} - 删除角色接口 + - [ ] Before 整理输入此接口的逻辑,等待用户确认后进行 - [ ] 20.1 扩展role.schema.ts - 定义删除角色Schema - [ ] 20.2 扩展role.response.ts - 定义删除角色响应格式 - [ ] 20.3 扩展role.service.ts - 实现删除角色业务逻辑 @@ -211,6 +223,7 @@ - [ ] 20.5 扩展role.test.md - 编写删除角色测试用例文档 - [ ] 21.0 GET /permissions - 权限列表接口 + - [ ] Before 整理输入此接口的逻辑,等待用户确认后进行 - [ ] 21.1 创建permission.schema.ts - 定义权限Schema - [ ] 21.2 创建permission.response.ts - 定义权限响应格式 - [ ] 21.3 创建permission.service.ts - 实现权限业务逻辑 @@ -218,6 +231,7 @@ - [ ] 21.5 创建permission.test.md - 编写权限测试用例文档 - [ ] 22.0 POST /roles/{id}/permissions - 权限分配接口 + - [ ] Before 整理输入此接口的逻辑,等待用户确认后进行 - [ ] 22.1 扩展role.schema.ts - 定义权限分配Schema - [ ] 22.2 扩展role.response.ts - 定义权限分配响应格式 - [ ] 22.3 扩展role.service.ts - 实现权限分配业务逻辑 @@ -225,6 +239,7 @@ - [ ] 22.5 扩展role.test.md - 编写权限分配测试用例文档 - [ ] 23.0 POST /users/{id}/roles - 用户角色分配接口 + - [ ] Before 整理输入此接口的逻辑,等待用户确认后进行 - [ ] 23.1 扩展user.schema.ts - 定义用户角色分配Schema - [ ] 23.2 扩展user.response.ts - 定义用户角色分配响应格式 - [ ] 23.3 扩展user.service.ts - 实现用户角色分配业务逻辑 @@ -234,6 +249,7 @@ ### 🏢 组织架构模块 (Organization Module) - P1优先级 - [ ] 24.0 GET /organizations - 组织列表接口 + - [ ] Before 整理输入此接口的逻辑,等待用户确认后进行 - [ ] 24.1 创建organization.schema.ts - 定义组织Schema - [ ] 24.2 创建organization.response.ts - 定义组织响应格式 - [ ] 24.3 创建organization.service.ts - 实现组织业务逻辑 @@ -241,6 +257,7 @@ - [ ] 24.5 创建organization.test.md - 编写组织测试用例文档 - [ ] 25.0 POST /organizations - 创建组织接口 + - [ ] Before 整理输入此接口的逻辑,等待用户确认后进行 - [ ] 25.1 扩展organization.schema.ts - 定义创建组织Schema - [ ] 25.2 扩展organization.response.ts - 定义创建组织响应格式 - [ ] 25.3 扩展organization.service.ts - 实现创建组织业务逻辑 @@ -248,6 +265,7 @@ - [ ] 25.5 扩展organization.test.md - 编写创建组织测试用例文档 - [ ] 26.0 PUT /organizations/{id} - 更新组织接口 + - [ ] Before 整理输入此接口的逻辑,等待用户确认后进行 - [ ] 26.1 扩展organization.schema.ts - 定义更新组织Schema - [ ] 26.2 扩展organization.response.ts - 定义更新组织响应格式 - [ ] 26.3 扩展organization.service.ts - 实现更新组织业务逻辑 @@ -255,6 +273,7 @@ - [ ] 26.5 扩展organization.test.md - 编写更新组织测试用例文档 - [ ] 27.0 DELETE /organizations/{id} - 删除组织接口 + - [ ] Before 整理输入此接口的逻辑,等待用户确认后进行 - [ ] 27.1 扩展organization.schema.ts - 定义删除组织Schema - [ ] 27.2 扩展organization.response.ts - 定义删除组织响应格式 - [ ] 27.3 扩展organization.service.ts - 实现删除组织业务逻辑 @@ -262,6 +281,7 @@ - [ ] 27.5 扩展organization.test.md - 编写删除组织测试用例文档 - [ ] 28.0 POST /users/{id}/organizations - 用户组织关系接口 + - [ ] Before 整理输入此接口的逻辑,等待用户确认后进行 - [ ] 28.1 扩展user.schema.ts - 定义用户组织关系Schema - [ ] 28.2 扩展user.response.ts - 定义用户组织关系响应格式 - [ ] 28.3 扩展user.service.ts - 实现用户组织关系业务逻辑 @@ -271,6 +291,7 @@ ### 🗂️ 系统基础模块 (System Module) - P1优先级 - [ ] 29.0 字典类型管理 - CRUD /dict-types + - [ ] Before 整理输入此接口的逻辑,等待用户确认后进行 - [ ] 29.1 创建dict.schema.ts - 定义字典类型Schema - [ ] 29.2 创建dict.response.ts - 定义字典类型响应格式 - [ ] 29.3 创建dict.service.ts - 实现字典类型业务逻辑 @@ -278,6 +299,7 @@ - [ ] 29.5 创建dict.test.md - 编写字典类型测试用例文档 - [ ] 30.0 字典项管理 - CRUD /dict-items + - [ ] Before 整理输入此接口的逻辑,等待用户确认后进行 - [ ] 30.1 扩展dict.schema.ts - 定义字典项Schema - [ ] 30.2 扩展dict.response.ts - 定义字典项响应格式 - [ ] 30.3 扩展dict.service.ts - 实现字典项业务逻辑 @@ -285,6 +307,7 @@ - [ ] 30.5 扩展dict.test.md - 编写字典项测试用例文档 - [ ] 31.0 标签管理 - CRUD /tags + - [ ] Before 整理输入此接口的逻辑,等待用户确认后进行 - [ ] 31.1 创建tag.schema.ts - 定义标签Schema - [ ] 31.2 创建tag.response.ts - 定义标签响应格式 - [ ] 31.3 创建tag.service.ts - 实现标签业务逻辑 @@ -292,6 +315,7 @@ - [ ] 31.5 创建tag.test.md - 编写标签测试用例文档 - [ ] 32.0 操作日志 - GET /logs/operations + - [ ] Before 整理输入此接口的逻辑,等待用户确认后进行 - [ ] 32.1 创建log.schema.ts - 定义操作日志Schema - [ ] 32.2 创建log.response.ts - 定义操作日志响应格式 - [ ] 32.3 创建log.service.ts - 实现操作日志业务逻辑 @@ -299,6 +323,7 @@ - [ ] 32.5 创建log.test.md - 编写操作日志测试用例文档 - [ ] 33.0 登录日志 - GET /logs/logins + - [ ] Before 整理输入此接口的逻辑,等待用户确认后进行 - [ ] 33.1 扩展log.schema.ts - 定义登录日志Schema - [ ] 33.2 扩展log.response.ts - 定义登录日志响应格式 - [ ] 33.3 扩展log.service.ts - 实现登录日志业务逻辑 @@ -308,6 +333,7 @@ ### 🔧 基础设施完善 - [ ] 34.0 JWT认证中间件 + - [ ] Before 整理输入此接口的逻辑,等待用户确认后进行 - [ ] 34.1 创建JWT认证插件 - [ ] 34.2 实现Token黑名单管理 - [ ] 34.3 实现RefreshToken机制 @@ -315,6 +341,7 @@ - [ ] 34.5 编写认证中间件测试 - [ ] 35.0 路由模块集成 + - [ ] Before 整理输入此接口的逻辑,等待用户确认后进行 - [ ] 35.1 更新src/modules/index.ts - 集成所有模块 - [ ] 35.2 更新src/app.ts - 注册所有路由 - [ ] 35.3 更新Swagger标签定义