2026-01-21 14:58:07 +09:00
|
|
|
import { errorResponse } from '../../schemas/index.js';
|
2026-01-23 11:14:17 +09:00
|
|
|
import { syncAllSchedules } from '../../services/meilisearch/index.js';
|
2026-01-23 11:24:42 +09:00
|
|
|
import { badRequest, notFound, serverError } from '../../utils/error.js';
|
2026-01-23 22:00:58 +09:00
|
|
|
import { nowKST } from '../../utils/date.js';
|
2026-03-02 17:04:07 +09:00
|
|
|
import { logActivity } from '../../utils/log.js';
|
2026-01-21 14:58:07 +09:00
|
|
|
|
|
|
|
|
// 봇 관련 스키마
|
|
|
|
|
const botResponse = {
|
|
|
|
|
type: 'object',
|
|
|
|
|
properties: {
|
|
|
|
|
id: { type: 'string' },
|
|
|
|
|
name: { type: 'string' },
|
2026-01-23 11:14:17 +09:00
|
|
|
type: { type: 'string', enum: ['youtube', 'x', 'meilisearch'] },
|
2026-01-21 14:58:07 +09:00
|
|
|
status: { type: 'string', enum: ['running', 'stopped', 'error'] },
|
|
|
|
|
last_check_at: { type: 'string', format: 'date-time' },
|
|
|
|
|
last_added_count: { type: 'integer' },
|
2026-01-23 22:00:58 +09:00
|
|
|
last_sync_duration: { type: 'integer', description: '마지막 동기화 소요 시간 (ms)' },
|
2026-01-21 14:58:07 +09:00
|
|
|
schedules_added: { type: 'integer' },
|
|
|
|
|
check_interval: { type: 'integer' },
|
|
|
|
|
error_message: { type: 'string' },
|
|
|
|
|
enabled: { type: 'boolean' },
|
2026-02-07 10:43:06 +09:00
|
|
|
// 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 },
|
2026-02-07 23:56:45 +09:00
|
|
|
// X 봇 전용 필드
|
|
|
|
|
username: { type: 'string' },
|
|
|
|
|
display_name: { type: 'string' },
|
|
|
|
|
avatar_url: { type: 'string' },
|
2026-02-08 09:23:45 +09:00
|
|
|
text_filters: { type: 'array', items: { type: 'string' } },
|
2026-01-21 14:58:07 +09:00
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const botIdParam = {
|
|
|
|
|
type: 'object',
|
|
|
|
|
properties: {
|
|
|
|
|
id: { type: 'string', description: '봇 ID' },
|
|
|
|
|
},
|
|
|
|
|
required: ['id'],
|
|
|
|
|
};
|
2026-01-18 23:45:54 +09:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 봇 관리 라우트
|
|
|
|
|
* 인증 필요
|
|
|
|
|
*/
|
|
|
|
|
export default async function botsRoutes(fastify) {
|
2026-03-02 17:04:07 +09:00
|
|
|
const { scheduler, redis, db } = fastify;
|
2026-01-18 23:45:54 +09:00
|
|
|
const QUOTA_WARNING_KEY = 'youtube:quota_warning';
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* GET /api/admin/bots
|
|
|
|
|
* 봇 목록 조회
|
|
|
|
|
*/
|
|
|
|
|
fastify.get('/', {
|
|
|
|
|
schema: {
|
|
|
|
|
tags: ['admin/bots'],
|
|
|
|
|
summary: '봇 목록 조회',
|
2026-01-21 14:58:07 +09:00
|
|
|
description: '등록된 모든 봇(YouTube, X)의 상태를 조회합니다.',
|
2026-01-18 23:45:54 +09:00
|
|
|
security: [{ bearerAuth: [] }],
|
2026-01-21 14:58:07 +09:00
|
|
|
response: {
|
|
|
|
|
200: {
|
|
|
|
|
type: 'array',
|
|
|
|
|
items: botResponse,
|
|
|
|
|
},
|
|
|
|
|
},
|
2026-01-18 23:45:54 +09:00
|
|
|
},
|
|
|
|
|
preHandler: [fastify.authenticate],
|
|
|
|
|
}, async (request, reply) => {
|
2026-02-07 10:43:06 +09:00
|
|
|
// API 호출 시에는 항상 fresh한 데이터 반환
|
|
|
|
|
const allBots = await scheduler.getBots(true);
|
2026-01-18 23:45:54 +09:00
|
|
|
const result = [];
|
|
|
|
|
|
2026-02-07 10:15:07 +09:00
|
|
|
for (const bot of allBots) {
|
2026-01-18 23:45:54 +09:00
|
|
|
const status = await scheduler.getStatus(bot.id);
|
|
|
|
|
|
2026-01-27 11:59:18 +09:00
|
|
|
// cron 표현식에서 간격 추출 (분 단위, 일일 스케줄은 1440분)
|
2026-01-18 23:45:54 +09:00
|
|
|
let checkInterval = 2; // 기본값
|
|
|
|
|
const cronMatch = bot.cron.match(/^\*\/(\d+)/);
|
|
|
|
|
if (cronMatch) {
|
|
|
|
|
checkInterval = parseInt(cronMatch[1]);
|
2026-01-27 11:59:18 +09:00
|
|
|
} else if (/^0 \d+ \* \* \*$/.test(bot.cron)) {
|
|
|
|
|
// 매일 특정 시간 (예: 0 12 * * *)
|
|
|
|
|
checkInterval = 1440; // 24시간 = 1440분
|
2026-01-18 23:45:54 +09:00
|
|
|
}
|
|
|
|
|
|
2026-02-07 10:43:06 +09:00
|
|
|
const botData = {
|
2026-01-18 23:45:54 +09:00
|
|
|
id: bot.id,
|
2026-01-23 11:14:17 +09:00
|
|
|
name: bot.name || bot.channelName || bot.username || bot.id,
|
2026-01-18 23:45:54 +09:00
|
|
|
type: bot.type,
|
|
|
|
|
status: status.status,
|
|
|
|
|
last_check_at: status.lastCheckAt,
|
|
|
|
|
last_added_count: status.lastAddedCount,
|
2026-01-23 22:00:58 +09:00
|
|
|
last_sync_duration: status.lastSyncDuration,
|
2026-01-18 23:45:54 +09:00
|
|
|
schedules_added: status.totalAdded,
|
|
|
|
|
check_interval: checkInterval,
|
|
|
|
|
error_message: status.errorMessage,
|
|
|
|
|
enabled: bot.enabled,
|
2026-02-07 10:43:06 +09:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// YouTube 봇인 경우 상세 정보 추가
|
|
|
|
|
if (bot.type === 'youtube') {
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-07 23:56:45 +09:00
|
|
|
// X 봇인 경우 상세 정보 추가
|
|
|
|
|
if (bot.type === 'x') {
|
|
|
|
|
botData.db_id = bot.dbId;
|
|
|
|
|
botData.username = bot.username;
|
|
|
|
|
botData.display_name = bot.displayName;
|
|
|
|
|
botData.avatar_url = bot.avatarUrl;
|
2026-02-08 09:23:45 +09:00
|
|
|
botData.text_filters = bot.textFilters || [];
|
2026-02-07 23:56:45 +09:00
|
|
|
botData.cron_interval = checkInterval;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-07 10:43:06 +09:00
|
|
|
result.push(botData);
|
2026-01-18 23:45:54 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return result;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* POST /api/admin/bots/:id/start
|
|
|
|
|
* 봇 시작
|
|
|
|
|
*/
|
|
|
|
|
fastify.post('/:id/start', {
|
|
|
|
|
schema: {
|
|
|
|
|
tags: ['admin/bots'],
|
|
|
|
|
summary: '봇 시작',
|
2026-01-21 14:58:07 +09:00
|
|
|
description: '지정된 봇의 스케줄러를 시작합니다.',
|
2026-01-18 23:45:54 +09:00
|
|
|
security: [{ bearerAuth: [] }],
|
2026-01-21 14:58:07 +09:00
|
|
|
params: botIdParam,
|
|
|
|
|
response: {
|
|
|
|
|
200: {
|
|
|
|
|
type: 'object',
|
|
|
|
|
properties: {
|
|
|
|
|
success: { type: 'boolean' },
|
|
|
|
|
message: { type: 'string' },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
400: errorResponse,
|
|
|
|
|
},
|
2026-01-18 23:45:54 +09:00
|
|
|
},
|
|
|
|
|
preHandler: [fastify.authenticate],
|
|
|
|
|
}, async (request, reply) => {
|
|
|
|
|
const { id } = request.params;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
await scheduler.startBot(id);
|
2026-03-02 17:04:07 +09:00
|
|
|
logActivity(db, { actor: 'admin', action: 'start', category: 'bot', targetType: null, targetId: null, summary: `봇 시작: ${id}` });
|
2026-01-18 23:45:54 +09:00
|
|
|
return { success: true, message: '봇이 시작되었습니다.' };
|
|
|
|
|
} catch (err) {
|
2026-01-23 11:24:42 +09:00
|
|
|
return badRequest(reply, err.message);
|
2026-01-18 23:45:54 +09:00
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* POST /api/admin/bots/:id/stop
|
|
|
|
|
* 봇 정지
|
|
|
|
|
*/
|
|
|
|
|
fastify.post('/:id/stop', {
|
|
|
|
|
schema: {
|
|
|
|
|
tags: ['admin/bots'],
|
|
|
|
|
summary: '봇 정지',
|
2026-01-21 14:58:07 +09:00
|
|
|
description: '지정된 봇의 스케줄러를 정지합니다.',
|
2026-01-18 23:45:54 +09:00
|
|
|
security: [{ bearerAuth: [] }],
|
2026-01-21 14:58:07 +09:00
|
|
|
params: botIdParam,
|
|
|
|
|
response: {
|
|
|
|
|
200: {
|
|
|
|
|
type: 'object',
|
|
|
|
|
properties: {
|
|
|
|
|
success: { type: 'boolean' },
|
|
|
|
|
message: { type: 'string' },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
400: errorResponse,
|
|
|
|
|
},
|
2026-01-18 23:45:54 +09:00
|
|
|
},
|
|
|
|
|
preHandler: [fastify.authenticate],
|
|
|
|
|
}, async (request, reply) => {
|
|
|
|
|
const { id } = request.params;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
await scheduler.stopBot(id);
|
2026-03-02 17:04:07 +09:00
|
|
|
logActivity(db, { actor: 'admin', action: 'stop', category: 'bot', targetType: null, targetId: null, summary: `봇 정지: ${id}` });
|
2026-01-18 23:45:54 +09:00
|
|
|
return { success: true, message: '봇이 정지되었습니다.' };
|
|
|
|
|
} catch (err) {
|
2026-01-23 11:24:42 +09:00
|
|
|
return badRequest(reply, err.message);
|
2026-01-18 23:45:54 +09:00
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* POST /api/admin/bots/:id/sync-all
|
|
|
|
|
* 전체 동기화
|
|
|
|
|
*/
|
|
|
|
|
fastify.post('/:id/sync-all', {
|
|
|
|
|
schema: {
|
|
|
|
|
tags: ['admin/bots'],
|
|
|
|
|
summary: '봇 전체 동기화',
|
2026-01-21 14:58:07 +09:00
|
|
|
description: '봇이 관리하는 모든 콘텐츠를 다시 동기화합니다.',
|
2026-01-18 23:45:54 +09:00
|
|
|
security: [{ bearerAuth: [] }],
|
2026-01-21 14:58:07 +09:00
|
|
|
params: botIdParam,
|
|
|
|
|
response: {
|
|
|
|
|
200: {
|
|
|
|
|
type: 'object',
|
|
|
|
|
properties: {
|
|
|
|
|
success: { type: 'boolean' },
|
|
|
|
|
addedCount: { type: 'integer', description: '추가된 일정 수' },
|
|
|
|
|
total: { type: 'integer', description: '총 처리 수' },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
400: errorResponse,
|
|
|
|
|
404: errorResponse,
|
|
|
|
|
500: errorResponse,
|
|
|
|
|
},
|
2026-01-18 23:45:54 +09:00
|
|
|
},
|
|
|
|
|
preHandler: [fastify.authenticate],
|
|
|
|
|
}, async (request, reply) => {
|
|
|
|
|
const { id } = request.params;
|
|
|
|
|
|
2026-02-07 10:15:07 +09:00
|
|
|
const allBots = await scheduler.getBots();
|
|
|
|
|
const bot = allBots.find(b => b.id === id);
|
2026-01-18 23:45:54 +09:00
|
|
|
if (!bot) {
|
2026-01-23 11:24:42 +09:00
|
|
|
return notFound(reply, '봇을 찾을 수 없습니다.');
|
2026-01-18 23:45:54 +09:00
|
|
|
}
|
|
|
|
|
|
2026-01-23 22:00:58 +09:00
|
|
|
const startTime = Date.now();
|
2026-01-18 23:45:54 +09:00
|
|
|
try {
|
|
|
|
|
let result;
|
|
|
|
|
if (bot.type === 'youtube') {
|
|
|
|
|
result = await fastify.youtubeBot.syncAllVideos(bot);
|
|
|
|
|
} else if (bot.type === 'x') {
|
|
|
|
|
result = await fastify.xBot.syncAllTweets(bot);
|
2026-01-23 11:14:17 +09:00
|
|
|
} else if (bot.type === 'meilisearch') {
|
|
|
|
|
const count = await syncAllSchedules(fastify.meilisearch, fastify.db);
|
|
|
|
|
result = { addedCount: count, total: count };
|
2026-01-18 23:45:54 +09:00
|
|
|
} else {
|
2026-01-23 11:24:42 +09:00
|
|
|
return badRequest(reply, '지원하지 않는 봇 타입입니다.');
|
2026-01-18 23:45:54 +09:00
|
|
|
}
|
|
|
|
|
|
2026-01-23 22:00:58 +09:00
|
|
|
const duration = Date.now() - startTime;
|
|
|
|
|
|
2026-01-18 23:45:54 +09:00
|
|
|
// 상태 업데이트
|
|
|
|
|
const status = await scheduler.getStatus(id);
|
|
|
|
|
await fastify.redis.set(`bot:status:${id}`, JSON.stringify({
|
|
|
|
|
...status,
|
2026-01-23 22:00:58 +09:00
|
|
|
lastCheckAt: nowKST(),
|
2026-01-18 23:45:54 +09:00
|
|
|
lastAddedCount: result.addedCount,
|
2026-01-23 22:00:58 +09:00
|
|
|
lastSyncDuration: duration,
|
2026-01-18 23:45:54 +09:00
|
|
|
totalAdded: (status.totalAdded || 0) + result.addedCount,
|
2026-01-23 22:00:58 +09:00
|
|
|
updatedAt: nowKST(),
|
2026-01-18 23:45:54 +09:00
|
|
|
}));
|
|
|
|
|
|
2026-03-02 17:04:07 +09:00
|
|
|
logActivity(db, { actor: 'admin', action: 'sync_complete', category: 'sync', targetType: null, targetId: null, summary: `전체 동기화: ${id} (${result.addedCount}개 추가)` });
|
2026-01-18 23:45:54 +09:00
|
|
|
return {
|
|
|
|
|
success: true,
|
|
|
|
|
addedCount: result.addedCount,
|
|
|
|
|
total: result.total,
|
|
|
|
|
};
|
|
|
|
|
} catch (err) {
|
|
|
|
|
fastify.log.error(`[${id}] 전체 동기화 오류:`, err);
|
2026-01-23 11:24:42 +09:00
|
|
|
return serverError(reply, err.message);
|
2026-01-18 23:45:54 +09:00
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* GET /api/admin/quota-warning
|
|
|
|
|
* 할당량 경고 조회
|
|
|
|
|
*/
|
|
|
|
|
fastify.get('/quota-warning', {
|
|
|
|
|
schema: {
|
|
|
|
|
tags: ['admin/bots'],
|
|
|
|
|
summary: '할당량 경고 조회',
|
2026-01-21 14:58:07 +09:00
|
|
|
description: 'YouTube API 할당량 경고 상태를 조회합니다.',
|
2026-01-18 23:45:54 +09:00
|
|
|
security: [{ bearerAuth: [] }],
|
2026-01-21 14:58:07 +09:00
|
|
|
response: {
|
|
|
|
|
200: {
|
|
|
|
|
type: 'object',
|
|
|
|
|
properties: {
|
|
|
|
|
active: { type: 'boolean' },
|
|
|
|
|
message: { type: 'string' },
|
|
|
|
|
timestamp: { type: 'string', format: 'date-time' },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
2026-01-18 23:45:54 +09:00
|
|
|
},
|
|
|
|
|
preHandler: [fastify.authenticate],
|
|
|
|
|
}, async (request, reply) => {
|
|
|
|
|
const data = await redis.get(QUOTA_WARNING_KEY);
|
|
|
|
|
if (data) {
|
|
|
|
|
return { active: true, ...JSON.parse(data) };
|
|
|
|
|
}
|
|
|
|
|
return { active: false };
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* DELETE /api/admin/quota-warning
|
|
|
|
|
* 할당량 경고 해제
|
|
|
|
|
*/
|
|
|
|
|
fastify.delete('/quota-warning', {
|
|
|
|
|
schema: {
|
|
|
|
|
tags: ['admin/bots'],
|
|
|
|
|
summary: '할당량 경고 해제',
|
2026-01-21 14:58:07 +09:00
|
|
|
description: 'YouTube API 할당량 경고를 해제합니다.',
|
2026-01-18 23:45:54 +09:00
|
|
|
security: [{ bearerAuth: [] }],
|
2026-01-21 14:58:07 +09:00
|
|
|
response: {
|
|
|
|
|
200: {
|
|
|
|
|
type: 'object',
|
|
|
|
|
properties: {
|
|
|
|
|
success: { type: 'boolean' },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
2026-01-18 23:45:54 +09:00
|
|
|
},
|
|
|
|
|
preHandler: [fastify.authenticate],
|
|
|
|
|
}, async (request, reply) => {
|
|
|
|
|
await redis.del(QUOTA_WARNING_KEY);
|
|
|
|
|
return { success: true };
|
|
|
|
|
});
|
|
|
|
|
}
|