Files

60 lines
4.5 KiB
JavaScript
Raw Permalink Normal View History

'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 };