第6章:公共组件与工具
🎯 本章目标
在这一章中,我们将:
- 创建高级拦截器(日志、缓存、转换)
- 实现自定义管道和验证器
- 创建全局异常过滤器
- 开发实用工具函数和装饰器
- 实现文件上传和处理
- 创建邮件服务和通知系统
📁 创建目录结构
src/
├── common/
│ ├── decorators/
│ │ ├── api-paginated-response.decorator.ts
│ │ ├── transform.decorator.ts
│ │ └── validate-object-id.decorator.ts
│ ├── filters/
│ │ ├── all-exceptions.filter.ts
│ │ ├── http-exception.filter.ts
│ │ └── prisma-exception.filter.ts
│ ├── guards/
│ │ ├── throttler.guard.ts
│ │ └── api-key.guard.ts
│ ├── interceptors/
│ │ ├── logging.interceptor.ts
│ │ ├── cache.interceptor.ts
│ │ ├── transform.interceptor.ts
│ │ └── timeout.interceptor.ts
│ ├── pipes/
│ │ ├── parse-object-id.pipe.ts
│ │ ├── validation.pipe.ts
│ │ └── file-validation.pipe.ts
│ ├── middleware/
│ │ ├── logger.middleware.ts
│ │ └── cors.middleware.ts
│ └── utils/
│ ├── crypto.util.ts
│ ├── date.util.ts
│ ├── file.util.ts
│ └── pagination.util.ts
├── modules/
│ ├── upload/
│ │ ├── dto/
│ │ ├── upload.controller.ts
│ │ ├── upload.service.ts
│ │ └── upload.module.ts
│ ├── mail/
│ │ ├── templates/
│ │ ├── mail.service.ts
│ │ └── mail.module.ts
│ └── notification/
│ ├── dto/
│ ├── notification.service.ts
│ └── notification.module.ts
🔍 高级拦截器
1. 日志拦截器
typescript
// src/common/interceptors/logging.interceptor.ts
import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
Logger,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';
import { Request, Response } from 'express';
@Injectable()
export class LoggingInterceptor implements NestInterceptor {
private readonly logger = new Logger(LoggingInterceptor.name);
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const request = context.switchToHttp().getRequest<Request>();
const response = context.switchToHttp().getResponse<Response>();
const { method, url, body, query, params, headers } = request;
const userAgent = headers['user-agent'] || '';
const ip = request.ip;
const now = Date.now();
const requestId = this.generateRequestId();
// 记录请求信息
this.logger.log(
`[${requestId}] ${method} ${url} - ${ip} - ${userAgent}`,
'REQUEST',
);
// 记录请求详情(开发环境)
if (process.env.NODE_ENV === 'development') {
this.logger.debug(
`[${requestId}] Body: ${JSON.stringify(body)} Query: ${JSON.stringify(query)} Params: ${JSON.stringify(params)}`,
'REQUEST_DETAILS',
);
}
return next.handle().pipe(
tap({
next: (data) => {
const responseTime = Date.now() - now;
this.logger.log(
`[${requestId}] ${method} ${url} ${response.statusCode} - ${responseTime}ms`,
'RESPONSE',
);
// 记录响应详情(开发环境)
if (process.env.NODE_ENV === 'development') {
this.logger.debug(
`[${requestId}] Response: ${JSON.stringify(data)}`,
'RESPONSE_DETAILS',
);
}
},
error: (error) => {
const responseTime = Date.now() - now;
this.logger.error(
`[${requestId}] ${method} ${url} ${error.status || 500} - ${responseTime}ms - ${error.message}`,
error.stack,
'ERROR',
);
},
}),
);
}
private generateRequestId(): string {
return Math.random().toString(36).substring(2, 15);
}
}
2. 缓存拦截器
typescript
// src/common/interceptors/cache.interceptor.ts
import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
} from '@nestjs/common';
import { Observable, of } from 'rxjs';
import { tap } from 'rxjs/operators';
import { Reflector } from '@nestjs/core';
// 缓存装饰器
export const CacheKey = (key: string) => Reflector.createDecorator<string>({ key });
export const CacheTTL = (ttl: number) => Reflector.createDecorator<number>({ ttl });
@Injectable()
export class CacheInterceptor implements NestInterceptor {
private cache = new Map<string, { data: any; expiry: number }>();
constructor(private reflector: Reflector) {}
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const cacheKey = this.reflector.get(CacheKey, context.getHandler());
const cacheTTL = this.reflector.get(CacheTTL, context.getHandler()) || 60000; // 默认1分钟
if (!cacheKey) {
return next.handle();
}
const request = context.switchToHttp().getRequest();
const fullCacheKey = this.generateCacheKey(cacheKey, request);
// 检查缓存
const cached = this.cache.get(fullCacheKey);
if (cached && cached.expiry > Date.now()) {
return of(cached.data);
}
return next.handle().pipe(
tap((data) => {
// 存储到缓存
this.cache.set(fullCacheKey, {
data,
expiry: Date.now() + cacheTTL,
});
// 清理过期缓存
this.cleanExpiredCache();
}),
);
}
private generateCacheKey(baseKey: string, request: any): string {
const { method, url, query, params } = request;
const queryString = JSON.stringify(query);
const paramsString = JSON.stringify(params);
return `${baseKey}:${method}:${url}:${queryString}:${paramsString}`;
}
private cleanExpiredCache(): void {
const now = Date.now();
for (const [key, value] of this.cache.entries()) {
if (value.expiry <= now) {
this.cache.delete(key);
}
}
}
// 清除指定缓存
clearCache(pattern?: string): void {
if (!pattern) {
this.cache.clear();
return;
}
for (const key of this.cache.keys()) {
if (key.includes(pattern)) {
this.cache.delete(key);
}
}
}
}
3. 响应转换拦截器
typescript
// src/common/interceptors/transform.interceptor.ts
import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
export interface Response<T> {
success: boolean;
data: T;
message?: string;
timestamp: string;
path: string;
}
@Injectable()
export class TransformInterceptor<T>
implements NestInterceptor<T, Response<T>>
{
intercept(
context: ExecutionContext,
next: CallHandler,
): Observable<Response<T>> {
const request = context.switchToHttp().getRequest();
return next.handle().pipe(
map((data) => ({
success: true,
data,
message: data?.message || 'Success',
timestamp: new Date().toISOString(),
path: request.url,
})),
);
}
}
4. 超时拦截器
typescript
// src/common/interceptors/timeout.interceptor.ts
import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
RequestTimeoutException,
} from '@nestjs/common';
import { Observable, throwError, TimeoutError } from 'rxjs';
import { catchError, timeout } from 'rxjs/operators';
@Injectable()
export class TimeoutInterceptor implements NestInterceptor {
constructor(private readonly timeoutValue: number = 30000) {} // 默认30秒
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
return next.handle().pipe(
timeout(this.timeoutValue),
catchError((err) => {
if (err instanceof TimeoutError) {
return throwError(() => new RequestTimeoutException('请求超时'));
}
return throwError(() => err);
}),
);
}
}
🔧 自定义管道
1. 对象ID验证管道
typescript
// src/common/pipes/parse-object-id.pipe.ts
import {
PipeTransform,
Injectable,
ArgumentMetadata,
BadRequestException,
} from '@nestjs/common';
import { validate as isUUID } from 'uuid';
@Injectable()
export class ParseObjectIdPipe implements PipeTransform<string, string> {
transform(value: string, metadata: ArgumentMetadata): string {
if (!value) {
throw new BadRequestException('ID不能为空');
}
if (!isUUID(value)) {
throw new BadRequestException('无效的ID格式');
}
return value;
}
}
2. 文件验证管道
typescript
// src/common/pipes/file-validation.pipe.ts
import {
PipeTransform,
Injectable,
ArgumentMetadata,
BadRequestException,
} from '@nestjs/common';
export interface FileValidationOptions {
maxSize?: number; // 最大文件大小(字节)
allowedMimeTypes?: string[]; // 允许的MIME类型
allowedExtensions?: string[]; // 允许的文件扩展名
}
@Injectable()
export class FileValidationPipe implements PipeTransform {
constructor(private readonly options: FileValidationOptions = {}) {}
transform(file: Express.Multer.File, metadata: ArgumentMetadata) {
if (!file) {
throw new BadRequestException('文件不能为空');
}
// 验证文件大小
if (this.options.maxSize && file.size > this.options.maxSize) {
throw new BadRequestException(
`文件大小不能超过 ${this.formatFileSize(this.options.maxSize)}`,
);
}
// 验证MIME类型
if (
this.options.allowedMimeTypes &&
!this.options.allowedMimeTypes.includes(file.mimetype)
) {
throw new BadRequestException(
`不支持的文件类型,支持的类型:${this.options.allowedMimeTypes.join(', ')}`,
);
}
// 验证文件扩展名
if (this.options.allowedExtensions) {
const extension = file.originalname.split('.').pop()?.toLowerCase();
if (!extension || !this.options.allowedExtensions.includes(extension)) {
throw new BadRequestException(
`不支持的文件扩展名,支持的扩展名:${this.options.allowedExtensions.join(', ')}`,
);
}
}
return file;
}
private formatFileSize(bytes: number): string {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
}
🚨 全局异常过滤器
1. 全局异常过滤器
typescript
// src/common/filters/all-exceptions.filter.ts
import {
ExceptionFilter,
Catch,
ArgumentsHost,
HttpException,
HttpStatus,
Logger,
} from '@nestjs/common';
import { Request, Response } from 'express';
import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library';
@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
private readonly logger = new Logger(AllExceptionsFilter.name);
catch(exception: unknown, host: ArgumentsHost): void {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
let status = HttpStatus.INTERNAL_SERVER_ERROR;
let message = 'Internal server error';
let error = 'Internal Server Error';
// 处理 HTTP 异常
if (exception instanceof HttpException) {
status = exception.getStatus();
const exceptionResponse = exception.getResponse();
if (typeof exceptionResponse === 'string') {
message = exceptionResponse;
} else if (typeof exceptionResponse === 'object') {
message = (exceptionResponse as any).message || exception.message;
error = (exceptionResponse as any).error || error;
}
}
// 处理 Prisma 异常
else if (exception instanceof PrismaClientKnownRequestError) {
const prismaError = this.handlePrismaError(exception);
status = prismaError.status;
message = prismaError.message;
error = prismaError.error;
}
// 处理其他异常
else if (exception instanceof Error) {
message = exception.message;
}
const errorResponse = {
success: false,
statusCode: status,
error,
message,
timestamp: new Date().toISOString(),
path: request.url,
method: request.method,
};
// 记录错误日志
this.logger.error(
`${request.method} ${request.url} - ${status} - ${message}`,
exception instanceof Error ? exception.stack : 'Unknown error',
);
response.status(status).json(errorResponse);
}
private handlePrismaError(exception: PrismaClientKnownRequestError) {
switch (exception.code) {
case 'P2002':
return {
status: HttpStatus.CONFLICT,
message: '数据已存在,违反唯一约束',
error: 'Conflict',
};
case 'P2025':
return {
status: HttpStatus.NOT_FOUND,
message: '记录不存在',
error: 'Not Found',
};
case 'P2003':
return {
status: HttpStatus.BAD_REQUEST,
message: '外键约束失败',
error: 'Bad Request',
};
case 'P2014':
return {
status: HttpStatus.BAD_REQUEST,
message: '数据关系冲突',
error: 'Bad Request',
};
default:
return {
status: HttpStatus.INTERNAL_SERVER_ERROR,
message: '数据库操作失败',
error: 'Internal Server Error',
};
}
}
}
2. HTTP异常过滤器
typescript
// src/common/filters/http-exception.filter.ts
import {
ExceptionFilter,
Catch,
ArgumentsHost,
HttpException,
Logger,
} from '@nestjs/common';
import { Request, Response } from 'express';
@Catch(HttpException)
export class HttpExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger(HttpExceptionFilter.name);
catch(exception: HttpException, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
const status = exception.getStatus();
const exceptionResponse = exception.getResponse();
let message: string | string[];
let error: string;
if (typeof exceptionResponse === 'string') {
message = exceptionResponse;
error = exception.name;
} else {
message = (exceptionResponse as any).message || exception.message;
error = (exceptionResponse as any).error || exception.name;
}
const errorResponse = {
success: false,
statusCode: status,
error,
message,
timestamp: new Date().toISOString(),
path: request.url,
method: request.method,
};
// 记录错误日志(4xx错误记录为warn,5xx错误记录为error)
if (status >= 500) {
this.logger.error(
`${request.method} ${request.url} - ${status} - ${JSON.stringify(message)}`,
exception.stack,
);
} else {
this.logger.warn(
`${request.method} ${request.url} - ${status} - ${JSON.stringify(message)}`,
);
}
response.status(status).json(errorResponse);
}
}
🛠️ 实用工具函数
1. 加密工具
typescript
// src/common/utils/crypto.util.ts
import * as crypto from 'crypto';
import * as bcrypt from 'bcrypt';
export class CryptoUtil {
/**
* 生成随机字符串
*/
static generateRandomString(length: number = 32): string {
return crypto.randomBytes(length).toString('hex');
}
/**
* 生成UUID
*/
static generateUUID(): string {
return crypto.randomUUID();
}
/**
* MD5哈希
*/
static md5(text: string): string {
return crypto.createHash('md5').update(text).digest('hex');
}
/**
* SHA256哈希
*/
static sha256(text: string): string {
return crypto.createHash('sha256').update(text).digest('hex');
}
/**
* 密码哈希
*/
static async hashPassword(password: string, rounds: number = 12): Promise<string> {
return bcrypt.hash(password, rounds);
}
/**
* 验证密码
*/
static async comparePassword(password: string, hash: string): Promise<boolean> {
return bcrypt.compare(password, hash);
}
/**
* AES加密
*/
static encrypt(text: string, key: string): string {
const algorithm = 'aes-256-cbc';
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipher(algorithm, key);
let encrypted = cipher.update(text, 'utf8', 'hex');
encrypted += cipher.final('hex');
return iv.toString('hex') + ':' + encrypted;
}
/**
* AES解密
*/
static decrypt(encryptedText: string, key: string): string {
const algorithm = 'aes-256-cbc';
const textParts = encryptedText.split(':');
const iv = Buffer.from(textParts.shift()!, 'hex');
const encrypted = textParts.join(':');
const decipher = crypto.createDecipher(algorithm, key);
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
}
/**
* 生成JWT密钥
*/
static generateJWTSecret(): string {
return crypto.randomBytes(64).toString('hex');
}
}
2. 日期工具
typescript
// src/common/utils/date.util.ts
export class DateUtil {
/**
* 格式化日期
*/
static format(date: Date, format: string = 'YYYY-MM-DD HH:mm:ss'): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
const seconds = String(date.getSeconds()).padStart(2, '0');
return format
.replace('YYYY', year.toString())
.replace('MM', month)
.replace('DD', day)
.replace('HH', hours)
.replace('mm', minutes)
.replace('ss', seconds);
}
/**
* 获取时间差(毫秒)
*/
static diff(date1: Date, date2: Date): number {
return Math.abs(date1.getTime() - date2.getTime());
}
/**
* 添加天数
*/
static addDays(date: Date, days: number): Date {
const result = new Date(date);
result.setDate(result.getDate() + days);
return result;
}
/**
* 添加小时
*/
static addHours(date: Date, hours: number): Date {
const result = new Date(date);
result.setHours(result.getHours() + hours);
return result;
}
/**
* 添加分钟
*/
static addMinutes(date: Date, minutes: number): Date {
const result = new Date(date);
result.setMinutes(result.getMinutes() + minutes);
return result;
}
/**
* 获取日期范围的开始和结束
*/
static getDateRange(type: 'today' | 'week' | 'month' | 'year'): { start: Date; end: Date } {
const now = new Date();
const start = new Date(now);
const end = new Date(now);
switch (type) {
case 'today':
start.setHours(0, 0, 0, 0);
end.setHours(23, 59, 59, 999);
break;
case 'week':
const dayOfWeek = now.getDay();
start.setDate(now.getDate() - dayOfWeek);
start.setHours(0, 0, 0, 0);
end.setDate(start.getDate() + 6);
end.setHours(23, 59, 59, 999);
break;
case 'month':
start.setDate(1);
start.setHours(0, 0, 0, 0);
end.setMonth(start.getMonth() + 1, 0);
end.setHours(23, 59, 59, 999);
break;
case 'year':
start.setMonth(0, 1);
start.setHours(0, 0, 0, 0);
end.setMonth(11, 31);
end.setHours(23, 59, 59, 999);
break;
}
return { start, end };
}
/**
* 判断是否为同一天
*/
static isSameDay(date1: Date, date2: Date): boolean {
return (
date1.getFullYear() === date2.getFullYear() &&
date1.getMonth() === date2.getMonth() &&
date1.getDate() === date2.getDate()
);
}
/**
* 获取相对时间描述
*/
static getRelativeTime(date: Date): string {
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffSeconds = Math.floor(diffMs / 1000);
const diffMinutes = Math.floor(diffSeconds / 60);
const diffHours = Math.floor(diffMinutes / 60);
const diffDays = Math.floor(diffHours / 24);
if (diffSeconds < 60) {
return '刚刚';
} else if (diffMinutes < 60) {
return `${diffMinutes}分钟前`;
} else if (diffHours < 24) {
return `${diffHours}小时前`;
} else if (diffDays < 7) {
return `${diffDays}天前`;
} else {
return this.format(date, 'YYYY-MM-DD');
}
}
}
3. 分页工具
typescript
// src/common/utils/pagination.util.ts
export interface PaginationOptions {
page: number;
limit: number;
}
export interface PaginationMeta {
total: number;
page: number;
limit: number;
totalPages: number;
hasNextPage: boolean;
hasPrevPage: boolean;
nextPage?: number;
prevPage?: number;
}
export interface PaginatedResult<T> {
data: T[];
meta: PaginationMeta;
}
export class PaginationUtil {
/**
* 计算跳过的记录数
*/
static getSkip(page: number, limit: number): number {
return (page - 1) * limit;
}
/**
* 创建分页元数据
*/
static createMeta(
total: number,
page: number,
limit: number,
): PaginationMeta {
const totalPages = Math.ceil(total / limit);
const hasNextPage = page < totalPages;
const hasPrevPage = page > 1;
return {
total,
page,
limit,
totalPages,
hasNextPage,
hasPrevPage,
nextPage: hasNextPage ? page + 1 : undefined,
prevPage: hasPrevPage ? page - 1 : undefined,
};
}
/**
* 创建分页结果
*/
static createResult<T>(
data: T[],
total: number,
page: number,
limit: number,
): PaginatedResult<T> {
return {
data,
meta: this.createMeta(total, page, limit),
};
}
/**
* 验证分页参数
*/
static validatePagination(page: number, limit: number): void {
if (page < 1) {
throw new Error('页码必须大于0');
}
if (limit < 1) {
throw new Error('每页数量必须大于0');
}
if (limit > 100) {
throw new Error('每页数量不能超过100');
}
}
}
4. 文件工具
typescript
// src/common/utils/file.util.ts
import * as path from 'path';
import * as fs from 'fs/promises';
import { createReadStream, createWriteStream } from 'fs';
import { pipeline } from 'stream/promises';
export class FileUtil {
/**
* 获取文件扩展名
*/
static getExtension(filename: string): string {
return path.extname(filename).toLowerCase();
}
/**
* 获取文件名(不含扩展名)
*/
static getBasename(filename: string): string {
return path.basename(filename, path.extname(filename));
}
/**
* 生成唯一文件名
*/
static generateUniqueFilename(originalName: string): string {
const ext = this.getExtension(originalName);
const basename = this.getBasename(originalName);
const timestamp = Date.now();
const random = Math.random().toString(36).substring(2, 8);
return `${basename}-${timestamp}-${random}${ext}`;
}
/**
* 格式化文件大小
*/
static formatFileSize(bytes: number): string {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
/**
* 检查文件是否存在
*/
static async exists(filePath: string): Promise<boolean> {
try {
await fs.access(filePath);
return true;
} catch {
return false;
}
}
/**
* 创建目录(如果不存在)
*/
static async ensureDir(dirPath: string): Promise<void> {
try {
await fs.mkdir(dirPath, { recursive: true });
} catch (error) {
// 忽略目录已存在的错误
if ((error as any).code !== 'EEXIST') {
throw error;
}
}
}
/**
* 复制文件
*/
static async copyFile(src: string, dest: string): Promise<void> {
await this.ensureDir(path.dirname(dest));
await pipeline(createReadStream(src), createWriteStream(dest));
}
/**
* 删除文件
*/
static async deleteFile(filePath: string): Promise<void> {
try {
await fs.unlink(filePath);
} catch (error) {
// 忽略文件不存在的错误
if ((error as any).code !== 'ENOENT') {
throw error;
}
}
}
/**
* 获取文件信息
*/
static async getFileInfo(filePath: string) {
const stats = await fs.stat(filePath);
return {
size: stats.size,
createdAt: stats.birthtime,
modifiedAt: stats.mtime,
isFile: stats.isFile(),
isDirectory: stats.isDirectory(),
};
}
/**
* 读取文件内容
*/
static async readFile(filePath: string, encoding: BufferEncoding = 'utf8'): Promise<string> {
return fs.readFile(filePath, encoding);
}
/**
* 写入文件内容
*/
static async writeFile(filePath: string, content: string): Promise<void> {
await this.ensureDir(path.dirname(filePath));
await fs.writeFile(filePath, content, 'utf8');
}
}
📁 文件上传服务
1. 安装依赖
bash
npm install multer @types/multer
npm install sharp # 图片处理库
2. 文件上传 DTO
typescript
// src/modules/upload/dto/upload.dto.ts
import { ApiProperty } from '@nestjs/swagger';
export class UploadResponseDto {
@ApiProperty({ description: '文件URL' })
url: string;
@ApiProperty({ description: '文件名' })
filename: string;
@ApiProperty({ description: '原始文件名' })
originalName: string;
@ApiProperty({ description: '文件大小(字节)' })
size: number;
@ApiProperty({ description: '文件类型' })
mimetype: string;
@ApiProperty({ description: '上传时间' })
uploadedAt: Date;
}
3. 文件上传服务
typescript
// src/modules/upload/upload.service.ts
import {
Injectable,
BadRequestException,
InternalServerErrorException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as path from 'path';
import * as sharp from 'sharp';
import { FileUtil } from '../../common/utils/file.util';
import { UploadResponseDto } from './dto/upload.dto';
@Injectable()
export class UploadService {
private readonly uploadPath: string;
private readonly maxFileSize: number;
private readonly allowedImageTypes: string[];
private readonly allowedDocumentTypes: string[];
constructor(private configService: ConfigService) {
this.uploadPath = this.configService.get<string>('upload.path') || './uploads';
this.maxFileSize = this.configService.get<number>('upload.maxSize') || 10 * 1024 * 1024; // 10MB
this.allowedImageTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
this.allowedDocumentTypes = ['application/pdf', 'text/plain', 'application/msword'];
}
async uploadImage(file: Express.Multer.File): Promise<UploadResponseDto> {
this.validateImageFile(file);
const filename = FileUtil.generateUniqueFilename(file.originalname);
const uploadDir = path.join(this.uploadPath, 'images');
const filePath = path.join(uploadDir, filename);
try {
// 确保上传目录存在
await FileUtil.ensureDir(uploadDir);
// 处理图片(压缩、调整大小)
await sharp(file.buffer)
.resize(1920, 1080, {
fit: 'inside',
withoutEnlargement: true
})
.jpeg({ quality: 85 })
.toFile(filePath);
// 生成缩略图
const thumbnailPath = path.join(uploadDir, 'thumbnails', filename);
await FileUtil.ensureDir(path.dirname(thumbnailPath));
await sharp(file.buffer)
.resize(300, 300, { fit: 'cover' })
.jpeg({ quality: 80 })
.toFile(thumbnailPath);
return {
url: `/uploads/images/${filename}`,
filename,
originalName: file.originalname,
size: file.size,
mimetype: file.mimetype,
uploadedAt: new Date(),
};
} catch (error) {
throw new InternalServerErrorException('文件上传失败');
}
}
async uploadDocument(file: Express.Multer.File): Promise<UploadResponseDto> {
this.validateDocumentFile(file);
const filename = FileUtil.generateUniqueFilename(file.originalname);
const uploadDir = path.join(this.uploadPath, 'documents');
const filePath = path.join(uploadDir, filename);
try {
await FileUtil.ensureDir(uploadDir);
await FileUtil.writeFile(filePath, file.buffer.toString('base64'));
return {
url: `/uploads/documents/${filename}`,
filename,
originalName: file.originalname,
size: file.size,
mimetype: file.mimetype,
uploadedAt: new Date(),
};
} catch (error) {
throw new InternalServerErrorException('文件上传失败');
}
}
async deleteFile(filename: string, type: 'images' | 'documents'): Promise<void> {
const filePath = path.join(this.uploadPath, type, filename);
try {
await FileUtil.deleteFile(filePath);
// 如果是图片,同时删除缩略图
if (type === 'images') {
const thumbnailPath = path.join(this.uploadPath, type, 'thumbnails', filename);
await FileUtil.deleteFile(thumbnailPath);
}
} catch (error) {
throw new InternalServerErrorException('文件删除失败');
}
}
private validateImageFile(file: Express.Multer.File): void {
if (!file) {
throw new BadRequestException('请选择文件');
}
if (file.size > this.maxFileSize) {
throw new BadRequestException(
`文件大小不能超过 ${FileUtil.formatFileSize(this.maxFileSize)}`
);
}
if (!this.allowedImageTypes.includes(file.mimetype)) {
throw new BadRequestException(
`不支持的图片格式,支持格式:${this.allowedImageTypes.join(', ')}`
);
}
}
private validateDocumentFile(file: Express.Multer.File): void {
if (!file) {
throw new BadRequestException('请选择文件');
}
if (file.size > this.maxFileSize) {
throw new BadRequestException(
`文件大小不能超过 ${FileUtil.formatFileSize(this.maxFileSize)}`
);
}
if (!this.allowedDocumentTypes.includes(file.mimetype)) {
throw new BadRequestException(
`不支持的文档格式,支持格式:${this.allowedDocumentTypes.join(', ')}`
);
}
}
}
4. 文件上传控制器
typescript
// src/modules/upload/upload.controller.ts
import {
Controller,
Post,
Delete,
Param,
UseInterceptors,
UploadedFile,
ParseFilePipe,
MaxFileSizeValidator,
FileTypeValidator,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiConsumes,
ApiBearerAuth,
} from '@nestjs/swagger';
import { UploadService } from './upload.service';
import { UploadResponseDto } from './dto/upload.dto';
import { FileValidationPipe } from '../../common/pipes/file-validation.pipe';
@ApiTags('文件上传')
@ApiBearerAuth()
@Controller('upload')
export class UploadController {
constructor(private readonly uploadService: UploadService) {}
@Post('image')
@UseInterceptors(FileInterceptor('file'))
@ApiOperation({ summary: '上传图片' })
@ApiConsumes('multipart/form-data')
@ApiResponse({
status: 201,
description: '上传成功',
type: UploadResponseDto
})
async uploadImage(
@UploadedFile(
new FileValidationPipe({
maxSize: 10 * 1024 * 1024, // 10MB
allowedMimeTypes: ['image/jpeg', 'image/png', 'image/gif', 'image/webp'],
allowedExtensions: ['jpg', 'jpeg', 'png', 'gif', 'webp'],
})
)
file: Express.Multer.File,
): Promise<UploadResponseDto> {
return this.uploadService.uploadImage(file);
}
@Post('document')
@UseInterceptors(FileInterceptor('file'))
@ApiOperation({ summary: '上传文档' })
@ApiConsumes('multipart/form-data')
@ApiResponse({
status: 201,
description: '上传成功',
type: UploadResponseDto
})
async uploadDocument(
@UploadedFile(
new FileValidationPipe({
maxSize: 10 * 1024 * 1024, // 10MB
allowedMimeTypes: ['application/pdf', 'text/plain', 'application/msword'],
allowedExtensions: ['pdf', 'txt', 'doc', 'docx'],
})
)
file: Express.Multer.File,
): Promise<UploadResponseDto> {
return this.uploadService.uploadDocument(file);
}
@Delete('image/:filename')
@ApiOperation({ summary: '删除图片' })
@ApiResponse({ status: 204, description: '删除成功' })
async deleteImage(@Param('filename') filename: string): Promise<void> {
return this.uploadService.deleteFile(filename, 'images');
}
@Delete('document/:filename')
@ApiOperation({ summary: '删除文档' })
@ApiResponse({ status: 204, description: '删除成功' })
async deleteDocument(@Param('filename') filename: string): Promise<void> {
return this.uploadService.deleteFile(filename, 'documents');
}
}
📧 邮件服务
1. 安装依赖
bash
npm install nodemailer @types/nodemailer
npm install handlebars # 邮件模板引擎
2. 邮件服务
typescript
// src/modules/mail/mail.service.ts
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as nodemailer from 'nodemailer';
import * as handlebars from 'handlebars';
import * as path from 'path';
import { FileUtil } from '../../common/utils/file.util';
export interface MailOptions {
to: string | string[];
subject: string;
template?: string;
context?: Record<string, any>;
html?: string;
text?: string;
attachments?: Array<{
filename: string;
path?: string;
content?: Buffer;
}>;
}
@Injectable()
export class MailService {
private readonly logger = new Logger(MailService.name);
private transporter: nodemailer.Transporter;
private templatesPath: string;
constructor(private configService: ConfigService) {
this.templatesPath = path.join(process.cwd(), 'src/modules/mail/templates');
this.createTransporter();
}
private createTransporter(): void {
const config = {
host: this.configService.get<string>('mail.host'),
port: this.configService.get<number>('mail.port'),
secure: this.configService.get<boolean>('mail.secure'),
auth: {
user: this.configService.get<string>('mail.user'),
pass: this.configService.get<string>('mail.password'),
},
};
this.transporter = nodemailer.createTransporter(config);
}
async sendMail(options: MailOptions): Promise<void> {
try {
let html = options.html;
// 如果指定了模板,渲染模板
if (options.template) {
html = await this.renderTemplate(options.template, options.context || {});
}
const mailOptions = {
from: this.configService.get<string>('mail.from'),
to: Array.isArray(options.to) ? options.to.join(', ') : options.to,
subject: options.subject,
html,
text: options.text,
attachments: options.attachments,
};
const result = await this.transporter.sendMail(mailOptions);
this.logger.log(`邮件发送成功: ${result.messageId}`);
} catch (error) {
this.logger.error('邮件发送失败:', error);
throw error;
}
}
async sendWelcomeEmail(to: string, username: string): Promise<void> {
await this.sendMail({
to,
subject: '欢迎注册!',
template: 'welcome',
context: {
username,
loginUrl: `${this.configService.get<string>('app.url')}/login`,
},
});
}
async sendPasswordResetEmail(to: string, resetToken: string): Promise<void> {
const resetUrl = `${this.configService.get<string>('app.url')}/reset-password?token=${resetToken}`;
await this.sendMail({
to,
subject: '密码重置',
template: 'password-reset',
context: {
resetUrl,
expiresIn: '1小时',
},
});
}
async sendVerificationEmail(to: string, verificationToken: string): Promise<void> {
const verificationUrl = `${this.configService.get<string>('app.url')}/verify-email?token=${verificationToken}`;
await this.sendMail({
to,
subject: '邮箱验证',
template: 'email-verification',
context: {
verificationUrl,
},
});
}
private async renderTemplate(templateName: string, context: Record<string, any>): Promise<string> {
try {
const templatePath = path.join(this.templatesPath, `${templateName}.hbs`);
const templateContent = await FileUtil.readFile(templatePath);
const template = handlebars.compile(templateContent);
return template(context);
} catch (error) {
this.logger.error(`模板渲染失败: ${templateName}`, error);
throw new Error(`邮件模板渲染失败: ${templateName}`);
}
}
}
3. 邮件模板
html
<!-- src/modules/mail/templates/welcome.hbs -->
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>欢迎注册</title>
<style>
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
.header { background: #007bff; color: white; padding: 20px; text-align: center; }
.content { padding: 20px; background: #f8f9fa; }
.button { display: inline-block; padding: 10px 20px; background: #007bff; color: white; text-decoration: none; border-radius: 5px; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>欢迎加入我们!</h1>
</div>
<div class="content">
<p>亲爱的 {{username}},</p>
<p>欢迎注册我们的平台!您的账户已经创建成功。</p>
<p>您现在可以登录并开始使用我们的服务了。</p>
<p style="text-align: center;">
<a href="{{loginUrl}}" class="button">立即登录</a>
</p>
<p>如果您有任何问题,请随时联系我们。</p>
<p>祝您使用愉快!</p>
</div>
</div>
</body>
</html>
html
<!-- src/modules/mail/templates/password-reset.hbs -->
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>密码重置</title>
<style>
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
.header { background: #dc3545; color: white; padding: 20px; text-align: center; }
.content { padding: 20px; background: #f8f9fa; }
.button { display: inline-block; padding: 10px 20px; background: #dc3545; color: white; text-decoration: none; border-radius: 5px; }
.warning { background: #fff3cd; border: 1px solid #ffeaa7; padding: 10px; border-radius: 5px; margin: 10px 0; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>密码重置</h1>
</div>
<div class="content">
<p>您好,</p>
<p>我们收到了您的密码重置请求。请点击下面的按钮来重置您的密码:</p>
<p style="text-align: center;">
<a href="{{resetUrl}}" class="button">重置密码</a>
</p>
<div class="warning">
<strong>注意:</strong>此链接将在 {{expiresIn}} 后过期。如果您没有请求重置密码,请忽略此邮件。
</div>
<p>如果按钮无法点击,请复制以下链接到浏览器地址栏:</p>
<p><a href="{{resetUrl}}">{{resetUrl}}</a></p>
</div>
</div>
</body>
</html>
🔧 模块配置
1. 上传模块
typescript
// src/modules/upload/upload.module.ts
import { Module } from '@nestjs/common';
import { MulterModule } from '@nestjs/platform-express';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { UploadController } from './upload.controller';
import { UploadService } from './upload.service';
@Module({
imports: [
MulterModule.registerAsync({
imports: [ConfigModule],
useFactory: async (configService: ConfigService) => ({
dest: configService.get<string>('upload.path') || './uploads',
limits: {
fileSize: configService.get<number>('upload.maxSize') || 10 * 1024 * 1024,
},
}),
inject: [ConfigService],
}),
],
controllers: [UploadController],
providers: [UploadService],
exports: [UploadService],
})
export class UploadModule {}
2. 邮件模块
typescript
// src/modules/mail/mail.module.ts
import { Module } from '@nestjs/common';
import { MailService } from './mail.service';
@Module({
providers: [MailService],
exports: [MailService],
})
export class MailModule {}
3. 通知服务
typescript
// src/modules/notification/notification.service.ts
import { Injectable, Logger } from '@nestjs/common';
import { MailService } from '../mail/mail.service';
export interface NotificationOptions {
type: 'email' | 'sms' | 'push';
recipient: string;
title: string;
content: string;
data?: Record<string, any>;
}
@Injectable()
export class NotificationService {
private readonly logger = new Logger(NotificationService.name);
constructor(private mailService: MailService) {}
async send(options: NotificationOptions): Promise<void> {
try {
switch (options.type) {
case 'email':
await this.sendEmail(options);
break;
case 'sms':
await this.sendSMS(options);
break;
case 'push':
await this.sendPush(options);
break;
default:
throw new Error(`不支持的通知类型: ${options.type}`);
}
} catch (error) {
this.logger.error(`通知发送失败: ${error.message}`, error.stack);
throw error;
}
}
private async sendEmail(options: NotificationOptions): Promise<void> {
await this.mailService.sendMail({
to: options.recipient,
subject: options.title,
html: options.content,
});
}
private async sendSMS(options: NotificationOptions): Promise<void> {
// 实现短信发送逻辑
this.logger.log(`发送短信到 ${options.recipient}: ${options.content}`);
}
private async sendPush(options: NotificationOptions): Promise<void> {
// 实现推送通知逻辑
this.logger.log(`发送推送通知到 ${options.recipient}: ${options.title}`);
}
// 批量发送通知
async sendBatch(notifications: NotificationOptions[]): Promise<void> {
const promises = notifications.map(notification => this.send(notification));
await Promise.allSettled(promises);
}
// 发送系统通知
async sendSystemNotification(
recipients: string[],
title: string,
content: string,
): Promise<void> {
const notifications = recipients.map(recipient => ({
type: 'email' as const,
recipient,
title,
content,
}));
await this.sendBatch(notifications);
}
}
4. 更新配置文件
typescript
// src/config/configuration.ts (添加新配置)
export default () => ({
// ... 现有配置
// 文件上传配置
upload: {
path: process.env.UPLOAD_PATH || './uploads',
maxSize: parseInt(process.env.UPLOAD_MAX_SIZE) || 10 * 1024 * 1024, // 10MB
allowedImageTypes: ['image/jpeg', 'image/png', 'image/gif', 'image/webp'],
allowedDocumentTypes: ['application/pdf', 'text/plain', 'application/msword'],
},
// 邮件配置
mail: {
host: process.env.MAIL_HOST || 'smtp.gmail.com',
port: parseInt(process.env.MAIL_PORT) || 587,
secure: process.env.MAIL_SECURE === 'true',
user: process.env.MAIL_USER,
password: process.env.MAIL_PASSWORD,
from: process.env.MAIL_FROM || 'noreply@example.com',
},
// CORS配置
cors: {
origin: process.env.CORS_ORIGIN || 'http://localhost:3000',
credentials: process.env.CORS_CREDENTIALS === 'true',
},
});
5. 更新环境变量
bash
# .env (添加新的环境变量)
# 文件上传配置
UPLOAD_PATH=./uploads
UPLOAD_MAX_SIZE=10485760
# 邮件配置
MAIL_HOST=smtp.gmail.com
MAIL_PORT=587
MAIL_SECURE=false
MAIL_USER=your-email@gmail.com
MAIL_PASSWORD=your-app-password
MAIL_FROM=noreply@yourapp.com
# CORS配置
CORS_ORIGIN=http://localhost:3000
CORS_CREDENTIALS=true
6. 更新主模块
typescript
// src/app.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { APP_GUARD, APP_INTERCEPTOR, APP_FILTER } from '@nestjs/core';
import { ServeStaticModule } from '@nestjs/serve-static';
import { join } from 'path';
// 现有导入
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { PrismaModule } from './prisma/prisma.module';
import { UsersModule } from './modules/users/users.module';
import { AuthModule } from './modules/auth/auth.module';
import { ArticlesModule } from './modules/articles/articles.module';
import { CategoriesModule } from './modules/categories/categories.module';
import { TagsModule } from './modules/tags/tags.module';
import { CommentsModule } from './modules/comments/comments.module';
// 新增导入
import { UploadModule } from './modules/upload/upload.module';
import { MailModule } from './modules/mail/mail.module';
import { NotificationModule } from './modules/notification/notification.module';
// 守卫和拦截器
import { JwtAuthGuard } from './modules/auth/guards/jwt-auth.guard';
import { LoggingInterceptor } from './common/interceptors/logging.interceptor';
import { TransformInterceptor } from './common/interceptors/transform.interceptor';
import { AllExceptionsFilter } from './common/filters/all-exceptions.filter';
import configuration from './config/configuration';
@Module({
imports: [
ConfigModule.forRoot({
load: [configuration],
isGlobal: true,
envFilePath: '.env',
}),
// 静态文件服务
ServeStaticModule.forRoot({
rootPath: join(__dirname, '..', 'uploads'),
serveRoot: '/uploads',
}),
PrismaModule,
UsersModule,
AuthModule,
ArticlesModule,
CategoriesModule,
TagsModule,
CommentsModule,
UploadModule,
MailModule,
NotificationModule,
],
controllers: [AppController],
providers: [
AppService,
// 全局守卫
{
provide: APP_GUARD,
useClass: JwtAuthGuard,
},
// 全局拦截器
{
provide: APP_INTERCEPTOR,
useClass: LoggingInterceptor,
},
{
provide: APP_INTERCEPTOR,
useClass: TransformInterceptor,
},
// 全局异常过滤器
{
provide: APP_FILTER,
useClass: AllExceptionsFilter,
},
],
})
export class AppModule {}
🎨 自定义装饰器
1. API分页响应装饰器
typescript
// src/common/decorators/api-paginated-response.decorator.ts
import { applyDecorators, Type } from '@nestjs/common';
import { ApiOkResponse, getSchemaPath } from '@nestjs/swagger';
export const ApiPaginatedResponse = <TModel extends Type<any>>(
model: TModel,
) => {
return applyDecorators(
ApiOkResponse({
schema: {
allOf: [
{
properties: {
data: {
type: 'array',
items: { $ref: getSchemaPath(model) },
},
meta: {
type: 'object',
properties: {
total: { type: 'number' },
page: { type: 'number' },
limit: { type: 'number' },
totalPages: { type: 'number' },
hasNextPage: { type: 'boolean' },
hasPrevPage: { type: 'boolean' },
},
},
},
},
],
},
}),
);
};
2. 数据转换装饰器
typescript
// src/common/decorators/transform.decorator.ts
import { Transform } from 'class-transformer';
// 转换为数字
export const ToNumber = () => Transform(({ value }) => {
const num = Number(value);
return isNaN(num) ? value : num;
});
// 转换为布尔值
export const ToBoolean = () => Transform(({ value }) => {
if (typeof value === 'string') {
return value.toLowerCase() === 'true';
}
return Boolean(value);
});
// 转换为日期
export const ToDate = () => Transform(({ value }) => {
if (typeof value === 'string' || typeof value === 'number') {
const date = new Date(value);
return isNaN(date.getTime()) ? value : date;
}
return value;
});
// 去除字符串两端空格
export const Trim = () => Transform(({ value }) => {
return typeof value === 'string' ? value.trim() : value;
});
// 转换为小写
export const ToLowerCase = () => Transform(({ value }) => {
return typeof value === 'string' ? value.toLowerCase() : value;
});
// 转换为大写
export const ToUpperCase = () => Transform(({ value }) => {
return typeof value === 'string' ? value.toUpperCase() : value;
});
✅ 使用示例
1. 在控制器中使用拦截器
typescript
// 使用缓存拦截器
@Get('popular')
@CacheKey('popular-articles')
@CacheTTL(300000) // 5分钟缓存
async getPopularArticles() {
return this.articlesService.getPopularArticles();
}
// 使用超时拦截器
@Get('slow-operation')
@UseInterceptors(new TimeoutInterceptor(5000)) // 5秒超时
async slowOperation() {
return this.someService.slowOperation();
}
2. 在服务中使用工具函数
typescript
// 使用加密工具
const hashedPassword = await CryptoUtil.hashPassword(password);
const isValid = await CryptoUtil.comparePassword(password, hashedPassword);
// 使用日期工具
const formattedDate = DateUtil.format(new Date(), 'YYYY-MM-DD HH:mm:ss');
const relativeTime = DateUtil.getRelativeTime(article.createdAt);
// 使用分页工具
const { data, meta } = PaginationUtil.createResult(
articles,
total,
page,
limit,
);
3. 发送邮件通知
typescript
// 在用户注册后发送欢迎邮件
await this.mailService.sendWelcomeEmail(user.email, user.username);
// 发送密码重置邮件
await this.mailService.sendPasswordResetEmail(user.email, resetToken);
// 发送系统通知
await this.notificationService.sendSystemNotification(
adminEmails,
'新用户注册',
`用户 ${user.username} 已注册`,
);
🎉 小结
在本章中,我们完成了:
- ✅ 创建了高级拦截器(日志、缓存、转换、超时)
- ✅ 实现了自定义管道和验证器
- ✅ 创建了全局异常过滤器
- ✅ 开发了实用工具函数和装饰器
- ✅ 实现了文件上传和处理功能
- ✅ 创建了邮件服务和通知系统
- ✅ 配置了全局组件和中间件
这些公共组件和工具将大大提高开发效率,并为应用提供统一的错误处理、日志记录、数据转换等功能。
在下一章中,我们将实现API文档和测试,包括Swagger配置、单元测试和集成测试。