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

1
server/.env Normal file
View File

@@ -0,0 +1 @@
DATABASE_URL="postgresql://postgres:ultimate@81.70.149.52:8000/promise?schema=public"

32
server/.eslintrc.js Normal file
View File

@@ -0,0 +1,32 @@
module.exports = {
parser: '@typescript-eslint/parser',
parserOptions: {
project: 'tsconfig.json',
tsconfigRootDir : __dirname,
sourceType: 'module',
},
plugins: ['@typescript-eslint/eslint-plugin'],
extends: [
'plugin:@typescript-eslint/recommended',
'plugin:prettier/recommended',
],
root: true,
env: {
node: true,
jest: true,
},
ignorePatterns: ['.eslintrc.js'],
rules: {
'@typescript-eslint/interface-name-prefix': 'off',
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-var-requires': 'off',
"prettier/prettier": [
"error",
{
"endOfLine": "auto"
}
]
},
};

35
server/.gitignore vendored Normal file
View File

@@ -0,0 +1,35 @@
# compiled output
/dist
/node_modules
# Logs
logs
*.log
npm-debug.log*
pnpm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# OS
.DS_Store
# Tests
/coverage
/.nyc_output
# IDEs and editors
/.idea
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# IDE - VSCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json

4
server/.prettierrc Normal file
View File

@@ -0,0 +1,4 @@
{
"singleQuote": true,
"trailingComma": "all"
}

4
server/README.md Normal file
View File

@@ -0,0 +1,4 @@
```shell
# 种子文件
npx prisma db seed
```

9
server/nest-cli.json Normal file
View File

@@ -0,0 +1,9 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"assets": ["**/*.yaml"],
"watchAssets": true
}
}

102
server/package.json Normal file
View File

@@ -0,0 +1,102 @@
{
"name": "server",
"version": "0.0.1",
"description": "",
"author": "",
"private": true,
"license": "UNLICENSED",
"scripts": {
"prebuild": "rimraf dist",
"build": "cross-env NODE_ENV=production nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"dev": "cross-env NODE_ENV=development 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/cache-manager": "^2.2.2",
"@nestjs/common": "^10.2.7",
"@nestjs/config": "^3.1.1",
"@nestjs/core": "^10.2.7",
"@nestjs/jwt": "^10.1.1",
"@nestjs/mapped-types": "*",
"@nestjs/platform-express": "^10.2.7",
"@prisma/client": "5.5.2",
"bcryptjs": "^2.4.3",
"cache-manager": "^5.5.3",
"cache-manager-redis-store": "^3.0.1",
"cache-manager-redis-yet": "^5.0.1",
"chalk": "^4.1.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.0",
"cross-env": "^7.0.3",
"js-yaml": "^4.1.0",
"lodash": "^4.17.21",
"mime-types": "^2.1.35",
"minio": "^8.0.1",
"nanoid": "^3.3.6",
"nest-winston": "^1.9.2",
"reflect-metadata": "^0.1.13",
"rimraf": "^3.0.2",
"rxjs": "^7.2.0",
"winston": "^3.9.0",
"winston-daily-rotate-file": "^4.7.1"
},
"prisma": {
"seed": "ts-node prisma/seed.ts"
},
"devDependencies": {
"@nestjs/cli": "^10.2.0",
"@nestjs/schematics": "^10.0.2",
"@nestjs/testing": "^10.2.7",
"@types/cache-manager-redis-store": "^2.0.4",
"@types/express": "^4.17.13",
"@types/jest": "27.5.0",
"@types/js-yaml": "^4.0.5",
"@types/mime-types": "^2.1.3",
"@types/multer": "^1.4.9",
"@types/node": "^16.18.101",
"@types/supertest": "^2.0.11",
"@typescript-eslint/eslint-plugin": "^5.0.0",
"@typescript-eslint/parser": "^5.0.0",
"eslint": "^8.0.1",
"eslint-config-prettier": "^8.3.0",
"eslint-plugin-prettier": "^4.0.0",
"jest": "28.0.3",
"prettier": "^2.3.2",
"prisma": "5.5.2",
"source-map-support": "^0.5.20",
"supertest": "^6.1.3",
"ts-jest": "28.0.1",
"ts-loader": "^9.2.3",
"ts-node": "^10.9.2",
"tsconfig-paths": "4.0.0",
"typescript": "^4.9.5"
},
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
},
"volta": {
"node": "18.20.4"
}
}

View File

@@ -0,0 +1,117 @@
-- CreateTable
CREATE TABLE "sys_user" (
"id" SERIAL NOT NULL,
"name" TEXT,
"account" TEXT NOT NULL,
"password" TEXT NOT NULL,
"password_salt" TEXT NOT NULL,
"status" SMALLINT NOT NULL DEFAULT 0,
"avatar_id" INTEGER,
"gender" SMALLINT NOT NULL DEFAULT 0,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "sys_user_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "sys_role" (
"id" SERIAL NOT NULL,
"code" TEXT NOT NULL,
"name" TEXT NOT NULL,
"status" SMALLINT NOT NULL DEFAULT 0,
CONSTRAINT "sys_role_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "sys_user_role" (
"id" SERIAL NOT NULL,
"user_id" INTEGER NOT NULL,
"role_id" INTEGER NOT NULL,
CONSTRAINT "sys_user_role_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "files" (
"id" SERIAL NOT NULL,
"name" TEXT NOT NULL,
"path" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "files_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "sys_permission" (
"id" SERIAL NOT NULL,
"type" INTEGER NOT NULL,
CONSTRAINT "sys_permission_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "sys_menu" (
"id" SERIAL NOT NULL,
"name" TEXT NOT NULL,
"url" TEXT NOT NULL,
"parent_id" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "sys_menu_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "sys_menu_permission" (
"id" SERIAL NOT NULL,
"menu_id" INTEGER NOT NULL,
"permission_id" INTEGER NOT NULL,
CONSTRAINT "sys_menu_permission_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "sys_role_permission" (
"id" SERIAL NOT NULL,
"role_id" INTEGER NOT NULL,
"permission_id" INTEGER NOT NULL,
CONSTRAINT "sys_role_permission_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "sys_config" (
"key" TEXT NOT NULL,
"value" TEXT NOT NULL,
"remark" TEXT
);
-- CreateIndex
CREATE UNIQUE INDEX "sys_user_account_key" ON "sys_user"("account");
-- CreateIndex
CREATE INDEX "sys_user_avatar_id_idx" ON "sys_user"("avatar_id");
-- CreateIndex
CREATE UNIQUE INDEX "sys_role_code_key" ON "sys_role"("code");
-- CreateIndex
CREATE INDEX "sys_user_role_user_id_idx" ON "sys_user_role"("user_id");
-- CreateIndex
CREATE INDEX "sys_user_role_role_id_idx" ON "sys_user_role"("role_id");
-- CreateIndex
CREATE INDEX "sys_menu_permission_menu_id_idx" ON "sys_menu_permission"("menu_id");
-- CreateIndex
CREATE INDEX "sys_menu_permission_permission_id_idx" ON "sys_menu_permission"("permission_id");
-- CreateIndex
CREATE INDEX "sys_role_permission_role_id_idx" ON "sys_role_permission"("role_id");
-- CreateIndex
CREATE INDEX "sys_role_permission_permission_id_idx" ON "sys_role_permission"("permission_id");
-- CreateIndex
CREATE UNIQUE INDEX "sys_config_key_key" ON "sys_config"("key");

View File

@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "postgresql"

123
server/prisma/schema.prisma Normal file
View File

@@ -0,0 +1,123 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
relationMode = "prisma"
}
// 系统用户表
model SysUser {
id Int @id @default(autoincrement()) /// 主键id
name String? /// 用户姓名
account String @unique /// 账户
password String /// 密码
passwordSalt String @map("password_salt") /// 密码盐
status Int @default(0) @db.SmallInt /// 用户状态
avatarId Int? @map("avatar_id") /// 用户头像id
avatar Files? @relation(fields: [avatarId], references: [id])
gender Int @default(0) @db.SmallInt /// 用户性别
createdAt DateTime @default(now()) @map("created_at") /// 创建时间
updatedAt DateTime @updatedAt @map("updated_at") /// 修改时间
userRole SysUserRole[]
@@index([avatarId])
@@map("sys_user")
}
// 系统角色表
model SysRole {
id Int @id @default(autoincrement())
code String @unique
name String
status Int @default(0) @db.SmallInt /// 角色状态
sysRolePermission SysRolePermission[]
sysUserRole SysUserRole[]
@@map("sys_role")
}
// 用户角色关联表
model SysUserRole {
id Int @id @default(autoincrement())
userId Int @map("user_id")
roleId Int @map("role_id")
user SysUser @relation(fields: [userId], references: [id], onDelete: Cascade)
role SysRole @relation(fields: [roleId], references: [id], onDelete: Cascade)
@@index([userId])
@@index([roleId])
@@map("sys_user_role")
}
// 文件表
model Files {
id Int @id @default(autoincrement())
name String /// 文件名称
path String
createdAt DateTime @default(now()) @map("created_at") /// 创建时间
sysUser SysUser[]
@@map("files")
}
// 权限表
model SysPermission {
id Int @id @default(autoincrement())
type Int
sysRolePermission SysRolePermission[]
sysMenuPermission SysMenuPermission[]
@@map("sys_permission")
}
// 系统菜单表
model SysMenu {
id Int @id @default(autoincrement())
name String
url String
parentId Int @default(0) @map("parent_id")
sysMenuPermission SysMenuPermission[]
@@map("sys_menu")
}
// 菜单权限关联表
model SysMenuPermission {
id Int @id @default(autoincrement())
menuId Int @map("menu_id")
permissionId Int @map("permission_id")
menu SysMenu @relation(fields: [menuId], references: [id], onDelete: Cascade)
permission SysPermission @relation(fields: [permissionId], references: [id], onDelete: Cascade)
@@index([menuId])
@@index([permissionId])
@@map("sys_menu_permission")
}
// 角色权限关联表
model SysRolePermission {
id Int @id @default(autoincrement())
roleId Int @map("role_id")
permissionId Int @map("permission_id")
role SysRole @relation(fields: [roleId], references: [id], onDelete: Cascade)
permission SysPermission @relation(fields: [permissionId], references: [id], onDelete: Cascade)
@@index([roleId])
@@index([permissionId])
@@map("sys_role_permission")
}
// 系统配置
model SysConfig {
key String @unique
value String
remark String?
@@map("sys_config")
}

51
server/prisma/seed.ts Normal file
View File

@@ -0,0 +1,51 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
enum PermissionType {
Menu,
}
async function main() {
await prisma.sysUser.upsert({
where: { account: 'admin' },
update: {},
create: {
name: '管理员',
account: 'admin',
password: '$2a$10$rWb52cHK1hrWuQQg3LiOJ.crDfIqjoOIoglGM96a.Ao1DL/dQwKCu',
passwordSalt: '$2a$10$rWb52cHK1hrWuQQg3LiOJ.',
status: 1,
},
});
const menus = [
{ name: '用户管理', url: 'user', parentId: 0 },
{ name: '角色管理', url: 'role', parentId: 0 },
{ name: '菜单管理', url: 'menu', parentId: 0 },
];
for (const menu of menus) {
// 创建菜单
await prisma.$transaction(async (_prisma) => {
const { id: menuId } = await _prisma.sysMenu.create({ data: menu });
// 创建菜单权限
const { id: permissionId } = await _prisma.sysPermission.create({
data: { type: PermissionType.Menu },
});
// 关联权限和菜单
await _prisma.sysMenuPermission.create({
data: { menuId, permissionId },
});
});
}
}
main()
.then(async () => {
console.log('seed done');
await prisma.$disconnect();
})
.catch(async (e) => {
console.error(e);
await prisma.$disconnect();
process.exit(1);
});

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

View File

@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}

30
server/tsconfig.json Normal file
View File

@@ -0,0 +1,30 @@
{
"compilerOptions": {
"module": "commonjs",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"target": "esnext",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": false,
"noImplicitAny": false,
"strictBindCallApply": false,
"forceConsistentCasingInFileNames": false,
"noFallthroughCasesInSwitch": false,
"paths": {
"@/*": ["src/*"]
}
},
"include": [
"prisma/**/*",
"src/**/*",
"type/**/*"
]
}

64
server/type/global.ts Normal file
View File

@@ -0,0 +1,64 @@
import { RedisGroup } from '@/common/enum';
declare global {
interface QueryListResult {
page: number;
pageSize: number;
count: number;
list: any[];
}
interface IRedisGetOptions {
group?: RedisGroup;
key: string;
}
interface IRedisSetOptions {
group?: RedisGroup;
key: string;
value: unknown;
ttl?: number;
}
interface IRedisDelOptions {
group?: RedisGroup;
key: string;
}
interface IRedisMgetOptions {
group?: RedisGroup;
keys: string[];
}
interface IRedisKeysOptions {
group?: RedisGroup;
pattern: string;
}
interface IRedisMsetOptions {
group?: RedisGroup;
data: { key: string; value: string }[];
ttl?: number;
}
interface IRedisMdelOptions {
group?: RedisGroup;
keys: string[];
}
interface IRedisService {
get<T>(args: IRedisGetOptions): Promise<T>;
set(args: IRedisSetOptions): Promise<void>;
del(args: IRedisDelOptions): Promise<void>;
mget<T>(args: IRedisMgetOptions): Promise<T>;
keys(args: IRedisKeysOptions): Promise<string[]>;
mset(args: IRedisMsetOptions): Promise<void>;
mdel(args: IRedisMdelOptions): Promise<void>;
}
}

5674
server/yarn.lock Normal file

File diff suppressed because it is too large Load Diff