From 756e9c0fb9ecff7704b1dc8ec7e44c62ab5f58eb Mon Sep 17 00:00:00 2001 From: si-zero Date: Tue, 1 Sep 2026 15:51:12 +0900 Subject: [PATCH 1/6] =?UTF-8?q?feat:=20=EC=8B=A0=EA=B7=9C=20=EA=B0=80?= =?UTF-8?q?=EC=9E=85=EC=9E=90=20=EA=B8=B0=EA=B0=84=EB=B3=84=20=EC=B6=94?= =?UTF-8?q?=EC=9D=B4=20=EC=A1=B0=ED=9A=8C=20=EC=A0=80=EC=9E=A5=EC=86=8C=20?= =?UTF-8?q?=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 --- .../user/repository/UserRepository.java | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) 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); } From d00a5427147cdc8e3acb936d1809dc3e87f4bdd5 Mon Sep 17 00:00:00 2001 From: si-zero Date: Tue, 1 Sep 2026 15:51:21 +0900 Subject: [PATCH 2/6] =?UTF-8?q?feat:=20=EC=8B=A0=EA=B7=9C=20=EA=B0=80?= =?UTF-8?q?=EC=9E=85=EC=9E=90=20=EC=B6=94=EC=9D=B4=20=EC=9D=91=EB=8B=B5=20?= =?UTF-8?q?DTO=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dashboard/response/AdminDashboardNewUsersResponse.java | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 src/main/java/com/swyp/picke/domain/admin/dto/dashboard/response/AdminDashboardNewUsersResponse.java 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..402a7e2 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/dashboard/response/AdminDashboardNewUsersResponse.java @@ -0,0 +1,7 @@ +package com.swyp.picke.domain.admin.dto.dashboard.response; + +import java.util.List; + +public record AdminDashboardNewUsersResponse( + List items +) {} From 7bdda36514aad6ee0cdfc72ad075d822d7962a28 Mon Sep 17 00:00:00 2001 From: si-zero Date: Tue, 1 Sep 2026 15:51:35 +0900 Subject: [PATCH 3/6] =?UTF-8?q?feat:=20=EC=8B=A0=EA=B7=9C=20=EA=B0=80?= =?UTF-8?q?=EC=9E=85=EC=9E=90=20=EC=B6=94=EC=9D=B4=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?=EC=84=9C=EB=B9=84=EC=8A=A4=20=EB=A1=9C=EC=A7=81=20=EB=B0=8F=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=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/service/AdminDashboardService.java | 17 ++++++++ .../service/AdminDashboardServiceTest.java | 41 +++++++++++++++++++ 2 files changed, 58 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 6a9f474..a05e2f3 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,20 @@ 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(); + + return new AdminDashboardNewUsersResponse(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 7829bc2..cee3e6e 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,47 @@ 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); + + var response = adminDashboardService.getNewUsersTrend(from, to, "day"); + + assertThat(response.items()).hasSize(2); + assertThat(response.items().get(1).count()).isEqualTo(5L); + } + + @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); + + var response = adminDashboardService.getNewUsersTrend(from, to, "week"); + + assertThat(response.items()).hasSize(1); + assertThat(response.items().get(0).count()).isEqualTo(20L); + } + + @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 From f6214eb833cc7583b0d09d0c1df80d71cdd33ef6 Mon Sep 17 00:00:00 2001 From: si-zero Date: Tue, 1 Sep 2026 15:51:43 +0900 Subject: [PATCH 4/6] =?UTF-8?q?feat:=20=EC=8B=A0=EA=B7=9C=20=EA=B0=80?= =?UTF-8?q?=EC=9E=85=EC=9E=90=20=EC=B6=94=EC=9D=B4=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?API=20=EC=97=94=EB=93=9C=ED=8F=AC=EC=9D=B8=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/controller/AdminDashboardController.java | 11 +++++++++++ 1 file changed, 11 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 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)); + } } From 9eda91e80c4fe8b3fdacd7bba0fec9a26e88b845 Mon Sep 17 00:00:00 2001 From: si-zero Date: Tue, 1 Sep 2026 15:51:43 +0900 Subject: [PATCH 5/6] =?UTF-8?q?docs:=20=EC=8B=A0=EA=B7=9C=20=EA=B0=80?= =?UTF-8?q?=EC=9E=85=EC=9E=90=20=EC=B6=94=EC=9D=B4=20API=20=EB=AA=85?= =?UTF-8?q?=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 | 54 ++++++++++++++++++++++++++++++++- 1 file changed, 53 insertions(+), 1 deletion(-) diff --git a/docs/api-specs/dashboard-api.md b/docs/api-specs/dashboard-api.md index 797163e..b19355c 100644 --- a/docs/api-specs/dashboard-api.md +++ b/docs/api-specs/dashboard-api.md @@ -92,7 +92,59 @@ --- -## 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`는 그 주의 시작일(월요일)입니다. + +성공 응답 `200 OK` (`granularity=day`): + +```json +{ + "statusCode": 200, + "data": { + "items": [ + { "date": "2026-08-01", "count": 12 }, + { "date": "2026-08-02", "count": 8 } + ] + }, + "error": null +} +``` + +성공 응답 `200 OK` (`granularity=week`): + +```json +{ + "statusCode": 200, + "data": { + "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 | 설명 | |---|:---:|---| From abfeeab0cac6dc1775efe4cd0cc527f8cc002266 Mon Sep 17 00:00:00 2001 From: si-zero Date: Tue, 1 Sep 2026 15:57:49 +0900 Subject: [PATCH 6/6] =?UTF-8?q?feat:=20=EC=8B=A0=EA=B7=9C=20=EA=B0=80?= =?UTF-8?q?=EC=9E=85=EC=9E=90=20=EC=B6=94=EC=9D=B4=20=EC=9D=91=EB=8B=B5?= =?UTF-8?q?=EC=97=90=20=EA=B8=B0=EA=B0=84=20=EC=A0=84=EC=B2=B4=20=ED=95=A9?= =?UTF-8?q?=EA=B3=84(totalCount)=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 | 4 ++++ .../dashboard/response/AdminDashboardNewUsersResponse.java | 1 + .../picke/domain/admin/service/AdminDashboardService.java | 6 +++++- .../domain/admin/service/AdminDashboardServiceTest.java | 4 ++++ 4 files changed, 14 insertions(+), 1 deletion(-) diff --git a/docs/api-specs/dashboard-api.md b/docs/api-specs/dashboard-api.md index b19355c..25c99ba 100644 --- a/docs/api-specs/dashboard-api.md +++ b/docs/api-specs/dashboard-api.md @@ -110,12 +110,15 @@ `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 } @@ -131,6 +134,7 @@ { "statusCode": 200, "data": { + "totalCount": 130, "items": [ { "date": "2026-07-27", "count": 65 }, { "date": "2026-08-03", "count": 71 } 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 index 402a7e2..3110208 100644 --- 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 @@ -3,5 +3,6 @@ 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 a05e2f3..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 @@ -67,6 +67,10 @@ public AdminDashboardNewUsersResponse getNewUsersTrend(LocalDate from, LocalDate .map(row -> new AdminDashboardTrendItemResponse(row.getActivityDate(), row.getCount())) .toList(); - return new AdminDashboardNewUsersResponse(items); + // 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/test/java/com/swyp/picke/domain/admin/service/AdminDashboardServiceTest.java b/src/test/java/com/swyp/picke/domain/admin/service/AdminDashboardServiceTest.java index cee3e6e..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 @@ -103,11 +103,13 @@ void getNewUsersTrend_day_returnsDailyCounts() { 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 @@ -117,11 +119,13 @@ void getNewUsersTrend_week_returnsWeeklyCounts() { 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