From 1f1d6987d16da2da6ef0f2016d485befe59623d5 Mon Sep 17 00:00:00 2001 From: caadiq Date: Mon, 2 Mar 2026 17:04:07 +0900 Subject: [PATCH] =?UTF-8?q?feat(backend):=20=EA=B4=80=EB=A6=AC=EC=9E=90/?= =?UTF-8?q?=EB=B4=87=20=EB=9D=BC=EC=9A=B0=ED=8A=B8=EC=97=90=20logActivity?= =?UTF-8?q?=20=ED=98=B8=EC=B6=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 12개 관리자 라우트와 3개 봇 서비스 파일에 활동 로그 기록 추가. 관리자 작업(일정/앨범/멤버/봇 CRUD)과 봇 동기화(완료/에러)를 logs 테이블에 fire-and-forget으로 기록. Co-Authored-By: Claude Opus 4.6 --- backend/src/plugins/scheduler.js | 33 +++++++++++++++++++++ backend/src/routes/admin/bots.js | 6 +++- backend/src/routes/admin/concert.js | 2 ++ backend/src/routes/admin/x-bots.js | 4 +++ backend/src/routes/admin/x.js | 2 ++ backend/src/routes/admin/youtube-bots.js | 4 +++ backend/src/routes/admin/youtube.js | 3 ++ backend/src/routes/albums/index.js | 4 +++ backend/src/routes/albums/photos.js | 4 +++ backend/src/routes/albums/teasers.js | 2 ++ backend/src/routes/members/index.js | 2 ++ backend/src/routes/schedules/index.js | 2 ++ backend/src/routes/schedules/suggestions.js | 2 ++ backend/src/services/x/index.js | 10 +++++++ backend/src/services/youtube/index.js | 9 ++++++ 15 files changed, 88 insertions(+), 1 deletion(-) diff --git a/backend/src/plugins/scheduler.js b/backend/src/plugins/scheduler.js index 6b07cb9..3cceb90 100644 --- a/backend/src/plugins/scheduler.js +++ b/backend/src/plugins/scheduler.js @@ -3,6 +3,7 @@ import cron from 'node-cron'; import staticBots from '../config/bots.js'; import { syncAllSchedules } from '../services/meilisearch/index.js'; import { nowKST } from '../utils/date.js'; +import { logActivity } from '../utils/log.js'; const REDIS_PREFIX = 'bot:status:'; const TIMEZONE = 'Asia/Seoul'; @@ -203,6 +204,15 @@ async function schedulerPlugin(fastify, opts) { const result = await syncFn(bot); const addedCount = await handleSyncResult(botId, result, { setRunningStatus: true }); fastify.log.info(`[${botId}] 동기화 완료: ${addedCount}개 추가`); + if (addedCount > 0) { + logActivity(fastify.db, { + actor: botId, + action: 'sync_complete', + category: 'sync', + summary: `${botId} 동기화 완료: ${addedCount}개 추가`, + details: { addedCount }, + }); + } } catch (err) { await updateStatus(botId, { status: 'error', @@ -210,6 +220,13 @@ async function schedulerPlugin(fastify, opts) { errorMessage: err.message, }); fastify.log.error(`[${botId}] 동기화 오류: ${err.message}`); + logActivity(fastify.db, { + actor: botId, + action: 'error', + category: 'sync', + summary: `${botId} 동기화 오류: ${err.message}`, + details: { error: err.message }, + }); } }, { timezone: TIMEZONE }); @@ -223,8 +240,24 @@ async function schedulerPlugin(fastify, opts) { const result = await syncFn(bot); const addedCount = await handleSyncResult(botId, result); fastify.log.info(`[${botId}] 초기 동기화 완료: ${addedCount}개 추가`); + if (addedCount > 0) { + logActivity(fastify.db, { + actor: botId, + action: 'sync_complete', + category: 'sync', + summary: `${botId} 초기 동기화 완료: ${addedCount}개 추가`, + details: { addedCount }, + }); + } } catch (err) { fastify.log.error(`[${botId}] 초기 동기화 오류: ${err.message}`); + logActivity(fastify.db, { + actor: botId, + action: 'error', + category: 'sync', + summary: `${botId} 초기 동기화 오류: ${err.message}`, + details: { error: err.message }, + }); } } } diff --git a/backend/src/routes/admin/bots.js b/backend/src/routes/admin/bots.js index e9929a4..8269aac 100644 --- a/backend/src/routes/admin/bots.js +++ b/backend/src/routes/admin/bots.js @@ -2,6 +2,7 @@ import { errorResponse } from '../../schemas/index.js'; import { syncAllSchedules } from '../../services/meilisearch/index.js'; import { badRequest, notFound, serverError } from '../../utils/error.js'; import { nowKST } from '../../utils/date.js'; +import { logActivity } from '../../utils/log.js'; // 봇 관련 스키마 const botResponse = { @@ -50,7 +51,7 @@ const botIdParam = { * 인증 필요 */ export default async function botsRoutes(fastify) { - const { scheduler, redis } = fastify; + const { scheduler, redis, db } = fastify; const QUOTA_WARNING_KEY = 'youtube:quota_warning'; /** @@ -161,6 +162,7 @@ export default async function botsRoutes(fastify) { try { await scheduler.startBot(id); + logActivity(db, { actor: 'admin', action: 'start', category: 'bot', targetType: null, targetId: null, summary: `봇 시작: ${id}` }); return { success: true, message: '봇이 시작되었습니다.' }; } catch (err) { return badRequest(reply, err.message); @@ -195,6 +197,7 @@ export default async function botsRoutes(fastify) { try { await scheduler.stopBot(id); + logActivity(db, { actor: 'admin', action: 'stop', category: 'bot', targetType: null, targetId: null, summary: `봇 정지: ${id}` }); return { success: true, message: '봇이 정지되었습니다.' }; } catch (err) { return badRequest(reply, err.message); @@ -263,6 +266,7 @@ export default async function botsRoutes(fastify) { updatedAt: nowKST(), })); + logActivity(db, { actor: 'admin', action: 'sync_complete', category: 'sync', targetType: null, targetId: null, summary: `전체 동기화: ${id} (${result.addedCount}개 추가)` }); return { success: true, addedCount: result.addedCount, diff --git a/backend/src/routes/admin/concert.js b/backend/src/routes/admin/concert.js index b45e0e2..afcc22e 100644 --- a/backend/src/routes/admin/concert.js +++ b/backend/src/routes/admin/concert.js @@ -3,6 +3,7 @@ import { uploadConcertPoster, uploadConcertMerchandise } from '../../services/im import { CATEGORY_IDS } from '../../config/index.js'; import { withTransaction } from '../../utils/transaction.js'; import { badRequest, serverError } from '../../utils/error.js'; +import { logActivity } from '../../utils/log.js'; const CONCERT_CATEGORY_ID = CATEGORY_IDS.CONCERT; @@ -201,6 +202,7 @@ export default async function concertRoutes(fastify) { } } + logActivity(db, { actor: 'admin', action: 'create', category: 'concert', targetType: 'concert', targetId: result.seriesId, summary: `콘서트 일정 생성: ${title}` }); return { success: true, seriesId: result.seriesId }; } catch (err) { fastify.log.error(`콘서트 일정 저장 오류: ${err.message}`); diff --git a/backend/src/routes/admin/x-bots.js b/backend/src/routes/admin/x-bots.js index 78bae28..d1dbbfa 100644 --- a/backend/src/routes/admin/x-bots.js +++ b/backend/src/routes/admin/x-bots.js @@ -1,6 +1,7 @@ import { errorResponse } from '../../schemas/index.js'; import { badRequest, notFound, serverError } from '../../utils/error.js'; import { fetchProfile } from '../../services/x/scraper.js'; +import { logActivity } from '../../utils/log.js'; /** * X 봇 스키마 @@ -241,6 +242,7 @@ export default async function xBotsRoutes(fastify) { const [newBot] = await db.query('SELECT * FROM bot_x WHERE id = ?', [result.insertId]); reply.code(201); + logActivity(db, { actor: 'admin', action: 'create', category: 'bot', targetType: 'x_bot', targetId: result.insertId, summary: `X 봇 생성: ${username}` }); return formatBotResponse(newBot[0]); }); @@ -341,6 +343,7 @@ export default async function xBotsRoutes(fastify) { } const [updatedBot] = await db.query('SELECT * FROM bot_x WHERE id = ?', [id]); + logActivity(db, { actor: 'admin', action: 'update', category: 'bot', targetType: 'x_bot', targetId: parseInt(id), summary: `X 봇 수정: ${existing[0].username}` }); return formatBotResponse(updatedBot[0]); }); @@ -388,6 +391,7 @@ export default async function xBotsRoutes(fastify) { // 스케줄러 캐시 무효화 scheduler.invalidateCache(); + logActivity(db, { actor: 'admin', action: 'delete', category: 'bot', targetType: 'x_bot', targetId: parseInt(id), summary: `X 봇 삭제: ${existing[0].username}` }); return { success: true }; }); } diff --git a/backend/src/routes/admin/x.js b/backend/src/routes/admin/x.js index 5ed88b7..6ec622c 100644 --- a/backend/src/routes/admin/x.js +++ b/backend/src/routes/admin/x.js @@ -8,6 +8,7 @@ import { xScheduleCreate, } from '../../schemas/index.js'; import { badRequest, conflict, serverError } from '../../utils/error.js'; +import { logActivity } from '../../utils/log.js'; const X_CATEGORY_ID = CATEGORY_IDS.X; const NITTER_URL = config.nitter?.url || process.env.NITTER_URL || 'http://nitter:8080'; @@ -153,6 +154,7 @@ export default async function xRoutes(fastify) { source_name: '', }); + logActivity(db, { actor: 'admin', action: 'create', category: 'schedule', targetType: 'x_schedule', targetId: scheduleId, summary: `X 일정 생성: ${title}` }); return { success: true, scheduleId }; } catch (err) { fastify.log.error(`X 일정 저장 오류: ${err.message}`); diff --git a/backend/src/routes/admin/youtube-bots.js b/backend/src/routes/admin/youtube-bots.js index 6b1c026..08c4e5b 100644 --- a/backend/src/routes/admin/youtube-bots.js +++ b/backend/src/routes/admin/youtube-bots.js @@ -1,6 +1,7 @@ import { errorResponse } from '../../schemas/index.js'; import { badRequest, notFound, serverError } from '../../utils/error.js'; import { getChannelByHandle } from '../../services/youtube/api.js'; +import { logActivity } from '../../utils/log.js'; /** * YouTube 봇 스키마 @@ -240,6 +241,7 @@ export default async function youtubeBotsRoutes(fastify) { const [newBot] = await db.query('SELECT * FROM bot_youtube WHERE id = ?', [result.insertId]); reply.code(201); + logActivity(db, { actor: 'admin', action: 'create', category: 'bot', targetType: 'youtube_bot', targetId: result.insertId, summary: `YouTube 봇 생성: ${channel_name}` }); return formatBotResponse(newBot[0]); }); @@ -348,6 +350,7 @@ export default async function youtubeBotsRoutes(fastify) { } const [updatedBot] = await db.query('SELECT * FROM bot_youtube WHERE id = ?', [id]); + logActivity(db, { actor: 'admin', action: 'update', category: 'bot', targetType: 'youtube_bot', targetId: parseInt(id), summary: `YouTube 봇 수정: ${existing[0].channel_name}` }); return formatBotResponse(updatedBot[0]); }); @@ -395,6 +398,7 @@ export default async function youtubeBotsRoutes(fastify) { // 스케줄러 캐시 무효화 scheduler.invalidateCache(); + logActivity(db, { actor: 'admin', action: 'delete', category: 'bot', targetType: 'youtube_bot', targetId: parseInt(id), summary: `YouTube 봇 삭제: ${existing[0].channel_name}` }); return { success: true }; }); } diff --git a/backend/src/routes/admin/youtube.js b/backend/src/routes/admin/youtube.js index 4db83ff..c4b4285 100644 --- a/backend/src/routes/admin/youtube.js +++ b/backend/src/routes/admin/youtube.js @@ -9,6 +9,7 @@ import { idParam, } from '../../schemas/index.js'; import { badRequest, notFound, conflict, serverError } from '../../utils/error.js'; +import { logActivity } from '../../utils/log.js'; const YOUTUBE_CATEGORY_ID = CATEGORY_IDS.YOUTUBE; @@ -149,6 +150,7 @@ export default async function youtubeRoutes(fastify) { source_name: channelName || '', }); + logActivity(db, { actor: 'admin', action: 'create', category: 'schedule', targetType: 'youtube_schedule', targetId: scheduleId, summary: `YouTube 일정 생성: ${title}` }); return { success: true, scheduleId }; } catch (err) { fastify.log.error(`YouTube 일정 저장 오류: ${err.message}`); @@ -252,6 +254,7 @@ export default async function youtubeRoutes(fastify) { source_name: channelName, }); + logActivity(db, { actor: 'admin', action: 'update', category: 'schedule', targetType: 'youtube_schedule', targetId: parseInt(id), summary: `YouTube 일정 수정: ${schedules[0].title}` }); return { success: true }; } catch (err) { fastify.log.error(`YouTube 일정 수정 오류: ${err.message}`); diff --git a/backend/src/routes/albums/index.js b/backend/src/routes/albums/index.js index 1fb6b49..e8d30e1 100644 --- a/backend/src/routes/albums/index.js +++ b/backend/src/routes/albums/index.js @@ -12,6 +12,7 @@ import photosRoutes from './photos.js'; import teasersRoutes from './teasers.js'; import { errorResponse, successResponse, idParam } from '../../schemas/index.js'; import { notFound, badRequest } from '../../utils/error.js'; +import { logActivity } from '../../utils/log.js'; /** * 앨범 라우트 @@ -203,6 +204,7 @@ export default async function albumsRoutes(fastify) { const result = await createAlbum(db, data, coverBuffer); await invalidateAlbumCache(redis); + logActivity(db, { actor: 'admin', action: 'create', category: 'album', targetType: 'album', targetId: result.albumId, summary: `앨범 생성: ${title}` }); return result; }); @@ -251,6 +253,7 @@ export default async function albumsRoutes(fastify) { return notFound(reply, '앨범을 찾을 수 없습니다.'); } await invalidateAlbumCache(redis, id); + logActivity(db, { actor: 'admin', action: 'update', category: 'album', targetType: 'album', targetId: parseInt(id), summary: `앨범 수정: ${data.title || id}` }); return result; }); @@ -277,6 +280,7 @@ export default async function albumsRoutes(fastify) { return notFound(reply, '앨범을 찾을 수 없습니다.'); } await invalidateAlbumCache(redis, id); + logActivity(db, { actor: 'admin', action: 'delete', category: 'album', targetType: 'album', targetId: parseInt(id), summary: `앨범 삭제: ${id}` }); return result; }); } diff --git a/backend/src/routes/albums/photos.js b/backend/src/routes/albums/photos.js index 3f6464a..2da6b74 100644 --- a/backend/src/routes/albums/photos.js +++ b/backend/src/routes/albums/photos.js @@ -5,6 +5,7 @@ import { } from '../../services/image.js'; import { withTransaction } from '../../utils/transaction.js'; import { notFound } from '../../utils/error.js'; +import { logActivity } from '../../utils/log.js'; /** * 앨범 사진 라우트 @@ -195,6 +196,8 @@ export default async function photosRoutes(fastify) { await connection.commit(); + logActivity(db, { actor: 'admin', action: 'upload', category: 'album', targetType: 'photo', targetId: parseInt(albumId), summary: `사진 업로드: ${uploadedPhotos.length}장 (앨범 ${albumId})` }); + reply.raw.write(`data: ${JSON.stringify({ done: true, message: `${uploadedPhotos.length}개의 사진이 업로드되었습니다.`, @@ -245,6 +248,7 @@ export default async function photosRoutes(fastify) { await connection.query('DELETE FROM album_photo_members WHERE photo_id = ?', [photoId]); await connection.query('DELETE FROM album_photos WHERE id = ?', [photoId]); + logActivity(db, { actor: 'admin', action: 'delete', category: 'album', targetType: 'photo', targetId: parseInt(photoId), summary: `사진 삭제: 앨범 ${albumId}` }); return { message: '사진이 삭제되었습니다.' }; }); }); diff --git a/backend/src/routes/albums/teasers.js b/backend/src/routes/albums/teasers.js index 28a8e89..e7f4f34 100644 --- a/backend/src/routes/albums/teasers.js +++ b/backend/src/routes/albums/teasers.js @@ -4,6 +4,7 @@ import { } from '../../services/image.js'; import { withTransaction } from '../../utils/transaction.js'; import { notFound } from '../../utils/error.js'; +import { logActivity } from '../../utils/log.js'; /** * 앨범 티저 라우트 @@ -78,6 +79,7 @@ export default async function teasersRoutes(fastify) { await connection.query('DELETE FROM album_teasers WHERE id = ?', [teaserId]); + logActivity(db, { actor: 'admin', action: 'delete', category: 'album', targetType: 'teaser', targetId: parseInt(teaserId), summary: `티저 삭제: 앨범 ${albumId}` }); return { message: '티저가 삭제되었습니다.' }; }); }); diff --git a/backend/src/routes/members/index.js b/backend/src/routes/members/index.js index 7dfec00..3b9f9c4 100644 --- a/backend/src/routes/members/index.js +++ b/backend/src/routes/members/index.js @@ -1,6 +1,7 @@ import { uploadMemberImage } from '../../services/image.js'; import { getAllMembers, getMemberByName, getMemberBasicByName, invalidateMemberCache } from '../../services/member.js'; import { notFound, serverError } from '../../utils/error.js'; +import { logActivity } from '../../utils/log.js'; /** * 멤버 라우트 @@ -159,6 +160,7 @@ export default async function membersRoutes(fastify, opts) { // 멤버 캐시 무효화 await invalidateMemberCache(redis); + logActivity(db, { actor: 'admin', action: 'update', category: 'member', targetType: 'member', targetId: memberId, summary: `멤버 수정: ${fields.name || decodedName}` }); return { message: '멤버 정보가 수정되었습니다', id: memberId }; } catch (err) { fastify.log.error(err); diff --git a/backend/src/routes/schedules/index.js b/backend/src/routes/schedules/index.js index 2044605..0557541 100644 --- a/backend/src/routes/schedules/index.js +++ b/backend/src/routes/schedules/index.js @@ -19,6 +19,7 @@ import { } from '../../schemas/index.js'; import { badRequest, notFound, serverError } from '../../utils/error.js'; import { withTransaction } from '../../utils/transaction.js'; +import { logActivity } from '../../utils/log.js'; export default async function schedulesRoutes(fastify) { const { db, meilisearch, redis } = fastify; @@ -219,6 +220,7 @@ export default async function schedulesRoutes(fastify) { // Meilisearch에서도 삭제 (트랜잭션 외부, 실패해도 무시) await deleteSchedule(meilisearch, id); + logActivity(db, { actor: 'admin', action: 'delete', category: 'schedule', targetType: null, targetId: parseInt(id), summary: `일정 삭제: ${id}` }); return { success: true }; } catch (err) { fastify.log.error(err); diff --git a/backend/src/routes/schedules/suggestions.js b/backend/src/routes/schedules/suggestions.js index 0a4f2a0..b4afbbe 100644 --- a/backend/src/routes/schedules/suggestions.js +++ b/backend/src/routes/schedules/suggestions.js @@ -5,6 +5,7 @@ import { readFile, writeFile } from 'fs/promises'; import { SuggestionService } from '../../services/suggestions/index.js'; import { reloadMorpheme, getUserDictPath } from '../../services/suggestions/morpheme.js'; import { badRequest, serverError } from '../../utils/error.js'; +import { logActivity } from '../../utils/log.js'; let suggestionService = null; @@ -185,6 +186,7 @@ export default async function suggestionsRoutes(fastify) { // 형태소 분석기 리로드 await reloadMorpheme(); + logActivity(db, { actor: 'admin', action: 'update', category: 'dict', targetType: 'dict', targetId: null, summary: '사전 저장' }); return { message: '사전이 저장되었습니다.' }; } catch (error) { fastify.log.error(`[Suggestions] 사전 저장 오류: ${error.message}`); diff --git a/backend/src/services/x/index.js b/backend/src/services/x/index.js index 782065e..8a73fab 100644 --- a/backend/src/services/x/index.js +++ b/backend/src/services/x/index.js @@ -4,6 +4,7 @@ import { fetchVideoInfo } from '../youtube/api.js'; import { formatDate, formatTime, nowKST } from '../../utils/date.js'; import { withTransaction } from '../../utils/transaction.js'; import { syncScheduleById } from '../meilisearch/index.js'; +import { logActivity } from '../../utils/log.js'; const X_CATEGORY_ID = 3; const YOUTUBE_CATEGORY_ID = 2; @@ -191,6 +192,15 @@ async function xBotPlugin(fastify, opts) { if (scheduleId) { // Meilisearch 동기화 await syncScheduleById(fastify.meilisearch, fastify.db, scheduleId); + const title = extractTitle(tweet.text); + logActivity(fastify.db, { + actor: bot.id, + action: 'create', + category: 'schedule', + targetType: 'x_schedule', + targetId: scheduleId, + summary: `X 트윗 추가: ${title}`, + }); addedCount++; // YouTube 링크 처리 (옵션이 켜져 있을 때만) if (bot.extractYoutube === true) { diff --git a/backend/src/services/youtube/index.js b/backend/src/services/youtube/index.js index e4a9de4..01ccbd9 100644 --- a/backend/src/services/youtube/index.js +++ b/backend/src/services/youtube/index.js @@ -3,6 +3,7 @@ import { fetchRecentVideoIds, fetchVideoInfo, fetchAllVideos } from './api.js'; import { CATEGORY_IDS } from '../../config/index.js'; import { withTransaction } from '../../utils/transaction.js'; import { syncScheduleById, deleteSchedule } from '../meilisearch/index.js'; +import { logActivity } from '../../utils/log.js'; const YOUTUBE_CATEGORY_ID = CATEGORY_IDS.YOUTUBE; @@ -364,6 +365,14 @@ async function youtubeBotPlugin(fastify) { const scheduleId = await saveVideo(video, bot); if (scheduleId) { await syncScheduleById(fastify.meilisearch, fastify.db, scheduleId); + logActivity(fastify.db, { + actor: bot.id, + action: 'create', + category: 'schedule', + targetType: 'youtube_schedule', + targetId: scheduleId, + summary: `YouTube 영상 추가: ${video.title}`, + }); addedCount++; } }