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 봇인 경우 상세 정보 추가
|
// YouTube 봇인 경우 상세 정보 추가
|
||||||
if (bot.type === 'youtube') {
|
if (bot.type === 'youtube') {
|
||||||
fastify.log.info(`YouTube bot dbId: ${bot.dbId}`);
|
|
||||||
botData.db_id = bot.dbId;
|
botData.db_id = bot.dbId;
|
||||||
botData.channel_id = bot.channelId;
|
botData.channel_id = bot.channelId;
|
||||||
botData.channel_handle = bot.channelHandle;
|
botData.channel_handle = bot.channelHandle;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { errorResponse } from '../../schemas/index.js';
|
import { errorResponse } from '../../schemas/index.js';
|
||||||
import { badRequest, notFound, serverError } from '../../utils/error.js';
|
import { badRequest, notFound, serverError } from '../../utils/error.js';
|
||||||
|
import { getChannelByHandle } from '../../services/youtube/api.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* YouTube 봇 스키마
|
* YouTube 봇 스키마
|
||||||
|
|
@ -32,10 +33,7 @@ const youtubeBotIdParam = {
|
||||||
/**
|
/**
|
||||||
* DB row를 API 응답 형식으로 변환
|
* DB row를 API 응답 형식으로 변환
|
||||||
*/
|
*/
|
||||||
function formatBotResponse(row, fastify) {
|
function formatBotResponse(row) {
|
||||||
if (fastify) {
|
|
||||||
fastify.log.info(`DB row.auto_schedule_config: ${JSON.stringify(row.auto_schedule_config)}, type: ${typeof row.auto_schedule_config}`);
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
channel_id: row.channel_id,
|
channel_id: row.channel_id,
|
||||||
|
|
@ -69,6 +67,49 @@ function formatBotResponse(row, fastify) {
|
||||||
export default async function youtubeBotsRoutes(fastify) {
|
export default async function youtubeBotsRoutes(fastify) {
|
||||||
const { db, scheduler } = 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
|
* GET /api/admin/youtube-bots
|
||||||
* YouTube 봇 목록 조회
|
* YouTube 봇 목록 조회
|
||||||
|
|
@ -115,7 +156,7 @@ export default async function youtubeBotsRoutes(fastify) {
|
||||||
return notFound(reply, 'YouTube 봇을 찾을 수 없습니다.');
|
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',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
channel_id: { type: 'string' },
|
channel_id: { type: 'string' },
|
||||||
channel_handle: { type: 'string' },
|
channel_handle: { type: ['string', 'null'] },
|
||||||
channel_name: { type: 'string' },
|
channel_name: { type: 'string' },
|
||||||
banner_url: { type: 'string' },
|
banner_url: { type: ['string', 'null'] },
|
||||||
cron_interval: { type: 'integer', default: 2 },
|
cron_interval: { type: 'integer', default: 2 },
|
||||||
title_filters: { type: 'array', items: { type: 'string' } },
|
title_filters: { type: ['array', 'null'], items: { type: 'string' } },
|
||||||
default_member_ids: { type: 'array', items: { type: 'integer' } },
|
default_member_ids: { type: ['array', 'null'], items: { type: 'integer' } },
|
||||||
extract_members_from_desc: { type: 'boolean', default: false },
|
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'],
|
required: ['channel_id', 'channel_name'],
|
||||||
},
|
},
|
||||||
|
|
@ -215,14 +256,14 @@ export default async function youtubeBotsRoutes(fastify) {
|
||||||
body: {
|
body: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
channel_handle: { type: 'string' },
|
channel_handle: { type: ['string', 'null'] },
|
||||||
channel_name: { type: 'string' },
|
channel_name: { type: 'string' },
|
||||||
banner_url: { type: 'string' },
|
banner_url: { type: ['string', 'null'] },
|
||||||
cron_interval: { type: 'integer' },
|
cron_interval: { type: 'integer' },
|
||||||
title_filters: { type: 'array', items: { type: 'string' } },
|
title_filters: { type: ['array', 'null'], items: { type: 'string' } },
|
||||||
default_member_ids: { type: 'array', items: { type: 'integer' } },
|
default_member_ids: { type: ['array', 'null'], items: { type: 'integer' } },
|
||||||
extract_members_from_desc: { type: 'boolean' },
|
extract_members_from_desc: { type: 'boolean' },
|
||||||
auto_schedule_config: { type: 'object' },
|
auto_schedule_config: { type: ['object', 'null'], additionalProperties: true },
|
||||||
enabled: { type: 'boolean' },
|
enabled: { type: 'boolean' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,41 @@ export async function getUploadsPlaylistId(channelId) {
|
||||||
return data.items[0].contentDetails.relatedPlaylists.uploads;
|
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}`);
|
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 봇 추가
|
* YouTube 봇 추가
|
||||||
* @param {object} data - 봇 데이터
|
* @param {object} data - 봇 데이터
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { Youtube, Search, X, ChevronDown, ChevronUp, Loader2 } from 'lucide-react';
|
import { Youtube, Search, X, ChevronDown, ChevronUp, Loader2 } from 'lucide-react';
|
||||||
import { getMembers } from '@/api/public/members';
|
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 = [
|
const INTERVAL_OPTIONS = [
|
||||||
|
|
@ -315,17 +315,12 @@ function YouTubeBotDialog({ isOpen, onClose, botId = null, onSuccess }) {
|
||||||
});
|
});
|
||||||
setInterval(bot.cron_interval || 2);
|
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
|
const config = bot.auto_schedule_config
|
||||||
? (typeof bot.auto_schedule_config === 'string'
|
? (typeof bot.auto_schedule_config === 'string'
|
||||||
? JSON.parse(bot.auto_schedule_config)
|
? JSON.parse(bot.auto_schedule_config)
|
||||||
: bot.auto_schedule_config)
|
: bot.auto_schedule_config)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
console.log('parsed config:', config);
|
|
||||||
|
|
||||||
// config가 존재하고 dayOfWeek가 정의되어 있으면 활성화
|
// config가 존재하고 dayOfWeek가 정의되어 있으면 활성화
|
||||||
if (config && config.dayOfWeek !== undefined) {
|
if (config && config.dayOfWeek !== undefined) {
|
||||||
setAutoScheduleEnabled(true);
|
setAutoScheduleEnabled(true);
|
||||||
|
|
@ -375,15 +370,20 @@ function YouTubeBotDialog({ isOpen, onClose, botId = null, onSuccess }) {
|
||||||
const handleLookup = async () => {
|
const handleLookup = async () => {
|
||||||
if (!handle.trim()) return;
|
if (!handle.trim()) return;
|
||||||
setLookupLoading(true);
|
setLookupLoading(true);
|
||||||
// TODO: API 호출
|
try {
|
||||||
setTimeout(() => {
|
const data = await lookupChannel(handle);
|
||||||
setChannelInfo({
|
setChannelInfo({
|
||||||
channelId: 'UC_EXAMPLE_ID',
|
channelId: data.channelId,
|
||||||
title: '예시 채널명',
|
title: data.title,
|
||||||
thumbnailUrl: null,
|
thumbnailUrl: data.thumbnailUrl,
|
||||||
|
bannerUrl: data.bannerUrl,
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('채널 조회 실패:', error);
|
||||||
|
alert(error.message || '채널을 찾을 수 없습니다.');
|
||||||
|
} finally {
|
||||||
setLookupLoading(false);
|
setLookupLoading(false);
|
||||||
}, 1000);
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 제출
|
// 제출
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,7 @@ function ScheduleBots() {
|
||||||
const [quotaWarning, setQuotaWarning] = useState(null); // 할당량 경고 상태
|
const [quotaWarning, setQuotaWarning] = useState(null); // 할당량 경고 상태
|
||||||
const [botDialogOpen, setBotDialogOpen] = useState(false); // 봇 추가/수정 다이얼로그
|
const [botDialogOpen, setBotDialogOpen] = useState(false); // 봇 추가/수정 다이얼로그
|
||||||
const [editingBotId, setEditingBotId] = useState(null); // 수정 중인 봇 DB ID
|
const [editingBotId, setEditingBotId] = useState(null); // 수정 중인 봇 DB ID
|
||||||
|
const [deletingBot, setDeletingBot] = useState(null); // 삭제할 봇
|
||||||
|
|
||||||
// 봇 목록 조회
|
// 봇 목록 조회
|
||||||
const {
|
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) => {
|
const getStatusInfo = (status) => {
|
||||||
switch (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
|
<motion.div
|
||||||
className="max-w-7xl mx-auto px-6 py-8"
|
className="max-w-7xl mx-auto px-6 py-8"
|
||||||
|
|
@ -414,12 +477,10 @@ function ScheduleBots() {
|
||||||
onSync={handleSyncAllVideos}
|
onSync={handleSyncAllVideos}
|
||||||
onToggle={toggleBot}
|
onToggle={toggleBot}
|
||||||
onEdit={(bot) => {
|
onEdit={(bot) => {
|
||||||
console.log('Edit bot:', bot);
|
|
||||||
console.log('db_id:', bot.db_id);
|
|
||||||
setEditingBotId(bot.db_id);
|
setEditingBotId(bot.db_id);
|
||||||
setBotDialogOpen(true);
|
setBotDialogOpen(true);
|
||||||
}}
|
}}
|
||||||
onDelete={(bot) => {/* TODO: 봇 삭제 확인 */}}
|
onDelete={(bot) => setDeletingBot(bot)}
|
||||||
onAnimationComplete={() =>
|
onAnimationComplete={() =>
|
||||||
isInitialLoad && index === sectionBots.length - 1 && setIsInitialLoad(false)
|
isInitialLoad && index === sectionBots.length - 1 && setIsInitialLoad(false)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue