feat: 优化项目目录结构
This commit is contained in:
11
apps/backend/src/user/dto/update-user-password.dto.ts
Normal file
11
apps/backend/src/user/dto/update-user-password.dto.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { IsString, Length, Matches } from 'class-validator';
|
||||
|
||||
export class UpdateUserPasswordDto {
|
||||
@IsString({ message: '密码不得为空' })
|
||||
@Length(6, 32, { message: '密码长度只能为6~32' })
|
||||
@Matches(
|
||||
/^(?=.*[a-zA-Z])(?=.*\d)[a-zA-Z\d!@#$%^&*()_+\-=\[\]{};:'",.<>/?]{6,32}$/,
|
||||
{ message: '密码必须包含字母和数字,且长度在6~32之间' },
|
||||
)
|
||||
password: string;
|
||||
}
|
||||
31
apps/backend/src/user/entities/user-session.entity.ts
Normal file
31
apps/backend/src/user/entities/user-session.entity.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
DeleteDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
PrimaryGeneratedColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
@Entity()
|
||||
@Index(['sessionId', 'userId'])
|
||||
export class UserSession {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ length: 36 })
|
||||
sessionId: string;
|
||||
|
||||
@Column({ length: 36 })
|
||||
userId: string;
|
||||
|
||||
@CreateDateColumn({ precision: 3 })
|
||||
createdAt: Date;
|
||||
|
||||
@DeleteDateColumn({ nullable: true, precision: 3 })
|
||||
deletedAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* 考虑是否使用sessionId代替id,以节省存储空间
|
||||
*/
|
||||
91
apps/backend/src/user/entities/user.entity.ts
Normal file
91
apps/backend/src/user/entities/user.entity.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { Role } from 'src/auth/role.enum';
|
||||
import {
|
||||
BeforeInsert,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
DeleteDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
@Entity()
|
||||
@Index('IDX_user_userid', ['userId'], { unique: true })
|
||||
@Index('IDX_user_username', ['username'], { unique: true })
|
||||
@Index('IDX_user_email', ['email'], {
|
||||
unique: true,
|
||||
where: 'email IS NOT NULL',
|
||||
})
|
||||
@Index('IDX_user_phone', ['phone'], {
|
||||
unique: true,
|
||||
where: 'phone IS NOT NULL',
|
||||
})
|
||||
export class User {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
userId: string;
|
||||
|
||||
@Column({ length: 32 })
|
||||
username: string;
|
||||
|
||||
@Column({ length: 30 })
|
||||
nickname: string;
|
||||
|
||||
@BeforeInsert()
|
||||
generateDefaults() {
|
||||
if (!this.username) {
|
||||
this.username = `user_${uuidv4().replace(/-/g, '').slice(0, 27)}`;
|
||||
}
|
||||
if (!this.nickname) {
|
||||
this.nickname = `用户_${uuidv4().replace(/-/g, '').slice(0, 8)}`;
|
||||
}
|
||||
}
|
||||
|
||||
@Column({ nullable: true, type: 'char', length: 32 })
|
||||
salt: string;
|
||||
|
||||
@Column({ nullable: true, type: 'char', length: 64 })
|
||||
password_hash: string;
|
||||
|
||||
@Column({
|
||||
nullable: true,
|
||||
length: 254,
|
||||
transformer: {
|
||||
to: (value: string | null) => value?.trim() || null,
|
||||
from: (value: string | null) => value,
|
||||
},
|
||||
}) // RFC 5321
|
||||
email: string | null;
|
||||
|
||||
@Column({
|
||||
nullable: true,
|
||||
length: 20,
|
||||
transformer: {
|
||||
to: (value: string | null) => value?.trim() || null,
|
||||
from: (value: string | null) => value,
|
||||
},
|
||||
}) // China Mainland
|
||||
phone: string | null;
|
||||
|
||||
@Column({
|
||||
nullable: true,
|
||||
transformer: {
|
||||
to: (value: string | null) => value?.trim() || null,
|
||||
from: (value: string | null) => value,
|
||||
},
|
||||
})
|
||||
avatar: string;
|
||||
|
||||
@CreateDateColumn({ precision: 3 })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ precision: 3 })
|
||||
updatedAt: Date;
|
||||
|
||||
@DeleteDateColumn({ nullable: true, precision: 3 })
|
||||
deletedAt: Date;
|
||||
|
||||
@Column('simple-array', { default: '' })
|
||||
roles: Role[];
|
||||
}
|
||||
46
apps/backend/src/user/services/user-session.service.ts
Normal file
46
apps/backend/src/user/services/user-session.service.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { UserSession } from '../entities/user-session.entity';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
@Injectable()
|
||||
export class UserSessionService {
|
||||
constructor(
|
||||
@InjectRepository(UserSession)
|
||||
private readonly userSessionRepository: Repository<UserSession>,
|
||||
) {}
|
||||
|
||||
async createSession(userId: string, sessionId: string): Promise<UserSession> {
|
||||
const session = this.userSessionRepository.create({
|
||||
userId,
|
||||
sessionId,
|
||||
});
|
||||
return await this.userSessionRepository.save(session);
|
||||
}
|
||||
|
||||
async isSessionValid(userId: string, sessionId: string): Promise<boolean> {
|
||||
const session = await this.userSessionRepository.findOne({
|
||||
where: {
|
||||
userId,
|
||||
sessionId,
|
||||
deletedAt: null,
|
||||
},
|
||||
});
|
||||
|
||||
return !!session;
|
||||
}
|
||||
|
||||
async invalidateSession(userId: string, sessionId: string): Promise<void> {
|
||||
const session = await this.userSessionRepository.findOne({
|
||||
where: {
|
||||
userId,
|
||||
sessionId,
|
||||
deletedAt: null,
|
||||
},
|
||||
});
|
||||
|
||||
if (session) {
|
||||
await this.userSessionRepository.softDelete(session.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
18
apps/backend/src/user/user.controller.spec.ts
Normal file
18
apps/backend/src/user/user.controller.spec.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { UserController } from './user.controller';
|
||||
|
||||
describe('UserController', () => {
|
||||
let controller: UserController;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [UserController],
|
||||
}).compile();
|
||||
|
||||
controller = module.get<UserController>(UserController);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(controller).toBeDefined();
|
||||
});
|
||||
});
|
||||
26
apps/backend/src/user/user.controller.ts
Normal file
26
apps/backend/src/user/user.controller.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Body, Controller, Get, Put, Request, UseGuards } from '@nestjs/common';
|
||||
import { UserService } from './user.service';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { UpdateUserPasswordDto } from './dto/update-user-password.dto';
|
||||
import { AuthService } from 'src/auth/auth.service';
|
||||
|
||||
@Controller('user')
|
||||
export class UserController {
|
||||
constructor(
|
||||
private readonly userService: UserService,
|
||||
private readonly authService: AuthService,
|
||||
) {}
|
||||
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@Get('me')
|
||||
async getMe(@Request() req) {
|
||||
const { user } = req;
|
||||
return this.userService.findOne({ userId: user.userId });
|
||||
}
|
||||
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@Put('password')
|
||||
async update(@Request() req, @Body() dto: UpdateUserPasswordDto) {
|
||||
return this.userService.setPassword(req.user.userId, dto.password);
|
||||
}
|
||||
}
|
||||
19
apps/backend/src/user/user.module.ts
Normal file
19
apps/backend/src/user/user.module.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { forwardRef, Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { User } from './entities/user.entity';
|
||||
import { UserController } from './user.controller';
|
||||
import { UserService } from './user.service';
|
||||
import { UserSession } from './entities/user-session.entity';
|
||||
import { AuthModule } from 'src/auth/auth.module';
|
||||
import { UserSessionService } from './services/user-session.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([User, UserSession]),
|
||||
forwardRef(() => AuthModule), // 解决循环依赖问题
|
||||
],
|
||||
controllers: [UserController],
|
||||
providers: [UserService, UserSessionService],
|
||||
exports: [UserService, UserSessionService],
|
||||
})
|
||||
export class UserModule {}
|
||||
18
apps/backend/src/user/user.service.spec.ts
Normal file
18
apps/backend/src/user/user.service.spec.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { UserService } from './user.service';
|
||||
|
||||
describe('UserService', () => {
|
||||
let service: UserService;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [UserService],
|
||||
}).compile();
|
||||
|
||||
service = module.get<UserService>(UserService);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
});
|
||||
149
apps/backend/src/user/user.service.ts
Normal file
149
apps/backend/src/user/user.service.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { User } from './entities/user.entity';
|
||||
import { QueryFailedError, Repository } from 'typeorm';
|
||||
import { createHash } from 'crypto';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
type UserFindOptions = Partial<
|
||||
Pick<User, 'userId' | 'username' | 'phone' | 'email'>
|
||||
>;
|
||||
|
||||
@Injectable()
|
||||
export class UserService {
|
||||
constructor(
|
||||
@InjectRepository(User)
|
||||
private readonly userRepository: Repository<User>,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @deprecated 尽量不使用该方法
|
||||
*/
|
||||
async findOne(
|
||||
options: UserFindOptions | UserFindOptions[],
|
||||
additionalOptions?: { withDeleted?: boolean },
|
||||
): Promise<User | null> {
|
||||
if (Object.keys(options).length === 0) {
|
||||
throw new BadRequestException('查询条件不能为空');
|
||||
}
|
||||
return this.userRepository.findOne({
|
||||
where: options,
|
||||
withDeleted: additionalOptions?.withDeleted || false,
|
||||
});
|
||||
}
|
||||
|
||||
async findById(userId: string): Promise<User | null> {
|
||||
return this.userRepository.findOne({
|
||||
where: {
|
||||
userId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async create(user: Partial<User>): Promise<User> {
|
||||
try {
|
||||
const newUser = this.userRepository.create(user);
|
||||
return await this.userRepository.save(newUser);
|
||||
} catch (error) {
|
||||
if (error instanceof QueryFailedError) {
|
||||
throw new ConflictException(this.getDuplicateErrorMessage(error));
|
||||
}
|
||||
throw new BadRequestException('创建用户失败');
|
||||
}
|
||||
}
|
||||
|
||||
async update(userId: string, user: Partial<User>): Promise<User> {
|
||||
const existingUser = await this.userRepository.findOne({
|
||||
where: { userId },
|
||||
});
|
||||
if (!existingUser) {
|
||||
throw new BadRequestException('User not found');
|
||||
}
|
||||
try {
|
||||
Object.assign(existingUser, user);
|
||||
return await this.userRepository.save(existingUser);
|
||||
} catch (error) {
|
||||
if (error instanceof QueryFailedError) {
|
||||
throw new ConflictException(this.getDuplicateErrorMessage(error));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async delete(userId: string, soft: boolean) {
|
||||
const existingUser = await this.userRepository.findOne({
|
||||
where: { userId },
|
||||
withDeleted: true,
|
||||
});
|
||||
if (!existingUser) {
|
||||
throw new BadRequestException('用户不存在');
|
||||
}
|
||||
|
||||
if (existingUser.deletedAt && soft) {
|
||||
throw new BadRequestException('账户已注销,不得重复操作');
|
||||
}
|
||||
|
||||
if (!existingUser.deletedAt && !soft) {
|
||||
throw new BadRequestException('账号未注销,请先注销再执行删除操作');
|
||||
}
|
||||
|
||||
return soft
|
||||
? await this.userRepository.softDelete(existingUser.userId)
|
||||
: await this.userRepository.delete(existingUser.userId);
|
||||
}
|
||||
|
||||
hashPassword(password: string, salt: string): string {
|
||||
return createHash('sha256').update(`${password}${salt}`).digest('hex');
|
||||
}
|
||||
|
||||
generateSalt(): string {
|
||||
return uuid().replace(/-/g, '');
|
||||
}
|
||||
|
||||
async setPassword(userId: string, password: string): Promise<User> {
|
||||
const user = await this.userRepository.findOne({ where: { userId } });
|
||||
if (!user) {
|
||||
throw new BadRequestException('User not found');
|
||||
}
|
||||
const salt = this.generateSalt();
|
||||
user.password_hash = this.hashPassword(password, salt);
|
||||
user.salt = salt;
|
||||
return this.userRepository.save(user);
|
||||
}
|
||||
|
||||
private getDuplicateErrorMessage(error: QueryFailedError): string {
|
||||
// 根据具体的错误信息返回友好的提示
|
||||
if (error.message.includes('IDX_user_username')) {
|
||||
return '账户名已被使用';
|
||||
}
|
||||
if (error.message.includes('IDX_user_email')) {
|
||||
return '邮箱已被使用';
|
||||
}
|
||||
if (error.message.includes('IDX_user_phone')) {
|
||||
return '手机号已被使用';
|
||||
}
|
||||
return '该登陆方式异常,请更换其他登陆方式或联系管理员';
|
||||
}
|
||||
|
||||
async list(page = 1, pageSize = 20) {
|
||||
const queryBuilder = this.userRepository.createQueryBuilder('user');
|
||||
|
||||
queryBuilder.withDeleted();
|
||||
|
||||
queryBuilder.orderBy('user.createdAt', 'DESC');
|
||||
|
||||
queryBuilder.skip((page - 1) * pageSize);
|
||||
queryBuilder.take(pageSize);
|
||||
|
||||
const [items, total] = await queryBuilder.getManyAndCount();
|
||||
return {
|
||||
items,
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user