初始化游戏小组管理系统后端项目
- 基于 NestJS + TypeScript + MySQL + Redis 架构 - 完整的模块化设计(认证、用户、小组、游戏、预约等) - JWT 认证和 RBAC 权限控制系统 - Docker 容器化部署支持 - 添加 CLAUDE.md 项目开发指南 - 配置 .gitignore 忽略文件 Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
140
src/modules/auth/auth.controller.spec.ts
Normal file
140
src/modules/auth/auth.controller.spec.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { INestApplication, ValidationPipe } from '@nestjs/common';
|
||||
import request from 'supertest';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
|
||||
describe('AuthController (e2e)', () => {
|
||||
let app: INestApplication;
|
||||
let authService: AuthService;
|
||||
|
||||
const mockAuthService = {
|
||||
register: jest.fn(),
|
||||
login: jest.fn(),
|
||||
refreshToken: jest.fn(),
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||
controllers: [AuthController],
|
||||
providers: [
|
||||
{
|
||||
provide: AuthService,
|
||||
useValue: mockAuthService,
|
||||
},
|
||||
],
|
||||
})
|
||||
.overrideGuard(JwtAuthGuard)
|
||||
.useValue({ canActivate: () => true })
|
||||
.compile();
|
||||
|
||||
app = moduleFixture.createNestApplication();
|
||||
app.useGlobalPipes(new ValidationPipe());
|
||||
await app.init();
|
||||
|
||||
authService = moduleFixture.get<AuthService>(AuthService);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('/api/auth/register (POST)', () => {
|
||||
it('应该成功注册并返回用户信息和Token', () => {
|
||||
const registerDto = {
|
||||
username: 'testuser',
|
||||
password: 'Password123!',
|
||||
email: 'test@example.com',
|
||||
};
|
||||
|
||||
const mockResponse = {
|
||||
user: {
|
||||
id: 'test-id',
|
||||
username: 'testuser',
|
||||
email: 'test@example.com',
|
||||
},
|
||||
accessToken: 'access-token',
|
||||
refreshToken: 'refresh-token',
|
||||
};
|
||||
|
||||
mockAuthService.register.mockResolvedValue(mockResponse);
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post('/auth/register')
|
||||
.send(registerDto)
|
||||
.expect(201)
|
||||
.expect((res) => {
|
||||
expect(res.body.data).toHaveProperty('user');
|
||||
expect(res.body.data).toHaveProperty('accessToken');
|
||||
expect(res.body.data).toHaveProperty('refreshToken');
|
||||
});
|
||||
});
|
||||
|
||||
it('应该在缺少必填字段时返回400', () => {
|
||||
return request(app.getHttpServer())
|
||||
.post('/auth/register')
|
||||
.send({
|
||||
username: 'testuser',
|
||||
// 缺少密码
|
||||
})
|
||||
.expect(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('/api/auth/login (POST)', () => {
|
||||
it('应该成功登录', () => {
|
||||
const loginDto = {
|
||||
username: 'testuser',
|
||||
password: 'Password123!',
|
||||
};
|
||||
|
||||
const mockResponse = {
|
||||
user: {
|
||||
id: 'test-id',
|
||||
username: 'testuser',
|
||||
},
|
||||
accessToken: 'access-token',
|
||||
refreshToken: 'refresh-token',
|
||||
};
|
||||
|
||||
mockAuthService.login.mockResolvedValue(mockResponse);
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post('/auth/login')
|
||||
.send(loginDto)
|
||||
.expect(200)
|
||||
.expect((res) => {
|
||||
expect(res.body.data).toHaveProperty('accessToken');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('/api/auth/refresh (POST)', () => {
|
||||
it('应该成功刷新Token', () => {
|
||||
const refreshDto = {
|
||||
refreshToken: 'valid-refresh-token',
|
||||
};
|
||||
|
||||
const mockResponse = {
|
||||
accessToken: 'new-access-token',
|
||||
refreshToken: 'new-refresh-token',
|
||||
};
|
||||
|
||||
mockAuthService.refreshToken.mockResolvedValue(mockResponse);
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post('/auth/refresh')
|
||||
.send(refreshDto)
|
||||
.expect(200)
|
||||
.expect((res) => {
|
||||
expect(res.body.data).toHaveProperty('accessToken');
|
||||
expect(res.body.data).toHaveProperty('refreshToken');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
37
src/modules/auth/auth.controller.ts
Normal file
37
src/modules/auth/auth.controller.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { Controller, Post, Body, HttpCode, HttpStatus, Ip } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
|
||||
import { AuthService } from './auth.service';
|
||||
import { RegisterDto, LoginDto, RefreshTokenDto } from './dto/auth.dto';
|
||||
import { Public } from '../../common/decorators/public.decorator';
|
||||
|
||||
@ApiTags('auth')
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(private readonly authService: AuthService) {}
|
||||
|
||||
@Public()
|
||||
@Post('register')
|
||||
@ApiOperation({ summary: '用户注册' })
|
||||
@ApiResponse({ status: 201, description: '注册成功' })
|
||||
async register(@Body() registerDto: RegisterDto) {
|
||||
return this.authService.register(registerDto);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('login')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: '用户登录' })
|
||||
@ApiResponse({ status: 200, description: '登录成功' })
|
||||
async login(@Body() loginDto: LoginDto, @Ip() ip: string) {
|
||||
return this.authService.login(loginDto, ip);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('refresh')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: '刷新令牌' })
|
||||
@ApiResponse({ status: 200, description: '刷新成功' })
|
||||
async refresh(@Body() refreshTokenDto: RefreshTokenDto) {
|
||||
return this.authService.refreshToken(refreshTokenDto.refreshToken);
|
||||
}
|
||||
}
|
||||
30
src/modules/auth/auth.module.ts
Normal file
30
src/modules/auth/auth.module.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { AuthService } from './auth.service';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { JwtStrategy } from './jwt.strategy';
|
||||
import { User } from '../../entities/user.entity';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([User]),
|
||||
PassportModule.register({ defaultStrategy: 'jwt' }),
|
||||
JwtModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
useFactory: (configService: ConfigService) => ({
|
||||
secret: configService.get('jwt.secret'),
|
||||
signOptions: {
|
||||
expiresIn: configService.get('jwt.expiresIn'),
|
||||
},
|
||||
}),
|
||||
inject: [ConfigService],
|
||||
}),
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService, JwtStrategy],
|
||||
exports: [AuthService, JwtStrategy],
|
||||
})
|
||||
export class AuthModule {}
|
||||
312
src/modules/auth/auth.service.spec.ts
Normal file
312
src/modules/auth/auth.service.spec.ts
Normal file
@@ -0,0 +1,312 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { BadRequestException, UnauthorizedException } from '@nestjs/common';
|
||||
import { AuthService } from './auth.service';
|
||||
import { User } from '../../entities/user.entity';
|
||||
import { CryptoUtil } from '../../common/utils/crypto.util';
|
||||
import { UserRole } from '../../common/enums';
|
||||
|
||||
describe('AuthService', () => {
|
||||
let service: AuthService;
|
||||
let userRepository: Repository<User>;
|
||||
let jwtService: JwtService;
|
||||
|
||||
const mockUser = {
|
||||
id: 'test-user-id',
|
||||
username: 'testuser',
|
||||
email: 'test@example.com',
|
||||
phone: '13800138000',
|
||||
password: 'hashedPassword',
|
||||
role: UserRole.USER,
|
||||
isMember: false,
|
||||
memberExpiredAt: null,
|
||||
lastLoginAt: null,
|
||||
lastLoginIp: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
const mockUserRepository = {
|
||||
findOne: jest.fn(),
|
||||
create: jest.fn(),
|
||||
save: jest.fn(),
|
||||
};
|
||||
|
||||
const mockJwtService = {
|
||||
sign: jest.fn(),
|
||||
signAsync: jest.fn(),
|
||||
verify: jest.fn(),
|
||||
verifyAsync: jest.fn(),
|
||||
};
|
||||
|
||||
const mockConfigService = {
|
||||
get: jest.fn((key: string) => {
|
||||
const config = {
|
||||
'jwt.secret': 'test-secret',
|
||||
'jwt.accessExpiresIn': '15m',
|
||||
'jwt.refreshExpiresIn': '7d',
|
||||
};
|
||||
return config[key];
|
||||
}),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
AuthService,
|
||||
{
|
||||
provide: ConfigService,
|
||||
useValue: mockConfigService,
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(User),
|
||||
useValue: mockUserRepository,
|
||||
},
|
||||
{
|
||||
provide: JwtService,
|
||||
useValue: mockJwtService,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<AuthService>(AuthService);
|
||||
userRepository = module.get<Repository<User>>(getRepositoryToken(User));
|
||||
jwtService = module.get<JwtService>(JwtService);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('register', () => {
|
||||
it('应该成功注册新用户', async () => {
|
||||
const registerDto = {
|
||||
username: 'newuser',
|
||||
password: 'Password123!',
|
||||
email: 'new@example.com',
|
||||
phone: '13900139000',
|
||||
};
|
||||
|
||||
mockUserRepository.findOne
|
||||
.mockResolvedValueOnce(null) // 邮箱检查
|
||||
.mockResolvedValueOnce(null); // 手机号检查
|
||||
|
||||
mockUserRepository.create.mockReturnValue({
|
||||
...registerDto,
|
||||
id: 'new-user-id',
|
||||
password: 'hashedPassword',
|
||||
});
|
||||
|
||||
mockUserRepository.save.mockResolvedValue({
|
||||
...registerDto,
|
||||
id: 'new-user-id',
|
||||
});
|
||||
|
||||
mockJwtService.signAsync
|
||||
.mockResolvedValueOnce('access-token')
|
||||
.mockResolvedValueOnce('refresh-token');
|
||||
|
||||
const result = await service.register(registerDto);
|
||||
|
||||
expect(result).toHaveProperty('user');
|
||||
expect(result).toHaveProperty('accessToken', 'access-token');
|
||||
expect(result).toHaveProperty('refreshToken', 'refresh-token');
|
||||
expect(mockUserRepository.findOne).toHaveBeenCalledTimes(2);
|
||||
expect(mockUserRepository.save).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('应该在邮箱已存在时抛出异常', async () => {
|
||||
const registerDto = {
|
||||
username: 'newuser',
|
||||
password: 'Password123!',
|
||||
email: 'existing@example.com',
|
||||
};
|
||||
|
||||
mockUserRepository.findOne.mockResolvedValueOnce(mockUser);
|
||||
|
||||
await expect(service.register(registerDto)).rejects.toThrow(
|
||||
BadRequestException,
|
||||
);
|
||||
});
|
||||
|
||||
it('应该在手机号已存在时抛出异常', async () => {
|
||||
const registerDto = {
|
||||
username: 'newuser',
|
||||
password: 'Password123!',
|
||||
phone: '13800138000',
|
||||
};
|
||||
|
||||
mockUserRepository.findOne
|
||||
.mockResolvedValueOnce(null) // 邮箱不存在
|
||||
.mockResolvedValueOnce(mockUser); // 手机号已存在
|
||||
|
||||
await expect(service.register(registerDto)).rejects.toThrow(
|
||||
BadRequestException,
|
||||
);
|
||||
});
|
||||
|
||||
it('应该在缺少邮箱和手机号时抛出异常', async () => {
|
||||
const registerDto = {
|
||||
username: 'newuser',
|
||||
password: 'Password123!',
|
||||
};
|
||||
|
||||
await expect(service.register(registerDto)).rejects.toThrow(
|
||||
BadRequestException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('login', () => {
|
||||
it('应该使用用户名成功登录', async () => {
|
||||
const loginDto = {
|
||||
account: 'testuser',
|
||||
password: 'Password123!',
|
||||
};
|
||||
|
||||
mockUserRepository.findOne.mockResolvedValue({
|
||||
...mockUser,
|
||||
password: await CryptoUtil.hashPassword('Password123!'),
|
||||
});
|
||||
|
||||
mockJwtService.signAsync
|
||||
.mockResolvedValueOnce('access-token')
|
||||
.mockResolvedValueOnce('refresh-token');
|
||||
|
||||
const result = await service.login(loginDto, '127.0.0.1');
|
||||
|
||||
expect(result).toHaveProperty('user');
|
||||
expect(result).toHaveProperty('accessToken', 'access-token');
|
||||
expect(result).toHaveProperty('refreshToken', 'refresh-token');
|
||||
expect(mockUserRepository.findOne).toHaveBeenCalledWith({
|
||||
where: { username: loginDto.account },
|
||||
});
|
||||
});
|
||||
|
||||
it('应该使用邮箱成功登录', async () => {
|
||||
const loginDto = {
|
||||
account: 'test@example.com',
|
||||
password: 'Password123!',
|
||||
};
|
||||
|
||||
mockUserRepository.findOne.mockResolvedValue({
|
||||
...mockUser,
|
||||
password: await CryptoUtil.hashPassword('Password123!'),
|
||||
});
|
||||
|
||||
mockJwtService.signAsync
|
||||
.mockResolvedValueOnce('access-token')
|
||||
.mockResolvedValueOnce('refresh-token');
|
||||
|
||||
const result = await service.login(loginDto, '127.0.0.1');
|
||||
|
||||
expect(result).toHaveProperty('user');
|
||||
expect(result).toHaveProperty('accessToken');
|
||||
expect(mockUserRepository.findOne).toHaveBeenCalledWith({
|
||||
where: { email: loginDto.account },
|
||||
});
|
||||
});
|
||||
|
||||
it('应该在用户不存在时抛出异常', async () => {
|
||||
const loginDto = {
|
||||
account: 'nonexistent',
|
||||
password: 'Password123!',
|
||||
};
|
||||
|
||||
mockUserRepository.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(service.login(loginDto, '127.0.0.1')).rejects.toThrow(
|
||||
UnauthorizedException,
|
||||
);
|
||||
});
|
||||
|
||||
it('应该在密码错误时抛出异常', async () => {
|
||||
const loginDto = {
|
||||
account: 'testuser',
|
||||
password: 'WrongPassword',
|
||||
};
|
||||
|
||||
mockUserRepository.findOne.mockResolvedValue({
|
||||
...mockUser,
|
||||
password: await CryptoUtil.hashPassword('CorrectPassword'),
|
||||
});
|
||||
|
||||
await expect(service.login(loginDto, '127.0.0.1')).rejects.toThrow(
|
||||
UnauthorizedException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('refreshToken', () => {
|
||||
it('应该成功刷新Token', async () => {
|
||||
const refreshToken = 'valid-refresh-token';
|
||||
|
||||
mockJwtService.verify.mockReturnValue({
|
||||
sub: 'test-user-id',
|
||||
username: 'testuser',
|
||||
});
|
||||
|
||||
mockUserRepository.findOne.mockResolvedValue(mockUser);
|
||||
|
||||
mockJwtService.signAsync
|
||||
.mockResolvedValueOnce('new-access-token')
|
||||
.mockResolvedValueOnce('new-refresh-token');
|
||||
|
||||
const result = await service.refreshToken(refreshToken);
|
||||
|
||||
expect(result).toHaveProperty('accessToken', 'new-access-token');
|
||||
expect(result).toHaveProperty('refreshToken', 'new-refresh-token');
|
||||
expect(mockJwtService.verify).toHaveBeenCalledWith('valid-refresh-token');
|
||||
});
|
||||
|
||||
it('应该在Token无效时抛出异常', async () => {
|
||||
const refreshToken = 'invalid-token';
|
||||
|
||||
mockJwtService.verify.mockImplementation(() => {
|
||||
throw new Error('Invalid token');
|
||||
});
|
||||
|
||||
await expect(service.refreshToken(refreshToken)).rejects.toThrow(
|
||||
UnauthorizedException,
|
||||
);
|
||||
});
|
||||
|
||||
it('应该在用户不存在时抛出异常', async () => {
|
||||
const refreshToken = 'valid-refresh-token';
|
||||
|
||||
mockJwtService.verify.mockReturnValue({
|
||||
sub: 'nonexistent-user-id',
|
||||
username: 'nonexistent',
|
||||
});
|
||||
|
||||
mockUserRepository.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(service.refreshToken(refreshToken)).rejects.toThrow(
|
||||
UnauthorizedException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateUser', () => {
|
||||
it('应该返回用户信息(排除密码)', async () => {
|
||||
mockUserRepository.findOne.mockResolvedValue(mockUser);
|
||||
|
||||
const result = await service.validateUser('test-user-id');
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.id).toBe('test-user-id');
|
||||
expect(result).not.toHaveProperty('password');
|
||||
});
|
||||
|
||||
it('应该在用户不存在时返回null', async () => {
|
||||
mockUserRepository.findOne.mockResolvedValue(null);
|
||||
|
||||
const result = await service.validateUser('nonexistent-id');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
233
src/modules/auth/auth.service.ts
Normal file
233
src/modules/auth/auth.service.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
import { Injectable, UnauthorizedException, BadRequestException, HttpException } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { User } from '../../entities/user.entity';
|
||||
import { RegisterDto, LoginDto } from './dto/auth.dto';
|
||||
import { CryptoUtil } from '../../common/utils/crypto.util';
|
||||
import { ErrorCode, ErrorMessage } from '../../common/interfaces/response.interface';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
@InjectRepository(User)
|
||||
private userRepository: Repository<User>,
|
||||
private jwtService: JwtService,
|
||||
private configService: ConfigService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 用户注册
|
||||
*/
|
||||
async register(registerDto: RegisterDto) {
|
||||
const { username, password, email, phone } = registerDto;
|
||||
|
||||
// 验证邮箱和手机号至少有一个
|
||||
if (!email && !phone) {
|
||||
throw new BadRequestException({
|
||||
code: ErrorCode.PARAM_ERROR,
|
||||
message: '邮箱和手机号至少填写一个',
|
||||
});
|
||||
}
|
||||
|
||||
// 检查用户名是否已存在
|
||||
const existingUser = await this.userRepository.findOne({
|
||||
where: [
|
||||
{ username },
|
||||
...(email ? [{ email }] : []),
|
||||
...(phone ? [{ phone }] : []),
|
||||
],
|
||||
});
|
||||
|
||||
if (existingUser) {
|
||||
if (existingUser.username === username) {
|
||||
throw new HttpException(
|
||||
{
|
||||
code: ErrorCode.USER_EXISTS,
|
||||
message: '用户名已存在',
|
||||
},
|
||||
400,
|
||||
);
|
||||
}
|
||||
if (email && existingUser.email === email) {
|
||||
throw new HttpException(
|
||||
{
|
||||
code: ErrorCode.USER_EXISTS,
|
||||
message: '邮箱已被注册',
|
||||
},
|
||||
400,
|
||||
);
|
||||
}
|
||||
if (phone && existingUser.phone === phone) {
|
||||
throw new HttpException(
|
||||
{
|
||||
code: ErrorCode.USER_EXISTS,
|
||||
message: '手机号已被注册',
|
||||
},
|
||||
400,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 加密密码
|
||||
const hashedPassword = await CryptoUtil.hashPassword(password);
|
||||
|
||||
// 创建用户
|
||||
const user = this.userRepository.create({
|
||||
username,
|
||||
password: hashedPassword,
|
||||
email,
|
||||
phone,
|
||||
});
|
||||
|
||||
await this.userRepository.save(user);
|
||||
|
||||
// 生成 token
|
||||
const tokens = await this.generateTokens(user);
|
||||
|
||||
return {
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
phone: user.phone,
|
||||
avatar: user.avatar,
|
||||
isMember: user.isMember,
|
||||
},
|
||||
...tokens,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户登录
|
||||
*/
|
||||
async login(loginDto: LoginDto, ip?: string) {
|
||||
const { account, password } = loginDto;
|
||||
|
||||
// 查找用户(支持用户名、邮箱、手机号登录)
|
||||
const user = await this.userRepository
|
||||
.createQueryBuilder('user')
|
||||
.where('user.username = :account', { account })
|
||||
.orWhere('user.email = :account', { account })
|
||||
.orWhere('user.phone = :account', { account })
|
||||
.addSelect('user.password')
|
||||
.getOne();
|
||||
|
||||
if (!user) {
|
||||
throw new UnauthorizedException({
|
||||
code: ErrorCode.USER_NOT_FOUND,
|
||||
message: ErrorMessage[ErrorCode.USER_NOT_FOUND],
|
||||
});
|
||||
}
|
||||
|
||||
// 验证密码
|
||||
const isPasswordValid = await CryptoUtil.comparePassword(
|
||||
password,
|
||||
user.password,
|
||||
);
|
||||
|
||||
if (!isPasswordValid) {
|
||||
throw new UnauthorizedException({
|
||||
code: ErrorCode.PASSWORD_ERROR,
|
||||
message: ErrorMessage[ErrorCode.PASSWORD_ERROR],
|
||||
});
|
||||
}
|
||||
|
||||
// 更新登录信息
|
||||
user.lastLoginIp = ip || null;
|
||||
user.lastLoginAt = new Date();
|
||||
await this.userRepository.save(user);
|
||||
|
||||
// 生成 token
|
||||
const tokens = await this.generateTokens(user);
|
||||
|
||||
return {
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
phone: user.phone,
|
||||
avatar: user.avatar,
|
||||
role: user.role,
|
||||
isMember: user.isMember,
|
||||
memberExpireAt: user.memberExpireAt,
|
||||
},
|
||||
...tokens,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新 token
|
||||
*/
|
||||
async refreshToken(refreshToken: string) {
|
||||
try {
|
||||
const payload = this.jwtService.verify(refreshToken, {
|
||||
secret: this.configService.get('jwt.refreshSecret'),
|
||||
});
|
||||
|
||||
const user = await this.userRepository.findOne({
|
||||
where: { id: payload.sub },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new UnauthorizedException({
|
||||
code: ErrorCode.USER_NOT_FOUND,
|
||||
message: ErrorMessage[ErrorCode.USER_NOT_FOUND],
|
||||
});
|
||||
}
|
||||
|
||||
return this.generateTokens(user);
|
||||
} catch (error) {
|
||||
throw new UnauthorizedException({
|
||||
code: ErrorCode.TOKEN_INVALID,
|
||||
message: ErrorMessage[ErrorCode.TOKEN_INVALID],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证用户
|
||||
*/
|
||||
async validateUser(userId: string): Promise<User> {
|
||||
const user = await this.userRepository.findOne({
|
||||
where: { id: userId },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new UnauthorizedException({
|
||||
code: ErrorCode.USER_NOT_FOUND,
|
||||
message: ErrorMessage[ErrorCode.USER_NOT_FOUND],
|
||||
});
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 access token 和 refresh token
|
||||
*/
|
||||
private async generateTokens(user: User) {
|
||||
const payload = {
|
||||
sub: user.id,
|
||||
username: user.username,
|
||||
role: user.role,
|
||||
};
|
||||
|
||||
const [accessToken, refreshToken] = await Promise.all([
|
||||
this.jwtService.signAsync(payload, {
|
||||
secret: this.configService.get('jwt.secret'),
|
||||
expiresIn: this.configService.get('jwt.expiresIn'),
|
||||
}),
|
||||
this.jwtService.signAsync(payload, {
|
||||
secret: this.configService.get('jwt.refreshSecret'),
|
||||
expiresIn: this.configService.get('jwt.refreshExpiresIn'),
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
};
|
||||
}
|
||||
}
|
||||
45
src/modules/auth/dto/auth.dto.ts
Normal file
45
src/modules/auth/dto/auth.dto.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { IsEmail, IsNotEmpty, IsOptional, IsString, MinLength } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class RegisterDto {
|
||||
@ApiProperty({ description: '用户名', example: 'john_doe' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '用户名不能为空' })
|
||||
@MinLength(3, { message: '用户名至少3个字符' })
|
||||
username: string;
|
||||
|
||||
@ApiProperty({ description: '密码', example: 'Password123!' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '密码不能为空' })
|
||||
@MinLength(6, { message: '密码至少6个字符' })
|
||||
password: string;
|
||||
|
||||
@ApiProperty({ description: '邮箱', example: 'john@example.com', required: false })
|
||||
@IsEmail({}, { message: '邮箱格式不正确' })
|
||||
@IsOptional()
|
||||
email?: string;
|
||||
|
||||
@ApiProperty({ description: '手机号', example: '13800138000', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
phone?: string;
|
||||
}
|
||||
|
||||
export class LoginDto {
|
||||
@ApiProperty({ description: '用户名/邮箱/手机号', example: 'john_doe' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '账号不能为空' })
|
||||
account: string;
|
||||
|
||||
@ApiProperty({ description: '密码', example: 'Password123!' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '密码不能为空' })
|
||||
password: string;
|
||||
}
|
||||
|
||||
export class RefreshTokenDto {
|
||||
@ApiProperty({ description: '刷新令牌' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '刷新令牌不能为空' })
|
||||
refreshToken: string;
|
||||
}
|
||||
33
src/modules/auth/jwt.strategy.ts
Normal file
33
src/modules/auth/jwt.strategy.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { AuthService } from './auth.service';
|
||||
import { ErrorCode, ErrorMessage } from '../../common/interfaces/response.interface';
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
constructor(
|
||||
private configService: ConfigService,
|
||||
private authService: AuthService,
|
||||
) {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: configService.get('jwt.secret') || 'default-secret',
|
||||
});
|
||||
}
|
||||
|
||||
async validate(payload: any) {
|
||||
const user = await this.authService.validateUser(payload.sub);
|
||||
|
||||
if (!user) {
|
||||
throw new UnauthorizedException({
|
||||
code: ErrorCode.UNAUTHORIZED,
|
||||
message: ErrorMessage[ErrorCode.UNAUTHORIZED],
|
||||
});
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user