feat: 完成券商和用户管理

This commit is contained in:
R524809
2026-01-07 16:21:16 +08:00
parent 712f66b725
commit 457ba6d765
33 changed files with 2851 additions and 177 deletions

View File

@@ -9,14 +9,25 @@ import {
Query,
HttpCode,
HttpStatus,
UseGuards,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiParam } from '@nestjs/swagger';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiParam,
ApiBearerAuth,
} 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 { PaginatedBrokerData } from './dto/paginated-response.dto';
import { Broker } from './broker.entity';
import { Roles } from '../auth/decorators/roles.decorator';
import { RolesGuard } from '../auth/guards/roles.guard';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
@ApiTags('broker')
@Controller('broker')
@@ -27,6 +38,9 @@ export class BrokerController {
* 单独创建 broker
*/
@Post()
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('admin', 'super_admin')
@ApiBearerAuth()
@HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: '创建券商', description: '创建单个券商信息' })
@ApiResponse({
@@ -44,6 +58,9 @@ export class BrokerController {
* 批量创建 broker
*/
@Post('batch')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('admin', 'super_admin')
@ApiBearerAuth()
@HttpCode(HttpStatus.CREATED)
@ApiOperation({
summary: '批量创建券商',
@@ -63,9 +80,9 @@ export class BrokerController {
}
/**
* 查询 broker支持多种查询条件
* 查询 broker支持多种查询条件和分页
* 支持按 broker_id、broker_code、broker_name、region 查询
* 返回一个或多个 broker
* 返回分页数据
*/
@Get()
@ApiOperation({
@@ -75,9 +92,9 @@ export class BrokerController {
@ApiResponse({
status: 200,
description: '查询成功',
type: [Broker],
type: PaginatedBrokerData,
})
findAll(@Query() queryDto: QueryBrokerDto): Promise<Broker[]> {
findAll(@Query() queryDto: QueryBrokerDto): Promise<PaginatedBrokerData> {
return this.brokerService.findAll(queryDto);
}
@@ -104,6 +121,9 @@ export class BrokerController {
* 更新 broker
*/
@Patch(':id')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('admin', 'super_admin')
@ApiBearerAuth()
@ApiOperation({
summary: '更新券商',
description: '更新券商的部分或全部信息',
@@ -127,6 +147,9 @@ export class BrokerController {
* 删除 broker
*/
@Delete(':id')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('admin', 'super_admin')
@ApiBearerAuth()
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({
summary: '删除券商',

View File

@@ -10,6 +10,7 @@ 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 { PaginationInfo } from '@/common/dto/pagination.dto';
@Injectable()
export class BrokerService {
@@ -109,9 +110,12 @@ export class BrokerService {
}
/**
* 查询 broker支持多种查询条件
* 查询 broker支持多种查询条件和分页
*/
async findAll(queryDto: QueryBrokerDto): Promise<Broker[]> {
async findAll(queryDto: QueryBrokerDto): Promise<{
list: Broker[];
pagination: PaginationInfo;
}> {
const where: FindOptionsWhere<Broker> = {};
if (queryDto.brokerId) {
@@ -134,13 +138,53 @@ export class BrokerService {
where.isActive = queryDto.isActive;
}
return this.brokerRepository.find({
// 分页参数
const page = queryDto.page || 1;
const limit = queryDto.limit || 10;
const skip = (page - 1) * limit;
// 排序字段映射
const sortBy = queryDto.sortBy || 'createdAt';
const sortOrder = queryDto.sortOrder || 'DESC';
// 构建排序对象
const order: Record<string, 'ASC' | 'DESC'> = {};
if (sortBy === 'createdAt') {
order.createdAt = sortOrder;
} else if (sortBy === 'sortOrder') {
order.sortOrder = sortOrder;
} else {
order.createdAt = 'DESC';
}
// 添加默认排序
if (sortBy !== 'sortOrder') {
order.sortOrder = 'ASC';
}
order.brokerId = 'ASC';
// 查询总数
const total = await this.brokerRepository.count({ where });
// 查询分页数据
const list = await this.brokerRepository.find({
where,
order: {
sortOrder: 'ASC',
brokerId: 'ASC',
},
order,
skip,
take: limit,
});
// 计算总页数
const total_page = Math.ceil(total / limit);
return {
list,
pagination: {
total,
total_page,
page_size: limit,
current_page: page,
},
};
}
/**

View File

@@ -0,0 +1,14 @@
import { ApiProperty } from '@nestjs/swagger';
import { Broker } from '../broker.entity';
import { PaginationInfo } from '@/common/dto/pagination.dto';
/**
* 券商分页响应数据
*/
export class PaginatedBrokerData {
@ApiProperty({ description: '券商列表', type: [Broker] })
list: Broker[];
@ApiProperty({ description: '分页信息', type: PaginationInfo })
pagination: PaginationInfo;
}

View File

@@ -0,0 +1,14 @@
import { ApiProperty } from '@nestjs/swagger';
import { User } from '../user.entity';
import { PaginationInfo } from '@/common/dto/pagination.dto';
/**
* 用户分页响应数据
*/
export class PaginatedUserData {
@ApiProperty({ description: '用户列表', type: [User] })
list: User[];
@ApiProperty({ description: '分页信息', type: PaginationInfo })
pagination: PaginationInfo;
}

View File

@@ -1,4 +1,12 @@
import { IsOptional, IsString, IsEmail } from 'class-validator';
import {
IsOptional,
IsString,
IsEmail,
IsNumber,
Min,
IsIn,
} from 'class-validator';
import { Type } from 'class-transformer';
import { ApiPropertyOptional } from '@nestjs/swagger';
export class QueryUserDto {
@@ -10,11 +18,91 @@ export class QueryUserDto {
@IsString()
username?: string;
@ApiPropertyOptional({
description: '昵称',
example: 'John Doe',
})
@IsOptional()
@IsString()
nickname?: string;
@ApiPropertyOptional({
description: '邮箱',
example: 'user@example.com',
})
@IsOptional()
@IsEmail()
@IsString()
email?: string;
@ApiPropertyOptional({
description: '电话',
example: '13800138000',
})
@IsOptional()
@IsString()
phone?: string;
@ApiPropertyOptional({
description: '角色',
example: 'user',
enum: ['user', 'admin', 'super_admin'],
})
@IsOptional()
@IsString()
@IsIn(['user', 'admin', 'super_admin'])
role?: string;
@ApiPropertyOptional({
description: '状态',
example: 'active',
enum: ['active', 'inactive', 'deleted'],
})
@IsOptional()
@IsString()
@IsIn(['active', 'inactive', 'deleted'])
status?: string;
@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()
@IsIn(['ASC', 'DESC'])
sortOrder?: 'ASC' | 'DESC' = 'DESC';
}

View File

@@ -0,0 +1,102 @@
/**
* 普通用户 Mock 数据
* 用于种子数据初始化
*/
export interface MockUserData {
username: string;
email: string;
nickname: string;
phone?: string;
password: string; // 明文密码,会自动加密
}
/**
* 12个普通用户的Mock数据
*/
export const MOCK_USERS: MockUserData[] = [
{
username: 'user001',
email: 'user001@vestmind.com',
nickname: '用户001',
phone: '13800010001',
password: 'user123456',
},
{
username: 'user002',
email: 'user002@vestmind.com',
nickname: '用户002',
phone: '13800010002',
password: 'user123456',
},
{
username: 'user003',
email: 'user003@vestmind.com',
nickname: '用户003',
phone: '13800010003',
password: 'user123456',
},
{
username: 'user004',
email: 'user004@vestmind.com',
nickname: '用户004',
phone: '13800010004',
password: 'user123456',
},
{
username: 'user005',
email: 'user005@vestmind.com',
nickname: '用户005',
phone: '13800010005',
password: 'user123456',
},
{
username: 'user006',
email: 'user006@vestmind.com',
nickname: '用户006',
phone: '13800010006',
password: 'user123456',
},
{
username: 'user007',
email: 'user007@vestmind.com',
nickname: '用户007',
phone: '13800010007',
password: 'user123456',
},
{
username: 'user008',
email: 'user008@vestmind.com',
nickname: '用户008',
phone: '13800010008',
password: 'user123456',
},
{
username: 'user009',
email: 'user009@vestmind.com',
nickname: '用户009',
phone: '13800010009',
password: 'user123456',
},
{
username: 'user010',
email: 'user010@vestmind.com',
nickname: '用户010',
phone: '13800010010',
password: 'user123456',
},
{
username: 'user011',
email: 'user011@vestmind.com',
nickname: '用户011',
phone: '13800010011',
password: 'user123456',
},
{
username: 'user012',
email: 'user012@vestmind.com',
nickname: '用户012',
phone: '13800010012',
password: 'user123456',
},
];

View File

@@ -6,6 +6,7 @@ import {
Patch,
Param,
Delete,
Query,
HttpCode,
HttpStatus,
UseGuards,
@@ -22,10 +23,12 @@ import { User } from './user.entity';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import { ChangePasswordDto } from './dto/change-password.dto';
import { QueryUserDto } from './dto/query-user.dto';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../auth/guards/roles.guard';
import { Roles } from '../auth/decorators/roles.decorator';
import { OwnerOrAdminGuard } from '../auth/guards/owner-or-admin.guard';
import { PaginatedUserData } from './dto/paginated-response.dto';
@ApiTags('user')
@Controller('user')
@@ -56,25 +59,25 @@ export class UserController {
}
/**
* 查询所有用户
* 查询所有用户(支持分页和筛选)
*/
@Get()
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('admin', 'super_admin')
@ApiBearerAuth()
@ApiOperation({
summary: '查询所有用户',
description: '获取所有用户列表(需要管理员权限)',
summary: '查询用户列表',
description: '获取用户列表,支持分页和多种筛选条件(需要管理员权限)',
})
@ApiResponse({
status: 200,
description: '查询成功',
type: [User],
type: PaginatedUserData,
})
@ApiResponse({ status: 401, description: '未授权' })
@ApiResponse({ status: 403, description: '权限不足' })
findAll(): Promise<User[]> {
return this.userService.findAll();
findAll(@Query() queryDto: QueryUserDto): Promise<PaginatedUserData> {
return this.userService.findAllPaginated(queryDto);
}
// /**

View File

@@ -1,14 +1,23 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Repository, In } from 'typeorm';
import { ConfigService } from '@nestjs/config';
import * as bcrypt from 'bcrypt';
import { User } from './user.entity';
import { MOCK_USERS } from './mock-users.data';
/**
* 用户数据种子Seeder
*
* 用途:在应用启动时自动创建初始管理员用户
* 用途:在应用启动时自动创建初始用户(超级管理员、管理员、普通用户
*
* 功能:
* 1. 创建超级管理员和管理员各一名(从环境变量读取配置)
* 2. 创建12名普通用户使用Mock数据
*
* 性能优化:
* - 使用批量查询IN查询检查用户是否存在只执行2次数据库查询
* - 只创建不存在的用户,保证幂等性
*
* 优点:
* 1. 代码化管理,版本控制友好
@@ -18,13 +27,14 @@ import { User } from './user.entity';
* 5. 幂等性:如果用户已存在,不会重复创建
*
* 使用方式:
* 1. 通过环境变量配置初始管理员信息
* 1. 通过环境变量配置管理员信息
* 2. 应用启动时自动执行
* 3. 仅在开发/测试环境自动执行,生产环境建议手动创建
*/
@Injectable()
export class UserSeeder implements OnModuleInit {
private readonly logger = new Logger(UserSeeder.name);
private readonly saltRounds = 10; // bcrypt 加盐轮数
constructor(
@InjectRepository(User)
@@ -44,15 +54,51 @@ export class UserSeeder implements OnModuleInit {
return;
}
await this.seedAdminUser();
// 执行种子数据创建
await this.seedAllUsers();
}
/**
* 创建初始管理员用户
* 创建所有种子用户(超级管理员、管理员、普通用户
*/
async seedAdminUser(): Promise<void> {
async seedAllUsers(): Promise<void> {
try {
// 从环境变量读取配置,如果没有则使用默认值
// 创建管理员用户(超级管理员和管理员)
await this.seedAdminUsers();
// 创建普通用户
await this.seedMockUsers();
} catch (error) {
this.logger.error('创建种子用户失败:', error);
// 不抛出错误,避免影响应用启动
}
}
/**
* 创建管理员用户(超级管理员和管理员)
*
* 性能优化说明:
* - 使用批量查询IN查询一次检查两个管理员用户是否存在
* - 只创建不存在的用户
* - 只执行1次数据库查询而不是2次
*/
async seedAdminUsers(): Promise<void> {
try {
// 从环境变量读取超级管理员配置
const superAdminUsername =
this.configService.get<string>('SUPER_ADMIN_USERNAME') ||
'superadmin';
const superAdminPassword =
this.configService.get<string>('SUPER_ADMIN_PASSWORD') ||
'admin123';
const superAdminEmail =
this.configService.get<string>('SUPER_ADMIN_EMAIL') ||
'superadmin@vestmind.com';
const superAdminNickname =
this.configService.get<string>('SUPER_ADMIN_NICKNAME') ||
'超级管理员';
// 从环境变量读取管理员配置
const adminUsername =
this.configService.get<string>('ADMIN_USERNAME') || 'admin';
const adminPassword =
@@ -63,57 +109,159 @@ export class UserSeeder implements OnModuleInit {
const adminNickname =
this.configService.get<string>('ADMIN_NICKNAME') ||
'系统管理员';
const adminRole =
this.configService.get<string>('ADMIN_ROLE') || 'admin';
// 检查管理员用户是否已存在
const existingAdmin = await this.userRepository.findOne({
where: { username: adminUsername },
// 构建管理员用户数据
const adminUsersToCreate = [
{
username: superAdminUsername,
email: superAdminEmail,
nickname: superAdminNickname,
password: superAdminPassword,
role: 'super_admin',
},
{
username: adminUsername,
email: adminEmail,
nickname: adminNickname,
password: adminPassword,
role: 'admin',
},
];
// 批量查询:一次检查所有管理员用户是否存在(性能优化)
const existingAdminUsernames = await this.userRepository.find({
where: {
username: In([superAdminUsername, adminUsername]),
},
select: ['username'],
});
if (existingAdmin) {
const existingUsernamesSet = new Set(
existingAdminUsernames.map((u) => u.username),
);
// 过滤出需要创建的用户(不存在的用户)
const usersToCreate = adminUsersToCreate.filter(
(user) => !existingUsernamesSet.has(user.username),
);
if (usersToCreate.length === 0) {
this.logger.log(
`管理员用户 "${adminUsername}" 已存在,跳过创建`,
`管理员用户已存在(超级管理员: ${superAdminUsername}, 管理员: ${adminUsername},跳过创建`,
);
return;
}
// 检查邮箱是否已被使用
const existingByEmail = await this.userRepository.findOne({
where: { email: adminEmail },
});
// 批量创建用户
const usersToSave = await Promise.all(
usersToCreate.map(async (userData) => {
const passwordHash = await bcrypt.hash(
userData.password,
this.saltRounds,
);
if (existingByEmail) {
return this.userRepository.create({
username: userData.username,
passwordHash,
email: userData.email,
nickname: userData.nickname,
role: userData.role,
status: 'active',
});
}),
);
await this.userRepository.save(usersToSave);
// 记录日志
const createdUsernames = usersToCreate.map((u) => u.username);
this.logger.log(
`✅ 成功创建管理员用户: ${createdUsernames.join(', ')}`,
);
usersToCreate.forEach((user) => {
this.logger.warn(
`邮箱 "${adminEmail}" 已被使用,跳过创建管理员用户`,
`⚠️ ${user.role === 'super_admin' ? '超级管理员' : '管理员'} "${user.username}" 默认密码: ${user.password},请尽快修改!`,
);
});
} catch (error) {
this.logger.error('创建管理员用户失败:', error);
throw error;
}
}
/**
* 创建普通用户Mock数据
*
* 性能优化说明:
* - 使用批量查询IN查询一次检查所有12个普通用户是否存在
* - 只创建不存在的用户
* - 只执行1次数据库查询而不是12次
*
* 关于只检查第一个用户的方案评估:
* - 如果第一个用户存在但其他11个用户被删除了会导致其他用户不会被创建
* - 使用批量查询既能保证性能只查询1次又能保证数据完整性
*/
async seedMockUsers(): Promise<void> {
try {
const mockUsernames = MOCK_USERS.map((user) => user.username);
// 批量查询:一次检查所有普通用户是否存在(性能优化)
const existingUsers = await this.userRepository.find({
where: {
username: In(mockUsernames),
},
select: ['username'],
});
const existingUsernamesSet = new Set(
existingUsers.map((u) => u.username),
);
// 过滤出需要创建的用户(不存在的用户)
const usersToCreate = MOCK_USERS.filter(
(user) => !existingUsernamesSet.has(user.username),
);
if (usersToCreate.length === 0) {
this.logger.log(
`所有普通用户(共${MOCK_USERS.length}名)已存在,跳过创建`,
);
return;
}
// 使用 bcrypt 加密密码
// saltRounds 指的是生成 bcrypt 哈希时的加盐轮数(成本系数),轮数越高,计算越慢,安全性越高,通常 10~12 为常用值
const saltRounds = 10;
const passwordHash = await bcrypt.hash(adminPassword, saltRounds);
// 批量创建用户
const usersToSave = await Promise.all(
usersToCreate.map(async (mockUser) => {
const passwordHash = await bcrypt.hash(
mockUser.password,
this.saltRounds,
);
// 创建管理员用户
const adminUser = this.userRepository.create({
username: adminUsername,
passwordHash,
email: adminEmail,
nickname: adminNickname,
role: adminRole,
status: 'active',
});
return this.userRepository.create({
username: mockUser.username,
passwordHash,
email: mockUser.email,
nickname: mockUser.nickname,
phone: mockUser.phone,
role: 'user',
status: 'active',
});
}),
);
await this.userRepository.save(adminUser);
await this.userRepository.save(usersToSave);
this.logger.log(
`✅ 成功创建初始管理员用户: ${adminUsername} (${adminEmail})`,
`✅ 成功创建 ${usersToCreate.length} 名普通用户(共 ${MOCK_USERS.length} 名)`,
);
this.logger.warn(`⚠️ 默认密码: ${adminPassword},请尽快修改!`);
if (usersToCreate.length < MOCK_USERS.length) {
this.logger.log(
` 已存在 ${MOCK_USERS.length - usersToCreate.length} 名用户,已跳过`,
);
}
} catch (error) {
this.logger.error('创建初始管理员用户失败:', error);
// 不抛出错误,避免影响应用启动
this.logger.error('创建普通用户失败:', error);
throw error;
}
}
@@ -121,6 +269,6 @@ export class UserSeeder implements OnModuleInit {
* 手动执行种子数据(可用于 CLI 命令)
*/
async run(): Promise<void> {
await this.seedAdminUser();
await this.seedAllUsers();
}
}

View File

@@ -5,13 +5,14 @@ import {
BadRequestException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Repository, FindOptionsWhere } from 'typeorm';
import * as bcrypt from 'bcrypt';
import { User } from './user.entity';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import { QueryUserDto } from './dto/query-user.dto';
import { ChangePasswordDto } from './dto/change-password.dto';
import { PaginationInfo } from '@/common/dto/pagination.dto';
@Injectable()
export class UserService {
@@ -119,6 +120,88 @@ export class UserService {
});
}
/**
* 查询用户(支持多种查询条件和分页)
*/
async findAllPaginated(queryDto: QueryUserDto): Promise<{
list: User[];
pagination: PaginationInfo;
}> {
const where: FindOptionsWhere<User> = {};
if (queryDto.username) {
where.username = queryDto.username;
}
if (queryDto.nickname) {
where.nickname = queryDto.nickname;
}
if (queryDto.email) {
where.email = queryDto.email;
}
if (queryDto.phone) {
where.phone = queryDto.phone;
}
if (queryDto.role) {
where.role = queryDto.role;
}
if (queryDto.status) {
where.status = queryDto.status;
}
// 分页参数
const page = queryDto.page || 1;
const limit = queryDto.limit || 10;
const skip = (page - 1) * limit;
// 排序字段映射
const sortBy = queryDto.sortBy || 'createdAt';
const sortOrder = queryDto.sortOrder || 'DESC';
// 构建排序对象
const order: Record<string, 'ASC' | 'DESC'> = {};
if (sortBy === 'createdAt') {
order.createdAt = sortOrder;
} else if (sortBy === 'updatedAt') {
order.updatedAt = sortOrder;
} else if (sortBy === 'lastLoginAt') {
order.lastLoginAt = sortOrder;
} else {
order.createdAt = 'DESC';
}
// 添加默认排序
order.userId = 'ASC';
// 查询总数
const total = await this.userRepository.count({ where });
// 查询分页数据
const list = await this.userRepository.find({
where,
order,
skip,
take: limit,
});
// 计算总页数
const total_page = Math.ceil(total / limit);
return {
list,
pagination: {
total,
total_page,
page_size: limit,
current_page: page,
},
};
}
/**
* 根据 username 或 email 查询单个用户
*/