## 构建、打包与分发系统重构 (build/) - 构建系统收敛至 build/ 目录:build.mjs + electron-builder.js + 6 个脚本 - electron-builder 配置从 package.json 提取为 build/electron-builder.js - 新增 --edition (personal/enterprise) 和 --channel (stable/beta/alpha) 参数 - OSS 路径按渠道隔离 - 新增 pre-build-check.mjs:Git/CHANGELOG/版本/环境变量校验 - 新增 bump-version.mjs:版本号管理 + CHANGELOG 自动生成 - updater.ts 发送 channel/edition/deviceFingerprint - 新增 src/shared/utils/rollout.ts 灰度测试工具 - 新增 10 条 NPM 脚本 ## VCHSM 五层架构迁移 - 7 个业务模块 + ~500 处导入路径同步 ## 修复 - MediaCardContextMenu 改用共享 ContextMenu 组件 - ComboBox 尺寸参数显示统一 (size-format.ts) ## 文档 - UpdateA.md / build/README.md / README.md / .env.example 更新 Co-Authored-By: Claude <noreply@anthropic.com>
258 lines
6.9 KiB
TypeScript
258 lines
6.9 KiB
TypeScript
// ============================================================
|
||
// 主进程日志核心 — 文件写入 / 轮转 / 清理
|
||
//
|
||
// 架构:
|
||
// - 日志写入 {userData}/logs/app-YYYY-MM-DD.log(JSON Lines 格式)
|
||
// - 每日轮转:日期变化时自动创建新文件
|
||
// - 保留策略:7 天前旧文件自动删除
|
||
// - 写入队列批量冲刷(setImmediate 聚合同一 tick 的日志)
|
||
// - 惰性初始化:app.getPath('userData') 在 ready 后才可用,
|
||
// initLogger() 在 whenReady 中显式调用预热
|
||
//
|
||
// 用法(主进程):
|
||
// import { logger, initLogger } from './main/logger';
|
||
// logger.info('updater', '检查更新', { version: '0.1.0' });
|
||
//
|
||
// 渲染进程日志通过 log-ipc.ts IPC 通道写入,调用 writeRendererLog()
|
||
// ============================================================
|
||
|
||
import { app } from 'electron';
|
||
import { createWriteStream, mkdirSync, readdirSync, statSync, unlinkSync } from 'node:fs';
|
||
import { join } from 'node:path';
|
||
import type { WriteStream } from 'node:fs';
|
||
import type { LogEntry, LogLevel, LogCategory, LogErrorSnapshot } from '../../shared/types/logging';
|
||
import { sanitizeLogEntry } from '../../shared/utils/sanitize';
|
||
|
||
// ---------- 内部状态 ----------
|
||
|
||
let writeStream: WriteStream | null = null;
|
||
let currentDate = '';
|
||
let logDir = '';
|
||
let logDirResolver: (() => string) | null = null;
|
||
const writeQueue: string[] = [];
|
||
let isWriting = false;
|
||
const MAX_DAYS = 7;
|
||
|
||
// ---------- 辅助函数 ----------
|
||
|
||
function getDateStr(): string {
|
||
return new Date().toISOString().slice(0, 10);
|
||
}
|
||
|
||
function getLogFilePath(date: string): string {
|
||
return join(logDir, `app-${date}.log`);
|
||
}
|
||
|
||
/** 确保 logs/ 目录存在 */
|
||
function ensureDir(): void {
|
||
if (logDir) return;
|
||
try {
|
||
logDir = logDirResolver ? logDirResolver() : join(app.getPath('userData'), 'logs');
|
||
} catch {
|
||
// app.getPath 尚不可用(app.ready 之前),静默跳过
|
||
return;
|
||
}
|
||
if (!logDir) return;
|
||
try {
|
||
mkdirSync(logDir, { recursive: true });
|
||
} catch {
|
||
/* 目录创建失败 — 后续写入静默丢弃 */
|
||
}
|
||
}
|
||
|
||
/** 日期轮转:日切时关闭旧流 → 创建新文件 → 清理过期文件 */
|
||
function rotateIfNeeded(): void {
|
||
const today = getDateStr();
|
||
if (currentDate === today && writeStream) return;
|
||
|
||
if (writeStream) {
|
||
try {
|
||
writeStream.end();
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
writeStream = null;
|
||
}
|
||
|
||
try {
|
||
writeStream = createWriteStream(getLogFilePath(today), { flags: 'a' });
|
||
currentDate = today;
|
||
} catch {
|
||
writeStream = null;
|
||
}
|
||
|
||
purgeOldLogs();
|
||
}
|
||
|
||
/** 清理超过 MAX_DAYS 天的旧日志文件 */
|
||
function purgeOldLogs(): void {
|
||
if (!logDir) return;
|
||
const now = Date.now();
|
||
const maxAge = MAX_DAYS * 24 * 60 * 60 * 1000;
|
||
|
||
try {
|
||
const files = readdirSync(logDir);
|
||
for (const file of files) {
|
||
if (!file.startsWith('app-') || !file.endsWith('.log')) continue;
|
||
try {
|
||
const stat = statSync(join(logDir, file));
|
||
if (now - stat.mtimeMs > maxAge) {
|
||
unlinkSync(join(logDir, file));
|
||
}
|
||
} catch {
|
||
/* skip inaccessible files */
|
||
}
|
||
}
|
||
} catch {
|
||
/* 目录读取失败 — 下次轮转再试 */
|
||
}
|
||
}
|
||
|
||
/** 批量冲刷写入队列 */
|
||
function processQueue(): void {
|
||
if (isWriting || writeQueue.length === 0) return;
|
||
isWriting = true;
|
||
|
||
ensureDir();
|
||
if (logDir) {
|
||
rotateIfNeeded();
|
||
if (writeStream) {
|
||
const batch = writeQueue.splice(0).join('');
|
||
try {
|
||
writeStream.write(batch);
|
||
} catch {
|
||
/* 写入失败 — 数据丢失,但日志不应阻断业务 */
|
||
}
|
||
} else {
|
||
writeQueue.length = 0;
|
||
}
|
||
} else {
|
||
// app ready 前尚无 userData 路径 — 丢弃(此时距 ready 极近)
|
||
writeQueue.length = 0;
|
||
}
|
||
|
||
isWriting = false;
|
||
}
|
||
|
||
/** 错误对象 → 可序列化快照 */
|
||
function errorToSnapshot(err: Error): LogErrorSnapshot {
|
||
return {
|
||
name: err.name,
|
||
message: err.message,
|
||
stack: err.stack,
|
||
cause: (err as { cause?: unknown }).cause
|
||
? String((err as { cause?: unknown }).cause)
|
||
: undefined,
|
||
};
|
||
}
|
||
|
||
/** 构建日志条目 */
|
||
function createEntry(
|
||
level: LogLevel,
|
||
category: LogCategory,
|
||
message: string,
|
||
processType: 'main' | 'renderer',
|
||
error?: Error,
|
||
context?: Record<string, unknown>,
|
||
): LogEntry {
|
||
const entry: LogEntry = {
|
||
timestamp: new Date().toISOString(),
|
||
level,
|
||
category,
|
||
message,
|
||
processType,
|
||
};
|
||
if (context && Object.keys(context).length > 0) {
|
||
entry.context = context;
|
||
}
|
||
if (error) {
|
||
entry.error = errorToSnapshot(error);
|
||
}
|
||
return entry;
|
||
}
|
||
|
||
/** 内部写入入口 */
|
||
function write(
|
||
level: LogLevel,
|
||
category: LogCategory,
|
||
message: string,
|
||
error?: Error,
|
||
context?: Record<string, unknown>,
|
||
): void {
|
||
const entry = sanitizeLogEntry(
|
||
createEntry(level, category, message, 'main', error, context),
|
||
);
|
||
writeQueue.push(JSON.stringify(entry) + '\n');
|
||
// 首个入队项触发 setImmediate 批量冲刷
|
||
if (writeQueue.length === 1) {
|
||
setImmediate(processQueue);
|
||
}
|
||
}
|
||
|
||
// ---------- 对外 API ----------
|
||
|
||
/** 主进程日志器(与渲染进程 API 完全一致) */
|
||
export const logger = {
|
||
debug(category: LogCategory, message: string, context?: Record<string, unknown>): void {
|
||
write('DEBUG', category, message, undefined, context);
|
||
},
|
||
info(category: LogCategory, message: string, context?: Record<string, unknown>): void {
|
||
write('INFO', category, message, undefined, context);
|
||
},
|
||
warn(
|
||
category: LogCategory,
|
||
message: string,
|
||
error?: Error,
|
||
context?: Record<string, unknown>,
|
||
): void {
|
||
write('WARN', category, message, error, context);
|
||
},
|
||
error(
|
||
category: LogCategory,
|
||
message: string,
|
||
error?: Error,
|
||
context?: Record<string, unknown>,
|
||
): void {
|
||
write('ERROR', category, message, error, context);
|
||
},
|
||
};
|
||
|
||
/** 注入自定义日志目录解析器(在 initLogger() 之前调用) */
|
||
export function setLogDirResolver(resolver: () => string): void {
|
||
logDirResolver = resolver;
|
||
}
|
||
|
||
/** 预热日志模块(在 app.whenReady() 中调用) */
|
||
export function initLogger(): void {
|
||
ensureDir();
|
||
if (logDir) {
|
||
rotateIfNeeded();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 接收渲染进程日志并写入文件
|
||
* 由 log-ipc.ts 的 IPC 监听器调用
|
||
*/
|
||
export function writeRendererLog(entry: LogEntry): void {
|
||
writeQueue.push(JSON.stringify(entry) + '\n');
|
||
if (writeQueue.length === 1) {
|
||
setImmediate(processQueue);
|
||
}
|
||
}
|
||
|
||
/** 冲刷所有未写入日志并关闭写入流(在 app.before-quit 中调用) */
|
||
export function flushLogger(): void {
|
||
if (writeQueue.length > 0) {
|
||
processQueue();
|
||
}
|
||
if (writeStream) {
|
||
try {
|
||
writeStream.end();
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
writeStream = null;
|
||
}
|
||
}
|