feat(log): 增强日志系统功能与追踪能力
- 新增模块级别日志控制和采样配置 - 为操作日志添加requestId字段支持全链路追踪 - 优化请求日志中间件,增强结构化记录能力 - 实现日志降级机制,数据库不可用时写入文件 - 添加相关数据库迁移脚本和模型索引
This commit is contained in:
+2288
File diff suppressed because it is too large
Load Diff
@@ -117,6 +117,17 @@ LOG_MAX_FILE_SIZE=20m
|
|||||||
# 日志文件最大保留天数
|
# 日志文件最大保留天数
|
||||||
LOG_MAX_FILES=30d
|
LOG_MAX_FILES=30d
|
||||||
|
|
||||||
|
# 模块级别日志控制(JSON格式,可选)
|
||||||
|
# 允许针对特定模块设置不同的日志级别
|
||||||
|
# 示例:{"device":"debug","backup":"warn","HTTP":"info"}
|
||||||
|
MODULE_LOG_LEVELS=
|
||||||
|
|
||||||
|
# 日志采样配置(JSON格式,可选)
|
||||||
|
# 高频日志采样输出,rate表示采样率(0-1)
|
||||||
|
# error和warn级别不受采样影响
|
||||||
|
# 示例:{"HTTP":0.1,"device":0.5}
|
||||||
|
LOG_SAMPLING=
|
||||||
|
|
||||||
# ==============================================
|
# ==============================================
|
||||||
# 前端配置
|
# 前端配置
|
||||||
# ==============================================
|
# ==============================================
|
||||||
|
|||||||
@@ -1,29 +1,53 @@
|
|||||||
/**
|
/**
|
||||||
* HTTP 请求日志中间件
|
* HTTP 请求日志中间件
|
||||||
* 为每个请求生成唯一 ID,记录请求/响应信息
|
* 支持:
|
||||||
|
* - 唯一请求ID生成(UUID格式,避免碰撞)
|
||||||
|
* - 全链路请求追踪
|
||||||
|
* - 请求/响应信息结构化记录
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const logger = require('../utils/logger');
|
const logger = require('../utils/logger');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成唯一请求ID
|
||||||
|
* 使用UUID格式确保全局唯一性
|
||||||
|
* @returns {string} 请求ID
|
||||||
|
*/
|
||||||
|
const generateRequestId = () => {
|
||||||
|
if (crypto.randomUUID) {
|
||||||
|
return crypto.randomUUID();
|
||||||
|
}
|
||||||
|
return `${Date.now().toString(36)}-${crypto.randomBytes(16).toString('hex')}`;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 请求日志中间件
|
* 请求日志中间件
|
||||||
* 记录每个 HTTP 请求的方法、URL、状态码、耗时、用户ID 等信息
|
* 为每个请求生成唯一ID,记录请求/响应信息
|
||||||
|
* 支持全链路追踪:requestId贯穿请求生命周期
|
||||||
*/
|
*/
|
||||||
const requestLogger = (req, res, next) => {
|
const requestLogger = (req, res, next) => {
|
||||||
const requestId = crypto.randomBytes(4).toString('hex');
|
const requestId = generateRequestId();
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
|
|
||||||
req.requestId = requestId;
|
req.requestId = requestId;
|
||||||
|
|
||||||
|
const reqLogger = logger.withRequestId(requestId, 'HTTP');
|
||||||
|
|
||||||
|
reqLogger.debug('请求开始', {
|
||||||
|
method: req.method,
|
||||||
|
url: req.originalUrl,
|
||||||
|
ip: req.ip || req.connection.remoteAddress,
|
||||||
|
userAgent: req.get('User-Agent'),
|
||||||
|
userId: req.user?.userId || null,
|
||||||
|
});
|
||||||
|
|
||||||
res.on('finish', () => {
|
res.on('finish', () => {
|
||||||
const duration = Date.now() - startTime;
|
const duration = Date.now() - startTime;
|
||||||
const level = res.statusCode >= 500 ? 'error'
|
const level = res.statusCode >= 500 ? 'error'
|
||||||
: res.statusCode >= 400 ? 'warn' : 'debug';
|
: res.statusCode >= 400 ? 'warn' : 'debug';
|
||||||
|
|
||||||
logger[level](`${req.method} ${req.originalUrl}`, {
|
const logData = {
|
||||||
module: 'HTTP',
|
|
||||||
requestId,
|
|
||||||
method: req.method,
|
method: req.method,
|
||||||
url: req.originalUrl,
|
url: req.originalUrl,
|
||||||
statusCode: res.statusCode,
|
statusCode: res.statusCode,
|
||||||
@@ -31,6 +55,21 @@ const requestLogger = (req, res, next) => {
|
|||||||
ip: req.ip || req.connection.remoteAddress,
|
ip: req.ip || req.connection.remoteAddress,
|
||||||
userAgent: req.get('User-Agent'),
|
userAgent: req.get('User-Agent'),
|
||||||
userId: req.user?.userId || null,
|
userId: req.user?.userId || null,
|
||||||
|
contentLength: res.get('Content-Length') || 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (res.statusCode >= 400) {
|
||||||
|
logData.errorMessage = res.locals.errorMessage || null;
|
||||||
|
logData.errorStack = res.locals.errorStack || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
reqLogger[level](`${req.method} ${req.originalUrl}`, logData);
|
||||||
|
});
|
||||||
|
|
||||||
|
res.on('error', (error) => {
|
||||||
|
reqLogger.error('响应错误', {
|
||||||
|
error: error.message,
|
||||||
|
stack: error.stack,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* 操作日志模型
|
||||||
|
* 支持请求追踪ID(requestId)关联
|
||||||
|
* 支持复合索引优化查询性能
|
||||||
|
*/
|
||||||
|
|
||||||
const { DataTypes } = require('sequelize');
|
const { DataTypes } = require('sequelize');
|
||||||
const { sequelize } = require('../db');
|
const { sequelize } = require('../db');
|
||||||
|
|
||||||
@@ -72,6 +78,10 @@ const OperationLog = sequelize.define(
|
|||||||
type: DataTypes.STRING,
|
type: DataTypes.STRING,
|
||||||
comment: '用户代理',
|
comment: '用户代理',
|
||||||
},
|
},
|
||||||
|
requestId: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
comment: '请求追踪ID,关联HTTP请求日志',
|
||||||
|
},
|
||||||
metadata: {
|
metadata: {
|
||||||
type: DataTypes.JSON,
|
type: DataTypes.JSON,
|
||||||
defaultValue: {},
|
defaultValue: {},
|
||||||
@@ -87,6 +97,8 @@ const OperationLog = sequelize.define(
|
|||||||
{ fields: ['targetId'] },
|
{ fields: ['targetId'] },
|
||||||
{ fields: ['operatorId'] },
|
{ fields: ['operatorId'] },
|
||||||
{ fields: ['createdAt'] },
|
{ fields: ['createdAt'] },
|
||||||
|
{ fields: ['requestId'] },
|
||||||
|
{ fields: ['module', 'createdAt'] },
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -128,6 +128,11 @@ const migrations = [
|
|||||||
description: '为 racks 表添加 rowPos、colPos、facing 字段,支持机柜位置定位',
|
description: '为 racks 表添加 rowPos、colPos、facing 字段,支持机柜位置定位',
|
||||||
migrate: migrateRackPositionFields,
|
migrate: migrateRackPositionFields,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: '操作日志请求追踪',
|
||||||
|
description: '为 operation_logs 表添加 requestId 字段和复合索引,支持请求追踪',
|
||||||
|
migrate: migrateOperationLogRequestId,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
async function runMigrations() {
|
async function runMigrations() {
|
||||||
@@ -900,6 +905,52 @@ async function migrateRackPositionFields() {
|
|||||||
console.log(' 机柜位置字段迁移完成');
|
console.log(' 机柜位置字段迁移完成');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function addIndexIfNotExists(tableName, indexName, fields) {
|
||||||
|
const dialect = sequelize.getDialect();
|
||||||
|
try {
|
||||||
|
if (dialect === 'sqlite') {
|
||||||
|
await sequelize.query(
|
||||||
|
`CREATE INDEX IF NOT EXISTS ${indexName} ON ${tableName}(${fields.join(', ')})`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const [indexes] = await sequelize.query(
|
||||||
|
`SELECT INDEX_NAME FROM INFORMATION_SCHEMA.STATISTICS WHERE TABLE_NAME = ? AND INDEX_NAME = ? AND TABLE_SCHEMA = DATABASE()`,
|
||||||
|
{ replacements: [tableName, indexName], type: sequelize.QueryTypes.SELECT }
|
||||||
|
);
|
||||||
|
if (!indexes || indexes.length === 0) {
|
||||||
|
await sequelize.query(
|
||||||
|
`CREATE INDEX \`${indexName}\` ON \`${tableName}\`(\`${fields.join('`, `')}\`)`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.log(` 索引 ${indexName} 已存在,跳过`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(` 索引 ${indexName} 创建成功`);
|
||||||
|
} catch (error) {
|
||||||
|
if (error.message.includes('already exists') || error.message.includes('Duplicate key name')) {
|
||||||
|
console.log(` 索引 ${indexName} 已存在,跳过`);
|
||||||
|
} else {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function migrateOperationLogRequestId() {
|
||||||
|
const tableName = 'operation_logs';
|
||||||
|
|
||||||
|
if (!(await tableExists(tableName))) {
|
||||||
|
console.log(` ${tableName} 表不存在,跳过`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await addColumnIfNotExists(tableName, 'requestId', 'VARCHAR(255)');
|
||||||
|
await addIndexIfNotExists(tableName, 'operation_logs_requestId', ['requestId']);
|
||||||
|
await addIndexIfNotExists(tableName, 'operation_logs_module_createdAt', ['module', 'createdAt']);
|
||||||
|
|
||||||
|
console.log(' 操作日志requestId字段和索引迁移完成');
|
||||||
|
}
|
||||||
|
|
||||||
// 执行迁移
|
// 执行迁移
|
||||||
runMigrations().catch(error => {
|
runMigrations().catch(error => {
|
||||||
console.error('迁移执行失败:', error);
|
console.error('迁移执行失败:', error);
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
/**
|
||||||
|
* 操作日志表添加 requestId 字段和索引
|
||||||
|
* 支持请求追踪ID关联,优化查询性能
|
||||||
|
* 支持 SQLite 和 MySQL,幂等执行
|
||||||
|
*/
|
||||||
|
|
||||||
|
require('dotenv').config({ path: require('path').join(__dirname, '../.env') });
|
||||||
|
|
||||||
|
const { sequelize, dbDialect } = require('../db');
|
||||||
|
|
||||||
|
async function getTableColumns(tableName) {
|
||||||
|
const dialect = sequelize.getDialect();
|
||||||
|
if (dialect === 'sqlite') {
|
||||||
|
const tableInfo = await sequelize.query(`PRAGMA table_info(${tableName})`, {
|
||||||
|
type: sequelize.QueryTypes.SELECT,
|
||||||
|
});
|
||||||
|
return tableInfo.map(col => col.name);
|
||||||
|
}
|
||||||
|
const tableInfo = await sequelize.query(`SHOW COLUMNS FROM ${tableName}`, {
|
||||||
|
type: sequelize.QueryTypes.SELECT,
|
||||||
|
});
|
||||||
|
return tableInfo.map(col => col.Field);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tableExists(tableName) {
|
||||||
|
const dialect = sequelize.getDialect();
|
||||||
|
if (dialect === 'sqlite') {
|
||||||
|
const tables = await sequelize.query(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type='table' AND name=?",
|
||||||
|
{ replacements: [tableName], type: sequelize.QueryTypes.SELECT }
|
||||||
|
);
|
||||||
|
return tables.length > 0;
|
||||||
|
}
|
||||||
|
const tables = await sequelize.query('SHOW TABLES LIKE ?', {
|
||||||
|
replacements: [tableName],
|
||||||
|
type: sequelize.QueryTypes.SELECT,
|
||||||
|
});
|
||||||
|
return tables.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addColumnIfNotExists(tableName, columnName, columnDef) {
|
||||||
|
const columns = await getTableColumns(tableName);
|
||||||
|
if (!columns.includes(columnName)) {
|
||||||
|
await sequelize.query(`ALTER TABLE ${tableName} ADD COLUMN ${columnName} ${columnDef}`);
|
||||||
|
console.log(` ${tableName} 表添加 ${columnName} 字段成功`);
|
||||||
|
} else {
|
||||||
|
console.log(` ${tableName} 表 ${columnName} 字段已存在,跳过`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addIndexIfNotExists(tableName, indexName, fields) {
|
||||||
|
const dialect = sequelize.getDialect();
|
||||||
|
try {
|
||||||
|
if (dialect === 'sqlite') {
|
||||||
|
await sequelize.query(
|
||||||
|
`CREATE INDEX IF NOT EXISTS ${indexName} ON ${tableName}(${fields.join(', ')})`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const [indexes] = await sequelize.query(
|
||||||
|
`SELECT INDEX_NAME FROM INFORMATION_SCHEMA.STATISTICS WHERE TABLE_NAME = ? AND INDEX_NAME = ? AND TABLE_SCHEMA = DATABASE()`,
|
||||||
|
{ replacements: [tableName, indexName], type: sequelize.QueryTypes.SELECT }
|
||||||
|
);
|
||||||
|
if (!indexes || indexes.length === 0) {
|
||||||
|
await sequelize.query(
|
||||||
|
`CREATE INDEX \`${indexName}\` ON \`${tableName}\`(\`${fields.join('`, `')}\`)`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.log(` 索引 ${indexName} 已存在,跳过`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(` 索引 ${indexName} 创建成功`);
|
||||||
|
} catch (error) {
|
||||||
|
if (error.message.includes('already exists') || error.message.includes('Duplicate key name')) {
|
||||||
|
console.log(` 索引 ${indexName} 已存在,跳过`);
|
||||||
|
} else {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function migrateOperationLogRequestId() {
|
||||||
|
const tableName = 'operation_logs';
|
||||||
|
|
||||||
|
if (!(await tableExists(tableName))) {
|
||||||
|
console.log(` ${tableName} 表不存在,跳过`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await addColumnIfNotExists(tableName, 'requestId', 'VARCHAR(255)');
|
||||||
|
|
||||||
|
await addIndexIfNotExists(tableName, 'operation_logs_requestId', ['requestId']);
|
||||||
|
await addIndexIfNotExists(tableName, 'operation_logs_module_createdAt', ['module', 'createdAt']);
|
||||||
|
|
||||||
|
console.log(' 操作日志requestId字段和索引迁移完成');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
console.log('========================================');
|
||||||
|
console.log(' 操作日志表添加requestId字段迁移脚本 ');
|
||||||
|
console.log('========================================');
|
||||||
|
console.log(`数据库类型: ${dbDialect}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await migrateOperationLogRequestId();
|
||||||
|
console.log('\n迁移完成');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('迁移失败:', error.message);
|
||||||
|
process.exit(1);
|
||||||
|
} finally {
|
||||||
|
await sequelize.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
run();
|
||||||
+142
-32
@@ -1,6 +1,11 @@
|
|||||||
/**
|
/**
|
||||||
* 统一日志模块
|
* 统一日志模块
|
||||||
* 基于 winston 的日志系统,支持文件轮转、级别控制、模块标识
|
* 基于 winston 的日志系统,支持:
|
||||||
|
* - 文件轮转、级别控制、模块标识
|
||||||
|
* - JSON结构化输出(生产环境)
|
||||||
|
* - 请求追踪ID传递
|
||||||
|
* - 模块级别精细控制
|
||||||
|
* - 日志采样过滤
|
||||||
* 全项目唯一日志入口
|
* 全项目唯一日志入口
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -8,32 +13,107 @@ const { createLogger, format, transports } = require('winston');
|
|||||||
const DailyRotateFile = require('winston-daily-rotate-file');
|
const DailyRotateFile = require('winston-daily-rotate-file');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
|
||||||
// 日志目录
|
|
||||||
const LOG_DIR = process.env.LOG_DIR || path.join(__dirname, '../logs');
|
const LOG_DIR = process.env.LOG_DIR || path.join(__dirname, '../logs');
|
||||||
|
|
||||||
// 日志级别
|
|
||||||
const LOG_LEVEL = process.env.LOG_LEVEL || (process.env.NODE_ENV === 'production' ? 'info' : 'debug');
|
const LOG_LEVEL = process.env.LOG_LEVEL || (process.env.NODE_ENV === 'production' ? 'info' : 'debug');
|
||||||
|
|
||||||
// 单个日志文件最大大小
|
|
||||||
const LOG_MAX_FILE_SIZE = process.env.LOG_MAX_FILE_SIZE || '20m';
|
const LOG_MAX_FILE_SIZE = process.env.LOG_MAX_FILE_SIZE || '20m';
|
||||||
|
|
||||||
// 日志文件最大保留天数
|
|
||||||
const LOG_MAX_FILES = process.env.LOG_MAX_FILES || '30d';
|
const LOG_MAX_FILES = process.env.LOG_MAX_FILES || '30d';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 统一日志格式:时间 | 级别 | 模块 | 消息 | 元数据
|
* 模块级别配置
|
||||||
|
* 支持按模块精细控制日志级别,JSON格式
|
||||||
|
* 示例:MODULE_LOG_LEVELS={"device":"debug","backup":"warn"}
|
||||||
*/
|
*/
|
||||||
const logFormat = format.combine(
|
let MODULE_LOG_LEVELS = {};
|
||||||
|
try {
|
||||||
|
MODULE_LOG_LEVELS = process.env.MODULE_LOG_LEVELS
|
||||||
|
? JSON.parse(process.env.MODULE_LOG_LEVELS)
|
||||||
|
: {};
|
||||||
|
} catch {
|
||||||
|
MODULE_LOG_LEVELS = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 日志采样配置
|
||||||
|
* 高频日志采样输出,rate表示采样率(0-1)
|
||||||
|
* 示例:LOG_SAMPLING={"HTTP":0.1,"device":0.5}
|
||||||
|
*/
|
||||||
|
let LOG_SAMPLING = {};
|
||||||
|
try {
|
||||||
|
LOG_SAMPLING = process.env.LOG_SAMPLING
|
||||||
|
? JSON.parse(process.env.LOG_SAMPLING)
|
||||||
|
: {};
|
||||||
|
} catch {
|
||||||
|
LOG_SAMPLING = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const samplingCounters = {};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取模块日志级别
|
||||||
|
* @param {string} moduleName - 模块名称
|
||||||
|
* @returns {string} 日志级别
|
||||||
|
*/
|
||||||
|
const getModuleLevel = (moduleName) => {
|
||||||
|
return MODULE_LOG_LEVELS[moduleName] || LOG_LEVEL;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 日志级别优先级映射
|
||||||
|
*/
|
||||||
|
const LEVEL_PRIORITY = { error: 0, warn: 1, info: 2, debug: 3 };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断是否应该输出该级别日志
|
||||||
|
* @param {string} level - 当前日志级别
|
||||||
|
* @param {string} minLevel - 最低输出级别
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
const shouldLog = (level, minLevel) => {
|
||||||
|
return (LEVEL_PRIORITY[level] || 99) <= (LEVEL_PRIORITY[minLevel] || 99);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 日志采样判断
|
||||||
|
* @param {string} moduleName - 模块名称
|
||||||
|
* @param {string} level - 日志级别
|
||||||
|
* @returns {boolean} 是否应该输出
|
||||||
|
*/
|
||||||
|
const shouldSample = (moduleName, level) => {
|
||||||
|
if (level === 'error' || level === 'warn') return true;
|
||||||
|
|
||||||
|
const samplingConfig = LOG_SAMPLING[moduleName];
|
||||||
|
if (!samplingConfig || samplingConfig.rate >= 1) return true;
|
||||||
|
|
||||||
|
samplingCounters[moduleName] = (samplingCounters[moduleName] || 0) + 1;
|
||||||
|
return samplingCounters[moduleName] % Math.floor(1 / samplingConfig.rate) === 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JSON格式输出(生产环境文件日志)
|
||||||
|
*/
|
||||||
|
const jsonFormat = format.combine(
|
||||||
format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss.SSS' }),
|
format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss.SSS' }),
|
||||||
format.errors({ stack: true }),
|
format.errors({ stack: true }),
|
||||||
format.metadata({ fillExcept: ['message', 'level', 'timestamp', 'module'] }),
|
format.metadata({ fillExcept: ['message', 'level', 'timestamp', 'module', 'requestId'] }),
|
||||||
format.printf(({ timestamp, level, message, module, metadata, stack }) => {
|
format.json()
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文本格式输出(开发环境控制台)
|
||||||
|
*/
|
||||||
|
const consoleFormat = format.combine(
|
||||||
|
format.colorize(),
|
||||||
|
format.timestamp({ format: 'HH:mm:ss.SSS' }),
|
||||||
|
format.printf(({ timestamp, level, message, module, requestId, metadata }) => {
|
||||||
const moduleTag = module ? `[${module}]` : '';
|
const moduleTag = module ? `[${module}]` : '';
|
||||||
|
const requestTag = requestId ? `[${requestId}]` : '';
|
||||||
const metaStr = metadata && Object.keys(metadata).length
|
const metaStr = metadata && Object.keys(metadata).length
|
||||||
? ' ' + JSON.stringify(metadata)
|
? ' ' + JSON.stringify(metadata)
|
||||||
: '';
|
: '';
|
||||||
const stackStr = stack ? `\n${stack}` : '';
|
return `${timestamp} ${level} ${moduleTag}${requestTag} ${message}${metaStr}`;
|
||||||
return `${timestamp} ${level.toUpperCase().padEnd(5)} ${moduleTag} ${message}${metaStr}${stackStr}`;
|
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -42,19 +122,17 @@ const logFormat = format.combine(
|
|||||||
*/
|
*/
|
||||||
const logger = createLogger({
|
const logger = createLogger({
|
||||||
level: LOG_LEVEL,
|
level: LOG_LEVEL,
|
||||||
format: logFormat,
|
format: jsonFormat,
|
||||||
defaultMeta: { service: 'idc-management' },
|
defaultMeta: { service: 'idc-management' },
|
||||||
transports: [
|
transports: [
|
||||||
// 应用日志 - 按天轮转
|
|
||||||
new DailyRotateFile({
|
new DailyRotateFile({
|
||||||
dirname: path.join(LOG_DIR, 'app'),
|
dirname: path.join(LOG_DIR, 'app'),
|
||||||
filename: 'application-%DATE%.log',
|
filename: 'application-%DATE%.log',
|
||||||
datePattern: 'YYYY-MM-DD',
|
datePattern: 'YYYY-MM-DD',
|
||||||
maxFiles: LOG_MAX_FILES,
|
maxFiles: LOG_MAX_FILES,
|
||||||
maxSize: LOG_MAX_FILE_SIZE,
|
maxSize: LOG_MAX_FILE_SIZE,
|
||||||
level: 'info',
|
level: 'debug',
|
||||||
}),
|
}),
|
||||||
// 错误日志 - 独立存储
|
|
||||||
new DailyRotateFile({
|
new DailyRotateFile({
|
||||||
dirname: path.join(LOG_DIR, 'error'),
|
dirname: path.join(LOG_DIR, 'error'),
|
||||||
filename: 'error-%DATE%.log',
|
filename: 'error-%DATE%.log',
|
||||||
@@ -63,8 +141,15 @@ const logger = createLogger({
|
|||||||
maxSize: LOG_MAX_FILE_SIZE,
|
maxSize: LOG_MAX_FILE_SIZE,
|
||||||
level: 'error',
|
level: 'error',
|
||||||
}),
|
}),
|
||||||
|
new DailyRotateFile({
|
||||||
|
dirname: path.join(LOG_DIR, 'request'),
|
||||||
|
filename: 'request-%DATE%.log',
|
||||||
|
datePattern: 'YYYY-MM-DD',
|
||||||
|
maxFiles: '15d',
|
||||||
|
maxSize: LOG_MAX_FILE_SIZE,
|
||||||
|
level: 'debug',
|
||||||
|
}),
|
||||||
],
|
],
|
||||||
// 未捕获异常处理
|
|
||||||
exceptionHandlers: [
|
exceptionHandlers: [
|
||||||
new DailyRotateFile({
|
new DailyRotateFile({
|
||||||
dirname: path.join(LOG_DIR, 'error'),
|
dirname: path.join(LOG_DIR, 'error'),
|
||||||
@@ -73,7 +158,6 @@ const logger = createLogger({
|
|||||||
maxFiles: LOG_MAX_FILES,
|
maxFiles: LOG_MAX_FILES,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
// 未处理 Promise 拒绝处理
|
|
||||||
rejectionHandlers: [
|
rejectionHandlers: [
|
||||||
new DailyRotateFile({
|
new DailyRotateFile({
|
||||||
dirname: path.join(LOG_DIR, 'error'),
|
dirname: path.join(LOG_DIR, 'error'),
|
||||||
@@ -84,30 +168,56 @@ const logger = createLogger({
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
// 开发环境添加控制台输出(带颜色)
|
|
||||||
if (process.env.NODE_ENV !== 'production') {
|
if (process.env.NODE_ENV !== 'production') {
|
||||||
logger.add(new transports.Console({
|
logger.add(new transports.Console({
|
||||||
format: format.combine(
|
format: consoleFormat,
|
||||||
format.colorize(),
|
level: 'debug',
|
||||||
format.timestamp({ format: 'HH:mm:ss' }),
|
|
||||||
format.printf(({ timestamp, level, message, module }) => {
|
|
||||||
const moduleTag = module ? `[${module}]` : '';
|
|
||||||
return `${timestamp} ${level} ${moduleTag} ${message}`;
|
|
||||||
})
|
|
||||||
),
|
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建带模块名的子 logger
|
* 创建带模块名的子 logger
|
||||||
|
* 支持模块级别精细控制和日志采样
|
||||||
* @param {string} moduleName - 模块名称
|
* @param {string} moduleName - 模块名称
|
||||||
* @returns {Object} 包含 debug/info/warn/error 方法的对象
|
* @returns {Object} 包含 debug/info/warn/error 方法的对象
|
||||||
*/
|
*/
|
||||||
logger.module = (moduleName) => ({
|
logger.module = (moduleName) => {
|
||||||
debug: (msg, meta) => logger.debug(msg, { module: moduleName, ...meta }),
|
const moduleLevel = getModuleLevel(moduleName);
|
||||||
info: (msg, meta) => logger.info(msg, { module: moduleName, ...meta }),
|
|
||||||
warn: (msg, meta) => logger.warn(msg, { module: moduleName, ...meta }),
|
const logWithLevel = (level, msg, meta = {}) => {
|
||||||
error: (msg, meta) => logger.error(msg, { module: moduleName, ...meta }),
|
if (!shouldLog(level, moduleLevel)) return;
|
||||||
});
|
if (!shouldSample(moduleName, level)) return;
|
||||||
|
logger.log(level, msg, { module: moduleName, ...meta });
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
debug: (msg, meta) => logWithLevel('debug', msg, meta),
|
||||||
|
info: (msg, meta) => logWithLevel('info', msg, meta),
|
||||||
|
warn: (msg, meta) => logWithLevel('warn', msg, meta),
|
||||||
|
error: (msg, meta) => logWithLevel('error', msg, meta),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建带请求ID的 logger
|
||||||
|
* 用于全链路请求追踪
|
||||||
|
* @param {string} requestId - 请求追踪ID
|
||||||
|
* @param {string} moduleName - 模块名称(可选)
|
||||||
|
* @returns {Object} 包含日志方法的对象
|
||||||
|
*/
|
||||||
|
logger.withRequestId = (requestId, moduleName = null) => {
|
||||||
|
const baseLogger = moduleName ? logger.module(moduleName) : logger;
|
||||||
|
|
||||||
|
const wrapMethod = (method) => (msg, meta = {}) => {
|
||||||
|
baseLogger[method](msg, { requestId, ...meta });
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
debug: wrapMethod('debug'),
|
||||||
|
info: wrapMethod('info'),
|
||||||
|
warn: wrapMethod('warn'),
|
||||||
|
error: wrapMethod('error'),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
module.exports = logger;
|
module.exports = logger;
|
||||||
|
|||||||
@@ -1,9 +1,27 @@
|
|||||||
|
/**
|
||||||
|
* 操作日志记录器
|
||||||
|
* 支持:
|
||||||
|
* - 关联请求追踪ID(requestId)
|
||||||
|
* - 数据库写入失败时降级到文件日志
|
||||||
|
* - 统一错误处理(使用logger替代console.error)
|
||||||
|
*/
|
||||||
|
|
||||||
const OperationLog = require('../models/OperationLog');
|
const OperationLog = require('../models/OperationLog');
|
||||||
|
const logger = require('./logger');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const logModule = logger.module('OperationLogger');
|
||||||
|
|
||||||
const generateRecordId = () => {
|
const generateRecordId = () => {
|
||||||
return `OPLOG_${Date.now().toString(36)}_${Math.random().toString(36).substr(2, 9)}`;
|
return `OPLOG_${Date.now().toString(36)}_${Math.random().toString(36).substr(2, 9)}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取操作人信息
|
||||||
|
* @param {Object} req - Express请求对象
|
||||||
|
* @returns {Object} 操作人信息
|
||||||
|
*/
|
||||||
const getOperatorInfo = req => {
|
const getOperatorInfo = req => {
|
||||||
if (!req || !req.user) {
|
if (!req || !req.user) {
|
||||||
return {
|
return {
|
||||||
@@ -19,9 +37,14 @@ const getOperatorInfo = req => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取客户端信息
|
||||||
|
* @param {Object} req - Express请求对象
|
||||||
|
* @returns {Object} 客户端信息(含requestId)
|
||||||
|
*/
|
||||||
const getClientInfo = req => {
|
const getClientInfo = req => {
|
||||||
if (!req) {
|
if (!req) {
|
||||||
return { ipAddress: null, userAgent: null };
|
return { ipAddress: null, userAgent: null, requestId: null };
|
||||||
}
|
}
|
||||||
const ipAddress =
|
const ipAddress =
|
||||||
req.headers['x-forwarded-for'] ||
|
req.headers['x-forwarded-for'] ||
|
||||||
@@ -29,8 +52,11 @@ const getClientInfo = req => {
|
|||||||
req.connection?.remoteAddress ||
|
req.connection?.remoteAddress ||
|
||||||
req.ip ||
|
req.ip ||
|
||||||
null;
|
null;
|
||||||
const userAgent = req.headers['user-agent'] || null;
|
return {
|
||||||
return { ipAddress, userAgent };
|
ipAddress,
|
||||||
|
userAgent: req.headers['user-agent'] || null,
|
||||||
|
requestId: req.requestId || null,
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEVICE_TYPE_MAP = {
|
const DEVICE_TYPE_MAP = {
|
||||||
@@ -43,6 +69,13 @@ const DEVICE_TYPE_MAP = {
|
|||||||
other: '其他设备',
|
other: '其他设备',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成设备操作描述
|
||||||
|
* @param {string} operation - 操作类型描述
|
||||||
|
* @param {Object} device - 设备信息
|
||||||
|
* @param {Object} options - 选项
|
||||||
|
* @returns {string} 操作描述
|
||||||
|
*/
|
||||||
const generateDeviceDescription = (operation, device, options = {}) => {
|
const generateDeviceDescription = (operation, device, options = {}) => {
|
||||||
const {
|
const {
|
||||||
includeRack = true,
|
includeRack = true,
|
||||||
@@ -82,6 +115,12 @@ const generateDeviceDescription = (operation, device, options = {}) => {
|
|||||||
return `${operation}${parts.join(',')}`;
|
return `${operation}${parts.join(',')}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建设备元数据
|
||||||
|
* @param {Object} device - 设备信息
|
||||||
|
* @param {Object} extra - 额外字段
|
||||||
|
* @returns {Object} 元数据
|
||||||
|
*/
|
||||||
const buildDeviceMetadata = (device, extra = {}) => {
|
const buildDeviceMetadata = (device, extra = {}) => {
|
||||||
return {
|
return {
|
||||||
deviceId: device.deviceId || null,
|
deviceId: device.deviceId || null,
|
||||||
@@ -99,6 +138,46 @@ const buildDeviceMetadata = (device, extra = {}) => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 降级写入文件日志
|
||||||
|
* 当数据库不可用时,将操作日志写入文件作为降级方案
|
||||||
|
* @param {Object} logData - 日志数据
|
||||||
|
*/
|
||||||
|
const fallbackToFile = (logData) => {
|
||||||
|
try {
|
||||||
|
const fallbackDir = path.join(process.env.LOG_DIR || './logs', 'fallback');
|
||||||
|
if (!fs.existsSync(fallbackDir)) {
|
||||||
|
fs.mkdirSync(fallbackDir, { recursive: true });
|
||||||
|
}
|
||||||
|
const fallbackPath = path.join(fallbackDir, `operation-fallback-${new Date().toISOString().slice(0, 10)}.log`);
|
||||||
|
const fallbackEntry = {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
level: 'warn',
|
||||||
|
module: 'OperationLogger',
|
||||||
|
message: '操作日志数据库写入失败(降级记录)',
|
||||||
|
data: logData,
|
||||||
|
};
|
||||||
|
fs.appendFileSync(fallbackPath, JSON.stringify(fallbackEntry) + '\n');
|
||||||
|
} catch {
|
||||||
|
// 文件写入也失败时静默忽略,避免级联错误
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 记录操作日志(核心方法)
|
||||||
|
* @param {Object} params - 参数对象
|
||||||
|
* @param {string} params.module - 模块名称
|
||||||
|
* @param {string} params.operationType - 操作类型
|
||||||
|
* @param {string} params.operationDescription - 操作描述
|
||||||
|
* @param {string} params.targetId - 目标ID
|
||||||
|
* @param {string} params.targetName - 目标名称
|
||||||
|
* @param {Object} params.beforeState - 操作前状态
|
||||||
|
* @param {Object} params.afterState - 操作后状态
|
||||||
|
* @param {string} params.result - 操作结果
|
||||||
|
* @param {Object} params.req - 请求对象
|
||||||
|
* @param {Object} params.metadata - 附加元数据
|
||||||
|
* @returns {Promise<Object|null>} 创建的日志记录
|
||||||
|
*/
|
||||||
async function logOperation({
|
async function logOperation({
|
||||||
module,
|
module,
|
||||||
operationType,
|
operationType,
|
||||||
@@ -111,12 +190,10 @@ async function logOperation({
|
|||||||
req,
|
req,
|
||||||
metadata = {},
|
metadata = {},
|
||||||
}) {
|
}) {
|
||||||
try {
|
|
||||||
const operatorInfo = getOperatorInfo(req);
|
const operatorInfo = getOperatorInfo(req);
|
||||||
const clientInfo = getClientInfo(req);
|
const clientInfo = getClientInfo(req);
|
||||||
|
|
||||||
await OperationLog.create({
|
const logData = {
|
||||||
recordId: generateRecordId(),
|
|
||||||
module,
|
module,
|
||||||
operationType,
|
operationType,
|
||||||
operationDescription,
|
operationDescription,
|
||||||
@@ -130,13 +207,47 @@ async function logOperation({
|
|||||||
result,
|
result,
|
||||||
ipAddress: clientInfo.ipAddress,
|
ipAddress: clientInfo.ipAddress,
|
||||||
userAgent: clientInfo.userAgent,
|
userAgent: clientInfo.userAgent,
|
||||||
|
requestId: clientInfo.requestId,
|
||||||
metadata,
|
metadata,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const operationLog = await OperationLog.create({
|
||||||
|
recordId: generateRecordId(),
|
||||||
|
...logData,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
logModule.debug('操作日志记录成功', {
|
||||||
|
recordId: operationLog.recordId,
|
||||||
|
module,
|
||||||
|
operationType,
|
||||||
|
targetId,
|
||||||
|
requestId: clientInfo.requestId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return operationLog;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('记录操作日志失败:', error);
|
logModule.error('记录操作日志失败', {
|
||||||
|
error: error.message,
|
||||||
|
module,
|
||||||
|
operationType,
|
||||||
|
targetId,
|
||||||
|
requestId: clientInfo.requestId,
|
||||||
|
});
|
||||||
|
|
||||||
|
fallbackToFile(logData);
|
||||||
|
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 记录设备操作日志
|
||||||
|
* @param {string} operationType - 操作类型
|
||||||
|
* @param {string} operationDescription - 操作描述
|
||||||
|
* @param {Object} params - 参数
|
||||||
|
* @returns {Promise<Object|null>}
|
||||||
|
*/
|
||||||
async function logDeviceOperation(
|
async function logDeviceOperation(
|
||||||
operationType,
|
operationType,
|
||||||
operationDescription,
|
operationDescription,
|
||||||
@@ -156,6 +267,13 @@ async function logDeviceOperation(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 记录用户操作日志
|
||||||
|
* @param {string} operationType - 操作类型
|
||||||
|
* @param {string} operationDescription - 操作描述
|
||||||
|
* @param {Object} params - 参数
|
||||||
|
* @returns {Promise<Object|null>}
|
||||||
|
*/
|
||||||
async function logUserOperation(
|
async function logUserOperation(
|
||||||
operationType,
|
operationType,
|
||||||
operationDescription,
|
operationDescription,
|
||||||
@@ -175,6 +293,13 @@ async function logUserOperation(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 记录角色操作日志
|
||||||
|
* @param {string} operationType - 操作类型
|
||||||
|
* @param {string} operationDescription - 操作描述
|
||||||
|
* @param {Object} params - 参数
|
||||||
|
* @returns {Promise<Object|null>}
|
||||||
|
*/
|
||||||
async function logRoleOperation(
|
async function logRoleOperation(
|
||||||
operationType,
|
operationType,
|
||||||
operationDescription,
|
operationDescription,
|
||||||
|
|||||||
Reference in New Issue
Block a user