fromis_9/backend/src/routes/admin/variety.js
caadiq 54e014c833 perf(schedule): 월별 일정 Redis 캐시 + 쓰기 시 무효화
캘린더(최다 호출 공개 엔드포인트) getMonthlySchedules를 getOrSet으로
캐시(TTL 60s). 무효화는 모든 쓰기가 수렴하는 3개 meili 동기화 함수
(addOrUpdateSchedule/syncScheduleById/deleteSchedule)에 redis 전달 시
schedule:monthly:* 무효화. 관리자 라우트·봇 경로에서 redis 전달(즉시
반영), festival/누락 경로는 60s TTL로 자동 치유.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-07 16:39:40 +09:00

267 lines
10 KiB
JavaScript

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