#!/usr/bin/env node /** * .env.ncue(원본 DB) → .env(대상 DB) 데이터 복사 * * 학습센터(강의 목록): lectures 테이블 * 선택: lecture_thumbnail_events (썸네일 작업 로그) * * 사용 전: * 1) 대상 DB에 스키마 적용: node scripts/apply-schema.js (.env 기준) * 2) 대상 서버에 강의 파일 동기화(별도): DB만으로는 PPT/슬라이드/썸네일 바이너리가 없음 * - resources/lecture/ (업로드 원본 PPT 등) * - uploads/thumbnails/ (썸네일) * - uploads/slides/<강의UUID>/ (슬라이드 이미지) * * 실행 (저장소 루트에서): * node scripts/migrate-db-ncue-to-env.js * node scripts/migrate-db-ncue-to-env.js --dry-run * node scripts/migrate-db-ncue-to-env.js --with-thumbnail-events * * 대상에 강의만 원본과 동일하게 맞추고(기존 행 삭제 후 복사): * node scripts/migrate-db-ncue-to-env.js --truncate-target * * 경로 변경: * SOURCE_ENV=.env.ncue TARGET_ENV=.env node scripts/migrate-db-ncue-to-env.js */ require("dotenv").config({ quiet: true }); const fs = require("fs"); const path = require("path"); const dotenv = require("dotenv"); const { Pool } = require("pg"); const ROOT = path.join(__dirname, ".."); const LECTURE_COLUMNS = [ "id", "type", "title", "description", "tags", "youtube_url", "file_name", "original_name", "preview_title", "slide_count", "thumbnail_url", "thumbnail_status", "thumbnail_retry_count", "thumbnail_error", "thumbnail_updated_at", "created_at", "updated_at", "list_section", "news_url", ]; const THUMB_EVENT_COLUMNS = [ "id", "occurred_at", "event_type", "lecture_id", "lecture_title", "reason", "force_flag", "queue_size_after", "retry_count", "duration_ms", "error_text", ]; function parseArgs(argv) { const out = { dryRun: false, withThumbnailEvents: false, truncateTarget: false }; for (const a of argv) { if (a === "--dry-run") out.dryRun = true; if (a === "--with-thumbnail-events") out.withThumbnailEvents = true; if (a === "--truncate-target") out.truncateTarget = true; } return out; } function loadEnvFile(filePath) { const abs = path.isAbsolute(filePath) ? filePath : path.join(ROOT, filePath); if (!fs.existsSync(abs)) { throw new Error(`환경 파일이 없습니다: ${abs}`); } return dotenv.parse(fs.readFileSync(abs, "utf8")); } function poolConfigFromEnv(e) { const missing = ["DB_HOST", "DB_DATABASE", "DB_USERNAME", "DB_PASSWORD"].filter((k) => !e[k]); if (missing.length) { throw new Error(`DB 설정 누락: ${missing.join(", ")}`); } return { host: e.DB_HOST, port: Number(e.DB_PORT || 5432), database: e.DB_DATABASE, user: e.DB_USERNAME, password: e.DB_PASSWORD, ssl: e.DB_SSL === "1" ? { rejectUnauthorized: false } : false, }; } function placeholders(n) { return Array.from({ length: n }, (_, i) => `$${i + 1}`).join(", "); } function buildLectureUpsert() { const cols = LECTURE_COLUMNS; const ph = placeholders(cols.length); const updates = cols .filter((c) => c !== "id") .map((c) => `${c} = EXCLUDED.${c}`) .join(", "); return ` INSERT INTO lectures (${cols.join(", ")}) VALUES (${ph}) ON CONFLICT (id) DO UPDATE SET ${updates} `; } function buildThumbEventUpsert() { const cols = THUMB_EVENT_COLUMNS; const ph = placeholders(cols.length); const updates = cols .filter((c) => c !== "id") .map((c) => `${c} = EXCLUDED.${c}`) .join(", "); return ` INSERT INTO lecture_thumbnail_events (${cols.join(", ")}) VALUES (${ph}) ON CONFLICT (id) DO UPDATE SET ${updates} `; } function rowValues(row, columns) { return columns.map((c) => row[c]); } async function truncateTargetTables(targetPool, dryRun) { if (dryRun) { console.log( "[truncate] dry-run: lecture_thumbnail_events, lectures TRUNCATE 생략" ); return; } await targetPool.query("TRUNCATE lecture_thumbnail_events"); await targetPool.query("TRUNCATE lectures"); console.log("[truncate] 대상 lecture_thumbnail_events, lectures 비움"); } async function migrateLectures(sourcePool, targetPool, dryRun) { const { rows } = await sourcePool.query( `SELECT ${LECTURE_COLUMNS.join(", ")} FROM lectures ORDER BY created_at ASC` ); console.log(`[lectures] 원본 행 수: ${rows.length}`); if (dryRun) { console.log("[lectures] dry-run: INSERT 생략"); return rows.length; } const sql = buildLectureUpsert(); const client = await targetPool.connect(); try { await client.query("BEGIN"); let n = 0; for (const row of rows) { await client.query(sql, rowValues(row, LECTURE_COLUMNS)); n++; } await client.query("COMMIT"); console.log(`[lectures] 대상 DB 반영 완료: ${n}건`); return n; } catch (e) { await client.query("ROLLBACK"); throw e; } finally { client.release(); } } async function migrateThumbnailEvents(sourcePool, targetPool, dryRun) { const { rows } = await sourcePool.query( `SELECT ${THUMB_EVENT_COLUMNS.join(", ")} FROM lecture_thumbnail_events ORDER BY occurred_at ASC` ); console.log(`[lecture_thumbnail_events] 원본 행 수: ${rows.length}`); if (dryRun) { console.log("[lecture_thumbnail_events] dry-run: INSERT 생략"); return rows.length; } const sql = buildThumbEventUpsert(); const client = await targetPool.connect(); try { await client.query("BEGIN"); let n = 0; for (const row of rows) { await client.query(sql, rowValues(row, THUMB_EVENT_COLUMNS)); n++; } await client.query("COMMIT"); console.log(`[lecture_thumbnail_events] 대상 DB 반영 완료: ${n}건`); return n; } catch (e) { await client.query("ROLLBACK"); throw e; } finally { client.release(); } } function printFileHint() { console.log(` [파일 동기화 안내] DB만 복사하면 메타데이터만 옮겨집니다. 강의 PPT/슬라이드/썸네일이 비어 있으면 원본 서버(또는 .env.ncue가 가리키는 환경의 디스크)에서 대상(ai.xavis.co.kr) 배포 디렉터리로 복사하세요. 예시 (원본 호스트에서 대상으로 rsync — 경로는 실제 배포에 맞게 조정): rsync -avz ./resources/lecture/ user@ai-host:/path/to/webplatform/resources/lecture/ rsync -avz ./uploads/thumbnails/ user@ai-host:/path/to/webplatform/uploads/thumbnails/ rsync -avz ./uploads/slides/ user@ai-host:/path/to/webplatform/uploads/slides/ `); } async function main() { const args = parseArgs(process.argv.slice(2)); const sourcePath = process.env.SOURCE_ENV || ".env.ncue"; const targetPath = process.env.TARGET_ENV || ".env"; const sourceEnv = loadEnvFile(sourcePath); const targetEnv = loadEnvFile(targetPath); const srcCfg = poolConfigFromEnv(sourceEnv); const tgtCfg = poolConfigFromEnv(targetEnv); console.log(`원본: ${sourcePath} → ${srcCfg.host}/${srcCfg.database}`); console.log(`대상: ${targetPath} → ${tgtCfg.host}/${tgtCfg.database}`); if (args.dryRun) { console.log("모드: DRY-RUN (쓰기 없음)\n"); } const sourcePool = new Pool(srcCfg); const targetPool = new Pool(tgtCfg); try { await sourcePool.query("SELECT 1"); await targetPool.query("SELECT 1"); } catch (e) { console.error("DB 연결 실패:", e.message); process.exit(1); } try { if (args.truncateTarget) { await truncateTargetTables(targetPool, args.dryRun); } await migrateLectures(sourcePool, targetPool, args.dryRun); if (args.withThumbnailEvents) { await migrateThumbnailEvents(sourcePool, targetPool, args.dryRun); } printFileHint(); console.log("완료."); } catch (e) { console.error("마이그레이션 실패:", e.message || e); process.exit(1); } finally { await sourcePool.end(); await targetPool.end(); } } main();