feat: 添加日志和邮件模块

master
luoer 2023-08-03 17:25:34 +08:00
parent 2ec5aac04c
commit 5e18102d61
41 changed files with 588 additions and 4333 deletions

2
.env
View File

@ -36,7 +36,7 @@ DB_MYSQL_DATABASE = test1
# 上传和静态文件配置
# ========================================================================================
# 上传文件目录
UPLOAD_DIR = ./content/uploads
UPLOAD_DIR = ./content/upload
# 静态文件目录
STATIC_DIR = ./content/html

1
.gitignore vendored
View File

@ -13,6 +13,7 @@ lerna-debug.log*
# OS
.DS_Store
*.local
# Tests
/coverage

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 190 KiB

View File

@ -37,6 +37,7 @@
"rxjs": "^7.2.0"
},
"devDependencies": {
"@nestjs/cache-manager": "^2.1.0",
"@nestjs/cli": "^9.0.0",
"@nestjs/config": "^2.3.1",
"@nestjs/devtools-integration": "^0.1.4",
@ -54,10 +55,13 @@
"@types/mockjs": "^1.0.7",
"@types/multer": "^1.4.7",
"@types/node": "^16.0.0",
"@types/nodemailer": "^6.4.9",
"@types/supertest": "^2.0.11",
"@types/uuid": "^9.0.1",
"@typescript-eslint/eslint-plugin": "^5.0.0",
"@typescript-eslint/parser": "^5.0.0",
"cache-manager": "^5.2.3",
"cache-manager-redis-store": "^3.0.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.0",
"dayjs": "^1.11.7",
@ -72,10 +76,12 @@
"multer": "1.4.5-lts.1",
"mysql2": "^3.2.0",
"nanoid": "^4.0.1",
"nodemailer": "^6.9.4",
"passport": "^0.6.0",
"passport-jwt": "^4.0.1",
"passport-local": "^1.0.0",
"prettier": "^2.3.2",
"redis": "^4.6.7",
"source-map-support": "^0.5.20",
"sqlite3": "^5.1.6",
"supertest": "^6.1.3",
@ -87,7 +93,9 @@
"typeorm-naming-strategies": "^4.1.0",
"typescript": "^4.3.5",
"uuid": "^9.0.0",
"webpack": "5"
"webpack": "5",
"winston": "^3.10.0",
"winston-daily-rotate-file": "^4.7.1"
},
"jest": {
"moduleFileExtensions": [

File diff suppressed because it is too large Load Diff

View File

@ -12,6 +12,7 @@ import { AuthModule } from '@/modules/auth';
import { UserModule } from '@/modules/user';
import { ResponseModule } from '@/common/response';
import { SerializationModule } from '@/common/serialization';
import { CacheModule } from './common/cache';
@Module({
imports: [
@ -25,6 +26,11 @@ import { SerializationModule } from '@/common/serialization';
* @description
*/
LoggerModule,
/**
*
* @description
*/
CacheModule,
/**
* ()
* @description /

View File

@ -14,13 +14,13 @@ export class BaseService {
/**
*
*/
formatPagination(page = this.config.defaultPage, size = this.config.defaultPageSize) {
if (size == 0) {
formatPagination(page = this.config.defaultPage, size = this.config.defaultPageSize, supportFull = false) {
if (size == 0 && supportFull) {
return {};
}
return {
skip: (page - 1) * size,
take: size,
take: size == 0 ? this.config.defaultPageSize : size,
};
}
}

7
src/common/cache/cache.controller.ts vendored Normal file
View File

@ -0,0 +1,7 @@
import { Controller } from '@nestjs/common';
import { CacheService } from './cache.service';
@Controller('cache')
export class CacheController {
constructor(private readonly cacheService: CacheService) {}
}

30
src/common/cache/cache.module.ts vendored Normal file
View File

@ -0,0 +1,30 @@
import { Module } from '@nestjs/common';
import { CacheService } from './cache.service';
import { CacheController } from './cache.controller';
import { CacheModule as _CacheModule } from '@nestjs/cache-manager';
import { ConfigService } from '@/config';
import { redisStore } from 'cache-manager-redis-store';
@Module({
imports: [
_CacheModule.registerAsync({
inject: [ConfigService],
useFactory: (config: ConfigService) => {
const { host, port } = config.redis;
return {
// store: () =>
// redisStore({
// commandsQueueMaxLength: 1000,
// socket: { host, port },
// }) as any,
db: 0,
ttl: 600,
};
},
}),
],
controllers: [CacheController],
providers: [CacheService],
exports: [CacheService],
})
export class CacheModule {}

16
src/common/cache/cache.service.ts vendored Normal file
View File

@ -0,0 +1,16 @@
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { Inject, Injectable } from '@nestjs/common';
import { Cache } from 'cache-manager';
@Injectable()
export class CacheService {
constructor(@Inject(CACHE_MANAGER) private cache: Cache) {}
get(key: string) {
return this.cache.get(key);
}
set(key: string, value: any, ttl?: number) {
return this.cache.set(key, value, ttl);
}
}

3
src/common/cache/index.ts vendored Normal file
View File

@ -0,0 +1,3 @@
export * from './cache.module';
export * from './cache.service';
export * from './cache.controller';

View File

@ -1,9 +1,58 @@
import { ConsoleLogger, Injectable } from '@nestjs/common';
import { Injectable, ConsoleLogger } from '@nestjs/common';
import { dayjs } from '@/libs';
import { ConfigService } from '@/config';
import { Logger, createLogger, format, transports } from 'winston';
import 'winston-daily-rotate-file';
@Injectable()
export class LoggerService extends ConsoleLogger {
/**
* Winston
*/
protected logger: Logger;
/**
*
*/
constructor(private config: ConfigService) {
super();
const { combine, timestamp, printf } = format;
const printLine = printf(({ level, message, timestamp, context }: any) => {
return `[Nest] ${process.pid} ${timestamp} ${level} [${context || this.context}]: ${message}`;
});
this.logger = createLogger({
format: combine(timestamp({ format: dayjs.DATETIME }), printLine),
transports: [
new transports.DailyRotateFile({
level: 'info',
dirname: this.config.logDir,
filename: '%DATE%.info.log',
datePattern: 'YYYY-MM-DD',
zippedArchive: true,
maxSize: '20m',
maxFiles: '14d',
format: combine(timestamp({ format: dayjs.DATETIME }), printLine),
}),
],
});
}
/**
*
*/
protected getTimestamp(): string {
return dayjs().format();
}
/**
* Info
*/
log(message: unknown, context?: unknown, ...rest: unknown[]) {
super.log(message, context);
this.logger.info({
message,
context: context || this.context,
...rest,
});
}
}

3
src/common/mail/index.ts Normal file
View File

@ -0,0 +1,3 @@
export * from './mail.controller';
export * from './mail.module';
export * from './mail.service';

View File

@ -0,0 +1,7 @@
import { Controller } from '@nestjs/common';
import { MailService } from './mail.service';
@Controller('mail')
export class MailController {
constructor(private readonly mailService: MailService) {}
}

View File

@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { MailService } from './mail.service';
import { MailController } from './mail.controller';
@Module({
controllers: [MailController],
providers: [MailService],
})
export class MailModule {}

View File

@ -0,0 +1,38 @@
import { ConfigService } from '@/config';
import { Injectable } from '@nestjs/common';
import nodemailer from 'nodemailer';
@Injectable()
export class MailService {
/**
* NodeMailer
*/
protected mailer: nodemailer.Transporter;
/**
*
*/
constructor(private config: ConfigService) {
const { host, port, user, pass } = this.config.smtp;
this.mailer = nodemailer.createTransport({
host,
port,
auth: { user, pass },
});
}
/**
*
* @param to
* @param subject
* @param html
*/
sendMail(to: string, subject: string, html: string) {
return this.mailer.sendMail({
from: `${this.config.title} <${this.config.smtp.user}>`,
to,
subject,
html,
});
}
}

View File

@ -1,16 +1,20 @@
import { ArgumentsHost, Catch, ExceptionFilter, HttpStatus } from '@nestjs/common';
import { Response as _Response } from 'express';
import { Request, Response as _Response } from 'express';
import { Response } from './response';
import { ResponseCode } from './response.code';
import { LoggerService } from '../logger';
@Catch()
export class AllExecptionFilter implements ExceptionFilter {
constructor(private logger: LoggerService) {}
catch(exception: Error, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const request = ctx.getRequest<Request>();
const response = ctx.getResponse<_Response>();
const message = exception.message;
const code = ResponseCode.UNKNOWN_ERROR;
console.log(exception);
this.logger.error(exception, `${request.method} ${request.url}`);
response.status(HttpStatus.INTERNAL_SERVER_ERROR).json(Response.create({ code, message, data: null }));
}
}

View File

@ -3,7 +3,13 @@ import { IsNumber, IsOptional, Min } from 'class-validator';
/**
* DTO
* @example { page: 1, size: 10 }
* @example
* ```
* {
* page: 1,
* size: 10
* }
* ```
*/
export class PaginationDto {
/**

View File

@ -13,7 +13,7 @@ export enum ResponseCode {
/**
*
*/
PARAM_ERROR = 4001,
PARAM_ERROR = 4005,
/**
*
*/
@ -21,5 +21,5 @@ export enum ResponseCode {
/**
*
*/
UNAUTHORIZED = 4003,
UNAUTHORIZED = 4001,
}

View File

@ -27,11 +27,7 @@ export class ResponseInterceptor implements NestInterceptor {
const size = Number(request.query.size || this.config.defaultPageSize);
return Response.success({
data: list,
meta: {
page,
size,
total,
},
meta: { page, size, total },
});
}
return Response.success({ data: list, total });

View File

@ -10,26 +10,14 @@ import { ResponseInterceptor } from './response.interceptor';
*/
@Module({
providers: [
/**
*
* @description {code, message, data, meta}
*/
{
provide: APP_FILTER,
useClass: AllExecptionFilter,
},
/**
* HTTP
* @description HTTP{code, message, data, meta}
*/
{
provide: APP_FILTER,
useClass: HttpExecptionFilter,
},
/**
*
* @description {code, message, data, meta}
*/
{
provide: APP_INTERCEPTOR,
useClass: ResponseInterceptor,

View File

@ -20,6 +20,7 @@ export const initSwagger = (app: INestApplication) => {
.addTag('permission', '权限管理')
.addTag('post', '文章管理')
.addTag('upload', '文件上传')
.addBearerAuth()
.build();
const options: SwaggerDocumentOptions = {
operationIdFactory(controllerKey, methodKey) {

View File

@ -1,12 +1,5 @@
export class AppValidationError extends Error {
messages: string[] = [];
constructor(message: string) {
super(message);
this.name = 'ValidationError';
}
setMessages(errors: string[]) {
this.messages = errors;
export class ValidationError extends Error {
constructor(public messages: string[]) {
super('参数错误');
}
}

View File

@ -1,11 +1,11 @@
import { ArgumentsHost, Catch, ExceptionFilter, HttpStatus } from '@nestjs/common';
import { Response } from 'express';
import { ResponseCode } from '../response';
import { AppValidationError } from './validation.error';
import { ValidationError } from './validation.error';
@Catch(AppValidationError)
@Catch(ValidationError)
export class ValidationExecptionFilter implements ExceptionFilter {
catch(exception: AppValidationError, host: ArgumentsHost) {
catch(exception: ValidationError, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const code = ResponseCode.PARAM_ERROR;

View File

@ -1,7 +1,7 @@
import { ValidationPipe } from '@nestjs/common';
import { AppValidationError } from './validation.error';
import { ValidationError } from './validation.error';
const MessageMap = {
const map = {
isString: '必须为字符串',
isNumber: '必须为数字',
isBoolean: '必须为布尔值',
@ -27,16 +27,14 @@ export const validationPipeFactory = () => {
transform: true,
whitelist: true,
exceptionFactory: (errors) => {
const error = new AppValidationError('参数错误');
const messages: string[] = [];
for (const error of errors) {
const { property, constraints } = error;
Object.keys(constraints).forEach((key) => {
messages.push(MessageMap[key] ? `参数(${property})${MessageMap[key]}` : constraints[key]);
});
for (const [key, val] of Object.entries(constraints)) {
messages.push(map[key] ? `参数(${property})${map[key]}` : val);
}
error.setMessages(messages);
return error;
}
return new ValidationError(messages);
},
});
};

View File

@ -100,10 +100,10 @@ export class ConfigService {
/**
*
* @default './content/uploads'
* @default './content/upload'
*/
get uploadDir(): string {
return this.config.get('UPLOAD_DIR', './content/uploads');
return this.config.get('UPLOAD_DIR', './content/upload');
}
/**
@ -153,4 +153,25 @@ export class ConfigService {
get logDir(): string {
return this.config.get('LOG_DIR', './content/logs');
}
/**
* SMTP
*/
get smtp() {
const host: string = this.config.get('SMTP_HOST');
const port = Number(this.config.get('SMTP_PORT'));
const user: string = this.config.get('SMTP_USER');
const pass: string = this.config.get('SMTP_PASS');
return { host, port, user, pass };
}
/**
* Redis
*/
get redis() {
const host: string = this.config.get('REDIS_HOST', 'localhost');
const port = Number(this.config.get('REDIS_PORT', 6379));
const pass: string = this.config.get('REDIS_PASS', '');
return { host, port, pass };
}
}

View File

@ -15,14 +15,14 @@ export class JwtGuard implements CanActivate {
if (token) {
const secret = this.config.jwtSecret;
const user = await this.jwtService.verifyAsync(token, { secret });
request['user'] = user;
request.user = user;
}
const metadata = [context.getClass(), context.getHandler()];
const isPublic = this.reflector.getAllAndOverride(PUBLICK_KEY, metadata);
if (isPublic === undefined || isPublic) {
const targets = [context.getClass(), context.getHandler()];
const isPublic = this.reflector.getAllAndOverride(PUBLICK_KEY, targets);
if (isPublic) {
return true;
}
if (isPublic !== false && request.method.toLowerCase() === 'GET') {
if (isPublic === undefined && request.method.toUpperCase() === 'GET') {
return true;
}
if (!token) {

View File

@ -1,9 +1,10 @@
import { Respond } from '@/common/response';
import { Controller, Delete, Get, Param, Patch, Post, UploadedFile, UseInterceptors } from '@nestjs/common';
import { Controller, Delete, Get, Ip, Param, Patch, Post, Req, UploadedFile, UseInterceptors } from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { ApiBody, ApiConsumes, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CreateUploadDto } from './dto/create-upload.dto';
import { UploadService } from './upload.service';
import { Request } from 'express';
@ApiTags('upload')
@Controller('upload')
@ -14,8 +15,9 @@ export class UploadController {
@UseInterceptors(FileInterceptor('file'))
@ApiConsumes('multipart/form-data')
@ApiBody({ description: '要上传的文件', type: CreateUploadDto })
@ApiOperation({ description: '上传文件', operationId: 'upload' })
create(@UploadedFile() file: Express.Multer.File) {
@ApiOperation({ description: '上传文件', operationId: 'addFile' })
create(@UploadedFile() file: Express.Multer.File, @Req() req: Request, @Ip() ip: string) {
console.log(`ip: ${ip}, req: ${JSON.stringify(req.user)}`);
return this.uploadService.create(file);
}
@ -27,19 +29,19 @@ export class UploadController {
}
@Get(':id')
@ApiOperation({ description: '查询', operationId: 'getUpload' })
@ApiOperation({ description: '查询', operationId: 'getFile' })
findOne(@Param('id') id: string) {
return this.uploadService.findOne(+id);
}
@Patch(':id')
@ApiOperation({ description: '更新', operationId: 'updateUpload' })
@ApiOperation({ description: '更新', operationId: 'updateFile' })
update() {
return this.uploadService.update();
}
@Delete(':id')
@ApiOperation({ description: '删除', operationId: 'delUpload' })
@ApiOperation({ description: '删除', operationId: 'delFile' })
remove(@Param('id') id: string) {
return this.uploadService.remove(+id);
}

View File

@ -6,17 +6,28 @@ import { diskStorage } from 'multer';
import { Upload } from './entities/upload.entity';
import { UploadController } from './upload.controller';
import { UploadService } from './upload.service';
import { extname, join } from 'path';
import { existsSync, mkdirSync } from 'fs';
@Module({
imports: [
TypeOrmModule.forFeature([Upload]),
MulterModule.registerAsync({
useFactory: async (config: ConfigService) => {
useFactory: (config: ConfigService) => {
return {
storage: diskStorage({
destination: config.uploadDir,
filename: (req, file, cb) => {
cb(null, file.originalname);
destination: (req, file, next) => {
const date = new Date();
const year = date.getFullYear();
const month = date.getMonth() + 1;
const dest = join(config.uploadDir, year.toString(), month.toString().padStart(2, '0'));
if (!existsSync(dest)) {
mkdirSync(dest, { recursive: true });
}
next(null, dest);
},
filename: (req, file, next) => {
next(null, Date.now() + extname(file.originalname));
},
}),
};

View File

@ -1,21 +1,32 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { parse } from 'path';
import { extname, posix, sep, relative } from 'path';
import { Repository } from 'typeorm';
import { Upload } from './entities/upload.entity';
import { BaseService } from '@/common/base';
@Injectable()
export class UploadService {
constructor(@InjectRepository(Upload) private readonly uploadRepository: Repository<Upload>) {}
export class UploadService extends BaseService {
constructor(@InjectRepository(Upload) private readonly uploadRepository: Repository<Upload>) {
super();
}
/**
*
* @param file
* @returns
*/
async create(file: Express.Multer.File) {
const path = relative(this.config.uploadDir, file.path).split(sep).join(posix.sep);
const uploadPrefix = this.config.uploadPrefix;
const uploadUrl = `${uploadPrefix}/${path}`;
const upload = this.uploadRepository.create({
name: file.originalname,
mimetype: file.mimetype,
size: file.size,
hash: file.filename,
path: file.path,
extension: parse(file.originalname).ext,
path: uploadUrl,
extension: extname(file.originalname),
});
await this.uploadRepository.save(upload);
return upload.id;
@ -26,7 +37,7 @@ export class UploadService {
}
findOne(id: number) {
return `This action returns a #${id} upload`;
return this.uploadRepository.findOne({ where: { id } });
}
update() {
@ -34,6 +45,6 @@ export class UploadService {
}
remove(id: number) {
return `This action removes a #${id} upload`;
return this.uploadRepository.softDelete(id);
}
}

View File

@ -3,7 +3,7 @@ import { Exclude } from 'class-transformer';
import { BaseEntity } from '@/database';
import { Post } from '@/modules/post';
import { Role } from '@/modules/role';
import { Column, Entity, JoinTable, ManyToMany } from 'typeorm';
import { Column, Entity, JoinTable, ManyToMany, RelationId } from 'typeorm';
@Entity({ orderBy: { id: 'DESC' } })
export class User extends BaseEntity {
@ -65,4 +65,7 @@ export class User extends BaseEntity {
@ManyToMany(() => Role, (role) => role.user)
@JoinTable()
roles: Role[];
@RelationId('roles')
roleIds: number[];
}

View File

@ -31,7 +31,7 @@ export class UserController extends BaseController {
@Get(':id')
@Version('2')
@ApiOperation({ description: '查询用户', operationId: 'getUserv2' })
@ApiOperation({ deprecated: true, description: '查询用户', operationId: 'getUserv2' })
findOne(@Param('id') id: number) {
return this.userService.findOne(+id);
}

View File

@ -31,7 +31,7 @@ export class UserService extends BaseService {
async findMany(findUserdto: FindUserDto) {
const { nickname: _nickname, page, size } = findUserdto;
const nickname = _nickname && Like(`%${_nickname}%`);
const { skip, take } = this.formatPagination(page, size);
const { skip, take } = this.formatPagination(page, size, true);
return this.userRepository.findAndCount({ skip, take, where: { nickname } });
}
@ -39,7 +39,7 @@ export class UserService extends BaseService {
* id
*/
findOne(idOrOptions: number | Partial<User>) {
const where = typeof idOrOptions === 'number' ? { id: idOrOptions } : idOrOptions;
const where = typeof idOrOptions === 'number' ? { id: idOrOptions } : (idOrOptions as any);
return this.userRepository.findOne({ where });
}

11
src/types/env.d.ts vendored
View File

@ -1,5 +1,16 @@
import 'express';
declare namespace NodeJS {
interface ProcessEnv {
NODE_ENV: 'development' | 'production' | 'test';
}
}
declare module 'express' {
interface Request {
user?: {
id: number;
username: string;
};
}
}