From 36671e46623dd3523865b8e7c92a8692cd2b356f Mon Sep 17 00:00:00 2001 From: si-zero Date: Tue, 1 Sep 2026 16:31:39 +0900 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20=EB=B0=B0=ED=8B=80=EB=8B=B9=20?= =?UTF-8?q?=EC=B0=B8=EC=97=AC=EC=9C=A8=20=EC=A7=91=EA=B3=84=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=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 --- .../battle/repository/BattleRepository.java | 32 +++++++++++++++++++ .../projection/BattleParticipationStats.java | 9 ++++++ 2 files changed, 41 insertions(+) create mode 100644 src/main/java/com/swyp/picke/domain/battle/repository/projection/BattleParticipationStats.java 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(); +} From 45c36fba7f6c0327458ad31c38c0346193cdb55d Mon Sep 17 00:00:00 2001 From: si-zero Date: Tue, 1 Sep 2026 16:34:00 +0900 Subject: [PATCH 2/5] =?UTF-8?q?feat:=20=EB=B0=B0=ED=8B=80=20=EC=B0=B8?= =?UTF-8?q?=EC=97=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 --- .../response/AdminDashboardBattleStatsResponse.java | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 src/main/java/com/swyp/picke/domain/admin/dto/dashboard/response/AdminDashboardBattleStatsResponse.java 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 +) {} From e9687455d6a26935cbc87ed39f8728e1392263af Mon Sep 17 00:00:00 2001 From: si-zero Date: Tue, 1 Sep 2026 16:34:13 +0900 Subject: [PATCH 3/5] =?UTF-8?q?feat:=20=EB=B0=B0=ED=8B=80=20=EC=B0=B8?= =?UTF-8?q?=EC=97=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 | 25 ++++++ .../service/AdminDashboardServiceTest.java | 80 +++++++++++++++++++ 2 files changed, 105 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 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/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 From 2aca89b1595eb51a938eb0cf52bc8cad3c698edf Mon Sep 17 00:00:00 2001 From: si-zero Date: Tue, 1 Sep 2026 16:38:08 +0900 Subject: [PATCH 4/5] =?UTF-8?q?feat:=20=EB=B0=B0=ED=8B=80=20=EC=B0=B8?= =?UTF-8?q?=EC=97=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 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)); + } } From 68bf6a3bed1e181671cce169c58b32ef165f8203 Mon Sep 17 00:00:00 2001 From: si-zero Date: Tue, 1 Sep 2026 16:38:08 +0900 Subject: [PATCH 5/5] =?UTF-8?q?docs:=20=EB=B0=B0=ED=8B=80=20=EC=B0=B8?= =?UTF-8?q?=EC=97=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 | 47 ++++++++++++++++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) 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 | 설명 | |---|:---:|---|