feat: first commit

This commit is contained in:
2024-10-26 12:10:13 +08:00
parent b244826aa1
commit b064c4cb87
158 changed files with 18888 additions and 1 deletions

45
server/src/app.module.ts Normal file
View File

@@ -0,0 +1,45 @@
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import configuration from './config/configuration';
import { APP_GUARD } from '@nestjs/core';
import { AuthGuard } from './common/guard/auth.guard';
import { PermissionGuard } from '@/common/guard/permission.guard';
import { SystemModule } from './system/system.module';
import { PrismaModule } from '@/prisma/prisma.module';
import { CacheModule } from '@nestjs/cache-manager';
import { redisStore } from 'cache-manager-redis-yet';
import { MinioService } from './minio/minio.service';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
load: [configuration],
}),
PrismaModule,
// 系统模块
SystemModule,
// https://github.com/dabroek/node-cache-manager-redis-store/issues/40
CacheModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
isGlobal: true,
useFactory: async (config: ConfigService) => ({
store: await redisStore({
ttl: config.get('redis.ttl'),
socket: {
host: config.get('redis.host'),
port: config.get('redis.port'),
},
}),
}),
}),
],
controllers: [],
providers: [
{ provide: APP_GUARD, useClass: AuthGuard },
{ provide: APP_GUARD, useClass: PermissionGuard },
MinioService,
],
})
export class AppModule {}

View File

@@ -0,0 +1,5 @@
import { SetMetadata } from '@nestjs/common';
import { Role } from '../enum';
export const ROLES_KEY = 'roles';
export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles);

View File

@@ -0,0 +1,5 @@
import { SetMetadata } from '@nestjs/common';
export const SKIP_AUTH = 'SKIP_AUTH';
export const SkipAuth = () => SetMetadata(SKIP_AUTH, true);

View File

@@ -0,0 +1,13 @@
import { IsOptional, Min } from 'class-validator';
import { Type } from 'class-transformer';
export class PaginationDto {
@IsOptional()
@Type(() => Number)
@Min(1)
public readonly page?: number = 1;
@IsOptional()
@Type(() => Number)
public readonly pageSize?: number = 10;
}

View File

@@ -0,0 +1,13 @@
export enum Role {
User = 'user',
Admin = 'admin',
}
export enum RedisGroup {
Config = 'config',
JwtJti = 'jwt_jti',
}
export enum PermissionType {
Menu,
}

View File

@@ -0,0 +1,65 @@
import {
CanActivate,
ExecutionContext,
Inject,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { Request } from 'express';
import { ConfigService } from '@nestjs/config';
import { Reflector } from '@nestjs/core';
import { SKIP_AUTH } from '@/common/decorators/skip-auth.decorator';
import { RedisGroup } from '@/common/enum';
import { Cache, CACHE_MANAGER } from '@nestjs/cache-manager';
@Injectable()
export class AuthGuard implements CanActivate {
constructor(
private jwtService: JwtService,
private readonly configService: ConfigService,
private reflector: Reflector,
@Inject(CACHE_MANAGER) private cacheService: Cache,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const isSkipAuth = this.reflector.getAllAndOverride<boolean>(SKIP_AUTH, [
context.getHandler(),
context.getClass(),
]);
if (isSkipAuth) {
request.isSkipAuth = SKIP_AUTH;
return true;
}
const token = this.extractTokenFromHeader(request);
if (!token) {
throw new UnauthorizedException('用户未登录!');
}
try {
const user = await this.jwtService.verifyAsync(token, {
secret: this.configService.get('jwt.secret'),
});
const isTokenPresent = await this.cacheService.get(
`${RedisGroup.JwtJti}:${user.sub}:${user.jti}`,
);
if (!isTokenPresent) {
throw new UnauthorizedException('登录已失效,请重新登录!');
}
request['user'] = user;
} catch {
throw new UnauthorizedException('登录已失效,请重新登录!');
}
return true;
}
private extractTokenFromHeader(request: Request): string | undefined {
const [type, token] = request.headers.authorization?.split(' ') ?? [];
return type === 'Bearer' ? token : undefined;
}
}

View File

@@ -0,0 +1,33 @@
import {
CanActivate,
ExecutionContext,
ForbiddenException,
Inject,
Injectable,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Reflector } from '@nestjs/core';
import { Cache, CACHE_MANAGER } from '@nestjs/cache-manager';
import { SKIP_AUTH } from '@/common/decorators/skip-auth.decorator';
@Injectable()
export class PermissionGuard implements CanActivate {
constructor(
private readonly configService: ConfigService,
private reflector: Reflector,
@Inject(CACHE_MANAGER) private cacheService: Cache,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
request['isAdmin'] = request?.user?.account === 'admin';
return true;
// if (request.isSkipAuth === SKIP_AUTH || request.user.account === 'admin') {
// return true;
// }
// throw new ForbiddenException('无权访问!');
}
}

View File

@@ -0,0 +1,43 @@
import {
ExceptionFilter,
Catch,
ArgumentsHost,
HttpException,
Logger,
HttpStatus,
} from '@nestjs/common';
import { Request, Response } from 'express';
import { inspect } from 'util';
import { HttpExceptionBody } from '@nestjs/common/interfaces/http/http-exception-body.interface';
import maskSensitiveData from '@/common/utils/maskSensitiveData';
@Catch(HttpException)
export class HttpExceptionFilter implements ExceptionFilter {
private logger = new Logger('HttpError');
catch(exception: HttpException, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const { method, url, body, query, headers, socket } =
ctx.getRequest<Request>();
const exceptionResponse = exception.getResponse() as HttpExceptionBody;
const message = exceptionResponse?.message;
const status = exception.getStatus();
const reason = {
method,
code: status,
url,
message,
};
const log = {
...reason,
body: maskSensitiveData(body),
query,
clientIp: headers['x-forwarded-for'] || socket.remoteAddress,
};
this.logger.error(inspect(log, { breakLength: 9999 }));
response.status(status).json(reason);
}
}

View File

@@ -0,0 +1,44 @@
import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
Logger,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { inspect } from 'util';
import { Request } from 'express';
import maskSensitiveData from '@/common/utils/maskSensitiveData';
@Injectable()
export class HttpInterceptor implements NestInterceptor {
private logger = new Logger('HttpSuccess');
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const ctx = context.switchToHttp();
const { method, body, query, url, headers, socket } =
ctx.getRequest<Request>();
const { statusCode } = ctx.getResponse();
const clientIp = headers['x-forwarded-for'] || socket.remoteAddress;
const log = {
method,
code: statusCode,
url,
body: maskSensitiveData(body),
query,
clientIp,
};
return next.handle().pipe(
map((data) => {
this.logger.log(inspect(log, { breakLength: 9999 }));
return {
code: statusCode,
message: '请求成功!',
data: data,
};
}),
);
}
}

View File

@@ -0,0 +1,43 @@
import { cloneDeep, has } from 'lodash';
// 需要脱敏的字段
const fieldList = [
{
field: 'account',
pattern: /^(.).+(.)$/,
replacement: '$1***$2',
},
{
field: 'password',
pattern: /.*/,
replacement: '******',
},
{
field: 'newPassword',
pattern: /.*/,
replacement: '******',
},
];
export default function maskSensitiveData(body: any) {
const obj = cloneDeep(body);
if (typeof obj !== 'object' && obj === null) {
return obj;
}
if (Array.isArray(obj)) {
return obj.map((item) => maskSensitiveData(item));
} else {
for (const { field, pattern, replacement } of fieldList) {
if (obj[field]) {
if (typeof obj[field] === 'string') {
obj[field] = obj[field].replace(pattern, replacement);
} else {
obj[field] = maskSensitiveData(obj[field]);
}
}
}
return obj;
}
}

View File

@@ -0,0 +1,36 @@
import {
ValidatorConstraint,
ValidatorConstraintInterface,
ValidationArguments,
registerDecorator,
ValidationOptions,
} from 'class-validator';
@ValidatorConstraint({ name: 'customerStartsWith', async: false })
export class CustomStartsWith implements ValidatorConstraintInterface {
validate(value: any, args: ValidationArguments) {
const [prefix] = args.constraints;
if (typeof value !== 'string') return false;
return value.startsWith(prefix);
}
defaultMessage(args: ValidationArguments) {
const [prefix] = args.constraints;
return `Field $property must start with "${prefix}"`;
}
}
export function StartsWithPrefix(
prefix: string,
validationOptions?: ValidationOptions,
) {
return function (object: { constructor: any }, propertyName: string) {
registerDecorator({
target: object.constructor,
propertyName: propertyName,
options: validationOptions,
constraints: [prefix],
validator: CustomStartsWith,
});
};
}

View File

@@ -0,0 +1,21 @@
export default () => ({
jwt: {
secret: 'ultimate',
expiresIn: '7d',
redisExpiresIn: 7 * 60 * 60 * 24 * 1000,
},
redis: {
host: '81.70.149.52',
port: 8002,
// 默认过期时间 5s
ttl: 5000,
},
minio: {
endPoint: 's3.ulti42.com',
port: 443,
useSSL: true,
accessKey: 'vjr5fW3Rk6W0WydqOpCt',
secretKey: '3sNkhS5zVf9tTXb3doSN2iJ3NO6WG8alJPoj1Pzd',
bucketName: 'static',
},
});

79
server/src/main.ts Normal file
View File

@@ -0,0 +1,79 @@
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { Logger, ValidationPipe } from '@nestjs/common';
import { PrismaService } from './prisma/prisma.service';
import { ConfigService } from './system/config/config.service';
import { HttpExceptionFilter } from 'src/common/http-exception.filter';
import { HttpInterceptor } from 'src/common/http.interceptor';
import { WinstonModule } from 'nest-winston';
import { transports, format } from 'winston';
import 'winston-daily-rotate-file';
async function bootstrap() {
const logFormat = format.combine(
format.timestamp(),
format.printf((info) => {
return `${info.timestamp} ${info.level} ${info.context} ${info.message}`;
}),
);
const app = await NestFactory.create(AppModule, {
logger: WinstonModule.createLogger({
transports: [
// 记录错误日志
new transports.DailyRotateFile({
filename: `logs/%DATE%/error.log`,
level: 'error',
datePattern: 'YYYY-MM-DD',
maxFiles: '30d',
format: logFormat,
}),
// logging all level
new transports.DailyRotateFile({
filename: `logs/%DATE%/combined.log`,
datePattern: 'YYYY-MM-DD',
maxFiles: '30d',
format: logFormat,
}),
// 控制台输出
new transports.Console({
format: format.combine(
format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
format.colorize({ all: true }),
logFormat,
),
}),
],
}),
});
app.useGlobalPipes(
new ValidationPipe({
transform: true,
whitelist: true,
}),
);
// 全局异常过滤器
app.useGlobalFilters(new HttpExceptionFilter());
app.useGlobalInterceptors(new HttpInterceptor());
// prisma
const prismaService = app.get(PrismaService);
await prismaService.enableShutdownHooks(app);
// 同步配置到redis
const configService = app.get(ConfigService);
await configService.syncConfig();
// 跨域
app.enableCors();
// 设置全局前缀
app.setGlobalPrefix('api');
const logger = new Logger('Server Running');
await app.listen(3000, '0.0.0.0', async () => {
logger.log(`Application is running on: ${await app.getUrl()}`);
});
}
bootstrap();

View File

@@ -0,0 +1,41 @@
import { Injectable } from '@nestjs/common';
import * as Minio from 'minio';
import { ConfigService } from '@nestjs/config';
@Injectable()
export class MinioService {
private readonly minioClient: Minio.Client;
private readonly minioConfig: string;
private readonly bucketName: string;
constructor(private readonly configService: ConfigService) {
const { bucketName, ...rest } = this.configService.get('minio');
this.minioConfig = rest;
this.bucketName = bucketName;
this.minioClient = new Minio.Client({
...rest,
});
}
async createBucketIfNotExists() {
const bucketExists = await this.minioClient.bucketExists(this.bucketName);
if (!bucketExists) {
await this.minioClient.makeBucket(this.bucketName, 'cn-north-1');
}
}
async uploadFile(file: Express.Multer.File, fileName: string) {
await this.minioClient.putObject(
this.bucketName,
fileName,
file.buffer,
file.size,
{ 'Content-Type': file.mimetype },
);
return `https://${this.minioConfig['endPoint']}/${this.bucketName}/${fileName}`;
}
async deleteFile(fileName: string) {
await this.minioClient.removeObject(this.bucketName, fileName);
}
}

View File

@@ -0,0 +1,9 @@
import { Module, Global } from '@nestjs/common';
import { PrismaService } from './prisma.service';
@Global()
@Module({
providers: [PrismaService],
exports: [PrismaService],
})
export class PrismaModule {}

View File

@@ -0,0 +1,25 @@
import { INestApplication, Injectable, OnModuleInit } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import process from 'process';
import { Reflector } from '@nestjs/core';
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit {
constructor(private reflector: Reflector) {
// super({
// log: ['query', 'info', 'warn', 'error'],
// errorFormat: 'colorless',
// });
super();
}
async onModuleInit() {
await this.$connect();
}
async enableShutdownHooks(app: INestApplication) {
process.on('beforeExit', async () => {
await app.close();
});
}
}

View File

@@ -0,0 +1,30 @@
import { Controller, Post, Body, Param } from '@nestjs/common';
import { AuthService } from './auth.service';
import { AuthDto } from './dto/auth.dto';
import { SkipAuth } from '@/common/decorators/skip-auth.decorator';
import { UserRoleDto } from './dto/user-role.dto';
import { RolePermissionDto } from './dto/role-permission.dto';
@Controller()
export class AuthController {
constructor(private readonly authService: AuthService) {}
// 用户登录
@Post('login')
@SkipAuth()
signIn(@Body() authDto: AuthDto) {
return this.authService.signIn(authDto);
}
// 授权用户角色
@Post('userRole/:id')
grantUserRole(@Param('id') id: string, @Body() dto: UserRoleDto) {
return this.authService.grantUserRole(+id, dto);
}
// 授权角色权限
@Post('rolePermission/:id')
grantRolePermission(@Param('id') id: string, @Body() dto: RolePermissionDto) {
return this.authService.grantRolePermission(+id, dto);
}
}

View File

@@ -0,0 +1,26 @@
import { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { UserModule } from '../user/user.module';
import { JwtModule } from '@nestjs/jwt';
import { ConfigModule, ConfigService } from '@nestjs/config';
@Module({
imports: [
UserModule,
JwtModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
global: true,
useFactory: async (config: ConfigService) => {
return {
secret: config.get('jwt.secret'),
signOptions: { expiresIn: config.get('jwt.expiresIn') },
};
},
}),
],
controllers: [AuthController],
providers: [AuthService],
})
export class AuthModule {}

View File

@@ -0,0 +1,136 @@
import {
ForbiddenException,
HttpException,
HttpStatus,
Inject,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { AuthDto } from './dto/auth.dto';
import * as bcrypt from 'bcryptjs';
import { PrismaService } from '@/prisma/prisma.service';
import { UserRoleDto } from './dto/user-role.dto';
import { RolePermissionDto } from './dto/role-permission.dto';
import { RedisGroup } from '@/common/enum';
import { ConfigService } from '@nestjs/config';
import { Cache, CACHE_MANAGER } from '@nestjs/cache-manager';
import { nanoid } from 'nanoid';
@Injectable()
export class AuthService {
constructor(
private readonly jwtService: JwtService,
private readonly prisma: PrismaService,
private readonly configService: ConfigService,
@Inject(CACHE_MANAGER) private cacheService: Cache,
) {}
// 登录
async signIn({ account, password }: AuthDto) {
const user = await this.prisma.sysUser.findUnique({
where: {
account,
},
});
if (!user) {
throw new UnauthorizedException('账号或密码错误!');
}
const pass = await bcrypt.hash(password, user.passwordSalt);
if (user?.password !== pass) {
throw new UnauthorizedException('账号或密码错误!');
}
if (user.status === 0) {
throw new ForbiddenException('此账户已被禁用!');
}
const jti = nanoid();
const payload = { sub: user.id, account: user.account, jti };
const accessToken = await this.jwtService.signAsync(payload);
await this.cacheService.set(
`${RedisGroup.JwtJti}:${user.id}:${jti}`,
user.id,
this.configService.get('jwt.redisExpiresIn'),
);
return { accessToken };
}
async grantUserRole(id: number, { roleIds }: UserRoleDto) {
const user = await this.prisma.sysUser.findUnique({
where: { id },
});
if (!user) throw new HttpException('用户不存在!', HttpStatus.NOT_FOUND);
// 查询所有角色id
const allRoles = await this.prisma.sysRole.findMany({
select: { id: true },
});
const allRoleIds = allRoles.map((item) => item.id);
const isContain = roleIds.every((item) => allRoleIds.includes(item));
if (!isContain)
throw new HttpException(
'出现不存在的角色id,请检查传入的roleIds',
HttpStatus.BAD_REQUEST,
);
const data = roleIds.map((item) => ({
userId: id,
roleId: item,
}));
await this.prisma.$transaction(async (prisma) => {
await prisma.sysUserRole.deleteMany({
where: { userId: id },
});
await prisma.sysUserRole.createMany({ data });
});
}
async grantRolePermission(id: number, { permissionIds }: RolePermissionDto) {
const role = await this.prisma.sysRole.findUnique({
where: { id },
});
if (!role)
throw new HttpException('角色信息不存在!', HttpStatus.NOT_FOUND);
// 查询所有权限
const allPermission = await this.prisma.sysPermission.findMany({
select: { id: true },
});
const allPermissionIds = allPermission.map((item) => item.id);
// 判断传入的id是否都存在于数据库中
const isContain = permissionIds.every((item) =>
allPermissionIds.includes(item),
);
if (!isContain)
throw new HttpException(
'出现不存在的权限id,请检查传入的permissionIds',
HttpStatus.BAD_REQUEST,
);
const data = permissionIds.map((permissionId) => ({
roleId: id,
permissionId,
}));
await this.prisma.$transaction([
this.prisma.sysRolePermission.deleteMany({
where: { permissionId: { in: permissionIds } },
}),
this.prisma.sysRolePermission.createMany({ data }),
]);
}
}

View File

@@ -0,0 +1,7 @@
import { CreateUserDto } from 'src/system/user/dto/create-user.dto';
import { PickType } from '@nestjs/mapped-types';
export class AuthDto extends PickType(CreateUserDto, [
'account',
'password',
] as const) {}

View File

@@ -0,0 +1,8 @@
import { ArrayUnique, IsInt, IsNotEmpty } from 'class-validator';
export class RolePermissionDto {
@IsNotEmpty()
@IsInt({ each: true })
@ArrayUnique()
permissionIds: number[];
}

View File

@@ -0,0 +1,8 @@
import { ArrayUnique, IsInt, IsNotEmpty } from 'class-validator';
export class UserRoleDto {
@IsNotEmpty()
@IsInt({ each: true })
@ArrayUnique()
roleIds: number[];
}

View File

@@ -0,0 +1,22 @@
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Role } from '@/common/enum';
import { ROLES_KEY } from '@/common/decorators/roles.decorator';
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const requiredRoles = this.reflector.getAllAndOverride<Role[]>(ROLES_KEY, [
context.getHandler(),
context.getClass(),
]);
if (!requiredRoles) {
return true;
}
const { user } = context.switchToHttp().getRequest();
return requiredRoles.some((role) => user.roles?.includes(role));
}
}

View File

@@ -0,0 +1,49 @@
import {
Controller,
Get,
Post,
Body,
Patch,
Param,
Delete,
Query,
} from '@nestjs/common';
import { ConfigService } from './config.service';
import { CreateConfigDto } from './dto/create-config.dto';
import { UpdateConfigDto } from './dto/update-config.dto';
import { QueryListDto } from './dto/query-list.dto';
@Controller()
export class ConfigController {
constructor(private readonly configService: ConfigService) {}
@Post()
create(@Body() dto: CreateConfigDto) {
return this.configService.create(dto);
}
@Get()
findAll(@Query() dto: QueryListDto) {
return this.configService.findAll(dto);
}
@Get(':key')
findOne(@Param('key') key: string) {
return this.configService.findOne(key);
}
@Patch(':key')
update(@Param('key') key: string, @Body() updateConfigDto: UpdateConfigDto) {
return this.configService.update(key, updateConfigDto);
}
@Delete(':key')
remove(@Param('key') key: string) {
return this.configService.remove(key);
}
@Post('sync')
syncConfig() {
return this.configService.syncConfig();
}
}

View File

@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { ConfigService } from './config.service';
import { ConfigController } from './config.controller';
@Module({
controllers: [ConfigController],
providers: [ConfigService],
})
export class ConfigModule {}

View File

@@ -0,0 +1,99 @@
import { Injectable, HttpException, HttpStatus, Inject } from '@nestjs/common';
import { CreateConfigDto } from './dto/create-config.dto';
import { UpdateConfigDto } from './dto/update-config.dto';
import { PrismaService } from '@/prisma/prisma.service';
import { QueryListDto } from './dto/query-list.dto';
import { Prisma } from '@prisma/client';
import { Cache, CACHE_MANAGER } from '@nestjs/cache-manager';
import { RedisGroup } from '@/common/enum';
@Injectable()
export class ConfigService {
constructor(
private readonly prisma: PrismaService,
@Inject(CACHE_MANAGER) private cacheService: Cache,
) {}
findOneByUnique(key: string) {
return this.prisma.sysConfig.findUnique({
where: {
key,
},
});
}
async create(data: CreateConfigDto) {
const doesExists = await this.findOneByUnique(data.key);
if (doesExists) {
throw new HttpException('key值不可重复', HttpStatus.BAD_REQUEST);
}
await this.prisma.sysConfig.create({ data });
}
async findAll({ page, pageSize }: QueryListDto): Promise<QueryListResult> {
const query: Prisma.SysConfigFindManyArgs = {
select: {
key: true,
value: true,
remark: true,
},
skip: pageSize * (page - 1),
take: pageSize,
};
const [list, count] = await this.prisma.$transaction([
this.prisma.sysConfig.findMany(query),
this.prisma.sysConfig.count({ where: query.where }),
]);
return {
page,
pageSize,
count,
list,
};
}
findOne(key: string) {
return this.findOneByUnique(key);
}
async update(key: string, updateConfigDto: UpdateConfigDto) {
const doesExists = await this.findOneByUnique(key);
if (!doesExists) {
throw new HttpException('修改失败key值不存在', HttpStatus.NOT_FOUND);
}
await this.prisma.sysConfig.update({
data: updateConfigDto,
where: { key },
});
}
async remove(key: string) {
const doesExists = await this.findOneByUnique(key);
if (!doesExists) {
throw new HttpException('删除失败key值不存在', HttpStatus.NOT_FOUND);
}
await this.prisma.sysConfig.delete({ where: { key } });
}
async syncConfig() {
const allConfigs = await this.prisma.sysConfig.findMany();
const keys = await this.cacheService.store.keys(`${RedisGroup.Config}:*`);
if (keys.length) await this.cacheService.store.mdel(...keys);
const config: [string, unknown][] = allConfigs.map((item) => [
item.key,
item.value,
]);
if (config.length) await this.cacheService.store.mset(config, 0);
}
}

View File

@@ -0,0 +1,14 @@
import { IsNotEmpty, IsOptional, IsString } from 'class-validator';
export class CreateConfigDto {
@IsNotEmpty()
@IsString()
key: string;
@IsNotEmpty()
@IsString()
value: string;
@IsOptional()
remark: string;
}

View File

@@ -0,0 +1,3 @@
import { PaginationDto } from 'src/common/dto/pagination.dto';
export class QueryListDto extends PaginationDto {}

View File

@@ -0,0 +1,6 @@
import { OmitType, PartialType } from '@nestjs/mapped-types';
import { CreateConfigDto } from './create-config.dto';
export class UpdateConfigDto extends PartialType(
OmitType(CreateConfigDto, ['key'] as const),
) {}

View File

@@ -0,0 +1,23 @@
import {
Controller,
Post,
UseInterceptors,
UploadedFile,
ParseFilePipe,
} from '@nestjs/common';
import { FilesService } from './files.service';
import { FileInterceptor } from '@nestjs/platform-express';
@Controller()
export class FilesController {
constructor(private readonly filesService: FilesService) {}
@Post()
@UseInterceptors(FileInterceptor('file'))
upload(
@UploadedFile(new ParseFilePipe({ validators: [] }))
file: Express.Multer.File,
) {
return this.filesService.upload(file);
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { FilesService } from './files.service';
import { FilesController } from './files.controller';
import { MinioService } from '@/minio/minio.service';
@Module({
controllers: [FilesController],
providers: [FilesService, MinioService],
})
export class FilesModule {}

View File

@@ -0,0 +1,31 @@
import { Inject, Injectable } from '@nestjs/common';
import { PrismaService } from '@/prisma/prisma.service';
import path from 'path';
import { Cache, CACHE_MANAGER } from '@nestjs/cache-manager';
import { MinioService } from '@/minio/minio.service';
import { nanoid } from 'nanoid';
@Injectable()
export class FilesService {
constructor(
private readonly prisma: PrismaService,
@Inject(CACHE_MANAGER) private cacheService: Cache,
private readonly minioService: MinioService,
) {}
async upload(file: Express.Multer.File) {
const fileName = `${nanoid()}${path.extname(file.originalname)}`;
const filePath = await this.minioService.uploadFile(file, fileName);
const res = await this.prisma.files.create({
data: { name: fileName, path: filePath },
});
return {
id: res.id,
path: res.path,
};
}
}

View File

@@ -0,0 +1,15 @@
import { IsInt, IsNotEmpty, IsOptional, IsString } from 'class-validator';
export class CreateMenuDto {
@IsNotEmpty()
@IsString()
name: string;
@IsNotEmpty()
@IsString()
url: string;
@IsOptional()
@IsInt()
parentId?: number;
}

View File

@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateMenuDto } from './create-menu.dto';
export class UpdateMenuDto extends PartialType(CreateMenuDto) {}

View File

@@ -0,0 +1,42 @@
import {
Controller,
Get,
Post,
Body,
Patch,
Param,
Delete,
} from '@nestjs/common';
import { MenuService } from './menu.service';
import { CreateMenuDto } from './dto/create-menu.dto';
import { UpdateMenuDto } from './dto/update-menu.dto';
@Controller()
export class MenuController {
constructor(private readonly menuService: MenuService) {}
@Post()
create(@Body() dto: CreateMenuDto) {
return this.menuService.create(dto);
}
@Get('tree')
getTreeMenu() {
return this.menuService.getTreeMenu();
}
@Get()
findAll() {
return this.menuService.findAll();
}
@Patch(':id')
update(@Param('id') id: string, @Body() updateMenuDto: UpdateMenuDto) {
return this.menuService.update(+id, updateMenuDto);
}
@Delete(':id')
remove(@Param('id') id: number) {
return this.menuService.remove(id);
}
}

View File

@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { MenuService } from './menu.service';
import { MenuController } from './menu.controller';
@Module({
controllers: [MenuController],
providers: [MenuService],
})
export class MenuModule {}

View File

@@ -0,0 +1,126 @@
import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
import { CreateMenuDto } from './dto/create-menu.dto';
import { UpdateMenuDto } from './dto/update-menu.dto';
import { PrismaService } from '@/prisma/prisma.service';
import { Prisma } from '@prisma/client';
import { PermissionType } from '@/common/enum';
@Injectable()
export class MenuService {
constructor(private readonly prisma: PrismaService) {}
findOne(params: { id?: number; excludeId?: number }) {
const { id, excludeId } = params;
return this.prisma.sysMenu.findFirst({
where: {
...(id ? { id } : {}),
...(excludeId ? { NOT: { id: excludeId } } : {}),
},
});
}
findOneById(id: number) {
return this.prisma.sysMenu.findUnique({ where: { id } });
}
async create(data: CreateMenuDto) {
await this.prisma.$transaction(async (prisma) => {
const { id: menuId } = await prisma.sysMenu.create({ data });
// 创建菜单权限
const { id: permissionId } = await prisma.sysPermission.create({
data: { type: PermissionType.Menu },
});
// 关联权限和菜单
await prisma.sysMenuPermission.create({ data: { menuId, permissionId } });
});
}
generatorMenu(data, parentId = 0) {
const menu = [];
for (const item of data) {
if (item.parentId === parentId) {
const children = this.generatorMenu(data, item.id);
if (children.length) {
item.children = children;
}
menu.push(item);
}
}
return menu;
}
async getTreeMenu() {
const query: Prisma.SysMenuFindManyArgs = {
select: {
id: true,
name: true,
url: true,
parentId: true,
},
};
const list = await this.prisma.sysMenu.findMany(query);
return this.generatorMenu(list);
}
findAll() {
const query: Prisma.SysMenuFindManyArgs = {
select: {
id: true,
name: true,
url: true,
parentId: true,
},
};
return this.prisma.sysMenu.findMany(query);
}
async update(id: number, data: UpdateMenuDto) {
const doesExists = await this.findOne({ id });
if (!doesExists) {
throw new HttpException('菜单不存在!', HttpStatus.NOT_FOUND);
}
if (data.parentId === id) {
throw new HttpException(
'父级id不可与自身id相同',
HttpStatus.BAD_REQUEST,
);
}
await this.prisma.sysMenu.update({ data, where: { id } });
}
async remove(menuId: number) {
const doesExists = await this.findOneById(menuId);
if (!doesExists) {
throw new HttpException('菜单不存在!', HttpStatus.NOT_FOUND);
}
// 判断是否存在子菜单
const doesExistsChildren = await this.prisma.sysMenu.findFirst({
where: { parentId: menuId },
});
if (doesExistsChildren) {
throw new HttpException(
'删除失败:请确保当前菜单下不存在子菜单!',
HttpStatus.FORBIDDEN,
);
}
await this.prisma.$transaction(async (prisma) => {
// step1 通过关联表删除权限表数据 这一步必须在前
// 因为schema文件中设置了onDelete: Cascade 删除菜单会删除关联表数据 则无法找出对应权限
await prisma.sysPermission.deleteMany({
where: {
sysMenuPermission: {
some: { menuId },
},
},
});
//step2 删除菜单数据
await prisma.sysMenu.delete({ where: { id: menuId } });
});
}
}

View File

@@ -0,0 +1,6 @@
import { IsNumber } from 'class-validator';
export class GrantMenuDto {
@IsNumber({}, { each: true })
menuIds: number[];
}

View File

@@ -0,0 +1,19 @@
import { Controller, Body, Patch, Param, Get } from '@nestjs/common';
import { PermissionService } from './permission.service';
import { GrantMenuDto } from '@/system/permission/dto/grant-menu.dto';
@Controller()
export class PermissionController {
constructor(private readonly permissionService: PermissionService) {}
@Get(':id/menu')
getMenu(@Param('id') id: number) {
return this.permissionService.getMenu(id);
}
@Patch(':id')
grantMenu(@Param('id') id: number, @Body() dto: GrantMenuDto) {
return this.permissionService.grantMenu(id, dto);
}
}

View File

@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { PermissionService } from './permission.service';
import { PermissionController } from './permission.controller';
@Module({
controllers: [PermissionController],
providers: [PermissionService],
})
export class PermissionModule {}

View File

@@ -0,0 +1,45 @@
import { Injectable } from '@nestjs/common';
import { GrantMenuDto } from '@/system/permission/dto/grant-menu.dto';
import { PrismaService } from '@/prisma/prisma.service';
@Injectable()
export class PermissionService {
constructor(private readonly prisma: PrismaService) {}
async getMenu(id: number) {
const menuIds = await this.prisma.sysMenuPermission.findMany({
select: {
menuId: true,
},
where: {
permission: {
sysRolePermission: {
some: { roleId: id },
},
},
},
});
return menuIds.map((item) => item.menuId);
}
async grantMenu(id: number, { menuIds }: GrantMenuDto) {
await this.prisma.$transaction(async (prisma) => {
// 删除角色对应的权限
await prisma.sysRolePermission.deleteMany({
where: { roleId: id },
});
// 通过菜单id 找出所有权限id
const rolePermIds = await prisma.sysMenuPermission.findMany({
select: { permissionId: true },
where: { menuId: { in: menuIds } },
});
// 根据权限id 关联菜单和权限
if (rolePermIds.length > 0) {
await prisma.sysRolePermission.createMany({
data: rolePermIds.map((item) => ({ ...item, roleId: id })),
});
}
});
}
}

View File

@@ -0,0 +1,12 @@
import { IsNotEmpty, IsNumber, IsOptional } from 'class-validator';
export class CreateRoleDto {
@IsOptional()
name: string;
@IsNotEmpty()
code: string;
@IsNumber()
status: number;
}

View File

@@ -0,0 +1,3 @@
import { PaginationDto } from 'src/common/dto/pagination.dto';
export class QueryListDto extends PaginationDto {}

View File

@@ -0,0 +1,6 @@
import { OmitType, PartialType } from '@nestjs/mapped-types';
import { CreateRoleDto } from './create-role.dto';
export class UpdateRoleDto extends PartialType(
OmitType(CreateRoleDto, ['code']),
) {}

View File

@@ -0,0 +1,44 @@
import {
Controller,
Get,
Post,
Body,
Patch,
Param,
Delete,
Query,
} from '@nestjs/common';
import { RoleService } from './role.service';
import { CreateRoleDto } from './dto/create-role.dto';
import { UpdateRoleDto } from './dto/update-role.dto';
import { QueryListDto } from './dto/query-list.dto';
@Controller()
export class RoleController {
constructor(private readonly roleService: RoleService) {}
@Post()
create(@Body() dto: CreateRoleDto) {
return this.roleService.create(dto);
}
@Get()
findPage(@Query() dto: QueryListDto): Promise<QueryListResult> {
return this.roleService.findPage(dto);
}
@Get('/all')
findAll() {
return this.roleService.findAll();
}
@Patch(':id')
update(@Param('id') id: string, @Body() dto: UpdateRoleDto) {
return this.roleService.update(+id, dto);
}
@Delete(':id')
remove(@Param('id') id: string) {
return this.roleService.remove(+id);
}
}

View File

@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { RoleService } from './role.service';
import { RoleController } from './role.controller';
@Module({
controllers: [RoleController],
providers: [RoleService],
})
export class RoleModule {}

View File

@@ -0,0 +1,81 @@
import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
import { CreateRoleDto } from './dto/create-role.dto';
import { UpdateRoleDto } from './dto/update-role.dto';
import { PrismaService } from '@/prisma/prisma.service';
import { Prisma, SysRole } from '@prisma/client';
import { QueryListDto } from './dto/query-list.dto';
@Injectable()
export class RoleService {
constructor(private readonly prisma: PrismaService) {}
async findOne(
params: Prisma.SysRoleWhereUniqueInput,
): Promise<SysRole | null> {
return this.prisma.sysRole.findUnique({
where: params,
});
}
async create(data: CreateRoleDto) {
const doesExists = await this.findOne({ code: data.code });
if (doesExists)
throw new HttpException('角色编码不可重复!', HttpStatus.NOT_FOUND);
await this.prisma.sysRole.create({ data });
}
async findPage({ page, pageSize }: QueryListDto): Promise<QueryListResult> {
const query: Prisma.SysRoleFindManyArgs = {
skip: pageSize * (page - 1),
take: pageSize,
select: {
id: true,
name: true,
code: true,
status: true,
},
};
const [list, count] = await this.prisma.$transaction([
this.prisma.sysRole.findMany(query),
this.prisma.sysRole.count({ where: query.where }),
]);
return {
page,
pageSize,
count,
list,
};
}
findAll() {
return this.prisma.sysRole.findMany({
select: {
id: true,
name: true,
code: true,
status: true,
},
});
}
async update(id: number, data: UpdateRoleDto) {
const role = await this.findOne({ id });
if (!role)
throw new HttpException('角色信息不存在!', HttpStatus.NOT_FOUND);
await this.prisma.sysRole.update({
data,
where: { id },
});
}
async remove(id: number) {
const role = await this.findOne({ id });
if (!role)
throw new HttpException('角色信息不存在!', HttpStatus.NOT_FOUND);
await this.prisma.sysRole.delete({ where: { id } });
}
}

View File

@@ -0,0 +1,58 @@
import { Module } from '@nestjs/common';
import { RouterModule } from '@nestjs/core';
import { UserModule } from './user/user.module';
import { AuthModule } from './auth/auth.module';
import { RoleModule } from './role/role.module';
import { FilesModule } from './files/files.module';
import { MenuModule } from './menu/menu.module';
import { ConfigModule } from './config/config.module';
import { PermissionModule } from './permission/permission.module';
@Module({
imports: [
RouterModule.register([
{
path: 'admin',
module: SystemModule,
children: [
{
path: 'user',
module: UserModule,
},
{
path: 'auth',
module: AuthModule,
},
{
path: 'role',
module: RoleModule,
},
{
path: 'files',
module: FilesModule,
},
{
path: 'menu',
module: MenuModule,
},
{
path: 'config',
module: ConfigModule,
},
{
path: 'permission',
module: PermissionModule,
},
],
},
]),
UserModule,
AuthModule,
RoleModule,
FilesModule,
MenuModule,
ConfigModule,
PermissionModule,
],
})
export class SystemModule {}

View File

@@ -0,0 +1,35 @@
import {
IsNotEmpty,
IsNumber,
IsOptional,
MaxLength,
MinLength,
} from 'class-validator';
export class CreateUserDto {
@IsOptional()
@MaxLength(20, { message: '用户名不要超过20个字符' })
name?: string;
@IsNotEmpty()
@MinLength(5, { message: '账户名不要低于6位字符' })
@MaxLength(50, { message: '账户名不要超过50个字符' })
account: string;
@IsOptional()
@IsNumber()
avatarId: number;
@IsNotEmpty()
@IsNumber()
gender: number;
@IsNotEmpty()
@MinLength(6, { message: '密码不要低于6位字符' })
@MaxLength(20, { message: '密码不要超过50个字符' })
password: string;
@IsOptional()
@IsNumber()
status?: number;
}

View File

@@ -0,0 +1,3 @@
import { PaginationDto } from 'src/common/dto/pagination.dto';
export class QueryListDto extends PaginationDto {}

View File

@@ -0,0 +1,10 @@
import { IsOptional, MaxLength, MinLength } from 'class-validator';
import { PickType } from '@nestjs/mapped-types';
import { CreateUserDto } from './create-user.dto';
export class UpdatePasswordDto extends PickType(CreateUserDto, ['password']) {
@IsOptional()
@MinLength(5)
@MaxLength(50)
newPassword?: string;
}

View File

@@ -0,0 +1,6 @@
import { OmitType, PartialType } from '@nestjs/mapped-types';
import { CreateUserDto } from './create-user.dto';
export class UpdateUserDto extends PartialType(
OmitType(CreateUserDto, ['account', 'password'] as const),
) {}

View File

@@ -0,0 +1,58 @@
import {
Controller,
Get,
Post,
Body,
Patch,
Param,
Delete,
Query,
Request,
} from '@nestjs/common';
import { UserService } from './user.service';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import { QueryListDto } from './dto/query-list.dto';
import { UpdatePasswordDto } from './dto/update-password.dto';
@Controller()
export class UserController {
constructor(private readonly userService: UserService) {}
@Post()
create(@Body() data: CreateUserDto) {
return this.userService.create(data);
}
@Get()
findAll(@Query() dto: QueryListDto): Promise<QueryListResult> {
return this.userService.findAll(dto);
}
@Get('/info')
getUserInfo(@Request() req) {
const { sub } = req['user'];
const { isAdmin } = req;
return this.userService.getUserInfo(sub, isAdmin);
}
@Get(':id')
findOneById(@Param('id') id: number) {
return this.userService.findOneById(id);
}
@Patch(':id/password')
updatePassword(@Param('id') id: string, @Body() dto: UpdatePasswordDto) {
return this.userService.updatePassword(+id, dto);
}
@Patch(':id')
update(@Param('id') id: string, @Body() dto: UpdateUserDto) {
return this.userService.update(+id, dto);
}
@Delete(':id')
remove(@Param('id') id: string) {
return this.userService.remove(+id);
}
}

View File

@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { UserService } from './user.service';
import { UserController } from './user.controller';
@Module({
controllers: [UserController],
providers: [UserService],
})
export class UserModule {}

View File

@@ -0,0 +1,208 @@
import {
HttpException,
HttpStatus,
Inject,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import { Prisma, SysUser } from '@prisma/client';
import { PrismaService } from '@/prisma/prisma.service';
import { QueryListDto } from './dto/query-list.dto';
import * as bcrypt from 'bcryptjs';
import { UpdatePasswordDto } from './dto/update-password.dto';
import { SkipAuth } from '@/common/decorators/skip-auth.decorator';
import { RedisGroup } from '@/common/enum';
import { Cache, CACHE_MANAGER } from '@nestjs/cache-manager';
@Injectable()
@SkipAuth()
export class UserService {
constructor(
private readonly prisma: PrismaService,
@Inject(CACHE_MANAGER) private cacheManager: Cache,
) {}
async findOne(
params: Prisma.SysUserWhereUniqueInput,
): Promise<SysUser | null> {
return this.prisma.sysUser.findUnique({
where: params,
});
}
async create(dto: CreateUserDto) {
const userExists = await this.findOne({ account: dto.account });
if (userExists)
throw new HttpException('账号已存在!', HttpStatus.UNPROCESSABLE_ENTITY);
const passwordSalt = await bcrypt.genSalt();
const password = await bcrypt.hash(dto.password, passwordSalt);
const data = {
...dto,
passwordSalt,
password,
};
await this.prisma.sysUser.create({ data });
}
async findAll({ page, pageSize }: QueryListDto): Promise<QueryListResult> {
const query: Prisma.SysUserFindManyArgs = {
select: {
id: true,
name: true,
account: true,
status: true,
gender: true,
createdAt: true,
updatedAt: true,
avatar: {
select: {
id: true,
path: true,
},
},
userRole: {
select: { roleId: true },
},
},
skip: pageSize * (page - 1),
take: pageSize,
};
const [list, count] = await this.prisma.$transaction([
this.prisma.sysUser.findMany(query),
this.prisma.sysUser.count({ where: query.where }),
]);
return {
page,
pageSize,
count,
list,
};
}
async findOneById(id: number) {
return this.prisma.sysUser.findUnique({
select: {
id: true,
name: true,
account: true,
status: true,
gender: true,
avatar: {
select: {
id: true,
path: true,
},
},
},
where: {
id,
},
});
}
async getUserInfo(id: number, isAdmin: boolean) {
const user = await this.prisma.sysUser.findUnique({
select: {
name: true,
gender: true,
account: true,
avatar: {
select: {
path: true,
},
},
createdAt: true,
},
where: {
id,
status: 1,
},
});
const query: Prisma.SysMenuFindManyArgs = { where: {} };
if (!isAdmin) {
query.where = {
sysMenuPermission: {
some: {
permission: {
sysRolePermission: {
some: { role: { sysUserRole: { some: { userId: id } } } },
},
},
},
},
};
}
const menus = await this.prisma.sysMenu.findMany(query);
return {
...user,
menus,
};
}
async update(id: number, data: UpdateUserDto) {
const user = await this.findOne({ id });
if (!user)
throw new HttpException('修改失败,用户不存在!', HttpStatus.NOT_FOUND);
await this.prisma.$transaction(async (prisma) => {
await prisma.sysUser.update({
data,
where: { id },
});
// 禁用用户 删除token
if (data.status === 0) {
if (data.status === 0) {
const keys = await this.cacheManager.store.keys(
`${RedisGroup.JwtJti}:${id}:*`,
);
await this.cacheManager.store.mdel(...keys);
}
}
});
}
async updatePassword(
id: number,
{ password, newPassword }: UpdatePasswordDto,
) {
const user = await this.findOne({ id });
if (!user)
throw new HttpException('修改失败,用户不存在!', HttpStatus.NOT_FOUND);
// 同时存在密码和新密码
const data: Partial<SysUser> = {};
const comparePasswordSalt = user.passwordSalt;
const comparePassword = await bcrypt.hash(password, comparePasswordSalt);
if (comparePassword !== user.password) {
throw new UnauthorizedException('密码错误,修改失败!');
} else {
data.passwordSalt = await bcrypt.genSalt();
data.password = await bcrypt.hash(newPassword, data.passwordSalt);
}
await this.prisma.sysUser.update({
data,
where: { id },
});
}
async remove(id: number) {
const user = await this.findOne({ id });
if (!user)
throw new HttpException('删除失败,用户不存在!', HttpStatus.NOT_FOUND);
await this.prisma.sysUser.delete({ where: { id } });
}
}

View File