diff --git a/backend/src/routes/admin/bots.js b/backend/src/routes/admin/bots.js new file mode 100644 index 0000000..a1b0750 --- /dev/null +++ b/backend/src/routes/admin/bots.js @@ -0,0 +1,180 @@ +import bots from '../../config/bots.js'; + +/** + * 봇 관리 라우트 + * 인증 필요 + */ +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: '봇 목록 조회', + security: [{ bearerAuth: [] }], + }, + preHandler: [fastify.authenticate], + }, async (request, reply) => { + const result = []; + + for (const bot of bots) { + const status = await scheduler.getStatus(bot.id); + + // cron 표현식에서 간격 추출 (분 단위) + let checkInterval = 2; // 기본값 + const cronMatch = bot.cron.match(/^\*\/(\d+)/); + if (cronMatch) { + checkInterval = parseInt(cronMatch[1]); + } + + result.push({ + id: bot.id, + name: bot.channelName || bot.username || bot.id, + type: bot.type, + status: status.status, + last_check_at: status.lastCheckAt, + last_added_count: status.lastAddedCount, + 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: '봇 시작', + security: [{ bearerAuth: [] }], + }, + preHandler: [fastify.authenticate], + }, async (request, reply) => { + const { id } = request.params; + + try { + await scheduler.startBot(id); + return { success: true, message: '봇이 시작되었습니다.' }; + } catch (err) { + return reply.code(400).send({ error: err.message }); + } + }); + + /** + * POST /api/admin/bots/:id/stop + * 봇 정지 + */ + fastify.post('/:id/stop', { + schema: { + tags: ['admin/bots'], + summary: '봇 정지', + security: [{ bearerAuth: [] }], + }, + preHandler: [fastify.authenticate], + }, async (request, reply) => { + const { id } = request.params; + + try { + await scheduler.stopBot(id); + return { success: true, message: '봇이 정지되었습니다.' }; + } catch (err) { + return reply.code(400).send({ error: err.message }); + } + }); + + /** + * POST /api/admin/bots/:id/sync-all + * 전체 동기화 + */ + fastify.post('/:id/sync-all', { + schema: { + tags: ['admin/bots'], + summary: '봇 전체 동기화', + security: [{ bearerAuth: [] }], + }, + preHandler: [fastify.authenticate], + }, async (request, reply) => { + const { id } = request.params; + + const bot = bots.find(b => b.id === id); + if (!bot) { + return reply.code(404).send({ error: '봇을 찾을 수 없습니다.' }); + } + + 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 { + return reply.code(400).send({ error: '지원하지 않는 봇 타입입니다.' }); + } + + // 상태 업데이트 + const status = await scheduler.getStatus(id); + await fastify.redis.set(`bot:status:${id}`, JSON.stringify({ + ...status, + lastCheckAt: new Date().toISOString(), + lastAddedCount: result.addedCount, + totalAdded: (status.totalAdded || 0) + result.addedCount, + updatedAt: new Date().toISOString(), + })); + + return { + success: true, + addedCount: result.addedCount, + total: result.total, + }; + } catch (err) { + fastify.log.error(`[${id}] 전체 동기화 오류:`, err); + return reply.code(500).send({ error: err.message }); + } + }); + + /** + * GET /api/admin/quota-warning + * 할당량 경고 조회 + */ + fastify.get('/quota-warning', { + schema: { + tags: ['admin/bots'], + summary: '할당량 경고 조회', + security: [{ bearerAuth: [] }], + }, + 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: '할당량 경고 해제', + security: [{ bearerAuth: [] }], + }, + preHandler: [fastify.authenticate], + }, async (request, reply) => { + await redis.del(QUOTA_WARNING_KEY); + return { success: true }; + }); +} diff --git a/backend/src/routes/index.js b/backend/src/routes/index.js index ba0ef65..b3c51f8 100644 --- a/backend/src/routes/index.js +++ b/backend/src/routes/index.js @@ -3,6 +3,7 @@ import membersRoutes from './members/index.js'; import albumsRoutes from './albums/index.js'; import schedulesRoutes from './schedules/index.js'; import statsRoutes from './stats/index.js'; +import botsRoutes from './admin/bots.js'; /** * 라우트 통합 @@ -23,4 +24,7 @@ export default async function routes(fastify) { // 통계 라우트 fastify.register(statsRoutes, { prefix: '/stats' }); + + // 관리자 - 봇 라우트 + fastify.register(botsRoutes, { prefix: '/admin/bots' }); } diff --git a/frontend/src/api/admin/bots.js b/frontend/src/api/admin/bots.js index 0bc1828..fab0d51 100644 --- a/frontend/src/api/admin/bots.js +++ b/frontend/src/api/admin/bots.js @@ -25,10 +25,10 @@ export async function syncAllVideos(id) { // 할당량 경고 조회 export async function getQuotaWarning() { - return fetchAdminApi("/api/admin/quota-warning"); + return fetchAdminApi("/api/admin/bots/quota-warning"); } // 할당량 경고 해제 export async function dismissQuotaWarning() { - return fetchAdminApi("/api/admin/quota-warning", { method: "DELETE" }); + return fetchAdminApi("/api/admin/bots/quota-warning", { method: "DELETE" }); } diff --git a/frontend/src/pages/pc/admin/AdminScheduleBots.jsx b/frontend/src/pages/pc/admin/AdminScheduleBots.jsx index 2f8b746..7b59e56 100644 --- a/frontend/src/pages/pc/admin/AdminScheduleBots.jsx +++ b/frontend/src/pages/pc/admin/AdminScheduleBots.jsx @@ -180,17 +180,15 @@ function AdminScheduleBots() { } }; - // 시간 포맷 (DB에 KST로 저장되어 있으므로 그대로 표시) + // 시간 포맷 (UTC → KST 변환) const formatTime = (dateString) => { if (!dateString) return '-'; - // DB의 KST 시간을 UTC로 재해석하지 않도록 Z 접미사 제거 - const cleanDateString = dateString.replace('Z', '').replace('T', ' '); - const date = new Date(cleanDateString); - return date.toLocaleString('ko-KR', { - month: 'short', - day: 'numeric', - hour: '2-digit', - minute: '2-digit' + const date = new Date(dateString); + return date.toLocaleString('ko-KR', { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' }); };