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
46 changes: 45 additions & 1 deletion docs/api-specs/dashboard-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 | 설명 |
|---|:---:|---|
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -60,4 +61,13 @@ public ApiResponse<AdminDashboardBattleStatsResponse> getBattleStats(
) {
return ApiResponse.onSuccess(adminDashboardService.getBattleStats(from, to));
}

@Operation(summary = "출석 체크율 조회", description = "기간 내 일자별 출석 인원, 총 출석 횟수, 평균 출석률, 개근 보너스(7일 연속 출석) 달성 건수를 반환한다.")
@GetMapping("/attendance-stats")
public ApiResponse<AdminDashboardAttendanceStatsResponse> getAttendanceStats(
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate from,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate to
) {
return ApiResponse.onSuccess(adminDashboardService.getAttendanceStats(from, to));
}
}
Original file line number Diff line number Diff line change
@@ -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<AdminDashboardTrendItemResponse> items
) {}
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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();
Expand Down Expand Up @@ -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<DailyUserCount> rows = attendanceRecordRepository.findDailyAttendanceCounts(from, to);
List<AdminDashboardTrendItemResponse> 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;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -27,4 +28,16 @@ public interface AttendanceRecordRepository extends JpaRepository<AttendanceReco
"WHERE ar.attendedDate BETWEEN :from AND :to " +
"GROUP BY ar.attendedDate ORDER BY ar.attendedDate")
List<Object[]> 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<DailyUserCount> findDailyAttendanceCounts(@Param("from") LocalDate from, @Param("to") LocalDate to);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -21,4 +22,6 @@ public interface CreditHistoryRepository extends JpaRepository<CreditHistory, Lo
boolean existsByUserIdAndCreditTypeAndReferenceId(Long userId, CreditType creditType, Long referenceId);

Page<CreditHistory> findByUserIdOrderByCreatedAtDesc(Long userId, Pageable pageable);

long countByCreditTypeAndCreatedAtBetween(CreditType creditType, LocalDateTime start, LocalDateTime end);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -37,6 +41,12 @@ class AdminDashboardServiceTest {
@Mock
private BattleRepository battleRepository;

@Mock
private AttendanceRecordRepository attendanceRecordRepository;

@Mock
private CreditHistoryRepository creditHistoryRepository;

@InjectMocks
private AdminDashboardService adminDashboardService;

Expand Down Expand Up @@ -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<DailyUserCount> 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
Expand Down
Loading