feat(admin): X 봇 CRUD API 추가

- POST /api/admin/x-bots/lookup: 프로필 조회
- GET /api/admin/x-bots: 목록 조회
- GET /api/admin/x-bots/🆔 상세 조회
- POST /api/admin/x-bots: 봇 추가
- PUT /api/admin/x-bots/🆔 봇 수정
- DELETE /api/admin/x-bots/🆔 봇 삭제

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
caadiq 2026-02-07 23:48:58 +09:00
parent 535fbb6768
commit 2355068c77
2 changed files with 332 additions and 0 deletions

View file

@ -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 };
});
}

View file

@ -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' });