feat: 콘서트 일정 저장 API 구현
콘서트 폼 데이터를 저장하는 백엔드 API 추가. multipart/form-data로 포스터, 굿즈 이미지, 회차, 세트리스트를 처리하고 트랜잭션으로 관련 테이블에 일괄 저장 후 Meilisearch 동기화. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
ad8406fdd7
commit
65b1d931f3
4 changed files with 256 additions and 0 deletions
|
|
@ -2,6 +2,7 @@
|
|||
export const CATEGORY_IDS = {
|
||||
YOUTUBE: 2,
|
||||
X: 3,
|
||||
CONCERT: 6,
|
||||
BIRTHDAY: 8,
|
||||
DEBUT: 9,
|
||||
};
|
||||
|
|
|
|||
210
backend/src/routes/admin/concert.js
Normal file
210
backend/src/routes/admin/concert.js
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -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' });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue