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

@Operation(summary = "배틀 참여율 조회", description = "기간(targetDate 기준) 내 발행된 배틀들의 배틀당 평균 사전투표/사후투표/관점작성/댓글작성 참여율을 반환한다.")
@GetMapping("/battle-stats")
public ApiResponse<AdminDashboardBattleStatsResponse> getBattleStats(
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate from,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate to
) {
return ApiResponse.onSuccess(adminDashboardService.getBattleStats(from, to));
}
}
Original file line number Diff line number Diff line change
@@ -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
) {}
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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();
Expand Down Expand Up @@ -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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -110,4 +111,35 @@ List<Battle> findRecommendedBattles(
@Param("excludeBattleIds") List<Long> 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
);
}
Original file line number Diff line number Diff line change
@@ -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();
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -31,6 +34,9 @@ class AdminDashboardServiceTest {
@Mock
private UserDailyActivityRepository userDailyActivityRepository;

@Mock
private BattleRepository battleRepository;

@InjectMocks
private AdminDashboardService adminDashboardService;

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