diff --git a/backend/src/main/java/org/sejongisc/backend/activity/listener/ActivityEventListener.java b/backend/src/main/java/org/sejongisc/backend/activity/listener/ActivityEventListener.java index c85fc899..4925bd42 100644 --- a/backend/src/main/java/org/sejongisc/backend/activity/listener/ActivityEventListener.java +++ b/backend/src/main/java/org/sejongisc/backend/activity/listener/ActivityEventListener.java @@ -10,6 +10,8 @@ import org.springframework.transaction.event.TransactionPhase; import org.springframework.transaction.event.TransactionalEventListener; +import static org.sejongisc.backend.admin.service.AdminDashboardService.ADMIN_CHANNEL; + @Component @RequiredArgsConstructor public class ActivityEventListener { @@ -31,6 +33,6 @@ public void handleActivityEvent(ActivityEvent event) { .build()); // 관리자 채널에 실시간 SSE 전송 (메인 대시보드 피드용) - sseService.send("ADMIN_DASHBOARD", "newLog", log); + sseService.send(ADMIN_CHANNEL, "newLog", log); } } \ No newline at end of file diff --git a/backend/src/main/java/org/sejongisc/backend/activity/repository/ActivityLogRepository.java b/backend/src/main/java/org/sejongisc/backend/activity/repository/ActivityLogRepository.java index 5d28c3d0..c1251888 100644 --- a/backend/src/main/java/org/sejongisc/backend/activity/repository/ActivityLogRepository.java +++ b/backend/src/main/java/org/sejongisc/backend/activity/repository/ActivityLogRepository.java @@ -6,31 +6,43 @@ import org.springframework.data.domain.Slice; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import java.time.LocalDateTime; import java.util.List; import java.util.UUID; public interface ActivityLogRepository extends JpaRepository { - - // 이슈 1: 메인 대시보드 실시간 로그 (최신순 20개) - List findTop20ByOrderByCreatedAtDesc(); - // 마이페이지 내 활동 조회 @Query("SELECT a FROM ActivityLog a WHERE a.userId = :userId " + "AND a.activityType IN :activityTypes " + "ORDER BY a.createdAt DESC") List findByUserIdAndActivityTypesOrderByCreatedAtDesc(UUID userId, List activityTypes); - // 이슈 3-1: 일일 방문자 수 통계 + @Query("SELECT COUNT(a) FROM ActivityLog a " + + "WHERE a.activityType IN :types " + + "AND a.createdAt BETWEEN :start AND :end") + long countActivitiesByTypeAndPeriod(@Param("types") List types, @Param("start") LocalDateTime start, @Param("end") LocalDateTime end); + + // 일일 방문자 수 통계 @Query("SELECT COUNT(DISTINCT a.userId) FROM ActivityLog a " + "WHERE a.activityType = 'AUTH_LOGIN' AND a.createdAt BETWEEN :start AND :end") long countDailyUniqueVisitors(LocalDateTime start, LocalDateTime end); - // 이슈 3-2: 게시판별 활동량 집계 (게시글+댓글+좋아요) + // 날짜별 방문자 추이 + @Query(value = "SELECT DATE(created_at) as date, COUNT(DISTINCT user_id) as count " + + "FROM activity_log " + + "WHERE activity_type = 'AUTH_LOGIN' AND created_at >= :startDate " + + "GROUP BY DATE(created_at) ORDER BY date ASC", nativeQuery = true) + List getDailyVisitorTrendNative(@Param("startDate") LocalDateTime startDate); + + // 게시판별 활동량 집계 (게시글+댓글+좋아요) @Query("SELECT a.boardName, COUNT(a) FROM ActivityLog a " + - "WHERE a.activityType IN ('BOARD_POST', 'BOARD_COMMENT', 'BOARD_LIKE') " + - "AND a.createdAt BETWEEN :start AND :end " + - "GROUP BY a.boardName") - List countActivityByBoard(LocalDateTime start, LocalDateTime end); + "WHERE a.activityType IN ('BOARD_POST', 'BOARD_COMMENT', 'BOARD_LIKE') " + + "AND (:start IS NULL OR a.createdAt >= :start) " + + "AND (:end IS NULL OR a.createdAt <= :end) " + + "GROUP BY a.boardName") + List countActivityByBoard(@Param("start") LocalDateTime start, @Param("end") LocalDateTime end); + + Slice findAllByOrderByCreatedAtDesc(Pageable pageable); } \ No newline at end of file diff --git a/backend/src/main/java/org/sejongisc/backend/admin/controller/AdminBoardController.java b/backend/src/main/java/org/sejongisc/backend/admin/controller/AdminBoardController.java index c4625e2e..5ab1ff5e 100644 --- a/backend/src/main/java/org/sejongisc/backend/admin/controller/AdminBoardController.java +++ b/backend/src/main/java/org/sejongisc/backend/admin/controller/AdminBoardController.java @@ -9,6 +9,7 @@ import org.sejongisc.backend.admin.service.AdminBoardService; import org.sejongisc.backend.common.auth.dto.CustomUserDetails; import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -36,6 +37,7 @@ public class AdminBoardController { + "회장만 생성할 수 있습니다." ) @PostMapping + @PreAuthorize("hasAnyRole('PRESIDENT', 'SYSTEM_ADMIN')") public ResponseEntity createBoard( @RequestBody @Valid BoardRequest request, @AuthenticationPrincipal CustomUserDetails customUserDetails) { @@ -52,11 +54,9 @@ public ResponseEntity createBoard( + "관련 첨부파일 및 댓글 등도 함께 삭제됩니다." ) @DeleteMapping("/{boardId}") - public ResponseEntity deleteBoard( - @PathVariable UUID boardId, - @AuthenticationPrincipal CustomUserDetails customUserDetails) { - UUID userId = customUserDetails.getUserId(); - adminBoardService.deleteBoard(boardId, userId); + @PreAuthorize("hasAnyRole('PRESIDENT', 'SYSTEM_ADMIN')") + public ResponseEntity deleteBoard(@PathVariable UUID boardId) { + adminBoardService.deleteBoard(boardId); return ResponseEntity.ok("게시판 삭제가 완료되었습니다."); } } diff --git a/backend/src/main/java/org/sejongisc/backend/admin/controller/AdminDashboardController.java b/backend/src/main/java/org/sejongisc/backend/admin/controller/AdminDashboardController.java index 50449fd4..9ba1141f 100644 --- a/backend/src/main/java/org/sejongisc/backend/admin/controller/AdminDashboardController.java +++ b/backend/src/main/java/org/sejongisc/backend/admin/controller/AdminDashboardController.java @@ -1,4 +1,81 @@ package org.sejongisc.backend.admin.controller; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.sejongisc.backend.activity.entity.ActivityLog; +import org.sejongisc.backend.admin.dto.dashboard.BoardActivityResponse; +import org.sejongisc.backend.admin.dto.dashboard.SummaryResponse; +import org.sejongisc.backend.admin.dto.dashboard.VisitorTrendResponse; +import org.sejongisc.backend.admin.service.AdminDashboardService; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.Sort; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import java.util.List; + + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/admin/dashboard") +@Tag(name = "00. 관리자 대시보드 API", description = "대시보드 통계 및 실시간 로그 API") public class AdminDashboardController { -} + + private final AdminDashboardService adminDashboardService; + + // --- [통계 요약 섹션] --- + + @Operation(summary = "주간 게시판 활동 요약", description = "이번 주(일~현재) 게시글 활동 수와 전주 대비 증감률(%)을 반환합니다.") + @GetMapping("/stats/boards/summary") + @PreAuthorize("hasAnyRole('PRESIDENT', 'SYSTEM_ADMIN')") + public ResponseEntity getWeeklyBoardSummary() { + return ResponseEntity.ok(adminDashboardService.getWeeklyBoardSummary()); + } + + @Operation(summary = "주간 방문자 요약", description = "이번 주(일~현재) 누적 방문자 수와 전주 대비 증감률(%)을 반환합니다.") + @GetMapping("/stats/visitors/summary") + @PreAuthorize("hasAnyRole('PRESIDENT', 'SYSTEM_ADMIN')") + public ResponseEntity getWeeklyVisitorSummary() { + return ResponseEntity.ok(adminDashboardService.getWeeklyVisitorSummary()); + } + + // --- [차트 데이터 섹션] --- + + @Operation(summary = "방문자 추이 데이터", description = "최근 n일간의 일별 방문자 수 데이터를 반환합니다.") + @GetMapping("/stats/visitors/trend") + @PreAuthorize("hasAnyRole('PRESIDENT', 'SYSTEM_ADMIN')") + public ResponseEntity> getVisitorTrend(@RequestParam(defaultValue = "7") int days) { + return ResponseEntity.ok(adminDashboardService.getVisitorTrend(days)); + } + + @Operation(summary = "게시판별 활동 분포", description = "최근 n일간의 게시판별 활동량(게시글+댓글+좋아요) 집계를 반환합니다.") + @GetMapping("/stats/boards/distribution") + @PreAuthorize("hasAnyRole('PRESIDENT', 'SYSTEM_ADMIN')") + public ResponseEntity> getBoardActivityStats(@RequestParam(defaultValue = "7") int days) { + return ResponseEntity.ok(adminDashboardService.getBoardActivityStats(days)); + } + + // --- [활동 로그 섹션] --- + + @Operation(summary = "실시간 활동 로그 스트림 (SSE)", description = "관리자용 실시간 활동 로그 구독 세션을 엽니다.") + @GetMapping(value = "/activities/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) + @PreAuthorize("hasAnyRole('PRESIDENT', 'SYSTEM_ADMIN')") + public ResponseEntity streamRealtimeLog() { + return ResponseEntity.ok(adminDashboardService.subscribeActivityStream()); + } + + @Operation(summary = "최근 활동 로그 목록 (페이징)", description = "대시보드 하단 로그를 최신순으로 조회합니다. hasNext 필드로 무한 스크롤을 구현하세요.") + @GetMapping("/activities") + @PreAuthorize("hasAnyRole('PRESIDENT', 'SYSTEM_ADMIN')") + public ResponseEntity> getRecentActivities( + @PageableDefault(size = 20, sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable + ) { + return ResponseEntity.ok(adminDashboardService.getRecentActivities(pageable)); + } +} \ No newline at end of file diff --git a/backend/src/main/java/org/sejongisc/backend/admin/controller/AdminUserController.java b/backend/src/main/java/org/sejongisc/backend/admin/controller/AdminUserController.java index 9ccb514c..c7a725cd 100644 --- a/backend/src/main/java/org/sejongisc/backend/admin/controller/AdminUserController.java +++ b/backend/src/main/java/org/sejongisc/backend/admin/controller/AdminUserController.java @@ -54,7 +54,7 @@ public ResponseEntity uploadMemberExcel(@RequestPart("file") """ ) @GetMapping - @PreAuthorize("hasAnyRole('SYSTEM_ADMIN')") + @PreAuthorize("hasAnyRole('PRESIDENT', 'SYSTEM_ADMIN')") public ResponseEntity> getAllUsers(@ModelAttribute AdminUserRequest request) { // TODO: 페이징 추후 고려 return ResponseEntity.ok(adminUserService.findAllUsers(request)); diff --git a/backend/src/main/java/org/sejongisc/backend/admin/dto/dashboard/BoardActivityResponse.java b/backend/src/main/java/org/sejongisc/backend/admin/dto/dashboard/BoardActivityResponse.java new file mode 100644 index 00000000..3fc56fd6 --- /dev/null +++ b/backend/src/main/java/org/sejongisc/backend/admin/dto/dashboard/BoardActivityResponse.java @@ -0,0 +1,6 @@ +package org.sejongisc.backend.admin.dto.dashboard; + +public record BoardActivityResponse( + String boardName, + long activityCount +) {} \ No newline at end of file diff --git a/backend/src/main/java/org/sejongisc/backend/admin/dto/dashboard/SummaryResponse.java b/backend/src/main/java/org/sejongisc/backend/admin/dto/dashboard/SummaryResponse.java new file mode 100644 index 00000000..4c676289 --- /dev/null +++ b/backend/src/main/java/org/sejongisc/backend/admin/dto/dashboard/SummaryResponse.java @@ -0,0 +1,6 @@ +package org.sejongisc.backend.admin.dto.dashboard; + +public record SummaryResponse( + long count, + double percentageComparedToLastWeek +) {} \ No newline at end of file diff --git a/backend/src/main/java/org/sejongisc/backend/admin/dto/dashboard/VisitorTrendResponse.java b/backend/src/main/java/org/sejongisc/backend/admin/dto/dashboard/VisitorTrendResponse.java new file mode 100644 index 00000000..810da0a5 --- /dev/null +++ b/backend/src/main/java/org/sejongisc/backend/admin/dto/dashboard/VisitorTrendResponse.java @@ -0,0 +1,6 @@ +package org.sejongisc.backend.admin.dto.dashboard; + +public record VisitorTrendResponse( + String date, // "YYYY-MM-DD" + long visitorCount +) {} \ No newline at end of file diff --git a/backend/src/main/java/org/sejongisc/backend/admin/service/AdminBoardService.java b/backend/src/main/java/org/sejongisc/backend/admin/service/AdminBoardService.java index 14a3f3cf..6464297c 100644 --- a/backend/src/main/java/org/sejongisc/backend/admin/service/AdminBoardService.java +++ b/backend/src/main/java/org/sejongisc/backend/admin/service/AdminBoardService.java @@ -64,14 +64,7 @@ public void createBoard(BoardRequest request, UUID userId) { // 게시판 삭제 @Transactional - public void deleteBoard(UUID boardId, UUID boardUserId) { - User user = userRepository.findById(boardUserId).orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); - - // 회장만 게시판 삭제 가능 - if (!user.getRole().equals(Role.PRESIDENT)) { - throw new CustomException(ErrorCode.BOARD_ACCESS_DENIED); - } - + public void deleteBoard(UUID boardId) { boardRepository.findById(boardId) .orElseThrow(() -> new CustomException(ErrorCode.BOARD_NOT_FOUND)); diff --git a/backend/src/main/java/org/sejongisc/backend/admin/service/AdminDashboardService.java b/backend/src/main/java/org/sejongisc/backend/admin/service/AdminDashboardService.java new file mode 100644 index 00000000..ed126d1f --- /dev/null +++ b/backend/src/main/java/org/sejongisc/backend/admin/service/AdminDashboardService.java @@ -0,0 +1,125 @@ +package org.sejongisc.backend.admin.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.sejongisc.backend.activity.entity.ActivityLog; +import org.sejongisc.backend.activity.entity.ActivityType; +import org.sejongisc.backend.activity.repository.ActivityLogRepository; +import org.sejongisc.backend.admin.dto.dashboard.BoardActivityResponse; +import org.sejongisc.backend.admin.dto.dashboard.SummaryResponse; +import org.sejongisc.backend.admin.dto.dashboard.VisitorTrendResponse; +import org.sejongisc.backend.common.sse.SseService; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import java.sql.Date; +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.temporal.TemporalAdjusters; +import java.util.List; +import java.util.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor +public class AdminDashboardService { + + private final ActivityLogRepository activityLogRepository; + private final SseService sseService; + + public static final String ADMIN_CHANNEL = "ADMIN_DASHBOARD"; + // 공통 시간 계산용 Record + private record WeekRange(LocalDateTime start, LocalDateTime now, LocalDateTime lastStart, LocalDateTime lastEnd) {} + + // 이번 주 일요일부터 현재까지의 범위 + private WeekRange calculateWeekRange() { + LocalDateTime startOfThisWeek = LocalDate.now() + .with(TemporalAdjusters.previousOrSame(DayOfWeek.SUNDAY)) + .atStartOfDay(); + LocalDateTime now = LocalDateTime.now(); + + return new WeekRange(startOfThisWeek, now, startOfThisWeek.minusWeeks(1), now.minusWeeks(1)); + } + + // 금주 활동량 요약 (일~토 기준) + @Transactional(readOnly = true) + public SummaryResponse getWeeklyBoardSummary() { + WeekRange range = calculateWeekRange(); + List boardTypes = List.of(ActivityType.BOARD_POST, ActivityType.BOARD_COMMENT, ActivityType.BOARD_LIKE); + + long thisWeek = activityLogRepository.countActivitiesByTypeAndPeriod(boardTypes, range.start(), range.now()); + long lastWeek = activityLogRepository.countActivitiesByTypeAndPeriod(boardTypes, range.lastStart(), range.lastEnd()); + + return new SummaryResponse(thisWeek, calculatePercentage(thisWeek, lastWeek)); + } + + // 금주 누적 방문자 요약 (일~토 기준 전주 대비) + @Transactional(readOnly = true) + public SummaryResponse getWeeklyVisitorSummary() { + WeekRange range = calculateWeekRange(); + + long thisWeekVisitors = activityLogRepository.countDailyUniqueVisitors(range.start(), range.now()); + long lastWeekVisitors = activityLogRepository.countDailyUniqueVisitors(range.lastStart(), range.lastEnd()); + + return new SummaryResponse(thisWeekVisitors, calculatePercentage(thisWeekVisitors, lastWeekVisitors)); + } + + // N 일 방문자 추이 (차트용) + @Transactional(readOnly = true) + public List getVisitorTrend(int days) { + LocalDateTime startDate = LocalDate.now().minusDays(days - 1).atStartOfDay(); + List results = activityLogRepository.getDailyVisitorTrendNative(startDate); + + return results.stream() + .map(result -> new VisitorTrendResponse( + ((Date) result[0]).toString(), // java.sql.Date -> String + ((Number) result[1]).longValue() + )) + .collect(Collectors.toList()); + } + + // 게시판별 활동량 집계 (차트용) + @Transactional(readOnly = true) + public List getBoardActivityStats(int days) { + LocalDateTime start = LocalDate.now().minusDays(days - 1).atStartOfDay(); + LocalDateTime end = LocalDateTime.now(); + + List results = activityLogRepository.countActivityByBoard(start, end); + + return results.stream() + .map(result -> new BoardActivityResponse( + (String) result[0], + ((Number) result[1]).longValue() + )) + .collect(Collectors.toList()); + } + + // 실시간 로그 스트림 구독 + public SseEmitter subscribeActivityStream() { + SseEmitter emitter = sseService.subscribe(ADMIN_CHANNEL); + try { + // 연결 시 503 에러 방지를 위한 더미 이벤트 발송 + emitter.send(SseEmitter.event().name("CONNECT").data("Connected to Admin SSE Stream")); + } catch (Exception e) { + sseService.removeEmitter(ADMIN_CHANNEL, emitter); + } + return emitter; + } + + // 최근 로그 20개 조회 (최초 렌더링용) + @Transactional(readOnly = true) + public Slice getRecentActivities(Pageable pageable) { + return activityLogRepository.findAllByOrderByCreatedAtDesc(pageable); + } + + // 증감률 계산 공통 메서드 + private double calculatePercentage(long current, long previous) { + if (previous == 0) return current > 0 ? 100.0 : 0.0; + double percentage = ((double) (current - previous) / previous) * 100; + return Math.round(percentage * 100.0) / 100.0; + } +} \ No newline at end of file diff --git a/backend/src/main/java/org/sejongisc/backend/common/config/security/SecurityConfig.java b/backend/src/main/java/org/sejongisc/backend/common/config/security/SecurityConfig.java index 0c764f58..bf45239d 100644 --- a/backend/src/main/java/org/sejongisc/backend/common/config/security/SecurityConfig.java +++ b/backend/src/main/java/org/sejongisc/backend/common/config/security/SecurityConfig.java @@ -12,6 +12,7 @@ import org.springframework.security.access.hierarchicalroles.RoleHierarchy; import org.springframework.security.access.hierarchicalroles.RoleHierarchyImpl; import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; @@ -31,6 +32,7 @@ @Configuration @EnableWebSecurity +@EnableMethodSecurity // @PreAuthorize 동작하려면 필요 @RequiredArgsConstructor public class SecurityConfig { diff --git a/backend/src/main/java/org/sejongisc/backend/common/exception/controller/GlobalExceptionHandler.java b/backend/src/main/java/org/sejongisc/backend/common/exception/controller/GlobalExceptionHandler.java index 2861b70e..13df53a5 100644 --- a/backend/src/main/java/org/sejongisc/backend/common/exception/controller/GlobalExceptionHandler.java +++ b/backend/src/main/java/org/sejongisc/backend/common/exception/controller/GlobalExceptionHandler.java @@ -18,7 +18,7 @@ public class GlobalExceptionHandler { */ @ExceptionHandler(CustomException.class) public ResponseEntity handleCustomException(CustomException e) { - log.error("CustomException 발생: {}", e.getMessage(), e); + log.error("CustomException 발생: {}", e.getMessage()); ErrorCode errorCode = e.getErrorCode(); return ResponseEntity