import express from "express"; import bcrypt from "bcrypt"; import jwt from "jsonwebtoken"; import multer from "multer"; import sharp from "sharp"; import ffmpeg from "fluent-ffmpeg"; import fs from "fs/promises"; import os from "os"; import path from "path"; import { S3Client, PutObjectCommand, DeleteObjectCommand, } from "@aws-sdk/client-s3"; import pool from "../lib/db.js"; import { syncNewVideos, syncAllVideos } from "../services/youtube-bot.js"; import { syncAllTweets } from "../services/x-bot.js"; import { syncAllSchedules } from "../services/meilisearch-bot.js"; import { startBot, stopBot } from "../services/youtube-scheduler.js"; import { addOrUpdateSchedule, deleteSchedule as deleteScheduleFromSearch, } from "../services/meilisearch.js"; const router = express.Router(); // JWT 설정 const JWT_SECRET = process.env.JWT_SECRET || "fromis9-admin-secret-key-2026"; const JWT_EXPIRES_IN = "30d"; // Multer 설정 (메모리 저장) const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 50 * 1024 * 1024 }, // 50MB (동영상 지원) fileFilter: (req, file, cb) => { // 이미지 또는 MP4 비디오 허용 if (file.mimetype.startsWith("image/") || file.mimetype === "video/mp4") { cb(null, true); } else { cb(new Error("이미지 또는 MP4 파일만 업로드 가능합니다."), false); } }, }); // S3 클라이언트 (RustFS) const s3Client = new S3Client({ endpoint: process.env.RUSTFS_ENDPOINT, region: "us-east-1", credentials: { accessKeyId: process.env.RUSTFS_ACCESS_KEY, secretAccessKey: process.env.RUSTFS_SECRET_KEY, }, forcePathStyle: true, }); const BUCKET = process.env.RUSTFS_BUCKET || "fromis-9"; // 토큰 검증 미들웨어 export const authenticateToken = (req, res, next) => { const authHeader = req.headers["authorization"]; const token = authHeader && authHeader.split(" ")[1]; if (!token) { return res.status(401).json({ error: "인증이 필요합니다." }); } jwt.verify(token, JWT_SECRET, (err, user) => { if (err) { return res.status(403).json({ error: "유효하지 않은 토큰입니다." }); } req.user = user; next(); }); }; // 관리자 로그인 router.post("/login", async (req, res) => { try { const { username, password } = req.body; if (!username || !password) { return res .status(400) .json({ error: "아이디와 비밀번호를 입력해주세요." }); } const [users] = await pool.query( "SELECT * FROM admin_users WHERE username = ?", [username] ); if (users.length === 0) { return res .status(401) .json({ error: "아이디 또는 비밀번호가 올바르지 않습니다." }); } const user = users[0]; const isValidPassword = await bcrypt.compare(password, user.password_hash); if (!isValidPassword) { return res .status(401) .json({ error: "아이디 또는 비밀번호가 올바르지 않습니다." }); } const token = jwt.sign( { id: user.id, username: user.username }, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN } ); res.json({ message: "로그인 성공", token, user: { id: user.id, username: user.username }, }); } catch (error) { console.error("로그인 오류:", error); res.status(500).json({ error: "로그인 처리 중 오류가 발생했습니다." }); } }); // 토큰 검증 엔드포인트 router.get("/verify", authenticateToken, (req, res) => { res.json({ valid: true, user: req.user }); }); // 초기 관리자 계정 생성 router.post("/init", async (req, res) => { try { const [existing] = await pool.query( "SELECT COUNT(*) as count FROM admin_users" ); if (existing[0].count > 0) { return res.status(400).json({ error: "이미 관리자 계정이 존재합니다." }); } const password = "auddnek0403!"; const passwordHash = await bcrypt.hash(password, 10); await pool.query( "INSERT INTO admin_users (username, password_hash) VALUES (?, ?)", ["admin", passwordHash] ); res.json({ message: "관리자 계정이 생성되었습니다." }); } catch (error) { console.error("계정 생성 오류:", error); res.status(500).json({ error: "계정 생성 중 오류가 발생했습니다." }); } }); // ==================== 앨범 관리 API ==================== // 앨범 생성 router.post( "/albums", authenticateToken, upload.single("cover"), async (req, res) => { const connection = await pool.getConnection(); try { await connection.beginTransaction(); const data = JSON.parse(req.body.data); const { title, album_type, album_type_short, release_date, folder_name, description, tracks, } = data; // 필수 필드 검증 if (!title || !album_type || !release_date || !folder_name) { return res .status(400) .json({ error: "필수 필드를 모두 입력해주세요." }); } let coverOriginalUrl = null; let coverMediumUrl = null; let coverThumbUrl = null; // 커버 이미지 업로드 (3개 해상도) if (req.file) { // 3가지 크기로 변환 (병렬) const [originalBuffer, mediumBuffer, thumbBuffer] = await Promise.all([ sharp(req.file.buffer).webp({ lossless: true }).toBuffer(), sharp(req.file.buffer) .resize(800, null, { withoutEnlargement: true }) .webp({ quality: 85 }) .toBuffer(), sharp(req.file.buffer) .resize(400, null, { withoutEnlargement: true }) .webp({ quality: 80 }) .toBuffer(), ]); const basePath = `album/${folder_name}/cover`; // S3 업로드 (병렬) await Promise.all([ s3Client.send( new PutObjectCommand({ Bucket: BUCKET, Key: `${basePath}/original/cover.webp`, Body: originalBuffer, ContentType: "image/webp", }) ), s3Client.send( new PutObjectCommand({ Bucket: BUCKET, Key: `${basePath}/medium_800/cover.webp`, Body: mediumBuffer, ContentType: "image/webp", }) ), s3Client.send( new PutObjectCommand({ Bucket: BUCKET, Key: `${basePath}/thumb_400/cover.webp`, Body: thumbBuffer, ContentType: "image/webp", }) ), ]); const publicUrl = process.env.RUSTFS_PUBLIC_URL || process.env.RUSTFS_ENDPOINT; coverOriginalUrl = `${publicUrl}/${BUCKET}/${basePath}/original/cover.webp`; coverMediumUrl = `${publicUrl}/${BUCKET}/${basePath}/medium_800/cover.webp`; coverThumbUrl = `${publicUrl}/${BUCKET}/${basePath}/thumb_400/cover.webp`; } // 앨범 삽입 const [albumResult] = await connection.query( `INSERT INTO albums (title, album_type, album_type_short, release_date, folder_name, cover_original_url, cover_medium_url, cover_thumb_url, description) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ title, album_type, album_type_short || null, release_date, folder_name, coverOriginalUrl, coverMediumUrl, coverThumbUrl, description || null, ] ); const albumId = albumResult.insertId; // 트랙 삽입 if (tracks && tracks.length > 0) { for (const track of tracks) { await connection.query( `INSERT INTO tracks (album_id, track_number, title, duration, is_title_track, lyricist, composer, arranger, lyrics, music_video_url) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ albumId, track.track_number, track.title, track.duration || null, track.is_title_track ? 1 : 0, track.lyricist || null, track.composer || null, track.arranger || null, track.lyrics || null, track.music_video_url || null, ] ); } } await connection.commit(); res.json({ message: "앨범이 생성되었습니다.", albumId }); } catch (error) { await connection.rollback(); console.error("앨범 생성 오류:", error); res.status(500).json({ error: "앨범 생성 중 오류가 발생했습니다." }); } finally { connection.release(); } } ); // 앨범 수정 router.put( "/albums/:id", authenticateToken, upload.single("cover"), async (req, res) => { const connection = await pool.getConnection(); try { await connection.beginTransaction(); const albumId = req.params.id; const data = JSON.parse(req.body.data); const { title, album_type, album_type_short, release_date, folder_name, description, tracks, } = data; // 기존 앨범 조회 const [existingAlbums] = await connection.query( "SELECT * FROM albums WHERE id = ?", [albumId] ); if (existingAlbums.length === 0) { return res.status(404).json({ error: "앨범을 찾을 수 없습니다." }); } let coverOriginalUrl = existingAlbums[0].cover_original_url; let coverMediumUrl = existingAlbums[0].cover_medium_url; let coverThumbUrl = existingAlbums[0].cover_thumb_url; // 커버 이미지 업로드 (3개 해상도) if (req.file) { // 3가지 크기로 변환 (병렬) const [originalBuffer, mediumBuffer, thumbBuffer] = await Promise.all([ sharp(req.file.buffer).webp({ lossless: true }).toBuffer(), sharp(req.file.buffer) .resize(800, null, { withoutEnlargement: true }) .webp({ quality: 85 }) .toBuffer(), sharp(req.file.buffer) .resize(400, null, { withoutEnlargement: true }) .webp({ quality: 80 }) .toBuffer(), ]); const basePath = `album/${folder_name}/cover`; // S3 업로드 (병렬) await Promise.all([ s3Client.send( new PutObjectCommand({ Bucket: BUCKET, Key: `${basePath}/original/cover.webp`, Body: originalBuffer, ContentType: "image/webp", }) ), s3Client.send( new PutObjectCommand({ Bucket: BUCKET, Key: `${basePath}/medium_800/cover.webp`, Body: mediumBuffer, ContentType: "image/webp", }) ), s3Client.send( new PutObjectCommand({ Bucket: BUCKET, Key: `${basePath}/thumb_400/cover.webp`, Body: thumbBuffer, ContentType: "image/webp", }) ), ]); const publicUrl = process.env.RUSTFS_PUBLIC_URL || process.env.RUSTFS_ENDPOINT; coverOriginalUrl = `${publicUrl}/${BUCKET}/${basePath}/original/cover.webp`; coverMediumUrl = `${publicUrl}/${BUCKET}/${basePath}/medium_800/cover.webp`; coverThumbUrl = `${publicUrl}/${BUCKET}/${basePath}/thumb_400/cover.webp`; } // 앨범 업데이트 await connection.query( `UPDATE albums SET title = ?, album_type = ?, album_type_short = ?, release_date = ?, folder_name = ?, cover_original_url = ?, cover_medium_url = ?, cover_thumb_url = ?, description = ? WHERE id = ?`, [ title, album_type, album_type_short || null, release_date, folder_name, coverOriginalUrl, coverMediumUrl, coverThumbUrl, description || null, albumId, ] ); // 기존 트랙 삭제 후 새 트랙 삽입 await connection.query("DELETE FROM tracks WHERE album_id = ?", [ albumId, ]); if (tracks && tracks.length > 0) { for (const track of tracks) { await connection.query( `INSERT INTO tracks (album_id, track_number, title, duration, is_title_track, lyricist, composer, arranger, lyrics, music_video_url) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ albumId, track.track_number, track.title, track.duration || null, track.is_title_track ? 1 : 0, track.lyricist || null, track.composer || null, track.arranger || null, track.lyrics || null, track.music_video_url || null, ] ); } } await connection.commit(); res.json({ message: "앨범이 수정되었습니다." }); } catch (error) { await connection.rollback(); console.error("앨범 수정 오류:", error); res.status(500).json({ error: "앨범 수정 중 오류가 발생했습니다." }); } finally { connection.release(); } } ); // 앨범 삭제 router.delete("/albums/:id", authenticateToken, async (req, res) => { const connection = await pool.getConnection(); try { await connection.beginTransaction(); const albumId = req.params.id; // 기존 앨범 조회 const [existingAlbums] = await connection.query( "SELECT * FROM albums WHERE id = ?", [albumId] ); if (existingAlbums.length === 0) { return res.status(404).json({ error: "앨범을 찾을 수 없습니다." }); } const album = existingAlbums[0]; // RustFS에서 커버 이미지 삭제 (3가지 크기) if (album.cover_original_url && album.folder_name) { const basePath = `album/${album.folder_name}/cover`; const sizes = ["original", "medium_800", "thumb_400"]; for (const size of sizes) { try { await s3Client.send( new DeleteObjectCommand({ Bucket: BUCKET, Key: `${basePath}/${size}/cover.webp`, }) ); } catch (s3Error) { console.error(`S3 커버 삭제 오류 (${size}):`, s3Error); } } } // 트랙 삭제 await connection.query("DELETE FROM tracks WHERE album_id = ?", [albumId]); // 앨범 삭제 await connection.query("DELETE FROM albums WHERE id = ?", [albumId]); await connection.commit(); res.json({ message: "앨범이 삭제되었습니다." }); } catch (error) { await connection.rollback(); console.error("앨범 삭제 오류:", error); res.status(500).json({ error: "앨범 삭제 중 오류가 발생했습니다." }); } finally { connection.release(); } }); // ============================================ // 앨범 사진 관리 API // ============================================ // 앨범 사진 목록 조회 router.get("/albums/:albumId/photos", async (req, res) => { try { const { albumId } = req.params; // 앨범 존재 확인 const [albums] = await pool.query( "SELECT folder_name FROM albums WHERE id = ?", [albumId] ); if (albums.length === 0) { return res.status(404).json({ error: "앨범을 찾을 수 없습니다." }); } const folderName = albums[0].folder_name; // 사진 조회 (멤버 정보 포함) const [photos] = await pool.query( ` SELECT p.id, p.original_url, p.medium_url, p.thumb_url, p.photo_type, p.concept_name, p.sort_order, p.width, p.height, p.file_size, GROUP_CONCAT(pm.member_id) as member_ids FROM album_photos p LEFT JOIN album_photo_members pm ON p.id = pm.photo_id WHERE p.album_id = ? GROUP BY p.id ORDER BY p.sort_order ASC `, [albumId] ); // 멤버 배열 파싱 const result = photos.map((photo) => ({ ...photo, members: photo.member_ids ? photo.member_ids.split(",").map(Number) : [], })); res.json(result); } catch (error) { console.error("사진 조회 오류:", error); res.status(500).json({ error: "사진 조회 중 오류가 발생했습니다." }); } }); // 앨범 티저 목록 조회 router.get("/albums/:albumId/teasers", async (req, res) => { try { const { albumId } = req.params; // 앨범 존재 확인 const [albums] = await pool.query( "SELECT folder_name FROM albums WHERE id = ?", [albumId] ); if (albums.length === 0) { return res.status(404).json({ error: "앨범을 찾을 수 없습니다." }); } // 티저 조회 const [teasers] = await pool.query( `SELECT id, original_url, medium_url, thumb_url, video_url, sort_order, media_type FROM album_teasers WHERE album_id = ? ORDER BY sort_order ASC`, [albumId] ); res.json(teasers); } catch (error) { console.error("티저 조회 오류:", error); res.status(500).json({ error: "티저 조회 중 오류가 발생했습니다." }); } }); // 티저 삭제 router.delete( "/albums/:albumId/teasers/:teaserId", authenticateToken, async (req, res) => { const connection = await pool.getConnection(); try { await connection.beginTransaction(); const { albumId, teaserId } = req.params; // 티저 정보 조회 const [teasers] = await connection.query( "SELECT t.*, a.folder_name FROM album_teasers t JOIN albums a ON t.album_id = a.id WHERE t.id = ? AND t.album_id = ?", [teaserId, albumId] ); if (teasers.length === 0) { return res.status(404).json({ error: "티저를 찾을 수 없습니다." }); } const teaser = teasers[0]; const filename = teaser.original_url.split("/").pop(); const basePath = `album/${teaser.folder_name}/teaser`; // RustFS에서 썸네일 삭제 (3가지 크기 모두) const sizes = ["original", "medium_800", "thumb_400"]; for (const size of sizes) { try { await s3Client.send( new DeleteObjectCommand({ Bucket: BUCKET, Key: `${basePath}/${size}/${filename}`, }) ); } catch (s3Error) { console.error(`S3 삭제 오류 (${size}):`, s3Error); } } // 비디오 파일 삭제 (video_url이 있는 경우) if (teaser.video_url) { const videoFilename = teaser.video_url.split("/").pop(); try { await s3Client.send( new DeleteObjectCommand({ Bucket: BUCKET, Key: `${basePath}/video/${videoFilename}`, }) ); } catch (s3Error) { console.error("S3 비디오 삭제 오류:", s3Error); } } // 티저 삭제 await connection.query("DELETE FROM album_teasers WHERE id = ?", [ teaserId, ]); await connection.commit(); res.json({ message: "티저가 삭제되었습니다." }); } catch (error) { await connection.rollback(); console.error("티저 삭제 오류:", error); res.status(500).json({ error: "티저 삭제 중 오류가 발생했습니다." }); } finally { connection.release(); } } ); // 사진 업로드 (SSE로 실시간 진행률 전송) router.post( "/albums/:albumId/photos", authenticateToken, upload.array("photos", 200), async (req, res) => { // SSE 헤더 설정 res.setHeader("Content-Type", "text/event-stream"); res.setHeader("Cache-Control", "no-cache"); res.setHeader("Connection", "keep-alive"); const sendProgress = (current, total, message) => { res.write(`data: ${JSON.stringify({ current, total, message })}\n\n`); }; const connection = await pool.getConnection(); try { await connection.beginTransaction(); const { albumId } = req.params; const metadata = JSON.parse(req.body.metadata || "[]"); const startNumber = parseInt(req.body.startNumber) || null; const photoType = req.body.photoType || "concept"; // 'concept' | 'teaser' // 앨범 정보 조회 const [albums] = await connection.query( "SELECT folder_name FROM albums WHERE id = ?", [albumId] ); if (albums.length === 0) { return res.status(404).json({ error: "앨범을 찾을 수 없습니다." }); } const folderName = albums[0].folder_name; // 시작 번호 결정 (클라이언트 지정 또는 기존 사진 다음 번호) let nextOrder; if (startNumber && startNumber > 0) { nextOrder = startNumber; } else { const [existingPhotos] = await connection.query( "SELECT MAX(sort_order) as maxOrder FROM album_photos WHERE album_id = ?", [albumId] ); nextOrder = (existingPhotos[0].maxOrder || 0) + 1; } const uploadedPhotos = []; const totalFiles = req.files.length; for (let i = 0; i < req.files.length; i++) { const file = req.files[i]; const meta = metadata[i] || {}; const orderNum = String(nextOrder + i).padStart(2, "0"); const isVideo = file.mimetype === "video/mp4"; const extension = isVideo ? "mp4" : "webp"; const filename = `${orderNum}.${extension}`; // 진행률 전송 sendProgress(i + 1, totalFiles, `${filename} 처리 중...`); let originalUrl, mediumUrl, thumbUrl, videoUrl; let originalBuffer, originalMeta; // 컨셉 포토: photo/, 티저: teaser/ const subFolder = photoType === "teaser" ? "teaser" : "photo"; const basePath = `album/${folderName}/${subFolder}`; if (isVideo) { // ===== 비디오 파일 처리 (티저 전용) ===== const tempDir = os.tmpdir(); const tempVideoPath = path.join(tempDir, `video_${Date.now()}.mp4`); const tempThumbPath = path.join(tempDir, `thumb_${Date.now()}.png`); const thumbFilename = `${orderNum}.webp`; try { // 1. 임시 파일로 MP4 저장 await fs.writeFile(tempVideoPath, file.buffer); // 2. ffmpeg로 첫 프레임 추출 (썸네일) await new Promise((resolve, reject) => { ffmpeg(tempVideoPath) .screenshots({ timestamps: ["00:00:00.001"], filename: path.basename(tempThumbPath), folder: tempDir, }) .on("end", resolve) .on("error", reject); }); // 3. 추출된 썸네일을 Sharp로 3가지 크기로 변환 const thumbBuffer = await fs.readFile(tempThumbPath); const [origBuf, medium800Buffer, thumb400Buffer] = await Promise.all([ sharp(thumbBuffer).webp({ lossless: true }).toBuffer(), sharp(thumbBuffer) .resize(800, null, { withoutEnlargement: true }) .webp({ quality: 85 }) .toBuffer(), sharp(thumbBuffer) .resize(400, null, { withoutEnlargement: true }) .webp({ quality: 80 }) .toBuffer(), ]); // 4. 썸네일 이미지들과 MP4 업로드 (병렬) await Promise.all([ // 썸네일 original s3Client.send( new PutObjectCommand({ Bucket: BUCKET, Key: `${basePath}/original/${thumbFilename}`, Body: origBuf, ContentType: "image/webp", }) ), // 썸네일 medium s3Client.send( new PutObjectCommand({ Bucket: BUCKET, Key: `${basePath}/medium_800/${thumbFilename}`, Body: medium800Buffer, ContentType: "image/webp", }) ), // 썸네일 thumb s3Client.send( new PutObjectCommand({ Bucket: BUCKET, Key: `${basePath}/thumb_400/${thumbFilename}`, Body: thumb400Buffer, ContentType: "image/webp", }) ), // 원본 MP4 s3Client.send( new PutObjectCommand({ Bucket: BUCKET, Key: `${basePath}/video/${filename}`, Body: file.buffer, ContentType: "video/mp4", }) ), ]); // 5. URL 설정 (썸네일은 WebP, 비디오는 MP4) originalUrl = `${process.env.RUSTFS_PUBLIC_URL}/${BUCKET}/${basePath}/original/${thumbFilename}`; mediumUrl = `${process.env.RUSTFS_PUBLIC_URL}/${BUCKET}/${basePath}/medium_800/${thumbFilename}`; thumbUrl = `${process.env.RUSTFS_PUBLIC_URL}/${BUCKET}/${basePath}/thumb_400/${thumbFilename}`; videoUrl = `${process.env.RUSTFS_PUBLIC_URL}/${BUCKET}/${basePath}/video/${filename}`; } finally { // 임시 파일 정리 await fs.unlink(tempVideoPath).catch(() => {}); await fs.unlink(tempThumbPath).catch(() => {}); } } else { // ===== 이미지 파일 처리 ===== // Sharp로 이미지 처리 (병렬) const [origBuf, medium800Buffer, thumb400Buffer] = await Promise.all([ sharp(file.buffer).webp({ lossless: true }).toBuffer(), sharp(file.buffer) .resize(800, null, { withoutEnlargement: true }) .webp({ quality: 85 }) .toBuffer(), sharp(file.buffer) .resize(400, null, { withoutEnlargement: true }) .webp({ quality: 80 }) .toBuffer(), ]); originalBuffer = origBuf; originalMeta = await sharp(originalBuffer).metadata(); // RustFS 업로드 (병렬) await Promise.all([ s3Client.send( new PutObjectCommand({ Bucket: BUCKET, Key: `${basePath}/original/${filename}`, Body: originalBuffer, ContentType: "image/webp", }) ), s3Client.send( new PutObjectCommand({ Bucket: BUCKET, Key: `${basePath}/medium_800/${filename}`, Body: medium800Buffer, ContentType: "image/webp", }) ), s3Client.send( new PutObjectCommand({ Bucket: BUCKET, Key: `${basePath}/thumb_400/${filename}`, Body: thumb400Buffer, ContentType: "image/webp", }) ), ]); originalUrl = `${process.env.RUSTFS_PUBLIC_URL}/${BUCKET}/${basePath}/original/${filename}`; mediumUrl = `${process.env.RUSTFS_PUBLIC_URL}/${BUCKET}/${basePath}/medium_800/${filename}`; thumbUrl = `${process.env.RUSTFS_PUBLIC_URL}/${BUCKET}/${basePath}/thumb_400/${filename}`; } let photoId; // DB 저장 - 티저와 컨셉 포토 분기 if (photoType === "teaser") { // 티저 이미지/비디오 → album_teasers 테이블 const mediaType = isVideo ? "video" : "image"; const [result] = await connection.query( `INSERT INTO album_teasers (album_id, original_url, medium_url, thumb_url, video_url, sort_order, media_type) VALUES (?, ?, ?, ?, ?, ?, ?)`, [ albumId, originalUrl, mediumUrl, thumbUrl, videoUrl || null, nextOrder + i, mediaType, ] ); photoId = result.insertId; } else { // 컨셉 포토 → album_photos 테이블 (이미지만) const [result] = await connection.query( `INSERT INTO album_photos (album_id, original_url, medium_url, thumb_url, photo_type, concept_name, sort_order, width, height, file_size) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ albumId, originalUrl, mediumUrl, thumbUrl, meta.groupType || "group", meta.conceptName || null, nextOrder + i, originalMeta.width, originalMeta.height, originalBuffer.length, ] ); photoId = result.insertId; // 멤버 태깅 저장 (컨셉 포토만) if (meta.members && meta.members.length > 0) { for (const memberId of meta.members) { await connection.query( "INSERT INTO album_photo_members (photo_id, member_id) VALUES (?, ?)", [photoId, memberId] ); } } } uploadedPhotos.push({ id: photoId, original_url: originalUrl, medium_url: mediumUrl, thumb_url: thumbUrl, video_url: videoUrl || null, filename, media_type: isVideo ? "video" : "image", }); } await connection.commit(); // 완료 이벤트 전송 res.write( `data: ${JSON.stringify({ done: true, message: `${uploadedPhotos.length}개의 사진이 업로드되었습니다.`, photos: uploadedPhotos, })}\n\n` ); res.end(); } catch (error) { await connection.rollback(); console.error("사진 업로드 오류:", error); res.write( `data: ${JSON.stringify({ error: "사진 업로드 중 오류가 발생했습니다.", })}\n\n` ); res.end(); } finally { connection.release(); } } ); // 사진 삭제 router.delete( "/albums/:albumId/photos/:photoId", authenticateToken, async (req, res) => { const connection = await pool.getConnection(); try { await connection.beginTransaction(); const { albumId, photoId } = req.params; // 사진 정보 조회 const [photos] = await connection.query( "SELECT p.*, a.folder_name FROM album_photos p JOIN albums a ON p.album_id = a.id WHERE p.id = ? AND p.album_id = ?", [photoId, albumId] ); if (photos.length === 0) { return res.status(404).json({ error: "사진을 찾을 수 없습니다." }); } const photo = photos[0]; const filename = photo.original_url.split("/").pop(); const basePath = `album/${photo.folder_name}/photo`; // RustFS에서 삭제 (3가지 크기 모두) const sizes = ["original", "medium_800", "thumb_400"]; for (const size of sizes) { try { await s3Client.send( new DeleteObjectCommand({ Bucket: BUCKET, Key: `${basePath}/${size}/${filename}`, }) ); } catch (s3Error) { console.error(`S3 삭제 오류 (${size}):`, s3Error); } } // 멤버 태깅 삭제 await connection.query( "DELETE FROM album_photo_members WHERE photo_id = ?", [photoId] ); // 사진 삭제 await connection.query("DELETE FROM album_photos WHERE id = ?", [ photoId, ]); await connection.commit(); res.json({ message: "사진이 삭제되었습니다." }); } catch (error) { await connection.rollback(); console.error("사진 삭제 오류:", error); res.status(500).json({ error: "사진 삭제 중 오류가 발생했습니다." }); } finally { connection.release(); } } ); // ==================== 멤버 관리 API ==================== // 멤버 상세 조회 (이름으로) router.get("/members/:name", authenticateToken, async (req, res) => { try { const memberName = decodeURIComponent(req.params.name); const [members] = await pool.query("SELECT * FROM members WHERE name = ?", [ memberName, ]); if (members.length === 0) { return res.status(404).json({ error: "멤버를 찾을 수 없습니다." }); } res.json(members[0]); } catch (error) { console.error("멤버 조회 오류:", error); res.status(500).json({ error: "멤버 조회 중 오류가 발생했습니다." }); } }); // 멤버 수정 (이름으로) router.put( "/members/:name", authenticateToken, upload.single("image"), async (req, res) => { try { const memberName = decodeURIComponent(req.params.name); const { name, birth_date, position, instagram, is_former } = req.body; // 기존 멤버 확인 const [existing] = await pool.query( "SELECT * FROM members WHERE name = ?", [memberName] ); if (existing.length === 0) { return res.status(404).json({ error: "멤버를 찾을 수 없습니다." }); } const memberId = existing[0].id; let imageUrl = existing[0].image_url; // 새 이미지 업로드 if (req.file) { const webpBuffer = await sharp(req.file.buffer) .webp({ quality: 90 }) .toBuffer(); const key = `member/${memberId}/profile.webp`; await s3Client.send( new PutObjectCommand({ Bucket: BUCKET, Key: key, Body: webpBuffer, ContentType: "image/webp", }) ); const publicUrl = process.env.RUSTFS_PUBLIC_URL || process.env.RUSTFS_ENDPOINT; imageUrl = `${publicUrl}/${BUCKET}/${key}`; } // 멤버 업데이트 await pool.query( `UPDATE members SET name = ?, birth_date = ?, position = ?, instagram = ?, is_former = ?, image_url = ? WHERE id = ?`, [ name, birth_date || null, position || null, instagram || null, is_former === "true" || is_former === true ? 1 : 0, imageUrl, memberId, ] ); res.json({ message: "멤버 정보가 수정되었습니다." }); } catch (error) { console.error("멤버 수정 오류:", error); res.status(500).json({ error: "멤버 수정 중 오류가 발생했습니다." }); } } ); // ==================== 일정 카테고리 관리 API ==================== // 카테고리 목록 조회 (인증 불필요 - 폼에서 사용) router.get("/schedule-categories", async (req, res) => { try { const [categories] = await pool.query( "SELECT * FROM schedule_categories ORDER BY sort_order ASC" ); res.json(categories); } catch (error) { console.error("카테고리 조회 오류:", error); res.status(500).json({ error: "카테고리 조회 중 오류가 발생했습니다." }); } }); // 카테고리 생성 router.post("/schedule-categories", authenticateToken, async (req, res) => { try { const { name, color } = req.body; if (!name || !color) { return res.status(400).json({ error: "이름과 색상은 필수입니다." }); } // 현재 최대 sort_order 조회 const [maxOrder] = await pool.query( "SELECT MAX(sort_order) as maxOrder FROM schedule_categories" ); const nextOrder = (maxOrder[0].maxOrder || 0) + 1; const [result] = await pool.query( "INSERT INTO schedule_categories (name, color, sort_order) VALUES (?, ?, ?)", [name, color, nextOrder] ); res.json({ message: "카테고리가 생성되었습니다.", id: result.insertId, sort_order: nextOrder, }); } catch (error) { console.error("카테고리 생성 오류:", error); res.status(500).json({ error: "카테고리 생성 중 오류가 발생했습니다." }); } }); // 카테고리 수정 router.put("/schedule-categories/:id", authenticateToken, async (req, res) => { try { const { id } = req.params; const { name, color, sort_order } = req.body; const [existing] = await pool.query( "SELECT * FROM schedule_categories WHERE id = ?", [id] ); if (existing.length === 0) { return res.status(404).json({ error: "카테고리를 찾을 수 없습니다." }); } await pool.query( "UPDATE schedule_categories SET name = ?, color = ?, sort_order = ? WHERE id = ?", [ name || existing[0].name, color || existing[0].color, sort_order !== undefined ? sort_order : existing[0].sort_order, id, ] ); res.json({ message: "카테고리가 수정되었습니다." }); } catch (error) { console.error("카테고리 수정 오류:", error); res.status(500).json({ error: "카테고리 수정 중 오류가 발생했습니다." }); } }); // 카테고리 삭제 router.delete( "/schedule-categories/:id", authenticateToken, async (req, res) => { try { const { id } = req.params; const [existing] = await pool.query( "SELECT * FROM schedule_categories WHERE id = ?", [id] ); if (existing.length === 0) { return res.status(404).json({ error: "카테고리를 찾을 수 없습니다." }); } // 기본 카테고리는 삭제 불가 if (existing[0].is_default === 1) { return res .status(400) .json({ error: "기본 카테고리는 삭제할 수 없습니다." }); } // 해당 카테고리를 사용하는 일정이 있는지 확인 const [usedSchedules] = await pool.query( "SELECT COUNT(*) as count FROM schedules WHERE category_id = ?", [id] ); if (usedSchedules[0].count > 0) { return res.status(400).json({ error: `해당 카테고리를 사용하는 일정이 ${usedSchedules[0].count}개 있어 삭제할 수 없습니다.`, }); } await pool.query("DELETE FROM schedule_categories WHERE id = ?", [id]); res.json({ message: "카테고리가 삭제되었습니다." }); } catch (error) { console.error("카테고리 삭제 오류:", error); res.status(500).json({ error: "카테고리 삭제 중 오류가 발생했습니다." }); } } ); // 카테고리 순서 일괄 업데이트 router.put( "/schedule-categories-order", authenticateToken, async (req, res) => { const connection = await pool.getConnection(); try { await connection.beginTransaction(); const { orders } = req.body; // [{ id: 1, sort_order: 1 }, { id: 2, sort_order: 2 }, ...] if (!orders || !Array.isArray(orders)) { return res.status(400).json({ error: "순서 데이터가 필요합니다." }); } for (const item of orders) { await connection.query( "UPDATE schedule_categories SET sort_order = ? WHERE id = ?", [item.sort_order, item.id] ); } await connection.commit(); res.json({ message: "순서가 업데이트되었습니다." }); } catch (error) { await connection.rollback(); console.error("순서 업데이트 오류:", error); res.status(500).json({ error: "순서 업데이트 중 오류가 발생했습니다." }); } finally { connection.release(); } } ); // ==================== 일정 관리 API ==================== // 일정 목록 조회 router.get("/schedules", async (req, res) => { try { const { year, month, search } = req.query; let whereConditions = []; let params = []; // 검색어가 있으면 전체 일정에서 검색 (년/월 필터 무시) if (search && search.trim()) { const searchTerm = `%${search.trim()}%`; whereConditions.push("(s.title LIKE ? OR s.description LIKE ?)"); params.push(searchTerm, searchTerm); } else { // 년/월 필터링 (검색이 아닐 때만) if (year && month) { whereConditions.push("YEAR(s.date) = ? AND MONTH(s.date) = ?"); params.push(parseInt(year), parseInt(month)); } else if (year) { whereConditions.push("YEAR(s.date) = ?"); params.push(parseInt(year)); } } const whereClause = whereConditions.length > 0 ? `WHERE ${whereConditions.join(" AND ")}` : ""; const [schedules] = await pool.query( `SELECT s.id, s.title, s.date, s.time, s.end_date, s.end_time, s.category_id, s.description, s.source_url, s.source_name, s.location_name, s.location_address, s.location_detail, s.location_lat, s.location_lng, s.created_at, c.name as category_name, c.color as category_color FROM schedules s LEFT JOIN schedule_categories c ON s.category_id = c.id ${whereClause} ORDER BY s.date ASC, s.time ASC`, params ); // 각 일정의 이미지와 멤버 조회 const schedulesWithDetails = await Promise.all( schedules.map(async (schedule) => { const [images] = await pool.query( "SELECT id, image_url, sort_order FROM schedule_images WHERE schedule_id = ? ORDER BY sort_order ASC", [schedule.id] ); const [members] = await pool.query( `SELECT m.id, m.name FROM schedule_members sm JOIN members m ON sm.member_id = m.id WHERE sm.schedule_id = ?`, [schedule.id] ); return { ...schedule, images, members }; }) ); res.json(schedulesWithDetails); } catch (error) { console.error("일정 조회 오류:", error); res.status(500).json({ error: "일정 조회 중 오류가 발생했습니다." }); } }); // 일정 생성 router.post( "/schedules", authenticateToken, upload.array("images", 20), async (req, res) => { const connection = await pool.getConnection(); try { await connection.beginTransaction(); const data = JSON.parse(req.body.data); const { title, date, time, endDate, endTime, isRange, category, description, url, sourceName, members, locationName, locationAddress, locationDetail, locationLat, locationLng, } = data; // 필수 필드 검증 if (!title || !date) { return res.status(400).json({ error: "제목과 날짜를 입력해주세요." }); } // 일정 삽입 const [scheduleResult] = await connection.query( `INSERT INTO schedules (title, date, time, end_date, end_time, category_id, description, source_url, source_name, location_name, location_address, location_detail, location_lat, location_lng) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ title, date, time || null, isRange && endDate ? endDate : null, isRange && endTime ? endTime : null, category || null, description || null, url || null, sourceName || null, locationName || null, locationAddress || null, locationDetail || null, locationLat || null, locationLng || null, ] ); const scheduleId = scheduleResult.insertId; // 멤버 연결 처리 (schedule_members 테이블) if (members && members.length > 0) { const memberValues = members.map((memberId) => [scheduleId, memberId]); await connection.query( `INSERT INTO schedule_members (schedule_id, member_id) VALUES ?`, [memberValues] ); } // 이미지 업로드 처리 if (req.files && req.files.length > 0) { const publicUrl = process.env.RUSTFS_PUBLIC_URL || process.env.RUSTFS_ENDPOINT; const basePath = `schedule/${scheduleId}`; for (let i = 0; i < req.files.length; i++) { const file = req.files[i]; const orderNum = String(i + 1).padStart(2, "0"); const filename = `${orderNum}.webp`; // WebP 변환 (원본만) const imageBuffer = await sharp(file.buffer) .webp({ quality: 90 }) .toBuffer(); // RustFS 업로드 (원본만) await s3Client.send( new PutObjectCommand({ Bucket: BUCKET, Key: `${basePath}/${filename}`, Body: imageBuffer, ContentType: "image/webp", }) ); const imageUrl = `${publicUrl}/${BUCKET}/${basePath}/${filename}`; // DB 저장 await connection.query( `INSERT INTO schedule_images (schedule_id, image_url, sort_order) VALUES (?, ?, ?)`, [scheduleId, imageUrl, i + 1] ); } } await connection.commit(); // Meilisearch에 동기화 try { const [categoryInfo] = await pool.query( "SELECT name, color FROM schedule_categories WHERE id = ?", [category || null] ); const [memberInfo] = await pool.query( "SELECT id, name FROM members WHERE id IN (?)", [members?.length ? members : [0]] ); await addOrUpdateSchedule({ id: scheduleId, title, description, date, time, category_id: category, category_name: categoryInfo[0]?.name || "", category_color: categoryInfo[0]?.color || "", source_name: sourceName, source_url: url, members: memberInfo, }); } catch (searchError) { console.error("Meilisearch 동기화 오류:", searchError.message); } res.json({ message: "일정이 생성되었습니다.", scheduleId }); } catch (error) { await connection.rollback(); console.error("일정 생성 오류:", error); res.status(500).json({ error: "일정 생성 중 오류가 발생했습니다." }); } finally { connection.release(); } } ); // 카카오 장소 검색 프록시 (CORS 우회) router.get("/kakao/places", authenticateToken, async (req, res) => { try { const { query } = req.query; if (!query) { return res.status(400).json({ error: "검색어를 입력해주세요." }); } const response = await fetch( `https://dapi.kakao.com/v2/local/search/keyword.json?query=${encodeURIComponent( query )}`, { headers: { Authorization: `KakaoAK ${process.env.KAKAO_REST_KEY}`, }, } ); if (!response.ok) { throw new Error(`Kakao API error: ${response.status}`); } const data = await response.json(); res.json(data); } catch (error) { console.error("카카오 장소 검색 오류:", error); res.status(500).json({ error: "장소 검색 중 오류가 발생했습니다." }); } }); // 일정 단일 조회 router.get("/schedules/:id", authenticateToken, async (req, res) => { try { const { id } = req.params; // 일정 기본 정보 조회 const [schedules] = await pool.query( `SELECT s.*, sc.name as category_name, sc.color as category_color FROM schedules s LEFT JOIN schedule_categories sc ON s.category_id = sc.id WHERE s.id = ?`, [id] ); if (schedules.length === 0) { return res.status(404).json({ error: "일정을 찾을 수 없습니다." }); } const schedule = schedules[0]; // 이미지 조회 const [images] = await pool.query( "SELECT id, image_url, sort_order FROM schedule_images WHERE schedule_id = ? ORDER BY sort_order ASC", [id] ); // 멤버 조회 const [members] = await pool.query( `SELECT m.id, m.name, m.image_url FROM schedule_members sm JOIN members m ON sm.member_id = m.id WHERE sm.schedule_id = ?`, [id] ); res.json({ ...schedule, images, members }); } catch (error) { console.error("일정 조회 오류:", error); res.status(500).json({ error: "일정 조회 중 오류가 발생했습니다." }); } }); // 일정 수정 router.put( "/schedules/:id", authenticateToken, upload.array("images", 20), async (req, res) => { const connection = await pool.getConnection(); try { await connection.beginTransaction(); const { id } = req.params; const data = JSON.parse(req.body.data || "{}"); const { title, date, time, endDate, endTime, isRange, category, description, url, sourceName, members, locationName, locationAddress, locationDetail, locationLat, locationLng, existingImages, // 유지할 기존 이미지 ID 배열 } = data; // 필수 필드 검증 if (!title || !date) { return res.status(400).json({ error: "제목과 날짜를 입력해주세요." }); } // 일정 업데이트 await connection.query( `UPDATE schedules SET title = ?, date = ?, time = ?, end_date = ?, end_time = ?, category_id = ?, description = ?, source_url = ?, source_name = ?, location_name = ?, location_address = ?, location_detail = ?, location_lat = ?, location_lng = ? WHERE id = ?`, [ title, date, time || null, isRange && endDate ? endDate : null, isRange && endTime ? endTime : null, category || null, description || null, url || null, sourceName || null, locationName || null, locationAddress || null, locationDetail || null, locationLat || null, locationLng || null, id, ] ); // 멤버 업데이트 (기존 삭제 후 새로 추가) await connection.query( "DELETE FROM schedule_members WHERE schedule_id = ?", [id] ); if (members && members.length > 0) { const memberValues = members.map((memberId) => [id, memberId]); await connection.query( `INSERT INTO schedule_members (schedule_id, member_id) VALUES ?`, [memberValues] ); } // 삭제할 이미지 처리 (existingImages에 없는 이미지 삭제) const existingImageIds = existingImages || []; if (existingImageIds.length > 0) { // 삭제할 이미지 조회 const [imagesToDelete] = await connection.query( `SELECT id, image_url FROM schedule_images WHERE schedule_id = ? AND id NOT IN (?)`, [id, existingImageIds] ); // S3에서 이미지 삭제 for (const img of imagesToDelete) { try { const key = img.image_url.replace( `${ process.env.RUSTFS_PUBLIC_URL || process.env.RUSTFS_ENDPOINT }/${process.env.RUSTFS_BUCKET}/`, "" ); await s3Client.send( new DeleteObjectCommand({ Bucket: process.env.RUSTFS_BUCKET, Key: key, }) ); } catch (err) { console.error("이미지 삭제 오류:", err); } } // DB에서 삭제 await connection.query( `DELETE FROM schedule_images WHERE schedule_id = ? AND id NOT IN (?)`, [id, existingImageIds] ); } else { // 기존 이미지 모두 삭제 const [allImages] = await connection.query( `SELECT id, image_url FROM schedule_images WHERE schedule_id = ?`, [id] ); for (const img of allImages) { try { const key = img.image_url.replace( `${ process.env.RUSTFS_PUBLIC_URL || process.env.RUSTFS_ENDPOINT }/${process.env.RUSTFS_BUCKET}/`, "" ); await s3Client.send( new DeleteObjectCommand({ Bucket: process.env.RUSTFS_BUCKET, Key: key, }) ); } catch (err) { console.error("이미지 삭제 오류:", err); } } await connection.query( `DELETE FROM schedule_images WHERE schedule_id = ?`, [id] ); } // 새 이미지 업로드 if (req.files && req.files.length > 0) { const publicUrl = process.env.RUSTFS_PUBLIC_URL || process.env.RUSTFS_ENDPOINT; const basePath = `schedule/${id}`; // 현재 최대 sort_order 조회 const [maxOrder] = await connection.query( "SELECT COALESCE(MAX(sort_order), 0) as max_order FROM schedule_images WHERE schedule_id = ?", [id] ); let currentOrder = maxOrder[0].max_order; for (let i = 0; i < req.files.length; i++) { const file = req.files[i]; currentOrder++; const orderNum = String(currentOrder).padStart(2, "0"); // 파일명: 01.webp, 02.webp 형식 (Date.now() 제거) const filename = `${orderNum}.webp`; const imageBuffer = await sharp(file.buffer) .webp({ quality: 90 }) .toBuffer(); await s3Client.send( new PutObjectCommand({ Bucket: process.env.RUSTFS_BUCKET, Key: `${basePath}/${filename}`, Body: imageBuffer, ContentType: "image/webp", }) ); const imageUrl = `${publicUrl}/${process.env.RUSTFS_BUCKET}/${basePath}/${filename}`; await connection.query( "INSERT INTO schedule_images (schedule_id, image_url, sort_order) VALUES (?, ?, ?)", [id, imageUrl, currentOrder] ); } } // sort_order 재정렬 (삭제로 인한 간격 제거) const [remainingImages] = await connection.query( "SELECT id FROM schedule_images WHERE schedule_id = ? ORDER BY sort_order ASC", [id] ); for (let i = 0; i < remainingImages.length; i++) { await connection.query( "UPDATE schedule_images SET sort_order = ? WHERE id = ?", [i + 1, remainingImages[i].id] ); } await connection.commit(); // Meilisearch 동기화 try { const [categoryInfo] = await pool.query( "SELECT name, color FROM schedule_categories WHERE id = ?", [category || null] ); const [memberInfo] = await pool.query( "SELECT id, name FROM members WHERE id IN (?)", [members?.length ? members : [0]] ); await addOrUpdateSchedule({ id: parseInt(id), title, description, date, time, category_id: category, category_name: categoryInfo[0]?.name || "", category_color: categoryInfo[0]?.color || "", source_name: sourceName, source_url: url, members: memberInfo, }); } catch (searchError) { console.error("Meilisearch 동기화 오류:", searchError.message); } res.json({ message: "일정이 수정되었습니다." }); } catch (error) { await connection.rollback(); console.error("일정 수정 오류:", error); res.status(500).json({ error: "일정 수정 중 오류가 발생했습니다." }); } finally { connection.release(); } } ); // 일정 삭제 router.delete("/schedules/:id", authenticateToken, async (req, res) => { const connection = await pool.getConnection(); try { await connection.beginTransaction(); const { id } = req.params; // 이미지 조회 const [images] = await connection.query( "SELECT image_url FROM schedule_images WHERE schedule_id = ?", [id] ); // S3에서 이미지 삭제 for (const img of images) { try { const key = img.image_url.replace( `${process.env.RUSTFS_PUBLIC_URL || process.env.RUSTFS_ENDPOINT}/${ process.env.RUSTFS_BUCKET }/`, "" ); await s3Client.send( new DeleteObjectCommand({ Bucket: process.env.RUSTFS_BUCKET, Key: key, }) ); } catch (err) { console.error("이미지 삭제 오류:", err); } } // 일정 삭제 (CASCADE로 schedule_images, schedule_members도 자동 삭제) await connection.query("DELETE FROM schedules WHERE id = ?", [id]); await connection.commit(); // Meilisearch에서도 삭제 try { await deleteScheduleFromSearch(id); } catch (searchError) { console.error("Meilisearch 삭제 오류:", searchError.message); } res.json({ message: "일정이 삭제되었습니다." }); } catch (error) { await connection.rollback(); console.error("일정 삭제 오류:", error); res.status(500).json({ error: "일정 삭제 중 오류가 발생했습니다." }); } finally { connection.release(); } }); // ===================================================== // YouTube 봇 API // ===================================================== // 봇 목록 조회 router.get("/bots", authenticateToken, async (req, res) => { try { const [bots] = await pool.query(` SELECT b.*, yc.channel_id, yc.channel_name, xc.username, xc.nitter_url FROM bots b LEFT JOIN bot_youtube_config yc ON b.id = yc.bot_id LEFT JOIN bot_x_config xc ON b.id = xc.bot_id ORDER BY b.id ASC `); res.json(bots); } catch (error) { console.error("봇 목록 조회 오류:", error); res.status(500).json({ error: "봇 목록 조회 중 오류가 발생했습니다." }); } }); // 봇 시작 router.post("/bots/:id/start", authenticateToken, async (req, res) => { try { const { id } = req.params; await startBot(id); res.json({ message: "봇이 시작되었습니다." }); } catch (error) { console.error("봇 시작 오류:", error); res .status(500) .json({ error: error.message || "봇 시작 중 오류가 발생했습니다." }); } }); // 봇 정지 router.post("/bots/:id/stop", authenticateToken, async (req, res) => { try { const { id } = req.params; await stopBot(id); res.json({ message: "봇이 정지되었습니다." }); } catch (error) { console.error("봇 정지 오류:", error); res .status(500) .json({ error: error.message || "봇 정지 중 오류가 발생했습니다." }); } }); // 전체 동기화 (초기화) router.post("/bots/:id/sync-all", authenticateToken, async (req, res) => { try { const { id } = req.params; // 봇 타입 조회 const [bots] = await pool.query("SELECT type FROM bots WHERE id = ?", [id]); if (bots.length === 0) { return res.status(404).json({ error: "봇을 찾을 수 없습니다." }); } const botType = bots[0].type; let result; if (botType === "youtube") { result = await syncAllVideos(id); } else if (botType === "x") { result = await syncAllTweets(id); } else if (botType === "meilisearch") { result = await syncAllSchedules(id); } else { return res .status(400) .json({ error: `지원하지 않는 봇 타입: ${botType}` }); } res.json({ message: `${result.addedCount}개 일정이 추가되었습니다.`, addedCount: result.addedCount, total: result.total, }); } catch (error) { console.error("전체 동기화 오류:", error); res .status(500) .json({ error: error.message || "전체 동기화 중 오류가 발생했습니다." }); } }); // ===================================================== // YouTube API 할당량 경고 Webhook // ===================================================== // 메모리에 경고 상태 저장 (서버 재시작 시 초기화) let quotaWarning = { active: false, timestamp: null, message: null, stoppedBots: [], // 할당량 초과로 중지된 봇 ID 목록 }; // 자정 재시작 타이머 let quotaResetTimer = null; /** * 자정(LA 시간)에 봇 재시작 예약 * YouTube 할당량은 LA 태평양 시간 자정에 리셋됨 */ async function scheduleQuotaReset() { // 기존 타이머 취소 if (quotaResetTimer) { clearTimeout(quotaResetTimer); } // LA 시간으로 다음 자정 계산 const now = new Date(); const laTime = new Date( now.toLocaleString("en-US", { timeZone: "America/Los_Angeles" }) ); const tomorrow = new Date(laTime); tomorrow.setDate(tomorrow.getDate() + 1); tomorrow.setHours(0, 1, 0, 0); // 자정 1분 후 (안전 마진) // 현재 LA 시간과 다음 자정까지의 밀리초 계산 const nowLA = new Date( now.toLocaleString("en-US", { timeZone: "America/Los_Angeles" }) ); const msUntilReset = tomorrow.getTime() - nowLA.getTime(); console.log( `[Quota Reset] ${Math.round( msUntilReset / 1000 / 60 )}분 후 봇 재시작 예약됨` ); quotaResetTimer = setTimeout(async () => { console.log("[Quota Reset] 할당량 리셋 시간 도달, 봇 재시작 중..."); try { // 할당량 초과로 중지된 봇들만 재시작 for (const botId of quotaWarning.stoppedBots) { await startBot(botId); console.log(`[Quota Reset] Bot ${botId} 재시작됨`); } // 경고 상태 초기화 quotaWarning = { active: false, timestamp: null, message: null, stoppedBots: [], }; console.log("[Quota Reset] 모든 봇 재시작 완료, 경고 상태 초기화"); } catch (error) { console.error("[Quota Reset] 봇 재시작 오류:", error.message); } }, msUntilReset); } // Webhook 인증 정보 const WEBHOOK_USERNAME = "fromis9_quota_webhook"; const WEBHOOK_PASSWORD = "Qw8$kLm3nP2xVr7tYz!9"; // Basic Auth 검증 미들웨어 const verifyWebhookAuth = (req, res, next) => { const authHeader = req.headers.authorization; if (!authHeader || !authHeader.startsWith("Basic ")) { return res.status(401).json({ error: "인증이 필요합니다." }); } const base64Credentials = authHeader.split(" ")[1]; const credentials = Buffer.from(base64Credentials, "base64").toString( "utf-8" ); const [username, password] = credentials.split(":"); if (username !== WEBHOOK_USERNAME || password !== WEBHOOK_PASSWORD) { return res.status(401).json({ error: "인증 실패" }); } next(); }; // Google Cloud 할당량 경고 Webhook 수신 router.post("/quota-alert", verifyWebhookAuth, async (req, res) => { console.log("[Quota Alert] Google Cloud에서 할당량 경고 수신:", req.body); quotaWarning = { active: true, timestamp: new Date().toISOString(), message: "일일 할당량의 95%를 사용했습니다. (9,500 / 10,000 units) - 봇이 자동 중지되었습니다.", }; // 모든 실행 중인 봇 중지 try { const [runningBots] = await pool.query( "SELECT id, name FROM bots WHERE status = 'running'" ); // 중지된 봇 ID 저장 (자정에 재시작용) quotaWarning.stoppedBots = runningBots.map((bot) => bot.id); for (const bot of runningBots) { await stopBot(bot.id); console.log(`[Quota Alert] Bot ${bot.name} 중지됨`); } console.log( `[Quota Alert] ${runningBots.length}개 봇이 할당량 초과로 중지됨` ); // 자정에 봇 재시작 예약 (LA 시간 기준 = YouTube 할당량 리셋 시간) scheduleQuotaReset(); } catch (error) { console.error("[Quota Alert] 봇 중지 오류:", error.message); } res .status(200) .json({ success: true, message: "경고가 등록되고 봇이 중지되었습니다." }); }); // 할당량 경고 상태 조회 (프론트엔드용) router.get("/quota-warning", authenticateToken, (req, res) => { res.json(quotaWarning); }); // 할당량 경고 해제 (수동) router.delete("/quota-warning", authenticateToken, (req, res) => { quotaWarning = { active: false, timestamp: null, message: null, }; res.json({ success: true, message: "경고가 해제되었습니다." }); }); export default router;