feat: 앨범 관리 기능 - CRUD API, RustFS 커버 업로드, 트랙 상세 정보, Toast 알림

This commit is contained in:
caadiq 2026-01-01 20:36:49 +09:00
parent bd787d57c3
commit 40fa94f9f5
5 changed files with 528 additions and 80 deletions

6
.env
View file

@ -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

View file

@ -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"
}
}

View file

@ -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) => {
// 관리자 로그인
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({
valid: true,
user: req.user,
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;

View file

@ -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 (
<AnimatePresence>
{toast && (
<motion.div
initial={{ opacity: 0, y: 50 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 50 }}
onClick={onClose}
className={`fixed bottom-8 inset-x-0 mx-auto w-fit z-[9999] backdrop-blur-sm text-white px-6 py-3 rounded-xl text-center font-medium shadow-lg cursor-pointer ${
toast.type === 'error'
? 'bg-red-500/90'
: toast.type === 'warning'
? 'bg-amber-500/90'
: 'bg-emerald-500/90'
}`}
>
{toast.message}
</motion.div>
)}
</AnimatePresence>
);
}
export default Toast;

View file

@ -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 */}
<Toast toast={toast} onClose={() => setToast(null)} />
{/* 헤더 */}
<header className="bg-white shadow-sm border-b border-gray-100">
<div className="max-w-7xl mx-auto px-6 py-4 flex items-center justify-between">
@ -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
/>
</div>
@ -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
/>
</div>
<p className="text-xs text-gray-400 mt-1">영문 소문자, 숫자, 하이픈만 사용</p>
@ -670,25 +704,25 @@ function AdminAlbumForm() {
앨범 타입 *
</label>
<CustomSelect
value={formData.album_type}
onChange={(val) => setFormData(prev => ({ ...prev, album_type: val }))}
value={formData.album_type_short}
onChange={(val) => setFormData(prev => ({ ...prev, album_type_short: val }))}
options={albumTypes}
placeholder="타입 선택"
/>
</div>
{/* 앨범 타입 약어 */}
{/* 앨범 유형 (전체) */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
타입 약어
앨범 유형 *
</label>
<input
type="text"
name="album_type_short"
value={formData.album_type_short}
name="album_type"
value={formData.album_type}
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="예: 스페셜 디지털 싱글"
placeholder="예: 미니 6집"
/>
</div>
@ -793,13 +827,98 @@ function AdminAlbumForm() {
<div>
<input
type="text"
value={track.duration}
value={track.duration || ''}
onChange={(e) => 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"
/>
</div>
</div>
{/* 상세 정보 토글 */}
<div className="mt-3">
<button
type="button"
onClick={() => updateTrack(index, 'showDetails', !track.showDetails)}
className="text-sm text-gray-500 hover:text-gray-700 flex items-center gap-1 transition-colors"
>
<ChevronDown
size={14}
className={`transition-transform ${track.showDetails ? 'rotate-180' : ''}`}
/>
상세 정보 {track.showDetails ? '접기' : '펼치기'}
</button>
</div>
<AnimatePresence>
{track.showDetails && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
className="overflow-hidden"
>
{/* 작사/작곡/편곡 */}
<div className="space-y-3 mt-3">
<div>
<label className="block text-xs text-gray-500 mb-1">작사</label>
<input
type="text"
value={track.lyricist || ''}
onChange={(e) => 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="여러 명일 경우 쉼표로 구분"
/>
</div>
<div>
<label className="block text-xs text-gray-500 mb-1">작곡</label>
<input
type="text"
value={track.composer || ''}
onChange={(e) => 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="여러 명일 경우 쉼표로 구분"
/>
</div>
<div>
<label className="block text-xs text-gray-500 mb-1">편곡</label>
<input
type="text"
value={track.arranger || ''}
onChange={(e) => 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="여러 명일 경우 쉼표로 구분"
/>
</div>
</div>
{/* MV URL */}
<div className="mt-3">
<label className="block text-xs text-gray-500 mb-1">뮤직비디오 URL</label>
<input
type="text"
value={track.music_video_url || ''}
onChange={(e) => 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=..."
/>
</div>
{/* 가사 */}
<div className="mt-3">
<label className="block text-xs text-gray-500 mb-1">가사</label>
<textarea
value={track.lyrics || ''}
onChange={(e) => updateTrack(index, 'lyrics', e.target.value)}
rows={12}
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 resize-y min-h-[200px]"
placeholder="가사를 입력하세요..."
/>
</div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
))}
</div>