forked from datawhale/whale-town-end
feat:实现用户认证系统
- 添加用户登录、注册、密码重置功能 - 支持用户名/邮箱/手机号多种登录方式 - 集成GitHub OAuth第三方登录 - 实现bcrypt密码加密存储 - 添加基于角色的权限控制 - 包含完整的数据验证和错误处理
This commit is contained in:
@@ -30,6 +30,8 @@
|
|||||||
"@nestjs/schedule": "^4.1.2",
|
"@nestjs/schedule": "^4.1.2",
|
||||||
"@nestjs/typeorm": "^11.0.0",
|
"@nestjs/typeorm": "^11.0.0",
|
||||||
"@nestjs/websockets": "^10.4.20",
|
"@nestjs/websockets": "^10.4.20",
|
||||||
|
"@types/bcrypt": "^6.0.0",
|
||||||
|
"bcrypt": "^6.0.0",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.14.3",
|
"class-validator": "^0.14.3",
|
||||||
"mysql2": "^3.16.0",
|
"mysql2": "^3.16.0",
|
||||||
@@ -45,8 +47,10 @@
|
|||||||
"@nestjs/testing": "^10.4.20",
|
"@nestjs/testing": "^10.4.20",
|
||||||
"@types/jest": "^29.5.14",
|
"@types/jest": "^29.5.14",
|
||||||
"@types/node": "^20.19.26",
|
"@types/node": "^20.19.26",
|
||||||
|
"@types/supertest": "^6.0.3",
|
||||||
"jest": "^29.7.0",
|
"jest": "^29.7.0",
|
||||||
"pino-pretty": "^13.1.3",
|
"pino-pretty": "^13.1.3",
|
||||||
|
"supertest": "^7.1.4",
|
||||||
"ts-jest": "^29.2.5",
|
"ts-jest": "^29.2.5",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.3"
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { AppController } from './app.controller';
|
|||||||
import { AppService } from './app.service';
|
import { AppService } from './app.service';
|
||||||
import { LoggerModule } from './core/utils/logger/logger.module';
|
import { LoggerModule } from './core/utils/logger/logger.module';
|
||||||
import { UsersModule } from './core/db/users/users.module';
|
import { UsersModule } from './core/db/users/users.module';
|
||||||
|
import { LoginCoreModule } from './core/login_core/login_core.module';
|
||||||
|
import { LoginModule } from './business/login/login.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -24,6 +26,8 @@ import { UsersModule } from './core/db/users/users.module';
|
|||||||
synchronize: false,
|
synchronize: false,
|
||||||
}),
|
}),
|
||||||
UsersModule,
|
UsersModule,
|
||||||
|
LoginCoreModule,
|
||||||
|
LoginModule,
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
providers: [AppService],
|
providers: [AppService],
|
||||||
|
|||||||
136
src/business/login/login.controller.ts
Normal file
136
src/business/login/login.controller.ts
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
/**
|
||||||
|
* 登录控制器
|
||||||
|
*
|
||||||
|
* 功能描述:
|
||||||
|
* - 处理登录相关的HTTP请求和响应
|
||||||
|
* - 提供RESTful API接口
|
||||||
|
* - 数据验证和格式化
|
||||||
|
*
|
||||||
|
* API端点:
|
||||||
|
* - POST /auth/login - 用户登录
|
||||||
|
* - POST /auth/register - 用户注册
|
||||||
|
* - POST /auth/github - GitHub OAuth登录
|
||||||
|
* - POST /auth/forgot-password - 发送密码重置验证码
|
||||||
|
* - POST /auth/reset-password - 重置密码
|
||||||
|
* - PUT /auth/change-password - 修改密码
|
||||||
|
*
|
||||||
|
* @author moyin
|
||||||
|
* @version 1.0.0
|
||||||
|
* @since 2025-12-17
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Controller, Post, Put, Body, HttpCode, HttpStatus, ValidationPipe, UsePipes, Logger } from '@nestjs/common';
|
||||||
|
import { LoginService, ApiResponse, LoginResponse } from './login.service';
|
||||||
|
import { LoginDto, RegisterDto, GitHubOAuthDto, ForgotPasswordDto, ResetPasswordDto, ChangePasswordDto } from './login.dto';
|
||||||
|
|
||||||
|
@Controller('auth')
|
||||||
|
export class LoginController {
|
||||||
|
private readonly logger = new Logger(LoginController.name);
|
||||||
|
|
||||||
|
constructor(private readonly loginService: LoginService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户登录
|
||||||
|
*
|
||||||
|
* @param loginDto 登录数据
|
||||||
|
* @returns 登录结果
|
||||||
|
*/
|
||||||
|
@Post('login')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@UsePipes(new ValidationPipe({ transform: true }))
|
||||||
|
async login(@Body() loginDto: LoginDto): Promise<ApiResponse<LoginResponse>> {
|
||||||
|
return await this.loginService.login({
|
||||||
|
identifier: loginDto.identifier,
|
||||||
|
password: loginDto.password
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户注册
|
||||||
|
*
|
||||||
|
* @param registerDto 注册数据
|
||||||
|
* @returns 注册结果
|
||||||
|
*/
|
||||||
|
@Post('register')
|
||||||
|
@HttpCode(HttpStatus.CREATED)
|
||||||
|
@UsePipes(new ValidationPipe({ transform: true }))
|
||||||
|
async register(@Body() registerDto: RegisterDto): Promise<ApiResponse<LoginResponse>> {
|
||||||
|
return await this.loginService.register({
|
||||||
|
username: registerDto.username,
|
||||||
|
password: registerDto.password,
|
||||||
|
nickname: registerDto.nickname,
|
||||||
|
email: registerDto.email,
|
||||||
|
phone: registerDto.phone
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GitHub OAuth登录
|
||||||
|
*
|
||||||
|
* @param githubDto GitHub OAuth数据
|
||||||
|
* @returns 登录结果
|
||||||
|
*/
|
||||||
|
@Post('github')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@UsePipes(new ValidationPipe({ transform: true }))
|
||||||
|
async githubOAuth(@Body() githubDto: GitHubOAuthDto): Promise<ApiResponse<LoginResponse>> {
|
||||||
|
return await this.loginService.githubOAuth({
|
||||||
|
github_id: githubDto.github_id,
|
||||||
|
username: githubDto.username,
|
||||||
|
nickname: githubDto.nickname,
|
||||||
|
email: githubDto.email,
|
||||||
|
avatar_url: githubDto.avatar_url
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送密码重置验证码
|
||||||
|
*
|
||||||
|
* @param forgotPasswordDto 忘记密码数据
|
||||||
|
* @returns 发送结果
|
||||||
|
*/
|
||||||
|
@Post('forgot-password')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@UsePipes(new ValidationPipe({ transform: true }))
|
||||||
|
async forgotPassword(@Body() forgotPasswordDto: ForgotPasswordDto): Promise<ApiResponse<{ verification_code?: string }>> {
|
||||||
|
return await this.loginService.sendPasswordResetCode(forgotPasswordDto.identifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重置密码
|
||||||
|
*
|
||||||
|
* @param resetPasswordDto 重置密码数据
|
||||||
|
* @returns 重置结果
|
||||||
|
*/
|
||||||
|
@Post('reset-password')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@UsePipes(new ValidationPipe({ transform: true }))
|
||||||
|
async resetPassword(@Body() resetPasswordDto: ResetPasswordDto): Promise<ApiResponse> {
|
||||||
|
return await this.loginService.resetPassword({
|
||||||
|
identifier: resetPasswordDto.identifier,
|
||||||
|
verificationCode: resetPasswordDto.verification_code,
|
||||||
|
newPassword: resetPasswordDto.new_password
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 修改密码
|
||||||
|
*
|
||||||
|
* @param changePasswordDto 修改密码数据
|
||||||
|
* @returns 修改结果
|
||||||
|
*/
|
||||||
|
@Put('change-password')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@UsePipes(new ValidationPipe({ transform: true }))
|
||||||
|
async changePassword(@Body() changePasswordDto: ChangePasswordDto): Promise<ApiResponse> {
|
||||||
|
// 实际应用中应从JWT令牌中获取用户ID
|
||||||
|
// 这里为了演示,使用请求体中的用户ID
|
||||||
|
const userId = BigInt(changePasswordDto.user_id);
|
||||||
|
|
||||||
|
return await this.loginService.changePassword(
|
||||||
|
userId,
|
||||||
|
changePasswordDto.old_password,
|
||||||
|
changePasswordDto.new_password
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
206
src/business/login/login.dto.ts
Normal file
206
src/business/login/login.dto.ts
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
/**
|
||||||
|
* 登录业务数据传输对象
|
||||||
|
*
|
||||||
|
* 功能描述:
|
||||||
|
* - 定义登录相关API的请求数据结构
|
||||||
|
* - 提供数据验证规则和错误提示
|
||||||
|
* - 确保API接口的数据格式一致性
|
||||||
|
*
|
||||||
|
* @author moyin
|
||||||
|
* @version 1.0.0
|
||||||
|
* @since 2025-12-17
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
IsString,
|
||||||
|
IsEmail,
|
||||||
|
IsPhoneNumber,
|
||||||
|
IsNotEmpty,
|
||||||
|
Length,
|
||||||
|
IsOptional,
|
||||||
|
Matches,
|
||||||
|
IsNumberString
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 登录请求DTO
|
||||||
|
*/
|
||||||
|
export class LoginDto {
|
||||||
|
/**
|
||||||
|
* 登录标识符
|
||||||
|
* 支持用户名、邮箱或手机号登录
|
||||||
|
*/
|
||||||
|
@IsString({ message: '登录标识符必须是字符串' })
|
||||||
|
@IsNotEmpty({ message: '登录标识符不能为空' })
|
||||||
|
@Length(1, 100, { message: '登录标识符长度需在1-100字符之间' })
|
||||||
|
identifier: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 密码
|
||||||
|
*/
|
||||||
|
@IsString({ message: '密码必须是字符串' })
|
||||||
|
@IsNotEmpty({ message: '密码不能为空' })
|
||||||
|
@Length(1, 128, { message: '密码长度需在1-128字符之间' })
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注册请求DTO
|
||||||
|
*/
|
||||||
|
export class RegisterDto {
|
||||||
|
/**
|
||||||
|
* 用户名
|
||||||
|
*/
|
||||||
|
@IsString({ message: '用户名必须是字符串' })
|
||||||
|
@IsNotEmpty({ message: '用户名不能为空' })
|
||||||
|
@Length(1, 50, { message: '用户名长度需在1-50字符之间' })
|
||||||
|
@Matches(/^[a-zA-Z0-9_]+$/, { message: '用户名只能包含字母、数字和下划线' })
|
||||||
|
username: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 密码
|
||||||
|
*/
|
||||||
|
@IsString({ message: '密码必须是字符串' })
|
||||||
|
@IsNotEmpty({ message: '密码不能为空' })
|
||||||
|
@Length(8, 128, { message: '密码长度需在8-128字符之间' })
|
||||||
|
@Matches(/^(?=.*[a-zA-Z])(?=.*\d)/, { message: '密码必须包含字母和数字' })
|
||||||
|
password: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 昵称
|
||||||
|
*/
|
||||||
|
@IsString({ message: '昵称必须是字符串' })
|
||||||
|
@IsNotEmpty({ message: '昵称不能为空' })
|
||||||
|
@Length(1, 50, { message: '昵称长度需在1-50字符之间' })
|
||||||
|
nickname: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 邮箱(可选)
|
||||||
|
*/
|
||||||
|
@IsOptional()
|
||||||
|
@IsEmail({}, { message: '邮箱格式不正确' })
|
||||||
|
email?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 手机号(可选)
|
||||||
|
*/
|
||||||
|
@IsOptional()
|
||||||
|
@IsPhoneNumber(null, { message: '手机号格式不正确' })
|
||||||
|
phone?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GitHub OAuth登录请求DTO
|
||||||
|
*/
|
||||||
|
export class GitHubOAuthDto {
|
||||||
|
/**
|
||||||
|
* GitHub用户ID
|
||||||
|
*/
|
||||||
|
@IsString({ message: 'GitHub ID必须是字符串' })
|
||||||
|
@IsNotEmpty({ message: 'GitHub ID不能为空' })
|
||||||
|
@Length(1, 100, { message: 'GitHub ID长度需在1-100字符之间' })
|
||||||
|
github_id: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户名
|
||||||
|
*/
|
||||||
|
@IsString({ message: '用户名必须是字符串' })
|
||||||
|
@IsNotEmpty({ message: '用户名不能为空' })
|
||||||
|
@Length(1, 50, { message: '用户名长度需在1-50字符之间' })
|
||||||
|
username: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 昵称
|
||||||
|
*/
|
||||||
|
@IsString({ message: '昵称必须是字符串' })
|
||||||
|
@IsNotEmpty({ message: '昵称不能为空' })
|
||||||
|
@Length(1, 50, { message: '昵称长度需在1-50字符之间' })
|
||||||
|
nickname: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 邮箱(可选)
|
||||||
|
*/
|
||||||
|
@IsOptional()
|
||||||
|
@IsEmail({}, { message: '邮箱格式不正确' })
|
||||||
|
email?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 头像URL(可选)
|
||||||
|
*/
|
||||||
|
@IsOptional()
|
||||||
|
@IsString({ message: '头像URL必须是字符串' })
|
||||||
|
avatar_url?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 忘记密码请求DTO
|
||||||
|
*/
|
||||||
|
export class ForgotPasswordDto {
|
||||||
|
/**
|
||||||
|
* 邮箱或手机号
|
||||||
|
*/
|
||||||
|
@IsString({ message: '标识符必须是字符串' })
|
||||||
|
@IsNotEmpty({ message: '邮箱或手机号不能为空' })
|
||||||
|
@Length(1, 100, { message: '标识符长度需在1-100字符之间' })
|
||||||
|
identifier: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重置密码请求DTO
|
||||||
|
*/
|
||||||
|
export class ResetPasswordDto {
|
||||||
|
/**
|
||||||
|
* 邮箱或手机号
|
||||||
|
*/
|
||||||
|
@IsString({ message: '标识符必须是字符串' })
|
||||||
|
@IsNotEmpty({ message: '邮箱或手机号不能为空' })
|
||||||
|
@Length(1, 100, { message: '标识符长度需在1-100字符之间' })
|
||||||
|
identifier: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证码
|
||||||
|
*/
|
||||||
|
@IsString({ message: '验证码必须是字符串' })
|
||||||
|
@IsNotEmpty({ message: '验证码不能为空' })
|
||||||
|
@Matches(/^\d{6}$/, { message: '验证码必须是6位数字' })
|
||||||
|
verification_code: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 新密码
|
||||||
|
*/
|
||||||
|
@IsString({ message: '新密码必须是字符串' })
|
||||||
|
@IsNotEmpty({ message: '新密码不能为空' })
|
||||||
|
@Length(8, 128, { message: '新密码长度需在8-128字符之间' })
|
||||||
|
@Matches(/^(?=.*[a-zA-Z])(?=.*\d)/, { message: '新密码必须包含字母和数字' })
|
||||||
|
new_password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 修改密码请求DTO
|
||||||
|
*/
|
||||||
|
export class ChangePasswordDto {
|
||||||
|
/**
|
||||||
|
* 用户ID
|
||||||
|
* 实际应用中应从JWT令牌中获取,这里为了演示放在请求体中
|
||||||
|
*/
|
||||||
|
@IsNumberString({}, { message: '用户ID必须是数字字符串' })
|
||||||
|
@IsNotEmpty({ message: '用户ID不能为空' })
|
||||||
|
user_id: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 旧密码
|
||||||
|
*/
|
||||||
|
@IsString({ message: '旧密码必须是字符串' })
|
||||||
|
@IsNotEmpty({ message: '旧密码不能为空' })
|
||||||
|
@Length(1, 128, { message: '旧密码长度需在1-128字符之间' })
|
||||||
|
old_password: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 新密码
|
||||||
|
*/
|
||||||
|
@IsString({ message: '新密码必须是字符串' })
|
||||||
|
@IsNotEmpty({ message: '新密码不能为空' })
|
||||||
|
@Length(8, 128, { message: '新密码长度需在8-128字符之间' })
|
||||||
|
@Matches(/^(?=.*[a-zA-Z])(?=.*\d)/, { message: '新密码必须包含字母和数字' })
|
||||||
|
new_password: string;
|
||||||
|
}
|
||||||
25
src/business/login/login.module.ts
Normal file
25
src/business/login/login.module.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
/**
|
||||||
|
* 登录业务模块
|
||||||
|
*
|
||||||
|
* 功能描述:
|
||||||
|
* - 整合登录相关的控制器、服务和依赖
|
||||||
|
* - 提供完整的登录业务功能模块
|
||||||
|
* - 可被其他模块导入使用
|
||||||
|
*
|
||||||
|
* @author moyin
|
||||||
|
* @version 1.0.0
|
||||||
|
* @since 2025-12-17
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { LoginController } from './login.controller';
|
||||||
|
import { LoginService } from './login.service';
|
||||||
|
import { LoginCoreModule } from '../../core/login_core/login_core.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [LoginCoreModule],
|
||||||
|
controllers: [LoginController],
|
||||||
|
providers: [LoginService],
|
||||||
|
exports: [LoginService],
|
||||||
|
})
|
||||||
|
export class LoginModule {}
|
||||||
174
src/business/login/login.service.spec.ts
Normal file
174
src/business/login/login.service.spec.ts
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
/**
|
||||||
|
* 登录业务服务测试
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { LoginService } from './login.service';
|
||||||
|
import { LoginCoreService } from '../../core/login_core/login_core.service';
|
||||||
|
|
||||||
|
describe('LoginService', () => {
|
||||||
|
let service: LoginService;
|
||||||
|
let loginCoreService: jest.Mocked<LoginCoreService>;
|
||||||
|
|
||||||
|
const mockUser = {
|
||||||
|
id: BigInt(1),
|
||||||
|
username: 'testuser',
|
||||||
|
email: 'test@example.com',
|
||||||
|
phone: '+8613800138000',
|
||||||
|
password_hash: '$2b$12$hashedpassword',
|
||||||
|
nickname: '测试用户',
|
||||||
|
github_id: null as string | null,
|
||||||
|
avatar_url: null as string | null,
|
||||||
|
role: 1,
|
||||||
|
created_at: new Date(),
|
||||||
|
updated_at: new Date()
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const mockLoginCoreService = {
|
||||||
|
login: jest.fn(),
|
||||||
|
register: jest.fn(),
|
||||||
|
githubOAuth: jest.fn(),
|
||||||
|
sendPasswordResetCode: jest.fn(),
|
||||||
|
resetPassword: jest.fn(),
|
||||||
|
changePassword: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
LoginService,
|
||||||
|
{
|
||||||
|
provide: LoginCoreService,
|
||||||
|
useValue: mockLoginCoreService,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
service = module.get<LoginService>(LoginService);
|
||||||
|
loginCoreService = module.get(LoginCoreService);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be defined', () => {
|
||||||
|
expect(service).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('login', () => {
|
||||||
|
it('should return success response for valid login', async () => {
|
||||||
|
loginCoreService.login.mockResolvedValue({
|
||||||
|
user: mockUser,
|
||||||
|
isNewUser: false
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.login({
|
||||||
|
identifier: 'testuser',
|
||||||
|
password: 'password123'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.data?.user.username).toBe('testuser');
|
||||||
|
expect(result.data?.access_token).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return error response for failed login', async () => {
|
||||||
|
loginCoreService.login.mockRejectedValue(new Error('登录失败'));
|
||||||
|
|
||||||
|
const result = await service.login({
|
||||||
|
identifier: 'testuser',
|
||||||
|
password: 'wrongpassword'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.message).toBe('登录失败');
|
||||||
|
expect(result.error_code).toBe('LOGIN_FAILED');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('register', () => {
|
||||||
|
it('should return success response for valid registration', async () => {
|
||||||
|
loginCoreService.register.mockResolvedValue({
|
||||||
|
user: mockUser,
|
||||||
|
isNewUser: true
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.register({
|
||||||
|
username: 'testuser',
|
||||||
|
password: 'password123',
|
||||||
|
nickname: '测试用户'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.data?.user.username).toBe('testuser');
|
||||||
|
expect(result.data?.is_new_user).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return error response for failed registration', async () => {
|
||||||
|
loginCoreService.register.mockRejectedValue(new Error('用户名已存在'));
|
||||||
|
|
||||||
|
const result = await service.register({
|
||||||
|
username: 'existinguser',
|
||||||
|
password: 'password123',
|
||||||
|
nickname: '测试用户'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.message).toBe('用户名已存在');
|
||||||
|
expect(result.error_code).toBe('REGISTER_FAILED');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('githubOAuth', () => {
|
||||||
|
it('should return success response for GitHub OAuth', async () => {
|
||||||
|
loginCoreService.githubOAuth.mockResolvedValue({
|
||||||
|
user: mockUser,
|
||||||
|
isNewUser: true
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.githubOAuth({
|
||||||
|
github_id: 'github123',
|
||||||
|
username: 'githubuser',
|
||||||
|
nickname: 'GitHub用户'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.data?.user.username).toBe('testuser');
|
||||||
|
expect(result.data?.is_new_user).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('sendPasswordResetCode', () => {
|
||||||
|
it('should return success response with verification code', async () => {
|
||||||
|
loginCoreService.sendPasswordResetCode.mockResolvedValue('123456');
|
||||||
|
|
||||||
|
const result = await service.sendPasswordResetCode('test@example.com');
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.data?.verification_code).toBe('123456');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resetPassword', () => {
|
||||||
|
it('should return success response for password reset', async () => {
|
||||||
|
loginCoreService.resetPassword.mockResolvedValue(mockUser);
|
||||||
|
|
||||||
|
const result = await service.resetPassword({
|
||||||
|
identifier: 'test@example.com',
|
||||||
|
verificationCode: '123456',
|
||||||
|
newPassword: 'newpassword123'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.message).toBe('密码重置成功');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('changePassword', () => {
|
||||||
|
it('should return success response for password change', async () => {
|
||||||
|
loginCoreService.changePassword.mockResolvedValue(mockUser);
|
||||||
|
|
||||||
|
const result = await service.changePassword(BigInt(1), 'oldpassword', 'newpassword123');
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.message).toBe('密码修改成功');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
328
src/business/login/login.service.ts
Normal file
328
src/business/login/login.service.ts
Normal file
@@ -0,0 +1,328 @@
|
|||||||
|
/**
|
||||||
|
* 登录业务服务
|
||||||
|
*
|
||||||
|
* 功能描述:
|
||||||
|
* - 处理登录相关的业务逻辑和流程控制
|
||||||
|
* - 整合核心服务,提供完整的业务功能
|
||||||
|
* - 处理业务规则、数据格式化和错误处理
|
||||||
|
*
|
||||||
|
* 职责分离:
|
||||||
|
* - 专注于业务流程和规则实现
|
||||||
|
* - 调用核心服务完成具体功能
|
||||||
|
* - 为控制器层提供业务接口
|
||||||
|
*
|
||||||
|
* @author moyin
|
||||||
|
* @version 1.0.0
|
||||||
|
* @since 2025-12-17
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { LoginCoreService, LoginRequest, RegisterRequest, GitHubOAuthRequest, PasswordResetRequest, AuthResult } from '../../core/login_core/login_core.service';
|
||||||
|
import { Users } from '../../core/db/users/users.entity';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 登录响应数据接口
|
||||||
|
*/
|
||||||
|
export interface LoginResponse {
|
||||||
|
/** 用户信息 */
|
||||||
|
user: {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
nickname: string;
|
||||||
|
email?: string;
|
||||||
|
phone?: string;
|
||||||
|
avatar_url?: string;
|
||||||
|
role: number;
|
||||||
|
created_at: Date;
|
||||||
|
};
|
||||||
|
/** 访问令牌(实际应用中应生成JWT) */
|
||||||
|
access_token: string;
|
||||||
|
/** 刷新令牌 */
|
||||||
|
refresh_token?: string;
|
||||||
|
/** 是否为新用户 */
|
||||||
|
is_new_user?: boolean;
|
||||||
|
/** 消息 */
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通用响应接口
|
||||||
|
*/
|
||||||
|
export interface ApiResponse<T = any> {
|
||||||
|
/** 是否成功 */
|
||||||
|
success: boolean;
|
||||||
|
/** 响应数据 */
|
||||||
|
data?: T;
|
||||||
|
/** 消息 */
|
||||||
|
message: string;
|
||||||
|
/** 错误代码 */
|
||||||
|
error_code?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class LoginService {
|
||||||
|
private readonly logger = new Logger(LoginService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly loginCoreService: LoginCoreService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户登录
|
||||||
|
*
|
||||||
|
* @param loginRequest 登录请求
|
||||||
|
* @returns 登录响应
|
||||||
|
*/
|
||||||
|
async login(loginRequest: LoginRequest): Promise<ApiResponse<LoginResponse>> {
|
||||||
|
try {
|
||||||
|
this.logger.log(`用户登录尝试: ${loginRequest.identifier}`);
|
||||||
|
|
||||||
|
// 调用核心服务进行认证
|
||||||
|
const authResult = await this.loginCoreService.login(loginRequest);
|
||||||
|
|
||||||
|
// 生成访问令牌(实际应用中应使用JWT)
|
||||||
|
const accessToken = this.generateAccessToken(authResult.user);
|
||||||
|
|
||||||
|
// 格式化响应数据
|
||||||
|
const response: LoginResponse = {
|
||||||
|
user: this.formatUserInfo(authResult.user),
|
||||||
|
access_token: accessToken,
|
||||||
|
is_new_user: authResult.isNewUser,
|
||||||
|
message: '登录成功'
|
||||||
|
};
|
||||||
|
|
||||||
|
this.logger.log(`用户登录成功: ${authResult.user.username} (ID: ${authResult.user.id})`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: response,
|
||||||
|
message: '登录成功'
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`用户登录失败: ${loginRequest.identifier}`, error instanceof Error ? error.stack : String(error));
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: error instanceof Error ? error.message : '登录失败',
|
||||||
|
error_code: 'LOGIN_FAILED'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户注册
|
||||||
|
*
|
||||||
|
* @param registerRequest 注册请求
|
||||||
|
* @returns 注册响应
|
||||||
|
*/
|
||||||
|
async register(registerRequest: RegisterRequest): Promise<ApiResponse<LoginResponse>> {
|
||||||
|
try {
|
||||||
|
this.logger.log(`用户注册尝试: ${registerRequest.username}`);
|
||||||
|
|
||||||
|
// 调用核心服务进行注册
|
||||||
|
const authResult = await this.loginCoreService.register(registerRequest);
|
||||||
|
|
||||||
|
// 生成访问令牌
|
||||||
|
const accessToken = this.generateAccessToken(authResult.user);
|
||||||
|
|
||||||
|
// 格式化响应数据
|
||||||
|
const response: LoginResponse = {
|
||||||
|
user: this.formatUserInfo(authResult.user),
|
||||||
|
access_token: accessToken,
|
||||||
|
is_new_user: true,
|
||||||
|
message: '注册成功'
|
||||||
|
};
|
||||||
|
|
||||||
|
this.logger.log(`用户注册成功: ${authResult.user.username} (ID: ${authResult.user.id})`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: response,
|
||||||
|
message: '注册成功'
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`用户注册失败: ${registerRequest.username}`, error instanceof Error ? error.stack : String(error));
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: error instanceof Error ? error.message : '注册失败',
|
||||||
|
error_code: 'REGISTER_FAILED'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GitHub OAuth登录
|
||||||
|
*
|
||||||
|
* @param oauthRequest OAuth请求
|
||||||
|
* @returns 登录响应
|
||||||
|
*/
|
||||||
|
async githubOAuth(oauthRequest: GitHubOAuthRequest): Promise<ApiResponse<LoginResponse>> {
|
||||||
|
try {
|
||||||
|
this.logger.log(`GitHub OAuth登录尝试: ${oauthRequest.github_id}`);
|
||||||
|
|
||||||
|
// 调用核心服务进行OAuth认证
|
||||||
|
const authResult = await this.loginCoreService.githubOAuth(oauthRequest);
|
||||||
|
|
||||||
|
// 生成访问令牌
|
||||||
|
const accessToken = this.generateAccessToken(authResult.user);
|
||||||
|
|
||||||
|
// 格式化响应数据
|
||||||
|
const response: LoginResponse = {
|
||||||
|
user: this.formatUserInfo(authResult.user),
|
||||||
|
access_token: accessToken,
|
||||||
|
is_new_user: authResult.isNewUser,
|
||||||
|
message: authResult.isNewUser ? 'GitHub账户绑定成功' : 'GitHub登录成功'
|
||||||
|
};
|
||||||
|
|
||||||
|
this.logger.log(`GitHub OAuth成功: ${authResult.user.username} (ID: ${authResult.user.id})`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: response,
|
||||||
|
message: response.message
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`GitHub OAuth失败: ${oauthRequest.github_id}`, error instanceof Error ? error.stack : String(error));
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: error instanceof Error ? error.message : 'GitHub登录失败',
|
||||||
|
error_code: 'GITHUB_OAUTH_FAILED'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送密码重置验证码
|
||||||
|
*
|
||||||
|
* @param identifier 邮箱或手机号
|
||||||
|
* @returns 响应结果
|
||||||
|
*/
|
||||||
|
async sendPasswordResetCode(identifier: string): Promise<ApiResponse<{ verification_code?: string }>> {
|
||||||
|
try {
|
||||||
|
this.logger.log(`发送密码重置验证码: ${identifier}`);
|
||||||
|
|
||||||
|
// 调用核心服务发送验证码
|
||||||
|
const verificationCode = await this.loginCoreService.sendPasswordResetCode(identifier);
|
||||||
|
|
||||||
|
this.logger.log(`密码重置验证码已发送: ${identifier}`);
|
||||||
|
|
||||||
|
// 实际应用中不应返回验证码,这里仅用于演示
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: { verification_code: verificationCode },
|
||||||
|
message: '验证码已发送,请查收'
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`发送密码重置验证码失败: ${identifier}`, error instanceof Error ? error.stack : String(error));
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: error instanceof Error ? error.message : '发送验证码失败',
|
||||||
|
error_code: 'SEND_CODE_FAILED'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重置密码
|
||||||
|
*
|
||||||
|
* @param resetRequest 重置请求
|
||||||
|
* @returns 响应结果
|
||||||
|
*/
|
||||||
|
async resetPassword(resetRequest: PasswordResetRequest): Promise<ApiResponse> {
|
||||||
|
try {
|
||||||
|
this.logger.log(`密码重置尝试: ${resetRequest.identifier}`);
|
||||||
|
|
||||||
|
// 调用核心服务重置密码
|
||||||
|
await this.loginCoreService.resetPassword(resetRequest);
|
||||||
|
|
||||||
|
this.logger.log(`密码重置成功: ${resetRequest.identifier}`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: '密码重置成功'
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`密码重置失败: ${resetRequest.identifier}`, error instanceof Error ? error.stack : String(error));
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: error instanceof Error ? error.message : '密码重置失败',
|
||||||
|
error_code: 'RESET_PASSWORD_FAILED'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 修改密码
|
||||||
|
*
|
||||||
|
* @param userId 用户ID
|
||||||
|
* @param oldPassword 旧密码
|
||||||
|
* @param newPassword 新密码
|
||||||
|
* @returns 响应结果
|
||||||
|
*/
|
||||||
|
async changePassword(userId: bigint, oldPassword: string, newPassword: string): Promise<ApiResponse> {
|
||||||
|
try {
|
||||||
|
this.logger.log(`修改密码尝试: 用户ID ${userId}`);
|
||||||
|
|
||||||
|
// 调用核心服务修改密码
|
||||||
|
await this.loginCoreService.changePassword(userId, oldPassword, newPassword);
|
||||||
|
|
||||||
|
this.logger.log(`修改密码成功: 用户ID ${userId}`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: '密码修改成功'
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`修改密码失败: 用户ID ${userId}`, error instanceof Error ? error.stack : String(error));
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: error instanceof Error ? error.message : '密码修改失败',
|
||||||
|
error_code: 'CHANGE_PASSWORD_FAILED'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化用户信息
|
||||||
|
*
|
||||||
|
* @param user 用户实体
|
||||||
|
* @returns 格式化的用户信息
|
||||||
|
*/
|
||||||
|
private formatUserInfo(user: Users) {
|
||||||
|
return {
|
||||||
|
id: user.id.toString(), // 将bigint转换为字符串
|
||||||
|
username: user.username,
|
||||||
|
nickname: user.nickname,
|
||||||
|
email: user.email,
|
||||||
|
phone: user.phone,
|
||||||
|
avatar_url: user.avatar_url,
|
||||||
|
role: user.role,
|
||||||
|
created_at: user.created_at
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成访问令牌
|
||||||
|
*
|
||||||
|
* @param user 用户信息
|
||||||
|
* @returns 访问令牌
|
||||||
|
*/
|
||||||
|
private generateAccessToken(user: Users): string {
|
||||||
|
// 实际应用中应使用JWT库生成真正的JWT令牌
|
||||||
|
// 这里仅用于演示,生成一个简单的令牌
|
||||||
|
const payload = {
|
||||||
|
userId: user.id.toString(),
|
||||||
|
username: user.username,
|
||||||
|
role: user.role,
|
||||||
|
timestamp: Date.now()
|
||||||
|
};
|
||||||
|
|
||||||
|
// 简单的Base64编码(实际应用中应使用JWT)
|
||||||
|
return Buffer.from(JSON.stringify(payload)).toString('base64');
|
||||||
|
}
|
||||||
|
}
|
||||||
23
src/core/login_core/login_core.module.ts
Normal file
23
src/core/login_core/login_core.module.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
/**
|
||||||
|
* 登录核心模块
|
||||||
|
*
|
||||||
|
* 功能描述:
|
||||||
|
* - 提供登录认证的核心服务模块
|
||||||
|
* - 集成用户数据服务和认证逻辑
|
||||||
|
* - 为业务层提供可复用的认证功能
|
||||||
|
*
|
||||||
|
* @author moyin
|
||||||
|
* @version 1.0.0
|
||||||
|
* @since 2025-12-17
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { LoginCoreService } from './login_core.service';
|
||||||
|
import { UsersModule } from '../db/users/users.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [UsersModule],
|
||||||
|
providers: [LoginCoreService],
|
||||||
|
exports: [LoginCoreService],
|
||||||
|
})
|
||||||
|
export class LoginCoreModule {}
|
||||||
216
src/core/login_core/login_core.service.spec.ts
Normal file
216
src/core/login_core/login_core.service.spec.ts
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
/**
|
||||||
|
* 登录核心服务测试
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { LoginCoreService } from './login_core.service';
|
||||||
|
import { UsersService } from '../db/users/users.service';
|
||||||
|
import { UnauthorizedException, ConflictException, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||||
|
|
||||||
|
describe('LoginCoreService', () => {
|
||||||
|
let service: LoginCoreService;
|
||||||
|
let usersService: jest.Mocked<UsersService>;
|
||||||
|
|
||||||
|
const mockUser = {
|
||||||
|
id: BigInt(1),
|
||||||
|
username: 'testuser',
|
||||||
|
email: 'test@example.com',
|
||||||
|
phone: '+8613800138000',
|
||||||
|
password_hash: '$2b$12$hashedpassword',
|
||||||
|
nickname: '测试用户',
|
||||||
|
github_id: null as string | null,
|
||||||
|
avatar_url: null as string | null,
|
||||||
|
role: 1,
|
||||||
|
created_at: new Date(),
|
||||||
|
updated_at: new Date()
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const mockUsersService = {
|
||||||
|
findByUsername: jest.fn(),
|
||||||
|
findByEmail: jest.fn(),
|
||||||
|
findByGithubId: jest.fn(),
|
||||||
|
findAll: jest.fn(),
|
||||||
|
create: jest.fn(),
|
||||||
|
update: jest.fn(),
|
||||||
|
findOne: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
LoginCoreService,
|
||||||
|
{
|
||||||
|
provide: UsersService,
|
||||||
|
useValue: mockUsersService,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
service = module.get<LoginCoreService>(LoginCoreService);
|
||||||
|
usersService = module.get(UsersService);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be defined', () => {
|
||||||
|
expect(service).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('login', () => {
|
||||||
|
it('should login successfully with valid credentials', async () => {
|
||||||
|
usersService.findByUsername.mockResolvedValue(mockUser);
|
||||||
|
jest.spyOn(service as any, 'verifyPassword').mockResolvedValue(true);
|
||||||
|
|
||||||
|
const result = await service.login({
|
||||||
|
identifier: 'testuser',
|
||||||
|
password: 'password123'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.user).toEqual(mockUser);
|
||||||
|
expect(result.isNewUser).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw UnauthorizedException for invalid user', async () => {
|
||||||
|
usersService.findByUsername.mockResolvedValue(null);
|
||||||
|
usersService.findByEmail.mockResolvedValue(null);
|
||||||
|
usersService.findAll.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await expect(service.login({
|
||||||
|
identifier: 'nonexistent',
|
||||||
|
password: 'password123'
|
||||||
|
})).rejects.toThrow(UnauthorizedException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw UnauthorizedException for wrong password', async () => {
|
||||||
|
usersService.findByUsername.mockResolvedValue(mockUser);
|
||||||
|
jest.spyOn(service as any, 'verifyPassword').mockResolvedValue(false);
|
||||||
|
|
||||||
|
await expect(service.login({
|
||||||
|
identifier: 'testuser',
|
||||||
|
password: 'wrongpassword'
|
||||||
|
})).rejects.toThrow(UnauthorizedException);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('register', () => {
|
||||||
|
it('should register successfully', async () => {
|
||||||
|
usersService.create.mockResolvedValue(mockUser);
|
||||||
|
jest.spyOn(service as any, 'hashPassword').mockResolvedValue('hashedpassword');
|
||||||
|
jest.spyOn(service as any, 'validatePasswordStrength').mockImplementation(() => {});
|
||||||
|
|
||||||
|
const result = await service.register({
|
||||||
|
username: 'testuser',
|
||||||
|
password: 'password123',
|
||||||
|
nickname: '测试用户'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.user).toEqual(mockUser);
|
||||||
|
expect(result.isNewUser).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should validate password strength', async () => {
|
||||||
|
jest.spyOn(service as any, 'validatePasswordStrength').mockImplementation(() => {
|
||||||
|
throw new BadRequestException('密码长度至少8位');
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(service.register({
|
||||||
|
username: 'testuser',
|
||||||
|
password: '123',
|
||||||
|
nickname: '测试用户'
|
||||||
|
})).rejects.toThrow(BadRequestException);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('githubOAuth', () => {
|
||||||
|
it('should login existing GitHub user', async () => {
|
||||||
|
usersService.findByGithubId.mockResolvedValue(mockUser);
|
||||||
|
usersService.update.mockResolvedValue(mockUser);
|
||||||
|
|
||||||
|
const result = await service.githubOAuth({
|
||||||
|
github_id: 'github123',
|
||||||
|
username: 'githubuser',
|
||||||
|
nickname: 'GitHub用户'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.user).toEqual(mockUser);
|
||||||
|
expect(result.isNewUser).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create new GitHub user', async () => {
|
||||||
|
usersService.findByGithubId.mockResolvedValue(null);
|
||||||
|
usersService.findByUsername.mockResolvedValue(null);
|
||||||
|
usersService.create.mockResolvedValue(mockUser);
|
||||||
|
|
||||||
|
const result = await service.githubOAuth({
|
||||||
|
github_id: 'github123',
|
||||||
|
username: 'githubuser',
|
||||||
|
nickname: 'GitHub用户'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.user).toEqual(mockUser);
|
||||||
|
expect(result.isNewUser).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('sendPasswordResetCode', () => {
|
||||||
|
it('should send reset code for email', async () => {
|
||||||
|
usersService.findByEmail.mockResolvedValue(mockUser);
|
||||||
|
|
||||||
|
const code = await service.sendPasswordResetCode('test@example.com');
|
||||||
|
|
||||||
|
expect(code).toMatch(/^\d{6}$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw NotFoundException for non-existent user', async () => {
|
||||||
|
usersService.findByEmail.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(service.sendPasswordResetCode('nonexistent@example.com'))
|
||||||
|
.rejects.toThrow(NotFoundException);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resetPassword', () => {
|
||||||
|
it('should reset password successfully', async () => {
|
||||||
|
usersService.findByEmail.mockResolvedValue(mockUser);
|
||||||
|
usersService.update.mockResolvedValue(mockUser);
|
||||||
|
jest.spyOn(service as any, 'hashPassword').mockResolvedValue('newhashedpassword');
|
||||||
|
jest.spyOn(service as any, 'validatePasswordStrength').mockImplementation(() => {});
|
||||||
|
|
||||||
|
const result = await service.resetPassword({
|
||||||
|
identifier: 'test@example.com',
|
||||||
|
verificationCode: '123456',
|
||||||
|
newPassword: 'newpassword123'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual(mockUser);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw BadRequestException for invalid verification code', async () => {
|
||||||
|
await expect(service.resetPassword({
|
||||||
|
identifier: 'test@example.com',
|
||||||
|
verificationCode: 'invalid',
|
||||||
|
newPassword: 'newpassword123'
|
||||||
|
})).rejects.toThrow(BadRequestException);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('changePassword', () => {
|
||||||
|
it('should change password successfully', async () => {
|
||||||
|
usersService.findOne.mockResolvedValue(mockUser);
|
||||||
|
usersService.update.mockResolvedValue(mockUser);
|
||||||
|
jest.spyOn(service as any, 'verifyPassword').mockResolvedValue(true);
|
||||||
|
jest.spyOn(service as any, 'hashPassword').mockResolvedValue('newhashedpassword');
|
||||||
|
jest.spyOn(service as any, 'validatePasswordStrength').mockImplementation(() => {});
|
||||||
|
|
||||||
|
const result = await service.changePassword(BigInt(1), 'oldpassword', 'newpassword123');
|
||||||
|
|
||||||
|
expect(result).toEqual(mockUser);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw UnauthorizedException for wrong old password', async () => {
|
||||||
|
usersService.findOne.mockResolvedValue(mockUser);
|
||||||
|
jest.spyOn(service as any, 'verifyPassword').mockResolvedValue(false);
|
||||||
|
|
||||||
|
await expect(service.changePassword(BigInt(1), 'wrongpassword', 'newpassword123'))
|
||||||
|
.rejects.toThrow(UnauthorizedException);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
423
src/core/login_core/login_core.service.ts
Normal file
423
src/core/login_core/login_core.service.ts
Normal file
@@ -0,0 +1,423 @@
|
|||||||
|
/**
|
||||||
|
* 登录核心服务
|
||||||
|
*
|
||||||
|
* 功能描述:
|
||||||
|
* - 提供用户认证的核心功能实现
|
||||||
|
* - 处理登录、注册、密码重置等核心逻辑
|
||||||
|
* - 为业务层提供基础的认证服务
|
||||||
|
*
|
||||||
|
* 职责分离:
|
||||||
|
* - 专注于认证功能的核心实现
|
||||||
|
* - 不处理HTTP请求和响应格式化
|
||||||
|
* - 为business层提供可复用的服务
|
||||||
|
*
|
||||||
|
* @author moyin
|
||||||
|
* @version 1.0.0
|
||||||
|
* @since 2025-12-17
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Injectable, UnauthorizedException, ConflictException, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||||
|
import { UsersService } from '../db/users/users.service';
|
||||||
|
import { Users } from '../db/users/users.entity';
|
||||||
|
import * as bcrypt from 'bcrypt';
|
||||||
|
import * as crypto from 'crypto';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 登录请求数据接口
|
||||||
|
*/
|
||||||
|
export interface LoginRequest {
|
||||||
|
/** 登录标识符:用户名、邮箱或手机号 */
|
||||||
|
identifier: string;
|
||||||
|
/** 密码 */
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注册请求数据接口
|
||||||
|
*/
|
||||||
|
export interface RegisterRequest {
|
||||||
|
/** 用户名 */
|
||||||
|
username: string;
|
||||||
|
/** 密码 */
|
||||||
|
password: string;
|
||||||
|
/** 昵称 */
|
||||||
|
nickname: string;
|
||||||
|
/** 邮箱(可选) */
|
||||||
|
email?: string;
|
||||||
|
/** 手机号(可选) */
|
||||||
|
phone?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GitHub OAuth登录请求数据接口
|
||||||
|
*/
|
||||||
|
export interface GitHubOAuthRequest {
|
||||||
|
/** GitHub用户ID */
|
||||||
|
github_id: string;
|
||||||
|
/** 用户名 */
|
||||||
|
username: string;
|
||||||
|
/** 昵称 */
|
||||||
|
nickname: string;
|
||||||
|
/** 邮箱 */
|
||||||
|
email?: string;
|
||||||
|
/** 头像URL */
|
||||||
|
avatar_url?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 密码重置请求数据接口
|
||||||
|
*/
|
||||||
|
export interface PasswordResetRequest {
|
||||||
|
/** 邮箱或手机号 */
|
||||||
|
identifier: string;
|
||||||
|
/** 验证码 */
|
||||||
|
verificationCode: string;
|
||||||
|
/** 新密码 */
|
||||||
|
newPassword: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 认证结果接口
|
||||||
|
*/
|
||||||
|
export interface AuthResult {
|
||||||
|
/** 用户信息 */
|
||||||
|
user: Users;
|
||||||
|
/** 是否为新用户 */
|
||||||
|
isNewUser?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class LoginCoreService {
|
||||||
|
constructor(
|
||||||
|
private readonly usersService: UsersService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户名密码登录
|
||||||
|
*
|
||||||
|
* @param loginRequest 登录请求数据
|
||||||
|
* @returns 认证结果
|
||||||
|
* @throws UnauthorizedException 认证失败时
|
||||||
|
*/
|
||||||
|
async login(loginRequest: LoginRequest): Promise<AuthResult> {
|
||||||
|
const { identifier, password } = loginRequest;
|
||||||
|
|
||||||
|
// 查找用户(支持用户名、邮箱、手机号登录)
|
||||||
|
let user: Users | null = null;
|
||||||
|
|
||||||
|
// 尝试用户名查找
|
||||||
|
user = await this.usersService.findByUsername(identifier);
|
||||||
|
|
||||||
|
// 如果用户名未找到,尝试邮箱查找
|
||||||
|
if (!user && this.isEmail(identifier)) {
|
||||||
|
user = await this.usersService.findByEmail(identifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果邮箱未找到,尝试手机号查找(简单验证)
|
||||||
|
if (!user && this.isPhoneNumber(identifier)) {
|
||||||
|
const users = await this.usersService.findAll();
|
||||||
|
user = users.find(u => u.phone === identifier) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 用户不存在
|
||||||
|
if (!user) {
|
||||||
|
throw new UnauthorizedException('用户名、邮箱或手机号不存在');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否为OAuth用户(没有密码)
|
||||||
|
if (!user.password_hash) {
|
||||||
|
throw new UnauthorizedException('该账户使用第三方登录,请使用对应的登录方式');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证密码
|
||||||
|
const isPasswordValid = await this.verifyPassword(password, user.password_hash);
|
||||||
|
if (!isPasswordValid) {
|
||||||
|
throw new UnauthorizedException('密码错误');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
user,
|
||||||
|
isNewUser: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户注册
|
||||||
|
*
|
||||||
|
* @param registerRequest 注册请求数据
|
||||||
|
* @returns 认证结果
|
||||||
|
* @throws ConflictException 用户已存在时
|
||||||
|
* @throws BadRequestException 数据验证失败时
|
||||||
|
*/
|
||||||
|
async register(registerRequest: RegisterRequest): Promise<AuthResult> {
|
||||||
|
const { username, password, nickname, email, phone } = registerRequest;
|
||||||
|
|
||||||
|
// 验证密码强度
|
||||||
|
this.validatePasswordStrength(password);
|
||||||
|
|
||||||
|
// 加密密码
|
||||||
|
const passwordHash = await this.hashPassword(password);
|
||||||
|
|
||||||
|
// 创建用户
|
||||||
|
const user = await this.usersService.create({
|
||||||
|
username,
|
||||||
|
password_hash: passwordHash,
|
||||||
|
nickname,
|
||||||
|
email,
|
||||||
|
phone,
|
||||||
|
role: 1 // 默认普通用户
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
user,
|
||||||
|
isNewUser: true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GitHub OAuth登录/注册
|
||||||
|
*
|
||||||
|
* @param oauthRequest OAuth请求数据
|
||||||
|
* @returns 认证结果
|
||||||
|
*/
|
||||||
|
async githubOAuth(oauthRequest: GitHubOAuthRequest): Promise<AuthResult> {
|
||||||
|
const { github_id, username, nickname, email, avatar_url } = oauthRequest;
|
||||||
|
|
||||||
|
// 查找是否已存在GitHub用户
|
||||||
|
let user = await this.usersService.findByGithubId(github_id);
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
// 用户已存在,更新信息
|
||||||
|
user = await this.usersService.update(user.id, {
|
||||||
|
nickname,
|
||||||
|
email,
|
||||||
|
avatar_url
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
user,
|
||||||
|
isNewUser: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查用户名是否已被占用
|
||||||
|
let finalUsername = username;
|
||||||
|
let counter = 1;
|
||||||
|
while (await this.usersService.findByUsername(finalUsername)) {
|
||||||
|
finalUsername = `${username}_${counter}`;
|
||||||
|
counter++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建新用户
|
||||||
|
user = await this.usersService.create({
|
||||||
|
username: finalUsername,
|
||||||
|
nickname,
|
||||||
|
email,
|
||||||
|
github_id,
|
||||||
|
avatar_url,
|
||||||
|
role: 1 // 默认普通用户
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
user,
|
||||||
|
isNewUser: true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送密码重置验证码
|
||||||
|
*
|
||||||
|
* @param identifier 邮箱或手机号
|
||||||
|
* @returns 验证码(实际应用中应发送到用户邮箱/手机)
|
||||||
|
* @throws NotFoundException 用户不存在时
|
||||||
|
*/
|
||||||
|
async sendPasswordResetCode(identifier: string): Promise<string> {
|
||||||
|
// 查找用户
|
||||||
|
let user: Users | null = null;
|
||||||
|
|
||||||
|
if (this.isEmail(identifier)) {
|
||||||
|
user = await this.usersService.findByEmail(identifier);
|
||||||
|
} else if (this.isPhoneNumber(identifier)) {
|
||||||
|
const users = await this.usersService.findAll();
|
||||||
|
user = users.find(u => u.phone === identifier) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new NotFoundException('用户不存在');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成6位数验证码
|
||||||
|
const verificationCode = this.generateVerificationCode();
|
||||||
|
|
||||||
|
// TODO: 实际应用中应该:
|
||||||
|
// 1. 将验证码存储到Redis等缓存中,设置过期时间(如5分钟)
|
||||||
|
// 2. 发送验证码到用户邮箱或手机
|
||||||
|
// 3. 返回成功消息而不是验证码本身
|
||||||
|
|
||||||
|
// 这里为了演示,直接返回验证码
|
||||||
|
console.log(`密码重置验证码(${identifier}): ${verificationCode}`);
|
||||||
|
|
||||||
|
return verificationCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重置密码
|
||||||
|
*
|
||||||
|
* @param resetRequest 重置请求数据
|
||||||
|
* @returns 更新后的用户信息
|
||||||
|
* @throws NotFoundException 用户不存在时
|
||||||
|
* @throws BadRequestException 验证码错误时
|
||||||
|
*/
|
||||||
|
async resetPassword(resetRequest: PasswordResetRequest): Promise<Users> {
|
||||||
|
const { identifier, verificationCode, newPassword } = resetRequest;
|
||||||
|
|
||||||
|
// TODO: 实际应用中应该验证验证码的有效性
|
||||||
|
// 这里为了演示,简单验证验证码格式
|
||||||
|
if (!/^\d{6}$/.test(verificationCode)) {
|
||||||
|
throw new BadRequestException('验证码格式错误');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查找用户
|
||||||
|
let user: Users | null = null;
|
||||||
|
|
||||||
|
if (this.isEmail(identifier)) {
|
||||||
|
user = await this.usersService.findByEmail(identifier);
|
||||||
|
} else if (this.isPhoneNumber(identifier)) {
|
||||||
|
const users = await this.usersService.findAll();
|
||||||
|
user = users.find(u => u.phone === identifier) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new NotFoundException('用户不存在');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证密码强度
|
||||||
|
this.validatePasswordStrength(newPassword);
|
||||||
|
|
||||||
|
// 加密新密码
|
||||||
|
const passwordHash = await this.hashPassword(newPassword);
|
||||||
|
|
||||||
|
// 更新密码
|
||||||
|
return await this.usersService.update(user.id, {
|
||||||
|
password_hash: passwordHash
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 修改密码
|
||||||
|
*
|
||||||
|
* @param userId 用户ID
|
||||||
|
* @param oldPassword 旧密码
|
||||||
|
* @param newPassword 新密码
|
||||||
|
* @returns 更新后的用户信息
|
||||||
|
* @throws UnauthorizedException 旧密码错误时
|
||||||
|
*/
|
||||||
|
async changePassword(userId: bigint, oldPassword: string, newPassword: string): Promise<Users> {
|
||||||
|
// 获取用户信息
|
||||||
|
const user = await this.usersService.findOne(userId);
|
||||||
|
|
||||||
|
// 检查是否为OAuth用户
|
||||||
|
if (!user.password_hash) {
|
||||||
|
throw new BadRequestException('OAuth用户无法修改密码');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证旧密码
|
||||||
|
const isOldPasswordValid = await this.verifyPassword(oldPassword, user.password_hash);
|
||||||
|
if (!isOldPasswordValid) {
|
||||||
|
throw new UnauthorizedException('旧密码错误');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证新密码强度
|
||||||
|
this.validatePasswordStrength(newPassword);
|
||||||
|
|
||||||
|
// 加密新密码
|
||||||
|
const passwordHash = await this.hashPassword(newPassword);
|
||||||
|
|
||||||
|
// 更新密码
|
||||||
|
return await this.usersService.update(userId, {
|
||||||
|
password_hash: passwordHash
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证用户密码
|
||||||
|
*
|
||||||
|
* @param password 明文密码
|
||||||
|
* @param hash 密码哈希值
|
||||||
|
* @returns 是否匹配
|
||||||
|
*/
|
||||||
|
private async verifyPassword(password: string, hash: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
return await bcrypt.compare(password, hash);
|
||||||
|
} catch (error) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加密密码
|
||||||
|
*
|
||||||
|
* @param password 明文密码
|
||||||
|
* @returns 密码哈希值
|
||||||
|
*/
|
||||||
|
private async hashPassword(password: string): Promise<string> {
|
||||||
|
const saltRounds = 12; // 推荐的盐值轮数
|
||||||
|
return await bcrypt.hash(password, saltRounds);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证密码强度
|
||||||
|
*
|
||||||
|
* @param password 密码
|
||||||
|
* @throws BadRequestException 密码强度不足时
|
||||||
|
*/
|
||||||
|
private validatePasswordStrength(password: string): void {
|
||||||
|
if (password.length < 8) {
|
||||||
|
throw new BadRequestException('密码长度至少8位');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password.length > 128) {
|
||||||
|
throw new BadRequestException('密码长度不能超过128位');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否包含字母和数字
|
||||||
|
const hasLetter = /[a-zA-Z]/.test(password);
|
||||||
|
const hasNumber = /\d/.test(password);
|
||||||
|
|
||||||
|
if (!hasLetter || !hasNumber) {
|
||||||
|
throw new BadRequestException('密码必须包含字母和数字');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成验证码
|
||||||
|
*
|
||||||
|
* @returns 6位数验证码
|
||||||
|
*/
|
||||||
|
private generateVerificationCode(): string {
|
||||||
|
return Math.floor(100000 + Math.random() * 900000).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查是否为邮箱格式
|
||||||
|
*
|
||||||
|
* @param str 字符串
|
||||||
|
* @returns 是否为邮箱
|
||||||
|
*/
|
||||||
|
private isEmail(str: string): boolean {
|
||||||
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
return emailRegex.test(str);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查是否为手机号格式(简单验证)
|
||||||
|
*
|
||||||
|
* @param str 字符串
|
||||||
|
* @returns 是否为手机号
|
||||||
|
*/
|
||||||
|
private isPhoneNumber(str: string): boolean {
|
||||||
|
// 简单的手机号验证,支持国际格式
|
||||||
|
const phoneRegex = /^(\+\d{1,3}[- ]?)?\d{10,11}$/;
|
||||||
|
return phoneRegex.test(str.replace(/\s/g, ''));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user