From cea8abb7bde46dfa11c5bac39f33c1736c379164 Mon Sep 17 00:00:00 2001 From: si-zero Date: Tue, 1 Sep 2026 14:56:56 +0900 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20DAU/MAU=20=EA=B8=B0=EA=B0=84?= =?UTF-8?q?=EB=B3=84=20=EC=B6=94=EC=9D=B4=20=EC=A1=B0=ED=9A=8C=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=EC=86=8C=20=EC=BF=BC=EB=A6=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../UserDailyActivityRepository.java | 28 +++++++++++++++++++ .../repository/projection/DailyUserCount.java | 8 ++++++ 2 files changed, 36 insertions(+) create mode 100644 src/main/java/com/swyp/picke/domain/user/repository/projection/DailyUserCount.java 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(); +} From ba546352f6ad364057c16d99e8f322dcbdd69724 Mon Sep 17 00:00:00 2001 From: si-zero Date: Tue, 1 Sep 2026 14:57:10 +0900 Subject: [PATCH 2/5] =?UTF-8?q?feat:=20DAU/MAU=20=EC=B6=94=EC=9D=B4=20?= =?UTF-8?q?=EC=9D=91=EB=8B=B5=20DTO=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dashboard/response/AdminDashboardDauMauResponse.java | 7 +++++++ .../response/AdminDashboardTrendItemResponse.java | 8 ++++++++ 2 files changed, 15 insertions(+) create mode 100644 src/main/java/com/swyp/picke/domain/admin/dto/dashboard/response/AdminDashboardDauMauResponse.java create mode 100644 src/main/java/com/swyp/picke/domain/admin/dto/dashboard/response/AdminDashboardTrendItemResponse.java 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 +) {} From b64a153e68ec968fceb6cc63767fb4f6ccad53b8 Mon Sep 17 00:00:00 2001 From: si-zero Date: Tue, 1 Sep 2026 14:57:20 +0900 Subject: [PATCH 3/5] =?UTF-8?q?feat:=20DAU/MAU=20=EC=B6=94=EC=9D=B4=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EC=84=9C=EB=B9=84=EC=8A=A4=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EB=B0=8F=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/service/AdminDashboardService.java | 22 +++++++ .../service/AdminDashboardServiceTest.java | 62 +++++++++++++++++++ 2 files changed, 84 insertions(+) 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/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; + } + }; + } } From 734d2030778d95ff69dcd85b797c2192050a32c4 Mon Sep 17 00:00:00 2001 From: si-zero Date: Tue, 1 Sep 2026 14:57:30 +0900 Subject: [PATCH 4/5] =?UTF-8?q?feat:=20DAU/MAU=20=EC=B6=94=EC=9D=B4=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20API=20=EC=97=94=EB=93=9C=ED=8F=AC=EC=9D=B8?= =?UTF-8?q?=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/controller/AdminDashboardController.java | 14 ++++++++++++++ 1 file changed, 14 insertions(+) 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)); + } } From 4805af9cf852125494ab966793ae57b15d43e6b1 Mon Sep 17 00:00:00 2001 From: si-zero Date: Tue, 1 Sep 2026 14:57:31 +0900 Subject: [PATCH 5/5] =?UTF-8?q?docs:=20DAU/MAU=20=EC=B6=94=EC=9D=B4=20API?= =?UTF-8?q?=20=EB=AA=85=EC=84=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/api-specs/dashboard-api.md | 51 ++++++++++++++++++++++++++++++++- 1 file changed, 50 insertions(+), 1 deletion(-) 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`보다 늦은 경우) |