diff --git a/star-tune/cursor.md b/star-tune/cursor.md new file mode 100644 index 0000000..4b84869 --- /dev/null +++ b/star-tune/cursor.md @@ -0,0 +1,147 @@ +# 本项目使用cursou说明 + +## 服务注入 + +> 大部分服务已经全局注入,直接在service中使用即可 + +- 日志服务:private readonly logger: CustomLogger, +- redis服务:private readonly redis: RedisService, +- 配置服务:private readonly config: ConfigService, +- 工具服务:private readonly utils: UtilsService, +- 数据库服务:private readonly database: DatabaseService, + +以上服务均已在appModule全局注入,不需要再向module中注入 + +## 路径别名 + +@代指src目录,请注意,引入任何文件采用路径别名,除开当前目录下的文件,不允许使用../获取上级目录 + +## 数据库 + +1. 数据库实体在@/drizzle/schema,通过这样导入: +```ts +import { + user, + userPassword, + userProfile, + userSignatureHistory, +} from '@/drizzle/schema'; +``` + +**注意**操作数据严格按照数据库实体的定义 + +2. 数据库使用drizzle和mysql2,下面是使用示例 + +```ts +import { user } from '@/drizzle/schema'; +// dto为参数 +const userExists = await this.database.db + .select({ + userId: user.userId, + }) + .from(user) + .where( + or( + eq(user.email, dto.email), + eq(user.username, dto.username), + ), + ) + .execute(); +if (userExists.length > 0) { + throw new BadRequestException('用户已存在'); +} +``` + +## 注释 + +**非常重要** !!!!!!!!!!! +1. 编写代码已经要有非常详细的注释,最好每行都有注释 + +参考 +```ts + // 检查邮箱是否可用 + async checkEmail(dto: CheckEmailDto) { + const exists = await this.database.db + .select() + .from(user) + .where(and(eq(user.email, dto.email), eq(user.isDeleted, 0))) + .execute(); + + return { available: exists.length === 0 }; + } + + // 发送验证码 + async sendEmailCode(dto: SendEmailCodeDto) { + // 邮箱验证码key + const codeKey = `${EmailCodeType[dto.type]}:${dto.email}`; + // 判断Key是否存在 + const keyExists = await this.redis.exists(codeKey); + // 验证码过期时间 + const codeEX = this.config.get('email.codeEX') || 300; + // 验证码冷却时间 + const codeEP = this.config.get('email.codeEP') || 60; + // 判断是否存在验证码 + if (keyExists) { + // 获取Key的过期时间 + const ttl = await this.redis.ttl(codeKey); + if (ttl > codeEX - codeEP) { + // 再等等吧 + throw new BadRequestException(`请等待${ttl}秒后再试`); + } else { + // 续杯 + await this.redis.expire(codeKey, codeEX); + // 获取验证码 + const code = await this.redis.get(codeKey); + // todo重新发送验证码 + this.logger.debug(`重新发送验证码: ${code}`); + await this.utils.sendEmail({ + to: dto.email, + subject: '账户注册验证码', + text: `您的验证码是:${code},5分钟内有效`, + }); + } + } else { + // 生成验证码 + const code = Math.random().toString().slice(2, 8); + // todo发送验证码 + this.logger.debug(`发送验证码: ${code}`); + await this.utils.sendEmail({ + to: dto.email, + subject: '账户注册验证码', + text: `您的验证码是:${code},5分钟内有效`, + }); + // 存储验证码 + await this.redis.set(codeKey, code, codeEX); + } + return { message: '验证码已发送' }; + } +``` +2. 每个方法前都要携带写作者、写作时间、方法描述,写作者为`Nie` + +## 常量 + +常量这样导入 `import { EmailCodeType } from '@/type/enum';` + +常量用于一些系统的关键字 + + +## redis + +使用参考 +```ts +// 加锁 + const lock = await this.redis.lock( + `${EmailCodeType.REGISTER}:${dto.email}`, + ); +``` + +方法一般在RedisService中定义 + +## 文件命名 + +采用小驼峰加`.service`、`.dto`类似的方式命名,禁止使用中划线 + +错误示例 +`add-user.dto.ts` +正确示例 +`addUser.dto.ts \ No newline at end of file diff --git a/star-tune/src/decorators/user.decorator.ts b/star-tune/src/decorators/user.decorator.ts index 2fb5754..143e8e8 100644 --- a/star-tune/src/decorators/user.decorator.ts +++ b/star-tune/src/decorators/user.decorator.ts @@ -1,10 +1,10 @@ import { createParamDecorator, ExecutionContext } from '@nestjs/common'; +import { UserGuard } from '@/type/userGuard'; export const User = createParamDecorator( - (data: string, ctx: ExecutionContext) => { - const request = ctx.switchToHttp().getRequest(); + (data: keyof UserGuard | undefined, ctx: ExecutionContext) => { + const request = ctx.switchToHttp().getRequest<{ user: UserGuard }>(); const user = request.user; - return data ? user?.[data] : user; }, ); diff --git a/star-tune/src/guards/auth.guard.ts b/star-tune/src/guards/auth.guard.ts index 1ff8794..c6a3784 100644 --- a/star-tune/src/guards/auth.guard.ts +++ b/star-tune/src/guards/auth.guard.ts @@ -6,7 +6,7 @@ import { } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { UtilsService } from '@/common/utils/utils.service'; -import { UserGuard } from '@/type/userGuard'; +import { UserGuard, UserGuardType } from '@/type/userGuard'; import { FastifyRequest } from 'fastify'; // 扩展 FastifyRequest 类型以包含 user 属性 @@ -26,15 +26,14 @@ export class AuthGuard implements CanActivate { canActivate(context: ExecutionContext): boolean { const request = context.switchToHttp().getRequest(); const path = request.url; - console.log(path); + // console.log(path); // 检查白名单路由 const whitelist = this.config.get('jwt.whitelist') || []; - console.log(whitelist); + // console.log(whitelist); if (whitelist.some((route) => path.startsWith(route))) { return true; } - console.log('?', request.headers); // 获取并验证 token const authHeader = request.headers.authorization; @@ -48,7 +47,20 @@ export class AuthGuard implements CanActivate { try { userGuard = this.utils.verifyToken(token); } catch { - throw new UnauthorizedException('无效的访问令牌'); + throw new UnauthorizedException('无效的访问令牌1'); + } + if (this.config.get('env') === 'production') { + if (userGuard.type === UserGuardType.REFRESH) { + if (path === '/api/user/refreshToken') { + return true; + } else { + throw new UnauthorizedException('无效的访问令牌2'); + } + } else if (userGuard.type === UserGuardType.ACCESS) { + if (path === '/api/user/refreshToken') { + throw new UnauthorizedException('无效的访问令牌3'); + } + } } // 将用户信息附加到请求对象 diff --git a/star-tune/src/main.ts b/star-tune/src/main.ts index 1bd0116..c4f31f7 100644 --- a/star-tune/src/main.ts +++ b/star-tune/src/main.ts @@ -70,6 +70,7 @@ async function bootstrap() { SwaggerModule.setup('api/docs', app, document); logger.debug( `Swagger 配置完成: http://${host === '0.0.0.0' ? '127.0.0.1' : host}:${port}/api/docs`, + 'Swagger', ); // 测试不同级别的日志输出 diff --git a/star-tune/src/module/user/dto/change-password.dto.ts b/star-tune/src/module/user/dto/change-password.dto.ts new file mode 100644 index 0000000..aeb6a7f --- /dev/null +++ b/star-tune/src/module/user/dto/change-password.dto.ts @@ -0,0 +1,14 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsString, MinLength } from 'class-validator'; + +export class ChangePasswordDto { + @ApiProperty({ description: '旧密码' }) + @IsString() + @MinLength(6) + oldPassword: string; + + @ApiProperty({ description: '新密码' }) + @IsString() + @MinLength(6) + newPassword: string; +} diff --git a/star-tune/src/module/user/dto/reset-password.dto.ts b/star-tune/src/module/user/dto/reset-password.dto.ts new file mode 100644 index 0000000..0204370 --- /dev/null +++ b/star-tune/src/module/user/dto/reset-password.dto.ts @@ -0,0 +1,17 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsString, MinLength, IsEmail } from 'class-validator'; + +export class ResetPasswordDto { + @ApiProperty({ description: '邮箱' }) + @IsEmail() + email: string; + + @ApiProperty({ description: '验证码' }) + @IsString() + code: string; + + @ApiProperty({ description: '新密码' }) + @IsString() + @MinLength(6) + newPassword: string; +} \ No newline at end of file diff --git a/star-tune/src/module/user/user.controller.ts b/star-tune/src/module/user/user.controller.ts index 64cc1e1..86e737e 100644 --- a/star-tune/src/module/user/user.controller.ts +++ b/star-tune/src/module/user/user.controller.ts @@ -18,6 +18,8 @@ import { EmailLoginDto } from './dto/email-login.dto'; import { UpdateUserDto } from './dto/update-user.dto'; import { UpdateProfileDto } from './dto/update-profile.dto'; import { UpdateSignatureDto } from './dto/update-signature.dto'; +import { ChangePasswordDto } from './dto/change-password.dto'; +import { ResetPasswordDto } from './dto/reset-password.dto'; import { AuthGuard } from '../../guards/auth.guard'; import { User } from '../../decorators/user.decorator'; import { @@ -26,6 +28,7 @@ import { ApiBearerAuth, ApiResponse, } from '@nestjs/swagger'; +import { UserGuard } from '@/type/userGuard'; @ApiTags('用户管理') @ApiBearerAuth('access-token') @@ -62,7 +65,7 @@ export class UserController { @ApiOperation({ summary: '检查邮箱是否可用' }) @ApiResponse({ status: 200, description: '邮箱检查结果' }) - @Post('check-email') + @Post('checkEmail') checkEmail(@Body() dto: CheckEmailDto) { return this.userService.checkEmail(dto); } @@ -73,7 +76,7 @@ export class UserController { status: 400, description: '发送失败,可能是因为发送过于频繁', }) - @Post('send-code') + @Post('sendCode') sendEmailCode(@Body() dto: SendEmailCodeDto) { return this.userService.sendEmailCode(dto); } @@ -101,13 +104,11 @@ export class UserController { // return this.userService.emailLogin(dto); // } - // @Post('refresh-token') - // refreshToken(@Headers('refresh-token') refreshToken: string) { - // if (!refreshToken) { - // throw new UnauthorizedException('缺少刷新令牌'); - // } - // return this.userService.refreshToken(refreshToken); - // } + @UseGuards(AuthGuard) + @Post('refreshToken') + refreshToken(@User() userGuard: UserGuard) { + return this.userService.refreshToken(userGuard); + } // @Post('logout') // @UseGuards(AuthGuard) @@ -156,4 +157,25 @@ export class UserController { // deleteUser(@User('userId') userId: number) { // return this.userService.deleteUser(userId); // } + + @ApiOperation({ summary: '修改密码' }) + @ApiResponse({ status: 200, description: '密码修改成功' }) + @ApiResponse({ status: 400, description: '旧密码错误' }) + @ApiBearerAuth('access-token') + @Patch('password') + @UseGuards(AuthGuard) + changePassword( + @User('userId') userId: number, + @Body() dto: ChangePasswordDto, + ) { + return this.userService.changePassword(userId, dto); + } + + @ApiOperation({ summary: '重置密码(忘记密码)' }) + @ApiResponse({ status: 200, description: '密码重置成功' }) + @ApiResponse({ status: 400, description: '验证码错误或已过期' }) + @Post('resetPassword') + resetPassword(@Body() dto: ResetPasswordDto) { + return this.userService.resetPassword(dto); + } } diff --git a/star-tune/src/module/user/user.service.ts b/star-tune/src/module/user/user.service.ts index ac99cbe..d881934 100644 --- a/star-tune/src/module/user/user.service.ts +++ b/star-tune/src/module/user/user.service.ts @@ -3,10 +3,15 @@ import { Injectable, UnauthorizedException, } from '@nestjs/common'; +import * as dayjs from 'dayjs'; +import { eq, and, or } from 'drizzle-orm'; import { RedisService } from '@/service/redis/redis.service'; import { ConfigService } from '@nestjs/config'; import { UtilsService } from '@/common/utils/utils.service'; -import { eq, and, or } from 'drizzle-orm'; +import { DatabaseService } from '@/service/database/database.service'; +import { CustomLogger } from '@/common/logger/logger.service'; + + import { CheckEmailDto } from './dto/check-email.dto'; import { SendEmailCodeDto } from './dto/send-email-code.dto'; import { RegisterDto } from './dto/register.dto'; @@ -15,20 +20,16 @@ import { EmailLoginDto } from './dto/email-login.dto'; import { UpdateUserDto } from './dto/update-user.dto'; import { UpdateProfileDto } from './dto/update-profile.dto'; import { UpdateSignatureDto } from './dto/update-signature.dto'; -import * as bcrypt from 'bcrypt'; -import * as jwt from 'jsonwebtoken'; -import { DatabaseService } from '@/service/database/database.service'; +import { ChangePasswordDto } from './dto/change-password.dto'; +import { ResetPasswordDto } from './dto/reset-password.dto'; import { user, userPassword, userProfile, userSignatureHistory, } from '@/drizzle/schema'; -import { CustomLogger } from '@/common/logger/logger.service'; import { EmailCodeType } from '@/type/enum'; -import * as dayjs from 'dayjs'; import { UserGuard } from '@/type/userGuard'; -import { SQL } from 'drizzle-orm'; @Injectable() export class UserService { @@ -259,29 +260,17 @@ export class UserService { return { accessToken, refreshToken }; } - // // 刷新令牌 - // async refreshToken(refreshToken: string) { - // try { - // const decoded = jwt.verify( - // refreshToken, - // this.config.get('JWT_SECRET'), - // ) as { userId: number }; - // const storedToken = await this.redis.get( - // `refresh_token:${decoded.userId}`, - // ); - - // if (!storedToken || storedToken !== refreshToken) { - // throw new UnauthorizedException('无效的刷新令牌'); - // } - - // await this.redis.del(`refresh_token:${decoded.userId}`); - // await this.redis.del(`access_token:${decoded.userId}`); - - // return this.generateTokens(decoded.userId); - // } catch (error) { - // throw new UnauthorizedException('无效的刷新令牌'); - // } - // } + // 刷新令牌 + refreshToken(userGuard: UserGuard) { + const { userId, username, email, time } = userGuard; + const accessToken = this.utils.generateAccessToken({ + userId, + username, + email, + time, + }); + return { accessToken }; + } // // 退出登录 // async logout(userId: number) { @@ -367,4 +356,92 @@ export class UserService { // await this.logout(userId); // return { message: '删除成功' }; // } + + async changePassword(userId: number, dto: ChangePasswordDto) { + const result = await this.database.db + .select({ + userId: user.userId, + }) + .from(user) + .where(eq(user.userId, userId)) + .execute(); + + if (result.length === 0) { + throw new BadRequestException('用户不存在'); + } + // 比对密码 + const userData = result[0]; + const passwordData = await this.database.db + .select({ + passwordHash: userPassword.passwordHash, + }) + .from(userPassword) + .where(eq(userPassword.userId, userData.userId)) + .execute(); + // 验证旧密码 + if ( + !(await this.utils.verifyPassword( + dto.oldPassword, + passwordData[0].passwordHash, + )) + ) { + throw new UnauthorizedException('旧密码错误'); + } + + // 加密新密码 + const hashedPassword = await this.utils.hashPassword(dto.newPassword); + + // 更新密码 + await this.database.db + .update(userPassword) + .set({ + passwordHash: hashedPassword, + }) + .where(eq(userPassword.userId, userId)); + + return { message: '密码修改成功' }; + } + + async resetPassword(dto: ResetPasswordDto) { + // 邮箱验证码key + const codeKey = `${EmailCodeType.resetpassword}:${dto.email}`; + this.logger.debug( + `邮箱验证码key: ${EmailCodeType.resetpassword}:${dto.email} ${codeKey}`, + ); + // 获取验证码 + const code = await this.redis.get(codeKey); + // 验证码错误或已过期 + if (!code || code !== dto.code) { + throw new BadRequestException('验证码错误或已过期'); + } + + const result = await this.database.db + .select({ + userId: user.userId, + }) + .from(user) + .where(eq(user.email, dto.email)) + .execute(); + + if (result.length === 0) { + throw new BadRequestException('用户不存在'); + } + + // 加密新密码 + const hashedPassword = await this.utils.hashPassword(dto.newPassword); + + // 更新密码 + // 更新密码 + await this.database.db + .update(userPassword) + .set({ + passwordHash: hashedPassword, + }) + .where(eq(userPassword.userId, result[0].userId)); + + // 删除验证码 + await this.redis.del(codeKey); + + return { message: '密码重置成功' }; + } } diff --git a/star-tune/src/type/enum.ts b/star-tune/src/type/enum.ts index 56f2e44..f2dc7c3 100644 --- a/star-tune/src/type/enum.ts +++ b/star-tune/src/type/enum.ts @@ -4,6 +4,8 @@ export enum EmailCodeType { LOGIN = 'login', register = 'REGISTER', login = 'LOGIN', + resetpassword = 'RESETPASSWORD', + RESETPASSWORD = 'resetpassword', } export const allColors = { @@ -57,4 +59,4 @@ export const allColors = { strikethrough: '\x1b[9m', // 删除线 }, reset: '\x1b[0m', -}; \ No newline at end of file +};