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: 51 additions & 0 deletions docs/api-specs/dashboard-api.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# 어드민 대시보드 API 명세서

---

## 1. 설계 메모

- 모든 API는 `Authorization: Bearer {access_token}` 헤더가 필요하며, `ROLE_ADMIN` 권한을 가진 계정만 호출 가능합니다.
- DAU(활동 유저)/로그인 유저 지표는 `user_daily_activities` 테이블 기반입니다. 유저-날짜(`user_id`, `activity_date`) 조합당 row 1개이며, 두 플래그를 관리합니다.
- `active`: 그날 인증된 API를 한 번이라도 호출했는지 (`JwtFilter`에서 매 요청마다 기록)
- `logged_in`: 그날 로그인(소셜/로컬)했는지 (`AuthService`에서 로그인 성공 시점에만 기록, 토큰 refresh는 제외)
- `totalUserCount`는 날짜 필터와 무관하게 현재 `UserStatus.ACTIVE` 상태인 전체 유저 수(누적)입니다.

---

## 2. `GET /api/v1/admin/dashboard/summary`

오늘(서버 로컬 날짜 기준) 요약 카드용 지표를 조회합니다.

요청 헤더:

- `Authorization: Bearer {access_token}`

성공 응답 `200 OK`:

```json
{
"statusCode": 200,
"data": {
"newUserCount": 12,
"loginUserCount": 340,
"activeUserCount": 500,
"totalUserCount": 58000
},
"error": null
}
```

| 필드 | 설명 |
|---|---|
| `newUserCount` | 오늘 가입한 유저 수 (`users.created_at`) |
| `loginUserCount` | 오늘 로그인한 유저 수 (`user_daily_activities.logged_in=true`) |
| `activeUserCount` | 오늘 활동(DAU)한 유저 수 (`user_daily_activities.active=true`) |
| `totalUserCount` | 현재 `ACTIVE` 상태 전체 유저 수 (날짜 무관 누적) |

---

## 3. 에러 코드

| Error Code | HTTP Status | 설명 |
|---|:---:|---|
| `AUTH_403` | `403` | 해당 API 접근 권한(관리자 권한)이 없습니다. |
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package com.swyp.picke.domain.admin.controller;

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 lombok.RequiredArgsConstructor;
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.RestController;

@Tag(name = "관리자 대시보드 API", description = "가입/활동 지표 등 어드민 대시보드 조회")
@RestController
@RequestMapping("/api/v1/admin/dashboard")
@RequiredArgsConstructor
@PreAuthorize("hasRole('ADMIN')")
public class AdminDashboardController {

private final AdminDashboardService adminDashboardService;

@Operation(summary = "오늘 요약 카드 조회", description = "오늘 기준 신규가입/로그인유저/활동유저(DAU) 수와 현재 전체 유저 수를 조회한다.")
@GetMapping("/summary")
public ApiResponse<AdminDashboardSummaryResponse> getSummary() {
return ApiResponse.onSuccess(adminDashboardService.getSummary());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.swyp.picke.domain.admin.dto.dashboard.response;

public record AdminDashboardSummaryResponse(
long newUserCount,
long loginUserCount,
long activeUserCount,
long totalUserCount
) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package com.swyp.picke.domain.admin.service;

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 java.time.LocalDate;
import java.time.LocalDateTime;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class AdminDashboardService {

private final UserRepository userRepository;
private final UserDailyActivityRepository userDailyActivityRepository;

public AdminDashboardSummaryResponse getSummary() {
LocalDate today = LocalDate.now();
LocalDateTime todayStart = today.atStartOfDay();
LocalDateTime tomorrowStart = todayStart.plusDays(1);

long newUserCount = userRepository.countByCreatedAtBetween(todayStart, tomorrowStart);
long loginUserCount = userDailyActivityRepository.countByActivityDateAndLoggedInTrue(today);
long activeUserCount = userDailyActivityRepository.countByActivityDateAndActiveTrue(today);
long totalUserCount = userRepository.countByStatus(UserStatus.ACTIVE);

return new AdminDashboardSummaryResponse(newUserCount, loginUserCount, activeUserCount, totalUserCount);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,8 @@ ON CONFLICT (user_id, activity_date)
DO UPDATE SET logged_in = true, updated_at = now()
""", nativeQuery = true)
void markLoggedIn(@Param("userId") Long userId, @Param("date") LocalDate date);

long countByActivityDateAndLoggedInTrue(LocalDate activityDate);

long countByActivityDateAndActiveTrue(LocalDate activityDate);
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
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 java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
import org.springframework.data.domain.Pageable;
Expand Down Expand Up @@ -39,4 +40,8 @@ or u.userTag like concat('%', :keyword, '%')
order by u.id desc
""")
Slice<User> searchByNicknameOrUserTag(@Param("keyword") String keyword, Pageable pageable);

long countByStatus(UserStatus status);

long countByCreatedAtBetween(LocalDateTime start, LocalDateTime end);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package com.swyp.picke.domain.admin.service;

import static org.assertj.core.api.Assertions.assertThat;
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.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 java.time.LocalDate;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;

@ExtendWith(MockitoExtension.class)
class AdminDashboardServiceTest {

@Mock
private UserRepository userRepository;

@Mock
private UserDailyActivityRepository userDailyActivityRepository;

@InjectMocks
private AdminDashboardService adminDashboardService;

@Test
@DisplayName("오늘 요약 카드를 조회한다")
void getSummary_returnsTodaySummary() {
when(userRepository.countByCreatedAtBetween(any(), any())).thenReturn(12L);
when(userDailyActivityRepository.countByActivityDateAndLoggedInTrue(eq(LocalDate.now()))).thenReturn(340L);
when(userDailyActivityRepository.countByActivityDateAndActiveTrue(eq(LocalDate.now()))).thenReturn(500L);
when(userRepository.countByStatus(UserStatus.ACTIVE)).thenReturn(58000L);

AdminDashboardSummaryResponse response = adminDashboardService.getSummary();

assertThat(response.newUserCount()).isEqualTo(12L);
assertThat(response.loginUserCount()).isEqualTo(340L);
assertThat(response.activeUserCount()).isEqualTo(500L);
assertThat(response.totalUserCount()).isEqualTo(58000L);
}
}
Loading