diff --git a/backend/src/routes/admin/bots.js b/backend/src/routes/admin/bots.js index 1a8429d..668045a 100644 --- a/backend/src/routes/admin/bots.js +++ b/backend/src/routes/admin/bots.js @@ -100,7 +100,6 @@ export default async function botsRoutes(fastify) { // YouTube 봇인 경우 상세 정보 추가 if (bot.type === 'youtube') { - fastify.log.info(`YouTube bot dbId: ${bot.dbId}`); botData.db_id = bot.dbId; botData.channel_id = bot.channelId; botData.channel_handle = bot.channelHandle; diff --git a/backend/src/routes/admin/youtube-bots.js b/backend/src/routes/admin/youtube-bots.js index 4544761..0d87a33 100644 --- a/backend/src/routes/admin/youtube-bots.js +++ b/backend/src/routes/admin/youtube-bots.js @@ -1,5 +1,6 @@ import { errorResponse } from '../../schemas/index.js'; import { badRequest, notFound, serverError } from '../../utils/error.js'; +import { getChannelByHandle } from '../../services/youtube/api.js'; /** * YouTube 봇 스키마 @@ -32,10 +33,7 @@ const youtubeBotIdParam = { /** * DB row를 API 응답 형식으로 변환 */ -function formatBotResponse(row, fastify) { - if (fastify) { - fastify.log.info(`DB row.auto_schedule_config: ${JSON.stringify(row.auto_schedule_config)}, type: ${typeof row.auto_schedule_config}`); - } +function formatBotResponse(row) { return { id: row.id, channel_id: row.channel_id, @@ -69,6 +67,49 @@ function formatBotResponse(row, fastify) { export default async function youtubeBotsRoutes(fastify) { const { db, scheduler } = fastify; + /** + * POST /api/admin/youtube-bots/lookup + * 채널 핸들로 채널 정보 조회 + */ + fastify.post('/lookup', { + schema: { + tags: ['admin/youtube-bots'], + summary: '채널 핸들로 채널 정보 조회', + security: [{ bearerAuth: [] }], + body: { + type: 'object', + properties: { + handle: { type: 'string', description: '@username 형식' }, + }, + required: ['handle'], + }, + response: { + 200: { + type: 'object', + properties: { + channelId: { type: 'string' }, + handle: { type: 'string' }, + title: { type: 'string' }, + description: { type: 'string' }, + thumbnailUrl: { type: 'string' }, + bannerUrl: { type: 'string' }, + }, + }, + 400: errorResponse, + }, + }, + preHandler: [fastify.authenticate], + }, async (request, reply) => { + const { handle } = request.body; + + try { + const channelInfo = await getChannelByHandle(handle); + return channelInfo; + } catch (err) { + return badRequest(reply, err.message); + } + }); + /** * GET /api/admin/youtube-bots * YouTube 봇 목록 조회 @@ -115,7 +156,7 @@ export default async function youtubeBotsRoutes(fastify) { return notFound(reply, 'YouTube 봇을 찾을 수 없습니다.'); } - return formatBotResponse(rows[0], fastify); + return formatBotResponse(rows[0]); }); /** @@ -131,14 +172,14 @@ export default async function youtubeBotsRoutes(fastify) { type: 'object', properties: { channel_id: { type: 'string' }, - channel_handle: { type: 'string' }, + channel_handle: { type: ['string', 'null'] }, channel_name: { type: 'string' }, - banner_url: { type: 'string' }, + banner_url: { type: ['string', 'null'] }, cron_interval: { type: 'integer', default: 2 }, - title_filters: { type: 'array', items: { type: 'string' } }, - default_member_ids: { type: 'array', items: { type: 'integer' } }, + title_filters: { type: ['array', 'null'], items: { type: 'string' } }, + default_member_ids: { type: ['array', 'null'], items: { type: 'integer' } }, extract_members_from_desc: { type: 'boolean', default: false }, - auto_schedule_config: { type: 'object' }, + auto_schedule_config: { type: ['object', 'null'], additionalProperties: true }, }, required: ['channel_id', 'channel_name'], }, @@ -215,14 +256,14 @@ export default async function youtubeBotsRoutes(fastify) { body: { type: 'object', properties: { - channel_handle: { type: 'string' }, + channel_handle: { type: ['string', 'null'] }, channel_name: { type: 'string' }, - banner_url: { type: 'string' }, + banner_url: { type: ['string', 'null'] }, cron_interval: { type: 'integer' }, - title_filters: { type: 'array', items: { type: 'string' } }, - default_member_ids: { type: 'array', items: { type: 'integer' } }, + title_filters: { type: ['array', 'null'], items: { type: 'string' } }, + default_member_ids: { type: ['array', 'null'], items: { type: 'integer' } }, extract_members_from_desc: { type: 'boolean' }, - auto_schedule_config: { type: 'object' }, + auto_schedule_config: { type: ['object', 'null'], additionalProperties: true }, enabled: { type: 'boolean' }, }, }, diff --git a/backend/src/services/youtube/api.js b/backend/src/services/youtube/api.js index 779f594..6faad9b 100644 --- a/backend/src/services/youtube/api.js +++ b/backend/src/services/youtube/api.js @@ -44,6 +44,41 @@ export async function getUploadsPlaylistId(channelId) { return data.items[0].contentDetails.relatedPlaylists.uploads; } +/** + * 핸들로 채널 조회 + * @param {string} handle - @username 형식 (@ 제외) + */ +export async function getChannelByHandle(handle) { + // @ 제거 + const cleanHandle = handle.startsWith('@') ? handle.slice(1) : handle; + const url = `${API_BASE}/channels?part=snippet,brandingSettings&forHandle=${cleanHandle}&key=${API_KEY}`; + const res = await fetch(url); + const data = await res.json(); + + if (data.error) { + throw new Error(data.error.message); + } + if (!data.items?.length) { + throw new Error('채널을 찾을 수 없습니다'); + } + + const channel = data.items[0]; + const { snippet, brandingSettings } = channel; + + // 배너 URL에 고해상도 파라미터 추가 + const bannerBase = brandingSettings?.image?.bannerExternalUrl; + const bannerUrl = bannerBase ? `${bannerBase}=w2560` : null; + + return { + channelId: channel.id, + handle: cleanHandle, + title: snippet.title, + description: snippet.description, + thumbnailUrl: snippet.thumbnails?.high?.url || snippet.thumbnails?.default?.url, + bannerUrl, + }; +} + /** * 채널 정보 조회 (배너 이미지 포함) */ diff --git a/frontend/src/api/admin/bots.js b/frontend/src/api/admin/bots.js index 88dad5f..a84da6a 100644 --- a/frontend/src/api/admin/bots.js +++ b/frontend/src/api/admin/bots.js @@ -20,6 +20,18 @@ export async function getYouTubeBot(id) { return fetchAuthApi(`/admin/youtube-bots/${id}`); } +/** + * 채널 핸들로 채널 정보 조회 + * @param {string} handle - @username 형식 + * @returns {Promise} + */ +export async function lookupChannel(handle) { + return fetchAuthApi('/admin/youtube-bots/lookup', { + method: 'POST', + body: JSON.stringify({ handle }), + }); +} + /** * YouTube 봇 추가 * @param {object} data - 봇 데이터 diff --git a/frontend/src/components/pc/admin/bot/YouTubeBotDialog.jsx b/frontend/src/components/pc/admin/bot/YouTubeBotDialog.jsx index c94b4df..e67e588 100644 --- a/frontend/src/components/pc/admin/bot/YouTubeBotDialog.jsx +++ b/frontend/src/components/pc/admin/bot/YouTubeBotDialog.jsx @@ -7,7 +7,7 @@ import { useQuery, useQueryClient } from '@tanstack/react-query'; import { motion, AnimatePresence } from 'framer-motion'; import { Youtube, Search, X, ChevronDown, ChevronUp, Loader2 } from 'lucide-react'; import { getMembers } from '@/api/public/members'; -import { getYouTubeBot, createYouTubeBot, updateYouTubeBot } from '@/api/admin/bots'; +import { getYouTubeBot, createYouTubeBot, updateYouTubeBot, lookupChannel } from '@/api/admin/bots'; // 동기화 간격 옵션 const INTERVAL_OPTIONS = [ @@ -315,17 +315,12 @@ function YouTubeBotDialog({ isOpen, onClose, botId = null, onSuccess }) { }); setInterval(bot.cron_interval || 2); - console.log('bot.auto_schedule_config:', bot.auto_schedule_config); - console.log('typeof:', typeof bot.auto_schedule_config); - const config = bot.auto_schedule_config ? (typeof bot.auto_schedule_config === 'string' ? JSON.parse(bot.auto_schedule_config) : bot.auto_schedule_config) : null; - console.log('parsed config:', config); - // config가 존재하고 dayOfWeek가 정의되어 있으면 활성화 if (config && config.dayOfWeek !== undefined) { setAutoScheduleEnabled(true); @@ -375,15 +370,20 @@ function YouTubeBotDialog({ isOpen, onClose, botId = null, onSuccess }) { const handleLookup = async () => { if (!handle.trim()) return; setLookupLoading(true); - // TODO: API 호출 - setTimeout(() => { + try { + const data = await lookupChannel(handle); setChannelInfo({ - channelId: 'UC_EXAMPLE_ID', - title: '예시 채널명', - thumbnailUrl: null, + channelId: data.channelId, + title: data.title, + thumbnailUrl: data.thumbnailUrl, + bannerUrl: data.bannerUrl, }); + } catch (error) { + console.error('채널 조회 실패:', error); + alert(error.message || '채널을 찾을 수 없습니다.'); + } finally { setLookupLoading(false); - }, 1000); + } }; // 제출 diff --git a/frontend/src/pages/pc/admin/schedules/ScheduleBots.jsx b/frontend/src/pages/pc/admin/schedules/ScheduleBots.jsx index 055bf5a..f4c298a 100644 --- a/frontend/src/pages/pc/admin/schedules/ScheduleBots.jsx +++ b/frontend/src/pages/pc/admin/schedules/ScheduleBots.jsx @@ -62,6 +62,7 @@ function ScheduleBots() { const [quotaWarning, setQuotaWarning] = useState(null); // 할당량 경고 상태 const [botDialogOpen, setBotDialogOpen] = useState(false); // 봇 추가/수정 다이얼로그 const [editingBotId, setEditingBotId] = useState(null); // 수정 중인 봇 DB ID + const [deletingBot, setDeletingBot] = useState(null); // 삭제할 봇 // 봇 목록 조회 const { @@ -155,6 +156,22 @@ function ScheduleBots() { } }; + // 봇 삭제 + const handleDeleteBot = async () => { + if (!deletingBot) return; + + try { + await botsApi.deleteYouTubeBot(deletingBot.db_id); + queryClient.invalidateQueries({ queryKey: ['admin', 'bots'] }); + setToast({ type: 'success', message: `${deletingBot.name} 봇이 삭제되었습니다.` }); + } catch (error) { + console.error('봇 삭제 오류:', error); + setToast({ type: 'error', message: error.message || '봇 삭제에 실패했습니다.' }); + } finally { + setDeletingBot(null); + } + }; + // 상태 아이콘 및 색상 const getStatusInfo = (status) => { switch (status) { @@ -249,6 +266,52 @@ function ScheduleBots() { }} /> + {/* 삭제 확인 다이얼로그 */} + + {deletingBot && ( + + e.stopPropagation()} + > +
+
+ +
+

봇 삭제

+
+

+ {deletingBot.name} 봇을 삭제하시겠습니까? +
+ 이 작업은 되돌릴 수 없습니다. +

+
+ + +
+
+
+ )} +
+ {/* 메인 콘텐츠 */} { - console.log('Edit bot:', bot); - console.log('db_id:', bot.db_id); setEditingBotId(bot.db_id); setBotDialogOpen(true); }} - onDelete={(bot) => {/* TODO: 봇 삭제 확인 */}} + onDelete={(bot) => setDeletingBot(bot)} onAnimationComplete={() => isInitialLoad && index === sectionBots.length - 1 && setIsInitialLoad(false) }