import bots from '../../config/bots.js'; import { errorResponse } from '../../schemas/index.js'; import { syncAllSchedules } from '../../services/meilisearch/index.js'; import { badRequest, notFound, serverError } from '../../utils/error.js'; import { nowKST } from '../../utils/date.js'; // 봇 관련 스키마 const botResponse = { type: 'object', properties: { id: { type: 'string' }, name: { type: 'string' }, type: { type: 'string', enum: ['youtube', 'x', 'meilisearch'] }, status: { type: 'string', enum: ['running', 'stopped', 'error'] }, last_check_at: { type: 'string', format: 'date-time' }, last_added_count: { type: 'integer' }, last_sync_duration: { type: 'integer', description: '마지막 동기화 소요 시간 (ms)' }, schedules_added: { type: 'integer' }, check_interval: { type: 'integer' }, error_message: { type: 'string' }, enabled: { type: 'boolean' }, }, }; const botIdParam = { type: 'object', properties: { id: { type: 'string', description: '봇 ID' }, }, required: ['id'], }; /** * 봇 관리 라우트 * 인증 필요 */ export default async function botsRoutes(fastify) { const { scheduler, redis } = fastify; const QUOTA_WARNING_KEY = 'youtube:quota_warning'; /** * GET /api/admin/bots * 봇 목록 조회 */ fastify.get('/', { schema: { tags: ['admin/bots'], summary: '봇 목록 조회', description: '등록된 모든 봇(YouTube, X)의 상태를 조회합니다.', security: [{ bearerAuth: [] }], response: { 200: { type: 'array', items: botResponse, }, }, }, preHandler: [fastify.authenticate], }, async (request, reply) => { const result = []; for (const bot of bots) { const status = await scheduler.getStatus(bot.id); // cron 표현식에서 간격 추출 (분 단위, 일일 스케줄은 1440분) let checkInterval = 2; // 기본값 const cronMatch = bot.cron.match(/^\*\/(\d+)/); if (cronMatch) { checkInterval = parseInt(cronMatch[1]); } else if (/^0 \d+ \* \* \*$/.test(bot.cron)) { // 매일 특정 시간 (예: 0 12 * * *) checkInterval = 1440; // 24시간 = 1440분 } result.push({ id: bot.id, name: bot.name || bot.channelName || bot.username || bot.id, type: bot.type, status: status.status, last_check_at: status.lastCheckAt, last_added_count: status.lastAddedCount, last_sync_duration: status.lastSyncDuration, schedules_added: status.totalAdded, check_interval: checkInterval, error_message: status.errorMessage, enabled: bot.enabled, }); } return result; }); /** * POST /api/admin/bots/:id/start * 봇 시작 */ fastify.post('/:id/start', { schema: { tags: ['admin/bots'], summary: '봇 시작', description: '지정된 봇의 스케줄러를 시작합니다.', security: [{ bearerAuth: [] }], params: botIdParam, response: { 200: { type: 'object', properties: { success: { type: 'boolean' }, message: { type: 'string' }, }, }, 400: errorResponse, }, }, preHandler: [fastify.authenticate], }, async (request, reply) => { const { id } = request.params; try { await scheduler.startBot(id); return { success: true, message: '봇이 시작되었습니다.' }; } catch (err) { return badRequest(reply, err.message); } }); /** * POST /api/admin/bots/:id/stop * 봇 정지 */ fastify.post('/:id/stop', { schema: { tags: ['admin/bots'], summary: '봇 정지', description: '지정된 봇의 스케줄러를 정지합니다.', security: [{ bearerAuth: [] }], params: botIdParam, response: { 200: { type: 'object', properties: { success: { type: 'boolean' }, message: { type: 'string' }, }, }, 400: errorResponse, }, }, preHandler: [fastify.authenticate], }, async (request, reply) => { const { id } = request.params; try { await scheduler.stopBot(id); return { success: true, message: '봇이 정지되었습니다.' }; } catch (err) { return badRequest(reply, err.message); } }); /** * POST /api/admin/bots/:id/sync-all * 전체 동기화 */ fastify.post('/:id/sync-all', { schema: { tags: ['admin/bots'], summary: '봇 전체 동기화', description: '봇이 관리하는 모든 콘텐츠를 다시 동기화합니다.', security: [{ bearerAuth: [] }], params: botIdParam, response: { 200: { type: 'object', properties: { success: { type: 'boolean' }, addedCount: { type: 'integer', description: '추가된 일정 수' }, total: { type: 'integer', description: '총 처리 수' }, }, }, 400: errorResponse, 404: errorResponse, 500: errorResponse, }, }, preHandler: [fastify.authenticate], }, async (request, reply) => { const { id } = request.params; const bot = bots.find(b => b.id === id); if (!bot) { return notFound(reply, '봇을 찾을 수 없습니다.'); } const startTime = Date.now(); try { let result; if (bot.type === 'youtube') { result = await fastify.youtubeBot.syncAllVideos(bot); } else if (bot.type === 'x') { result = await fastify.xBot.syncAllTweets(bot); } else if (bot.type === 'meilisearch') { const count = await syncAllSchedules(fastify.meilisearch, fastify.db); result = { addedCount: count, total: count }; } else { return badRequest(reply, '지원하지 않는 봇 타입입니다.'); } const duration = Date.now() - startTime; // 상태 업데이트 const status = await scheduler.getStatus(id); await fastify.redis.set(`bot:status:${id}`, JSON.stringify({ ...status, lastCheckAt: nowKST(), lastAddedCount: result.addedCount, lastSyncDuration: duration, totalAdded: (status.totalAdded || 0) + result.addedCount, updatedAt: nowKST(), })); return { success: true, addedCount: result.addedCount, total: result.total, }; } catch (err) { fastify.log.error(`[${id}] 전체 동기화 오류:`, err); return serverError(reply, err.message); } }); /** * GET /api/admin/quota-warning * 할당량 경고 조회 */ fastify.get('/quota-warning', { schema: { tags: ['admin/bots'], summary: '할당량 경고 조회', description: 'YouTube API 할당량 경고 상태를 조회합니다.', security: [{ bearerAuth: [] }], response: { 200: { type: 'object', properties: { active: { type: 'boolean' }, message: { type: 'string' }, timestamp: { type: 'string', format: 'date-time' }, }, }, }, }, preHandler: [fastify.authenticate], }, async (request, reply) => { const data = await redis.get(QUOTA_WARNING_KEY); if (data) { return { active: true, ...JSON.parse(data) }; } return { active: false }; }); /** * DELETE /api/admin/quota-warning * 할당량 경고 해제 */ fastify.delete('/quota-warning', { schema: { tags: ['admin/bots'], summary: '할당량 경고 해제', description: 'YouTube API 할당량 경고를 해제합니다.', security: [{ bearerAuth: [] }], response: { 200: { type: 'object', properties: { success: { type: 'boolean' }, }, }, }, }, preHandler: [fastify.authenticate], }, async (request, reply) => { await redis.del(QUOTA_WARNING_KEY); return { success: true }; }); }