diff --git a/docs/api-specs/dashboard-api.md b/docs/api-specs/dashboard-api.md index 797163e..25c99ba 100644 --- a/docs/api-specs/dashboard-api.md +++ b/docs/api-specs/dashboard-api.md @@ -92,7 +92,63 @@ --- -## 4. 에러 코드 +## 4. `GET /api/v1/admin/dashboard/new-users` + +기간별 신규 가입자 추이(꺾은선 그래프용)를 조회합니다. `dau-mau`와 동일한 파라미터 형태를 쓰되, 롤링 윈도우 없이 단순 카운트만 수행합니다. 가입자가 없는 날짜/주도 0으로 채워서 반환합니다. + +요청 헤더: + +- `Authorization: Bearer {access_token}` + +쿼리 파라미터: + +| 파라미터 | 타입 | 필수 | 설명 | +|---|---|---|---| +| `from` | `string` (`YYYY-MM-DD`) | Y | 조회 시작일 | +| `to` | `string` (`YYYY-MM-DD`) | Y | 조회 종료일 | +| `granularity` | `string` | N | `day`(기본값, 일자별) \| `week`(주별, ISO 8601 월요일 시작) | + +`granularity=week`일 때 각 항목의 `date`는 그 주의 시작일(월요일)입니다. + +`totalCount`는 `from`~`to` 구간 전체의 정확한 합계입니다. `granularity=week`로 조회하면 주 경계가 `from`/`to`를 벗어나는 날짜까지 포함할 수 있어(예: `to`가 주 중간이면 그 주 전체가 한 항목으로 잡힘) `items`의 `count`를 그냥 더한 값과 `totalCount`가 다를 수 있습니다 — **임의 기간의 정확한 합계가 필요하면 `granularity` 값과 무관하게 `totalCount`를 사용하세요.** + +성공 응답 `200 OK` (`granularity=day`): + +```json +{ + "statusCode": 200, + "data": { + "totalCount": 20, + "items": [ + { "date": "2026-08-01", "count": 12 }, + { "date": "2026-08-02", "count": 8 } + ] + }, + "error": null +} +``` + +성공 응답 `200 OK` (`granularity=week`): + +```json +{ + "statusCode": 200, + "data": { + "totalCount": 130, + "items": [ + { "date": "2026-07-27", "count": 65 }, + { "date": "2026-08-03", "count": 71 } + ] + }, + "error": null +} +``` + +`from`이 `to`보다 늦으면 `dau-mau`와 동일하게 `COMMON_400`으로 400을 반환합니다. + +--- + +## 5. 에러 코드 | Error Code | HTTP Status | 설명 | |---|:---:|---| 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 4ae6223..2b21e04 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,6 +1,7 @@ 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.AdminDashboardNewUsersResponse; 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; @@ -39,4 +40,14 @@ public ApiResponse getDauMauTrend( ) { return ApiResponse.onSuccess(adminDashboardService.getDauMauTrend(from, to, granularity)); } + + @Operation(summary = "신규 가입자 추이 조회", description = "granularity=day면 일자별, week면 주별(ISO 8601, 월요일 시작) 신규 가입자 수를 반환한다.") + @GetMapping("/new-users") + public ApiResponse getNewUsersTrend( + @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.getNewUsersTrend(from, to, granularity)); + } } diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/dashboard/response/AdminDashboardNewUsersResponse.java b/src/main/java/com/swyp/picke/domain/admin/dto/dashboard/response/AdminDashboardNewUsersResponse.java new file mode 100644 index 0000000..3110208 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/dashboard/response/AdminDashboardNewUsersResponse.java @@ -0,0 +1,8 @@ +package com.swyp.picke.domain.admin.dto.dashboard.response; + +import java.util.List; + +public record AdminDashboardNewUsersResponse( + long totalCount, + List items +) {} 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 6a9f474..190c7ef 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,6 +1,7 @@ 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.AdminDashboardNewUsersResponse; 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; @@ -52,4 +53,24 @@ public AdminDashboardDauMauResponse getDauMauTrend(LocalDate from, LocalDate to, return new AdminDashboardDauMauResponse(items); } + + public AdminDashboardNewUsersResponse getNewUsersTrend(LocalDate from, LocalDate to, String granularity) { + if (from.isAfter(to)) { + throw new CustomException(ErrorCode.COMMON_INVALID_PARAMETER); + } + + List rows = "week".equalsIgnoreCase(granularity) + ? userRepository.findWeeklyNewUserCounts(from, to) + : userRepository.findDailyNewUserCounts(from, to); + + List items = rows.stream() + .map(row -> new AdminDashboardTrendItemResponse(row.getActivityDate(), row.getCount())) + .toList(); + + // week 단위 items는 주 경계가 from~to 범위를 벗어날 수 있어(예: to가 주 중간이면 그 주 전체를 포함), + // 요청한 기간 전체의 정확한 합계는 items 합산이 아니라 별도 카운트로 계산한다. + long totalCount = userRepository.countByCreatedAtBetween(from.atStartOfDay(), to.plusDays(1).atStartOfDay()); + + return new AdminDashboardNewUsersResponse(totalCount, items); + } } 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 a515874..9bf3d74 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,8 @@ 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 com.swyp.picke.domain.user.repository.projection.DailyUserCount; +import java.time.LocalDate; import java.time.LocalDateTime; import java.util.List; import java.util.Optional; @@ -44,4 +46,33 @@ or u.userTag like concat('%', :keyword, '%') long countByStatus(UserStatus status); long countByCreatedAtBetween(LocalDateTime start, LocalDateTime end); + + /** + * from~to 구간의 일자별 신규 가입자 수. 가입자가 없는 날짜도 0으로 채워서 반환한다. + */ + @Query(value = """ + SELECT gs::date AS activity_date, COUNT(u.id) AS count + FROM generate_series(CAST(:from AS timestamp), CAST(:to AS timestamp), interval '1 day') AS gs + LEFT JOIN users u ON CAST(u.created_at AS date) = gs::date + GROUP BY gs::date + ORDER BY gs::date + """, nativeQuery = true) + List findDailyNewUserCounts(@Param("from") LocalDate from, @Param("to") LocalDate to); + + /** + * from~to 구간이 걸쳐있는 주(ISO 8601, 월요일 시작)별 신규 가입자 수. 가입자가 없는 주도 0으로 채워서 반환하며, + * 반환되는 날짜는 각 주의 시작일(월요일)이다. + */ + @Query(value = """ + SELECT gs::date AS activity_date, COUNT(u.id) AS count + FROM generate_series( + date_trunc('week', CAST(:from AS timestamp)), + date_trunc('week', CAST(:to AS timestamp)), + interval '1 week' + ) AS gs + LEFT JOIN users u ON date_trunc('week', u.created_at) = gs + GROUP BY gs::date + ORDER BY gs::date + """, nativeQuery = true) + List findWeeklyNewUserCounts(@Param("from") LocalDate from, @Param("to") LocalDate to); } 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 7829bc2..1d752d0 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 @@ -93,6 +93,51 @@ void getDauMauTrend_throws_whenFromIsAfterTo() { .isInstanceOf(CustomException.class); } + @Test + @DisplayName("granularity=day면 일자별 신규 가입자 수를 조회한다") + void getNewUsersTrend_day_returnsDailyCounts() { + LocalDate from = LocalDate.of(2026, 8, 1); + LocalDate to = LocalDate.of(2026, 8, 2); + List rows = List.of( + dailyUserCount(LocalDate.of(2026, 8, 1), 3L), + dailyUserCount(LocalDate.of(2026, 8, 2), 5L) + ); + when(userRepository.findDailyNewUserCounts(from, to)).thenReturn(rows); + when(userRepository.countByCreatedAtBetween(any(), any())).thenReturn(8L); + + var response = adminDashboardService.getNewUsersTrend(from, to, "day"); + + assertThat(response.items()).hasSize(2); + assertThat(response.items().get(1).count()).isEqualTo(5L); + assertThat(response.totalCount()).isEqualTo(8L); + } + + @Test + @DisplayName("granularity=week면 주별 신규 가입자 수를 조회한다") + void getNewUsersTrend_week_returnsWeeklyCounts() { + LocalDate from = LocalDate.of(2026, 8, 1); + LocalDate to = LocalDate.of(2026, 8, 14); + List rows = List.of(dailyUserCount(LocalDate.of(2026, 7, 27), 20L)); + when(userRepository.findWeeklyNewUserCounts(from, to)).thenReturn(rows); + when(userRepository.countByCreatedAtBetween(any(), any())).thenReturn(18L); + + var response = adminDashboardService.getNewUsersTrend(from, to, "week"); + + assertThat(response.items()).hasSize(1); + assertThat(response.items().get(0).count()).isEqualTo(20L); + assertThat(response.totalCount()).isEqualTo(18L); + } + + @Test + @DisplayName("신규 가입자 추이 조회 시 from이 to보다 늦으면 예외를 던진다") + void getNewUsersTrend_throws_whenFromIsAfterTo() { + LocalDate from = LocalDate.of(2026, 8, 10); + LocalDate to = LocalDate.of(2026, 8, 1); + + assertThatThrownBy(() -> adminDashboardService.getNewUsersTrend(from, to, "day")) + .isInstanceOf(CustomException.class); + } + private DailyUserCount dailyUserCount(LocalDate date, long count) { return new DailyUserCount() { @Override