const logger = require('../utils/logger'); const db = require('../db'); /** * 根据请求方法和 URL 获取操作说明 */ const getActionDescription = (method, url) => { // 移除查询参数 const path = url.split('?')[0]; // --- Auth --- if (path === '/api/auth/login' && method === 'POST') return '用户登录'; if (path === '/api/auth/register' && method === 'POST') return '用户注册'; if (path === '/api/auth/send-code' && method === 'POST') return '发送验证码'; if (path === '/api/auth/reset-password' && method === 'POST') return '重置密码'; // --- Achievements --- if (path === '/api/achievements' && method === 'POST') return '上传成果'; if (path === '/api/achievements' && method === 'GET') { if (url.includes('export_action=true')) return '导出用户成果数据'; return '查看成果列表'; } if (path === '/api/achievements/export/csv' && method === 'GET') return '导出成果(CSV)'; if (path === '/api/achievements/export/zip' && method === 'GET') return '导出成果(ZIP)'; if (path === '/api/achievements/validate' && method === 'GET') return '数据校验'; // 动态路径匹配 if (path.match(/^\/api\/achievements\/audit\/\d+$/) && method === 'POST') return '审核成果'; if (path.match(/^\/api\/achievements\/\d+\/attachments\/zip$/) && method === 'GET') return '下载成果附件包'; if (path.match(/^\/api\/achievements\/attachments\/\d+\/download$/) && method === 'GET') return '下载单个附件'; if (path.match(/^\/api\/achievements\/\d+$/)) { if (method === 'GET') return '查看成果详情'; if (method === 'PUT') return '修改成果'; if (method === 'DELETE') return '删除成果'; } // --- Users --- if (path === '/api/users' && method === 'POST') return '创建用户'; if (path === '/api/users' && method === 'GET') return '查看用户列表'; if (path === '/api/users/search' && method === 'GET') return '搜索用户'; if (path === '/api/users/pending-list' && method === 'GET') return '查看待定用户'; if (path.match(/^\/api\/users\/\d+$/)) { if (method === 'PUT') return '修改用户'; if (method === 'DELETE') return '删除用户'; } // --- Dictionaries --- if (path === '/api/dictionaries/departments' && method === 'GET') return '查看部门列表(公开)'; if (path.match(/^\/api\/dictionaries\/[^/]+$/)) { if (method === 'GET') return '查看字典列表'; if (method === 'POST') return '添加字典项'; } if (path.match(/^\/api\/dictionaries\/[^/]+\/\d+$/)) { if (method === 'PUT') return '修改字典项'; if (method === 'DELETE') return '删除字典项'; } // --- Notifications --- if (path === '/api/notifications' && method === 'GET') return '查看通知列表'; if (path === '/api/notifications' && method === 'POST') return '发布通知'; if (path.match(/^\/api\/notifications\/attachments\/\d+\/download$/) && method === 'GET') return '下载通知附件'; if (path.match(/^\/api\/notifications\/\d+$/)) { if (method === 'GET') return '查看通知详情'; if (method === 'DELETE') return '删除通知'; } // --- Statistics --- if (path === '/api/statistics' && method === 'GET') return '查看统计数据'; // --- Logs --- if (path === '/api/logs' && method === 'GET') return '查看系统日志'; // 默认 return null; }; /** * 全局审计日志中间件 * 记录所有 API 请求的操作人、IP、方法、URL 和 结果状态 */ const auditLogger = (req, res, next) => { // 记录请求开始时间 const start = Date.now(); // 监听请求完成事件 res.on('finish', async () => { const duration = Date.now() - start; const clientIp = req.headers['x-forwarded-for'] || req.socket.remoteAddress; const method = req.method; const url = req.originalUrl || req.url; const status = res.statusCode; // 忽略非 API 请求 (虽然通常挂载在 /api 下,但双重保险) if (!url.startsWith('/api/')) return; // 忽略 OPTIONS 预检请求,避免日志刷屏 if (method === 'OPTIONS') return; // URL 脱敏处理:隐藏 token, password, code 等敏感参数 let safeUrl = url; try { safeUrl = safeUrl.replace(/([?&])(token|password|code)=([^&]+)/gi, '$1$2=***'); } catch (e) { // 忽略正则错误,保持原样 } let userInfo = 'Guest'; let userId = null; let username = null; let realName = null; // 1. 尝试获取已登录用户信息 // 注意:req.user 由 verifyToken 中间件挂载。 // 由于我们在 res 'finish' 事件中读取,此时路由处理已完成,req.user 应该已存在(如果通过了认证)。 if (req.user) { userInfo = `${req.user.real_name || req.user.username} (ID:${req.user.id})`; userId = req.user.id; username = req.user.username; realName = req.user.real_name; } // 2. 对于未登录的关键接口,尝试从 body 中提取身份信息 else if (req.body) { if (url.includes('/login')) { userInfo = `[尝试登录: ${req.body.username || '未知'}]`; username = req.body.username; } else if (url.includes('/register')) { userInfo = `[尝试注册: ${req.body.username || '未知'}]`; username = req.body.username; } else if (url.includes('/send-code')) { userInfo = `[请求验证码: ${req.body.phoneNumber || '未知'}]`; username = req.body.phoneNumber; } else if (url.includes('/reset-password')) { userInfo = `[重置密码: ${req.body.phoneNumber || '未知'}]`; username = req.body.phoneNumber; } } // 获取操作说明 const description = getActionDescription(method, url); // 构建日志消息 const logMessage = `[AUDIT] IP:${clientIp} | User:${userInfo} | ${method} ${safeUrl} | Action:${description || 'Unknown'} | Status:${status} | Time:${duration}ms`; // 根据状态码记录不同级别的日志 if (status >= 500) { logger.error(logMessage); } else if (status >= 400) { logger.warn(logMessage); } else { logger.info(logMessage); } // 写入数据库 try { await db.query( 'INSERT INTO audit_logs (user_id, username, real_name, ip_address, method, url, description, status, duration) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)', [userId, username, realName, clientIp, method, safeUrl, description, status, duration] ); } catch (err) { // 数据库写入失败不应影响主流程,仅记录错误 console.error('审计日志写入数据库失败:', err); } }); next(); }; module.exports = auditLogger;