feat(backend): 관리자/봇 라우트에 logActivity 호출 추가

12개 관리자 라우트와 3개 봇 서비스 파일에 활동 로그 기록 추가.
관리자 작업(일정/앨범/멤버/봇 CRUD)과 봇 동기화(완료/에러)를
logs 테이블에 fire-and-forget으로 기록.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
caadiq 2026-03-02 17:04:07 +09:00
parent 357fd7fc88
commit 1f1d6987d1
15 changed files with 88 additions and 1 deletions

View file

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

View file

@ -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,

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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: '사진이 삭제되었습니다.' };
});
});

View file

@ -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: '티저가 삭제되었습니다.' };
});
});

View file

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

View file

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

View file

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

View file

@ -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) {

View file

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