diff --git a/docs/api-specs/dashboard-api.md b/docs/api-specs/dashboard-api.md new file mode 100644 index 0000000..1e53c14 --- /dev/null +++ b/docs/api-specs/dashboard-api.md @@ -0,0 +1,51 @@ +# 어드민 대시보드 API 명세서 + +--- + +## 1. 설계 메모 + +- 모든 API는 `Authorization: Bearer {access_token}` 헤더가 필요하며, `ROLE_ADMIN` 권한을 가진 계정만 호출 가능합니다. +- DAU(활동 유저)/로그인 유저 지표는 `user_daily_activities` 테이블 기반입니다. 유저-날짜(`user_id`, `activity_date`) 조합당 row 1개이며, 두 플래그를 관리합니다. + - `active`: 그날 인증된 API를 한 번이라도 호출했는지 (`JwtFilter`에서 매 요청마다 기록) + - `logged_in`: 그날 로그인(소셜/로컬)했는지 (`AuthService`에서 로그인 성공 시점에만 기록, 토큰 refresh는 제외) +- `totalUserCount`는 날짜 필터와 무관하게 현재 `UserStatus.ACTIVE` 상태인 전체 유저 수(누적)입니다. + +--- + +## 2. `GET /api/v1/admin/dashboard/summary` + +오늘(서버 로컬 날짜 기준) 요약 카드용 지표를 조회합니다. + +요청 헤더: + +- `Authorization: Bearer {access_token}` + +성공 응답 `200 OK`: + +```json +{ + "statusCode": 200, + "data": { + "newUserCount": 12, + "loginUserCount": 340, + "activeUserCount": 500, + "totalUserCount": 58000 + }, + "error": null +} +``` + +| 필드 | 설명 | +|---|---| +| `newUserCount` | 오늘 가입한 유저 수 (`users.created_at`) | +| `loginUserCount` | 오늘 로그인한 유저 수 (`user_daily_activities.logged_in=true`) | +| `activeUserCount` | 오늘 활동(DAU)한 유저 수 (`user_daily_activities.active=true`) | +| `totalUserCount` | 현재 `ACTIVE` 상태 전체 유저 수 (날짜 무관 누적) | + +--- + +## 3. 에러 코드 + +| Error Code | HTTP Status | 설명 | +|---|:---:|---| +| `AUTH_403` | `403` | 해당 API 접근 권한(관리자 권한)이 없습니다. | diff --git a/src/main/java/com/swyp/picke/domain/admin/controller/AdminDashboardController.java b/src/main/java/com/swyp/picke/domain/admin/controller/AdminDashboardController.java new file mode 100644 index 0000000..b0020ff --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/controller/AdminDashboardController.java @@ -0,0 +1,28 @@ +package com.swyp.picke.domain.admin.controller; + +import com.swyp.picke.domain.admin.dto.dashboard.response.AdminDashboardSummaryResponse; +import com.swyp.picke.domain.admin.service.AdminDashboardService; +import com.swyp.picke.global.common.response.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "관리자 대시보드 API", description = "가입/활동 지표 등 어드민 대시보드 조회") +@RestController +@RequestMapping("/api/v1/admin/dashboard") +@RequiredArgsConstructor +@PreAuthorize("hasRole('ADMIN')") +public class AdminDashboardController { + + private final AdminDashboardService adminDashboardService; + + @Operation(summary = "오늘 요약 카드 조회", description = "오늘 기준 신규가입/로그인유저/활동유저(DAU) 수와 현재 전체 유저 수를 조회한다.") + @GetMapping("/summary") + public ApiResponse getSummary() { + return ApiResponse.onSuccess(adminDashboardService.getSummary()); + } +} diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/dashboard/response/AdminDashboardSummaryResponse.java b/src/main/java/com/swyp/picke/domain/admin/dto/dashboard/response/AdminDashboardSummaryResponse.java new file mode 100644 index 0000000..876615d --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/dashboard/response/AdminDashboardSummaryResponse.java @@ -0,0 +1,8 @@ +package com.swyp.picke.domain.admin.dto.dashboard.response; + +public record AdminDashboardSummaryResponse( + long newUserCount, + long loginUserCount, + long activeUserCount, + long totalUserCount +) {} diff --git a/src/main/java/com/swyp/picke/domain/admin/service/AdminDashboardService.java b/src/main/java/com/swyp/picke/domain/admin/service/AdminDashboardService.java new file mode 100644 index 0000000..c651f15 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/service/AdminDashboardService.java @@ -0,0 +1,33 @@ +package com.swyp.picke.domain.admin.service; + +import com.swyp.picke.domain.admin.dto.dashboard.response.AdminDashboardSummaryResponse; +import com.swyp.picke.domain.user.enums.UserStatus; +import com.swyp.picke.domain.user.repository.UserDailyActivityRepository; +import com.swyp.picke.domain.user.repository.UserRepository; +import java.time.LocalDate; +import java.time.LocalDateTime; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AdminDashboardService { + + private final UserRepository userRepository; + private final UserDailyActivityRepository userDailyActivityRepository; + + public AdminDashboardSummaryResponse getSummary() { + LocalDate today = LocalDate.now(); + LocalDateTime todayStart = today.atStartOfDay(); + LocalDateTime tomorrowStart = todayStart.plusDays(1); + + long newUserCount = userRepository.countByCreatedAtBetween(todayStart, tomorrowStart); + long loginUserCount = userDailyActivityRepository.countByActivityDateAndLoggedInTrue(today); + long activeUserCount = userDailyActivityRepository.countByActivityDateAndActiveTrue(today); + long totalUserCount = userRepository.countByStatus(UserStatus.ACTIVE); + + return new AdminDashboardSummaryResponse(newUserCount, loginUserCount, activeUserCount, totalUserCount); + } +} diff --git a/src/main/java/com/swyp/picke/domain/user/repository/UserDailyActivityRepository.java b/src/main/java/com/swyp/picke/domain/user/repository/UserDailyActivityRepository.java index f61b32f..b95618e 100644 --- a/src/main/java/com/swyp/picke/domain/user/repository/UserDailyActivityRepository.java +++ b/src/main/java/com/swyp/picke/domain/user/repository/UserDailyActivityRepository.java @@ -29,4 +29,8 @@ ON CONFLICT (user_id, activity_date) DO UPDATE SET logged_in = true, updated_at = now() """, nativeQuery = true) void markLoggedIn(@Param("userId") Long userId, @Param("date") LocalDate date); + + long countByActivityDateAndLoggedInTrue(LocalDate activityDate); + + long countByActivityDateAndActiveTrue(LocalDate activityDate); } diff --git a/src/main/java/com/swyp/picke/domain/user/repository/UserRepository.java b/src/main/java/com/swyp/picke/domain/user/repository/UserRepository.java index 0911ecc..a515874 100644 --- a/src/main/java/com/swyp/picke/domain/user/repository/UserRepository.java +++ b/src/main/java/com/swyp/picke/domain/user/repository/UserRepository.java @@ -3,6 +3,7 @@ import com.swyp.picke.domain.user.entity.User; import com.swyp.picke.domain.user.enums.UserRole; import com.swyp.picke.domain.user.enums.UserStatus; +import java.time.LocalDateTime; import java.util.List; import java.util.Optional; import org.springframework.data.domain.Pageable; @@ -39,4 +40,8 @@ or u.userTag like concat('%', :keyword, '%') order by u.id desc """) Slice searchByNicknameOrUserTag(@Param("keyword") String keyword, Pageable pageable); + + long countByStatus(UserStatus status); + + long countByCreatedAtBetween(LocalDateTime start, LocalDateTime end); } diff --git a/src/test/java/com/swyp/picke/domain/admin/service/AdminDashboardServiceTest.java b/src/test/java/com/swyp/picke/domain/admin/service/AdminDashboardServiceTest.java new file mode 100644 index 0000000..d43e429 --- /dev/null +++ b/src/test/java/com/swyp/picke/domain/admin/service/AdminDashboardServiceTest.java @@ -0,0 +1,47 @@ +package com.swyp.picke.domain.admin.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +import com.swyp.picke.domain.admin.dto.dashboard.response.AdminDashboardSummaryResponse; +import com.swyp.picke.domain.user.enums.UserStatus; +import com.swyp.picke.domain.user.repository.UserDailyActivityRepository; +import com.swyp.picke.domain.user.repository.UserRepository; +import java.time.LocalDate; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class AdminDashboardServiceTest { + + @Mock + private UserRepository userRepository; + + @Mock + private UserDailyActivityRepository userDailyActivityRepository; + + @InjectMocks + private AdminDashboardService adminDashboardService; + + @Test + @DisplayName("오늘 요약 카드를 조회한다") + void getSummary_returnsTodaySummary() { + when(userRepository.countByCreatedAtBetween(any(), any())).thenReturn(12L); + when(userDailyActivityRepository.countByActivityDateAndLoggedInTrue(eq(LocalDate.now()))).thenReturn(340L); + when(userDailyActivityRepository.countByActivityDateAndActiveTrue(eq(LocalDate.now()))).thenReturn(500L); + when(userRepository.countByStatus(UserStatus.ACTIVE)).thenReturn(58000L); + + AdminDashboardSummaryResponse response = adminDashboardService.getSummary(); + + assertThat(response.newUserCount()).isEqualTo(12L); + assertThat(response.loginUserCount()).isEqualTo(340L); + assertThat(response.activeUserCount()).isEqualTo(500L); + assertThat(response.totalUserCount()).isEqualTo(58000L); + } +}