diff --git a/package.json b/package.json index af81d25..d9a152a 100644 --- a/package.json +++ b/package.json @@ -28,12 +28,16 @@ "@nestjs/platform-express": "^10.4.20", "@nestjs/platform-socket.io": "^10.4.20", "@nestjs/schedule": "^4.1.2", + "@nestjs/typeorm": "^11.0.0", "@nestjs/websockets": "^10.4.20", + "class-transformer": "^0.5.1", + "class-validator": "^0.14.3", + "mysql2": "^3.16.0", "nestjs-pino": "^4.5.0", "pino": "^10.1.0", - "reflect-metadata": "^0.1.14", - "rxjs": "^7.8.2" + "rxjs": "^7.8.2", + "typeorm": "^0.3.28" }, "devDependencies": { "@nestjs/cli": "^10.4.9", diff --git a/src/app.module.ts b/src/app.module.ts index 844dc24..5154842 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,8 +1,10 @@ import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; +import { TypeOrmModule } from '@nestjs/typeorm'; import { AppController } from './app.controller'; import { AppService } from './app.service'; import { LoggerModule } from './core/utils/logger/logger.module'; +import { UsersModule } from './core/db/users/users.module'; @Module({ imports: [ @@ -11,6 +13,17 @@ import { LoggerModule } from './core/utils/logger/logger.module'; envFilePath: '.env', }), LoggerModule, + TypeOrmModule.forRoot({ + type: 'mysql', + host: process.env.DB_HOST, + port: parseInt(process.env.DB_PORT), + username: process.env.DB_USERNAME, + password: process.env.DB_PASSWORD, + database: process.env.DB_NAME, + entities: [__dirname + '/**/*.entity{.ts,.js}'], + synchronize: false, + }), + UsersModule, ], controllers: [AppController], providers: [AppService], diff --git a/src/core/db/.gitkeep b/src/core/db/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/core/db/users/users.dto.ts b/src/core/db/users/users.dto.ts new file mode 100644 index 0000000..ec9f493 --- /dev/null +++ b/src/core/db/users/users.dto.ts @@ -0,0 +1,59 @@ +// src/user/dto/create-user.dto.ts +import { + IsString, + IsEmail, + IsPhoneNumber, + IsInt, + Min, + Max, + IsOptional, + Length, + IsNotEmpty +} from 'class-validator' + +export class CreateUserDto { + // 用户名:必填、字符串、长度1-50 + @IsString() + @IsNotEmpty({ message: '用户名不能为空' }) + @Length(1, 50, { message: '用户名长度需在1-50字符之间' }) + username: string; + + // 邮箱:可选、合法邮箱格式 + @IsOptional() + @IsEmail({}, { message: '邮箱格式不正确' }) + email?: string; + + // 手机号:可选、合法手机号格式(支持全球号码) + @IsOptional() + @IsPhoneNumber(null, { message: '手机号格式不正确' }) + phone?: string; + + // 密码哈希:可选(OAuth登录为空) + @IsOptional() + @IsString({ message: '密码哈希必须是字符串' }) + password_hash?: string; + + // 昵称:必填、字符串、长度1-50 + @IsString() + @IsNotEmpty({ message: '昵称不能为空' }) + @Length(1, 50, { message: '昵称长度需在1-50字符之间' }) + nickname: string; + + // GitHub ID:可选、字符串、长度1-100 + @IsOptional() + @IsString({ message: 'GitHub ID必须是字符串' }) + @Length(1, 100, { message: 'GitHub ID长度需在1-100字符之间' }) + github_id?: string; + + // 头像URL:可选、字符串 + @IsOptional() + @IsString({ message: '头像URL必须是字符串' }) + avatar_url?: string; + + // 角色:可选、数字、1(普通)或9(管理员) + @IsOptional() + @IsInt({ message: '角色必须是数字' }) + @Min(1, { message: '角色值最小为1' }) + @Max(9, { message: '角色值最大为9' }) + role?: number = 1; // 默认普通用户 +} \ No newline at end of file diff --git a/src/core/db/users/users.entity.ts b/src/core/db/users/users.entity.ts new file mode 100644 index 0000000..a8a29b7 --- /dev/null +++ b/src/core/db/users/users.entity.ts @@ -0,0 +1,106 @@ +import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn } from 'typeorm'; + +@Entity('users') // 对应数据库表名 +export class Users { + // id:bigint、主键、非空、唯一、自增 + @PrimaryGeneratedColumn({ + type: 'bigint', + comment: '主键ID' + }) + id: bigint; + + // username:varchar(50)、非空、唯一 + @Column({ + type: 'varchar', + length: 50, + nullable: false, + unique: true, + comment: '唯一用户名/登录名' + }) + username: string; + + // email:varchar(100)、允许空、唯一 + @Column({ + type: 'varchar', + length: 100, + nullable: true, + unique: true, + comment: '邮箱(用于找回/通知)' + }) + email: string; + + // phone:varchar(30)、允许空、唯一 + @Column({ + type: 'varchar', + length: 30, + nullable: true, + unique: true, + comment: '全球电话号码(用于找回/通知)' + }) + phone: string; + + // password_hash:varchar(255)、允许空 + @Column({ + type: 'varchar', + length: 255, + nullable: true, + comment: '密码哈希(OAuth登录为空)' + }) + password_hash: string; + + // nickname:varchar(50)、非空 + @Column({ + type: 'varchar', + length: 50, + nullable: false, + comment: '显示昵称(头顶显示)' + }) + nickname: string; + + // github_id:varchar(100)、允许空、唯一 + @Column({ + type: 'varchar', + length: 100, + nullable: true, + unique: true, + comment: 'GitHub OpenID(第三方登录用)' + }) + github_id: string; + + // avatar_url:varchar(255)、允许空 + @Column({ + type: 'varchar', + length: 255, + nullable: true, + comment: 'GitHub头像或自定义头像URL' + }) + avatar_url: string; + + // role:tinyint、非空、默认1 + @Column({ + type: 'tinyint', + nullable: false, + default: 1, + comment: '角色:1-普通,9-管理员' + }) + role: number; + + // created_at:datetime、非空、默认当前时间 + @CreateDateColumn({ + type: 'datetime', + nullable: false, + default: () => 'CURRENT_TIMESTAMP', + comment: '注册时间' + }) + created_at: Date; + + // updated_at:datetime、非空、默认当前时间 + @UpdateDateColumn({ + type: 'datetime', + nullable: false, + default: () => 'CURRENT_TIMESTAMP', + onUpdate: 'CURRENT_TIMESTAMP', // 数据库更新时自动刷新时间 + comment: '更新时间' + }) + updated_at: Date; +} \ No newline at end of file diff --git a/src/core/db/users/users.module.ts b/src/core/db/users/users.module.ts new file mode 100644 index 0000000..fef0d77 --- /dev/null +++ b/src/core/db/users/users.module.ts @@ -0,0 +1,26 @@ +/** + * 用户模块 + * + * 功能描述: + * - 整合用户相关的实体、服务和控制器 + * - 配置TypeORM实体和Repository + * - 导出用户服务供其他模块使用 + * + * @author moyin + * @version 1.0.0 + * @since 2024-12-17 + */ + +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Users } from './users.entity'; +import { UsersService } from './users.service'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([Users]) + ], + providers: [UsersService], + exports: [UsersService, TypeOrmModule], +}) +export class UsersModule {} \ No newline at end of file diff --git a/src/core/db/users/users.service.spec.ts b/src/core/db/users/users.service.spec.ts new file mode 100644 index 0000000..b1f1f96 --- /dev/null +++ b/src/core/db/users/users.service.spec.ts @@ -0,0 +1,491 @@ +/** + * 用户实体、DTO和服务的完整测试套件 + * + * 功能: + * - 测试Users实体的结构和装饰器 + * - 测试CreateUserDto的验证规则 + * - 测试UsersService的所有CRUD操作 + * - 验证数据类型和约束条件 + * + * @author moyin + * @version 1.0.0 + * @since 2024-12-17 + */ + +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository, DataSource } from 'typeorm'; +import { ConflictException, NotFoundException, BadRequestException } from '@nestjs/common'; +import { validate } from 'class-validator'; +import { plainToClass } from 'class-transformer'; + +import { Users } from './users.entity'; +import { CreateUserDto } from './users.dto'; +import { UsersService } from './users.service'; + +describe('Users Entity, DTO and Service Tests', () => { + let service: UsersService; + let repository: Repository; + let module: TestingModule; + + // 模拟的Repository方法 + const mockRepository = { + save: jest.fn(), + find: jest.fn(), + findOne: jest.fn(), + delete: jest.fn(), + softRemove: jest.fn(), + count: jest.fn(), + createQueryBuilder: jest.fn(), + }; + + // 测试数据 + const mockUser: Users = { + id: BigInt(1), + username: 'testuser', + email: 'test@example.com', + phone: '+8613800138000', + password_hash: 'hashed_password', + nickname: '测试用户', + github_id: 'github_123', + avatar_url: 'https://example.com/avatar.jpg', + role: 1, + created_at: new Date(), + updated_at: new Date(), + }; + + const createUserDto: CreateUserDto = { + username: 'testuser', + email: 'test@example.com', + phone: '+8613800138000', + password_hash: 'hashed_password', + nickname: '测试用户', + github_id: 'github_123', + avatar_url: 'https://example.com/avatar.jpg', + role: 1 + }; + + beforeEach(async () => { + module = await Test.createTestingModule({ + providers: [ + UsersService, + { + provide: getRepositoryToken(Users), + useValue: mockRepository, + }, + ], + }).compile(); + + service = module.get(UsersService); + repository = module.get>(getRepositoryToken(Users)); + + // 清理所有mock + jest.clearAllMocks(); + }); + + afterEach(async () => { + await module.close(); + }); + + describe('Users Entity Tests', () => { + it('应该正确创建用户实体实例', () => { + const user = new Users(); + user.username = 'testuser'; + user.email = 'test@example.com'; + user.phone = '+8613800138000'; + user.password_hash = 'hashed_password'; + user.nickname = '测试用户'; + user.github_id = 'github_123'; + user.avatar_url = 'https://example.com/avatar.jpg'; + user.role = 1; + user.created_at = new Date(); + user.updated_at = new Date(); + + expect(user.username).toBe('testuser'); + expect(user.email).toBe('test@example.com'); + expect(user.nickname).toBe('测试用户'); + expect(user.role).toBe(1); + }); + + it('应该能够设置和获取所有属性', () => { + const user = new Users(); + + // 验证可以设置和获取所有属性 + user.username = 'testuser'; + user.email = 'test@example.com'; + user.phone = '+8613800138000'; + user.password_hash = 'hashed_password'; + user.nickname = '测试用户'; + user.github_id = 'github_123'; + user.avatar_url = 'https://example.com/avatar.jpg'; + user.role = 1; + user.created_at = new Date(); + user.updated_at = new Date(); + + expect(user.username).toBe('testuser'); + expect(user.email).toBe('test@example.com'); + expect(user.phone).toBe('+8613800138000'); + expect(user.password_hash).toBe('hashed_password'); + expect(user.nickname).toBe('测试用户'); + expect(user.github_id).toBe('github_123'); + expect(user.avatar_url).toBe('https://example.com/avatar.jpg'); + expect(user.role).toBe(1); + expect(user.created_at).toBeInstanceOf(Date); + expect(user.updated_at).toBeInstanceOf(Date); + }); + }); + + describe('CreateUserDto Validation Tests', () => { + it('应该通过有效数据的验证', async () => { + const validData = { + username: 'testuser', + email: 'test@example.com', + phone: '+8613800138000', + password_hash: 'hashed_password', + nickname: '测试用户', + github_id: 'github_123', + avatar_url: 'https://example.com/avatar.jpg', + role: 1 + }; + + const dto = plainToClass(CreateUserDto, validData); + const errors = await validate(dto); + + expect(errors).toHaveLength(0); + }); + + it('应该拒绝缺少必填字段的数据', async () => { + const invalidData = { + email: 'test@example.com' + // 缺少 username 和 nickname + }; + + const dto = plainToClass(CreateUserDto, invalidData); + const errors = await validate(dto); + + expect(errors.length).toBeGreaterThan(0); + + const usernameError = errors.find(error => error.property === 'username'); + const nicknameError = errors.find(error => error.property === 'nickname'); + + expect(usernameError).toBeDefined(); + expect(nicknameError).toBeDefined(); + }); + + it('应该拒绝无效的邮箱格式', async () => { + const invalidData = { + username: 'testuser', + nickname: '测试用户', + email: 'invalid-email' + }; + + const dto = plainToClass(CreateUserDto, invalidData); + const errors = await validate(dto); + + const emailError = errors.find(error => error.property === 'email'); + expect(emailError).toBeDefined(); + }); + + it('应该拒绝超长的用户名', async () => { + const invalidData = { + username: 'a'.repeat(51), // 超过50字符 + nickname: '测试用户' + }; + + const dto = plainToClass(CreateUserDto, invalidData); + const errors = await validate(dto); + + const usernameError = errors.find(error => error.property === 'username'); + expect(usernameError).toBeDefined(); + }); + + it('应该拒绝无效的角色值', async () => { + const invalidData = { + username: 'testuser', + nickname: '测试用户', + role: 10 // 超出1-9范围 + }; + + const dto = plainToClass(CreateUserDto, invalidData); + const errors = await validate(dto); + + const roleError = errors.find(error => error.property === 'role'); + expect(roleError).toBeDefined(); + }); + }); + + describe('UsersService CRUD Tests', () => { + + describe('create()', () => { + it('应该成功创建新用户', async () => { + mockRepository.findOne.mockResolvedValue(null); // 没有重复用户 + mockRepository.save.mockResolvedValue(mockUser); + + const result = await service.create(createUserDto); + + expect(mockRepository.save).toHaveBeenCalled(); + expect(result).toEqual(mockUser); + }); + + it('应该在用户名重复时抛出ConflictException', async () => { + mockRepository.findOne.mockResolvedValue(mockUser); // 模拟用户名已存在 + + await expect(service.create(createUserDto)).rejects.toThrow(ConflictException); + expect(mockRepository.save).not.toHaveBeenCalled(); + }); + + it('应该在数据验证失败时抛出BadRequestException', async () => { + const invalidDto = { username: '', nickname: '' }; // 无效数据 + + await expect(service.create(invalidDto as CreateUserDto)).rejects.toThrow(BadRequestException); + }); + }); + + describe('findAll()', () => { + it('应该返回用户列表', async () => { + const mockUsers = [mockUser]; + mockRepository.find.mockResolvedValue(mockUsers); + + const result = await service.findAll(); + + expect(mockRepository.find).toHaveBeenCalledWith({ + take: 100, + skip: 0, + order: { created_at: 'DESC' } + }); + expect(result).toEqual(mockUsers); + }); + + it('应该支持分页参数', async () => { + mockRepository.find.mockResolvedValue([]); + + await service.findAll(50, 10); + + expect(mockRepository.find).toHaveBeenCalledWith({ + take: 50, + skip: 10, + order: { created_at: 'DESC' } + }); + }); + }); + + describe('findOne()', () => { + it('应该根据ID返回用户', async () => { + mockRepository.findOne.mockResolvedValue(mockUser); + + const result = await service.findOne(BigInt(1)); + + expect(mockRepository.findOne).toHaveBeenCalledWith({ + where: { id: BigInt(1) } + }); + expect(result).toEqual(mockUser); + }); + + it('应该在用户不存在时抛出NotFoundException', async () => { + mockRepository.findOne.mockResolvedValue(null); + + await expect(service.findOne(BigInt(999))).rejects.toThrow(NotFoundException); + }); + }); + + describe('findByUsername()', () => { + it('应该根据用户名返回用户', async () => { + mockRepository.findOne.mockResolvedValue(mockUser); + + const result = await service.findByUsername('testuser'); + + expect(mockRepository.findOne).toHaveBeenCalledWith({ + where: { username: 'testuser' } + }); + expect(result).toEqual(mockUser); + }); + + it('应该在用户不存在时返回null', async () => { + mockRepository.findOne.mockResolvedValue(null); + + const result = await service.findByUsername('nonexistent'); + + expect(result).toBeNull(); + }); + }); + + describe('findByEmail()', () => { + it('应该根据邮箱返回用户', async () => { + mockRepository.findOne.mockResolvedValue(mockUser); + + const result = await service.findByEmail('test@example.com'); + + expect(mockRepository.findOne).toHaveBeenCalledWith({ + where: { email: 'test@example.com' } + }); + expect(result).toEqual(mockUser); + }); + }); + + describe('update()', () => { + it('应该成功更新用户信息', async () => { + const updatedUser = { ...mockUser, nickname: '更新后的昵称' }; + + mockRepository.findOne + .mockResolvedValueOnce(mockUser) // findOne in update method + .mockResolvedValueOnce(null); // 检查昵称是否重复 + mockRepository.save.mockResolvedValue(updatedUser); + + const result = await service.update(BigInt(1), { nickname: '更新后的昵称' }); + + expect(mockRepository.save).toHaveBeenCalled(); + expect(result.nickname).toBe('更新后的昵称'); + }); + + it('应该在更新不存在的用户时抛出NotFoundException', async () => { + mockRepository.findOne.mockResolvedValue(null); + + await expect(service.update(BigInt(999), { nickname: '新昵称' })).rejects.toThrow(NotFoundException); + }); + + it('应该在更新数据冲突时抛出ConflictException', async () => { + mockRepository.findOne + .mockResolvedValueOnce(mockUser) // 找到要更新的用户 + .mockResolvedValueOnce(mockUser); // 发现用户名冲突 + + await expect(service.update(BigInt(1), { username: 'conflictuser' })).rejects.toThrow(ConflictException); + }); + }); + + describe('remove()', () => { + it('应该成功删除用户', async () => { + mockRepository.findOne.mockResolvedValue(mockUser); + mockRepository.delete.mockResolvedValue({ affected: 1 }); + + const result = await service.remove(BigInt(1)); + + expect(mockRepository.delete).toHaveBeenCalledWith({ id: BigInt(1) }); + expect(result.affected).toBe(1); + expect(result.message).toContain('成功删除'); + }); + + it('应该在删除不存在的用户时抛出NotFoundException', async () => { + mockRepository.findOne.mockResolvedValue(null); + + await expect(service.remove(BigInt(999))).rejects.toThrow(NotFoundException); + }); + }); + + describe('count()', () => { + it('应该返回用户总数', async () => { + mockRepository.count.mockResolvedValue(10); + + const result = await service.count(); + + expect(mockRepository.count).toHaveBeenCalled(); + expect(result).toBe(10); + }); + }); + + describe('exists()', () => { + it('应该在用户存在时返回true', async () => { + mockRepository.count.mockResolvedValue(1); + + const result = await service.exists(BigInt(1)); + + expect(result).toBe(true); + }); + + it('应该在用户不存在时返回false', async () => { + mockRepository.count.mockResolvedValue(0); + + const result = await service.exists(BigInt(999)); + + expect(result).toBe(false); + }); + }); + + describe('search()', () => { + it('应该根据关键词搜索用户', async () => { + const mockQueryBuilder = { + where: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + limit: jest.fn().mockReturnThis(), + getMany: jest.fn().mockResolvedValue([mockUser]), + }; + + mockRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder); + + const result = await service.search('test'); + + expect(mockRepository.createQueryBuilder).toHaveBeenCalledWith('user'); + expect(mockQueryBuilder.where).toHaveBeenCalled(); + expect(result).toEqual([mockUser]); + }); + }); + + describe('findByRole()', () => { + it('应该根据角色查询用户', async () => { + mockRepository.find.mockResolvedValue([mockUser]); + + const result = await service.findByRole(1); + + expect(mockRepository.find).toHaveBeenCalledWith({ + where: { role: 1 }, + order: { created_at: 'DESC' } + }); + expect(result).toEqual([mockUser]); + }); + }); + }); + + describe('Integration Tests', () => { + it('应该完成从DTO到Entity的完整流程', async () => { + // 1. 验证DTO + const dto = plainToClass(CreateUserDto, createUserDto); + const validationErrors = await validate(dto); + expect(validationErrors).toHaveLength(0); + + // 2. 创建匹配的mock用户数据 + const expectedUser = { ...mockUser, nickname: dto.nickname }; + + // 3. 模拟服务创建用户 + mockRepository.findOne.mockResolvedValue(null); + mockRepository.save.mockResolvedValue(expectedUser); + + const result = await service.create(dto); + + // 4. 验证结果 + expect(result).toBeDefined(); + expect(result.username).toBe(dto.username); + expect(result.nickname).toBe(dto.nickname); + }); + + it('应该正确处理可选字段', async () => { + const minimalDto: CreateUserDto = { + username: 'minimaluser', + nickname: '最小用户' + }; + + const dto = plainToClass(CreateUserDto, minimalDto); + const validationErrors = await validate(dto); + expect(validationErrors).toHaveLength(0); + + // 验证可选字段的默认值处理 + expect(dto.role).toBe(1); // 默认角色 + expect(dto.email).toBeUndefined(); + expect(dto.phone).toBeUndefined(); + }); + }); + + describe('Error Handling Tests', () => { + it('应该正确处理数据库连接错误', async () => { + mockRepository.save.mockRejectedValue(new Error('Database connection failed')); + + await expect(service.create(createUserDto)).rejects.toThrow('Database connection failed'); + }); + + it('应该正确处理并发创建冲突', async () => { + // 模拟并发情况:检查时不存在,保存时出现唯一约束错误 + mockRepository.findOne.mockResolvedValue(null); + mockRepository.save.mockRejectedValue(new Error('Duplicate entry')); + + await expect(service.create(createUserDto)).rejects.toThrow('Duplicate entry'); + }); + }); +}); \ No newline at end of file diff --git a/src/core/db/users/users.service.ts b/src/core/db/users/users.service.ts new file mode 100644 index 0000000..fb7fea3 --- /dev/null +++ b/src/core/db/users/users.service.ts @@ -0,0 +1,330 @@ +/** + * 用户服务类 + * + * 功能描述: + * - 提供用户的增删改查操作 + * - 处理用户数据的业务逻辑 + * - 数据验证和错误处理 + * + * @author moyin + * @version 1.0.0 + * @since 2024-12-17 + */ + +import { Injectable, ConflictException, NotFoundException, BadRequestException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, FindOptionsWhere } from 'typeorm'; +import { Users } from './users.entity'; +import { CreateUserDto } from './users.dto'; +import { validate } from 'class-validator'; +import { plainToClass } from 'class-transformer'; + +@Injectable() +export class UsersService { + constructor( + @InjectRepository(Users) + private readonly usersRepository: Repository, + ) {} + + /** + * 创建新用户 + * + * @param createUserDto 创建用户的数据传输对象 + * @returns 创建的用户实体 + * @throws ConflictException 当用户名、邮箱或手机号已存在时 + * @throws BadRequestException 当数据验证失败时 + */ + async create(createUserDto: CreateUserDto): Promise { + // 验证DTO + const dto = plainToClass(CreateUserDto, createUserDto); + const validationErrors = await validate(dto); + + if (validationErrors.length > 0) { + const errorMessages = validationErrors.map(error => + Object.values(error.constraints || {}).join(', ') + ).join('; '); + throw new BadRequestException(`数据验证失败: ${errorMessages}`); + } + + // 检查用户名是否已存在 + if (createUserDto.username) { + const existingUser = await this.usersRepository.findOne({ + where: { username: createUserDto.username } + }); + if (existingUser) { + throw new ConflictException('用户名已存在'); + } + } + + // 检查邮箱是否已存在 + if (createUserDto.email) { + const existingEmail = await this.usersRepository.findOne({ + where: { email: createUserDto.email } + }); + if (existingEmail) { + throw new ConflictException('邮箱已存在'); + } + } + + // 检查手机号是否已存在 + if (createUserDto.phone) { + const existingPhone = await this.usersRepository.findOne({ + where: { phone: createUserDto.phone } + }); + if (existingPhone) { + throw new ConflictException('手机号已存在'); + } + } + + // 检查GitHub ID是否已存在 + if (createUserDto.github_id) { + const existingGithub = await this.usersRepository.findOne({ + where: { github_id: createUserDto.github_id } + }); + if (existingGithub) { + throw new ConflictException('GitHub ID已存在'); + } + } + + // 创建用户实体 + const user = new Users(); + user.username = createUserDto.username; + user.email = createUserDto.email || null; + user.phone = createUserDto.phone || null; + user.password_hash = createUserDto.password_hash || null; + user.nickname = createUserDto.nickname; + user.github_id = createUserDto.github_id || null; + user.avatar_url = createUserDto.avatar_url || null; + user.role = createUserDto.role || 1; + + // 保存到数据库 + return await this.usersRepository.save(user); + } + + /** + * 查询所有用户 + * + * @param limit 限制返回数量,默认100 + * @param offset 偏移量,默认0 + * @returns 用户列表 + */ + async findAll(limit: number = 100, offset: number = 0): Promise { + return await this.usersRepository.find({ + take: limit, + skip: offset, + order: { created_at: 'DESC' } + }); + } + + /** + * 根据ID查询用户 + * + * @param id 用户ID + * @returns 用户实体 + * @throws NotFoundException 当用户不存在时 + */ + async findOne(id: bigint): Promise { + const user = await this.usersRepository.findOne({ + where: { id } + }); + + if (!user) { + throw new NotFoundException(`ID为 ${id} 的用户不存在`); + } + + return user; + } + + /** + * 根据用户名查询用户 + * + * @param username 用户名 + * @returns 用户实体或null + */ + async findByUsername(username: string): Promise { + return await this.usersRepository.findOne({ + where: { username } + }); + } + + /** + * 根据邮箱查询用户 + * + * @param email 邮箱 + * @returns 用户实体或null + */ + async findByEmail(email: string): Promise { + return await this.usersRepository.findOne({ + where: { email } + }); + } + + /** + * 根据GitHub ID查询用户 + * + * @param githubId GitHub ID + * @returns 用户实体或null + */ + async findByGithubId(githubId: string): Promise { + return await this.usersRepository.findOne({ + where: { github_id: githubId } + }); + } + + /** + * 更新用户信息 + * + * @param id 用户ID + * @param updateData 更新的数据 + * @returns 更新后的用户实体 + * @throws NotFoundException 当用户不存在时 + * @throws ConflictException 当更新的数据与其他用户冲突时 + */ + async update(id: bigint, updateData: Partial): Promise { + // 检查用户是否存在 + const existingUser = await this.findOne(id); + + // 检查更新数据的唯一性约束 + if (updateData.username && updateData.username !== existingUser.username) { + const usernameExists = await this.usersRepository.findOne({ + where: { username: updateData.username } + }); + if (usernameExists) { + throw new ConflictException('用户名已存在'); + } + } + + if (updateData.email && updateData.email !== existingUser.email) { + const emailExists = await this.usersRepository.findOne({ + where: { email: updateData.email } + }); + if (emailExists) { + throw new ConflictException('邮箱已存在'); + } + } + + if (updateData.phone && updateData.phone !== existingUser.phone) { + const phoneExists = await this.usersRepository.findOne({ + where: { phone: updateData.phone } + }); + if (phoneExists) { + throw new ConflictException('手机号已存在'); + } + } + + if (updateData.github_id && updateData.github_id !== existingUser.github_id) { + const githubExists = await this.usersRepository.findOne({ + where: { github_id: updateData.github_id } + }); + if (githubExists) { + throw new ConflictException('GitHub ID已存在'); + } + } + + // 更新用户数据 + Object.assign(existingUser, updateData); + + return await this.usersRepository.save(existingUser); + } + + /** + * 删除用户 + * + * @param id 用户ID + * @returns 删除操作结果 + * @throws NotFoundException 当用户不存在时 + */ + async remove(id: bigint): Promise<{ affected: number; message: string }> { + // 检查用户是否存在 + await this.findOne(id); + + // 执行删除 - 使用where条件来处理bigint类型 + const result = await this.usersRepository.delete({ id }); + + return { + affected: result.affected || 0, + message: `成功删除ID为 ${id} 的用户` + }; + } + + /** + * 软删除用户(如果需要保留数据) + * 注意:需要在实体中添加 @DeleteDateColumn 装饰器 + * + * @param id 用户ID + * @returns 软删除操作结果 + */ + async softRemove(id: bigint): Promise { + const user = await this.findOne(id); + return await this.usersRepository.softRemove(user); + } + + /** + * 统计用户数量 + * + * @param conditions 查询条件 + * @returns 用户数量 + */ + async count(conditions?: FindOptionsWhere): Promise { + return await this.usersRepository.count({ where: conditions }); + } + + /** + * 检查用户是否存在 + * + * @param id 用户ID + * @returns 是否存在 + */ + async exists(id: bigint): Promise { + const count = await this.usersRepository.count({ where: { id } }); + return count > 0; + } + + /** + * 批量创建用户 + * + * @param createUserDtos 用户数据数组 + * @returns 创建的用户列表 + */ + async createBatch(createUserDtos: CreateUserDto[]): Promise { + const users: Users[] = []; + + for (const dto of createUserDtos) { + const user = await this.create(dto); + users.push(user); + } + + return users; + } + + /** + * 根据角色查询用户 + * + * @param role 角色值 + * @returns 用户列表 + */ + async findByRole(role: number): Promise { + return await this.usersRepository.find({ + where: { role }, + order: { created_at: 'DESC' } + }); + } + + /** + * 搜索用户(根据用户名或昵称) + * + * @param keyword 搜索关键词 + * @param limit 限制数量 + * @returns 用户列表 + */ + async search(keyword: string, limit: number = 20): Promise { + return await this.usersRepository + .createQueryBuilder('user') + .where('user.username LIKE :keyword OR user.nickname LIKE :keyword', { + keyword: `%${keyword}%` + }) + .orderBy('user.created_at', 'DESC') + .limit(limit) + .getMany(); + } +} \ No newline at end of file diff --git a/src/core/utils/.gitkeep b/src/core/utils/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/core/utils/logger/log-management.service.ts b/src/core/utils/logger/log_management.service.ts similarity index 99% rename from src/core/utils/logger/log-management.service.ts rename to src/core/utils/logger/log_management.service.ts index 2a1f947..f3de155 100644 --- a/src/core/utils/logger/log-management.service.ts +++ b/src/core/utils/logger/log_management.service.ts @@ -12,9 +12,9 @@ * - AppLoggerService: 应用日志服务 * - ScheduleModule: 定时任务模块 * - * @author 开发团队 + * @author moyin * @version 1.0.0 - * @since 2024-12-13 + * @since 2025-12-13 */ import { Injectable } from '@nestjs/common'; diff --git a/src/core/utils/logger/logger.config.ts b/src/core/utils/logger/logger.config.ts index 9f3fa2f..77fc642 100644 --- a/src/core/utils/logger/logger.config.ts +++ b/src/core/utils/logger/logger.config.ts @@ -7,9 +7,9 @@ * - 根据环境自动调整日志策略 * - 提供日志文件清理和归档功能 * - * @author 开发团队 + * @author moyin * @version 1.0.0 - * @since 2024-12-13 + * @since 2025-12-13 */ import { ConfigService } from '@nestjs/config'; @@ -82,7 +82,7 @@ export class LoggerConfigFactory { }, // 自定义错误响应消息 - customErrorMessage: (req: any, res: any, err: any) => { + customErrorMessage: (req: any, _res: any, err: any) => { return `${req.method} ${req.url} failed: ${err.message}`; }, }, @@ -152,20 +152,7 @@ export class LoggerConfigFactory { translateTime: 'SYS:yyyy-mm-dd HH:MM:ss', ignore: 'pid,hostname', messageFormat: '{app} [{level}] {msg}', - customPrettifiers: { - time: (timestamp: any) => `🕐 ${timestamp}`, - level: (logLevel: any) => { - const levelEmojis: Record = { - 10: '🔍', // trace - 20: '🐛', // debug - 30: '📝', // info - 40: '⚠️', // warn - 50: '❌', // error - 60: '💀', // fatal - }; - return `${levelEmojis[logLevel] || '📝'} ${logLevel}`; - }, - }, + // 移除 customPrettifiers 以避免 Worker 线程序列化问题 }, level: logLevel, }, @@ -231,13 +218,13 @@ export class LoggerConfigFactory { /** * 自定义日志级别判断 * - * @param req HTTP 请求对象 + * @param _req HTTP 请求对象 * @param res HTTP 响应对象 * @param err 错误对象 * @returns 日志级别 * @private */ - private static customLogLevel(req: any, res: any, err: any) { + private static customLogLevel(_req: any, res: any, err: any) { if (res.statusCode >= 400 && res.statusCode < 500) { return 'warn'; } else if (res.statusCode >= 500 || err) { @@ -255,7 +242,7 @@ export class LoggerConfigFactory { * @private */ private static generateRequestId(): string { - return `req_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + return `req_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`; } /** diff --git a/src/core/utils/logger/logger.module.ts b/src/core/utils/logger/logger.module.ts index 8195333..0bb7d9c 100644 --- a/src/core/utils/logger/logger.module.ts +++ b/src/core/utils/logger/logger.module.ts @@ -12,9 +12,9 @@ * - PinoLoggerModule: Pino 日志模块 * - AppLoggerService: 应用日志服务 * - * @author 开发团队 + * @author moyin * @version 1.0.0 - * @since 2024-12-13 + * @since 2025-12-13 */ import { Module } from '@nestjs/common'; @@ -23,7 +23,7 @@ import { LoggerModule as PinoLoggerModule } from 'nestjs-pino'; import { ScheduleModule } from '@nestjs/schedule'; import { AppLoggerService } from './logger.service'; import { LoggerConfigFactory } from './logger.config'; -import { LogManagementService } from './log-management.service'; +import { LogManagementService } from './log_management.service'; /** * 日志模块类 diff --git a/src/core/utils/logger/logger.service.spec.ts b/src/core/utils/logger/logger.service.spec.ts index 5851548..c781238 100644 --- a/src/core/utils/logger/logger.service.spec.ts +++ b/src/core/utils/logger/logger.service.spec.ts @@ -15,7 +15,7 @@ * * @author moyin * @version 1.0.0 - * @since 2024-12-13 + * @since 2025-12-13 */ import { Test, TestingModule } from '@nestjs/testing'; diff --git a/src/core/utils/logger/logger.service.ts b/src/core/utils/logger/logger.service.ts index ac59b42..d3d4da4 100644 --- a/src/core/utils/logger/logger.service.ts +++ b/src/core/utils/logger/logger.service.ts @@ -14,7 +14,7 @@ * * @author moyin * @version 1.0.0 - * @since 2024-12-13 + * @since 2025-12-13 */ import { Injectable, Logger, Inject, Optional } from '@nestjs/common'; diff --git a/src/main.ts b/src/main.ts index cd3ba5e..1ea34d4 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,8 +1,17 @@ import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; +import { ValidationPipe } from '@nestjs/common'; async function bootstrap() { const app = await NestFactory.create(AppModule); + // 全局启用校验管道(核心配置) + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, // 过滤掉 DTO 中未定义的字段(比如传了个 `age` 但 DTO 里没有,会自动忽略) + forbidNonWhitelisted: true, // 若传了未定义的字段,直接报错(防止传多余参数) + transform: true, // 自动把入参转为 DTO 对应的类型(比如前端传的字符串数字 `'1'` 转为数字 `1`) + }), + ); await app.listen(3000); console.log('Pixel Game Server is running on http://localhost:3000'); } diff --git a/test-users-functionality.ts b/test-users-functionality.ts new file mode 100644 index 0000000..ab9c9cd --- /dev/null +++ b/test-users-functionality.ts @@ -0,0 +1,95 @@ +/** + * 用户功能测试脚本 + * + * 使用方法:npx ts-node test-users-functionality.ts + */ + +import 'reflect-metadata'; +import { NestFactory } from '@nestjs/core'; +import { AppModule } from './src/app.module'; +import { UsersService } from './src/core/db/users/users.service'; +import { CreateUserDto } from './src/core/db/users/users.dto'; + +async function testUsersFunctionality() { + console.log('🚀 启动用户功能测试...\n'); + + try { + // 创建NestJS应用 + const app = await NestFactory.createApplicationContext(AppModule, { + logger: false, // 禁用日志以保持输出清洁 + }); + + // 获取用户服务 + const usersService = app.get(UsersService); + console.log('✅ 成功获取UsersService实例'); + + // 测试数据 + const testUserDto: CreateUserDto = { + username: `testuser_${Date.now()}`, + email: `test_${Date.now()}@example.com`, + phone: `+86138${Date.now().toString().slice(-8)}`, + password_hash: 'hashed_password_123', + nickname: '功能测试用户', + github_id: `github_${Date.now()}`, + avatar_url: 'https://example.com/avatar.jpg', + role: 1 + }; + + console.log('\n📝 测试创建用户...'); + const createdUser = await usersService.create(testUserDto); + console.log('✅ 用户创建成功:', { + id: createdUser.id.toString(), + username: createdUser.username, + nickname: createdUser.nickname, + email: createdUser.email + }); + + console.log('\n🔍 测试查询用户...'); + const foundUser = await usersService.findOne(createdUser.id); + console.log('✅ 用户查询成功:', foundUser.username); + + console.log('\n📊 测试用户统计...'); + const userCount = await usersService.count(); + console.log('✅ 当前用户总数:', userCount); + + console.log('\n🔍 测试根据用户名查询...'); + const userByUsername = await usersService.findByUsername(createdUser.username); + console.log('✅ 根据用户名查询成功:', userByUsername?.nickname); + + console.log('\n✏️ 测试更新用户...'); + const updatedUser = await usersService.update(createdUser.id, { + nickname: '更新后的昵称' + }); + console.log('✅ 用户更新成功:', updatedUser.nickname); + + console.log('\n📋 测试查询所有用户...'); + const allUsers = await usersService.findAll(5); // 限制5个 + console.log('✅ 查询到用户数量:', allUsers.length); + + console.log('\n🔍 测试搜索功能...'); + const searchResults = await usersService.search('测试'); + console.log('✅ 搜索结果数量:', searchResults.length); + + console.log('\n🗑️ 测试删除用户...'); + const deleteResult = await usersService.remove(createdUser.id); + console.log('✅ 用户删除成功:', deleteResult.message); + + // 验证删除 + console.log('\n✅ 验证删除结果...'); + try { + await usersService.findOne(createdUser.id); + console.log('❌ 删除验证失败:用户仍然存在'); + } catch (error) { + console.log('✅ 删除验证成功:用户已不存在'); + } + + await app.close(); + console.log('\n🎉 所有功能测试通过!'); + + } catch (error) { + console.error('❌ 测试失败:', error); + process.exit(1); + } +} + +testUsersFunctionality(); \ No newline at end of file diff --git a/src/business/.gitkeep b/test/core/db/users.test.ts similarity index 100% rename from src/business/.gitkeep rename to test/core/db/users.test.ts