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
50 changes: 49 additions & 1 deletion docs/api-specs/dashboard-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -237,7 +237,55 @@

---

## 7. 에러 코드
## 7. `GET /api/v1/admin/dashboard/credit-stats`

기간 내 크레딧 지급/차감 현황을 `CreditType`별로 집계합니다. 매주 월요일 배치(`MAJORITY_WIN`/`BEST_COMMENT`)가 정상 실행됐는지, 크레딧이 이상 지급되고 있진 않은지 확인하는 용도입니다.

요청 헤더:

- `Authorization: Bearer {access_token}`

쿼리 파라미터:

| 파라미터 | 타입 | 필수 | 설명 |
|---|---|---|---|
| `from` | `string` (`YYYY-MM-DD`) | Y | 조회 시작일 |
| `to` | `string` (`YYYY-MM-DD`) | Y | 조회 종료일 |

성공 응답 `200 OK`:

```json
{
"statusCode": 200,
"data": {
"totalGranted": 45000,
"totalDeducted": 3200,
"byType": [
{ "creditType": "TODAY_CREDIT", "count": 1200, "totalAmount": 6000 },
{ "creditType": "MAJORITY_WIN", "count": 340, "totalAmount": 1700 },
{ "creditType": "BATTLE_ENTRY", "count": 640, "totalAmount": -3200 },
{ "creditType": "FREE_CHARGE", "count": 890, "totalAmount": 17800 }
]
},
"error": null
}
```

| 필드 | 설명 |
|---|---|
| `totalGranted` | 기간 내 지급된 크레딧 총합 (`totalAmount`가 양수인 타입들의 합) |
| `totalDeducted` | 기간 내 차감된 크레딧 총합의 절대값 (`totalAmount`가 음수인 타입들의 합에 `-1`을 곱한 값) |
| `byType[].creditType` | `CreditType` enum 값 (`TODAY_CREDIT`, `ATTENDANCE_STREAK`, `DEFAULT_CREDIT`, `BATTLE_ENTRY`, `MAJORITY_WIN`, `BEST_COMMENT`, `FREE_CHARGE`, `TOPIC_SUGGEST`, `TOPIC_ADOPTED`) |
| `byType[].count` | 해당 타입의 지급/차감 건수 |
| `byType[].totalAmount` | 해당 타입의 금액 합계 (차감 타입은 음수로 내려감) |

기간 내 발생 이력이 없는 타입은 `byType`에 포함되지 않습니다.

`from`이 `to`보다 늦으면 `COMMON_400`으로 400을 반환합니다.

---

## 8. 에러 코드

| Error Code | HTTP Status | 설명 |
|---|:---:|---|
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

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.AdminDashboardCreditStatsResponse;
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 @@ -70,4 +71,13 @@ public ApiResponse<AdminDashboardAttendanceStatsResponse> getAttendanceStats(
) {
return ApiResponse.onSuccess(adminDashboardService.getAttendanceStats(from, to));
}

@Operation(summary = "크레딧 지급 현황 조회", description = "기간 내 크레딧 타입별 지급/차감 건수와 총액, 전체 지급/차감 합계를 반환한다.")
@GetMapping("/credit-stats")
public ApiResponse<AdminDashboardCreditStatsResponse> getCreditStats(
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate from,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate to
) {
return ApiResponse.onSuccess(adminDashboardService.getCreditStats(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;

import java.util.List;

public record AdminDashboardCreditStatsResponse(
long totalGranted,
long totalDeducted,
List<AdminDashboardCreditTypeStatResponse> byType
) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.swyp.picke.domain.admin.dto.dashboard.response;

import com.swyp.picke.domain.user.enums.CreditType;

public record AdminDashboardCreditTypeStatResponse(
CreditType creditType,
long count,
long totalAmount
) {}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

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.AdminDashboardCreditStatsResponse;
import com.swyp.picke.domain.admin.dto.dashboard.response.AdminDashboardCreditTypeStatResponse;
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 All @@ -14,6 +16,7 @@
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.CreditTypeStats;
import com.swyp.picke.domain.user.repository.projection.DailyUserCount;
import com.swyp.picke.global.common.exception.CustomException;
import com.swyp.picke.global.common.exception.ErrorCode;
Expand Down Expand Up @@ -124,6 +127,31 @@ public AdminDashboardAttendanceStatsResponse getAttendanceStats(LocalDate from,
return new AdminDashboardAttendanceStatsResponse(avgAttendanceRate, totalCount, streakAchievedCount, items);
}

public AdminDashboardCreditStatsResponse getCreditStats(LocalDate from, LocalDate to) {
if (from.isAfter(to)) {
throw new CustomException(ErrorCode.COMMON_INVALID_PARAMETER);
}

List<CreditTypeStats> rows = creditHistoryRepository.findStatsByCreatedAtBetween(
from.atStartOfDay(), to.plusDays(1).atStartOfDay());

List<AdminDashboardCreditTypeStatResponse> byType = rows.stream()
.map(row -> new AdminDashboardCreditTypeStatResponse(
row.getCreditType(), row.getCount(), row.getTotalAmount()))
.toList();

long totalGranted = byType.stream()
.filter(stat -> stat.totalAmount() > 0)
.mapToLong(AdminDashboardCreditTypeStatResponse::totalAmount)
.sum();
long totalDeducted = byType.stream()
.filter(stat -> stat.totalAmount() < 0)
.mapToLong(stat -> -stat.totalAmount())
.sum();

return new AdminDashboardCreditStatsResponse(totalGranted, totalDeducted, byType);
}

private double orZero(Double value) {
return value != null ? value : 0.0;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@

import com.swyp.picke.domain.user.entity.CreditHistory;
import com.swyp.picke.domain.user.enums.CreditType;
import com.swyp.picke.domain.user.repository.projection.CreditTypeStats;
import java.time.LocalDateTime;
import java.util.List;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
Expand All @@ -24,4 +26,19 @@ public interface CreditHistoryRepository extends JpaRepository<CreditHistory, Lo
Page<CreditHistory> findByUserIdOrderByCreatedAtDesc(Long userId, Pageable pageable);

long countByCreditTypeAndCreatedAtBetween(CreditType creditType, LocalDateTime start, LocalDateTime end);

/**
* 어드민 대시보드용: from~to 구간의 크레딧 타입별 지급/차감 건수 및 총액.
*/
@Query("""
SELECT c.creditType AS creditType, COUNT(c) AS count, COALESCE(SUM(c.amount), 0) AS totalAmount
FROM CreditHistory c
WHERE c.createdAt >= :start AND c.createdAt < :end
GROUP BY c.creditType
ORDER BY c.creditType
""")
List<CreditTypeStats> findStatsByCreatedAtBetween(
@Param("start") LocalDateTime start,
@Param("end") LocalDateTime end
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.swyp.picke.domain.user.repository.projection;

import com.swyp.picke.domain.user.enums.CreditType;

public interface CreditTypeStats {
CreditType getCreditType();
long getCount();
long getTotalAmount();
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

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.AdminDashboardCreditStatsResponse;
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;
Expand All @@ -18,6 +19,7 @@
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.CreditTypeStats;
import com.swyp.picke.domain.user.repository.projection.DailyUserCount;
import com.swyp.picke.global.common.exception.CustomException;
import java.time.LocalDate;
Expand Down Expand Up @@ -260,6 +262,54 @@ void getAttendanceStats_throws_whenFromIsAfterTo() {
.isInstanceOf(CustomException.class);
}

@Test
@DisplayName("크레딧 타입별 지급/차감 현황을 조회한다")
void getCreditStats_returnsTypeBreakdownAndTotals() {
LocalDate from = LocalDate.of(2026, 8, 1);
LocalDate to = LocalDate.of(2026, 8, 7);
List<CreditTypeStats> rows = List.of(
creditTypeStats(CreditType.TODAY_CREDIT, 100L, 500L),
creditTypeStats(CreditType.MAJORITY_WIN, 20L, 100L),
creditTypeStats(CreditType.BATTLE_ENTRY, 30L, -150L)
);
when(creditHistoryRepository.findStatsByCreatedAtBetween(any(), any())).thenReturn(rows);

AdminDashboardCreditStatsResponse response = adminDashboardService.getCreditStats(from, to);

assertThat(response.byType()).hasSize(3);
assertThat(response.totalGranted()).isEqualTo(600L); // 500 + 100
assertThat(response.totalDeducted()).isEqualTo(150L); // |-150|
}

@Test
@DisplayName("크레딧 지급 현황 조회 시 from이 to보다 늦으면 예외를 던진다")
void getCreditStats_throws_whenFromIsAfterTo() {
LocalDate from = LocalDate.of(2026, 8, 10);
LocalDate to = LocalDate.of(2026, 8, 1);

assertThatThrownBy(() -> adminDashboardService.getCreditStats(from, to))
.isInstanceOf(CustomException.class);
}

private CreditTypeStats creditTypeStats(CreditType creditType, long count, long totalAmount) {
return new CreditTypeStats() {
@Override
public CreditType getCreditType() {
return creditType;
}

@Override
public long getCount() {
return count;
}

@Override
public long getTotalAmount() {
return totalAmount;
}
};
}

private DailyUserCount dailyUserCount(LocalDate date, long count) {
return new DailyUserCount() {
@Override
Expand Down
Loading