feat(backend): 활동 로그 유틸리티 및 API 엔드포인트 추가

- logActivity() fire-and-forget 유틸리티 함수
- GET /api/admin/logs 엔드포인트 (필터/페이지네이션)
- 라우트 등록

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
caadiq 2026-03-02 16:56:44 +09:00
parent c6332c4f96
commit 357fd7fc88
3 changed files with 159 additions and 0 deletions

View file

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

View file

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

26
backend/src/utils/log.js Normal file
View file

@ -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) {
// 로그 실패는 무시 — 비즈니스 로직에 영향 주지 않음
}
}