feat(admin): YouTube 봇 CRUD API 및 수정 다이얼로그 개선
- YouTube 봇 전용 API 라우트 추가 (GET/POST/PUT/DELETE /api/admin/youtube-bots) - 봇 목록 API에 YouTube 봇 상세 정보 포함 (db_id, channel_id 등) - 수정 다이얼로그에서 useQuery로 봇 데이터 조회 - 채널 배너 이미지 표시 추가 - Fastify 스키마에 additionalProperties 설정으로 auto_schedule_config 정상 반환 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
a8c12aa76d
commit
ec3839bcc7
7 changed files with 571 additions and 41 deletions
|
|
@ -239,7 +239,7 @@ async function schedulerPlugin(fastify, opts) {
|
||||||
startAll,
|
startAll,
|
||||||
stopAll,
|
stopAll,
|
||||||
getStatus,
|
getStatus,
|
||||||
getBots: () => getAllBots(),
|
getBots: (forceRefresh = false) => getAllBots(forceRefresh),
|
||||||
invalidateCache,
|
invalidateCache,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,17 @@ const botResponse = {
|
||||||
check_interval: { type: 'integer' },
|
check_interval: { type: 'integer' },
|
||||||
error_message: { type: 'string' },
|
error_message: { type: 'string' },
|
||||||
enabled: { type: 'boolean' },
|
enabled: { type: 'boolean' },
|
||||||
|
// YouTube 봇 전용 필드
|
||||||
|
db_id: { type: 'integer' },
|
||||||
|
channel_id: { type: 'string' },
|
||||||
|
channel_handle: { type: 'string' },
|
||||||
|
channel_name: { type: 'string' },
|
||||||
|
banner_url: { type: 'string' },
|
||||||
|
cron_interval: { type: 'integer' },
|
||||||
|
title_filters: { type: 'array', items: { type: 'string' } },
|
||||||
|
default_member_ids: { type: 'array', items: { type: 'integer' } },
|
||||||
|
extract_members_from_desc: { type: 'boolean' },
|
||||||
|
auto_schedule_config: { type: ['object', 'null'], additionalProperties: true },
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -56,7 +67,8 @@ export default async function botsRoutes(fastify) {
|
||||||
},
|
},
|
||||||
preHandler: [fastify.authenticate],
|
preHandler: [fastify.authenticate],
|
||||||
}, async (request, reply) => {
|
}, async (request, reply) => {
|
||||||
const allBots = await scheduler.getBots();
|
// API 호출 시에는 항상 fresh한 데이터 반환
|
||||||
|
const allBots = await scheduler.getBots(true);
|
||||||
const result = [];
|
const result = [];
|
||||||
|
|
||||||
for (const bot of allBots) {
|
for (const bot of allBots) {
|
||||||
|
|
@ -72,7 +84,7 @@ export default async function botsRoutes(fastify) {
|
||||||
checkInterval = 1440; // 24시간 = 1440분
|
checkInterval = 1440; // 24시간 = 1440분
|
||||||
}
|
}
|
||||||
|
|
||||||
result.push({
|
const botData = {
|
||||||
id: bot.id,
|
id: bot.id,
|
||||||
name: bot.name || bot.channelName || bot.username || bot.id,
|
name: bot.name || bot.channelName || bot.username || bot.id,
|
||||||
type: bot.type,
|
type: bot.type,
|
||||||
|
|
@ -84,7 +96,24 @@ export default async function botsRoutes(fastify) {
|
||||||
check_interval: checkInterval,
|
check_interval: checkInterval,
|
||||||
error_message: status.errorMessage,
|
error_message: status.errorMessage,
|
||||||
enabled: bot.enabled,
|
enabled: bot.enabled,
|
||||||
});
|
};
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
botData.channel_name = bot.channelName;
|
||||||
|
botData.banner_url = bot.bannerUrl;
|
||||||
|
botData.cron_interval = checkInterval;
|
||||||
|
botData.title_filters = bot.titleFilters || [];
|
||||||
|
botData.default_member_ids = bot.defaultMemberIds || [];
|
||||||
|
botData.extract_members_from_desc = bot.extractMembersFromDesc || false;
|
||||||
|
botData.auto_schedule_config = bot.autoScheduleNext || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
result.push(botData);
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
|
|
|
||||||
358
backend/src/routes/admin/youtube-bots.js
Normal file
358
backend/src/routes/admin/youtube-bots.js
Normal file
|
|
@ -0,0 +1,358 @@
|
||||||
|
import { errorResponse } from '../../schemas/index.js';
|
||||||
|
import { badRequest, notFound, serverError } from '../../utils/error.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* YouTube 봇 스키마
|
||||||
|
*/
|
||||||
|
const youtubeBotResponse = {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
id: { type: 'integer' },
|
||||||
|
channel_id: { type: 'string' },
|
||||||
|
channel_handle: { type: 'string' },
|
||||||
|
channel_name: { type: 'string' },
|
||||||
|
banner_url: { type: 'string' },
|
||||||
|
cron_interval: { type: 'integer' },
|
||||||
|
enabled: { type: 'boolean' },
|
||||||
|
title_filters: { type: 'array', items: { type: 'string' } },
|
||||||
|
default_member_ids: { type: 'array', items: { type: 'integer' } },
|
||||||
|
extract_members_from_desc: { type: 'boolean' },
|
||||||
|
auto_schedule_config: { type: ['object', 'null'], additionalProperties: true },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const youtubeBotIdParam = {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
id: { type: 'integer', description: 'YouTube 봇 DB ID' },
|
||||||
|
},
|
||||||
|
required: ['id'],
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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}`);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
channel_id: row.channel_id,
|
||||||
|
channel_handle: row.channel_handle,
|
||||||
|
channel_name: row.channel_name,
|
||||||
|
banner_url: row.banner_url,
|
||||||
|
cron_interval: row.cron_interval,
|
||||||
|
enabled: row.enabled === 1,
|
||||||
|
title_filters: row.title_filters
|
||||||
|
? (typeof row.title_filters === 'string'
|
||||||
|
? JSON.parse(row.title_filters)
|
||||||
|
: row.title_filters)
|
||||||
|
: [],
|
||||||
|
default_member_ids: row.default_member_ids
|
||||||
|
? (typeof row.default_member_ids === 'string'
|
||||||
|
? JSON.parse(row.default_member_ids)
|
||||||
|
: row.default_member_ids)
|
||||||
|
: [],
|
||||||
|
extract_members_from_desc: row.extract_members_from_desc === 1,
|
||||||
|
auto_schedule_config: row.auto_schedule_config
|
||||||
|
? (typeof row.auto_schedule_config === 'string'
|
||||||
|
? JSON.parse(row.auto_schedule_config)
|
||||||
|
: row.auto_schedule_config)
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* YouTube 봇 관리 라우트
|
||||||
|
*/
|
||||||
|
export default async function youtubeBotsRoutes(fastify) {
|
||||||
|
const { db, scheduler } = fastify;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/admin/youtube-bots
|
||||||
|
* YouTube 봇 목록 조회
|
||||||
|
*/
|
||||||
|
fastify.get('/', {
|
||||||
|
schema: {
|
||||||
|
tags: ['admin/youtube-bots'],
|
||||||
|
summary: 'YouTube 봇 목록 조회',
|
||||||
|
security: [{ bearerAuth: [] }],
|
||||||
|
response: {
|
||||||
|
200: {
|
||||||
|
type: 'array',
|
||||||
|
items: youtubeBotResponse,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
preHandler: [fastify.authenticate],
|
||||||
|
}, async (request, reply) => {
|
||||||
|
const [rows] = await db.query('SELECT * FROM youtube_bots ORDER BY id');
|
||||||
|
return rows.map(formatBotResponse);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/admin/youtube-bots/:id
|
||||||
|
* YouTube 봇 상세 조회
|
||||||
|
*/
|
||||||
|
fastify.get('/:id', {
|
||||||
|
schema: {
|
||||||
|
tags: ['admin/youtube-bots'],
|
||||||
|
summary: 'YouTube 봇 상세 조회',
|
||||||
|
security: [{ bearerAuth: [] }],
|
||||||
|
params: youtubeBotIdParam,
|
||||||
|
response: {
|
||||||
|
200: youtubeBotResponse,
|
||||||
|
404: errorResponse,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
preHandler: [fastify.authenticate],
|
||||||
|
}, async (request, reply) => {
|
||||||
|
const { id } = request.params;
|
||||||
|
const [rows] = await db.query('SELECT * FROM youtube_bots WHERE id = ?', [id]);
|
||||||
|
|
||||||
|
if (rows.length === 0) {
|
||||||
|
return notFound(reply, 'YouTube 봇을 찾을 수 없습니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return formatBotResponse(rows[0], fastify);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/admin/youtube-bots
|
||||||
|
* YouTube 봇 추가
|
||||||
|
*/
|
||||||
|
fastify.post('/', {
|
||||||
|
schema: {
|
||||||
|
tags: ['admin/youtube-bots'],
|
||||||
|
summary: 'YouTube 봇 추가',
|
||||||
|
security: [{ bearerAuth: [] }],
|
||||||
|
body: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
channel_id: { type: 'string' },
|
||||||
|
channel_handle: { type: 'string' },
|
||||||
|
channel_name: { type: 'string' },
|
||||||
|
banner_url: { type: 'string' },
|
||||||
|
cron_interval: { type: 'integer', default: 2 },
|
||||||
|
title_filters: { type: 'array', items: { type: 'string' } },
|
||||||
|
default_member_ids: { type: 'array', items: { type: 'integer' } },
|
||||||
|
extract_members_from_desc: { type: 'boolean', default: false },
|
||||||
|
auto_schedule_config: { type: 'object' },
|
||||||
|
},
|
||||||
|
required: ['channel_id', 'channel_name'],
|
||||||
|
},
|
||||||
|
response: {
|
||||||
|
201: youtubeBotResponse,
|
||||||
|
400: errorResponse,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
preHandler: [fastify.authenticate],
|
||||||
|
}, async (request, reply) => {
|
||||||
|
const {
|
||||||
|
channel_id,
|
||||||
|
channel_handle,
|
||||||
|
channel_name,
|
||||||
|
banner_url,
|
||||||
|
cron_interval = 2,
|
||||||
|
title_filters,
|
||||||
|
default_member_ids,
|
||||||
|
extract_members_from_desc = false,
|
||||||
|
auto_schedule_config,
|
||||||
|
} = request.body;
|
||||||
|
|
||||||
|
// 중복 체크
|
||||||
|
const [existing] = await db.query(
|
||||||
|
'SELECT id FROM youtube_bots WHERE channel_id = ?',
|
||||||
|
[channel_id]
|
||||||
|
);
|
||||||
|
if (existing.length > 0) {
|
||||||
|
return badRequest(reply, '이미 등록된 채널입니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const [result] = await db.query(
|
||||||
|
`INSERT INTO youtube_bots
|
||||||
|
(channel_id, channel_handle, channel_name, banner_url, cron_interval,
|
||||||
|
title_filters, default_member_ids, extract_members_from_desc, auto_schedule_config, enabled)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1)`,
|
||||||
|
[
|
||||||
|
channel_id,
|
||||||
|
channel_handle || null,
|
||||||
|
channel_name,
|
||||||
|
banner_url || null,
|
||||||
|
cron_interval,
|
||||||
|
title_filters ? JSON.stringify(title_filters) : null,
|
||||||
|
default_member_ids ? JSON.stringify(default_member_ids) : null,
|
||||||
|
extract_members_from_desc ? 1 : 0,
|
||||||
|
auto_schedule_config ? JSON.stringify(auto_schedule_config) : null,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 스케줄러 캐시 무효화 및 봇 시작
|
||||||
|
scheduler.invalidateCache();
|
||||||
|
const botId = `youtube-${result.insertId}`;
|
||||||
|
try {
|
||||||
|
await scheduler.startBot(botId);
|
||||||
|
} catch (err) {
|
||||||
|
fastify.log.error(`[${botId}] 봇 시작 실패:`, err);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [newBot] = await db.query('SELECT * FROM youtube_bots WHERE id = ?', [result.insertId]);
|
||||||
|
reply.code(201);
|
||||||
|
return formatBotResponse(newBot[0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /api/admin/youtube-bots/:id
|
||||||
|
* YouTube 봇 수정
|
||||||
|
*/
|
||||||
|
fastify.put('/:id', {
|
||||||
|
schema: {
|
||||||
|
tags: ['admin/youtube-bots'],
|
||||||
|
summary: 'YouTube 봇 수정',
|
||||||
|
security: [{ bearerAuth: [] }],
|
||||||
|
params: youtubeBotIdParam,
|
||||||
|
body: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
channel_handle: { type: 'string' },
|
||||||
|
channel_name: { type: 'string' },
|
||||||
|
banner_url: { type: 'string' },
|
||||||
|
cron_interval: { type: 'integer' },
|
||||||
|
title_filters: { type: 'array', items: { type: 'string' } },
|
||||||
|
default_member_ids: { type: 'array', items: { type: 'integer' } },
|
||||||
|
extract_members_from_desc: { type: 'boolean' },
|
||||||
|
auto_schedule_config: { type: 'object' },
|
||||||
|
enabled: { type: 'boolean' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
response: {
|
||||||
|
200: youtubeBotResponse,
|
||||||
|
404: errorResponse,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
preHandler: [fastify.authenticate],
|
||||||
|
}, async (request, reply) => {
|
||||||
|
const { id } = request.params;
|
||||||
|
const updates = request.body;
|
||||||
|
|
||||||
|
// 존재 확인
|
||||||
|
const [existing] = await db.query('SELECT * FROM youtube_bots WHERE id = ?', [id]);
|
||||||
|
if (existing.length === 0) {
|
||||||
|
return notFound(reply, 'YouTube 봇을 찾을 수 없습니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 동적 업데이트 쿼리 생성
|
||||||
|
const fields = [];
|
||||||
|
const values = [];
|
||||||
|
|
||||||
|
if (updates.channel_handle !== undefined) {
|
||||||
|
fields.push('channel_handle = ?');
|
||||||
|
values.push(updates.channel_handle);
|
||||||
|
}
|
||||||
|
if (updates.channel_name !== undefined) {
|
||||||
|
fields.push('channel_name = ?');
|
||||||
|
values.push(updates.channel_name);
|
||||||
|
}
|
||||||
|
if (updates.banner_url !== undefined) {
|
||||||
|
fields.push('banner_url = ?');
|
||||||
|
values.push(updates.banner_url);
|
||||||
|
}
|
||||||
|
if (updates.cron_interval !== undefined) {
|
||||||
|
fields.push('cron_interval = ?');
|
||||||
|
values.push(updates.cron_interval);
|
||||||
|
}
|
||||||
|
if (updates.title_filters !== undefined) {
|
||||||
|
fields.push('title_filters = ?');
|
||||||
|
values.push(JSON.stringify(updates.title_filters));
|
||||||
|
}
|
||||||
|
if (updates.default_member_ids !== undefined) {
|
||||||
|
fields.push('default_member_ids = ?');
|
||||||
|
values.push(JSON.stringify(updates.default_member_ids));
|
||||||
|
}
|
||||||
|
if (updates.extract_members_from_desc !== undefined) {
|
||||||
|
fields.push('extract_members_from_desc = ?');
|
||||||
|
values.push(updates.extract_members_from_desc ? 1 : 0);
|
||||||
|
}
|
||||||
|
if (updates.auto_schedule_config !== undefined) {
|
||||||
|
fields.push('auto_schedule_config = ?');
|
||||||
|
values.push(updates.auto_schedule_config ? JSON.stringify(updates.auto_schedule_config) : null);
|
||||||
|
}
|
||||||
|
if (updates.enabled !== undefined) {
|
||||||
|
fields.push('enabled = ?');
|
||||||
|
values.push(updates.enabled ? 1 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fields.length > 0) {
|
||||||
|
values.push(id);
|
||||||
|
await db.query(
|
||||||
|
`UPDATE youtube_bots SET ${fields.join(', ')} WHERE id = ?`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
|
||||||
|
// 스케줄러 캐시 무효화 및 봇 재시작
|
||||||
|
scheduler.invalidateCache();
|
||||||
|
const botId = `youtube-${id}`;
|
||||||
|
try {
|
||||||
|
await scheduler.stopBot(botId);
|
||||||
|
if (updates.enabled !== false && existing[0].enabled === 1) {
|
||||||
|
await scheduler.startBot(botId);
|
||||||
|
} else if (updates.enabled === true) {
|
||||||
|
await scheduler.startBot(botId);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
fastify.log.error(`[${botId}] 봇 재시작 실패:`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [updatedBot] = await db.query('SELECT * FROM youtube_bots WHERE id = ?', [id]);
|
||||||
|
return formatBotResponse(updatedBot[0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/admin/youtube-bots/:id
|
||||||
|
* YouTube 봇 삭제
|
||||||
|
*/
|
||||||
|
fastify.delete('/:id', {
|
||||||
|
schema: {
|
||||||
|
tags: ['admin/youtube-bots'],
|
||||||
|
summary: 'YouTube 봇 삭제',
|
||||||
|
security: [{ bearerAuth: [] }],
|
||||||
|
params: youtubeBotIdParam,
|
||||||
|
response: {
|
||||||
|
200: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
success: { type: 'boolean' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
404: errorResponse,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
preHandler: [fastify.authenticate],
|
||||||
|
}, async (request, reply) => {
|
||||||
|
const { id } = request.params;
|
||||||
|
|
||||||
|
// 존재 확인
|
||||||
|
const [existing] = await db.query('SELECT * FROM youtube_bots WHERE id = ?', [id]);
|
||||||
|
if (existing.length === 0) {
|
||||||
|
return notFound(reply, 'YouTube 봇을 찾을 수 없습니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 봇 정지
|
||||||
|
const botId = `youtube-${id}`;
|
||||||
|
try {
|
||||||
|
await scheduler.stopBot(botId);
|
||||||
|
} catch (err) {
|
||||||
|
// 이미 정지된 경우 무시
|
||||||
|
}
|
||||||
|
|
||||||
|
// DB에서 삭제
|
||||||
|
await db.query('DELETE FROM youtube_bots WHERE id = ?', [id]);
|
||||||
|
|
||||||
|
// 스케줄러 캐시 무효화
|
||||||
|
scheduler.invalidateCache();
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,7 @@ import albumsRoutes from './albums/index.js';
|
||||||
import schedulesRoutes from './schedules/index.js';
|
import schedulesRoutes from './schedules/index.js';
|
||||||
import statsRoutes from './stats/index.js';
|
import statsRoutes from './stats/index.js';
|
||||||
import botsRoutes from './admin/bots.js';
|
import botsRoutes from './admin/bots.js';
|
||||||
|
import youtubeBotsRoutes from './admin/youtube-bots.js';
|
||||||
import youtubeAdminRoutes from './admin/youtube.js';
|
import youtubeAdminRoutes from './admin/youtube.js';
|
||||||
import xAdminRoutes from './admin/x.js';
|
import xAdminRoutes from './admin/x.js';
|
||||||
import concertAdminRoutes from './admin/concert.js';
|
import concertAdminRoutes from './admin/concert.js';
|
||||||
|
|
@ -32,6 +33,9 @@ export default async function routes(fastify) {
|
||||||
// 관리자 - 봇 라우트
|
// 관리자 - 봇 라우트
|
||||||
fastify.register(botsRoutes, { prefix: '/admin/bots' });
|
fastify.register(botsRoutes, { prefix: '/admin/bots' });
|
||||||
|
|
||||||
|
// 관리자 - YouTube 봇 라우트
|
||||||
|
fastify.register(youtubeBotsRoutes, { prefix: '/admin/youtube-bots' });
|
||||||
|
|
||||||
// 관리자 - YouTube 라우트
|
// 관리자 - YouTube 라우트
|
||||||
fastify.register(youtubeAdminRoutes, { prefix: '/admin/youtube' });
|
fastify.register(youtubeAdminRoutes, { prefix: '/admin/youtube' });
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,49 @@ export async function getBots() {
|
||||||
return fetchAuthApi('/admin/bots');
|
return fetchAuthApi('/admin/bots');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* YouTube 봇 상세 조회
|
||||||
|
* @param {number} id - YouTube 봇 DB ID
|
||||||
|
* @returns {Promise<object>}
|
||||||
|
*/
|
||||||
|
export async function getYouTubeBot(id) {
|
||||||
|
return fetchAuthApi(`/admin/youtube-bots/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* YouTube 봇 추가
|
||||||
|
* @param {object} data - 봇 데이터
|
||||||
|
* @returns {Promise<object>}
|
||||||
|
*/
|
||||||
|
export async function createYouTubeBot(data) {
|
||||||
|
return fetchAuthApi('/admin/youtube-bots', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* YouTube 봇 수정
|
||||||
|
* @param {number} id - YouTube 봇 DB ID
|
||||||
|
* @param {object} data - 업데이트할 데이터
|
||||||
|
* @returns {Promise<object>}
|
||||||
|
*/
|
||||||
|
export async function updateYouTubeBot(id, data) {
|
||||||
|
return fetchAuthApi(`/admin/youtube-bots/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* YouTube 봇 삭제
|
||||||
|
* @param {number} id - YouTube 봇 DB ID
|
||||||
|
* @returns {Promise<object>}
|
||||||
|
*/
|
||||||
|
export async function deleteYouTubeBot(id) {
|
||||||
|
return fetchAuthApi(`/admin/youtube-bots/${id}`, { method: 'DELETE' });
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 봇 시작
|
* 봇 시작
|
||||||
* @param {string} id - 봇 ID
|
* @param {string} id - 봇 ID
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,11 @@
|
||||||
*/
|
*/
|
||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
|
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 } 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';
|
||||||
|
|
||||||
// 동기화 간격 옵션
|
// 동기화 간격 옵션
|
||||||
const INTERVAL_OPTIONS = [
|
const INTERVAL_OPTIONS = [
|
||||||
|
|
@ -252,14 +254,16 @@ function MultiSelect({ values = [], options, onChange, placeholder = '선택', c
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function YouTubeBotDialog({ isOpen, onClose, bot = null, onSubmit }) {
|
function YouTubeBotDialog({ isOpen, onClose, botId = null, onSuccess }) {
|
||||||
const isEdit = !!bot;
|
const queryClient = useQueryClient();
|
||||||
|
const isEdit = !!botId;
|
||||||
|
|
||||||
// 폼 상태
|
// 폼 상태
|
||||||
const [handle, setHandle] = useState('');
|
const [handle, setHandle] = useState('');
|
||||||
const [channelInfo, setChannelInfo] = useState(null);
|
const [channelInfo, setChannelInfo] = useState(null);
|
||||||
const [lookupLoading, setLookupLoading] = useState(false);
|
const [lookupLoading, setLookupLoading] = useState(false);
|
||||||
const [interval, setInterval] = useState(2);
|
const [interval, setInterval] = useState(2);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
// 예정 일정 설정
|
// 예정 일정 설정
|
||||||
const [autoScheduleEnabled, setAutoScheduleEnabled] = useState(false);
|
const [autoScheduleEnabled, setAutoScheduleEnabled] = useState(false);
|
||||||
|
|
@ -278,6 +282,14 @@ function YouTubeBotDialog({ isOpen, onClose, bot = null, onSubmit }) {
|
||||||
// 멤버 목록 (탈퇴 멤버 제외)
|
// 멤버 목록 (탈퇴 멤버 제외)
|
||||||
const [members, setMembers] = useState([]);
|
const [members, setMembers] = useState([]);
|
||||||
|
|
||||||
|
// YouTube 봇 상세 조회 (수정 모드)
|
||||||
|
const { data: bot, isLoading: botLoading } = useQuery({
|
||||||
|
queryKey: ['admin', 'youtube-bot', botId],
|
||||||
|
queryFn: () => getYouTubeBot(botId),
|
||||||
|
enabled: isOpen && !!botId,
|
||||||
|
staleTime: 0, // 항상 fresh 데이터 가져오기
|
||||||
|
});
|
||||||
|
|
||||||
// 멤버 목록 로드
|
// 멤버 목록 로드
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
|
|
@ -287,36 +299,62 @@ function YouTubeBotDialog({ isOpen, onClose, bot = null, onSubmit }) {
|
||||||
}
|
}
|
||||||
}, [isOpen]);
|
}, [isOpen]);
|
||||||
|
|
||||||
// 수정 모드일 때 기존 데이터 로드
|
// 다이얼로그 열릴 때 데이터 설정 (수정/추가 모드)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!isOpen) {
|
||||||
|
return; // 닫혀있으면 아무것도 안 함
|
||||||
|
}
|
||||||
|
|
||||||
if (bot) {
|
if (bot) {
|
||||||
|
// 수정 모드: 기존 데이터 로드
|
||||||
setHandle(bot.channel_handle || '');
|
setHandle(bot.channel_handle || '');
|
||||||
setChannelInfo({
|
setChannelInfo({
|
||||||
channelId: bot.channel_id,
|
channelId: bot.channel_id,
|
||||||
title: bot.channel_name,
|
title: bot.channel_name,
|
||||||
|
bannerUrl: bot.banner_url,
|
||||||
});
|
});
|
||||||
setInterval(bot.cron_interval || 2);
|
setInterval(bot.cron_interval || 2);
|
||||||
|
|
||||||
if (bot.auto_schedule_config) {
|
console.log('bot.auto_schedule_config:', bot.auto_schedule_config);
|
||||||
const config = typeof bot.auto_schedule_config === 'string'
|
console.log('typeof:', typeof bot.auto_schedule_config);
|
||||||
? JSON.parse(bot.auto_schedule_config)
|
|
||||||
: 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);
|
setAutoScheduleEnabled(true);
|
||||||
setScheduleDayOfWeek(config.dayOfWeek ?? 4);
|
setScheduleDayOfWeek(config.dayOfWeek);
|
||||||
setScheduleTime(config.time?.slice(0, 5) || '18:00');
|
setScheduleTime(config.time?.slice(0, 5) || '18:00');
|
||||||
setTitleTemplate(config.titleTemplate || '{channelName} {episode}화');
|
setTitleTemplate(config.titleTemplate || '{channelName} {episode}화');
|
||||||
setDeadlineDayOfWeek(config.deadlineDayOfWeek ?? 5);
|
setDeadlineDayOfWeek(config.deadlineDayOfWeek ?? 5);
|
||||||
|
} else {
|
||||||
|
setAutoScheduleEnabled(false);
|
||||||
|
setScheduleDayOfWeek(4);
|
||||||
|
setScheduleTime('18:00');
|
||||||
|
setTitleTemplate('{channelName} {episode}화');
|
||||||
|
setDeadlineDayOfWeek(5);
|
||||||
}
|
}
|
||||||
|
|
||||||
setTitleFilters(bot.title_filters || []);
|
setTitleFilters(bot.title_filters || []);
|
||||||
setDefaultMemberIds(bot.default_member_ids || []);
|
setDefaultMemberIds(bot.default_member_ids || []);
|
||||||
setExtractMembers(bot.extract_members_from_desc || false);
|
setExtractMembers(bot.extract_members_from_desc || false);
|
||||||
}
|
|
||||||
}, [bot]);
|
|
||||||
|
|
||||||
// 다이얼로그 닫힐 때 초기화
|
// 고급 설정이 있으면 펼침
|
||||||
useEffect(() => {
|
if ((bot.title_filters && bot.title_filters.length > 0) ||
|
||||||
if (!isOpen) {
|
(bot.default_member_ids && bot.default_member_ids.length > 0) ||
|
||||||
|
bot.extract_members_from_desc) {
|
||||||
|
setShowAdvanced(true);
|
||||||
|
} else {
|
||||||
|
setShowAdvanced(false);
|
||||||
|
}
|
||||||
|
} else if (!botId) {
|
||||||
|
// 추가 모드: 초기값으로 리셋
|
||||||
setHandle('');
|
setHandle('');
|
||||||
setChannelInfo(null);
|
setChannelInfo(null);
|
||||||
setInterval(2);
|
setInterval(2);
|
||||||
|
|
@ -331,7 +369,7 @@ function YouTubeBotDialog({ isOpen, onClose, bot = null, onSubmit }) {
|
||||||
setDefaultMemberIds([]);
|
setDefaultMemberIds([]);
|
||||||
setExtractMembers(false);
|
setExtractMembers(false);
|
||||||
}
|
}
|
||||||
}, [isOpen]);
|
}, [isOpen, bot, botId]);
|
||||||
|
|
||||||
// 채널 조회
|
// 채널 조회
|
||||||
const handleLookup = async () => {
|
const handleLookup = async () => {
|
||||||
|
|
@ -349,10 +387,48 @@ function YouTubeBotDialog({ isOpen, onClose, bot = null, onSubmit }) {
|
||||||
};
|
};
|
||||||
|
|
||||||
// 제출
|
// 제출
|
||||||
const handleSubmit = (e) => {
|
const handleSubmit = async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
// TODO: onSubmit 호출
|
if (!channelInfo) return;
|
||||||
onClose();
|
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
const data = {
|
||||||
|
channel_handle: handle || null,
|
||||||
|
channel_name: channelInfo.title,
|
||||||
|
cron_interval: interval,
|
||||||
|
title_filters: titleFilters.length > 0 ? titleFilters : null,
|
||||||
|
default_member_ids: defaultMemberIds.length > 0 ? defaultMemberIds : null,
|
||||||
|
extract_members_from_desc: extractMembers,
|
||||||
|
auto_schedule_config: autoScheduleEnabled
|
||||||
|
? {
|
||||||
|
dayOfWeek: scheduleDayOfWeek,
|
||||||
|
time: `${scheduleTime}:00`,
|
||||||
|
titleTemplate,
|
||||||
|
deadlineDayOfWeek,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isEdit) {
|
||||||
|
await updateYouTubeBot(botId, data);
|
||||||
|
} else {
|
||||||
|
data.channel_id = channelInfo.channelId;
|
||||||
|
await createYouTubeBot(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 캐시 무효화
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin', 'bots'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin', 'youtube-bot'] });
|
||||||
|
|
||||||
|
onSuccess?.();
|
||||||
|
onClose();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('봇 저장 실패:', error);
|
||||||
|
alert(error.message || '봇 저장에 실패했습니다.');
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
|
|
@ -390,6 +466,11 @@ function YouTubeBotDialog({ isOpen, onClose, bot = null, onSubmit }) {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 본문 */}
|
{/* 본문 */}
|
||||||
|
{botLoading ? (
|
||||||
|
<div className="flex-1 flex items-center justify-center p-12">
|
||||||
|
<Loader2 size={32} className="animate-spin text-red-500" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<form onSubmit={handleSubmit} className="flex-1 overflow-y-auto p-6 space-y-5">
|
<form onSubmit={handleSubmit} className="flex-1 overflow-y-auto p-6 space-y-5">
|
||||||
{/* 채널 핸들 */}
|
{/* 채널 핸들 */}
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -426,13 +507,24 @@ function YouTubeBotDialog({ isOpen, onClose, bot = null, onSubmit }) {
|
||||||
</div>
|
</div>
|
||||||
{/* 채널 정보 표시 */}
|
{/* 채널 정보 표시 */}
|
||||||
{channelInfo && (
|
{channelInfo && (
|
||||||
<div className="mt-2 p-3 bg-gray-50 rounded-lg flex items-center gap-3">
|
<div className="mt-2 bg-gray-50 rounded-lg overflow-hidden">
|
||||||
<div className="w-10 h-10 bg-gray-200 rounded-full flex items-center justify-center">
|
{channelInfo.bannerUrl && (
|
||||||
<Youtube size={20} className="text-gray-400" />
|
<div className="h-20 overflow-hidden">
|
||||||
</div>
|
<img
|
||||||
<div className="flex-1 min-w-0">
|
src={channelInfo.bannerUrl}
|
||||||
<p className="font-medium text-gray-900 truncate">{channelInfo.title}</p>
|
alt="채널 배너"
|
||||||
<p className="text-xs text-gray-500">{channelInfo.channelId}</p>
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="p-3 flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 bg-gray-200 rounded-full flex items-center justify-center">
|
||||||
|
<Youtube size={20} className="text-gray-400" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="font-medium text-gray-900 truncate">{channelInfo.title}</p>
|
||||||
|
<p className="text-xs text-gray-500">{channelInfo.channelId}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -627,22 +719,25 @@ function YouTubeBotDialog({ isOpen, onClose, bot = null, onSubmit }) {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 푸터 */}
|
{/* 푸터 */}
|
||||||
<div className="flex justify-end gap-3 px-6 py-4 border-t border-gray-100 bg-gray-50">
|
<div className="flex justify-end gap-3 px-6 py-4 border-t border-gray-100 bg-gray-50">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="px-4 py-2.5 text-gray-600 hover:text-gray-900 hover:bg-gray-200 rounded-lg transition-colors"
|
disabled={submitting}
|
||||||
|
className="px-4 py-2.5 text-gray-600 hover:text-gray-900 hover:bg-gray-200 rounded-lg transition-colors disabled:opacity-50"
|
||||||
>
|
>
|
||||||
취소
|
취소
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
disabled={!channelInfo}
|
disabled={!channelInfo || submitting || botLoading}
|
||||||
className="px-4 py-2.5 bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
className="px-4 py-2.5 bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||||
>
|
>
|
||||||
|
{submitting && <Loader2 size={16} className="animate-spin" />}
|
||||||
{isEdit ? '수정' : '추가'}
|
{isEdit ? '수정' : '추가'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -61,7 +61,7 @@ function ScheduleBots() {
|
||||||
const [syncing, setSyncing] = useState(null); // 동기화 중인 봇 ID
|
const [syncing, setSyncing] = useState(null); // 동기화 중인 봇 ID
|
||||||
const [quotaWarning, setQuotaWarning] = useState(null); // 할당량 경고 상태
|
const [quotaWarning, setQuotaWarning] = useState(null); // 할당량 경고 상태
|
||||||
const [botDialogOpen, setBotDialogOpen] = useState(false); // 봇 추가/수정 다이얼로그
|
const [botDialogOpen, setBotDialogOpen] = useState(false); // 봇 추가/수정 다이얼로그
|
||||||
const [editingBot, setEditingBot] = useState(null); // 수정 중인 봇
|
const [editingBotId, setEditingBotId] = useState(null); // 수정 중인 봇 DB ID
|
||||||
|
|
||||||
// 봇 목록 조회
|
// 봇 목록 조회
|
||||||
const {
|
const {
|
||||||
|
|
@ -73,7 +73,7 @@ function ScheduleBots() {
|
||||||
queryKey: ['admin', 'bots'],
|
queryKey: ['admin', 'bots'],
|
||||||
queryFn: botsApi.getBots,
|
queryFn: botsApi.getBots,
|
||||||
enabled: isAuthenticated,
|
enabled: isAuthenticated,
|
||||||
staleTime: 30000,
|
staleTime: 0, // 항상 fresh 데이터
|
||||||
});
|
});
|
||||||
|
|
||||||
// 할당량 경고 상태 조회
|
// 할당량 경고 상태 조회
|
||||||
|
|
@ -241,12 +241,11 @@ function ScheduleBots() {
|
||||||
isOpen={botDialogOpen}
|
isOpen={botDialogOpen}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setBotDialogOpen(false);
|
setBotDialogOpen(false);
|
||||||
setEditingBot(null);
|
setEditingBotId(null);
|
||||||
}}
|
}}
|
||||||
bot={editingBot}
|
botId={editingBotId}
|
||||||
onSubmit={(data) => {
|
onSuccess={() => {
|
||||||
// TODO: API 호출
|
setToast({ type: 'success', message: editingBotId ? '봇이 수정되었습니다.' : '봇이 추가되었습니다.' });
|
||||||
console.log('submit', data);
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
@ -369,7 +368,7 @@ function ScheduleBots() {
|
||||||
{section.canAdd && (
|
{section.canAdd && (
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setEditingBot(null);
|
setEditingBotId(null);
|
||||||
setBotDialogOpen(true);
|
setBotDialogOpen(true);
|
||||||
}}
|
}}
|
||||||
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"
|
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"
|
||||||
|
|
@ -415,7 +414,9 @@ function ScheduleBots() {
|
||||||
onSync={handleSyncAllVideos}
|
onSync={handleSyncAllVideos}
|
||||||
onToggle={toggleBot}
|
onToggle={toggleBot}
|
||||||
onEdit={(bot) => {
|
onEdit={(bot) => {
|
||||||
setEditingBot(bot);
|
console.log('Edit bot:', bot);
|
||||||
|
console.log('db_id:', bot.db_id);
|
||||||
|
setEditingBotId(bot.db_id);
|
||||||
setBotDialogOpen(true);
|
setBotDialogOpen(true);
|
||||||
}}
|
}}
|
||||||
onDelete={(bot) => {/* TODO: 봇 삭제 확인 */}}
|
onDelete={(bot) => {/* TODO: 봇 삭제 확인 */}}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue