diff --git a/backend/sql/schedule_variety.sql b/backend/sql/schedule_variety.sql index 721e281..e154f31 100644 --- a/backend/sql/schedule_variety.sql +++ b/backend/sql/schedule_variety.sql @@ -3,7 +3,7 @@ CREATE TABLE IF NOT EXISTS schedule_variety ( schedule_id INT NOT NULL, broadcaster VARCHAR(100) NOT NULL COMMENT '방송사/플랫폼 (KBS, MBC, 유튜브, 티빙 등)', replay_url VARCHAR(500) DEFAULT NULL COMMENT '다시보기 링크', - thumbnail_url VARCHAR(500) DEFAULT NULL COMMENT '썸네일 이미지 URL', + thumbnail_id INT DEFAULT NULL COMMENT '썸네일 이미지 ID (images 테이블 참조)', PRIMARY KEY (schedule_id), CONSTRAINT fk_variety_schedule FOREIGN KEY (schedule_id) REFERENCES schedules(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='예능 일정 상세'; diff --git a/backend/src/routes/admin/variety.js b/backend/src/routes/admin/variety.js index fdeaac5..27c2f4c 100644 --- a/backend/src/routes/admin/variety.js +++ b/backend/src/routes/admin/variety.js @@ -1,4 +1,5 @@ import { CATEGORY_IDS } from '../../config/index.js'; +import { uploadVarietyThumbnail } from '../../services/image.js'; import { addOrUpdateSchedule, syncScheduleById } from '../../services/meilisearch/index.js'; import { badRequest, notFound, serverError } from '../../utils/error.js'; import { logActivity } from '../../utils/log.js'; @@ -13,61 +14,77 @@ export default async function varietyRoutes(fastify) { /** * POST /api/admin/variety/schedule - * 예능 일정 저장 + * 예능 일정 저장 (multipart/form-data) */ fastify.post('/schedule', { - schema: { - tags: ['admin/variety'], - summary: '예능 일정 저장', - security: [{ bearerAuth: [] }], - }, preHandler: [fastify.authenticate], }, async (request, reply) => { - const { title, date, time, broadcaster, replayUrl, thumbnailUrl, memberIds } = request.body; + const parts = request.parts(); - if (!title?.trim()) { - return badRequest(reply, '프로그램명은 필수입니다.'); - } - if (!date) { - return badRequest(reply, '날짜는 필수입니다.'); - } - if (!broadcaster?.trim()) { - return badRequest(reply, '방송사/플랫폼은 필수입니다.'); + let title = ''; + let date = ''; + let time = null; + let broadcaster = ''; + let replayUrl = null; + let memberIds = []; + let thumbnailBuffer = null; + + for await (const part of parts) { + if (part.type === 'file' && part.fieldname === 'thumbnail') { + thumbnailBuffer = await part.toBuffer(); + } else if (part.type === 'field') { + if (part.fieldname === 'title') title = part.value; + else if (part.fieldname === 'date') date = part.value; + else if (part.fieldname === 'time') time = part.value || null; + else if (part.fieldname === 'broadcaster') broadcaster = part.value; + else if (part.fieldname === 'replayUrl') replayUrl = part.value || null; + else if (part.fieldname === 'memberIds') memberIds = JSON.parse(part.value); + } } + if (!title?.trim()) return badRequest(reply, '프로그램명은 필수입니다.'); + if (!date) return badRequest(reply, '날짜는 필수입니다.'); + if (!broadcaster?.trim()) return badRequest(reply, '방송사/플랫폼은 필수입니다.'); + try { // schedules 테이블 const [scheduleResult] = await db.query( 'INSERT INTO schedules (category_id, title, date, time) VALUES (?, ?, ?, ?)', - [VARIETY_CATEGORY_ID, title.trim(), date, time || null] + [VARIETY_CATEGORY_ID, title.trim(), date, time] ); const scheduleId = scheduleResult.insertId; + // 썸네일 업로드 + let thumbnailId = null; + if (thumbnailBuffer && thumbnailBuffer.length > 0) { + const { originalUrl, mediumUrl, thumbUrl } = await uploadVarietyThumbnail(scheduleId, thumbnailBuffer); + const [imgResult] = await db.query( + 'INSERT INTO images (original_url, medium_url, thumb_url) VALUES (?, ?, ?)', + [originalUrl, mediumUrl, thumbUrl] + ); + thumbnailId = imgResult.insertId; + } + // schedule_variety 테이블 await db.query( - 'INSERT INTO schedule_variety (schedule_id, broadcaster, replay_url, thumbnail_url) VALUES (?, ?, ?, ?)', - [scheduleId, broadcaster.trim(), replayUrl?.trim() || null, thumbnailUrl?.trim() || null] + 'INSERT INTO schedule_variety (schedule_id, broadcaster, replay_url, thumbnail_id) VALUES (?, ?, ?, ?)', + [scheduleId, broadcaster.trim(), replayUrl?.trim() || null, thumbnailId] ); // schedule_members 테이블 - if (memberIds && memberIds.length > 0) { + if (memberIds.length > 0) { const values = memberIds.map(memberId => [scheduleId, memberId]); await db.query('INSERT INTO schedule_members (schedule_id, member_id) VALUES ?', [values]); } // Meilisearch 동기화 - const [categoryRows] = await db.query( - 'SELECT name, color FROM schedule_categories WHERE id = ?', - [VARIETY_CATEGORY_ID] - ); + const [categoryRows] = await db.query('SELECT name, color FROM schedule_categories WHERE id = ?', [VARIETY_CATEGORY_ID]); const category = categoryRows[0] || {}; - let memberNames = ''; - if (memberIds && memberIds.length > 0) { + 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(','); } - await addOrUpdateSchedule(meilisearch, { id: scheduleId, title: title.trim(), @@ -79,15 +96,7 @@ export default async function varietyRoutes(fastify) { member_names: memberNames, }); - logActivity(db, { - actor: 'admin', - action: 'create', - category: 'schedule', - targetType: 'variety_schedule', - targetId: scheduleId, - summary: `예능 일정 생성: ${title.trim()}`, - }); - + logActivity(db, { actor: 'admin', action: 'create', category: 'schedule', targetType: 'variety_schedule', targetId: scheduleId, summary: `예능 일정 생성: ${title.trim()}` }); return { success: true, scheduleId }; } catch (err) { fastify.log.error(`예능 일정 저장 오류: ${err.message}`); @@ -97,69 +106,82 @@ export default async function varietyRoutes(fastify) { /** * PUT /api/admin/variety/schedule/:id - * 예능 일정 수정 + * 예능 일정 수정 (multipart/form-data) */ fastify.put('/schedule/:id', { - schema: { - tags: ['admin/variety'], - summary: '예능 일정 수정', - security: [{ bearerAuth: [] }], - }, preHandler: [fastify.authenticate], }, async (request, reply) => { const { id } = request.params; - const { title, date, time, broadcaster, replayUrl, thumbnailUrl, memberIds } = request.body; + const parts = request.parts(); - if (!title?.trim()) { - return badRequest(reply, '프로그램명은 필수입니다.'); + let title = ''; + let date = ''; + let time = null; + let broadcaster = ''; + let replayUrl = null; + let memberIds = []; + let thumbnailBuffer = null; + let removeThumbnail = false; + + for await (const part of parts) { + if (part.type === 'file' && part.fieldname === 'thumbnail') { + thumbnailBuffer = await part.toBuffer(); + } else if (part.type === 'field') { + if (part.fieldname === 'title') title = part.value; + else if (part.fieldname === 'date') date = part.value; + else if (part.fieldname === 'time') time = part.value || null; + else if (part.fieldname === 'broadcaster') broadcaster = part.value; + else if (part.fieldname === 'replayUrl') replayUrl = part.value || null; + else if (part.fieldname === 'memberIds') memberIds = JSON.parse(part.value); + else if (part.fieldname === 'removeThumbnail') removeThumbnail = part.value === 'true'; + } } + if (!title?.trim()) return badRequest(reply, '프로그램명은 필수입니다.'); + try { - // 존재 확인 const [existing] = await db.query('SELECT id FROM schedules WHERE id = ?', [id]); - if (existing.length === 0) { - return notFound(reply, '일정을 찾을 수 없습니다.'); - } + if (existing.length === 0) return notFound(reply, '일정을 찾을 수 없습니다.'); // schedules 업데이트 - await db.query( - 'UPDATE schedules SET title = ?, date = ?, time = ? WHERE id = ?', - [title.trim(), date, time || null, id] - ); + await db.query('UPDATE schedules SET title = ?, date = ?, time = ? WHERE id = ?', [title.trim(), date, time, id]); - // schedule_variety 업데이트 (upsert) - const [varietyExisting] = await db.query('SELECT schedule_id FROM schedule_variety WHERE schedule_id = ?', [id]); - if (varietyExisting.length > 0) { - await db.query( - 'UPDATE schedule_variety SET broadcaster = ?, replay_url = ?, thumbnail_url = ? WHERE schedule_id = ?', - [broadcaster?.trim() || '', replayUrl?.trim() || null, thumbnailUrl?.trim() || null, id] - ); + // 기존 variety 데이터 조회 + const [varietyRows] = await db.query('SELECT thumbnail_id FROM schedule_variety WHERE schedule_id = ?', [id]); + let thumbnailId = varietyRows[0]?.thumbnail_id || null; + + // 썸네일 업데이트 + if (thumbnailBuffer && thumbnailBuffer.length > 0) { + const { originalUrl, mediumUrl, thumbUrl } = await uploadVarietyThumbnail(id, thumbnailBuffer); + if (thumbnailId) { + await db.query('UPDATE images SET original_url = ?, medium_url = ?, thumb_url = ? WHERE id = ?', [originalUrl, mediumUrl, thumbUrl, thumbnailId]); + } else { + const [imgResult] = await db.query('INSERT INTO images (original_url, medium_url, thumb_url) VALUES (?, ?, ?)', [originalUrl, mediumUrl, thumbUrl]); + thumbnailId = imgResult.insertId; + } + } else if (removeThumbnail && thumbnailId) { + await db.query('DELETE FROM images WHERE id = ?', [thumbnailId]); + thumbnailId = null; + } + + // schedule_variety upsert + if (varietyRows.length > 0) { + await db.query('UPDATE schedule_variety SET broadcaster = ?, replay_url = ?, thumbnail_id = ? WHERE schedule_id = ?', + [broadcaster?.trim() || '', replayUrl?.trim() || null, thumbnailId, id]); } else { - await db.query( - 'INSERT INTO schedule_variety (schedule_id, broadcaster, replay_url, thumbnail_url) VALUES (?, ?, ?, ?)', - [id, broadcaster?.trim() || '', replayUrl?.trim() || null, thumbnailUrl?.trim() || null] - ); + await db.query('INSERT INTO schedule_variety (schedule_id, broadcaster, replay_url, thumbnail_id) VALUES (?, ?, ?, ?)', + [id, broadcaster?.trim() || '', replayUrl?.trim() || null, thumbnailId]); } // 멤버 업데이트 await db.query('DELETE FROM schedule_members WHERE schedule_id = ?', [id]); - if (memberIds && memberIds.length > 0) { + if (memberIds.length > 0) { const values = memberIds.map(memberId => [id, memberId]); await db.query('INSERT INTO schedule_members (schedule_id, member_id) VALUES ?', [values]); } - // Meilisearch 동기화 await syncScheduleById(meilisearch, db, parseInt(id)); - - logActivity(db, { - actor: 'admin', - action: 'update', - category: 'schedule', - targetType: 'variety_schedule', - targetId: parseInt(id), - summary: `예능 일정 수정: ${title.trim()}`, - }); - + logActivity(db, { actor: 'admin', action: 'update', category: 'schedule', targetType: 'variety_schedule', targetId: parseInt(id), summary: `예능 일정 수정: ${title.trim()}` }); return { success: true }; } catch (err) { fastify.log.error(`예능 일정 수정 오류: ${err.message}`); @@ -172,11 +194,6 @@ export default async function varietyRoutes(fastify) { * 예능 일정 상세 조회 (수정 폼용) */ fastify.get('/schedule/:id', { - schema: { - tags: ['admin/variety'], - summary: '예능 일정 상세 조회', - security: [{ bearerAuth: [] }], - }, preHandler: [fastify.authenticate], }, async (request, reply) => { const { id } = request.params; @@ -184,32 +201,27 @@ export default async function varietyRoutes(fastify) { try { const [rows] = await db.query(` SELECT s.id, s.title, s.date, s.time, - sv.broadcaster, sv.replay_url, sv.thumbnail_url + sv.broadcaster, sv.replay_url, sv.thumbnail_id, + i.original_url as thumb_original, i.medium_url as thumb_medium, i.thumb_url as thumb_thumb FROM schedules s LEFT JOIN schedule_variety sv ON s.id = sv.schedule_id + LEFT JOIN images i ON sv.thumbnail_id = i.id WHERE s.id = ? `, [id]); - if (rows.length === 0) { - return notFound(reply, '일정을 찾을 수 없습니다.'); - } + if (rows.length === 0) return notFound(reply, '일정을 찾을 수 없습니다.'); - const schedule = rows[0]; - - // 멤버 조회 - const [memberRows] = await db.query( - 'SELECT member_id FROM schedule_members WHERE schedule_id = ?', - [id] - ); + const s = rows[0]; + const [memberRows] = await db.query('SELECT member_id FROM schedule_members WHERE schedule_id = ?', [id]); return { - id: schedule.id, - title: schedule.title, - date: schedule.date instanceof Date ? schedule.date.toISOString().split('T')[0] : schedule.date?.split('T')[0] || '', - time: schedule.time ? schedule.time.substring(0, 5) : '', - broadcaster: schedule.broadcaster || '', - replayUrl: schedule.replay_url || '', - thumbnailUrl: schedule.thumbnail_url || '', + id: s.id, + title: s.title, + date: s.date instanceof Date ? s.date.toISOString().split('T')[0] : s.date?.split('T')[0] || '', + time: s.time ? s.time.substring(0, 5) : '', + broadcaster: s.broadcaster || '', + replayUrl: s.replay_url || '', + thumbnailUrl: s.thumb_medium || s.thumb_original || '', memberIds: memberRows.map(r => r.member_id), }; } catch (err) { diff --git a/backend/src/services/image.js b/backend/src/services/image.js index c9af846..80a09b8 100644 --- a/backend/src/services/image.js +++ b/backend/src/services/image.js @@ -256,3 +256,23 @@ export async function uploadConcertMerchandise(seriesId, filename, buffer) { return { originalUrl, mediumUrl, thumbUrl }; } + +/** + * 예능 일정 썸네일 업로드 + * @param {number} scheduleId - 일정 ID + * @param {Buffer} buffer - 이미지 버퍼 + * @returns {Promise<{originalUrl: string, mediumUrl: string, thumbUrl: string}>} + */ +export async function uploadVarietyThumbnail(scheduleId, buffer) { + const { originalBuffer, mediumBuffer, thumbBuffer } = await processImage(buffer); + + const basePath = `schedule/${scheduleId}/thumbnail`; + + const [originalUrl, mediumUrl, thumbUrl] = await Promise.all([ + uploadToS3(`${basePath}/original/thumbnail.webp`, originalBuffer), + uploadToS3(`${basePath}/medium_800/thumbnail.webp`, mediumBuffer), + uploadToS3(`${basePath}/thumb_400/thumbnail.webp`, thumbBuffer), + ]); + + return { originalUrl, mediumUrl, thumbUrl }; +} diff --git a/backend/src/services/schedule.js b/backend/src/services/schedule.js index c4af345..9f11f48 100644 --- a/backend/src/services/schedule.js +++ b/backend/src/services/schedule.js @@ -203,12 +203,13 @@ export async function getScheduleDetail(db, id, getXProfile = null) { sx.image_urls as x_image_urls, sv.broadcaster as variety_broadcaster, sv.replay_url as variety_replay_url, - sv.thumbnail_url as variety_thumbnail_url + svi.medium_url as variety_thumbnail_url FROM schedules s LEFT JOIN schedule_categories c ON s.category_id = c.id LEFT JOIN schedule_youtube sy ON s.id = sy.schedule_id LEFT JOIN schedule_x sx ON s.id = sx.schedule_id LEFT JOIN schedule_variety sv ON s.id = sv.schedule_id + LEFT JOIN images svi ON sv.thumbnail_id = svi.id WHERE s.id = ? `, [id]); diff --git a/frontend/src/api/admin/variety.js b/frontend/src/api/admin/variety.js index 92cab4f..4b44e9e 100644 --- a/frontend/src/api/admin/variety.js +++ b/frontend/src/api/admin/variety.js @@ -1,16 +1,13 @@ /** * 예능 관리자 API */ -import { fetchAuthApi } from '@/api/client'; +import { fetchAuthApi, fetchFormData } from '@/api/client'; /** * 예능 일정 생성 */ -export async function createVarietySchedule(data) { - return fetchAuthApi('/admin/variety/schedule', { - method: 'POST', - body: JSON.stringify(data), - }); +export async function createVarietySchedule(formData) { + return fetchFormData('/admin/variety/schedule', formData, 'POST'); } /** @@ -23,9 +20,6 @@ export async function getVarietySchedule(id) { /** * 예능 일정 수정 */ -export async function updateVarietySchedule(id, data) { - return fetchAuthApi(`/admin/variety/schedule/${id}`, { - method: 'PUT', - body: JSON.stringify(data), - }); +export async function updateVarietySchedule(id, formData) { + return fetchFormData(`/admin/variety/schedule/${id}`, formData, 'PUT'); } diff --git a/frontend/src/pages/pc/admin/schedules/edit/VarietyEditForm.jsx b/frontend/src/pages/pc/admin/schedules/edit/VarietyEditForm.jsx index ddf0d31..6270f36 100644 --- a/frontend/src/pages/pc/admin/schedules/edit/VarietyEditForm.jsx +++ b/frontend/src/pages/pc/admin/schedules/edit/VarietyEditForm.jsx @@ -46,7 +46,9 @@ function VarietyEditForm() { const [date, setDate] = useState(""); const [time, setTime] = useState(""); const [replayUrl, setReplayUrl] = useState(""); - const [thumbnailUrl, setThumbnailUrl] = useState(""); + const [thumbnailFile, setThumbnailFile] = useState(null); + const [thumbnailPreview, setThumbnailPreview] = useState(null); + const [removeThumbnail, setRemoveThumbnail] = useState(false); const [selectedMemberIds, setSelectedMemberIds] = useState([]); const [saving, setSaving] = useState(false); const [initialized, setInitialized] = useState(false); @@ -58,7 +60,7 @@ function VarietyEditForm() { setDate(scheduleData.date || ""); setTime(scheduleData.time || ""); setReplayUrl(scheduleData.replayUrl || ""); - setThumbnailUrl(scheduleData.thumbnailUrl || ""); + if (scheduleData.thumbnailUrl) setThumbnailPreview(scheduleData.thumbnailUrl); setSelectedMemberIds(scheduleData.memberIds || []); setInitialized(true); } @@ -85,15 +87,17 @@ function VarietyEditForm() { setSaving(true); try { - await updateVarietySchedule(id, { - title: title.trim(), - broadcaster: broadcaster.trim(), - date, - time: time || null, - replayUrl: replayUrl.trim() || null, - thumbnailUrl: thumbnailUrl.trim() || null, - memberIds: selectedMemberIds, - }); + const formData = new FormData(); + formData.append("title", title.trim()); + formData.append("broadcaster", broadcaster.trim()); + formData.append("date", date); + if (time) formData.append("time", time); + if (replayUrl.trim()) formData.append("replayUrl", replayUrl.trim()); + formData.append("memberIds", JSON.stringify(selectedMemberIds)); + if (thumbnailFile) formData.append("thumbnail", thumbnailFile); + if (removeThumbnail) formData.append("removeThumbnail", "true"); + + await updateVarietySchedule(id, formData); sessionStorage.setItem("scheduleToast", JSON.stringify({ type: "success", message: "예능 일정이 수정되었습니다." })); navigate("/admin/schedule"); } catch (err) { @@ -176,9 +180,18 @@ function VarietyEditForm() { setReplayUrl(e.target.value)} placeholder="https://..." className="w-full px-3 py-2.5 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent" />