feat(admin): YouTube 봇 추가/수정/삭제 기능 완성

- 채널 핸들로 채널 정보 조회 API 추가 (POST /youtube-bots/lookup)
- getChannelByHandle 함수 추가 (YouTube API forHandle 사용)
- 봇 추가 시 채널 조회 후 배너 이미지 표시
- 봇 수정 API 스키마에 null 허용 추가
- 삭제 확인 다이얼로그 및 삭제 기능 구현
- 디버깅 로그 제거

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
caadiq 2026-02-07 10:51:45 +09:00
parent ec3839bcc7
commit 2e7fe697fc
6 changed files with 179 additions and 31 deletions

View file

@ -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;

View file

@ -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' },
},
},

View file

@ -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,
};
}
/**
* 채널 정보 조회 (배너 이미지 포함)
*/

View file

@ -20,6 +20,18 @@ export async function getYouTubeBot(id) {
return fetchAuthApi(`/admin/youtube-bots/${id}`);
}
/**
* 채널 핸들로 채널 정보 조회
* @param {string} handle - @username 형식
* @returns {Promise<object>}
*/
export async function lookupChannel(handle) {
return fetchAuthApi('/admin/youtube-bots/lookup', {
method: 'POST',
body: JSON.stringify({ handle }),
});
}
/**
* YouTube 추가
* @param {object} data - 데이터

View file

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

View file

@ -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() {
}}
/>
{/* 삭제 확인 다이얼로그 */}
<AnimatePresence>
{deletingBot && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
>
<motion.div
initial={{ scale: 0.95, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.95, opacity: 0 }}
className="bg-white rounded-2xl w-full max-w-sm mx-4 p-6 shadow-xl"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-full bg-red-100 flex items-center justify-center">
<XCircle size={20} className="text-red-500" />
</div>
<h3 className="text-lg font-bold text-gray-900"> 삭제</h3>
</div>
<p className="text-gray-600 mb-6">
<strong>{deletingBot.name}</strong> 봇을 삭제하시겠습니까?
<br />
<span className="text-sm text-gray-400"> 작업은 되돌릴 없습니다.</span>
</p>
<div className="flex justify-end gap-3">
<button
onClick={() => setDeletingBot(null)}
className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
>
취소
</button>
<button
onClick={handleDeleteBot}
className="px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors"
>
삭제
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
{/* 메인 콘텐츠 */}
<motion.div
className="max-w-7xl mx-auto px-6 py-8"
@ -414,12 +477,10 @@ function ScheduleBots() {
onSync={handleSyncAllVideos}
onToggle={toggleBot}
onEdit={(bot) => {
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)
}