refactor: 봇 관리 페이지 타입별 섹션 분리
- Meilisearch, YouTube, X 세 섹션으로 분리 - 각 섹션에 아이콘 및 색상 적용 - YouTube 섹션에 "봇 추가" 버튼 추가 (기능은 추후 구현) - YouTube 봇 동적 관리 계획서 추가 (docs/youtube-bots-plan.md) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
46295a5f15
commit
1c04f4ed6d
2 changed files with 366 additions and 51 deletions
232
docs/youtube-bots-plan.md
Normal file
232
docs/youtube-bots-plan.md
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
# YouTube 봇 동적 관리 기능 구현 계획
|
||||
|
||||
## 개요
|
||||
현재 `config/bots.js`에 하드코딩된 YouTube 봇 설정을 DB 기반으로 변경하여, 관리 페이지에서 채널을 추가/수정/삭제할 수 있도록 함.
|
||||
|
||||
## 주요 기능
|
||||
1. YouTube 핸들(@username) 입력 → 채널 정보 자동 조회
|
||||
2. 동기화 간격 설정 (분 단위)
|
||||
3. 다음 주 예정 일정 자동 생성 옵션 (요일, 시간, 제목 템플릿)
|
||||
4. 봇 활성화/비활성화
|
||||
5. 봇 삭제
|
||||
|
||||
---
|
||||
|
||||
## 1. DB 스키마
|
||||
|
||||
### `youtube_bots` 테이블 생성
|
||||
|
||||
```sql
|
||||
CREATE TABLE youtube_bots (
|
||||
id VARCHAR(50) PRIMARY KEY, -- 봇 ID (youtube-{handle})
|
||||
channel_id VARCHAR(30) NOT NULL, -- UC...
|
||||
channel_handle VARCHAR(50) NOT NULL, -- @username
|
||||
channel_name VARCHAR(100) NOT NULL, -- 채널 이름
|
||||
uploads_playlist_id VARCHAR(50), -- UU... (캐싱용)
|
||||
cron_interval INT DEFAULT 2, -- 분 단위 (2 = */2 * * * *)
|
||||
enabled TINYINT(1) DEFAULT 1,
|
||||
|
||||
-- 제목 필터 (선택)
|
||||
title_filter VARCHAR(100),
|
||||
|
||||
-- 멤버 설정 (선택)
|
||||
default_member_id INT,
|
||||
extract_members_from_desc TINYINT(1) DEFAULT 0,
|
||||
|
||||
-- 다음 주 예정 일정 설정 (JSON)
|
||||
auto_schedule_config JSON,
|
||||
-- 예: {"dayOfWeek": 4, "time": "18:00:00", "titleTemplate": "{channelName} {episode}화", "deadlineDayOfWeek": 5}
|
||||
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
|
||||
UNIQUE KEY uk_channel_id (channel_id)
|
||||
);
|
||||
```
|
||||
|
||||
**파일**: `backend/sql/youtube_bots.sql`
|
||||
|
||||
---
|
||||
|
||||
## 2. 백엔드 API
|
||||
|
||||
### 2.1 채널 정보 조회 API (신규)
|
||||
|
||||
**POST /api/admin/youtube-bots/lookup**
|
||||
|
||||
YouTube API `forHandle` 파라미터로 채널 정보 조회.
|
||||
|
||||
```js
|
||||
// 요청
|
||||
{ "handle": "@studiofromis_9" }
|
||||
|
||||
// 응답
|
||||
{
|
||||
"channelId": "UCeUJ8B3krxw8zuDi19AlhaA",
|
||||
"handle": "studiofromis_9",
|
||||
"title": "스프 : 스튜디오 프로미스나인",
|
||||
"thumbnailUrl": "...",
|
||||
"uploadsPlaylistId": "UUeUJ8B3krxw8zuDi19AlhaA"
|
||||
}
|
||||
```
|
||||
|
||||
**파일**: `backend/src/services/youtube/api.js` - `getChannelByHandle()` 추가
|
||||
|
||||
### 2.2 봇 CRUD API (신규)
|
||||
|
||||
**파일**: `backend/src/routes/admin/youtube-bots.js`
|
||||
|
||||
| 메서드 | 경로 | 설명 |
|
||||
|--------|------|------|
|
||||
| GET | /api/admin/youtube-bots | 봇 목록 조회 |
|
||||
| POST | /api/admin/youtube-bots | 봇 추가 |
|
||||
| PUT | /api/admin/youtube-bots/:id | 봇 수정 |
|
||||
| DELETE | /api/admin/youtube-bots/:id | 봇 삭제 |
|
||||
|
||||
### 2.3 기존 봇 API 수정
|
||||
|
||||
**파일**: `backend/src/routes/admin/bots.js`
|
||||
|
||||
- `GET /api/admin/bots`: DB에서 YouTube 봇 목록 조회하도록 수정
|
||||
- 기존 `bots.js`의 meilisearch-sync, x-fromis9는 유지 (YouTube 봇만 DB로 이동)
|
||||
|
||||
---
|
||||
|
||||
## 3. 스케줄러 수정
|
||||
|
||||
**파일**: `backend/src/plugins/scheduler.js`
|
||||
|
||||
### 변경 사항
|
||||
1. 시작 시 DB에서 YouTube 봇 목록 로드
|
||||
2. `bots.js`의 meilisearch-sync, x-fromis9는 기존대로 사용
|
||||
3. 봇 추가/수정/삭제 시 스케줄 동적 업데이트
|
||||
|
||||
```js
|
||||
// getBots() 함수를 async로 변경하여 DB에서 조회
|
||||
async getBots() {
|
||||
const staticBots = bots.filter(b => b.type !== 'youtube');
|
||||
const [youtubeBots] = await fastify.db.query('SELECT * FROM youtube_bots WHERE enabled = 1');
|
||||
return [...staticBots, ...youtubeBots.map(convertDbBotToConfig)];
|
||||
}
|
||||
|
||||
// 봇 추가/삭제 시 스케줄러 동적 업데이트 메서드 추가
|
||||
async addBot(bot) { ... }
|
||||
async removeBot(botId) { ... }
|
||||
async updateBot(botId, config) { ... }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. YouTube 서비스 수정
|
||||
|
||||
**파일**: `backend/src/services/youtube/index.js`
|
||||
|
||||
### 변경 사항
|
||||
- `getManagedChannelIds()`: DB에서 조회하도록 수정
|
||||
|
||||
---
|
||||
|
||||
## 5. 프론트엔드 UI
|
||||
|
||||
### 5.1 봇 추가/수정 모달
|
||||
|
||||
**파일**: `frontend/src/components/pc/admin/YouTubeBotModal.jsx`
|
||||
|
||||
**입력 필드**:
|
||||
- 채널 핸들 (@username) - 입력 후 "조회" 버튼 → 채널 정보 표시
|
||||
- 동기화 간격 (분): 드롭다운 (1, 2, 5, 10, 30, 60)
|
||||
- 다음 주 예정 일정 활성화 (토글)
|
||||
- 요일 선택 (일~토)
|
||||
- 시간 입력 (HH:MM)
|
||||
- 제목 템플릿 (예: {channelName} {episode}화)
|
||||
- 마감 요일 선택
|
||||
- 고급 설정 (접기/펼치기)
|
||||
- 제목 필터 (특정 키워드 포함 영상만)
|
||||
- 기본 멤버 선택
|
||||
- description에서 멤버 추출 (토글)
|
||||
|
||||
### 5.2 봇 목록 페이지 수정
|
||||
|
||||
**파일**: `frontend/src/pages/pc/admin/schedules/ScheduleBots.jsx`
|
||||
|
||||
- 섹션별로 분리: Meilisearch, YouTube, X
|
||||
- YouTube 섹션에만 "봇 추가" 버튼
|
||||
- BotCard에 "수정", "삭제" 버튼 추가 (YouTube 봇만)
|
||||
- meilisearch, x 봇은 수정/삭제 버튼 없이 읽기 전용
|
||||
|
||||
### 5.3 BotCard 컴포넌트 수정
|
||||
|
||||
**파일**: `frontend/src/components/pc/admin/BotCard.jsx`
|
||||
|
||||
- `onEdit`, `onDelete` props 추가
|
||||
- YouTube 타입일 때만 수정/삭제 버튼 표시
|
||||
|
||||
### 5.4 API 클라이언트
|
||||
|
||||
**파일**: `frontend/src/api/admin/bots.js`
|
||||
|
||||
```js
|
||||
// 추가할 함수
|
||||
export const lookupChannel = (handle) => fetch('/admin/youtube-bots/lookup', { method: 'POST', body: { handle } })
|
||||
export const createYouTubeBot = (data) => fetch('/admin/youtube-bots', { method: 'POST', body: data })
|
||||
export const updateYouTubeBot = (id, data) => fetch(`/admin/youtube-bots/${id}`, { method: 'PUT', body: data })
|
||||
export const deleteYouTubeBot = (id) => fetch(`/admin/youtube-bots/${id}`, { method: 'DELETE' })
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 마이그레이션
|
||||
|
||||
기존 `bots.js`의 YouTube 봇 3개를 DB로 이동:
|
||||
|
||||
```sql
|
||||
INSERT INTO youtube_bots (id, channel_id, channel_handle, channel_name, cron_interval, enabled)
|
||||
VALUES
|
||||
('youtube-fromis9', 'UCXbRURMKT3H_w8dT-DWLIxA', 'fromis9', 'fromis_9', 2, 1),
|
||||
('youtube-studio', 'UCeUJ8B3krxw8zuDi19AlhaA', 'studiofromis_9', '스프 : 스튜디오 프로미스나인', 2, 1),
|
||||
('youtube-musinsa', 'UCtfyAiqf095_0_ux8ruwGfA', 'maboroshimusinsaTV', 'MUSINSA TV', 2, 1);
|
||||
|
||||
-- youtube-studio 예정 일정 설정
|
||||
UPDATE youtube_bots SET auto_schedule_config = '{"dayOfWeek":4,"time":"18:00:00","titleTemplate":"{channelName} {episode}화","deadlineDayOfWeek":5}' WHERE id = 'youtube-studio';
|
||||
|
||||
-- youtube-musinsa 필터/멤버 설정
|
||||
UPDATE youtube_bots SET title_filter = '성수기', default_member_id = 7, extract_members_from_desc = 1 WHERE id = 'youtube-musinsa';
|
||||
```
|
||||
|
||||
**파일**: `backend/sql/youtube_bots_seed.sql`
|
||||
|
||||
---
|
||||
|
||||
## 파일 변경 목록
|
||||
|
||||
### 신규 파일
|
||||
| 파일 | 설명 |
|
||||
|------|------|
|
||||
| `backend/sql/youtube_bots.sql` | 테이블 생성 SQL |
|
||||
| `backend/sql/youtube_bots_seed.sql` | 초기 데이터 SQL |
|
||||
| `backend/src/routes/admin/youtube-bots.js` | YouTube 봇 CRUD API |
|
||||
| `frontend/src/components/pc/admin/YouTubeBotModal.jsx` | 봇 추가/수정 모달 |
|
||||
|
||||
### 수정 파일
|
||||
| 파일 | 변경 내용 |
|
||||
|------|-----------|
|
||||
| `backend/src/services/youtube/api.js` | `getChannelByHandle()` 추가 |
|
||||
| `backend/src/services/youtube/index.js` | DB 기반으로 변경 |
|
||||
| `backend/src/plugins/scheduler.js` | DB에서 봇 로드, 동적 업데이트 |
|
||||
| `backend/src/routes/admin/bots.js` | DB 통합 조회 |
|
||||
| `backend/src/routes/index.js` | youtube-bots 라우트 등록 |
|
||||
| `backend/src/config/bots.js` | YouTube 봇 제거 |
|
||||
| `frontend/src/pages/pc/admin/schedules/ScheduleBots.jsx` | 섹션별 분리, 추가/수정/삭제 UI |
|
||||
| `frontend/src/components/pc/admin/BotCard.jsx` | 수정/삭제 버튼 |
|
||||
| `frontend/src/api/admin/bots.js` | API 함수 추가 |
|
||||
|
||||
---
|
||||
|
||||
## 검증 방법
|
||||
|
||||
1. **DB 테이블 생성**: `docker compose exec fromis9-db mysql -u... -e "DESC youtube_bots"`
|
||||
2. **채널 조회 API**: `@studiofromis_9` 입력 → 채널 정보 반환 확인
|
||||
3. **봇 추가**: 새 채널 추가 후 목록에 표시, 스케줄러 동작 확인
|
||||
4. **봇 수정**: 동기화 간격 변경 후 cron 재등록 확인
|
||||
5. **봇 삭제**: 삭제 후 목록에서 제거, 스케줄러 중지 확인
|
||||
6. **예정 일정**: 설정된 요일에 예정 일정 자동 생성 확인
|
||||
|
|
@ -1,14 +1,40 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
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 } from 'lucide-react';
|
||||
import { Home, ChevronRight, Bot, CheckCircle, XCircle, RefreshCw, Plus, Search, Database, Youtube, Twitter } from 'lucide-react';
|
||||
import { Toast, Tooltip, AnimatedNumber } from '@/components/common';
|
||||
import { AdminLayout, BotCard } from '@/components/pc/admin';
|
||||
import { useAdminAuth } from '@/hooks/pc/admin';
|
||||
import { useToast } from '@/hooks/common';
|
||||
import * as botsApi from '@/api/admin/bots';
|
||||
|
||||
// 섹션 설정
|
||||
const SECTIONS = {
|
||||
meilisearch: {
|
||||
title: 'Meilisearch',
|
||||
icon: Database,
|
||||
color: 'text-purple-500',
|
||||
bgColor: 'bg-purple-50',
|
||||
borderColor: 'border-purple-100',
|
||||
},
|
||||
youtube: {
|
||||
title: 'YouTube',
|
||||
icon: Youtube,
|
||||
color: 'text-red-500',
|
||||
bgColor: 'bg-red-50',
|
||||
borderColor: 'border-red-100',
|
||||
canAdd: true,
|
||||
},
|
||||
x: {
|
||||
title: 'X (Twitter)',
|
||||
icon: Twitter,
|
||||
color: 'text-gray-700',
|
||||
bgColor: 'bg-gray-50',
|
||||
borderColor: 'border-gray-200',
|
||||
},
|
||||
};
|
||||
|
||||
// 애니메이션 variants
|
||||
const containerVariants = {
|
||||
hidden: { opacity: 0 },
|
||||
|
|
@ -195,6 +221,17 @@ function ScheduleBots() {
|
|||
const stoppedCount = bots.filter((b) => b.status === 'stopped').length;
|
||||
const errorCount = bots.filter((b) => b.status === 'error').length;
|
||||
|
||||
// 봇을 타입별로 그룹화
|
||||
const botsByType = useMemo(() => {
|
||||
const grouped = { meilisearch: [], youtube: [], x: [] };
|
||||
bots.forEach((bot) => {
|
||||
if (grouped[bot.type]) {
|
||||
grouped[bot.type].push(bot);
|
||||
}
|
||||
});
|
||||
return grouped;
|
||||
}, [bots]);
|
||||
|
||||
return (
|
||||
<AdminLayout user={user}>
|
||||
<Toast toast={toast} onClose={() => setToast(null)} />
|
||||
|
|
@ -281,56 +318,102 @@ function ScheduleBots() {
|
|||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* 봇 목록 */}
|
||||
<motion.div variants={itemVariants} className="bg-white rounded-2xl shadow-sm border border-gray-100 overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-gray-100 flex items-center justify-between">
|
||||
<h2 className="font-bold text-gray-900">봇 목록</h2>
|
||||
<Tooltip text="새로고침">
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsInitialLoad(true);
|
||||
fetchBots();
|
||||
}}
|
||||
disabled={loading}
|
||||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors text-gray-500 hover:text-gray-700 disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw size={18} className={loading ? 'animate-spin' : ''} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{/* 로딩 상태 */}
|
||||
{loading ? (
|
||||
<motion.div variants={itemVariants} className="flex justify-center items-center py-20">
|
||||
<div className="animate-spin rounded-full h-10 w-10 border-4 border-primary border-t-transparent"></div>
|
||||
</motion.div>
|
||||
) : bots.length === 0 ? (
|
||||
<motion.div variants={itemVariants} className="text-center py-20 text-gray-400">
|
||||
<Bot size={48} className="mx-auto mb-4 opacity-30" />
|
||||
<p>등록된 봇이 없습니다</p>
|
||||
</motion.div>
|
||||
) : (
|
||||
/* 섹션별 봇 목록 */
|
||||
<div className="space-y-6">
|
||||
{Object.entries(SECTIONS).map(([type, section]) => {
|
||||
const sectionBots = botsByType[type] || [];
|
||||
if (sectionBots.length === 0 && !section.canAdd) return null;
|
||||
|
||||
{loading ? (
|
||||
<div className="flex justify-center items-center py-20">
|
||||
<div className="animate-spin rounded-full h-10 w-10 border-4 border-primary border-t-transparent"></div>
|
||||
</div>
|
||||
) : bots.length === 0 ? (
|
||||
<div className="text-center py-20 text-gray-400">
|
||||
<Bot size={48} className="mx-auto mb-4 opacity-30" />
|
||||
<p>등록된 봇이 없습니다</p>
|
||||
<p className="text-sm mt-1">위의 버튼을 클릭하여 봇을 추가하세요</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-6 grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{bots.map((bot, index) => (
|
||||
<BotCard
|
||||
key={bot.id}
|
||||
bot={bot}
|
||||
index={index}
|
||||
isInitialLoad={isInitialLoad}
|
||||
syncing={syncing}
|
||||
statusInfo={getStatusInfo(bot.status)}
|
||||
onSync={handleSyncAllVideos}
|
||||
onToggle={toggleBot}
|
||||
onAnimationComplete={() =>
|
||||
isInitialLoad && index === bots.length - 1 && setIsInitialLoad(false)
|
||||
}
|
||||
formatTime={formatTime}
|
||||
formatInterval={formatInterval}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
const SectionIcon = section.icon;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={type}
|
||||
variants={itemVariants}
|
||||
className="bg-white rounded-2xl shadow-sm border border-gray-100 overflow-hidden"
|
||||
>
|
||||
{/* 섹션 헤더 */}
|
||||
<div className={`px-6 py-4 border-b ${section.borderColor} ${section.bgColor} flex items-center justify-between`}>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-8 h-8 rounded-lg ${section.bgColor} flex items-center justify-center`}>
|
||||
<SectionIcon size={18} className={section.color} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="font-bold text-gray-900">{section.title}</h2>
|
||||
<p className="text-xs text-gray-500">{sectionBots.length}개의 봇</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{section.canAdd && (
|
||||
<button
|
||||
onClick={() => {/* TODO: 봇 추가 모달 */}}
|
||||
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"
|
||||
>
|
||||
<Plus size={16} />
|
||||
봇 추가
|
||||
</button>
|
||||
)}
|
||||
<Tooltip text="새로고침">
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsInitialLoad(true);
|
||||
fetchBots();
|
||||
}}
|
||||
disabled={loading}
|
||||
className="p-2 hover:bg-white/50 rounded-lg transition-colors text-gray-500 hover:text-gray-700 disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw size={18} className={loading ? 'animate-spin' : ''} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 봇 카드 목록 */}
|
||||
{sectionBots.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-400">
|
||||
<Bot size={36} className="mx-auto mb-3 opacity-30" />
|
||||
<p className="text-sm">등록된 봇이 없습니다</p>
|
||||
{section.canAdd && (
|
||||
<p className="text-xs mt-1">위의 버튼을 클릭하여 봇을 추가하세요</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-6 grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{sectionBots.map((bot, index) => (
|
||||
<BotCard
|
||||
key={bot.id}
|
||||
bot={bot}
|
||||
index={index}
|
||||
isInitialLoad={isInitialLoad}
|
||||
syncing={syncing}
|
||||
statusInfo={getStatusInfo(bot.status)}
|
||||
onSync={handleSyncAllVideos}
|
||||
onToggle={toggleBot}
|
||||
onAnimationComplete={() =>
|
||||
isInitialLoad && index === sectionBots.length - 1 && setIsInitialLoad(false)
|
||||
}
|
||||
formatTime={formatTime}
|
||||
formatInterval={formatInterval}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
</AdminLayout>
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue