diff --git a/backend/sql/bot_festival.sql b/backend/sql/bot_festival.sql new file mode 100644 index 0000000..0a9c046 --- /dev/null +++ b/backend/sql/bot_festival.sql @@ -0,0 +1,20 @@ +-- 대학 축제 크롤러 봇 설정 +CREATE TABLE IF NOT EXISTS bot_festival ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(100) NOT NULL COMMENT '봇 이름', + search_url VARCHAR(500) NOT NULL COMMENT '크롤링할 검색 페이지 URL', + cron_interval INT NOT NULL DEFAULT 360 COMMENT '동기화 간격 (분)', + enabled TINYINT(1) NOT NULL DEFAULT 1, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='대학 축제 크롤러 봇 설정'; + +-- 축제 크롤러 처리 로그 (memogipost 글 URL 중복 방지) +CREATE TABLE IF NOT EXISTS festival_crawl_log ( + id INT AUTO_INCREMENT PRIMARY KEY, + post_url VARCHAR(500) NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'processed' COMMENT 'processed | no_event | error', + result_count INT DEFAULT 0 COMMENT '추출된 행사 수', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uk_post_url (post_url) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='축제 크롤러 처리 로그'; diff --git a/frontend/src/api/admin/bots.js b/frontend/src/api/admin/bots.js index 61616ae..25906be 100644 --- a/frontend/src/api/admin/bots.js +++ b/frontend/src/api/admin/bots.js @@ -121,6 +121,49 @@ export async function deleteXBot(id) { return fetchAuthApi(`/admin/x-bots/${id}`, { method: 'DELETE' }); } +/** + * 축제 봇 상세 조회 + * @param {number} id - 축제 봇 DB ID + * @returns {Promise} + */ +export async function getFestivalBot(id) { + return fetchAuthApi(`/admin/festival-bots/${id}`); +} + +/** + * 축제 봇 추가 + * @param {object} data - 봇 데이터 + * @returns {Promise} + */ +export async function createFestivalBot(data) { + return fetchAuthApi('/admin/festival-bots', { + method: 'POST', + body: JSON.stringify(data), + }); +} + +/** + * 축제 봇 수정 + * @param {number} id - 축제 봇 DB ID + * @param {object} data - 업데이트할 데이터 + * @returns {Promise} + */ +export async function updateFestivalBot(id, data) { + return fetchAuthApi(`/admin/festival-bots/${id}`, { + method: 'PUT', + body: JSON.stringify(data), + }); +} + +/** + * 축제 봇 삭제 + * @param {number} id - 축제 봇 DB ID + * @returns {Promise} + */ +export async function deleteFestivalBot(id) { + return fetchAuthApi(`/admin/festival-bots/${id}`, { method: 'DELETE' }); +} + /** * 봇 시작 * @param {string} id - 봇 ID diff --git a/frontend/src/components/pc/admin/bot/BotCard.jsx b/frontend/src/components/pc/admin/bot/BotCard.jsx index 39fa658..442c86a 100644 --- a/frontend/src/components/pc/admin/bot/BotCard.jsx +++ b/frontend/src/components/pc/admin/bot/BotCard.jsx @@ -270,8 +270,8 @@ export const BotTableRow = memo(function BotTableRow({ {bot.status === 'running' ? : } - {/* 수정 (YouTube, X) */} - {(bot.type === 'youtube' || bot.type === 'x') && onEdit && ( + {/* 수정 (YouTube, X, 축제) */} + {(bot.type === 'youtube' || bot.type === 'x' || bot.type === 'festival') && onEdit && ( )} - {/* 삭제 (YouTube, X) */} - {(bot.type === 'youtube' || bot.type === 'x') && onDelete && ( + {/* 삭제 (YouTube, X, 축제) */} + {(bot.type === 'youtube' || bot.type === 'x' || bot.type === 'festival') && onDelete && ( + {createPortal( + + {isOpen && ( + + {options.map((opt) => ( + + ))} + + )} + , + document.body + )} + + ); +} + +function FestivalBotDialog({ isOpen, onClose, botId = null, onSuccess }) { + const queryClient = useQueryClient(); + const isEdit = !!botId; + + // 폼 상태 + const [name, setName] = useState(''); + const [searchUrl, setSearchUrl] = useState(''); + const [interval, setInterval] = useState(360); + const [submitting, setSubmitting] = useState(false); + + // 축제 봇 상세 조회 (수정 모드) + const { data: bot, isLoading: botLoading } = useQuery({ + queryKey: ['admin', 'festival-bot', botId], + queryFn: () => getFestivalBot(botId), + enabled: isOpen && !!botId, + staleTime: 0, + }); + + // 다이얼로그 열릴 때 데이터 설정 + useEffect(() => { + if (!isOpen) return; + + if (bot) { + // 수정 모드 + setName(bot.name || ''); + setSearchUrl(bot.search_url || ''); + setInterval(bot.cron_interval || 360); + } else if (!botId) { + // 추가 모드 + setName(''); + setSearchUrl(''); + setInterval(360); + } + }, [isOpen, bot, botId]); + + // 제출 + const handleSubmit = async (e) => { + e.preventDefault(); + if (!name.trim() || !searchUrl.trim()) return; + + setSubmitting(true); + try { + const data = { + name: name.trim(), + search_url: searchUrl.trim(), + cron_interval: interval, + }; + + if (isEdit) { + await updateFestivalBot(botId, data); + } else { + await createFestivalBot(data); + } + + queryClient.invalidateQueries({ queryKey: ['admin', 'bots'] }); + queryClient.invalidateQueries({ queryKey: ['admin', 'festival-bot'] }); + + onSuccess?.(); + onClose(); + } catch (error) { + console.error('봇 저장 실패:', error); + alert(error.message || '봇 저장에 실패했습니다.'); + } finally { + setSubmitting(false); + } + }; + + return createPortal( + + {isOpen && ( + + e.stopPropagation()} + > + {/* 헤더 */} +
+
+
+ +
+

+ {isEdit ? '축제 봇 수정' : '축제 봇 추가'} +

+
+ +
+ + {/* 본문 */} + {botLoading ? ( +
+ +
+ ) : ( +
+ {/* 봇 이름 */} +
+ + setName(e.target.value)} + placeholder="대학 축제 봇" + className="w-full px-4 py-2.5 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-emerald-500/20 focus:border-emerald-500" + /> +
+ + {/* 검색 URL */} +
+ + setSearchUrl(e.target.value)} + placeholder="https://memogipost.tistory.com/search/프로미스나인" + className="w-full px-4 py-2.5 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-emerald-500/20 focus:border-emerald-500" + /> +

+ 축제 정보를 수집할 검색 페이지 URL을 입력하세요 +

+
+ + {/* 동기화 간격 */} +
+ + +
+
+ )} + + {/* 푸터 */} +
+ + +
+
+
+ )} +
, + document.body + ); +} + +export default FestivalBotDialog; diff --git a/frontend/src/components/pc/admin/bot/index.js b/frontend/src/components/pc/admin/bot/index.js index 9b869ee..29d7386 100644 --- a/frontend/src/components/pc/admin/bot/index.js +++ b/frontend/src/components/pc/admin/bot/index.js @@ -1,3 +1,4 @@ export { default as BotCard, XIcon, MeilisearchIcon, BotListItem, BotMiniCard, BotTableRow, BotTable } from './BotCard'; export { default as YouTubeBotDialog } from './YouTubeBotDialog'; export { default as XBotDialog } from './XBotDialog'; +export { default as FestivalBotDialog } from './FestivalBotDialog'; diff --git a/frontend/src/pages/pc/admin/schedules/ScheduleBots.jsx b/frontend/src/pages/pc/admin/schedules/ScheduleBots.jsx index 3565f4c..e35d98b 100644 --- a/frontend/src/pages/pc/admin/schedules/ScheduleBots.jsx +++ b/frontend/src/pages/pc/admin/schedules/ScheduleBots.jsx @@ -2,9 +2,9 @@ import { useState, useEffect, useMemo } from 'react'; import { Link } from 'react-router-dom'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { motion, AnimatePresence } from 'framer-motion'; -import { Home, ChevronRight, Bot, CheckCircle, XCircle, RefreshCw, Plus, Youtube } from 'lucide-react'; +import { Home, ChevronRight, Bot, CheckCircle, XCircle, RefreshCw, Plus, Youtube, PartyPopper } from 'lucide-react'; import { Toast, Tooltip, AnimatedNumber } from '@/components/common'; -import { AdminLayout, XIcon, MeilisearchIcon, BotTableRow, BotTable, YouTubeBotDialog, XBotDialog } from '@/components/pc/admin'; +import { AdminLayout, XIcon, MeilisearchIcon, BotTableRow, BotTable, YouTubeBotDialog, XBotDialog, FestivalBotDialog } from '@/components/pc/admin'; import { useAdminAuth } from '@/hooks/pc/admin'; import { useToast } from '@/hooks/common'; import * as botsApi from '@/api/admin/bots'; @@ -34,6 +34,14 @@ const SECTIONS = { borderColor: 'border-gray-200', canAdd: true, }, + festival: { + title: '축제', + icon: PartyPopper, + color: 'text-emerald-500', + bgColor: 'bg-emerald-50', + borderColor: 'border-emerald-100', + canAdd: true, + }, }; // 애니메이션 variants @@ -63,6 +71,7 @@ function ScheduleBots() { const [quotaWarning, setQuotaWarning] = useState(null); // 할당량 경고 상태 const [youtubeDialogOpen, setYoutubeDialogOpen] = useState(false); // YouTube 봇 다이얼로그 const [xDialogOpen, setXDialogOpen] = useState(false); // X 봇 다이얼로그 + const [festivalDialogOpen, setFestivalDialogOpen] = useState(false); // 축제 봇 다이얼로그 const [editingBotId, setEditingBotId] = useState(null); // 수정 중인 봇 DB ID const [editingBotType, setEditingBotType] = useState(null); // 수정 중인 봇 타입 const [deletingBot, setDeletingBot] = useState(null); // 삭제할 봇 @@ -168,6 +177,8 @@ function ScheduleBots() { await botsApi.deleteYouTubeBot(deletingBot.db_id); } else if (deletingBot.type === 'x') { await botsApi.deleteXBot(deletingBot.db_id); + } else if (deletingBot.type === 'festival') { + await botsApi.deleteFestivalBot(deletingBot.db_id); } queryClient.invalidateQueries({ queryKey: ['admin', 'bots'] }); setToast({ type: 'success', message: `${deletingBot.name} 봇이 삭제되었습니다.` }); @@ -249,7 +260,7 @@ function ScheduleBots() { // 봇을 타입별로 그룹화 const botsByType = useMemo(() => { - const grouped = { meilisearch: [], youtube: [], x: [] }; + const grouped = { meilisearch: [], youtube: [], x: [], festival: [] }; bots.forEach((bot) => { if (grouped[bot.type]) { grouped[bot.type].push(bot); @@ -285,6 +296,18 @@ function ScheduleBots() { setToast({ type: 'success', message: editingBotId ? '봇이 수정되었습니다.' : '봇이 추가되었습니다.' }); }} /> + { + setFestivalDialogOpen(false); + setEditingBotId(null); + setEditingBotType(null); + }} + botId={editingBotId} + onSuccess={() => { + setToast({ type: 'success', message: editingBotId ? '봇이 수정되었습니다.' : '봇이 추가되었습니다.' }); + }} + /> {/* 삭제 확인 다이얼로그 */} @@ -457,6 +480,8 @@ function ScheduleBots() { setYoutubeDialogOpen(true); } else if (type === 'x') { setXDialogOpen(true); + } else if (type === 'festival') { + setFestivalDialogOpen(true); } }} className="flex items-center gap-1.5 px-3 py-1.5 bg-white border border-gray-200 rounded-lg text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors" @@ -508,6 +533,8 @@ function ScheduleBots() { setYoutubeDialogOpen(true); } else if (bot.type === 'x') { setXDialogOpen(true); + } else if (bot.type === 'festival') { + setFestivalDialogOpen(true); } }} onDelete={(bot) => setDeletingBot(bot)}