diff --git a/docs/api-specs/dashboard-api.md b/docs/api-specs/dashboard-api.md index 1e53c14..797163e 100644 --- a/docs/api-specs/dashboard-api.md +++ b/docs/api-specs/dashboard-api.md @@ -44,8 +44,57 @@ --- -## 3. 에러 코드 +## 3. `GET /api/v1/admin/dashboard/dau-mau` + +기간별 DAU/MAU 추이(꺾은선 그래프용)를 조회합니다. 활동이 없는 날짜도 0으로 채워서 반환합니다. + +요청 헤더: + +- `Authorization: Bearer {access_token}` + +쿼리 파라미터: + +| 파라미터 | 타입 | 필수 | 설명 | +|---|---|---|---| +| `from` | `string` (`YYYY-MM-DD`) | Y | 조회 시작일 | +| `to` | `string` (`YYYY-MM-DD`) | Y | 조회 종료일 | +| `granularity` | `string` | N | `day`(기본값, DAU) \| `month`(MAU) | + +`granularity=day`는 그날그날의 활동 유저 수(단순 카운트)를, `granularity=month`는 그날 기준 최근 30일 롤링 윈도우의 distinct 활동 유저 수(MAU)를 반환합니다. 예를 들어 `to=2026-08-15`일 때 8/15 항목의 `count`는 `2026-07-17~2026-08-15` 사이에 한 번이라도 활동한 유저 수입니다. + +성공 응답 `200 OK` (`granularity=day`): + +```json +{ + "statusCode": 200, + "data": { + "items": [ + { "date": "2026-08-01", "count": 320 }, + { "date": "2026-08-02", "count": 410 } + ] + }, + "error": null +} +``` + +예외 응답 `400 - from이 to보다 늦음`: + +```json +{ + "statusCode": 400, + "data": null, + "error": { + "code": "COMMON_400", + "message": "요청 파라미터가 잘못되었습니다." + } +} +``` + +--- + +## 4. 에러 코드 | Error Code | HTTP Status | 설명 | |---|:---:|---| | `AUTH_403` | `403` | 해당 API 접근 권한(관리자 권한)이 없습니다. | +| `COMMON_400` | `400` | 요청 파라미터가 잘못되었습니다. (예: `from`이 `to`보다 늦은 경우) | 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 index b0020ff..4ae6223 100644 --- a/src/main/java/com/swyp/picke/domain/admin/controller/AdminDashboardController.java +++ b/src/main/java/com/swyp/picke/domain/admin/controller/AdminDashboardController.java @@ -1,14 +1,18 @@ package com.swyp.picke.domain.admin.controller; +import com.swyp.picke.domain.admin.dto.dashboard.response.AdminDashboardDauMauResponse; 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 java.time.LocalDate; import lombok.RequiredArgsConstructor; +import org.springframework.format.annotation.DateTimeFormat; 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.RequestParam; import org.springframework.web.bind.annotation.RestController; @Tag(name = "관리자 대시보드 API", description = "가입/활동 지표 등 어드민 대시보드 조회") @@ -25,4 +29,14 @@ public class AdminDashboardController { public ApiResponse getSummary() { return ApiResponse.onSuccess(adminDashboardService.getSummary()); } + + @Operation(summary = "DAU/MAU 추이 조회", description = "granularity=day면 일자별 DAU, month면 그날 기준 최근 30일 롤링 윈도우의 MAU를 반환한다.") + @GetMapping("/dau-mau") + public ApiResponse getDauMauTrend( + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate from, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate to, + @RequestParam(defaultValue = "day") String granularity + ) { + return ApiResponse.onSuccess(adminDashboardService.getDauMauTrend(from, to, granularity)); + } } diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/dashboard/response/AdminDashboardDauMauResponse.java b/src/main/java/com/swyp/picke/domain/admin/dto/dashboard/response/AdminDashboardDauMauResponse.java new file mode 100644 index 0000000..0647690 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/dashboard/response/AdminDashboardDauMauResponse.java @@ -0,0 +1,7 @@ +package com.swyp.picke.domain.admin.dto.dashboard.response; + +import java.util.List; + +public record AdminDashboardDauMauResponse( + List items +) {} diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/dashboard/response/AdminDashboardTrendItemResponse.java b/src/main/java/com/swyp/picke/domain/admin/dto/dashboard/response/AdminDashboardTrendItemResponse.java new file mode 100644 index 0000000..9ac9df0 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/dashboard/response/AdminDashboardTrendItemResponse.java @@ -0,0 +1,8 @@ +package com.swyp.picke.domain.admin.dto.dashboard.response; + +import java.time.LocalDate; + +public record AdminDashboardTrendItemResponse( + LocalDate date, + long count +) {} 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 index c651f15..6a9f474 100644 --- a/src/main/java/com/swyp/picke/domain/admin/service/AdminDashboardService.java +++ b/src/main/java/com/swyp/picke/domain/admin/service/AdminDashboardService.java @@ -1,11 +1,17 @@ package com.swyp.picke.domain.admin.service; +import com.swyp.picke.domain.admin.dto.dashboard.response.AdminDashboardDauMauResponse; import com.swyp.picke.domain.admin.dto.dashboard.response.AdminDashboardSummaryResponse; +import com.swyp.picke.domain.admin.dto.dashboard.response.AdminDashboardTrendItemResponse; 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 com.swyp.picke.domain.user.repository.projection.DailyUserCount; +import com.swyp.picke.global.common.exception.CustomException; +import com.swyp.picke.global.common.exception.ErrorCode; import java.time.LocalDate; import java.time.LocalDateTime; +import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -30,4 +36,20 @@ public AdminDashboardSummaryResponse getSummary() { return new AdminDashboardSummaryResponse(newUserCount, loginUserCount, activeUserCount, totalUserCount); } + + public AdminDashboardDauMauResponse getDauMauTrend(LocalDate from, LocalDate to, String granularity) { + if (from.isAfter(to)) { + throw new CustomException(ErrorCode.COMMON_INVALID_PARAMETER); + } + + List rows = "month".equalsIgnoreCase(granularity) + ? userDailyActivityRepository.findRollingMonthlyActiveUserCounts(from, to) + : userDailyActivityRepository.findDailyActiveUserCounts(from, to); + + List items = rows.stream() + .map(row -> new AdminDashboardTrendItemResponse(row.getActivityDate(), row.getCount())) + .toList(); + + return new AdminDashboardDauMauResponse(items); + } } 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 b95618e..a4e995f 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 @@ -1,7 +1,9 @@ package com.swyp.picke.domain.user.repository; import com.swyp.picke.domain.user.entity.UserDailyActivity; +import com.swyp.picke.domain.user.repository.projection.DailyUserCount; import java.time.LocalDate; +import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; @@ -33,4 +35,30 @@ ON CONFLICT (user_id, activity_date) long countByActivityDateAndLoggedInTrue(LocalDate activityDate); long countByActivityDateAndActiveTrue(LocalDate activityDate); + + /** + * from~to 구간의 일자별 DAU(그날 active=true인 유저 수). 활동이 없는 날짜도 0으로 채워서 반환한다. + */ + @Query(value = """ + SELECT gs::date AS activity_date, COUNT(uda.user_id) AS count + FROM generate_series(CAST(:from AS timestamp), CAST(:to AS timestamp), interval '1 day') AS gs + LEFT JOIN user_daily_activities uda + ON uda.activity_date = gs::date AND uda.active = true + GROUP BY gs::date + ORDER BY gs::date + """, nativeQuery = true) + List findDailyActiveUserCounts(@Param("from") LocalDate from, @Param("to") LocalDate to); + + /** + * from~to 구간의 일자별 MAU(그날 기준 최근 30일 롤링 윈도우의 distinct active 유저 수). + */ + @Query(value = """ + SELECT gs::date AS activity_date, COUNT(DISTINCT uda.user_id) AS count + FROM generate_series(CAST(:from AS timestamp), CAST(:to AS timestamp), interval '1 day') AS gs + LEFT JOIN user_daily_activities uda + ON uda.activity_date BETWEEN (gs::date - INTERVAL '29 days') AND gs::date AND uda.active = true + GROUP BY gs::date + ORDER BY gs::date + """, nativeQuery = true) + List findRollingMonthlyActiveUserCounts(@Param("from") LocalDate from, @Param("to") LocalDate to); } diff --git a/src/main/java/com/swyp/picke/domain/user/repository/projection/DailyUserCount.java b/src/main/java/com/swyp/picke/domain/user/repository/projection/DailyUserCount.java new file mode 100644 index 0000000..c615daa --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/user/repository/projection/DailyUserCount.java @@ -0,0 +1,8 @@ +package com.swyp.picke.domain.user.repository.projection; + +import java.time.LocalDate; + +public interface DailyUserCount { + LocalDate getActivityDate(); + long getCount(); +} 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 index d43e429..7829bc2 100644 --- a/src/test/java/com/swyp/picke/domain/admin/service/AdminDashboardServiceTest.java +++ b/src/test/java/com/swyp/picke/domain/admin/service/AdminDashboardServiceTest.java @@ -1,15 +1,20 @@ package com.swyp.picke.domain.admin.service; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; 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.AdminDashboardDauMauResponse; 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 com.swyp.picke.domain.user.repository.projection.DailyUserCount; +import com.swyp.picke.global.common.exception.CustomException; import java.time.LocalDate; +import java.util.List; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -44,4 +49,61 @@ void getSummary_returnsTodaySummary() { assertThat(response.activeUserCount()).isEqualTo(500L); assertThat(response.totalUserCount()).isEqualTo(58000L); } + + @Test + @DisplayName("granularity=day면 일자별 DAU 카운트를 조회한다") + void getDauMauTrend_day_returnsDailyActiveCounts() { + LocalDate from = LocalDate.of(2026, 8, 1); + LocalDate to = LocalDate.of(2026, 8, 2); + List rows = List.of( + dailyUserCount(LocalDate.of(2026, 8, 1), 10L), + dailyUserCount(LocalDate.of(2026, 8, 2), 15L) + ); + when(userDailyActivityRepository.findDailyActiveUserCounts(from, to)).thenReturn(rows); + + AdminDashboardDauMauResponse response = adminDashboardService.getDauMauTrend(from, to, "day"); + + assertThat(response.items()).hasSize(2); + assertThat(response.items().get(0).date()).isEqualTo(LocalDate.of(2026, 8, 1)); + assertThat(response.items().get(0).count()).isEqualTo(10L); + assertThat(response.items().get(1).count()).isEqualTo(15L); + } + + @Test + @DisplayName("granularity=month면 롤링 30일 MAU 카운트를 조회한다") + void getDauMauTrend_month_returnsRollingMonthlyCounts() { + LocalDate from = LocalDate.of(2026, 8, 1); + LocalDate to = LocalDate.of(2026, 8, 1); + List rows = List.of(dailyUserCount(LocalDate.of(2026, 8, 1), 1200L)); + when(userDailyActivityRepository.findRollingMonthlyActiveUserCounts(from, to)).thenReturn(rows); + + AdminDashboardDauMauResponse response = adminDashboardService.getDauMauTrend(from, to, "month"); + + assertThat(response.items()).hasSize(1); + assertThat(response.items().get(0).count()).isEqualTo(1200L); + } + + @Test + @DisplayName("from이 to보다 늦으면 예외를 던진다") + void getDauMauTrend_throws_whenFromIsAfterTo() { + LocalDate from = LocalDate.of(2026, 8, 10); + LocalDate to = LocalDate.of(2026, 8, 1); + + assertThatThrownBy(() -> adminDashboardService.getDauMauTrend(from, to, "day")) + .isInstanceOf(CustomException.class); + } + + private DailyUserCount dailyUserCount(LocalDate date, long count) { + return new DailyUserCount() { + @Override + public LocalDate getActivityDate() { + return date; + } + + @Override + public long getCount() { + return count; + } + }; + } }