diff --git a/docs/api-specs/dashboard-api.md b/docs/api-specs/dashboard-api.md new file mode 100644 index 00000000..8a20064d --- /dev/null +++ b/docs/api-specs/dashboard-api.md @@ -0,0 +1,293 @@ +# 어드민 대시보드 API 명세서 + +--- + +## 1. 설계 메모 + +- 모든 API는 `Authorization: Bearer {access_token}` 헤더가 필요하며, `ROLE_ADMIN` 권한을 가진 계정만 호출 가능합니다. +- DAU(활동 유저)/로그인 유저 지표는 `user_daily_activities` 테이블 기반입니다. 유저-날짜(`user_id`, `activity_date`) 조합당 row 1개이며, 두 플래그를 관리합니다. + - `active`: 그날 인증된 API를 한 번이라도 호출했는지 (`JwtFilter`에서 매 요청마다 기록) + - `logged_in`: 그날 로그인(소셜/로컬)했는지 (`AuthService`에서 로그인 성공 시점에만 기록, 토큰 refresh는 제외) +- `totalUserCount`는 날짜 필터와 무관하게 현재 `UserStatus.ACTIVE` 상태인 전체 유저 수(누적)입니다. + +--- + +## 2. `GET /api/v1/admin/dashboard/summary` + +오늘(서버 로컬 날짜 기준) 요약 카드용 지표를 조회합니다. + +요청 헤더: + +- `Authorization: Bearer {access_token}` + +성공 응답 `200 OK`: + +```json +{ + "statusCode": 200, + "data": { + "newUserCount": 12, + "loginUserCount": 340, + "activeUserCount": 500, + "totalUserCount": 58000 + }, + "error": null +} +``` + +| 필드 | 설명 | +|---|---| +| `newUserCount` | 오늘 가입한 유저 수 (`users.created_at`) | +| `loginUserCount` | 오늘 로그인한 유저 수 (`user_daily_activities.logged_in=true`) | +| `activeUserCount` | 오늘 활동(DAU)한 유저 수 (`user_daily_activities.active=true`) | +| `totalUserCount` | 현재 `ACTIVE` 상태 전체 유저 수 (날짜 무관 누적) | + +--- + +## 3. `GET /api/v1/admin/dashboard/dau-mau` + +기간별 DAU/MAU 추이(꺾은선 그래프용)를 조회합니다. 활동이 없는 날짜도 0으로 채워서 반환합니다. + +요청 헤더: + +- `Authorization: Bearer {access_token}` + +쿼리 파라미터: + +| 파라미터 | 타입 | 필수 | 설명 | +|---|---|---|---| +| `from` | `string` (`YYYY-MM-DD`) | Y | 조회 시작일 | +| `to` | `string` (`YYYY-MM-DD`) | Y | 조회 종료일 | +| `granularity` | `string` | N | `day`(기본값, DAU) \| `month`(MAU) | + +`granularity=day`는 그날그날의 활동 유저 수(단순 카운트)를, `granularity=month`는 그날 기준 최근 30일 롤링 윈도우의 distinct 활동 유저 수(MAU)를 반환합니다. 예를 들어 `to=2026-08-15`일 때 8/15 항목의 `count`는 `2026-07-17~2026-08-15` 사이에 한 번이라도 활동한 유저 수입니다. + +성공 응답 `200 OK` (`granularity=day`): + +```json +{ + "statusCode": 200, + "data": { + "items": [ + { "date": "2026-08-01", "count": 320 }, + { "date": "2026-08-02", "count": 410 } + ] + }, + "error": null +} +``` + +예외 응답 `400 - from이 to보다 늦음`: + +```json +{ + "statusCode": 400, + "data": null, + "error": { + "code": "COMMON_400", + "message": "요청 파라미터가 잘못되었습니다." + } +} +``` + +--- + +## 4. `GET /api/v1/admin/dashboard/new-users` + +기간별 신규 가입자 추이(꺾은선 그래프용)를 조회합니다. `dau-mau`와 동일한 파라미터 형태를 쓰되, 롤링 윈도우 없이 단순 카운트만 수행합니다. 가입자가 없는 날짜/주도 0으로 채워서 반환합니다. + +요청 헤더: + +- `Authorization: Bearer {access_token}` + +쿼리 파라미터: + +| 파라미터 | 타입 | 필수 | 설명 | +|---|---|---|---| +| `from` | `string` (`YYYY-MM-DD`) | Y | 조회 시작일 | +| `to` | `string` (`YYYY-MM-DD`) | Y | 조회 종료일 | +| `granularity` | `string` | N | `day`(기본값, 일자별) \| `week`(주별, ISO 8601 월요일 시작) | + +`granularity=week`일 때 각 항목의 `date`는 그 주의 시작일(월요일)입니다. + +`totalCount`는 `from`~`to` 구간 전체의 정확한 합계입니다. `granularity=week`로 조회하면 주 경계가 `from`/`to`를 벗어나는 날짜까지 포함할 수 있어(예: `to`가 주 중간이면 그 주 전체가 한 항목으로 잡힘) `items`의 `count`를 그냥 더한 값과 `totalCount`가 다를 수 있습니다 — **임의 기간의 정확한 합계가 필요하면 `granularity` 값과 무관하게 `totalCount`를 사용하세요.** + +성공 응답 `200 OK` (`granularity=day`): + +```json +{ + "statusCode": 200, + "data": { + "totalCount": 20, + "items": [ + { "date": "2026-08-01", "count": 12 }, + { "date": "2026-08-02", "count": 8 } + ] + }, + "error": null +} +``` + +성공 응답 `200 OK` (`granularity=week`): + +```json +{ + "statusCode": 200, + "data": { + "totalCount": 130, + "items": [ + { "date": "2026-07-27", "count": 65 }, + { "date": "2026-08-03", "count": 71 } + ] + }, + "error": null +} +``` + +`from`이 `to`보다 늦으면 `dau-mau`와 동일하게 `COMMON_400`으로 400을 반환합니다. + +--- + +## 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. `GET /api/v1/admin/dashboard/attendance-stats` + +기간 내 출석 체크 현황과 개근 보너스 달성 건수를 조회합니다. 출석 체크는 로그인 시 자동으로 되는 게 아니라 유저가 명시적으로 출석 체크 API를 호출해야 기록됩니다. + +요청 헤더: + +- `Authorization: Bearer {access_token}` + +쿼리 파라미터: + +| 파라미터 | 타입 | 필수 | 설명 | +|---|---|---|---| +| `from` | `string` (`YYYY-MM-DD`) | Y | 조회 시작일 | +| `to` | `string` (`YYYY-MM-DD`) | Y | 조회 종료일 | + +성공 응답 `200 OK`: + +```json +{ + "statusCode": 200, + "data": { + "avgAttendanceRate": 0.12, + "totalCount": 850, + "streakAchievedCount": 42, + "items": [ + { "date": "2026-08-01", "count": 100 }, + { "date": "2026-08-02", "count": 120 } + ] + }, + "error": null +} +``` + +| 필드 | 설명 | +|---|---| +| `avgAttendanceRate` | 일자별 (출석 인원 / 전체 `ACTIVE` 유저 수)의 기간 평균 | +| `totalCount` | 기간 내 총 출석 횟수 | +| `streakAchievedCount` | 기간 내 개근 보너스(`CreditType.ATTENDANCE_STREAK`, 월~토 개근 후 일요일 출석 시 지급)를 받은 건수 | +| `items` | 일자별 출석 인원 수 (출석이 없는 날짜도 `0`으로 채움) | + +`from`이 `to`보다 늦으면 `COMMON_400`으로 400을 반환합니다. + +--- + +## 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 | 설명 | +|---|:---:|---| +| `AUTH_403` | `403` | 해당 API 접근 권한(관리자 권한)이 없습니다. | +| `COMMON_400` | `400` | 요청 파라미터가 잘못되었습니다. (예: `from`이 `to`보다 늦은 경우) | diff --git a/docs/erd/notice-notification.puml b/docs/erd/notice-notification.puml index 5402b62e..be440e83 100644 --- a/docs/erd/notice-notification.puml +++ b/docs/erd/notice-notification.puml @@ -7,35 +7,70 @@ entity "USERS\n사용자" as users { * id : Long <> } -entity "NOTICES\n전체 공지" as notices { +entity "NOTIFICATIONS\n인앱 알림 / 공지 (user_id NULL이면 전체 broadcast)" as notifications { * id : Long <> -- - title : string - body : text - notice_type : string - is_pinned : boolean - starts_at : datetime - ends_at : datetime + user_id : Long <> + category : ENUM('CONTENT', 'NOTICE', 'EVENT') + detail_code : ENUM('NEW_BATTLE', 'COMMENT_LIKE', 'NEW_COMMENT', 'CREDIT_EARNED', 'POLICY_CHANGE', 'PROMOTION', 'VOTE_RESULT', 'DAILY_MESSAGE') + title : VARCHAR(150) + body : TEXT + reference_id : Long (nullable) + perspective_id : Long (nullable) + is_read : boolean + read_at : datetime (nullable) + deleted_at : datetime (nullable) created_at : datetime + updated_at : datetime } -entity "NOTIFICATIONS\n알림 발송 이력" as notifications { +entity "NOTIFICATION_READS\nbroadcast 알림 유저별 읽음 처리" as notification_reads { * id : Long <> -- - user_id : Long <> - notification_type : string - title : string - body : text - payload_json : text - status : string - scheduled_at : datetime - sent_at : datetime - failed_at : datetime - provider_message_id : string - failure_reason : string + notification_id : Long <> + user_id : Long created_at : datetime + updated_at : datetime + UNIQUE(notification_id, user_id) +} + +entity "NOTIFICATION_SCHEDULES\n관리자 예약 발송(매일 정해진 시각)" as notification_schedules { + * id : Long <> + -- + title : VARCHAR(150) + subtitle : VARCHAR(150) + send_time : TIME + enabled : boolean + last_sent_date : DATE (nullable) + created_at : datetime + updated_at : datetime +} + +entity "NOTIFICATION_DELIVERY_RESULTS\n공지/예약 발송 결과(대상/성공/실패 건수)" as notification_delivery_results { + * id : Long <> + -- + notification_id : Long <> + target_count : int + success_count : int + failure_count : int + created_at : datetime + updated_at : datetime } users ||--o{ notifications +notifications ||--o{ notification_reads +notifications ||--o| notification_delivery_results + +note right of notifications + user_id가 NULL이면 전체 broadcast 알림(공지/이벤트/예약메시지). + broadcast 알림의 유저별 읽음 상태는 notification_reads에 별도 기록하고, + 개인 알림(user_id 존재)의 읽음 상태는 is_read/read_at을 직접 사용한다. + deleted_at은 관리자 공지 soft delete용. +end note + +note right of notification_delivery_results + notifyAdminNotice()로 발송되는 공지(NOTICE/EVENT)와 예약 알림만 생성된다. + CONTENT 카테고리 공지는 이 발송 트리거를 타지 않아 결과가 없다. +end note @enduml diff --git a/docs/erd/user-ops.puml b/docs/erd/user-ops.puml index db748bcc..f722a71b 100644 --- a/docs/erd/user-ops.puml +++ b/docs/erd/user-ops.puml @@ -33,14 +33,27 @@ entity "USER_AGREEMENTS\n사용자 동의 이력" as user_agreements { agreed_at : timestamp } -entity "USER_DEVICES\n사용자 디바이스" as user_devices { +entity "USER_DEVICES\n사용자 디바이스(FCM/APNs 토큰)" as user_devices { * id : BIGINT <> -- user_id : BIGINT <> - device_token : string - platform : string - last_seen_at : timestamp + fcm_token : VARCHAR(255) <> + platform : ENUM('ANDROID', 'IOS') + last_used_at : timestamp created_at : timestamp + updated_at : timestamp +} + +entity "USER_DAILY_ACTIVITIES\n어드민 대시보드 DAU/MAU 집계용 유저-날짜별 활동" as user_daily_activities { + * id : BIGINT <> + -- + user_id : BIGINT <> + activity_date : DATE + logged_in : boolean + active : boolean + created_at : timestamp + updated_at : timestamp + UNIQUE(user_id, activity_date) } entity "USER_BLOCKS\n사용자 차단" as user_blocks { @@ -54,18 +67,20 @@ entity "USER_BLOCKS\n사용자 차단" as user_blocks { users -[hidden]down- user_settings user_settings -[hidden]down- user_agreements user_agreements -[hidden]down- user_devices -user_devices -[hidden]down- user_blocks +user_devices -[hidden]down- user_daily_activities +user_daily_activities -[hidden]down- user_blocks users ||--|| user_settings users ||--o{ user_agreements users ||--o{ user_devices +users ||--o{ user_daily_activities users ||--o{ user_blocks : blocker users ||--o{ user_blocks : blocked note bottom of user_blocks 공통 컬럼 정책 - BaseEntity: created_at, updated_at - - agreed_at, last_seen_at 은 도메인별 개별 컬럼 + - agreed_at, last_used_at 은 도메인별 개별 컬럼 end note @enduml 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 new file mode 100644 index 00000000..a831bf5d --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/controller/AdminDashboardController.java @@ -0,0 +1,83 @@ +package com.swyp.picke.domain.admin.controller; + +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; +import com.swyp.picke.domain.admin.service.AdminDashboardService; +import com.swyp.picke.global.common.response.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.time.LocalDate; +import lombok.RequiredArgsConstructor; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "관리자 대시보드 API", description = "가입/활동 지표 등 어드민 대시보드 조회") +@RestController +@RequestMapping("/api/v1/admin/dashboard") +@RequiredArgsConstructor +@PreAuthorize("hasRole('ADMIN')") +public class AdminDashboardController { + + private final AdminDashboardService adminDashboardService; + + @Operation(summary = "오늘 요약 카드 조회", description = "오늘 기준 신규가입/로그인유저/활동유저(DAU) 수와 현재 전체 유저 수를 조회한다.") + @GetMapping("/summary") + public ApiResponse getSummary() { + return ApiResponse.onSuccess(adminDashboardService.getSummary()); + } + + @Operation(summary = "DAU/MAU 추이 조회", description = "granularity=day면 일자별 DAU, month면 그날 기준 최근 30일 롤링 윈도우의 MAU를 반환한다.") + @GetMapping("/dau-mau") + public ApiResponse getDauMauTrend( + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate from, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate to, + @RequestParam(defaultValue = "day") String granularity + ) { + return ApiResponse.onSuccess(adminDashboardService.getDauMauTrend(from, to, granularity)); + } + + @Operation(summary = "신규 가입자 추이 조회", description = "granularity=day면 일자별, week면 주별(ISO 8601, 월요일 시작) 신규 가입자 수를 반환한다.") + @GetMapping("/new-users") + public ApiResponse getNewUsersTrend( + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate from, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate to, + @RequestParam(defaultValue = "day") String granularity + ) { + 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)); + } + + @Operation(summary = "출석 체크율 조회", description = "기간 내 일자별 출석 인원, 총 출석 횟수, 평균 출석률, 개근 보너스(7일 연속 출석) 달성 건수를 반환한다.") + @GetMapping("/attendance-stats") + public ApiResponse getAttendanceStats( + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate from, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate to + ) { + 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)); + } +} diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/dashboard/response/AdminDashboardAttendanceStatsResponse.java b/src/main/java/com/swyp/picke/domain/admin/dto/dashboard/response/AdminDashboardAttendanceStatsResponse.java new file mode 100644 index 00000000..23c4c208 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/dashboard/response/AdminDashboardAttendanceStatsResponse.java @@ -0,0 +1,10 @@ +package com.swyp.picke.domain.admin.dto.dashboard.response; + +import java.util.List; + +public record AdminDashboardAttendanceStatsResponse( + double avgAttendanceRate, + long totalCount, + long streakAchievedCount, + List items +) {} 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/dto/dashboard/response/AdminDashboardCreditStatsResponse.java b/src/main/java/com/swyp/picke/domain/admin/dto/dashboard/response/AdminDashboardCreditStatsResponse.java new file mode 100644 index 00000000..19666a5c --- /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 00000000..412fabcd --- /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 +) {} diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/dashboard/response/AdminDashboardDauMauResponse.java b/src/main/java/com/swyp/picke/domain/admin/dto/dashboard/response/AdminDashboardDauMauResponse.java new file mode 100644 index 00000000..06476906 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/dashboard/response/AdminDashboardDauMauResponse.java @@ -0,0 +1,7 @@ +package com.swyp.picke.domain.admin.dto.dashboard.response; + +import java.util.List; + +public record AdminDashboardDauMauResponse( + List items +) {} diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/dashboard/response/AdminDashboardNewUsersResponse.java b/src/main/java/com/swyp/picke/domain/admin/dto/dashboard/response/AdminDashboardNewUsersResponse.java new file mode 100644 index 00000000..3110208a --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/dashboard/response/AdminDashboardNewUsersResponse.java @@ -0,0 +1,8 @@ +package com.swyp.picke.domain.admin.dto.dashboard.response; + +import java.util.List; + +public record AdminDashboardNewUsersResponse( + long totalCount, + List items +) {} diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/dashboard/response/AdminDashboardSummaryResponse.java b/src/main/java/com/swyp/picke/domain/admin/dto/dashboard/response/AdminDashboardSummaryResponse.java new file mode 100644 index 00000000..876615dc --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/dashboard/response/AdminDashboardSummaryResponse.java @@ -0,0 +1,8 @@ +package com.swyp.picke.domain.admin.dto.dashboard.response; + +public record AdminDashboardSummaryResponse( + long newUserCount, + long loginUserCount, + long activeUserCount, + long totalUserCount +) {} diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/dashboard/response/AdminDashboardTrendItemResponse.java b/src/main/java/com/swyp/picke/domain/admin/dto/dashboard/response/AdminDashboardTrendItemResponse.java new file mode 100644 index 00000000..9ac9df0a --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/dashboard/response/AdminDashboardTrendItemResponse.java @@ -0,0 +1,8 @@ +package com.swyp.picke.domain.admin.dto.dashboard.response; + +import java.time.LocalDate; + +public record AdminDashboardTrendItemResponse( + LocalDate date, + long count +) {} 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 new file mode 100644 index 00000000..79fe339e --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/service/AdminDashboardService.java @@ -0,0 +1,158 @@ +package com.swyp.picke.domain.admin.service; + +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; +import com.swyp.picke.domain.admin.dto.dashboard.response.AdminDashboardTrendItemResponse; +import com.swyp.picke.domain.attendance.repository.AttendanceRecordRepository; +import com.swyp.picke.domain.battle.repository.BattleRepository; +import com.swyp.picke.domain.battle.repository.projection.BattleParticipationStats; +import com.swyp.picke.domain.user.enums.CreditType; +import com.swyp.picke.domain.user.enums.UserStatus; +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; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AdminDashboardService { + + private final UserRepository userRepository; + private final UserDailyActivityRepository userDailyActivityRepository; + private final BattleRepository battleRepository; + private final AttendanceRecordRepository attendanceRecordRepository; + private final CreditHistoryRepository creditHistoryRepository; + + public AdminDashboardSummaryResponse getSummary() { + LocalDate today = LocalDate.now(); + LocalDateTime todayStart = today.atStartOfDay(); + LocalDateTime tomorrowStart = todayStart.plusDays(1); + + long newUserCount = userRepository.countByCreatedAtBetween(todayStart, tomorrowStart); + long loginUserCount = userDailyActivityRepository.countByActivityDateAndLoggedInTrue(today); + long activeUserCount = userDailyActivityRepository.countByActivityDateAndActiveTrue(today); + long totalUserCount = userRepository.countByStatus(UserStatus.ACTIVE); + + return new AdminDashboardSummaryResponse(newUserCount, loginUserCount, activeUserCount, totalUserCount); + } + + public AdminDashboardDauMauResponse getDauMauTrend(LocalDate from, LocalDate to, String granularity) { + if (from.isAfter(to)) { + throw new CustomException(ErrorCode.COMMON_INVALID_PARAMETER); + } + + List rows = "month".equalsIgnoreCase(granularity) + ? userDailyActivityRepository.findRollingMonthlyActiveUserCounts(from, to) + : userDailyActivityRepository.findDailyActiveUserCounts(from, to); + + List items = rows.stream() + .map(row -> new AdminDashboardTrendItemResponse(row.getActivityDate(), row.getCount())) + .toList(); + + return new AdminDashboardDauMauResponse(items); + } + + public AdminDashboardNewUsersResponse getNewUsersTrend(LocalDate from, LocalDate to, String granularity) { + if (from.isAfter(to)) { + throw new CustomException(ErrorCode.COMMON_INVALID_PARAMETER); + } + + List rows = "week".equalsIgnoreCase(granularity) + ? userRepository.findWeeklyNewUserCounts(from, to) + : userRepository.findDailyNewUserCounts(from, to); + + List items = rows.stream() + .map(row -> new AdminDashboardTrendItemResponse(row.getActivityDate(), row.getCount())) + .toList(); + + // week 단위 items는 주 경계가 from~to 범위를 벗어날 수 있어(예: to가 주 중간이면 그 주 전체를 포함), + // 요청한 기간 전체의 정확한 합계는 items 합산이 아니라 별도 카운트로 계산한다. + long totalCount = userRepository.countByCreatedAtBetween(from.atStartOfDay(), to.plusDays(1).atStartOfDay()); + + 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()) + ); + } + + public AdminDashboardAttendanceStatsResponse getAttendanceStats(LocalDate from, LocalDate to) { + if (from.isAfter(to)) { + throw new CustomException(ErrorCode.COMMON_INVALID_PARAMETER); + } + + List rows = attendanceRecordRepository.findDailyAttendanceCounts(from, to); + List items = rows.stream() + .map(row -> new AdminDashboardTrendItemResponse(row.getActivityDate(), row.getCount())) + .toList(); + + long totalCount = items.stream().mapToLong(AdminDashboardTrendItemResponse::count).sum(); + + long totalUsers = userRepository.countByStatus(UserStatus.ACTIVE); + double avgAttendanceRate = (totalUsers > 0 && !items.isEmpty()) + ? items.stream().mapToDouble(item -> (double) item.count() / totalUsers).average().orElse(0.0) + : 0.0; + + long streakAchievedCount = creditHistoryRepository.countByCreditTypeAndCreatedAtBetween( + CreditType.ATTENDANCE_STREAK, from.atStartOfDay(), to.plusDays(1).atStartOfDay()); + + 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/main/java/com/swyp/picke/domain/attendance/repository/AttendanceRecordRepository.java b/src/main/java/com/swyp/picke/domain/attendance/repository/AttendanceRecordRepository.java index 748e5166..c56767e7 100644 --- a/src/main/java/com/swyp/picke/domain/attendance/repository/AttendanceRecordRepository.java +++ b/src/main/java/com/swyp/picke/domain/attendance/repository/AttendanceRecordRepository.java @@ -1,6 +1,7 @@ package com.swyp.picke.domain.attendance.repository; import com.swyp.picke.domain.attendance.entity.AttendanceRecord; +import com.swyp.picke.domain.user.repository.projection.DailyUserCount; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; @@ -27,4 +28,16 @@ public interface AttendanceRecordRepository extends JpaRepository countAttendanceGroupByDate(@Param("from") LocalDate from, @Param("to") LocalDate to); + + /** + * 어드민 대시보드용: from~to 구간의 일자별 출석 인원 수. 출석이 없는 날짜도 0으로 채워서 반환한다. + */ + @Query(value = """ + SELECT gs::date AS activity_date, COUNT(ar.user_id) AS count + FROM generate_series(CAST(:from AS timestamp), CAST(:to AS timestamp), interval '1 day') AS gs + LEFT JOIN attendance_records ar ON ar.attended_date = gs::date + GROUP BY gs::date + ORDER BY gs::date + """, nativeQuery = true) + List findDailyAttendanceCounts(@Param("from") LocalDate from, @Param("to") LocalDate to); } 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/main/java/com/swyp/picke/domain/oauth/jwt/JwtFilter.java b/src/main/java/com/swyp/picke/domain/oauth/jwt/JwtFilter.java index 42c11fc2..474f9158 100644 --- a/src/main/java/com/swyp/picke/domain/oauth/jwt/JwtFilter.java +++ b/src/main/java/com/swyp/picke/domain/oauth/jwt/JwtFilter.java @@ -1,6 +1,7 @@ package com.swyp.picke.domain.oauth.jwt; import com.fasterxml.jackson.databind.ObjectMapper; +import com.swyp.picke.domain.user.repository.UserDailyActivityRepository; import com.swyp.picke.global.common.exception.ErrorCode; import com.swyp.picke.global.common.response.ApiResponse; import jakarta.servlet.FilterChain; @@ -16,6 +17,7 @@ import org.springframework.web.filter.OncePerRequestFilter; import java.io.IOException; +import java.time.LocalDate; import java.util.List; @Slf4j @@ -23,6 +25,7 @@ public class JwtFilter extends OncePerRequestFilter { private final JwtProvider jwtProvider; + private final UserDailyActivityRepository userDailyActivityRepository; private final ObjectMapper objectMapper = new ObjectMapper(); // 1. 화이트리스트 상수 관리 @@ -89,6 +92,7 @@ protected void doFilterInternal(HttpServletRequest request, ); SecurityContextHolder.getContext().setAuthentication(authentication); + recordActivity(userId); } else { // shouldNotFilter가 잡지 못한 주소 중 토큰이 없다면 무조건 401 차단 @@ -105,6 +109,17 @@ protected void doFilterInternal(HttpServletRequest request, } } + /** + * DAU/MAU 집계를 위한 활동 기록. 대시보드 집계용 부가 기능이라 실패해도 인증 흐름을 막지 않는다. + */ + private void recordActivity(Long userId) { + try { + userDailyActivityRepository.markActive(userId, LocalDate.now()); + } catch (Exception e) { + log.warn("[JwtFilter] Failed to record daily activity for userId={}: {}", userId, e.getMessage()); + } + } + private void setErrorResponse(HttpServletResponse response, ErrorCode errorCode) throws IOException { response.setContentType("application/json;charset=UTF-8"); response.setStatus(errorCode.getHttpStatus().value()); diff --git a/src/main/java/com/swyp/picke/domain/oauth/service/AuthService.java b/src/main/java/com/swyp/picke/domain/oauth/service/AuthService.java index 75d68024..2a0839e5 100644 --- a/src/main/java/com/swyp/picke/domain/oauth/service/AuthService.java +++ b/src/main/java/com/swyp/picke/domain/oauth/service/AuthService.java @@ -26,6 +26,7 @@ import com.swyp.picke.domain.user.enums.UserStatus; import com.swyp.picke.domain.user.entity.UserTendencyScore; import com.swyp.picke.domain.user.entity.UserWithdrawal; +import com.swyp.picke.domain.user.repository.UserDailyActivityRepository; import com.swyp.picke.domain.user.repository.UserProfileRepository; import com.swyp.picke.domain.user.repository.UserRepository; import com.swyp.picke.domain.user.repository.UserSettingsRepository; @@ -43,6 +44,7 @@ import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; +import java.time.LocalDate; import java.time.LocalDateTime; import java.util.Base64; import java.util.List; @@ -94,6 +96,7 @@ public class AuthService { private final UserSettingsRepository userSettingsRepository; private final UserTendencyScoreRepository userTendencyScoreRepository; private final UserWithdrawalRepository userWithdrawalRepository; + private final UserDailyActivityRepository userDailyActivityRepository; private final JwtProvider jwtProvider; private final CreditService creditService; private final PasswordEncoder passwordEncoder; @@ -201,6 +204,8 @@ private LoginResponse issueTokensAndBuildResponse(User user, boolean isNewUser) throw new CustomException(ErrorCode.USER_SUSPENDED); } + userDailyActivityRepository.markLoggedIn(user.getId(), LocalDate.now()); + // 기존 refresh token 삭제 후 새로 발급 refreshTokenRepository.deleteByUser(user); diff --git a/src/main/java/com/swyp/picke/domain/user/entity/UserDailyActivity.java b/src/main/java/com/swyp/picke/domain/user/entity/UserDailyActivity.java new file mode 100644 index 00000000..414c8e81 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/user/entity/UserDailyActivity.java @@ -0,0 +1,38 @@ +package com.swyp.picke.domain.user.entity; + +import com.swyp.picke.global.common.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import java.time.LocalDate; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 어드민 대시보드 DAU/MAU 집계를 위한 유저-날짜별 활동 기록. + * {@code active}/{@code logged_in}은 JwtFilter/AuthService에서 UPSERT로 기록되며, + * 이 엔티티는 조회 전용으로만 사용한다. + */ +@Getter +@Entity +@Table( + name = "user_daily_activities", + uniqueConstraints = @UniqueConstraint(columnNames = {"user_id", "activity_date"}) +) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class UserDailyActivity extends BaseEntity { + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(name = "activity_date", nullable = false) + private LocalDate activityDate; + + @Column(name = "logged_in", nullable = false) + private boolean loggedIn; + + @Column(name = "active", nullable = false) + private boolean active; +} 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 d420cca0..ccf8477b 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,6 +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; @@ -21,4 +24,21 @@ 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/UserDailyActivityRepository.java b/src/main/java/com/swyp/picke/domain/user/repository/UserDailyActivityRepository.java new file mode 100644 index 00000000..a4e995fe --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/user/repository/UserDailyActivityRepository.java @@ -0,0 +1,64 @@ +package com.swyp.picke.domain.user.repository; + +import com.swyp.picke.domain.user.entity.UserDailyActivity; +import com.swyp.picke.domain.user.repository.projection.DailyUserCount; +import java.time.LocalDate; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.transaction.annotation.Transactional; + +public interface UserDailyActivityRepository extends JpaRepository { + + @Modifying + @Transactional + @Query(value = """ + INSERT INTO user_daily_activities (user_id, activity_date, logged_in, active, created_at, updated_at) + VALUES (:userId, :date, false, true, now(), now()) + ON CONFLICT (user_id, activity_date) + DO UPDATE SET active = true, updated_at = now() + """, nativeQuery = true) + void markActive(@Param("userId") Long userId, @Param("date") LocalDate date); + + @Modifying + @Transactional + @Query(value = """ + INSERT INTO user_daily_activities (user_id, activity_date, logged_in, active, created_at, updated_at) + VALUES (:userId, :date, true, false, now(), now()) + ON CONFLICT (user_id, activity_date) + DO UPDATE SET logged_in = true, updated_at = now() + """, nativeQuery = true) + void markLoggedIn(@Param("userId") Long userId, @Param("date") LocalDate date); + + long countByActivityDateAndLoggedInTrue(LocalDate activityDate); + + long countByActivityDateAndActiveTrue(LocalDate activityDate); + + /** + * from~to 구간의 일자별 DAU(그날 active=true인 유저 수). 활동이 없는 날짜도 0으로 채워서 반환한다. + */ + @Query(value = """ + SELECT gs::date AS activity_date, COUNT(uda.user_id) AS count + FROM generate_series(CAST(:from AS timestamp), CAST(:to AS timestamp), interval '1 day') AS gs + LEFT JOIN user_daily_activities uda + ON uda.activity_date = gs::date AND uda.active = true + GROUP BY gs::date + ORDER BY gs::date + """, nativeQuery = true) + List findDailyActiveUserCounts(@Param("from") LocalDate from, @Param("to") LocalDate to); + + /** + * from~to 구간의 일자별 MAU(그날 기준 최근 30일 롤링 윈도우의 distinct active 유저 수). + */ + @Query(value = """ + SELECT gs::date AS activity_date, COUNT(DISTINCT uda.user_id) AS count + FROM generate_series(CAST(:from AS timestamp), CAST(:to AS timestamp), interval '1 day') AS gs + LEFT JOIN user_daily_activities uda + ON uda.activity_date BETWEEN (gs::date - INTERVAL '29 days') AND gs::date AND uda.active = true + GROUP BY gs::date + ORDER BY gs::date + """, nativeQuery = true) + List findRollingMonthlyActiveUserCounts(@Param("from") LocalDate from, @Param("to") LocalDate to); +} diff --git a/src/main/java/com/swyp/picke/domain/user/repository/UserRepository.java b/src/main/java/com/swyp/picke/domain/user/repository/UserRepository.java index 0911ecc3..9bf3d747 100644 --- a/src/main/java/com/swyp/picke/domain/user/repository/UserRepository.java +++ b/src/main/java/com/swyp/picke/domain/user/repository/UserRepository.java @@ -3,6 +3,9 @@ import com.swyp.picke.domain.user.entity.User; import com.swyp.picke.domain.user.enums.UserRole; import com.swyp.picke.domain.user.enums.UserStatus; +import com.swyp.picke.domain.user.repository.projection.DailyUserCount; +import java.time.LocalDate; +import java.time.LocalDateTime; import java.util.List; import java.util.Optional; import org.springframework.data.domain.Pageable; @@ -39,4 +42,37 @@ or u.userTag like concat('%', :keyword, '%') order by u.id desc """) Slice searchByNicknameOrUserTag(@Param("keyword") String keyword, Pageable pageable); + + long countByStatus(UserStatus status); + + long countByCreatedAtBetween(LocalDateTime start, LocalDateTime end); + + /** + * from~to 구간의 일자별 신규 가입자 수. 가입자가 없는 날짜도 0으로 채워서 반환한다. + */ + @Query(value = """ + SELECT gs::date AS activity_date, COUNT(u.id) AS count + FROM generate_series(CAST(:from AS timestamp), CAST(:to AS timestamp), interval '1 day') AS gs + LEFT JOIN users u ON CAST(u.created_at AS date) = gs::date + GROUP BY gs::date + ORDER BY gs::date + """, nativeQuery = true) + List findDailyNewUserCounts(@Param("from") LocalDate from, @Param("to") LocalDate to); + + /** + * from~to 구간이 걸쳐있는 주(ISO 8601, 월요일 시작)별 신규 가입자 수. 가입자가 없는 주도 0으로 채워서 반환하며, + * 반환되는 날짜는 각 주의 시작일(월요일)이다. + */ + @Query(value = """ + SELECT gs::date AS activity_date, COUNT(u.id) AS count + FROM generate_series( + date_trunc('week', CAST(:from AS timestamp)), + date_trunc('week', CAST(:to AS timestamp)), + interval '1 week' + ) AS gs + LEFT JOIN users u ON date_trunc('week', u.created_at) = gs + GROUP BY gs::date + ORDER BY gs::date + """, nativeQuery = true) + List findWeeklyNewUserCounts(@Param("from") LocalDate from, @Param("to") LocalDate to); } 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 00000000..2adcab56 --- /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(); +} diff --git a/src/main/java/com/swyp/picke/domain/user/repository/projection/DailyUserCount.java b/src/main/java/com/swyp/picke/domain/user/repository/projection/DailyUserCount.java new file mode 100644 index 00000000..c615daa1 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/user/repository/projection/DailyUserCount.java @@ -0,0 +1,8 @@ +package com.swyp.picke.domain.user.repository.projection; + +import java.time.LocalDate; + +public interface DailyUserCount { + LocalDate getActivityDate(); + long getCount(); +} diff --git a/src/main/java/com/swyp/picke/global/config/SecurityConfig.java b/src/main/java/com/swyp/picke/global/config/SecurityConfig.java index 73ca62b7..d0037617 100644 --- a/src/main/java/com/swyp/picke/global/config/SecurityConfig.java +++ b/src/main/java/com/swyp/picke/global/config/SecurityConfig.java @@ -2,6 +2,7 @@ import com.swyp.picke.domain.oauth.jwt.JwtFilter; import com.swyp.picke.domain.oauth.jwt.JwtProvider; +import com.swyp.picke.domain.user.repository.UserDailyActivityRepository; import java.util.Arrays; import java.util.List; import lombok.RequiredArgsConstructor; @@ -28,6 +29,7 @@ public class SecurityConfig { private final JwtProvider jwtProvider; + private final UserDailyActivityRepository userDailyActivityRepository; @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { @@ -70,7 +72,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .requestMatchers("/api/v1/admin/**").hasRole("ADMIN") .anyRequest().authenticated() ) - .addFilterBefore(new JwtFilter(jwtProvider), + .addFilterBefore(new JwtFilter(jwtProvider, userDailyActivityRepository), UsernamePasswordAuthenticationFilter.class); return http.build(); diff --git a/src/main/resources/db/migration/V20260826_01__add_deleted_at_to_notifications.sql b/src/main/resources/db/migration/V20260826_01__add_deleted_at_to_notifications.sql new file mode 100644 index 00000000..717c9117 --- /dev/null +++ b/src/main/resources/db/migration/V20260826_01__add_deleted_at_to_notifications.sql @@ -0,0 +1,3 @@ +-- 관리자 공지사항 soft delete를 위한 deleted_at 컬럼 추가 +ALTER TABLE notifications + ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMP; diff --git a/src/main/resources/db/migration/V20260826_02__create_notification_delivery_results.sql b/src/main/resources/db/migration/V20260826_02__create_notification_delivery_results.sql new file mode 100644 index 00000000..6d754e67 --- /dev/null +++ b/src/main/resources/db/migration/V20260826_02__create_notification_delivery_results.sql @@ -0,0 +1,11 @@ +-- 관리자 공지/예약 알림 발송 결과(대상/성공/실패 건수) 집계 테이블 +CREATE TABLE IF NOT EXISTS notification_delivery_results ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + notification_id BIGINT NOT NULL, + target_count INT NOT NULL, + success_count INT NOT NULL DEFAULT 0, + failure_count INT NOT NULL DEFAULT 0, + created_at TIMESTAMP, + updated_at TIMESTAMP, + CONSTRAINT uk_notification_delivery_results_notification_id UNIQUE (notification_id) +); diff --git a/src/main/resources/db/migration/V20260830_01__create_user_daily_activities.sql b/src/main/resources/db/migration/V20260830_01__create_user_daily_activities.sql new file mode 100644 index 00000000..daa6c787 --- /dev/null +++ b/src/main/resources/db/migration/V20260830_01__create_user_daily_activities.sql @@ -0,0 +1,11 @@ +-- 어드민 대시보드 DAU/MAU 집계를 위한 유저-날짜별 활동 기록 테이블 +CREATE TABLE IF NOT EXISTS user_daily_activities ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + user_id BIGINT NOT NULL, + activity_date DATE NOT NULL, + logged_in BOOLEAN NOT NULL DEFAULT FALSE, + active BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMP, + updated_at TIMESTAMP, + CONSTRAINT uk_user_daily_activities_user_date UNIQUE (user_id, activity_date) +); 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 new file mode 100644 index 00000000..2be9448a --- /dev/null +++ b/src/test/java/com/swyp/picke/domain/admin/service/AdminDashboardServiceTest.java @@ -0,0 +1,326 @@ +package com.swyp.picke.domain.admin.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +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; +import com.swyp.picke.domain.battle.repository.BattleRepository; +import com.swyp.picke.domain.battle.repository.projection.BattleParticipationStats; +import com.swyp.picke.domain.user.enums.CreditType; +import com.swyp.picke.domain.user.enums.UserStatus; +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; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class AdminDashboardServiceTest { + + @Mock + private UserRepository userRepository; + + @Mock + private UserDailyActivityRepository userDailyActivityRepository; + + @Mock + private BattleRepository battleRepository; + + @Mock + private AttendanceRecordRepository attendanceRecordRepository; + + @Mock + private CreditHistoryRepository creditHistoryRepository; + + @InjectMocks + private AdminDashboardService adminDashboardService; + + @Test + @DisplayName("오늘 요약 카드를 조회한다") + void getSummary_returnsTodaySummary() { + when(userRepository.countByCreatedAtBetween(any(), any())).thenReturn(12L); + when(userDailyActivityRepository.countByActivityDateAndLoggedInTrue(eq(LocalDate.now()))).thenReturn(340L); + when(userDailyActivityRepository.countByActivityDateAndActiveTrue(eq(LocalDate.now()))).thenReturn(500L); + when(userRepository.countByStatus(UserStatus.ACTIVE)).thenReturn(58000L); + + AdminDashboardSummaryResponse response = adminDashboardService.getSummary(); + + assertThat(response.newUserCount()).isEqualTo(12L); + assertThat(response.loginUserCount()).isEqualTo(340L); + assertThat(response.activeUserCount()).isEqualTo(500L); + assertThat(response.totalUserCount()).isEqualTo(58000L); + } + + @Test + @DisplayName("granularity=day면 일자별 DAU 카운트를 조회한다") + void getDauMauTrend_day_returnsDailyActiveCounts() { + LocalDate from = LocalDate.of(2026, 8, 1); + LocalDate to = LocalDate.of(2026, 8, 2); + List rows = List.of( + dailyUserCount(LocalDate.of(2026, 8, 1), 10L), + dailyUserCount(LocalDate.of(2026, 8, 2), 15L) + ); + when(userDailyActivityRepository.findDailyActiveUserCounts(from, to)).thenReturn(rows); + + AdminDashboardDauMauResponse response = adminDashboardService.getDauMauTrend(from, to, "day"); + + assertThat(response.items()).hasSize(2); + assertThat(response.items().get(0).date()).isEqualTo(LocalDate.of(2026, 8, 1)); + assertThat(response.items().get(0).count()).isEqualTo(10L); + assertThat(response.items().get(1).count()).isEqualTo(15L); + } + + @Test + @DisplayName("granularity=month면 롤링 30일 MAU 카운트를 조회한다") + void getDauMauTrend_month_returnsRollingMonthlyCounts() { + LocalDate from = LocalDate.of(2026, 8, 1); + LocalDate to = LocalDate.of(2026, 8, 1); + List rows = List.of(dailyUserCount(LocalDate.of(2026, 8, 1), 1200L)); + when(userDailyActivityRepository.findRollingMonthlyActiveUserCounts(from, to)).thenReturn(rows); + + AdminDashboardDauMauResponse response = adminDashboardService.getDauMauTrend(from, to, "month"); + + assertThat(response.items()).hasSize(1); + assertThat(response.items().get(0).count()).isEqualTo(1200L); + } + + @Test + @DisplayName("from이 to보다 늦으면 예외를 던진다") + void getDauMauTrend_throws_whenFromIsAfterTo() { + LocalDate from = LocalDate.of(2026, 8, 10); + LocalDate to = LocalDate.of(2026, 8, 1); + + assertThatThrownBy(() -> adminDashboardService.getDauMauTrend(from, to, "day")) + .isInstanceOf(CustomException.class); + } + + @Test + @DisplayName("granularity=day면 일자별 신규 가입자 수를 조회한다") + void getNewUsersTrend_day_returnsDailyCounts() { + LocalDate from = LocalDate.of(2026, 8, 1); + LocalDate to = LocalDate.of(2026, 8, 2); + List rows = List.of( + dailyUserCount(LocalDate.of(2026, 8, 1), 3L), + dailyUserCount(LocalDate.of(2026, 8, 2), 5L) + ); + when(userRepository.findDailyNewUserCounts(from, to)).thenReturn(rows); + when(userRepository.countByCreatedAtBetween(any(), any())).thenReturn(8L); + + var response = adminDashboardService.getNewUsersTrend(from, to, "day"); + + assertThat(response.items()).hasSize(2); + assertThat(response.items().get(1).count()).isEqualTo(5L); + assertThat(response.totalCount()).isEqualTo(8L); + } + + @Test + @DisplayName("granularity=week면 주별 신규 가입자 수를 조회한다") + void getNewUsersTrend_week_returnsWeeklyCounts() { + LocalDate from = LocalDate.of(2026, 8, 1); + LocalDate to = LocalDate.of(2026, 8, 14); + List rows = List.of(dailyUserCount(LocalDate.of(2026, 7, 27), 20L)); + when(userRepository.findWeeklyNewUserCounts(from, to)).thenReturn(rows); + when(userRepository.countByCreatedAtBetween(any(), any())).thenReturn(18L); + + var response = adminDashboardService.getNewUsersTrend(from, to, "week"); + + assertThat(response.items()).hasSize(1); + assertThat(response.items().get(0).count()).isEqualTo(20L); + assertThat(response.totalCount()).isEqualTo(18L); + } + + @Test + @DisplayName("신규 가입자 추이 조회 시 from이 to보다 늦으면 예외를 던진다") + void getNewUsersTrend_throws_whenFromIsAfterTo() { + LocalDate from = LocalDate.of(2026, 8, 10); + LocalDate to = LocalDate.of(2026, 8, 1); + + assertThatThrownBy(() -> adminDashboardService.getNewUsersTrend(from, to, "day")) + .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; + } + }; + } + + @Test + @DisplayName("출석 체크율과 개근 보너스 달성 건수를 조회한다") + void getAttendanceStats_returnsRateAndStreakCount() { + LocalDate from = LocalDate.of(2026, 8, 1); + LocalDate to = LocalDate.of(2026, 8, 2); + List rows = List.of( + dailyUserCount(LocalDate.of(2026, 8, 1), 100L), + dailyUserCount(LocalDate.of(2026, 8, 2), 200L) + ); + when(attendanceRecordRepository.findDailyAttendanceCounts(from, to)).thenReturn(rows); + when(userRepository.countByStatus(UserStatus.ACTIVE)).thenReturn(1000L); + when(creditHistoryRepository.countByCreditTypeAndCreatedAtBetween( + eq(CreditType.ATTENDANCE_STREAK), any(), any())).thenReturn(42L); + + AdminDashboardAttendanceStatsResponse response = adminDashboardService.getAttendanceStats(from, to); + + assertThat(response.items()).hasSize(2); + assertThat(response.totalCount()).isEqualTo(300L); + assertThat(response.avgAttendanceRate()).isCloseTo(0.15, org.assertj.core.data.Offset.offset(0.0001)); // (0.1 + 0.2) / 2 + assertThat(response.streakAchievedCount()).isEqualTo(42L); + } + + @Test + @DisplayName("출석 체크율 조회 시 from이 to보다 늦으면 예외를 던진다") + void getAttendanceStats_throws_whenFromIsAfterTo() { + LocalDate from = LocalDate.of(2026, 8, 10); + LocalDate to = LocalDate.of(2026, 8, 1); + + assertThatThrownBy(() -> adminDashboardService.getAttendanceStats(from, to)) + .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 + public LocalDate getActivityDate() { + return date; + } + + @Override + public long getCount() { + return count; + } + }; + } +} diff --git a/src/test/java/com/swyp/picke/domain/oauth/jwt/JwtFilterTest.java b/src/test/java/com/swyp/picke/domain/oauth/jwt/JwtFilterTest.java new file mode 100644 index 00000000..563781c4 --- /dev/null +++ b/src/test/java/com/swyp/picke/domain/oauth/jwt/JwtFilterTest.java @@ -0,0 +1,67 @@ +package com.swyp.picke.domain.oauth.jwt; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.swyp.picke.domain.user.repository.UserDailyActivityRepository; +import jakarta.servlet.FilterChain; +import java.time.LocalDate; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +@ExtendWith(MockitoExtension.class) +class JwtFilterTest { + + @Mock + private JwtProvider jwtProvider; + + @Mock + private UserDailyActivityRepository userDailyActivityRepository; + + @Mock + private FilterChain filterChain; + + @Test + void doFilterInternal_유효한_토큰이면_오늘자_활동을_기록한다() throws Exception { + JwtFilter jwtFilter = new JwtFilter(jwtProvider, userDailyActivityRepository); + + when(jwtProvider.validateToken("valid-token")).thenReturn(true); + when(jwtProvider.getUserId("valid-token")).thenReturn(42L); + when(jwtProvider.getRole("valid-token")).thenReturn("USER"); + + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/v1/me/mypage"); + request.addHeader("Authorization", "Bearer valid-token"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + jwtFilter.doFilterInternal(request, response, filterChain); + + verify(userDailyActivityRepository).markActive(eq(42L), any(LocalDate.class)); + verify(filterChain).doFilter(request, response); + } + + @Test + void doFilterInternal_활동_기록이_실패해도_요청_처리는_계속된다() throws Exception { + JwtFilter jwtFilter = new JwtFilter(jwtProvider, userDailyActivityRepository); + + when(jwtProvider.validateToken("valid-token")).thenReturn(true); + when(jwtProvider.getUserId("valid-token")).thenReturn(42L); + when(jwtProvider.getRole("valid-token")).thenReturn("USER"); + doThrow(new RuntimeException("DB down")) + .when(userDailyActivityRepository).markActive(any(), any()); + + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/v1/me/mypage"); + request.addHeader("Authorization", "Bearer valid-token"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + jwtFilter.doFilterInternal(request, response, filterChain); + + verify(filterChain).doFilter(request, response); + } +} diff --git a/src/test/java/com/swyp/picke/domain/oauth/service/OAuthServiceTest.java b/src/test/java/com/swyp/picke/domain/oauth/service/OAuthServiceTest.java index eba3aaa6..81dce108 100644 --- a/src/test/java/com/swyp/picke/domain/oauth/service/OAuthServiceTest.java +++ b/src/test/java/com/swyp/picke/domain/oauth/service/OAuthServiceTest.java @@ -24,6 +24,7 @@ import com.swyp.picke.domain.user.enums.UserRole; import com.swyp.picke.domain.user.enums.UserStatus; import com.swyp.picke.domain.user.enums.WithdrawalReason; +import com.swyp.picke.domain.user.repository.UserDailyActivityRepository; import com.swyp.picke.domain.user.repository.UserProfileRepository; import com.swyp.picke.domain.user.repository.UserRepository; import com.swyp.picke.domain.user.repository.UserSettingsRepository; @@ -60,6 +61,7 @@ class OAuthServiceTest { @Mock private UserSettingsRepository userSettingsRepository; @Mock private UserTendencyScoreRepository userTendencyScoreRepository; @Mock private UserWithdrawalRepository userWithdrawalRepository; + @Mock private UserDailyActivityRepository userDailyActivityRepository; @Mock private JwtProvider jwtProvider; @Mock private CreditService creditService; @Mock private PasswordEncoder passwordEncoder; @@ -73,7 +75,7 @@ void setUp() { kakaoOAuthClient, googleOAuthClient, appleOAuthClient, userRepository, socialAccountRepository, localAccountRepository, refreshTokenRepository, userProfileRepository, userSettingsRepository, userTendencyScoreRepository, - userWithdrawalRepository, + userWithdrawalRepository, userDailyActivityRepository, jwtProvider, creditService, passwordEncoder ); } @@ -112,6 +114,7 @@ void setUp() { assertThat(response.getAccessToken()).isEqualTo("jwt-access"); assertThat(response.isNewUser()).isFalse(); verify(refreshTokenRepository).save(any()); + verify(userDailyActivityRepository).markLoggedIn(eq(user.getId()), any(java.time.LocalDate.class)); } @Test