import { uploadAlbumCover, deleteAlbumCover, uploadAlbumPhoto, deleteAlbumPhoto, uploadAlbumVideo, deleteAlbumVideo, } from '../../services/image.js'; /** * 앨범 관리 라우트 */ export default async function albumsRoutes(fastify, opts) { const { db } = fastify; // ==================== 앨범 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(); } }); }