From 65b1d931f3a233df46369d3b50a2866deac20aae Mon Sep 17 00:00:00 2001 From: caadiq Date: Sat, 31 Jan 2026 11:48:50 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=BD=98=EC=84=9C=ED=8A=B8=20=EC=9D=BC?= =?UTF-8?q?=EC=A0=95=20=EC=A0=80=EC=9E=A5=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 콘서트 폼 데이터를 저장하는 백엔드 API 추가. multipart/form-data로 포스터, 굿즈 이미지, 회차, 세트리스트를 처리하고 트랜잭션으로 관련 테이블에 일괄 저장 후 Meilisearch 동기화. Co-Authored-By: Claude Opus 4.5 --- backend/src/config/index.js | 1 + backend/src/routes/admin/concert.js | 210 ++++++++++++++++++++++++++++ backend/src/routes/index.js | 4 + backend/src/services/image.js | 41 ++++++ 4 files changed, 256 insertions(+) create mode 100644 backend/src/routes/admin/concert.js diff --git a/backend/src/config/index.js b/backend/src/config/index.js index b185fea..92696f4 100644 --- a/backend/src/config/index.js +++ b/backend/src/config/index.js @@ -2,6 +2,7 @@ export const CATEGORY_IDS = { YOUTUBE: 2, X: 3, + CONCERT: 6, BIRTHDAY: 8, DEBUT: 9, }; diff --git a/backend/src/routes/admin/concert.js b/backend/src/routes/admin/concert.js new file mode 100644 index 0000000..b45e0e2 --- /dev/null +++ b/backend/src/routes/admin/concert.js @@ -0,0 +1,210 @@ +import { addOrUpdateSchedule } from '../../services/meilisearch/index.js'; +import { uploadConcertPoster, uploadConcertMerchandise } from '../../services/image.js'; +import { CATEGORY_IDS } from '../../config/index.js'; +import { withTransaction } from '../../utils/transaction.js'; +import { badRequest, serverError } from '../../utils/error.js'; + +const CONCERT_CATEGORY_ID = CATEGORY_IDS.CONCERT; + +/** + * 콘서트 관련 관리자 라우트 + */ +export default async function concertRoutes(fastify) { + const { db, meilisearch } = fastify; + + /** + * POST /api/admin/concert/schedule + * 콘서트 일정 저장 (multipart/form-data) + */ + fastify.post('/schedule', { + preHandler: [fastify.authenticate], + }, async (request, reply) => { + const parts = request.parts(); + + // multipart 파싱 + let title = ''; + let memberIds = []; + let rounds = []; + let setlist = []; + let posterBuffer = null; + const merchandiseBuffers = []; + + for await (const part of parts) { + if (part.type === 'file') { + const buffer = await part.toBuffer(); + if (part.fieldname === 'poster') { + posterBuffer = buffer; + } else if (part.fieldname === 'merchandise') { + merchandiseBuffers.push(buffer); + } + } else { + // field + if (part.fieldname === 'title') title = part.value; + else if (part.fieldname === 'memberIds') memberIds = JSON.parse(part.value); + else if (part.fieldname === 'rounds') rounds = JSON.parse(part.value); + else if (part.fieldname === 'setlist') setlist = JSON.parse(part.value); + } + } + + // 검증 + if (!title || !title.trim()) { + return badRequest(reply, '공연명은 필수입니다.'); + } + if (!rounds || rounds.length === 0) { + return badRequest(reply, '최소 1개 이상의 공연 일정이 필요합니다.'); + } + for (const round of rounds) { + if (!round.date) { + return badRequest(reply, '모든 회차에 날짜는 필수입니다.'); + } + } + + try { + // 트랜잭션으로 DB 작업 수행 + const result = await withTransaction(db, async (conn) => { + // 1. concert_series 생성 + const [seriesResult] = await conn.query( + 'INSERT INTO concert_series (title) VALUES (?)', + [title.trim()] + ); + const seriesId = seriesResult.insertId; + + // 2. 포스터 업로드 → images → concert_series.poster_id + if (posterBuffer) { + const { originalUrl, mediumUrl, thumbUrl } = await uploadConcertPoster(seriesId, posterBuffer); + const [imageResult] = await conn.query( + 'INSERT INTO images (original_url, medium_url, thumb_url) VALUES (?, ?, ?)', + [originalUrl, mediumUrl, thumbUrl] + ); + await conn.query( + 'UPDATE concert_series SET poster_id = ? WHERE id = ?', + [imageResult.insertId, seriesId] + ); + } + + // 3. 각 회차 처리 + const scheduleIds = []; + const concertIds = []; + + for (const round of rounds) { + // venue 처리 + let venueId = null; + if (round.venueId) { + venueId = round.venueId; + } else if (round.venueName) { + const [venueResult] = await conn.query( + 'INSERT INTO concert_venues (name, country, address, lat, lng) VALUES (?, ?, ?, ?, ?)', + [round.venueName, round.venueCountry || null, round.venueAddress || null, round.venueLat || null, round.venueLng || null] + ); + venueId = venueResult.insertId; + } + + // schedules 테이블 + const [scheduleResult] = await conn.query( + 'INSERT INTO schedules (category_id, title, date, time) VALUES (?, ?, ?, ?)', + [CONCERT_CATEGORY_ID, title.trim(), round.date, round.time || null] + ); + const scheduleId = scheduleResult.insertId; + scheduleIds.push(scheduleId); + + // schedule_concert 테이블 + const [concertResult] = await conn.query( + 'INSERT INTO schedule_concert (schedule_id, series_id, venue_id) VALUES (?, ?, ?)', + [scheduleId, seriesId, venueId] + ); + concertIds.push(concertResult.insertId); + + // schedule_members 테이블 + if (memberIds.length > 0) { + const values = memberIds.map(memberId => [scheduleId, memberId]); + await conn.query( + 'INSERT INTO schedule_members (schedule_id, member_id) VALUES ?', + [values] + ); + } + } + + // 4. 세트리스트 (첫 번째 concert_id 기준으로 저장) + const primaryConcertId = concertIds[0]; + + for (let i = 0; i < setlist.length; i++) { + const song = setlist[i]; + if (!song.songName || !song.songName.trim()) continue; + + const [setlistResult] = await conn.query( + 'INSERT INTO concert_setlists (concert_id, order_num, song_name, album_name) VALUES (?, ?, ?, ?)', + [primaryConcertId, i + 1, song.songName.trim(), song.albumName?.trim() || null] + ); + const setlistId = setlistResult.insertId; + + // 곡별 멤버 + if (song.memberIds && song.memberIds.length > 0) { + const memberValues = song.memberIds.map(memberId => [setlistId, memberId]); + await conn.query( + 'INSERT INTO concert_setlist_members (setlist_id, member_id) VALUES ?', + [memberValues] + ); + } + } + + // 5. 굿즈(MD) 이미지 + for (let i = 0; i < merchandiseBuffers.length; i++) { + const filename = `${String(i + 1).padStart(2, '0')}.webp`; + const { originalUrl, mediumUrl, thumbUrl } = await uploadConcertMerchandise(seriesId, filename, merchandiseBuffers[i]); + + const [imageResult] = await conn.query( + 'INSERT INTO images (original_url, medium_url, thumb_url) VALUES (?, ?, ?)', + [originalUrl, mediumUrl, thumbUrl] + ); + await conn.query( + 'INSERT INTO concert_series_md (series_id, image_id, sort_order) VALUES (?, ?, ?)', + [seriesId, imageResult.insertId, i + 1] + ); + } + + return { seriesId, scheduleIds }; + }); + + // 6. Meilisearch 동기화 (트랜잭션 외부) + const [categoryRows] = await db.query( + 'SELECT name, color FROM schedule_categories WHERE id = ?', + [CONCERT_CATEGORY_ID] + ); + const category = categoryRows[0] || {}; + + let memberNames = ''; + if (memberIds.length > 0) { + const [members] = await db.query( + 'SELECT name FROM members WHERE id IN (?) ORDER BY id', + [memberIds] + ); + memberNames = members.map(m => m.name).join(','); + } + + for (const scheduleId of result.scheduleIds) { + const [scheduleRows] = await db.query( + 'SELECT title, date, time FROM schedules WHERE id = ?', + [scheduleId] + ); + const s = scheduleRows[0]; + if (s) { + await addOrUpdateSchedule(meilisearch, { + id: scheduleId, + title: s.title, + date: s.date instanceof Date ? s.date.toISOString().split('T')[0] : s.date, + time: s.time || '', + category_id: CONCERT_CATEGORY_ID, + category_name: category.name || '', + category_color: category.color || '', + member_names: memberNames, + }); + } + } + + return { success: true, seriesId: result.seriesId }; + } catch (err) { + fastify.log.error(`콘서트 일정 저장 오류: ${err.message}`); + return serverError(reply, err.message); + } + }); +} diff --git a/backend/src/routes/index.js b/backend/src/routes/index.js index 5b9f977..640a4ca 100644 --- a/backend/src/routes/index.js +++ b/backend/src/routes/index.js @@ -6,6 +6,7 @@ import statsRoutes from './stats/index.js'; import botsRoutes from './admin/bots.js'; import youtubeAdminRoutes from './admin/youtube.js'; import xAdminRoutes from './admin/x.js'; +import concertAdminRoutes from './admin/concert.js'; /** * 라우트 통합 @@ -35,4 +36,7 @@ export default async function routes(fastify) { // 관리자 - X 라우트 fastify.register(xAdminRoutes, { prefix: '/admin/x' }); + + // 관리자 - 콘서트 라우트 + fastify.register(concertAdminRoutes, { prefix: '/admin/concert' }); } diff --git a/backend/src/services/image.js b/backend/src/services/image.js index 897557c..c9af846 100644 --- a/backend/src/services/image.js +++ b/backend/src/services/image.js @@ -215,3 +215,44 @@ export async function uploadAlbumVideo(folderName, filename, buffer) { export async function deleteAlbumVideo(folderName, filename) { await deleteFromS3(`album/${folderName}/teaser/video/${filename}`); } + +/** + * 콘서트 포스터 업로드 + * @param {number} seriesId - 콘서트 시리즈 ID + * @param {Buffer} buffer - 이미지 버퍼 + * @returns {Promise<{originalUrl: string, mediumUrl: string, thumbUrl: string}>} + */ +export async function uploadConcertPoster(seriesId, buffer) { + const { originalBuffer, mediumBuffer, thumbBuffer } = await processImage(buffer); + + const basePath = `concert/${seriesId}/poster`; + + const [originalUrl, mediumUrl, thumbUrl] = await Promise.all([ + uploadToS3(`${basePath}/original/poster.webp`, originalBuffer), + uploadToS3(`${basePath}/medium_800/poster.webp`, mediumBuffer), + uploadToS3(`${basePath}/thumb_400/poster.webp`, thumbBuffer), + ]); + + return { originalUrl, mediumUrl, thumbUrl }; +} + +/** + * 콘서트 MD(굿즈) 이미지 업로드 + * @param {number} seriesId - 콘서트 시리즈 ID + * @param {string} filename - 파일명 (예: '01.webp') + * @param {Buffer} buffer - 이미지 버퍼 + * @returns {Promise<{originalUrl: string, mediumUrl: string, thumbUrl: string}>} + */ +export async function uploadConcertMerchandise(seriesId, filename, buffer) { + const { originalBuffer, mediumBuffer, thumbBuffer } = await processImage(buffer); + + const basePath = `concert/${seriesId}/md`; + + 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 }; +}