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:
parent
ec3839bcc7
commit
2e7fe697fc
6 changed files with 179 additions and 31 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 채널 정보 조회 (배너 이미지 포함)
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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 - 봇 데이터
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
// 제출
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue