From 2355068c7746838d42af240f3da67ed65eeca78b Mon Sep 17 00:00:00 2001 From: caadiq Date: Sat, 7 Feb 2026 23:48:58 +0900 Subject: [PATCH] =?UTF-8?q?feat(admin):=20X=20=EB=B4=87=20CRUD=20API=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - POST /api/admin/x-bots/lookup: 프로필 조회 - GET /api/admin/x-bots: 목록 조회 - GET /api/admin/x-bots/:id: 상세 조회 - POST /api/admin/x-bots: 봇 추가 - PUT /api/admin/x-bots/:id: 봇 수정 - DELETE /api/admin/x-bots/:id: 봇 삭제 Co-Authored-By: Claude Opus 4.5 --- backend/src/routes/admin/x-bots.js | 328 +++++++++++++++++++++++++++++ backend/src/routes/index.js | 4 + 2 files changed, 332 insertions(+) create mode 100644 backend/src/routes/admin/x-bots.js diff --git a/backend/src/routes/admin/x-bots.js b/backend/src/routes/admin/x-bots.js new file mode 100644 index 0000000..4e1c668 --- /dev/null +++ b/backend/src/routes/admin/x-bots.js @@ -0,0 +1,328 @@ +import { errorResponse } from '../../schemas/index.js'; +import { badRequest, notFound, serverError } from '../../utils/error.js'; +import { fetchProfile } from '../../services/x/scraper.js'; + +/** + * X 봇 스키마 + */ +const xBotResponse = { + type: 'object', + properties: { + id: { type: 'integer' }, + username: { type: 'string' }, + display_name: { type: 'string' }, + avatar_url: { type: 'string' }, + cron_interval: { type: 'integer' }, + enabled: { type: 'boolean' }, + }, +}; + +const xBotIdParam = { + type: 'object', + properties: { + id: { type: 'integer', description: 'X 봇 DB ID' }, + }, + required: ['id'], +}; + +/** + * DB row를 API 응답 형식으로 변환 + */ +function formatBotResponse(row) { + return { + id: row.id, + username: row.username, + display_name: row.display_name, + avatar_url: row.avatar_url, + cron_interval: row.cron_interval, + enabled: row.enabled === 1, + }; +} + +/** + * X 봇 관리 라우트 + */ +export default async function xBotsRoutes(fastify) { + const { db, scheduler } = fastify; + const nitterUrl = process.env.NITTER_URL || 'http://nitter:8080'; + + /** + * POST /api/admin/x-bots/lookup + * username으로 프로필 정보 조회 + */ + fastify.post('/lookup', { + schema: { + tags: ['admin/x-bots'], + summary: 'X username으로 프로필 정보 조회', + security: [{ bearerAuth: [] }], + body: { + type: 'object', + properties: { + username: { type: 'string', description: 'X username (@ 없이)' }, + }, + required: ['username'], + }, + response: { + 200: { + type: 'object', + properties: { + username: { type: 'string' }, + displayName: { type: 'string' }, + avatarUrl: { type: 'string' }, + }, + }, + 400: errorResponse, + }, + }, + preHandler: [fastify.authenticate], + }, async (request, reply) => { + const { username } = request.body; + + try { + const profile = await fetchProfile(nitterUrl, username); + return profile; + } catch (err) { + return badRequest(reply, err.message); + } + }); + + /** + * GET /api/admin/x-bots + * X 봇 목록 조회 + */ + fastify.get('/', { + schema: { + tags: ['admin/x-bots'], + summary: 'X 봇 목록 조회', + security: [{ bearerAuth: [] }], + response: { + 200: { + type: 'array', + items: xBotResponse, + }, + }, + }, + preHandler: [fastify.authenticate], + }, async (request, reply) => { + const [rows] = await db.query('SELECT * FROM bot_x ORDER BY id'); + return rows.map(formatBotResponse); + }); + + /** + * GET /api/admin/x-bots/:id + * X 봇 상세 조회 + */ + fastify.get('/:id', { + schema: { + tags: ['admin/x-bots'], + summary: 'X 봇 상세 조회', + security: [{ bearerAuth: [] }], + params: xBotIdParam, + response: { + 200: xBotResponse, + 404: errorResponse, + }, + }, + preHandler: [fastify.authenticate], + }, async (request, reply) => { + const { id } = request.params; + const [rows] = await db.query('SELECT * FROM bot_x WHERE id = ?', [id]); + + if (rows.length === 0) { + return notFound(reply, 'X 봇을 찾을 수 없습니다.'); + } + + return formatBotResponse(rows[0]); + }); + + /** + * POST /api/admin/x-bots + * X 봇 추가 + */ + fastify.post('/', { + schema: { + tags: ['admin/x-bots'], + summary: 'X 봇 추가', + security: [{ bearerAuth: [] }], + body: { + type: 'object', + properties: { + username: { type: 'string' }, + display_name: { type: ['string', 'null'] }, + avatar_url: { type: ['string', 'null'] }, + cron_interval: { type: 'integer', default: 1 }, + }, + required: ['username'], + }, + response: { + 201: xBotResponse, + 400: errorResponse, + }, + }, + preHandler: [fastify.authenticate], + }, async (request, reply) => { + const { + username, + display_name, + avatar_url, + cron_interval = 1, + } = request.body; + + // 중복 체크 + const [existing] = await db.query( + 'SELECT id FROM bot_x WHERE username = ?', + [username] + ); + if (existing.length > 0) { + return badRequest(reply, '이미 등록된 X 계정입니다.'); + } + + const [result] = await db.query( + `INSERT INTO bot_x (username, display_name, avatar_url, cron_interval, enabled) + VALUES (?, ?, ?, ?, 1)`, + [username, display_name || null, avatar_url || null, cron_interval] + ); + + // 스케줄러 캐시 무효화 및 봇 시작 + scheduler.invalidateCache(); + const botId = `x-${result.insertId}`; + try { + await scheduler.startBot(botId); + } catch (err) { + fastify.log.error(`[${botId}] 봇 시작 실패:`, err); + } + + const [newBot] = await db.query('SELECT * FROM bot_x WHERE id = ?', [result.insertId]); + reply.code(201); + return formatBotResponse(newBot[0]); + }); + + /** + * PUT /api/admin/x-bots/:id + * X 봇 수정 + */ + fastify.put('/:id', { + schema: { + tags: ['admin/x-bots'], + summary: 'X 봇 수정', + security: [{ bearerAuth: [] }], + params: xBotIdParam, + body: { + type: 'object', + properties: { + display_name: { type: ['string', 'null'] }, + avatar_url: { type: ['string', 'null'] }, + cron_interval: { type: 'integer' }, + enabled: { type: 'boolean' }, + }, + }, + response: { + 200: xBotResponse, + 404: errorResponse, + }, + }, + preHandler: [fastify.authenticate], + }, async (request, reply) => { + const { id } = request.params; + const updates = request.body; + + // 존재 확인 + const [existing] = await db.query('SELECT * FROM bot_x WHERE id = ?', [id]); + if (existing.length === 0) { + return notFound(reply, 'X 봇을 찾을 수 없습니다.'); + } + + // 동적 업데이트 쿼리 생성 + const fields = []; + const values = []; + + if (updates.display_name !== undefined) { + fields.push('display_name = ?'); + values.push(updates.display_name); + } + if (updates.avatar_url !== undefined) { + fields.push('avatar_url = ?'); + values.push(updates.avatar_url); + } + if (updates.cron_interval !== undefined) { + fields.push('cron_interval = ?'); + values.push(updates.cron_interval); + } + if (updates.enabled !== undefined) { + fields.push('enabled = ?'); + values.push(updates.enabled ? 1 : 0); + } + + if (fields.length > 0) { + values.push(id); + await db.query( + `UPDATE bot_x SET ${fields.join(', ')} WHERE id = ?`, + values + ); + + // 스케줄러 캐시 무효화 및 봇 재시작 + scheduler.invalidateCache(); + const botId = `x-${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 bot_x WHERE id = ?', [id]); + return formatBotResponse(updatedBot[0]); + }); + + /** + * DELETE /api/admin/x-bots/:id + * X 봇 삭제 + */ + fastify.delete('/:id', { + schema: { + tags: ['admin/x-bots'], + summary: 'X 봇 삭제', + security: [{ bearerAuth: [] }], + params: xBotIdParam, + 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 bot_x WHERE id = ?', [id]); + if (existing.length === 0) { + return notFound(reply, 'X 봇을 찾을 수 없습니다.'); + } + + // 봇 정지 + const botId = `x-${id}`; + try { + await scheduler.stopBot(botId); + } catch (err) { + // 이미 정지된 경우 무시 + } + + // DB에서 삭제 + await db.query('DELETE FROM bot_x WHERE id = ?', [id]); + + // 스케줄러 캐시 무효화 + scheduler.invalidateCache(); + + return { success: true }; + }); +} diff --git a/backend/src/routes/index.js b/backend/src/routes/index.js index a701184..3125223 100644 --- a/backend/src/routes/index.js +++ b/backend/src/routes/index.js @@ -5,6 +5,7 @@ import schedulesRoutes from './schedules/index.js'; import statsRoutes from './stats/index.js'; import botsRoutes from './admin/bots.js'; import youtubeBotsRoutes from './admin/youtube-bots.js'; +import xBotsRoutes from './admin/x-bots.js'; import youtubeAdminRoutes from './admin/youtube.js'; import xAdminRoutes from './admin/x.js'; import concertAdminRoutes from './admin/concert.js'; @@ -36,6 +37,9 @@ export default async function routes(fastify) { // 관리자 - YouTube 봇 라우트 fastify.register(youtubeBotsRoutes, { prefix: '/admin/youtube-bots' }); + // 관리자 - X 봇 라우트 + fastify.register(xBotsRoutes, { prefix: '/admin/x-bots' }); + // 관리자 - YouTube 라우트 fastify.register(youtubeAdminRoutes, { prefix: '/admin/youtube' });