From fd8b4ec01b168a941c17e584ca66e2c121b8159c Mon Sep 17 00:00:00 2001 From: mazxd Date: Sun, 11 May 2025 17:55:59 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E6=97=A5=E5=BF=97?= =?UTF-8?q?=E6=89=93=E5=8D=B0=E5=92=8C=E5=BC=82=E5=B8=B8=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .cursor/rules/common-rule.mdc | 10 + backend/package-lock.json | 2 +- backend/package.json | 2 +- backend/src/adapters/BaiduDriveAdapter.ts | 1 + backend/src/controllers/searchController.ts | 4 +- backend/src/decorators/errorHandler.ts | 49 ++++ backend/src/lib/logger.ts | 67 +++++ backend/src/lib/prisma.ts | 55 ++++ backend/src/middleware/activityLogger.ts | 48 ++++ backend/src/routes/searchRoutes.ts | 63 +++-- backend/src/services/activityLogService.ts | 66 +++++ backend/src/services/searchService.ts | 187 +++++++------ backend/src/utils/AppError.d.ts | 7 + backend/src/utils/AppError.ts | 14 + backend/tsconfig.json | 4 +- package-lock.json | 287 +++++++++++++++++++- package.json | 3 +- src/app.ts | 21 ++ src/decorators/errorHandler.ts | 48 ++++ src/middleware/errorHandler.ts | 106 ++++++++ src/utils/AppError.ts | 14 + 21 files changed, 939 insertions(+), 119 deletions(-) create mode 100644 .cursor/rules/common-rule.mdc create mode 100644 backend/src/decorators/errorHandler.ts create mode 100644 backend/src/lib/logger.ts create mode 100644 backend/src/lib/prisma.ts create mode 100644 backend/src/middleware/activityLogger.ts create mode 100644 backend/src/services/activityLogService.ts create mode 100644 backend/src/utils/AppError.d.ts create mode 100644 backend/src/utils/AppError.ts create mode 100644 src/app.ts create mode 100644 src/decorators/errorHandler.ts create mode 100644 src/middleware/errorHandler.ts create mode 100644 src/utils/AppError.ts diff --git a/.cursor/rules/common-rule.mdc b/.cursor/rules/common-rule.mdc new file mode 100644 index 0000000..2729d9a --- /dev/null +++ b/.cursor/rules/common-rule.mdc @@ -0,0 +1,10 @@ +--- +description: +globs: +alwaysApply: true +--- +# 通用规则 +1. 如果需要使用npm安装任何包,请使用pnpm来代替 +2. Windows环境下请注意Powershell 不支持 && 连续命令,请自行分开命令 + +3. 写业务类的时候自动使用logger打印日志,并且日志用中文显示。 \ No newline at end of file diff --git a/backend/package-lock.json b/backend/package-lock.json index b250dfa..4b313e0 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -24,7 +24,7 @@ "redis": "^5.0.1", "swagger-jsdoc": "^6.2.8", "swagger-ui-express": "^5.0.1", - "winston": "^3.11.0" + "winston": "^3.17.0" }, "devDependencies": { "@types/cors": "^2.8.0", diff --git a/backend/package.json b/backend/package.json index 70c32e6..547849a 100644 --- a/backend/package.json +++ b/backend/package.json @@ -28,7 +28,7 @@ "redis": "^5.0.1", "swagger-jsdoc": "^6.2.8", "swagger-ui-express": "^5.0.1", - "winston": "^3.11.0" + "winston": "^3.17.0" }, "devDependencies": { "@types/cors": "^2.8.0", diff --git a/backend/src/adapters/BaiduDriveAdapter.ts b/backend/src/adapters/BaiduDriveAdapter.ts index 45a2488..9402231 100644 --- a/backend/src/adapters/BaiduDriveAdapter.ts +++ b/backend/src/adapters/BaiduDriveAdapter.ts @@ -9,6 +9,7 @@ export class BaiduDriveAdapter implements DriveAdapter { account: Account ): Promise { try { + logger.info('Baidu drive transferAndShare', { sourceLink, sourceLinkType, accountId: account.id }); // TODO: 实现百度网盘的具体转存和分享逻辑 // 1. 解析账号凭证 const credentials = JSON.parse(account.credentials); diff --git a/backend/src/controllers/searchController.ts b/backend/src/controllers/searchController.ts index 94f4f42..6b0fe1c 100644 --- a/backend/src/controllers/searchController.ts +++ b/backend/src/controllers/searchController.ts @@ -86,7 +86,7 @@ export async function getSearchResults(req: Request, res: Response) { // 转换参数类型 const searchParams = { keyword: keyword as string, - types: Array.isArray(types) ? types : types ? [types as string] : [], + types: Array.isArray(types) ? types.map(t => String(t)) : types ? [String(types)] : [], page: Number(page), fileType: fileType as string, fileSize: fileSize as string, @@ -94,6 +94,7 @@ export async function getSearchResults(req: Request, res: Response) { mode: mode as 'standard' | 'fuzzy' }; + const clientIp = req.ip || req.socket.remoteAddress; const result = await search(searchParams); res.json(result); } catch (error) { @@ -158,6 +159,7 @@ export async function getLinkForResource(req: Request, res: Response) { }); } + const clientIp = req.ip || req.socket.remoteAddress; const result = await generateLink(resourceId); res.json(result); } catch (error) { diff --git a/backend/src/decorators/errorHandler.ts b/backend/src/decorators/errorHandler.ts new file mode 100644 index 0000000..1c548c7 --- /dev/null +++ b/backend/src/decorators/errorHandler.ts @@ -0,0 +1,49 @@ +import { AppError } from '../utils/AppError'; +import logger from '../lib/logger'; +import { AxiosError } from 'axios'; + +export function handleError(errorMessage?: string) { + return function ( + target: any, + propertyKey: string, + descriptor: PropertyDescriptor + ) { + const originalMethod = descriptor.value; + + descriptor.value = async function (...args: any[]) { + try { + return await originalMethod.apply(this, args); + } catch (error: unknown) { + // 记录错误日志 + logger.error(`${errorMessage || '操作执行失败'}:`, { + method: propertyKey, + args, + error: error instanceof Error ? error.message : '未知错误', + stack: error instanceof Error ? error.stack : undefined + }); + + // 如果是 AppError,直接抛出 + if (error instanceof AppError) { + throw error; + } + + // 如果是 Axios 错误,转换为 AppError + if (error instanceof AxiosError) { + const statusCode = error.response?.status || 500; + throw new AppError( + error.response?.data?.message || '请求外部服务失败', + statusCode + ); + } + + // 其他错误转换为 AppError + throw new AppError( + error instanceof Error ? error.message : '操作执行失败', + 500 + ); + } + }; + + return descriptor; + }; +} \ No newline at end of file diff --git a/backend/src/lib/logger.ts b/backend/src/lib/logger.ts new file mode 100644 index 0000000..c328de5 --- /dev/null +++ b/backend/src/lib/logger.ts @@ -0,0 +1,67 @@ +import winston from 'winston'; +import path from 'path'; + +// 定义日志级别 +const levels = { + error: 0, + warn: 1, + info: 2, + http: 3, + debug: 4, +}; + +// 根据环境选择日志级别 +const level = () => { + const env = process.env.NODE_ENV || 'development'; + const isDevelopment = env === 'development'; + return isDevelopment ? 'debug' : 'warn'; +}; + +// 定义日志颜色 +const colors = { + error: 'red', + warn: 'yellow', + info: 'green', + http: 'magenta', + debug: 'white', +}; + +// 添加颜色 +winston.addColors(colors); + +// 定义日志格式 +const format = winston.format.combine( + // 添加时间戳 + winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss:ms' }), + // 添加颜色 + winston.format.colorize({ all: true }), + // 定义日志打印格式 + winston.format.printf( + (info) => `${info.timestamp} ${info.level}: ${info.message}`, + ), +); + +// 定义日志输出目标 +const transports = [ + // 控制台输出 + new winston.transports.Console(), + // 错误日志文件 + new winston.transports.File({ + filename: path.join('logs', 'error.log'), + level: 'error', + }), + // 所有日志文件 + new winston.transports.File({ + filename: path.join('logs', 'all.log') + }), +]; + +// 创建日志记录器 +const logger = winston.createLogger({ + level: level(), + levels, + format, + transports, +}); + +export default logger; \ No newline at end of file diff --git a/backend/src/lib/prisma.ts b/backend/src/lib/prisma.ts new file mode 100644 index 0000000..9a4a85f --- /dev/null +++ b/backend/src/lib/prisma.ts @@ -0,0 +1,55 @@ +import { PrismaClient, Prisma } from '@prisma/client'; +import logger from './logger'; + +declare global { + var prisma: PrismaClient | undefined; +} + +export const prisma = global.prisma || new PrismaClient({ + log: [ + { + emit: 'event', + level: 'query', + }, + { + emit: 'event', + level: 'error', + }, + { + emit: 'event', + level: 'info', + }, + { + emit: 'event', + level: 'warn', + }, + ], +}); + +// 监听 SQL 查询 +(prisma as any).$on('query', (e: Prisma.QueryEvent) => { + logger.debug('SQL Query', { + query: e.query, + params: e.params, + duration: e.duration, + }); +}); + +// 监听错误 +(prisma as any).$on('error', (e: Prisma.LogEvent) => { + logger.error('Prisma Error', { error: e }); +}); + +// 监听信息 +(prisma as any).$on('info', (e: Prisma.LogEvent) => { + logger.info('Prisma Info', { message: e }); +}); + +// 监听警告 +(prisma as any).$on('warn', (e: Prisma.LogEvent) => { + logger.warn('Prisma Warning', { message: e }); +}); + +if (process.env.NODE_ENV !== 'production') { + global.prisma = prisma; +} \ No newline at end of file diff --git a/backend/src/middleware/activityLogger.ts b/backend/src/middleware/activityLogger.ts new file mode 100644 index 0000000..a4d93e4 --- /dev/null +++ b/backend/src/middleware/activityLogger.ts @@ -0,0 +1,48 @@ +import { Request, Response, NextFunction } from 'express'; +import { ActivityLogService } from '../services/activityLogService'; + +export interface LoggedRequest extends Request { + startTime?: number; +} + +export function activityLogger(operationType: string) { + return async (req: LoggedRequest, res: Response, next: NextFunction) => { + // 记录开始时间 + req.startTime = Date.now(); + + // 保存原始的 res.json 方法 + const originalJson = res.json; + + // 重写 res.json 方法 + res.json = function (body: any) { + // 计算请求持续时间 + const durationMs = req.startTime ? Date.now() - req.startTime : undefined; + + // 异步记录日志,不等待完成 + ActivityLogService.logActivity({ + operationType, + clientIp: req.ip || req.socket.remoteAddress, + keyword: req.query.keyword as string, + itemId: req.params.resourceId, + details: JSON.stringify({ + method: req.method, + path: req.path, + query: req.query, + params: req.params, + statusCode: res.statusCode, + responseTime: durationMs + }), + durationMs, + errorCode: res.statusCode >= 400 ? `HTTP_${res.statusCode}` : undefined, + errorMessage: res.statusCode >= 400 ? body.message : undefined + }).catch(error => { + console.error('Failed to log activity:', error); + }); + + // 调用原始的 json 方法 + return originalJson.call(this, body); + }; + + next(); + }; +} \ No newline at end of file diff --git a/backend/src/routes/searchRoutes.ts b/backend/src/routes/searchRoutes.ts index 0cbd936..a2f15dd 100644 --- a/backend/src/routes/searchRoutes.ts +++ b/backend/src/routes/searchRoutes.ts @@ -1,40 +1,53 @@ import { Router } from 'express'; import { SearchService } from '../services/searchService'; +import { activityLogger } from '../middleware/activityLogger'; const router = Router(); const searchService = new SearchService(); -router.get('/search', async (req, res) => { - try { - const { keyword } = req.query; +router.get('/search', + activityLogger('SEARCH'), + async (req, res) => { + try { + const { keyword } = req.query; - if (!keyword || typeof keyword !== 'string') { - return res.status(400).json({ - success: false, - code: 400, - message: 'Keyword is required and must be a string', - }); - } + if (!keyword || typeof keyword !== 'string') { + return res.status(400).json({ + success: false, + code: 400, + message: 'Keyword is required and must be a string', + }); + } - const result = await searchService.search(keyword); - - if (!result) { - return res.status(500).json({ + const result = await searchService.search(keyword); + res.json(result); + } catch (error) { + console.error('Search route error:', error); + res.status(500).json({ success: false, code: 500, - message: 'Search service error', + message: 'Internal server error', }); } - - return res.json(result); - } catch (error) { - console.error('Search route error:', error); - return res.status(500).json({ - success: false, - code: 500, - message: 'Internal server error', - }); } -}); +); + +router.get('/link/:resourceId', + activityLogger('GENERATE_LINK'), + async (req, res) => { + try { + const { resourceId } = req.params; + const result = await searchService.generateLink(resourceId); + res.json(result); + } catch (error) { + console.error('Generate link route error:', error); + res.status(500).json({ + success: false, + code: 500, + message: 'Failed to generate link', + }); + } + } +); export default router; \ No newline at end of file diff --git a/backend/src/services/activityLogService.ts b/backend/src/services/activityLogService.ts new file mode 100644 index 0000000..4e49b2a --- /dev/null +++ b/backend/src/services/activityLogService.ts @@ -0,0 +1,66 @@ +import { prisma } from '../lib/prisma'; +import { Prisma } from '@prisma/client'; + +// 监听 SQL 查询 +(prisma as any).$on('query', (e: Prisma.QueryEvent) => { + console.log('\n=== SQL Query ==='); + console.log('Query:', e.query); + console.log('Params:', e.params); + console.log('Duration:', e.duration, 'ms'); + console.log('================\n'); +}); + +// 监听错误 +(prisma as any).$on('error', (e: Error) => { + console.error('\n=== Prisma Error ==='); + console.error(e); + console.error('===================\n'); +}); + +export class ActivityLogService { + static async logActivity({ + operationType, + clientIp, + keyword, + itemId, + driveType, + managedAccountId, + generatedLink, + errorCode, + errorMessage, + durationMs, + details + }: { + operationType: string; + clientIp?: string; + keyword?: string; + itemId?: string; + driveType?: string; + managedAccountId?: number; + generatedLink?: string; + errorCode?: string; + errorMessage?: string; + durationMs?: number; + details?: string; + }) { + try { + await prisma.activityLog.create({ + data: { + operationType, + clientIp, + keyword, + itemId, + driveType, + managedAccountId, + generatedLink, + errorCode, + errorMessage, + durationMs, + details + } + }); + } catch (error) { + console.error('Failed to log activity:', error); + } + } +} \ No newline at end of file diff --git a/backend/src/services/searchService.ts b/backend/src/services/searchService.ts index d48bcbd..1ddae5d 100644 --- a/backend/src/services/searchService.ts +++ b/backend/src/services/searchService.ts @@ -1,21 +1,24 @@ import axios from 'axios'; import { config } from '../config/config'; import { createClient } from 'redis'; +import logger from '../lib/logger'; +import { handleError } from '../decorators/errorHandler'; +import { AppError } from '../utils/AppError'; const redis = createClient({ url: `redis://${config.redis.host}:${config.redis.port}`, password: config.redis.password }); -// Redis error handling and connection monitoring -redis.on('error', err => console.error('Redis Client Error:', err)); -redis.on('connect', () => console.log('Redis Client Connected')); -redis.on('ready', () => console.log('Redis Client Ready')); -redis.on('reconnecting', () => console.log('Redis Client Reconnecting')); +// Redis 错误处理和连接监控 +redis.on('error', err => logger.error('Redis 客户端错误:', { error: err })); +redis.on('connect', () => logger.info('Redis 客户端已连接')); +redis.on('ready', () => logger.info('Redis 客户端就绪')); +redis.on('reconnecting', () => logger.warn('Redis 客户端正在重连')); -// Initialize Redis connection +// 初始化 Redis 连接 redis.connect().catch(err => { - console.error('Redis Connection Error:', err); + logger.error('Redis 连接错误:', { error: err }); }); const TOKEN_KEY = 'search_service_token'; @@ -89,98 +92,106 @@ export class SearchService { constructor() { this.baseUrl = config.searchService.baseUrl; + logger.info('搜索服务已初始化', { baseUrl: this.baseUrl }); } - private async getToken(): Promise { - try { - // Try to get token from Redis - const cachedToken = await redis.get(TOKEN_KEY); - if (cachedToken) { - return cachedToken; - } - - // Login to get new token - const response = await axios.post( - `${this.baseUrl}${config.searchService.endpoints.login}`, - { - username: process.env.SEARCH_SERVICE_USERNAME, - password: process.env.SEARCH_SERVICE_PASSWORD, - } - ); - - if (response.data.success && response.data.data.token) { - // Cache token in Redis - await redis.set(TOKEN_KEY, response.data.data.token, { - EX: 21600, // 6 hours - }); - return response.data.data.token; - } - } catch (error) { - console.error('Error getting token:', error); + @handleError('获取认证 token 失败') + private async getToken(): Promise { + // 尝试从 Redis 获取 token + const cachedToken = await redis.get(TOKEN_KEY); + if (cachedToken) { + logger.debug('从缓存中获取到 token'); + return cachedToken; } - return null; - } - - public async search(keyword: string): Promise { - try { - const token = await this.getToken(); - if (!token) { - throw new Error('Failed to get authentication token'); + logger.info('缓存中未找到 token,尝试登录获取'); + // 登录获取新 token + const response = await axios.post( + `${this.baseUrl}${config.searchService.endpoints.login}`, + { + username: process.env.SEARCH_SERVICE_USERNAME, + password: process.env.SEARCH_SERVICE_PASSWORD, } + ); - const response = await axios.get( - `${this.baseUrl}${config.searchService.endpoints.search}`, - { - params: { keyword }, - headers: { - Authorization: `Bearer ${token}`, - }, - } - ); - - return response.data; - } catch (error) { - console.error('Error performing search:', error); - if (axios.isAxiosError(error) && error.response?.status === 401) { - // Token expired, clear it from Redis - await redis.del(TOKEN_KEY); - } - return null; + if (!response.data.success || !response.data.data.token) { + throw new AppError('登录失败', 401); } + + logger.info('成功获取新 token'); + // 将 token 缓存到 Redis + await redis.set(TOKEN_KEY, response.data.data.token, { + EX: 21600, // 6 小时 + }); + return response.data.data.token; } - public async searchByParams(params: SearchParams): Promise { - try { - // TODO: 实现实际的搜索逻辑 - return { - success: true, - code: 0, - data: [], - message: '搜索成功' - }; - } catch (error) { - console.error('Search service error:', error); - throw error; - } - } + @handleError('搜索执行失败') + public async search(keyword: string): Promise { + logger.info('开始执行搜索', { keyword }); + const token = await this.getToken(); - public async generateLink(resourceId: string): Promise { - try { - // TODO: 实现实际的链接生成逻辑 - return { - success: true, - code: 0, - data: { - url: `https://example.com/download/${resourceId}`, - expireTime: new Date(Date.now() + 30 * 60 * 1000).toISOString() // 30分钟后过期 + const response = await axios.get( + `${this.baseUrl}${config.searchService.endpoints.search}`, + { + params: { keyword }, + headers: { + Authorization: `Bearer ${token}`, }, - message: '链接生成成功' - }; - } catch (error) { - console.error('Generate link service error:', error); - throw error; - } + } + ); + + logger.info('搜索完成', { + keyword, + resultCount: response.data.data?.length || 0 + }); + return response.data; + } + + @handleError('参数化搜索失败') + public async searchByParams(params: SearchParams): Promise { + logger.info('开始执行参数化搜索', { + keyword: params.keyword, + types: params.types, + page: params.page, + mode: params.mode + }); + + // TODO: 实现实际的搜索逻辑 + const result = { + success: true, + code: 0, + data: [], + message: '搜索成功' + }; + + logger.info('参数化搜索完成', { + keyword: params.keyword, + resultCount: result.data.length + }); + return result; + } + + @handleError('生成资源链接失败') + public async generateLink(resourceId: string): Promise { + logger.info('开始生成资源链接', { resourceId }); + + // TODO: 实现实际的链接生成逻辑 + const result = { + success: true, + code: 0, + data: { + url: `https://example.com/download/${resourceId}`, + expireTime: new Date(Date.now() + 30 * 60 * 1000).toISOString() // 30分钟后过期 + }, + message: '链接生成成功' + }; + + logger.info('资源链接生成成功', { + resourceId, + expireTime: result.data.expireTime + }); + return result; } } diff --git a/backend/src/utils/AppError.d.ts b/backend/src/utils/AppError.d.ts new file mode 100644 index 0000000..53429ac --- /dev/null +++ b/backend/src/utils/AppError.d.ts @@ -0,0 +1,7 @@ +export class AppError extends Error { + statusCode: number; + status: string; + isOperational: boolean; + + constructor(message: string, statusCode: number); +} \ No newline at end of file diff --git a/backend/src/utils/AppError.ts b/backend/src/utils/AppError.ts new file mode 100644 index 0000000..c16250d --- /dev/null +++ b/backend/src/utils/AppError.ts @@ -0,0 +1,14 @@ +export class AppError extends Error { + statusCode: number; + status: string; + isOperational: boolean; + + constructor(message: string, statusCode: number) { + super(message); + this.statusCode = statusCode; + this.status = `${statusCode}`.startsWith('4') ? 'fail' : 'error'; + this.isOperational = true; + + Error.captureStackTrace(this, this.constructor); + } +} \ No newline at end of file diff --git a/backend/tsconfig.json b/backend/tsconfig.json index 780ae83..620ba88 100644 --- a/backend/tsconfig.json +++ b/backend/tsconfig.json @@ -6,7 +6,9 @@ "rootDir": "./src", "strict": true, "esModuleInterop": true, - "skipLibCheck": true + "skipLibCheck": true, + "experimentalDecorators": true, + "emitDecoratorMetadata": true }, "include": ["src/**/*.ts"], "exclude": ["node_modules"] diff --git a/package-lock.json b/package-lock.json index 1924116..7881c19 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,13 +9,23 @@ "@types/redis": "^4.0.11", "axios": "^1.9.0", "dotenv": "^16.5.0", - "redis": "^5.0.1" + "redis": "^5.0.1", + "winston": "^3.17.0" }, "devDependencies": { "ts-node": "^10.9.2", "typescript": "^5.8.3" } }, + "node_modules/@colors/colors": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz", + "integrity": "sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==", + "license": "MIT", + "engines": { + "node": ">=0.1.90" + } + }, "node_modules/@cspotcode/source-map-support": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", @@ -29,6 +39,17 @@ "node": ">=12" } }, + "node_modules/@dabh/diagnostics": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@dabh/diagnostics/-/diagnostics-2.0.3.tgz", + "integrity": "sha512-hrlQOIi7hAfzsMqlGSFyVucrx38O+j6wiGOf//H2ecvIEqYN4ADBSS2iLMh5UFyDunCNniUIPk/q3riFv45xRA==", + "license": "MIT", + "dependencies": { + "colorspace": "1.1.x", + "enabled": "2.0.x", + "kuler": "^2.0.0" + } + }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", @@ -169,6 +190,12 @@ "redis": "*" } }, + "node_modules/@types/triple-beam": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/triple-beam/-/triple-beam-1.3.5.tgz", + "integrity": "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==", + "license": "MIT" + }, "node_modules/acorn": { "version": "8.14.1", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", @@ -202,6 +229,12 @@ "dev": true, "license": "MIT" }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "license": "MIT" + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -237,6 +270,51 @@ "node": ">=0.10.0" } }, + "node_modules/color": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/color/-/color-3.2.1.tgz", + "integrity": "sha512-aBl7dZI9ENN6fUGC7mWpMTPNHmWUSNan9tuWN6ahh5ZLNk9baLJOnSMlrQkHcrfFgz2/RigjUVAjdx36VcemKA==", + "license": "MIT", + "dependencies": { + "color-convert": "^1.9.3", + "color-string": "^1.6.0" + } + }, + "node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "license": "MIT", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "license": "MIT" + }, + "node_modules/color-string": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "license": "MIT", + "dependencies": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, + "node_modules/colorspace": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/colorspace/-/colorspace-1.1.4.tgz", + "integrity": "sha512-BgvKJiuVu1igBUF2kEjRCZXol6wiiGbY5ipL/oVPwm0BL9sIpMIzM8IK7vwuxIIzOXMV3Ey5w+vxhm0rR/TN8w==", + "license": "MIT", + "dependencies": { + "color": "^3.1.3", + "text-hex": "1.0.x" + } + }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -297,6 +375,12 @@ "node": ">= 0.4" } }, + "node_modules/enabled": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/enabled/-/enabled-2.0.0.tgz", + "integrity": "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==", + "license": "MIT" + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -338,6 +422,18 @@ "node": ">= 0.4" } }, + "node_modules/fecha": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/fecha/-/fecha-4.2.3.tgz", + "integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==", + "license": "MIT" + }, + "node_modules/fn.name": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fn.name/-/fn.name-1.1.0.tgz", + "integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==", + "license": "MIT" + }, "node_modules/follow-redirects": { "version": "1.15.9", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", @@ -461,6 +557,53 @@ "node": ">= 0.4" } }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/is-arrayish": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz", + "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==", + "license": "MIT" + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/kuler": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz", + "integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==", + "license": "MIT" + }, + "node_modules/logform": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/logform/-/logform-2.7.0.tgz", + "integrity": "sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ==", + "license": "MIT", + "dependencies": { + "@colors/colors": "1.6.0", + "@types/triple-beam": "^1.3.2", + "fecha": "^4.2.0", + "ms": "^2.1.1", + "safe-stable-stringify": "^2.3.1", + "triple-beam": "^1.3.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, "node_modules/make-error": { "version": "1.3.6", "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", @@ -495,11 +638,40 @@ "node": ">= 0.6" } }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/one-time": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/one-time/-/one-time-1.0.0.tgz", + "integrity": "sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==", + "license": "MIT", + "dependencies": { + "fn.name": "1.x.x" + } + }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/redis": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/redis/-/redis-5.0.1.tgz", @@ -515,6 +687,77 @@ "node": ">= 18" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/simple-swizzle": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", + "integrity": "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.3.1" + } + }, + "node_modules/stack-trace": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz", + "integrity": "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/text-hex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz", + "integrity": "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==", + "license": "MIT" + }, + "node_modules/triple-beam": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/triple-beam/-/triple-beam-1.4.1.tgz", + "integrity": "sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==", + "license": "MIT", + "engines": { + "node": ">= 14.0.0" + } + }, "node_modules/ts-node": { "version": "10.9.2", "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", @@ -581,6 +824,12 @@ "license": "MIT", "peer": true }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, "node_modules/v8-compile-cache-lib": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", @@ -588,6 +837,42 @@ "dev": true, "license": "MIT" }, + "node_modules/winston": { + "version": "3.17.0", + "resolved": "https://registry.npmjs.org/winston/-/winston-3.17.0.tgz", + "integrity": "sha512-DLiFIXYC5fMPxaRg832S6F5mJYvePtmO5G9v9IgUFPhXm9/GkXarH/TUrBAVzhTCzAj9anE/+GjrgXp/54nOgw==", + "license": "MIT", + "dependencies": { + "@colors/colors": "^1.6.0", + "@dabh/diagnostics": "^2.0.2", + "async": "^3.2.3", + "is-stream": "^2.0.0", + "logform": "^2.7.0", + "one-time": "^1.0.0", + "readable-stream": "^3.4.0", + "safe-stable-stringify": "^2.3.1", + "stack-trace": "0.0.x", + "triple-beam": "^1.3.0", + "winston-transport": "^4.9.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/winston-transport": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/winston-transport/-/winston-transport-4.9.0.tgz", + "integrity": "sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A==", + "license": "MIT", + "dependencies": { + "logform": "^2.7.0", + "readable-stream": "^3.6.2", + "triple-beam": "^1.3.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, "node_modules/yn": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", diff --git a/package.json b/package.json index e11aa79..f1d82fd 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,8 @@ "@types/redis": "^4.0.11", "axios": "^1.9.0", "dotenv": "^16.5.0", - "redis": "^5.0.1" + "redis": "^5.0.1", + "winston": "^3.17.0" }, "devDependencies": { "ts-node": "^10.9.2", diff --git a/src/app.ts b/src/app.ts new file mode 100644 index 0000000..65f41c0 --- /dev/null +++ b/src/app.ts @@ -0,0 +1,21 @@ +import express from 'express'; +import { errorHandler } from './middleware/errorHandler'; +import { AppError } from './utils/AppError'; + +const app = express(); + +// 中间件 +app.use(express.json()); + +// 路由 +// ... 你的路由配置 ... + +// 处理未找到的路由 +app.all('*', (req, res, next) => { + next(new AppError(`Can't find ${req.originalUrl} on this server!`, 404)); +}); + +// 错误处理中间件 (必须放在所有路由之后) +app.use(errorHandler); + +export default app; \ No newline at end of file diff --git a/src/decorators/errorHandler.ts b/src/decorators/errorHandler.ts new file mode 100644 index 0000000..ef7c5de --- /dev/null +++ b/src/decorators/errorHandler.ts @@ -0,0 +1,48 @@ +import { AppError } from '../utils/AppError'; +import logger from '../lib/logger'; + +export function handleError(errorMessage?: string) { + return function ( + target: any, + propertyKey: string, + descriptor: PropertyDescriptor + ) { + const originalMethod = descriptor.value; + + descriptor.value = async function (...args: any[]) { + try { + return await originalMethod.apply(this, args); + } catch (error) { + // 记录错误日志 + logger.error(`${errorMessage || '操作执行失败'}:`, { + method: propertyKey, + args, + error: error instanceof Error ? error.message : '未知错误', + stack: error instanceof Error ? error.stack : undefined + }); + + // 如果是 AppError,直接抛出 + if (error instanceof AppError) { + throw error; + } + + // 如果是 Axios 错误,转换为 AppError + if (error.isAxiosError) { + const statusCode = error.response?.status || 500; + throw new AppError( + error.response?.data?.message || '请求外部服务失败', + statusCode + ); + } + + // 其他错误转换为 AppError + throw new AppError( + error instanceof Error ? error.message : '操作执行失败', + 500 + ); + } + }; + + return descriptor; + }; +} \ No newline at end of file diff --git a/src/middleware/errorHandler.ts b/src/middleware/errorHandler.ts new file mode 100644 index 0000000..cb54388 --- /dev/null +++ b/src/middleware/errorHandler.ts @@ -0,0 +1,106 @@ +import { Request, Response, NextFunction } from 'express'; +import { AppError } from '../utils/AppError'; +import winston from 'winston'; + +// 创建日志记录器 +const logger = winston.createLogger({ + level: 'error', + format: winston.format.combine( + winston.format.timestamp(), + winston.format.json() + ), + transports: [ + new winston.transports.File({ filename: 'error.log' }), + new winston.transports.Console({ + format: winston.format.combine( + winston.format.colorize(), + winston.format.simple() + ) + }) + ] +}); + +// 开发环境错误处理 +const sendErrorDev = (err: any, res: Response) => { + res.status(err.statusCode).json({ + status: err.status, + error: err, + message: err.message, + stack: err.stack + }); +}; + +// 生产环境错误处理 +const sendErrorProd = (err: any, res: Response) => { + // 可操作的错误:发送详细信息给客户端 + if (err.isOperational) { + res.status(err.statusCode).json({ + status: err.status, + message: err.message + }); + } + // 编程或其他未知错误:不泄露错误详情 + else { + // 记录错误 + logger.error('ERROR 💥', err); + + // 发送通用错误消息 + res.status(500).json({ + status: 'error', + message: 'Something went wrong!' + }); + } +}; + +// 处理特定类型的错误 +const handleCastErrorDB = (err: any) => { + const message = `Invalid ${err.path}: ${err.value}.`; + return new AppError(message, 400); +}; + +const handleDuplicateFieldsDB = (err: any) => { + const value = err.errmsg.match(/(["'])(\\?.)*?\1/)[0]; + const message = `Duplicate field value: ${value}. Please use another value!`; + return new AppError(message, 400); +}; + +const handleValidationErrorDB = (err: any) => { + const errors = Object.values(err.errors).map((el: any) => el.message); + const message = `Invalid input data. ${errors.join('. ')}`; + return new AppError(message, 400); +}; + +// 全局错误处理中间件 +export const errorHandler = ( + err: any, + req: Request, + res: Response, + next: NextFunction +) => { + err.statusCode = err.statusCode || 500; + err.status = err.status || 'error'; + + // 记录错误日志 + logger.error({ + message: err.message, + stack: err.stack, + path: req.path, + method: req.method, + body: req.body, + params: req.params, + query: req.query + }); + + if (process.env.NODE_ENV === 'development') { + sendErrorDev(err, res); + } else { + let error = { ...err }; + error.message = err.message; + + if (error.name === 'CastError') error = handleCastErrorDB(error); + if (error.code === 11000) error = handleDuplicateFieldsDB(error); + if (error.name === 'ValidationError') error = handleValidationErrorDB(error); + + sendErrorProd(error, res); + } +}; \ No newline at end of file diff --git a/src/utils/AppError.ts b/src/utils/AppError.ts new file mode 100644 index 0000000..c16250d --- /dev/null +++ b/src/utils/AppError.ts @@ -0,0 +1,14 @@ +export class AppError extends Error { + statusCode: number; + status: string; + isOperational: boolean; + + constructor(message: string, statusCode: number) { + super(message); + this.statusCode = statusCode; + this.status = `${statusCode}`.startsWith('4') ? 'fail' : 'error'; + this.isOperational = true; + + Error.captureStackTrace(this, this.constructor); + } +} \ No newline at end of file