From f5ae81d21a6657ef7305a8f402af043efbcf2203 Mon Sep 17 00:00:00 2001 From: caadiq Date: Fri, 16 Jan 2026 23:16:41 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=95=A8=EB=B2=94=20=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=20API=20=EA=B5=AC=ED=98=84=20=EB=B0=8F=20=ED=94=84=EB=A1=A0?= =?UTF-8?q?=ED=8A=B8=EC=97=94=EB=93=9C=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - 앨범 CRUD API 구현 (목록, 상세, 생성, 수정, 삭제) - 앨범 사진 관리 API 구현 (업로드, 삭제, 티저 관리) - 이미지 서비스에 앨범 관련 함수 추가 - Public 라우트 추가 (앨범, 멤버 공개 API) Frontend: - AdminAlbums.jsx admin API로 변경 Co-Authored-By: Claude Opus 4.5 --- backend/src/app.js | 2 + backend/src/routes/admin/albums.js | 673 ++++++++++++++++++++ backend/src/routes/admin/index.js | 4 + backend/src/routes/public/albums.js | 179 ++++++ backend/src/routes/public/index.js | 13 + backend/src/routes/public/members.js | 38 ++ backend/src/services/image.js | 101 +++ frontend/src/pages/pc/admin/AdminAlbums.jsx | 3 +- 8 files changed, 1011 insertions(+), 2 deletions(-) create mode 100644 backend/src/routes/admin/albums.js create mode 100644 backend/src/routes/public/albums.js create mode 100644 backend/src/routes/public/index.js create mode 100644 backend/src/routes/public/members.js diff --git a/backend/src/app.js b/backend/src/app.js index f0f7f49..e08b5d4 100644 --- a/backend/src/app.js +++ b/backend/src/app.js @@ -12,6 +12,7 @@ import schedulerPlugin from './plugins/scheduler.js'; // 라우트 import adminRoutes from './routes/admin/index.js'; +import publicRoutes from './routes/public/index.js'; export async function buildApp(opts = {}) { const fastify = Fastify({ @@ -41,6 +42,7 @@ export async function buildApp(opts = {}) { // 라우트 등록 await fastify.register(adminRoutes, { prefix: '/api/admin' }); + await fastify.register(publicRoutes, { prefix: '/api' }); // 헬스 체크 엔드포인트 fastify.get('/api/health', async () => { diff --git a/backend/src/routes/admin/albums.js b/backend/src/routes/admin/albums.js new file mode 100644 index 0000000..f8f45f4 --- /dev/null +++ b/backend/src/routes/admin/albums.js @@ -0,0 +1,673 @@ +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(); + } + }); +} diff --git a/backend/src/routes/admin/index.js b/backend/src/routes/admin/index.js index 6cf1585..f93a7e6 100644 --- a/backend/src/routes/admin/index.js +++ b/backend/src/routes/admin/index.js @@ -1,5 +1,6 @@ import authRoutes from './auth.js'; import membersRoutes from './members.js'; +import albumsRoutes from './albums.js'; /** * 어드민 라우트 통합 @@ -10,4 +11,7 @@ export default async function adminRoutes(fastify, opts) { // 멤버 관리 라우트 fastify.register(membersRoutes, { prefix: '/members' }); + + // 앨범 관리 라우트 + fastify.register(albumsRoutes, { prefix: '/albums' }); } diff --git a/backend/src/routes/public/albums.js b/backend/src/routes/public/albums.js new file mode 100644 index 0000000..b3579ca --- /dev/null +++ b/backend/src/routes/public/albums.js @@ -0,0 +1,179 @@ +/** + * 공개 앨범 라우트 + */ +export default async function publicAlbumsRoutes(fastify, opts) { + const { db } = fastify; + + /** + * 앨범 상세 정보 조회 헬퍼 함수 (트랙, 티저, 컨셉포토 포함) + */ + async function getAlbumDetails(album) { + // 트랙 정보 조회 + const [tracks] = await db.query( + 'SELECT * FROM tracks WHERE album_id = ? ORDER BY track_number', + [album.id] + ); + album.tracks = tracks; + + // 티저 이미지/비디오 조회 + const [teasers] = await db.query( + `SELECT original_url, medium_url, thumb_url, video_url, media_type + FROM album_teasers WHERE album_id = ? ORDER BY sort_order`, + [album.id] + ); + album.teasers = teasers; + + // 컨셉 포토 조회 (멤버 정보 포함) + 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, + GROUP_CONCAT(m.name ORDER BY m.id SEPARATOR ', ') as members + FROM album_photos p + LEFT JOIN album_photo_members pm ON p.id = pm.photo_id + LEFT JOIN members m ON pm.member_id = m.id + WHERE p.album_id = ? + GROUP BY p.id + ORDER BY p.sort_order`, + [album.id] + ); + + // 컨셉별로 그룹화 + const conceptPhotos = {}; + for (const photo of photos) { + const concept = photo.concept_name || 'Default'; + if (!conceptPhotos[concept]) { + conceptPhotos[concept] = []; + } + conceptPhotos[concept].push({ + id: photo.id, + original_url: photo.original_url, + medium_url: photo.medium_url, + thumb_url: photo.thumb_url, + width: photo.width, + height: photo.height, + type: photo.photo_type, + members: photo.members, + sortOrder: photo.sort_order, + }); + } + album.conceptPhotos = conceptPhotos; + + return album; + } + + /** + * 전체 앨범 조회 (트랙 포함) + * GET /api/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 + 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, lyricist, composer, arranger + FROM tracks WHERE album_id = ? ORDER BY track_number`, + [album.id] + ); + album.tracks = tracks; + } + + return albums; + }); + + /** + * 앨범명과 트랙명으로 트랙 상세 조회 + * GET /api/albums/by-name/:albumName/track/:trackTitle + */ + fastify.get('/by-name/:albumName/track/:trackTitle', async (request, reply) => { + const albumName = decodeURIComponent(request.params.albumName); + const trackTitle = decodeURIComponent(request.params.trackTitle); + + // 앨범 조회 + const [albums] = await db.query( + 'SELECT * FROM albums WHERE folder_name = ? OR title = ?', + [albumName, albumName] + ); + + 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 = ? AND title = ?', + [album.id, trackTitle] + ); + + if (tracks.length === 0) { + return reply.code(404).send({ error: '트랙을 찾을 수 없습니다.' }); + } + + const track = tracks[0]; + + // 앨범의 다른 트랙 목록 조회 + const [otherTracks] = await db.query( + 'SELECT id, track_number, title, is_title_track, duration FROM tracks WHERE album_id = ? ORDER BY track_number', + [album.id] + ); + + return { + ...track, + album: { + id: album.id, + title: album.title, + folder_name: album.folder_name, + cover_thumb_url: album.cover_thumb_url, + cover_medium_url: album.cover_medium_url, + release_date: album.release_date, + album_type: album.album_type, + }, + otherTracks, + }; + }); + + /** + * 앨범 folder_name 또는 title로 조회 + * GET /api/albums/by-name/:name + */ + fastify.get('/by-name/:name', async (request, reply) => { + const name = decodeURIComponent(request.params.name); + + const [albums] = await db.query( + 'SELECT * FROM albums WHERE folder_name = ? OR title = ?', + [name, name] + ); + + if (albums.length === 0) { + return reply.code(404).send({ error: '앨범을 찾을 수 없습니다.' }); + } + + const album = await getAlbumDetails(albums[0]); + return album; + }); + + /** + * ID로 앨범 조회 + * GET /api/albums/:id + */ + fastify.get('/:id', async (request, reply) => { + const [albums] = await db.query('SELECT * FROM albums WHERE id = ?', [ + request.params.id, + ]); + + if (albums.length === 0) { + return reply.code(404).send({ error: '앨범을 찾을 수 없습니다.' }); + } + + const album = await getAlbumDetails(albums[0]); + return album; + }); +} diff --git a/backend/src/routes/public/index.js b/backend/src/routes/public/index.js new file mode 100644 index 0000000..cbbb4be --- /dev/null +++ b/backend/src/routes/public/index.js @@ -0,0 +1,13 @@ +import albumsRoutes from './albums.js'; +import membersRoutes from './members.js'; + +/** + * 공개 라우트 통합 + */ +export default async function publicRoutes(fastify, opts) { + // 앨범 라우트 + fastify.register(albumsRoutes, { prefix: '/albums' }); + + // 멤버 라우트 + fastify.register(membersRoutes, { prefix: '/members' }); +} diff --git a/backend/src/routes/public/members.js b/backend/src/routes/public/members.js new file mode 100644 index 0000000..269d70d --- /dev/null +++ b/backend/src/routes/public/members.js @@ -0,0 +1,38 @@ +/** + * 공개 멤버 라우트 + */ +export default async function publicMembersRoutes(fastify, opts) { + const { db } = fastify; + + /** + * 전체 멤버 조회 + * GET /api/members + */ + fastify.get('/', async (request, reply) => { + const [members] = await db.query(` + SELECT id, name, name_en, birth_date, instagram, image_url, is_former + FROM members + ORDER BY id ASC + `); + return members; + }); + + /** + * 멤버 상세 조회 (이름으로) + * GET /api/members/:name + */ + fastify.get('/:name', async (request, reply) => { + const memberName = decodeURIComponent(request.params.name); + + const [members] = await db.query( + 'SELECT * FROM members WHERE name = ?', + [memberName] + ); + + if (members.length === 0) { + return reply.code(404).send({ error: '멤버를 찾을 수 없습니다.' }); + } + + return members[0]; + }); +} diff --git a/backend/src/services/image.js b/backend/src/services/image.js index a4cd215..8ed3136 100644 --- a/backend/src/services/image.js +++ b/backend/src/services/image.js @@ -97,3 +97,104 @@ export async function deleteMemberImage(name) { sizes.map(size => deleteFromS3(`${basePath}/${size}/${filename}`)) ); } + +/** + * 앨범 커버 이미지 업로드 + * @param {string} folderName - 앨범 폴더명 + * @param {Buffer} buffer - 이미지 버퍼 + * @returns {Promise<{originalUrl: string, mediumUrl: string, thumbUrl: string}>} + */ +export async function uploadAlbumCover(folderName, buffer) { + const { originalBuffer, mediumBuffer, thumbBuffer } = await processImage(buffer); + + const basePath = `album/${folderName}/cover`; + + const [originalUrl, mediumUrl, thumbUrl] = await Promise.all([ + uploadToS3(`${basePath}/original/cover.webp`, originalBuffer), + uploadToS3(`${basePath}/medium_800/cover.webp`, mediumBuffer), + uploadToS3(`${basePath}/thumb_400/cover.webp`, thumbBuffer), + ]); + + return { originalUrl, mediumUrl, thumbUrl }; +} + +/** + * 앨범 커버 이미지 삭제 + * @param {string} folderName - 앨범 폴더명 + */ +export async function deleteAlbumCover(folderName) { + const basePath = `album/${folderName}/cover`; + const sizes = ['original', 'medium_800', 'thumb_400']; + + await Promise.all( + sizes.map(size => deleteFromS3(`${basePath}/${size}/cover.webp`)) + ); +} + +/** + * 앨범 사진 업로드 (컨셉포토 또는 티저) + * @param {string} folderName - 앨범 폴더명 + * @param {string} subFolder - 'photo' 또는 'teaser' + * @param {string} filename - 파일명 (예: '01.webp') + * @param {Buffer} buffer - 이미지 버퍼 + * @returns {Promise<{originalUrl: string, mediumUrl: string, thumbUrl: string, metadata: object}>} + */ +export async function uploadAlbumPhoto(folderName, subFolder, filename, buffer) { + const { originalBuffer, mediumBuffer, thumbBuffer } = await processImage(buffer); + const metadata = await sharp(originalBuffer).metadata(); + + const basePath = `album/${folderName}/${subFolder}`; + + const [originalUrl, mediumUrl, thumbUrl] = await Promise.all([ + uploadToS3(`${basePath}/original/${filename}`, originalBuffer), + uploadToS3(`${basePath}/medium_800/${filename}`, mediumBuffer), + uploadToS3(`${basePath}/thumb_400/${filename}`, thumbBuffer), + ]); + + return { + originalUrl, + mediumUrl, + thumbUrl, + metadata: { + width: metadata.width, + height: metadata.height, + size: originalBuffer.length, + }, + }; +} + +/** + * 앨범 사진 삭제 + * @param {string} folderName - 앨범 폴더명 + * @param {string} subFolder - 'photo' 또는 'teaser' + * @param {string} filename - 파일명 + */ +export async function deleteAlbumPhoto(folderName, subFolder, filename) { + const basePath = `album/${folderName}/${subFolder}`; + const sizes = ['original', 'medium_800', 'thumb_400']; + + await Promise.all( + sizes.map(size => deleteFromS3(`${basePath}/${size}/${filename}`)) + ); +} + +/** + * 앨범 비디오 업로드 (티저 전용) + * @param {string} folderName - 앨범 폴더명 + * @param {string} filename - 파일명 (예: '01.mp4') + * @param {Buffer} buffer - 비디오 버퍼 + * @returns {Promise} - 비디오 URL + */ +export async function uploadAlbumVideo(folderName, filename, buffer) { + const key = `album/${folderName}/teaser/video/${filename}`; + return await uploadToS3(key, buffer, 'video/mp4'); +} + +/** + * 앨범 비디오 삭제 + * @param {string} folderName - 앨범 폴더명 + * @param {string} filename - 파일명 + */ +export async function deleteAlbumVideo(folderName, filename) { + await deleteFromS3(`album/${folderName}/teaser/video/${filename}`); +} diff --git a/frontend/src/pages/pc/admin/AdminAlbums.jsx b/frontend/src/pages/pc/admin/AdminAlbums.jsx index d9b3caa..bdb471c 100644 --- a/frontend/src/pages/pc/admin/AdminAlbums.jsx +++ b/frontend/src/pages/pc/admin/AdminAlbums.jsx @@ -8,7 +8,6 @@ import AdminLayout from '../../../components/admin/AdminLayout'; import ConfirmDialog from '../../../components/admin/ConfirmDialog'; import useAdminAuth from '../../../hooks/useAdminAuth'; import useToast from '../../../hooks/useToast'; -import { getAlbums } from '../../../api/public/albums'; import * as albumsApi from '../../../api/admin/albums'; function AdminAlbums() { @@ -30,7 +29,7 @@ function AdminAlbums() { const fetchAlbums = async () => { try { - const data = await getAlbums(); + const data = await albumsApi.getAlbums(); setAlbums(data); } catch (error) { console.error('앨범 로드 오류:', error);