fromis_9/backend/routes/admin.js

836 lines
24 KiB
JavaScript
Raw Normal View History

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 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, 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;