169 lines
7.1 KiB
JavaScript
169 lines
7.1 KiB
JavaScript
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;
|