diff --git a/backend/src/routes/admin/logs.js b/backend/src/routes/admin/logs.js new file mode 100644 index 0000000..210f7e2 --- /dev/null +++ b/backend/src/routes/admin/logs.js @@ -0,0 +1,129 @@ +import { errorResponse } from '../../schemas/index.js'; +import { serverError } from '../../utils/error.js'; + +/** + * 활동 로그 관리자 라우트 + */ +export default async function logsRoutes(fastify) { + const { db } = fastify; + + /** + * GET /api/admin/logs + * 활동 로그 목록 조회 + */ + fastify.get('/', { + schema: { + tags: ['admin/logs'], + summary: '활동 로그 목록 조회', + security: [{ bearerAuth: [] }], + querystring: { + type: 'object', + properties: { + page: { type: 'integer', minimum: 1, default: 1 }, + limit: { type: 'integer', minimum: 1, maximum: 100, default: 50 }, + category: { type: 'string', description: '카테고리 필터 (콤마 구분)' }, + actor: { type: 'string', description: '행위자 필터 (admin 또는 bot)' }, + search: { type: 'string', description: 'summary 검색' }, + from: { type: 'string', description: '시작 날짜 (YYYY-MM-DD)' }, + to: { type: 'string', description: '종료 날짜 (YYYY-MM-DD)' }, + }, + }, + response: { + 200: { + type: 'object', + properties: { + logs: { + type: 'array', + items: { + type: 'object', + properties: { + id: { type: 'integer' }, + actor: { type: 'string' }, + action: { type: 'string' }, + category: { type: 'string' }, + target_type: { type: 'string', nullable: true }, + target_id: { type: 'integer', nullable: true }, + summary: { type: 'string' }, + details: { type: 'object', nullable: true }, + created_at: { type: 'string' }, + }, + }, + }, + total: { type: 'integer' }, + page: { type: 'integer' }, + limit: { type: 'integer' }, + totalPages: { type: 'integer' }, + }, + }, + 500: errorResponse, + }, + }, + preHandler: [fastify.authenticate], + }, async (request, reply) => { + const { page = 1, limit = 50, category, actor, search, from, to } = request.query; + + try { + const conditions = []; + const params = []; + + // 카테고리 필터 + if (category) { + const categories = category.split(',').map(c => c.trim()).filter(Boolean); + if (categories.length > 0) { + conditions.push(`category IN (${categories.map(() => '?').join(',')})`); + params.push(...categories); + } + } + + // 행위자 필터 + if (actor === 'admin') { + conditions.push("actor = 'admin'"); + } else if (actor === 'bot') { + conditions.push("actor != 'admin'"); + } + + // 텍스트 검색 + if (search) { + conditions.push('summary LIKE ?'); + params.push(`%${search}%`); + } + + // 날짜 필터 + if (from) { + conditions.push('created_at >= ?'); + params.push(`${from} 00:00:00`); + } + if (to) { + conditions.push('created_at <= ?'); + params.push(`${to} 23:59:59`); + } + + const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; + const offset = (page - 1) * limit; + + // 총 개수 조회 + const [countResult] = await db.query( + `SELECT COUNT(*) as total FROM logs ${whereClause}`, + params + ); + const total = countResult[0].total; + + // 로그 조회 + const [logs] = await db.query( + `SELECT * FROM logs ${whereClause} ORDER BY created_at DESC LIMIT ? OFFSET ?`, + [...params, limit, offset] + ); + + return { + logs, + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }; + } catch (err) { + fastify.log.error(`활동 로그 조회 오류: ${err.message}`); + return serverError(reply, err.message); + } + }); +} diff --git a/backend/src/routes/index.js b/backend/src/routes/index.js index 3125223..3f8dbf7 100644 --- a/backend/src/routes/index.js +++ b/backend/src/routes/index.js @@ -10,6 +10,7 @@ import youtubeAdminRoutes from './admin/youtube.js'; import xAdminRoutes from './admin/x.js'; import concertAdminRoutes from './admin/concert.js'; import placesAdminRoutes from './admin/places.js'; +import logsAdminRoutes from './admin/logs.js'; /** * 라우트 통합 @@ -51,4 +52,7 @@ export default async function routes(fastify) { // 관리자 - 장소 검색 라우트 fastify.register(placesAdminRoutes, { prefix: '/admin' }); + + // 관리자 - 활동 로그 라우트 + fastify.register(logsAdminRoutes, { prefix: '/admin/logs' }); } diff --git a/backend/src/utils/log.js b/backend/src/utils/log.js new file mode 100644 index 0000000..f4d3300 --- /dev/null +++ b/backend/src/utils/log.js @@ -0,0 +1,26 @@ +/** + * 활동 로그 유틸리티 + * fire-and-forget: 로그 실패가 비즈니스 로직에 영향 주지 않도록 처리 + */ + +/** + * @param {object} db - DB 커넥션 + * @param {object} params + * @param {string} params.actor - 행위자 ("admin", "youtube-3", "x-1" 등) + * @param {string} params.action - 행동 (create, update, delete, upload, start, stop, sync_complete, error) + * @param {string} params.category - 대분류 (album, schedule, member, bot, category, dict, concert, sync) + * @param {string} [params.targetType] - 대상 타입 (youtube_schedule, x_schedule, album, photo, member 등) + * @param {number} [params.targetId] - 대상 DB ID + * @param {string} params.summary - 한 줄 요약 + * @param {object} [params.details] - 추가 상세 정보 (JSON) + */ +export async function logActivity(db, { actor, action, category, targetType, targetId, summary, details }) { + try { + await db.query( + 'INSERT INTO logs (actor, action, category, target_type, target_id, summary, details) VALUES (?, ?, ?, ?, ?, ?, ?)', + [actor, action, category, targetType || null, targetId || null, summary, details ? JSON.stringify(details) : null] + ); + } catch (err) { + // 로그 실패는 무시 — 비즈니스 로직에 영향 주지 않음 + } +}