diff --git a/docs/api-specs/dashboard-api.md b/docs/api-specs/dashboard-api.md index 209aea43..dd844139 100644 --- a/docs/api-specs/dashboard-api.md +++ b/docs/api-specs/dashboard-api.md @@ -193,7 +193,51 @@ --- -## 6. 에러 코드 +## 6. `GET /api/v1/admin/dashboard/attendance-stats` + +기간 내 출석 체크 현황과 개근 보너스 달성 건수를 조회합니다. 출석 체크는 로그인 시 자동으로 되는 게 아니라 유저가 명시적으로 출석 체크 API를 호출해야 기록됩니다. + +요청 헤더: + +- `Authorization: Bearer {access_token}` + +쿼리 파라미터: + +| 파라미터 | 타입 | 필수 | 설명 | +|---|---|---|---| +| `from` | `string` (`YYYY-MM-DD`) | Y | 조회 시작일 | +| `to` | `string` (`YYYY-MM-DD`) | Y | 조회 종료일 | + +성공 응답 `200 OK`: + +```json +{ + "statusCode": 200, + "data": { + "avgAttendanceRate": 0.12, + "totalCount": 850, + "streakAchievedCount": 42, + "items": [ + { "date": "2026-08-01", "count": 100 }, + { "date": "2026-08-02", "count": 120 } + ] + }, + "error": null +} +``` + +| 필드 | 설명 | +|---|---| +| `avgAttendanceRate` | 일자별 (출석 인원 / 전체 `ACTIVE` 유저 수)의 기간 평균 | +| `totalCount` | 기간 내 총 출석 횟수 | +| `streakAchievedCount` | 기간 내 개근 보너스(`CreditType.ATTENDANCE_STREAK`, 월~토 개근 후 일요일 출석 시 지급)를 받은 건수 | +| `items` | 일자별 출석 인원 수 (출석이 없는 날짜도 `0`으로 채움) | + +`from`이 `to`보다 늦으면 `COMMON_400`으로 400을 반환합니다. + +--- + +## 7. 에러 코드 | 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 12673390..416e6745 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,5 +1,6 @@ package com.swyp.picke.domain.admin.controller; +import com.swyp.picke.domain.admin.dto.dashboard.response.AdminDashboardAttendanceStatsResponse; import com.swyp.picke.domain.admin.dto.dashboard.response.AdminDashboardBattleStatsResponse; import com.swyp.picke.domain.admin.dto.dashboard.response.AdminDashboardDauMauResponse; import com.swyp.picke.domain.admin.dto.dashboard.response.AdminDashboardNewUsersResponse; @@ -60,4 +61,13 @@ public ApiResponse getBattleStats( ) { return ApiResponse.onSuccess(adminDashboardService.getBattleStats(from, to)); } + + @Operation(summary = "출석 체크율 조회", description = "기간 내 일자별 출석 인원, 총 출석 횟수, 평균 출석률, 개근 보너스(7일 연속 출석) 달성 건수를 반환한다.") + @GetMapping("/attendance-stats") + public ApiResponse getAttendanceStats( + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate from, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate to + ) { + return ApiResponse.onSuccess(adminDashboardService.getAttendanceStats(from, to)); + } } diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/dashboard/response/AdminDashboardAttendanceStatsResponse.java b/src/main/java/com/swyp/picke/domain/admin/dto/dashboard/response/AdminDashboardAttendanceStatsResponse.java new file mode 100644 index 00000000..23c4c208 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/dashboard/response/AdminDashboardAttendanceStatsResponse.java @@ -0,0 +1,10 @@ +package com.swyp.picke.domain.admin.dto.dashboard.response; + +import java.util.List; + +public record AdminDashboardAttendanceStatsResponse( + double avgAttendanceRate, + long totalCount, + long streakAchievedCount, + 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 54d17bd5..114b0d5d 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,13 +1,17 @@ package com.swyp.picke.domain.admin.service; +import com.swyp.picke.domain.admin.dto.dashboard.response.AdminDashboardAttendanceStatsResponse; import com.swyp.picke.domain.admin.dto.dashboard.response.AdminDashboardBattleStatsResponse; 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.attendance.repository.AttendanceRecordRepository; import com.swyp.picke.domain.battle.repository.BattleRepository; import com.swyp.picke.domain.battle.repository.projection.BattleParticipationStats; +import com.swyp.picke.domain.user.enums.CreditType; import com.swyp.picke.domain.user.enums.UserStatus; +import com.swyp.picke.domain.user.repository.CreditHistoryRepository; 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; @@ -28,6 +32,8 @@ public class AdminDashboardService { private final UserRepository userRepository; private final UserDailyActivityRepository userDailyActivityRepository; private final BattleRepository battleRepository; + private final AttendanceRecordRepository attendanceRecordRepository; + private final CreditHistoryRepository creditHistoryRepository; public AdminDashboardSummaryResponse getSummary() { LocalDate today = LocalDate.now(); @@ -95,6 +101,29 @@ public AdminDashboardBattleStatsResponse getBattleStats(LocalDate from, LocalDat ); } + public AdminDashboardAttendanceStatsResponse getAttendanceStats(LocalDate from, LocalDate to) { + if (from.isAfter(to)) { + throw new CustomException(ErrorCode.COMMON_INVALID_PARAMETER); + } + + List rows = attendanceRecordRepository.findDailyAttendanceCounts(from, to); + List items = rows.stream() + .map(row -> new AdminDashboardTrendItemResponse(row.getActivityDate(), row.getCount())) + .toList(); + + long totalCount = items.stream().mapToLong(AdminDashboardTrendItemResponse::count).sum(); + + long totalUsers = userRepository.countByStatus(UserStatus.ACTIVE); + double avgAttendanceRate = (totalUsers > 0 && !items.isEmpty()) + ? items.stream().mapToDouble(item -> (double) item.count() / totalUsers).average().orElse(0.0) + : 0.0; + + long streakAchievedCount = creditHistoryRepository.countByCreditTypeAndCreatedAtBetween( + CreditType.ATTENDANCE_STREAK, from.atStartOfDay(), to.plusDays(1).atStartOfDay()); + + return new AdminDashboardAttendanceStatsResponse(avgAttendanceRate, totalCount, streakAchievedCount, items); + } + private double orZero(Double value) { return value != null ? value : 0.0; } diff --git a/src/main/java/com/swyp/picke/domain/attendance/repository/AttendanceRecordRepository.java b/src/main/java/com/swyp/picke/domain/attendance/repository/AttendanceRecordRepository.java index 748e5166..c56767e7 100644 --- a/src/main/java/com/swyp/picke/domain/attendance/repository/AttendanceRecordRepository.java +++ b/src/main/java/com/swyp/picke/domain/attendance/repository/AttendanceRecordRepository.java @@ -1,6 +1,7 @@ package com.swyp.picke.domain.attendance.repository; import com.swyp.picke.domain.attendance.entity.AttendanceRecord; +import com.swyp.picke.domain.user.repository.projection.DailyUserCount; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; @@ -27,4 +28,16 @@ public interface AttendanceRecordRepository extends JpaRepository countAttendanceGroupByDate(@Param("from") LocalDate from, @Param("to") LocalDate to); + + /** + * 어드민 대시보드용: from~to 구간의 일자별 출석 인원 수. 출석이 없는 날짜도 0으로 채워서 반환한다. + */ + @Query(value = """ + SELECT gs::date AS activity_date, COUNT(ar.user_id) AS count + FROM generate_series(CAST(:from AS timestamp), CAST(:to AS timestamp), interval '1 day') AS gs + LEFT JOIN attendance_records ar ON ar.attended_date = gs::date + GROUP BY gs::date + ORDER BY gs::date + """, nativeQuery = true) + List findDailyAttendanceCounts(@Param("from") LocalDate from, @Param("to") LocalDate to); } diff --git a/src/main/java/com/swyp/picke/domain/user/repository/CreditHistoryRepository.java b/src/main/java/com/swyp/picke/domain/user/repository/CreditHistoryRepository.java index d420cca0..42214854 100644 --- a/src/main/java/com/swyp/picke/domain/user/repository/CreditHistoryRepository.java +++ b/src/main/java/com/swyp/picke/domain/user/repository/CreditHistoryRepository.java @@ -2,6 +2,7 @@ import com.swyp.picke.domain.user.entity.CreditHistory; import com.swyp.picke.domain.user.enums.CreditType; +import java.time.LocalDateTime; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; @@ -21,4 +22,6 @@ public interface CreditHistoryRepository extends JpaRepository findByUserIdOrderByCreatedAtDesc(Long userId, Pageable pageable); + + long countByCreditTypeAndCreatedAtBetween(CreditType creditType, LocalDateTime start, LocalDateTime end); } 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 3e96c662..0e8fc886 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 @@ -6,12 +6,16 @@ import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.when; +import com.swyp.picke.domain.admin.dto.dashboard.response.AdminDashboardAttendanceStatsResponse; import com.swyp.picke.domain.admin.dto.dashboard.response.AdminDashboardBattleStatsResponse; 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.attendance.repository.AttendanceRecordRepository; import com.swyp.picke.domain.battle.repository.BattleRepository; import com.swyp.picke.domain.battle.repository.projection.BattleParticipationStats; +import com.swyp.picke.domain.user.enums.CreditType; import com.swyp.picke.domain.user.enums.UserStatus; +import com.swyp.picke.domain.user.repository.CreditHistoryRepository; 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; @@ -37,6 +41,12 @@ class AdminDashboardServiceTest { @Mock private BattleRepository battleRepository; + @Mock + private AttendanceRecordRepository attendanceRecordRepository; + + @Mock + private CreditHistoryRepository creditHistoryRepository; + @InjectMocks private AdminDashboardService adminDashboardService; @@ -218,6 +228,38 @@ public Double getAvgCommentRate() { }; } + @Test + @DisplayName("출석 체크율과 개근 보너스 달성 건수를 조회한다") + void getAttendanceStats_returnsRateAndStreakCount() { + LocalDate from = LocalDate.of(2026, 8, 1); + LocalDate to = LocalDate.of(2026, 8, 2); + List rows = List.of( + dailyUserCount(LocalDate.of(2026, 8, 1), 100L), + dailyUserCount(LocalDate.of(2026, 8, 2), 200L) + ); + when(attendanceRecordRepository.findDailyAttendanceCounts(from, to)).thenReturn(rows); + when(userRepository.countByStatus(UserStatus.ACTIVE)).thenReturn(1000L); + when(creditHistoryRepository.countByCreditTypeAndCreatedAtBetween( + eq(CreditType.ATTENDANCE_STREAK), any(), any())).thenReturn(42L); + + AdminDashboardAttendanceStatsResponse response = adminDashboardService.getAttendanceStats(from, to); + + assertThat(response.items()).hasSize(2); + assertThat(response.totalCount()).isEqualTo(300L); + assertThat(response.avgAttendanceRate()).isCloseTo(0.15, org.assertj.core.data.Offset.offset(0.0001)); // (0.1 + 0.2) / 2 + assertThat(response.streakAchievedCount()).isEqualTo(42L); + } + + @Test + @DisplayName("출석 체크율 조회 시 from이 to보다 늦으면 예외를 던진다") + void getAttendanceStats_throws_whenFromIsAfterTo() { + LocalDate from = LocalDate.of(2026, 8, 10); + LocalDate to = LocalDate.of(2026, 8, 1); + + assertThatThrownBy(() -> adminDashboardService.getAttendanceStats(from, to)) + .isInstanceOf(CustomException.class); + } + private DailyUserCount dailyUserCount(LocalDate date, long count) { return new DailyUserCount() { @Override