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:
parent
c6332c4f96
commit
357fd7fc88
3 changed files with 159 additions and 0 deletions
129
backend/src/routes/admin/logs.js
Normal file
129
backend/src/routes/admin/logs.js
Normal 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -10,6 +10,7 @@ import youtubeAdminRoutes from './admin/youtube.js';
|
||||||
import xAdminRoutes from './admin/x.js';
|
import xAdminRoutes from './admin/x.js';
|
||||||
import concertAdminRoutes from './admin/concert.js';
|
import concertAdminRoutes from './admin/concert.js';
|
||||||
import placesAdminRoutes from './admin/places.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(placesAdminRoutes, { prefix: '/admin' });
|
||||||
|
|
||||||
|
// 관리자 - 활동 로그 라우트
|
||||||
|
fastify.register(logsAdminRoutes, { prefix: '/admin/logs' });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
26
backend/src/utils/log.js
Normal file
26
backend/src/utils/log.js
Normal 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) {
|
||||||
|
// 로그 실패는 무시 — 비즈니스 로직에 영향 주지 않음
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue