From c4cd0dec3032fd449a8bd17e23833de56d2e3216 Mon Sep 17 00:00:00 2001 From: caadiq Date: Mon, 2 Mar 2026 16:16:59 +0900 Subject: [PATCH] =?UTF-8?q?feat(admin):=20=ED=99=9C=EB=8F=99=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=20=EB=B0=8F=20=EC=84=A4=EA=B3=84=20=EB=AC=B8?= =?UTF-8?q?=EC=84=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 더미데이터로 활동 로그 UI 구현 (필터, 테이블, 페이지네이션) 라우트/메뉴 연결은 다음 단계에서 진행 Co-Authored-By: Claude Opus 4.6 --- docs/activity-logs.md | 197 +++++++++ .../src/pages/pc/admin/logs/ActivityLogs.jsx | 398 ++++++++++++++++++ 2 files changed, 595 insertions(+) create mode 100644 docs/activity-logs.md create mode 100644 frontend/src/pages/pc/admin/logs/ActivityLogs.jsx diff --git a/docs/activity-logs.md b/docs/activity-logs.md new file mode 100644 index 0000000..bf202e5 --- /dev/null +++ b/docs/activity-logs.md @@ -0,0 +1,197 @@ +# 활동 로그 시스템 + +## 개요 + +관리자 페이지에서 모든 행동(관리자 수동 작업 + 봇 자동 작업)에 대한 로그를 조회할 수 있는 시스템. +앨범 CRUD, 멤버 수정, 일정 추가/수정/삭제, 봇 동기화 등 모든 활동을 DB에 기록하고 관리자 페이지에서 필터링/페이지네이션으로 조회. + +--- + +## DB 테이블 + +### `activity_logs` + +```sql +CREATE TABLE activity_logs ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + actor VARCHAR(50) NOT NULL, -- "admin" 또는 봇 ID ("youtube-3", "x-1" 등) + action VARCHAR(50) NOT NULL, -- create, update, delete, start, stop, sync_complete, error 등 + category VARCHAR(30) NOT NULL, -- album, schedule, member, bot, category, dict, concert, sync + target_type VARCHAR(50) DEFAULT NULL, -- youtube_schedule, x_schedule, album, photo, member 등 + target_id INT UNSIGNED DEFAULT NULL, + summary VARCHAR(500) NOT NULL, -- 사람이 읽을 수 있는 한 줄 요약 + details JSON DEFAULT NULL, -- 추가 상세 정보 + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + INDEX idx_created_at (created_at), + INDEX idx_category (category), + INDEX idx_actor (actor) +); +``` + +### 컬럼 설명 + +| 컬럼 | 설명 | 예시 | +|------|------|------| +| `actor` | 행위자 | `"admin"`, `"youtube-3"`, `"x-1"`, `"meilisearch"` | +| `action` | 행동 유형 | `create`, `update`, `delete`, `upload`, `start`, `stop`, `sync_complete`, `error`, `reorder` | +| `category` | 대분류 | `album`, `schedule`, `member`, `bot`, `category`, `dict`, `concert`, `sync` | +| `target_type` | 대상 타입 | `youtube_schedule`, `x_schedule`, `album`, `photo`, `teaser`, `member`, `youtube_bot`, `x_bot`, `category`, `concert` | +| `target_id` | 대상 DB ID | 해당 레코드의 PK | +| `summary` | 한 줄 요약 | `"YouTube 일정 생성: fromis_9 영상 제목"` | +| `details` | 추가 정보 (JSON) | `{ "videoId": "abc123", "channelName": "채널명" }` | + +--- + +## 백엔드 구현 + +### 로그 유틸리티 + +**파일:** `backend/src/utils/activityLog.js` + +```javascript +logActivity(db, { actor, action, category, targetType, targetId, summary, details }) +``` + +- fire-and-forget: 로그 실패가 비즈니스 로직에 영향 주지 않도록 try/catch 감싸기 +- 트랜잭션 외부에서 호출 (로그 실패가 롤백 유발하지 않도록) + +### API 엔드포인트 + +**GET /api/admin/activity-logs** — 로그 목록 조회 (인증 필수) + +| 파라미터 | 타입 | 기본값 | 설명 | +|----------|------|--------|------| +| `page` | integer | 1 | 페이지 번호 | +| `limit` | integer | 50 | 페이지당 개수 (최대 100) | +| `category` | string | - | 카테고리 필터 (콤마 구분) | +| `actor` | string | - | 행위자 필터 (`"admin"` 또는 `"bot"`) | +| `search` | string | - | summary 검색 | +| `from` | string | - | 시작 날짜 (YYYY-MM-DD) | +| `to` | string | - | 종료 날짜 (YYYY-MM-DD) | + +**응답:** +```json +{ + "logs": [ + { + "id": 1, + "actor": "admin", + "action": "create", + "category": "schedule", + "target_type": "youtube_schedule", + "target_id": 456, + "summary": "YouTube 일정 생성: fromis_9 영상 제목", + "details": { "videoId": "abc123", "channelName": "채널명" }, + "created_at": "2026-03-01T14:30:00" + } + ], + "total": 1234, + "page": 1, + "limit": 50, + "totalPages": 25 +} +``` + +### 로그 삽입 대상 + +#### 관리자 수동 작업 + +| 파일 | 로그 대상 | +|------|----------| +| `routes/admin/youtube.js` | YouTube 일정 생성/수정 | +| `routes/admin/x.js` | X 일정 생성 | +| `routes/admin/concert.js` | 콘서트 일정 생성 | +| `routes/admin/youtube-bots.js` | YouTube 봇 생성/수정/삭제 | +| `routes/admin/x-bots.js` | X 봇 생성/수정/삭제 | +| `routes/admin/bots.js` | 봇 시작/정지/전체동기화 | +| `routes/albums/index.js` | 앨범 생성/수정/삭제 | +| `routes/albums/photos.js` | 사진 업로드/삭제 | +| `routes/albums/teasers.js` | 티저 삭제 | +| `routes/members/index.js` | 멤버 수정 | +| `routes/schedules/index.js` | 일정 삭제, 카테고리 CRUD, 순서변경 | +| `routes/schedules/suggestions.js` | 사전 저장 | + +#### 봇 자동 작업 + +| 파일 | 로그 대상 | +|------|----------| +| `plugins/scheduler.js` | 동기화 완료 (addedCount > 0일 때만), 에러 | +| `services/youtube/index.js` | 영상 추가 성공 | +| `services/x/index.js` | 트윗 추가 성공 | + +> **봇 로그 전략:** 변화 없는 동기화는 로그 안 남김. `addedCount > 0`이거나 에러인 경우만 기록. + +--- + +## 프론트엔드 구현 + +### 로그 페이지 + +**경로:** `/admin/logs` + +**UI 구성:** +- 필터 바: 카테고리 칩, 행위자 드롭다운, 기간 선택, 텍스트 검색 +- 로그 테이블: 시간, 행위자(아이콘), 액션 뱃지(색상별), summary +- 페이지네이션 + +**액션 뱃지 색상:** +| 액션 | 색상 | +|------|------| +| create / upload | 초록 | +| update / reorder | 파랑 | +| delete | 빨강 | +| sync_complete | 보라 | +| error | 빨강 | +| start / stop | 노랑 | + +### 대시보드 메뉴 + +대시보드 menuItems에 활동 로그 항목 추가 (ScrollText 아이콘) + +--- + +## 수정 파일 목록 + +### 신규 생성 (4개) + +| 파일 | 내용 | +|------|------| +| `backend/src/utils/activityLog.js` | logActivity 유틸리티 함수 | +| `backend/src/routes/admin/activity-logs.js` | API 엔드포인트 | +| `frontend/src/api/admin/activityLogs.js` | API 클라이언트 | +| `frontend/src/pages/pc/admin/logs/ActivityLogs.jsx` | 로그 페이지 컴포넌트 | + +### 수정 (백엔드 15개 + 프론트엔드 3개) + +| 파일 | 변경 | +|------|------| +| `backend/src/routes/index.js` | 라우트 등록 | +| `backend/src/routes/admin/youtube.js` | logActivity 호출 | +| `backend/src/routes/admin/x.js` | logActivity 호출 | +| `backend/src/routes/admin/concert.js` | logActivity 호출 | +| `backend/src/routes/admin/youtube-bots.js` | logActivity 호출 | +| `backend/src/routes/admin/x-bots.js` | logActivity 호출 | +| `backend/src/routes/admin/bots.js` | logActivity 호출 | +| `backend/src/routes/albums/index.js` | logActivity 호출 | +| `backend/src/routes/albums/photos.js` | logActivity 호출 | +| `backend/src/routes/albums/teasers.js` | logActivity 호출 | +| `backend/src/routes/members/index.js` | logActivity 호출 | +| `backend/src/routes/schedules/index.js` | logActivity 호출 | +| `backend/src/routes/schedules/suggestions.js` | logActivity 호출 | +| `backend/src/plugins/scheduler.js` | 동기화 로그 | +| `backend/src/services/youtube/index.js` | 영상 추가 로그 | +| `backend/src/services/x/index.js` | 트윗 추가 로그 | +| `frontend/src/api/admin/index.js` | export 추가 | +| `frontend/src/routes/pc/admin/index.jsx` | 라우트 등록 | +| `frontend/src/pages/pc/admin/dashboard/Dashboard.jsx` | 메뉴 추가 | + +--- + +## 구현 순서 + +1. DB 테이블 생성 +2. 백엔드: logActivity 유틸리티 + API 엔드포인트 + 라우트 등록 +3. 백엔드: 각 라우트/서비스에 logActivity 호출 추가 +4. 프론트엔드: API 클라이언트 + 로그 페이지 + 라우트/대시보드 연결 +5. 서버 재빌드 + 테스트 +6. 문서 업데이트 diff --git a/frontend/src/pages/pc/admin/logs/ActivityLogs.jsx b/frontend/src/pages/pc/admin/logs/ActivityLogs.jsx new file mode 100644 index 0000000..1df507d --- /dev/null +++ b/frontend/src/pages/pc/admin/logs/ActivityLogs.jsx @@ -0,0 +1,398 @@ +/** + * 관리자 활동 로그 페이지 + */ +import { useState, useMemo } from 'react'; +import { Link } from 'react-router-dom'; +import { motion } from 'framer-motion'; +import { + Home, ChevronRight, Search, ChevronLeft, ChevronDown, + User, Bot, ScrollText, X, +} from 'lucide-react'; +import { AdminLayout } from '@/components/pc/admin'; +import { useAdminAuth } from '@/hooks/pc/admin'; + +// 더미 데이터 +const DUMMY_LOGS = [ + { id: 1, actor: 'admin', action: 'create', category: 'album', target_type: 'album', target_id: 12, summary: '앨범 생성: Unlock My World', details: null, created_at: '2026-03-02T14:30:00' }, + { id: 2, actor: 'admin', action: 'upload', category: 'album', target_type: 'photo', target_id: 45, summary: '사진 업로드: Unlock My World (3장)', details: { count: 3 }, created_at: '2026-03-02T14:25:00' }, + { id: 3, actor: 'youtube-3', action: 'sync_complete', category: 'sync', target_type: 'youtube_bot', target_id: 3, summary: '동기화 완료: 스프 채널 (2개 추가)', details: { addedCount: 2, channelName: '스프' }, created_at: '2026-03-02T14:20:00' }, + { id: 4, actor: 'admin', action: 'create', category: 'schedule', target_type: 'youtube_schedule', target_id: 789, summary: 'YouTube 일정 생성: fromis_9 컴백 티저', details: { videoId: 'abc123' }, created_at: '2026-03-02T14:15:00' }, + { id: 5, actor: 'admin', action: 'update', category: 'member', target_type: 'member', target_id: 1, summary: '멤버 수정: 이서연 프로필 업데이트', details: null, created_at: '2026-03-02T14:10:00' }, + { id: 6, actor: 'x-1', action: 'sync_complete', category: 'sync', target_type: 'x_bot', target_id: 1, summary: '동기화 완료: fromis_9 공식 (1개 추가)', details: { addedCount: 1 }, created_at: '2026-03-02T14:05:00' }, + { id: 7, actor: 'admin', action: 'delete', category: 'schedule', target_type: 'youtube_schedule', target_id: 456, summary: 'YouTube 일정 삭제: 이전 영상', details: null, created_at: '2026-03-02T14:00:00' }, + { id: 8, actor: 'admin', action: 'create', category: 'concert', target_type: 'concert', target_id: 5, summary: '콘서트 일정 생성: fromis_9 팬미팅', details: null, created_at: '2026-03-02T13:55:00' }, + { id: 9, actor: 'admin', action: 'update', category: 'category', target_type: 'category', target_id: 3, summary: '카테고리 수정: 음악방송', details: null, created_at: '2026-03-02T13:50:00' }, + { id: 10, actor: 'admin', action: 'reorder', category: 'category', target_type: 'category', target_id: null, summary: '카테고리 순서 변경', details: null, created_at: '2026-03-02T13:45:00' }, + { id: 11, actor: 'youtube-1', action: 'error', category: 'sync', target_type: 'youtube_bot', target_id: 1, summary: '동기화 에러: API 할당량 초과', details: { error: 'quotaExceeded' }, created_at: '2026-03-02T13:40:00' }, + { id: 12, actor: 'admin', action: 'start', category: 'bot', target_type: 'youtube_bot', target_id: 3, summary: 'YouTube 봇 시작: 스프', details: null, created_at: '2026-03-02T13:35:00' }, + { id: 13, actor: 'admin', action: 'stop', category: 'bot', target_type: 'youtube_bot', target_id: 2, summary: 'YouTube 봇 정지: 채널 비활성화', details: null, created_at: '2026-03-02T13:30:00' }, + { id: 14, actor: 'admin', action: 'create', category: 'dict', target_type: 'dict', target_id: 10, summary: '사전 저장: fromis_9 → 프로미스나인', details: null, created_at: '2026-03-02T13:25:00' }, + { id: 15, actor: 'admin', action: 'delete', category: 'album', target_type: 'teaser', target_id: 8, summary: '티저 삭제: Unlock My World 티저 1', details: null, created_at: '2026-03-02T13:20:00' }, + { id: 16, actor: 'admin', action: 'create', category: 'schedule', target_type: 'x_schedule', target_id: 100, summary: 'X 일정 생성: fromis_9 공식 트윗', details: null, created_at: '2026-03-02T13:15:00' }, + { id: 17, actor: 'youtube-3', action: 'sync_complete', category: 'sync', target_type: 'youtube_bot', target_id: 3, summary: '동기화 완료: 스프 채널 (1개 추가)', details: { addedCount: 1 }, created_at: '2026-03-02T13:10:00' }, + { id: 18, actor: 'admin', action: 'update', category: 'schedule', target_type: 'youtube_schedule', target_id: 780, summary: 'YouTube 일정 수정: 제목 변경', details: null, created_at: '2026-03-02T13:05:00' }, + { id: 19, actor: 'admin', action: 'create', category: 'bot', target_type: 'youtube_bot', target_id: 4, summary: 'YouTube 봇 생성: 새 채널', details: null, created_at: '2026-03-02T13:00:00' }, + { id: 20, actor: 'admin', action: 'delete', category: 'bot', target_type: 'x_bot', target_id: 2, summary: 'X 봇 삭제: 비활성 계정', details: null, created_at: '2026-03-02T12:55:00' }, + { id: 21, actor: 'x-1', action: 'sync_complete', category: 'sync', target_type: 'x_bot', target_id: 1, summary: '동기화 완료: fromis_9 공식 (3개 추가)', details: { addedCount: 3 }, created_at: '2026-03-02T12:50:00' }, + { id: 22, actor: 'admin', action: 'upload', category: 'album', target_type: 'photo', target_id: 50, summary: '사진 업로드: My Little Society (5장)', details: { count: 5 }, created_at: '2026-03-02T12:45:00' }, + { id: 23, actor: 'admin', action: 'update', category: 'album', target_type: 'album', target_id: 5, summary: '앨범 수정: My Little Society 정보 변경', details: null, created_at: '2026-03-02T12:40:00' }, + { id: 24, actor: 'youtube-1', action: 'sync_complete', category: 'sync', target_type: 'youtube_bot', target_id: 1, summary: '동기화 완료: 공식 채널 (1개 추가)', details: { addedCount: 1 }, created_at: '2026-03-02T12:35:00' }, + { id: 25, actor: 'admin', action: 'create', category: 'schedule', target_type: 'youtube_schedule', target_id: 791, summary: 'YouTube 일정 생성: 연습 영상', details: null, created_at: '2026-03-02T12:30:00' }, +]; + +// 카테고리 목록 +const CATEGORIES = [ + { value: 'album', label: '앨범' }, + { value: 'schedule', label: '일정' }, + { value: 'member', label: '멤버' }, + { value: 'bot', label: '봇' }, + { value: 'category', label: '카테고리' }, + { value: 'dict', label: '사전' }, + { value: 'concert', label: '콘서트' }, + { value: 'sync', label: '동기화' }, +]; + +// 액션 뱃지 색상 +const ACTION_STYLES = { + create: 'bg-emerald-100 text-emerald-700', + upload: 'bg-emerald-100 text-emerald-700', + update: 'bg-blue-100 text-blue-700', + reorder: 'bg-blue-100 text-blue-700', + delete: 'bg-red-100 text-red-700', + sync_complete: 'bg-purple-100 text-purple-700', + error: 'bg-red-100 text-red-700', + start: 'bg-amber-100 text-amber-700', + stop: 'bg-amber-100 text-amber-700', +}; + +// 액션 한글 라벨 +const ACTION_LABELS = { + create: '생성', + upload: '업로드', + update: '수정', + reorder: '순서변경', + delete: '삭제', + sync_complete: '동기화', + error: '에러', + start: '시작', + stop: '정지', +}; + +const ITEMS_PER_PAGE = 15; + +function ActivityLogs() { + const { user } = useAdminAuth(); + + // 필터 상태 + const [searchQuery, setSearchQuery] = useState(''); + const [selectedCategories, setSelectedCategories] = useState([]); + const [actorFilter, setActorFilter] = useState('all'); // all, admin, bot + const [dateFrom, setDateFrom] = useState(''); + const [dateTo, setDateTo] = useState(''); + const [currentPage, setCurrentPage] = useState(1); + const [actorDropdownOpen, setActorDropdownOpen] = useState(false); + + // 카테고리 토글 + const toggleCategory = (cat) => { + setSelectedCategories((prev) => + prev.includes(cat) ? prev.filter((c) => c !== cat) : [...prev, cat] + ); + setCurrentPage(1); + }; + + // 필터링된 로그 + const filteredLogs = useMemo(() => { + return DUMMY_LOGS.filter((log) => { + // 카테고리 필터 + if (selectedCategories.length > 0 && !selectedCategories.includes(log.category)) { + return false; + } + // 행위자 필터 + if (actorFilter === 'admin' && log.actor !== 'admin') return false; + if (actorFilter === 'bot' && log.actor === 'admin') return false; + // 텍스트 검색 + if (searchQuery && !log.summary.toLowerCase().includes(searchQuery.toLowerCase())) { + return false; + } + // 날짜 필터 + if (dateFrom) { + const logDate = log.created_at.split('T')[0]; + if (logDate < dateFrom) return false; + } + if (dateTo) { + const logDate = log.created_at.split('T')[0]; + if (logDate > dateTo) return false; + } + return true; + }); + }, [searchQuery, selectedCategories, actorFilter, dateFrom, dateTo]); + + // 페이지네이션 + const totalPages = Math.ceil(filteredLogs.length / ITEMS_PER_PAGE); + const paginatedLogs = filteredLogs.slice( + (currentPage - 1) * ITEMS_PER_PAGE, + currentPage * ITEMS_PER_PAGE + ); + + // 날짜/시간 포맷 + const formatDateTime = (dateStr) => { + const date = new Date(dateStr); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + const hours = String(date.getHours()).padStart(2, '0'); + const minutes = String(date.getMinutes()).padStart(2, '0'); + return `${month}.${day} ${hours}:${minutes}`; + }; + + // 행위자 아이콘 + const renderActorBadge = (actor) => { + if (actor === 'admin') { + return ( + + + 관리자 + + ); + } + return ( + + + {actor} + + ); + }; + + // 필터 초기화 + const clearFilters = () => { + setSearchQuery(''); + setSelectedCategories([]); + setActorFilter('all'); + setDateFrom(''); + setDateTo(''); + setCurrentPage(1); + }; + + const hasActiveFilters = searchQuery || selectedCategories.length > 0 || actorFilter !== 'all' || dateFrom || dateTo; + + return ( + +
+ {/* 브레드크럼 */} +
+ + + + + 활동 로그 +
+ + {/* 타이틀 */} +
+

활동 로그

+

모든 관리자 및 봇 활동 기록을 확인합니다

+
+ + {/* 필터 영역 */} +
+ {/* 상단: 검색 + 행위자 + 날짜 */} +
+ {/* 검색 */} +
+ + { setSearchQuery(e.target.value); setCurrentPage(1); }} + placeholder="로그 검색..." + className="w-full pl-10 pr-4 py-2 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent text-sm" + /> +
+ + {/* 행위자 드롭다운 */} +
+ + {actorDropdownOpen && ( + <> +
setActorDropdownOpen(false)} /> +
+ {[ + { value: 'all', label: '전체 행위자' }, + { value: 'admin', label: '관리자' }, + { value: 'bot', label: '봇' }, + ].map((opt) => ( + + ))} +
+ + )} +
+ + {/* 날짜 필터 */} +
+ { setDateFrom(e.target.value); setCurrentPage(1); }} + className="px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent text-gray-600" + /> + ~ + { setDateTo(e.target.value); setCurrentPage(1); }} + className="px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent text-gray-600" + /> +
+ + {/* 필터 초기화 */} + {hasActiveFilters && ( + + )} +
+ + {/* 하단: 카테고리 칩 */} +
+ 카테고리 + {CATEGORIES.map((cat) => ( + + ))} +
+
+ + {/* 결과 개수 */} +
+

+ 총 {filteredLogs.length}개의 로그 +

+
+ + {/* 로그 테이블 */} + + + + + + + + + + + + + {paginatedLogs.map((log, index) => ( + + + + + + + + ))} + +
시간행위자액션카테고리내용
+ {formatDateTime(log.created_at)} + + {renderActorBadge(log.actor)} + + + {ACTION_LABELS[log.action] || log.action} + + + + {CATEGORIES.find((c) => c.value === log.category)?.label || log.category} + + + {log.summary} +
+ + {paginatedLogs.length === 0 && ( +
+ +

+ {hasActiveFilters ? '검색 결과가 없습니다.' : '활동 로그가 없습니다.'} +

+
+ )} +
+ + {/* 페이지네이션 */} + {totalPages > 1 && ( +
+ + {Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => ( + + ))} + +
+ )} +
+ + ); +} + +export default ActivityLogs;