From 0d28a674ad61e8e005f2d1e78bbca472919cb2db Mon Sep 17 00:00:00 2001 From: si-zero Date: Tue, 1 Sep 2026 21:32:14 +0900 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20=EC=B6=9C=EC=84=9D=20=EC=B2=B4?= =?UTF-8?q?=ED=81=AC=EC=9C=A8/=EA=B0=9C=EA=B7=BC=20=EB=B3=B4=EB=84=88?= =?UTF-8?q?=EC=8A=A4=20=EC=A7=91=EA=B3=84=EC=9A=A9=20=EC=A0=80=EC=9E=A5?= =?UTF-8?q?=EC=86=8C=20=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 --- .../repository/AttendanceRecordRepository.java | 13 +++++++++++++ .../user/repository/CreditHistoryRepository.java | 3 +++ 2 files changed, 16 insertions(+) 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); } From ec43b19768b7c113daf27127356954c3fdc2749b Mon Sep 17 00:00:00 2001 From: si-zero Date: Tue, 1 Sep 2026 21:32:29 +0900 Subject: [PATCH 2/5] =?UTF-8?q?feat:=20=EC=B6=9C=EC=84=9D=20=EC=B2=B4?= =?UTF-8?q?=ED=81=AC=EC=9C=A8=20=EC=9D=91=EB=8B=B5=20DTO=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 --- .../AdminDashboardAttendanceStatsResponse.java | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 src/main/java/com/swyp/picke/domain/admin/dto/dashboard/response/AdminDashboardAttendanceStatsResponse.java 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 +) {} From 632b5270a08fbc07154bf77339a3ab296e4da7cd Mon Sep 17 00:00:00 2001 From: si-zero Date: Tue, 1 Sep 2026 21:32:55 +0900 Subject: [PATCH 3/5] =?UTF-8?q?feat:=20=EC=B6=9C=EC=84=9D=20=EC=B2=B4?= =?UTF-8?q?=ED=81=AC=EC=9C=A8=20=EC=A1=B0=ED=9A=8C=20=EC=84=9C=EB=B9=84?= =?UTF-8?q?=EC=8A=A4=20=EB=A1=9C=EC=A7=81=20=EB=B0=8F=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=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 | 29 +++++++++++++ .../service/AdminDashboardServiceTest.java | 42 +++++++++++++++++++ 2 files changed, 71 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 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/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 From 65d9c4d9a96a7f7322f37c9bdbf55ccedef14f87 Mon Sep 17 00:00:00 2001 From: si-zero Date: Tue, 1 Sep 2026 21:33:04 +0900 Subject: [PATCH 4/5] =?UTF-8?q?feat:=20=EC=B6=9C=EC=84=9D=20=EC=B2=B4?= =?UTF-8?q?=ED=81=AC=EC=9C=A8=20=EC=A1=B0=ED=9A=8C=20API=20=EC=97=94?= =?UTF-8?q?=EB=93=9C=ED=8F=AC=EC=9D=B8=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/controller/AdminDashboardController.java | 10 ++++++++++ 1 file changed, 10 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 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)); + } } From 8f6bb506e0b4f639cbe3f11d6b3c77806bada57e Mon Sep 17 00:00:00 2001 From: si-zero Date: Tue, 1 Sep 2026 21:33:04 +0900 Subject: [PATCH 5/5] =?UTF-8?q?docs:=20=EC=B6=9C=EC=84=9D=20=EC=B2=B4?= =?UTF-8?q?=ED=81=AC=EC=9C=A8=20API=20=EB=AA=85=EC=84=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 --- docs/api-specs/dashboard-api.md | 46 ++++++++++++++++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) 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 | 설명 | |---|:---:|---|