#include "loghandler.h" #include #include #include #include #include #include #include #include #include #include #include #include #include // Qt6 将 QTextStream 编码接口改为 QStringConverter 枚举;Qt5 分支继续使用 setCodec, // 条件包含避免 Qt5 公用库因为不存在该头文件而无法构建。 #if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) #include #endif #if defined(Q_OS_WIN) #include #else #include #endif namespace { constexpr qint64 kLogFlushIntervalMs = 1000; constexpr qint64 kMaxLogFileBytes = 10LL * 1024 * 1024; constexpr int kLogRetentionDays = 30; // 日志运行状态集中在一个私有对象中,所有文件访问和安装状态均受同一把锁保护, // 使重复安装、退出期卸载和后台线程同时输出日志时不会访问到半关闭的 QTextStream。 struct LogState { QMutex mutex; QFile file; QTextStream stream; QtMessageHandler previousHandler = nullptr; qint64 lastFlushMs = 0; qint64 currentFileBytes = 0; int currentPart = 0; QDate activeDate; bool installed = false; #if defined(MYLOGGER_TESTING) // 测试编译时将文件系统副作用收敛到临时目录,并用小阈值快速覆盖分卷逻辑。 QString testLogDirectory; qint64 testMaxLogFileBytes = 0; #endif }; LogState g_logState; // Linux 部署包可能由启动脚本指定可写运行根目录,或由 AppImage 挂载在只读位置; // 这里优先复用 LC 已验证的环境变量策略,未提供时保持各平台原有的应用目录回退行为。 QString runtimeRootPath() { #if defined(Q_OS_LINUX) const QByteArray configuredRoot = qgetenv("LC_APP_ROOT"); if (!configuredRoot.isEmpty()) { return QDir::cleanPath(QString::fromLocal8Bit(configuredRoot)); } const QByteArray appImagePath = qgetenv("APPIMAGE"); if (!appImagePath.isEmpty()) { return QFileInfo(QString::fromLocal8Bit(appImagePath)).absolutePath(); } #endif return QCoreApplication::applicationDirPath(); } // 测试覆盖目录优先于部署策略,正式环境则在统一运行根目录下创建 logs, // 从而同时兼容 Windows exe 同级日志、Linux 启动脚本和 AppImage 部署。 QString logDirectoryPath() { #if defined(MYLOGGER_TESTING) if (!g_logState.testLogDirectory.isEmpty()) { return g_logState.testLogDirectory; } #endif return runtimeRootPath() + QStringLiteral("/logs"); } // 正式分卷阈值保持 10 MiB,测试仅通过编译期访问类降低阈值以缩短执行时间。 qint64 maxLogFileBytes() { #if defined(MYLOGGER_TESTING) if (g_logState.testMaxLogFileBytes > 0) { return g_logState.testMaxLogFileBytes; } #endif return kMaxLogFileBytes; } // Qt 内部或文件错误路径可能再次产生日志;同线程递归时不再进入互斥写盘路径, // 防止非递归互斥锁死锁,同时保留最小 stderr 输出用于诊断原始故障。 thread_local bool g_isHandlingMessage = false; // 仅在标准输出确实连接终端时插入 ANSI 颜色,避免控制字符污染重定向文件和 Windows GUI 进程输出。 bool supportsAnsiColor() { #if defined(Q_OS_WIN) return _isatty(_fileno(stdout)) != 0; #else return isatty(fileno(stdout)) != 0; #endif } // 将 Qt 日志等级转换为固定宽度文本,未知枚举值也保留可读标识以兼容后续 Qt 扩展。 QString levelName(QtMsgType type) { switch (type) { case QtDebugMsg: return QStringLiteral("DEBUG"); case QtInfoMsg: return QStringLiteral("INFO "); case QtWarningMsg: return QStringLiteral("WARN "); case QtCriticalMsg: return QStringLiteral("ERROR"); case QtFatalMsg: return QStringLiteral("FATAL"); default: return QStringLiteral("UNKWN"); } } // 控制台颜色仅是交互显示效果,文件日志始终使用无控制字符的等级文本。 QString consoleLevel(QtMsgType type, const QString &level) { if (!supportsAnsiColor()) { return level; } const char *color = "\033[0m"; switch (type) { case QtDebugMsg: color = "\033[36m"; break; case QtInfoMsg: color = "\033[32m"; break; case QtWarningMsg: color = "\033[33m"; break; case QtCriticalMsg: case QtFatalMsg: color = "\033[31m"; break; default: break; } return QString::fromLatin1(color) + level + QStringLiteral("\033[0m"); } // 兼容 Windows 反斜杠与编译器可能输出的正斜杠路径,只在日志中保留文件名以控制行宽。 QString shortFileName(const QMessageLogContext &context) { const QString fileName = context.file ? QString::fromUtf8(context.file) : QStringLiteral("?"); const int separatorIndex = qMax(fileName.lastIndexOf(QLatin1Char('/')), fileName.lastIndexOf(QLatin1Char('\\'))); return separatorIndex >= 0 ? fileName.mid(separatorIndex + 1) : fileName; } // 仅清理本模块按日期、进程号和分卷号命名的日志,避免公共 logs 目录中的宿主业务文件被误删。 void removeExpiredLogs(const QDir &logDir, const QDate &today) { const QRegularExpression logNamePattern( QStringLiteral("^\\d{4}-\\d{2}-\\d{2}_\\d+_\\d{3}\\.log$")); const QDate oldestDate = today.addDays(-kLogRetentionDays); const QFileInfoList files = logDir.entryInfoList(QStringList() << QStringLiteral("*.log"), QDir::Files | QDir::Readable); for (const QFileInfo &fileInfo : files) { if (!logNamePattern.match(fileInfo.fileName()).hasMatch()) { continue; } const QDate fileDate = QDate::fromString(fileInfo.fileName().left(10), Qt::ISODate); if (fileDate.isValid() && fileDate < oldestDate) { QFile::remove(fileInfo.absoluteFilePath()); } } } // 在锁保护下选择当前进程当天可继续写入的分卷;已满分卷永不复用,防止重启后突破大小上限。 bool openCurrentLogFileLocked(LogState &state, const QDir &logDir, const QDate &today) { const QString prefix = QStringLiteral("%1_%2_") .arg(today.toString(QStringLiteral("yyyy-MM-dd"))) .arg(QCoreApplication::applicationPid()); int part = 1; while (true) { const QString filePath = logDir.filePath(prefix + QStringLiteral("%1.log").arg(part, 3, 10, QLatin1Char('0'))); const QFileInfo fileInfo(filePath); if (!fileInfo.exists() || fileInfo.size() < maxLogFileBytes()) { state.file.setFileName(filePath); if (!state.file.open(QIODevice::WriteOnly | QIODevice::Text | QIODevice::Append)) { return false; } state.stream.setDevice(&state.file); #if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) state.stream.setEncoding(QStringConverter::Utf8); #else state.stream.setCodec("UTF-8"); #endif state.currentPart = part; // QTextStream 可能缓存尚未落盘的内容,分卷判断必须从已打开文件大小开始累计, // 不能在每次消息时直接依赖 QFile::size(),否则高频日志会在 flush 前突破上限。 state.currentFileBytes = state.file.size(); state.activeDate = today; return true; } ++part; } } // 在当前文件即将超过上限时切到下一个空闲分卷;连续序号便于现场按时间顺序定位日志。 bool rotateLogFileLocked(LogState &state) { const QFileInfo currentInfo(state.file); const QDir logDir = currentInfo.dir(); const QString prefix = QStringLiteral("%1_%2_") .arg(QDate::currentDate().toString(QStringLiteral("yyyy-MM-dd"))) .arg(QCoreApplication::applicationPid()); int part = state.currentPart + 1; QString filePath; do { filePath = logDir.filePath(prefix + QStringLiteral("%1.log").arg(part, 3, 10, QLatin1Char('0'))); ++part; } while (QFileInfo::exists(filePath)); state.stream.flush(); state.file.flush(); state.file.close(); state.stream.setDevice(nullptr); state.file.setFileName(filePath); if (!state.file.open(QIODevice::WriteOnly | QIODevice::Text | QIODevice::Append)) { return false; } state.stream.setDevice(&state.file); #if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) state.stream.setEncoding(QStringConverter::Utf8); #else state.stream.setCodec("UTF-8"); #endif state.currentPart = part - 1; state.currentFileBytes = 0; state.activeDate = QDate::currentDate(); return true; } // 全局 Qt 回调负责格式化并串行写盘;致命等级只保证最后一次 flush,终止动作仍由 Qt 在回调返回后执行。 void messageHandler(QtMsgType type, const QMessageLogContext &context, const QString &msg) { if (g_isHandlingMessage) { const QByteArray recursiveMessage = msg.toLocal8Bit(); std::fputs(recursiveMessage.constData(), stderr); std::fputc('\n', stderr); return; } QScopedValueRollback recursionGuard(g_isHandlingMessage, true); const QString level = levelName(type); const QString timestamp = QDateTime::currentDateTime().toString(QStringLiteral("yyyy-MM-dd hh:mm:ss.zzz")); const QString fileName = shortFileName(context); const QString functionName = context.function ? QString::fromUtf8(context.function) : QStringLiteral("?"); const QString fileMessage = QStringLiteral("%1-[%2](%3:%4,%5): %6") .arg(timestamp, level, fileName) .arg(context.line) .arg(functionName, msg); const QString consoleMessage = QStringLiteral("%1-[%2](%3:%4,%5): %6") .arg(timestamp, consoleLevel(type, level), fileName) .arg(context.line) .arg(functionName, msg); const QByteArray consoleBytes = consoleMessage.toLocal8Bit(); std::fwrite(consoleBytes.constData(), 1, static_cast(consoleBytes.size()), stdout); std::fputc('\n', stdout); QMutexLocker locker(&g_logState.mutex); if (!g_logState.installed) { return; } // 跨午夜时先关闭旧日期文件再按新日期选择分卷;轮转失败后的后续消息也会重试打开, // 这样临时磁盘或权限故障恢复后无需重启应用即可恢复文件日志。 const QDate today = QDate::currentDate(); if (g_logState.activeDate != today && g_logState.file.isOpen()) { g_logState.stream.flush(); g_logState.file.flush(); g_logState.file.close(); g_logState.stream.setDevice(nullptr); g_logState.currentFileBytes = 0; g_logState.currentPart = 0; } if (!g_logState.file.isOpen()) { const QDir logDir(logDirectoryPath()); if (!openCurrentLogFileLocked(g_logState, logDir, today)) { return; } } const qint64 messageBytes = fileMessage.toUtf8().size() + 1; if (g_logState.currentFileBytes + messageBytes > maxLogFileBytes() && !rotateLogFileLocked(g_logState)) { return; } g_logState.stream << fileMessage << '\n'; g_logState.currentFileBytes += messageBytes; const qint64 now = QDateTime::currentMSecsSinceEpoch(); if (type == QtCriticalMsg || type == QtFatalMsg || now - g_logState.lastFlushMs >= kLogFlushIntervalMs) { g_logState.stream.flush(); g_logState.file.flush(); g_logState.lastFlushMs = now; if (type == QtCriticalMsg || type == QtFatalMsg) { std::fflush(stdout); } } } } // 安装函数只在文件已成功打开后才接管 Qt 全局 handler,失败时保留宿主原有 handler 并允许后续重试。 bool LogHandler::installMessageHandler() { QMutexLocker locker(&g_logState.mutex); if (g_logState.installed) { return true; } const QDir logDir(logDirectoryPath()); if (!logDir.exists() && !QDir().mkpath(logDir.absolutePath())) { return false; } const QDate today = QDate::currentDate(); removeExpiredLogs(logDir, today); if (!openCurrentLogFileLocked(g_logState, logDir, today)) { return false; } g_logState.lastFlushMs = QDateTime::currentMSecsSinceEpoch(); g_logState.previousHandler = qInstallMessageHandler(messageHandler); g_logState.installed = true; return true; } // 卸载函数在锁内先同步关闭流再恢复旧 handler,确保回调并发进入时不会写到已释放的 QTextStream。 void LogHandler::uninstallMessageHandler() { QMutexLocker locker(&g_logState.mutex); if (!g_logState.installed) { return; } qInstallMessageHandler(g_logState.previousHandler); g_logState.previousHandler = nullptr; if (g_logState.file.isOpen()) { g_logState.stream.flush(); g_logState.file.flush(); g_logState.file.close(); } g_logState.stream.setDevice(nullptr); g_logState.currentFileBytes = 0; g_logState.currentPart = 0; g_logState.activeDate = QDate(); g_logState.installed = false; } // 查询函数与安装/卸载共用互斥锁,避免其他线程在关闭过程看到过期安装状态。 bool LogHandler::isInstalled() const { QMutexLocker locker(&g_logState.mutex); return g_logState.installed; } #if defined(MYLOGGER_TESTING) // 测试目录设置在同一把锁下更新,并要求 handler 已卸载,保证没有线程持有旧目录的文件句柄。 void LogHandlerTestAccess::setLogDirectory(const QString &directory) { QMutexLocker locker(&g_logState.mutex); Q_ASSERT(!g_logState.installed); g_logState.testLogDirectory = directory; } // 分卷阈值只影响随后打开的测试文件;非正值表示删除覆盖并回退到正式默认值。 void LogHandlerTestAccess::setMaxLogFileBytes(qint64 bytes) { QMutexLocker locker(&g_logState.mutex); Q_ASSERT(!g_logState.installed); g_logState.testMaxLogFileBytes = bytes > 0 ? bytes : 0; } // 测试结束后统一清除覆盖状态,避免同一 QtTest 进程中的下一条用例意外复用临时环境。 void LogHandlerTestAccess::reset() { QMutexLocker locker(&g_logState.mutex); Q_ASSERT(!g_logState.installed); g_logState.testLogDirectory.clear(); g_logState.testMaxLogFileBytes = 0; } #endif