import express from "express"; import bcrypt from "bcrypt"; import jwt from "jsonwebtoken"; import multer from "multer"; import sharp from "sharp"; import { S3Client, PutObjectCommand, DeleteObjectCommand, } from "@aws-sdk/client-s3"; import pool from "../lib/db.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, 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); } } // 티저 삭제 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; let originalBuffer, originalMeta; // 컨셉 포토: photo/, 티저: teaser/ const subFolder = photoType === "teaser" ? "teaser" : "photo"; const basePath = `album/${folderName}/${subFolder}`; if (isVideo) { // ===== 비디오 파일 처리 (티저 전용) ===== // 원본 MP4만 업로드 (리사이즈 없음) await s3Client.send( new PutObjectCommand({ Bucket: BUCKET, Key: `${basePath}/original/${filename}`, Body: file.buffer, ContentType: "video/mp4", }) ); originalUrl = `${process.env.RUSTFS_PUBLIC_URL}/${BUCKET}/${basePath}/original/${filename}`; mediumUrl = originalUrl; // 비디오는 원본만 사용 thumbUrl = originalUrl; } 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, sort_order, media_type) VALUES (?, ?, ?, ?, ?, ?)`, [ albumId, originalUrl, mediumUrl, thumbUrl, 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, 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(); } } ); export default router;