Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 50 additions & 1 deletion docs/api-specs/dashboard-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`보다 늦은 경우) |
Original file line number Diff line number Diff line change
@@ -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 = "가입/활동 지표 등 어드민 대시보드 조회")
Expand All @@ -25,4 +29,14 @@ public class AdminDashboardController {
public ApiResponse<AdminDashboardSummaryResponse> getSummary() {
return ApiResponse.onSuccess(adminDashboardService.getSummary());
}

@Operation(summary = "DAU/MAU 추이 조회", description = "granularity=day면 일자별 DAU, month면 그날 기준 최근 30일 롤링 윈도우의 MAU를 반환한다.")
@GetMapping("/dau-mau")
public ApiResponse<AdminDashboardDauMauResponse> 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));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.swyp.picke.domain.admin.dto.dashboard.response;

import java.util.List;

public record AdminDashboardDauMauResponse(
List<AdminDashboardTrendItemResponse> items
) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.swyp.picke.domain.admin.dto.dashboard.response;

import java.time.LocalDate;

public record AdminDashboardTrendItemResponse(
LocalDate date,
long count
) {}
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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<DailyUserCount> rows = "month".equalsIgnoreCase(granularity)
? userDailyActivityRepository.findRollingMonthlyActiveUserCounts(from, to)
: userDailyActivityRepository.findDailyActiveUserCounts(from, to);

List<AdminDashboardTrendItemResponse> items = rows.stream()
.map(row -> new AdminDashboardTrendItemResponse(row.getActivityDate(), row.getCount()))
.toList();

return new AdminDashboardDauMauResponse(items);
}
}
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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<DailyUserCount> 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<DailyUserCount> findRollingMonthlyActiveUserCounts(@Param("from") LocalDate from, @Param("to") LocalDate to);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.swyp.picke.domain.user.repository.projection;

import java.time.LocalDate;

public interface DailyUserCount {
LocalDate getActivityDate();
long getCount();
}
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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<DailyUserCount> 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<DailyUserCount> 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;
}
};
}
}
Loading