feat: 开发broker相关代码,开发全局代码
This commit is contained in:
@@ -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
|
||||
|
||||
714
apps/api/NESTJS-GENERATE-GUIDE.md
Normal file
714
apps/api/NESTJS-GENERATE-GUIDE.md
Normal file
@@ -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<User> {
|
||||
// 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<User[]> {
|
||||
// 使用 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**
|
||||
@@ -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',
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
|
||||
96
apps/api/src/common/filters/http-exception.filter.ts
Normal file
96
apps/api/src/common/filters/http-exception.filter.ts
Normal file
@@ -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<string, unknown>;
|
||||
}
|
||||
|
||||
@Catch()
|
||||
export class HttpExceptionFilter implements ExceptionFilter {
|
||||
catch(exception: unknown, host: ArgumentsHost) {
|
||||
const ctx = host.switchToHttp();
|
||||
const response = ctx.getResponse<Response>();
|
||||
|
||||
let status = HttpStatus.INTERNAL_SERVER_ERROR;
|
||||
let message = 'Internal server error';
|
||||
let code = -1; // 默认错误码
|
||||
let errors: string[] | Record<string, unknown> | 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<string, unknown>;
|
||||
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<number, number> = {
|
||||
[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;
|
||||
}
|
||||
}
|
||||
49
apps/api/src/common/interceptors/transform.interceptor.ts
Normal file
49
apps/api/src/common/interceptors/transform.interceptor.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import {
|
||||
Injectable,
|
||||
NestInterceptor,
|
||||
ExecutionContext,
|
||||
CallHandler,
|
||||
} from '@nestjs/common';
|
||||
import { Observable } from 'rxjs';
|
||||
import { map } from 'rxjs/operators';
|
||||
|
||||
export interface Response<T> {
|
||||
code: number;
|
||||
message: string;
|
||||
data: T;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class TransformInterceptor<T>
|
||||
implements NestInterceptor<T, Response<T>>
|
||||
{
|
||||
intercept(
|
||||
context: ExecutionContext,
|
||||
next: CallHandler,
|
||||
): Observable<Response<T>> {
|
||||
return next.handle().pipe(
|
||||
map((data) => {
|
||||
// 如果响应已经是统一格式,直接返回
|
||||
if (
|
||||
data &&
|
||||
typeof data === 'object' &&
|
||||
'code' in data &&
|
||||
'message' in data &&
|
||||
'data' in data
|
||||
) {
|
||||
return data as Response<T>;
|
||||
}
|
||||
|
||||
// 统一包装响应格式
|
||||
// code: 0 表示业务处理成功(HTTP 状态码 200 表示请求成功)
|
||||
return {
|
||||
code: 0,
|
||||
message: 'success',
|
||||
data: (data ?? null) as T,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
34
apps/api/src/core/core.module.ts
Normal file
34
apps/api/src/core/core.module.ts
Normal file
@@ -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 {}
|
||||
@@ -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<number>('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();
|
||||
|
||||
222
apps/api/src/modules/broker/__tests__/broker.controller.spec.ts
Normal file
222
apps/api/src/modules/broker/__tests__/broker.controller.spec.ts
Normal file
@@ -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>(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,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
349
apps/api/src/modules/broker/__tests__/broker.service.spec.ts
Normal file
349
apps/api/src/modules/broker/__tests__/broker.service.spec.ts
Normal file
@@ -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<Broker>;
|
||||
|
||||
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>(BrokerService);
|
||||
repository = module.get<Repository<Broker>>(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();
|
||||
});
|
||||
});
|
||||
});
|
||||
141
apps/api/src/modules/broker/broker.controller.ts
Normal file
141
apps/api/src/modules/broker/broker.controller.ts
Normal file
@@ -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<Broker> {
|
||||
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<Broker[]> {
|
||||
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<Broker[]> {
|
||||
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<Broker> {
|
||||
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<Broker> {
|
||||
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<void> {
|
||||
return this.brokerService.remove(+id);
|
||||
}
|
||||
}
|
||||
96
apps/api/src/modules/broker/broker.entity.ts
Normal file
96
apps/api/src/modules/broker/broker.entity.ts
Normal file
@@ -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;
|
||||
}
|
||||
13
apps/api/src/modules/broker/broker.module.ts
Normal file
13
apps/api/src/modules/broker/broker.module.ts
Normal file
@@ -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 {}
|
||||
256
apps/api/src/modules/broker/broker.service.ts
Normal file
256
apps/api/src/modules/broker/broker.service.ts
Normal file
@@ -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>,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 单独创建 broker
|
||||
*/
|
||||
async create(createBrokerDto: CreateBrokerDto): Promise<Broker> {
|
||||
// 检查同一地区的 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<Broker[]> {
|
||||
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<Broker[]> {
|
||||
const where: FindOptionsWhere<Broker> = {};
|
||||
|
||||
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<Broker> {
|
||||
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<Broker> {
|
||||
const where: FindOptionsWhere<Broker> = {};
|
||||
|
||||
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<Broker> {
|
||||
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<void> {
|
||||
const broker = await this.findOne(id);
|
||||
await this.brokerRepository.remove(broker);
|
||||
}
|
||||
}
|
||||
165
apps/api/src/modules/broker/config/brokers.config.ts
Normal file
165
apps/api/src/modules/broker/config/brokers.config.ts
Normal file
@@ -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,
|
||||
},
|
||||
];
|
||||
29
apps/api/src/modules/broker/dto/batch-create-broker.dto.ts
Normal file
29
apps/api/src/modules/broker/dto/batch-create-broker.dto.ts
Normal file
@@ -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[];
|
||||
}
|
||||
76
apps/api/src/modules/broker/dto/create-broker.dto.ts
Normal file
76
apps/api/src/modules/broker/dto/create-broker.dto.ts
Normal file
@@ -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;
|
||||
}
|
||||
99
apps/api/src/modules/broker/dto/query-broker.dto.ts
Normal file
99
apps/api/src/modules/broker/dto/query-broker.dto.ts
Normal file
@@ -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';
|
||||
}
|
||||
4
apps/api/src/modules/broker/dto/update-broker.dto.ts
Normal file
4
apps/api/src/modules/broker/dto/update-broker.dto.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { PartialType } from '@nestjs/swagger';
|
||||
import { CreateBrokerDto } from './create-broker.dto';
|
||||
|
||||
export class UpdateBrokerDto extends PartialType(CreateBrokerDto) {}
|
||||
4
apps/api/src/modules/user/user.module.ts
Normal file
4
apps/api/src/modules/user/user.module.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
@Module({})
|
||||
export class UserModule {}
|
||||
7
apps/api/src/modules/user/user.spec.ts
Normal file
7
apps/api/src/modules/user/user.spec.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { User } from './user';
|
||||
|
||||
describe('User', () => {
|
||||
it('should be defined', () => {
|
||||
expect(new User()).toBeDefined();
|
||||
});
|
||||
});
|
||||
1
apps/api/src/modules/user/user.ts
Normal file
1
apps/api/src/modules/user/user.ts
Normal file
@@ -0,0 +1 @@
|
||||
export class User {}
|
||||
12
apps/api/test/jest-integration.json
Normal file
12
apps/api/test/jest-integration.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"moduleFileExtensions": ["js", "json", "ts"],
|
||||
"rootDir": "..",
|
||||
"testEnvironment": "node",
|
||||
"testMatch": ["**/test/**/*.integration.spec.ts"],
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
},
|
||||
"moduleNameMapper": {
|
||||
"^src/(.*)$": "<rootDir>/src/$1"
|
||||
}
|
||||
}
|
||||
240
apps/api/test/modules/broker/broker.e2e-spec.ts
Normal file
240
apps/api/test/modules/broker/broker.e2e-spec.ts
Normal file
@@ -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<Broker>).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<Broker>).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<Broker>).data
|
||||
.brokerId;
|
||||
|
||||
// 删除券商
|
||||
await request(app.getHttpServer())
|
||||
.delete(`/api/broker/${brokerId}`)
|
||||
.expect(204);
|
||||
|
||||
// 验证已删除
|
||||
await request(app.getHttpServer())
|
||||
.get(`/api/broker/${brokerId}`)
|
||||
.expect(404);
|
||||
});
|
||||
});
|
||||
});
|
||||
150
apps/api/test/modules/broker/broker.service.integration.spec.ts
Normal file
150
apps/api/test/modules/broker/broker.service.integration.spec.ts
Normal file
@@ -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<Broker>;
|
||||
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<string>('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>(BrokerService);
|
||||
repository = module.get<Repository<Broker>>(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 都通过
|
||||
});
|
||||
|
||||
// 注意:不删除数据,保留在数据库中
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user