fromis_9/backend/src/routes/admin/albums.js

677 lines
20 KiB
JavaScript
Raw Normal View History

import {
uploadAlbumCover,
deleteAlbumCover,
uploadAlbumPhoto,
deleteAlbumPhoto,
uploadAlbumVideo,
deleteAlbumVideo,
} from '../../services/image.js';
/**
* 앨범 관리 라우트
*/
export default async function albumsRoutes(fastify, opts) {
const { db } = fastify;
// 모든 라우트에 인증 적용
fastify.addHook('preHandler', fastify.authenticate);
// ==================== 앨범 CRUD ====================
/**
* 앨범 목록 조회 (트랙 포함)
* GET /api/admin/albums
*/
fastify.get('/', async (request, reply) => {
const [albums] = await db.query(`
SELECT id, title, folder_name, album_type, album_type_short, release_date,
cover_original_url, cover_medium_url, cover_thumb_url, description
FROM albums
ORDER BY release_date DESC
`);
// 각 앨범에 트랙 정보 추가
for (const album of albums) {
const [tracks] = await db.query(
`SELECT id, track_number, title, is_title_track, duration
FROM tracks WHERE album_id = ? ORDER BY track_number`,
[album.id]
);
album.tracks = tracks;
}
return albums;
});
/**
* 앨범 상세 조회
* GET /api/admin/albums/:id
*/
fastify.get('/:id', async (request, reply) => {
const { id } = request.params;
const [albums] = await db.query('SELECT * FROM albums WHERE id = ?', [id]);
if (albums.length === 0) {
return reply.code(404).send({ error: '앨범을 찾을 수 없습니다.' });
}
const album = albums[0];
// 트랙 정보 조회
const [tracks] = await db.query(
`SELECT * FROM tracks WHERE album_id = ? ORDER BY track_number`,
[id]
);
album.tracks = tracks;
return album;
});
/**
* 앨범 생성
* POST /api/admin/albums
*/
fastify.post('/', async (request, reply) => {
const parts = request.parts();
let data = null;
let coverBuffer = null;
for await (const part of parts) {
if (part.type === 'file' && part.fieldname === 'cover') {
coverBuffer = await part.toBuffer();
} else if (part.fieldname === 'data') {
data = JSON.parse(part.value);
}
}
if (!data) {
return reply.code(400).send({ error: '데이터가 필요합니다.' });
}
const {
title,
album_type,
album_type_short,
release_date,
folder_name,
description,
tracks,
} = data;
// 필수 필드 검증
if (!title || !album_type || !release_date || !folder_name) {
return reply.code(400).send({ error: '필수 필드를 모두 입력해주세요.' });
}
const connection = await db.getConnection();
try {
await connection.beginTransaction();
let coverOriginalUrl = null;
let coverMediumUrl = null;
let coverThumbUrl = null;
// 커버 이미지 업로드
if (coverBuffer) {
const urls = await uploadAlbumCover(folder_name, coverBuffer);
coverOriginalUrl = urls.originalUrl;
coverMediumUrl = urls.mediumUrl;
coverThumbUrl = urls.thumbUrl;
}
// 앨범 삽입
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();
return { message: '앨범이 생성되었습니다.', albumId };
} catch (error) {
await connection.rollback();
throw error;
} finally {
connection.release();
}
});
/**
* 앨범 수정
* PUT /api/admin/albums/:id
*/
fastify.put('/:id', async (request, reply) => {
const { id } = request.params;
const parts = request.parts();
let data = null;
let coverBuffer = null;
for await (const part of parts) {
if (part.type === 'file' && part.fieldname === 'cover') {
coverBuffer = await part.toBuffer();
} else if (part.fieldname === 'data') {
data = JSON.parse(part.value);
}
}
if (!data) {
return reply.code(400).send({ error: '데이터가 필요합니다.' });
}
const {
title,
album_type,
album_type_short,
release_date,
folder_name,
description,
tracks,
} = data;
const connection = await db.getConnection();
try {
await connection.beginTransaction();
// 기존 앨범 조회
const [existingAlbums] = await connection.query(
'SELECT * FROM albums WHERE id = ?',
[id]
);
if (existingAlbums.length === 0) {
return reply.code(404).send({ error: '앨범을 찾을 수 없습니다.' });
}
const existing = existingAlbums[0];
let coverOriginalUrl = existing.cover_original_url;
let coverMediumUrl = existing.cover_medium_url;
let coverThumbUrl = existing.cover_thumb_url;
// 새 커버 이미지 업로드
if (coverBuffer) {
const urls = await uploadAlbumCover(folder_name, coverBuffer);
coverOriginalUrl = urls.originalUrl;
coverMediumUrl = urls.mediumUrl;
coverThumbUrl = urls.thumbUrl;
}
// 앨범 업데이트
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,
id,
]
);
// 기존 트랙 삭제 후 새 트랙 삽입
await connection.query('DELETE FROM tracks WHERE album_id = ?', [id]);
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
id,
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();
return { message: '앨범이 수정되었습니다.' };
} catch (error) {
await connection.rollback();
throw error;
} finally {
connection.release();
}
});
/**
* 앨범 삭제
* DELETE /api/admin/albums/:id
*/
fastify.delete('/:id', async (request, reply) => {
const { id } = request.params;
const connection = await db.getConnection();
try {
await connection.beginTransaction();
// 기존 앨범 조회
const [existingAlbums] = await connection.query(
'SELECT * FROM albums WHERE id = ?',
[id]
);
if (existingAlbums.length === 0) {
return reply.code(404).send({ error: '앨범을 찾을 수 없습니다.' });
}
const album = existingAlbums[0];
// S3에서 커버 이미지 삭제
if (album.cover_original_url && album.folder_name) {
await deleteAlbumCover(album.folder_name);
}
// 트랙 삭제
await connection.query('DELETE FROM tracks WHERE album_id = ?', [id]);
// 앨범 삭제
await connection.query('DELETE FROM albums WHERE id = ?', [id]);
await connection.commit();
return { message: '앨범이 삭제되었습니다.' };
} catch (error) {
await connection.rollback();
throw error;
} finally {
connection.release();
}
});
// ==================== 앨범 사진 관리 ====================
/**
* 앨범 사진 목록 조회
* GET /api/admin/albums/:albumId/photos
*/
fastify.get('/:albumId/photos', async (request, reply) => {
const { albumId } = request.params;
// 앨범 존재 확인
const [albums] = await db.query(
'SELECT folder_name FROM albums WHERE id = ?',
[albumId]
);
if (albums.length === 0) {
return reply.code(404).send({ error: '앨범을 찾을 수 없습니다.' });
}
// 사진 조회 (멤버 정보 포함)
const [photos] = await db.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]
);
// 멤버 배열 파싱
return photos.map((photo) => ({
...photo,
members: photo.member_ids ? photo.member_ids.split(',').map(Number) : [],
}));
});
/**
* 앨범 티저 목록 조회
* GET /api/admin/albums/:albumId/teasers
*/
fastify.get('/:albumId/teasers', async (request, reply) => {
const { albumId } = request.params;
// 앨범 존재 확인
const [albums] = await db.query(
'SELECT folder_name FROM albums WHERE id = ?',
[albumId]
);
if (albums.length === 0) {
return reply.code(404).send({ error: '앨범을 찾을 수 없습니다.' });
}
// 티저 조회
const [teasers] = await db.query(
`SELECT id, original_url, medium_url, thumb_url, video_url, sort_order, media_type
FROM album_teasers
WHERE album_id = ?
ORDER BY sort_order ASC`,
[albumId]
);
return teasers;
});
/**
* 앨범 사진 업로드 (SSE)
* POST /api/admin/albums/:albumId/photos
*/
fastify.post('/:albumId/photos', async (request, reply) => {
const { albumId } = request.params;
// SSE 헤더 설정
reply.raw.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
});
const sendProgress = (current, total, message) => {
reply.raw.write(`data: ${JSON.stringify({ current, total, message })}\n\n`);
};
const connection = await db.getConnection();
try {
await connection.beginTransaction();
// 앨범 정보 조회
const [albums] = await connection.query(
'SELECT folder_name FROM albums WHERE id = ?',
[albumId]
);
if (albums.length === 0) {
reply.raw.write(`data: ${JSON.stringify({ error: '앨범을 찾을 수 없습니다.' })}\n\n`);
reply.raw.end();
return;
}
const folderName = albums[0].folder_name;
const parts = request.parts();
let metadata = [];
let startNumber = null;
let photoType = 'concept';
const files = [];
for await (const part of parts) {
if (part.type === 'file' && part.fieldname === 'photos') {
const buffer = await part.toBuffer();
files.push({ buffer, mimetype: part.mimetype });
} else if (part.fieldname === 'metadata') {
metadata = JSON.parse(part.value);
} else if (part.fieldname === 'startNumber') {
startNumber = parseInt(part.value) || null;
} else if (part.fieldname === 'photoType') {
photoType = part.value;
}
}
// 시작 번호 결정
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 = files.length;
const subFolder = photoType === 'teaser' ? 'teaser' : 'photo';
for (let i = 0; i < files.length; i++) {
const file = files[i];
const meta = metadata[i] || {};
const orderNum = String(nextOrder + i).padStart(2, '0');
const isVideo = file.mimetype === 'video/mp4';
const filename = `${orderNum}.${isVideo ? 'mp4' : 'webp'}`;
sendProgress(i + 1, totalFiles, `${filename} 처리 중...`);
let originalUrl, mediumUrl, thumbUrl, videoUrl;
let photoMetadata = {};
if (isVideo) {
// 비디오 파일은 별도 처리 필요 (썸네일 생성 등)
// 현재는 간단히 업로드만
videoUrl = await uploadAlbumVideo(folderName, filename, file.buffer);
// 썸네일 없이 일단 저장
originalUrl = videoUrl;
mediumUrl = videoUrl;
thumbUrl = videoUrl;
} else {
// 이미지 파일 처리
const result = await uploadAlbumPhoto(folderName, subFolder, filename, file.buffer);
originalUrl = result.originalUrl;
mediumUrl = result.mediumUrl;
thumbUrl = result.thumbUrl;
photoMetadata = result.metadata;
}
let photoId;
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, video_url, sort_order, media_type)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[albumId, originalUrl, mediumUrl, thumbUrl, videoUrl || null, 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,
photoMetadata.width || null,
photoMetadata.height || null,
photoMetadata.size || null,
]
);
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,
video_url: videoUrl || null,
filename,
media_type: isVideo ? 'video' : 'image',
});
}
await connection.commit();
// 완료 이벤트
reply.raw.write(`data: ${JSON.stringify({
done: true,
message: `${uploadedPhotos.length}개의 사진이 업로드되었습니다.`,
photos: uploadedPhotos,
})}\n\n`);
reply.raw.end();
} catch (error) {
await connection.rollback();
console.error('사진 업로드 오류:', error);
reply.raw.write(`data: ${JSON.stringify({ error: '사진 업로드 중 오류가 발생했습니다.' })}\n\n`);
reply.raw.end();
} finally {
connection.release();
}
});
/**
* 앨범 사진 삭제
* DELETE /api/admin/albums/:albumId/photos/:photoId
*/
fastify.delete('/:albumId/photos/:photoId', async (request, reply) => {
const { albumId, photoId } = request.params;
const connection = await db.getConnection();
try {
await connection.beginTransaction();
// 사진 정보 조회
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 reply.code(404).send({ error: '사진을 찾을 수 없습니다.' });
}
const photo = photos[0];
const filename = photo.original_url.split('/').pop();
// S3에서 삭제
await deleteAlbumPhoto(photo.folder_name, 'photo', filename);
// 멤버 태깅 삭제
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();
return { message: '사진이 삭제되었습니다.' };
} catch (error) {
await connection.rollback();
throw error;
} finally {
connection.release();
}
});
/**
* 티저 삭제
* DELETE /api/admin/albums/:albumId/teasers/:teaserId
*/
fastify.delete('/:albumId/teasers/:teaserId', async (request, reply) => {
const { albumId, teaserId } = request.params;
const connection = await db.getConnection();
try {
await connection.beginTransaction();
// 티저 정보 조회
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 reply.code(404).send({ error: '티저를 찾을 수 없습니다.' });
}
const teaser = teasers[0];
const filename = teaser.original_url.split('/').pop();
// S3에서 썸네일 삭제
await deleteAlbumPhoto(teaser.folder_name, 'teaser', filename);
// 비디오 파일 삭제
if (teaser.video_url) {
const videoFilename = teaser.video_url.split('/').pop();
await deleteAlbumVideo(teaser.folder_name, videoFilename);
}
// 티저 삭제
await connection.query('DELETE FROM album_teasers WHERE id = ?', [teaserId]);
await connection.commit();
return { message: '티저가 삭제되었습니다.' };
} catch (error) {
await connection.rollback();
throw error;
} finally {
connection.release();
}
});
}