feat: 콘서트 일정 저장 API 구현

콘서트 폼 데이터를 저장하는 백엔드 API 추가.
multipart/form-data로 포스터, 굿즈 이미지, 회차, 세트리스트를 처리하고
트랜잭션으로 관련 테이블에 일괄 저장 후 Meilisearch 동기화.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
caadiq 2026-01-31 11:48:50 +09:00
parent ad8406fdd7
commit 65b1d931f3
4 changed files with 256 additions and 0 deletions

View file

@ -2,6 +2,7 @@
export const CATEGORY_IDS = {
YOUTUBE: 2,
X: 3,
CONCERT: 6,
BIRTHDAY: 8,
DEBUT: 9,
};

View file

@ -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);
}
});
}

View file

@ -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' });
}

View file

@ -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 };
}