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: 10 * 1024 * 1024 }, // 10MB fileFilter: (req, file, cb) => { if (file.mimetype.startsWith("image/")) { cb(null, true); } else { cb(new Error("이미지 파일만 업로드 가능합니다."), 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 coverUrl = null; // 커버 이미지 업로드 if (req.file) { // WebP 변환 (원본 크기, 무손실) const processedImage = await sharp(req.file.buffer) .webp({ lossless: true }) .toBuffer(); const coverKey = `album/${folder_name}/cover.webp`; await s3Client.send( new PutObjectCommand({ Bucket: BUCKET, Key: coverKey, Body: processedImage, ContentType: "image/webp", }) ); coverUrl = `${process.env.RUSTFS_ENDPOINT}/${BUCKET}/${coverKey}`; } // 앨범 삽입 const [albumResult] = await connection.query( `INSERT INTO albums (title, album_type, album_type_short, release_date, folder_name, cover_url, description) VALUES (?, ?, ?, ?, ?, ?, ?)`, [ title, album_type, album_type_short || null, release_date, folder_name, coverUrl, 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 coverUrl = existingAlbums[0].cover_url; // WebP 변환 (원본 크기, 무손실) if (req.file) { const processedImage = await sharp(req.file.buffer) .webp({ lossless: true }) .toBuffer(); const coverKey = `album/${folder_name}/cover.webp`; await s3Client.send( new PutObjectCommand({ Bucket: BUCKET, Key: coverKey, Body: processedImage, ContentType: "image/webp", }) ); coverUrl = `${process.env.RUSTFS_ENDPOINT}/${BUCKET}/${coverKey}`; } // 앨범 업데이트 await connection.query( `UPDATE albums SET title = ?, album_type = ?, album_type_short = ?, release_date = ?, folder_name = ?, cover_url = ?, description = ? WHERE id = ?`, [ title, album_type, album_type_short || null, release_date, folder_name, coverUrl, 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에서 커버 이미지 삭제 if (album.cover_url && album.folder_name) { try { await s3Client.send( new DeleteObjectCommand({ Bucket: BUCKET, Key: `album/${album.folder_name}/cover.webp`, }) ); } catch (s3Error) { console.error("S3 삭제 오류:", 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 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", 50), 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 filename = `${orderNum}.webp`; // 진행률 전송 sendProgress(i + 1, totalFiles, `${filename} 처리 중...`); // Sharp로 이미지 처리 (병렬) const [originalBuffer, 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(), ]); const originalMeta = await sharp(originalBuffer).metadata(); // RustFS 업로드 (병렬) // 컨셉 포토: photo/, 티저 이미지: teaser/ const subFolder = photoType === "teaser" ? "teaser" : "photo"; const basePath = `album/${folderName}/${subFolder}`; 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", }) ), ]); // 3개 해상도별 URL 생성 const originalUrl = `${process.env.RUSTFS_PUBLIC_URL}/${BUCKET}/${basePath}/original/${filename}`; const mediumUrl = `${process.env.RUSTFS_PUBLIC_URL}/${BUCKET}/${basePath}/medium_800/${filename}`; const thumbUrl = `${process.env.RUSTFS_PUBLIC_URL}/${BUCKET}/${basePath}/thumb_400/${filename}`; let photoId; // DB 저장 - 티저와 컨셉 포토 분기 if (photoType === "teaser") { // 티저 이미지 → album_teasers 테이블 const [result] = await connection.query( `INSERT INTO album_teasers (album_id, original_url, medium_url, thumb_url, sort_order) VALUES (?, ?, ?, ?, ?)`, [albumId, originalUrl, mediumUrl, thumbUrl, nextOrder + i] ); 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, }); } 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.photo_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;