Преглед изворни кода

feat: 增加全局错误捕捉及验证

IlhamTahir пре 1 година
родитељ
комит
bbf5360ef7

+ 21 - 0
src/core/controller/role.controller.ts

@@ -0,0 +1,21 @@
+import { Body, Controller, HttpStatus, Post } from '@nestjs/common';
+import { RoleService } from '../service/role.service';
+import { CreateRoleRequest } from '../dto/create-role.request';
+import { ApiResponse } from '@nestjs/swagger';
+import { RoleVo } from '../vo/role.vo';
+import { RoleMapper } from '../mapper/role.mapper';
+
+@Controller('roles')
+export class RoleController {
+  constructor(private readonly roleService: RoleService) {}
+
+  @ApiResponse({
+    status: HttpStatus.OK,
+    description: 'Role',
+    type: RoleVo,
+  })
+  @Post()
+  async create(@Body() createRoleRequest: CreateRoleRequest) {
+    return RoleMapper.toVo(await this.roleService.create(createRoleRequest));
+  }
+}

+ 3 - 3
src/core/controller/token.controller.ts

@@ -1,8 +1,8 @@
 import { Body, Controller, HttpStatus, Post } from '@nestjs/common';
-import { TokenCreateRequest } from '../dto/token-create.request';
+import { CreateTokenRequest } from '../dto/create-token.request';
 import { NoAuth } from '../decorators/no-auth.decorator';
 import { AuthService } from '../service/auth.service';
-import { TokenVo } from '../vo/TokenVo';
+import { TokenVo } from '../vo/token.vo';
 import { ApiResponse } from '@nestjs/swagger';
 
 @Controller('/tokens')
@@ -15,7 +15,7 @@ export class TokenController {
     description: 'Token',
     type: TokenVo,
   })
-  create(@Body() tokenCreateRequest: TokenCreateRequest) {
+  create(@Body() tokenCreateRequest: CreateTokenRequest) {
     return this.authService.createToken(tokenCreateRequest);
   }
 }

+ 4 - 0
src/core/controller/user.controller.ts

@@ -0,0 +1,4 @@
+import { Controller } from '@nestjs/common';
+
+@Controller('user')
+export class UserController {}

+ 7 - 2
src/core/core.module.ts

@@ -9,9 +9,13 @@ import { AuthGuard } from './guards/auth.guard';
 import { JwtModule, JwtService } from '@nestjs/jwt';
 import { AuthService } from './service/auth.service';
 import { JWT_EXPIRATION, JWT_SECRET } from './constants/jwt';
+import { RoleController } from './controller/role.controller';
+import { RoleService } from './service/role.service';
+import { Role } from './entity/role.entity';
+import { Permission } from './entity/permission.entity';
 
 @Module({
-  controllers: [TokenController],
+  controllers: [TokenController, RoleController],
   imports: [
     ConfigModule.forRoot({
       isGlobal: true,
@@ -36,7 +40,7 @@ import { JWT_EXPIRATION, JWT_SECRET } from './constants/jwt';
       }),
       inject: [ConfigService],
     }),
-    TypeOrmModule.forFeature([User]),
+    TypeOrmModule.forFeature([User, Role, Permission]),
     JwtModule.register({
       global: true,
       secret: JWT_SECRET,
@@ -53,6 +57,7 @@ import { JWT_EXPIRATION, JWT_SECRET } from './constants/jwt';
     UserService,
     JwtService,
     AuthService,
+    RoleService,
   ],
   exports: [UserService],
 })

+ 15 - 0
src/core/dto/create-role.request.ts

@@ -0,0 +1,15 @@
+import { ApiProperty } from '@nestjs/swagger';
+import { IsNotEmpty, Matches } from 'class-validator';
+
+export class CreateRoleRequest {
+  @ApiProperty()
+  @IsNotEmpty({ message: '角色名称不能为空' })
+  @Matches(/^[A-Z]+(_[A-Z]+)*$/, {
+    message: '角色名称格式不正确,应为大写英文并用下划线分隔,例如:ROLE_ADMIN',
+  })
+  name: string;
+
+  @IsNotEmpty({ message: '角色标识不能为空' })
+  @ApiProperty()
+  label: string;
+}

+ 1 - 1
src/core/dto/token-create.request.ts → src/core/dto/create-token.request.ts

@@ -1,7 +1,7 @@
 import { ApiProperty } from '@nestjs/swagger';
 import { IsNotEmpty } from 'class-validator';
 
-export class TokenCreateRequest {
+export class CreateTokenRequest {
   @ApiProperty({
     required: true,
   })

+ 12 - 0
src/core/entity/permission.entity.ts

@@ -0,0 +1,12 @@
+import { Column, Entity, ManyToMany } from 'typeorm';
+import { BaseEntity } from './base.entity';
+import { Role } from './role.entity';
+
+@Entity()
+export class Permission extends BaseEntity {
+  @Column({ unique: true })
+  name: string;
+
+  @ManyToMany(() => Role, (role) => role.permissions)
+  roles: Role[];
+}

+ 22 - 0
src/core/entity/role.entity.ts

@@ -0,0 +1,22 @@
+import { Column, Entity, JoinTable, ManyToMany } from 'typeorm';
+import { BaseEntity } from './base.entity';
+import { User } from './user.entity';
+import { Permission } from './permission.entity';
+
+@Entity()
+export class Role extends BaseEntity {
+  @Column({ unique: true })
+  name: string;
+
+  @Column()
+  label: string;
+
+  @ManyToMany(() => Permission, (permission) => permission.roles)
+  @JoinTable({
+    name: 'role_permission',
+  })
+  permissions: Permission[];
+
+  @ManyToMany(() => User, (user) => user.roles)
+  users: User[];
+}

+ 8 - 1
src/core/entity/user.entity.ts

@@ -1,5 +1,6 @@
-import { Column, Entity } from 'typeorm';
+import { Column, Entity, JoinTable, ManyToMany } from 'typeorm';
 import { BaseEntity } from './base.entity';
+import { Role } from './role.entity';
 
 @Entity()
 export class User extends BaseEntity {
@@ -20,4 +21,10 @@ export class User extends BaseEntity {
     default: true,
   })
   enabled: boolean;
+
+  @ManyToMany(() => Role, (role) => role.users)
+  @JoinTable({
+    name: 'user_role',
+  })
+  roles: Role[];
 }

+ 11 - 0
src/core/errors/error.response.ts

@@ -0,0 +1,11 @@
+import { ApiProperty } from '@nestjs/swagger';
+
+export class ErrorResponse {
+  @ApiProperty({ example: 400, description: '错误码' })
+  code: number;
+
+  @ApiProperty({
+    description: '错误信息',
+  })
+  message: string;
+}

+ 8 - 0
src/core/errors/user.error.ts

@@ -0,0 +1,8 @@
+import { ErrorResponse } from './error.response';
+
+export const UserError: Record<string, ErrorResponse> = {
+  NOT_FOUND: {
+    code: 1001,
+    message: 'User not found',
+  },
+};

+ 8 - 0
src/core/exceptions/biz.exception.ts

@@ -0,0 +1,8 @@
+import { HttpException, HttpStatus } from '@nestjs/common';
+import { ErrorResponse } from '../errors/error.response';
+
+export class BizException extends HttpException {
+  constructor(error: ErrorResponse) {
+    super(error, HttpStatus.BAD_REQUEST);
+  }
+}

+ 70 - 0
src/core/exceptions/global-exception.filter.ts

@@ -0,0 +1,70 @@
+import {
+  ExceptionFilter,
+  Catch,
+  ArgumentsHost,
+  HttpException,
+  HttpStatus,
+} from '@nestjs/common';
+import { ErrorResponse } from '../errors/error.response';
+
+@Catch()
+export class GlobalExceptionFilter implements ExceptionFilter {
+  catch(exception: any, host: ArgumentsHost) {
+    const ctx = host.switchToHttp();
+    const response = ctx.getResponse();
+
+    const { status, errorResponse } = this.formatException(exception);
+
+    response.status(status).json(errorResponse);
+  }
+
+  private formatException(exception: any): {
+    status: number;
+    errorResponse: ErrorResponse | ErrorResponse[];
+  } {
+    if (Array.isArray(exception)) {
+      // 如果是 ValidationPipe 抛出的 ErrorResponse[]
+      return { status: HttpStatus.BAD_REQUEST, errorResponse: exception };
+    }
+
+    if (exception instanceof HttpException) {
+      return this.handleHttpException(exception);
+    }
+
+    return this.handleUnknownException(exception);
+  }
+
+  private handleHttpException(exception: HttpException): {
+    status: number;
+    errorResponse: ErrorResponse;
+  } {
+    const status = exception.getStatus();
+    const exceptionResponse = exception.getResponse();
+
+    const message =
+      typeof exceptionResponse === 'string'
+        ? exceptionResponse
+        : (exceptionResponse as any).message || 'An unexpected error occurred.';
+
+    return {
+      status,
+      errorResponse: { code: status, message },
+    };
+  }
+
+  private handleUnknownException(exception: any): {
+    status: number;
+    errorResponse: ErrorResponse;
+  } {
+    const status = HttpStatus.INTERNAL_SERVER_ERROR;
+
+    return {
+      status,
+      errorResponse: {
+        code: status,
+        message:
+          exception.message || 'An unexpected internal server error occurred.',
+      },
+    };
+  }
+}

+ 12 - 0
src/core/mapper/role.mapper.ts

@@ -0,0 +1,12 @@
+import { Role } from '../entity/role.entity';
+import { RoleVo } from '../vo/role.vo';
+
+export class RoleMapper {
+  static toVo(entity: Role): RoleVo {
+    const vo = new RoleVo();
+    vo.id = entity.id;
+    vo.name = entity.name;
+    vo.label = entity.label;
+    return vo;
+  }
+}

+ 21 - 0
src/core/pipe/global-validation.pipe.ts

@@ -0,0 +1,21 @@
+import { HttpStatus, ValidationError, ValidationPipe } from '@nestjs/common';
+import { ErrorResponse } from '../errors/error.response';
+
+export class GlobalValidationPipe extends ValidationPipe {
+  constructor() {
+    super({
+      whitelist: true, // 移除未定义的字段
+      forbidNonWhitelisted: true, // 禁止多余字段
+      transform: true, // 自动类型转换
+      exceptionFactory: (errors: ValidationError[]): ErrorResponse[] => {
+        // 将验证错误格式化为统一的 ErrorResponse[]
+        return errors.flatMap((error) =>
+          Object.values(error.constraints || {}).map((message) => ({
+            code: HttpStatus.BAD_REQUEST,
+            message: message,
+          })),
+        );
+      },
+    });
+  }
+}

+ 3 - 3
src/core/service/auth.service.ts

@@ -1,9 +1,9 @@
 import { Injectable, UnauthorizedException } from '@nestjs/common';
 import { UserService } from './user.service';
 import { JwtService } from '@nestjs/jwt';
-import { TokenCreateRequest } from '../dto/token-create.request';
+import { CreateTokenRequest } from '../dto/create-token.request';
 import * as bcrypt from 'bcrypt';
-import { TokenVo } from '../vo/TokenVo';
+import { TokenVo } from '../vo/token.vo';
 import { JWT_SECRET } from '../constants/jwt';
 
 @Injectable()
@@ -13,7 +13,7 @@ export class AuthService {
     private readonly jwtService: JwtService,
   ) {}
 
-  async createToken(tokenCreateRequest: TokenCreateRequest) {
+  async createToken(tokenCreateRequest: CreateTokenRequest) {
     // 验证用户信息和密码
     const user = await this.userService.findByUserName(
       tokenCreateRequest.username,

+ 22 - 0
src/core/service/role.service.ts

@@ -0,0 +1,22 @@
+import { Injectable } from '@nestjs/common';
+import { InjectRepository } from '@nestjs/typeorm';
+import { Role } from '../entity/role.entity';
+import { Permission } from '../entity/permission.entity';
+import { Repository } from 'typeorm';
+import { CreateRoleRequest } from '../dto/create-role.request';
+
+@Injectable()
+export class RoleService {
+  constructor(
+    @InjectRepository(Role) private readonly roleRepository: Repository<Role>,
+    @InjectRepository(Permission)
+    private readonly permissionRepository: Repository<Permission>,
+  ) {}
+
+  async create(createRoleRequest: CreateRoleRequest) {
+    const role = new Role();
+    role.name = createRoleRequest.name;
+    role.label = createRoleRequest.label;
+    return this.roleRepository.save(role);
+  }
+}

+ 7 - 0
src/core/vo/base.vo.ts

@@ -0,0 +1,7 @@
+export abstract class BaseVo {
+  id: string;
+
+  createdTime: string;
+
+  updatedTime: string;
+}

+ 9 - 0
src/core/vo/role.vo.ts

@@ -0,0 +1,9 @@
+import { BaseVo } from './base.vo';
+import { ApiProperty } from '@nestjs/swagger';
+
+export class RoleVo extends BaseVo {
+  @ApiProperty()
+  name: string;
+  @ApiProperty()
+  label: string;
+}

+ 0 - 0
src/core/vo/TokenVo.ts → src/core/vo/token.vo.ts


+ 9 - 6
src/main.ts

@@ -3,6 +3,8 @@ import { AppModule } from './app.module';
 import { SnowflakeUtil } from './core/util/snowflake.util';
 import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
 import { ValidationPipe } from '@nestjs/common';
+import { GlobalExceptionFilter } from './core/exceptions/global-exception.filter';
+import { GlobalValidationPipe } from './core/pipe/global-validation.pipe';
 
 async function bootstrap() {
   SnowflakeUtil.initialize(
@@ -15,16 +17,17 @@ async function bootstrap() {
       .setTitle(process.env.APP_TITLE)
       .setDescription('The api description')
       .setVersion('1.0')
+
       .build();
     const document = SwaggerModule.createDocument(app, config);
-    SwaggerModule.setup('docs', app, document);
+    SwaggerModule.setup('docs', app, document, {
+      jsonDocumentUrl: 'docs/json',
+    });
   }
 
-  app.useGlobalPipes(
-    new ValidationPipe({
-      transform: true,
-    }),
-  );
+  app.useGlobalPipes(new GlobalValidationPipe());
+  app.useGlobalFilters(new GlobalExceptionFilter());
+
   await app.listen(3000);
 }
 bootstrap();

+ 0 - 24
test/app.e2e-spec.ts

@@ -1,24 +0,0 @@
-import { Test, TestingModule } from '@nestjs/testing';
-import { INestApplication } from '@nestjs/common';
-import * as request from 'supertest';
-import { AppModule } from './../src/app.module';
-
-describe('AppController (e2e)', () => {
-  let app: INestApplication;
-
-  beforeEach(async () => {
-    const moduleFixture: TestingModule = await Test.createTestingModule({
-      imports: [AppModule],
-    }).compile();
-
-    app = moduleFixture.createNestApplication();
-    await app.init();
-  });
-
-  it('/ (GET)', () => {
-    return request(app.getHttpServer())
-      .get('/')
-      .expect(200)
-      .expect('Hello World!');
-  });
-});