/** * PPT 썸네일 작업 이벤트 로그 — PostgreSQL 우선, PG 없을 때만 data/thumbnail-events.json 폴백 */ const fs = require("fs/promises"); const { v4: uuidv4 } = require("uuid"); /** * @param {import("pg").QueryResultRow} row */ function mapRowToEvent(row) { if (!row) return null; return { id: row.id, at: row.occurred_at ? new Date(row.occurred_at).toISOString() : null, type: row.event_type, lectureId: row.lecture_id || undefined, lectureTitle: row.lecture_title || undefined, reason: row.reason || undefined, force: row.force_flag === true, queueSizeAfter: row.queue_size_after != null ? Number(row.queue_size_after) : undefined, retryCount: row.retry_count != null ? Number(row.retry_count) : undefined, durationMs: row.duration_ms != null ? Number(row.duration_ms) : undefined, error: row.error_text || undefined, }; } /** * @param {import("pg").Pool | null} pgPool * @param {string} jsonPath */ async function readThumbnailEvents(pgPool, jsonPath) { if (pgPool) { const r = await pgPool.query( `SELECT id, occurred_at, event_type, lecture_id, lecture_title, reason, force_flag, queue_size_after, retry_count, duration_ms, error_text FROM lecture_thumbnail_events ORDER BY occurred_at ASC` ); return (r.rows || []).map(mapRowToEvent); } try { const raw = await fs.readFile(jsonPath, "utf-8"); const parsed = JSON.parse(raw); return Array.isArray(parsed) ? parsed : []; } catch { return []; } } /** * @param {import("pg").Pool} pgPool * @param {number} keep */ async function pruneThumbnailEvents(pgPool, keep) { const k = Math.max(Number(keep) || 200, 20); await pgPool.query( `DELETE FROM lecture_thumbnail_events a USING ( SELECT id FROM ( SELECT id, ROW_NUMBER() OVER (ORDER BY occurred_at DESC) AS rn FROM lecture_thumbnail_events ) t WHERE t.rn > $1 ) d WHERE a.id = d.id`, [k] ); } /** * @param {import("pg").Pool | null} pgPool * @param {string} jsonPath * @param {number} keep * @param {object} payload */ async function appendThumbnailEvent(pgPool, jsonPath, keep, payload) { const id = uuidv4(); const at = new Date().toISOString(); if (pgPool) { await pgPool.query( `INSERT INTO lecture_thumbnail_events ( id, occurred_at, event_type, lecture_id, lecture_title, reason, force_flag, queue_size_after, retry_count, duration_ms, error_text ) VALUES ($1, $2::timestamptz, $3, $4::uuid, $5, $6, $7, $8, $9, $10, $11)`, [ id, at, payload.type || "unknown", payload.lectureId || null, payload.lectureTitle || null, payload.reason || null, payload.force === true, payload.queueSizeAfter != null ? payload.queueSizeAfter : null, payload.retryCount != null ? payload.retryCount : null, payload.durationMs != null ? payload.durationMs : null, payload.error || null, ] ); await pruneThumbnailEvents(pgPool, keep); return; } const events = await readThumbnailEvents(null, jsonPath); events.push({ id, at, ...payload }); const sliced = events.slice(-Math.max(keep, 20)); await fs.writeFile(jsonPath, JSON.stringify(sliced, null, 2), "utf-8"); } /** * @param {import("pg").Pool | null} pgPool * @param {string} jsonPath */ async function clearThumbnailEvents(pgPool, jsonPath) { if (pgPool) { await pgPool.query(`DELETE FROM lecture_thumbnail_events`); return; } await fs.writeFile(jsonPath, "[]", "utf-8"); } /** * 기존 JSON 파일을 한 번 DB로 옮긴 뒤 파일을 백업 이름으로 변경 (PG 사용 시) * @param {import("pg").Pool | null} pgPool * @param {string} jsonPath */ /** * @param {import("pg").Pool | null} pgPool * @param {string} jsonPath * @param {number} [keep] */ async function migrateThumbnailEventsFromJson(pgPool, jsonPath, keep) { if (!pgPool) return; const keepN = Math.max(Number(keep) || 200, 20); let rows; try { const raw = await fs.readFile(jsonPath, "utf-8"); const parsed = JSON.parse(raw); rows = Array.isArray(parsed) ? parsed : []; } catch { return; } if (rows.length === 0) return; for (const evt of rows) { if (!evt || !evt.id || !evt.type) continue; try { await pgPool.query( `INSERT INTO lecture_thumbnail_events ( id, occurred_at, event_type, lecture_id, lecture_title, reason, force_flag, queue_size_after, retry_count, duration_ms, error_text ) VALUES ($1::uuid, $2::timestamptz, $3, $4::uuid, $5, $6, $7, $8, $9, $10, $11) ON CONFLICT (id) DO NOTHING`, [ evt.id, evt.at || new Date().toISOString(), evt.type, evt.lectureId || null, evt.lectureTitle || null, evt.reason || null, evt.force === true, evt.queueSizeAfter != null ? evt.queueSizeAfter : null, evt.retryCount != null ? evt.retryCount : null, evt.durationMs != null ? evt.durationMs : null, evt.error || null, ] ); } catch { /* ignore row */ } } await pruneThumbnailEvents(pgPool, keepN); try { const fsSync = require("fs"); if (fsSync.existsSync(jsonPath)) { fsSync.renameSync(jsonPath, `${jsonPath}.migrated.bak`); } } catch (e) { console.warn("[thumbnail-events] JSON migration rename failed:", e?.message || e); } } module.exports = { readThumbnailEvents, appendThumbnailEvent, clearThumbnailEvents, migrateThumbnailEventsFromJson, };