diff --git a/apps/api/.env.development b/apps/api/.env.development index 6bb97ff..6697180 100644 --- a/apps/api/.env.development +++ b/apps/api/.env.development @@ -1,8 +1,9 @@ # 开发环境配置 PORT=3200 -DB_HOST=127.0.0.1 +DB_HOST=localhost DB_PORT=5432 -DB_USER=joey -DB_PASSWORD=vest_mind_0228 -DB_DATABASE=vest_mind +DB_USER= +DB_PASSWORD= +DB_DATABASE=vest_mind_test +# DB_DATABASE_TEST=vest_mind_test diff --git a/apps/api/NESTJS-GENERATE-GUIDE.md b/apps/api/NESTJS-GENERATE-GUIDE.md new file mode 100644 index 0000000..7c9eee0 --- /dev/null +++ b/apps/api/NESTJS-GENERATE-GUIDE.md @@ -0,0 +1,714 @@ +# NestJS 模块生成指南 + +## 快速生成完整模块 + +### 方法一:使用 `resource` 命令(推荐) + +一次性生成包含 module、controller、service、entity 的完整 CRUD 资源: + +```bash +# 生成 user 模块(会在 src/modules/user 目录下创建所有文件) +pnpm nest g resource modules/user + +# 或者使用别名 +pnpm nest g res modules/user +``` + +**交互式选项:** + +- `What transport layer do you use?` → 选择 `REST API` +- `Would you like to generate CRUD entry points?` → 选择 `Yes` + +**生成的文件结构:** + +``` +src/modules/user/ +├── user.controller.ts # 控制器 +├── user.controller.spec.ts # 控制器测试 +├── user.service.ts # 服务 +├── user.service.spec.ts # 服务测试 +├── user.module.ts # 模块 +└── entities/ + └── user.entity.ts # 实体(TypeORM) +``` + +### 方法二:分别生成各个文件 + +```bash +# 生成模块 +pnpm nest g module modules/user + +# 生成控制器 +pnpm nest g controller modules/user + +# 生成服务 +pnpm nest g service modules/user + +# 生成实体(需要手动创建,或使用 TypeORM CLI) +``` + +### 方法三:使用 TypeORM 生成实体 + +```bash +# 安装 TypeORM CLI(如果还没有) +pnpm add -D typeorm + +# 生成实体(需要先配置 TypeORM) +pnpm typeorm entity:create -n User +``` + +## 文件组织模式 + +### 模式一:按功能模块组织(推荐)⭐ + +``` +src/ +├── modules/ +│ ├── user/ +│ │ ├── user.module.ts +│ │ ├── user.controller.ts +│ │ ├── user.service.ts +│ │ ├── user.entity.ts +│ │ ├── user.dto.ts # DTO 文件 +│ │ ├── user.controller.spec.ts +│ │ └── user.service.spec.ts +│ ├── order/ +│ │ ├── order.module.ts +│ │ ├── order.controller.ts +│ │ ├── order.service.ts +│ │ ├── order.entity.ts +│ │ └── ... +│ └── product/ +│ └── ... +├── database/ +│ ├── database.module.ts +│ └── database.config.ts +└── app.module.ts +``` + +**优点:** + +- 模块化清晰,每个功能独立 +- 易于维护和扩展 +- 符合 NestJS 最佳实践 + +**适用场景:** + +- 大型企业级应用 +- 需要复杂业务逻辑 +- 需要清晰的架构分层 + +## 推荐的文件组织(当前项目) + +基于你的项目结构,推荐使用**模式一**: + +``` +src/ +├── modules/ # 业务模块 +│ ├── user/ +│ │ ├── user.module.ts +│ │ ├── user.controller.ts +│ │ ├── user.service.ts +│ │ ├── user.entity.ts +│ │ ├── dto/ # DTO 文件(可选) +│ │ │ ├── create-user.dto.ts +│ │ │ └── update-user.dto.ts +│ │ └── interfaces/ # 接口定义(可选) +│ │ └── user.interface.ts +│ ├── order/ +│ └── product/ +├── database/ # 数据库配置 +│ ├── database.module.ts +│ └── database.config.ts +├── common/ # 公共模块(可选) +│ ├── filters/ +│ ├── guards/ +│ ├── interceptors/ +│ └── pipes/ +└── app.module.ts +``` + +## 实际生成示例 + +### 生成 user 模块 + +```bash +# 1. 生成完整资源 +cd /Users/joey-xd/sites/vest-mind/vest-mind-backend/apps/api +pnpm nest g resource modules/user + +# 2. 选择选项: +# - REST API +# - Yes (生成 CRUD) +``` + +### 生成后的文件内容示例 + +**user.entity.ts:** + +```typescript +import { Entity, Column, PrimaryGeneratedColumn } from 'typeorm'; + +@Entity() +export class User { + @PrimaryGeneratedColumn() + id: number; + + @Column() + name: string; +} +``` + +**user.service.ts:** + +```typescript +import { Injectable } from '@nestjs/common'; +import { CreateUserDto } from './dto/create-user.dto'; +import { UpdateUserDto } from './dto/update-user.dto'; + +@Injectable() +export class UserService { + create(createUserDto: CreateUserDto) { + return 'This action adds a new user'; + } + + findAll() { + return `This action returns all user`; + } + + findOne(id: number) { + return `This action returns a #${id} user`; + } + + update(id: number, updateUserDto: UpdateUserDto) { + return `This action updates a #${id} user`; + } + + remove(id: number) { + return `This action removes a #${id} user`; + } +} +``` + +**user.controller.ts:** + +```typescript +import { + Controller, + Get, + Post, + Body, + Patch, + Param, + Delete, +} from '@nestjs/common'; +import { UserService } from './user.service'; +import { CreateUserDto } from './dto/create-user.dto'; +import { UpdateUserDto } from './dto/update-user.dto'; + +@Controller('user') +export class UserController { + constructor(private readonly userService: UserService) {} + + @Post() + create(@Body() createUserDto: CreateUserDto) { + return this.userService.create(createUserDto); + } + + @Get() + findAll() { + return this.userService.findAll(); + } + + @Get(':id') + findOne(@Param('id') id: string) { + return this.userService.findOne(+id); + } + + @Patch(':id') + update(@Param('id') id: string, @Body() updateUserDto: UpdateUserDto) { + return this.userService.update(+id, updateUserDto); + } + + @Delete(':id') + remove(@Param('id') id: string) { + return this.userService.remove(+id); + } +} +``` + +## 常用生成命令速查 + +```bash +# 生成资源(完整 CRUD) +pnpm nest g resource modules/user + +# 生成模块 +pnpm nest g module modules/user + +# 生成控制器 +pnpm nest g controller modules/user + +# 生成服务 +pnpm nest g service modules/user + +# 生成守卫 +pnpm nest g guard modules/user/guards/auth + +# 生成拦截器 +pnpm nest g interceptor modules/user/interceptors/logging + +# 生成过滤器 +pnpm nest g filter modules/user/filters/http-exception + +# 生成管道 +pnpm nest g pipe modules/user/pipes/validation + +# 生成装饰器 +pnpm nest g decorator modules/user/decorators/roles +``` + +## 注意事项 + +1. **路径规范:** + - 使用 `modules/模块名` 作为路径 + - 会自动创建目录结构 + +2. **自动导入:** + - 生成的文件会自动导入到相应的模块中 + - 如果不想自动导入,使用 `--skip-import` 选项 + +3. **测试文件:** + - 默认会生成 `.spec.ts` 测试文件 + - 使用 `--no-spec` 可以跳过测试文件生成 + +4. **扁平结构:** + - 使用 `--flat` 可以生成扁平结构(所有文件在同一目录) + - 不推荐使用,会破坏模块化结构 + +## DTO(Data Transfer Object)详解 + +### 什么是 DTO? + +DTO(Data Transfer Object)是数据传输对象,用于在不同层之间传输数据。在 NestJS 中,DTO 主要用于: + +1. **定义 API 请求和响应的数据结构** +2. **数据验证**(结合 `class-validator`) +3. **类型安全** +4. **API 文档生成**(结合 Swagger) + +### 为什么需要 DTO? + +#### 1. 数据验证 + +```typescript +// 没有 DTO - 不安全 +@Post() +create(@Body() body: any) { + // body 可能是任何数据,没有验证 + return this.userService.create(body); +} + +// 使用 DTO - 安全 +@Post() +create(@Body() createUserDto: CreateUserDto) { + // 数据已经验证,类型安全 + return this.userService.create(createUserDto); +} +``` + +#### 2. 类型安全 + +```typescript +// DTO 定义了明确的数据结构 +export class CreateUserDto { + username: string; + email: string; + age: number; +} + +// TypeScript 会检查类型 +const user = new CreateUserDto(); +user.username = 'john'; // ✅ 正确 +user.age = '25'; // ❌ TypeScript 错误 +``` + +#### 3. API 文档 + +使用 Swagger 时,DTO 会自动生成 API 文档: + +```typescript +import { ApiProperty } from '@nestjs/swagger'; + +export class CreateUserDto { + @ApiProperty({ description: '用户名', example: 'john' }) + username: string; + + @ApiProperty({ description: '邮箱', example: 'john@example.com' }) + email: string; +} +``` + +### DTO 的类型 + +#### 1. Create DTO(创建数据) + +用于创建新资源时的数据验证: + +```typescript +// dto/create-user.dto.ts +import { + IsString, + IsEmail, + IsOptional, + MinLength, + MaxLength, +} from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class CreateUserDto { + @ApiProperty({ description: '用户名', example: 'john' }) + @IsString() + @MinLength(3) + @MaxLength(20) + username: string; + + @ApiProperty({ description: '邮箱', example: 'john@example.com' }) + @IsEmail() + email: string; + + @ApiProperty({ description: '密码', example: 'password123' }) + @IsString() + @MinLength(6) + password: string; + + @ApiProperty({ description: '年龄', example: 25, required: false }) + @IsOptional() + @IsNumber() + age?: number; +} +``` + +#### 2. Update DTO(更新数据) + +用于更新资源时的数据验证: + +```typescript +// dto/update-user.dto.ts +import { PartialType } from '@nestjs/mapped-types'; +import { CreateUserDto } from './create-user.dto'; + +// 方式一:使用 PartialType(推荐) +export class UpdateUserDto extends PartialType(CreateUserDto) {} + +// 方式二:手动定义(更灵活) +// export class UpdateUserDto { +// @IsOptional() +// @IsString() +// username?: string; +// +// @IsOptional() +// @IsEmail() +// email?: string; +// } +``` + +#### 3. Query DTO(查询参数) + +用于查询列表时的参数验证: + +```typescript +// dto/query-user.dto.ts +import { IsOptional, IsNumber, Min, Max } from 'class-validator'; +import { Type } from 'class-transformer'; + +export class QueryUserDto { + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(1) + page?: number = 1; + + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(1) + @Max(100) + limit?: number = 10; + + @IsOptional() + @IsString() + search?: string; + + @IsOptional() + @IsString() + sortBy?: string = 'createdAt'; + + @IsOptional() + @IsString() + sortOrder?: 'ASC' | 'DESC' = 'DESC'; +} +``` + +#### 4. Response DTO(响应数据) + +用于定义 API 响应的数据结构: + +```typescript +// dto/user-response.dto.ts +import { ApiProperty } from '@nestjs/swagger'; + +export class UserResponseDto { + @ApiProperty({ description: '用户ID', example: 1 }) + id: number; + + @ApiProperty({ description: '用户名', example: 'john' }) + username: string; + + @ApiProperty({ description: '邮箱', example: 'john@example.com' }) + email: string; + + @ApiProperty({ description: '创建时间', example: '2024-01-01T00:00:00Z' }) + createdAt: Date; + + @ApiProperty({ description: '更新时间', example: '2024-01-01T00:00:00Z' }) + updatedAt: Date; +} +``` + +### 在 Controller 中使用 DTO + +```typescript +import { + Controller, + Get, + Post, + Body, + Patch, + Param, + Query, +} from '@nestjs/common'; +import { UserService } from './user.service'; +import { CreateUserDto } from './dto/create-user.dto'; +import { UpdateUserDto } from './dto/update-user.dto'; +import { QueryUserDto } from './dto/query-user.dto'; + +@Controller('users') +export class UserController { + constructor(private readonly userService: UserService) {} + + @Post() + create(@Body() createUserDto: CreateUserDto) { + return this.userService.create(createUserDto); + } + + @Get() + findAll(@Query() queryUserDto: QueryUserDto) { + return this.userService.findAll(queryUserDto); + } + + @Get(':id') + findOne(@Param('id') id: string) { + return this.userService.findOne(+id); + } + + @Patch(':id') + update(@Param('id') id: string, @Body() updateUserDto: UpdateUserDto) { + return this.userService.update(+id, updateUserDto); + } +} +``` + +### 启用全局验证管道 + +在 `main.ts` 中启用全局验证: + +```typescript +import { ValidationPipe } from '@nestjs/common'; + +async function bootstrap() { + const app = await NestFactory.create(AppModule); + + // 启用全局验证管道 + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, // 自动删除不在 DTO 中的属性 + forbidNonWhitelisted: true, // 如果请求包含未定义的属性,抛出错误 + transform: true, // 自动转换类型(如字符串转数字) + transformOptions: { + enableImplicitConversion: true, + }, + }), + ); + + await app.listen(3000); +} +``` + +### 常用验证装饰器 + +```typescript +import { + IsString, // 字符串 + IsNumber, // 数字 + IsBoolean, // 布尔值 + IsEmail, // 邮箱 + IsUrl, // URL + IsDate, // 日期 + IsOptional, // 可选字段 + IsNotEmpty, // 非空 + MinLength, // 最小长度 + MaxLength, // 最大长度 + Min, // 最小值 + Max, // 最大值 + IsEnum, // 枚举 + IsArray, // 数组 + IsObject, // 对象 + ValidateNested, // 嵌套对象验证 + IsUUID, // UUID + Matches, // 正则匹配 +} from 'class-validator'; +``` + +### DTO 示例:完整的用户模块 + +```typescript +// dto/create-user.dto.ts +import { + IsString, + IsEmail, + IsOptional, + MinLength, + IsNumber, + Min, + Max, +} from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class CreateUserDto { + @ApiProperty({ description: '用户名', example: 'john' }) + @IsString() + @MinLength(3) + @MaxLength(20) + username: string; + + @ApiProperty({ description: '邮箱', example: 'john@example.com' }) + @IsEmail() + email: string; + + @ApiProperty({ description: '密码', example: 'password123' }) + @IsString() + @MinLength(6) + password: string; + + @ApiProperty({ description: '年龄', example: 25, required: false }) + @IsOptional() + @IsNumber() + @Min(18) + @Max(100) + age?: number; +} +``` + +```typescript +// dto/update-user.dto.ts +import { PartialType } from '@nestjs/mapped-types'; +import { CreateUserDto } from './create-user.dto'; + +export class UpdateUserDto extends PartialType(CreateUserDto) {} +``` + +```typescript +// dto/query-user.dto.ts +import { IsOptional, IsNumber, IsString, Min, Max } from 'class-validator'; +import { Type } from 'class-transformer'; + +export class QueryUserDto { + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(1) + page?: number = 1; + + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(1) + @Max(100) + limit?: number = 10; + + @IsOptional() + @IsString() + search?: string; +} +``` + +### 安装必要的依赖 + +```bash +# 安装验证库 +pnpm add class-validator class-transformer + +# 如果使用 Swagger(可选) +pnpm add @nestjs/swagger swagger-ui-express +``` + +### DTO vs Entity + +| 特性 | DTO | Entity | +| -------- | ---------------------- | ------------------- | +| **用途** | 数据传输和验证 | 数据库模型 | +| **位置** | `dto/` 目录 | `entities/` 目录 | +| **验证** | 使用 `class-validator` | 使用 TypeORM 装饰器 | +| **暴露** | 暴露给 API 客户端 | 不直接暴露 | +| **示例** | `CreateUserDto` | `User` entity | + +**最佳实践:** + +- ✅ Entity 包含数据库字段(如 `id`, `createdAt`) +- ✅ DTO 只包含客户端需要传递的字段 +- ✅ 使用 DTO 转换 Entity,避免暴露敏感信息 + +### DTO 转换示例 + +```typescript +// service 中使用 +@Injectable() +export class UserService { + async create(createUserDto: CreateUserDto): Promise { + // DTO → Entity + const user = this.userRepository.create({ + username: createUserDto.username, + email: createUserDto.email, + // 不直接传递 password,需要加密 + passwordHash: await this.hashPassword(createUserDto.password), + }); + + return this.userRepository.save(user); + } + + async findAll(queryDto: QueryUserDto): Promise { + // 使用 DTO 中的查询参数 + return this.userRepository.find({ + skip: (queryDto.page - 1) * queryDto.limit, + take: queryDto.limit, + }); + } +} +``` + +## 最佳实践 + +1. ✅ **使用 `resource` 命令生成完整模块** +2. ✅ **按功能模块组织文件** +3. ✅ **每个模块包含:module、controller、service、entity** +4. ✅ **DTO 文件放在模块目录下的 `dto/` 子目录** +5. ✅ **为每个操作创建对应的 DTO(Create、Update、Query、Response)** +6. ✅ **使用 `class-validator` 进行数据验证** +7. ✅ **使用 `PartialType` 创建 Update DTO** +8. ✅ **启用全局验证管道** +9. ✅ **区分 DTO 和 Entity,不要混用** +10. ✅ **公共组件放在 `common/` 目录** +11. ❌ **避免使用扁平结构** +12. ❌ **避免按类型组织文件** +13. ❌ **避免在 Entity 中直接暴露给 API** diff --git a/apps/api/eslint.config.mjs b/apps/api/eslint.config.mjs index 212c0d4..d3fc95a 100644 --- a/apps/api/eslint.config.mjs +++ b/apps/api/eslint.config.mjs @@ -28,6 +28,7 @@ export default tseslint.config( }, { rules: { + '@typescript-eslint/no-unsafe-call': 'off', '@typescript-eslint/no-explicit-any': 'off', '@typescript-eslint/no-floating-promises': 'warn', '@typescript-eslint/no-unsafe-argument': 'warn', diff --git a/apps/api/package.json b/apps/api/package.json index 7bb4729..89c85ad 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -1,76 +1,90 @@ { - "name": "api", - "version": "0.0.1", - "description": "", - "author": "", - "private": true, - "license": "UNLICENSED", - "scripts": { - "build": "nest build", - "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", - "start": "nest start", - "start:dev": "nest start --watch", - "start:debug": "nest start --debug --watch", - "start:prod": "node dist/main", - "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", - "test": "jest", - "test:watch": "jest --watch", - "test:cov": "jest --coverage", - "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", - "test:e2e": "jest --config ./test/jest-e2e.json" - }, - "dependencies": { - "@nestjs/common": "^11.0.1", - "@nestjs/config": "^4.0.2", - "@nestjs/core": "^11.0.1", - "@nestjs/platform-express": "^11.0.1", - "@nestjs/typeorm": "^11.0.0", - "pg": "^8.16.3", - "reflect-metadata": "^0.2.2", - "rxjs": "^7.8.1", - "typeorm": "^0.3.27" - }, - "devDependencies": { - "@eslint/eslintrc": "^3.2.0", - "@eslint/js": "^9.18.0", - "@nestjs/cli": "^11.0.0", - "@nestjs/schematics": "^11.0.0", - "@nestjs/testing": "^11.0.1", - "@types/express": "^5.0.0", - "@types/jest": "^30.0.0", - "@types/node": "^22.10.7", - "@types/pg": "^8.15.6", - "@types/supertest": "^6.0.2", - "eslint": "^9.18.0", - "eslint-config-prettier": "^10.0.1", - "eslint-plugin-prettier": "^5.2.2", - "globals": "^16.0.0", - "jest": "^30.0.0", - "prettier": "^3.4.2", - "source-map-support": "^0.5.21", - "supertest": "^7.0.0", - "ts-jest": "^29.2.5", - "ts-loader": "^9.5.2", - "ts-node": "^10.9.2", - "tsconfig-paths": "^4.2.0", - "typescript": "^5.7.3", - "typescript-eslint": "^8.20.0" - }, - "jest": { - "moduleFileExtensions": [ - "js", - "json", - "ts" - ], - "rootDir": "src", - "testRegex": ".*\\.spec\\.ts$", - "transform": { - "^.+\\.(t|j)s$": "ts-jest" + "name": "api", + "version": "0.0.1", + "description": "", + "author": "", + "private": true, + "license": "UNLICENSED", + "scripts": { + "build": "nest build", + "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", + "start": "nest start", + "start:dev": "nest start --watch", + "start:debug": "nest start --debug --watch", + "start:prod": "node dist/main", + "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", + "test": "jest", + "test:watch": "jest --watch", + "test:cov": "jest --coverage", + "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", + "test:e2e": "jest --config ./test/jest-e2e.json", + "test:integration": "jest --config ./test/jest-integration.json" }, - "collectCoverageFrom": [ - "**/*.(t|j)s" - ], - "coverageDirectory": "../coverage", - "testEnvironment": "node" - } + "dependencies": { + "@nestjs/common": "^11.0.1", + "@nestjs/config": "^4.0.2", + "@nestjs/core": "^11.0.1", + "@nestjs/mapped-types": "^2.1.0", + "@nestjs/platform-express": "^11.0.1", + "@nestjs/swagger": "^11.2.2", + "@nestjs/typeorm": "^11.0.0", + "body-parser": "^2.2.0", + "class-transformer": "^0.5.1", + "class-validator": "^0.14.2", + "compression": "^1.8.1", + "express-rate-limit": "^8.2.1", + "helmet": "^8.1.0", + "pg": "^8.16.3", + "reflect-metadata": "^0.2.2", + "rxjs": "^7.8.1", + "typeorm": "^0.3.27" + }, + "devDependencies": { + "@eslint/eslintrc": "^3.2.0", + "@eslint/js": "^9.18.0", + "@nestjs/cli": "^11.0.0", + "@nestjs/schematics": "^11.0.0", + "@nestjs/testing": "^11.0.1", + "@types/body-parser": "^1.19.6", + "@types/compression": "^1.8.1", + "@types/express": "^5.0.0", + "@types/jest": "^30.0.0", + "@types/node": "^22.10.7", + "@types/pg": "^8.15.6", + "@types/supertest": "^6.0.2", + "eslint": "^9.18.0", + "eslint-config-prettier": "^10.0.1", + "eslint-plugin-prettier": "^5.2.2", + "globals": "^16.0.0", + "jest": "^30.0.0", + "prettier": "^3.4.2", + "source-map-support": "^0.5.21", + "supertest": "^7.0.0", + "ts-jest": "^29.2.5", + "ts-loader": "^9.5.2", + "ts-node": "^10.9.2", + "tsconfig-paths": "^4.2.0", + "typescript": "^5.7.3", + "typescript-eslint": "^8.20.0" + }, + "jest": { + "moduleFileExtensions": [ + "js", + "json", + "ts" + ], + "rootDir": "src", + "testMatch": [ + "**/__tests__/**/*.spec.ts", + "**/*.spec.ts" + ], + "transform": { + "^.+\\.(t|j)s$": "ts-jest" + }, + "collectCoverageFrom": [ + "**/*.(t|j)s" + ], + "coverageDirectory": "../coverage", + "testEnvironment": "node" + } } diff --git a/apps/api/src/app.controller.ts b/apps/api/src/app.controller.ts index cce879e..a325e8b 100644 --- a/apps/api/src/app.controller.ts +++ b/apps/api/src/app.controller.ts @@ -3,10 +3,10 @@ import { AppService } from './app.service'; @Controller() export class AppController { - constructor(private readonly appService: AppService) {} + constructor(private readonly appService: AppService) {} - @Get() - getHello(): string { - return this.appService.getHello(); - } + @Get() + getHello(): string { + return this.appService.getHello(); + } } diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 62a8f48..2f7b470 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -1,6 +1,8 @@ import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { DatabaseModule } from './database/database.module'; +import { CoreModule } from './core/core.module'; +import { BrokerModule } from './modules/broker/broker.module'; import { AppController } from './app.controller'; import { AppService } from './app.service'; @@ -13,7 +15,9 @@ import { AppService } from './app.service'; '.env', ], }), + CoreModule, DatabaseModule, + BrokerModule, ], controllers: [AppController], providers: [AppService], diff --git a/apps/api/src/common/filters/http-exception.filter.ts b/apps/api/src/common/filters/http-exception.filter.ts new file mode 100644 index 0000000..54f6042 --- /dev/null +++ b/apps/api/src/common/filters/http-exception.filter.ts @@ -0,0 +1,96 @@ +import { + ExceptionFilter, + Catch, + ArgumentsHost, + HttpException, + HttpStatus, +} from '@nestjs/common'; +import { Response } from 'express'; + +export interface ErrorResponse { + code: number; + message: string; + data: null; + timestamp: string; + errors?: string[] | Record; +} + +@Catch() +export class HttpExceptionFilter implements ExceptionFilter { + catch(exception: unknown, host: ArgumentsHost) { + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + + let status = HttpStatus.INTERNAL_SERVER_ERROR; + let message = 'Internal server error'; + let code = -1; // 默认错误码 + let errors: string[] | Record | null = null; + + if (exception instanceof HttpException) { + status = exception.getStatus(); + const exceptionResponse = exception.getResponse(); + + if (typeof exceptionResponse === 'string') { + message = exceptionResponse; + } else if ( + typeof exceptionResponse === 'object' && + exceptionResponse !== null + ) { + const responseObj = exceptionResponse as { + message?: string | string[]; + errors?: string[] | Record; + code?: number; + }; + message = + typeof responseObj.message === 'string' + ? responseObj.message + : Array.isArray(responseObj.message) + ? responseObj.message.join(', ') + : message; + errors = responseObj.errors || null; + + // 如果有自定义的 code,使用它 + if (responseObj.code !== undefined) { + code = responseObj.code; + } else { + // 根据 HTTP 状态码设置业务错误码 + code = this.getBusinessCode(status); + } + } + } else if (exception instanceof Error) { + message = exception.message; + } + + const errorResponse: ErrorResponse = { + code, + message, + data: null, + timestamp: new Date().toISOString(), + }; + + // 如果是验证错误,添加详细错误信息 + if (errors) { + errorResponse.errors = errors; + } + + response.status(status).json(errorResponse); + } + + /** + * 根据 HTTP 状态码返回业务错误码 + */ + private getBusinessCode(httpStatus: number): number { + const codeMap: Record = { + [HttpStatus.BAD_REQUEST]: 400, // 请求参数错误 + [HttpStatus.UNAUTHORIZED]: 401, // 未授权 + [HttpStatus.FORBIDDEN]: 403, // 禁止访问 + [HttpStatus.NOT_FOUND]: 404, // 资源不存在 + [HttpStatus.CONFLICT]: 409, // 资源冲突 + [HttpStatus.UNPROCESSABLE_ENTITY]: 422, // 验证失败 + [HttpStatus.TOO_MANY_REQUESTS]: 429, // 请求过多 + [HttpStatus.INTERNAL_SERVER_ERROR]: 500, // 服务器错误 + }; + + return codeMap[httpStatus] ?? -1; + } +} diff --git a/apps/api/src/common/interceptors/transform.interceptor.ts b/apps/api/src/common/interceptors/transform.interceptor.ts new file mode 100644 index 0000000..1d95b6a --- /dev/null +++ b/apps/api/src/common/interceptors/transform.interceptor.ts @@ -0,0 +1,49 @@ +import { + Injectable, + NestInterceptor, + ExecutionContext, + CallHandler, +} from '@nestjs/common'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; + +export interface Response { + code: number; + message: string; + data: T; + timestamp: string; +} + +@Injectable() +export class TransformInterceptor + implements NestInterceptor> +{ + intercept( + context: ExecutionContext, + next: CallHandler, + ): Observable> { + return next.handle().pipe( + map((data) => { + // 如果响应已经是统一格式,直接返回 + if ( + data && + typeof data === 'object' && + 'code' in data && + 'message' in data && + 'data' in data + ) { + return data as Response; + } + + // 统一包装响应格式 + // code: 0 表示业务处理成功(HTTP 状态码 200 表示请求成功) + return { + code: 0, + message: 'success', + data: (data ?? null) as T, + timestamp: new Date().toISOString(), + }; + }), + ); + } +} diff --git a/apps/api/src/core/core.module.ts b/apps/api/src/core/core.module.ts new file mode 100644 index 0000000..6ab5315 --- /dev/null +++ b/apps/api/src/core/core.module.ts @@ -0,0 +1,34 @@ +import { Module, Global } from '@nestjs/common'; +import { APP_FILTER, APP_INTERCEPTOR, APP_PIPE } from '@nestjs/core'; +import { ValidationPipe } from '@nestjs/common'; +import { TransformInterceptor } from '../common/interceptors/transform.interceptor'; +import { HttpExceptionFilter } from '../common/filters/http-exception.filter'; + +@Global() +@Module({ + providers: [ + // 全局异常过滤器 + { + provide: APP_FILTER, + useClass: HttpExceptionFilter, + }, + // 全局响应拦截器 + { + provide: APP_INTERCEPTOR, + useClass: TransformInterceptor, + }, + // 全局验证管道 + { + provide: APP_PIPE, + useValue: new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + transformOptions: { + enableImplicitConversion: true, + }, + }), + }, + ], +}) +export class CoreModule {} diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index f113fec..b04940a 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -1,12 +1,61 @@ import { NestFactory } from '@nestjs/core'; import { ConfigService } from '@nestjs/config'; import { AppModule } from './app.module'; +import compression from 'compression'; +import helmet from 'helmet'; +import rateLimit from 'express-rate-limit'; +import bodyParser from 'body-parser'; +import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; async function bootstrap() { - const app = await NestFactory.create(AppModule); - const configService = app.get(ConfigService); + const configService = new ConfigService(); + // const isProduction = configService.get('NODE_ENV') === 'production'; + const app = await NestFactory.create(AppModule, { + bodyParser: false, // 禁用默认 bodyParser,使用自定义配置 + }); + + // 安全头设置(必须在其他中间件之前) + app.use(helmet()); + + // 速率限制 + app.use( + rateLimit({ + windowMs: 60 * 1000, // 1 分钟 + max: 100, // 限制每个 IP 在 windowMs 时间内最多 10000 个请求 + }), + ); + + // 请求体解析(设置大小限制为 10mb) + app.use(bodyParser.json({ limit: '10mb' })); + app.use(bodyParser.urlencoded({ limit: '10mb', extended: true })); + + app.enableCors(); + /* app.enableCors({ + // 允许的域名 + origin: ['http://localhost:3200'], + }); */ + app.setGlobalPrefix('api'); + // 响应压缩(在 helmet 之后) + app.use(compression()); + + // Swagger API 文档配置 + const swaggerConfig = new DocumentBuilder() + .setTitle('Vest Mind API') + .setDescription('Vest Mind 投资管理系统 API 文档') + .setVersion('1.0') + .addTag('broker', '券商管理相关接口') + .addBearerAuth() + .build(); + const document = SwaggerModule.createDocument(app, swaggerConfig); + SwaggerModule.setup('api-docs', app, document, { + swaggerOptions: { + persistAuthorization: true, // 保持授权状态 + }, + }); + const port = configService.get('PORT', 3200); await app.listen(port); console.log(`Application is running on: http://localhost:${port}`); + console.log(`Swagger API Docs: http://localhost:${port}/api-docs`); } void bootstrap(); diff --git a/apps/api/src/modules/broker/__tests__/broker.controller.spec.ts b/apps/api/src/modules/broker/__tests__/broker.controller.spec.ts new file mode 100644 index 0000000..1c2b89b --- /dev/null +++ b/apps/api/src/modules/broker/__tests__/broker.controller.spec.ts @@ -0,0 +1,222 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { BrokerController } from '../broker.controller'; +import { BrokerService } from '../broker.service'; +import { Broker } from '../broker.entity'; +import { CreateBrokerDto } from '../dto/create-broker.dto'; +import { BatchCreateBrokerDto } from '../dto/batch-create-broker.dto'; + +describe('BrokerController', () => { + let controller: BrokerController; + + const mockBrokerService = { + create: jest.fn(), + batchCreate: jest.fn(), + findAll: jest.fn(), + findOne: jest.fn(), + update: jest.fn(), + remove: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [BrokerController], + providers: [ + { + provide: BrokerService, + useValue: mockBrokerService, + }, + ], + }).compile(); + + controller = module.get(BrokerController); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('create', () => { + const createBrokerDto: CreateBrokerDto = { + brokerCode: 'HTZQ', + brokerName: '华泰证券', + region: 'CN', + sortOrder: 1, + isActive: true, + }; + + const mockBroker: Broker = { + brokerId: 1, + ...createBrokerDto, + createdAt: new Date(), + updatedAt: new Date(), + }; + + it('应该成功创建券商并返回 201 状态码', async () => { + mockBrokerService.create.mockResolvedValue(mockBroker); + + const result = await controller.create(createBrokerDto); + + expect(result).toEqual(mockBroker); + expect(mockBrokerService.create).toHaveBeenCalledWith( + createBrokerDto, + ); + expect(mockBrokerService.create).toHaveBeenCalledTimes(1); + }); + + it('应该正确传递 DTO 到服务层', async () => { + const dto: CreateBrokerDto = { + brokerCode: 'ZSZQ', + brokerName: '招商证券', + region: 'CN', + }; + + const mockResult: Broker = { + brokerId: 2, + ...dto, + sortOrder: 0, + isActive: true, + createdAt: new Date(), + updatedAt: new Date(), + }; + + mockBrokerService.create.mockResolvedValue(mockResult); + + await controller.create(dto); + + expect(mockBrokerService.create).toHaveBeenCalledWith(dto); + expect(mockBrokerService.create).toHaveBeenCalledWith( + expect.objectContaining({ + brokerCode: 'ZSZQ', + brokerName: '招商证券', + region: 'CN', + }), + ); + }); + }); + + describe('batchCreate', () => { + const batchCreateDto: BatchCreateBrokerDto = { + brokers: [ + { + brokerCode: 'HTZQ', + brokerName: '华泰证券', + region: 'CN', + sortOrder: 1, + }, + { + brokerCode: 'ZSZQ', + brokerName: '招商证券', + region: 'CN', + sortOrder: 2, + }, + ], + }; + + const mockBrokers: Broker[] = [ + { + brokerId: 1, + ...batchCreateDto.brokers[0], + isActive: true, + createdAt: new Date(), + updatedAt: new Date(), + }, + { + brokerId: 2, + ...batchCreateDto.brokers[1], + isActive: true, + createdAt: new Date(), + updatedAt: new Date(), + }, + ]; + + it('应该成功批量创建券商并返回 201 状态码', async () => { + mockBrokerService.batchCreate.mockResolvedValue(mockBrokers); + + const result = await controller.batchCreate(batchCreateDto); + + expect(result).toEqual(mockBrokers); + expect(result).toHaveLength(2); + expect(mockBrokerService.batchCreate).toHaveBeenCalledWith( + batchCreateDto, + ); + expect(mockBrokerService.batchCreate).toHaveBeenCalledTimes(1); + }); + + it('应该正确传递批量 DTO 到服务层', async () => { + const dto: BatchCreateBrokerDto = { + brokers: [ + { + brokerCode: 'HTZQ', + brokerName: '华泰证券', + region: 'CN', + }, + ], + }; + + const mockResult: Broker[] = [ + { + brokerId: 1, + ...dto.brokers[0], + sortOrder: 0, + isActive: true, + createdAt: new Date(), + updatedAt: new Date(), + }, + ]; + + mockBrokerService.batchCreate.mockResolvedValue(mockResult); + + await controller.batchCreate(dto); + + expect(mockBrokerService.batchCreate).toHaveBeenCalledWith(dto); + // 验证调用了 batchCreate,并且参数包含正确的数据 + expect(mockBrokerService.batchCreate).toHaveBeenCalledTimes(1); + }); + + it('应该处理空数组的情况', async () => { + const emptyDto: BatchCreateBrokerDto = { + brokers: [], + }; + + mockBrokerService.batchCreate.mockResolvedValue([]); + + const result = await controller.batchCreate(emptyDto); + + expect(result).toEqual([]); + expect(result).toHaveLength(0); + expect(mockBrokerService.batchCreate).toHaveBeenCalledWith( + emptyDto, + ); + }); + + it('应该处理大量券商批量创建', async () => { + const largeBatchDto: BatchCreateBrokerDto = { + brokers: Array.from({ length: 10 }, (_, i) => ({ + brokerCode: `CODE${i}`, + brokerName: `券商${i}`, + region: 'CN', + })), + }; + + const mockLargeResult: Broker[] = largeBatchDto.brokers.map( + (broker, i) => ({ + brokerId: i + 1, + ...broker, + sortOrder: 0, + isActive: true, + createdAt: new Date(), + updatedAt: new Date(), + }), + ); + + mockBrokerService.batchCreate.mockResolvedValue(mockLargeResult); + + const result = await controller.batchCreate(largeBatchDto); + + expect(result).toHaveLength(10); + expect(mockBrokerService.batchCreate).toHaveBeenCalledWith( + largeBatchDto, + ); + }); + }); +}); diff --git a/apps/api/src/modules/broker/__tests__/broker.service.spec.ts b/apps/api/src/modules/broker/__tests__/broker.service.spec.ts new file mode 100644 index 0000000..a3e8173 --- /dev/null +++ b/apps/api/src/modules/broker/__tests__/broker.service.spec.ts @@ -0,0 +1,349 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { ConflictException } from '@nestjs/common'; +import { BrokerService } from '../broker.service'; +import { Broker } from '../broker.entity'; +import { CreateBrokerDto } from '../dto/create-broker.dto'; +import { BatchCreateBrokerDto } from '../dto/batch-create-broker.dto'; + +describe('BrokerService', () => { + let service: BrokerService; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + let repository: Repository; + + const mockRepository = { + create: jest.fn(), + save: jest.fn(), + findOne: jest.fn(), + find: jest.fn(), + remove: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + BrokerService, + { + provide: getRepositoryToken(Broker), + useValue: mockRepository, + }, + ], + }).compile(); + + service = module.get(BrokerService); + repository = module.get>(getRepositoryToken(Broker)); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('create', () => { + const createBrokerDto: CreateBrokerDto = { + brokerCode: 'HTZQ', + brokerName: '华泰证券', + region: 'CN', + sortOrder: 1, + isActive: true, + }; + + const mockBroker: Broker = { + brokerId: 1, + ...createBrokerDto, + createdAt: new Date(), + updatedAt: new Date(), + }; + + it('应该成功创建一个券商', async () => { + // 模拟数据库中不存在相同 code 和 region 的券商 + mockRepository.findOne.mockResolvedValue(null); + mockRepository.create.mockReturnValue(mockBroker); + mockRepository.save.mockResolvedValue(mockBroker); + + const result = await service.create(createBrokerDto); + + expect(result).toEqual(mockBroker); + expect(mockRepository.findOne).toHaveBeenCalledTimes(2); // 检查 code 和 name + expect(mockRepository.findOne).toHaveBeenCalledWith({ + where: { + brokerCode: createBrokerDto.brokerCode, + region: createBrokerDto.region, + }, + }); + expect(mockRepository.create).toHaveBeenCalledWith({ + ...createBrokerDto, + sortOrder: 1, + isActive: true, + }); + expect(mockRepository.save).toHaveBeenCalledWith(mockBroker); + }); + + it('应该使用默认值当 sortOrder 和 isActive 未提供时', async () => { + const dtoWithoutDefaults: CreateBrokerDto = { + brokerCode: 'ZSZQ', + brokerName: '招商证券', + region: 'CN', + }; + + const mockBrokerWithDefaults: Broker = { + brokerId: 2, + ...dtoWithoutDefaults, + sortOrder: 0, + isActive: true, + createdAt: new Date(), + updatedAt: new Date(), + }; + + mockRepository.findOne.mockResolvedValue(null); + mockRepository.create.mockReturnValue(mockBrokerWithDefaults); + mockRepository.save.mockResolvedValue(mockBrokerWithDefaults); + + const result = await service.create(dtoWithoutDefaults); + + expect(result.sortOrder).toBe(0); + expect(result.isActive).toBe(true); + expect(mockRepository.create).toHaveBeenCalledWith({ + ...dtoWithoutDefaults, + sortOrder: 0, + isActive: true, + }); + }); + + it('应该抛出 ConflictException 当 broker_code 已存在时', async () => { + const existingBroker: Broker = { + brokerId: 1, + brokerCode: 'HTZQ', + brokerName: '华泰证券', + region: 'CN', + sortOrder: 0, + isActive: true, + createdAt: new Date(), + updatedAt: new Date(), + }; + + mockRepository.findOne.mockResolvedValueOnce(existingBroker); + + await expect(service.create(createBrokerDto)).rejects.toThrow( + ConflictException, + ); + + expect(mockRepository.save).not.toHaveBeenCalled(); + }); + + it('应该抛出 ConflictException 当 broker_name 已存在时', async () => { + mockRepository.findOne + .mockResolvedValueOnce(null) // 第一次检查 code,不存在 + .mockResolvedValueOnce({ + // 第二次检查 name,已存在 + brokerId: 1, + brokerCode: 'OTHER', + brokerName: '华泰证券', + region: 'CN', + sortOrder: 0, + isActive: true, + createdAt: new Date(), + updatedAt: new Date(), + }); + + await expect(service.create(createBrokerDto)).rejects.toThrow( + ConflictException, + ); + + expect(mockRepository.save).not.toHaveBeenCalled(); + }); + }); + + describe('batchCreate', () => { + const batchCreateDto: BatchCreateBrokerDto = { + brokers: [ + { + brokerCode: 'HTZQ', + brokerName: '华泰证券', + region: 'CN', + sortOrder: 1, + }, + { + brokerCode: 'ZSZQ', + brokerName: '招商证券', + region: 'CN', + sortOrder: 2, + }, + ], + }; + + const mockBrokers: Broker[] = [ + { + brokerId: 1, + ...batchCreateDto.brokers[0], + brokerImage: 'https://example.com/broker1.jpg', + isActive: true, + createdAt: new Date(), + updatedAt: new Date(), + }, + { + brokerId: 2, + ...batchCreateDto.brokers[1], + isActive: true, + createdAt: new Date(), + updatedAt: new Date(), + }, + ]; + + it('应该成功批量创建券商', async () => { + // 模拟数据库中不存在这些券商 + mockRepository.find.mockResolvedValue([]); + mockRepository.create + .mockReturnValueOnce(mockBrokers[0]) + .mockReturnValueOnce(mockBrokers[1]); + mockRepository.save.mockResolvedValue(mockBrokers); + + const result = await service.batchCreate(batchCreateDto); + + expect(result).toEqual(mockBrokers); + expect(result).toHaveLength(2); + expect(mockRepository.create).toHaveBeenCalledTimes(2); + expect(mockRepository.save).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ + brokerCode: 'HTZQ', + }), + expect.objectContaining({ + brokerCode: 'ZSZQ', + }), + ]), + ); + }); + + it('应该为每个券商设置默认值', async () => { + const dtoWithoutDefaults: BatchCreateBrokerDto = { + brokers: [ + { + brokerCode: 'HTZQ', + brokerName: '华泰证券', + region: 'CN', + }, + ], + }; + + const mockBroker: Broker = { + brokerId: 1, + ...dtoWithoutDefaults.brokers[0], + sortOrder: 0, + isActive: true, + createdAt: new Date(), + updatedAt: new Date(), + }; + + mockRepository.find.mockResolvedValue([]); + mockRepository.create.mockReturnValue(mockBroker); + mockRepository.save.mockResolvedValue([mockBroker]); + + const result = await service.batchCreate(dtoWithoutDefaults); + + expect(result[0].sortOrder).toBe(0); + expect(result[0].isActive).toBe(true); + }); + + it('应该抛出 ConflictException 当批量数据中有已存在的券商时', async () => { + const existingBroker: Broker = { + brokerId: 1, + brokerCode: 'HTZQ', + brokerName: '华泰证券', + region: 'CN', + sortOrder: 0, + isActive: true, + createdAt: new Date(), + updatedAt: new Date(), + }; + + mockRepository.find.mockResolvedValue([existingBroker]); + + await expect(service.batchCreate(batchCreateDto)).rejects.toThrow( + ConflictException, + ); + await expect(service.batchCreate(batchCreateDto)).rejects.toThrow( + expect.stringContaining('already exist'), + ); + + expect(mockRepository.save).not.toHaveBeenCalled(); + }); + + it('应该抛出 ConflictException 当批量数据内部有重复的 code+region 组合时', async () => { + const duplicateDto: BatchCreateBrokerDto = { + brokers: [ + { + brokerCode: 'HTZQ', + brokerName: '华泰证券', + region: 'CN', + }, + { + brokerCode: 'HTZQ', // 重复的 code + brokerName: '华泰证券2', + region: 'CN', // 相同的 region + }, + ], + }; + + mockRepository.find.mockResolvedValue([]); + + await expect(service.batchCreate(duplicateDto)).rejects.toThrow( + ConflictException, + ); + await expect(service.batchCreate(duplicateDto)).rejects.toThrow( + 'Duplicate broker_code and region combinations in batch data', + ); + + expect(mockRepository.save).not.toHaveBeenCalled(); + }); + + it('应该成功创建不同地区的相同 code', async () => { + const differentRegionDto: BatchCreateBrokerDto = { + brokers: [ + { + brokerCode: 'HTZQ', + brokerName: '华泰证券', + region: 'CN', + }, + { + brokerCode: 'HTZQ', // 相同的 code + brokerName: 'Huatai Securities', + region: 'US', // 不同的 region + }, + ], + }; + + const mockBrokersDifferentRegion: Broker[] = [ + { + brokerId: 1, + ...differentRegionDto.brokers[0], + sortOrder: 0, + isActive: true, + createdAt: new Date(), + updatedAt: new Date(), + }, + { + brokerId: 2, + ...differentRegionDto.brokers[1], + sortOrder: 0, + isActive: true, + createdAt: new Date(), + updatedAt: new Date(), + }, + ]; + + mockRepository.find.mockResolvedValue([]); + mockRepository.create + .mockReturnValueOnce(mockBrokersDifferentRegion[0]) + .mockReturnValueOnce(mockBrokersDifferentRegion[1]); + mockRepository.save.mockResolvedValue(mockBrokersDifferentRegion); + + const result = await service.batchCreate(differentRegionDto); + + expect(result).toHaveLength(2); + expect(result[0].region).toBe('CN'); + expect(result[1].region).toBe('US'); + expect(mockRepository.save).toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/api/src/modules/broker/broker.controller.ts b/apps/api/src/modules/broker/broker.controller.ts new file mode 100644 index 0000000..15a41a2 --- /dev/null +++ b/apps/api/src/modules/broker/broker.controller.ts @@ -0,0 +1,141 @@ +import { + Controller, + Get, + Post, + Body, + Patch, + Param, + Delete, + Query, + HttpCode, + HttpStatus, +} from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiResponse, ApiParam } from '@nestjs/swagger'; +import { BrokerService } from './broker.service'; +import { CreateBrokerDto } from './dto/create-broker.dto'; +import { UpdateBrokerDto } from './dto/update-broker.dto'; +import { QueryBrokerDto } from './dto/query-broker.dto'; +import { BatchCreateBrokerDto } from './dto/batch-create-broker.dto'; +import { Broker } from './broker.entity'; + +@ApiTags('broker') +@Controller('broker') +export class BrokerController { + constructor(private readonly brokerService: BrokerService) {} + + /** + * 单独创建 broker + */ + @Post() + @HttpCode(HttpStatus.CREATED) + @ApiOperation({ summary: '创建券商', description: '创建单个券商信息' }) + @ApiResponse({ + status: 201, + description: '创建成功', + type: Broker, + }) + @ApiResponse({ status: 400, description: '请求参数错误' }) + @ApiResponse({ status: 409, description: '券商代码或名称已存在' }) + create(@Body() createBrokerDto: CreateBrokerDto): Promise { + return this.brokerService.create(createBrokerDto); + } + + /** + * 批量创建 broker + */ + @Post('batch') + @HttpCode(HttpStatus.CREATED) + @ApiOperation({ + summary: '批量创建券商', + description: '一次性创建多个券商信息', + }) + @ApiResponse({ + status: 201, + description: '批量创建成功', + type: [Broker], + }) + @ApiResponse({ status: 400, description: '请求参数错误' }) + @ApiResponse({ status: 409, description: '存在重复的券商代码或名称' }) + batchCreate( + @Body() batchCreateBrokerDto: BatchCreateBrokerDto, + ): Promise { + return this.brokerService.batchCreate(batchCreateBrokerDto); + } + + /** + * 查询 broker(支持多种查询条件) + * 支持按 broker_id、broker_code、broker_name、region 查询 + * 返回一个或多个 broker + */ + @Get() + @ApiOperation({ + summary: '查询券商列表', + description: '支持按多个条件查询券商,支持分页和排序', + }) + @ApiResponse({ + status: 200, + description: '查询成功', + type: [Broker], + }) + findAll(@Query() queryDto: QueryBrokerDto): Promise { + return this.brokerService.findAll(queryDto); + } + + /** + * 根据 ID 查询单个 broker + */ + @Get(':id') + @ApiOperation({ + summary: '根据ID查询券商', + description: '根据券商ID获取详细信息', + }) + @ApiParam({ name: 'id', description: '券商ID', type: Number }) + @ApiResponse({ + status: 200, + description: '查询成功', + type: Broker, + }) + @ApiResponse({ status: 404, description: '券商不存在' }) + findOne(@Param('id') id: string): Promise { + return this.brokerService.findOne(+id); + } + + /** + * 更新 broker + */ + @Patch(':id') + @ApiOperation({ + summary: '更新券商', + description: '更新券商的部分或全部信息', + }) + @ApiParam({ name: 'id', description: '券商ID', type: Number }) + @ApiResponse({ + status: 200, + description: '更新成功', + type: Broker, + }) + @ApiResponse({ status: 404, description: '券商不存在' }) + @ApiResponse({ status: 409, description: '更新后的券商代码或名称已存在' }) + update( + @Param('id') id: string, + @Body() updateBrokerDto: UpdateBrokerDto, + ): Promise { + return this.brokerService.update(+id, updateBrokerDto); + } + + /** + * 删除 broker + */ + @Delete(':id') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ + summary: '删除券商', + description: '根据券商ID删除券商信息', + }) + @ApiParam({ name: 'id', description: '券商ID', type: Number }) + @ApiResponse({ status: 204, description: '删除成功' }) + @ApiResponse({ status: 404, description: '券商不存在' }) + remove(@Param('id') id: string): Promise { + return this.brokerService.remove(+id); + } +} diff --git a/apps/api/src/modules/broker/broker.entity.ts b/apps/api/src/modules/broker/broker.entity.ts new file mode 100644 index 0000000..4575514 --- /dev/null +++ b/apps/api/src/modules/broker/broker.entity.ts @@ -0,0 +1,96 @@ +import { + Entity, + Column, + PrimaryGeneratedColumn, + CreateDateColumn, + UpdateDateColumn, + Index, +} from 'typeorm'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +@Entity('broker') +export class Broker { + @ApiProperty({ description: '券商ID', example: 1 }) + @PrimaryGeneratedColumn({ name: 'broker_id' }) + brokerId: number; + + @ApiProperty({ + description: '券商代码', + example: 'HTZQ', + maxLength: 50, + }) + @Column({ name: 'broker_code', type: 'varchar', length: 50 }) + @Index() + brokerCode: string; + + @ApiProperty({ + description: '券商名称', + example: '华泰证券', + maxLength: 100, + }) + @Column({ name: 'broker_name', type: 'varchar', length: 100 }) + @Index() + brokerName: string; + + @ApiPropertyOptional({ + description: '券商图片地址', + example: 'https://example.com/broker-image.jpg', + maxLength: 200, + }) + @Column({ + name: 'broker_image', + type: 'varchar', + length: 200, + nullable: true, + }) + brokerImage?: string; + + @ApiProperty({ + description: '地区/国家代码', + example: 'CN', + enum: ['CN', 'US', 'HK', 'SG', 'JP', 'UK', 'AU', 'CA', 'OTHER'], + default: 'CN', + }) + @Column({ name: 'region', type: 'varchar', length: 50, default: 'CN' }) + region: string; + + @ApiPropertyOptional({ + description: '排序顺序', + example: 1, + default: 0, + }) + @Column({ + name: 'sort_order', + type: 'integer', + default: 0, + nullable: false, + }) + sortOrder?: number; // 可选,数据库有默认值 + + @ApiPropertyOptional({ + description: '是否启用', + example: true, + default: true, + }) + @Column({ + name: 'is_active', + type: 'boolean', + default: true, + nullable: false, + }) + isActive?: boolean; // 可选,数据库有默认值 + + @ApiProperty({ + description: '创建时间', + example: '2024-01-01T00:00:00.000Z', + }) + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + @ApiProperty({ + description: '更新时间', + example: '2024-01-01T00:00:00.000Z', + }) + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt: Date; +} diff --git a/apps/api/src/modules/broker/broker.module.ts b/apps/api/src/modules/broker/broker.module.ts new file mode 100644 index 0000000..f1ddb9f --- /dev/null +++ b/apps/api/src/modules/broker/broker.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { BrokerService } from './broker.service'; +import { BrokerController } from './broker.controller'; +import { Broker } from './broker.entity'; + +@Module({ + imports: [TypeOrmModule.forFeature([Broker])], + controllers: [BrokerController], + providers: [BrokerService], + exports: [BrokerService], +}) +export class BrokerModule {} diff --git a/apps/api/src/modules/broker/broker.service.ts b/apps/api/src/modules/broker/broker.service.ts new file mode 100644 index 0000000..a9e753c --- /dev/null +++ b/apps/api/src/modules/broker/broker.service.ts @@ -0,0 +1,256 @@ +import { + Injectable, + NotFoundException, + ConflictException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, FindOptionsWhere } from 'typeorm'; +import { Broker } from './broker.entity'; +import { CreateBrokerDto } from './dto/create-broker.dto'; +import { UpdateBrokerDto } from './dto/update-broker.dto'; +import { QueryBrokerDto } from './dto/query-broker.dto'; +import { BatchCreateBrokerDto } from './dto/batch-create-broker.dto'; + +@Injectable() +export class BrokerService { + constructor( + @InjectRepository(Broker) + private readonly brokerRepository: Repository, + ) {} + + /** + * 单独创建 broker + */ + async create(createBrokerDto: CreateBrokerDto): Promise { + // 检查同一地区的 broker_code 是否已存在 + const existingByCode = await this.brokerRepository.findOne({ + where: { + brokerCode: createBrokerDto.brokerCode, + region: createBrokerDto.region, + }, + }); + + if (existingByCode) { + throw new ConflictException( + `Broker with code "${createBrokerDto.brokerCode}" already exists in region "${createBrokerDto.region}"`, + ); + } + + // 检查同一地区的 broker_name 是否已存在 + const existingByName = await this.brokerRepository.findOne({ + where: { + brokerName: createBrokerDto.brokerName, + region: createBrokerDto.region, + }, + }); + + if (existingByName) { + throw new ConflictException( + `Broker with name "${createBrokerDto.brokerName}" already exists in region "${createBrokerDto.region}"`, + ); + } + + const broker = this.brokerRepository.create({ + ...createBrokerDto, + sortOrder: createBrokerDto.sortOrder ?? 0, + isActive: createBrokerDto.isActive ?? true, + }); + + return this.brokerRepository.save(broker); + } + + /** + * 批量创建 broker + */ + async batchCreate( + batchCreateBrokerDto: BatchCreateBrokerDto, + ): Promise { + const brokers = batchCreateBrokerDto.brokers.map((dto) => + this.brokerRepository.create({ + ...dto, + sortOrder: dto.sortOrder ?? 0, + isActive: dto.isActive ?? true, + }), + ); + + // 检查是否有重复的 broker_code + region 组合 + const codeRegionPairs = brokers.map((b) => ({ + brokerCode: b.brokerCode, + region: b.region, + })); + + const existingBrokers = await this.brokerRepository.find({ + where: codeRegionPairs.map((pair) => ({ + brokerCode: pair.brokerCode, + region: pair.region, + })), + }); + + if (existingBrokers.length > 0) { + const conflicts = existingBrokers.map( + (b) => `${b.brokerCode} in ${b.region}`, + ); + throw new ConflictException( + `The following brokers already exist: ${conflicts.join(', ')}`, + ); + } + + // 检查批量数据内部是否有重复 + const uniquePairs = new Set( + codeRegionPairs.map((p) => `${p.brokerCode}-${p.region}`), + ); + if (uniquePairs.size !== codeRegionPairs.length) { + throw new ConflictException( + 'Duplicate broker_code and region combinations in batch data', + ); + } + + return this.brokerRepository.save(brokers); + } + + /** + * 查询 broker(支持多种查询条件) + */ + async findAll(queryDto: QueryBrokerDto): Promise { + const where: FindOptionsWhere = {}; + + if (queryDto.brokerId) { + where.brokerId = queryDto.brokerId; + } + + if (queryDto.brokerCode) { + where.brokerCode = queryDto.brokerCode; + } + + if (queryDto.brokerName) { + where.brokerName = queryDto.brokerName; + } + + if (queryDto.region) { + where.region = queryDto.region; + } + + if (queryDto.isActive !== undefined) { + where.isActive = queryDto.isActive; + } + + return this.brokerRepository.find({ + where, + order: { + sortOrder: 'ASC', + brokerId: 'ASC', + }, + }); + } + + /** + * 根据 ID 查询单个 broker + */ + async findOne(id: number): Promise { + const broker = await this.brokerRepository.findOne({ + where: { brokerId: id }, + }); + + if (!broker) { + throw new NotFoundException(`Broker with ID ${id} not found`); + } + + return broker; + } + + /** + * 根据条件查询单个 broker(返回第一个匹配的) + */ + async findOneByCondition(queryDto: QueryBrokerDto): Promise { + const where: FindOptionsWhere = {}; + + if (queryDto.brokerId) { + where.brokerId = queryDto.brokerId; + } + + if (queryDto.brokerCode) { + where.brokerCode = queryDto.brokerCode; + } + + if (queryDto.brokerName) { + where.brokerName = queryDto.brokerName; + } + + if (queryDto.region) { + where.region = queryDto.region; + } + + if (queryDto.isActive !== undefined) { + where.isActive = queryDto.isActive; + } + + const broker = await this.brokerRepository.findOne({ where }); + + if (!broker) { + throw new NotFoundException( + 'Broker not found with the given conditions', + ); + } + + return broker; + } + + /** + * 更新 broker + */ + async update( + id: number, + updateBrokerDto: UpdateBrokerDto, + ): Promise { + const broker = await this.findOne(id); + + // 如果更新 broker_code 或 region,检查是否冲突 + if ('brokerCode' in updateBrokerDto || 'region' in updateBrokerDto) { + const newCode = updateBrokerDto.brokerCode ?? broker.brokerCode; + const newRegion = updateBrokerDto.region ?? broker.region; + + const existing = await this.brokerRepository.findOne({ + where: { + brokerCode: newCode, + region: newRegion, + }, + }); + + if (existing && existing.brokerId !== id) { + throw new ConflictException( + `Broker with code "${newCode}" already exists in region "${newRegion}"`, + ); + } + } + + // 如果更新 broker_name 或 region,检查是否冲突 + if ('brokerName' in updateBrokerDto || 'region' in updateBrokerDto) { + const newName = updateBrokerDto.brokerName ?? broker.brokerName; + const newRegion = updateBrokerDto.region ?? broker.region; + + const existing = await this.brokerRepository.findOne({ + where: { + brokerName: newName, + region: newRegion, + }, + }); + + if (existing && existing.brokerId !== id) { + throw new ConflictException( + `Broker with name "${newName}" already exists in region "${newRegion}"`, + ); + } + } + + Object.assign(broker, updateBrokerDto); + return this.brokerRepository.save(broker); + } + + /** + * 删除 broker + */ + async remove(id: number): Promise { + const broker = await this.findOne(id); + await this.brokerRepository.remove(broker); + } +} diff --git a/apps/api/src/modules/broker/config/brokers.config.ts b/apps/api/src/modules/broker/config/brokers.config.ts new file mode 100644 index 0000000..17bbcb6 --- /dev/null +++ b/apps/api/src/modules/broker/config/brokers.config.ts @@ -0,0 +1,165 @@ +import { CreateBrokerDto } from '../dto/create-broker.dto'; + +/** + * 主要券商配置数据 + * 包含A股、港股和美股的主要券商信息 + */ +export const brokersConfig: CreateBrokerDto[] = [ + // A股券商 + { + brokerCode: 'HTZQ', + brokerName: '华泰证券', + region: 'CN', + sortOrder: 1, + isActive: true, + }, + { + brokerCode: 'ZSZQ', + brokerName: '招商证券', + region: 'CN', + sortOrder: 2, + isActive: true, + }, + { + brokerCode: 'GTJA', + brokerName: '国泰君安', + region: 'CN', + sortOrder: 3, + isActive: true, + }, + { + brokerCode: 'ZXZQ', + brokerName: '中信证券', + region: 'CN', + sortOrder: 4, + isActive: true, + }, + { + brokerCode: 'HXZQ', + brokerName: '海通证券', + region: 'CN', + sortOrder: 5, + isActive: true, + }, + { + brokerCode: 'GFZQ', + brokerName: '广发证券', + region: 'CN', + sortOrder: 6, + isActive: true, + }, + { + brokerCode: 'ZJZQ', + brokerName: '中金公司', + region: 'CN', + sortOrder: 7, + isActive: true, + }, + { + brokerCode: 'DFZQ', + brokerName: '东方证券', + region: 'CN', + sortOrder: 8, + isActive: true, + }, + { + brokerCode: 'XZQ', + brokerName: '兴业证券', + region: 'CN', + sortOrder: 9, + isActive: true, + }, + { + brokerCode: 'SWZQ', + brokerName: '申万宏源', + region: 'CN', + sortOrder: 10, + isActive: true, + }, + // 港股券商 从21开始 + { + brokerCode: 'FUTU', + brokerName: '富途证券', + region: 'HK', + sortOrder: 21, + isActive: true, + }, + { + brokerCode: 'TIGER', + brokerName: '老虎证券', + region: 'HK', + sortOrder: 22, + isActive: true, + }, + { + brokerCode: 'HSBC', + brokerName: '汇丰银行', + region: 'HK', + sortOrder: 23, + isActive: true, + }, + { + brokerCode: 'CITIC', + brokerName: '中信里昂', + region: 'HK', + sortOrder: 24, + isActive: true, + }, + { + brokerCode: 'UBS', + brokerName: '瑞银证券', + region: 'HK', + sortOrder: 25, + isActive: true, + }, + // 美股券商 从31开始 + { + brokerCode: 'IBKR', + brokerName: 'Interactive Brokers', + region: 'US', + sortOrder: 31, + isActive: true, + }, + { + brokerCode: 'SCHWAB', + brokerName: 'Charles Schwab', + region: 'US', + sortOrder: 32, + isActive: true, + }, + { + brokerCode: 'FIDELITY', + brokerName: 'Fidelity', + region: 'US', + sortOrder: 33, + isActive: true, + }, + { + brokerCode: 'TD', + brokerName: 'TD Ameritrade', + region: 'US', + sortOrder: 34, + isActive: true, + }, + { + brokerCode: 'ETRADE', + brokerName: 'E*TRADE', + region: 'US', + sortOrder: 35, + isActive: true, + }, + { + brokerCode: 'ROBINHOOD', + brokerName: 'Robinhood', + region: 'US', + sortOrder: 36, + isActive: true, + }, + { + brokerCode: 'WEBULL', + brokerName: 'Webull', + region: 'US', + sortOrder: 37, + isActive: true, + }, +]; diff --git a/apps/api/src/modules/broker/dto/batch-create-broker.dto.ts b/apps/api/src/modules/broker/dto/batch-create-broker.dto.ts new file mode 100644 index 0000000..06eb922 --- /dev/null +++ b/apps/api/src/modules/broker/dto/batch-create-broker.dto.ts @@ -0,0 +1,29 @@ +import { IsArray, ValidateNested } from 'class-validator'; +import { Type } from 'class-transformer'; +import { ApiProperty } from '@nestjs/swagger'; +import { CreateBrokerDto } from './create-broker.dto'; + +export class BatchCreateBrokerDto { + @ApiProperty({ + description: '券商列表', + type: [CreateBrokerDto], + example: [ + { + brokerCode: 'HTZQ', + brokerName: '华泰证券', + region: 'CN', + sortOrder: 1, + }, + { + brokerCode: 'ZSZQ', + brokerName: '招商证券', + region: 'CN', + sortOrder: 2, + }, + ], + }) + @IsArray() + @ValidateNested({ each: true }) + @Type(() => CreateBrokerDto) + brokers: CreateBrokerDto[]; +} diff --git a/apps/api/src/modules/broker/dto/create-broker.dto.ts b/apps/api/src/modules/broker/dto/create-broker.dto.ts new file mode 100644 index 0000000..fe66f1d --- /dev/null +++ b/apps/api/src/modules/broker/dto/create-broker.dto.ts @@ -0,0 +1,76 @@ +import { + IsString, + IsNotEmpty, + IsOptional, + IsNumber, + IsBoolean, + MinLength, + MaxLength, + IsIn, + Min, +} from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class CreateBrokerDto { + @ApiProperty({ + description: '券商代码', + example: 'HTZQ', + maxLength: 50, + }) + @IsString() + @IsNotEmpty() + @MinLength(1) + @MaxLength(50) + brokerCode: string; + + @ApiProperty({ + description: '券商名称', + example: '华泰证券', + maxLength: 100, + }) + @IsString() + @IsNotEmpty() + @MinLength(1) + @MaxLength(100) + brokerName: string; + + @ApiProperty({ + description: '地区/国家代码', + example: 'CN', + enum: ['CN', 'US', 'HK', 'SG', 'JP', 'UK', 'AU', 'CA', 'OTHER'], + }) + @IsString() + @IsNotEmpty() + @IsIn(['CN', 'US', 'HK', 'SG', 'JP', 'UK', 'AU', 'CA', 'OTHER']) + region: string; + + @ApiPropertyOptional({ + description: '排序顺序', + example: 1, + minimum: 0, + default: 0, + }) + @IsOptional() + @IsNumber() + @Min(0) + sortOrder?: number; + + @ApiPropertyOptional({ + description: '是否启用', + example: true, + default: true, + }) + @IsOptional() + @IsBoolean() + isActive?: boolean; + + @ApiPropertyOptional({ + description: '券商图片地址', + example: 'https://example.com/broker-image.jpg', + maxLength: 200, + }) + @IsOptional() + @IsString() + @MaxLength(200) + brokerImage?: string; +} diff --git a/apps/api/src/modules/broker/dto/query-broker.dto.ts b/apps/api/src/modules/broker/dto/query-broker.dto.ts new file mode 100644 index 0000000..0a5f91c --- /dev/null +++ b/apps/api/src/modules/broker/dto/query-broker.dto.ts @@ -0,0 +1,99 @@ +import { + IsOptional, + IsString, + IsNumber, + IsBoolean, + Min, +} from 'class-validator'; +import { Type } from 'class-transformer'; +import { ApiPropertyOptional } from '@nestjs/swagger'; + +export class QueryBrokerDto { + @ApiPropertyOptional({ + description: '券商ID', + example: 1, + minimum: 1, + }) + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(1) + brokerId?: number; + + @ApiPropertyOptional({ + description: '券商代码', + example: 'HTZQ', + }) + @IsOptional() + @IsString() + brokerCode?: string; + + @ApiPropertyOptional({ + description: '券商名称', + example: '华泰证券', + }) + @IsOptional() + @IsString() + brokerName?: string; + + @ApiPropertyOptional({ + description: '地区/国家代码', + example: 'CN', + enum: ['CN', 'US', 'HK', 'SG', 'JP', 'UK', 'AU', 'CA', 'OTHER'], + }) + @IsOptional() + @IsString() + region?: string; + + @ApiPropertyOptional({ + description: '是否启用', + example: true, + }) + @IsOptional() + @Type(() => Boolean) + @IsBoolean() + isActive?: boolean; + + @ApiPropertyOptional({ + description: '页码', + example: 1, + minimum: 1, + default: 1, + }) + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(1) + page?: number = 1; + + @ApiPropertyOptional({ + description: '每页数量', + example: 10, + minimum: 1, + default: 10, + }) + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(1) + limit?: number = 10; + + @ApiPropertyOptional({ + description: '排序字段', + example: 'createdAt', + default: 'createdAt', + }) + @IsOptional() + @IsString() + sortBy?: string = 'createdAt'; + + @ApiPropertyOptional({ + description: '排序方向', + example: 'DESC', + enum: ['ASC', 'DESC'], + default: 'DESC', + }) + @IsOptional() + @IsString() + sortOrder?: 'ASC' | 'DESC' = 'DESC'; +} diff --git a/apps/api/src/modules/broker/dto/update-broker.dto.ts b/apps/api/src/modules/broker/dto/update-broker.dto.ts new file mode 100644 index 0000000..164c469 --- /dev/null +++ b/apps/api/src/modules/broker/dto/update-broker.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/swagger'; +import { CreateBrokerDto } from './create-broker.dto'; + +export class UpdateBrokerDto extends PartialType(CreateBrokerDto) {} diff --git a/apps/api/src/modules/broker/interfaces/borker.interface.ts b/apps/api/src/modules/broker/interfaces/borker.interface.ts new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/src/modules/user/user.module.ts b/apps/api/src/modules/user/user.module.ts new file mode 100644 index 0000000..309e84a --- /dev/null +++ b/apps/api/src/modules/user/user.module.ts @@ -0,0 +1,4 @@ +import { Module } from '@nestjs/common'; + +@Module({}) +export class UserModule {} diff --git a/apps/api/src/modules/user/user.spec.ts b/apps/api/src/modules/user/user.spec.ts new file mode 100644 index 0000000..f30b81e --- /dev/null +++ b/apps/api/src/modules/user/user.spec.ts @@ -0,0 +1,7 @@ +import { User } from './user'; + +describe('User', () => { + it('should be defined', () => { + expect(new User()).toBeDefined(); + }); +}); diff --git a/apps/api/src/modules/user/user.ts b/apps/api/src/modules/user/user.ts new file mode 100644 index 0000000..4f82c14 --- /dev/null +++ b/apps/api/src/modules/user/user.ts @@ -0,0 +1 @@ +export class User {} diff --git a/apps/api/test/jest-integration.json b/apps/api/test/jest-integration.json new file mode 100644 index 0000000..bc0fbd7 --- /dev/null +++ b/apps/api/test/jest-integration.json @@ -0,0 +1,12 @@ +{ + "moduleFileExtensions": ["js", "json", "ts"], + "rootDir": "..", + "testEnvironment": "node", + "testMatch": ["**/test/**/*.integration.spec.ts"], + "transform": { + "^.+\\.(t|j)s$": "ts-jest" + }, + "moduleNameMapper": { + "^src/(.*)$": "/src/$1" + } +} diff --git a/apps/api/test/modules/broker/broker.e2e-spec.ts b/apps/api/test/modules/broker/broker.e2e-spec.ts new file mode 100644 index 0000000..cd239a0 --- /dev/null +++ b/apps/api/test/modules/broker/broker.e2e-spec.ts @@ -0,0 +1,240 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { INestApplication } from '@nestjs/common'; +import request from 'supertest'; +import { AppModule } from '../../../src/app.module'; +import { Broker } from '../../../src/modules/broker/broker.entity'; +import { Response } from '../../../src/common/interceptors/transform.interceptor'; + +describe('BrokerController (e2e)', () => { + let app: INestApplication; + + beforeAll(async () => { + jest.setTimeout(30000); + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], // 导入整个应用模块 + }).compile(); + + app = moduleFixture.createNestApplication(); + await app.init(); // 初始化应用(包括所有中间件、管道、拦截器等) + }); + + afterAll(async () => { + await app.close(); + }); + + describe('POST /api/broker', () => { + it('应该成功创建券商(完整流程)', () => { + return request(app.getHttpServer()) + .post('/api/broker') + .send({ + brokerCode: 'E2E_TEST', + brokerName: 'E2E测试券商', + region: 'CN', + sortOrder: 1, + isActive: true, + }) + .expect(201) + .expect((res) => { + // 验证响应格式(经过 TransformInterceptor 处理) + expect(res.body).toHaveProperty('code', 0); + expect(res.body).toHaveProperty('message', 'success'); + expect(res.body).toHaveProperty('data'); + expect(res.body.data).toHaveProperty( + 'brokerCode', + 'E2E_TEST', + ); + expect(res.body.data).toHaveProperty( + 'brokerName', + 'E2E测试券商', + ); + expect(res.body.data).toHaveProperty('brokerId'); + }); + }); + + it('应该返回 400 当数据验证失败时', () => { + return request(app.getHttpServer()) + .post('/api/broker') + .send({ + // 缺少必需字段 + brokerCode: 'TEST', + }) + .expect(400) + .expect((res) => { + // 验证错误响应格式(经过 HttpExceptionFilter 处理) + expect(res.body).toHaveProperty('code'); + expect(res.body).toHaveProperty('message'); + expect(res.body.code).not.toBe(0); + }); + }); + + it('应该返回 409 当券商代码已存在时', async () => { + // 先创建一个券商 + await request(app.getHttpServer()) + .post('/api/broker') + .send({ + brokerCode: 'DUPLICATE_TEST', + brokerName: '重复测试', + region: 'CN', + }) + .expect(201); + + // 尝试创建重复的券商 + return request(app.getHttpServer()) + .post('/api/broker') + .send({ + brokerCode: 'DUPLICATE_TEST', + brokerName: '重复测试2', + region: 'CN', + }) + .expect(409) + .expect((res) => { + expect(res.body.code).toBe(409); + expect(res.body.message).toContain('already exists'); + }); + }); + }); + + describe('GET /api/broker', () => { + it('应该返回券商列表', async () => { + // 先创建一个券商 + const createResponse = await request(app.getHttpServer()) + .post('/api/broker') + .send({ + brokerCode: 'GET_TEST', + brokerName: '查询测试', + region: 'CN', + }) + .expect(201); + + // 查询券商列表 + return request(app.getHttpServer()) + .get('/api/broker') + .expect(200) + .expect((res) => { + expect(res.body).toHaveProperty('code', 0); + expect(res.body).toHaveProperty('data'); + expect(Array.isArray(res.body.data)).toBe(true); + // 验证创建的券商在列表中 + const found = res.body.data.find( + (b: { brokerCode: string }) => + b.brokerCode === 'GET_TEST', + ); + expect(found).toBeDefined(); + }); + }); + + it('应该支持按条件查询', async () => { + // 先创建一个券商 + await request(app.getHttpServer()) + .post('/api/broker') + .send({ + brokerCode: 'QUERY_TEST', + brokerName: '条件查询测试', + region: 'CN', + }) + .expect(201); + + // 按 brokerCode 查询 + return request(app.getHttpServer()) + .get('/api/broker?brokerCode=QUERY_TEST') + .expect(200) + .expect((res) => { + expect(res.body.code).toBe(0); + expect(res.body.data).toHaveLength(1); + expect(res.body.data[0].brokerCode).toBe('QUERY_TEST'); + }); + }); + }); + + describe('GET /api/broker/:id', () => { + it('应该返回单个券商', async () => { + // 先创建一个券商 + const createResponse = await request(app.getHttpServer()) + .post('/api/broker') + .send({ + brokerCode: 'GET_ONE_TEST', + brokerName: '单个查询测试', + region: 'CN', + }) + .expect(201); + + const brokerId = (createResponse.body as Response).data + .brokerId; + + // 查询单个券商 + return request(app.getHttpServer()) + .get(`/api/broker/${brokerId}`) + .expect(200) + .expect((res) => { + expect(res.body.code).toBe(0); + expect(res.body.data.brokerId).toBe(brokerId); + expect(res.body.data.brokerCode).toBe('GET_ONE_TEST'); + }); + }); + + it('应该返回 404 当券商不存在时', () => { + return request(app.getHttpServer()) + .get('/api/broker/99999') + .expect(404) + .expect((res) => { + expect(res.body.code).toBe(404); + }); + }); + }); + + describe('PATCH /api/broker/:id', () => { + it('应该成功更新券商', async () => { + // 先创建一个券商 + const createResponse = await request(app.getHttpServer()) + .post('/api/broker') + .send({ + brokerCode: 'UPDATE_TEST', + brokerName: '更新测试', + region: 'CN', + }) + .expect(201); + + const brokerId = (createResponse.body as Response).data + .brokerId; + + // 更新券商 + return request(app.getHttpServer()) + .patch(`/api/broker/${brokerId}`) + .send({ + brokerName: '更新后的名称', + }) + .expect(200) + .expect((res) => { + expect(res.body.code).toBe(0); + expect(res.body.data.brokerName).toBe('更新后的名称'); + }); + }); + }); + + describe('DELETE /api/broker/:id', () => { + it('应该成功删除券商', async () => { + // 先创建一个券商 + const createResponse = await request(app.getHttpServer()) + .post('/api/broker') + .send({ + brokerCode: 'DELETE_TEST', + brokerName: '删除测试', + region: 'CN', + }) + .expect(201); + + const brokerId = (createResponse.body as Response).data + .brokerId; + + // 删除券商 + await request(app.getHttpServer()) + .delete(`/api/broker/${brokerId}`) + .expect(204); + + // 验证已删除 + await request(app.getHttpServer()) + .get(`/api/broker/${brokerId}`) + .expect(404); + }); + }); +}); diff --git a/apps/api/test/modules/broker/broker.service.integration.spec.ts b/apps/api/test/modules/broker/broker.service.integration.spec.ts new file mode 100644 index 0000000..3a93c2e --- /dev/null +++ b/apps/api/test/modules/broker/broker.service.integration.spec.ts @@ -0,0 +1,150 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { BrokerService } from '../../../src/modules/broker/broker.service'; +import { Broker } from '../../../src/modules/broker/broker.entity'; +import { BrokerModule } from '../../../src/modules/broker/broker.module'; +import { CreateBrokerDto } from '../../../src/modules/broker/dto/create-broker.dto'; +import { BatchCreateBrokerDto } from '../../../src/modules/broker/dto/batch-create-broker.dto'; +import { getDatabaseConfig } from '../../../src/database/database.config'; +import { brokersConfig } from '../../../src/modules/broker/config/brokers.config'; + +describe('BrokerService (集成测试)', () => { + let service: BrokerService; + let repository: Repository; + let module: TestingModule; + + beforeAll(async () => { + jest.setTimeout(30000); // 设置超时为 30 秒 + // 创建测试模块,使用真实数据库连接 + module = await Test.createTestingModule({ + imports: [ + ConfigModule.forRoot({ + isGlobal: true, + envFilePath: ['.env.development', '.env'], + }), + TypeOrmModule.forRootAsync({ + imports: [ConfigModule], + useFactory: (configService: ConfigService) => { + const config = getDatabaseConfig(configService); + // 使用测试数据库(如果配置了),否则使用开发数据库 + const testDatabase = + configService.get('DB_DATABASE') || + (config.database as string); + return { + ...config, + database: testDatabase, + synchronize: true, // 测试环境允许同步 + dropSchema: false, // 不删除现有数据 + } as typeof config; + }, + inject: [ConfigService], + }), + BrokerModule, + ], + }).compile(); + + service = module.get(BrokerService); + repository = module.get>(getRepositoryToken(Broker)); + + // 清理测试数据(可选) + await repository.clear(); + }); + + afterAll(async () => { + // 测试结束后清理 + // await repository.clear(); + await module.close(); + }); + + describe.skip('create - 集成测试', () => { + it('应该成功在数据库中创建券商', async () => { + const createBrokerDto: CreateBrokerDto = { + brokerCode: 'HTZQ_TEST', + brokerName: '华泰证券测试', + region: 'CN', + sortOrder: 1, + isActive: true, + }; + + const result = await service.create(createBrokerDto); + console.log(result); + + expect(result).toBeDefined(); + expect(result.brokerId).toBeDefined(); + expect(result.brokerCode).toBe(createBrokerDto.brokerCode); + expect(result.brokerName).toBe(createBrokerDto.brokerName); + + // 验证数据确实保存到数据库 + const savedBroker = await repository.findOne({ + where: { brokerId: result.brokerId }, + }); + expect(savedBroker).toBeDefined(); + expect(savedBroker?.brokerCode).toBe(createBrokerDto.brokerCode); + + // 清理 + // await repository.remove(result); + }); + + it('应该使用数据库默认值', async () => { + const createBrokerDto: CreateBrokerDto = { + brokerCode: 'ZSZQ_TEST', + brokerName: '招商证券测试', + region: 'CN', + // 不提供 sortOrder 和 isActive + }; + + const result = await service.create(createBrokerDto); + + expect(result.sortOrder).toBe(0); // 数据库默认值 + expect(result.isActive).toBe(true); // 数据库默认值 + + // 清理 + // await repository.remove(result); + }); + }); + + describe.only('batchCreate - 集成测试', () => { + it('应该成功从配置文件批量创建券商', async () => { + // 从配置文件读取券商数据 + const batchCreateDto: BatchCreateBrokerDto = { + brokers: brokersConfig, + }; + + const result = await service.batchCreate(batchCreateDto); + + // 验证创建成功 + expect(result).toBeDefined(); + expect(Array.isArray(result)).toBe(true); + expect(result.length).toBeGreaterThan(0); + + // 验证数据确实保存到数据库 + const savedBrokers = await repository.find({ + where: brokersConfig.map((broker) => ({ + brokerCode: broker.brokerCode, + region: broker.region, + })), + }); + + expect(savedBrokers.length).toBe(brokersConfig.length); + + // 验证部分数据 + const cnBrokers = savedBrokers.filter((b) => b.region === 'CN'); + const hkBrokers = savedBrokers.filter((b) => b.region === 'HK'); + const usBrokers = savedBrokers.filter((b) => b.region === 'US'); + + expect(cnBrokers.length).toBeGreaterThan(0); + expect(hkBrokers.length).toBeGreaterThan(0); + expect(usBrokers.length).toBeGreaterThan(0); + + // 验证 brokerImage 为 null 或 undefined + savedBrokers.forEach((broker) => { + expect(broker.brokerImage).toBeFalsy(); // null 或 undefined 都通过 + }); + + // 注意:不删除数据,保留在数据库中 + }); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a4695a2..12a191e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,19 +12,43 @@ importers: dependencies: '@nestjs/common': specifier: ^11.0.1 - version: 11.1.9(reflect-metadata@0.2.2)(rxjs@7.8.2) + version: 11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/config': specifier: ^4.0.2 - version: 4.0.2(@nestjs/common@11.1.9(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2) + version: 4.0.2(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2) '@nestjs/core': specifier: ^11.0.1 - version: 11.1.9(@nestjs/common@11.1.9(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.9)(reflect-metadata@0.2.2)(rxjs@7.8.2) + version: 11.1.9(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.9)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/mapped-types': + specifier: ^2.1.0 + version: 2.1.0(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2) '@nestjs/platform-express': specifier: ^11.0.1 - version: 11.1.9(@nestjs/common@11.1.9(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.9) + version: 11.1.9(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.9) + '@nestjs/swagger': + specifier: ^11.2.2 + version: 11.2.2(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.9)(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2) '@nestjs/typeorm': specifier: ^11.0.0 - version: 11.0.0(@nestjs/common@11.1.9(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.9)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.27(pg@8.16.3)(reflect-metadata@0.2.2)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3))) + version: 11.0.0(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.9)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.27(pg@8.16.3)(reflect-metadata@0.2.2)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3))) + body-parser: + specifier: ^2.2.0 + version: 2.2.0 + class-transformer: + specifier: ^0.5.1 + version: 0.5.1 + class-validator: + specifier: ^0.14.2 + version: 0.14.2 + compression: + specifier: ^1.8.1 + version: 1.8.1 + express-rate-limit: + specifier: ^8.2.1 + version: 8.2.1(express@5.1.0) + helmet: + specifier: ^8.1.0 + version: 8.1.0 pg: specifier: ^8.16.3 version: 8.16.3 @@ -52,7 +76,13 @@ importers: version: 11.0.9(chokidar@4.0.3)(typescript@5.9.3) '@nestjs/testing': specifier: ^11.0.1 - version: 11.1.9(@nestjs/common@11.1.9(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.9)(@nestjs/platform-express@11.1.9) + version: 11.1.9(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.9)(@nestjs/platform-express@11.1.9) + '@types/body-parser': + specifier: ^1.19.6 + version: 1.19.6 + '@types/compression': + specifier: ^1.8.1 + version: 1.8.1 '@types/express': specifier: ^5.0.0 version: 5.0.5 @@ -654,6 +684,9 @@ packages: resolution: {integrity: sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==} engines: {node: '>=8'} + '@microsoft/tsdoc@0.16.0': + resolution: {integrity: sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==} + '@napi-rs/wasm-runtime@0.2.12': resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} @@ -707,6 +740,19 @@ packages: '@nestjs/websockets': optional: true + '@nestjs/mapped-types@2.1.0': + resolution: {integrity: sha512-W+n+rM69XsFdwORF11UqJahn4J3xi4g/ZEOlJNL6KoW5ygWSmBB2p0S2BZ4FQeS/NDH72e6xIcu35SfJnE8bXw==} + peerDependencies: + '@nestjs/common': ^10.0.0 || ^11.0.0 + class-transformer: ^0.4.0 || ^0.5.0 + class-validator: ^0.13.0 || ^0.14.0 + reflect-metadata: ^0.1.12 || ^0.2.0 + peerDependenciesMeta: + class-transformer: + optional: true + class-validator: + optional: true + '@nestjs/platform-express@11.1.9': resolution: {integrity: sha512-GVd3+0lO0mJq2m1kl9hDDnVrX3Nd4oH3oDfklz0pZEVEVS0KVSp63ufHq2Lu9cyPdSBuelJr9iPm2QQ1yX+Kmw==} peerDependencies: @@ -718,6 +764,23 @@ packages: peerDependencies: typescript: '>=4.8.2' + '@nestjs/swagger@11.2.2': + resolution: {integrity: sha512-i16GRaZ7vlTHIqk8C1UvV/WwQYbWwQymocTvU8mr6QIUBZ6fJc1uGEsw0Mu/JWC0kaV3nbsTj1hZbXrc5Ui4NA==} + peerDependencies: + '@fastify/static': ^8.0.0 + '@nestjs/common': ^11.0.1 + '@nestjs/core': ^11.0.1 + class-transformer: '*' + class-validator: '*' + reflect-metadata: ^0.1.12 || ^0.2.0 + peerDependenciesMeta: + '@fastify/static': + optional: true + class-transformer: + optional: true + class-validator: + optional: true + '@nestjs/testing@11.1.9': resolution: {integrity: sha512-UFxerBDdb0RUNxQNj25pvkvNE7/vxKhXYWBt3QuwBFnYISzRIzhVlyIqLfoV5YI3zV0m0Nn4QAn1KM0zzwfEng==} peerDependencies: @@ -772,6 +835,9 @@ packages: resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + '@scarf/scarf@1.4.0': + resolution: {integrity: sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==} + '@sinclair/typebox@0.34.41': resolution: {integrity: sha512-6gS8pZzSXdyRHTIqoqSVknxolr1kzfy4/CeDnrzsVz8TTIWUbOBr6gnzOmTYJ3eXQNh4IYHIGi5aIL7sOZ2G/g==} @@ -821,6 +887,9 @@ packages: '@types/body-parser@1.19.6': resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} + '@types/compression@1.8.1': + resolution: {integrity: sha512-kCFuWS0ebDbmxs0AXYn6e2r2nrGAb5KwQhknjSPSPgJcGd8+HVSILlUyFhGqML2gk39HcG7D1ydW9/qpYkN00Q==} + '@types/connect@3.4.38': resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} @@ -896,6 +965,9 @@ packages: '@types/supertest@6.0.3': resolution: {integrity: sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==} + '@types/validator@13.15.10': + resolution: {integrity: sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==} + '@types/yargs-parser@21.0.3': resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} @@ -1369,6 +1441,12 @@ packages: cjs-module-lexer@2.1.1: resolution: {integrity: sha512-+CmxIZ/L2vNcEfvNtLdU0ZQ6mbq3FZnwAP2PPTiKP+1QOoKwlKlPgb8UKV0Dds7QVaMnHm+FwSft2VB0s/SLjQ==} + class-transformer@0.5.1: + resolution: {integrity: sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==} + + class-validator@0.14.2: + resolution: {integrity: sha512-3kMVRF2io8N8pY1IFIXlho9r8IPUUIfHe2hYVtiebvAzU2XeQFXTv+XI4WX+TnXmtwXMDcjngcpkiPM0O9PvLw==} + cli-cursor@3.1.0: resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==} engines: {node: '>=8'} @@ -1425,6 +1503,14 @@ packages: component-emitter@1.3.1: resolution: {integrity: sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==} + compressible@2.0.18: + resolution: {integrity: sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==} + engines: {node: '>= 0.6'} + + compression@1.8.1: + resolution: {integrity: sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==} + engines: {node: '>= 0.8.0'} + concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} @@ -1484,6 +1570,14 @@ packages: dayjs@1.11.19: resolution: {integrity: sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==} + debug@2.6.9: + resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -1705,6 +1799,12 @@ packages: resolution: {integrity: sha512-u/feCi0GPsI+988gU2FLcsHyAHTU0MX1Wg68NhAnN7z/+C5wqG+CY8J53N9ioe8RXgaoz0nBR/TYMf3AycUuPw==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + express-rate-limit@8.2.1: + resolution: {integrity: sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==} + engines: {node: '>= 16'} + peerDependencies: + express: '>= 4.11' + express@5.1.0: resolution: {integrity: sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==} engines: {node: '>= 18'} @@ -1910,6 +2010,10 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + helmet@8.1.0: + resolution: {integrity: sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==} + engines: {node: '>=18.0.0'} + html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} @@ -1960,6 +2064,10 @@ packages: inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + ip-address@10.0.1: + resolution: {integrity: sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==} + engines: {node: '>= 12'} + ipaddr.js@1.9.1: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} @@ -2232,6 +2340,9 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} + libphonenumber-js@1.12.26: + resolution: {integrity: sha512-MagMOuqEXB2Pa90cWE+BoCmcKJx+de5uBIicaUkQ+uiEslZ0OBMNOkSZT/36syXNHu68UeayTxPm3DYM2IHoLQ==} + lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} @@ -2369,6 +2480,9 @@ packages: resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==} hasBin: true + ms@2.0.0: + resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -2388,6 +2502,10 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + negotiator@0.6.4: + resolution: {integrity: sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==} + engines: {node: '>= 0.6'} + negotiator@1.0.0: resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} engines: {node: '>= 0.6'} @@ -2427,6 +2545,10 @@ packages: resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} engines: {node: '>= 0.8'} + on-headers@1.1.0: + resolution: {integrity: sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==} + engines: {node: '>= 0.8'} + once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} @@ -2871,6 +2993,9 @@ packages: resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} engines: {node: '>=10'} + swagger-ui-dist@5.30.2: + resolution: {integrity: sha512-HWCg1DTNE/Nmapt+0m2EPXFwNKNeKK4PwMjkwveN/zn1cV2Kxi9SURd+m0SpdcSgWEK/O64sf8bzXdtUhigtHA==} + symbol-observable@4.0.0: resolution: {integrity: sha512-b19dMThMV4HVFynSAM1++gBHAbk2Tc/osgLIBZMKsyqh34jb2e8Os7T6ZW/Bt3pJFdBTd2JwAnAAEQV7rSNvcQ==} engines: {node: '>=0.10'} @@ -3150,6 +3275,10 @@ packages: resolution: {integrity: sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==} engines: {node: '>=10.12.0'} + validator@13.15.23: + resolution: {integrity: sha512-4yoz1kEWqUjzi5zsPbAS/903QXSYp0UOtHsPpp7p9rHAw/W+dkInskAE386Fat3oKRROwO98d9ZB0G4cObgUyw==} + engines: {node: '>= 0.10'} + vary@1.1.2: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} @@ -3950,6 +4079,8 @@ snapshots: '@lukeed/csprng@1.1.0': {} + '@microsoft/tsdoc@0.16.0': {} + '@napi-rs/wasm-runtime@0.2.12': dependencies: '@emnapi/core': 1.7.1 @@ -3984,7 +4115,7 @@ snapshots: - uglify-js - webpack-cli - '@nestjs/common@11.1.9(reflect-metadata@0.2.2)(rxjs@7.8.2)': + '@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2)': dependencies: file-type: 21.1.0 iterare: 1.2.1 @@ -3993,20 +4124,23 @@ snapshots: rxjs: 7.8.2 tslib: 2.8.1 uid: 2.0.2 + optionalDependencies: + class-transformer: 0.5.1 + class-validator: 0.14.2 transitivePeerDependencies: - supports-color - '@nestjs/config@4.0.2(@nestjs/common@11.1.9(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2)': + '@nestjs/config@4.0.2(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2)': dependencies: - '@nestjs/common': 11.1.9(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) dotenv: 16.4.7 dotenv-expand: 12.0.1 lodash: 4.17.21 rxjs: 7.8.2 - '@nestjs/core@11.1.9(@nestjs/common@11.1.9(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.9)(reflect-metadata@0.2.2)(rxjs@7.8.2)': + '@nestjs/core@11.1.9(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.9)(reflect-metadata@0.2.2)(rxjs@7.8.2)': dependencies: - '@nestjs/common': 11.1.9(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nuxt/opencollective': 0.4.1 fast-safe-stringify: 2.1.1 iterare: 1.2.1 @@ -4016,12 +4150,20 @@ snapshots: tslib: 2.8.1 uid: 2.0.2 optionalDependencies: - '@nestjs/platform-express': 11.1.9(@nestjs/common@11.1.9(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.9) + '@nestjs/platform-express': 11.1.9(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.9) - '@nestjs/platform-express@11.1.9(@nestjs/common@11.1.9(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.9)': + '@nestjs/mapped-types@2.1.0(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)': dependencies: - '@nestjs/common': 11.1.9(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.9(@nestjs/common@11.1.9(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.9)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) + reflect-metadata: 0.2.2 + optionalDependencies: + class-transformer: 0.5.1 + class-validator: 0.14.2 + + '@nestjs/platform-express@11.1.9(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.9)': + dependencies: + '@nestjs/common': 11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.9(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.9)(reflect-metadata@0.2.2)(rxjs@7.8.2) cors: 2.8.5 express: 5.1.0 multer: 2.0.2 @@ -4052,18 +4194,33 @@ snapshots: transitivePeerDependencies: - chokidar - '@nestjs/testing@11.1.9(@nestjs/common@11.1.9(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.9)(@nestjs/platform-express@11.1.9)': + '@nestjs/swagger@11.2.2(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.9)(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)': dependencies: - '@nestjs/common': 11.1.9(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.9(@nestjs/common@11.1.9(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.9)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@microsoft/tsdoc': 0.16.0 + '@nestjs/common': 11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.9(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.9)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/mapped-types': 2.1.0(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2) + js-yaml: 4.1.1 + lodash: 4.17.21 + path-to-regexp: 8.3.0 + reflect-metadata: 0.2.2 + swagger-ui-dist: 5.30.2 + optionalDependencies: + class-transformer: 0.5.1 + class-validator: 0.14.2 + + '@nestjs/testing@11.1.9(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.9)(@nestjs/platform-express@11.1.9)': + dependencies: + '@nestjs/common': 11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.9(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.9)(reflect-metadata@0.2.2)(rxjs@7.8.2) tslib: 2.8.1 optionalDependencies: - '@nestjs/platform-express': 11.1.9(@nestjs/common@11.1.9(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.9) + '@nestjs/platform-express': 11.1.9(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.9) - '@nestjs/typeorm@11.0.0(@nestjs/common@11.1.9(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.9)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.27(pg@8.16.3)(reflect-metadata@0.2.2)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)))': + '@nestjs/typeorm@11.0.0(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.9)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.27(pg@8.16.3)(reflect-metadata@0.2.2)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)))': dependencies: - '@nestjs/common': 11.1.9(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.9(@nestjs/common@11.1.9(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.9)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.9(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.9)(reflect-metadata@0.2.2)(rxjs@7.8.2) reflect-metadata: 0.2.2 rxjs: 7.8.2 typeorm: 0.3.27(pg@8.16.3)(reflect-metadata@0.2.2)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)) @@ -4095,6 +4252,8 @@ snapshots: '@pkgr/core@0.2.9': {} + '@scarf/scarf@1.4.0': {} + '@sinclair/typebox@0.34.41': {} '@sinonjs/commons@3.0.1': @@ -4156,6 +4315,11 @@ snapshots: '@types/connect': 3.4.38 '@types/node': 22.19.1 + '@types/compression@1.8.1': + dependencies: + '@types/express': 5.0.5 + '@types/node': 22.19.1 + '@types/connect@3.4.38': dependencies: '@types/node': 22.19.1 @@ -4253,6 +4417,8 @@ snapshots: '@types/methods': 1.1.4 '@types/superagent': 8.1.9 + '@types/validator@13.15.10': {} + '@types/yargs-parser@21.0.3': {} '@types/yargs@17.0.35': @@ -4761,6 +4927,14 @@ snapshots: cjs-module-lexer@2.1.1: {} + class-transformer@0.5.1: {} + + class-validator@0.14.2: + dependencies: + '@types/validator': 13.15.10 + libphonenumber-js: 1.12.26 + validator: 13.15.23 + cli-cursor@3.1.0: dependencies: restore-cursor: 3.1.0 @@ -4809,6 +4983,22 @@ snapshots: component-emitter@1.3.1: {} + compressible@2.0.18: + dependencies: + mime-db: 1.54.0 + + compression@1.8.1: + dependencies: + bytes: 3.1.2 + compressible: 2.0.18 + debug: 2.6.9 + negotiator: 0.6.4 + on-headers: 1.1.0 + safe-buffer: 5.2.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + concat-map@0.0.1: {} concat-stream@2.0.0: @@ -4860,6 +5050,10 @@ snapshots: dayjs@1.11.19: {} + debug@2.6.9: + dependencies: + ms: 2.0.0 + debug@4.4.3: dependencies: ms: 2.1.3 @@ -5069,6 +5263,11 @@ snapshots: jest-mock: 30.2.0 jest-util: 30.2.0 + express-rate-limit@8.2.1(express@5.1.0): + dependencies: + express: 5.1.0 + ip-address: 10.0.1 + express@5.1.0: dependencies: accepts: 2.0.0 @@ -5333,6 +5532,8 @@ snapshots: dependencies: function-bind: 1.1.2 + helmet@8.1.0: {} + html-escaper@2.0.2: {} http-errors@2.0.0: @@ -5378,6 +5579,8 @@ snapshots: inherits@2.0.4: {} + ip-address@10.0.1: {} + ipaddr.js@1.9.1: {} is-arrayish@0.2.1: {} @@ -5817,6 +6020,8 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 + libphonenumber-js@1.12.26: {} + lines-and-columns@1.2.4: {} load-esm@1.0.3: {} @@ -5923,6 +6128,8 @@ snapshots: dependencies: minimist: 1.2.8 + ms@2.0.0: {} + ms@2.1.3: {} multer@2.0.2: @@ -5941,6 +6148,8 @@ snapshots: natural-compare@1.4.0: {} + negotiator@0.6.4: {} + negotiator@1.0.0: {} neo-async@2.6.2: {} @@ -5969,6 +6178,8 @@ snapshots: dependencies: ee-first: 1.1.1 + on-headers@1.1.0: {} + once@1.4.0: dependencies: wrappy: 1.0.2 @@ -6418,6 +6629,10 @@ snapshots: dependencies: has-flag: 4.0.0 + swagger-ui-dist@5.30.2: + dependencies: + '@scarf/scarf': 1.4.0 + symbol-observable@4.0.0: {} synckit@0.11.11: @@ -6666,6 +6881,8 @@ snapshots: '@types/istanbul-lib-coverage': 2.0.6 convert-source-map: 2.0.0 + validator@13.15.23: {} + vary@1.1.2: {} walker@1.0.8: