| 12 | const STATS_PERSIST_DEBOUNCE_MS = 5000 |
| 13 | // 备份(data.json.bak)最小刷新间隔——不必每次落盘都多写一份 |
| 14 | const BACKUP_MIN_INTERVAL_MS = 5 * 60 * 1000 |
| 15 | // rename 重试间隔:Windows / 网络盘上目标文件被占用时可能短暂 EPERM / EBUSY |
| 16 | const RENAME_RETRY_DELAYS_MS = [20, 50, 100] |
| 17 | |
| 18 | // 进程内串行化:所有对 data.json 的 read-modify-write 排队执行。 |
| 19 | // 队列必须是模块级的——进程里存在多个 DataPersistence 实例 |
| 20 | // (utils/account.js 一个、routes/settings.js 一个),实例级的锁挡不住并发。 |
| 21 | // 注:PM2 cluster(PM2_INSTANCES > 1)下跨进程仍可能「后写覆盖先写」丢一次统计, |
| 22 | // 但原子 rename 保证任何时刻文件都是完整 JSON,不会再出现半截内容。 |
| 23 | let fileWriteQueue = Promise.resolve() |
| 24 | let tmpFileCounter = 0 |
| 25 | let lastBackupAt = 0 |
| 26 | |
| 27 | /** |
| 28 | * 把一次文件读写排入串行队列 |
| 29 | * @param {Function} task - 返回 Promise 的任务 |
| 30 | * @returns {Promise<*>} 任务结果 |
| 31 | */ |
| 32 | const enqueueFileTask = (task) => { |
| 33 | const result = fileWriteQueue.then(task, task) |
| 34 | // 队列本身不能因为单次失败而中断 |
| 35 | fileWriteQueue = result.then(() => { }, () => { }) |
| 36 | return result |
| 37 | } |
| 38 | |
| 39 | const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms)) |
| 40 | |
| 41 | class DataPersistence { |
| 42 | constructor() { |
| 43 | this.dataFilePath = path.join(__dirname, '../../data/data.json') |
| 44 | // 最近一次「解析成功」的快照,用于文件损坏时自愈 |
| 45 | this.backupFilePath = `${this.dataFilePath}.bak` |
| 46 | // 每个 email 的待持久化 stats 与定时器(debounce) |
| 47 | this._statsPersistTimers = new Map() |
| 48 | this._statsPendingPayload = new Map() |
| 49 | } |
| 50 | |
| 51 | /** |
| 52 | * 加载所有账户数据 |
| 53 | * @returns {Promise<Array>} 账户列表 |
| 54 | */ |
| 55 | async loadAccounts() { |
| 56 | try { |
| 57 | switch (config.dataSaveMode) { |
| 58 | case 'redis': |
| 59 | return await this._loadFromRedis() |
| 60 | case 'file': |
| 61 | return await this._loadFromFile() |
| 62 | case 'none': |
| 63 | return await this._loadFromEnv() |
| 64 | default: |
| 65 | logger.error(`不支持的数据保存模式: ${config.dataSaveMode}`, 'DATA') |
| 66 | throw new Error(`不支持的数据保存模式: ${config.dataSaveMode}`) |
| 67 | } |
| 68 | } catch (error) { |
| 69 | logger.error('加载账户数据失败', 'DATA', '', error) |
| 70 | throw error |
| 71 | } |
nothing calls this directly
no outgoing calls
no test coverage detected