From 5338f2e5dbc486917ddc79840551c77569de21d8 Mon Sep 17 00:00:00 2001 From: si-zero Date: Wed, 2 Sep 2026 15:29:57 +0900 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20=ED=81=AC=EB=A0=88=EB=94=A7=20?= =?UTF-8?q?=ED=83=80=EC=9E=85=EB=B3=84=20=EC=A7=80=EA=B8=89/=EC=B0=A8?= =?UTF-8?q?=EA=B0=90=20=EC=A7=91=EA=B3=84=20=EC=A0=80=EC=9E=A5=EC=86=8C=20?= =?UTF-8?q?=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 --- .../repository/CreditHistoryRepository.java | 17 +++++++++++++++++ .../repository/projection/CreditTypeStats.java | 9 +++++++++ 2 files changed, 26 insertions(+) create mode 100644 src/main/java/com/swyp/picke/domain/user/repository/projection/CreditTypeStats.java diff --git a/src/main/java/com/swyp/picke/domain/user/repository/CreditHistoryRepository.java b/src/main/java/com/swyp/picke/domain/user/repository/CreditHistoryRepository.java index 4221485..ccf8477 100644 --- a/src/main/java/com/swyp/picke/domain/user/repository/CreditHistoryRepository.java +++ b/src/main/java/com/swyp/picke/domain/user/repository/CreditHistoryRepository.java @@ -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; @@ -24,4 +26,19 @@ public interface CreditHistoryRepository extends JpaRepository 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 findStatsByCreatedAtBetween( + @Param("start") LocalDateTime start, + @Param("end") LocalDateTime end + ); } diff --git a/src/main/java/com/swyp/picke/domain/user/repository/projection/CreditTypeStats.java b/src/main/java/com/swyp/picke/domain/user/repository/projection/CreditTypeStats.java new file mode 100644 index 0000000..2adcab5 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/user/repository/projection/CreditTypeStats.java @@ -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(); +} From c3ee8919a6f1ab55c1bd09d14c87073b43713ba4 Mon Sep 17 00:00:00 2001 From: si-zero Date: Wed, 2 Sep 2026 15:30:09 +0900 Subject: [PATCH 2/5] =?UTF-8?q?feat:=20=ED=81=AC=EB=A0=88=EB=94=A7=20?= =?UTF-8?q?=EC=A7=80=EA=B8=89=20=ED=98=84=ED=99=A9=20=EC=9D=91=EB=8B=B5=20?= =?UTF-8?q?DTO=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../response/AdminDashboardCreditStatsResponse.java | 9 +++++++++ .../response/AdminDashboardCreditTypeStatResponse.java | 9 +++++++++ 2 files changed, 18 insertions(+) create mode 100644 src/main/java/com/swyp/picke/domain/admin/dto/dashboard/response/AdminDashboardCreditStatsResponse.java create mode 100644 src/main/java/com/swyp/picke/domain/admin/dto/dashboard/response/AdminDashboardCreditTypeStatResponse.java diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/dashboard/response/AdminDashboardCreditStatsResponse.java b/src/main/java/com/swyp/picke/domain/admin/dto/dashboard/response/AdminDashboardCreditStatsResponse.java new file mode 100644 index 0000000..19666a5 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/dashboard/response/AdminDashboardCreditStatsResponse.java @@ -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 byType +) {} diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/dashboard/response/AdminDashboardCreditTypeStatResponse.java b/src/main/java/com/swyp/picke/domain/admin/dto/dashboard/response/AdminDashboardCreditTypeStatResponse.java new file mode 100644 index 0000000..412fabc --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/dashboard/response/AdminDashboardCreditTypeStatResponse.java @@ -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 +) {} From a4dc36b64a86b7edd9f321f58555894a19b68c85 Mon Sep 17 00:00:00 2001 From: si-zero Date: Wed, 2 Sep 2026 15:30:19 +0900 Subject: [PATCH 3/5] =?UTF-8?q?feat:=20=ED=81=AC=EB=A0=88=EB=94=A7=20?= =?UTF-8?q?=EC=A7=80=EA=B8=89=20=ED=98=84=ED=99=A9=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?=EC=84=9C=EB=B9=84=EC=8A=A4=20=EB=A1=9C=EC=A7=81=20=EB=B0=8F=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=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 | 28 +++++++++++ .../service/AdminDashboardServiceTest.java | 50 +++++++++++++++++++ 2 files changed, 78 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 114b0d5..79fe339 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 @@ -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; @@ -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; @@ -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 rows = creditHistoryRepository.findStatsByCreatedAtBetween( + from.atStartOfDay(), to.plusDays(1).atStartOfDay()); + + List 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; } 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 0e8fc88..2be9448 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 @@ -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; @@ -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; @@ -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 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 From 02a1ad8a70d0fcbd7efd2fd11d9e7e57a3aeff04 Mon Sep 17 00:00:00 2001 From: si-zero Date: Wed, 2 Sep 2026 15:30:29 +0900 Subject: [PATCH 4/5] =?UTF-8?q?feat:=20=ED=81=AC=EB=A0=88=EB=94=A7=20?= =?UTF-8?q?=EC=A7=80=EA=B8=89=20=ED=98=84=ED=99=A9=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?API=20=EC=97=94=EB=93=9C=ED=8F=AC=EC=9D=B8=ED=8A=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 --- .../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 416e674..a831bf5 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 @@ -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; @@ -70,4 +71,13 @@ public ApiResponse getAttendanceStats( ) { return ApiResponse.onSuccess(adminDashboardService.getAttendanceStats(from, to)); } + + @Operation(summary = "크레딧 지급 현황 조회", description = "기간 내 크레딧 타입별 지급/차감 건수와 총액, 전체 지급/차감 합계를 반환한다.") + @GetMapping("/credit-stats") + public ApiResponse getCreditStats( + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate from, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate to + ) { + return ApiResponse.onSuccess(adminDashboardService.getCreditStats(from, to)); + } } From 891e3971350abd9b0f6c20715575761bed7395dd Mon Sep 17 00:00:00 2001 From: si-zero Date: Wed, 2 Sep 2026 15:30:29 +0900 Subject: [PATCH 5/5] =?UTF-8?q?docs:=20=ED=81=AC=EB=A0=88=EB=94=A7=20?= =?UTF-8?q?=EC=A7=80=EA=B8=89=20=ED=98=84=ED=99=A9=20API=20=EB=AA=85?= =?UTF-8?q?=EC=84=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/api-specs/dashboard-api.md | 50 ++++++++++++++++++++++++++++++++- 1 file changed, 49 insertions(+), 1 deletion(-) diff --git a/docs/api-specs/dashboard-api.md b/docs/api-specs/dashboard-api.md index dd84413..8a20064 100644 --- a/docs/api-specs/dashboard-api.md +++ b/docs/api-specs/dashboard-api.md @@ -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 | 설명 | |---|:---:|---|