初始化游戏小组管理系统后端项目

- 基于 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:
UGREEN USER
2026-01-28 10:42:06 +08:00
commit b25aa5b143
134 changed files with 30536 additions and 0 deletions

View 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');
});
});
});
});

View 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);
}
}

View 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 {}

View 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();
});
});
});

View 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,
};
}
}

View 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;
}

View 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;
}
}