Files
ai_platform/scripts/migrate-db-ncue-to-env.js

270 lines
7.8 KiB
JavaScript

#!/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();