diff --git a/.env b/.env
index efe78d8..3842af0 100644
--- a/.env
+++ b/.env
@@ -23,6 +23,9 @@ KAKAO_REST_KEY=e7a5516bf6cb1b398857789ee2ea6eea
# Google API
GOOGLE_API_KEY=AIzaSyC6l3nFlcHgLc0d1Q9WPyYQjVKTv21ZqFs
+# Gemini API (대학 축제 크롤러 봇용)
+GEMINI_API_KEY=AIzaSyC7TZKmbklcdSsX1qcpt8sTE29LWv9i_co
+
# Meilisearch
MEILI_MASTER_KEY=xMLNzlGX4xYji494JOb5IMlLHULcYw91
diff --git a/CLAUDE.md b/CLAUDE.md
index 8e46814..cd66f05 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -22,13 +22,15 @@ DB 및 외부 서비스 접근 정보는 `.env` 파일 참조:
- RUSTFS_* (S3 호환 스토리지)
- YOUTUBE_API_KEY
- MEILI_MASTER_KEY (Meilisearch)
+- KAKAO_REST_KEY (카카오맵 장소 검색)
+- GEMINI_API_KEY (대학 축제 크롤러 봇)
## 문서
-- [docs/migration.md](docs/migration.md) - 마이그레이션 현황 및 남은 작업
- [docs/architecture.md](docs/architecture.md) - 프로젝트 구조
- [docs/api.md](docs/api.md) - API 명세
- [docs/development.md](docs/development.md) - 개발/배포 가이드
+- [docs/logs.md](docs/logs.md) - 활동 로그 시스템
## 작업 시 주의사항
diff --git a/backend/src/app.js b/backend/src/app.js
index ed97774..ce72c1a 100644
--- a/backend/src/app.js
+++ b/backend/src/app.js
@@ -18,6 +18,7 @@ import authPlugin from './plugins/auth.js';
import meilisearchPlugin from './plugins/meilisearch.js';
import youtubeBotPlugin from './services/youtube/index.js';
import xBotPlugin from './services/x/index.js';
+import festivalBotPlugin from './services/festival/index.js';
import schedulerPlugin from './plugins/scheduler.js';
// 라우트
@@ -64,6 +65,7 @@ export async function buildApp(opts = {}) {
await fastify.register(meilisearchPlugin);
await fastify.register(youtubeBotPlugin);
await fastify.register(xBotPlugin);
+ await fastify.register(festivalBotPlugin);
await fastify.register(schedulerPlugin);
// 공유 스키마 등록 (라우트에서 $ref로 참조 가능)
diff --git a/backend/src/config/index.js b/backend/src/config/index.js
index 5955d13..f7f04e5 100644
--- a/backend/src/config/index.js
+++ b/backend/src/config/index.js
@@ -52,6 +52,9 @@ export default {
google: {
apiKey: process.env.GOOGLE_API_KEY,
},
+ gemini: {
+ apiKey: process.env.GEMINI_API_KEY,
+ },
jwt: {
secret: process.env.JWT_SECRET,
expiresIn: '30d',
diff --git a/backend/src/plugins/scheduler.js b/backend/src/plugins/scheduler.js
index e2ec83d..c759c3a 100644
--- a/backend/src/plugins/scheduler.js
+++ b/backend/src/plugins/scheduler.js
@@ -97,6 +97,39 @@ async function schedulerPlugin(fastify, opts) {
}));
}
+ /**
+ * 동기화 간격(분)을 cron 표현식으로 변환
+ * - 60분 미만: 분 단위 (*\/N * * * *)
+ * - 60분 이상: 시간 단위 (0 *\/H * * *)
+ */
+ function intervalToCron(minutes) {
+ if (!minutes || minutes < 60) {
+ return `*/${minutes || 2} * * * *`;
+ }
+ const hours = Math.floor(minutes / 60);
+ if (hours >= 24) {
+ return '0 0 * * *';
+ }
+ return `0 */${hours} * * *`;
+ }
+
+ /**
+ * DB에서 축제 봇 목록 조회
+ */
+ async function getFestivalBotsFromDB() {
+ const [rows] = await fastify.db.query('SELECT * FROM bot_festival');
+ return rows.map(row => ({
+ id: `festival-${row.id}`,
+ dbId: row.id,
+ type: 'festival',
+ name: row.name,
+ searchUrl: row.search_url,
+ cron: intervalToCron(row.cron_interval),
+ cronInterval: row.cron_interval,
+ enabled: row.enabled === 1,
+ }));
+ }
+
/**
* 모든 봇 목록 가져오기 (정적 + DB)
*/
@@ -106,7 +139,8 @@ async function schedulerPlugin(fastify, opts) {
}
const youtubeBots = await getYouTubeBotsFromDB();
const xBots = await getXBotsFromDB();
- cachedBots = [...staticBots, ...youtubeBots, ...xBots];
+ const festivalBots = await getFestivalBotsFromDB();
+ cachedBots = [...staticBots, ...youtubeBots, ...xBots, ...festivalBots];
return cachedBots;
}
@@ -155,6 +189,8 @@ async function schedulerPlugin(fastify, opts) {
return fastify.youtubeBot.syncNewVideos;
} else if (bot.type === 'x') {
return fastify.xBot.syncNewTweets;
+ } else if (bot.type === 'festival') {
+ return fastify.festivalBot.syncNewFestivals;
} else if (bot.type === 'meilisearch') {
return async () => {
const count = await syncAllSchedules(fastify.meilisearch, fastify.db);
@@ -190,9 +226,10 @@ async function schedulerPlugin(fastify, opts) {
* DB의 enabled 필드 업데이트 (정적 봇은 무시)
*/
async function setEnabled(botId, enabled) {
- const match = botId.match(/^(youtube|x)-(\d+)$/);
+ const match = botId.match(/^(youtube|x|festival)-(\d+)$/);
if (!match) return; // 정적 봇 (meilisearch 등)
- const table = match[1] === 'x' ? 'bot_x' : 'bot_youtube';
+ const tableMap = { x: 'bot_x', youtube: 'bot_youtube', festival: 'bot_festival' };
+ const table = tableMap[match[1]];
const dbId = match[2];
await fastify.db.query(`UPDATE ${table} SET enabled = ? WHERE id = ?`, [enabled ? 1 : 0, dbId]);
invalidateCache();
@@ -415,5 +452,5 @@ async function schedulerPlugin(fastify, opts) {
export default fp(schedulerPlugin, {
name: 'scheduler',
- dependencies: ['db', 'redis', 'meilisearch', 'youtubeBot', 'xBot'],
+ dependencies: ['db', 'redis', 'meilisearch', 'youtubeBot', 'xBot', 'festivalBot'],
});
diff --git a/backend/src/routes/admin/bots.js b/backend/src/routes/admin/bots.js
index 8269aac..2e6bf6e 100644
--- a/backend/src/routes/admin/bots.js
+++ b/backend/src/routes/admin/bots.js
@@ -10,7 +10,7 @@ const botResponse = {
properties: {
id: { type: 'string' },
name: { type: 'string' },
- type: { type: 'string', enum: ['youtube', 'x', 'meilisearch'] },
+ type: { type: 'string', enum: ['youtube', 'x', 'meilisearch', 'festival'] },
status: { type: 'string', enum: ['running', 'stopped', 'error'] },
last_check_at: { type: 'string', format: 'date-time' },
last_added_count: { type: 'integer' },
@@ -35,6 +35,8 @@ const botResponse = {
display_name: { type: 'string' },
avatar_url: { type: 'string' },
text_filters: { type: 'array', items: { type: 'string' } },
+ // 축제 봇 전용 필드
+ search_url: { type: 'string' },
},
};
@@ -82,12 +84,21 @@ export default async function botsRoutes(fastify) {
// cron 표현식에서 간격 추출 (분 단위, 일일 스케줄은 1440분)
let checkInterval = 2; // 기본값
- const cronMatch = bot.cron.match(/^\*\/(\d+)/);
- if (cronMatch) {
- checkInterval = parseInt(cronMatch[1]);
- } else if (/^0 \d+ \* \* \*$/.test(bot.cron)) {
- // 매일 특정 시간 (예: 0 12 * * *)
- checkInterval = 1440; // 24시간 = 1440분
+ if (bot.cronInterval) {
+ // 축제 봇 등 분 단위 간격을 직접 가진 봇
+ checkInterval = bot.cronInterval;
+ } else {
+ const cronMatch = bot.cron.match(/^\*\/(\d+)/);
+ const hourMatch = bot.cron.match(/^0 \*\/(\d+) \* \* \*$/);
+ if (cronMatch) {
+ checkInterval = parseInt(cronMatch[1]);
+ } else if (hourMatch) {
+ // 시간 단위 (예: 0 *\/6 * * *)
+ checkInterval = parseInt(hourMatch[1]) * 60;
+ } else if (/^0 \d+ \* \* \*$/.test(bot.cron)) {
+ // 매일 특정 시간 (예: 0 12 * * *)
+ checkInterval = 1440;
+ }
}
const botData = {
@@ -128,6 +139,13 @@ export default async function botsRoutes(fastify) {
botData.cron_interval = checkInterval;
}
+ // 축제 봇인 경우 상세 정보 추가
+ if (bot.type === 'festival') {
+ botData.db_id = bot.dbId;
+ botData.search_url = bot.searchUrl;
+ botData.cron_interval = checkInterval;
+ }
+
result.push(botData);
}
@@ -246,6 +264,8 @@ export default async function botsRoutes(fastify) {
result = await fastify.youtubeBot.syncAllVideos(bot);
} else if (bot.type === 'x') {
result = await fastify.xBot.syncAllTweets(bot);
+ } else if (bot.type === 'festival') {
+ result = await fastify.festivalBot.syncNewFestivals(bot);
} else if (bot.type === 'meilisearch') {
const count = await syncAllSchedules(fastify.meilisearch, fastify.db);
result = { addedCount: count, total: count };
diff --git a/backend/src/routes/admin/events.js b/backend/src/routes/admin/events.js
index 1061c24..4aa741e 100644
--- a/backend/src/routes/admin/events.js
+++ b/backend/src/routes/admin/events.js
@@ -1,41 +1,13 @@
import { CATEGORY_IDS } from '../../config/index.js';
import { withTransaction } from '../../utils/transaction.js';
import { uploadEventPoster } from '../../services/image.js';
+import { upsertVenue } from '../../services/event.js';
import { logActivity } from '../../utils/log.js';
import { syncScheduleById } from '../../services/meilisearch/index.js';
const EVENT_CATEGORY_ID = CATEGORY_IDS.EVENT;
const VALID_SUBTYPES = ['university'];
-/**
- * 장소를 upsert (kakao_id 기준) 후 venue_id 반환
- */
-async function upsertVenue(db, venue) {
- if (!venue) return null;
- if (venue.id) return venue.id;
- if (!venue.name) return null;
-
- // kakao_id가 있으면 먼저 조회
- if (venue.kakao_id) {
- const [rows] = await db.query('SELECT id FROM event_venues WHERE kakao_id = ?', [venue.kakao_id]);
- if (rows.length > 0) return rows[0].id;
- }
-
- const [result] = await db.query(
- `INSERT INTO event_venues (name, address, road_address, lat, lng, kakao_id)
- VALUES (?, ?, ?, ?, ?, ?)`,
- [
- venue.name,
- venue.address || null,
- venue.road_address || venue.roadAddress || null,
- venue.lat ?? null,
- venue.lng ?? null,
- venue.kakao_id || venue.kakaoId || null,
- ]
- );
- return result.insertId;
-}
-
/**
* multipart에서 payload(JSON 문자열) + poster 파일들 추출
*/
diff --git a/backend/src/routes/admin/festival-bots.js b/backend/src/routes/admin/festival-bots.js
new file mode 100644
index 0000000..671a4ca
--- /dev/null
+++ b/backend/src/routes/admin/festival-bots.js
@@ -0,0 +1,259 @@
+import { errorResponse } from '../../schemas/index.js';
+import { badRequest, notFound } from '../../utils/error.js';
+import { logActivity } from '../../utils/log.js';
+
+/**
+ * 축제 봇 응답 스키마
+ */
+const festivalBotResponse = {
+ type: 'object',
+ properties: {
+ id: { type: 'integer' },
+ name: { type: 'string' },
+ search_url: { type: 'string' },
+ cron_interval: { type: 'integer' },
+ enabled: { type: 'boolean' },
+ },
+};
+
+const festivalBotIdParam = {
+ type: 'object',
+ properties: {
+ id: { type: 'integer', description: '축제 봇 DB ID' },
+ },
+ required: ['id'],
+};
+
+/**
+ * DB row → API 응답 형식
+ */
+function formatBotResponse(row) {
+ return {
+ id: row.id,
+ name: row.name,
+ search_url: row.search_url,
+ cron_interval: row.cron_interval,
+ enabled: row.enabled === 1,
+ };
+}
+
+/**
+ * 축제 봇 관리 라우트
+ */
+export default async function festivalBotsRoutes(fastify) {
+ const { db, scheduler } = fastify;
+
+ /**
+ * GET /api/admin/festival-bots
+ * 축제 봇 목록 조회
+ */
+ fastify.get('/', {
+ schema: {
+ tags: ['admin/festival-bots'],
+ summary: '축제 봇 목록 조회',
+ security: [{ bearerAuth: [] }],
+ response: { 200: { type: 'array', items: festivalBotResponse } },
+ },
+ preHandler: [fastify.authenticate],
+ }, async () => {
+ const [rows] = await db.query('SELECT * FROM bot_festival ORDER BY id');
+ return rows.map(formatBotResponse);
+ });
+
+ /**
+ * GET /api/admin/festival-bots/:id
+ * 축제 봇 상세 조회
+ */
+ fastify.get('/:id', {
+ schema: {
+ tags: ['admin/festival-bots'],
+ summary: '축제 봇 상세 조회',
+ security: [{ bearerAuth: [] }],
+ params: festivalBotIdParam,
+ response: { 200: festivalBotResponse, 404: errorResponse },
+ },
+ preHandler: [fastify.authenticate],
+ }, async (request, reply) => {
+ const { id } = request.params;
+ const [rows] = await db.query('SELECT * FROM bot_festival WHERE id = ?', [id]);
+ if (rows.length === 0) {
+ return notFound(reply, '축제 봇을 찾을 수 없습니다.');
+ }
+ return formatBotResponse(rows[0]);
+ });
+
+ /**
+ * POST /api/admin/festival-bots
+ * 축제 봇 추가
+ */
+ fastify.post('/', {
+ schema: {
+ tags: ['admin/festival-bots'],
+ summary: '축제 봇 추가',
+ security: [{ bearerAuth: [] }],
+ body: {
+ type: 'object',
+ properties: {
+ name: { type: 'string' },
+ search_url: { type: 'string' },
+ cron_interval: { type: 'integer', default: 360 },
+ },
+ required: ['name', 'search_url'],
+ },
+ response: { 201: festivalBotResponse, 400: errorResponse },
+ },
+ preHandler: [fastify.authenticate],
+ }, async (request, reply) => {
+ const { name, search_url, cron_interval = 360 } = request.body;
+
+ if (!name?.trim() || !search_url?.trim()) {
+ return badRequest(reply, '이름과 크롤링 URL은 필수입니다.');
+ }
+
+ const [result] = await db.query(
+ `INSERT INTO bot_festival (name, search_url, cron_interval, enabled)
+ VALUES (?, ?, ?, 1)`,
+ [name.trim(), search_url.trim(), cron_interval]
+ );
+
+ scheduler.invalidateCache();
+ const botId = `festival-${result.insertId}`;
+
+ // 봇 시작 (스케줄러 등록)
+ try {
+ await scheduler.startBot(botId);
+ } catch (err) {
+ fastify.log.error(`[${botId}] 봇 시작 실패: ${err.message}`);
+ }
+
+ const [newBot] = await db.query('SELECT * FROM bot_festival WHERE id = ?', [result.insertId]);
+ logActivity(db, {
+ actor: 'admin', action: 'create', category: 'bot',
+ targetType: 'festival_bot', targetId: result.insertId,
+ summary: `축제 봇 생성: ${name.trim()}`,
+ });
+ reply.code(201);
+ return formatBotResponse(newBot[0]);
+ });
+
+ /**
+ * PUT /api/admin/festival-bots/:id
+ * 축제 봇 수정
+ */
+ fastify.put('/:id', {
+ schema: {
+ tags: ['admin/festival-bots'],
+ summary: '축제 봇 수정',
+ security: [{ bearerAuth: [] }],
+ params: festivalBotIdParam,
+ body: {
+ type: 'object',
+ properties: {
+ name: { type: 'string' },
+ search_url: { type: 'string' },
+ cron_interval: { type: 'integer' },
+ enabled: { type: 'boolean' },
+ },
+ },
+ response: { 200: festivalBotResponse, 404: errorResponse },
+ },
+ preHandler: [fastify.authenticate],
+ }, async (request, reply) => {
+ const { id } = request.params;
+ const updates = request.body;
+
+ const [existing] = await db.query('SELECT * FROM bot_festival WHERE id = ?', [id]);
+ if (existing.length === 0) {
+ return notFound(reply, '축제 봇을 찾을 수 없습니다.');
+ }
+
+ const fields = [];
+ const values = [];
+ if (updates.name !== undefined) {
+ fields.push('name = ?');
+ values.push(updates.name);
+ }
+ if (updates.search_url !== undefined) {
+ fields.push('search_url = ?');
+ values.push(updates.search_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_festival SET ${fields.join(', ')} WHERE id = ?`, values);
+
+ // 스케줄러 캐시 무효화 및 봇 재시작
+ scheduler.invalidateCache();
+ const botId = `festival-${id}`;
+ const shouldBeEnabled = updates.enabled !== undefined
+ ? updates.enabled
+ : existing[0].enabled === 1;
+ try {
+ await scheduler.stopBot(botId);
+ if (shouldBeEnabled) {
+ await scheduler.startBot(botId);
+ }
+ } catch (err) {
+ fastify.log.error(`[${botId}] 봇 재시작 실패: ${err.message}`);
+ }
+ }
+
+ const [updated] = await db.query('SELECT * FROM bot_festival WHERE id = ?', [id]);
+ logActivity(db, {
+ actor: 'admin', action: 'update', category: 'bot',
+ targetType: 'festival_bot', targetId: parseInt(id),
+ summary: `축제 봇 수정: ${existing[0].name}`,
+ });
+ return formatBotResponse(updated[0]);
+ });
+
+ /**
+ * DELETE /api/admin/festival-bots/:id
+ * 축제 봇 삭제
+ */
+ fastify.delete('/:id', {
+ schema: {
+ tags: ['admin/festival-bots'],
+ summary: '축제 봇 삭제',
+ security: [{ bearerAuth: [] }],
+ params: festivalBotIdParam,
+ 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_festival WHERE id = ?', [id]);
+ if (existing.length === 0) {
+ return notFound(reply, '축제 봇을 찾을 수 없습니다.');
+ }
+
+ const botId = `festival-${id}`;
+ try {
+ await scheduler.stopBot(botId);
+ } catch (err) {
+ // 이미 정지된 경우 무시
+ }
+
+ await db.query('DELETE FROM bot_festival WHERE id = ?', [id]);
+ scheduler.invalidateCache();
+
+ logActivity(db, {
+ actor: 'admin', action: 'delete', category: 'bot',
+ targetType: 'festival_bot', targetId: parseInt(id),
+ summary: `축제 봇 삭제: ${existing[0].name}`,
+ });
+ return { success: true };
+ });
+}
diff --git a/backend/src/routes/index.js b/backend/src/routes/index.js
index c9a84ba..6a90bb8 100644
--- a/backend/src/routes/index.js
+++ b/backend/src/routes/index.js
@@ -6,6 +6,7 @@ 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 festivalBotsRoutes from './admin/festival-bots.js';
import youtubeAdminRoutes from './admin/youtube.js';
import xAdminRoutes from './admin/x.js';
import concertAdminRoutes from './admin/concert.js';
@@ -43,6 +44,9 @@ export default async function routes(fastify) {
// 관리자 - X 봇 라우트
fastify.register(xBotsRoutes, { prefix: '/admin/x-bots' });
+ // 관리자 - 축제 봇 라우트
+ fastify.register(festivalBotsRoutes, { prefix: '/admin/festival-bots' });
+
// 관리자 - YouTube 라우트
fastify.register(youtubeAdminRoutes, { prefix: '/admin/youtube' });
diff --git a/backend/src/services/event.js b/backend/src/services/event.js
new file mode 100644
index 0000000..33a3485
--- /dev/null
+++ b/backend/src/services/event.js
@@ -0,0 +1,134 @@
+/**
+ * 행사(이벤트) 공통 서비스
+ * - 관리자 라우트(events.js)와 축제 크롤러 봇(festival)이 공유
+ */
+import { CATEGORY_IDS } from '../config/index.js';
+import { withTransaction } from '../utils/transaction.js';
+import { syncScheduleById } from './meilisearch/index.js';
+
+const EVENT_CATEGORY_ID = CATEGORY_IDS.EVENT;
+const KAKAO_REST_KEY = process.env.KAKAO_REST_KEY;
+
+/**
+ * 장소를 upsert (kakao_id 기준) 후 venue_id 반환
+ * @param {object} conn - DB 연결 (트랜잭션 conn 또는 pool)
+ * @param {object} venue - 장소 정보
+ */
+export async function upsertVenue(conn, venue) {
+ if (!venue) return null;
+ if (venue.id) return venue.id;
+ if (!venue.name) return null;
+
+ const kakaoId = venue.kakao_id || venue.kakaoId || null;
+
+ // kakao_id가 있으면 먼저 조회
+ if (kakaoId) {
+ const [rows] = await conn.query('SELECT id FROM event_venues WHERE kakao_id = ?', [kakaoId]);
+ if (rows.length > 0) return rows[0].id;
+ }
+
+ const [result] = await conn.query(
+ `INSERT INTO event_venues (name, address, road_address, lat, lng, kakao_id)
+ VALUES (?, ?, ?, ?, ?, ?)`,
+ [
+ venue.name,
+ venue.address || null,
+ venue.road_address || venue.roadAddress || null,
+ venue.lat ?? null,
+ venue.lng ?? null,
+ kakaoId,
+ ]
+ );
+ return result.insertId;
+}
+
+/**
+ * 행사 일정 생성 (schedules + schedule_event + schedule_members)
+ * 포스터는 호출 측에서 별도 처리 (S3 업로드 후 poster_image_ids UPDATE)
+ *
+ * @param {object} db - DB pool
+ * @param {object} meilisearch - Meilisearch 클라이언트
+ * @param {object} data - { title, date, time, subtype, schoolName, memberIds, venue, postUrls }
+ * @returns {Promise