feat: 앨범 관리 API 구현 및 프론트엔드 연동
Backend: - 앨범 CRUD API 구현 (목록, 상세, 생성, 수정, 삭제) - 앨범 사진 관리 API 구현 (업로드, 삭제, 티저 관리) - 이미지 서비스에 앨범 관련 함수 추가 - Public 라우트 추가 (앨범, 멤버 공개 API) Frontend: - AdminAlbums.jsx admin API로 변경 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
6462949bc7
commit
f5ae81d21a
8 changed files with 1011 additions and 2 deletions
|
|
@ -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 () => {
|
||||
|
|
|
|||
673
backend/src/routes/admin/albums.js
Normal file
673
backend/src/routes/admin/albums.js
Normal file
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -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' });
|
||||
}
|
||||
|
|
|
|||
179
backend/src/routes/public/albums.js
Normal file
179
backend/src/routes/public/albums.js
Normal file
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
13
backend/src/routes/public/index.js
Normal file
13
backend/src/routes/public/index.js
Normal file
|
|
@ -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' });
|
||||
}
|
||||
38
backend/src/routes/public/members.js
Normal file
38
backend/src/routes/public/members.js
Normal file
|
|
@ -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];
|
||||
});
|
||||
}
|
||||
|
|
@ -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<string>} - 비디오 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}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue