diff --git a/docs/api-specs/dashboard-api.md b/docs/api-specs/dashboard-api.md index 25c99ba5..209aea43 100644 --- a/docs/api-specs/dashboard-api.md +++ b/docs/api-specs/dashboard-api.md @@ -148,7 +148,52 @@ --- -## 5. 에러 코드 +## 5. `GET /api/v1/admin/dashboard/battle-stats` + +기간(`targetDate` 기준) 내 `PUBLISHED` 상태로 발행된 배틀들의 **배틀당 평균 참여율**을 조회합니다. + +요청 헤더: + +- `Authorization: Bearer {access_token}` + +쿼리 파라미터: + +| 파라미터 | 타입 | 필수 | 설명 | +|---|---|---|---| +| `from` | `string` (`YYYY-MM-DD`) | Y | 조회 시작일 (배틀의 `targetDate` 기준) | +| `to` | `string` (`YYYY-MM-DD`) | Y | 조회 종료일 | + +성공 응답 `200 OK`: + +```json +{ + "statusCode": 200, + "data": { + "battleCount": 5, + "avgPreVoteRate": 0.42, + "avgPostVoteRate": 0.31, + "avgPerspectiveWriteRate": 0.18, + "avgCommentWriteRate": 0.07 + }, + "error": null +} +``` + +| 필드 | 설명 | +|---|---| +| `battleCount` | 기간 내 발행된 배틀 수 | +| `avgPreVoteRate` | 배틀당 평균 사전투표 참여율 (0~1 비율) | +| `avgPostVoteRate` | 배틀당 평균 사후투표 참여율 | +| `avgPerspectiveWriteRate` | 배틀당 평균 관점(의견) 작성률 | +| `avgCommentWriteRate` | 배틀당 평균 댓글(다른 사람 관점에 대한 답글) 작성률 | + +각 배틀마다 (참여자 수 / 전체 `ACTIVE` 유저 수)로 참여율을 구한 뒤, 기간 내 배틀들의 평균을 냅니다. 분모는 **현재 시점 기준 전체 `ACTIVE` 유저 수**를 사용하는 근사치이며, 배틀이 발행되던 시점의 실제 유저 수가 아닙니다. 기간 내 발행된 배틀이 하나도 없으면 모든 비율 필드는 `0`입니다. + +`from`이 `to`보다 늦으면 `COMMON_400`으로 400을 반환합니다. + +--- + +## 6. 에러 코드 | 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 2b21e044..12673390 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.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; @@ -50,4 +51,13 @@ public ApiResponse getNewUsersTrend( ) { return ApiResponse.onSuccess(adminDashboardService.getNewUsersTrend(from, to, granularity)); } + + @Operation(summary = "배틀 참여율 조회", description = "기간(targetDate 기준) 내 발행된 배틀들의 배틀당 평균 사전투표/사후투표/관점작성/댓글작성 참여율을 반환한다.") + @GetMapping("/battle-stats") + public ApiResponse getBattleStats( + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate from, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate to + ) { + return ApiResponse.onSuccess(adminDashboardService.getBattleStats(from, to)); + } } diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/dashboard/response/AdminDashboardBattleStatsResponse.java b/src/main/java/com/swyp/picke/domain/admin/dto/dashboard/response/AdminDashboardBattleStatsResponse.java new file mode 100644 index 00000000..9ee17372 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/dashboard/response/AdminDashboardBattleStatsResponse.java @@ -0,0 +1,9 @@ +package com.swyp.picke.domain.admin.dto.dashboard.response; + +public record AdminDashboardBattleStatsResponse( + long battleCount, + double avgPreVoteRate, + double avgPostVoteRate, + double avgPerspectiveWriteRate, + double avgCommentWriteRate +) {} 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 190c7ef2..54d17bd5 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,9 +1,12 @@ package com.swyp.picke.domain.admin.service; +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.battle.repository.BattleRepository; +import com.swyp.picke.domain.battle.repository.projection.BattleParticipationStats; import com.swyp.picke.domain.user.enums.UserStatus; import com.swyp.picke.domain.user.repository.UserDailyActivityRepository; import com.swyp.picke.domain.user.repository.UserRepository; @@ -24,6 +27,7 @@ public class AdminDashboardService { private final UserRepository userRepository; private final UserDailyActivityRepository userDailyActivityRepository; + private final BattleRepository battleRepository; public AdminDashboardSummaryResponse getSummary() { LocalDate today = LocalDate.now(); @@ -73,4 +77,25 @@ public AdminDashboardNewUsersResponse getNewUsersTrend(LocalDate from, LocalDate return new AdminDashboardNewUsersResponse(totalCount, items); } + + public AdminDashboardBattleStatsResponse getBattleStats(LocalDate from, LocalDate to) { + if (from.isAfter(to)) { + throw new CustomException(ErrorCode.COMMON_INVALID_PARAMETER); + } + + long totalUsers = userRepository.countByStatus(UserStatus.ACTIVE); + BattleParticipationStats stats = battleRepository.findParticipationStats(from, to, totalUsers); + + return new AdminDashboardBattleStatsResponse( + stats.getBattleCount(), + orZero(stats.getAvgPreVoteRate()), + orZero(stats.getAvgPostVoteRate()), + orZero(stats.getAvgPerspectiveRate()), + orZero(stats.getAvgCommentRate()) + ); + } + + private double orZero(Double value) { + return value != null ? value : 0.0; + } } diff --git a/src/main/java/com/swyp/picke/domain/battle/repository/BattleRepository.java b/src/main/java/com/swyp/picke/domain/battle/repository/BattleRepository.java index 79731fe5..b0d6dc9b 100644 --- a/src/main/java/com/swyp/picke/domain/battle/repository/BattleRepository.java +++ b/src/main/java/com/swyp/picke/domain/battle/repository/BattleRepository.java @@ -2,6 +2,7 @@ import com.swyp.picke.domain.battle.entity.Battle; import com.swyp.picke.domain.battle.enums.BattleStatus; +import com.swyp.picke.domain.battle.repository.projection.BattleParticipationStats; import java.time.LocalDate; import java.time.LocalDateTime; import java.util.List; @@ -110,4 +111,35 @@ List findRecommendedBattles( @Param("excludeBattleIds") List excludeBattleIds, Pageable pageable ); + + /** + * 어드민 대시보드용: from~to(targetDate 기준) 구간에 발행된 배틀들의 배틀당 평균 참여율. + * totalUsers를 분모로 사전투표/사후투표/관점작성/댓글작성 참여율을 배틀별로 구한 뒤 평균낸다. + */ + @Query(value = """ + SELECT + COUNT(*) AS battle_count, + AVG(sub.pre_vote_count::decimal / NULLIF(:totalUsers, 0)) AS avg_pre_vote_rate, + AVG(sub.post_vote_count::decimal / NULLIF(:totalUsers, 0)) AS avg_post_vote_rate, + AVG(sub.perspective_count::decimal / NULLIF(:totalUsers, 0)) AS avg_perspective_rate, + AVG(sub.comment_count::decimal / NULLIF(:totalUsers, 0)) AS avg_comment_rate + FROM ( + SELECT + b.id, + (SELECT COUNT(*) FROM votes v WHERE v.battle_id = b.id AND v.pre_vote_option_id IS NOT NULL) AS pre_vote_count, + (SELECT COUNT(*) FROM votes v WHERE v.battle_id = b.id AND v.post_vote_option_id IS NOT NULL) AS post_vote_count, + (SELECT COUNT(DISTINCT p.user_id) FROM perspectives p WHERE p.battle_id = b.id) AS perspective_count, + (SELECT COUNT(DISTINCT pc.user_id) FROM perspective_comments pc + JOIN perspectives p2 ON p2.id = pc.perspective_id + WHERE p2.battle_id = b.id) AS comment_count + FROM battles b + WHERE b.status = 'PUBLISHED' AND b.deleted_at IS NULL + AND b.target_date BETWEEN :from AND :to + ) sub + """, nativeQuery = true) + BattleParticipationStats findParticipationStats( + @Param("from") LocalDate from, + @Param("to") LocalDate to, + @Param("totalUsers") long totalUsers + ); } diff --git a/src/main/java/com/swyp/picke/domain/battle/repository/projection/BattleParticipationStats.java b/src/main/java/com/swyp/picke/domain/battle/repository/projection/BattleParticipationStats.java new file mode 100644 index 00000000..70d338d6 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/battle/repository/projection/BattleParticipationStats.java @@ -0,0 +1,9 @@ +package com.swyp.picke.domain.battle.repository.projection; + +public interface BattleParticipationStats { + long getBattleCount(); + Double getAvgPreVoteRate(); + Double getAvgPostVoteRate(); + Double getAvgPerspectiveRate(); + Double getAvgCommentRate(); +} 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 1d752d01..3e96c662 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,8 +6,11 @@ import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.when; +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.battle.repository.BattleRepository; +import com.swyp.picke.domain.battle.repository.projection.BattleParticipationStats; import com.swyp.picke.domain.user.enums.UserStatus; import com.swyp.picke.domain.user.repository.UserDailyActivityRepository; import com.swyp.picke.domain.user.repository.UserRepository; @@ -31,6 +34,9 @@ class AdminDashboardServiceTest { @Mock private UserDailyActivityRepository userDailyActivityRepository; + @Mock + private BattleRepository battleRepository; + @InjectMocks private AdminDashboardService adminDashboardService; @@ -138,6 +144,80 @@ void getNewUsersTrend_throws_whenFromIsAfterTo() { .isInstanceOf(CustomException.class); } + @Test + @DisplayName("배틀당 평균 참여율을 조회한다") + void getBattleStats_returnsAverageRates() { + LocalDate from = LocalDate.of(2026, 8, 1); + LocalDate to = LocalDate.of(2026, 8, 7); + when(userRepository.countByStatus(UserStatus.ACTIVE)).thenReturn(1000L); + BattleParticipationStats stats = battleParticipationStats(5L, 0.8, 0.6, 0.4, 0.1); + when(battleRepository.findParticipationStats(from, to, 1000L)).thenReturn(stats); + + AdminDashboardBattleStatsResponse response = adminDashboardService.getBattleStats(from, to); + + assertThat(response.battleCount()).isEqualTo(5L); + assertThat(response.avgPreVoteRate()).isEqualTo(0.8); + assertThat(response.avgPostVoteRate()).isEqualTo(0.6); + assertThat(response.avgPerspectiveWriteRate()).isEqualTo(0.4); + assertThat(response.avgCommentWriteRate()).isEqualTo(0.1); + } + + @Test + @DisplayName("기간 내 발행된 배틀이 없으면 참여율은 0으로 반환한다") + void getBattleStats_returnsZero_whenNoBattlesPublished() { + LocalDate from = LocalDate.of(2026, 8, 1); + LocalDate to = LocalDate.of(2026, 8, 7); + when(userRepository.countByStatus(UserStatus.ACTIVE)).thenReturn(1000L); + BattleParticipationStats stats = battleParticipationStats(0L, null, null, null, null); + when(battleRepository.findParticipationStats(from, to, 1000L)).thenReturn(stats); + + AdminDashboardBattleStatsResponse response = adminDashboardService.getBattleStats(from, to); + + assertThat(response.battleCount()).isEqualTo(0L); + assertThat(response.avgPreVoteRate()).isEqualTo(0.0); + assertThat(response.avgCommentWriteRate()).isEqualTo(0.0); + } + + @Test + @DisplayName("배틀 참여율 조회 시 from이 to보다 늦으면 예외를 던진다") + void getBattleStats_throws_whenFromIsAfterTo() { + LocalDate from = LocalDate.of(2026, 8, 10); + LocalDate to = LocalDate.of(2026, 8, 1); + + assertThatThrownBy(() -> adminDashboardService.getBattleStats(from, to)) + .isInstanceOf(CustomException.class); + } + + private BattleParticipationStats battleParticipationStats( + long battleCount, Double preVote, Double postVote, Double perspective, Double comment) { + return new BattleParticipationStats() { + @Override + public long getBattleCount() { + return battleCount; + } + + @Override + public Double getAvgPreVoteRate() { + return preVote; + } + + @Override + public Double getAvgPostVoteRate() { + return postVote; + } + + @Override + public Double getAvgPerspectiveRate() { + return perspective; + } + + @Override + public Double getAvgCommentRate() { + return comment; + } + }; + } + private DailyUserCount dailyUserCount(LocalDate date, long count) { return new DailyUserCount() { @Override