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'; const VARIETY_CATEGORY_ID = CATEGORY_IDS.VARIETY; const BROADCASTER_KEY = 'variety:broadcasters'; /** * 예능 관련 관리자 라우트 */ export default async function varietyRoutes(fastify) { const { db, meilisearch, redis } = fastify; /** * GET /api/admin/variety/broadcasters * 자주 사용된 방송사/플랫폼 목록 (상위 10개) */ fastify.get('/broadcasters', { preHandler: [fastify.authenticate], }, async () => { // Redis에 캐시가 있으면 사용 const cached = await redis.get(BROADCASTER_KEY); if (cached) { return JSON.parse(cached); } // DB에서 빈도수 조회 const [rows] = await db.query( `SELECT broadcaster, COUNT(*) as cnt FROM schedule_variety GROUP BY broadcaster ORDER BY cnt DESC LIMIT 10` ); const broadcasters = rows.map(r => r.broadcaster); // Redis 캐시 (1시간) await redis.setex(BROADCASTER_KEY, 3600, JSON.stringify(broadcasters)); return broadcasters; }); /** * POST /api/admin/variety/schedule * 예능 일정 저장 (multipart/form-data) */ fastify.post('/schedule', { preHandler: [fastify.authenticate], }, async (request, reply) => { const parts = request.parts(); 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] ); 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_id) VALUES (?, ?, ?, ?)', [scheduleId, broadcaster.trim(), replayUrl?.trim() || null, thumbnailId] ); // schedule_members 테이블 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 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(','); } await addOrUpdateSchedule(meilisearch, { id: scheduleId, title: title.trim(), date, time: time || '', category_id: VARIETY_CATEGORY_ID, category_name: category.name || '', category_color: category.color || '', member_names: memberNames, }, redis); // 방송사 캐시 무효화 await redis.del(BROADCASTER_KEY); 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}`); return serverError(reply, err.message); } }); /** * PUT /api/admin/variety/schedule/:id * 예능 일정 수정 (multipart/form-data) */ fastify.put('/schedule/:id', { preHandler: [fastify.authenticate], }, async (request, reply) => { const { id } = request.params; const parts = request.parts(); 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, '일정을 찾을 수 없습니다.'); // schedules 업데이트 await db.query('UPDATE schedules SET title = ?, date = ?, time = ? WHERE id = ?', [title.trim(), date, time, 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_id) VALUES (?, ?, ?, ?)', [id, broadcaster?.trim() || '', replayUrl?.trim() || null, thumbnailId]); } // 멤버 업데이트 await db.query('DELETE FROM schedule_members WHERE schedule_id = ?', [id]); if (memberIds.length > 0) { const values = memberIds.map(memberId => [id, memberId]); await db.query('INSERT INTO schedule_members (schedule_id, member_id) VALUES ?', [values]); } await syncScheduleById(meilisearch, db, parseInt(id), redis); await redis.del(BROADCASTER_KEY); 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}`); return serverError(reply, err.message); } }); /** * GET /api/admin/variety/schedule/:id * 예능 일정 상세 조회 (수정 폼용) */ fastify.get('/schedule/:id', { preHandler: [fastify.authenticate], }, async (request, reply) => { const { id } = request.params; try { const [rows] = await db.query(` SELECT s.id, s.title, s.date, s.time, 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, '일정을 찾을 수 없습니다.'); const s = rows[0]; const [memberRows] = await db.query('SELECT member_id FROM schedule_members WHERE schedule_id = ?', [id]); return { 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) { fastify.log.error(`예능 일정 조회 오류: ${err.message}`); return serverError(reply, err.message); } }); }