270 lines
7.8 KiB
JavaScript
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();
|