Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
e4d7c01
[BE] [FEAT] activityLog 추가 및 이벤트 관련 코드 추가
discipline24 Feb 26, 2026
2dfdcc2
[BE] [FIX] deploy 관련 설정 변경
discipline24 Feb 27, 2026
3a5c8ce
[BE] [CHORE] db 설정 파일 주석 수정
discipline24 Feb 27, 2026
343c592
[BE] [FEAT] 댓글, 좋아요시 이벤트 발행 추가
discipline24 Feb 27, 2026
4c450af
[BE] [FIX] securityConfig에 세션 설정 stateless 로 변경
discipline24 Feb 27, 2026
ddded86
[BE] [FIX] 토끼 리뷰 반영
discipline24 Feb 27, 2026
b442969
Update deploy.yml
discipline24 Feb 27, 2026
a11403d
[BE] [FIX] message 형식 변경
discipline24 Feb 27, 2026
ae77ba2
[BE] [FIX] 회원가입 메서드 user -> auth로 이관
discipline24 Feb 27, 2026
082a15a
[BE] [FIX] auth -> user 메서드 이관
discipline24 Feb 27, 2026
6a5db7d
[BE] [CHORES] 비밀번호 초기화 docs 수정
discipline24 Feb 27, 2026
62feee0
[BE] [FEAT] 마이페이지 본인 관련 로그 조회 구현
discipline24 Feb 27, 2026
e6bc815
Update docker-compose.yml
discipline24 Feb 27, 2026
90c43f2
Merge branch 'main' into 20260226_#236_관리자_페이지_통계_대시보드_실시간_활동_로그_추가
discipline24 Feb 28, 2026
c0fb2eb
[BE] [FIX] 토끼 리뷰 반영
discipline24 Feb 27, 2026
19ed65d
[BE] [FIX] 비밀번호 초기화 로직 오류 수정
discipline24 Feb 28, 2026
8a40860
Merge branch 'main' into 20260226_#236_관리자_페이지_통계_대시보드_실시간_활동_로그_추가
discipline24 Feb 28, 2026
2a255a7
[BE] [FEAT] 대시보드 API 구현
discipline24 Mar 2, 2026
b2f5d9e
[BE] [FEAT] @PreAuthorize 이용한 관리자 API 명시적 권한 검증 추가
discipline24 Mar 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -31,6 +33,6 @@ public void handleActivityEvent(ActivityEvent event) {
.build());

// 관리자 채널에 실시간 SSE 전송 (메인 대시보드 피드용)
sseService.send("ADMIN_DASHBOARD", "newLog", log);
sseService.send(ADMIN_CHANNEL, "newLog", log);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<ActivityLog, Long> {

// 이슈 1: 메인 대시보드 실시간 로그 (최신순 20개)
List<ActivityLog> findTop20ByOrderByCreatedAtDesc();

// 마이페이지 내 활동 조회
@Query("SELECT a FROM ActivityLog a WHERE a.userId = :userId " +
"AND a.activityType IN :activityTypes " +
"ORDER BY a.createdAt DESC")
List<ActivityLog> findByUserIdAndActivityTypesOrderByCreatedAtDesc(UUID userId, List<ActivityType> 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<ActivityType> 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<Object[]> 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<Object[]> 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<Object[]> countActivityByBoard(@Param("start") LocalDateTime start, @Param("end") LocalDateTime end);

Slice<ActivityLog> findAllByOrderByCreatedAtDesc(Pageable pageable);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -36,6 +37,7 @@ public class AdminBoardController {
+ "회장만 생성할 수 있습니다."
)
@PostMapping
@PreAuthorize("hasAnyRole('PRESIDENT', 'SYSTEM_ADMIN')")
public ResponseEntity<Void> createBoard(
@RequestBody @Valid BoardRequest request,
@AuthenticationPrincipal CustomUserDetails customUserDetails) {
Expand All @@ -52,11 +54,9 @@ public ResponseEntity<Void> 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("게시판 삭제가 완료되었습니다.");
}
}
Original file line number Diff line number Diff line change
@@ -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<SummaryResponse> getWeeklyBoardSummary() {
return ResponseEntity.ok(adminDashboardService.getWeeklyBoardSummary());
}

@Operation(summary = "주간 방문자 요약", description = "이번 주(일~현재) 누적 방문자 수와 전주 대비 증감률(%)을 반환합니다.")
@GetMapping("/stats/visitors/summary")
@PreAuthorize("hasAnyRole('PRESIDENT', 'SYSTEM_ADMIN')")
public ResponseEntity<SummaryResponse> getWeeklyVisitorSummary() {
return ResponseEntity.ok(adminDashboardService.getWeeklyVisitorSummary());
}

// --- [차트 데이터 섹션] ---

@Operation(summary = "방문자 추이 데이터", description = "최근 n일간의 일별 방문자 수 데이터를 반환합니다.")
@GetMapping("/stats/visitors/trend")
@PreAuthorize("hasAnyRole('PRESIDENT', 'SYSTEM_ADMIN')")
public ResponseEntity<List<VisitorTrendResponse>> 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<List<BoardActivityResponse>> 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<SseEmitter> streamRealtimeLog() {
return ResponseEntity.ok(adminDashboardService.subscribeActivityStream());
}

@Operation(summary = "최근 활동 로그 목록 (페이징)", description = "대시보드 하단 로그를 최신순으로 조회합니다. hasNext 필드로 무한 스크롤을 구현하세요.")
@GetMapping("/activities")
@PreAuthorize("hasAnyRole('PRESIDENT', 'SYSTEM_ADMIN')")
public ResponseEntity<Slice<ActivityLog>> getRecentActivities(
@PageableDefault(size = 20, sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable
) {
return ResponseEntity.ok(adminDashboardService.getRecentActivities(pageable));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ public ResponseEntity<ExcelSyncResponse> uploadMemberExcel(@RequestPart("file")
"""
)
@GetMapping
@PreAuthorize("hasAnyRole('SYSTEM_ADMIN')")
@PreAuthorize("hasAnyRole('PRESIDENT', 'SYSTEM_ADMIN')")
public ResponseEntity<List<AdminUserResponse>> getAllUsers(@ModelAttribute AdminUserRequest request) {
// TODO: 페이징 추후 고려
return ResponseEntity.ok(adminUserService.findAllUsers(request));
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package org.sejongisc.backend.admin.dto.dashboard;

public record BoardActivityResponse(
String boardName,
long activityCount
) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package org.sejongisc.backend.admin.dto.dashboard;

public record SummaryResponse(
long count,
double percentageComparedToLastWeek
) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package org.sejongisc.backend.admin.dto.dashboard;

public record VisitorTrendResponse(
String date, // "YYYY-MM-DD"
long visitorCount
) {}
Original file line number Diff line number Diff line change
Expand Up @@ -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));

Expand Down
Original file line number Diff line number Diff line change
@@ -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<ActivityType> 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<VisitorTrendResponse> getVisitorTrend(int days) {
LocalDateTime startDate = LocalDate.now().minusDays(days - 1).atStartOfDay();
List<Object[]> 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<BoardActivityResponse> getBoardActivityStats(int days) {
LocalDateTime start = LocalDate.now().minusDays(days - 1).atStartOfDay();
LocalDateTime end = LocalDateTime.now();

List<Object[]> 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<ActivityLog> 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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -31,6 +32,7 @@

@Configuration
@EnableWebSecurity
@EnableMethodSecurity // @PreAuthorize 동작하려면 필요
@RequiredArgsConstructor
public class SecurityConfig {

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ public class GlobalExceptionHandler {
*/
@ExceptionHandler(CustomException.class)
public ResponseEntity<ErrorResponse> handleCustomException(CustomException e) {
log.error("CustomException 발생: {}", e.getMessage(), e);
log.error("CustomException 발생: {}", e.getMessage());

ErrorCode errorCode = e.getErrorCode();
return ResponseEntity
Expand Down