'use strict'; // ============================================================================ // JSON 저장소 공통 — 원자적 쓰기 · 파일별 직렬화 잠금 · 손상 감지 (v1.6.31, 요구 10) // // 왜: 여러 저장소(users·settings·credits·domains·sites·modules)가 각자 fs.writeFileSync 를 써서 // (1) 쓰는 도중 프로세스가 죽으면 반쪽 JSON 이 남고, (2) 같은 파일을 두 요청이 읽고-고치고-쓰면 뒤의 것이 앞의 변경을 덮고, // (3) 읽기 실패를 조용히 빈 값으로 바꿔 "정상인 것처럼" 동작했다(검토 보고서 6.1·패치 3). // 제공 // writeJsonAtomic(file, data, {mode, keepBackup}) : 임시 파일에 쓰고 fsync 한 뒤 rename — 중간 상태가 보이지 않는다. keepBackup 이면 직전 정상본을 .bak 으로 남긴다. // readJson(file, fallback, {strict}) : 없으면 fallback. 있는데 못 읽으면 손상 목록(corrupt)에 올리고 .bak 을 시도한다. strict 면 예외. // withLock(key, fn) : 같은 키(파일 경로)의 비동기 읽기-수정-쓰기를 한 줄로 세운다(프로세스 안 직렬화). // health() : 손상이 감지된 파일 목록 (관리자 콘솔 운영 복구 화면·기동 로그) // 단일 프로세스 서비스라 프로세스 안 잠금으로 충분하다. 프로세스가 여럿이면 파일 잠금이 더 필요하다(지금 구조에서는 없음). // ============================================================================ const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); const locks = new Map(); // key → Promise(마지막 작업) const corrupt = new Map(); // file → { at, error, recoveredFromBackup } function writeJsonAtomic(file, data, opts = {}) { const dir = path.dirname(file); fs.mkdirSync(dir, { recursive: true }); const tmp = `${file}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`; const text = typeof data === 'string' ? data : JSON.stringify(data, null, opts.indent == null ? 1 : opts.indent); let fd = null; try { fd = fs.openSync(tmp, 'w', opts.mode || 0o600); fs.writeFileSync(fd, text, 'utf8'); try { fs.fsyncSync(fd); } catch (_) {} fs.closeSync(fd); fd = null; if (opts.keepBackup && fs.existsSync(file)) { try { fs.copyFileSync(file, file + '.bak'); } catch (_) {} } fs.renameSync(tmp, file); if (opts.mode) { try { fs.chmodSync(file, opts.mode); } catch (_) {} } } catch (e) { if (fd !== null) { try { fs.closeSync(fd); } catch (_) {} } try { fs.unlinkSync(tmp); } catch (_) {} throw e; } } /** * JSON 읽기. 파일이 없으면 fallback(정상). 파일이 있는데 깨졌으면 손상으로 기록하고 .bak 을 읽어 본다 — 그것도 안 되면 strict 일 때 예외, 아니면 fallback 을 돌려주되 * health() 에 남아 관리자가 알 수 있다(요구 10: "빈 데이터로 바꿔 정상처럼 처리하지 않는다"). */ function readJson(file, fallback, opts = {}) { let raw; try { raw = fs.readFileSync(file, 'utf8'); } catch (e) { if (e.code === 'ENOENT') return fallback; corrupt.set(file, { at: new Date().toISOString(), error: e.message }); if (opts.strict) throw e; return fallback; } try { const v = JSON.parse(raw); corrupt.delete(file); return v; } catch (e) { let recovered = false; let val = fallback; try { val = JSON.parse(fs.readFileSync(file + '.bak', 'utf8')); recovered = true; } catch (_) {} corrupt.set(file, { at: new Date().toISOString(), error: `JSON 파싱 실패: ${e.message}`, recoveredFromBackup: recovered, size: raw.length }); if (!recovered && opts.strict) throw Object.assign(new Error(`${path.basename(file)} 이(가) 손상되었습니다 (${e.message}). 백업(.bak)도 없습니다 — 관리자 확인 필요`), { status: 500, corruptFile: file }); return val; } } /** 같은 키의 비동기 작업을 순서대로 */ async function withLock(key, fn) { const prev = locks.get(key) || Promise.resolve(); let release; const mine = new Promise((r) => { release = r; }); const chained = prev.then(() => mine); locks.set(key, chained); await prev; try { return await fn(); } finally { release(); if (locks.get(key) === chained) locks.delete(key); } } /** 손상 파일 목록 */ function health() { return [...corrupt.entries()].map(([file, v]) => ({ file, ...v })); } function clearCorrupt(file) { corrupt.delete(file); } module.exports = { writeJsonAtomic, readJson, withLock, health, clearCorrupt };