From 40fa94f9f59542c75a57100c9a3422bff4ca2a6e Mon Sep 17 00:00:00 2001 From: caadiq Date: Thu, 1 Jan 2026 20:36:49 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=95=A8=EB=B2=94=20=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=20=EA=B8=B0=EB=8A=A5=20-=20CRUD=20API,=20RustFS=20=EC=BB=A4?= =?UTF-8?q?=EB=B2=84=20=EC=97=85=EB=A1=9C=EB=93=9C,=20=ED=8A=B8=EB=9E=99?= =?UTF-8?q?=20=EC=83=81=EC=84=B8=20=EC=A0=95=EB=B3=B4,=20Toast=20=EC=95=8C?= =?UTF-8?q?=EB=A6=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env | 6 + backend/package.json | 7 +- backend/routes/admin.js | 420 +++++++++++++++--- frontend/src/components/Toast.jsx | 34 ++ .../src/pages/pc/admin/AdminAlbumForm.jsx | 141 +++++- 5 files changed, 528 insertions(+), 80 deletions(-) create mode 100644 frontend/src/components/Toast.jsx diff --git a/.env b/.env index 7b0917d..fcb376c 100644 --- a/.env +++ b/.env @@ -9,3 +9,9 @@ DB_NAME=fromis9 PORT=80 NODE_ENV=production JWT_SECRET=fromis9-admin-jwt-secret-2026-xK9mP2vL7nQ4w + +# RustFS (S3 Compatible) +RUSTFS_ENDPOINT=https://rustfs.caadiq.co.kr +RUSTFS_ACCESS_KEY=iOpbGJIn4VumvxXlSC6D +RUSTFS_SECRET_KEY=tDTwLkcHN5UVuWnea2s8OECrmiv013qoSQIpYbBd +RUSTFS_BUCKET=fromis-9 diff --git a/backend/package.json b/backend/package.json index 7207a3a..6cc2474 100644 --- a/backend/package.json +++ b/backend/package.json @@ -7,9 +7,12 @@ "start": "node server.js" }, "dependencies": { + "@aws-sdk/client-s3": "^3.700.0", "bcrypt": "^6.0.0", "express": "^4.18.2", "jsonwebtoken": "^9.0.3", - "mysql2": "^3.11.0" + "multer": "^1.4.5-lts.1", + "mysql2": "^3.11.0", + "sharp": "^0.33.5" } -} +} \ No newline at end of file diff --git a/backend/routes/admin.js b/backend/routes/admin.js index dcfa1da..da6ee2e 100644 --- a/backend/routes/admin.js +++ b/backend/routes/admin.js @@ -1,73 +1,51 @@ 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 비밀키 (실제 운영에서는 환경변수로 관리) +// JWT 설정 const JWT_SECRET = process.env.JWT_SECRET || "fromis9-admin-secret-key-2026"; -const JWT_EXPIRES_IN = "30d"; // 30일 +const JWT_EXPIRES_IN = "30d"; -// 관리자 로그인 -router.post("/login", async (req, res) => { - try { - const { username, password } = req.body; - - if (!username || !password) { - return res - .status(400) - .json({ error: "아이디와 비밀번호를 입력해주세요." }); +// 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); } - - // 사용자 조회 - 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: "아이디 또는 비밀번호가 올바르지 않습니다." }); - } - - // JWT 토큰 발급 - 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: "로그인 처리 중 오류가 발생했습니다." }); - } + }, }); +// 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]; // Bearer TOKEN + const token = authHeader && authHeader.split(" ")[1]; if (!token) { return res.status(401).json({ error: "인증이 필요합니다." }); @@ -82,18 +60,62 @@ export const authenticateToken = (req, res, next) => { }); }; -// 토큰 검증 엔드포인트 -router.get("/verify", authenticateToken, (req, res) => { - res.json({ - valid: true, - user: req.user, - }); +// 관리자 로그인 +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" ); @@ -102,12 +124,9 @@ router.post("/init", async (req, res) => { return res.status(400).json({ error: "이미 관리자 계정이 존재합니다." }); } - // 비밀번호 해시 생성 const password = "auddnek0403!"; - const saltRounds = 10; - const passwordHash = await bcrypt.hash(password, saltRounds); + const passwordHash = await bcrypt.hash(password, 10); - // 계정 생성 await pool.query( "INSERT INTO admin_users (username, password_hash) VALUES (?, ?)", ["admin", passwordHash] @@ -120,4 +139,271 @@ router.post("/init", async (req, res) => { } }); +// ==================== 앨범 관리 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(); + } +}); + export default router; diff --git a/frontend/src/components/Toast.jsx b/frontend/src/components/Toast.jsx new file mode 100644 index 0000000..b5cd515 --- /dev/null +++ b/frontend/src/components/Toast.jsx @@ -0,0 +1,34 @@ +import { motion, AnimatePresence } from 'framer-motion'; + +/** + * Toast 컴포넌트 (Minecraft Web 스타일) + * - 하단 중앙에 표시 + * - type: 'success' | 'error' | 'warning' + */ +function Toast({ toast, onClose }) { + if (!toast) return null; + + return ( + + {toast && ( + + {toast.message} + + )} + + ); +} + +export default Toast; diff --git a/frontend/src/pages/pc/admin/AdminAlbumForm.jsx b/frontend/src/pages/pc/admin/AdminAlbumForm.jsx index ecbc240..4b4a540 100644 --- a/frontend/src/pages/pc/admin/AdminAlbumForm.jsx +++ b/frontend/src/pages/pc/admin/AdminAlbumForm.jsx @@ -5,6 +5,7 @@ import { Save, Home, ChevronRight, LogOut, Music, Trash2, Plus, Image, Star, ChevronDown, ChevronLeft, Calendar } from 'lucide-react'; +import Toast from '../../../components/Toast'; // 커스텀 드롭다운 컴포넌트 function CustomSelect({ value, onChange, options, placeholder }) { @@ -355,6 +356,15 @@ function AdminAlbumForm() { const [saving, setSaving] = useState(false); const [coverPreview, setCoverPreview] = useState(null); const [coverFile, setCoverFile] = useState(null); + const [toast, setToast] = useState(null); + + // Toast 자동 숨김 + useEffect(() => { + if (toast) { + const timer = setTimeout(() => setToast(null), 3000); + return () => clearTimeout(timer); + } + }, [toast]); const [formData, setFormData] = useState({ title: '', @@ -453,6 +463,29 @@ function AdminAlbumForm() { const handleSubmit = async (e) => { e.preventDefault(); + + // 커스텀 검증 + if (!formData.title.trim()) { + setToast({ message: '앨범명을 입력해주세요.', type: 'warning' }); + return; + } + if (!formData.folder_name.trim()) { + setToast({ message: 'RustFS 폴더명을 입력해주세요.', type: 'warning' }); + return; + } + if (!formData.album_type_short) { + setToast({ message: '앨범 타입을 선택해주세요.', type: 'warning' }); + return; + } + if (!formData.release_date) { + setToast({ message: '발매일을 선택해주세요.', type: 'warning' }); + return; + } + if (!formData.album_type.trim()) { + setToast({ message: '앨범 유형을 입력해주세요.', type: 'warning' }); + return; + } + setSaving(true); try { @@ -481,7 +514,7 @@ function AdminAlbumForm() { navigate('/admin/albums'); } catch (error) { console.error('저장 오류:', error); - alert('저장 중 오류가 발생했습니다.'); + setToast({ message: '저장 중 오류가 발생했습니다.', type: 'error' }); } finally { setSaving(false); } @@ -504,6 +537,9 @@ function AdminAlbumForm() { variants={pageVariants} transition={{ duration: 0.3 }} > + {/* Toast */} + setToast(null)} /> + {/* 헤더 */}
@@ -640,7 +676,6 @@ function AdminAlbumForm() { onChange={handleInputChange} className="w-full px-4 py-2.5 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent" placeholder="예: 하얀 그리움" - required />
@@ -658,7 +693,6 @@ function AdminAlbumForm() { onChange={handleInputChange} className="flex-1 px-4 py-2.5 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent" placeholder="예: white-memories" - required />

영문 소문자, 숫자, 하이픈만 사용

@@ -670,25 +704,25 @@ function AdminAlbumForm() { 앨범 타입 * setFormData(prev => ({ ...prev, album_type: val }))} + value={formData.album_type_short} + onChange={(val) => setFormData(prev => ({ ...prev, album_type_short: val }))} options={albumTypes} placeholder="타입 선택" /> - {/* 앨범 타입 약어 */} + {/* 앨범 유형 (전체) */}
@@ -793,13 +827,98 @@ function AdminAlbumForm() {
updateTrack(index, 'duration', e.target.value)} className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent text-center" placeholder="3:30" />
+ + {/* 상세 정보 토글 */} +
+ +
+ + + {track.showDetails && ( + + {/* 작사/작곡/편곡 */} +
+
+ + updateTrack(index, 'lyricist', e.target.value)} + className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent" + placeholder="여러 명일 경우 쉼표로 구분" + /> +
+
+ + updateTrack(index, 'composer', e.target.value)} + className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent" + placeholder="여러 명일 경우 쉼표로 구분" + /> +
+
+ + updateTrack(index, 'arranger', e.target.value)} + className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent" + placeholder="여러 명일 경우 쉼표로 구분" + /> +
+
+ + {/* MV URL */} +
+ + updateTrack(index, 'music_video_url', e.target.value)} + className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent" + placeholder="https://youtube.com/watch?v=..." + /> +
+ + {/* 가사 */} +
+ +