diff --git a/.ai/ARCHITECTURE.md b/.ai/ARCHITECTURE.md new file mode 100644 index 0000000..031ca9f --- /dev/null +++ b/.ai/ARCHITECTURE.md @@ -0,0 +1,250 @@ +# ARCHITECTURE.md + +Geumpumta 백엔드 시스템 아키텍처 문서. + +--- + +## 1. 시스템 개요 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 클라이언트 (모바일 앱) │ +└────────────────────────────┬────────────────────────────────────┘ + │ HTTPS + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ Security Filter Chain │ +│ CORS → OAuth2Login → JwtAuthenticationFilter → @PreAuthorize │ +├─────────────────────────────────────────────────────────────────┤ +│ Controller Layer (@AssignUserId AOP → userId 자동 주입) │ +├─────────────────────────────────────────────────────────────────┤ +│ Service Layer │ +│ study │ rank │ statistics │ user │ token │ board │ fcm │ wifi │ +├─────────────────────────────────────────────────────────────────┤ +│ Repository Layer (JPA │ Native Query │ JDBC Batch │ Redis) │ +├─────────────────────────────────────────────────────────────────┤ +│ Scheduler Layer │ +│ RankingScheduler │ SeasonTransition │ MaxFocus │ TokenCleanup │ +└────────┬──────────┬──────────┬──────────┬───────────────────────┘ + │ │ │ │ + ┌─────▼───┐ ┌───▼────┐ ┌──▼───┐ ┌───▼──────┐ + │ MySQL 8 │ │ Redis │ │ FCM │ │Cloudinary│ + └─────────┘ └────────┘ └──────┘ └──────────┘ +``` + +--- + +## 2. 엔티티 관계도 + +``` + ┌─────────────┐ + │ User │ + │ role │ GUEST → USER → ADMIN + │ department │ Enum (25개 학과) + │ provider │ KAKAO, GOOGLE, APPLE + │ fcmToken │ + └──────┬──────┘ + │ + ┌──────────────┼──────────────┐ + │ 1:N (FK) │ 1:N (FK) │ 1:N (FK 없음) + ▼ ▼ ▼ + ┌─────────────┐ ┌───────────┐ ┌─────────────┐ + │StudySession │ │UserRanking│ │RefreshToken │ + │ startTime │ │ rank │ │ userId │ + │ endTime │ │ totalMillis│ │ refreshToken│ + │ totalMillis │ │ rankingType│ │ expiredAt │ + │ status │ │calculatedAt│ └─────────────┘ + └─────────────┘ └───────────┘ + +┌──────────────────┐ ┌───────────────────────┐ ┌────────┐ +│DepartmentRanking │ │SeasonRankingSnapshot │ │ Season │ +│ department (Enum)│ │ seasonId (FK없음) │ │ type │ +│ rank, totalMillis│ │ userId (FK없음) │ │ status │ +│ rankingType │ │ rankType, finalRank │ │ start │ +│ calculatedAt │ │ department (nullable) │ │ end │ +└──────────────────┘ └───────────────────────┘ └────────┘ +``` + +**설계 결정:** +- `SeasonRankingSnapshot`에 FK 없음 → 시즌/유저 삭제 후에도 이력 보존 +- `RefreshToken`에 FK 없음 → 유저 soft-delete와 독립적으로 토큰 정리 +- `User` soft-delete 시 `deleted_` prefix → unique 제약 유지하면서 재가입 허용 + +--- + +## 3. 인증 플로우 + +``` +[OAuth2 로그인] +앱 → /oauth2/authorization/{provider}?redirect_uri=... + → CustomAuthorizationRequestResolver (redirect_uri를 state에 인코딩) + → OAuth2 Provider 인증 + → CustomOAuth2UserService.loadUser() → User 조회/생성 (role=GUEST) + → SuccessHandler → JWT 발급 → redirect_uri?accessToken=...&refreshToken=... + +[회원가입 완료] +POST /email/request-code → Redis에 인증코드 (TTL 5분) +POST /email/verify-code → 코드 검증 +POST /user/complete-registration → GUEST→USER 승격, 새 JWT 발급 + +[API 요청] +Authorization: Bearer {token} + → JwtAuthenticationFilter → parseToken (JJWT, HMAC-SHA256) + → withdrawn=true이면 /restore 외 차단 + → @PreAuthorize → @AssignUserId AOP → Controller +``` + +--- + +## 4. 랭킹 시스템 + +### 이중 랭킹 구조 + +``` +date 파라미터 유무로 분기: + +date 없음 (현재 기간) date 있음 (과거 기간) + │ │ + ▼ ▼ +실시간 랭킹 확정 랭킹 +StudySession Native Query로 UserRanking / DepartmentRanking +직접 계산 (진행중 세션 포함) 테이블에서 조회 (스케줄러가 저장) +``` + +### 시즌 랭킹 계산 + +``` +현재 시즌 랭킹 = ① + ② + ③ 합산 후 순위 부여 + +① 확정 월간 합산 (시즌 시작 ~ 전월 말) + → UserRankingRepository JPQL +② 현재 월 일간 합산 (이번 달 1일 ~ 어제) + → UserRankingRepository JPQL +③ 오늘 실시간 데이터 + → StudySessionRepository Native Query + +종료된 시즌 → SeasonRankingSnapshot 불변 스냅샷 조회 (계산 없음) +``` + +### 시즌 전환 (매일 00:05) + +``` +SeasonTransitionScheduler + → 캐시 우회 DB 조회 → today ≥ endDate+1 ? + → Yes: activeSeason 캐시 clear + → transitionToNextSeason (현재=ENDED, 다음=ACTIVE) + → createSeasonSnapshot (@Retryable 3회, JDBC 배치 2000건) + → No: return +``` + +### 학과 랭킹 + +학과별 상위 30명의 공부 시간 합산. Native Query + CTE로 25개 학과 처리. +`ROW_NUMBER() PARTITION BY department` → 상위 30 필터 → `SUM GROUP BY` → `RANK()`. + +--- + +## 5. 학습 세션 흐름 + +``` +[시작] POST /study/start {gatewayIp, clientIp} + → WiFi 검증 (@Cacheable) → 중복 STARTED 확인 → 세션 생성 (서버 시간) + +[종료] POST /study/end {studySessionId} + → 세션 조회 → endTime=서버시간, totalMillis=Duration 계산 → FINISHED + +[자동종료] 매 10분 스케줄러 + → STARTED + 3시간 초과 세션 → 자동 종료 + FCM 알림 +``` + +--- + +## 6. 크로스 도메인 의존성 + +### 서비스 의존 그래프 + +``` +StudySessionService ──→ CampusWiFiValidationService, FcmService +PersonalRankService ──→ StudySessionRepository, UserRankingRepository +DepartmentRankService → StudySessionRepository, DepartmentRankingRepository +SeasonRankService ────→ SeasonService(@Cacheable), UserRankingRepo, StudySessionRepo +SeasonSnapshotService → UserRankingRepo, SeasonSnapshotBatchService(JDBC) +StatisticsService ────→ StudySessionRepository (12개 CTE) +UserService ──────────→ JwtHandler, RefreshTokenRepo, FcmService +TokenService ─────────→ JwtHandler, RefreshTokenRepo +``` + +### StudySessionRepository — 쿼리 허브 + +3개 도메인(study, rank, statistics)이 공유. 수정 시 전체 영향. + +| 쿼리 | 도메인 | 용도 | +|------|--------|------| +| `calculateCurrentPeriodRanking` | rank | 실시간 개인 랭킹 | +| `calculateCurrentDepartmentRanking` | rank | 실시간 학과 랭킹 | +| `calculateFinalizedPeriodRanking` | rank | 확정 개인 랭킹 배치 | +| `calculateFinalizedDepartmentRanking` | rank | 확정 학과 랭킹 배치 | +| `getTwoHourSlotStats` | statistics | 일간 2시간 슬롯 | +| `getWeeklyStatistics` | statistics | 주간 통계 | +| `getMonthlyStatistics` | statistics | 월간 통계 | +| `getGrassStatistics` | statistics | 잔디 차트 (NTILE) | +| `sumCompletedStudySessionByUserId` | study | 오늘 총 공부 시간 | + +--- + +## 7. 캐싱 전략 + +| 캐시 | 저장소 | 키 | TTL | 무효화 | +|------|--------|-----|-----|--------| +| `wifiValidation` | Caffeine | `gatewayIp:clientIp` | 10분 | 자동 만료 | +| `activeSeason` | Caffeine | 단일 엔트리 | 10분 | 시즌 전환 시 수동 clear | +| 이메일 인증코드 | Redis | `{userId}email:{email}` | 5분 | 자동 만료 | + +--- + +## 8. 스케줄러 타임라인 + +``` +매일: +00:00:00 RefreshTokenDelete 만료 토큰 삭제 +00:00:05 DailyRanking 전일 개인/학과 랭킹 확정 +00:05:00 SeasonTransition 시즌 종료 확인 → 전환/스냅샷 + ★ MonthlyRanking(00:02) 이후 실행 (데이터 의존) +월요일: 00:01 WeeklyRanking +1일: 00:02 MonthlyRanking +매 10분: MaxFocusStudy 3시간 초과 세션 자동 종료 + FCM +``` + +--- + +## 9. 예외 처리 경로 + +``` +경로 1: Service 예외 + throw BusinessException(ExceptionType) → GlobalExceptionHandler + → {"success":false, "code":"ST002", "msg":"..."} + +경로 2: 인증 예외 + JwtAuthenticationFilter catch → HttpServletResponse 직접 JSON 작성 + → {"success":false, "code":"S004", "msg":"..."} + +경로 3: Validation 예외 + @Valid MethodArgumentNotValidException → GlobalExceptionHandler + → {"success":false, "code":"C002", "msg":"커스텀 메시지"} +``` + +``` +예외 계층: +RuntimeException + ├── BusinessException (ExceptionType: code + message + HttpStatus) + └── JwtAuthenticationException + ├── JwtTokenExpiredException (S004, 401) + ├── JwtTokenInvalidException (S005, 401) + ├── JwtNotExistException (S006, 401) + └── JwtAccessDeniedException (S003, 403) + +응답 구조: +ResponseBody (sealed) + ├── SuccessResponseBody → {"success":true, "data":{...}} + └── FailedResponseBody → {"success":false, "code":"...", "msg":"..."} +``` diff --git a/.ai/TESTING.md b/.ai/TESTING.md new file mode 100644 index 0000000..11a82ca --- /dev/null +++ b/.ai/TESTING.md @@ -0,0 +1,249 @@ +# TESTING.md + +이 프로젝트의 테스트 전략, 작성 규칙, 커버리지 기준을 정의한다. + +--- + +## 테스트 원칙 + +1. **핵심 비즈니스 로직은 반드시 단위 테스트한다** — 시간 계산, 랭킹 병합, 인증, 상태 전이 +2. **외부 경계는 통합 테스트한다** — HTTP 요청/응답, 인증 필터, Native Query 정합성 +3. **단순 위임/CRUD는 테스트하지 않는다** — `repository.save()` 호출만 하는 메서드, getter/setter +4. **외부 서비스는 Mock한다** — Cloudinary, FCM, SMTP를 실제 호출하지 않음 +5. **테스트가 실패하면 배포하지 않는다** — CI에서 전체 테스트 통과 필수 + +--- + +## 커버리지 기준 + +| 계층 | 목표 | 기준 | +|------|------|------| +| Service (핵심 로직) | **85%** | 모든 public 메서드 + 분기 커버 | +| Domain Entity (상태 전이) | **90%** | 도메인 메서드 전체 | +| Controller (통합) | **70%** | 정상 + 인증실패 + 주요 예외 | +| Scheduler (로직) | **80%** | 계산 로직 + 예외 처리 | +| Repository (복잡 쿼리) | **60%** | Native Query, CTE 정합성 | + +### 도메인별 우선순위 + +| 순위 | 도메인 | 유형 | 이유 | +|------|--------|------|------| +| **P0** | study, rank, token, user | Unit + Integration | 핵심 기능, 계산 정확성, 보안 | +| **P1** | scheduler, email, statistics | Unit | 데이터 무결성, 인증 | +| **P2** | wifi, image | Unit | 이미 완성 or 외부 서비스 래핑 | +| **P3** | board | 선택 | 단순 CRUD | + +--- + +## 테스트 구조 + +``` +src/test/java/com/gpt/geumpumtabackend/ +├── unit/ # Mockito + H2 +│ ├── config/ +│ │ └── BaseUnitTest.java # 모든 단위 테스트 상속 +│ └── {domain}/service/ +│ └── {Domain}ServiceTest.java +└── integration/ # TestContainers (MySQL 8.0 + Redis 7.0) + ├── config/ + │ └── BaseIntegrationTest.java # TRUNCATE + FLUSHALL 격리 + └── {domain}/controller/ + └── {Domain}ControllerIntegrationTest.java +``` + +--- + +## 단위 테스트 + +### 작성 대상 + +| 테스트한다 | 테스트하지 않는다 | +|-----------|-----------------| +| 조건 분기가 있는 비즈니스 로직 | 단순 getter/setter | +| 계산 로직 (시간, 랭킹 합산) | `repository.save()` 호출만 하는 메서드 | +| 상태 전이 (STARTED→FINISHED) | `@ConfigurationProperties` 바인딩 | +| 예외 발생 조건 | 외부 SDK 래핑 (Cloudinary upload) | +| 데이터 병합/변환, fallback | 단순 위임 메서드 | + +### 작성 패턴 + +```java +class ExampleServiceTest extends BaseUnitTest { + + @InjectMocks private ExampleService exampleService; + @Mock private ExampleRepository exampleRepository; + + @Nested + @DisplayName("기능 그룹") + class MethodGroup { + + @Test + @DisplayName("정상 — 설명") + void shouldReturnResult_whenValidInput() { + // given + given(exampleRepository.findById(1L)).willReturn(Optional.of(entity)); + // when + var result = exampleService.getExample(1L); + // then + assertThat(result).isNotNull(); + assertThat(result.name()).isEqualTo("expected"); + } + + @Test + @DisplayName("예외 — 설명") + void shouldThrow_whenNotFound() { + // given + given(exampleRepository.findById(999L)).willReturn(Optional.empty()); + // when & then + assertThatThrownBy(() -> exampleService.getExample(999L)) + .isInstanceOf(BusinessException.class) + .extracting(e -> ((BusinessException) e).getExceptionType()) + .isEqualTo(ExceptionType.EXAMPLE_NOT_FOUND); + } + } +} +``` + +### 네이밍 + +- 클래스: `{대상}Test` — `StudySessionServiceTest` +- 메서드: `should{결과}_when{조건}` — `shouldThrow_whenSessionAlreadyExists` +- `@DisplayName`: 한글 — `"예외 — 이미 진행중인 세션이 있을 때"` +- `@Nested`: 기능 그룹 — `class 학습시작`, `class 학습종료` + +--- + +## 통합 테스트 + +### 작성 대상 + +| 테스트한다 | 테스트하지 않는다 | +|-----------|-----------------| +| Controller 인증/응답 형식 | 단순 Service 로직 (단위로 충분) | +| Native Query / CTE 정합성 | 외부 API 호출 | +| 인증 필터 체인 (JWT→PreAuthorize→AssignUserId) | | + +### 작성 패턴 + +```java +class ExampleControllerIntegrationTest extends BaseIntegrationTest { + + @Autowired private UserRepository userRepository; + @Autowired private JwtHandler jwtHandler; + private String accessToken; + + @BeforeEach + void setUp() { + var user = userRepository.save(User.builder() + .email("test@kumoh.ac.kr").role(UserRole.USER) + .nickname("tester").provider(OAuth2Provider.KAKAO) + .providerId("id").department(Department.COMPUTER_ENGINEERING).build()); + accessToken = jwtHandler.createTokens( + new JwtUserClaim(user.getId(), UserRole.USER, false)).getAccessToken(); + } + + @Test + @DisplayName("200 — 정상 조회") + void shouldReturn200() throws Exception { + mockMvc.perform(get("/api/v1/example") + .header("Authorization", "Bearer " + accessToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)); + } + + @Test + @DisplayName("401 — 토큰 없음") + void shouldReturn401() throws Exception { + mockMvc.perform(get("/api/v1/example")) + .andExpect(status().isUnauthorized()); + } +} +``` + +### 엔드포인트별 최소 검증 + +| 케이스 | 검증 | +|--------|------| +| 정상 요청 | 200, `success: true`, 데이터 구조 | +| 인증 없음 | 401 | +| 주요 예외 | 4xx, `success: false`, 에러 코드 | +| ADMIN 전용 | USER로 접근 시 403 | + +--- + +## 도메인별 테스트 필요 항목 + +### P0 — 반드시 작성 + +| 서비스 | 테스트할 핵심 로직 | 유형 | +|--------|-------------------|------| +| `StudySessionService` | 세션 시작(중복 방지), 종료(시간 계산), 최대시간 자동종료 | Unit | +| `PersonalRankService` | 실시간/확정 분기, fallback, 동점 처리 | Unit | +| `DepartmentRankService` | Top30 합산, 0시간 필터링, 본인 학과 포함 | Unit | +| `SeasonRankService` | 3중 병합(월간+일간+실시간), 종료 시즌 스냅샷 | Unit | +| `SeasonService` | 4시즌 순환 전환, 날짜 검증, 윤년 | Unit | +| `TokenService` | 토큰 갱신(만료 토큰 파싱, 리프레시 매칭, 재발급) | Unit | +| `UserService` | GUEST→USER 승격, 탈퇴(prefix), 복구(prefix 제거) | Unit | +| `StudySessionController` | 시작/종료/조회 E2E, 인증 | Integration | +| `RankController` (전체) | 일간/주간/월간 랭킹 응답 구조, 인증 | Integration | +| `UserController` | 가입 완료, 프로필, 탈퇴/복구 | Integration | +| `TokenController` | 토큰 갱신 응답 | Integration | + +### P1 — 권장 + +| 서비스 | 테스트할 핵심 로직 | 유형 | +|--------|-------------------|------| +| `RankingSchedulerService` | 기간 계산(어제/전주/전월), 랭킹 저장 | Unit | +| `SeasonTransitionScheduler` | 종료일 판단, 캐시 clear, 전환+스냅샷 순서 | Unit | +| `SeasonSnapshotService` | 재시도(3회), 중복 방지, 배치 인서트 | Unit | +| `EmailService` | 인증코드 Redis 저장/검증/만료 | Unit | +| `StatisticsService` | 2시간 슬롯, 잔디 차트 데이터 가공 | Unit | + +### P2~P3 — 선택 + +| 서비스 | 비고 | +|--------|------| +| `CampusWiFiValidationService` | 이미 5개 테스트 완성, 추가 불필요 | +| `ImageService` | Mock 기반 — 파일 검증, 크기 초과, 업로드 실패 롤백 | +| `FcmService` | Mock 기반 — 토큰 없는 사용자 skip, 발송 실패 처리 | +| `BoardService` | 단순 CRUD, ADMIN 권한 통합 테스트만 고려 | + +--- + +## 테스트 환경 + +| 항목 | 단위 (unit-test) | 통합 (test) | +|------|------------------|-------------| +| DB | H2 인메모리 | TestContainers MySQL 8.0 | +| Redis | 비활성 | TestContainers Redis 7.0 | +| 외부 서비스 | `@Mock` | `@MockBean` | +| 베이스 클래스 | `BaseUnitTest` | `BaseIntegrationTest` | +| 테스트 격리 | Mockito 초기화 | TRUNCATE ALL + FLUSHALL | +| JVM 옵션 | `-XX:+EnableDynamicAgentLoading` | 동일 | + +--- + +## 실행 명령 + +```bash +./gradlew test # 전체 +./gradlew test --tests "com.gpt.geumpumtabackend.unit.*" # 단위만 +./gradlew test --tests "com.gpt.geumpumtabackend.integration.*" # 통합만 +./gradlew test --tests "StudySessionServiceTest" # 특정 클래스 +./gradlew test --tests "StudySessionServiceTest.shouldThrow*" # 특정 메서드 +``` + +--- + +## 체크리스트: 새 기능 추가 시 + +``` +□ Service에 단위 테스트 작성 + □ 정상 케이스 (최소 1개) + □ 예외 케이스 (BusinessException 조건 전부) + □ 경계값 (null, 빈 리스트, 0) +□ 엔티티에 상태 전이/계산 로직이 있으면 테스트 +□ Controller에 통합 테스트 작성 (200 + 401 + 주요 4xx) +□ Native Query 추가/수정 시 통합 테스트로 검증 +□ 기존 테스트 깨지지 않음 확인 (./gradlew test) +``` diff --git a/.ai/USE-CASES.md b/.ai/USE-CASES.md new file mode 100644 index 0000000..0a16f58 --- /dev/null +++ b/.ai/USE-CASES.md @@ -0,0 +1,349 @@ +# USE-CASES.md + +현재 구현된 기능을 유즈케이스 단위로 정리한 문서. 총 **42개** 유즈케이스. + +--- + +## 액터 정의 + +| 액터 | 설명 | +|------|------| +| GUEST | OAuth2 로그인 완료, 회원가입 미완료 (이메일 인증·학과 선택 전) | +| USER | 회원가입 완료 사용자 | +| ADMIN | 관리자 (USER 권한 포함) | +| SYSTEM | 스케줄러·내부 서비스 호출 | + +--- + +## 1. 학습 세션 (Study) + +### UC-ST-001 오늘의 학습 현황 조회 +| 항목 | 내용 | +|------|------| +| 액터 | USER | +| 엔드포인트 | `GET /api/v1/study` | +| 설명 | 오늘 완료된 세션의 총 공부 시간 + 현재 진행 중 여부 반환 | +| 비즈니스 규칙 | FINISHED 세션만 합산, STARTED 세션은 isStudying 플래그로 표시 | + +### UC-ST-002 학습 세션 시작 +| 항목 | 내용 | +|------|------| +| 액터 | USER | +| 엔드포인트 | `POST /api/v1/study/start` | +| 전제조건 | 캠퍼스 Wi-Fi 접속 상태, 진행 중인 세션 없음 | +| 흐름 | Wi-Fi 검증 → 중복 세션 확인 → 세션 생성 (startTime=서버 시간, status=STARTED) | +| 비즈니스 규칙 | 클라이언트 타임스탬프 사용 금지, 1인 1세션 제한 | +| 에러코드 | `W001` `W002` `W003` `ST002` `U001` | + +### UC-ST-003 학습 세션 종료 +| 항목 | 내용 | +|------|------| +| 액터 | USER | +| 엔드포인트 | `POST /api/v1/study/end` | +| 흐름 | 세션 조회 → endTime=서버 시간 → totalMillis 계산 → status=FINISHED | +| 에러코드 | `ST001` `U001` | + +### UC-ST-004 최대 집중시간 초과 자동 종료 +| 항목 | 내용 | +|------|------| +| 액터 | SYSTEM | +| 트리거 | 매 10분 (`0 */10 * * * *`) | +| 흐름 | STARTED + 3시간 초과 세션 검색 → endTime=startTime+3h → FINISHED → FCM 알림 | +| 비즈니스 규칙 | FCM 실패해도 세션 종료는 진행, endTime은 현재 시간이 아닌 startTime+maxHours | + +--- + +## 2. 개인 랭킹 (Personal Rank) + +> **이중 랭킹 구조**: `date` 파라미터 없으면 실시간 계산 (Native Query), 있으면 확정 랭킹 조회 (UserRanking 테이블) + +### UC-RK-001~006 개인 랭킹 조회 (일간/주간/월간 × 실시간/확정) + +| UC | 엔드포인트 | 유형 | 기간 기준 | +|----|-----------|------|----------| +| 001 | `GET /personal/daily` | 실시간 | 오늘 00:00~23:59 | +| 002 | `GET /personal/daily?date=` | 확정 | 지정 날짜 | +| 003 | `GET /personal/weekly` | 실시간 | 이번 주 월~일 | +| 004 | `GET /personal/weekly?date=` | 확정 | 지정 주 (월요일 기준) | +| 005 | `GET /personal/monthly` | 실시간 | 이번 달 1일~말일 | +| 006 | `GET /personal/monthly?date=` | 확정 | 지정 월 (1일 기준) | + +**공통 규칙:** +- 실시간: 진행 중 세션 포함 (startTime~now), LEAST/GREATEST로 기간 경계 처리 +- 확정: 스케줄러가 저장한 UserRanking에서 조회 +- 응답: 상위 랭킹 목록 + 본인 순위 (없으면 rank=listSize+1, totalMillis=0) + +--- + +## 3. 학과 랭킹 (Department Rank) + +### UC-RK-007~009 학과 랭킹 조회 (일간/주간/월간 × 실시간/확정) + +| UC | 엔드포인트 | 유형 | +|----|-----------|------| +| 007 | `GET /department/daily` | 실시간 | +| 008 | `GET /department/daily?date=` | 확정 | +| 009 | `GET /department/{weekly,monthly}` | 실시간/확정 | + +**학과 랭킹 계산 규칙:** +- 학과별 상위 30명의 공부 시간 합산 +- Native Query + CTE, `ROW_NUMBER() PARTITION BY department` → 상위 30 필터 → SUM → RANK() +- 25개 학과 대상, 0시간 학과는 topRanks에서 제외 (본인 학과는 항상 포함) + +--- + +## 4. 시즌 랭킹 (Season Rank) + +### UC-RK-010 현재 시즌 전체 랭킹 +| 항목 | 내용 | +|------|------| +| 액터 | USER | +| 엔드포인트 | `GET /api/v1/rank/season/current` | +| 흐름 | ①확정 월간 합산 + ②이번 달 일간 합산 + ③오늘 실시간 → 유저별 merge → 순위 부여 | +| 에러코드 | `SE001` `U001` | + +### UC-RK-011 현재 시즌 학과별 랭킹 +| 항목 | 내용 | +|------|------| +| 엔드포인트 | `GET /api/v1/rank/season/current/department?department=` | +| 흐름 | UC-RK-010과 동일하나 학과 필터 적용 | + +### UC-RK-012 종료 시즌 전체 랭킹 +| 항목 | 내용 | +|------|------| +| 엔드포인트 | `GET /api/v1/rank/season/{seasonId}` | +| 전제조건 | 시즌 status=ENDED | +| 흐름 | SeasonRankingSnapshot (rankType=OVERALL) 조회 | +| 에러코드 | `SE002` `SE003` | + +### UC-RK-013 종료 시즌 학과별 랭킹 +| 항목 | 내용 | +|------|------| +| 엔드포인트 | `GET /api/v1/rank/season/{seasonId}/department?department=` | +| 흐름 | SeasonRankingSnapshot (rankType=DEPARTMENT) + 학과 필터 | + +--- + +## 5. 랭킹 스케줄러 (Rank Scheduler) + +### UC-RK-014 일간 랭킹 확정 +| 항목 | 내용 | +|------|------| +| 트리거 | 매일 00:00:05 (`5 0 0 * * *`) | +| 흐름 | 전일 StudySession 계산 → UserRanking (DAILY) + DepartmentRanking (DAILY) 저장 | + +### UC-RK-015 주간 랭킹 확정 +| 항목 | 내용 | +|------|------| +| 트리거 | 매주 월요일 00:01 (`0 1 0 ? * MON`) | +| 흐름 | 전주 데이터 → UserRanking (WEEKLY) + DepartmentRanking (WEEKLY) 저장 | + +### UC-RK-016 월간 랭킹 확정 +| 항목 | 내용 | +|------|------| +| 트리거 | 매월 1일 00:02 (`0 2 0 1 * ?`) | +| 흐름 | 전월 데이터 → UserRanking (MONTHLY) + DepartmentRanking (MONTHLY) 저장 | + +### UC-RK-017 시즌 전환 및 스냅샷 생성 +| 항목 | 내용 | +|------|------| +| 트리거 | 매일 00:05 (`0 5 0 * * *`) | +| 전제조건 | today ≥ activeSeason.endDate + 1 | +| 흐름 | 캐시 클리어 → 현재 시즌 ENDED → 다음 시즌 ACTIVE → SeasonRankingSnapshot 배치 생성 | +| 비즈니스 규칙 | @Retryable 3회 (5초 backoff), JDBC 배치 2000건 청크, 중복 방지 체크 | +| 에러코드 | `SE001` `SE002` | + +--- + +## 6. 통계 (Statistics) + +> 모든 통계는 본인 또는 타 유저 조회 가능 (`targetUserId` 파라미터) + +### UC-STAT-001 일간 통계 +| 항목 | 내용 | +|------|------| +| 엔드포인트 | `GET /api/v1/statistics/day?date=&targetUserId=` | +| 응답 | 2시간 슬롯 12개 (00~02, 02~04, ...) + 최대 집중 시간 + 총 공부 시간 | + +### UC-STAT-002 주간 통계 +| 항목 | 내용 | +|------|------| +| 엔드포인트 | `GET /api/v1/statistics/week?date=&targetUserId=` | +| 응답 | 요일별 공부 시간 + 최대 집중 시간 | + +### UC-STAT-003 월간 통계 +| 항목 | 내용 | +|------|------| +| 엔드포인트 | `GET /api/v1/statistics/month?date=&targetUserId=` | +| 응답 | 일별 공부 시간 집계 | + +### UC-STAT-004 잔디 차트 +| 항목 | 내용 | +|------|------| +| 엔드포인트 | `GET /api/v1/statistics/grass?date=&targetUserId=` | +| 응답 | 5개월 범위 (전 3개월~다음 1개월) 일별 공부 기록 | + +--- + +## 7. 사용자 (User) + +### UC-US-001 회원가입 완료 +| 항목 | 내용 | +|------|------| +| 액터 | GUEST | +| 엔드포인트 | `POST /api/v1/user/complete-registration` | +| 전제조건 | 이메일 인증 완료 (UC-US-006) | +| 흐름 | schoolEmail·studentId·department 저장 → 랜덤 닉네임 생성 → GUEST→USER 승격 → 새 JWT 발급 | +| 비즈니스 규칙 | 닉네임 = {형용사}{명사}{1~100}, 중복 시 재생성 | + +### UC-US-002 프로필 조회 +| 항목 | 내용 | +|------|------| +| 엔드포인트 | `GET /api/v1/user/profile` | +| 응답 | nickname, email, department, picture 등 | + +### UC-US-003 닉네임 중복 확인 +| 항목 | 내용 | +|------|------| +| 엔드포인트 | `GET /api/v1/user/nickname/verify?nickname=` | +| 응답 | 사용 가능 여부 (boolean) | + +### UC-US-004 프로필 수정 +| 항목 | 내용 | +|------|------| +| 엔드포인트 | `POST /api/v1/user/profile` | +| 흐름 | imageUrl·publicId·nickname 업데이트 | + +### UC-US-005 이메일 인증코드 요청 +| 항목 | 내용 | +|------|------| +| 액터 | GUEST | +| 엔드포인트 | `POST /api/v1/email/request-code` | +| 흐름 | 6자리 랜덤 코드 생성 → Redis 저장 (TTL 5분) → 이메일 발송 | +| 비즈니스 규칙 | @kumoh.ac.kr 이메일만 허용, Redis 키: `{userId}email:{email}` | +| 에러코드 | `M001` | + +### UC-US-006 이메일 인증코드 검증 +| 항목 | 내용 | +|------|------| +| 액터 | GUEST | +| 엔드포인트 | `POST /api/v1/email/verify-code` | +| 흐름 | Redis에서 코드 조회 → 일치 시 삭제 (일회용) → 성공/실패 boolean 반환 | + +### UC-US-007 로그아웃 +| 항목 | 내용 | +|------|------| +| 엔드포인트 | `DELETE /api/v1/user/logout` | +| 흐름 | RefreshToken 전체 삭제 + FCM 토큰 제거 | + +### UC-US-008 회원 탈퇴 (Soft Delete) +| 항목 | 내용 | +|------|------| +| 엔드포인트 | `DELETE /api/v1/user/withdraw` | +| 흐름 | RefreshToken 삭제 + FCM 제거 + @SQLDelete 마스킹 (필드 앞에 `deleted_` 접두사) | +| 비즈니스 규칙 | 데이터 보존, unique 제약 유지하면서 재가입 허용 | + +### UC-US-009 탈퇴 복구 +| 항목 | 내용 | +|------|------| +| 엔드포인트 | `POST /api/v1/user/restore` | +| 흐름 | `deleted_` 접두사 제거 → deletedAt 초기화 → 새 JWT 발급 | + +--- + +## 8. 토큰 (Token) + +### UC-TK-001 토큰 갱신 +| 항목 | 내용 | +|------|------| +| 액터 | 인증 불필요 | +| 엔드포인트 | `POST /auth/token/refresh` | +| 흐름 | accessToken 디코딩 → refreshToken DB 매칭 → 기존 삭제 → 새 토큰 쌍 발급 | +| 에러코드 | `S005` `T001` `T002` | + +--- + +## 9. 게시판 (Board) + +### UC-BD-001~004 + +| UC | 엔드포인트 | 액터 | 설명 | +|----|-----------|------|------| +| 001 | `GET /board/list` | USER | 최근 10건 목록 조회 | +| 002 | `GET /board/{id}` | USER | 상세 조회 | +| 003 | `POST /board` | ADMIN | 공지 작성 | +| 004 | `DELETE /board/{id}` | ADMIN | 공지 삭제 (soft delete) | + +--- + +## 10. 이미지 (Image) + +### UC-IM-001 프로필 이미지 업로드 +| 항목 | 내용 | +|------|------| +| 엔드포인트 | `POST /api/v1/image/profile` | +| 흐름 | 파일 검증 (빈 파일/크기/타입) → Cloudinary 업로드 → URL 반환 | +| 비즈니스 규칙 | 최대 10MB, JPEG·PNG·WebP·GIF만 허용, 실패 시 업로드 롤백 시도 | +| 에러코드 | `I001` `I002` `I003` | + +--- + +## 11. FCM (Firebase Cloud Messaging) + +### UC-FC-001 디바이스 토큰 등록 +| 항목 | 내용 | +|------|------| +| 엔드포인트 | `POST /api/v1/fcm/register` | +| 흐름 | fcmToken을 User 엔티티에 저장 (1인 1토큰, 덮어쓰기) | +| 에러코드 | `F001` | + +### UC-FC-002 디바이스 토큰 삭제 +| 항목 | 내용 | +|------|------| +| 엔드포인트 | `DELETE /api/v1/fcm/token` | +| 흐름 | User.fcmToken = null | + +### UC-FC-003 최대 집중시간 알림 발송 +| 항목 | 내용 | +|------|------| +| 액터 | SYSTEM (UC-ST-004에서 호출) | +| 흐름 | fcmToken 존재 시 푸시 발송, 실패해도 예외 미전파 | + +--- + +## 12. 인증 (OAuth2 / Auth) + +### UC-AU-001 OAuth2 소셜 로그인 +| 항목 | 내용 | +|------|------| +| 액터 | 미인증 사용자 | +| 진입점 | `/oauth2/authorization/{kakao,google,apple}` | +| 흐름 | Provider 인증 → User 조회/생성 (GUEST) → JWT 발급 → redirect_uri로 토큰 전달 | +| 비즈니스 규칙 | 최초 로그인 시 GUEST 생성, 탈퇴 유저는 withdrawn 표시, redirect_uri 화이트리스트 검증 | + +### UC-AU-002 만료 리프레시 토큰 정리 +| 항목 | 내용 | +|------|------| +| 트리거 | 매일 00:00 (`0 0 0 * * *`) | +| 흐름 | expiredAt < now인 RefreshToken 일괄 삭제 | + +--- + +## 유즈케이스 요약 + +| 도메인 | 수 | API | 스케줄러 | 내부 호출 | +|--------|-----|-----|---------|----------| +| Study | 4 | 3 | 1 | — | +| Personal Rank | 6 | 6 | — | — | +| Department Rank | 3 | 6 | — | — | +| Season Rank | 4 | 4 | — | — | +| Rank Scheduler | 4 | — | 4 | — | +| Statistics | 4 | 4 | — | — | +| User | 9 | 9 | — | — | +| Token | 1 | 1 | — | — | +| Board | 4 | 4 | — | — | +| Image | 1 | 1 | — | — | +| FCM | 3 | 2 | — | 1 | +| Auth | 2 | 1 | 1 | — | +| WiFi | 1 | — | — | 1 | +| **합계** | **46** | **41** | **6** | **2** | diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..f103184 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,37 @@ +{ + "permissions": { + "allow": [ + "Read(**)", + "Glob(**)", + "Grep(**)", + "Bash(./gradlew clean build)", + "Bash(./gradlew test*)", + "Bash(./gradlew bootRun*)", + "Bash(git status*)", + "Bash(git diff*)", + "Bash(git log*)", + "Bash(git branch*)", + "Bash(ls *)", + "Bash(docker-compose up*)", + "Bash(docker-compose down*)", + "Bash(docker-compose ps*)" + ], + "deny": [ + "Edit(src/main/resources/security/**)", + "Write(src/main/resources/security/**)", + "Read(src/main/resources/security/application-database.yml)", + "Read(src/main/resources/security/application-security.yml)", + "Read(src/main/resources/security/application-mail.yml)", + "Read(src/main/resources/security/application-cloudinary.yml)", + "Bash(git push*)", + "Bash(git reset --hard*)", + "Bash(git checkout -- *)", + "Bash(git clean -f*)", + "Bash(rm -rf *)", + "Bash(*MYSQL_PASSWORD*)", + "Bash(*MYSQL_ROOT_PASSWORD*)", + "Bash(*secret*)", + "Bash(*credential*)" + ] + } +} diff --git a/.github/workflows/dev-ci.yml b/.github/workflows/dev-ci.yml index 56f42b6..298c97f 100644 --- a/.github/workflows/dev-ci.yml +++ b/.github/workflows/dev-ci.yml @@ -2,9 +2,9 @@ name: Dev - CI (Build & Push) on: push: - branches: [ "dev" ] + branches: ["dev"] pull_request: - branches: [ "dev" ] + branches: ["dev"] permissions: contents: read @@ -12,23 +12,15 @@ permissions: pull-requests: write packages: write +env: + # CI에서 Testcontainers 안정성 확보(테스트 병렬 워커 제한) + GRADLE_OPTS: "-Dorg.gradle.workers.max=1" + jobs: - build: + test: + name: Test (Gradle + Testcontainers) runs-on: ubuntu-latest - - services: - redis: - image: redis:alpine - ports: - - 6379:6379 - options: >- - --health-cmd "redis-cli ping" - --health-interval 10s - --health-timeout 5s - --health-retries 5 - - outputs: - image-tag: ${{ steps.meta.outputs.tags }} + timeout-minutes: 30 steps: - name: Checkout @@ -40,8 +32,8 @@ jobs: - name: Set up JDK 21 uses: actions/setup-java@v4 with: - java-version: '21' - distribution: 'temurin' + java-version: "21" + distribution: "temurin" - name: Setup Gradle uses: gradle/actions/setup-gradle@v4 @@ -49,20 +41,78 @@ jobs: - name: Add +x permission to gradlew run: chmod +x gradlew - - name: Build with Gradle - run: ./gradlew clean build + - name: Docker sanity check + run: | + docker version + docker info + + # (선택) 이미지 미리 pull 해서 간헐적 네트워크 이슈/시간초과 감소 + - name: Pre-pull docker images for tests + run: | + docker pull mysql:8.0 + docker pull redis:7.0-alpine + + - name: Run tests + env: + DOCKER_HOST: unix:///var/run/docker.sock + DOCKER_API_VERSION: 1.44 + TESTCONTAINERS_RYUK_DISABLED: true + run: ./gradlew clean test --no-daemon --info - name: Publish Test Report uses: mikepenz/action-junit-report@v5 if: success() || failure() with: - report_paths: '**/build/test-results/test/TEST-*.xml' + report_paths: "**/build/test-results/test/TEST-*.xml" + + # 실패 시 원인 파악용 덤프(컨테이너 종료/로그 확인) + - name: Dump docker state on failure + if: failure() + run: | + echo "==== docker ps -a ====" + docker ps -a + echo "==== mysql logs (tail) ====" + docker ps -a --format "{{.ID}} {{.Image}}" | grep mysql | awk '{print $1}' | xargs -r -n1 docker logs --tail=200 + echo "==== redis logs (tail) ====" + docker ps -a --format "{{.ID}} {{.Image}}" | grep redis | awk '{print $1}' | xargs -r -n1 docker logs --tail=200 + + docker: + name: Docker Build & Push + runs-on: ubuntu-latest + needs: test + if: github.event_name != 'pull_request' + + outputs: + image-tag: ${{ steps.meta.outputs.tags }} + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + submodules: true + token: ${{ secrets.ACTION_TOKEN }} + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + java-version: "21" + distribution: "temurin" + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Add +x permission to gradlew + run: chmod +x gradlew + + # 테스트는 앞 job에서 끝났으니, 여기서는 jar만 생성(테스트 재실행 방지) + - name: Build bootJar (skip tests) + run: ./gradlew bootJar -x test --no-daemon + + - name: Setup QEMU (multi-arch) + uses: docker/setup-qemu-action@v3 - name: Setup Docker Buildx uses: docker/setup-buildx-action@v3 - with: - driver: docker-container - driver-opts: network=host - name: Login to Github Container Registry uses: docker/login-action@v3 @@ -91,7 +141,6 @@ jobs: cache-from: type=gha cache-to: type=gha,mode=max - # [필수 추가] 분리된 CD 파일로 태그 정보를 넘겨주기 위해 파일로 저장 - name: Export Image Tag run: echo "${{ steps.meta.outputs.tags }}" > image_tag.txt diff --git a/.github/workflows/prod-ci.yml b/.github/workflows/prod-ci.yml index 9a0110c..cb55ffc 100644 --- a/.github/workflows/prod-ci.yml +++ b/.github/workflows/prod-ci.yml @@ -45,6 +45,18 @@ jobs: - name: Add +x permission to gradlew run: chmod +x gradlew + # Docker 환경 확인 + - name: Docker sanity check + run: | + docker version + docker info + + # TestContainers용 이미지 미리 pull + - name: Pre-pull docker images for tests + run: | + docker pull mysql:8.0 + docker pull redis:7.0-alpine + - name: Build with Gradle run: ./gradlew clean build diff --git a/AGENT.md b/AGENT.md new file mode 100644 index 0000000..375ccef --- /dev/null +++ b/AGENT.md @@ -0,0 +1,190 @@ +# AGENT.md + +AI 에이전트(Claude Code 등)가 이 코드베이스에서 작업할 때 따라야 할 통합 가이드. + +--- + +## 문서 계층 구조 + +``` +CLAUDE.md ← 프로젝트 전체 온보딩 (WHY·WHAT·HOW) +ARCHITECTURE.md ← 시스템 아키텍처, 데이터 흐름 +TESTING.md ← 테스트 전략, 커버리지 기준 +AGENT.md (이 파일) ← AI 에이전트 행동 규칙 + +src/.../study/CLAUDE.md ← 도메인별 상세 컨텍스트 +src/.../rank/CLAUDE.md +src/.../wifi/CLAUDE.md + +.claude/settings.json ← 권한/보안 설정 (자동 적용) +``` + +**읽는 순서:** 에이전트는 작업 시작 전 `CLAUDE.md` → 작업 대상 도메인의 `CLAUDE.md` 순서로 컨텍스트를 로드한다. + +--- + +## 에이전트 행동 규칙 + +### 1. 코드를 읽기 전에 수정하지 않는다 + +- 수정 대상 파일을 반드시 먼저 읽는다 +- 기존 패턴을 파악한 후 동일한 스타일로 작성한다 +- 추측으로 코드를 생성하지 않는다 + +### 2. 프로젝트 컨벤션을 따른다 + +| 규칙 | 내용 | +|------|------| +| 모듈 구조 | `api/ → controller/ → service/ → repository/ → domain/ → dto/` | +| 인증 | `@PreAuthorize("isAuthenticated() and hasRole('USER')")` + `@AssignUserId` | +| 응답 | `ResponseUtil.createSuccessResponse(data)` | +| 예외 | `throw new BusinessException(ExceptionType.XXX)` | +| DTO | `record` 사용, `@Valid` 바인딩 | +| Entity | `BaseEntity` 상속, `@Getter`, `@NoArgsConstructor(access = PROTECTED)` | +| 트랜잭션 | 클래스 `@Transactional(readOnly = true)` + 쓰기 메서드에 `@Transactional` | +| 시간 | 서버 `LocalDateTime.now()` — 클라이언트 타임스탬프 금지 | + +### 3. 변경 영향 범위를 확인한다 + +| 변경 대상 | 영향 범위 | 확인 방법 | +|-----------|----------|----------| +| `StudySessionRepository` 쿼리 | study, rank, statistics 3개 도메인 | `rank/CLAUDE.md` 참고 | +| `SecurityConfig` | 전체 인증 체계 | 통합 테스트 전체 실행 | +| `ExceptionType` enum | 전체 예외 응답 | 접두사 규칙 확인 | +| `BaseEntity` | 전체 엔티티 | 모든 도메인 테스트 | +| `activeSeason` 캐시 관련 | 시즌 랭킹 전체 | `SeasonTransitionScheduler` 확인 | + +### 4. 검증 후 제출한다 + +```bash +# 코드 수정 후 반드시 실행 +./gradlew clean build # 컴파일 확인 + +# 테스트 대상이 있으면 +./gradlew test --tests "관련Test" # 관련 테스트 +./gradlew test # 전체 테스트 (권장) +``` + +--- + +## 보안 경계 + +### 접근 금지 영역 + +| 대상 | 이유 | +|------|------| +| `src/main/resources/security/` | git submodule 민감 설정 (DB 비밀번호, JWT 시크릿, OAuth 키) | +| `.env`, `*credential*`, `*secret*` | 자격증명 노출 방지 | + +### 실행 금지 명령 + +| 명령 | 이유 | +|------|------| +| `git push`, `git push --force` | 원격 저장소 변경은 사람이 판단 | +| `git reset --hard`, `git clean -f` | 작업 내용 소실 위험 | +| `rm -rf` | 파일 대량 삭제 위험 | + +### 수정 전 확인 필요 (Ask First) + +| 대상 | 이유 | +|------|------| +| 기존 엔티티 필드 추가/변경 | DB 스키마 영향 | +| 스케줄러 cron 변경 | 랭킹/시즌 시스템 타이밍 영향 | +| `SecurityConfig` 변경 | 인증 체계 전체 영향 | +| Native Query 시그니처 변경 | 3개 도메인 영향 | + +--- + +## 작업 유형별 가이드 + +### 새 도메인 추가 + +``` +1. CLAUDE.md의 Module structure 확인 +2. 패키지 생성: {domain}/api, controller, service, repository, domain, dto +3. Entity → Repository → Service → Controller → Api 순서로 구현 +4. ExceptionType에 새 에러 코드 추가 (접두사 규칙) +5. 단위 테스트 작성 (Service) +6. 통합 테스트 작성 (Controller) +7. 빌드 확인: ./gradlew clean build +``` + +### 기존 기능 수정 + +``` +1. 대상 파일 읽기 +2. 도메인 CLAUDE.md 확인 (있으면) +3. 기존 테스트 확인 → 깨지는 테스트 없는지 파악 +4. 수정 +5. 영향받는 테스트 실행 +6. 전체 빌드 확인 +``` + +### 버그 수정 + +``` +1. 재현 조건 파악 +2. 관련 코드 읽기 +3. 실패하는 테스트 먼저 작성 (가능하면) +4. 수정 +5. 테스트 통과 확인 +``` + +### 테스트 작성 + +``` +1. TESTING.md의 커버리지 기준 확인 +2. 대상 Service/Entity 읽기 +3. BaseUnitTest 또는 BaseIntegrationTest 상속 +4. given/when/then 패턴, @DisplayName 한글 +5. 정상 + 예외 + 경계값 +6. ./gradlew test --tests "ClassName" 실행 확인 +``` + +--- + +## 도메인별 컨텍스트 파일 + +특정 도메인 작업 시 해당 `CLAUDE.md`를 먼저 읽는다. + +| 도메인 | 컨텍스트 파일 | 핵심 내용 | +|--------|-------------|----------| +| study | `src/.../study/CLAUDE.md` | 세션 생명주기, 서버 시간 원칙, Repository 쿼리 공유 관계 | +| rank | `src/.../rank/CLAUDE.md` | 이중 랭킹 구조, 시즌 시스템, 스케줄러 cron, 배치 인서트 | +| wifi | `src/.../wifi/CLAUDE.md` | 검증 흐름, CIDR 설정, 캐시 키 구조 | +| 그 외 | 루트 `CLAUDE.md` | 프로젝트 전체 구조, 패턴, 체크리스트 | + +--- + +## 프롬프트 패턴 + +에이전트에게 작업을 지시할 때 효과적인 패턴: + +### 좋은 프롬프트 + +``` +"StudySessionService에 getTodayStudySession 메서드의 단위 테스트를 작성해줘. +TESTING.md의 패턴을 따르고, 정상 케이스와 빈 데이터 케이스를 포함해." +``` + +``` +"rank 도메인에 새 API를 추가해야 해. +rank/CLAUDE.md를 먼저 읽고, 기존 PersonalRankController 패턴을 따라 구현해줘." +``` + +### 나쁜 프롬프트 + +``` +"랭킹 기능 만들어줘" → 범위 불명확, 기존 코드 무시 가능 +"모든 테스트 작성해줘" → 범위 과대, 우선순위 없음 +"이 코드 고쳐줘" (코드 미첨부) → 대상 불명확 +``` + +### 프롬프트 체크리스트 + +``` +□ 대상 파일/메서드가 명시되어 있는가 +□ 참고할 문서나 기존 패턴을 지정했는가 +□ 기대 결과가 구체적인가 +□ 범위가 한 작업 단위로 적절한가 +``` diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..16fc832 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,316 @@ +# CLAUDE.md + +이 문서는 Claude Code가 본 코드베이스에서 작업할 때 참고하는 온보딩 가이드다. + +--- + +## WHY — 프로젝트 존재 이유 + +**Geumpumta(열정품은타이머)** 는 금오공과대학교 학생들이 **캠퍼스 안에서** 공부 시간을 측정·경쟁하는 모바일 앱의 백엔드다. + +핵심 가치: +- **장소 인증**: 캠퍼스 Wi-Fi에 접속한 상태에서만 공부 타이머 시작 가능 → 실제 등교를 보장 +- **서버 기반 시간 관리**: 클라이언트 타임스탬프를 **절대 신뢰하지 않음** → 모든 시간은 서버에서 생성 +- **경쟁·동기부여**: 개인/학과/시즌 랭킹으로 학습 동기 유발 +- **대학 인증**: OAuth2 소셜 로그인 + @kumoh.ac.kr 이메일 인증으로 재학생만 이용 + +--- + +## WHAT — 시스템이 하는 일 + +### 도메인 요약 + +| 도메인 | 역할 | 핵심 엔티티 | +|--------|------|-------------| +| `study` | 학습 세션 시작/종료, 시간 계산 | `StudySession` | +| `rank` | 개인/학과/시즌 랭킹 계산·저장 | `UserRanking`, `DepartmentRanking`, `Season`, `SeasonRankingSnapshot` | +| `statistics` | 일간/주간/월간 통계, 잔디 차트 | (엔티티 없음, 쿼리 기반) | +| `user` | 사용자 관리, 이메일 인증, 프로필 | `User` | +| `token` | JWT 토큰 발급/갱신 | `RefreshToken` | +| `board` | 공지사항 게시판 | `Board` | +| `image` | Cloudinary 프로필 이미지 업로드 | (엔티티 없음) | +| `wifi` | 캠퍼스 Wi-Fi 네트워크 검증 | (엔티티 없음, 설정 기반) | +| `fcm` | Firebase 푸시 알림 | (토큰은 `User.fcmToken`에 저장) | + +### 전체 API 엔드포인트 + +#### 학습 세션 (`/api/v1/study`) +| Method | Path | 설명 | 권한 | +|--------|------|------|------| +| GET | `/api/v1/study` | 오늘의 총 공부 시간 + 진행중 여부 | USER | +| POST | `/api/v1/study/start` | 세션 시작 (Wi-Fi 검증 필수) | USER | +| POST | `/api/v1/study/end` | 세션 종료 | USER | + +#### 개인 랭킹 (`/api/v1/rank/personal`) +| Method | Path | 설명 | 권한 | +|--------|------|------|------| +| GET | `/daily?date=` | 일간 개인 랭킹 (date 없으면 실시간) | USER | +| GET | `/weekly?date=` | 주간 개인 랭킹 (월요일 기준) | USER | +| GET | `/monthly?date=` | 월간 개인 랭킹 (1일 기준) | USER | + +#### 학과 랭킹 (`/api/v1/rank/department`) +| Method | Path | 설명 | 권한 | +|--------|------|------|------| +| GET | `/daily?date=` | 일간 학과 랭킹 | USER | +| GET | `/weekly?date=` | 주간 학과 랭킹 | USER | +| GET | `/monthly?date=` | 월간 학과 랭킹 | USER | + +#### 시즌 랭킹 (`/api/v1/rank/season`) +| Method | Path | 설명 | 권한 | +|--------|------|------|------| +| GET | `/current` | 현재 시즌 전체 랭킹 | USER | +| GET | `/current/department?department=` | 현재 시즌 학과별 랭킹 | USER | +| GET | `/{seasonId}` | 종료된 시즌 전체 랭킹 (스냅샷) | USER | +| GET | `/{seasonId}/department?department=` | 종료된 시즌 학과별 랭킹 | USER | + +#### 통계 (`/api/v1/statistics`) +| Method | Path | 설명 | 권한 | +|--------|------|------|------| +| GET | `/day?date=&targetUserId=` | 일간 통계 (2시간 슬롯) | USER | +| GET | `/week?date=&targetUserId=` | 주간 통계 | USER | +| GET | `/month?date=&targetUserId=` | 월간 통계 | USER | +| GET | `/grass?date=&targetUserId=` | 잔디 차트 | USER | + +#### 사용자 (`/api/v1/user`) +| Method | Path | 설명 | 권한 | +|--------|------|------|------| +| POST | `/complete-registration` | 회원가입 완료 (학교 이메일, 학과, 학번) | GUEST | +| GET | `/profile` | 프로필 조회 | USER | +| GET | `/nickname/verify?nickname=` | 닉네임 중복 확인 | USER | +| POST | `/profile` | 프로필 수정 | USER | +| DELETE | `/logout` | 로그아웃 | USER | +| DELETE | `/withdraw` | 회원 탈퇴 (soft delete) | USER | +| POST | `/restore` | 탈퇴 복구 | USER | + +#### 이메일 (`/api/v1/email`) +| Method | Path | 설명 | 권한 | +|--------|------|------|------| +| POST | `/request-code` | 학교 이메일 인증 코드 발송 | GUEST | +| POST | `/verify-code` | 인증 코드 검증 | GUEST | + +#### 토큰 (`/api/v1/token`) +| Method | Path | 설명 | 권한 | +|--------|------|------|------| +| POST | `/refresh` | 액세스/리프레시 토큰 갱신 | 없음 | + +#### 게시판 (`/api/v1/board`) +| Method | Path | 설명 | 권한 | +|--------|------|------|------| +| GET | `/list` | 공지 목록 | USER | +| GET | `/{boardId}` | 공지 상세 | USER | +| POST | `/` | 공지 작성 | ADMIN | +| DELETE | `/{boardId}` | 공지 삭제 | ADMIN | + +#### 이미지 (`/api/v1/image`) +| Method | Path | 설명 | 권한 | +|--------|------|------|------| +| POST | `/profile` | 프로필 이미지 업로드 (최대 10MB) | USER | + +#### FCM (`/api/v1/fcm`) +| Method | Path | 설명 | 권한 | +|--------|------|------|------| +| POST | `/register` | FCM 디바이스 토큰 등록 | USER | +| DELETE | `/token` | FCM 토큰 삭제 | USER | + +### 에러 코드 체계 (`ExceptionType`) + +| 접두사 | 도메인 | 예시 | +|--------|--------|------| +| C | 공통 | `C001` UNEXPECTED_SERVER_ERROR, `C002` BINDING_ERROR | +| S | 보안 | `S001`~`S006` OAuth/JWT 관련 | +| T | 토큰 | `T001` REFRESH_TOKEN_NOT_EXIST, `T002` TOKEN_NOT_MATCHED | +| U | 사용자 | `U001`~`U006` 사용자/학교이메일/학번 관련 | +| M | 메일 | `M001` CANT_SEND_MAIL | +| ST | 학습 | `ST001`~`ST003` 세션 미발견/중복/시간 오류 | +| W | WiFi | `W001`~`W003` 네트워크 검증 | +| I | 이미지 | `I001`~`I003` 파일/크기/업로드 | +| B | 게시판 | `B001` BOARD_NOT_FOUND | +| SE | 시즌 | `SE001`~`SE005` 시즌 관련 | +| F | FCM | `F001`~`F003` 푸시 알림 | + +### 스케줄러 + +| 작업 | Cron | 설명 | +|------|------|------| +| 일간 랭킹 확정 | `5 0 0 * * *` | 매일 00:00:05 — 전일 랭킹 계산·저장 | +| 주간 랭킹 확정 | `0 1 0 ? * MON` | 매주 월요일 00:01 | +| 월간 랭킹 확정 | `0 2 0 1 * ?` | 매월 1일 00:02 | +| 시즌 전환 확인 | `0 5 0 * * *` | 매일 00:05 — 시즌 종료 시 스냅샷 생성 + 다음 시즌 시작 | +| 최대 공부시간 체크 | `0 */10 * * * *` | 10분마다 — 3시간 초과 세션 자동 종료 + FCM 알림 | +| 만료 리프레시 토큰 삭제 | 별도 스케줄러 | 만료된 RefreshToken 정리 | + +--- + +## HOW — 기술 구현 + +### 기술 스택 + +| 구분 | 기술 | +|------|------| +| 언어/프레임워크 | Java 21, Spring Boot 3.5.6, Gradle | +| 인증 | Spring Security + OAuth2 Client (Kakao, Google, Apple) | +| JWT | JJWT 0.12.6 + Nimbus JOSE JWT 9.37.4 (Apple용) | +| 데이터베이스 | Spring Data JPA + MySQL 8 | +| 캐시 | Spring Data Redis + Caffeine (로컬 캐시) | +| API 문서 | SpringDoc OpenAPI 2.8.14 | +| 이미지 | Cloudinary 1.39.0 | +| 네트워크 | Apache Commons Net 3.11.1 (CIDR 검증) | +| 재시도 | Spring Retry + Spring Aspects | +| 푸시 알림 | Firebase Admin SDK 9.7.1 | +| 모니터링 | Micrometer + Prometheus | +| 테스트 | JUnit 5, Mockito, TestContainers (MySQL 8.0, Redis 7.0) | +| 기타 | Lombok | + +### 프로젝트 구조 + +``` +src/main/java/com/gpt/geumpumtabackend/ +├── GeumpumtaBackendApplication.java +│ +├── global/ # 공통 인프라 +│ ├── aop/ # @AssignUserId — JWT에서 userId 자동 주입 +│ ├── base/ # BaseEntity (createdAt, updatedAt, deletedAt) +│ ├── config/ # cache, fcm, image, mail, redis, retry, security, swagger +│ ├── exception/ # GlobalExceptionHandler, BusinessException, ExceptionType +│ ├── jwt/ # JwtHandler, TokenProvider, JwtAuthenticationFilter +│ ├── oauth/ # OAuth2 (Kakao/Google/Apple), handlers, resolvers +│ ├── response/ # ResponseUtil, ResponseBody, GlobalPageResponse +│ └── scheduler/ # RefreshTokenDeleteScheduler +│ +├── board/ # 게시판 (CRUD, soft delete) +├── fcm/ # FCM 푸시 알림 (토큰 등록/삭제, 메시지 발송) +├── image/ # 이미지 업로드 (Cloudinary) +├── rank/ # 랭킹 시스템 (개인/학과/시즌, 스케줄러) +├── statistics/ # 통계 (일간/주간/월간, 잔디 차트) +├── study/ # 학습 세션 (시작/종료, Wi-Fi 검증, 최대 시간 스케줄러) +├── token/ # JWT 토큰 관리 (발급/갱신) +├── user/ # 사용자 관리, 이메일 인증, 프로필 +└── wifi/ # 캠퍼스 Wi-Fi 검증 (CIDR, 캐싱) +``` + +각 도메인 모듈은 `api/ → controller/ → service/ → repository/ → domain/ → dto/` 계층 구조를 따른다. + +### 아키텍처 패턴 + +#### Layered Architecture +`Controller → Service → Repository → Entity`. Controller는 HTTP 처리만 담당하고, Service에 비즈니스 로직을 집중한다. + +#### AOP 사용자 컨텍스트 주입 +```java +@PreAuthorize("isAuthenticated() and hasRole('USER')") +@AssignUserId // JWT에서 userId 자동 주입 +public ResponseEntity endpoint(Long userId) { ... } +``` + +#### 표준 응답 형식 +```java +ResponseUtil.createSuccessResponse(data); +ResponseUtil.createFailureResponse(ExceptionType.ERROR_TYPE); +``` + +#### 예외 처리 +- `GlobalExceptionHandler`(`@RestControllerAdvice`)에서 전역 처리 +- `ExceptionType` enum으로 에러 코드/메시지/HTTP 상태 관리 +- 도메인 예외는 `BusinessException` 상속 + +#### Soft Delete +모든 엔티티가 `BaseEntity` 상속 → `createdAt`, `updatedAt`, `deletedAt` 자동 관리. `User`는 `@SQLDelete`로 삭제 시 데이터 마스킹 처리. + +#### 이중 랭킹 구조 +- **실시간 랭킹**: `StudySessionRepository` Native Query로 현재 기간 직접 계산 (진행중 세션 포함) +- **확정 랭킹**: 기간 종료 후 `UserRanking`/`DepartmentRanking` 테이블에 저장 +- 컨트롤러에서 `date` 파라미터 유무로 분기 + +#### 시즌 시스템 +4개 시즌 순환: `SPRING_SEMESTER`(3~6월) → `SUMMER_VACATION`(7~8월) → `FALL_SEMESTER`(9~12월) → `WINTER_VACATION`(1~2월). 시즌 종료 시 `SeasonRankingSnapshot`으로 불변 이력 저장 (Spring Retry 3회, 5초 backoff, JDBC 배치 2000건 청크). + +#### 학과 랭킹 계산 +학과별 상위 30명의 공부 시간 합산. Native Query + CTE로 25개 학과 처리. MySQL 8+ 필수. + +### 인증 플로우 + +``` +OAuth2 로그인 (Kakao/Google/Apple) + → GUEST 역할로 User 생성 + → 학교 이메일 인증 (@kumoh.ac.kr) + → 학과/학번 입력 → USER 역할로 승격 + → JWT 발급 (Access + Refresh, 14일) + → API 요청 시 Authorization: Bearer 헤더 + → JwtAuthenticationFilter → @PreAuthorize → @AssignUserId +``` + +역할 계층: `ADMIN` ⊃ `USER` ⊃ `GUEST` + +### 프로파일 설정 + +| Profile | DB | DDL | 용도 | +|---------|-----|-----|------| +| `local` | MySQL localhost:3311 | create-drop | 로컬 개발 | +| `dev` | Docker MySQL | update | 개발 서버 | +| `prod` | Production DB | validate | 운영 | +| `test` | TestContainers MySQL 8.0 | - | 통합 테스트 | +| `unit-test` | H2 | - | 단위 테스트 | + +### 민감 설정 (Git Submodule) +`src/main/resources/security/` 디렉토리에 git submodule로 관리. **절대 직접 커밋 금지.** +- `application-database.yml`, `application-security.yml`, `application-mail.yml` +- `application-swagger.yml`, `application-wifi.yml`, `application-cloudinary.yml` + +### CI/CD + +**GitHub Actions:** +- **CI** (dev/prod): Java 21 빌드, TestContainers + Redis 서비스 연동, 테스트, JUnit 리포트 +- **CD** (dev): Docker 멀티 아키텍처 빌드 (AMD64/ARM64) → GHCR 푸시 → self-hosted 배포 +- **CD** (prod): 아티팩트 기반 Docker 빌드 → GHCR → production 환경 배포 + +**Docker:** +- Base image: `amd64/openjdk:21-jdk-slim` +- `docker-compose.yml`: MySQL 8.4.0 (포트 3311) + Redis Alpine (포트 6379) + +--- + +## Build & Run + +```bash +# 인프라 실행 (MySQL 8.4, Redis) +docker-compose up -d + +# 빌드 +./gradlew clean build + +# 로컬 실행 +./gradlew bootRun --args='--spring.profiles.active=local' + +# 전체 테스트 +./gradlew test + +# 단일 테스트 클래스 +./gradlew test --tests "ClassName" +``` + +## 테스트 + +### 단위 테스트 (`src/test/java/.../unit/`) +- JUnit 5 + Mockito + AssertJ, `BaseUnitTest` 기반 +- 프로파일: `unit-test` (H2, Redis 비활성) +- 대상: StudySession 시간 계산, 랭킹 로직, Wi-Fi 검증, 시즌 서비스, 시즌 스냅샷 재시도 + +### 통합 테스트 (`src/test/java/.../integration/`) +- TestContainers MySQL 8.0 + Redis 7.0, `BaseIntegrationTest` 기반 +- 프로파일: `test` +- 각 테스트 후 전체 테이블 TRUNCATE + Redis FLUSHALL +- 대상: Controller E2E 테스트 + +--- + +## Development Checklist + +1. 도메인 모듈 구조 준수: `api/ → controller/ → service/ → repository/ → domain/ → dto/` +2. 인증 필요 엔드포인트에 `@AssignUserId` + `@PreAuthorize` 사용 +3. `@Transactional` 적절히 적용 (읽기 전용은 `readOnly = true`) +4. `ResponseUtil`로 응답 표준화 +5. 새 에러는 `ExceptionType` enum에 추가 (접두사 규칙 준수) +6. `security/` 디렉토리 파일 커밋 금지 +7. `StudySessionRepository` Native Query 수정 시 랭킹/통계 도메인 영향 확인 +8. 시간은 반드시 서버에서 생성 — 클라이언트 타임스탬프 파라미터 추가 금지 +9. 시즌/캐시 관련 코드 수정 시 `activeSeason` 캐시 eviction 로직 확인 diff --git a/README.md b/README.md new file mode 100644 index 0000000..f346ad7 --- /dev/null +++ b/README.md @@ -0,0 +1,68 @@ + +# Geumpumta Backend +[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/Geumpumta/backend) + +공학 계열 대학생을 위한 집중 학습 시간 검증 및 랭킹 서비스 백엔드입니다. + +## 프로젝트 개요 + +Geumpumta는 대학생의 실제 학습 시간을 정확하게 측정하고 +개인 및 학과 단위의 랭킹 시스템을 제공하는 학습 관리 서비스입니다. + +앱은 Wi-Fi SSID 기반 인증을 사용하여 부정 기록을 방지하고, +타이머 기반 Heartbeat 구조를 통해 앱과 실시간으로 학습 시간을 동기화합니다. + +## 주요 기능 +- OAuth2 로그인 (Google/Kakao/Apple) + JWT 인증 +- 캠퍼스 Wi-Fi 검증 기반 학습 세션 시작/하트비트/종료 +- 학습 통계 (일/주/월/잔디형) +- 개인/학부 랭킹 +- 게시판 기능 +- 프로필/닉네임 검증, 이메일 인증 +- 이미지 업로드 (Cloudinary) + +## 기술 스택 +- Java 21, Spring Boot 3.5.6 +- Spring Security, OAuth2 Client, JWT +- Spring Data JPA, MySQL 8 +- Redis +- Springdoc OpenAPI +- Spring Mail +- Cloudinary +- Actuator + Prometheus + +## 아키텍처 +아키텍쳐 drawio + +## 패키지 구조 +``` +com.gpt.geumpumtabackend +├─ global +├─ user +│ ├─ api +│ ├─ controller +│ ├─ domain +│ ├─ repository +│ ├─ dto +│ └─ service +├─ token +├─ study +├─ statistics +├─ rank +├─ board +├─ image +└─ wifi +``` +- 모놀리식 아키텍처 기반의 모듈형 패키지 구조 (도메인별 패키지 분리) +- 계층형 아키텍처(Controller/API → Service → Repository → Domain) 패턴 +- DTO/Response 객체로 API 경계 분리 + +## 설정 +- 프로파일: `local`, `dev`, `prod` +- 민감 정보는 GitHub 서브모듈로 별도 관리 + +## 팀원 +| 채주혁 | 권오빈 | +|:----------------------------------------------------------------------------:|:-----------------------------------------------------------------------------:| +| | | +| [@Juhye0k](https://github.com/Juhye0k) | [@kon28289](https://github.com/kon28289) | diff --git a/build.gradle b/build.gradle index a4a6796..9f189f1 100644 --- a/build.gradle +++ b/build.gradle @@ -39,8 +39,6 @@ dependencies { testRuntimeOnly 'org.junit.platform:junit-platform-launcher' implementation 'org.springframework.boot:spring-boot-starter-data-redis' - // h2 - testImplementation 'com.h2database:h2' // jwt implementation 'io.jsonwebtoken:jjwt-api:0.12.6' @@ -59,13 +57,33 @@ dependencies { // Apache Commons Net for IP range validation implementation 'commons-net:commons-net:3.11.1' + // Caffeine Cache + implementation 'com.github.ben-manes.caffeine:caffeine:3.1.8' + implementation 'org.springframework.boot:spring-boot-starter-cache' + // Cloudinary implementation 'com.cloudinary:cloudinary-http45:1.39.0' // Jwt implementation 'com.nimbusds:nimbus-jose-jwt:9.37.4' + + // Spring Retry + implementation 'org.springframework.retry:spring-retry' + implementation 'org.springframework:spring-aspects' + + // TestContainers + testImplementation 'org.testcontainers:junit-jupiter:1.20.4' + testImplementation 'org.testcontainers:mysql:1.20.4' + testImplementation 'org.testcontainers:testcontainers:1.20.4' + + // FCM + implementation 'com.google.firebase:firebase-admin:9.7.1' } tasks.named('test') { useJUnitPlatform() + + // Mockito inline mock maker를 Java agent로 설정 (Java 21 호환) + // Docker 29+ 호환: shaded docker-java의 API 버전을 1.44로 설정 + jvmArgs "-XX:+EnableDynamicAgentLoading", "-Dapi.version=1.44" } diff --git a/src/main/java/com/gpt/geumpumtabackend/badge/api/BadgeApi.java b/src/main/java/com/gpt/geumpumtabackend/badge/api/BadgeApi.java new file mode 100644 index 0000000..b6dec2b --- /dev/null +++ b/src/main/java/com/gpt/geumpumtabackend/badge/api/BadgeApi.java @@ -0,0 +1,183 @@ +package com.gpt.geumpumtabackend.badge.api; + +import com.gpt.geumpumtabackend.badge.dto.request.BadgeCreateRequest; +import com.gpt.geumpumtabackend.badge.dto.request.RepresentativeBadgeRequest; +import com.gpt.geumpumtabackend.badge.dto.response.BadgeCreateResponse; +import com.gpt.geumpumtabackend.badge.dto.response.BadgeResponse; +import com.gpt.geumpumtabackend.badge.dto.response.MyBadgeResponse; +import com.gpt.geumpumtabackend.badge.dto.response.MyBadgeStatusResponse; +import com.gpt.geumpumtabackend.badge.dto.response.RepresentativeBadgeResponse; +import com.gpt.geumpumtabackend.global.aop.AssignUserId; +import com.gpt.geumpumtabackend.global.config.swagger.SwaggerApiFailedResponse; +import com.gpt.geumpumtabackend.global.config.swagger.SwaggerApiResponses; +import com.gpt.geumpumtabackend.global.config.swagger.SwaggerApiSuccessResponse; +import com.gpt.geumpumtabackend.global.exception.ExceptionType; +import com.gpt.geumpumtabackend.global.response.ResponseBody; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@Tag(name = "배지 API", description = """ + 배지 생성/조회/삭제 및 사용자 배지 조회 기능을 제공합니다. + """) +public interface BadgeApi { + + @Operation( + summary = "배지 생성", + description = """ + ADMIN 권한으로 새로운 배지를 생성합니다. + - code: 배지 고유 코드 (중복 불가) + - badgeType: 배지 종류 + - thresholdValue: 누적 시간/연속 일수 계열 배지 기준값 + - rank: 시즌 랭킹 배지 등수 값(예: 1,2,3) + """ + ) + @ApiResponse(content = @Content(schema = @Schema(implementation = BadgeCreateResponse.class))) + @SwaggerApiResponses( + success = @SwaggerApiSuccessResponse( + response = BadgeCreateResponse.class, + description = "배지 생성 성공" + ), + errors = { + @SwaggerApiFailedResponse(ExceptionType.NEED_AUTHORIZED), + @SwaggerApiFailedResponse(ExceptionType.ACCESS_DENIED), + @SwaggerApiFailedResponse(ExceptionType.BADGE_CODE_ALREADY_EXISTS) + } + ) + @PostMapping + @PreAuthorize("isAuthenticated() and hasRole('ADMIN')") + ResponseEntity> createBadge( + @RequestBody @Valid BadgeCreateRequest request + ); + + @Operation( + summary = "전체 배지 조회", + description = "ADMIN 권한으로 전체 배지 목록을 조회합니다." + ) + @ApiResponse(content = @Content(schema = @Schema(implementation = BadgeResponse.class))) + @SwaggerApiResponses( + success = @SwaggerApiSuccessResponse( + response = BadgeResponse.class, + description = "전체 배지 조회 성공" + ), + errors = { + @SwaggerApiFailedResponse(ExceptionType.NEED_AUTHORIZED), + @SwaggerApiFailedResponse(ExceptionType.ACCESS_DENIED) + } + ) + @GetMapping + @PreAuthorize("isAuthenticated() and hasRole('ADMIN')") + ResponseEntity>> getAllBadges(); + + @Operation( + summary = "배지 삭제", + description = """ + ADMIN 권한으로 배지를 삭제합니다. + 이미 사용자에게 지급된 이력이 있으면 삭제할 수 없고 B004(BADGE_IN_USE)를 반환합니다. + """ + ) + @ApiResponse(content = @Content(schema = @Schema(implementation = ResponseBody.class))) + @SwaggerApiResponses( + success = @SwaggerApiSuccessResponse(description = "배지 삭제 성공"), + errors = { + @SwaggerApiFailedResponse(ExceptionType.NEED_AUTHORIZED), + @SwaggerApiFailedResponse(ExceptionType.ACCESS_DENIED), + @SwaggerApiFailedResponse(ExceptionType.BADGE_NOT_FOUND), + @SwaggerApiFailedResponse(ExceptionType.BADGE_IN_USE) + } + ) + @DeleteMapping("/{badgeId}") + @PreAuthorize("isAuthenticated() and hasRole('ADMIN')") + ResponseEntity> deleteBadge( + @PathVariable Long badgeId + ); + + @Operation( + summary = "내 배지 조회", + description = """ + 항상 전체 배지 목록을 반환합니다. + 각 원소는 아래 정보를 포함합니다. + - owned: 사용자의 배지 보유 여부 + - awardedAt: 배지 획득 시각 (owned=false이면 null) + """ + ) + @ApiResponse(content = @Content(schema = @Schema(implementation = MyBadgeStatusResponse.class))) + @SwaggerApiResponses( + success = @SwaggerApiSuccessResponse( + response = MyBadgeStatusResponse.class, + description = "내 배지 조회 성공" + ), + errors = { + @SwaggerApiFailedResponse(ExceptionType.NEED_AUTHORIZED), + @SwaggerApiFailedResponse(ExceptionType.USER_NOT_FOUND) + } + ) + @GetMapping("/me") + @AssignUserId + @PreAuthorize("isAuthenticated() and hasRole('USER')") + ResponseEntity>> getMyBadges( + @Parameter(hidden = true) Long userId + ); + + @Operation( + summary = "대표 배지 설정", + description = """ + 보유한 배지 중 하나를 대표 배지로 설정합니다. + 요청은 badgeCode 기준으로 처리됩니다. + """ + ) + @ApiResponse(content = @Content(schema = @Schema(implementation = RepresentativeBadgeResponse.class))) + @SwaggerApiResponses( + success = @SwaggerApiSuccessResponse( + response = RepresentativeBadgeResponse.class, + description = "대표 배지 설정 성공" + ), + errors = { + @SwaggerApiFailedResponse(ExceptionType.NEED_AUTHORIZED), + @SwaggerApiFailedResponse(ExceptionType.USER_NOT_FOUND), + @SwaggerApiFailedResponse(ExceptionType.BADGE_NOT_FOUND), + @SwaggerApiFailedResponse(ExceptionType.BADGE_NOT_OWNED) + } + ) + @PostMapping("/me/representative-badge") + @AssignUserId + @PreAuthorize("isAuthenticated() and hasRole('USER')") + ResponseEntity> setRepresentativeBadge( + @RequestBody RepresentativeBadgeRequest request, + @Parameter(hidden = true) Long userId + ); + + @Operation( + summary = "미확인 배지 조회", + description = """ + 사용자의 미확인 배지 목록을 조회합니다. + 조회된 배지는 같은 요청 트랜잭션에서 확인 처리(notifiedAt 설정)됩니다. + """ + ) + @ApiResponse(content = @Content(schema = @Schema(implementation = MyBadgeResponse.class))) + @SwaggerApiResponses( + success = @SwaggerApiSuccessResponse( + response = MyBadgeResponse.class, + description = "미확인 배지 조회 성공" + ), + errors = { + @SwaggerApiFailedResponse(ExceptionType.NEED_AUTHORIZED), + @SwaggerApiFailedResponse(ExceptionType.USER_NOT_FOUND) + } + ) + @GetMapping("/unnotified") + @AssignUserId + @PreAuthorize("isAuthenticated() and hasRole('USER')") + ResponseEntity>> getUnnotifiedBadges( + @Parameter(hidden = true) Long userId + ); +} diff --git a/src/main/java/com/gpt/geumpumtabackend/badge/controller/BadgeController.java b/src/main/java/com/gpt/geumpumtabackend/badge/controller/BadgeController.java new file mode 100644 index 0000000..57ddef5 --- /dev/null +++ b/src/main/java/com/gpt/geumpumtabackend/badge/controller/BadgeController.java @@ -0,0 +1,90 @@ +package com.gpt.geumpumtabackend.badge.controller; + +import com.gpt.geumpumtabackend.badge.api.BadgeApi; +import com.gpt.geumpumtabackend.badge.dto.request.BadgeCreateRequest; +import com.gpt.geumpumtabackend.badge.dto.request.RepresentativeBadgeRequest; +import com.gpt.geumpumtabackend.badge.dto.response.BadgeCreateResponse; +import com.gpt.geumpumtabackend.badge.dto.response.BadgeResponse; +import com.gpt.geumpumtabackend.badge.dto.response.MyBadgeResponse; +import com.gpt.geumpumtabackend.badge.dto.response.MyBadgeStatusResponse; +import com.gpt.geumpumtabackend.badge.dto.response.RepresentativeBadgeResponse; +import com.gpt.geumpumtabackend.badge.service.BadgeService; +import com.gpt.geumpumtabackend.global.aop.AssignUserId; +import com.gpt.geumpumtabackend.global.response.ResponseBody; +import com.gpt.geumpumtabackend.global.response.ResponseUtil; +import lombok.RequiredArgsConstructor; +import jakarta.validation.Valid; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/badge") +public class BadgeController implements BadgeApi { + + private final BadgeService badgeService; + + @PostMapping + @PreAuthorize("isAuthenticated() and hasRole('ADMIN')") + public ResponseEntity> createBadge( + @RequestBody @Valid BadgeCreateRequest request + ){ + return ResponseEntity.ok(ResponseUtil.createSuccessResponse( + badgeService.createBadge(request) + )); + } + + @GetMapping + @PreAuthorize("isAuthenticated() and hasRole('ADMIN')") + public ResponseEntity>> getAllBadges() { + return ResponseEntity.ok(ResponseUtil.createSuccessResponse( + badgeService.getAllBadges() + )); + } + + @DeleteMapping("/{badgeId}") + @PreAuthorize("isAuthenticated() and hasRole('ADMIN')") + public ResponseEntity> deleteBadge( + @PathVariable Long badgeId + ) { + badgeService.deleteBadge(badgeId); + return ResponseEntity.ok(ResponseUtil.createSuccessResponse()); + } + + @GetMapping("/me") + @AssignUserId + @PreAuthorize("isAuthenticated() and hasRole('USER')") + public ResponseEntity>> getMyBadges( + Long userId + ){ + return ResponseEntity.ok(ResponseUtil.createSuccessResponse( + badgeService.getMyBadges(userId) + )); + } + + @PostMapping("/me/representative-badge") + @AssignUserId + @PreAuthorize("isAuthenticated() and hasRole('USER')") + public ResponseEntity> setRepresentativeBadge( + @RequestBody RepresentativeBadgeRequest request, + Long userId + ){ + return ResponseEntity.ok(ResponseUtil.createSuccessResponse( + badgeService.setRepresentativeBadge(request, userId) + )); + } + + @GetMapping("/unnotified") + @AssignUserId + @PreAuthorize("isAuthenticated() and hasRole('USER')") + public ResponseEntity>> getUnnotifiedBadges( + Long userId + ){ + return ResponseEntity.ok(ResponseUtil.createSuccessResponse( + badgeService.getUnnotifiedBadges(userId) + )); + } +} diff --git a/src/main/java/com/gpt/geumpumtabackend/badge/domain/Badge.java b/src/main/java/com/gpt/geumpumtabackend/badge/domain/Badge.java new file mode 100644 index 0000000..6880a08 --- /dev/null +++ b/src/main/java/com/gpt/geumpumtabackend/badge/domain/Badge.java @@ -0,0 +1,54 @@ +package com.gpt.geumpumtabackend.badge.domain; + +import com.gpt.geumpumtabackend.global.base.BaseEntity; +import jakarta.persistence.*; +import lombok.Builder; +import lombok.Getter; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Badge extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(unique = true) + private String code; + + private String name; + + private String description; + + private String iconUrl; + + @Enumerated(EnumType.STRING) + private BadgeType badgeType; + + private Long thresholdValue; + + @Column(name = "badge_rank") + private Long rank; + + @Builder + private Badge( + String code, + String name, + String description, + String iconUrl, + BadgeType badgeType, + Long thresholdValue, + Long rank + ) { + this.code = code; + this.name = name; + this.description = description; + this.iconUrl = iconUrl; + this.badgeType = badgeType; + this.thresholdValue = thresholdValue; + this.rank = rank; + } +} diff --git a/src/main/java/com/gpt/geumpumtabackend/badge/domain/BadgeType.java b/src/main/java/com/gpt/geumpumtabackend/badge/domain/BadgeType.java new file mode 100644 index 0000000..f352131 --- /dev/null +++ b/src/main/java/com/gpt/geumpumtabackend/badge/domain/BadgeType.java @@ -0,0 +1,8 @@ +package com.gpt.geumpumtabackend.badge.domain; + +public enum BadgeType { + WELCOME, + STREAK_DAYS, + TOTAL_HOURS, + SEASON_PERSONAL_RANK +} diff --git a/src/main/java/com/gpt/geumpumtabackend/badge/domain/UserBadge.java b/src/main/java/com/gpt/geumpumtabackend/badge/domain/UserBadge.java new file mode 100644 index 0000000..e0aa7ca --- /dev/null +++ b/src/main/java/com/gpt/geumpumtabackend/badge/domain/UserBadge.java @@ -0,0 +1,41 @@ +package com.gpt.geumpumtabackend.badge.domain; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Entity +@Getter +@NoArgsConstructor +@Table( + name = "user_badge", + uniqueConstraints = @UniqueConstraint(name="uk_user_badge", columnNames = {"user_id", "badge_id"}) +) +public class UserBadge { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "user_id") + private Long userId; + + @Column(name = "badge_id") + private Long badgeId; + + private LocalDateTime awardedAt; + + private LocalDateTime notifiedAt; + + public UserBadge(Long userId, Long badgeId, LocalDateTime awardedAt, LocalDateTime notifiedAt) { + this.userId = userId; + this.badgeId = badgeId; + this.awardedAt = awardedAt; + this.notifiedAt = notifiedAt; + } + + public void markNotified(LocalDateTime notifiedAt) { + this.notifiedAt = notifiedAt; + } +} diff --git a/src/main/java/com/gpt/geumpumtabackend/badge/dto/request/BadgeCreateRequest.java b/src/main/java/com/gpt/geumpumtabackend/badge/dto/request/BadgeCreateRequest.java new file mode 100644 index 0000000..fd22ee1 --- /dev/null +++ b/src/main/java/com/gpt/geumpumtabackend/badge/dto/request/BadgeCreateRequest.java @@ -0,0 +1,16 @@ +package com.gpt.geumpumtabackend.badge.dto.request; + +import com.gpt.geumpumtabackend.badge.domain.BadgeType; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +public record BadgeCreateRequest( + @NotBlank String code, + @NotBlank String name, + @NotBlank String description, + @NotBlank String iconUrl, + @NotNull BadgeType badgeType, + Long thresholdValue, + Long rank +) { +} diff --git a/src/main/java/com/gpt/geumpumtabackend/badge/dto/request/RepresentativeBadgeRequest.java b/src/main/java/com/gpt/geumpumtabackend/badge/dto/request/RepresentativeBadgeRequest.java new file mode 100644 index 0000000..9656ac1 --- /dev/null +++ b/src/main/java/com/gpt/geumpumtabackend/badge/dto/request/RepresentativeBadgeRequest.java @@ -0,0 +1,6 @@ +package com.gpt.geumpumtabackend.badge.dto.request; + +public record RepresentativeBadgeRequest( + String badgeCode +) { +} diff --git a/src/main/java/com/gpt/geumpumtabackend/badge/dto/response/BadgeCreateResponse.java b/src/main/java/com/gpt/geumpumtabackend/badge/dto/response/BadgeCreateResponse.java new file mode 100644 index 0000000..eb5a423 --- /dev/null +++ b/src/main/java/com/gpt/geumpumtabackend/badge/dto/response/BadgeCreateResponse.java @@ -0,0 +1,28 @@ +package com.gpt.geumpumtabackend.badge.dto.response; + +import com.gpt.geumpumtabackend.badge.domain.Badge; +import com.gpt.geumpumtabackend.badge.domain.BadgeType; + +public record BadgeCreateResponse( + Long id, + String code, + String name, + String description, + String iconUrl, + BadgeType badgeType, + Long thresholdValue, + Long rank +) { + public static BadgeCreateResponse from(Badge badge) { + return new BadgeCreateResponse( + badge.getId(), + badge.getCode(), + badge.getName(), + badge.getDescription(), + badge.getIconUrl(), + badge.getBadgeType(), + badge.getThresholdValue(), + badge.getRank() + ); + } +} diff --git a/src/main/java/com/gpt/geumpumtabackend/badge/dto/response/BadgeResponse.java b/src/main/java/com/gpt/geumpumtabackend/badge/dto/response/BadgeResponse.java new file mode 100644 index 0000000..809a0b1 --- /dev/null +++ b/src/main/java/com/gpt/geumpumtabackend/badge/dto/response/BadgeResponse.java @@ -0,0 +1,28 @@ +package com.gpt.geumpumtabackend.badge.dto.response; + +import com.gpt.geumpumtabackend.badge.domain.Badge; +import com.gpt.geumpumtabackend.badge.domain.BadgeType; + +public record BadgeResponse( + Long id, + String code, + String name, + String description, + String iconUrl, + BadgeType badgeType, + Long thresholdValue, + Long rank +) { + public static BadgeResponse from(Badge badge) { + return new BadgeResponse( + badge.getId(), + badge.getCode(), + badge.getName(), + badge.getDescription(), + badge.getIconUrl(), + badge.getBadgeType(), + badge.getThresholdValue(), + badge.getRank() + ); + } +} diff --git a/src/main/java/com/gpt/geumpumtabackend/badge/dto/response/MyBadgeResponse.java b/src/main/java/com/gpt/geumpumtabackend/badge/dto/response/MyBadgeResponse.java new file mode 100644 index 0000000..edfe683 --- /dev/null +++ b/src/main/java/com/gpt/geumpumtabackend/badge/dto/response/MyBadgeResponse.java @@ -0,0 +1,12 @@ +package com.gpt.geumpumtabackend.badge.dto.response; + +import java.time.LocalDateTime; + +public record MyBadgeResponse( + String code, + String name, + String description, + String iconUrl, + LocalDateTime awardedAt +) { +} diff --git a/src/main/java/com/gpt/geumpumtabackend/badge/dto/response/MyBadgeStatusResponse.java b/src/main/java/com/gpt/geumpumtabackend/badge/dto/response/MyBadgeStatusResponse.java new file mode 100644 index 0000000..4c96039 --- /dev/null +++ b/src/main/java/com/gpt/geumpumtabackend/badge/dto/response/MyBadgeStatusResponse.java @@ -0,0 +1,25 @@ +package com.gpt.geumpumtabackend.badge.dto.response; + +import com.gpt.geumpumtabackend.badge.domain.Badge; + +import java.time.LocalDateTime; + +public record MyBadgeStatusResponse( + String code, + String name, + String description, + String iconUrl, + boolean owned, + LocalDateTime awardedAt +) { + public static MyBadgeStatusResponse from(Badge badge, LocalDateTime awardedAt) { + return new MyBadgeStatusResponse( + badge.getCode(), + badge.getName(), + badge.getDescription(), + badge.getIconUrl(), + awardedAt != null, + awardedAt + ); + } +} diff --git a/src/main/java/com/gpt/geumpumtabackend/badge/dto/response/NewBadgeResponse.java b/src/main/java/com/gpt/geumpumtabackend/badge/dto/response/NewBadgeResponse.java new file mode 100644 index 0000000..7d8efe7 --- /dev/null +++ b/src/main/java/com/gpt/geumpumtabackend/badge/dto/response/NewBadgeResponse.java @@ -0,0 +1,19 @@ +package com.gpt.geumpumtabackend.badge.dto.response; + +import com.gpt.geumpumtabackend.badge.domain.Badge; + +public record NewBadgeResponse( + String code, + String name, + String description, + String iconUrl +) { + public static NewBadgeResponse from(Badge badge) { + return new NewBadgeResponse( + badge.getCode(), + badge.getName(), + badge.getDescription(), + badge.getIconUrl() + ); + } +} diff --git a/src/main/java/com/gpt/geumpumtabackend/badge/dto/response/RepresentativeBadgeResponse.java b/src/main/java/com/gpt/geumpumtabackend/badge/dto/response/RepresentativeBadgeResponse.java new file mode 100644 index 0000000..b83461d --- /dev/null +++ b/src/main/java/com/gpt/geumpumtabackend/badge/dto/response/RepresentativeBadgeResponse.java @@ -0,0 +1,19 @@ +package com.gpt.geumpumtabackend.badge.dto.response; + +import com.gpt.geumpumtabackend.badge.domain.Badge; + +public record RepresentativeBadgeResponse( + String code, + String name, + String description, + String iconUrl +) { + public static RepresentativeBadgeResponse from(Badge badge){ + return new RepresentativeBadgeResponse( + badge.getCode(), + badge.getName(), + badge.getDescription(), + badge.getIconUrl() + ); + } +} diff --git a/src/main/java/com/gpt/geumpumtabackend/badge/repository/BadgeRepository.java b/src/main/java/com/gpt/geumpumtabackend/badge/repository/BadgeRepository.java new file mode 100644 index 0000000..4462546 --- /dev/null +++ b/src/main/java/com/gpt/geumpumtabackend/badge/repository/BadgeRepository.java @@ -0,0 +1,18 @@ +package com.gpt.geumpumtabackend.badge.repository; + +import com.gpt.geumpumtabackend.badge.domain.Badge; +import com.gpt.geumpumtabackend.badge.domain.BadgeType; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface BadgeRepository extends JpaRepository { + Optional findByBadgeType(BadgeType badgeType); + + List findAllByBadgeType(BadgeType badgeType); + + Optional findByCode(String code); + + boolean existsByCode(String code); +} diff --git a/src/main/java/com/gpt/geumpumtabackend/badge/repository/UserBadgeRepository.java b/src/main/java/com/gpt/geumpumtabackend/badge/repository/UserBadgeRepository.java new file mode 100644 index 0000000..4a08501 --- /dev/null +++ b/src/main/java/com/gpt/geumpumtabackend/badge/repository/UserBadgeRepository.java @@ -0,0 +1,41 @@ +package com.gpt.geumpumtabackend.badge.repository; + +import com.gpt.geumpumtabackend.badge.domain.UserBadge; +import com.gpt.geumpumtabackend.badge.dto.response.MyBadgeResponse; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import java.util.List; + +public interface UserBadgeRepository extends JpaRepository { + @Query(""" + select new com.gpt.geumpumtabackend.badge.dto.response.MyBadgeResponse( + b.code, b.name, b.description, b.iconUrl, ub.awardedAt + ) + from UserBadge ub + join Badge b on b.id = ub.badgeId + where ub.userId = :userId + order by ub.awardedAt desc + """) + List findMyBadges(Long userId); + + @Query(""" + select new com.gpt.geumpumtabackend.badge.dto.response.MyBadgeResponse( + b.code, b.name, b.description, b.iconUrl, ub.awardedAt + ) + from UserBadge ub + join Badge b on b.id = ub.badgeId + where ub.userId = :userId + and ub.notifiedAt is null + order by ub.awardedAt desc + """) + List findUnnotifiedBadgeResponses(Long userId); + + boolean existsByUserIdAndBadgeId(Long userId, Long badgeId); + + boolean existsByBadgeId(Long badgeId); + + List findByUserId(Long userId); + + List findByUserIdAndNotifiedAtIsNull(Long userId); + +} diff --git a/src/main/java/com/gpt/geumpumtabackend/badge/service/BadgeService.java b/src/main/java/com/gpt/geumpumtabackend/badge/service/BadgeService.java new file mode 100644 index 0000000..c2db5c5 --- /dev/null +++ b/src/main/java/com/gpt/geumpumtabackend/badge/service/BadgeService.java @@ -0,0 +1,267 @@ +package com.gpt.geumpumtabackend.badge.service; + +import com.gpt.geumpumtabackend.badge.domain.Badge; +import com.gpt.geumpumtabackend.badge.domain.BadgeType; +import com.gpt.geumpumtabackend.badge.domain.UserBadge; +import com.gpt.geumpumtabackend.badge.dto.request.BadgeCreateRequest; +import com.gpt.geumpumtabackend.badge.dto.request.RepresentativeBadgeRequest; +import com.gpt.geumpumtabackend.badge.dto.response.BadgeCreateResponse; +import com.gpt.geumpumtabackend.badge.dto.response.BadgeResponse; +import com.gpt.geumpumtabackend.badge.dto.response.MyBadgeResponse; +import com.gpt.geumpumtabackend.badge.dto.response.MyBadgeStatusResponse; +import com.gpt.geumpumtabackend.badge.dto.response.NewBadgeResponse; +import com.gpt.geumpumtabackend.badge.dto.response.RepresentativeBadgeResponse; +import com.gpt.geumpumtabackend.badge.repository.BadgeRepository; +import com.gpt.geumpumtabackend.badge.repository.UserBadgeRepository; +import com.gpt.geumpumtabackend.global.exception.BusinessException; +import com.gpt.geumpumtabackend.global.exception.ExceptionType; +import com.gpt.geumpumtabackend.rank.domain.RankType; +import com.gpt.geumpumtabackend.rank.domain.SeasonRankingSnapshot; +import com.gpt.geumpumtabackend.rank.repository.SeasonRankingSnapshotRepository; +import com.gpt.geumpumtabackend.user.domain.User; +import com.gpt.geumpumtabackend.user.repository.UserRepository; +import com.gpt.geumpumtabackend.study.repository.StudySessionRepository; +import com.gpt.geumpumtabackend.statistics.repository.StatisticsRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class BadgeService { + + private final BadgeRepository badgeRepository; + private final UserBadgeRepository userBadgeRepository; + private final UserRepository userRepository; + private final StudySessionRepository studySessionRepository; + private final StatisticsRepository statisticsRepository; + private final SeasonRankingSnapshotRepository seasonRankingSnapshotRepository; + + private static final long STREAK_MIN_MILLIS = 30L * 60L * 1000L; + + public List getMyBadges(Long userId) { + validateUserExists(userId); + + Map awardedAtByBadgeId = userBadgeRepository.findByUserId(userId).stream() + .collect(Collectors.toMap( + UserBadge::getBadgeId, + UserBadge::getAwardedAt, + (existing, ignored) -> existing + )); + + return badgeRepository.findAll().stream() + .sorted(Comparator.comparing(Badge::getId)) + .map(badge -> MyBadgeStatusResponse.from(badge, awardedAtByBadgeId.get(badge.getId()))) + .toList(); + } + + public List getAllBadges() { + return badgeRepository.findAll().stream() + .map(BadgeResponse::from) + .toList(); + } + + @Transactional + public BadgeCreateResponse createBadge(BadgeCreateRequest request) { + if (badgeRepository.existsByCode(request.code())) { + throw new BusinessException(ExceptionType.BADGE_CODE_ALREADY_EXISTS); + } + + Badge badge = Badge.builder() + .code(request.code()) + .name(request.name()) + .description(request.description()) + .iconUrl(request.iconUrl()) + .badgeType(request.badgeType()) + .thresholdValue(request.thresholdValue()) + .rank(request.rank()) + .build(); + + Badge saved = badgeRepository.save(badge); + return BadgeCreateResponse.from(saved); + } + + @Transactional + public void deleteBadge(Long badgeId) { + Badge badge = badgeRepository.findById(badgeId) + .orElseThrow(() -> new BusinessException(ExceptionType.BADGE_NOT_FOUND)); + + if (userBadgeRepository.existsByBadgeId(badgeId)) { + throw new BusinessException(ExceptionType.BADGE_IN_USE); + } + + badgeRepository.delete(badge); + } + + @Transactional + public RepresentativeBadgeResponse setRepresentativeBadge(RepresentativeBadgeRequest request, Long userId) { + Badge badge = badgeRepository.findByCode(request.badgeCode()) + .orElseThrow(() -> new BusinessException(ExceptionType.BADGE_NOT_FOUND)); + User user = userRepository.findById(userId) + .orElseThrow(() -> new BusinessException(ExceptionType.USER_NOT_FOUND)); + + if(!userBadgeRepository.existsByUserIdAndBadgeId(userId, badge.getId())) + throw new BusinessException(ExceptionType.BADGE_NOT_OWNED); + + user.setRepresentativeBadge(badge.getId()); + return RepresentativeBadgeResponse.from(badge); + } + + @Transactional + public NewBadgeResponse grantWelcomeBadge(Long userId) { + validateUserExists(userId); + + Badge badge = badgeRepository.findByBadgeType(BadgeType.WELCOME) + .orElseThrow(() -> new BusinessException(ExceptionType.BADGE_NOT_FOUND)); + if (userBadgeRepository.existsByUserIdAndBadgeId(userId, badge.getId())) { + return NewBadgeResponse.from(badge); + } + LocalDateTime now = LocalDateTime.now(); + userBadgeRepository.save(new UserBadge(userId, badge.getId(), now, null)); + return NewBadgeResponse.from(badge); + } + + @Transactional + public List getUnnotifiedBadges(Long userId) { + validateUserExists(userId); + + List responses = userBadgeRepository.findUnnotifiedBadgeResponses(userId); + if (responses.isEmpty()) { + return Collections.emptyList(); + } + + markBadgesNotified(userId); + + return responses; + } + + private void markBadgesNotified(Long userId) { + List userBadges = userBadgeRepository.findByUserIdAndNotifiedAtIsNull(userId); + LocalDateTime now = LocalDateTime.now(); + for (UserBadge userBadge : userBadges) { + userBadge.markNotified(now); + } + userBadgeRepository.saveAll(userBadges); + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public List grantStudyAchievementBadges(Long userId) { + validateUserExists(userId); + + LocalDateTime now = LocalDateTime.now(); + List newlyGranted = new ArrayList<>(); + + grantTotalHoursBadge(userId, now, newlyGranted); + grantStreakBadge(userId, now, newlyGranted); + + return newlyGranted; + } + + @Transactional + public int grantSeasonRankingBadges(Long seasonId) { + return grantSeasonRankTypeBadges( + seasonId, + RankType.OVERALL, + BadgeType.SEASON_PERSONAL_RANK + ); + } + + private int grantSeasonRankTypeBadges(Long seasonId, RankType rankType, BadgeType badgeType) { + List badges = badgeRepository.findAllByBadgeType(badgeType); + if (badges.isEmpty()) { + return 0; + } + + Map badgeByRank = badges.stream() + .filter(badge -> badge.getRank() != null) + .collect(Collectors.toMap( + badge -> badge.getRank().intValue(), + badge -> badge, + (existing, ignored) -> existing + )); + if (badgeByRank.isEmpty()) { + return 0; + } + + Set targetRanks = badgeByRank.keySet(); + List snapshots = + seasonRankingSnapshotRepository.findBySeasonIdAndRankTypeAndFinalRankIn( + seasonId, + rankType, + targetRanks + ); + + int grantedCount = 0; + LocalDateTime now = LocalDateTime.now(); + for (SeasonRankingSnapshot snapshot : snapshots) { + Badge badge = badgeByRank.get(snapshot.getFinalRank()); + if (badge == null) { + continue; + } + if (userBadgeRepository.existsByUserIdAndBadgeId(snapshot.getUserId(), badge.getId())) { + continue; + } + userBadgeRepository.save(new UserBadge(snapshot.getUserId(), badge.getId(), now, null)); + grantedCount++; + } + + return grantedCount; + } + + private void grantTotalHoursBadge(Long userId, LocalDateTime now, List newlyGranted) { + Long totalMillis = studySessionRepository.sumTotalStudyMillisByUserId(userId); + if (totalMillis == null) { + totalMillis = 0L; + } + + List hourBadges = badgeRepository.findAllByBadgeType(BadgeType.TOTAL_HOURS); + for (Badge badge : hourBadges) { + Long thresholdHours = badge.getThresholdValue(); + if (thresholdHours == null) { + continue; + } + long thresholdMillis = thresholdHours * 60L * 60L * 1000L; + if (totalMillis >= thresholdMillis + && !userBadgeRepository.existsByUserIdAndBadgeId(userId, badge.getId())) { + userBadgeRepository.save(new UserBadge(userId, badge.getId(), now, now)); + newlyGranted.add(NewBadgeResponse.from(badge)); + } + } + } + + private void grantStreakBadge(Long userId, LocalDateTime now, List newlyGranted) { + int consecutiveDays = statisticsRepository.countCurrentConsecutiveStudyDays( + userId, LocalDate.now(), STREAK_MIN_MILLIS + ); + List streakBadges = badgeRepository.findAllByBadgeType(BadgeType.STREAK_DAYS); + for (Badge badge : streakBadges) { + Long thresholdDays = badge.getThresholdValue(); + if (thresholdDays == null) { + continue; + } + if (consecutiveDays >= thresholdDays + && !userBadgeRepository.existsByUserIdAndBadgeId(userId, badge.getId())) { + userBadgeRepository.save(new UserBadge(userId, badge.getId(), now, now)); + newlyGranted.add(NewBadgeResponse.from(badge)); + } + } + } + + private void validateUserExists(Long userId) { + if (!userRepository.existsById(userId)) { + throw new BusinessException(ExceptionType.USER_NOT_FOUND); + } + } +} diff --git a/src/main/java/com/gpt/geumpumtabackend/fcm/api/FcmApi.java b/src/main/java/com/gpt/geumpumtabackend/fcm/api/FcmApi.java new file mode 100644 index 0000000..ccfafc9 --- /dev/null +++ b/src/main/java/com/gpt/geumpumtabackend/fcm/api/FcmApi.java @@ -0,0 +1,128 @@ +package com.gpt.geumpumtabackend.fcm.api; + +import com.gpt.geumpumtabackend.fcm.dto.request.FcmTokenRequest; +import com.gpt.geumpumtabackend.global.aop.AssignUserId; +import com.gpt.geumpumtabackend.global.config.swagger.SwaggerApiFailedResponse; +import com.gpt.geumpumtabackend.global.config.swagger.SwaggerApiResponses; +import com.gpt.geumpumtabackend.global.config.swagger.SwaggerApiSuccessResponse; +import com.gpt.geumpumtabackend.global.exception.ExceptionType; +import com.gpt.geumpumtabackend.global.response.ResponseBody; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; + +@Tag(name = "FCM API", description = """ + Firebase Cloud Messaging 푸시 알림 API + + ## 클라이언트 호출 가이드 (권장) + + 1. 앱 시작 또는 로그인 직후 + - FCM 권한 허용 후 디바이스 토큰 발급 시 `POST /api/v1/fcm/token` 호출 + 2. 토큰 갱신 이벤트(OnNewToken) + - 토큰이 변경되면 즉시 `POST /api/v1/fcm/token` 재호출 + 3. 로그아웃/알림 비활성화 + - `DELETE /api/v1/fcm/token` 호출하여 서버 토큰 바인딩 정리 + 4. 계정 탈퇴 + - 즉시 삭제 요청을 보내고, 서버 정리 동작도 함께 기대 + + ## 자동으로 전송되는 FCM 메시지 + + ### 최대 공부시간 초과 알림 + 3시간 연속 공부 시 서버가 세션을 자동 종료하고 아래 FCM 메시지를 전송합니다. + + **Notification (클라이언트 화면 표시):** + ```json + { + "title": "최대 공부시간 초과", + "body": "3시간 이상 연속 공부가 제한되어 세션이 자동 종료됩니다." + } + ``` + + **Data Message (앱 내부 처리):** + ```json + { + "type": "STUDY_SESSION_FORCE_ENDED", + "maxFocusHours": "3" + } + ``` + + **클라이언트 처리 가이드:** + - data.type == "STUDY_SESSION_FORCE_ENDED" 이면 세션 강제 종료 UI로 이동 + - notification은 화면 표시용으로 활용 + + ## FCM 전송 오류 처리 정책 + + 서버는 FCM 전송 실패 시 오류 유형에 따라 다르게 처리합니다. + + ### 영구 오류 (재시도 없음) + | ErrorCode | 처리 | + |-----------|------| + | `UNREGISTERED` | 무효 토큰 자동 삭제 후 전송 중단 (앱 삭제/재설치 시 발생) | + | `INVALID_ARGUMENT` | 로그 경고 후 전송 중단 | + | `SENDER_ID_MISMATCH` | 로그 경고 후 전송 중단 | + | `THIRD_PARTY_AUTH_ERROR` | 로그 경고 후 전송 중단 | + + ### 일시적 오류 (최대 3회 재시도, 1s → 2s → 4s backoff) + | ErrorCode | 설명 | + |-----------|------| + | `UNAVAILABLE` | FCM 서버 일시 장애 | + | `INTERNAL` | FCM 내부 오류 | + | `QUOTA_EXCEEDED` | 전송 한도 초과 | + + 3회 재시도 실패 시 `F001 FCM_SEND_FAILED` 오류가 발생하지만, 최대 집중시간 알림의 경우 로그만 남기고 세션 종료에는 영향을 주지 않습니다. + + ### 클라이언트 권장 사항 + - `UNREGISTERED` 발생 시 서버가 토큰을 자동 삭제하므로, 앱 재시작 시 `POST /api/v1/fcm/token`을 다시 호출하세요. + """) +public interface FcmApi { + + @Operation( + summary = "FCM 토큰 등록", + description = "요청자의 FCM 디바이스 토큰을 등록합니다. 앱 시작/로그인 후 또는 토큰 갱신 시 호출하세요." + ) + @SwaggerApiResponses( + success = @SwaggerApiSuccessResponse( + response = Void.class, + description = "FCM 토큰 등록 완료" + ), + errors = { + @SwaggerApiFailedResponse(ExceptionType.NEED_AUTHORIZED), + @SwaggerApiFailedResponse(ExceptionType.USER_NOT_FOUND), + @SwaggerApiFailedResponse(ExceptionType.FCM_INVALID_TOKEN) + } + ) + @PostMapping("/token") + @AssignUserId + @PreAuthorize("isAuthenticated() and hasRole('USER')") + ResponseEntity> registerFcmToken( + @RequestBody @Valid FcmTokenRequest request, + @Parameter(hidden = true) Long userId + ); + + @Operation( + summary = "FCM 토큰 삭제", + description = "등록된 FCM 토큰을 삭제합니다. 로그아웃, 알림 비활성화, 탈퇴 시 호출하세요." + ) + @SwaggerApiResponses( + success = @SwaggerApiSuccessResponse( + response = Void.class, + description = "FCM 토큰 삭제 완료" + ), + errors = { + @SwaggerApiFailedResponse(ExceptionType.NEED_AUTHORIZED), + @SwaggerApiFailedResponse(ExceptionType.USER_NOT_FOUND) + } + ) + @DeleteMapping("/token") + @AssignUserId + @PreAuthorize("isAuthenticated() and hasRole('USER')") + ResponseEntity> removeFcmToken( + @Parameter(hidden = true) Long userId + ); +} diff --git a/src/main/java/com/gpt/geumpumtabackend/fcm/controller/FcmController.java b/src/main/java/com/gpt/geumpumtabackend/fcm/controller/FcmController.java new file mode 100644 index 0000000..01494f3 --- /dev/null +++ b/src/main/java/com/gpt/geumpumtabackend/fcm/controller/FcmController.java @@ -0,0 +1,39 @@ +package com.gpt.geumpumtabackend.fcm.controller; + +import com.gpt.geumpumtabackend.fcm.api.FcmApi; +import com.gpt.geumpumtabackend.fcm.dto.request.FcmTokenRequest; +import com.gpt.geumpumtabackend.fcm.service.FcmService; +import com.gpt.geumpumtabackend.global.aop.AssignUserId; +import com.gpt.geumpumtabackend.global.response.ResponseBody; +import com.gpt.geumpumtabackend.global.response.ResponseUtil; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/v1/fcm") +@RequiredArgsConstructor +public class FcmController implements FcmApi { + + private final FcmService fcmService; + + @Override + @PostMapping("/token") + @AssignUserId + @PreAuthorize("isAuthenticated() and hasRole('USER')") + public ResponseEntity> registerFcmToken(@RequestBody @Valid FcmTokenRequest request, Long userId) { + fcmService.registerFcmToken(userId, request.fcmToken()); + return ResponseEntity.ok(ResponseUtil.createSuccessResponse()); + } + + @Override + @DeleteMapping("/token") + @AssignUserId + @PreAuthorize("isAuthenticated() and hasRole('USER')") + public ResponseEntity> removeFcmToken(Long userId) { + fcmService.removeFcmToken(userId); + return ResponseEntity.ok(ResponseUtil.createSuccessResponse()); + } +} diff --git a/src/main/java/com/gpt/geumpumtabackend/fcm/dto/FcmMessageDto.java b/src/main/java/com/gpt/geumpumtabackend/fcm/dto/FcmMessageDto.java new file mode 100644 index 0000000..1c716cb --- /dev/null +++ b/src/main/java/com/gpt/geumpumtabackend/fcm/dto/FcmMessageDto.java @@ -0,0 +1,16 @@ +package com.gpt.geumpumtabackend.fcm.dto; + +import lombok.Builder; +import lombok.Getter; + +import java.util.Map; + +@Getter +@Builder +public class FcmMessageDto { + private String token; + private String title; + private String body; + private String imageUrl; + private Map data; +} diff --git a/src/main/java/com/gpt/geumpumtabackend/fcm/dto/request/FcmTokenRequest.java b/src/main/java/com/gpt/geumpumtabackend/fcm/dto/request/FcmTokenRequest.java new file mode 100644 index 0000000..ae8570b --- /dev/null +++ b/src/main/java/com/gpt/geumpumtabackend/fcm/dto/request/FcmTokenRequest.java @@ -0,0 +1,12 @@ +package com.gpt.geumpumtabackend.fcm.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; + +@Schema(description = "FCM 토큰 등록 요청") +public record FcmTokenRequest( + @NotBlank(message = "FCM 토큰은 필수입니다.") + @Schema(description = "FCM 디바이스 토큰", example = "eXaMpLeToKeN123...") + String fcmToken +) { +} diff --git a/src/main/java/com/gpt/geumpumtabackend/fcm/service/FcmMessageSender.java b/src/main/java/com/gpt/geumpumtabackend/fcm/service/FcmMessageSender.java new file mode 100644 index 0000000..d19fd03 --- /dev/null +++ b/src/main/java/com/gpt/geumpumtabackend/fcm/service/FcmMessageSender.java @@ -0,0 +1,85 @@ +package com.gpt.geumpumtabackend.fcm.service; + +import com.google.firebase.messaging.FirebaseMessaging; +import com.google.firebase.messaging.FirebaseMessagingException; +import com.google.firebase.messaging.Message; +import com.google.firebase.messaging.MessagingErrorCode; +import com.google.firebase.messaging.Notification; +import com.gpt.geumpumtabackend.fcm.dto.FcmMessageDto; +import com.gpt.geumpumtabackend.global.exception.BusinessException; +import com.gpt.geumpumtabackend.global.exception.ExceptionType; +import com.gpt.geumpumtabackend.user.domain.User; +import com.gpt.geumpumtabackend.user.repository.UserRepository; +import java.util.Set; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.retry.annotation.Backoff; +import org.springframework.retry.annotation.Recover; +import org.springframework.retry.annotation.Retryable; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +@Slf4j +public class FcmMessageSender { + + private static final Set PERMANENT_ERROR_CODES = Set.of( + MessagingErrorCode.UNREGISTERED, + MessagingErrorCode.INVALID_ARGUMENT, + MessagingErrorCode.SENDER_ID_MISMATCH, + MessagingErrorCode.THIRD_PARTY_AUTH_ERROR + ); + + private final UserRepository userRepository; + + @Retryable( + retryFor = FirebaseMessagingException.class, + maxAttempts = 3, + backoff = @Backoff(delay = 1000, multiplier = 2) + ) + public void send(FcmMessageDto messageDto) throws FirebaseMessagingException { + Notification notification = Notification.builder() + .setTitle(messageDto.getTitle()) + .setBody(messageDto.getBody()) + .setImage(messageDto.getImageUrl()) + .build(); + + Message.Builder messageBuilder = Message.builder() + .setToken(messageDto.getToken()) + .setNotification(notification); + + if (messageDto.getData() != null && !messageDto.getData().isEmpty()) { + messageBuilder.putAllData(messageDto.getData()); + } + + try { + FirebaseMessaging.getInstance().send(messageBuilder.build()); + } catch (FirebaseMessagingException e) { + handleSendFailure(e, messageDto.getToken()); + } + } + + @Recover + public void sendRecover(FirebaseMessagingException e, FcmMessageDto messageDto) { + log.error("FCM send failed after 3 retries for token {}", messageDto.getToken(), e); + throw new BusinessException(ExceptionType.FCM_SEND_FAILED); + } + + private void handleSendFailure(FirebaseMessagingException e, String token) + throws FirebaseMessagingException { + MessagingErrorCode errorCode = e.getMessagingErrorCode(); + + if (errorCode == null || !PERMANENT_ERROR_CODES.contains(errorCode)) { + throw e; + } + + if (errorCode == MessagingErrorCode.UNREGISTERED) { + log.warn("FCM token unregistered, clearing token: {}", token); + userRepository.findByFcmToken(token) + .ifPresent(User::clearFcmToken); + return; + } + + log.warn("FCM permanent error [{}] for token {}: {}", errorCode, token, e.getMessage()); + } +} diff --git a/src/main/java/com/gpt/geumpumtabackend/fcm/service/FcmService.java b/src/main/java/com/gpt/geumpumtabackend/fcm/service/FcmService.java new file mode 100644 index 0000000..b6a32e4 --- /dev/null +++ b/src/main/java/com/gpt/geumpumtabackend/fcm/service/FcmService.java @@ -0,0 +1,66 @@ +package com.gpt.geumpumtabackend.fcm.service; + +import com.gpt.geumpumtabackend.fcm.dto.FcmMessageDto; +import com.gpt.geumpumtabackend.global.exception.BusinessException; +import com.gpt.geumpumtabackend.global.exception.ExceptionType; +import com.gpt.geumpumtabackend.user.domain.User; +import com.gpt.geumpumtabackend.user.repository.UserRepository; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Slf4j +@Transactional(readOnly = true) +public class FcmService { + + private final UserRepository userRepository; + private final FcmMessageSender fcmMessageSender; + + @Transactional + public void registerFcmToken(Long userId, String fcmToken) { + if (fcmToken == null || fcmToken.isBlank()) { + throw new BusinessException(ExceptionType.FCM_INVALID_TOKEN); + } + + userRepository.findByFcmToken(fcmToken) + .ifPresent(User::clearFcmToken); + + User user = userRepository.findById(userId) + .orElseThrow(() -> new BusinessException(ExceptionType.USER_NOT_FOUND)); + + user.updateFcmToken(fcmToken); + } + + @Transactional + public void removeFcmToken(Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new BusinessException(ExceptionType.USER_NOT_FOUND)); + + user.clearFcmToken(); + } + + public void sendMaxFocusNotification(User user, int hours) { + if (user.getFcmToken() == null || user.getFcmToken().isBlank()) { + return; + } + + FcmMessageDto messageDto = FcmMessageDto.builder() + .token(user.getFcmToken()) + .title("최대 집중 시간 도달") + .body(String.format("%d시간 동안 열심히 공부하셨습니다! 잠시 휴식을 취해보세요.", hours)) + .data(Map.of( + "type", "STUDY_SESSION_FORCE_ENDED", + "maxFocusHours", String.valueOf(hours) + )) + .build(); + try { + fcmMessageSender.send(messageDto); + } catch (Exception e) { + log.error("Failed to send max focus notification to user {}", user.getId(), e); + } + } +} diff --git a/src/main/java/com/gpt/geumpumtabackend/global/config/cache/CacheConfig.java b/src/main/java/com/gpt/geumpumtabackend/global/config/cache/CacheConfig.java new file mode 100644 index 0000000..5703e4f --- /dev/null +++ b/src/main/java/com/gpt/geumpumtabackend/global/config/cache/CacheConfig.java @@ -0,0 +1,30 @@ +package com.gpt.geumpumtabackend.global.config.cache; + +import com.github.benmanes.caffeine.cache.Caffeine; +import org.springframework.cache.CacheManager; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.cache.caffeine.CaffeineCacheManager; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.util.concurrent.TimeUnit; + +@Configuration +@EnableCaching +public class CacheConfig { + + @Bean + public CacheManager cacheManager() { + CaffeineCacheManager cacheManager = new CaffeineCacheManager( + "wifiValidation", // WiFi 검증 캐시 + "activeSeason" // 활성 시즌 캐시 + ); + + cacheManager.setCaffeine(Caffeine.newBuilder() + .expireAfterWrite(10, TimeUnit.MINUTES) // 10분 후 만료 + .maximumSize(1000) // 최대 1000개 항목 + ); + + return cacheManager; + } +} diff --git a/src/main/java/com/gpt/geumpumtabackend/global/config/fcm/FcmConfig.java b/src/main/java/com/gpt/geumpumtabackend/global/config/fcm/FcmConfig.java new file mode 100644 index 0000000..4f255c2 --- /dev/null +++ b/src/main/java/com/gpt/geumpumtabackend/global/config/fcm/FcmConfig.java @@ -0,0 +1,49 @@ +package com.gpt.geumpumtabackend.global.config.fcm; + +import com.google.auth.oauth2.GoogleCredentials; +import com.google.firebase.FirebaseApp; +import com.google.firebase.FirebaseOptions; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.io.Resource; +import org.springframework.core.io.ResourceLoader; + +import org.springframework.context.annotation.Profile; + +import java.io.IOException; +import java.io.InputStream; + +@Configuration +@RequiredArgsConstructor +@EnableConfigurationProperties(FcmProperties.class) +@Profile("!test") +@Slf4j +public class FcmConfig { + + private final FcmProperties fcmProperties; + private final ResourceLoader resourceLoader; + + @Bean + public FirebaseApp firebaseApp() throws IOException { + if (FirebaseApp.getApps().isEmpty()) { + Resource resource = resourceLoader.getResource(fcmProperties.getServiceAccountPath()); + + try (InputStream serviceAccount = resource.getInputStream()) { + FirebaseOptions options = FirebaseOptions.builder() + .setCredentials(GoogleCredentials.fromStream(serviceAccount)) + .setProjectId(fcmProperties.getProjectId()) + .build(); + + FirebaseApp app = FirebaseApp.initializeApp(options); + log.info("Firebase App initialized successfully: {}", app.getName()); + return app; + } + } + + log.info("Firebase App already initialized"); + return FirebaseApp.getInstance(); + } +} diff --git a/src/main/java/com/gpt/geumpumtabackend/global/config/fcm/FcmProperties.java b/src/main/java/com/gpt/geumpumtabackend/global/config/fcm/FcmProperties.java new file mode 100644 index 0000000..2f21f5b --- /dev/null +++ b/src/main/java/com/gpt/geumpumtabackend/global/config/fcm/FcmProperties.java @@ -0,0 +1,13 @@ +package com.gpt.geumpumtabackend.global.config.fcm; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@Getter +@Setter +@ConfigurationProperties(prefix = "firebase") +public class FcmProperties { + private String serviceAccountPath; + private String projectId; +} diff --git a/src/main/java/com/gpt/geumpumtabackend/global/config/redis/RedisConfig.java b/src/main/java/com/gpt/geumpumtabackend/global/config/redis/RedisConfig.java index 1b4e50c..11f044b 100644 --- a/src/main/java/com/gpt/geumpumtabackend/global/config/redis/RedisConfig.java +++ b/src/main/java/com/gpt/geumpumtabackend/global/config/redis/RedisConfig.java @@ -1,6 +1,7 @@ package com.gpt.geumpumtabackend.global.config.redis; -import org.springframework.beans.factory.annotation.Value; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.autoconfigure.data.redis.RedisProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.data.redis.connection.RedisConnectionFactory; @@ -12,43 +13,41 @@ @Configuration @EnableRedisRepositories +@RequiredArgsConstructor public class RedisConfig { - @Value("${spring.data.redis.host}") - private String host; - @Value("${spring.data.redis.port}") - private int port; - - @Value("${spring.data.redis.password:}") - private String password; + private final RedisProperties redisProperties; @Bean public RedisConnectionFactory redisConnectionFactory() { - RedisStandaloneConfiguration config = new RedisStandaloneConfiguration(host, port); - if(password != null) { - config.setPassword(password); + RedisStandaloneConfiguration config = + new RedisStandaloneConfiguration( + redisProperties.getHost(), + redisProperties.getPort() + ); + + if (redisProperties.getPassword() != null && !redisProperties.getPassword().isEmpty()) { + config.setPassword(redisProperties.getPassword()); } + return new LettuceConnectionFactory(config); } @Bean - public RedisTemplate redisTemplate() { + public RedisTemplate redisTemplate( + RedisConnectionFactory connectionFactory + ) { RedisTemplate redisTemplate = new RedisTemplate<>(); - redisTemplate.setConnectionFactory(redisConnectionFactory()); - - // 일반 - redisTemplate.setKeySerializer(new StringRedisSerializer()); - redisTemplate.setValueSerializer(new StringRedisSerializer()); + redisTemplate.setConnectionFactory(connectionFactory); - // Hash - redisTemplate.setHashKeySerializer(new StringRedisSerializer()); - redisTemplate.setHashValueSerializer(new StringRedisSerializer()); + StringRedisSerializer serializer = new StringRedisSerializer(); - // 모든 경우 - redisTemplate.setDefaultSerializer(new StringRedisSerializer()); + redisTemplate.setKeySerializer(serializer); + redisTemplate.setValueSerializer(serializer); + redisTemplate.setHashKeySerializer(serializer); + redisTemplate.setHashValueSerializer(serializer); + redisTemplate.setDefaultSerializer(serializer); return redisTemplate; } } - - diff --git a/src/main/java/com/gpt/geumpumtabackend/global/config/retry/RetryConfig.java b/src/main/java/com/gpt/geumpumtabackend/global/config/retry/RetryConfig.java new file mode 100644 index 0000000..4d67706 --- /dev/null +++ b/src/main/java/com/gpt/geumpumtabackend/global/config/retry/RetryConfig.java @@ -0,0 +1,14 @@ +package com.gpt.geumpumtabackend.global.config.retry; + +import org.springframework.context.annotation.Configuration; +import org.springframework.retry.annotation.EnableRetry; + +/** + * Spring Retry 설정 + * - @Retryable 어노테이션 활성화 + * - 스냅샷 생성 실패 시 자동 재시도 지원 + */ +@Configuration +@EnableRetry +public class RetryConfig { +} diff --git a/src/main/java/com/gpt/geumpumtabackend/global/config/security/SecurityConfig.java b/src/main/java/com/gpt/geumpumtabackend/global/config/security/SecurityConfig.java index fc106a7..b84c701 100644 --- a/src/main/java/com/gpt/geumpumtabackend/global/config/security/SecurityConfig.java +++ b/src/main/java/com/gpt/geumpumtabackend/global/config/security/SecurityConfig.java @@ -3,6 +3,7 @@ import com.gpt.geumpumtabackend.global.jwt.JwtAuthenticationFilter; +import com.gpt.geumpumtabackend.global.maintenance.MaintenanceFilter; import com.gpt.geumpumtabackend.global.oauth.handler.OAuth2AuthenticationFailureHandler; import com.gpt.geumpumtabackend.global.oauth.handler.OAuth2AuthenticationSuccessHandler; import com.gpt.geumpumtabackend.global.oauth.resolver.CustomAuthorizationRequestResolver; @@ -36,6 +37,7 @@ public class SecurityConfig { private final CustomAuthorizationRequestResolver customAuthorizationRequestResolver; private final OAuth2AccessTokenResponseClient authorizationCodeTokenResponseClient; private final OAuth2AuthenticationFailureHandler oAuth2AuthenticationFailureHandler; + private final MaintenanceFilter maintenanceFilter; @Bean public SecurityFilterChain filterChainPermitAll(HttpSecurity http) throws Exception { @@ -62,6 +64,7 @@ public HttpSecurity defaultSecurity(HttpSecurity http) throws Exception { .successHandler(oAuth2AuthenticationSuccessHandler) .failureHandler(oAuth2AuthenticationFailureHandler) ) + .addFilterBefore(maintenanceFilter, UsernamePasswordAuthenticationFilter.class) .addFilterAfter(new JwtAuthenticationFilter(authenticationManager), UsernamePasswordAuthenticationFilter.class); } @@ -82,4 +85,4 @@ public MethodSecurityExpressionHandler expressionHandler(RoleHierarchy roleHiera return expressionHandler; } -} \ No newline at end of file +} diff --git a/src/main/java/com/gpt/geumpumtabackend/global/exception/ExceptionType.java b/src/main/java/com/gpt/geumpumtabackend/global/exception/ExceptionType.java index d936575..97b7f11 100644 --- a/src/main/java/com/gpt/geumpumtabackend/global/exception/ExceptionType.java +++ b/src/main/java/com/gpt/geumpumtabackend/global/exception/ExceptionType.java @@ -30,7 +30,7 @@ public enum ExceptionType { // User USER_NOT_FOUND(NOT_FOUND, "U001","사용자가 존재하지 않습니다"), SCHOOL_EMAIL_ALREADY_REGISTERED(FORBIDDEN, "U002", "학교 이메일이 등록된 상태입니다"), - DUPLICATED_SCHOOL_EMAIL(CONFLICT, "U003", "이미 사용중인 이메일입니다"), + DUPLICATED_SCHOOL_EMAIL(CONFLICT, "U003", "이미 사용중인 학교 이메일입니다"), DEPARTMENT_NOT_FOUND(BAD_REQUEST, "U004", "존재하지 않는 학과 명입니다"), USER_WITHDRAWN(FORBIDDEN, "U005", "탈퇴한 사용자입니다."), DUPLICATED_STUDENT_ID(CONFLICT, "U006", "이미 사용중인 학번입니다."), @@ -40,6 +40,8 @@ public enum ExceptionType { // Study STUDY_SESSION_NOT_FOUND(NOT_FOUND,"ST001","해당 공부 세션을 찾을 수 없습니다."), + ALREADY_STUDY_SESSION(CONFLICT, "ST002", "세션은 하나만 가능합니다."), + INVALID_END_TIME(CONFLICT,"ST003","유효하지 않은 종료시간입니다."), // WiFi WIFI_NOT_CAMPUS_NETWORK(FORBIDDEN, "W001", "캠퍼스 네트워크가 아닙니다"), @@ -53,8 +55,28 @@ public enum ExceptionType { // board - BOARD_NOT_FOUND(BAD_REQUEST, "B001", "존재하지 않는 게시물입니다."), + BOARD_NOT_FOUND(BAD_REQUEST, "BD001", "존재하지 않는 게시물입니다."), + // Season + NO_ACTIVE_SEASON(NOT_FOUND, "SE001", "현재 진행중인 시즌이 없습니다"), + SEASON_NOT_FOUND(NOT_FOUND, "SE002", "시즌을 찾을 수 없습니다"), + SEASON_NOT_ENDED(BAD_REQUEST, "SE003", "시즌이 아직 종료되지 않았습니다"), + SEASON_ALREADY_ENDED(BAD_REQUEST, "SE004", "이미 종료된 시즌입니다"), + SEASON_INVALID_DATE_RANGE(BAD_REQUEST, "SE005", "시즌 종료일은 시작일보다 이후여야 합니다"), + + // FCM + FCM_SEND_FAILED(INTERNAL_SERVER_ERROR, "F001", "푸시 알림 전송에 실패했습니다."), + FCM_INVALID_TOKEN(BAD_REQUEST, "F002", "유효하지 않은 FCM 토큰입니다."), + FCM_TOKEN_NOT_FOUND(NOT_FOUND, "F003", "등록된 FCM 토큰이 없습니다."), + + // Badge + BADGE_NOT_FOUND(NOT_FOUND, "B001", "배지가 존재하지 않습니다"), + BADGE_NOT_OWNED(FORBIDDEN, "B002", "해당 배지를 소유하지 않습니다"), + BADGE_CODE_ALREADY_EXISTS(CONFLICT, "B003", "이미 존재하는 배지 코드입니다"), + BADGE_IN_USE(CONFLICT, "B004", "이미 지급되어 삭제할 수 없는 배지입니다"), + + // Maintenance + MAINTENANCE_IN_PROGRESS(SERVICE_UNAVAILABLE, "MT001", "서버 점검 중입니다.") ; private final HttpStatus status; diff --git a/src/main/java/com/gpt/geumpumtabackend/global/maintenance/MaintenanceFilter.java b/src/main/java/com/gpt/geumpumtabackend/global/maintenance/MaintenanceFilter.java new file mode 100644 index 0000000..ce91451 --- /dev/null +++ b/src/main/java/com/gpt/geumpumtabackend/global/maintenance/MaintenanceFilter.java @@ -0,0 +1,64 @@ +package com.gpt.geumpumtabackend.global.maintenance; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.gpt.geumpumtabackend.global.exception.ExceptionType; +import com.gpt.geumpumtabackend.global.response.ResponseUtil; +import com.gpt.geumpumtabackend.maintenance.service.MaintenanceService; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.util.AntPathMatcher; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.List; + +@Component +@RequiredArgsConstructor +public class MaintenanceFilter extends OncePerRequestFilter { + + private static final List WHITELIST = List.of( + "/api/v1/maintenance/status", + "/actuator/health", + "/swagger-ui", + "/swagger-ui/", + "/swagger-ui/**", + "/v3/api-docs", + "/v3/api-docs/**", + "/error" + ); + + private final MaintenanceService maintenanceService; + private final ObjectMapper objectMapper; + private final AntPathMatcher pathMatcher = new AntPathMatcher(); + + @Override + protected void doFilterInternal( + HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain + ) throws ServletException, IOException { + String requestUri = request.getServletPath(); + + if (isWhitelisted(requestUri) || !maintenanceService.isMaintenanceInProgress()) { + filterChain.doFilter(request, response); + return; + } + + response.setStatus(ExceptionType.MAINTENANCE_IN_PROGRESS.getStatus().value()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding("UTF-8"); + objectMapper.writeValue( + response.getWriter(), + ResponseUtil.createFailureResponse(ExceptionType.MAINTENANCE_IN_PROGRESS) + ); + } + + private boolean isWhitelisted(String requestUri) { + return WHITELIST.stream().anyMatch(pattern -> pathMatcher.match(pattern, requestUri)); + } +} diff --git a/src/main/java/com/gpt/geumpumtabackend/global/oauth/handler/OAuth2AuthenticationSuccessHandler.java b/src/main/java/com/gpt/geumpumtabackend/global/oauth/handler/OAuth2AuthenticationSuccessHandler.java index e8834f4..8bb417f 100644 --- a/src/main/java/com/gpt/geumpumtabackend/global/oauth/handler/OAuth2AuthenticationSuccessHandler.java +++ b/src/main/java/com/gpt/geumpumtabackend/global/oauth/handler/OAuth2AuthenticationSuccessHandler.java @@ -1,9 +1,7 @@ package com.gpt.geumpumtabackend.global.oauth.handler; - - -import com.gpt.geumpumtabackend.global.jwt.JwtHandler; import com.gpt.geumpumtabackend.global.jwt.JwtUserClaim; +import com.gpt.geumpumtabackend.global.oauth.service.OAuthLoginPolicyService; import com.gpt.geumpumtabackend.global.oauth.service.OAuth2UserPrincipal; import com.gpt.geumpumtabackend.global.oauth.util.RedirectUrlValidator; import com.gpt.geumpumtabackend.global.oauth.util.StateUtil; @@ -20,13 +18,14 @@ import org.springframework.web.util.UriComponentsBuilder; import java.io.IOException; +import java.util.Optional; @Component @RequiredArgsConstructor @Slf4j public class OAuth2AuthenticationSuccessHandler implements AuthenticationSuccessHandler { - private final JwtHandler jwtHandler; + private final OAuthLoginPolicyService oAuthLoginPolicyService; @Override public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, @@ -45,15 +44,22 @@ public void onAuthenticationSuccess(HttpServletRequest request, HttpServletRespo Boolean isWithdrawn = principal.getUser().getDeletedAt() != null; JwtUserClaim jwtUserClaim = new JwtUserClaim(userId, role, isWithdrawn); - Token token = jwtHandler.createTokens(jwtUserClaim); + Optional token = oAuthLoginPolicyService.issueTokenIfNoActiveSession(jwtUserClaim); + if (token.isEmpty()) { + String blockedUrl = UriComponentsBuilder.fromUriString(redirectUri) + .queryParam("error", "already_logged_in") + .build().toUriString(); + response.sendRedirect(blockedUrl); + return; + } + Token issuedToken = token.get(); // 토큰 붙여서 리다이렉트 String redirectUrl = UriComponentsBuilder.fromUriString(redirectUri) - .queryParam("accessToken", token.getAccessToken()) - .queryParam("refreshToken", token.getRefreshToken()) + .queryParam("accessToken", issuedToken.getAccessToken()) + .queryParam("refreshToken", issuedToken.getRefreshToken()) .build().toUriString(); - System.out.println(redirectUrl); response.sendRedirect(redirectUrl); } } diff --git a/src/main/java/com/gpt/geumpumtabackend/global/oauth/service/OAuthLoginPolicyService.java b/src/main/java/com/gpt/geumpumtabackend/global/oauth/service/OAuthLoginPolicyService.java new file mode 100644 index 0000000..35d9db5 --- /dev/null +++ b/src/main/java/com/gpt/geumpumtabackend/global/oauth/service/OAuthLoginPolicyService.java @@ -0,0 +1,45 @@ +package com.gpt.geumpumtabackend.global.oauth.service; + +import com.gpt.geumpumtabackend.global.jwt.JwtHandler; +import com.gpt.geumpumtabackend.global.jwt.JwtUserClaim; +import com.gpt.geumpumtabackend.global.exception.BusinessException; +import com.gpt.geumpumtabackend.global.exception.ExceptionType; +import com.gpt.geumpumtabackend.token.domain.RefreshToken; +import com.gpt.geumpumtabackend.token.domain.Token; +import com.gpt.geumpumtabackend.token.repository.RefreshTokenRepository; +import com.gpt.geumpumtabackend.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.Optional; + +@Service +@RequiredArgsConstructor +public class OAuthLoginPolicyService { + + private static final ZoneId KST = ZoneId.of("Asia/Seoul"); + + private final UserRepository userRepository; + private final RefreshTokenRepository refreshTokenRepository; + private final JwtHandler jwtHandler; + + @Transactional + public Optional issueTokenIfNoActiveSession(JwtUserClaim jwtUserClaim) { + userRepository.findByIdForUpdate(jwtUserClaim.userId()) + .orElseThrow(() -> new BusinessException(ExceptionType.USER_NOT_FOUND)); + + Optional existingToken = refreshTokenRepository.findByUserId(jwtUserClaim.userId()); + if (existingToken.isPresent()) { + LocalDateTime now = LocalDateTime.now(KST); + if (existingToken.get().getExpiredAt().isAfter(now)) { + return Optional.empty(); + } + refreshTokenRepository.deleteByUserId(jwtUserClaim.userId()); + } + + return Optional.of(jwtHandler.createTokens(jwtUserClaim)); + } +} diff --git a/src/main/java/com/gpt/geumpumtabackend/maintenance/api/MaintenanceApi.java b/src/main/java/com/gpt/geumpumtabackend/maintenance/api/MaintenanceApi.java new file mode 100644 index 0000000..3fa36df --- /dev/null +++ b/src/main/java/com/gpt/geumpumtabackend/maintenance/api/MaintenanceApi.java @@ -0,0 +1,63 @@ +package com.gpt.geumpumtabackend.maintenance.api; + +import com.gpt.geumpumtabackend.global.config.swagger.SwaggerApiFailedResponse; +import com.gpt.geumpumtabackend.global.config.swagger.SwaggerApiResponses; +import com.gpt.geumpumtabackend.global.config.swagger.SwaggerApiSuccessResponse; +import com.gpt.geumpumtabackend.global.exception.ExceptionType; +import com.gpt.geumpumtabackend.global.response.ResponseBody; +import com.gpt.geumpumtabackend.maintenance.dto.request.MaintenanceStatusUpdateRequest; +import com.gpt.geumpumtabackend.maintenance.dto.response.MaintenanceStatusResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.RequestBody; + +@Tag(name = "서비스 상태 API", description = "서비스 점검 상태 조회 및 변경 API") +public interface MaintenanceApi { + + @Operation( + summary = "점검 상태 조회", + description = "현재 서비스 점검 상태와 안내 메시지를 조회합니다." + ) + @ApiResponse(content = @Content(schema = @Schema(implementation = MaintenanceStatusResponse.class))) + @SwaggerApiResponses( + success = @SwaggerApiSuccessResponse( + response = MaintenanceStatusResponse.class, + description = "점검 상태 조회 성공" + ) + ) + @GetMapping("/status") + ResponseEntity> getCurrentStatus(); + + @Operation( + summary = "점검 상태 변경", + description = """ + ADMIN 권한으로 서비스 점검 상태를 변경합니다. + - status: NORMAL 또는 MAINTENANCE + - message: 점검 안내 문구 (선택) + """ + ) + @ApiResponse(content = @Content(schema = @Schema(implementation = MaintenanceStatusResponse.class))) + @SwaggerApiResponses( + success = @SwaggerApiSuccessResponse( + response = MaintenanceStatusResponse.class, + description = "점검 상태 변경 성공" + ), + errors = { + @SwaggerApiFailedResponse(ExceptionType.NEED_AUTHORIZED), + @SwaggerApiFailedResponse(ExceptionType.ACCESS_DENIED) + } + ) + @PatchMapping("/status") + @PreAuthorize("isAuthenticated() and hasRole('ADMIN')") + ResponseEntity> updateStatus( + @RequestBody @Valid MaintenanceStatusUpdateRequest request + ); +} diff --git a/src/main/java/com/gpt/geumpumtabackend/maintenance/controller/MaintenanceController.java b/src/main/java/com/gpt/geumpumtabackend/maintenance/controller/MaintenanceController.java new file mode 100644 index 0000000..a1960a9 --- /dev/null +++ b/src/main/java/com/gpt/geumpumtabackend/maintenance/controller/MaintenanceController.java @@ -0,0 +1,42 @@ +package com.gpt.geumpumtabackend.maintenance.controller; + +import com.gpt.geumpumtabackend.global.response.ResponseBody; +import com.gpt.geumpumtabackend.global.response.ResponseUtil; +import com.gpt.geumpumtabackend.maintenance.api.MaintenanceApi; +import com.gpt.geumpumtabackend.maintenance.dto.request.MaintenanceStatusUpdateRequest; +import com.gpt.geumpumtabackend.maintenance.dto.response.MaintenanceStatusResponse; +import com.gpt.geumpumtabackend.maintenance.service.MaintenanceService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/maintenance") +public class MaintenanceController implements MaintenanceApi { + + private final MaintenanceService maintenanceService; + + @GetMapping("/status") + public ResponseEntity> getCurrentStatus() { + return ResponseEntity.ok(ResponseUtil.createSuccessResponse( + maintenanceService.getCurrentStatus() + )); + } + + @PatchMapping("/status") + @PreAuthorize("isAuthenticated() and hasRole('ADMIN')") + public ResponseEntity> updateStatus( + @RequestBody @Valid MaintenanceStatusUpdateRequest request + ) { + return ResponseEntity.ok(ResponseUtil.createSuccessResponse( + maintenanceService.updateStatus(request) + )); + } +} diff --git a/src/main/java/com/gpt/geumpumtabackend/maintenance/domain/Maintenance.java b/src/main/java/com/gpt/geumpumtabackend/maintenance/domain/Maintenance.java new file mode 100644 index 0000000..62cc9e9 --- /dev/null +++ b/src/main/java/com/gpt/geumpumtabackend/maintenance/domain/Maintenance.java @@ -0,0 +1,44 @@ +package com.gpt.geumpumtabackend.maintenance.domain; + +import com.gpt.geumpumtabackend.global.base.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Id; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Maintenance extends BaseEntity { + + public static final Long DEFAULT_ID = 1L; + + @Id + private Long id; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private ServiceStatus status; + + @Column(length = 255) + private String message; + + private Maintenance(Long id, ServiceStatus status, String message) { + this.id = id; + this.status = status; + this.message = message; + } + + public static Maintenance initialize(ServiceStatus status, String message) { + return new Maintenance(DEFAULT_ID, status, message); + } + + public void update(ServiceStatus status, String message) { + this.status = status; + this.message = message; + } +} diff --git a/src/main/java/com/gpt/geumpumtabackend/maintenance/domain/ServiceStatus.java b/src/main/java/com/gpt/geumpumtabackend/maintenance/domain/ServiceStatus.java new file mode 100644 index 0000000..acf93a3 --- /dev/null +++ b/src/main/java/com/gpt/geumpumtabackend/maintenance/domain/ServiceStatus.java @@ -0,0 +1,15 @@ +package com.gpt.geumpumtabackend.maintenance.domain; + +import lombok.Getter; + +@Getter +public enum ServiceStatus { + NORMAL("정상"), + MAINTENANCE("점검중"); + + private final String status; + + ServiceStatus(String status) { + this.status = status; + } +} diff --git a/src/main/java/com/gpt/geumpumtabackend/maintenance/dto/request/MaintenanceStatusUpdateRequest.java b/src/main/java/com/gpt/geumpumtabackend/maintenance/dto/request/MaintenanceStatusUpdateRequest.java new file mode 100644 index 0000000..fcf4fb1 --- /dev/null +++ b/src/main/java/com/gpt/geumpumtabackend/maintenance/dto/request/MaintenanceStatusUpdateRequest.java @@ -0,0 +1,10 @@ +package com.gpt.geumpumtabackend.maintenance.dto.request; + +import com.gpt.geumpumtabackend.maintenance.domain.ServiceStatus; +import jakarta.validation.constraints.NotNull; + +public record MaintenanceStatusUpdateRequest( + @NotNull ServiceStatus status, + String message +) { +} diff --git a/src/main/java/com/gpt/geumpumtabackend/maintenance/dto/response/MaintenanceStatusResponse.java b/src/main/java/com/gpt/geumpumtabackend/maintenance/dto/response/MaintenanceStatusResponse.java new file mode 100644 index 0000000..f446834 --- /dev/null +++ b/src/main/java/com/gpt/geumpumtabackend/maintenance/dto/response/MaintenanceStatusResponse.java @@ -0,0 +1,16 @@ +package com.gpt.geumpumtabackend.maintenance.dto.response; + +import com.gpt.geumpumtabackend.maintenance.domain.Maintenance; +import com.gpt.geumpumtabackend.maintenance.domain.ServiceStatus; + +public record MaintenanceStatusResponse( + ServiceStatus status, + String message +) { + public static MaintenanceStatusResponse from(Maintenance maintenance) { + return new MaintenanceStatusResponse( + maintenance.getStatus(), + maintenance.getMessage() + ); + } +} diff --git a/src/main/java/com/gpt/geumpumtabackend/maintenance/repository/MaintenanceRepository.java b/src/main/java/com/gpt/geumpumtabackend/maintenance/repository/MaintenanceRepository.java new file mode 100644 index 0000000..568265d --- /dev/null +++ b/src/main/java/com/gpt/geumpumtabackend/maintenance/repository/MaintenanceRepository.java @@ -0,0 +1,7 @@ +package com.gpt.geumpumtabackend.maintenance.repository; + +import com.gpt.geumpumtabackend.maintenance.domain.Maintenance; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface MaintenanceRepository extends JpaRepository { +} diff --git a/src/main/java/com/gpt/geumpumtabackend/maintenance/service/MaintenanceService.java b/src/main/java/com/gpt/geumpumtabackend/maintenance/service/MaintenanceService.java new file mode 100644 index 0000000..fe4c4d7 --- /dev/null +++ b/src/main/java/com/gpt/geumpumtabackend/maintenance/service/MaintenanceService.java @@ -0,0 +1,41 @@ +package com.gpt.geumpumtabackend.maintenance.service; + +import com.gpt.geumpumtabackend.maintenance.domain.Maintenance; +import com.gpt.geumpumtabackend.maintenance.domain.ServiceStatus; +import com.gpt.geumpumtabackend.maintenance.dto.request.MaintenanceStatusUpdateRequest; +import com.gpt.geumpumtabackend.maintenance.dto.response.MaintenanceStatusResponse; +import com.gpt.geumpumtabackend.maintenance.repository.MaintenanceRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class MaintenanceService { + + private final MaintenanceRepository maintenanceRepository; + + @Transactional + public MaintenanceStatusResponse updateStatus(MaintenanceStatusUpdateRequest request) { + Maintenance maintenance = maintenanceRepository.findById(Maintenance.DEFAULT_ID) + .orElseGet(() -> Maintenance.initialize(request.status(), request.message())); + + maintenance.update(request.status(), request.message()); + + return MaintenanceStatusResponse.from(maintenanceRepository.save(maintenance)); + } + + public MaintenanceStatusResponse getCurrentStatus() { + Maintenance maintenance = maintenanceRepository.findById(Maintenance.DEFAULT_ID) + .orElseGet(() -> Maintenance.initialize( + ServiceStatus.NORMAL, + null + )); + return MaintenanceStatusResponse.from(maintenance); + } + + public boolean isMaintenanceInProgress() { + return getCurrentStatus().status() == ServiceStatus.MAINTENANCE; + } +} diff --git a/src/main/java/com/gpt/geumpumtabackend/rank/CLAUDE.md b/src/main/java/com/gpt/geumpumtabackend/rank/CLAUDE.md new file mode 100644 index 0000000..b04ea8c --- /dev/null +++ b/src/main/java/com/gpt/geumpumtabackend/rank/CLAUDE.md @@ -0,0 +1,128 @@ +# Rank Domain CLAUDE.md + +## 개요 + +개인/학과 랭킹 및 시즌 시스템을 담당하는 도메인. 실시간 랭킹 계산, 확정 랭킹 저장, 시즌 전환, 스냅샷 생성을 포함한다. + +## 파일 구조 + +``` +rank/ +├── api/ +│ ├── PersonalRankApi.java # 개인 랭킹 Swagger 문서 +│ ├── DepartmentRankApi.java # 학과 랭킹 Swagger 문서 +│ └── SeasonRankApi.java # 시즌 랭킹 Swagger 문서 +├── controller/ +│ ├── PersonalRankController.java # /api/v1/rank/personal/* +│ ├── DepartmentRankController.java # /api/v1/rank/department/* +│ └── SeasonRankController.java # /api/v1/rank/season/* +├── domain/ +│ ├── UserRanking.java # 개인 랭킹 엔티티 +│ ├── DepartmentRanking.java # 학과 랭킹 엔티티 +│ ├── Season.java # 시즌 엔티티 (기간 검증 포함) +│ ├── SeasonRankingSnapshot.java # 시즌 종료 시 확정 랭킹 스냅샷 +│ ├── RankType.java # enum: OVERALL, DEPARTMENT +│ ├── RankingType.java # enum: DAILY, WEEKLY, MONTHLY +│ ├── SeasonType.java # enum: SPRING_SEMESTER, SUMMER_VACATION, FALL_SEMESTER, WINTER_VACATION +│ └── SeasonStatus.java # enum: ACTIVE, ENDED +├── dto/ +│ ├── PersonalRankingTemp.java # JPQL 프로젝션용 DTO (userId, nickname, department, totalMillis, ranking) +│ ├── DepartmentRankingTemp.java # 학과 집계용 DTO +│ └── response/ +│ ├── PersonalRankingResponse.java # topRanks + myRanking +│ ├── PersonalRankingEntryResponse.java # 개인 랭킹 항목 +│ ├── DepartmentRankingResponse.java # topRanks + myDepartmentRanking +│ ├── DepartmentRankingEntryResponse.java # 학과 랭킹 항목 +│ └── SeasonRankingResponse.java # 시즌 랭킹 (seasonId, seasonName, dates, rankings) +├── repository/ +│ ├── UserRankingRepository.java # 개인 랭킹 JPQL 쿼리 +│ ├── DepartmentRankingRepository.java # 학과 랭킹 Native Query (CTE 사용) +│ ├── SeasonRepository.java # 시즌 조회 (날짜 범위) +│ └── SeasonRankingSnapshotRepository.java # 스냅샷 조회/존재 확인 +├── service/ +│ ├── PersonalRankService.java # 개인 랭킹 조회 (실시간/확정) +│ ├── DepartmentRankService.java # 학과 랭킹 조회 +│ ├── SeasonRankService.java # 시즌 랭킹 계산 (월간+일간+실시간 병합) +│ ├── SeasonService.java # 시즌 생명주기 관리 (@Cacheable) +│ ├── SeasonSnapshotService.java # 스냅샷 생성 (@Retryable, 3회, 5초 backoff) +│ └── SeasonSnapshotBatchService.java # JDBC 배치 인서트 (2000건 청크) +└── scheduler/ + ├── RankingSchedulerService.java # 일간/주간/월간 랭킹 스케줄러 + └── SeasonTransitionScheduler.java # 시즌 전환 스케줄러 +``` + +## 핵심 개념 + +### 이중 랭킹 구조 +- **실시간 랭킹**: `StudySessionRepository`에서 직접 계산 (현재 기간) +- **확정 랭킹**: 기간 종료 후 `UserRanking`/`DepartmentRanking`에 저장 (과거 기간) + +컨트롤러에서 `date` 파라미터 유무로 분기: +- `date` 없음 → 현재 기간 실시간 랭킹 +- `date` 있음 → 해당 날짜의 확정 랭킹 + +### 시즌 시스템 +4개 시즌이 순환: +| SeasonType | 기간 | +|---|---| +| SPRING_SEMESTER | 3/1 ~ 6/30 | +| SUMMER_VACATION | 7/1 ~ 8/31 | +| FALL_SEMESTER | 9/1 ~ 12/31 | +| WINTER_VACATION | 1/1 ~ 2/28(29) | + +시즌 랭킹 = 확정 월간 합산 + 현재 월 일간 합산 + 오늘 실시간 데이터를 `mergeAndRank()`로 병합. + +### 학과 랭킹 계산 +- 학과별 상위 30명의 공부 시간을 합산 +- Native Query + CTE로 25개 학과 처리 +- 공부 시간 0인 학과는 topRanks에서 제외하되, 본인 학과는 0이어도 표시 + +### Fallback 로직 +랭킹에 포함되지 않은 사용자: `rank = listSize + 1`, `totalMillis = 0` + +## 스케줄러 실행 시점 + +| 작업 | Cron | 설명 | +|------|------|------| +| 일간 랭킹 계산 | `5 0 0 * * *` | 매일 00:00:05 | +| 주간 랭킹 계산 | `0 1 0 ? * MON` | 매주 월요일 00:01 | +| 월간 랭킹 계산 | `0 2 0 1 * ?` | 매월 1일 00:02 | +| 시즌 전환 확인 | `0 5 0 * * *` | 매일 00:05 | + +## API 엔드포인트 + +### 개인 랭킹 (`/api/v1/rank/personal`) +- `GET /daily?date=` — 일간 개인 랭킹 +- `GET /weekly?date=` — 주간 개인 랭킹 (월요일 기준) +- `GET /monthly?date=` — 월간 개인 랭킹 (1일 기준) + +### 학과 랭킹 (`/api/v1/rank/department`) +- `GET /daily?date=` — 일간 학과 랭킹 +- `GET /weekly?date=` — 주간 학과 랭킹 +- `GET /monthly?date=` — 월간 학과 랭킹 + +### 시즌 랭킹 (`/api/v1/rank/season`) +- `GET /current` — 현재 시즌 전체 랭킹 +- `GET /current/department?department=` — 현재 시즌 학과별 랭킹 +- `GET /{seasonId}` — 종료된 시즌 전체 랭킹 +- `GET /{seasonId}/department?department=` — 종료된 시즌 학과별 랭킹 + +## 테스트 + +### Unit Tests +- `PersonalRankServiceTest` — 실시간/확정 랭킹, fallback, 동점 처리, 빈 리스트 +- `DepartmentRankServiceTest` — 0시간 학과 필터링, 본인 학과 포함, 학과명 변환 +- `SeasonRankServiceTest` — 데이터 병합, 동점 처리, 스냅샷 조회, 예외(SEASON_NOT_FOUND, SEASON_NOT_ENDED) +- `SeasonServiceTest` — 시즌 생성/전환, 4개 시즌 순환, 윤년 처리, 날짜 검증 +- `SeasonSnapshotServiceRetryTest` — 재시도 메커니즘, 중복 방지 + +### Integration Tests +- `DepartmentRankControllerIntegrationTest` — E2E API 테스트, 인증, 데이터 격리 + +## 개발 시 주의사항 + +1. 랭킹 쿼리가 복잡하므로 `StudySessionRepository`의 Native Query도 함께 확인할 것 +2. 시즌 전환 시 `activeSeason` 캐시가 evict됨 — 캐시 관련 코드 수정 시 주의 +3. `SeasonSnapshotBatchService`는 JDBC 직접 사용 — JPA와 별도 트랜잭션 +4. `DepartmentRankingRepository`의 Native Query는 CTE 사용 — MySQL 8+ 필수 +5. `PersonalRankingTemp`에 Department enum/String 두 가지 생성자 존재 — JPQL 프로젝션 방식에 따라 다름 diff --git a/src/main/java/com/gpt/geumpumtabackend/rank/api/SeasonRankApi.java b/src/main/java/com/gpt/geumpumtabackend/rank/api/SeasonRankApi.java new file mode 100644 index 0000000..0368605 --- /dev/null +++ b/src/main/java/com/gpt/geumpumtabackend/rank/api/SeasonRankApi.java @@ -0,0 +1,161 @@ +package com.gpt.geumpumtabackend.rank.api; + +import com.gpt.geumpumtabackend.global.aop.AssignUserId; +import com.gpt.geumpumtabackend.global.config.swagger.SwaggerApiFailedResponse; +import com.gpt.geumpumtabackend.global.config.swagger.SwaggerApiResponses; +import com.gpt.geumpumtabackend.global.config.swagger.SwaggerApiSuccessResponse; +import com.gpt.geumpumtabackend.global.exception.ExceptionType; +import com.gpt.geumpumtabackend.global.response.ResponseBody; +import com.gpt.geumpumtabackend.rank.dto.response.SeasonDepartmentRankingResponse; +import com.gpt.geumpumtabackend.rank.dto.response.SeasonRankingResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; + +@Tag(name = "시즌 랭킹 API", description = """ + 학기별 시즌 랭킹을 제공합니다. + + 📅 **시즌 구성:** + - 봄학기: 3월 1일 ~ 6월 30일 + - 여름방학: 7월 1일 ~ 8월 31일 + - 가을학기: 9월 1일 ~ 12월 31일 + - 겨울방학: 1월 1일 ~ 2월 말일 + + 🏆 **시즌 랭킹 특징:** + - 현재 활성 시즌: 실시간 랭킹 (월간+일간+오늘 누적) + - 종료된 시즌: 스냅샷 기반 확정 랭킹 + - 전체 랭킹 및 학과별 랭킹 지원 + """) +public interface SeasonRankApi { + + @Operation( + summary = "현재 시즌 전체 랭킹 조회", + description = """ + 현재 활성 중인 시즌의 전체 사용자 랭킹을 조회합니다. + + 📊 **랭킹 계산:** + - 완료된 월간 랭킹 합산 + - 현재 진행 중인 월의 일간 랭킹 합산 + - 오늘 실시간 학습 세션 합산 + """ + ) + @ApiResponse(content = @Content(schema = @Schema(implementation = SeasonRankingResponse.class))) + @SwaggerApiResponses( + success = @SwaggerApiSuccessResponse( + response = SeasonRankingResponse.class, + description = "현재 시즌 전체 랭킹 조회 성공"), + errors = { + @SwaggerApiFailedResponse(ExceptionType.NEED_AUTHORIZED), + @SwaggerApiFailedResponse(ExceptionType.SEASON_NOT_FOUND) + } + ) + @GetMapping("/current") + @AssignUserId + @PreAuthorize("isAuthenticated() and hasRole('USER')") + ResponseEntity> getCurrentSeasonRanking( + @Parameter(hidden = true) Long userId + ); + + @Operation( + summary = "현재 시즌 학과별 집계 랭킹 조회", + description = """ + 현재 활성 중인 시즌의 학과별 집계 랭킹을 조회합니다. + + 📊 **랭킹 계산:** + - 학과별 상위 30명의 공부 시간 합산 + - 완료된 월간 학과 랭킹 합산 + - 현재 진행 중인 월의 일간 학과 랭킹 합산 + - 오늘 실시간 학과 랭킹 합산 + """ + ) + @ApiResponse(content = @Content(schema = @Schema(implementation = SeasonDepartmentRankingResponse.class))) + @SwaggerApiResponses( + success = @SwaggerApiSuccessResponse( + response = SeasonDepartmentRankingResponse.class, + description = "현재 시즌 학과별 집계 랭킹 조회 성공"), + errors = { + @SwaggerApiFailedResponse(ExceptionType.NEED_AUTHORIZED), + @SwaggerApiFailedResponse(ExceptionType.SEASON_NOT_FOUND) + } + ) + @GetMapping("/current/department") + @AssignUserId + @PreAuthorize("isAuthenticated() and hasRole('USER')") + ResponseEntity> getCurrentSeasonDepartmentRanking( + @Parameter(hidden = true) Long userId + ); + + @Operation( + summary = "종료된 시즌 전체 랭킹 조회", + description = """ + 종료된 시즌의 전체 사용자 최종 랭킹을 조회합니다. + + 💾 **스냅샷 기반:** + - 시즌 종료 시점에 생성된 확정 랭킹 스냅샷 + - 시즌 종료 후 변경되지 않는 영구 기록 + """ + ) + @ApiResponse(content = @Content(schema = @Schema(implementation = SeasonRankingResponse.class))) + @SwaggerApiResponses( + success = @SwaggerApiSuccessResponse( + response = SeasonRankingResponse.class, + description = "종료된 시즌 전체 랭킹 조회 성공"), + errors = { + @SwaggerApiFailedResponse(ExceptionType.NEED_AUTHORIZED), + @SwaggerApiFailedResponse(ExceptionType.SEASON_NOT_FOUND), + @SwaggerApiFailedResponse(ExceptionType.SEASON_NOT_ENDED) + } + ) + @GetMapping("/{seasonId}") + @AssignUserId + @PreAuthorize("isAuthenticated() and hasRole('USER')") + ResponseEntity> getEndedSeasonRanking( + @Parameter(hidden = true) Long userId, + @Parameter( + description = "시즌 ID", + example = "1" + ) + @PathVariable Long seasonId + ); + + @Operation( + summary = "종료된 시즌 학과별 집계 랭킹 조회", + description = """ + 종료된 시즌의 학과별 집계 최종 랭킹을 조회합니다. + + 💾 **스냅샷 기반:** + - 시즌 종료 시점에 생성된 학과별 확정 랭킹 스냅샷 + - 학과별 상위 30명의 공부 시간 합산 + - 시즌 종료 후 변경되지 않는 영구 기록 + """ + ) + @ApiResponse(content = @Content(schema = @Schema(implementation = SeasonDepartmentRankingResponse.class))) + @SwaggerApiResponses( + success = @SwaggerApiSuccessResponse( + response = SeasonDepartmentRankingResponse.class, + description = "종료된 시즌 학과별 집계 랭킹 조회 성공"), + errors = { + @SwaggerApiFailedResponse(ExceptionType.NEED_AUTHORIZED), + @SwaggerApiFailedResponse(ExceptionType.SEASON_NOT_FOUND), + @SwaggerApiFailedResponse(ExceptionType.SEASON_NOT_ENDED) + } + ) + @GetMapping("/{seasonId}/department") + @AssignUserId + @PreAuthorize("isAuthenticated() and hasRole('USER')") + ResponseEntity> getEndedSeasonDepartmentRanking( + @Parameter(hidden = true) Long userId, + @Parameter( + description = "시즌 ID", + example = "1" + ) + @PathVariable Long seasonId + ); +} diff --git a/src/main/java/com/gpt/geumpumtabackend/rank/controller/DepartmentRankController.java b/src/main/java/com/gpt/geumpumtabackend/rank/controller/DepartmentRankController.java index 31ee7e1..428bb25 100644 --- a/src/main/java/com/gpt/geumpumtabackend/rank/controller/DepartmentRankController.java +++ b/src/main/java/com/gpt/geumpumtabackend/rank/controller/DepartmentRankController.java @@ -7,6 +7,7 @@ import com.gpt.geumpumtabackend.rank.dto.response.DepartmentRankingResponse; import com.gpt.geumpumtabackend.rank.service.DepartmentRankService; import lombok.RequiredArgsConstructor; +import org.springframework.format.annotation.DateTimeFormat; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.GetMapping; @@ -29,7 +30,7 @@ public class DepartmentRankController implements DepartmentRankApi { @GetMapping("/daily") @PreAuthorize("isAuthenticated() AND hasRole('USER')") @AssignUserId - public ResponseEntity> getDailyRanking(Long userId, @RequestParam(required = false) LocalDateTime date){ + public ResponseEntity> getDailyRanking(Long userId, @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime date){ DepartmentRankingResponse response; if (date == null) { @@ -49,7 +50,7 @@ public ResponseEntity> getDailyRanking(L @GetMapping("/weekly") @PreAuthorize("isAuthenticated() AND hasRole('USER')") @AssignUserId - public ResponseEntity> getWeeklyRanking(Long userId, @RequestParam(required = false) LocalDateTime date){ + public ResponseEntity> getWeeklyRanking(Long userId, @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime date){ DepartmentRankingResponse response; if (date == null) { @@ -68,7 +69,7 @@ public ResponseEntity> getWeeklyRanking( @GetMapping("/monthly") @PreAuthorize("isAuthenticated() AND hasRole('USER')") @AssignUserId - public ResponseEntity> getMonthlyRanking(Long userId, @RequestParam(required = false) LocalDateTime date){ + public ResponseEntity> getMonthlyRanking(Long userId, @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime date){ DepartmentRankingResponse response; if (date == null) { diff --git a/src/main/java/com/gpt/geumpumtabackend/rank/controller/SeasonRankController.java b/src/main/java/com/gpt/geumpumtabackend/rank/controller/SeasonRankController.java new file mode 100644 index 0000000..d492201 --- /dev/null +++ b/src/main/java/com/gpt/geumpumtabackend/rank/controller/SeasonRankController.java @@ -0,0 +1,61 @@ +package com.gpt.geumpumtabackend.rank.controller; + +import com.gpt.geumpumtabackend.global.aop.AssignUserId; +import com.gpt.geumpumtabackend.global.response.ResponseBody; +import com.gpt.geumpumtabackend.global.response.ResponseUtil; +import com.gpt.geumpumtabackend.rank.api.SeasonRankApi; +import com.gpt.geumpumtabackend.rank.dto.response.SeasonDepartmentRankingResponse; +import com.gpt.geumpumtabackend.rank.dto.response.SeasonRankingResponse; +import com.gpt.geumpumtabackend.rank.service.SeasonRankService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/v1/rank/season") +@RequiredArgsConstructor +public class SeasonRankController implements SeasonRankApi { + + private final SeasonRankService seasonRankService; + + @GetMapping("/current") + @PreAuthorize("isAuthenticated() AND hasRole('USER')") + @AssignUserId + public ResponseEntity> getCurrentSeasonRanking(Long userId) { + SeasonRankingResponse response = seasonRankService.getCurrentSeasonRanking(); + return ResponseEntity.ok(ResponseUtil.createSuccessResponse(response)); + } + + @GetMapping("/current/department") + @PreAuthorize("isAuthenticated() AND hasRole('USER')") + @AssignUserId + public ResponseEntity> getCurrentSeasonDepartmentRanking( + Long userId + ) { + SeasonDepartmentRankingResponse response = seasonRankService.getCurrentSeasonDepartmentRanking(userId); + return ResponseEntity.ok(ResponseUtil.createSuccessResponse(response)); + } + + @GetMapping("/{seasonId}") + @PreAuthorize("isAuthenticated() AND hasRole('USER')") + @AssignUserId + public ResponseEntity> getEndedSeasonRanking( + Long userId, + @PathVariable Long seasonId + ) { + SeasonRankingResponse response = seasonRankService.getEndedSeasonRanking(seasonId); + return ResponseEntity.ok(ResponseUtil.createSuccessResponse(response)); + } + + @GetMapping("/{seasonId}/department") + @PreAuthorize("isAuthenticated() AND hasRole('USER')") + @AssignUserId + public ResponseEntity> getEndedSeasonDepartmentRanking( + Long userId, + @PathVariable Long seasonId + ) { + SeasonDepartmentRankingResponse response = seasonRankService.getEndedSeasonDepartmentRanking(seasonId, userId); + return ResponseEntity.ok(ResponseUtil.createSuccessResponse(response)); + } +} diff --git a/src/main/java/com/gpt/geumpumtabackend/rank/domain/RankType.java b/src/main/java/com/gpt/geumpumtabackend/rank/domain/RankType.java new file mode 100644 index 0000000..ca1fa69 --- /dev/null +++ b/src/main/java/com/gpt/geumpumtabackend/rank/domain/RankType.java @@ -0,0 +1,17 @@ +package com.gpt.geumpumtabackend.rank.domain; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + + +@Getter +@RequiredArgsConstructor +public enum RankType { + + + OVERALL("전체"), + + DEPARTMENT("학과별"); + + private final String displayName; +} diff --git a/src/main/java/com/gpt/geumpumtabackend/rank/domain/Season.java b/src/main/java/com/gpt/geumpumtabackend/rank/domain/Season.java new file mode 100644 index 0000000..9581daf --- /dev/null +++ b/src/main/java/com/gpt/geumpumtabackend/rank/domain/Season.java @@ -0,0 +1,67 @@ +package com.gpt.geumpumtabackend.rank.domain; + +import com.gpt.geumpumtabackend.global.base.BaseEntity; +import com.gpt.geumpumtabackend.global.exception.BusinessException; +import com.gpt.geumpumtabackend.global.exception.ExceptionType; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; + + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Season extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, length = 50) + private String name; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 30) + private SeasonType seasonType; + + @Column(nullable = false, name = "start_date") + private LocalDate startDate; + + @Column(nullable = false, name = "end_date") + private LocalDate endDate; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private SeasonStatus status; + + @Builder + public Season(String name, SeasonType seasonType, LocalDate startDate, + LocalDate endDate, SeasonStatus status) { + validateDates(startDate, endDate); + this.name = name; + this.seasonType = seasonType; + this.startDate = startDate; + this.endDate = endDate; + this.status = status; + } + + public void end() { + if (this.status != SeasonStatus.ACTIVE) { + throw new BusinessException(ExceptionType.SEASON_ALREADY_ENDED); + } + this.status = SeasonStatus.ENDED; + } + + private void validateDates(LocalDate start, LocalDate end) { + if (end == null || start == null) { + throw new BusinessException(ExceptionType.SEASON_INVALID_DATE_RANGE); + } + if (end.isBefore(start) || end.isEqual(start)) { + throw new BusinessException(ExceptionType.SEASON_INVALID_DATE_RANGE); + } + } +} diff --git a/src/main/java/com/gpt/geumpumtabackend/rank/domain/SeasonRankingSnapshot.java b/src/main/java/com/gpt/geumpumtabackend/rank/domain/SeasonRankingSnapshot.java new file mode 100644 index 0000000..2503553 --- /dev/null +++ b/src/main/java/com/gpt/geumpumtabackend/rank/domain/SeasonRankingSnapshot.java @@ -0,0 +1,59 @@ +package com.gpt.geumpumtabackend.rank.domain; + +import com.gpt.geumpumtabackend.global.base.BaseEntity; +import com.gpt.geumpumtabackend.user.domain.Department; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class SeasonRankingSnapshot extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + + @Column(nullable = false, name = "season_id") + private Long seasonId; + + @Column(nullable = false, name = "user_id") + private Long userId; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 30, name = "rank_type") + private RankType rankType; + + @Column(nullable = false, name = "final_rank") + private Integer finalRank; + + @Column(nullable = false, name = "final_total_millis") + private Long finalTotalMillis; + + @Enumerated(EnumType.STRING) + @Column(length = 50) + private Department department; + + @Column(nullable = false, name = "snapshot_at") + private LocalDateTime snapshotAt; + + @Builder + public SeasonRankingSnapshot(Long seasonId, Long userId, RankType rankType, + Integer finalRank, Long finalTotalMillis, + Department department, LocalDateTime snapshotAt) { + this.seasonId = seasonId; + this.userId = userId; + this.rankType = rankType; + this.finalRank = finalRank; + this.finalTotalMillis = finalTotalMillis; + this.department = department; + this.snapshotAt = snapshotAt; + } +} diff --git a/src/main/java/com/gpt/geumpumtabackend/rank/domain/SeasonStatus.java b/src/main/java/com/gpt/geumpumtabackend/rank/domain/SeasonStatus.java new file mode 100644 index 0000000..e78bbe2 --- /dev/null +++ b/src/main/java/com/gpt/geumpumtabackend/rank/domain/SeasonStatus.java @@ -0,0 +1,16 @@ +package com.gpt.geumpumtabackend.rank.domain; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + + +@Getter +@RequiredArgsConstructor +public enum SeasonStatus { + + + ACTIVE("진행중"), + ENDED("종료"); + + private final String displayName; +} diff --git a/src/main/java/com/gpt/geumpumtabackend/rank/domain/SeasonType.java b/src/main/java/com/gpt/geumpumtabackend/rank/domain/SeasonType.java new file mode 100644 index 0000000..f3c2925 --- /dev/null +++ b/src/main/java/com/gpt/geumpumtabackend/rank/domain/SeasonType.java @@ -0,0 +1,24 @@ +package com.gpt.geumpumtabackend.rank.domain; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +/** + * 시즌 타입 + * - 학기와 방학을 구분하여 4개 시즌 운영 + */ +@Getter +@RequiredArgsConstructor +public enum SeasonType { + + + SPRING_SEMESTER("1학기"), + + SUMMER_VACATION("여름방학"), + + FALL_SEMESTER("2학기"), + + WINTER_VACATION("겨울방학"); + + private final String displayName; +} diff --git a/src/main/java/com/gpt/geumpumtabackend/rank/dto/PersonalRankingTemp.java b/src/main/java/com/gpt/geumpumtabackend/rank/dto/PersonalRankingTemp.java index c9dc22b..3a960db 100644 --- a/src/main/java/com/gpt/geumpumtabackend/rank/dto/PersonalRankingTemp.java +++ b/src/main/java/com/gpt/geumpumtabackend/rank/dto/PersonalRankingTemp.java @@ -12,16 +12,19 @@ public class PersonalRankingTemp { private String imageUrl; private String department; - // 기본 생성자 - SQL Native Query 결과 순서에 맞춤 - public PersonalRankingTemp(Long userId, String nickname, String imageUrl, String department, Long totalMillis, Long ranking) { + public PersonalRankingTemp(Long userId, String nickname, String imageUrl, Object department, Long totalMillis, Long ranking) { this.userId = userId; this.nickname = nickname; + this.imageUrl = imageUrl; + if (department instanceof Department dept) { + this.department = dept.name(); + } else if (department != null) { + this.department = department.toString(); + } this.totalMillis = totalMillis; this.ranking = ranking; - this.imageUrl = imageUrl; - this.department = department; // 원본값 그대로 저장 } - + // Department enum 값을 한국어로 변환하는 메서드 public String getDepartmentKoreanName() { if (department == null) return null; diff --git a/src/main/java/com/gpt/geumpumtabackend/rank/dto/response/SeasonDepartmentRankingResponse.java b/src/main/java/com/gpt/geumpumtabackend/rank/dto/response/SeasonDepartmentRankingResponse.java new file mode 100644 index 0000000..030575e --- /dev/null +++ b/src/main/java/com/gpt/geumpumtabackend/rank/dto/response/SeasonDepartmentRankingResponse.java @@ -0,0 +1,34 @@ +package com.gpt.geumpumtabackend.rank.dto.response; + +import com.gpt.geumpumtabackend.rank.domain.Season; +import com.gpt.geumpumtabackend.rank.dto.DepartmentRankingTemp; + +import java.time.LocalDate; +import java.util.List; +import java.util.stream.Collectors; + + +public record SeasonDepartmentRankingResponse( + Long seasonId, + String seasonName, + LocalDate startDate, + LocalDate endDate, + List topRanks, + DepartmentRankingEntryResponse myDepartmentRanking +) { + + public static SeasonDepartmentRankingResponse of( + Season season, + List topRanks, + DepartmentRankingEntryResponse myDepartmentRanking + ) { + return new SeasonDepartmentRankingResponse( + season.getId(), + season.getName(), + season.getStartDate(), + season.getEndDate(), + topRanks, + myDepartmentRanking + ); + } +} diff --git a/src/main/java/com/gpt/geumpumtabackend/rank/dto/response/SeasonRankingResponse.java b/src/main/java/com/gpt/geumpumtabackend/rank/dto/response/SeasonRankingResponse.java new file mode 100644 index 0000000..1774a7d --- /dev/null +++ b/src/main/java/com/gpt/geumpumtabackend/rank/dto/response/SeasonRankingResponse.java @@ -0,0 +1,33 @@ +package com.gpt.geumpumtabackend.rank.dto.response; + +import com.gpt.geumpumtabackend.rank.domain.Season; +import com.gpt.geumpumtabackend.rank.dto.PersonalRankingTemp; + +import java.time.LocalDate; +import java.util.List; +import java.util.stream.Collectors; + + +public record SeasonRankingResponse( + Long seasonId, + String seasonName, + LocalDate startDate, + LocalDate endDate, + List rankings +) { + + public static SeasonRankingResponse of(Season season, List rankings) { + List safeRankings = (rankings == null) ? List.of() : rankings; + List rankingEntries = safeRankings.stream() + .map(PersonalRankingEntryResponse::of) + .collect(Collectors.toList()); + + return new SeasonRankingResponse( + season.getId(), + season.getName(), + season.getStartDate(), + season.getEndDate(), + rankingEntries + ); + } +} diff --git a/src/main/java/com/gpt/geumpumtabackend/rank/repository/DepartmentRankingRepository.java b/src/main/java/com/gpt/geumpumtabackend/rank/repository/DepartmentRankingRepository.java index d8c0a75..1fbc6e4 100644 --- a/src/main/java/com/gpt/geumpumtabackend/rank/repository/DepartmentRankingRepository.java +++ b/src/main/java/com/gpt/geumpumtabackend/rank/repository/DepartmentRankingRepository.java @@ -88,4 +88,36 @@ AND DATE(dr.calculated_at) = DATE(:period) ORDER BY COALESCE(rr.totalMillis, dr.total_millis, 0) DESC """, nativeQuery = true) List getFinishedDepartmentRanking(@Param("period") LocalDateTime period, @Param("rankingType") String rankingType); + + + @Query(value = """ + SELECT dr.department as department, + CAST(SUM(dr.total_millis) AS SIGNED) as totalMillis, + 0 as ranking + FROM department_ranking dr + WHERE dr.ranking_type = 'MONTHLY' + AND dr.calculated_at >= :seasonStart + AND dr.calculated_at < :monthStart + GROUP BY dr.department + """, nativeQuery = true) + List calculateSeasonFromMonthlyDepartmentRankings( + @Param("seasonStart") LocalDateTime seasonStart, + @Param("monthStart") LocalDateTime monthStart + ); + + + @Query(value = """ + SELECT dr.department as department, + CAST(SUM(dr.total_millis) AS SIGNED) as totalMillis, + 0 as ranking + FROM department_ranking dr + WHERE dr.ranking_type = 'DAILY' + AND dr.calculated_at >= :monthStart + AND dr.calculated_at < :today + GROUP BY dr.department + """, nativeQuery = true) + List calculateCurrentMonthFromDailyDepartmentRankings( + @Param("monthStart") LocalDateTime monthStart, + @Param("today") LocalDateTime today + ); } diff --git a/src/main/java/com/gpt/geumpumtabackend/rank/repository/SeasonRankingSnapshotRepository.java b/src/main/java/com/gpt/geumpumtabackend/rank/repository/SeasonRankingSnapshotRepository.java new file mode 100644 index 0000000..6cd8101 --- /dev/null +++ b/src/main/java/com/gpt/geumpumtabackend/rank/repository/SeasonRankingSnapshotRepository.java @@ -0,0 +1,48 @@ +package com.gpt.geumpumtabackend.rank.repository; + +import com.gpt.geumpumtabackend.rank.domain.RankType; +import com.gpt.geumpumtabackend.rank.domain.SeasonRankingSnapshot; +import com.gpt.geumpumtabackend.rank.dto.DepartmentRankingTemp; +import com.gpt.geumpumtabackend.user.domain.Department; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Set; + +public interface SeasonRankingSnapshotRepository extends JpaRepository { + + + boolean existsBySeasonId(Long seasonId); + + + List findBySeasonIdAndRankType(Long seasonId, RankType rankType); + + + List findBySeasonIdAndRankTypeAndDepartment( + Long seasonId, RankType rankType, Department department + ); + + int countBySeasonId(Long seasonId); + + List findBySeasonIdAndRankTypeAndFinalRankIn( + Long seasonId, + RankType rankType, + Set finalRanks + ); + + + @Query(value = """ + SELECT s.department as department, + CAST(SUM(s.final_total_millis) AS SIGNED) as totalMillis, + 0 as ranking + FROM season_ranking_snapshot s + WHERE s.season_id = :seasonId + AND s.rank_type = 'DEPARTMENT' + AND s.final_rank <= 30 + GROUP BY s.department + ORDER BY SUM(s.final_total_millis) DESC + """, nativeQuery = true) + List aggregateDepartmentRankingBySeasonId(@Param("seasonId") Long seasonId); +} diff --git a/src/main/java/com/gpt/geumpumtabackend/rank/repository/SeasonRepository.java b/src/main/java/com/gpt/geumpumtabackend/rank/repository/SeasonRepository.java new file mode 100644 index 0000000..d942d50 --- /dev/null +++ b/src/main/java/com/gpt/geumpumtabackend/rank/repository/SeasonRepository.java @@ -0,0 +1,20 @@ +package com.gpt.geumpumtabackend.rank.repository; + +import com.gpt.geumpumtabackend.rank.domain.Season; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.LocalDate; +import java.util.Optional; + +public interface SeasonRepository extends JpaRepository { + + + @Query(value = """ + SELECT * + FROM season + WHERE start_date <= :date AND end_date >= :date + """, nativeQuery = true) + Optional findByDateRange(@Param("date") LocalDate date); +} diff --git a/src/main/java/com/gpt/geumpumtabackend/rank/repository/UserRankingRepository.java b/src/main/java/com/gpt/geumpumtabackend/rank/repository/UserRankingRepository.java index 423bf36..7b47ec1 100644 --- a/src/main/java/com/gpt/geumpumtabackend/rank/repository/UserRankingRepository.java +++ b/src/main/java/com/gpt/geumpumtabackend/rank/repository/UserRankingRepository.java @@ -3,6 +3,7 @@ import com.gpt.geumpumtabackend.rank.domain.RankingType; import com.gpt.geumpumtabackend.rank.domain.UserRanking; import com.gpt.geumpumtabackend.rank.dto.PersonalRankingTemp; +import com.gpt.geumpumtabackend.user.domain.Department; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -22,14 +23,105 @@ public interface UserRankingRepository extends JpaRepository ur.user.id, ur.user.nickname, ur.user.picture, - CAST(ur.user.department AS string), + ur.user.department, ur.totalMillis, ur.rank) - FROM UserRanking ur + FROM UserRanking ur WHERE DATE(ur.calculatedAt) = DATE(:date) AND ur.rankingType = :rankingType ORDER BY ur.rank ASC """) List getFinishedPersonalRanking(@Param("date") LocalDateTime period, @Param("rankingType") RankingType rankingType); + + @Query(""" + SELECT new com.gpt.geumpumtabackend.rank.dto.PersonalRankingTemp( + ur.user.id, + ur.user.nickname, + ur.user.picture, + ur.user.department, + SUM(ur.totalMillis), + 0L + ) + FROM UserRanking ur + WHERE ur.rankingType = 'MONTHLY' + AND ur.calculatedAt >= :seasonStart + AND ur.calculatedAt < :currentMonthStart + GROUP BY ur.user.id, ur.user.nickname, ur.user.picture, ur.user.department + """) + List calculateSeasonRankingFromMonthlyRankings( + @Param("seasonStart") LocalDateTime seasonStart, + @Param("currentMonthStart") LocalDateTime currentMonthStart + ); + + + @Query(""" + SELECT new com.gpt.geumpumtabackend.rank.dto.PersonalRankingTemp( + ur.user.id, + ur.user.nickname, + ur.user.picture, + ur.user.department, + SUM(ur.totalMillis), + 0L + ) + FROM UserRanking ur + WHERE ur.rankingType = 'DAILY' + AND ur.calculatedAt >= :currentMonthStart + AND ur.calculatedAt < :today + GROUP BY ur.user.id, ur.user.nickname, ur.user.picture, ur.user.department + """) + List calculateCurrentMonthRankingFromDailyRankings( + @Param("currentMonthStart") LocalDateTime currentMonthStart, + @Param("today") LocalDateTime today + ); + + /** + * 학과별 - 완료된 월 월간 랭킹 합산 + */ + @Query(""" + SELECT new com.gpt.geumpumtabackend.rank.dto.PersonalRankingTemp( + ur.user.id, + ur.user.nickname, + ur.user.picture, + ur.user.department, + SUM(ur.totalMillis), + 0L + ) + FROM UserRanking ur + WHERE ur.rankingType = 'MONTHLY' + AND ur.calculatedAt >= :seasonStart + AND ur.calculatedAt < :currentMonthStart + AND ur.user.department = :department + GROUP BY ur.user.id, ur.user.nickname, ur.user.picture, ur.user.department + """) + List calculateSeasonDepartmentRankingFromMonthlyRankings( + @Param("seasonStart") LocalDateTime seasonStart, + @Param("currentMonthStart") LocalDateTime currentMonthStart, + @Param("department") Department department + ); + + /** + * 학과별 - 현재 월 일간 랭킹 합산 + */ + @Query(""" + SELECT new com.gpt.geumpumtabackend.rank.dto.PersonalRankingTemp( + ur.user.id, + ur.user.nickname, + ur.user.picture, + ur.user.department, + SUM(ur.totalMillis), + 0L + ) + FROM UserRanking ur + WHERE ur.rankingType = 'DAILY' + AND ur.calculatedAt >= :currentMonthStart + AND ur.calculatedAt < :today + AND ur.user.department = :department + GROUP BY ur.user.id, ur.user.nickname, ur.user.picture, ur.user.department + """) + List calculateCurrentMonthDepartmentRankingFromDailyRankings( + @Param("currentMonthStart") LocalDateTime currentMonthStart, + @Param("today") LocalDateTime today, + @Param("department") Department department + ); } diff --git a/src/main/java/com/gpt/geumpumtabackend/rank/service/RankingSchedulerService.java b/src/main/java/com/gpt/geumpumtabackend/rank/scheduler/RankingSchedulerService.java similarity index 89% rename from src/main/java/com/gpt/geumpumtabackend/rank/service/RankingSchedulerService.java rename to src/main/java/com/gpt/geumpumtabackend/rank/scheduler/RankingSchedulerService.java index 329dcdd..c4c39f9 100644 --- a/src/main/java/com/gpt/geumpumtabackend/rank/service/RankingSchedulerService.java +++ b/src/main/java/com/gpt/geumpumtabackend/rank/scheduler/RankingSchedulerService.java @@ -1,4 +1,4 @@ -package com.gpt.geumpumtabackend.rank.service; +package com.gpt.geumpumtabackend.rank.scheduler; import com.gpt.geumpumtabackend.global.exception.BusinessException; import com.gpt.geumpumtabackend.global.exception.ExceptionType; @@ -36,24 +36,18 @@ public class RankingSchedulerService { private final UserRepository userRepository; private final DepartmentRankingRepository departmentRankingRepository; - /* - 일간 랭킹 스케줄러 - */ - @Scheduled(cron = "0 0 0 * * *") + + @Scheduled(cron = "5 0 0 * * *") public void dailyRankingScheduler() { - // 해당 시간이 되면, StudySession에서 진행중인 세션을 종료하고, 모든 세션을 합하여 정렬한 뒤 랭킹에 넣어야함 - LocalDate yesterDay = LocalDate.now().minusDays(1); - LocalDateTime dayStart = yesterDay.atStartOfDay(); - LocalDateTime dayEnd = yesterDay.atTime(23, 59, 59); + LocalDate yesterday = LocalDate.now().minusDays(1); + LocalDateTime dayStart = yesterday.atStartOfDay(); + LocalDateTime dayEnd = yesterday.atTime(23, 59, 59); calculateAndSavePersonalRanking(dayStart, dayEnd, RankingType.DAILY); calculateAndSaveDepartmentRanking(dayStart, dayEnd, RankingType.DAILY); - } - /* - 주간 랭킹 스케줄러 - */ - @Scheduled(cron = "0 0 0 ? * MON") + + @Scheduled(cron = "0 1 0 ? * MON") public void weeklyRankingScheduler() { LocalDate today = LocalDate.now(); LocalDate lastWeekStartDay = today.minusWeeks(1).with(DayOfWeek.MONDAY); @@ -65,10 +59,8 @@ public void weeklyRankingScheduler() { calculateAndSaveDepartmentRanking(weekStartTime, weekEndTime, RankingType.WEEKLY); } - /* - 월간 랭킹 스케줄러 - */ - @Scheduled(cron = "0 0 0 1 * ?") + + @Scheduled(cron = "0 2 0 1 * ?") public void monthlyRankingScheduler() { LocalDate lastMonth = LocalDate.now().minusMonths(1); LocalDate monthStart = lastMonth.withDayOfMonth(1); diff --git a/src/main/java/com/gpt/geumpumtabackend/rank/scheduler/SeasonTransitionScheduler.java b/src/main/java/com/gpt/geumpumtabackend/rank/scheduler/SeasonTransitionScheduler.java new file mode 100644 index 0000000..d05f415 --- /dev/null +++ b/src/main/java/com/gpt/geumpumtabackend/rank/scheduler/SeasonTransitionScheduler.java @@ -0,0 +1,64 @@ +package com.gpt.geumpumtabackend.rank.scheduler; + +import com.gpt.geumpumtabackend.badge.service.BadgeService; +import com.gpt.geumpumtabackend.rank.domain.Season; +import com.gpt.geumpumtabackend.rank.service.SeasonService; +import com.gpt.geumpumtabackend.rank.service.SeasonSnapshotService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.cache.CacheManager; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; + +@Component +@RequiredArgsConstructor +@Slf4j +public class SeasonTransitionScheduler { + + private final SeasonService seasonService; + private final SeasonSnapshotService snapshotService; + private final BadgeService badgeService; + private final CacheManager cacheManager; + + + + @Scheduled(cron = "0 5 0 * * *") + public void processSeasonTransition() { + LocalDate today = LocalDate.now(); + + try { + Season activeSeason = seasonService.getActiveSeasonNoCache(); + + if (today.isBefore(activeSeason.getEndDate().plusDays(1))) { + return; + } + + Long endedSeasonId = activeSeason.getId(); + + + // 캐시 먼저 클리어 (시즌 전환 전) + if (cacheManager.getCache("activeSeason") != null) { + cacheManager.getCache("activeSeason").clear(); + } + + // 시즌 전환 + seasonService.transitionToNextSeason(activeSeason); + + // 스냅샷 생성 + int snapshotCount = snapshotService.createSeasonSnapshot(endedSeasonId); + int grantedBadgeCount = 0; + try { + grantedBadgeCount = badgeService.grantSeasonRankingBadges(endedSeasonId); + } catch (Exception e) { + log.error("[SEASON_BADGE_GRANT_FAILED] seasonId={}", endedSeasonId, e); + } + log.info("[SEASON_TRANSITION] seasonId={}, snapshots={}, rankingBadges={}", + endedSeasonId, snapshotCount, grantedBadgeCount); + } catch (Exception e) { + log.error("[SEASON_TRANSITION_ERROR] Failed", e); + // TODO: 슬랙/이메일 알림 + } + } +} diff --git a/src/main/java/com/gpt/geumpumtabackend/rank/service/DepartmentRankService.java b/src/main/java/com/gpt/geumpumtabackend/rank/service/DepartmentRankService.java index 240f588..41291c5 100644 --- a/src/main/java/com/gpt/geumpumtabackend/rank/service/DepartmentRankService.java +++ b/src/main/java/com/gpt/geumpumtabackend/rank/service/DepartmentRankService.java @@ -93,24 +93,26 @@ private DepartmentRankingResponse buildDepartmentRankingResponse(List topRankings = new ArrayList<>(); User user = userRepository.findById(userId).orElseThrow(()->new BusinessException(ExceptionType.USER_NOT_FOUND)); + for (DepartmentRankingTemp temp : departmentRankingList) { DepartmentRankingEntryResponse entry = DepartmentRankingEntryResponse.of(temp); topRankings.add(entry); + // 사용자의 학과 찾기 (myRanking용) if(user.getDepartment() != null && user.getDepartment().getKoreanName().equals(temp.getDepartmentName())){ myRanking = entry; } } - + // 사용자의 학과를 찾지 못한 경우 0초, 마지막 순위로 설정 if (myRanking == null && user.getDepartment() != null) { myRanking = new DepartmentRankingEntryResponse( - user.getDepartment().getKoreanName(), - 0L, - (long) departmentRankingList.size() + 1 + user.getDepartment().getKoreanName(), + 0L, + (long) topRankings.size() + 1 ); } - + return new DepartmentRankingResponse(topRankings, myRanking); } } diff --git a/src/main/java/com/gpt/geumpumtabackend/rank/service/SeasonRankService.java b/src/main/java/com/gpt/geumpumtabackend/rank/service/SeasonRankService.java new file mode 100644 index 0000000..c89e210 --- /dev/null +++ b/src/main/java/com/gpt/geumpumtabackend/rank/service/SeasonRankService.java @@ -0,0 +1,323 @@ +package com.gpt.geumpumtabackend.rank.service; + +import com.gpt.geumpumtabackend.global.exception.BusinessException; +import com.gpt.geumpumtabackend.global.exception.ExceptionType; +import com.gpt.geumpumtabackend.rank.domain.*; +import com.gpt.geumpumtabackend.rank.dto.DepartmentRankingTemp; +import com.gpt.geumpumtabackend.rank.dto.PersonalRankingTemp; +import com.gpt.geumpumtabackend.rank.dto.response.DepartmentRankingEntryResponse; +import com.gpt.geumpumtabackend.rank.dto.response.SeasonDepartmentRankingResponse; +import com.gpt.geumpumtabackend.rank.dto.response.SeasonRankingResponse; +import com.gpt.geumpumtabackend.rank.repository.DepartmentRankingRepository; +import com.gpt.geumpumtabackend.rank.repository.SeasonRankingSnapshotRepository; +import com.gpt.geumpumtabackend.rank.repository.SeasonRepository; +import com.gpt.geumpumtabackend.rank.repository.UserRankingRepository; +import com.gpt.geumpumtabackend.study.repository.StudySessionRepository; +import com.gpt.geumpumtabackend.user.domain.Department; +import com.gpt.geumpumtabackend.user.domain.User; +import com.gpt.geumpumtabackend.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.*; +import java.util.stream.Collectors; + + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +@Slf4j +public class SeasonRankService { + + private final UserRankingRepository userRankingRepository; + private final DepartmentRankingRepository departmentRankingRepository; + private final StudySessionRepository studySessionRepository; + private final SeasonService seasonService; + private final SeasonRepository seasonRepository; + private final SeasonRankingSnapshotRepository snapshotRepository; + private final UserRepository userRepository; + + + public SeasonRankingResponse getCurrentSeasonRanking() { + Season activeSeason = seasonService.getActiveSeason(); + + LocalDate seasonStart = activeSeason.getStartDate(); + LocalDate today = LocalDate.now(); + LocalDate currentMonthStart = today.withDayOfMonth(1); + + List allData = new ArrayList<>(); + + if (currentMonthStart.isAfter(seasonStart)) { + List completedMonths = userRankingRepository + .calculateSeasonRankingFromMonthlyRankings( + seasonStart.atStartOfDay(), + currentMonthStart.atStartOfDay() + ); + allData.addAll(completedMonths); + } + + if (today.isAfter(currentMonthStart)) { + List currentMonth = userRankingRepository + .calculateCurrentMonthRankingFromDailyRankings( + currentMonthStart.atStartOfDay(), + today.atStartOfDay() + ); + allData.addAll(currentMonth); + } + + LocalDateTime todayEnd = today.plusDays(1).atStartOfDay(); + List todayRanking = studySessionRepository + .calculateCurrentPeriodRanking( + today.atStartOfDay(), + todayEnd, + LocalDateTime.now() + ); + allData.addAll(todayRanking); + + List finalRankings = mergeAndRank(allData); + + return SeasonRankingResponse.of(activeSeason, finalRankings); + } + + + public SeasonDepartmentRankingResponse getCurrentSeasonDepartmentRanking(Long userId) { + Season activeSeason = seasonService.getActiveSeason(); + + LocalDate seasonStart = activeSeason.getStartDate(); + LocalDate today = LocalDate.now(); + LocalDate currentMonthStart = today.withDayOfMonth(1); + + List allData = new ArrayList<>(); + + if (currentMonthStart.isAfter(seasonStart)) { + List completedMonths = departmentRankingRepository + .calculateSeasonFromMonthlyDepartmentRankings( + seasonStart.atStartOfDay(), + currentMonthStart.atStartOfDay() + ); + allData.addAll(completedMonths); + } + + if (today.isAfter(currentMonthStart)) { + List currentMonth = departmentRankingRepository + .calculateCurrentMonthFromDailyDepartmentRankings( + currentMonthStart.atStartOfDay(), + today.atStartOfDay() + ); + allData.addAll(currentMonth); + } + + LocalDateTime todayEnd = today.plusDays(1).atStartOfDay(); + List todayRanking = studySessionRepository + .calculateCurrentDepartmentRanking( + today.atStartOfDay(), + todayEnd, + LocalDateTime.now() + ); + allData.addAll(todayRanking); + + List finalRankings = mergeAndRankDepartments(allData); + + return buildSeasonDepartmentRankingResponse(activeSeason, finalRankings, userId); + } + + + public SeasonRankingResponse getEndedSeasonRanking(Long seasonId) { + Season season = seasonRepository.findById(seasonId) + .orElseThrow(() -> new BusinessException(ExceptionType.SEASON_NOT_FOUND)); + + if (season.getStatus() == SeasonStatus.ACTIVE) { + throw new BusinessException(ExceptionType.SEASON_NOT_ENDED); + } + + List snapshots = snapshotRepository + .findBySeasonIdAndRankType(seasonId, RankType.OVERALL); + + List rankings = convertSnapshotsToRankings(snapshots); + + return SeasonRankingResponse.of(season, rankings); + } + + + public SeasonDepartmentRankingResponse getEndedSeasonDepartmentRanking(Long seasonId, Long userId) { + Season season = seasonRepository.findById(seasonId) + .orElseThrow(() -> new BusinessException(ExceptionType.SEASON_NOT_FOUND)); + + if (season.getStatus() == SeasonStatus.ACTIVE) { + throw new BusinessException(ExceptionType.SEASON_NOT_ENDED); + } + + List aggregated = snapshotRepository + .aggregateDepartmentRankingBySeasonId(seasonId); + + List finalRankings = mergeAndRankDepartments(aggregated); + + return buildSeasonDepartmentRankingResponse(season, finalRankings, userId); + } + + + private List convertSnapshotsToRankings(List snapshots) { + if (snapshots.isEmpty()) { + return Collections.emptyList(); + } + + // User ID 리스트 추출 + List userIds = snapshots.stream() + .map(SeasonRankingSnapshot::getUserId) + .collect(Collectors.toList()); + + // User 정보 일괄 조회 + Map userMap = userRepository.findAllById(userIds).stream() + .collect(Collectors.toMap(User::getId, user -> user)); + + return snapshots.stream() + .map(snapshot -> { + User user = userMap.get(snapshot.getUserId()); + if (user == null) { + return null; + } + return new PersonalRankingTemp( + user.getId(), + user.getNickname(), + user.getPicture(), + user.getDepartment() != null ? user.getDepartment().name() : null, + snapshot.getFinalTotalMillis(), + (long) snapshot.getFinalRank() + ); + }) + .filter(Objects::nonNull) + .collect(Collectors.toList()); + } + + + private List mergeAndRankDepartments(List allData) { + if (allData.isEmpty()) { + return Collections.emptyList(); + } + + Map mergedMap = new HashMap<>(); + for (DepartmentRankingTemp data : allData) { + mergedMap.merge(data.getDepartment(), data.getTotalMillis(), Long::sum); + } + + List> sorted = mergedMap.entrySet().stream() + .sorted(Map.Entry.comparingByValue().reversed()) + .toList(); + + List result = new ArrayList<>(); + long currentRank = 1; + Long previousMillis = null; + + for (int i = 0; i < sorted.size(); i++) { + Map.Entry entry = sorted.get(i); + + if (previousMillis == null || !previousMillis.equals(entry.getValue())) { + currentRank = i + 1; + } + + result.add(new DepartmentRankingTemp( + entry.getKey(), + entry.getValue(), + currentRank + )); + + previousMillis = entry.getValue(); + } + + return result; + } + + + private SeasonDepartmentRankingResponse buildSeasonDepartmentRankingResponse( + Season season, List rankings, Long userId) { + + User user = userRepository.findById(userId) + .orElseThrow(() -> new BusinessException(ExceptionType.USER_NOT_FOUND)); + + DepartmentRankingEntryResponse myRanking = null; + List topRanks = new ArrayList<>(); + + for (DepartmentRankingTemp temp : rankings) { + DepartmentRankingEntryResponse entry = DepartmentRankingEntryResponse.of(temp); + + if (temp.getTotalMillis() != null && temp.getTotalMillis() > 0) { + topRanks.add(entry); + } + + if (user.getDepartment() != null && user.getDepartment().getKoreanName().equals(temp.getDepartmentName())) { + myRanking = entry; + } + } + + if (myRanking == null && user.getDepartment() != null) { + myRanking = new DepartmentRankingEntryResponse( + user.getDepartment().getKoreanName(), + 0L, + (long) rankings.size() + 1 + ); + } + + return SeasonDepartmentRankingResponse.of(season, topRanks, myRanking); + } + + + private List mergeAndRank(List allData) { + if (allData.isEmpty()) { + return Collections.emptyList(); + } + + // 1단계: userId별로 totalMillis 합산 + Map mergedMap = new HashMap<>(); + for (PersonalRankingTemp data : allData) { + mergedMap.merge( + data.getUserId(), + data, + (existing, newData) -> new PersonalRankingTemp( + existing.getUserId(), + existing.getNickname(), + existing.getImageUrl(), + existing.getDepartment(), + existing.getTotalMillis() + newData.getTotalMillis(), + 0L + ) + ); + } + + // 2단계: totalMillis 내림차순 정렬 + List sorted = mergedMap.values().stream() + .sorted(Comparator.comparing(PersonalRankingTemp::getTotalMillis).reversed()) + .toList(); + + // 3단계: 동점자 처리하며 순위 부여 (MySQL RANK() 함수와 동일) + List result = new ArrayList<>(); + long currentRank = 1; + Long previousMillis = null; + + for (int i = 0; i < sorted.size(); i++) { + PersonalRankingTemp temp = sorted.get(i); + + // 동점자가 아니면 실제 순위(i+1)를 부여 + if (previousMillis == null || !previousMillis.equals(temp.getTotalMillis())) { + currentRank = i + 1; + } + // 동점자면 이전 순위 유지 + + result.add(new PersonalRankingTemp( + temp.getUserId(), + temp.getNickname(), + temp.getImageUrl(), + temp.getDepartment(), + temp.getTotalMillis(), + currentRank + )); + + previousMillis = temp.getTotalMillis(); + } + + return result; + } +} diff --git a/src/main/java/com/gpt/geumpumtabackend/rank/service/SeasonService.java b/src/main/java/com/gpt/geumpumtabackend/rank/service/SeasonService.java new file mode 100644 index 0000000..54bdb3e --- /dev/null +++ b/src/main/java/com/gpt/geumpumtabackend/rank/service/SeasonService.java @@ -0,0 +1,139 @@ +package com.gpt.geumpumtabackend.rank.service; + +import com.gpt.geumpumtabackend.global.exception.BusinessException; +import com.gpt.geumpumtabackend.global.exception.ExceptionType; +import com.gpt.geumpumtabackend.rank.domain.Season; +import com.gpt.geumpumtabackend.rank.domain.SeasonStatus; +import com.gpt.geumpumtabackend.rank.domain.SeasonType; +import com.gpt.geumpumtabackend.rank.repository.SeasonRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.Year; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +@Slf4j +public class SeasonService { + + private final SeasonRepository seasonRepository; + + + @Cacheable(value = "activeSeason", unless = "#result == null") + public Season getActiveSeason() { + LocalDate today = LocalDate.now(); + return seasonRepository.findByDateRange(today) + .orElseThrow(() -> new BusinessException(ExceptionType.NO_ACTIVE_SEASON)); + } + + + public Season getActiveSeasonNoCache() { + LocalDate today = LocalDate.now(); + return seasonRepository.findByDateRange(today) + .orElseThrow(() -> new BusinessException(ExceptionType.NO_ACTIVE_SEASON)); + } + + + @Transactional + public Season createInitialSeason() { + LocalDate today = LocalDate.now(); + SeasonType seasonType = determineSeasonType(today); + + Season season = Season.builder() + .name(generateSeasonName(today.getYear(), seasonType)) + .seasonType(seasonType) + .startDate(getSeasonStartDate(today, seasonType)) + .endDate(getSeasonEndDate(today, seasonType)) + .status(SeasonStatus.ACTIVE) + .build(); + + Season savedSeason = seasonRepository.save(season); + log.info("[SEASON] Initial season created: {}", savedSeason.getName()); + return savedSeason; + } + + + @Transactional + public void transitionToNextSeason(Season currentSeason) { + + LocalDate nextStart = currentSeason.getEndDate().plusDays(1); + SeasonType nextType = getNextSeasonType(currentSeason.getSeasonType()); + int year = nextStart.getYear(); + + Season nextSeason = Season.builder() + .name(generateSeasonName(year, nextType)) + .seasonType(nextType) + .startDate(nextStart) + .endDate(getSeasonEndDate(nextStart, nextType)) + .status(SeasonStatus.ACTIVE) + .build(); + + seasonRepository.save(nextSeason); + + currentSeason.end(); + seasonRepository.save(currentSeason); + + log.info("[SEASON] Transition completed: {} → {}", + currentSeason.getName(), nextSeason.getName()); + } + + + private SeasonType determineSeasonType(LocalDate date) { + int month = date.getMonthValue(); + if (month >= 3 && month <= 6) return SeasonType.SPRING_SEMESTER; + if (month >= 7 && month <= 8) return SeasonType.SUMMER_VACATION; + if (month >= 9 && month <= 12) return SeasonType.FALL_SEMESTER; + return SeasonType.WINTER_VACATION; + } + + + private SeasonType getNextSeasonType(SeasonType current) { + return switch (current) { + case SPRING_SEMESTER -> SeasonType.SUMMER_VACATION; + case SUMMER_VACATION -> SeasonType.FALL_SEMESTER; + case FALL_SEMESTER -> SeasonType.WINTER_VACATION; + case WINTER_VACATION -> SeasonType.SPRING_SEMESTER; + }; + } + + + private LocalDate getSeasonStartDate(LocalDate referenceDate, SeasonType type) { + int year = referenceDate.getYear(); + return switch (type) { + case SPRING_SEMESTER -> LocalDate.of(year, 3, 1); + case SUMMER_VACATION -> LocalDate.of(year, 7, 1); + case FALL_SEMESTER -> LocalDate.of(year, 9, 1); + case WINTER_VACATION -> LocalDate.of(year, 1, 1); + }; + } + + + private LocalDate getSeasonEndDate(LocalDate startDate, SeasonType type) { + int year = startDate.getYear(); + return switch (type) { + case SPRING_SEMESTER -> LocalDate.of(year, 6, 30); + case SUMMER_VACATION -> LocalDate.of(year, 8, 31); + case FALL_SEMESTER -> LocalDate.of(year, 12, 31); + case WINTER_VACATION -> { + boolean isLeap = Year.isLeap(year); + yield LocalDate.of(year, 2, isLeap ? 29 : 28); + } + }; + } + + + private String generateSeasonName(int year, SeasonType type) { + String typeName = switch (type) { + case SPRING_SEMESTER -> "1학기"; + case SUMMER_VACATION -> "여름방학"; + case FALL_SEMESTER -> "2학기"; + case WINTER_VACATION -> "겨울방학"; + }; + return year + " " + typeName + " 시즌"; + } +} diff --git a/src/main/java/com/gpt/geumpumtabackend/rank/service/SeasonSnapshotBatchService.java b/src/main/java/com/gpt/geumpumtabackend/rank/service/SeasonSnapshotBatchService.java new file mode 100644 index 0000000..9e99fa5 --- /dev/null +++ b/src/main/java/com/gpt/geumpumtabackend/rank/service/SeasonSnapshotBatchService.java @@ -0,0 +1,67 @@ +package com.gpt.geumpumtabackend.rank.service; + +import com.gpt.geumpumtabackend.rank.domain.SeasonRankingSnapshot; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.sql.Timestamp; +import java.time.LocalDateTime; +import java.util.List; + + +@Service +@RequiredArgsConstructor +@Slf4j +public class SeasonSnapshotBatchService { + + private final JdbcTemplate jdbcTemplate; + + @Transactional + public int saveBatchWithJdbc(List snapshots) { + if (snapshots == null || snapshots.isEmpty()) { + return 0; + } + + String sql = """ + INSERT INTO season_ranking_snapshot + (season_id, user_id, rank_type, final_rank, final_total_millis, + department, snapshot_at, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """; + + int batchSize = 2000; + int totalSaved = 0; + + LocalDateTime now = LocalDateTime.now(); + + for (int i = 0; i < snapshots.size(); i += batchSize) { + int end = Math.min(i + batchSize, snapshots.size()); + List batch = snapshots.subList(i, end); + + int[][] updateCounts = jdbcTemplate.batchUpdate(sql, batch, batchSize, + (ps, snapshot) -> { + ps.setLong(1, snapshot.getSeasonId()); + ps.setLong(2, snapshot.getUserId()); + ps.setString(3, snapshot.getRankType().name()); + ps.setInt(4, snapshot.getFinalRank()); + ps.setLong(5, snapshot.getFinalTotalMillis()); + if (snapshot.getDepartment() != null) { + ps.setString(6, snapshot.getDepartment().name()); + } else { + ps.setNull(6, java.sql.Types.VARCHAR); + } + ps.setTimestamp(7, Timestamp.valueOf(snapshot.getSnapshotAt())); + ps.setTimestamp(8, Timestamp.valueOf(now)); + ps.setTimestamp(9, Timestamp.valueOf(now)); + }); + + for (int[] batchUpdateCounts : updateCounts) { + totalSaved += batchUpdateCounts.length; + } + } + return totalSaved; + } +} diff --git a/src/main/java/com/gpt/geumpumtabackend/rank/service/SeasonSnapshotService.java b/src/main/java/com/gpt/geumpumtabackend/rank/service/SeasonSnapshotService.java new file mode 100644 index 0000000..c96f087 --- /dev/null +++ b/src/main/java/com/gpt/geumpumtabackend/rank/service/SeasonSnapshotService.java @@ -0,0 +1,167 @@ +package com.gpt.geumpumtabackend.rank.service; + +import com.gpt.geumpumtabackend.global.exception.BusinessException; +import com.gpt.geumpumtabackend.global.exception.ExceptionType; +import com.gpt.geumpumtabackend.rank.domain.*; +import com.gpt.geumpumtabackend.rank.dto.PersonalRankingTemp; +import com.gpt.geumpumtabackend.rank.repository.SeasonRankingSnapshotRepository; +import com.gpt.geumpumtabackend.rank.repository.SeasonRepository; +import com.gpt.geumpumtabackend.rank.repository.UserRankingRepository; +import com.gpt.geumpumtabackend.user.domain.Department; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.DataAccessException; +import org.springframework.retry.annotation.Backoff; +import org.springframework.retry.annotation.Recover; +import org.springframework.retry.annotation.Retryable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.sql.SQLException; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.stream.Collectors; + + +@Service +@RequiredArgsConstructor +@Slf4j +public class SeasonSnapshotService { + + private final UserRankingRepository userRankingRepository; + private final SeasonRankingSnapshotRepository snapshotRepository; + private final SeasonRepository seasonRepository; + private final SeasonSnapshotBatchService batchService; + + + @Retryable( + retryFor = {DataAccessException.class, SQLException.class}, + maxAttempts = 3, + backoff = @Backoff(delay = 5000) + ) + @Transactional + public int createSeasonSnapshot(Long seasonId) { + Season season = seasonRepository.findById(seasonId) + .orElseThrow(() -> new BusinessException(ExceptionType.SEASON_NOT_FOUND)); + + if (snapshotRepository.existsBySeasonId(seasonId)) { + return 0; + } + + LocalDateTime seasonStart = season.getStartDate().atStartOfDay(); + LocalDateTime seasonEndInclusive = season.getEndDate().plusDays(1).atStartOfDay(); + LocalDateTime snapshotAt = LocalDateTime.now(); + + + List overallRankings = calculateSeasonRanking( + seasonStart, seasonEndInclusive + ); + + List overallSnapshots = overallRankings.stream() + .map(temp -> SeasonRankingSnapshot.builder() + .seasonId(seasonId) + .userId(temp.getUserId()) + .rankType(RankType.OVERALL) + .finalRank(temp.getRanking().intValue()) + .finalTotalMillis(temp.getTotalMillis()) + .snapshotAt(snapshotAt) + .build()) + .collect(Collectors.toList()); + + batchService.saveBatchWithJdbc(overallSnapshots); + + int deptCount = 0; + for (Department dept : Department.values()) { + List deptRankings = calculateSeasonDepartmentRanking( + seasonStart, seasonEndInclusive, dept + ); + + if (deptRankings.isEmpty()) { + continue; + } + + List deptSnapshots = deptRankings.stream() + .map(temp -> SeasonRankingSnapshot.builder() + .seasonId(seasonId) + .userId(temp.getUserId()) + .rankType(RankType.DEPARTMENT) + .department(dept) + .finalRank(temp.getRanking().intValue()) + .finalTotalMillis(temp.getTotalMillis()) + .snapshotAt(snapshotAt) + .build()) + .collect(Collectors.toList()); + + batchService.saveBatchWithJdbc(deptSnapshots); + deptCount += deptSnapshots.size(); + } + + int totalCount = overallSnapshots.size() + deptCount; + + return totalCount; + } + + + @Recover + public int recoverCreateSeasonSnapshot(Exception e, Long seasonId) { + log.error("[SNAPSHOT_FAILED] Season {} snapshot creation failed after 3 retries", + seasonId, e); + return 0; + } + + + private List calculateSeasonRanking( + LocalDateTime seasonStart, LocalDateTime seasonEnd) { + + List monthlyData = userRankingRepository + .calculateSeasonRankingFromMonthlyRankings(seasonStart, seasonEnd); + + return assignRanks(monthlyData); + } + + + private List calculateSeasonDepartmentRanking( + LocalDateTime seasonStart, LocalDateTime seasonEnd, Department department) { + + List monthlyData = userRankingRepository + .calculateSeasonDepartmentRankingFromMonthlyRankings( + seasonStart, seasonEnd, department + ); + + return assignRanks(monthlyData); + } + + + private List assignRanks(List data) { + List sorted = data.stream() + .sorted(Comparator.comparing(PersonalRankingTemp::getTotalMillis).reversed()) + .toList(); + + List result = new ArrayList<>(); + long currentRank = 1; + Long previousMillis = null; + + for (int i = 0; i < sorted.size(); i++) { + PersonalRankingTemp temp = sorted.get(i); + + if (previousMillis == null || !previousMillis.equals(temp.getTotalMillis())) { + currentRank = i + 1; + } + + result.add(new PersonalRankingTemp( + temp.getUserId(), + temp.getNickname(), + temp.getImageUrl(), + temp.getDepartment(), + temp.getTotalMillis(), + currentRank + )); + + previousMillis = temp.getTotalMillis(); + } + + return result; + } +} diff --git a/src/main/java/com/gpt/geumpumtabackend/statistics/controller/StatisticsController.java b/src/main/java/com/gpt/geumpumtabackend/statistics/controller/StatisticsController.java index 0b250df..eac732a 100644 --- a/src/main/java/com/gpt/geumpumtabackend/statistics/controller/StatisticsController.java +++ b/src/main/java/com/gpt/geumpumtabackend/statistics/controller/StatisticsController.java @@ -10,7 +10,6 @@ import com.gpt.geumpumtabackend.statistics.dto.response.WeeklyStatisticsResponse; import com.gpt.geumpumtabackend.statistics.service.StatisticsService; import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; import org.springframework.format.annotation.DateTimeFormat; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; diff --git a/src/main/java/com/gpt/geumpumtabackend/statistics/dto/DayMaxFocusAndFullTimeStatistics.java b/src/main/java/com/gpt/geumpumtabackend/statistics/dto/DayMaxFocusAndFullTimeStatistics.java index 29b9368..74596f3 100644 --- a/src/main/java/com/gpt/geumpumtabackend/statistics/dto/DayMaxFocusAndFullTimeStatistics.java +++ b/src/main/java/com/gpt/geumpumtabackend/statistics/dto/DayMaxFocusAndFullTimeStatistics.java @@ -1,6 +1,6 @@ package com.gpt.geumpumtabackend.statistics.dto; public interface DayMaxFocusAndFullTimeStatistics { - Integer getTotalStudySeconds(); - Integer getMaxFocusSeconds(); -} \ No newline at end of file + Integer getTotalStudyMillis(); + Integer getMaxFocusMillis(); +} diff --git a/src/main/java/com/gpt/geumpumtabackend/statistics/dto/MonthlyStatistics.java b/src/main/java/com/gpt/geumpumtabackend/statistics/dto/MonthlyStatistics.java index 34befc8..264da6a 100644 --- a/src/main/java/com/gpt/geumpumtabackend/statistics/dto/MonthlyStatistics.java +++ b/src/main/java/com/gpt/geumpumtabackend/statistics/dto/MonthlyStatistics.java @@ -1,8 +1,8 @@ package com.gpt.geumpumtabackend.statistics.dto; public interface MonthlyStatistics { - Long getTotalMonthSeconds(); // 총 공부시간(초) - Integer getAverageDailySeconds(); // 월 일수로 나눈 일일 평균(초) + Long getTotalMonthMillis(); // 총 공부시간(ms) + Integer getAverageDailyMillis(); // 월 일수로 나눈 일일 평균(ms) Integer getMaxConsecutiveStudyDays(); // 해당 월 내 최장 연속 공부 일수 - Integer getStudiedDays(); // 이번 달 공부 일수(>0초인 날의 수) + Integer getStudiedDays(); // 이번 달 공부 일수(>0ms인 날의 수) } diff --git a/src/main/java/com/gpt/geumpumtabackend/statistics/dto/TwoHourSlotStatistics.java b/src/main/java/com/gpt/geumpumtabackend/statistics/dto/TwoHourSlotStatistics.java index 5bf5edc..27942c6 100644 --- a/src/main/java/com/gpt/geumpumtabackend/statistics/dto/TwoHourSlotStatistics.java +++ b/src/main/java/com/gpt/geumpumtabackend/statistics/dto/TwoHourSlotStatistics.java @@ -3,5 +3,5 @@ public interface TwoHourSlotStatistics { String getSlotStart(); String getSlotEnd(); - Integer getSecondsStudied(); + Integer getMillisecondsStudied(); } diff --git a/src/main/java/com/gpt/geumpumtabackend/statistics/dto/WeeklyStatistics.java b/src/main/java/com/gpt/geumpumtabackend/statistics/dto/WeeklyStatistics.java index 3dd66c6..6f827c8 100644 --- a/src/main/java/com/gpt/geumpumtabackend/statistics/dto/WeeklyStatistics.java +++ b/src/main/java/com/gpt/geumpumtabackend/statistics/dto/WeeklyStatistics.java @@ -1,7 +1,7 @@ package com.gpt.geumpumtabackend.statistics.dto; public interface WeeklyStatistics { - Long getTotalWeekSeconds(); + Long getTotalWeekMillis(); Integer getMaxConsecutiveStudyDays(); - Integer getAverageDailySeconds(); // 7일 평균(초), 소수점 버림 + Integer getAverageDailyMillis(); // 7일 평균(ms), 소수점 버림 } diff --git a/src/main/java/com/gpt/geumpumtabackend/statistics/repository/StatisticsRepository.java b/src/main/java/com/gpt/geumpumtabackend/statistics/repository/StatisticsRepository.java new file mode 100644 index 0000000..d7f9904 --- /dev/null +++ b/src/main/java/com/gpt/geumpumtabackend/statistics/repository/StatisticsRepository.java @@ -0,0 +1,421 @@ +package com.gpt.geumpumtabackend.statistics.repository; + +import com.gpt.geumpumtabackend.statistics.dto.*; +import com.gpt.geumpumtabackend.study.domain.StudySession; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +public interface StatisticsRepository extends JpaRepository { + // 2시간 단위로 일일 통계를 불러옴 + @Query( + value = """ + WITH RECURSIVE buckets AS ( + SELECT + 0 AS idx, + :dayStart AS bucket_start, + :dayStart + INTERVAL 2 HOUR AS bucket_end + UNION ALL + SELECT + idx + 1, + bucket_end, + bucket_end + INTERVAL 2 HOUR + FROM buckets + WHERE idx < 11 + ) + SELECT + DATE_FORMAT(b.bucket_start, '%H:%i') AS slotStart, + DATE_FORMAT(b.bucket_end, '%H:%i') AS slotEnd, + COALESCE(SUM( + GREATEST( + 0, + TIMESTAMPDIFF( + MICROSECOND, + GREATEST(s.start_time, b.bucket_start), + LEAST(s.end_time, b.bucket_end) + ) / 1000 + ) + ), 0) AS millisecondsStudied + FROM buckets b + LEFT JOIN study_session s + ON s.user_id = :userId + AND s.start_time < b.bucket_end + AND s.end_time > b.bucket_start + GROUP BY b.idx, b.bucket_start, b.bucket_end + ORDER BY b.idx + """, + nativeQuery = true + ) + List getTwoHourSlotStats( + @Param("dayStart") LocalDateTime dayStart, + @Param("dayEnd") LocalDateTime dayEnd, + @Param("userId") Long userId + ); + + + @Query(value = """ + WITH clipped AS ( + SELECT GREATEST( + 0, + TIMESTAMPDIFF( + MICROSECOND, + GREATEST(s.start_time, :dayStart), + LEAST(s.end_time, :dayEnd) + ) / 1000 + ) AS overlap_ms + FROM study_session s + WHERE s.user_id = :userId + AND s.start_time < :dayEnd + AND s.end_time > :dayStart + ) + SELECT + CAST(COALESCE(SUM(c.overlap_ms), 0) AS SIGNED) AS totalStudyMillis, + CAST(COALESCE(MAX(c.overlap_ms), 0) AS SIGNED) AS maxFocusMillis + FROM clipped c + """, nativeQuery = true) + DayMaxFocusAndFullTimeStatistics getDayMaxFocusAndFullTime( + @Param("dayStart") LocalDateTime dayStart, + @Param("dayEnd") LocalDateTime dayEnd, + @Param("userId") Long userId + ); + + @Query(value = """ + WITH RECURSIVE days AS ( + SELECT 0 AS day_idx, + :weekStart AS day_start, + DATE_ADD(:weekStart, INTERVAL 1 DAY) AS day_end + UNION ALL + SELECT day_idx + 1, + DATE_ADD(:weekStart, INTERVAL day_idx + 1 DAY), + DATE_ADD(:weekStart, INTERVAL day_idx + 2 DAY) + FROM days + WHERE day_idx < 6 + ), + per_day AS ( + SELECT + d.day_idx, + CAST( + COALESCE( + SUM( + GREATEST( + 0, + TIMESTAMPDIFF( + MICROSECOND, + GREATEST(s.start_time, d.day_start), + LEAST(COALESCE(s.end_time, d.day_end), d.day_end) + ) / 1000 + ) + ), + 0 + ) AS SIGNED + ) AS day_millis + FROM days d + LEFT JOIN study_session s + ON s.user_id = :userId + AND s.start_time < d.day_end + AND s.end_time > d.day_start + GROUP BY d.day_idx + ), + flags AS ( + SELECT + day_idx, + day_millis, + CASE WHEN day_millis > 0 THEN 1 ELSE 0 END AS has_study + FROM per_day + ), + breaks AS ( + SELECT + day_idx, + day_millis, + has_study, + SUM(CASE WHEN has_study = 0 THEN 1 ELSE 0 END) + OVER (ORDER BY day_idx) AS zero_grp + FROM flags + ), + streaks AS ( + SELECT zero_grp, COUNT(*) AS streak_len + FROM breaks + WHERE has_study = 1 + GROUP BY zero_grp + ) + SELECT + /* 주간 총 공부시간(ms) */ + (SELECT CAST(COALESCE(SUM(day_millis), 0) AS SIGNED) FROM per_day) AS totalWeekMillis, + /* 주간 최장 연속 공부일수 */ + COALESCE((SELECT MAX(streak_len) FROM streaks), 0) AS maxConsecutiveStudyDays, + /* 7일 평균(ms) — 소수점 버림 */ + CAST(((SELECT COALESCE(SUM(day_millis), 0) FROM per_day) / 7) AS SIGNED) AS averageDailyMillis + """, nativeQuery = true) + WeeklyStatistics getWeeklyStatistics( + @Param("weekStart") LocalDateTime weekStart, + @Param("userId") Long userId + ); + + @Query(value = """ + WITH RECURSIVE + bounds AS ( + SELECT + :monthStart AS start_at, + DATE_ADD(LAST_DAY(:monthStart), INTERVAL 1 DAY) AS end_at, + TIMESTAMPDIFF( + DAY, :monthStart, + DATE_ADD(LAST_DAY(:monthStart), INTERVAL 1 DAY) + ) AS days_cnt + ), + + /* (개선) 이번 달과 겹치는 "완료된" 세션만 먼저 선필터링 */ + filtered_sessions AS ( + SELECT s.user_id, s.start_time, s.end_time + FROM study_session s + JOIN bounds b + ON s.user_id = :userId + AND s.end_time IS NOT NULL /* 진행 중 세션 제외 (의도 유지) */ + AND s.start_time < b.end_at /* 달 끝보다 먼저 시작 */ + AND s.end_time > b.start_at /* 달 시작보다 나중에 끝 */ + ), + + /* 월 전체 일자를 day_idx=0..(days_cnt-1)로 생성 */ + days AS ( + SELECT + 0 AS day_idx, + b.start_at AS day_start, + LEAST(DATE_ADD(b.start_at, INTERVAL 1 DAY), b.end_at) AS day_end + FROM bounds b + UNION ALL + SELECT + d.day_idx + 1, + DATE_ADD(d.day_start, INTERVAL 1 DAY), + LEAST(DATE_ADD(d.day_start, INTERVAL 2 DAY), b.end_at) + FROM days d + JOIN bounds b + ON d.day_end < b.end_at + ), + + /* 일자별 공부 총합(ms) */ + per_day AS ( + SELECT + d.day_idx, + CAST( + COALESCE( + SUM( + GREATEST( + 0, + TIMESTAMPDIFF( + MICROSECOND, + GREATEST(s.start_time, d.day_start), + LEAST(s.end_time, d.day_end) + ) / 1000 + ) + ), 0 + ) AS SIGNED + ) AS day_millis + FROM days d + LEFT JOIN filtered_sessions s + ON s.start_time < d.day_end + AND s.end_time > d.day_start + GROUP BY d.day_idx + ), + + flags AS ( + SELECT + day_idx, + day_millis, + CASE WHEN day_millis > 0 THEN 1 ELSE 0 END AS has_study + FROM per_day + ), + + breaks AS ( + /* 0(공부 안 한 날)을 경계로 그룹을 나눠 연속 구간 식별 */ + SELECT + day_idx, + day_millis, + has_study, + SUM(CASE WHEN has_study = 0 THEN 1 ELSE 0 END) + OVER (ORDER BY day_idx) AS zero_grp + FROM flags + ), + + streaks AS ( + SELECT zero_grp, COUNT(*) AS streak_len + FROM breaks + WHERE has_study = 1 + GROUP BY zero_grp + ) + + SELECT + /* 총 공부시간(ms) */ + CAST(COALESCE((SELECT SUM(day_millis) FROM per_day), 0) AS SIGNED) AS totalMonthMillis, + + /* 월 일수로 나눈 일일 평균(ms; 소수 버림) */ + CAST( (COALESCE((SELECT SUM(day_millis) FROM per_day), 0) + / NULLIF((SELECT days_cnt FROM bounds), 0)) AS SIGNED) AS averageDailyMillis, + + /* 최장 연속 공부 일수 */ + COALESCE((SELECT MAX(streak_len) FROM streaks), 0) AS maxConsecutiveStudyDays, + + /* 이번 달 공부 일수(>0ms) */ + (SELECT COUNT(*) FROM per_day WHERE day_millis > 0) AS studiedDays + """, nativeQuery = true) + MonthlyStatistics getMonthlyStatistics( + @Param("monthStart") LocalDateTime monthStart, // 해당 월 1일 00:00 + @Param("userId") Long userId + ); + + @Query(value = """ + WITH RECURSIVE + bounds AS ( + SELECT DATE_SUB(DATE(:today), INTERVAL 364 DAY) AS start_date, + DATE(:today) AS end_date + ), + days AS ( + SELECT b.start_date AS day_date, + CAST(b.start_date AS DATETIME) AS day_start, + CAST(DATE_ADD(b.start_date, INTERVAL 1 DAY) AS DATETIME) AS day_end + FROM bounds b + UNION ALL + SELECT DATE_ADD(d.day_date, INTERVAL 1 DAY), + DATE_ADD(d.day_start, INTERVAL 1 DAY), + DATE_ADD(d.day_end, INTERVAL 1 DAY) + FROM days d + JOIN bounds b ON d.day_date < b.end_date + ), + sessions_in_window AS ( + SELECT s.start_time, + COALESCE(s.end_time, CAST(DATE_ADD(DATE(:today), INTERVAL 1 DAY) AS DATETIME)) AS end_time + FROM study_session s + JOIN bounds b + ON s.user_id = :userId + AND s.start_time < CAST(DATE_ADD(b.end_date, INTERVAL 1 DAY) AS DATETIME) + AND COALESCE(s.end_time, CAST(DATE_ADD(DATE(:today), INTERVAL 1 DAY) AS DATETIME)) > CAST(b.start_date AS DATETIME) + ), + day_overlap AS ( + SELECT d.day_date, + GREATEST(s.start_time, d.day_start) AS seg_start, + LEAST(s.end_time, d.day_end) AS seg_end + FROM days d + JOIN sessions_in_window s + ON s.start_time < d.day_end + AND s.end_time > d.day_start + ), + per_day AS ( + SELECT d.day_date, + CAST(COALESCE(SUM( + GREATEST(TIMESTAMPDIFF(MICROSECOND, o.seg_start, o.seg_end), 0) + ) / 1000, 0) AS SIGNED) AS day_millis + FROM days d + LEFT JOIN day_overlap o ON o.day_date = d.day_date + GROUP BY d.day_date + ), + qualified AS ( + SELECT day_date + FROM per_day + WHERE day_millis >= :thresholdMillis + ), + numbered AS ( + SELECT day_date, + ROW_NUMBER() OVER (ORDER BY day_date) AS rn + FROM qualified + ), + islands AS ( + SELECT day_date, + DATE_SUB(day_date, INTERVAL rn DAY) AS grp_key + FROM numbered + ), + streaks AS ( + SELECT grp_key, + MIN(day_date) AS streak_start, + MAX(day_date) AS streak_end, + COUNT(*) AS streak_len + FROM islands + GROUP BY grp_key + ) + SELECT COALESCE(( + SELECT streak_len + FROM streaks + WHERE streak_end = DATE(:today) + LIMIT 1 + ), 0) + """, nativeQuery = true) + Integer countCurrentConsecutiveStudyDays( + @Param("userId") Long userId, + @Param("today") LocalDate today, + @Param("thresholdMillis") Long thresholdMillis + ); + + + + @Query(value = """ + WITH RECURSIVE + bounds AS ( + SELECT DATE(:monthStart) AS start_at, + DATE(:monthEnd) AS end_at_exclusive + ), + days AS ( + SELECT b.start_at AS day_date, + CAST(b.start_at AS DATETIME) AS day_start, + CAST(DATE_ADD(b.start_at, INTERVAL 1 DAY) AS DATETIME) AS day_end + FROM bounds b + UNION ALL + SELECT DATE_ADD(d.day_date, INTERVAL 1 DAY), + DATE_ADD(d.day_start, INTERVAL 1 DAY), + DATE_ADD(d.day_end, INTERVAL 1 DAY) + FROM days d + JOIN bounds b ON d.day_date < DATE_SUB(b.end_at_exclusive, INTERVAL 1 DAY) + ), + sessions_in_window AS ( + SELECT s.user_id, s.start_time, s.end_time + FROM study_session s + JOIN bounds b + ON s.user_id = :userId + AND s.end_time > b.start_at + AND s.start_time < b.end_at_exclusive + AND s.end_time IS NOT NULL + ), + day_overlap AS ( + SELECT d.day_date, + GREATEST(s.start_time, d.day_start) AS seg_start, + LEAST(s.end_time, d.day_end) AS seg_end + FROM days d + JOIN sessions_in_window s + ON s.end_time > d.day_start + AND s.start_time < d.day_end + ), + daily_ms AS ( + SELECT day_date AS date, + GREATEST(SUM(GREATEST(TIMESTAMPDIFF(MICROSECOND, seg_start, seg_end), 0)) / 1000, 0) AS total_millis + FROM day_overlap + GROUP BY day_date + ), + daily_full AS ( + SELECT d.day_date AS date, + COALESCE(ds.total_millis, 0) AS total_millis + FROM days d + LEFT JOIN daily_ms ds ON ds.date = d.day_date + ), + leveled AS ( + SELECT date, + CASE + WHEN total_millis = 0 THEN 0 + ELSE NTILE(4) OVER ( + PARTITION BY YEAR(date), MONTH(date) + ORDER BY total_millis + ) + END AS level + FROM daily_full + ) + SELECT + DATE_FORMAT(date, '%Y-%m-%d') AS date, + CAST(level AS UNSIGNED) AS level + FROM leveled + ORDER BY date + """, nativeQuery = true) + List getGrassStatistics( + @Param("monthStart") LocalDate monthStart, + @Param("monthEnd") LocalDate monthEnd, + @Param("userId") Long userId + ); +} diff --git a/src/main/java/com/gpt/geumpumtabackend/statistics/service/StatisticsService.java b/src/main/java/com/gpt/geumpumtabackend/statistics/service/StatisticsService.java index 93cff58..6dd8a24 100644 --- a/src/main/java/com/gpt/geumpumtabackend/statistics/service/StatisticsService.java +++ b/src/main/java/com/gpt/geumpumtabackend/statistics/service/StatisticsService.java @@ -10,7 +10,7 @@ import com.gpt.geumpumtabackend.statistics.dto.response.GrassStatisticsResponse; import com.gpt.geumpumtabackend.statistics.dto.response.MonthlyStatisticsResponse; import com.gpt.geumpumtabackend.statistics.dto.response.WeeklyStatisticsResponse; -import com.gpt.geumpumtabackend.study.repository.StudySessionRepository; +import com.gpt.geumpumtabackend.statistics.repository.StatisticsRepository; import com.gpt.geumpumtabackend.user.repository.UserRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -28,7 +28,7 @@ @Transactional(readOnly = true) public class StatisticsService { - private final StudySessionRepository studySessionRepository; + private final StatisticsRepository statisticsRepository; private final UserRepository userRepository; private final ZoneId zone = ZoneId.of("Asia/Seoul"); @@ -110,7 +110,7 @@ public GrassStatisticsResponse getGrassStatistics( .orElseThrow(() -> new BusinessException(ExceptionType.USER_NOT_FOUND)); LocalDate firstDayOfMonth = date.minusMonths(3).withDayOfMonth(1); LocalDate endOfMonth = date.plusMonths(1).withDayOfMonth(1); - return GrassStatisticsResponse.from(studySessionRepository.getGrassStatistics(firstDayOfMonth, endOfMonth, targetUserId)); + return GrassStatisticsResponse.from(statisticsRepository.getGrassStatistics(firstDayOfMonth, endOfMonth, targetUserId)); } public List getTwoHourSlots( @@ -118,7 +118,7 @@ public List getTwoHourSlots( LocalDateTime dayEnd, Long targetUserId ){ - return studySessionRepository.getTwoHourSlotStats(dayStart, dayEnd, targetUserId); + return statisticsRepository.getTwoHourSlotStats(dayStart, dayEnd, targetUserId); } public DayMaxFocusAndFullTimeStatistics getDayMaxFocusStatistics( @@ -126,14 +126,14 @@ public DayMaxFocusAndFullTimeStatistics getDayMaxFocusStatistics( LocalDateTime dayEnd, Long targetUserId ){ - return studySessionRepository.getDayMaxFocusAndFullTime(dayStart, dayEnd, targetUserId); + return statisticsRepository.getDayMaxFocusAndFullTime(dayStart, dayEnd, targetUserId); } public WeeklyStatistics getWeeklyStatistics( LocalDateTime weekStart, Long targetUserId ){ - return studySessionRepository.getWeeklyStatistics(weekStart, targetUserId); + return statisticsRepository.getWeeklyStatistics(weekStart, targetUserId); } @@ -141,7 +141,7 @@ public MonthlyStatistics getMonthlyStatistics( LocalDateTime monthStart, Long targetUserId ){ - return studySessionRepository.getMonthlyStatistics(monthStart, targetUserId); + return statisticsRepository.getMonthlyStatistics(monthStart, targetUserId); } } diff --git a/src/main/java/com/gpt/geumpumtabackend/study/CLAUDE.md b/src/main/java/com/gpt/geumpumtabackend/study/CLAUDE.md new file mode 100644 index 0000000..24e864f --- /dev/null +++ b/src/main/java/com/gpt/geumpumtabackend/study/CLAUDE.md @@ -0,0 +1,126 @@ +# Study Domain CLAUDE.md + +## 개요 + +학습 세션(타이머) 관리 도메인. 공부 시작/종료, 시간 계산, 오늘의 학습 시간 조회를 담당한다. 모든 타임스탬프는 서버에서 관리하며, 시작 시 캠퍼스 Wi-Fi 검증을 수행한다. + +## 파일 구조 + +``` +study/ +├── api/ +│ └── StudySessionApi.java # Swagger 문서 인터페이스 +├── controller/ +│ └── StudySessionController.java # /api/v1/study/* +├── domain/ +│ ├── StudySession.java # 학습 세션 엔티티 +│ └── StudyStatus.java # enum: STARTED(진행중), FINISHED(완료) +├── dto/ +│ ├── request/ +│ │ ├── StudyStartRequest.java # record(gatewayIp, clientIp) — 클라이언트 타임스탬프 없음 +│ │ └── StudyEndRequest.java # record(studySessionId) +│ └── response/ +│ ├── StudyStartResponse.java # record(studySessionId) +│ └── StudySessionResponse.java # record(totalStudySession, isStudying) +├── repository/ +│ └── StudySessionRepository.java # JPA + 통계/랭킹 Native Query +└── service/ + └── StudySessionService.java # 핵심 비즈니스 로직 +``` + +## 핵심 비즈니스 로직 + +### 학습 세션 생명주기 + +``` +시작 요청 → Wi-Fi 검증 → 중복 세션 확인 → StudySession 생성 (STARTED, 서버 시간) + ↓ +종료 요청 → 세션 조회 → endTime 설정 (서버 시간) → totalMillis 계산 → FINISHED + +``` + +### 최대 공부시간 +``` +만약 사용자가 3시간 공부했다 -> 종료시켜야함 + +1. 서버에서 스케줄러를 통해 3시간 이상 진행중인 공부세션이 있는지 체크한다. +2. 만약 있다면, end_time을 공부시작 시간으로부터 3시간 이후로 설정한다. +3. FCM에 메세지를 보내서 클라이언트가 +``` + +### StudySession 엔티티 +```java +// 주요 필드 +Long id +LocalDateTime startTime // 서버에서 설정 +LocalDateTime endTime // 서버에서 설정 +Long totalMillis // Duration.between(startTime, endTime).toMillis() +StudyStatus status // STARTED → FINISHED +User user // @ManyToOne(LAZY) +``` + +- `startStudySession()`: startTime과 STARTED 상태 설정 +- `endStudySession()`: endTime 설정, totalMillis 계산, FINISHED로 전환. endTime < startTime이면 예외 + +### StudySessionService 주요 메서드 + +| 메서드 | 설명 | +|--------|------| +| `startStudySession()` | Wi-Fi 검증 → 중복 STARTED 세션 확인 → 새 세션 생성 | +| `endStudySession()` | 세션 조회 → 서버 시간으로 종료 처리 | +| `getTodayStudySession()` | 오늘 00:00~현재 총 공부 시간 + 현재 진행 중 여부 | +| `verifyCampusWifiConnection()` | CampusWiFiValidationService 호출, 결과에 따라 예외 발생 | + +### 서버 사이드 시간 관리 +클라이언트 타임스탬프를 **절대 사용하지 않는다**. 모든 시간은 `LocalDateTime.now()`로 서버에서 생성. +- `StudyStartRequest`에 시간 필드 없음 (gatewayIp, clientIp만) +- `StudyEndRequest`에 시간 필드 없음 (studySessionId만) + +## API 엔드포인트 + +모든 엔드포인트: `@AssignUserId` + `@PreAuthorize("isAuthenticated() and hasRole('USER')")` + +| Method | Path | 설명 | +|--------|------|------| +| GET | `/api/v1/study` | 오늘의 학습 시간 조회 | +| POST | `/api/v1/study/start` | 학습 세션 시작 | +| POST | `/api/v1/study/end` | 학습 세션 종료 | + +## Repository 쿼리 + +`StudySessionRepository`는 study 도메인 외에도 **랭킹, 통계 도메인에서 사용하는 Native Query**를 다수 포함: + +| 쿼리 메서드 | 사용처 | +|---|---| +| `findByIdAndUser_Id()`, `findByUser_IdAndStatus()` | Study 도메인 | +| `sumCompletedStudySessionByUserId()` | Study 도메인 (오늘 총 시간) | +| `calculateCurrentPeriodRanking()` | Rank 도메인 (실시간 개인 랭킹) | +| `calculateCurrentPeriodDepartmentRanking()` | Rank 도메인 (실시간 학과별 개인 랭킹) | +| `calculateCurrentDepartmentRanking()` | Rank 도메인 (실시간 학과 랭킹) | +| `calculateFinalizedPeriodRanking()` | Rank 도메인 (확정 개인 랭킹) | +| `calculateFinalizedDepartmentRanking()` | Rank 도메인 (확정 학과 랭킹) | +| `getTwoHourSlotStats()`, `getDayMaxFocusAndFullTime()` | Statistics 도메인 | +| `getWeeklyStatistics()`, `getMonthlyStatistics()` | Statistics 도메인 | +| `getGrassStatistics()` | Statistics 도메인 (잔디 차트) | + +실시간 랭킹 쿼리는 진행 중인 세션(`STARTED`)의 시간도 `LEAST/GREATEST`로 기간 겹침을 계산하여 포함한다. + +## 테스트 + +### Unit Tests (`StudySessionServiceTest`) +- Wi-Fi 검증 성공/실패(INVALID/ERROR) 시 예외 처리 +- 시간 계산: 정상(90분), 매우 짧은 세션(1초), 자정 넘김 +- 초기 세션 상태 검증 + +### Integration Tests (`StudySessionControllerIntegrationTest`) +- 정상 시작/종료 플로우 +- 인증 없이 요청 시 403, 잘못된 토큰 시 401 +- 오늘의 공부 기록 조회, 빈 응답, 다른 사용자 데이터 격리 +- 시작~종료 전체 플로우 E2E + +## 개발 시 주의사항 + +1. **시간은 반드시 서버에서** — 클라이언트 시간 파라미터 추가 금지 +2. **중복 세션 방지** — STARTED 상태 세션이 있으면 새 세션 생성 불가 +3. `StudySessionRepository`의 Native Query 수정 시 랭킹/통계 도메인에 영향 — 반드시 관련 테스트 실행 +4. Wi-Fi 검증은 `wifi` 도메인에 위임 — `CampusWiFiValidationService` 참조 diff --git a/src/main/java/com/gpt/geumpumtabackend/study/api/StudySessionApi.java b/src/main/java/com/gpt/geumpumtabackend/study/api/StudySessionApi.java index c53847a..9486c16 100644 --- a/src/main/java/com/gpt/geumpumtabackend/study/api/StudySessionApi.java +++ b/src/main/java/com/gpt/geumpumtabackend/study/api/StudySessionApi.java @@ -6,10 +6,8 @@ import com.gpt.geumpumtabackend.global.config.swagger.SwaggerApiSuccessResponse; import com.gpt.geumpumtabackend.global.exception.ExceptionType; import com.gpt.geumpumtabackend.global.response.ResponseBody; -import com.gpt.geumpumtabackend.study.dto.request.HeartBeatRequest; import com.gpt.geumpumtabackend.study.dto.request.StudyEndRequest; import com.gpt.geumpumtabackend.study.dto.request.StudyStartRequest; -import com.gpt.geumpumtabackend.study.dto.response.HeartBeatResponse; import com.gpt.geumpumtabackend.study.dto.response.StudySessionResponse; import com.gpt.geumpumtabackend.study.dto.response.StudyStartResponse; import io.swagger.v3.oas.annotations.Operation; @@ -27,17 +25,22 @@ @Tag(name = "학습 세션 API", description = """ 금오공대 캠퍼스 내에서만 사용할 수 있는 학습 타이머 기능을 제공합니다. - + 📋 **사용 흐름:** 1. `GET /api/v1/study` - 오늘 총 학습 시간 조회 2. `POST /api/v1/study/start` - 학습 시작 (Wi-Fi 검증 필수) 3. `POST /api/v1/study/heart-beat` - 30초마다 연결 상태 확인 4. `POST /api/v1/study/end` - 학습 종료 - + ⚠️ **중요사항:** - 모든 API는 캠퍼스 Wi-Fi 연결 시에만 작동 - 하트비트 중단시 90초 후 자동 세션 종료 + + ⏰ **최대 집중 시간 자동 종료:** + - 3시간 연속 공부 시 서버가 1초 주기로 감지하여 자동 종료합니다. + - 종료 시 FCM 푸시 알림(`type: STUDY_SESSION_FORCE_ENDED`)이 전송됩니다. (상세 페이로드는 FCM API 문서 참조) + - **클라이언트 필수 구현**: 앱이 포그라운드로 전환될 때마다 `GET /api/v1/study`를 호출하여 `isStudying` 값을 확인하고, `false`이면 타이머 UI를 중지해야 합니다. FCM 알림이 도달하지 않을 수 있으므로 이 폴링이 최종 안전장치입니다. """) public interface StudySessionApi { @@ -45,7 +48,7 @@ public interface StudySessionApi { summary = "오늘의 학습 시간 조회", description = """ 사용자의 오늘 하루 총 학습 시간을 조회합니다. - + 📊 **반환 정보:** - 오늘 00:00부터 현재까지의 누적 학습 시간 (밀리초) """ @@ -71,19 +74,19 @@ ResponseEntity> getTodayStudySession( summary = "학습 세션 시작", description = """ 새로운 학습 세션을 시작합니다. 캠퍼스 Wi-Fi 검증이 필수입니다. - + 🔐 **Wi-Fi 검증 과정:** 1. Gateway IP 검증 - 캠퍼스 게이트웨이 IP (172.30.64.1)와 일치하는지 확인 2. IP 대역 검증 - 클라이언트 IP가 캠퍼스 범위(172.30.64.0/18) 내인지 확인 - + 💡 **보안 특징:** - + - Gateway IP는 점-십진 표기법 문자열로 전송 (예: "172.30.64.1") - + ✅ **성공 시:** - 새로운 학습 세션 생성 - 세션 ID 반환 (하트비트에서 사용) - + ❌ **실패 사유:** - 캠퍼스 외부에서 접근 - Wi-Fi 정보 불일치 @@ -114,11 +117,16 @@ ResponseEntity> startStudySession( summary = "학습 세션 종료", description = """ 현재 진행 중인 학습 세션을 종료합니다. - + 📊 **종료 시 처리:** - 총 학습 시간 계산 및 저장 - 세션 상태를 FINISHED로 변경 - 랭킹 시스템에 반영 (다음 스케줄링 시) + - 배지 지급은 트랜잭션 커밋 이후 동기적으로 처리 + + 🎖️ **배지 확인 방법:** + - 이 API 응답에는 배지 정보가 포함되지 않습니다. + - 종료 성공 후 `GET /api/v1/badge/unnotified`를 호출해 새 배지를 조회하세요. """ ) @SwaggerApiResponses( @@ -137,43 +145,4 @@ ResponseEntity> endStudySession( @Valid @RequestBody StudyEndRequest request, @Parameter(hidden = true) Long userId ); - - @Operation( - summary = "하트비트 전송", - description = """ - 학습 중 연결 상태를 유지하기 위한 하트비트를 전송합니다. - - ⏱️ **전송 주기:** 30초마다 자동 전송 권장 - - 🔄 **동작 원리:** - 1. Wi-Fi 연결 상태 재검증 (Gateway IP + IP 대역 확인) - 2. 클라이언트 실제 IP 주소 재확인 (서버에서 추출) - 1. Wi-Fi 연결 상태 재검증 (Gateway IP + IP 대역 확인) - 2. 클라이언트 실제 IP 주소 재확인 (서버에서 추출) - 3. 최대 집중 시간(3시간) 초과 여부 확인 및 자동 세션 종료 - - 🚨 **실패 시 대응:** - - Wi-Fi 연결 끊김: 재연결 후 다시 `/start` 호출 - - 세션 만료: 새로운 세션 시작 필요 - - """ - ) - @ApiResponse(content = @Content(schema = @Schema(implementation = HeartBeatResponse.class))) - @SwaggerApiResponses( - success = @SwaggerApiSuccessResponse( - description = "하트비트 전송 성공 - 세션 유지"), - errors = { - @SwaggerApiFailedResponse(ExceptionType.NEED_AUTHORIZED), - @SwaggerApiFailedResponse(ExceptionType.USER_NOT_FOUND), - @SwaggerApiFailedResponse(ExceptionType.STUDY_SESSION_NOT_FOUND), - @SwaggerApiFailedResponse(ExceptionType.WIFI_NOT_CAMPUS_NETWORK), - @SwaggerApiFailedResponse(ExceptionType.WIFI_VALIDATION_ERROR) - } - ) - @PostMapping("/heart-beat") - @AssignUserId - @PreAuthorize("isAuthenticated() and hasRole('USER')") - ResponseEntity> processHeartBeat( - @Valid @RequestBody HeartBeatRequest heartBeatRequest, - @Parameter(hidden = true) Long userId); -} \ No newline at end of file +} diff --git a/src/main/java/com/gpt/geumpumtabackend/study/config/StudyProperties.java b/src/main/java/com/gpt/geumpumtabackend/study/config/StudyProperties.java new file mode 100644 index 0000000..bc9e5ec --- /dev/null +++ b/src/main/java/com/gpt/geumpumtabackend/study/config/StudyProperties.java @@ -0,0 +1,17 @@ +package com.gpt.geumpumtabackend.study.config; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Component +@ConfigurationProperties(prefix = "study") +@Getter +@Setter +public class StudyProperties { + /** + * 최대 집중 공부 시간 (시간 단위) + */ + private int maxFocusHours = 3; +} diff --git a/src/main/java/com/gpt/geumpumtabackend/study/controller/StudySessionController.java b/src/main/java/com/gpt/geumpumtabackend/study/controller/StudySessionController.java index 4f052f1..7176498 100644 --- a/src/main/java/com/gpt/geumpumtabackend/study/controller/StudySessionController.java +++ b/src/main/java/com/gpt/geumpumtabackend/study/controller/StudySessionController.java @@ -4,10 +4,8 @@ import com.gpt.geumpumtabackend.global.response.ResponseBody; import com.gpt.geumpumtabackend.global.response.ResponseUtil; import com.gpt.geumpumtabackend.study.api.StudySessionApi; -import com.gpt.geumpumtabackend.study.dto.request.HeartBeatRequest; import com.gpt.geumpumtabackend.study.dto.request.StudyEndRequest; import com.gpt.geumpumtabackend.study.dto.request.StudyStartRequest; -import com.gpt.geumpumtabackend.study.dto.response.HeartBeatResponse; import com.gpt.geumpumtabackend.study.dto.response.StudySessionResponse; import com.gpt.geumpumtabackend.study.dto.response.StudyStartResponse; import com.gpt.geumpumtabackend.study.service.StudySessionService; @@ -58,14 +56,4 @@ public ResponseEntity> endStudySession(@Valid @RequestBody St studySessionService.endStudySession(request, userId); return ResponseEntity.ok(ResponseUtil.createSuccessResponse()); } - - /* - 하트비트 수신 - */ - @PostMapping("/heart-beat") - @PreAuthorize("isAuthenticated() and hasRole('USER')") - @AssignUserId - public ResponseEntity> processHeartBeat(@Valid @RequestBody HeartBeatRequest heartBeatRequest, Long userId){ - return ResponseEntity.ok(ResponseUtil.createSuccessResponse(studySessionService.updateHeartBeat(heartBeatRequest, userId))); - } } diff --git a/src/main/java/com/gpt/geumpumtabackend/study/domain/StudySession.java b/src/main/java/com/gpt/geumpumtabackend/study/domain/StudySession.java index 3e13fec..82b25e7 100644 --- a/src/main/java/com/gpt/geumpumtabackend/study/domain/StudySession.java +++ b/src/main/java/com/gpt/geumpumtabackend/study/domain/StudySession.java @@ -1,5 +1,7 @@ package com.gpt.geumpumtabackend.study.domain; +import com.gpt.geumpumtabackend.global.exception.BusinessException; +import com.gpt.geumpumtabackend.global.exception.ExceptionType; import com.gpt.geumpumtabackend.user.domain.User; import jakarta.persistence.*; import lombok.Getter; @@ -32,9 +34,6 @@ public class StudySession { @JoinColumn(name = "user_id", nullable = false) private User user; - - private LocalDateTime heartBeatAt; - public void startStudySession(LocalDateTime startTime, User user) { this.startTime = startTime; this.user = user; @@ -42,12 +41,15 @@ public void startStudySession(LocalDateTime startTime, User user) { } public void endStudySession(LocalDateTime endTime) { + if(endTime.isBefore(startTime)) + throw new BusinessException(ExceptionType.INVALID_END_TIME); this.endTime = endTime; status = StudyStatus.FINISHED; this.totalMillis = Duration.between(this.startTime, this.endTime).toMillis(); } - - public void updateHeartBeatAt(LocalDateTime heartBeatAt) { - this.heartBeatAt = heartBeatAt; + public void endMaxFocusStudySession(int maxFocusTime) { + this.endTime = this.startTime.plusMinutes(maxFocusTime); + status = StudyStatus.FINISHED; + this.totalMillis = Duration.between(this.startTime, this.endTime).toMillis(); } } diff --git a/src/main/java/com/gpt/geumpumtabackend/study/dto/request/HeartBeatRequest.java b/src/main/java/com/gpt/geumpumtabackend/study/dto/request/HeartBeatRequest.java deleted file mode 100644 index 9c5bada..0000000 --- a/src/main/java/com/gpt/geumpumtabackend/study/dto/request/HeartBeatRequest.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.gpt.geumpumtabackend.study.dto.request; - -import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; - -@Schema(description = "학습 세션 하트비트 요청") -public record HeartBeatRequest( - @Schema(description = "학습 세션 ID", example = "1") - @NotNull(message = "sessionId는 필수입니다") - Long sessionId, - - @Schema(description = "캠퍼스 네트워크 게이트웨이 IP 주소", example = "172.30.64.1") - @NotBlank(message = "Gateway IP는 필수입니다") - String gatewayIp, - - @Schema(description = "클라이언트 IP 주소", example = "192.168.1.100") - @NotBlank(message = "Client IP는 필수입니다") - String clientIp -) { - -} diff --git a/src/main/java/com/gpt/geumpumtabackend/study/dto/response/HeartBeatResponse.java b/src/main/java/com/gpt/geumpumtabackend/study/dto/response/HeartBeatResponse.java deleted file mode 100644 index 3c9da01..0000000 --- a/src/main/java/com/gpt/geumpumtabackend/study/dto/response/HeartBeatResponse.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.gpt.geumpumtabackend.study.dto.response; - -public record HeartBeatResponse(boolean sessionActive, String message) { -} diff --git a/src/main/java/com/gpt/geumpumtabackend/study/dto/response/StudySessionResponse.java b/src/main/java/com/gpt/geumpumtabackend/study/dto/response/StudySessionResponse.java index 10fd100..d012a5b 100644 --- a/src/main/java/com/gpt/geumpumtabackend/study/dto/response/StudySessionResponse.java +++ b/src/main/java/com/gpt/geumpumtabackend/study/dto/response/StudySessionResponse.java @@ -1,8 +1,8 @@ package com.gpt.geumpumtabackend.study.dto.response; -public record StudySessionResponse(Long totalStudySession) { +public record StudySessionResponse(Long totalStudySession, boolean isStudying) { - public static StudySessionResponse of(Long totalStudySession) { - return new StudySessionResponse(totalStudySession); + public static StudySessionResponse of(Long totalStudySession, boolean isStudying) { + return new StudySessionResponse(totalStudySession, isStudying); } } diff --git a/src/main/java/com/gpt/geumpumtabackend/study/event/StudyBadgeGrantEventListener.java b/src/main/java/com/gpt/geumpumtabackend/study/event/StudyBadgeGrantEventListener.java new file mode 100644 index 0000000..b70637c --- /dev/null +++ b/src/main/java/com/gpt/geumpumtabackend/study/event/StudyBadgeGrantEventListener.java @@ -0,0 +1,25 @@ +package com.gpt.geumpumtabackend.study.event; + +import com.gpt.geumpumtabackend.badge.service.BadgeService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Component +@RequiredArgsConstructor +@Slf4j +public class StudyBadgeGrantEventListener { + + private final BadgeService badgeService; + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleStudySessionEnded(StudySessionEndedEvent event) { + try { + badgeService.grantStudyAchievementBadges(event.userId()); + } catch (Exception e) { + log.warn("배지 지급 실패 - userId={}", event.userId(), e); + } + } +} diff --git a/src/main/java/com/gpt/geumpumtabackend/study/event/StudySessionEndedEvent.java b/src/main/java/com/gpt/geumpumtabackend/study/event/StudySessionEndedEvent.java new file mode 100644 index 0000000..12eb793 --- /dev/null +++ b/src/main/java/com/gpt/geumpumtabackend/study/event/StudySessionEndedEvent.java @@ -0,0 +1,4 @@ +package com.gpt.geumpumtabackend.study.event; + +public record StudySessionEndedEvent(Long userId) { +} diff --git a/src/main/java/com/gpt/geumpumtabackend/study/repository/StudySessionRepository.java b/src/main/java/com/gpt/geumpumtabackend/study/repository/StudySessionRepository.java index da5ad46..26d6067 100644 --- a/src/main/java/com/gpt/geumpumtabackend/study/repository/StudySessionRepository.java +++ b/src/main/java/com/gpt/geumpumtabackend/study/repository/StudySessionRepository.java @@ -4,6 +4,8 @@ import com.gpt.geumpumtabackend.rank.dto.PersonalRankingTemp; import com.gpt.geumpumtabackend.statistics.dto.*; import com.gpt.geumpumtabackend.study.domain.StudySession; +import com.gpt.geumpumtabackend.study.domain.StudyStatus; +import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -21,6 +23,7 @@ public interface StudySessionRepository extends JpaRepository findByIdAndUser_Id(Long id, Long userId); + Optional findByUser_IdAndStatus(Long userId, StudyStatus status); // 날짜가 오늘이고, userId와 일치하고, endTime이 null이 아닌 것 @Query(value = "SELECT COALESCE(SUM(s.total_millis), 0) " + @@ -32,9 +35,16 @@ Long sumCompletedStudySessionByUserId( @Param("startOfDay") LocalDateTime startOfDay, @Param("endOfDay") LocalDateTime endOfDay); - @Query(value = "SELECT s FROM StudySession s " + - "WHERE s.status = 'STARTED' AND s.heartBeatAt < :threshold") - List findAllZombieSession(@Param("threshold") LocalDateTime threshold); + @Query(value = "SELECT COALESCE(SUM(s.total_millis), 0) " + + "FROM study_session s " + + "WHERE s.user_id = :userId " + + "AND s.end_time IS NOT NULL", nativeQuery = true) + Long sumTotalStudyMillisByUserId(@Param("userId") Long userId); + + + + @EntityGraph(attributePaths = {"user"}) + List findAllByStatusAndStartTimeBefore(StudyStatus status, LocalDateTime now); /* 현재 진행중인 기간의 공부 시간 연산 @@ -48,7 +58,7 @@ Long sumCompletedStudySessionByUserId( TIMESTAMPDIFF(MICROSECOND, GREATEST(s.start_time, :periodStart), CASE - WHEN s.end_time IS NULL THEN :now + WHEN s.end_time IS NULL THEN LEAST(:now, :periodEnd) WHEN s.end_time > :periodEnd THEN :periodEnd ELSE s.end_time END @@ -58,7 +68,7 @@ Long sumCompletedStudySessionByUserId( TIMESTAMPDIFF(MICROSECOND, GREATEST(s.start_time, :periodStart), CASE - WHEN s.end_time IS NULL THEN :now + WHEN s.end_time IS NULL THEN LEAST(:now, :periodEnd) WHEN s.end_time > :periodEnd THEN :periodEnd ELSE s.end_time END @@ -73,7 +83,7 @@ Long sumCompletedStudySessionByUserId( ORDER BY COALESCE(SUM(TIMESTAMPDIFF(MICROSECOND, GREATEST(s.start_time, :periodStart), CASE - WHEN s.end_time IS NULL THEN :now + WHEN s.end_time IS NULL THEN LEAST(:now, :periodEnd) WHEN s.end_time > :periodEnd THEN :periodEnd ELSE s.end_time END @@ -86,8 +96,56 @@ List calculateCurrentPeriodRanking( @Param("now") LocalDateTime now ); - - + /* + 현재 진행중인 기간의 학과별 공부 시간 연산 + */ + @Query(value = """ + SELECT u.id as userId, + u.nickname as nickname, + u.picture as imageUrl, + u.department as department, + CAST(COALESCE(SUM( + TIMESTAMPDIFF(MICROSECOND, + GREATEST(s.start_time, :periodStart), + CASE + WHEN s.end_time IS NULL THEN LEAST(:now, :periodEnd) + WHEN s.end_time > :periodEnd THEN :periodEnd + ELSE s.end_time + END + ) / 1000 + ), 0) AS SIGNED) as totalMillis, + RANK() OVER (ORDER BY COALESCE(SUM( + TIMESTAMPDIFF(MICROSECOND, + GREATEST(s.start_time, :periodStart), + CASE + WHEN s.end_time IS NULL THEN LEAST(:now, :periodEnd) + WHEN s.end_time > :periodEnd THEN :periodEnd + ELSE s.end_time + END + ) / 1000 + ), 0) DESC) as ranking + FROM user u + LEFT JOIN study_session s ON u.id = s.user_id + AND s.start_time <= :periodEnd + AND (s.end_time >= :periodStart OR s.end_time IS NULL) + WHERE u.role = 'USER' AND u.department = :department + GROUP BY u.id, u.nickname, u.picture, u.department + ORDER BY COALESCE(SUM(TIMESTAMPDIFF(MICROSECOND, + GREATEST(s.start_time, :periodStart), + CASE + WHEN s.end_time IS NULL THEN LEAST(:now, :periodEnd) + WHEN s.end_time > :periodEnd THEN :periodEnd + ELSE s.end_time + END + ) / 1000), 0) DESC + LIMIT 100 +""", nativeQuery = true) + List calculateCurrentPeriodDepartmentRanking( + @Param("periodStart") LocalDateTime periodStart, + @Param("periodEnd") LocalDateTime periodEnd, + @Param("now") LocalDateTime now, + @Param("department") String department + ); /* 랭킹 집계 시 공부 시간 @@ -141,47 +199,79 @@ List calculateFinalizedPeriodRanking( ); @Query(value = """ - SELECT - department, - CAST(SUM(totalMillis) AS SIGNED) as totalMillis, - RANK() OVER (ORDER BY SUM(totalMillis) DESC) as ranking - FROM ( - SELECT - u.department, - u.id as userId, - COALESCE(SUM( - TIMESTAMPDIFF(MICROSECOND, - GREATEST(s.start_time, :periodStart), - CASE - WHEN s.end_time IS NULL THEN :now - WHEN s.end_time > :periodEnd THEN :periodEnd - ELSE s.end_time - END - ) / 1000 - ), 0) as totalMillis, - ROW_NUMBER() OVER ( - PARTITION BY u.department - ORDER BY COALESCE(SUM( + WITH all_departments AS ( + SELECT 'ARCHITECTURE_ENGINEERING' as dept + UNION ALL SELECT 'ARCHITECTURE' + UNION ALL SELECT 'CIVIL_ENGINEERING' + UNION ALL SELECT 'ENVIRONMENTAL_ENGINEERING' + UNION ALL SELECT 'MECHANICAL_ENGINEERING' + UNION ALL SELECT 'MECHANICAL_SYSTEMS_ENGINEERING' + UNION ALL SELECT 'SMART_MOBILITY' + UNION ALL SELECT 'INDUSTRIAL_ENGINEERING' + UNION ALL SELECT 'APPLIED_MATH_BIGDATA' + UNION ALL SELECT 'POLYMER_ENGINEERING' + UNION ALL SELECT 'MATERIALS_ENGINEERING' + UNION ALL SELECT 'SEMICONDUCTOR_SYSTEMS' + UNION ALL SELECT 'ELECTRONIC_SYSTEMS' + UNION ALL SELECT 'SOFTWARE' + UNION ALL SELECT 'ARTIFICIAL_INTELLIGENCE' + UNION ALL SELECT 'COMPUTER_ENGINEERING' + UNION ALL SELECT 'MATERIALS_DESIGN_ENGINEERING' + UNION ALL SELECT 'CHEMICAL_ENGINEERING' + UNION ALL SELECT 'CHEMICAL_BIO_MATERIALS' + UNION ALL SELECT 'OPTICAL_SYSTEMS' + UNION ALL SELECT 'BIOMEDICAL_ENGINEERING' + UNION ALL SELECT 'IT_CONVERGENCE' + UNION ALL SELECT 'LIBERAL_MAJOR' + UNION ALL SELECT 'BUSINESS_ADMINISTRATION' + ), + dept_rankings AS ( + SELECT + department, + CAST(SUM(totalMillis) AS SIGNED) as totalMillis + FROM ( + SELECT + u.department, + u.id as userId, + COALESCE(SUM( TIMESTAMPDIFF(MICROSECOND, GREATEST(s.start_time, :periodStart), CASE - WHEN s.end_time IS NULL THEN :now + WHEN s.end_time IS NULL THEN LEAST(:now, :periodEnd) WHEN s.end_time > :periodEnd THEN :periodEnd ELSE s.end_time END ) / 1000 - ), 0) DESC - ) as deptRank - FROM user u - LEFT JOIN study_session s ON u.id = s.user_id - AND s.start_time <= :periodEnd - AND (s.end_time >= :periodStart OR s.end_time IS NULL) - WHERE u.role = 'USER' AND u.department IS NOT NULL - GROUP BY u.department, u.id - ) ranked_users - WHERE deptRank <= 30 - GROUP BY department - ORDER BY SUM(totalMillis) DESC + ), 0) as totalMillis, + ROW_NUMBER() OVER ( + PARTITION BY u.department + ORDER BY COALESCE(SUM( + TIMESTAMPDIFF(MICROSECOND, + GREATEST(s.start_time, :periodStart), + CASE + WHEN s.end_time IS NULL THEN LEAST(:now, :periodEnd) + WHEN s.end_time > :periodEnd THEN :periodEnd + ELSE s.end_time + END + ) / 1000 + ), 0) DESC + ) as deptRank + FROM user u + LEFT JOIN study_session s ON u.id = s.user_id + AND s.start_time <= :periodEnd + AND (s.end_time >= :periodStart OR s.end_time IS NULL) + WHERE u.role = 'USER' AND u.department IS NOT NULL + GROUP BY u.department, u.id + ) ranked_users + WHERE deptRank <= 30 + GROUP BY department + ) + SELECT d.dept as department, + COALESCE(dr.totalMillis, 0) as totalMillis, + RANK() OVER (ORDER BY COALESCE(dr.totalMillis, 0) DESC) as ranking + FROM all_departments d + LEFT JOIN dept_rankings dr ON d.dept = dr.department + ORDER BY COALESCE(dr.totalMillis, 0) DESC """, nativeQuery = true) List calculateCurrentDepartmentRanking( @Param("periodStart") LocalDateTime periodStart, @@ -189,349 +279,74 @@ List calculateCurrentDepartmentRanking( @Param("now") LocalDateTime now); @Query(value = """ - SELECT - department, - CAST(SUM(totalMillis) AS SIGNED) as totalMillis, - RANK() OVER (ORDER BY SUM(totalMillis) DESC) as ranking - FROM ( - SELECT - u.department, - u.id as userId, - COALESCE(SUM( - TIMESTAMPDIFF(MICROSECOND, - GREATEST(s.start_time, :periodStart), - LEAST(s.end_time, :periodEnd) - ) / 1000 - ), 0) as totalMillis, - ROW_NUMBER() OVER ( - PARTITION BY u.department - ORDER BY COALESCE(SUM( + WITH all_departments AS ( + SELECT 'ARCHITECTURE_ENGINEERING' as dept + UNION ALL SELECT 'ARCHITECTURE' + UNION ALL SELECT 'CIVIL_ENGINEERING' + UNION ALL SELECT 'ENVIRONMENTAL_ENGINEERING' + UNION ALL SELECT 'MECHANICAL_ENGINEERING' + UNION ALL SELECT 'MECHANICAL_SYSTEMS_ENGINEERING' + UNION ALL SELECT 'SMART_MOBILITY' + UNION ALL SELECT 'INDUSTRIAL_ENGINEERING' + UNION ALL SELECT 'APPLIED_MATH_BIGDATA' + UNION ALL SELECT 'POLYMER_ENGINEERING' + UNION ALL SELECT 'MATERIALS_ENGINEERING' + UNION ALL SELECT 'SEMICONDUCTOR_SYSTEMS' + UNION ALL SELECT 'ELECTRONIC_SYSTEMS' + UNION ALL SELECT 'SOFTWARE' + UNION ALL SELECT 'ARTIFICIAL_INTELLIGENCE' + UNION ALL SELECT 'COMPUTER_ENGINEERING' + UNION ALL SELECT 'MATERIALS_DESIGN_ENGINEERING' + UNION ALL SELECT 'CHEMICAL_ENGINEERING' + UNION ALL SELECT 'CHEMICAL_BIO_MATERIALS' + UNION ALL SELECT 'OPTICAL_SYSTEMS' + UNION ALL SELECT 'BIOMEDICAL_ENGINEERING' + UNION ALL SELECT 'IT_CONVERGENCE' + UNION ALL SELECT 'LIBERAL_MAJOR' + UNION ALL SELECT 'BUSINESS_ADMINISTRATION' + ), + dept_rankings AS ( + SELECT + department, + CAST(SUM(totalMillis) AS SIGNED) as totalMillis + FROM ( + SELECT + u.department, + u.id as userId, + COALESCE(SUM( TIMESTAMPDIFF(MICROSECOND, GREATEST(s.start_time, :periodStart), LEAST(s.end_time, :periodEnd) ) / 1000 - ), 0) DESC - ) as deptRank - FROM user u - LEFT JOIN study_session s ON u.id = s.user_id - AND s.start_time <= :periodEnd - AND s.end_time >= :periodStart - WHERE u.role = 'USER' AND u.department IS NOT NULL - GROUP BY u.department, u.id - ) ranked_users - WHERE deptRank <= 30 - GROUP BY department - ORDER BY SUM(totalMillis) DESC + ), 0) as totalMillis, + ROW_NUMBER() OVER ( + PARTITION BY u.department + ORDER BY COALESCE(SUM( + TIMESTAMPDIFF(MICROSECOND, + GREATEST(s.start_time, :periodStart), + LEAST(s.end_time, :periodEnd) + ) / 1000 + ), 0) DESC + ) as deptRank + FROM user u + LEFT JOIN study_session s ON u.id = s.user_id + AND s.start_time <= :periodEnd + AND s.end_time >= :periodStart + WHERE u.role = 'USER' AND u.department IS NOT NULL + GROUP BY u.department, u.id + ) ranked_users + WHERE deptRank <= 30 + GROUP BY department + ) + SELECT d.dept as department, + COALESCE(dr.totalMillis, 0) as totalMillis, + RANK() OVER (ORDER BY COALESCE(dr.totalMillis, 0) DESC) as ranking + FROM all_departments d + LEFT JOIN dept_rankings dr ON d.dept = dr.department + ORDER BY COALESCE(dr.totalMillis, 0) DESC """, nativeQuery = true) List calculateFinalizedDepartmentRanking( @Param("periodStart") LocalDateTime periodStart, @Param("periodEnd") LocalDateTime periodEnd ); - - // 2시간 단위로 일일 통계를 불러옴 - @Query( - value = """ - WITH RECURSIVE buckets AS ( - SELECT - 0 AS idx, - :dayStart AS bucket_start, - :dayStart + INTERVAL 2 HOUR AS bucket_end - UNION ALL - SELECT - idx + 1, - bucket_end, - bucket_end + INTERVAL 2 HOUR - FROM buckets - WHERE idx < 11 - ) - SELECT - DATE_FORMAT(b.bucket_start, '%H:%i') AS slotStart, - DATE_FORMAT(b.bucket_end, '%H:%i') AS slotEnd, - COALESCE(SUM( - GREATEST( - 0, - TIMESTAMPDIFF( - SECOND, - GREATEST(s.start_time, b.bucket_start), - LEAST(s.end_time, b.bucket_end) - ) - ) - ), 0) AS secondsStudied - FROM buckets b - LEFT JOIN study_session s - ON s.user_id = :userId - AND s.start_time < b.bucket_end - AND s.end_time > b.bucket_start - GROUP BY b.idx, b.bucket_start, b.bucket_end - ORDER BY b.idx - """, - nativeQuery = true - ) - List getTwoHourSlotStats( - @Param("dayStart") LocalDateTime dayStart, - @Param("dayEnd") LocalDateTime dayEnd, - @Param("userId") Long userId - ); - - - @Query(value = """ - WITH clipped AS ( - SELECT GREATEST( - 0, - TIMESTAMPDIFF( - SECOND, - GREATEST(s.start_time, :dayStart), - LEAST(s.end_time, :dayEnd) - ) - ) AS overlap_sec - FROM study_session s - WHERE s.user_id = :userId - AND s.start_time < :dayEnd - AND s.end_time > :dayStart - ) - SELECT - CAST(COALESCE(SUM(c.overlap_sec), 0) AS SIGNED) AS totalStudySeconds, - CAST(COALESCE(MAX(c.overlap_sec), 0) AS SIGNED) AS maxFocusSeconds - FROM clipped c - """, nativeQuery = true) - DayMaxFocusAndFullTimeStatistics getDayMaxFocusAndFullTime( - @Param("dayStart") LocalDateTime dayStart, - @Param("dayEnd") LocalDateTime dayEnd, - @Param("userId") Long userId - ); - - @Query(value = """ - WITH RECURSIVE days AS ( - SELECT 0 AS day_idx, - :weekStart AS day_start, - DATE_ADD(:weekStart, INTERVAL 1 DAY) AS day_end - UNION ALL - SELECT day_idx + 1, - DATE_ADD(:weekStart, INTERVAL day_idx + 1 DAY), - DATE_ADD(:weekStart, INTERVAL day_idx + 2 DAY) - FROM days - WHERE day_idx < 6 - ), - per_day AS ( - SELECT - d.day_idx, - CAST( - COALESCE( - SUM( - GREATEST( - 0, - TIMESTAMPDIFF( - SECOND, - GREATEST(s.start_time, d.day_start), - LEAST(COALESCE(s.end_time, d.day_end), d.day_end) - ) - ) - ), - 0 - ) AS SIGNED - ) AS day_seconds - FROM days d - LEFT JOIN study_session s - ON s.user_id = :userId - AND s.start_time < d.day_end - AND s.end_time > d.day_start - GROUP BY d.day_idx - ), - flags AS ( - SELECT - day_idx, - day_seconds, - CASE WHEN day_seconds > 0 THEN 1 ELSE 0 END AS has_study - FROM per_day - ), - breaks AS ( - SELECT - day_idx, - day_seconds, - has_study, - SUM(CASE WHEN has_study = 0 THEN 1 ELSE 0 END) - OVER (ORDER BY day_idx) AS zero_grp - FROM flags - ), - streaks AS ( - SELECT zero_grp, COUNT(*) AS streak_len - FROM breaks - WHERE has_study = 1 - GROUP BY zero_grp - ) - SELECT - /* 주간 총 공부시간(초) */ - (SELECT CAST(COALESCE(SUM(day_seconds), 0) AS SIGNED) FROM per_day) AS totalWeekSeconds, - /* 주간 최장 연속 공부일수 */ - COALESCE((SELECT MAX(streak_len) FROM streaks), 0) AS maxConsecutiveStudyDays, - /* 7일 평균(초) — 소수점 버림 */ - CAST(((SELECT COALESCE(SUM(day_seconds), 0) FROM per_day) / 7) AS SIGNED) AS averageDailySeconds - """, nativeQuery = true) - WeeklyStatistics getWeeklyStatistics( - @Param("weekStart") LocalDateTime weekStart, - @Param("userId") Long userId - ); - - @Query(value = """ - WITH RECURSIVE - bounds AS ( - SELECT - :monthStart AS start_at, - DATE_ADD(LAST_DAY(:monthStart), INTERVAL 1 DAY) AS end_at, - TIMESTAMPDIFF( - DAY, :monthStart, - DATE_ADD(LAST_DAY(:monthStart), INTERVAL 1 DAY) - ) AS days_cnt - ), - /* 월 전체 일자를 day_idx=0..(days_cnt-1)로 생성 */ - days AS ( - SELECT - 0 AS day_idx, - b.start_at AS day_start, - LEAST(DATE_ADD(b.start_at, INTERVAL 1 DAY), b.end_at) AS day_end - FROM bounds b - UNION ALL - SELECT - d.day_idx + 1, - DATE_ADD(d.day_start, INTERVAL 1 DAY), - LEAST(DATE_ADD(d.day_start, INTERVAL 2 DAY), b.end_at) - FROM days d - JOIN bounds b - ON d.day_end < b.end_at - ), - /* 일자별 공부 총합(초) */ - per_day AS ( - SELECT - d.day_idx, - CAST( - COALESCE( - SUM( - GREATEST( - 0, - TIMESTAMPDIFF( - SECOND, - GREATEST(s.start_time, d.day_start), - LEAST(COALESCE(s.end_time, d.day_end), d.day_end) - ) - ) - ), 0 - ) AS SIGNED - ) AS day_seconds - FROM days d - LEFT JOIN study_session s - ON s.user_id = :userId - AND s.start_time < d.day_end - AND s.end_time > d.day_start - GROUP BY d.day_idx - ), - flags AS ( - SELECT - day_idx, - day_seconds, - CASE WHEN day_seconds > 0 THEN 1 ELSE 0 END AS has_study - FROM per_day - ), - breaks AS ( - /* 0(공부 안 한 날)을 경계로 그룹을 나눠 연속 구간 식별 */ - SELECT - day_idx, - day_seconds, - has_study, - SUM(CASE WHEN has_study = 0 THEN 1 ELSE 0 END) - OVER (ORDER BY day_idx) AS zero_grp - FROM flags - ), - streaks AS ( - SELECT zero_grp, COUNT(*) AS streak_len - FROM breaks - WHERE has_study = 1 - GROUP BY zero_grp - ) - SELECT - /* 총 공부시간(초) */ - CAST(COALESCE((SELECT SUM(day_seconds) FROM per_day), 0) AS SIGNED) AS totalMonthSeconds, - /* 월 일수로 나눈 일일 평균(초; 소수 버림) */ - CAST( (COALESCE((SELECT SUM(day_seconds) FROM per_day), 0) - / NULLIF((SELECT days_cnt FROM bounds), 0)) AS SIGNED) AS averageDailySeconds, - /* 최장 연속 공부 일수 */ - COALESCE((SELECT MAX(streak_len) FROM streaks), 0) AS maxConsecutiveStudyDays, - /* 이번 달 공부 일수(>0초) */ - (SELECT COUNT(*) FROM per_day WHERE day_seconds > 0) AS studiedDays - """, nativeQuery = true) - MonthlyStatistics getMonthlyStatistics( - @Param("monthStart") LocalDateTime monthStart, // 해당 월 1일 00:00 - @Param("userId") Long userId - ); - - - @Query(value = """ - WITH RECURSIVE - bounds AS ( - SELECT DATE(:monthStart) AS start_at, - DATE(:monthEnd) AS end_at_exclusive - ), - days AS ( - SELECT b.start_at AS day_date, - CAST(b.start_at AS DATETIME) AS day_start, - CAST(DATE_ADD(b.start_at, INTERVAL 1 DAY) AS DATETIME) AS day_end - FROM bounds b - UNION ALL - SELECT DATE_ADD(d.day_date, INTERVAL 1 DAY), - DATE_ADD(d.day_start, INTERVAL 1 DAY), - DATE_ADD(d.day_end, INTERVAL 1 DAY) - FROM days d - JOIN bounds b ON d.day_date < b.end_at_exclusive - ), - sessions_in_window AS ( - SELECT s.user_id, s.start_time, s.end_time - FROM study_session s - JOIN bounds b - ON s.user_id = :userId - AND s.end_time > b.start_at - AND s.start_time < b.end_at_exclusive - AND s.end_time IS NOT NULL - ), - day_overlap AS ( - SELECT d.day_date, - GREATEST(s.start_time, d.day_start) AS seg_start, - LEAST(s.end_time, d.day_end) AS seg_end - FROM days d - JOIN sessions_in_window s - ON s.end_time > d.day_start - AND s.start_time < d.day_end - ), - daily_sec AS ( - SELECT day_date AS date, - GREATEST(SUM(GREATEST(TIMESTAMPDIFF(SECOND, seg_start, seg_end), 0)), 0) AS total_seconds - FROM day_overlap - GROUP BY day_date - ), - daily_full AS ( - SELECT d.day_date AS date, - COALESCE(ds.total_seconds, 0) AS total_seconds - FROM days d - LEFT JOIN daily_sec ds ON ds.date = d.day_date - ), - leveled AS ( - SELECT date, - CASE - WHEN total_seconds = 0 THEN 0 - ELSE NTILE(4) OVER ( - PARTITION BY YEAR(date), MONTH(date) - ORDER BY total_seconds - ) - END AS level - FROM daily_full - ) - SELECT - DATE_FORMAT(date, '%Y-%m-%d') AS date, - CAST(level AS UNSIGNED) AS level - FROM leveled - ORDER BY date - """, nativeQuery = true) - List getGrassStatistics( - @Param("monthStart") LocalDate monthStart, - @Param("monthEnd") LocalDate monthEnd, - @Param("userId") Long userId - ); } diff --git a/src/main/java/com/gpt/geumpumtabackend/study/scheduler/MaxFocusStudyScheduler.java b/src/main/java/com/gpt/geumpumtabackend/study/scheduler/MaxFocusStudyScheduler.java new file mode 100644 index 0000000..d1c9fab --- /dev/null +++ b/src/main/java/com/gpt/geumpumtabackend/study/scheduler/MaxFocusStudyScheduler.java @@ -0,0 +1,39 @@ +package com.gpt.geumpumtabackend.study.scheduler; + +import com.gpt.geumpumtabackend.fcm.service.FcmService; +import com.gpt.geumpumtabackend.study.config.StudyProperties; +import com.gpt.geumpumtabackend.study.service.StudySessionService; +import com.gpt.geumpumtabackend.user.domain.User; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +@Slf4j +public class MaxFocusStudyScheduler { + + private final StudySessionService studySessionService; + private final FcmService fcmService; + private final StudyProperties studyProperties; + + @Scheduled(fixedRate = 1000) + public void checkAndFinishMaxFocusSessions() { + try { + List usersToNotify = studySessionService.endExpiredMaxFocusSessions(); + + int maxFocusHours = studyProperties.getMaxFocusHours(); + for (User user : usersToNotify) { + try { + fcmService.sendMaxFocusNotification(user, maxFocusHours); + } catch (Exception e) { + log.error("Failed to send FCM max focus notification for user {}", user.getId(), e); + } + } + } catch (Exception e) { + log.error("[MAX_FOCUS_SCHEDULER] Failed to check max focus sessions", e); + } + } +} diff --git a/src/main/java/com/gpt/geumpumtabackend/study/service/StudySessionService.java b/src/main/java/com/gpt/geumpumtabackend/study/service/StudySessionService.java index 493c558..0fdea44 100644 --- a/src/main/java/com/gpt/geumpumtabackend/study/service/StudySessionService.java +++ b/src/main/java/com/gpt/geumpumtabackend/study/service/StudySessionService.java @@ -1,28 +1,28 @@ package com.gpt.geumpumtabackend.study.service; - import com.gpt.geumpumtabackend.global.exception.BusinessException; import com.gpt.geumpumtabackend.global.exception.ExceptionType; +import com.gpt.geumpumtabackend.study.config.StudyProperties; import com.gpt.geumpumtabackend.study.domain.StudySession; -import com.gpt.geumpumtabackend.study.dto.request.HeartBeatRequest; +import com.gpt.geumpumtabackend.study.domain.StudyStatus; import com.gpt.geumpumtabackend.study.dto.request.StudyEndRequest; import com.gpt.geumpumtabackend.study.dto.request.StudyStartRequest; -import com.gpt.geumpumtabackend.study.dto.response.HeartBeatResponse; import com.gpt.geumpumtabackend.study.dto.response.StudySessionResponse; import com.gpt.geumpumtabackend.study.dto.response.StudyStartResponse; +import com.gpt.geumpumtabackend.study.event.StudySessionEndedEvent; import com.gpt.geumpumtabackend.study.repository.StudySessionRepository; import com.gpt.geumpumtabackend.user.domain.User; import com.gpt.geumpumtabackend.user.repository.UserRepository; import com.gpt.geumpumtabackend.wifi.dto.WiFiValidationResult; import com.gpt.geumpumtabackend.wifi.service.CampusWiFiValidationService; - import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; - -import java.time.Duration; import java.time.LocalDate; import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; @Service @RequiredArgsConstructor @@ -32,15 +32,18 @@ public class StudySessionService { private final StudySessionRepository studySessionRepository; private final UserRepository userRepository; private final CampusWiFiValidationService wifiValidationService; - private static final Integer MAX_FOCUS_TIME = 3; + private final StudyProperties studyProperties; + private final ApplicationEventPublisher eventPublisher; + /* 메인 홈 */ public StudySessionResponse getTodayStudySession(Long userId) { LocalDateTime startOfDay = LocalDate.now().atStartOfDay(); LocalDateTime now = LocalDateTime.now(); + boolean isStudying = studySessionRepository.findByUser_IdAndStatus(userId, StudyStatus.STARTED).isPresent(); Long totalStudySession = studySessionRepository.sumCompletedStudySessionByUserId(userId, startOfDay, now); - return StudySessionResponse.of(totalStudySession); + return StudySessionResponse.of(totalStudySession,isStudying); } /* @@ -48,24 +51,8 @@ public StudySessionResponse getTodayStudySession(Long userId) { */ @Transactional public StudyStartResponse startStudySession(StudyStartRequest request, Long userId) { - // Wi-Fi 검증 - WiFiValidationResult validationResult = wifiValidationService.validateFromCache( - request.gatewayIp(), request.clientIp() - ); - - if (!validationResult.isValid()) { - log.warn("Wi-Fi validation failed for user {}: {}", userId, validationResult.getMessage()); - throw mapWiFiValidationException(validationResult); - } - - // 검증 성공 시 학습 세션 시작 - StudySession studySession = new StudySession(); - User user = userRepository.findById(userId) - .orElseThrow(()->new BusinessException(ExceptionType.USER_NOT_FOUND)); - LocalDateTime startTime = LocalDateTime.now(); - studySession.startStudySession(startTime, user); - - StudySession savedSession = studySessionRepository.save(studySession); + verifyCampusWifiConnection(request, userId); + StudySession savedSession = makeStudySession(userId); return StudyStartResponse.fromEntity(savedSession); } @@ -78,45 +65,56 @@ public void endStudySession(StudyEndRequest endRequest, Long userId) { .orElseThrow(()->new BusinessException(ExceptionType.STUDY_SESSION_NOT_FOUND)); LocalDateTime endTime = LocalDateTime.now(); studysession.endStudySession(endTime); + eventPublisher.publishEvent(new StudySessionEndedEvent(userId)); } - - /* - 하트비트 처리 - */ - @Transactional - public HeartBeatResponse updateHeartBeat(HeartBeatRequest heartBeatRequest, Long userId) { - Long sessionId = heartBeatRequest.sessionId(); - - // Wi-Fi 검증 (캐시 우선 사용) - WiFiValidationResult validationResult = wifiValidationService.validateFromCache( - heartBeatRequest.gatewayIp(), heartBeatRequest.clientIp() + private BusinessException mapWiFiValidationException(WiFiValidationResult result) { + return switch (result.getStatus()) { + case INVALID -> new BusinessException(ExceptionType.WIFI_NOT_CAMPUS_NETWORK); + case ERROR -> new BusinessException(ExceptionType.WIFI_VALIDATION_ERROR); + default -> new BusinessException(ExceptionType.WIFI_INVALID_FORMAT); + }; + } + public void verifyCampusWifiConnection(StudyStartRequest request, Long userId) { + WiFiValidationResult validationResult = wifiValidationService.validateCampusWiFi( + request.gatewayIp(), request.clientIp() ); - + if (!validationResult.isValid()) { - log.warn("Heartbeat Wi-Fi validation failed for user {}, session {}: {}", - userId, sessionId, validationResult.getMessage()); + log.warn("Wi-Fi validation failed for user {}: {}", userId, validationResult.getMessage()); throw mapWiFiValidationException(validationResult); } - - // 유효하면 해당 세션의 lastHeartBeatAt 시간을 now()로 갱신한다. - StudySession studySession = studySessionRepository.findByIdAndUser_Id(sessionId, userId) - .orElseThrow(()->new BusinessException(ExceptionType.STUDY_SESSION_NOT_FOUND)); + } + public StudySession makeStudySession(Long userId){ + // 현재 진행 중인 세션(STARTED 상태)만 체크 + studySessionRepository.findByUser_IdAndStatus(userId, StudyStatus.STARTED) + .ifPresent(session -> { + throw new BusinessException(ExceptionType.ALREADY_STUDY_SESSION); + }); - Duration elapsed = Duration.between(studySession.getStartTime(), LocalDateTime.now()); - if(elapsed.compareTo(Duration.ofHours(MAX_FOCUS_TIME)) >= 0) { - studySession.endStudySession(studySession.getStartTime().plusHours(MAX_FOCUS_TIME)); - return new HeartBeatResponse(false, "최대 집중시간은 3시간입니다."); - } - studySession.updateHeartBeatAt(LocalDateTime.now()); - return new HeartBeatResponse(true,"정상 세션"); + User user = userRepository.findById(userId) + .orElseThrow(() -> new BusinessException(ExceptionType.USER_NOT_FOUND)); + + StudySession newStudySession = new StudySession(); + LocalDateTime startTime = LocalDateTime.now(); + newStudySession.startStudySession(startTime, user); + return studySessionRepository.save(newStudySession); } - private BusinessException mapWiFiValidationException(WiFiValidationResult result) { - return switch (result.getStatus()) { - case INVALID -> new BusinessException(ExceptionType.WIFI_NOT_CAMPUS_NETWORK); - case ERROR -> new BusinessException(ExceptionType.WIFI_VALIDATION_ERROR); - default -> new BusinessException(ExceptionType.WIFI_INVALID_FORMAT); - }; + @Transactional + public List endExpiredMaxFocusSessions() { + int maxFocusHours = studyProperties.getMaxFocusHours(); + LocalDateTime cutoffTime = LocalDateTime.now().minusMinutes(maxFocusHours); + + List expiredSessions = studySessionRepository.findAllByStatusAndStartTimeBefore( + StudyStatus.STARTED, cutoffTime + ); + + List usersToNotify = new ArrayList<>(); + for (StudySession expiredSession : expiredSessions) { + expiredSession.endMaxFocusStudySession(maxFocusHours); + usersToNotify.add(expiredSession.getUser()); + } + return usersToNotify; } } diff --git a/src/main/java/com/gpt/geumpumtabackend/user/api/UserApi.java b/src/main/java/com/gpt/geumpumtabackend/user/api/UserApi.java index 52cd317..23a814c 100644 --- a/src/main/java/com/gpt/geumpumtabackend/user/api/UserApi.java +++ b/src/main/java/com/gpt/geumpumtabackend/user/api/UserApi.java @@ -14,6 +14,7 @@ import com.gpt.geumpumtabackend.user.dto.request.CompleteRegistrationRequest; import com.gpt.geumpumtabackend.user.dto.request.NicknameVerifyRequest; import com.gpt.geumpumtabackend.user.dto.request.ProfileUpdateRequest; +import com.gpt.geumpumtabackend.user.dto.response.CompleteRegistrationResponse; import com.gpt.geumpumtabackend.user.dto.response.NicknameVerifyResponse; import com.gpt.geumpumtabackend.user.dto.response.UserProfileResponse; import io.swagger.v3.oas.annotations.Operation; @@ -35,10 +36,10 @@ public interface UserApi { description = "GUEST 권한을 가진 사용자는 회원가입 완료를 위해 학번과 학부를 입력합니다." + "사용자의 권한이 USER로 변경되고 accessToken과 refreshToken을 재발급받습니다." ) - @ApiResponse(content = @Content(schema = @Schema(implementation = TokenResponse.class))) + @ApiResponse(content = @Content(schema = @Schema(implementation = CompleteRegistrationResponse.class))) @SwaggerApiResponses( success = @SwaggerApiSuccessResponse( - response = TokenResponse.class, + response = CompleteRegistrationResponse.class, description = "회원가입 완료 및 accessToken과 refreshToken 재발급 완료"), errors = { @SwaggerApiFailedResponse(ExceptionType.NEED_AUTHORIZED), @@ -48,7 +49,7 @@ public interface UserApi { @PostMapping("/complete-registration") @AssignUserId @PreAuthorize("isAuthenticated() and hasRole('GUEST')") - public ResponseEntity> completeRegistration( + public ResponseEntity> completeRegistration( @RequestBody @Valid CompleteRegistrationRequest request, @Parameter(hidden = true) Long userId ); diff --git a/src/main/java/com/gpt/geumpumtabackend/user/controller/UserController.java b/src/main/java/com/gpt/geumpumtabackend/user/controller/UserController.java index 9f633b7..5418be9 100644 --- a/src/main/java/com/gpt/geumpumtabackend/user/controller/UserController.java +++ b/src/main/java/com/gpt/geumpumtabackend/user/controller/UserController.java @@ -9,6 +9,7 @@ import com.gpt.geumpumtabackend.user.dto.request.CompleteRegistrationRequest; import com.gpt.geumpumtabackend.user.dto.request.NicknameVerifyRequest; import com.gpt.geumpumtabackend.user.dto.request.ProfileUpdateRequest; +import com.gpt.geumpumtabackend.user.dto.response.CompleteRegistrationResponse; import com.gpt.geumpumtabackend.user.dto.response.NicknameVerifyResponse; import com.gpt.geumpumtabackend.user.dto.response.UserProfileResponse; import com.gpt.geumpumtabackend.user.service.UserService; @@ -35,11 +36,11 @@ public class UserController implements UserApi { @PostMapping("/complete-registration") @AssignUserId @PreAuthorize("isAuthenticated() and hasRole('GUEST')") - public ResponseEntity> completeRegistration( + public ResponseEntity> completeRegistration( @RequestBody @Valid CompleteRegistrationRequest request, Long userId ){ - TokenResponse response = userService.completeRegistration(request, userId); + CompleteRegistrationResponse response = userService.completeRegistration(request, userId); return ResponseEntity.ok(ResponseUtil.createSuccessResponse(response)); } diff --git a/src/main/java/com/gpt/geumpumtabackend/user/domain/User.java b/src/main/java/com/gpt/geumpumtabackend/user/domain/User.java index 6628d77..0c4d403 100644 --- a/src/main/java/com/gpt/geumpumtabackend/user/domain/User.java +++ b/src/main/java/com/gpt/geumpumtabackend/user/domain/User.java @@ -14,12 +14,13 @@ @NoArgsConstructor @Getter @SQLDelete(sql = """ - UPDATE user + UPDATE `user` SET deleted_at = NOW(), email = CONCAT('deleted_', email), school_email= CONCAT('deleted_', school_email), nickname = CONCAT('deleted_', nickname), - student_id = CONCAT('deleted_', student_id) + student_id = CONCAT('deleted_', student_id), + fcm_token = NULL WHERE id = ? """) public class User extends BaseEntity { @@ -59,6 +60,11 @@ public class User extends BaseEntity { @Enumerated(value = EnumType.STRING) private Department department; + @Column(length = 255) + private String fcmToken; + + private Long representativeBadgeId; + @Builder public User(String email, UserRole role, String name, String picture, OAuth2Provider provider, String providerId, Department department) { this.email = email; @@ -94,4 +100,16 @@ public void restore(String nickname, String email, String schoolEmail, String st this.studentId = studentId; super.restore(); } + + public void updateFcmToken(String fcmToken) { + this.fcmToken = fcmToken; + } + + public void clearFcmToken() { + this.fcmToken = null; + } + + public void setRepresentativeBadge(Long badgeId) { + this.representativeBadgeId = badgeId; + } } diff --git a/src/main/java/com/gpt/geumpumtabackend/user/dto/response/CompleteRegistrationResponse.java b/src/main/java/com/gpt/geumpumtabackend/user/dto/response/CompleteRegistrationResponse.java new file mode 100644 index 0000000..64e7faf --- /dev/null +++ b/src/main/java/com/gpt/geumpumtabackend/user/dto/response/CompleteRegistrationResponse.java @@ -0,0 +1,11 @@ +package com.gpt.geumpumtabackend.user.dto.response; + +import com.gpt.geumpumtabackend.token.dto.response.TokenResponse; + +public record CompleteRegistrationResponse( + TokenResponse token +) { + public static CompleteRegistrationResponse of(TokenResponse token) { + return new CompleteRegistrationResponse(token); + } +} diff --git a/src/main/java/com/gpt/geumpumtabackend/user/repository/UserRepository.java b/src/main/java/com/gpt/geumpumtabackend/user/repository/UserRepository.java index b381a9b..e6d75f5 100644 --- a/src/main/java/com/gpt/geumpumtabackend/user/repository/UserRepository.java +++ b/src/main/java/com/gpt/geumpumtabackend/user/repository/UserRepository.java @@ -2,9 +2,12 @@ import com.gpt.geumpumtabackend.global.oauth.user.OAuth2Provider; import com.gpt.geumpumtabackend.user.domain.User; +import jakarta.persistence.LockModeType; +import org.springframework.data.jpa.repository.Lock; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; -import java.util.List; import java.util.Optional; public interface UserRepository extends JpaRepository { @@ -19,4 +22,14 @@ public interface UserRepository extends JpaRepository { Optional findByProviderAndProviderIdAndDeletedAtIsNull(OAuth2Provider provider, String providerId); Optional findByProviderAndProviderId(OAuth2Provider provider, String providerId); + + boolean existsByStudentId(String studentId); + + boolean existsBySchoolEmail(String schoolEmail); + + Optional findByFcmToken(String fcmToken); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT u FROM User u WHERE u.id = :userId") + Optional findByIdForUpdate(@Param("userId") Long userId); } diff --git a/src/main/java/com/gpt/geumpumtabackend/user/service/UserService.java b/src/main/java/com/gpt/geumpumtabackend/user/service/UserService.java index 8dee7e4..358a5ac 100644 --- a/src/main/java/com/gpt/geumpumtabackend/user/service/UserService.java +++ b/src/main/java/com/gpt/geumpumtabackend/user/service/UserService.java @@ -1,10 +1,13 @@ package com.gpt.geumpumtabackend.user.service; +import com.gpt.geumpumtabackend.fcm.service.FcmService; import com.gpt.geumpumtabackend.global.exception.BusinessException; import com.gpt.geumpumtabackend.global.exception.ExceptionType; import com.gpt.geumpumtabackend.global.jwt.JwtHandler; import com.gpt.geumpumtabackend.global.jwt.JwtUserClaim; +import com.gpt.geumpumtabackend.badge.dto.response.NewBadgeResponse; +import com.gpt.geumpumtabackend.badge.service.BadgeService; import com.gpt.geumpumtabackend.token.domain.Token; import com.gpt.geumpumtabackend.token.dto.response.TokenResponse; import com.gpt.geumpumtabackend.token.repository.RefreshTokenRepository; @@ -13,6 +16,7 @@ import com.gpt.geumpumtabackend.user.dto.request.CompleteRegistrationRequest; import com.gpt.geumpumtabackend.user.dto.request.NicknameVerifyRequest; import com.gpt.geumpumtabackend.user.dto.request.ProfileUpdateRequest; +import com.gpt.geumpumtabackend.user.dto.response.CompleteRegistrationResponse; import com.gpt.geumpumtabackend.user.dto.response.UserProfileResponse; import com.gpt.geumpumtabackend.user.repository.UserRepository; import lombok.RequiredArgsConstructor; @@ -30,6 +34,8 @@ public class UserService { private final UserRepository userRepository; private final RefreshTokenRepository refreshTokenRepository; private final JwtHandler jwtHandler; + private final FcmService fcmService; + private final BadgeService badgeService; private static final Random RANDOM = new Random(); private static final List ADJECTIVES = List.of( @@ -55,18 +61,30 @@ public void generateRandomNickname(User user){ user.setInitialNickname(nickname); } - // TODO : 데이터 중복 검증 추가하기 @Transactional - public TokenResponse completeRegistration(CompleteRegistrationRequest request, Long userId) { + public CompleteRegistrationResponse completeRegistration(CompleteRegistrationRequest request, Long userId) { User user = userRepository.findById(userId) .orElseThrow(()->new BusinessException(ExceptionType.USER_NOT_FOUND)); + validateDuplication(request); user.completeRegistration(request); generateRandomNickname(user); // 토큰 재발급 JwtUserClaim jwtUserClaim = JwtUserClaim.create(user); Token token = jwtHandler.createTokens(jwtUserClaim); - return TokenResponse.to(token); + TokenResponse tokenResponse = TokenResponse.to(token); + badgeService.grantWelcomeBadge(userId); + return CompleteRegistrationResponse.of(tokenResponse); + } + + private void validateDuplication(CompleteRegistrationRequest request) { + if(userRepository.existsBySchoolEmail((request.email()))){ + throw new BusinessException(ExceptionType.DUPLICATED_SCHOOL_EMAIL); + } + + if(userRepository.existsByStudentId(request.studentId())){ + throw new BusinessException(ExceptionType.DUPLICATED_STUDENT_ID); + } } public UserProfileResponse getUserProfile(Long userId) { @@ -96,6 +114,7 @@ public void logout(Long userId) { userRepository.findById(userId) .orElseThrow(() -> new BusinessException(ExceptionType.USER_NOT_FOUND)); refreshTokenRepository.deleteByUserId(userId); + fcmService.removeFcmToken(userId); } @Transactional @@ -104,6 +123,7 @@ public void withdrawUser(Long userId) { .orElseThrow(() -> new BusinessException(ExceptionType.USER_NOT_FOUND)); refreshTokenRepository.deleteByUserId(userId); + fcmService.removeFcmToken(userId); userRepository.deleteById(userId); } diff --git a/src/main/java/com/gpt/geumpumtabackend/wifi/CLAUDE.md b/src/main/java/com/gpt/geumpumtabackend/wifi/CLAUDE.md new file mode 100644 index 0000000..3e9cde0 --- /dev/null +++ b/src/main/java/com/gpt/geumpumtabackend/wifi/CLAUDE.md @@ -0,0 +1,96 @@ +# WiFi Domain CLAUDE.md + +## 개요 + +캠퍼스 Wi-Fi 네트워크 검증 도메인. 학습 세션 시작 시 사용자가 금오공대 캠퍼스 네트워크에 접속해 있는지 gateway IP와 client IP를 검증한다. + +## 파일 구조 + +``` +wifi/ +├── config/ +│ └── CampusWiFiProperties.java # @ConfigurationProperties 설정 +├── dto/ +│ └── WiFiValidationResult.java # 검증 결과 DTO +└── service/ + └── CampusWiFiValidationService.java # 검증 서비스 (@Cacheable) +``` + +## 검증 흐름 + +``` +validateCampusWiFi(gatewayIp, clientIp) + → 캐시 확인 (key: "gatewayIp:clientIp") + → 캐시 미스 시: + → active 네트워크 목록 필터링 + → 각 네트워크에 대해: + 1. Gateway IP가 네트워크의 gatewayIps 목록에 포함? (불일치 → 다음 네트워크) + 2. Client IP가 네트워크의 ipRanges(CIDR) 내? (일치 → VALID) + → 모두 불일치 → INVALID + → 예외 발생 시 → ERROR +``` + +## 주요 클래스 + +### CampusWiFiProperties (Record) +`application-wifi.yml`의 `campus.wifi` 프리픽스에 바인딩. + +```yaml +campus: + wifi: + networks: + - name: "kit-main" + gatewayIps: ["172.30.64.1"] + ipRanges: ["172.30.0.0/16"] + active: true + description: "금오공대 메인 네트워크" + validation: + cacheTtlMinutes: 60 +``` + +- `WiFiNetwork.isValidGatewayIP()`: gateway IP 목록에 포함 여부 +- `WiFiNetwork.isValidIP()`: Apache Commons Net `SubnetUtils`로 CIDR 범위 검사 +- `networks`가 null이면 빈 리스트, `validation`이 null이면 기본 60분 TTL + +### WiFiValidationResult +3가지 상태를 가진 결과 DTO: + +| 상태 | valid | 의미 | +|------|-------|------| +| `VALID` | true | 캠퍼스 네트워크 확인 | +| `INVALID` | false | 캠퍼스 네트워크 아님 (IP 대역 불일치) | +| `ERROR` | false | 시스템 오류 | + +팩토리 메서드: `WiFiValidationResult.valid()`, `.invalid()`, `.error()` + +### CampusWiFiValidationService +- `@Cacheable(value = "wifiValidation", key = "#gatewayIp + ':' + #clientIp")` 적용 +- 캐시 설정은 `CacheConfig`에서 Caffeine으로 관리 +- active가 false인 네트워크는 검증에서 제외 + +## 사용처 + +`StudySessionService.verifyCampusWifiConnection()`에서 호출: +```java +WiFiValidationResult result = wifiValidationService.validateCampusWiFi(gatewayIp, clientIp); +// INVALID → WIFI_NOT_CAMPUS_NETWORK 예외 +// ERROR → WIFI_VALIDATION_ERROR 예외 +``` + +## 테스트 (`CampusWiFiValidationServiceTest`) + +5개 테스트 케이스: +- 유효한 네트워크 매칭 → 검증 성공 +- 매칭되는 네트워크 없음 → 검증 실패 +- Gateway IP 매칭, Client IP 범위 불일치 → 검증 실패 +- 비활성화 네트워크 제외 확인 +- 여러 네트워크 중 하나라도 매칭 → 검증 성공 + +`TestWiFiMockConfig`에서 테스트용 Wi-Fi 설정을 제공. + +## 개발 시 주의사항 + +1. 네트워크 설정은 `application-wifi.yml`(security submodule)에서 관리 — 직접 커밋 금지 +2. CIDR 형식 오류 시 `SubnetUtils`가 예외를 던짐 — `isIpInRange()`에서 catch 후 false 반환 +3. 캐시 키가 `gatewayIp:clientIp` 조합 — 같은 IP 조합은 캐시 TTL 동안 재검증하지 않음 +4. 새 캠퍼스 네트워크 추가 시 yml 설정만 변경하면 됨 (코드 수정 불필요) diff --git a/src/main/java/com/gpt/geumpumtabackend/wifi/service/CampusWiFiValidationService.java b/src/main/java/com/gpt/geumpumtabackend/wifi/service/CampusWiFiValidationService.java index b03528f..6e7eb10 100644 --- a/src/main/java/com/gpt/geumpumtabackend/wifi/service/CampusWiFiValidationService.java +++ b/src/main/java/com/gpt/geumpumtabackend/wifi/service/CampusWiFiValidationService.java @@ -4,10 +4,9 @@ import com.gpt.geumpumtabackend.wifi.dto.WiFiValidationResult; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.cache.annotation.Cacheable; import org.springframework.stereotype.Service; -import java.time.Duration; import java.util.List; @@ -15,90 +14,56 @@ @RequiredArgsConstructor @Slf4j public class CampusWiFiValidationService { - + private final CampusWiFiProperties wifiProperties; - private final RedisTemplate redisTemplate; - - // Redis 캐시 키 접두사 - private static final String WIFI_CACHE_KEY_PREFIX = "campus_wifi_validation:"; - + /** + * 캠퍼스 WiFi 검증 (캐시 적용) + * + * @param gatewayIp 게이트웨이 IP + * @param clientIp 클라이언트 IP + * @return 검증 결과 + */ + @Cacheable(value = "wifiValidation", key = "#gatewayIp + ':' + #clientIp") public WiFiValidationResult validateCampusWiFi(String gatewayIp, String clientIp) { - try { + log.info("WiFi 검증 실행 (캐시 미스) - Gateway IP: {}, Client IP: {}", gatewayIp, clientIp); + // 캠퍼스 내부인지 확인 boolean isInCampus = isInCampusNetwork(gatewayIp, clientIp); if (isInCampus) { - cacheValidationResult(gatewayIp, clientIp, true); return WiFiValidationResult.valid("캠퍼스 네트워크입니다"); } else { - cacheValidationResult(gatewayIp, clientIp, false); return WiFiValidationResult.invalid("캠퍼스 네트워크가 아닙니다"); } } catch (Exception e) { + log.error("WiFi 검증 중 오류 발생", e); return WiFiValidationResult.error("Wi-Fi 검증 중 오류가 발생했습니다: " + e.getMessage()); } } - - - public WiFiValidationResult validateFromCache(String gatewayIp, String clientIp) { - try { - // Gateway IP와 클라이언트 IP를 통해 키를 생성 후 Redis에서 조회 - log.info("Gateway IP: {}, Client IP: {}", gatewayIp, clientIp); - String cacheKey = buildCacheKey(gatewayIp, clientIp); - Object cachedValue = redisTemplate.opsForValue().get(cacheKey); - Boolean cachedResult = null; - if (cachedValue instanceof Boolean) { - cachedResult = (Boolean) cachedValue; - } else if (cachedValue instanceof String) { - cachedResult = Boolean.parseBoolean((String) cachedValue); - } - - if (cachedResult != null) { - return cachedResult - ? WiFiValidationResult.valid("캠퍼스 네트워크입니다 (캐시)") - : WiFiValidationResult.invalid("캠퍼스 네트워크가 아닙니다 (캐시)"); - } - - // 캐시에 없으면 전체 검증 수행 - return validateCampusWiFi(gatewayIp, clientIp); - - } catch (Exception e) { - return WiFiValidationResult.error("Wi-Fi 검증 중 오류가 발생했습니다: " + e.getMessage()); - } - } - + /** + * 캠퍼스 네트워크 검증 (실제 로직) + */ private boolean isInCampusNetwork(String gatewayIp, String ipAddress) { - // 설정 파일 Wi-fi 목록 불러오기 List activeNetworks = wifiProperties.networks() .stream() .filter(CampusWiFiProperties.WiFiNetwork::active) .toList(); + for (CampusWiFiProperties.WiFiNetwork network : activeNetworks) { // 1. Gateway IP 체크 (SSID 대신 사용) if (!network.isValidGatewayIP(gatewayIp)) { continue; } + // 2. Client IP가 해당 네트워크 범위 내인지 체크 if (network.isValidIP(ipAddress)) { return true; // 매칭되면 즉시 성공! } } return false; } - - - private String buildCacheKey(String gatewayIp, String ipAddress) { - return WIFI_CACHE_KEY_PREFIX + gatewayIp + ":" + ipAddress; - } - - - private void cacheValidationResult(String gatewayIp, String ipAddress, boolean isValid) { - String cacheKey = buildCacheKey(gatewayIp, ipAddress); - Duration ttl = Duration.ofMinutes(wifiProperties.validation().cacheTtlMinutes()); - redisTemplate.opsForValue().set(cacheKey, String.valueOf(isValid), ttl); - } } diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index f70cdb2..5430dc1 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -9,9 +9,10 @@ spring: - security/application-swagger.yml - security/application-wifi.yml - security/application-cloudinary.yml + - security/application-fcm.yml datasource: - url: ${geumpumta.mysql.url} + url: ${geumpumta.mysql.url}&rewriteBatchedStatements=true&cachePrepStmts=true&useServerPrepStmts=true username: ${geumpumta.mysql.username} password: ${geumpumta.mysql.password} driver-class-name: com.mysql.cj.jdbc.Driver @@ -31,7 +32,7 @@ spring: hibernate: ddl-auto: update open-in-view: false - show-sql: true + show-sql: false properties: hibernate: format_sql: false @@ -48,13 +49,13 @@ server: logging: level: # ---- HTTP 트래픽 흐름을 살펴봅니다. - org.springframework.web: debug + org.springframework.web: info # ---- Hibernate가 실행할 질의문을 살펴봅니다. org.hibernate.SQL: debug # ---- 질의문에 바인딩되는 파라미터를 살펴봅니다. - org.hibernate.orm.jdbc.bind: trace + org.hibernate.orm.jdbc.bind: info # ---- 질의문 실행 결과를 살펴봅니다. - org.hibernate.orm.jdbc.extract: trace + org.hibernate.orm.jdbc.extract: info management: endpoints: web: diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index 27dcf0f..46a2529 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -9,9 +9,10 @@ spring: - security/application-swagger.yml - security/application-wifi.yml - security/application-cloudinary.yml + - security/application-fcm.yml datasource: - url: ${geumpumta.mysql.url} + url: ${geumpumta.mysql.url}&rewriteBatchedStatements=true&cachePrepStmts=true&useServerPrepStmts=true username: ${geumpumta.mysql.username} password: ${geumpumta.mysql.password} driver-class-name: com.mysql.cj.jdbc.Driver diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index f7c968a..f91c669 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -9,9 +9,10 @@ spring: - security/application-swagger.yml - security/application-wifi.yml - security/application-cloudinary.yml + - security/application-fcm.yml datasource: - url: ${geumpumta.mysql.url} + url: ${geumpumta.mysql.url}&rewriteBatchedStatements=true&cachePrepStmts=true&useServerPrepStmts=true username: ${geumpumta.mysql.username} password: ${geumpumta.mysql.password} driver-class-name: com.mysql.cj.jdbc.Driver @@ -31,7 +32,7 @@ spring: hibernate: ddl-auto: validate open-in-view: false - show-sql: true + show-sql: false properties: hibernate: format_sql: false diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 8c0e171..f35774c 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -11,3 +11,6 @@ spring: spring: profiles: active: local + +study: + max-focus-hours: 3 diff --git a/src/main/resources/security b/src/main/resources/security index 99e622e..3676dd0 160000 --- a/src/main/resources/security +++ b/src/main/resources/security @@ -1 +1 @@ -Subproject commit 99e622ebd10e5f8099c625030415c837ea000567 +Subproject commit 3676dd000b7d4ad6e08ceed16704fff136c9f25e diff --git a/src/test/java/com/gpt/geumpumtabackend/GeumpumtaBackendApplicationTests.java b/src/test/java/com/gpt/geumpumtabackend/GeumpumtaBackendApplicationTests.java index 7348fc4..ab33119 100644 --- a/src/test/java/com/gpt/geumpumtabackend/GeumpumtaBackendApplicationTests.java +++ b/src/test/java/com/gpt/geumpumtabackend/GeumpumtaBackendApplicationTests.java @@ -1,12 +1,9 @@ package com.gpt.geumpumtabackend; +import com.gpt.geumpumtabackend.integration.config.BaseIntegrationTest; import org.junit.jupiter.api.Test; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.context.ActiveProfiles; -@SpringBootTest -@ActiveProfiles("test") -class GeumpumtaBackendApplicationTests { +class GeumpumtaBackendApplicationTests extends BaseIntegrationTest { @Test void contextLoads() { diff --git a/src/test/java/com/gpt/geumpumtabackend/integration/config/BaseIntegrationTest.java b/src/test/java/com/gpt/geumpumtabackend/integration/config/BaseIntegrationTest.java new file mode 100644 index 0000000..bd00577 --- /dev/null +++ b/src/test/java/com/gpt/geumpumtabackend/integration/config/BaseIntegrationTest.java @@ -0,0 +1,159 @@ +package com.gpt.geumpumtabackend.integration.config; + +import org.junit.jupiter.api.AfterEach; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.transaction.annotation.Transactional; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.MySQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; + +import java.time.Duration; +import java.util.List; + +/** + * Base class for integration tests using TestContainers. + * + * Configuration approach: + * - Uses programmatic TestContainers management (@Container) + * - Containers are shared across all test classes (static) + * - Container reuse enabled for faster local development + * - Startup timeout increased for CI environments (90s for MySQL, 60s for Redis) + * - @DynamicPropertySource overrides application-test.yml datasource settings + */ +@SpringBootTest( + properties = { + "spring.test.database.replace=NONE", + "spring.jpa.hibernate.ddl-auto=create-drop" + } +) +@ActiveProfiles("test") +@Testcontainers +@org.springframework.test.annotation.DirtiesContext(classMode = org.springframework.test.annotation.DirtiesContext.ClassMode.AFTER_CLASS) +public abstract class BaseIntegrationTest { + + @Container + static final MySQLContainer mysqlContainer = new MySQLContainer<>("mysql:8.0") + .withDatabaseName("test_geumpumta") + .withUsername("test") + .withPassword("test") + .withCommand( + "--default-authentication-plugin=mysql_native_password", + "--max_connections=500", + "--wait_timeout=28800" + ) + .withStartupTimeout(Duration.ofSeconds(120)) + .withReuse(false) // CI 환경에서는 재사용 비활성화 + .waitingFor(org.testcontainers.containers.wait.strategy.Wait + .forLogMessage(".*ready for connections.*\\n", 2)); // MySQL이 2번 ready 메시지 출력할 때까지 대기 + + @Container + static final GenericContainer redisContainer = new GenericContainer<>(DockerImageName.parse("redis:7.0-alpine")) + .withExposedPorts(6379) + .withStartupTimeout(Duration.ofSeconds(90)) + .withReuse(false) // CI 환경에서는 재사용 비활성화 + .waitingFor(org.testcontainers.containers.wait.strategy.Wait + .forLogMessage(".*Ready to accept connections.*\\n", 1)); + + @DynamicPropertySource + static void configureProperties(DynamicPropertyRegistry registry) { + // MySQL 설정 + registry.add("spring.datasource.url", mysqlContainer::getJdbcUrl); + registry.add("spring.datasource.username", mysqlContainer::getUsername); + registry.add("spring.datasource.password", mysqlContainer::getPassword); + registry.add("spring.datasource.driver-class-name", () -> "com.mysql.cj.jdbc.Driver"); + + // Redis 설정 + registry.add("spring.data.redis.host", () -> redisContainer.getHost()); + registry.add("spring.data.redis.port", () -> redisContainer.getMappedPort(6379).toString()); + registry.add("spring.data.redis.password", () -> ""); + + // WiFi 검증을 위한 테스트 설정 + registry.add("campus.wifi.networks[0].name", () -> "KUMOH_TEST"); + registry.add("campus.wifi.networks[0].gateway-ips[0]", () -> "172.30.64.1"); + registry.add("campus.wifi.networks[0].ip-ranges[0]", () -> "172.30.64.0/18"); + registry.add("campus.wifi.networks[0].active", () -> "true"); + registry.add("campus.wifi.networks[0].description", () -> "Test Network"); + registry.add("campus.wifi.validation.cache-ttl-minutes", () -> "5"); + } + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Autowired + private RedisTemplate redisTemplate; + + @AfterEach + void cleanUp() { + try { + truncateAllTables(); + cleanRedisCache(); + } catch (Exception e) { + System.err.println("Cleanup failed, but continuing: " + e.getMessage()); + } + } + + private void truncateAllTables() { + try { + // Connection을 try-with-resources로 자동 close + String dbProductName; + try (var connection = jdbcTemplate.getDataSource().getConnection()) { + dbProductName = connection.getMetaData().getDatabaseProductName(); + } + boolean isH2 = "H2".equalsIgnoreCase(dbProductName); + + // 외래 키 제약 조건 비활성화 + if (isH2) { + jdbcTemplate.execute("SET REFERENTIAL_INTEGRITY FALSE"); + } else { + jdbcTemplate.execute("SET FOREIGN_KEY_CHECKS = 0"); + } + + List tableNames; + if (isH2) { + tableNames = jdbcTemplate.queryForList( + "SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = 'PUBLIC' AND TABLE_TYPE = 'BASE TABLE'", + String.class + ); + } else { + tableNames = jdbcTemplate.queryForList( + "SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_TYPE = 'BASE TABLE'", + String.class + ); + } + + for (String tableName : tableNames) { + jdbcTemplate.execute("TRUNCATE TABLE `" + tableName + "`"); + } + + // 외래 키 제약 조건 재활성화 + if (isH2) { + jdbcTemplate.execute("SET REFERENTIAL_INTEGRITY TRUE"); + } else { + jdbcTemplate.execute("SET FOREIGN_KEY_CHECKS = 1"); + } + } catch (Exception e) { + throw new RuntimeException("Failed to truncate tables", e); + } + } + + private void cleanRedisCache() { + // Redis의 모든 캐시 데이터 삭제 (Connection을 try-with-resources로 자동 close) + try { + if (redisTemplate != null && redisTemplate.getConnectionFactory() != null) { + try (var connection = redisTemplate.getConnectionFactory().getConnection()) { + connection.serverCommands().flushAll(); + } + } + } catch (Exception e) { + System.err.println("Redis cleanup failed: " + e.getMessage()); + } + } +} \ No newline at end of file diff --git a/src/test/java/com/gpt/geumpumtabackend/integration/config/TestFcmConfig.java b/src/test/java/com/gpt/geumpumtabackend/integration/config/TestFcmConfig.java new file mode 100644 index 0000000..c9a1cd6 --- /dev/null +++ b/src/test/java/com/gpt/geumpumtabackend/integration/config/TestFcmConfig.java @@ -0,0 +1,26 @@ +package com.gpt.geumpumtabackend.integration.config; + +import com.google.auth.oauth2.AccessToken; +import com.google.auth.oauth2.GoogleCredentials; +import com.google.firebase.FirebaseApp; +import com.google.firebase.FirebaseOptions; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; + +@Configuration +@Profile("test") +public class TestFcmConfig { + + @Bean + public FirebaseApp firebaseApp() { + if (FirebaseApp.getApps().isEmpty()) { + FirebaseOptions options = FirebaseOptions.builder() + .setCredentials(GoogleCredentials.create(new AccessToken("test-token", null))) + .setProjectId("test-project") + .build(); + return FirebaseApp.initializeApp(options); + } + return FirebaseApp.getInstance(); + } +} diff --git a/src/test/java/com/gpt/geumpumtabackend/integration/rank/controller/DepartmentRankControllerIntegrationTest.java b/src/test/java/com/gpt/geumpumtabackend/integration/rank/controller/DepartmentRankControllerIntegrationTest.java new file mode 100644 index 0000000..c805865 --- /dev/null +++ b/src/test/java/com/gpt/geumpumtabackend/integration/rank/controller/DepartmentRankControllerIntegrationTest.java @@ -0,0 +1,338 @@ +package com.gpt.geumpumtabackend.integration.rank.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.gpt.geumpumtabackend.global.jwt.JwtHandler; +import com.gpt.geumpumtabackend.global.jwt.JwtUserClaim; +import com.gpt.geumpumtabackend.global.oauth.user.OAuth2Provider; +import com.gpt.geumpumtabackend.integration.config.BaseIntegrationTest; +import com.gpt.geumpumtabackend.rank.repository.DepartmentRankingRepository; +import com.gpt.geumpumtabackend.study.domain.StudySession; +import com.gpt.geumpumtabackend.study.repository.StudySessionRepository; +import com.gpt.geumpumtabackend.token.domain.Token; +import com.gpt.geumpumtabackend.user.domain.Department; +import com.gpt.geumpumtabackend.user.domain.User; +import com.gpt.geumpumtabackend.user.domain.UserRole; +import com.gpt.geumpumtabackend.user.repository.UserRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.test.web.servlet.MockMvc; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@DisplayName("DepartmentRank Controller 통합 테스트") +@AutoConfigureMockMvc +class DepartmentRankControllerIntegrationTest extends BaseIntegrationTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private JwtHandler jwtHandler; + + @Autowired + private UserRepository userRepository; + + @Autowired + private StudySessionRepository studySessionRepository; + + @Autowired + private DepartmentRankingRepository departmentRankingRepository; + + private User softwareUser1; + private User softwareUser2; + private User computerUser; + private User electronicUser; + private String softwareUserToken; + + @BeforeEach + void setUp() { + // 테스트 사용자 생성 - 올바른 빌더 사용 + softwareUser1 = createUser("소프트웨어1", "sw1@kumoh.ac.kr", Department.SOFTWARE); + softwareUser2 = createUser("소프트웨어2", "sw2@kumoh.ac.kr", Department.SOFTWARE); + computerUser = createUser("컴퓨터공학", "ce@kumoh.ac.kr", Department.COMPUTER_ENGINEERING); + electronicUser = createUser("전자공학", "ee@kumoh.ac.kr", Department.ELECTRONIC_SYSTEMS); + + // 소프트웨어 유저 토큰 생성 + JwtUserClaim claim = new JwtUserClaim(softwareUser1.getId(), UserRole.USER, false); + Token token = jwtHandler.createTokens(claim); + softwareUserToken = token.getAccessToken(); + } + + private User createUser(String name, String email, Department department) { + User user = User.builder() + .name(name) + .email(email) + .department(department) + .role(UserRole.USER) + .picture("profile.jpg") + .provider(OAuth2Provider.GOOGLE) + .providerId("provider-" + email) + .build(); + return userRepository.save(user); + } + + private void createStudySession(User user, LocalDateTime startTime, long durationHours) { + LocalDateTime endTime = startTime.plusHours(durationHours); + + StudySession session = new StudySession(); + session.startStudySession(startTime, user); + session.endStudySession(endTime); + studySessionRepository.save(session); + } + + @Nested + @DisplayName("일간 학과 랭킹 조회 API") + class GetDailyDepartmentRanking { + + @Test + @DisplayName("현재_진행중인_일간_랭킹을_조회한다") + void 현재_진행중인_일간_랭킹을_조회한다() throws Exception { + // Given - 오늘의 학습 기록 + LocalDateTime today = LocalDateTime.now().withHour(10).withMinute(0); + createStudySession(softwareUser1, today, 3); // 소프트웨어: 3시간 + createStudySession(softwareUser2, today, 2); // 소프트웨어: 2시간 (총 5시간) + createStudySession(computerUser, today, 4); // 컴퓨터공학: 4시간 + createStudySession(electronicUser, today, 1); // 전자공학: 1시간 + + // When & Then + mockMvc.perform(get("/api/v1/rank/department/daily") + .header("Authorization", "Bearer " + softwareUserToken)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value("true")) + .andExpect(jsonPath("$.data.topRanks").isArray()) + .andExpect(jsonPath("$.data.topRanks", hasSize(greaterThanOrEqualTo(1)))) + .andExpect(jsonPath("$.data.topRanks[0].departmentName").exists()) + .andExpect(jsonPath("$.data.topRanks[0].totalMillis").value(greaterThan(0))) + .andExpect(jsonPath("$.data.myDepartmentRanking").exists()) + .andExpect(jsonPath("$.data.myDepartmentRanking.departmentName").value("소프트웨어전공")); + } + + @Test + @DisplayName("특정_날짜의_확정된_일간_랭킹을_조회한다") + void 특정_날짜의_확정된_일간_랭킹을_조회한다() throws Exception { + // Given - 어제의 학습 기록 + LocalDateTime yesterday = LocalDateTime.now().minusDays(1).withHour(10).withMinute(0); + createStudySession(softwareUser1, yesterday, 5); + createStudySession(computerUser, yesterday, 3); + + // When & Then + mockMvc.perform(get("/api/v1/rank/department/daily") + .param("date", yesterday.toString()) + .header("Authorization", "Bearer " + softwareUserToken)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value("true")) + .andExpect(jsonPath("$.data.topRanks").isArray()) + .andExpect(jsonPath("$.data.myDepartmentRanking").exists()); + } + + @Test + @DisplayName("인증_없이_요청하면_403_에러가_발생한다") + void 인증_없이_요청하면_403_에러가_발생한다() throws Exception { + // When & Then + mockMvc.perform(get("/api/v1/rank/department/daily")) + .andDo(print()) + .andExpect(status().isForbidden()); + } + + @Test + @DisplayName("아무도_학습하지_않은_날에도_전체_학과가_0시간으로_반환된다") + void 아무도_학습하지_않은_날에도_전체_학과가_0시간으로_반환된다() throws Exception { + // Given - 학습 기록 없음 + + // When & Then + mockMvc.perform(get("/api/v1/rank/department/daily") + .header("Authorization", "Bearer " + softwareUserToken)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value("true")) + .andExpect(jsonPath("$.data.topRanks", hasSize(24))) + .andExpect(jsonPath("$.data.myDepartmentRanking").exists()) + .andExpect(jsonPath("$.data.myDepartmentRanking.rank").value(1)) + .andExpect(jsonPath("$.data.myDepartmentRanking.totalMillis").value(0)); + } + } + + @Nested + @DisplayName("주간 학과 랭킹 조회 API") + class GetWeeklyDepartmentRanking { + + @Test + @DisplayName("현재_진행중인_주간_랭킹을_조회한다") + void 현재_진행중인_주간_랭킹을_조회한다() throws Exception { + // Given - 이번 주의 학습 기록 + LocalDateTime thisWeek = LocalDateTime.now().withHour(10).withMinute(0); + createStudySession(softwareUser1, thisWeek, 10); + createStudySession(softwareUser2, thisWeek.minusDays(1), 8); + createStudySession(computerUser, thisWeek, 7); + + // When & Then + mockMvc.perform(get("/api/v1/rank/department/weekly") + .header("Authorization", "Bearer " + softwareUserToken)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value("true")) + .andExpect(jsonPath("$.data.topRanks").isArray()) + .andExpect(jsonPath("$.data.myDepartmentRanking").exists()); + } + + @Test + @DisplayName("특정_날짜가_포함된_주의_확정된_주간_랭킹을_조회한다") + void 특정_날짜가_포함된_주의_확정된_주간_랭킹을_조회한다() throws Exception { + // Given - 지난 주의 학습 기록 + LocalDateTime lastWeek = LocalDateTime.now().minusWeeks(1).withHour(10).withMinute(0); + createStudySession(softwareUser1, lastWeek, 20); + createStudySession(computerUser, lastWeek, 15); + + // When & Then + mockMvc.perform(get("/api/v1/rank/department/weekly") + .param("date", lastWeek.toString()) + .header("Authorization", "Bearer " + softwareUserToken)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value("true")) + .andExpect(jsonPath("$.data.topRanks").isArray()); + } + } + + @Nested + @DisplayName("월간 학과 랭킹 조회 API") + class GetMonthlyDepartmentRanking { + + @Test + @DisplayName("현재_진행중인_월간_랭킹을_조회한다") + void 현재_진행중인_월간_랭킹을_조회한다() throws Exception { + // Given - 이번 달의 학습 기록 + LocalDateTime thisMonth = LocalDateTime.now().withHour(10).withMinute(0); + createStudySession(softwareUser1, thisMonth, 50); + createStudySession(softwareUser2, thisMonth.minusDays(5), 40); + createStudySession(computerUser, thisMonth, 30); + + // When & Then + mockMvc.perform(get("/api/v1/rank/department/monthly") + .header("Authorization", "Bearer " + softwareUserToken)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value("true")) + .andExpect(jsonPath("$.data.topRanks").isArray()) + .andExpect(jsonPath("$.data.myDepartmentRanking").exists()); + } + + @Test + @DisplayName("특정_날짜가_포함된_월의_확정된_월간_랭킹을_조회한다") + void 특정_날짜가_포함된_월의_확정된_월간_랭킹을_조회한다() throws Exception { + // Given - 지난 달의 학습 기록 + LocalDateTime lastMonth = LocalDateTime.now().minusMonths(1).withHour(10).withMinute(0); + createStudySession(softwareUser1, lastMonth, 100); + createStudySession(computerUser, lastMonth, 80); + + // When & Then + mockMvc.perform(get("/api/v1/rank/department/monthly") + .param("date", lastMonth.toString()) + .header("Authorization", "Bearer " + softwareUserToken)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value("true")) + .andExpect(jsonPath("$.data.topRanks").isArray()); + } + } + + @Nested + @DisplayName("Controller-Service-Repository 전체 흐름 테스트") + class FullFlowTest { + + @Test + @DisplayName("학습_기록부터_랭킹_조회까지_전체_흐름이_정상_동작한다") + void 학습_기록부터_랭킹_조회까지_전체_흐름이_정상_동작한다() throws Exception { + // 1. 여러 학과 학생들이 학습 + LocalDateTime today = LocalDateTime.now().withHour(10).withMinute(0); + createStudySession(softwareUser1, today, 5); + createStudySession(softwareUser2, today, 3); // 소프트웨어: 총 8시간 + createStudySession(computerUser, today, 6); // 컴퓨터공학: 6시간 + createStudySession(electronicUser, today, 2); // 전자공학: 2시간 + + // 2. 일간 랭킹 조회 - 소프트웨어가 1등이어야 함 + mockMvc.perform(get("/api/v1/rank/department/daily") + .header("Authorization", "Bearer " + softwareUserToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.topRanks[0].departmentName").value("소프트웨어전공")) + .andExpect(jsonPath("$.data.myDepartmentRanking.rank").value(1)); + + // 3. 전자공학 학생 토큰으로 조회 - 같은 랭킹이지만 내 학과는 다름 + JwtUserClaim electronicClaim = new JwtUserClaim(electronicUser.getId(), UserRole.USER, false); + Token electronicToken = jwtHandler.createTokens(electronicClaim); + + mockMvc.perform(get("/api/v1/rank/department/daily") + .header("Authorization", "Bearer " + electronicToken.getAccessToken())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.topRanks[0].departmentName").value("소프트웨어전공")) + .andExpect(jsonPath("$.data.myDepartmentRanking.departmentName").value("전자시스템전공")) + .andExpect(jsonPath("$.data.myDepartmentRanking.rank").value(greaterThan(1))); + + // 4. 주간 랭킹도 정상 조회 + mockMvc.perform(get("/api/v1/rank/department/weekly") + .header("Authorization", "Bearer " + softwareUserToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.topRanks").isArray()); + + // 5. 월간 랭킹도 정상 조회 + mockMvc.perform(get("/api/v1/rank/department/monthly") + .header("Authorization", "Bearer " + softwareUserToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.topRanks").isArray()); + } + + @Test + @DisplayName("다른_사용자가_조회해도_같은_랭킹을_보지만_내_학과_정보만_다르다") + void 다른_사용자가_조회해도_같은_랭킹을_보지만_내_학과_정보만_다르다() throws Exception { + // Given + LocalDateTime today = LocalDateTime.now().withHour(10).withMinute(0); + createStudySession(softwareUser1, today, 10); + createStudySession(computerUser, today, 8); + + // 소프트웨어 유저로 조회 + String swResponse = mockMvc.perform(get("/api/v1/rank/department/daily") + .header("Authorization", "Bearer " + softwareUserToken)) + .andExpect(status().isOk()) + .andReturn() + .getResponse() + .getContentAsString(); + + // 컴퓨터공학 유저로 조회 + JwtUserClaim computerClaim = new JwtUserClaim(computerUser.getId(), UserRole.USER, false); + Token computerToken = jwtHandler.createTokens(computerClaim); + + String ceResponse = mockMvc.perform(get("/api/v1/rank/department/daily") + .header("Authorization", "Bearer " + computerToken.getAccessToken())) + .andExpect(status().isOk()) + .andReturn() + .getResponse() + .getContentAsString(); + + // 전체 랭킹은 동일하지만, myDepartmentRanking은 달라야 함 + var swData = objectMapper.readTree(swResponse).get("data"); + var ceData = objectMapper.readTree(ceResponse).get("data"); + + // topRanks는 동일 + assertThat(swData.get("topRanks").size()).isEqualTo(ceData.get("topRanks").size()); + + // myDepartmentRanking은 다름 + assertThat(swData.get("myDepartmentRanking").get("departmentName").asText()) + .isNotEqualTo(ceData.get("myDepartmentRanking").get("departmentName").asText()); + } + } +} diff --git a/src/test/java/com/gpt/geumpumtabackend/integration/statistics/StatisticsControllerIntegrationTest.java b/src/test/java/com/gpt/geumpumtabackend/integration/statistics/StatisticsControllerIntegrationTest.java new file mode 100644 index 0000000..aa3e930 --- /dev/null +++ b/src/test/java/com/gpt/geumpumtabackend/integration/statistics/StatisticsControllerIntegrationTest.java @@ -0,0 +1,165 @@ +package com.gpt.geumpumtabackend.integration.statistics; + +import com.gpt.geumpumtabackend.global.jwt.JwtHandler; +import com.gpt.geumpumtabackend.global.jwt.JwtUserClaim; +import com.gpt.geumpumtabackend.global.oauth.user.OAuth2Provider; +import com.gpt.geumpumtabackend.integration.config.BaseIntegrationTest; +import com.gpt.geumpumtabackend.study.domain.StudySession; +import com.gpt.geumpumtabackend.study.repository.StudySessionRepository; +import com.gpt.geumpumtabackend.token.domain.Token; +import com.gpt.geumpumtabackend.user.domain.Department; +import com.gpt.geumpumtabackend.user.domain.User; +import com.gpt.geumpumtabackend.user.domain.UserRole; +import com.gpt.geumpumtabackend.user.repository.UserRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.test.web.servlet.MockMvc; + +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.temporal.TemporalAdjusters; + +import static org.hamcrest.Matchers.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@DisplayName("Statistics Controller 통합 테스트") +@AutoConfigureMockMvc +class StatisticsControllerIntegrationTest extends BaseIntegrationTest { + + private static final LocalDate BASE_DATE = LocalDate.of(2024, 1, 10); + private static final long ONE_HOUR_MILLIS = 60 * 60 * 1000L; + + @Autowired + private MockMvc mockMvc; + + @Autowired + private JwtHandler jwtHandler; + + @Autowired + private UserRepository userRepository; + + @Autowired + private StudySessionRepository studySessionRepository; + + private User testUser; + private User otherUser; + private String accessToken; + + @BeforeEach + void setUp() { + testUser = createUser("테스트유저", "stats@kumoh.ac.kr", Department.SOFTWARE); + otherUser = createUser("다른유저", "other-stats@kumoh.ac.kr", Department.COMPUTER_ENGINEERING); + + JwtUserClaim claim = new JwtUserClaim(testUser.getId(), UserRole.USER, false); + Token token = jwtHandler.createTokens(claim); + accessToken = token.getAccessToken(); + } + + private User createUser(String name, String email, Department department) { + User user = User.builder() + .name(name) + .email(email) + .department(department) + .role(UserRole.USER) + .picture("profile.jpg") + .provider(OAuth2Provider.GOOGLE) + .providerId("provider-" + email) + .build(); + return userRepository.save(user); + } + + private StudySession createStudySession(User user, LocalDateTime startTime, LocalDateTime endTime) { + StudySession session = new StudySession(); + session.startStudySession(startTime, user); + session.endStudySession(endTime); + return studySessionRepository.save(session); + } + + @Test + @DisplayName("일간 통계를 2시간 슬롯과 최대 집중 시간으로 조회한다") + void 일간_통계를_조회한다() throws Exception { + LocalDateTime startTime = BASE_DATE.atTime(1, 0); + LocalDateTime endTime = BASE_DATE.atTime(3, 0); + createStudySession(testUser, startTime, endTime); + + createStudySession(otherUser, BASE_DATE.atTime(2, 0), BASE_DATE.atTime(4, 0)); + + mockMvc.perform(get("/api/v1/statistics/day") + .param("date", BASE_DATE.toString()) + .header("Authorization", "Bearer " + accessToken)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value("true")) + .andExpect(jsonPath("$.data.statisticsList", hasSize(12))) + .andExpect(jsonPath("$.data.statisticsList[0].slotStart").value("00:00")) + .andExpect(jsonPath("$.data.statisticsList[0].slotEnd").value("02:00")) + .andExpect(jsonPath("$.data.statisticsList[0].millisecondsStudied").value((int) ONE_HOUR_MILLIS)) + .andExpect(jsonPath("$.data.statisticsList[1].slotStart").value("02:00")) + .andExpect(jsonPath("$.data.statisticsList[1].slotEnd").value("04:00")) + .andExpect(jsonPath("$.data.statisticsList[1].millisecondsStudied").value((int) ONE_HOUR_MILLIS)) + .andExpect(jsonPath("$.data.dayMaxFocusAndFullTimeStatistics.totalStudyMillis").value((int) (ONE_HOUR_MILLIS * 2))) + .andExpect(jsonPath("$.data.dayMaxFocusAndFullTimeStatistics.maxFocusMillis").value((int) (ONE_HOUR_MILLIS * 2))); + } + + @Test + @DisplayName("주간 통계의 총합/연속일/평균을 계산한다") + void 주간_통계를_조회한다() throws Exception { + LocalDate weekDate = BASE_DATE; + LocalDate monday = weekDate.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)); + + createStudySession(testUser, monday.atTime(10, 0), monday.atTime(11, 0)); + createStudySession(testUser, monday.plusDays(1).atTime(12, 0), monday.plusDays(1).atTime(13, 0)); + + mockMvc.perform(get("/api/v1/statistics/week") + .param("date", weekDate.toString()) + .header("Authorization", "Bearer " + accessToken)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value("true")) + .andExpect(jsonPath("$.data.weeklyStatistics.totalWeekMillis").value((int) (ONE_HOUR_MILLIS * 2))) + .andExpect(jsonPath("$.data.weeklyStatistics.maxConsecutiveStudyDays").value(2)) + .andExpect(jsonPath("$.data.weeklyStatistics.averageDailyMillis").value(1_028_571)); + } + + @Test + @DisplayName("월간 통계의 총합/연속일/평균/공부일수를 계산한다") + void 월간_통계를_조회한다() throws Exception { + createStudySession(testUser, BASE_DATE.withDayOfMonth(2).atTime(9, 0), BASE_DATE.withDayOfMonth(2).atTime(10, 0)); + createStudySession(testUser, BASE_DATE.withDayOfMonth(3).atTime(14, 0), BASE_DATE.withDayOfMonth(3).atTime(16, 0)); + createStudySession(testUser, BASE_DATE.withDayOfMonth(5).atTime(20, 0), BASE_DATE.withDayOfMonth(5).atTime(21, 0)); + + mockMvc.perform(get("/api/v1/statistics/month") + .param("date", BASE_DATE.toString()) + .header("Authorization", "Bearer " + accessToken)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value("true")) + .andExpect(jsonPath("$.data.monthlyStatistics.totalMonthMillis").value((int) 14_400_000L)) + .andExpect(jsonPath("$.data.monthlyStatistics.averageDailyMillis").value(464_516)) + .andExpect(jsonPath("$.data.monthlyStatistics.maxConsecutiveStudyDays").value(2)) + .andExpect(jsonPath("$.data.monthlyStatistics.studiedDays").value(3)); + } + + @Test + @DisplayName("잔디 통계는 4개월 범위의 날짜별 레벨을 반환한다") + void 잔디_통계를_조회한다() throws Exception { + createStudySession(testUser, BASE_DATE.withDayOfMonth(2).atTime(9, 0), BASE_DATE.withDayOfMonth(2).atTime(10, 0)); + + mockMvc.perform(get("/api/v1/statistics/grass") + .param("date", BASE_DATE.toString()) + .header("Authorization", "Bearer " + accessToken)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value("true")) + .andExpect(jsonPath("$.data.grassStatistics", hasSize(123))) + .andExpect(jsonPath("$.data.grassStatistics[?(@.date=='2024-01-02')].level", + hasItem(greaterThanOrEqualTo(1)))); + } +} diff --git a/src/test/java/com/gpt/geumpumtabackend/integration/study/controller/StudySessionControllerIntegrationTest.java b/src/test/java/com/gpt/geumpumtabackend/integration/study/controller/StudySessionControllerIntegrationTest.java new file mode 100644 index 0000000..8b62150 --- /dev/null +++ b/src/test/java/com/gpt/geumpumtabackend/integration/study/controller/StudySessionControllerIntegrationTest.java @@ -0,0 +1,290 @@ +package com.gpt.geumpumtabackend.integration.study.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.gpt.geumpumtabackend.global.jwt.JwtHandler; +import com.gpt.geumpumtabackend.global.jwt.JwtUserClaim; +import com.gpt.geumpumtabackend.global.oauth.user.OAuth2Provider; +import com.gpt.geumpumtabackend.integration.config.BaseIntegrationTest; +import com.gpt.geumpumtabackend.study.domain.StudySession; +import com.gpt.geumpumtabackend.study.domain.StudyStatus; +import com.gpt.geumpumtabackend.study.dto.request.StudyEndRequest; +import com.gpt.geumpumtabackend.study.dto.request.StudyStartRequest; +import com.gpt.geumpumtabackend.study.repository.StudySessionRepository; +import com.gpt.geumpumtabackend.token.domain.Token; +import com.gpt.geumpumtabackend.user.domain.Department; +import com.gpt.geumpumtabackend.user.domain.User; +import com.gpt.geumpumtabackend.user.domain.UserRole; +import com.gpt.geumpumtabackend.user.repository.UserRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@DisplayName("StudySession Controller 통합 테스트") +@AutoConfigureMockMvc +class StudySessionControllerIntegrationTest extends BaseIntegrationTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private JwtHandler jwtHandler; + + @Autowired + private UserRepository userRepository; + + @Autowired + private StudySessionRepository studySessionRepository; + + private User testUser; + private String accessToken; + + @BeforeEach + void setUp() { + // 테스트 사용자 생성 - 올바른 빌더 사용 + testUser = User.builder() + .name("테스트유저") + .email("test@kumoh.ac.kr") + .department(Department.SOFTWARE) + .role(UserRole.USER) + .picture("test.jpg") + .provider(OAuth2Provider.GOOGLE) + .providerId("test-provider-id") + .build(); + testUser = userRepository.save(testUser); + + // JWT 토큰 생성 + JwtUserClaim claim = new JwtUserClaim(testUser.getId(), UserRole.USER, false); + Token token = jwtHandler.createTokens(claim); + accessToken = token.getAccessToken(); + } + + @Nested + @DisplayName("공부 시작 API") + class StartStudySession { + + @Test + @DisplayName("정상적으로_공부를_시작하고_세션ID를_반환한다") + void 정상적으로_공부를_시작하고_세션ID를_반환한다() throws Exception { + // Given + StudyStartRequest request = new StudyStartRequest("172.30.64.1", "172.30.64.100"); + + // When & Then + mockMvc.perform(post("/api/v1/study/start") + .header("Authorization", "Bearer " + accessToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value("true")) + .andExpect(jsonPath("$.data.studySessionId").exists()) + .andExpect(jsonPath("$.data.studySessionId").isNumber()); + + // DB 검증 + StudySession savedSession = studySessionRepository.findAll().get(0); + assertThat(savedSession.getUser().getId()).isEqualTo(testUser.getId()); + assertThat(savedSession.getStatus()).isEqualTo(StudyStatus.STARTED); + assertThat(savedSession.getStartTime()).isNotNull(); + } + + @Test + @DisplayName("인증_없이_요청하면_403_에러가_발생한다") + void 인증_없이_요청하면_403_에러가_발생한다() throws Exception { + // Given + StudyStartRequest request = new StudyStartRequest("172.30.64.1", "172.30.64.100"); + + // When & Then + mockMvc.perform(post("/api/v1/study/start") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isForbidden()); + } + + @Test + @DisplayName("잘못된_토큰으로_요청하면_401_에러가_발생한다") + void 잘못된_토큰으로_요청하면_401_에러가_발생한다() throws Exception { + // Given + StudyStartRequest request = new StudyStartRequest("172.30.64.1", "172.30.64.100"); + + // When & Then + mockMvc.perform(post("/api/v1/study/start") + .header("Authorization", "Bearer invalid-token") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isUnauthorized()); + } + } + + @Nested + @DisplayName("공부 종료 API") + class EndStudySession { + + @Test + @DisplayName("정상적으로_공부를_종료하고_시간을_계산한다") + void 정상적으로_공부를_종료하고_시간을_계산한다() throws Exception { + // Given - 먼저 공부 시작 (올바른 도메인 생성 방법) + LocalDateTime startTime = LocalDateTime.now().minusHours(2); + StudySession session = new StudySession(); + session.startStudySession(startTime, testUser); + session = studySessionRepository.save(session); + + StudyEndRequest request = new StudyEndRequest(session.getId()); + + // When & Then + mockMvc.perform(post("/api/v1/study/end") + .header("Authorization", "Bearer " + accessToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value("true")); + + // DB 검증 + StudySession endedSession = studySessionRepository.findById(session.getId()).orElseThrow(); + assertThat(endedSession.getStatus()).isEqualTo(StudyStatus.FINISHED); + assertThat(endedSession.getEndTime()).isNotNull(); + assertThat(endedSession.getTotalMillis()).isGreaterThan(0); + } + } + + @Nested + @DisplayName("오늘의 공부 세션 조회 API") + class GetTodayStudySession { + + @Test + @DisplayName("오늘의_공부_기록을_조회한다") + void 오늘의_공부_기록을_조회한다() throws Exception { + // Given - 오늘 공부 기록 생성 (현재 시각 이전으로 설정) + LocalDateTime endTime = LocalDateTime.now().minusHours(1); // 1시간 전에 종료 + LocalDateTime startTime = endTime.minusHours(2); // 3시간 전에 시작 + + StudySession session = new StudySession(); + session.startStudySession(startTime, testUser); + session.endStudySession(endTime); + studySessionRepository.save(session); + + // When & Then + mockMvc.perform(get("/api/v1/study") + .header("Authorization", "Bearer " + accessToken)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value("true")) + .andExpect(jsonPath("$.data").exists()) + .andExpect(jsonPath("$.data.totalStudySession").value(greaterThan(0))); + } + + @Test + @DisplayName("공부_기록이_없으면_빈_응답을_반환한다") + void 공부_기록이_없으면_빈_응답을_반환한다() throws Exception { + // When & Then + mockMvc.perform(get("/api/v1/study") + .header("Authorization", "Bearer " + accessToken)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value("true")) + .andExpect(jsonPath("$.data").exists()); + } + + @Test + @DisplayName("다른_사용자의_공부_기록은_조회되지_않는다") + void 다른_사용자의_공부_기록은_조회되지_않는다() throws Exception { + // Given - 다른 사용자의 공부 기록 + User otherUser = User.builder() + .name("다른유저") + .email("other@kumoh.ac.kr") + .department(Department.COMPUTER_ENGINEERING) + .role(UserRole.USER) + .picture("other.jpg") + .provider(OAuth2Provider.GOOGLE) + .providerId("other-provider-id") + .build(); + otherUser = userRepository.save(otherUser); + + LocalDateTime endTime = LocalDateTime.now().minusHours(1); + LocalDateTime startTime = endTime.minusHours(3); + + StudySession otherSession = new StudySession(); + otherSession.startStudySession(startTime, otherUser); + otherSession.endStudySession(endTime); + studySessionRepository.save(otherSession); + + // When & Then - 내 토큰으로 조회하면 다른 사람 기록은 안 보임 + mockMvc.perform(get("/api/v1/study") + .header("Authorization", "Bearer " + accessToken)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value("true")) + .andExpect(jsonPath("$.data.totalStudySession").value(0)); + } + } + + @Nested + @DisplayName("Controller-Service-Repository 전체 흐름 테스트") + class FullFlowTest { + + @Test + @DisplayName("공부_시작부터_종료까지_전체_흐름이_정상_동작한다") + void 공부_시작부터_종료까지_전체_흐름이_정상_동작한다() throws Exception { + // 1. 공부 시작 + StudyStartRequest startRequest = new StudyStartRequest("172.30.64.1", "172.30.64.100"); + String startResponse = mockMvc.perform(post("/api/v1/study/start") + .header("Authorization", "Bearer " + accessToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(startRequest))) + .andExpect(status().isOk()) + .andReturn() + .getResponse() + .getContentAsString(); + + Long sessionId = objectMapper.readTree(startResponse) + .get("data") + .get("studySessionId") + .asLong(); + + // 2. 오늘의 공부 세션 조회 (진행중) + mockMvc.perform(get("/api/v1/study") + .header("Authorization", "Bearer " + accessToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data").exists()); + + // 3. 공부 종료 + StudyEndRequest endRequest = new StudyEndRequest(sessionId); + mockMvc.perform(post("/api/v1/study/end") + .header("Authorization", "Bearer " + accessToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(endRequest))) + .andExpect(status().isOk()); + + // 4. 다시 조회 (종료됨) + mockMvc.perform(get("/api/v1/study") + .header("Authorization", "Bearer " + accessToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.totalStudySession").value(greaterThan(0))); + + // 5. DB 최종 검증 + StudySession finalSession = studySessionRepository.findById(sessionId).orElseThrow(); + assertThat(finalSession.getStatus()).isEqualTo(StudyStatus.FINISHED); + assertThat(finalSession.getUser().getId()).isEqualTo(testUser.getId()); + assertThat(finalSession.getStartTime()).isNotNull(); + assertThat(finalSession.getEndTime()).isNotNull(); + assertThat(finalSession.getTotalMillis()).isGreaterThan(0); + } + } +} diff --git a/src/test/java/com/gpt/geumpumtabackend/unit/badge/service/BadgeServiceTest.java b/src/test/java/com/gpt/geumpumtabackend/unit/badge/service/BadgeServiceTest.java new file mode 100644 index 0000000..5b9e64a --- /dev/null +++ b/src/test/java/com/gpt/geumpumtabackend/unit/badge/service/BadgeServiceTest.java @@ -0,0 +1,549 @@ +package com.gpt.geumpumtabackend.unit.badge.service; + +import com.gpt.geumpumtabackend.badge.domain.Badge; +import com.gpt.geumpumtabackend.badge.domain.BadgeType; +import com.gpt.geumpumtabackend.badge.domain.UserBadge; +import com.gpt.geumpumtabackend.badge.dto.request.BadgeCreateRequest; +import com.gpt.geumpumtabackend.badge.dto.response.BadgeCreateResponse; +import com.gpt.geumpumtabackend.badge.dto.response.BadgeResponse; +import com.gpt.geumpumtabackend.badge.dto.response.MyBadgeResponse; +import com.gpt.geumpumtabackend.badge.dto.response.MyBadgeStatusResponse; +import com.gpt.geumpumtabackend.badge.dto.response.NewBadgeResponse; +import com.gpt.geumpumtabackend.badge.repository.BadgeRepository; +import com.gpt.geumpumtabackend.badge.repository.UserBadgeRepository; +import com.gpt.geumpumtabackend.badge.service.BadgeService; +import com.gpt.geumpumtabackend.global.exception.BusinessException; +import com.gpt.geumpumtabackend.global.exception.ExceptionType; +import com.gpt.geumpumtabackend.rank.domain.RankType; +import com.gpt.geumpumtabackend.rank.domain.SeasonRankingSnapshot; +import com.gpt.geumpumtabackend.rank.repository.SeasonRankingSnapshotRepository; +import com.gpt.geumpumtabackend.study.repository.StudySessionRepository; +import com.gpt.geumpumtabackend.statistics.repository.StatisticsRepository; +import com.gpt.geumpumtabackend.user.domain.User; +import com.gpt.geumpumtabackend.user.repository.UserRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +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.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("BadgeService 단위 테스트") +class BadgeServiceTest { + + @Mock + private BadgeRepository badgeRepository; + + @Mock + private UserBadgeRepository userBadgeRepository; + + @Mock + private UserRepository userRepository; + + @Mock + private StudySessionRepository studySessionRepository; + + @Mock + private StatisticsRepository statisticsRepository; + + @Mock + private SeasonRankingSnapshotRepository seasonRankingSnapshotRepository; + + @InjectMocks + private BadgeService badgeService; + + @Test + @DisplayName("관리자가 배지를 생성하면 저장 후 응답을 반환한다") + void 배지를_생성하면_응답을_반환한다() { + // Given + BadgeCreateRequest request = new BadgeCreateRequest( + "WELCOME_001", + "웰컴 배지", + "회원가입 축하 배지", + "https://example.com/welcome.png", + BadgeType.WELCOME, + 0L, + null + ); + Badge savedBadge = createBadge("WELCOME_001", BadgeType.WELCOME, null); + ReflectionTestUtils.setField(savedBadge, "id", 100L); + + when(badgeRepository.existsByCode(request.code())).thenReturn(false); + when(badgeRepository.save(any(Badge.class))).thenReturn(savedBadge); + + // When + BadgeCreateResponse response = badgeService.createBadge(request); + + // Then + assertThat(response.id()).isEqualTo(100L); + assertThat(response.code()).isEqualTo("WELCOME_001"); + verify(badgeRepository).save(any(Badge.class)); + } + + @Test + @DisplayName("중복 코드로 배지 생성 시 BADGE_CODE_ALREADY_EXISTS 예외가 발생한다") + void 중복코드_배지생성시_예외발생() { + // Given + BadgeCreateRequest request = new BadgeCreateRequest( + "WELCOME_001", + "웰컴 배지", + "회원가입 축하 배지", + "https://example.com/welcome.png", + BadgeType.WELCOME, + 0L, + null + ); + when(badgeRepository.existsByCode(request.code())).thenReturn(true); + + // When & Then + assertThatThrownBy(() -> badgeService.createBadge(request)) + .isInstanceOf(BusinessException.class) + .hasFieldOrPropertyWithValue("exceptionType", ExceptionType.BADGE_CODE_ALREADY_EXISTS); + verify(badgeRepository, never()).save(any(Badge.class)); + } + + @Test + @DisplayName("관리자가 전체 배지를 조회하면 배지 목록을 반환한다") + void 전체_배지를_조회하면_목록을_반환한다() { + // Given + Badge badge1 = createBadge("WELCOME_001", BadgeType.WELCOME, null); + Badge badge2 = createBadge("TOTAL_HOURS_50", BadgeType.TOTAL_HOURS, null); + when(badgeRepository.findAll()).thenReturn(List.of(badge1, badge2)); + + // When + List responses = badgeService.getAllBadges(); + + // Then + assertThat(responses).hasSize(2); + assertThat(responses).extracting(BadgeResponse::code) + .containsExactly("WELCOME_001", "TOTAL_HOURS_50"); + } + + @Test + @DisplayName("내 배지 조회 시 전체 배지를 반환하고 보유 여부를 표시한다") + void 내배지_조회시_전체배지와_보유여부를_반환한다() { + // Given + Long userId = 1L; + Badge badge1 = createBadge("WELCOME_001", BadgeType.WELCOME, null); + Badge badge2 = createBadge("TOTAL_HOURS_50", BadgeType.TOTAL_HOURS, null); + ReflectionTestUtils.setField(badge1, "id", 1L); + ReflectionTestUtils.setField(badge2, "id", 2L); + LocalDateTime awardedAt = LocalDateTime.of(2026, 2, 1, 10, 0); + UserBadge ownedBadge = new UserBadge(userId, 1L, awardedAt, awardedAt); + + when(userRepository.existsById(userId)).thenReturn(true); + when(badgeRepository.findAll()).thenReturn(List.of(badge2, badge1)); + when(userBadgeRepository.findByUserId(userId)).thenReturn(List.of(ownedBadge)); + + // When + List responses = badgeService.getMyBadges(userId); + + // Then + assertThat(responses).hasSize(2); + assertThat(responses).extracting(MyBadgeStatusResponse::code) + .containsExactly("WELCOME_001", "TOTAL_HOURS_50"); + assertThat(responses.get(0).owned()).isTrue(); + assertThat(responses.get(0).awardedAt()).isEqualTo(awardedAt); + assertThat(responses.get(1).owned()).isFalse(); + assertThat(responses.get(1).awardedAt()).isNull(); + } + + @Test + @DisplayName("지급된 이력이 없는 배지는 삭제한다") + void 지급이력_없는_배지는_삭제한다() { + // Given + Long badgeId = 30L; + Badge badge = createBadge("TOTAL_HOURS_100", BadgeType.TOTAL_HOURS, null); + ReflectionTestUtils.setField(badge, "id", badgeId); + when(badgeRepository.findById(badgeId)).thenReturn(Optional.of(badge)); + when(userBadgeRepository.existsByBadgeId(badgeId)).thenReturn(false); + + // When + badgeService.deleteBadge(badgeId); + + // Then + verify(badgeRepository, times(1)).delete(badge); + } + + @Test + @DisplayName("이미 지급된 배지는 삭제 시 BADGE_IN_USE 예외가 발생한다") + void 이미_지급된_배지는_삭제할수없다() { + // Given + Long badgeId = 31L; + Badge badge = createBadge("TOTAL_HOURS_200", BadgeType.TOTAL_HOURS, null); + ReflectionTestUtils.setField(badge, "id", badgeId); + when(badgeRepository.findById(badgeId)).thenReturn(Optional.of(badge)); + when(userBadgeRepository.existsByBadgeId(badgeId)).thenReturn(true); + + // When & Then + assertThatThrownBy(() -> badgeService.deleteBadge(badgeId)) + .isInstanceOf(BusinessException.class) + .hasFieldOrPropertyWithValue("exceptionType", ExceptionType.BADGE_IN_USE); + verify(badgeRepository, never()).delete(any(Badge.class)); + } + + @Test + @DisplayName("회원가입 후 웰컴 배지가 정상 반환된다") + void 회원가입후_웰컴배지가_반환된다() { + // Given + Long userId = 1L; + Badge badge = createBadge("WELCOME_001", BadgeType.WELCOME, 10L); + + when(userRepository.existsById(userId)).thenReturn(true); + when(badgeRepository.findByBadgeType(BadgeType.WELCOME)).thenReturn(Optional.of(badge)); + when(userBadgeRepository.existsByUserIdAndBadgeId(userId, badge.getId())).thenReturn(false); + when(userBadgeRepository.save(any(UserBadge.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + // When + NewBadgeResponse granted = badgeService.grantWelcomeBadge(userId); + + // Then + assertThat(granted.code()).isEqualTo(badge.getCode()); + + // mock 메서드 호출 시 전달된 인자를 캡처해서, 전달된 데이터 검증 + ArgumentCaptor captor = ArgumentCaptor.forClass(UserBadge.class); + verify(userBadgeRepository, times(1)).save(captor.capture()); + UserBadge saved = captor.getValue(); + assertThat(saved.getUserId()).isEqualTo(userId); + assertThat(saved.getBadgeId()).isEqualTo(badge.getId()); + assertThat(saved.getNotifiedAt()).isNull(); + } + + @Test + @DisplayName("미확인 배지 조회 시 배지를 반환하고 확인 처리한다") + void 미확인_배지_조회시_배지를_반환하고_확인처리한다() { + // Given + Long userId = 2L; + Badge badge1 = createBadge("SEASON_2025_RANK_1", BadgeType.SEASON_PERSONAL_RANK, 1L); + Badge badge2 = createBadge("SEASON_2025_RANK_2", BadgeType.SEASON_PERSONAL_RANK, 2L); + LocalDateTime awardedAt1 = LocalDateTime.of(2026, 1, 1, 10, 0); + LocalDateTime awardedAt2 = LocalDateTime.of(2026, 1, 2, 11, 0); + UserBadge userBadge1 = new UserBadge(userId, badge1.getId(), awardedAt1, null); + UserBadge userBadge2 = new UserBadge(userId, badge2.getId(), awardedAt2, null); + + when(userRepository.existsById(userId)).thenReturn(true); + when(userBadgeRepository.findUnnotifiedBadgeResponses(userId)).thenReturn(List.of( + new MyBadgeResponse(badge1.getCode(), badge1.getName(), badge1.getDescription(), badge1.getIconUrl(), awardedAt1), + new MyBadgeResponse(badge2.getCode(), badge2.getName(), badge2.getDescription(), badge2.getIconUrl(), awardedAt2) + )); + when(userBadgeRepository.findByUserIdAndNotifiedAtIsNull(userId)).thenReturn(List.of(userBadge1, userBadge2)); + when(userBadgeRepository.saveAll(any())).thenAnswer(invocation -> invocation.getArgument(0)); + + // When + List responses = badgeService.getUnnotifiedBadges(userId); + + // Then + assertThat(responses).hasSize(2); + assertThat(responses) + .extracting(MyBadgeResponse::code) + .containsExactlyInAnyOrder(badge1.getCode(), badge2.getCode()); + assertThat(responses) + .extracting(MyBadgeResponse::awardedAt) + .containsExactlyInAnyOrder(awardedAt1, awardedAt2); + + // mock 메서드 호출 시 전달된 인자를 캡처해서, 전달된 데이터 검증 + ArgumentCaptor> listCaptor = ArgumentCaptor.forClass(List.class); + verify(userBadgeRepository, times(1)).saveAll(listCaptor.capture()); + List saved = listCaptor.getValue(); + assertThat(saved).hasSize(2); + assertThat(saved).allSatisfy(ub -> assertThat(ub.getNotifiedAt()).isNotNull()); + LocalDateTime notifiedAt = saved.get(0).getNotifiedAt(); + assertThat(saved) + .extracting(UserBadge::getNotifiedAt) + .allMatch(time -> time.equals(notifiedAt)); + } + + @Test + @DisplayName("대표 배지 설정 시 배지 코드로 조회해 대표 배지를 설정한다") + void 대표_배지_설정시_코드로_배지를_찾아_설정한다() { + // Given + Long userId = 10L; + Badge badge = createBadge("WELCOME_001", BadgeType.WELCOME, 10L); + User user = mock(User.class); + + when(badgeRepository.findByCode("WELCOME_001")).thenReturn(Optional.of(badge)); + when(userRepository.findById(userId)).thenReturn(Optional.of(user)); + when(userBadgeRepository.existsByUserIdAndBadgeId(userId, badge.getId())).thenReturn(true); + + // When + badgeService.setRepresentativeBadge(new com.gpt.geumpumtabackend.badge.dto.request.RepresentativeBadgeRequest("WELCOME_001"), userId); + + // Then + verify(user, times(1)).setRepresentativeBadge(badge.getId()); + } + + @Test + @DisplayName("누적 공부시간 기준 배지를 미보유하면 발급한다") + void 누적_공부시간_기준_배지를_미보유시_발급한다() { + // Given + Long userId = 3L; + Badge badge = createBadge("TOTAL_HOURS_50", BadgeType.TOTAL_HOURS, 1L); + ReflectionTestUtils.setField(badge, "thresholdValue", 50L); + long totalMillis = 50L * 60L * 60L * 1000L; + + when(userRepository.existsById(userId)).thenReturn(true); + when(studySessionRepository.sumTotalStudyMillisByUserId(userId)).thenReturn(totalMillis); + when(badgeRepository.findAllByBadgeType(BadgeType.TOTAL_HOURS)).thenReturn(List.of(badge)); + when(statisticsRepository.countCurrentConsecutiveStudyDays(eq(userId), any(), anyLong())).thenReturn(0); + when(userBadgeRepository.existsByUserIdAndBadgeId(userId, badge.getId())).thenReturn(false); + when(userBadgeRepository.save(any(UserBadge.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + // When + List responses = badgeService.grantStudyAchievementBadges(userId); + + // Then + assertThat(responses).hasSize(1); + assertThat(responses.get(0).code()).isEqualTo(badge.getCode()); + verify(userBadgeRepository, times(1)).save(any(UserBadge.class)); + } + + @Test + @DisplayName("누적 공부시간 기준 배지를 이미 보유하면 발급하지 않는다") + void 누적_공부시간_기준_배지를_이미_보유한_경우_발급하지_않는다() { + // Given + Long userId = 4L; + Badge badge = createBadge("TOTAL_HOURS_50", BadgeType.TOTAL_HOURS, 1L); + ReflectionTestUtils.setField(badge, "thresholdValue", 50L); + long totalMillis = 60L * 60L * 60L * 1000L; + + when(userRepository.existsById(userId)).thenReturn(true); + when(studySessionRepository.sumTotalStudyMillisByUserId(userId)).thenReturn(totalMillis); + when(badgeRepository.findAllByBadgeType(BadgeType.TOTAL_HOURS)).thenReturn(List.of(badge)); + when(statisticsRepository.countCurrentConsecutiveStudyDays(eq(userId), any(), anyLong())).thenReturn(0); + when(userBadgeRepository.existsByUserIdAndBadgeId(userId, badge.getId())).thenReturn(true); + + // When + List responses = badgeService.grantStudyAchievementBadges(userId); + + // Then + assertThat(responses).isEmpty(); + verify(userBadgeRepository, never()).save(any(UserBadge.class)); + } + + @Test + @DisplayName("누적 공부시간 기준 미달이면 배지를 발급하지 않는다") + void 누적_공부시간_기준_미달이면_배지를_발급하지_않는다() { + // Given + Long userId = 5L; + Badge badge = createBadge("TOTAL_HOURS_50", BadgeType.TOTAL_HOURS, 1L); + ReflectionTestUtils.setField(badge, "thresholdValue", 50L); + long totalMillis = 49L * 60L * 60L * 1000L; + + when(userRepository.existsById(userId)).thenReturn(true); + when(studySessionRepository.sumTotalStudyMillisByUserId(userId)).thenReturn(totalMillis); + when(badgeRepository.findAllByBadgeType(BadgeType.TOTAL_HOURS)).thenReturn(List.of(badge)); + when(statisticsRepository.countCurrentConsecutiveStudyDays(eq(userId), any(), anyLong())).thenReturn(0); + + // When + List responses = badgeService.grantStudyAchievementBadges(userId); + + // Then + assertThat(responses).isEmpty(); + verify(userBadgeRepository, never()).save(any(UserBadge.class)); + } + + @Test + @DisplayName("연속 공부일수 기준 배지를 미보유하면 발급한다") + void 연속_공부일수_기준_배지를_미보유시_발급한다() { + // Given + Long userId = 6L; + Badge badge = createBadge("STREAK_DAYS_7", BadgeType.STREAK_DAYS, 1L); + ReflectionTestUtils.setField(badge, "thresholdValue", 7L); + + when(userRepository.existsById(userId)).thenReturn(true); + when(studySessionRepository.sumTotalStudyMillisByUserId(userId)).thenReturn(0L); + when(badgeRepository.findAllByBadgeType(BadgeType.TOTAL_HOURS)).thenReturn(List.of()); + when(statisticsRepository.countCurrentConsecutiveStudyDays(eq(userId), any(), anyLong())).thenReturn(7); + when(badgeRepository.findAllByBadgeType(BadgeType.STREAK_DAYS)).thenReturn(List.of(badge)); + when(userBadgeRepository.existsByUserIdAndBadgeId(userId, badge.getId())).thenReturn(false); + when(userBadgeRepository.save(any(UserBadge.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + // When + List responses = badgeService.grantStudyAchievementBadges(userId); + + // Then + assertThat(responses).hasSize(1); + assertThat(responses.get(0).code()).isEqualTo(badge.getCode()); + verify(userBadgeRepository, times(1)).save(any(UserBadge.class)); + } + + @Test + @DisplayName("연속 공부일수 기준 미달이면 배지를 발급하지 않는다") + void 연속_공부일수_기준_미달이면_배지를_발급하지_않는다() { + // Given + Long userId = 7L; + Badge badge = createBadge("STREAK_DAYS_7", BadgeType.STREAK_DAYS, 1L); + ReflectionTestUtils.setField(badge, "thresholdValue", 7L); + + when(userRepository.existsById(userId)).thenReturn(true); + when(studySessionRepository.sumTotalStudyMillisByUserId(userId)).thenReturn(0L); + when(badgeRepository.findAllByBadgeType(BadgeType.TOTAL_HOURS)).thenReturn(List.of()); + when(statisticsRepository.countCurrentConsecutiveStudyDays(eq(userId), any(), anyLong())).thenReturn(6); + when(badgeRepository.findAllByBadgeType(BadgeType.STREAK_DAYS)).thenReturn(List.of(badge)); + + // When + List responses = badgeService.grantStudyAchievementBadges(userId); + + // Then + assertThat(responses).isEmpty(); + verify(userBadgeRepository, never()).save(any(UserBadge.class)); + } + + @Test + @DisplayName("시즌 전체랭킹 배지를 지급한다") + void 시즌_전체랭킹_배지를_지급한다() { + // Given + Long seasonId = 100L; + Long userId = 11L; + Badge overallRank1Badge = createBadge("SEASON_OVERALL_1", BadgeType.SEASON_PERSONAL_RANK, 1L); + SeasonRankingSnapshot snapshot = SeasonRankingSnapshot.builder() + .seasonId(seasonId) + .userId(userId) + .rankType(RankType.OVERALL) + .finalRank(1) + .finalTotalMillis(1_000_000L) + .snapshotAt(LocalDateTime.now()) + .build(); + + when(badgeRepository.findAllByBadgeType(BadgeType.SEASON_PERSONAL_RANK)) + .thenReturn(List.of(overallRank1Badge)); + when(seasonRankingSnapshotRepository.findBySeasonIdAndRankTypeAndFinalRankIn( + seasonId, RankType.OVERALL, Set.of(1))) + .thenReturn(List.of(snapshot)); + when(userBadgeRepository.existsByUserIdAndBadgeId(userId, overallRank1Badge.getId())) + .thenReturn(false); + when(userBadgeRepository.save(any(UserBadge.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + // When + int grantedCount = badgeService.grantSeasonRankingBadges(seasonId); + + // Then + assertThat(grantedCount).isEqualTo(1); + verify(userBadgeRepository, times(1)).save(any(UserBadge.class)); + } + + @Test + @DisplayName("시즌 랭킹 배지를 이미 보유하면 지급하지 않는다") + void 시즌_랭킹_배지를_이미_보유하면_지급하지_않는다() { + // Given + Long seasonId = 101L; + Long userId = 12L; + Badge overallRank1Badge = createBadge("SEASON_OVERALL_1", BadgeType.SEASON_PERSONAL_RANK, 1L); + SeasonRankingSnapshot snapshot = SeasonRankingSnapshot.builder() + .seasonId(seasonId) + .userId(userId) + .rankType(RankType.OVERALL) + .finalRank(1) + .finalTotalMillis(1_000_000L) + .snapshotAt(LocalDateTime.now()) + .build(); + + when(badgeRepository.findAllByBadgeType(BadgeType.SEASON_PERSONAL_RANK)) + .thenReturn(List.of(overallRank1Badge)); + when(seasonRankingSnapshotRepository.findBySeasonIdAndRankTypeAndFinalRankIn( + seasonId, RankType.OVERALL, Set.of(1))) + .thenReturn(List.of(snapshot)); + when(userBadgeRepository.existsByUserIdAndBadgeId(userId, overallRank1Badge.getId())) + .thenReturn(true); + + // When + int grantedCount = badgeService.grantSeasonRankingBadges(seasonId); + + // Then + assertThat(grantedCount).isZero(); + verify(userBadgeRepository, never()).save(any(UserBadge.class)); + } + + @Test + @DisplayName("시즌 전체랭킹 1,2,3등 유저에게 각 등수 배지를 지급한다") + void 시즌_전체랭킹_1_2_3등에_각각_해당_배지를_지급한다() { + // Given + Long seasonId = 102L; + Long user1 = 21L; + Long user2 = 22L; + Long user3 = 23L; + + Badge rank1Badge = createBadge("SEASON_OVERALL_1", BadgeType.SEASON_PERSONAL_RANK, 1L); + Badge rank2Badge = createBadge("SEASON_OVERALL_2", BadgeType.SEASON_PERSONAL_RANK, 2L); + Badge rank3Badge = createBadge("SEASON_OVERALL_3", BadgeType.SEASON_PERSONAL_RANK, 3L); + + SeasonRankingSnapshot snapshot1 = SeasonRankingSnapshot.builder() + .seasonId(seasonId) + .userId(user1) + .rankType(RankType.OVERALL) + .finalRank(1) + .finalTotalMillis(3_000_000L) + .snapshotAt(LocalDateTime.now()) + .build(); + SeasonRankingSnapshot snapshot2 = SeasonRankingSnapshot.builder() + .seasonId(seasonId) + .userId(user2) + .rankType(RankType.OVERALL) + .finalRank(2) + .finalTotalMillis(2_000_000L) + .snapshotAt(LocalDateTime.now()) + .build(); + SeasonRankingSnapshot snapshot3 = SeasonRankingSnapshot.builder() + .seasonId(seasonId) + .userId(user3) + .rankType(RankType.OVERALL) + .finalRank(3) + .finalTotalMillis(1_000_000L) + .snapshotAt(LocalDateTime.now()) + .build(); + + when(badgeRepository.findAllByBadgeType(BadgeType.SEASON_PERSONAL_RANK)) + .thenReturn(List.of(rank1Badge, rank2Badge, rank3Badge)); + when(seasonRankingSnapshotRepository.findBySeasonIdAndRankTypeAndFinalRankIn( + seasonId, RankType.OVERALL, Set.of(1, 2, 3))) + .thenReturn(List.of(snapshot3, snapshot1, snapshot2)); + when(userBadgeRepository.existsByUserIdAndBadgeId(anyLong(), anyLong())) + .thenReturn(false); + when(userBadgeRepository.save(any(UserBadge.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + // When + int grantedCount = badgeService.grantSeasonRankingBadges(seasonId); + + // Then + assertThat(grantedCount).isEqualTo(3); + + ArgumentCaptor captor = ArgumentCaptor.forClass(UserBadge.class); + verify(userBadgeRepository, times(3)).save(captor.capture()); + List saved = captor.getAllValues(); + Map badgeByUser = saved.stream() + .collect(Collectors.toMap(UserBadge::getUserId, UserBadge::getBadgeId)); + + assertThat(badgeByUser).containsEntry(user1, rank1Badge.getId()); + assertThat(badgeByUser).containsEntry(user2, rank2Badge.getId()); + assertThat(badgeByUser).containsEntry(user3, rank3Badge.getId()); + } + + private Badge createBadge(String code, BadgeType type, Long rank) { + Badge badge = Badge.builder() + .code(code) + .name("badge-name") + .description("badge-desc") + .iconUrl("http://example.com/badge.png") + .badgeType(type) + .thresholdValue(10L) + .rank(rank) + .build(); + ReflectionTestUtils.setField(badge, "id", rank); + return badge; + } +} diff --git a/src/test/java/com/gpt/geumpumtabackend/unit/config/BaseUnitTest.java b/src/test/java/com/gpt/geumpumtabackend/unit/config/BaseUnitTest.java new file mode 100644 index 0000000..50a40cd --- /dev/null +++ b/src/test/java/com/gpt/geumpumtabackend/unit/config/BaseUnitTest.java @@ -0,0 +1,13 @@ +package com.gpt.geumpumtabackend.unit.config; + +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +@SpringBootTest +@ActiveProfiles("unit-test") +public abstract class BaseUnitTest { + // 단위테스트 기본 설정 + // - H2 Database + // - Redis 완전 비활성화 + // - Mock 기반 테스트 +} \ No newline at end of file diff --git a/src/test/java/com/gpt/geumpumtabackend/unit/config/TestWiFiMockConfig.java b/src/test/java/com/gpt/geumpumtabackend/unit/config/TestWiFiMockConfig.java new file mode 100644 index 0000000..656733e --- /dev/null +++ b/src/test/java/com/gpt/geumpumtabackend/unit/config/TestWiFiMockConfig.java @@ -0,0 +1,35 @@ +package com.gpt.geumpumtabackend.unit.config; + +import com.gpt.geumpumtabackend.wifi.dto.WiFiValidationResult; +import com.gpt.geumpumtabackend.wifi.service.CampusWiFiValidationService; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Primary; + +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@TestConfiguration +public class TestWiFiMockConfig { + + @Bean + @Primary + public CampusWiFiValidationService mockWiFiValidationService() { + CampusWiFiValidationService mock = mock(CampusWiFiValidationService.class); + + // 기본적으로 캠퍼스 네트워크로 인식하도록 설정 (192.168.1.x) + when(mock.validateCampusWiFi("192.168.1.1", anyString())) + .thenReturn(WiFiValidationResult.valid("캠퍼스 네트워크입니다 (Mock)")); + + // 캠퍼스가 아닌 네트워크 (192.168.10.x) + when(mock.validateCampusWiFi("192.168.10.1", anyString())) + .thenReturn(WiFiValidationResult.invalid("캠퍼스 네트워크가 아닙니다 (Mock)")); + + // 에러 시뮬레이션용 (특정 IP에서 에러 발생) + when(mock.validateCampusWiFi("error.test.ip", anyString())) + .thenReturn(WiFiValidationResult.error("Redis 연결 실패 (Mock)")); + + return mock; + } +} \ No newline at end of file diff --git a/src/test/java/com/gpt/geumpumtabackend/unit/global/maintenance/MaintenanceFilterTest.java b/src/test/java/com/gpt/geumpumtabackend/unit/global/maintenance/MaintenanceFilterTest.java new file mode 100644 index 0000000..3ec8971 --- /dev/null +++ b/src/test/java/com/gpt/geumpumtabackend/unit/global/maintenance/MaintenanceFilterTest.java @@ -0,0 +1,104 @@ +package com.gpt.geumpumtabackend.unit.global.maintenance; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.gpt.geumpumtabackend.global.maintenance.MaintenanceFilter; +import com.gpt.geumpumtabackend.maintenance.service.MaintenanceService; +import jakarta.servlet.FilterChain; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +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; + +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@DisplayName("MaintenanceFilter 단위 테스트") +class MaintenanceFilterTest { + + @Mock + private MaintenanceService maintenanceService; + + private MaintenanceFilter maintenanceFilter; + + @BeforeEach + void setUp() { + maintenanceFilter = new MaintenanceFilter(maintenanceService, new ObjectMapper()); + } + + @Test + @DisplayName("점검 중이면 일반 API 요청을 503으로 차단한다") + void 점검중이면_일반요청을_차단한다() throws Exception { + when(maintenanceService.isMaintenanceInProgress()).thenReturn(true); + + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/v1/board/list"); + request.setServletPath("/api/v1/board/list"); + MockHttpServletResponse response = new MockHttpServletResponse(); + AtomicBoolean chainCalled = new AtomicBoolean(false); + FilterChain filterChain = (req, res) -> chainCalled.set(true); + + maintenanceFilter.doFilter(request, response, filterChain); + + assertThat(response.getStatus()).isEqualTo(503); + assertThat(response.getContentType()).isEqualTo("application/json;charset=UTF-8"); + assertThat(response.getContentAsString()).contains("MT001"); + assertThat(response.getContentAsString()).contains("서버 점검 중입니다."); + assertThat(chainCalled).isFalse(); + } + + @Test + @DisplayName("점검 중이어도 화이트리스트 경로는 통과시킨다") + void 점검중이어도_화이트리스트는_통과한다() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/v1/maintenance/status"); + request.setServletPath("/api/v1/maintenance/status"); + MockHttpServletResponse response = new MockHttpServletResponse(); + AtomicBoolean chainCalled = new AtomicBoolean(false); + FilterChain filterChain = (req, res) -> chainCalled.set(true); + + maintenanceFilter.doFilter(request, response, filterChain); + + assertThat(chainCalled).isTrue(); + verifyNoInteractions(maintenanceService); + } + + @Test + @DisplayName("컨텍스트 패스가 있어도 화이트리스트 경로는 통과시킨다") + void 컨텍스트패스가_있어도_화이트리스트는_통과한다() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/geumpumta/api/v1/maintenance/status"); + request.setContextPath("/geumpumta"); + request.setServletPath("/api/v1/maintenance/status"); + + MockHttpServletResponse response = new MockHttpServletResponse(); + AtomicBoolean chainCalled = new AtomicBoolean(false); + FilterChain filterChain = (req, res) -> chainCalled.set(true); + + maintenanceFilter.doFilter(request, response, filterChain); + + assertThat(chainCalled).isTrue(); + verifyNoInteractions(maintenanceService); + } + + @Test + @DisplayName("정상 상태면 일반 API 요청을 통과시킨다") + void 정상상태면_일반요청을_통과시킨다() throws Exception { + when(maintenanceService.isMaintenanceInProgress()).thenReturn(false); + + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/v1/board/list"); + request.setServletPath("/api/v1/board/list"); + MockHttpServletResponse response = new MockHttpServletResponse(); + AtomicBoolean chainCalled = new AtomicBoolean(false); + FilterChain filterChain = (req, res) -> chainCalled.set(true); + + maintenanceFilter.doFilter(request, response, filterChain); + + assertThat(chainCalled).isTrue(); + assertThat(response.getContentAsString()).isEmpty(); + } +} diff --git a/src/test/java/com/gpt/geumpumtabackend/unit/maintenance/service/MaintenanceServiceTest.java b/src/test/java/com/gpt/geumpumtabackend/unit/maintenance/service/MaintenanceServiceTest.java new file mode 100644 index 0000000..b07816d --- /dev/null +++ b/src/test/java/com/gpt/geumpumtabackend/unit/maintenance/service/MaintenanceServiceTest.java @@ -0,0 +1,70 @@ +package com.gpt.geumpumtabackend.unit.maintenance.service; + +import com.gpt.geumpumtabackend.maintenance.domain.Maintenance; +import com.gpt.geumpumtabackend.maintenance.domain.ServiceStatus; +import com.gpt.geumpumtabackend.maintenance.dto.request.MaintenanceStatusUpdateRequest; +import com.gpt.geumpumtabackend.maintenance.dto.response.MaintenanceStatusResponse; +import com.gpt.geumpumtabackend.maintenance.repository.MaintenanceRepository; +import com.gpt.geumpumtabackend.maintenance.service.MaintenanceService; +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; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@DisplayName("MaintenanceService 단위 테스트") +class MaintenanceServiceTest { + + @Mock + private MaintenanceRepository maintenanceRepository; + + @InjectMocks + private MaintenanceService maintenanceService; + + @Test + @DisplayName("점검 상태가 없으면 생성 후 저장한다") + void 상태가_없으면_생성후_저장한다() { + MaintenanceStatusUpdateRequest request = new MaintenanceStatusUpdateRequest( + ServiceStatus.MAINTENANCE, + "점검 중입니다." + ); + Maintenance saved = Maintenance.initialize(ServiceStatus.MAINTENANCE, "점검 중입니다."); + + when(maintenanceRepository.findById(Maintenance.DEFAULT_ID)).thenReturn(Optional.empty()); + when(maintenanceRepository.save(any(Maintenance.class))).thenReturn(saved); + + MaintenanceStatusResponse response = maintenanceService.updateStatus(request); + + assertThat(response.status()).isEqualTo(ServiceStatus.MAINTENANCE); + assertThat(response.message()).isEqualTo("점검 중입니다."); + verify(maintenanceRepository).save(any(Maintenance.class)); + } + + @Test + @DisplayName("기존 상태가 있으면 값을 갱신한다") + void 기존_상태가_있으면_갱신한다() { + Maintenance maintenance = Maintenance.initialize(ServiceStatus.NORMAL, null); + MaintenanceStatusUpdateRequest request = new MaintenanceStatusUpdateRequest( + ServiceStatus.NORMAL, + "정상 운영 중입니다." + ); + + when(maintenanceRepository.findById(Maintenance.DEFAULT_ID)).thenReturn(Optional.of(maintenance)); + when(maintenanceRepository.save(maintenance)).thenReturn(maintenance); + + MaintenanceStatusResponse response = maintenanceService.updateStatus(request); + + assertThat(response.status()).isEqualTo(ServiceStatus.NORMAL); + assertThat(response.message()).isEqualTo("정상 운영 중입니다."); + verify(maintenanceRepository).save(maintenance); + } +} diff --git a/src/test/java/com/gpt/geumpumtabackend/unit/oauth/service/OAuthLoginPolicyServiceTest.java b/src/test/java/com/gpt/geumpumtabackend/unit/oauth/service/OAuthLoginPolicyServiceTest.java new file mode 100644 index 0000000..684f5ac --- /dev/null +++ b/src/test/java/com/gpt/geumpumtabackend/unit/oauth/service/OAuthLoginPolicyServiceTest.java @@ -0,0 +1,102 @@ +package com.gpt.geumpumtabackend.unit.oauth.service; + +import com.gpt.geumpumtabackend.global.exception.BusinessException; +import com.gpt.geumpumtabackend.global.jwt.JwtHandler; +import com.gpt.geumpumtabackend.global.jwt.JwtUserClaim; +import com.gpt.geumpumtabackend.global.oauth.service.OAuthLoginPolicyService; +import com.gpt.geumpumtabackend.token.domain.RefreshToken; +import com.gpt.geumpumtabackend.token.domain.Token; +import com.gpt.geumpumtabackend.token.repository.RefreshTokenRepository; +import com.gpt.geumpumtabackend.user.domain.User; +import com.gpt.geumpumtabackend.user.domain.UserRole; +import com.gpt.geumpumtabackend.user.repository.UserRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class OAuthLoginPolicyServiceTest { + + @Mock + private UserRepository userRepository; + + @Mock + private RefreshTokenRepository refreshTokenRepository; + + @Mock + private JwtHandler jwtHandler; + + private OAuthLoginPolicyService oAuthLoginPolicyService; + + @BeforeEach + void setUp() { + oAuthLoginPolicyService = new OAuthLoginPolicyService(userRepository, refreshTokenRepository, jwtHandler); + } + + @Test + void 활성_세션이_있으면_토큰_발급을_차단한다() { + Long userId = 1L; + JwtUserClaim claim = new JwtUserClaim(userId, UserRole.USER, false); + RefreshToken activeToken = RefreshToken.builder() + .userId(userId) + .refreshToken("active-token") + .times(3600L) + .build(); + + when(userRepository.findByIdForUpdate(userId)).thenReturn(Optional.of(mock(User.class))); + when(refreshTokenRepository.findByUserId(userId)).thenReturn(Optional.of(activeToken)); + + Optional result = oAuthLoginPolicyService.issueTokenIfNoActiveSession(claim); + + assertThat(result).isEmpty(); + verify(jwtHandler, never()).createTokens(any()); + verify(refreshTokenRepository, never()).deleteByUserId(userId); + } + + @Test + void 만료된_토큰만_있으면_삭제후_새_토큰을_발급한다() { + Long userId = 1L; + JwtUserClaim claim = new JwtUserClaim(userId, UserRole.USER, false); + RefreshToken expiredToken = RefreshToken.builder() + .userId(userId) + .refreshToken("expired-token") + .times(-1L) + .build(); + Token issuedToken = Token.builder() + .accessToken("new-access") + .refreshToken("new-refresh") + .build(); + + when(userRepository.findByIdForUpdate(userId)).thenReturn(Optional.of(mock(User.class))); + when(refreshTokenRepository.findByUserId(userId)).thenReturn(Optional.of(expiredToken)); + when(jwtHandler.createTokens(claim)).thenReturn(issuedToken); + + Optional result = oAuthLoginPolicyService.issueTokenIfNoActiveSession(claim); + + assertThat(result).contains(issuedToken); + verify(refreshTokenRepository).deleteByUserId(userId); + verify(jwtHandler).createTokens(claim); + } + + @Test + void 사용자_락_대상_없으면_예외를_던진다() { + Long userId = 1L; + JwtUserClaim claim = new JwtUserClaim(userId, UserRole.USER, false); + + when(userRepository.findByIdForUpdate(userId)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> oAuthLoginPolicyService.issueTokenIfNoActiveSession(claim)) + .isInstanceOf(BusinessException.class); + + verify(refreshTokenRepository, never()).findByUserId(anyLong()); + verify(jwtHandler, never()).createTokens(any()); + } +} diff --git a/src/test/java/com/gpt/geumpumtabackend/unit/rank/service/DepartmentRankServiceTest.java b/src/test/java/com/gpt/geumpumtabackend/unit/rank/service/DepartmentRankServiceTest.java new file mode 100644 index 0000000..a9ddec6 --- /dev/null +++ b/src/test/java/com/gpt/geumpumtabackend/unit/rank/service/DepartmentRankServiceTest.java @@ -0,0 +1,325 @@ +package com.gpt.geumpumtabackend.unit.rank.service; + +import com.gpt.geumpumtabackend.rank.domain.RankingType; +import com.gpt.geumpumtabackend.rank.dto.DepartmentRankingTemp; +import com.gpt.geumpumtabackend.rank.dto.response.DepartmentRankingResponse; +import com.gpt.geumpumtabackend.rank.repository.DepartmentRankingRepository; +import com.gpt.geumpumtabackend.rank.service.DepartmentRankService; +import com.gpt.geumpumtabackend.study.repository.StudySessionRepository; +import com.gpt.geumpumtabackend.user.domain.Department; +import com.gpt.geumpumtabackend.user.domain.User; +import com.gpt.geumpumtabackend.user.repository.UserRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +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; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("DepartmentRankService 단위 테스트") +class DepartmentRankServiceTest { + + @Mock + private DepartmentRankingRepository departmentRankingRepository; + + @Mock + private StudySessionRepository studySessionRepository; + + @Mock + private UserRepository userRepository; + + @InjectMocks + private DepartmentRankService departmentRankService; + + @Nested + @DisplayName("현재 일간 학과 랭킹 조회") + class GetCurrentDailyDepartmentRanking { + + @Test + @DisplayName("현재 일간 학과 랭킹 조회 시 정상적으로 랭킹 정보가 반환된다") + void getCurrentDaily_정상조회_학과랭킹정보반환() { + // Given + Long userId = 1L; + User testUser = createTestUser(userId, "김철수", Department.SOFTWARE); + + List mockDepartmentRankingData = List.of( + createMockDepartmentRankingTemp("SOFTWARE", 25200000L, 1L), + createMockDepartmentRankingTemp("COMPUTER_ENGINEERING", 21600000L, 2L), + createMockDepartmentRankingTemp("ELECTRONIC_SYSTEMS", 18000000L, 3L) + ); + + given(userRepository.findById(userId)).willReturn(Optional.of(testUser)); + given(studySessionRepository.calculateCurrentDepartmentRanking(any(), any(), any())) + .willReturn(mockDepartmentRankingData); + + // When + DepartmentRankingResponse response = departmentRankService.getCurrentDailyDepartmentRanking(userId); + + // Then + assertThat(response).isNotNull(); + assertThat(response.topRanks()).hasSize(3); + assertThat(response.myDepartmentRanking()).isNotNull(); + assertThat(response.myDepartmentRanking().departmentName()).isEqualTo("소프트웨어전공"); + assertThat(response.myDepartmentRanking().rank()).isEqualTo(1L); + assertThat(response.myDepartmentRanking().totalMillis()).isEqualTo(25200000L); + + verify(studySessionRepository).calculateCurrentDepartmentRanking(any(), any(), any()); + } + + @Test + @DisplayName("사용자의 학과가 랭킹에 없을 때 fallback 랭킹이 생성된다") + void getCurrentDaily_학과랭킹없음_fallback랭킹생성() { + // Given + Long userId = 1L; + User testUser = createTestUser(userId, "김철수", Department.MECHANICAL_ENGINEERING); // 기계공학과 + + List mockDepartmentRankingData = List.of( + createMockDepartmentRankingTemp("SOFTWARE", 25200000L, 1L), + createMockDepartmentRankingTemp("COMPUTER_ENGINEERING", 21600000L, 2L) + ); + + given(userRepository.findById(userId)).willReturn(Optional.of(testUser)); + given(studySessionRepository.calculateCurrentDepartmentRanking(any(), any(), any())) + .willReturn(mockDepartmentRankingData); + + // When + DepartmentRankingResponse response = departmentRankService.getCurrentDailyDepartmentRanking(userId); + + // Then + assertThat(response.topRanks()).hasSize(2); + assertThat(response.myDepartmentRanking()).isNotNull(); + assertThat(response.myDepartmentRanking().departmentName()).isEqualTo("기계공학전공"); + assertThat(response.myDepartmentRanking().totalMillis()).isEqualTo(0L); + assertThat(response.myDepartmentRanking().rank()).isEqualTo(3L); // 마지막 순위 + 1 + + verify(userRepository).findById(userId); + } + } + + + @Nested + @DisplayName("완료된 일간 학과 랭킹 조회") + class GetCompletedDailyDepartmentRanking { + + @Test + @DisplayName("완료된 일간 학과 랭킹 조회 시 정상적으로 랭킹 정보가 반환된다") + void getCompletedDaily_정상조회_학과랭킹정보반환() { + // Given + Long userId = 1L; + LocalDateTime startDay = LocalDateTime.of(2024, 1, 1, 0, 0); + User testUser = createTestUser(userId, "김철수", Department.SOFTWARE); + + List mockDepartmentRankingData = List.of( + createMockDepartmentRankingTemp("SOFTWARE", 30000000L, 1L), + createMockDepartmentRankingTemp("COMPUTER_ENGINEERING", 25000000L, 2L) + ); + + given(userRepository.findById(userId)).willReturn(Optional.of(testUser)); + given(departmentRankingRepository.getFinishedDepartmentRanking(startDay, RankingType.DAILY.name())) + .willReturn(mockDepartmentRankingData); + + // When + DepartmentRankingResponse response = departmentRankService.getCompletedDailyDepartmentRanking(userId, startDay); + + // Then + assertThat(response).isNotNull(); + assertThat(response.topRanks()).hasSize(2); + assertThat(response.myDepartmentRanking()).isNotNull(); + assertThat(response.myDepartmentRanking().departmentName()).isEqualTo("소프트웨어전공"); + + verify(departmentRankingRepository).getFinishedDepartmentRanking(startDay, RankingType.DAILY.name()); + } + } + + @Nested + @DisplayName("현재 주간 학과 랭킹 조회") + class GetCurrentWeeklyDepartmentRanking { + + @Test + @DisplayName("현재 주간 학과 랭킹 조회 시 월요일부터 일요일까지의 기간으로 계산된다") + void getCurrentWeekly_정상조회_주간기간계산() { + // Given + Long userId = 1L; + User testUser = createTestUser(userId, "김철수", Department.SOFTWARE); + + List mockDepartmentRankingData = List.of( + createMockDepartmentRankingTemp("SOFTWARE", 100800000L, 1L) + ); + + given(userRepository.findById(userId)).willReturn(Optional.of(testUser)); + given(studySessionRepository.calculateCurrentDepartmentRanking(any(), any(), any())) + .willReturn(mockDepartmentRankingData); + + // When + DepartmentRankingResponse response = departmentRankService.getCurrentWeeklyDepartmentRanking(userId); + + // Then + assertThat(response).isNotNull(); + assertThat(response.topRanks()).hasSize(1); + assertThat(response.myDepartmentRanking().departmentName()).isEqualTo("소프트웨어전공"); + + verify(studySessionRepository).calculateCurrentDepartmentRanking(any(), any(), any()); + } + } + + @Nested + @DisplayName("현재 월간 학과 랭킹 조회") + class GetCurrentMonthlyDepartmentRanking { + + @Test + @DisplayName("현재 월간 학과 랭킹 조회 시 해당 월의 첫날부터 마지막날까지의 기간으로 계산된다") + void getCurrentMonthly_정상조회_월간기간계산() { + // Given + Long userId = 1L; + User testUser = createTestUser(userId, "김철수", Department.SOFTWARE); + + List mockDepartmentRankingData = List.of( + createMockDepartmentRankingTemp("SOFTWARE", 432000000L, 1L) + ); + + given(userRepository.findById(userId)).willReturn(Optional.of(testUser)); + given(studySessionRepository.calculateCurrentDepartmentRanking(any(), any(), any())) + .willReturn(mockDepartmentRankingData); + + // When + DepartmentRankingResponse response = departmentRankService.getCurrentMonthlyDepartmentRanking(userId); + + // Then + assertThat(response).isNotNull(); + assertThat(response.topRanks()).hasSize(1); + assertThat(response.myDepartmentRanking().departmentName()).isEqualTo("소프트웨어전공"); + + verify(studySessionRepository).calculateCurrentDepartmentRanking(any(), any(), any()); + } + } + + @Nested + @DisplayName("학과 랭킹 응답 생성 로직") + class BuildDepartmentRankingResponse { + + @Test + @DisplayName("빈 학과 랭킹 목록에서도 내 학과 랭킹이 정상적으로 생성된다") + void buildResponse_빈학과랭킹목록_내학과랭킹생성정상() { + // Given + Long userId = 1L; + User testUser = createTestUser(userId, "홀로학과원", Department.ELECTRONIC_SYSTEMS); + List emptyRankingData = List.of(); + + given(userRepository.findById(userId)).willReturn(Optional.of(testUser)); + given(studySessionRepository.calculateCurrentDepartmentRanking(any(), any(), any())) + .willReturn(emptyRankingData); + + // When + DepartmentRankingResponse response = departmentRankService.getCurrentDailyDepartmentRanking(userId); + + // Then + assertThat(response.topRanks()).isEmpty(); + assertThat(response.myDepartmentRanking()).isNotNull(); + assertThat(response.myDepartmentRanking().departmentName()).isEqualTo("전자시스템전공"); + assertThat(response.myDepartmentRanking().rank()).isEqualTo(1L); // 0 + 1 + assertThat(response.myDepartmentRanking().totalMillis()).isEqualTo(0L); + } + + @Test + @DisplayName("대량의 학과 랭킹 데이터에서 내 학과를 정확히 찾는다") + void buildResponse_대량학과랭킹데이터_내학과정확검색() { + // Given + Long userId = 1L; + User testUser = createTestUser(userId, "컴공생", Department.COMPUTER_ENGINEERING); + + List largeRankingData = List.of( + createMockDepartmentRankingTemp("SOFTWARE", 50000000L, 1L), + createMockDepartmentRankingTemp("COMPUTER_ENGINEERING", 40000000L, 2L), + createMockDepartmentRankingTemp("ELECTRONIC_SYSTEMS", 30000000L, 3L), + createMockDepartmentRankingTemp("MECHANICAL_ENGINEERING", 20000000L, 4L), + createMockDepartmentRankingTemp("ARTIFICIAL_INTELLIGENCE", 10000000L, 5L) + ); + + given(userRepository.findById(userId)).willReturn(Optional.of(testUser)); + given(studySessionRepository.calculateCurrentDepartmentRanking(any(), any(), any())) + .willReturn(largeRankingData); + + // When + DepartmentRankingResponse response = departmentRankService.getCurrentDailyDepartmentRanking(userId); + + // Then + assertThat(response.topRanks()).hasSize(5); + assertThat(response.myDepartmentRanking()).isNotNull(); + assertThat(response.myDepartmentRanking().departmentName()).isEqualTo("컴퓨터공학전공"); + assertThat(response.myDepartmentRanking().rank()).isEqualTo(2L); + assertThat(response.myDepartmentRanking().totalMillis()).isEqualTo(40000000L); + } + + @Test + @DisplayName("학과명 매칭은 정확한 문자열 비교로 동작한다") + void buildResponse_학과명매칭_정확한문자열비교() { + // Given + Long userId = 1L; + User testUser = createTestUser(userId, "소프트웨어생", Department.SOFTWARE); + + List mockData = List.of( + createMockDepartmentRankingTemp("SOFTWARE", 50000000L, 1L), + createMockDepartmentRankingTemp("ARTIFICIAL_INTELLIGENCE", 40000000L, 2L) // 다른 학과 + ); + + given(userRepository.findById(userId)).willReturn(Optional.of(testUser)); + given(studySessionRepository.calculateCurrentDepartmentRanking(any(), any(), any())) + .willReturn(mockData); + + // When + DepartmentRankingResponse response = departmentRankService.getCurrentDailyDepartmentRanking(userId); + + // Then + assertThat(response.myDepartmentRanking()).isNotNull(); + assertThat(response.myDepartmentRanking().departmentName()).isEqualTo("소프트웨어전공"); + assertThat(response.myDepartmentRanking().rank()).isEqualTo(1L); // 정확히 매칭된 것만 + } + } + + // 테스트 데이터 생성 헬퍼 메서드 + private User createTestUser(Long id, String name, Department department) { + User user = User.builder() + .name(name) + .email("test@kumoh.ac.kr") + .department(department) + .picture("test.jpg") + .role(com.gpt.geumpumtabackend.user.domain.UserRole.USER) + .provider(com.gpt.geumpumtabackend.global.oauth.user.OAuth2Provider.GOOGLE) + .providerId("test-provider-id") + .build(); + + // 테스트용 ID 설정 (Reflection 사용) + try { + java.lang.reflect.Field idField = User.class.getDeclaredField("id"); + idField.setAccessible(true); + idField.set(user, id); + } catch (Exception e) { + throw new RuntimeException("Failed to set test user ID", e); + } + + return user; + } + + private DepartmentRankingTemp createMockDepartmentRankingTemp(String department, Long totalMillis, Long ranking) { + DepartmentRankingTemp mock = mock(DepartmentRankingTemp.class); + // getDepartment()는 실제로 사용되지 않으므로 stubbing 제거 + given(mock.getTotalMillis()).willReturn(totalMillis); + given(mock.getRanking()).willReturn(ranking); + + // getDepartmentName만 모킹 (DepartmentRankingEntryResponse.of()에서 실제 사용됨) + String koreanName = Department.valueOf(department).getKoreanName(); + given(mock.getDepartmentName()).willReturn(koreanName); + + return mock; + } +} diff --git a/src/test/java/com/gpt/geumpumtabackend/unit/rank/service/PersonalRankServiceTest.java b/src/test/java/com/gpt/geumpumtabackend/unit/rank/service/PersonalRankServiceTest.java new file mode 100644 index 0000000..b95484e --- /dev/null +++ b/src/test/java/com/gpt/geumpumtabackend/unit/rank/service/PersonalRankServiceTest.java @@ -0,0 +1,435 @@ +package com.gpt.geumpumtabackend.unit.rank.service; + +import com.gpt.geumpumtabackend.global.exception.BusinessException; +import com.gpt.geumpumtabackend.global.exception.ExceptionType; +import com.gpt.geumpumtabackend.rank.domain.RankingType; +import com.gpt.geumpumtabackend.rank.dto.PersonalRankingTemp; +import com.gpt.geumpumtabackend.rank.dto.response.PersonalRankingResponse; +import com.gpt.geumpumtabackend.rank.repository.UserRankingRepository; +import com.gpt.geumpumtabackend.rank.service.PersonalRankService; +import com.gpt.geumpumtabackend.study.repository.StudySessionRepository; +import com.gpt.geumpumtabackend.user.domain.Department; +import com.gpt.geumpumtabackend.user.domain.User; +import com.gpt.geumpumtabackend.user.repository.UserRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +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; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("PersonalRankService 단위 테스트") +class PersonalRankServiceTest { + + @Mock + private UserRankingRepository userRankingRepository; + + @Mock + private StudySessionRepository studySessionRepository; + + @Mock + private UserRepository userRepository; + + @InjectMocks + private PersonalRankService personalRankService; + + @Nested + @DisplayName("현재 일간 랭킹 조회") + class GetCurrentDaily { + + @Test + @DisplayName("현재 일간 랭킹 조회 시 정상적으로 랭킹 정보가 반환된다") + void getCurrentDaily_정상조회_랭킹정보반환() { + // Given + Long userId = 2L; + List mockRankingData = List.of( + createMockPersonalRankingTemp(1L, "김철수", "profile1.jpg", "SOFTWARE", 7200000L, 1L), + createMockPersonalRankingTemp(2L, "박영희", "profile2.jpg", "COMPUTER_ENGINEERING", 5400000L, 2L), + createMockPersonalRankingTemp(3L, "이민수", "profile3.jpg", "ELECTRONIC_SYSTEMS", 3600000L, 3L) + ); + + given(studySessionRepository.calculateCurrentPeriodRanking(any(), any(), any())) + .willReturn(mockRankingData); + + // When + PersonalRankingResponse response = personalRankService.getCurrentDaily(userId); + + // Then + assertThat(response).isNotNull(); + assertThat(response.topRanks()).hasSize(3); + assertThat(response.myRanking()).isNotNull(); + assertThat(response.myRanking().userId()).isEqualTo(2L); + assertThat(response.myRanking().rank()).isEqualTo(2L); + assertThat(response.myRanking().totalMillis()).isEqualTo(5400000L); + + verify(studySessionRepository).calculateCurrentPeriodRanking(any(), any(), any()); + } + + @Test + @DisplayName("사용자가 랭킹에 없을 때 fallback 랭킹 정보가 생성된다") + void getCurrentDaily_사용자랭킹없음_fallback랭킹생성() { + // Given + Long userId = 999L; + List mockRankingData = List.of( + createMockPersonalRankingTemp(1L, "김철수", "profile1.jpg", "SOFTWARE", 7200000L, 1L), + createMockPersonalRankingTemp(2L, "박영희", "profile2.jpg", "COMPUTER_ENGINEERING", 5400000L, 2L) + ); + + User testUser = createTestUser(userId, "홍길동", Department.SOFTWARE); + + given(studySessionRepository.calculateCurrentPeriodRanking(any(), any(), any())) + .willReturn(mockRankingData); + given(userRepository.findById(userId)) + .willReturn(Optional.of(testUser)); + + // When + PersonalRankingResponse response = personalRankService.getCurrentDaily(userId); + + // Then + assertThat(response.topRanks()).hasSize(2); + assertThat(response.myRanking()).isNotNull(); + assertThat(response.myRanking().userId()).isEqualTo(userId); + assertThat(response.myRanking().totalMillis()).isEqualTo(0L); + assertThat(response.myRanking().rank()).isEqualTo(3L); // 마지막 순위 + 1 + assertThat(response.myRanking().username()).isEqualTo("홍길동"); + assertThat(response.myRanking().department()).isEqualTo("소프트웨어전공"); + + verify(userRepository).findById(userId); + } + + @Test + @DisplayName("사용자가 랭킹에도 없고 DB에도 없을 때 USER_NOT_FOUND 예외가 발생한다") + void getCurrentDaily_사용자없음_예외발생() { + // Given + Long userId = 999L; + List mockRankingData = List.of( + createMockPersonalRankingTemp(1L, "김철수", "profile1.jpg", "SOFTWARE", 7200000L, 1L) + ); + + given(studySessionRepository.calculateCurrentPeriodRanking(any(), any(), any())) + .willReturn(mockRankingData); + given(userRepository.findById(userId)) + .willReturn(Optional.empty()); + + // When & Then + assertThatThrownBy(() -> personalRankService.getCurrentDaily(userId)) + .isInstanceOf(BusinessException.class) + .hasFieldOrPropertyWithValue("exceptionType", ExceptionType.USER_NOT_FOUND); + } + } + + @Nested + @DisplayName("완료된 일간 랭킹 조회") + class GetCompletedDaily { + + @Test + @DisplayName("완료된 일간 랭킹 조회 시 정상적으로 랭킹 정보가 반환된다") + void getCompletedDaily_정상조회_랭킹정보반환() { + // Given + Long userId = 1L; + LocalDateTime day = LocalDateTime.of(2024, 1, 1, 0, 0); + + List mockRankingData = List.of( + createMockPersonalRankingTemp(1L, "김철수", "profile1.jpg", "SOFTWARE", 10800000L, 1L), + createMockPersonalRankingTemp(2L, "박영희", "profile2.jpg", "COMPUTER_ENGINEERING", 9000000L, 2L) + ); + + given(userRankingRepository.getFinishedPersonalRanking(day, RankingType.DAILY)) + .willReturn(mockRankingData); + + // When + PersonalRankingResponse response = personalRankService.getCompletedDaily(userId, day); + + // Then + assertThat(response).isNotNull(); + assertThat(response.topRanks()).hasSize(2); + assertThat(response.myRanking()).isNotNull(); + assertThat(response.myRanking().userId()).isEqualTo(1L); + assertThat(response.myRanking().rank()).isEqualTo(1L); + + verify(userRankingRepository).getFinishedPersonalRanking(day, RankingType.DAILY); + } + } + + @Nested + @DisplayName("현재 주간 랭킹 조회") + class GetCurrentWeekly { + + @Test + @DisplayName("현재 주간 랭킹 조회 시 월요일부터 일요일까지의 기간으로 계산된다") + void getCurrentWeekly_정상조회_주간기간계산() { + // Given + Long userId = 1L; + List mockRankingData = List.of( + createMockPersonalRankingTemp(1L, "김철수", "profile1.jpg", "SOFTWARE", 25200000L, 1L) + ); + + given(studySessionRepository.calculateCurrentPeriodRanking(any(), any(), any())) + .willReturn(mockRankingData); + + // When + PersonalRankingResponse response = personalRankService.getCurrentWeekly(userId); + + // Then + assertThat(response).isNotNull(); + assertThat(response.topRanks()).hasSize(1); + assertThat(response.myRanking().userId()).isEqualTo(1L); + + verify(studySessionRepository).calculateCurrentPeriodRanking(any(), any(), any()); + } + } + + @Nested + @DisplayName("현재 월간 랭킹 조회") + class GetCurrentMonthly { + + @Test + @DisplayName("현재 월간 랭킹 조회 시 해당 월의 첫날부터 마지막날까지의 기간으로 계산된다") + void getCurrentMonthly_정상조회_월간기간계산() { + // Given + Long userId = 1L; + List mockRankingData = List.of( + createMockPersonalRankingTemp(1L, "김철수", "profile1.jpg", "SOFTWARE", 108000000L, 1L) + ); + + given(studySessionRepository.calculateCurrentPeriodRanking(any(), any(), any())) + .willReturn(mockRankingData); + + // When + PersonalRankingResponse response = personalRankService.getCurrentMonthly(userId); + + // Then + assertThat(response).isNotNull(); + assertThat(response.topRanks()).hasSize(1); + assertThat(response.myRanking().userId()).isEqualTo(1L); + + verify(studySessionRepository).calculateCurrentPeriodRanking(any(), any(), any()); + } + } + + @Nested + @DisplayName("랭킹 Fallback 로직") + class RankingFallbackLogic { + + @Test + @DisplayName("랭킹에_없는_사용자는_꼴찌_다음_순위가_된다") + void 랭킹에_없는_사용자는_꼴찌_다음_순위가_된다() { + // Given + Long notInRankingUserId = 999L; + List rankings = List.of( + createMockPersonalRankingTemp(1L, "1위자", "p1.jpg", "SOFTWARE", 10000L, 1L), + createMockPersonalRankingTemp(2L, "2위자", "p2.jpg", "COMPUTER_ENGINEERING", 5000L, 2L) + ); + User notInRankingUser = createTestUser(notInRankingUserId, "신규사용자", Department.SOFTWARE); + + given(studySessionRepository.calculateCurrentPeriodRanking(any(), any(), any())) + .willReturn(rankings); + given(userRepository.findById(notInRankingUserId)) + .willReturn(Optional.of(notInRankingUser)); + + // When + PersonalRankingResponse response = personalRankService.getCurrentDaily(notInRankingUserId); + + // Then + assertThat(response.myRanking().rank()).isEqualTo(3L); // 마지막 순위(2) + 1 + assertThat(response.myRanking().totalMillis()).isEqualTo(0L); // 0시간 공부 + assertThat(response.myRanking().userId()).isEqualTo(notInRankingUserId); + assertThat(response.myRanking().username()).isEqualTo("신규사용자"); + } + + @Test + @DisplayName("빈_랭킹_목록에서도_첫번째_순위가_된다") + void 빈_랭킹_목록에서도_첫번째_순위가_된다() { + // Given + Long userId = 1L; + List emptyRankings = List.of(); + User firstUser = createTestUser(userId, "처음사용자", Department.SOFTWARE); + + given(studySessionRepository.calculateCurrentPeriodRanking(any(), any(), any())) + .willReturn(emptyRankings); + given(userRepository.findById(userId)) + .willReturn(Optional.of(firstUser)); + + // When + PersonalRankingResponse response = personalRankService.getCurrentDaily(userId); + + // Then + assertThat(response.myRanking().rank()).isEqualTo(1L); // 0 + 1 + assertThat(response.myRanking().totalMillis()).isEqualTo(0L); + assertThat(response.topRanks()).isEmpty(); + } + + @Test + @DisplayName("많은_랭킹_데이터에서_마지막_순위_종합") + void 많은_랭킹_데이터에서_마지막_순위_종합() { + // Given + Long notInRankingUserId = 999L; + + // 3명의 랭킹 데이터 생성 + List largeRankings = List.of( + createMockPersonalRankingTemp(1L, "TOP1", "p1.jpg", "SOFTWARE", 1000000L, 1L), + createMockPersonalRankingTemp(50L, "MIDDLE", "p50.jpg", "COMPUTER_ENGINEERING", 500000L, 50L), + createMockPersonalRankingTemp(100L, "LAST", "p100.jpg", "ELECTRONIC_SYSTEMS", 10000L, 100L) + ); + + User notInRankingUser = createTestUser(notInRankingUserId, "완전신규사용자", Department.SOFTWARE); + + given(studySessionRepository.calculateCurrentPeriodRanking(any(), any(), any())) + .willReturn(largeRankings); + given(userRepository.findById(notInRankingUserId)) + .willReturn(Optional.of(notInRankingUser)); + + // When + PersonalRankingResponse response = personalRankService.getCurrentDaily(notInRankingUserId); + + // Then + assertThat(response.myRanking().rank()).isEqualTo(4L); // 3명 + 1 = 4등 + assertThat(response.myRanking().totalMillis()).isEqualTo(0L); + } + + @Test + @DisplayName("부서_없는_사용자도_랭킹_fallback이_정상_동작한다") + void 부서_없는_사용자도_랭킹_fallback이_정상_동작한다() { + // Given + Long userId = 999L; + List rankings = List.of( + createMockPersonalRankingTemp(1L, "1등", "p1.jpg", "SOFTWARE", 10000L, 1L) + ); + + User userWithNoDepartment = User.builder() + .name("무소속사용자") + .email("nodept@kumoh.ac.kr") + .department(null) // 부서 없음 + .picture("profile.jpg") + .role(com.gpt.geumpumtabackend.user.domain.UserRole.USER) + .provider(com.gpt.geumpumtabackend.global.oauth.user.OAuth2Provider.GOOGLE) + .providerId("test-provider-id") + .build(); + + // Reflection으로 ID 설정 + try { + java.lang.reflect.Field idField = User.class.getDeclaredField("id"); + idField.setAccessible(true); + idField.set(userWithNoDepartment, userId); + } catch (Exception e) { + throw new RuntimeException("Failed to set test user ID", e); + } + + given(studySessionRepository.calculateCurrentPeriodRanking(any(), any(), any())) + .willReturn(rankings); + given(userRepository.findById(userId)) + .willReturn(Optional.of(userWithNoDepartment)); + + // When + PersonalRankingResponse response = personalRankService.getCurrentDaily(userId); + + // Then + assertThat(response.myRanking().rank()).isEqualTo(2L); // 1 + 1 + assertThat(response.myRanking().department()).isNull(); // null 부서 처리 + assertThat(response.myRanking().username()).isEqualTo("무소속사용자"); + } + + @Test + @DisplayName("랭킹에_있는_사용자는_실제_랭킹_정보를_반환한다") + void 랭킹에_있는_사용자는_실제_랭킹_정보를_반환한다() { + // Given + Long userId = 2L; // 랭킹에 있는 사용자 + List rankings = List.of( + createMockPersonalRankingTemp(1L, "1등", "p1.jpg", "SOFTWARE", 10000L, 1L), + createMockPersonalRankingTemp(2L, "2등", "p2.jpg", "COMPUTER_ENGINEERING", 8000L, 2L), + createMockPersonalRankingTemp(3L, "3등", "p3.jpg", "ELECTRONIC_SYSTEMS", 5000L, 3L) + ); + + given(studySessionRepository.calculateCurrentPeriodRanking(any(), any(), any())) + .willReturn(rankings); + // 랭킹에 있으므로 userRepository.findById 호출 안됨 + + // When + PersonalRankingResponse response = personalRankService.getCurrentDaily(userId); + + // Then + assertThat(response.myRanking().rank()).isEqualTo(2L); // 실제 랭킹 + assertThat(response.myRanking().totalMillis()).isEqualTo(8000L); // 실제 공부시간 + assertThat(response.myRanking().username()).isEqualTo("2등"); + + // fallback 로직을 타지 않으므로 userRepository는 호출되지 않음 + verify(userRepository, never()).findById(anyLong()); + } + } + + @Nested + @DisplayName("예외 상황 처리") + class ExceptionHandling { + + @Test + @DisplayName("랭킹에도_없고_DB에도_없는_사용자에게_USER_NOT_FOUND_예외가_발생한다") + void 랭킹에도_없고_DB에도_없는_사용자에게_USER_NOT_FOUND_예외가_발생한다() { + // Given + Long nonExistentUserId = 999L; + List rankings = List.of( + createMockPersonalRankingTemp(1L, "1등", "p1.jpg", "SOFTWARE", 10000L, 1L) + ); + + given(studySessionRepository.calculateCurrentPeriodRanking(any(), any(), any())) + .willReturn(rankings); + given(userRepository.findById(nonExistentUserId)) + .willReturn(Optional.empty()); // DB에 사용자 없음 + + // When & Then + assertThatThrownBy(() -> personalRankService.getCurrentDaily(nonExistentUserId)) + .isInstanceOf(BusinessException.class) + .hasFieldOrPropertyWithValue("exceptionType", ExceptionType.USER_NOT_FOUND); + + verify(userRepository).findById(nonExistentUserId); + } + } + + // 테스트 데이터 생성 헬퍼 메서드 + private User createTestUser(Long id, String name, Department department) { + User user = User.builder() + .name(name) + .email("test@kumoh.ac.kr") + .department(department) + .picture("test.jpg") + .role(com.gpt.geumpumtabackend.user.domain.UserRole.USER) + .provider(com.gpt.geumpumtabackend.global.oauth.user.OAuth2Provider.GOOGLE) + .providerId("test-provider-id") + .build(); + + // 테스트용 ID 설정 (Reflection 사용) + try { + java.lang.reflect.Field idField = User.class.getDeclaredField("id"); + idField.setAccessible(true); + idField.set(user, id); + } catch (Exception e) { + throw new RuntimeException("Failed to set test user ID", e); + } + return user; + } + + private PersonalRankingTemp createMockPersonalRankingTemp(Long userId, String nickname, String imageUrl, String department, Long totalMillis, Long ranking) { + PersonalRankingTemp mock = mock(PersonalRankingTemp.class); + given(mock.getUserId()).willReturn(userId); + given(mock.getNickname()).willReturn(nickname); + given(mock.getImageUrl()).willReturn(imageUrl); + // getDepartment()는 실제로 사용되지 않으므로 stubbing 제거 + given(mock.getTotalMillis()).willReturn(totalMillis); + given(mock.getRanking()).willReturn(ranking); + + // getDepartmentKoreanName만 모킹 (PersonalRankingEntryResponse.of()에서 실제 사용됨) + String koreanName = Department.valueOf(department).getKoreanName(); + given(mock.getDepartmentKoreanName()).willReturn(koreanName); + + return mock; + } +} \ No newline at end of file diff --git a/src/test/java/com/gpt/geumpumtabackend/unit/study/service/StudySessionServiceTest.java b/src/test/java/com/gpt/geumpumtabackend/unit/study/service/StudySessionServiceTest.java new file mode 100644 index 0000000..e570cc1 --- /dev/null +++ b/src/test/java/com/gpt/geumpumtabackend/unit/study/service/StudySessionServiceTest.java @@ -0,0 +1,314 @@ +package com.gpt.geumpumtabackend.unit.study.service; + +import com.gpt.geumpumtabackend.global.exception.BusinessException; +import com.gpt.geumpumtabackend.global.exception.ExceptionType; +import com.gpt.geumpumtabackend.study.config.StudyProperties; +import com.gpt.geumpumtabackend.study.domain.StudySession; +import com.gpt.geumpumtabackend.study.dto.request.StudyEndRequest; +import com.gpt.geumpumtabackend.study.dto.request.StudyStartRequest; +import com.gpt.geumpumtabackend.study.dto.response.StudyStartResponse; +import com.gpt.geumpumtabackend.study.event.StudySessionEndedEvent; +import com.gpt.geumpumtabackend.study.repository.StudySessionRepository; +import com.gpt.geumpumtabackend.study.service.StudySessionService; +import com.gpt.geumpumtabackend.user.domain.Department; +import com.gpt.geumpumtabackend.user.domain.User; +import com.gpt.geumpumtabackend.user.repository.UserRepository; +import com.gpt.geumpumtabackend.wifi.dto.WiFiValidationResult; +import com.gpt.geumpumtabackend.wifi.service.CampusWiFiValidationService; +import org.springframework.context.ApplicationEventPublisher; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +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; + +import java.time.LocalDateTime; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.*; +import org.springframework.test.context.ActiveProfiles; + +@ExtendWith(MockitoExtension.class) +@ActiveProfiles("unit-test") // 단위테스트 프로필 사용 (Redis 비활성화) +@DisplayName("StudySessionService 단위 테스트") +class StudySessionServiceTest { + + @Mock + private StudySessionRepository studySessionRepository; + + @Mock + private UserRepository userRepository; + + @Mock + private CampusWiFiValidationService wifiValidationService; + + @Mock + private StudyProperties studyProperties; + + @Mock + private ApplicationEventPublisher eventPublisher; + + @InjectMocks + private StudySessionService studySessionService; + + @Nested + @DisplayName("공부 세션 시작") + class StartStudySession { + + @Test + @DisplayName("Wi-Fi 검증 성공 시 세션이 정상 시작된다") + void 검증성공_세션시작성공() { + // Given + Long userId = 1L; + String gatewayIp = "192.168.1.1"; + String clientIp = "192.168.1.100"; + + StudyStartRequest request = new StudyStartRequest(gatewayIp, clientIp); + + User testUser = createTestUser(userId, "테스트사용자", Department.SOFTWARE); + + // Mock StudySession with proper ID + StudySession mockSession = mock(StudySession.class); + given(mockSession.getId()).willReturn(100L); + + // Mock 설정 + given(wifiValidationService.validateCampusWiFi(gatewayIp, clientIp)) + .willReturn(WiFiValidationResult.valid("캠퍼스 네트워크입니다")); + given(userRepository.findById(userId)) + .willReturn(Optional.of(testUser)); + given(studySessionRepository.save(any(StudySession.class))) + .willReturn(mockSession); + + // When + StudyStartResponse response = studySessionService.startStudySession(request, userId); + + // Then + assertThat(response).isNotNull(); + assertThat(response.studySessionId()).isEqualTo(100L); + + verify(wifiValidationService).validateCampusWiFi(gatewayIp, clientIp); + verify(userRepository).findById(userId); + verify(studySessionRepository).save(any(StudySession.class)); + } + + @Test + @DisplayName("Wi-Fi 검증 실패(INVALID) 시 WIFI_NOT_CAMPUS_NETWORK 예외가 발생한다") + void 검증실패_INVALID_예외발생() { + // Given + Long userId = 1L; + String gatewayIp = "192.168.10.1"; // 잘못된 게이트웨이 + String clientIp = "192.168.10.100"; + LocalDateTime startTime = LocalDateTime.now(); + + StudyStartRequest request = new StudyStartRequest(gatewayIp, clientIp); + + given(wifiValidationService.validateCampusWiFi(gatewayIp, clientIp)) + .willReturn(WiFiValidationResult.invalid("캠퍼스 네트워크가 아닙니다")); + + // When & Then + assertThatThrownBy(() -> studySessionService.startStudySession(request, userId)) + .isInstanceOf(BusinessException.class) + .hasFieldOrPropertyWithValue("exceptionType", ExceptionType.WIFI_NOT_CAMPUS_NETWORK); + + verify(wifiValidationService).validateCampusWiFi(gatewayIp, clientIp); + verify(userRepository, never()).findById(anyLong()); + verify(studySessionRepository, never()).save(any()); + } + + @Test + @DisplayName("Wi-Fi 검증 에러(ERROR) 시 WIFI_VALIDATION_ERROR 예외가 발생한다") + void 검증에러_ERROR_예외발생() { + // Given + Long userId = 1L; + String gatewayIp = "192.168.1.1"; + String clientIp = "192.168.1.100"; + LocalDateTime startTime = LocalDateTime.now(); + + StudyStartRequest request = new StudyStartRequest(gatewayIp, clientIp); + + given(wifiValidationService.validateCampusWiFi(gatewayIp, clientIp)) + .willReturn(WiFiValidationResult.error("Redis 연결 실패")); + + // When & Then + assertThatThrownBy(() -> studySessionService.startStudySession(request, userId)) + .isInstanceOf(BusinessException.class) + .hasFieldOrPropertyWithValue("exceptionType", ExceptionType.WIFI_VALIDATION_ERROR); + } + + @Test + @DisplayName("존재하지 않는 사용자 ID로 세션 시작 시 USER_NOT_FOUND 예외가 발생한다") + void 존재하지않는사용자_예외발생() { + // Given + Long userId = 999L; + String gatewayIp = "192.168.1.1"; + String clientIp = "192.168.1.100"; + LocalDateTime startTime = LocalDateTime.now(); + + StudyStartRequest request = new StudyStartRequest(gatewayIp, clientIp); + + given(wifiValidationService.validateCampusWiFi(gatewayIp, clientIp)) + .willReturn(WiFiValidationResult.valid("캠퍼스 네트워크입니다")); + given(userRepository.findById(userId)) + .willReturn(Optional.empty()); + + // When & Then + assertThatThrownBy(() -> studySessionService.startStudySession(request, userId)) + .isInstanceOf(BusinessException.class) + .hasFieldOrPropertyWithValue("exceptionType", ExceptionType.USER_NOT_FOUND); + } + } + + // 하트비트 기능이 현재 서비스에서 제거된 상태로 확인됨 + // updateHeartBeat 메서드가 존재하지 않으므로 관련 테스트 제거 + + @Nested + @DisplayName("공부시간 계산 로직") + class StudySessionCalculation { + + @Test + @DisplayName("정상적인 공부세션 종료시 올바른 시간이 계산된다") + void 정상적인_공부세션_종료시_올바른_시간이_계산된다() { + // Given + LocalDateTime startTime = LocalDateTime.of(2024, 1, 1, 9, 0); + LocalDateTime endTime = LocalDateTime.of(2024, 1, 1, 10, 30); + User testUser = createTestUser(1L, "테스트사용자", Department.SOFTWARE); + + StudySession session = new StudySession(); + + // When + session.startStudySession(startTime, testUser); + session.endStudySession(endTime); + + // Then + assertThat(session.getTotalMillis()).isEqualTo(5400000L); // 90분 = 90 * 60 * 1000ms + assertThat(session.getStatus()).isEqualTo(com.gpt.geumpumtabackend.study.domain.StudyStatus.FINISHED); + assertThat(session.getEndTime()).isEqualTo(endTime); + } + + @Test + @DisplayName("매우 짧은 세션(1초)도 올바르게 계산된다") + void 매우_짧은_세션도_올바르게_계산된다() { + // Given + LocalDateTime startTime = LocalDateTime.of(2024, 1, 1, 9, 0, 0); + LocalDateTime endTime = LocalDateTime.of(2024, 1, 1, 9, 0, 1); + User testUser = createTestUser(1L, "테스트사용자", Department.SOFTWARE); + + StudySession session = new StudySession(); + + // When + session.startStudySession(startTime, testUser); + session.endStudySession(endTime); + + // Then + assertThat(session.getTotalMillis()).isEqualTo(1000L); // 1초 = 1000ms + } + + @Test + @DisplayName("자정을 넘어가는 세션도 올바르게 계산된다") + void 자정을_넘어가는_세션도_올바르게_계산된다() { + // Given + LocalDateTime startTime = LocalDateTime.of(2024, 1, 1, 23, 30); + LocalDateTime endTime = LocalDateTime.of(2024, 1, 2, 1, 30); + User testUser = createTestUser(1L, "테스트사용자", Department.SOFTWARE); + + StudySession session = new StudySession(); + + // When + session.startStudySession(startTime, testUser); + session.endStudySession(endTime); + + // Then + assertThat(session.getTotalMillis()).isEqualTo(7200000L); // 2시간 = 2 * 60 * 60 * 1000ms + } + + @Test + @DisplayName("초기 세션 생성시 상태가 올바르게 설정된다") + void 초기_세션_생성시_상태가_올바르게_설정된다() { + // Given + LocalDateTime startTime = LocalDateTime.now(); + User testUser = createTestUser(1L, "테스트사용자", Department.SOFTWARE); + StudySession session = new StudySession(); + + // When + session.startStudySession(startTime, testUser); + + // Then + assertThat(session.getStartTime()).isEqualTo(startTime); + assertThat(session.getUser()).isEqualTo(testUser); + assertThat(session.getStatus()).isEqualTo(com.gpt.geumpumtabackend.study.domain.StudyStatus.STARTED); + assertThat(session.getEndTime()).isNull(); + assertThat(session.getTotalMillis()).isNull(); + } + } + + @Nested + @DisplayName("공부 종료") + class EndStudySession { + + @Test + @DisplayName("공부 종료 시 세션을 종료하고 AFTER_COMMIT 이벤트를 발행한다") + void 공부종료시_세션종료후_이벤트를_발행한다() { + // Given + Long userId = 1L; + Long sessionId = 10L; + User testUser = createTestUser(userId, "테스트사용자", Department.SOFTWARE); + StudySession session = new StudySession(); + session.startStudySession(LocalDateTime.now().minusHours(1), testUser); + + given(studySessionRepository.findByIdAndUser_Id(sessionId, userId)) + .willReturn(Optional.of(session)); + + // When + studySessionService.endStudySession(new StudyEndRequest(sessionId), userId); + + // Then + assertThat(session.getStatus()).isEqualTo(com.gpt.geumpumtabackend.study.domain.StudyStatus.FINISHED); + verify(eventPublisher).publishEvent(new StudySessionEndedEvent(userId)); + } + + @Test + @DisplayName("세션이 없으면 STUDY_SESSION_NOT_FOUND 예외가 발생한다") + void 세션이_없으면_예외가_발생한다() { + // Given + Long userId = 1L; + Long sessionId = 11L; + given(studySessionRepository.findByIdAndUser_Id(sessionId, userId)) + .willReturn(Optional.empty()); + + // When + assertThatThrownBy(() -> studySessionService.endStudySession(new StudyEndRequest(sessionId), userId)) + .isInstanceOf(BusinessException.class) + .hasFieldOrPropertyWithValue("exceptionType", ExceptionType.STUDY_SESSION_NOT_FOUND); + verify(eventPublisher, never()).publishEvent(any()); + } + } + + // 테스트 데이터 생성 헬퍼 메서드 + private User createTestUser(Long id, String name, Department department) { + User user = User.builder() + .name(name) + .email("test@kumoh.ac.kr") + .department(department) + .picture("test.jpg") + .role(com.gpt.geumpumtabackend.user.domain.UserRole.USER) + .provider(com.gpt.geumpumtabackend.global.oauth.user.OAuth2Provider.GOOGLE) + .providerId("test-provider-id") + .build(); + + // 테스트용 ID 설정 (Reflection 사용) + try { + java.lang.reflect.Field idField = User.class.getDeclaredField("id"); + idField.setAccessible(true); + idField.set(user, id); + } catch (Exception e) { + throw new RuntimeException("Failed to set test user ID", e); + } + + return user; + } +} diff --git a/src/test/java/com/gpt/geumpumtabackend/unit/user/service/UserServiceTest.java b/src/test/java/com/gpt/geumpumtabackend/unit/user/service/UserServiceTest.java new file mode 100644 index 0000000..9b03ba9 --- /dev/null +++ b/src/test/java/com/gpt/geumpumtabackend/unit/user/service/UserServiceTest.java @@ -0,0 +1,459 @@ +package com.gpt.geumpumtabackend.unit.user.service; + +import com.gpt.geumpumtabackend.badge.dto.response.NewBadgeResponse; +import com.gpt.geumpumtabackend.badge.service.BadgeService; +import com.gpt.geumpumtabackend.fcm.service.FcmService; +import com.gpt.geumpumtabackend.global.exception.BusinessException; +import com.gpt.geumpumtabackend.global.exception.ExceptionType; +import com.gpt.geumpumtabackend.global.jwt.JwtHandler; +import com.gpt.geumpumtabackend.global.jwt.JwtUserClaim; +import com.gpt.geumpumtabackend.global.oauth.user.OAuth2Provider; +import com.gpt.geumpumtabackend.token.domain.Token; +import com.gpt.geumpumtabackend.token.dto.response.TokenResponse; +import com.gpt.geumpumtabackend.token.repository.RefreshTokenRepository; +import com.gpt.geumpumtabackend.user.domain.Department; +import com.gpt.geumpumtabackend.user.domain.User; +import com.gpt.geumpumtabackend.user.domain.UserRole; +import com.gpt.geumpumtabackend.user.dto.request.CompleteRegistrationRequest; +import com.gpt.geumpumtabackend.user.dto.request.NicknameVerifyRequest; +import com.gpt.geumpumtabackend.user.dto.request.ProfileUpdateRequest; +import com.gpt.geumpumtabackend.user.dto.response.CompleteRegistrationResponse; +import com.gpt.geumpumtabackend.user.dto.response.UserProfileResponse; +import com.gpt.geumpumtabackend.user.repository.UserRepository; +import com.gpt.geumpumtabackend.user.service.UserService; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +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; + +import java.time.LocalDateTime; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("UserService 단위 테스트") +class UserServiceTest { + + @Mock + private UserRepository userRepository; + + @Mock + private RefreshTokenRepository refreshTokenRepository; + + @Mock + private JwtHandler jwtHandler; + + @Mock + private FcmService fcmService; + + @Mock + private BadgeService badgeService; + + @InjectMocks + private UserService userService; + + @Nested + @DisplayName("관리자 여부 확인") + class IsAdmin { + + @Test + @DisplayName("관리자 권한 사용자면 true를 반환한다") + void 관리자면_true_반환() { + // Given + Long userId = 1L; + User adminUser = createTestUser(userId, UserRole.ADMIN); + given(userRepository.findById(userId)).willReturn(Optional.of(adminUser)); + + // When + boolean result = userService.isAdmin(userId); + + // Then + assertThat(result).isTrue(); + } + + @Test + @DisplayName("관리자 권한이 아니면 false를 반환한다") + void 관리자가아니면_false_반환() { + // Given + Long userId = 1L; + User normalUser = createTestUser(userId, UserRole.USER); + given(userRepository.findById(userId)).willReturn(Optional.of(normalUser)); + + // When + boolean result = userService.isAdmin(userId); + + // Then + assertThat(result).isFalse(); + } + } + + @Nested + @DisplayName("랜덤 닉네임 생성") + class GenerateRandomNickname { + + @Test + @DisplayName("중복 닉네임이 있으면 재생성 후 닉네임이 설정된다") + void 중복닉네임_재생성후_닉네임설정() { + // Given + User user = createTestUser(1L, UserRole.USER); + given(userRepository.existsByNickname(any())).willReturn(true, false); + + // When + userService.generateRandomNickname(user); + + // Then + assertThat(user.getNickname()).isNotBlank(); + verify(userRepository, times(2)).existsByNickname(any()); + } + } + + @Nested + @DisplayName("회원가입 완료") + class CompleteRegistration { + + @Test + @DisplayName("회원가입 완료 시 사용자 정보가 갱신되고 토큰이 반환된다") + void 회원가입완료_정상처리() { + // Given + Long userId = 1L; + User user = createTestUser(userId, UserRole.GUEST); + CompleteRegistrationRequest request = new CompleteRegistrationRequest( + "test@kumoh.ac.kr", + "20240001", + "Software Engineering" + ); + + Token token = Token.builder() + .accessToken("access-token") + .refreshToken("refresh-token") + .build(); + NewBadgeResponse newBadge = new NewBadgeResponse( + "WELCOME_001", + "웰컴 배지", + "회원가입 기념 배지", + "https://example.com/welcome.png" + ); + + given(userRepository.findById(userId)).willReturn(Optional.of(user)); + given(userRepository.existsBySchoolEmail(request.email())).willReturn(false); + given(userRepository.existsByStudentId(request.studentId())).willReturn(false); + given(userRepository.existsByNickname(any())).willReturn(false); + given(jwtHandler.createTokens(any(JwtUserClaim.class))).willReturn(token); + given(badgeService.grantWelcomeBadge(userId)).willReturn(newBadge); + + // When + CompleteRegistrationResponse response = userService.completeRegistration(request, userId); + + // Then + assertThat(response.token().accessToken()).isEqualTo("access-token"); + assertThat(response.token().refreshToken()).isEqualTo("refresh-token"); + assertThat(user.getSchoolEmail()).isEqualTo(request.email()); + assertThat(user.getStudentId()).isEqualTo(request.studentId()); + assertThat(user.getDepartment()).isEqualTo(Department.SOFTWARE); + assertThat(user.getRole()).isEqualTo(UserRole.USER); + assertThat(user.getNickname()).isNotBlank(); + verify(jwtHandler).createTokens(argThat(claim -> + claim.userId().equals(userId) && + claim.role().equals(UserRole.USER) && + !claim.withdrawn() + )); + verify(badgeService).grantWelcomeBadge(userId); + } + + @Test + @DisplayName("이메일이 중복이면 DUPLICATED_SCHOOL_EMAIL 예외가 발생한다") + void 이메일중복_예외발생() { + // Given + Long userId = 1L; + User user = createTestUser(userId, UserRole.GUEST); + CompleteRegistrationRequest request = new CompleteRegistrationRequest( + "test@kumoh.ac.kr", + "20240001", + "Software Engineering" + ); + + given(userRepository.findById(userId)).willReturn(Optional.of(user)); + given(userRepository.existsBySchoolEmail(request.email())).willReturn(true); + + // When & Then + assertThatThrownBy(() -> userService.completeRegistration(request, userId)) + .isInstanceOf(BusinessException.class) + .hasFieldOrPropertyWithValue("exceptionType", ExceptionType.DUPLICATED_SCHOOL_EMAIL); + } + + @Test + @DisplayName("학번이 중복이면 DUPLICATED_STUDENT_ID 예외가 발생한다") + void 학번중복_예외발생() { + // Given + Long userId = 1L; + User user = createTestUser(userId, UserRole.GUEST); + CompleteRegistrationRequest request = new CompleteRegistrationRequest( + "test@kumoh.ac.kr", + "20240001", + "Software Engineering" + ); + + given(userRepository.findById(userId)).willReturn(Optional.of(user)); + given(userRepository.existsBySchoolEmail(request.email())).willReturn(false); + given(userRepository.existsByStudentId(request.studentId())).willReturn(true); + + // When & Then + assertThatThrownBy(() -> userService.completeRegistration(request, userId)) + .isInstanceOf(BusinessException.class) + .hasFieldOrPropertyWithValue("exceptionType", ExceptionType.DUPLICATED_STUDENT_ID); + } + } + + @Nested + @DisplayName("사용자 프로필 조회") + class GetUserProfile { + + @Test + @DisplayName("사용자 프로필 조회 시 사용자 정보가 반환된다") + void 프로필조회_정상반환() { + // Given + Long userId = 1L; + User user = createTestUser(userId, UserRole.USER); + setField(user, "nickname", "tester"); + setField(user, "schoolEmail", "school@kumoh.ac.kr"); + setField(user, "studentId", "20240001"); + + given(userRepository.findById(userId)).willReturn(Optional.of(user)); + + // When + UserProfileResponse response = userService.getUserProfile(userId); + + // Then + assertThat(response).isNotNull(); + assertThat(response.email()).isEqualTo(user.getEmail()); + assertThat(response.schoolEmail()).isEqualTo("school@kumoh.ac.kr"); + assertThat(response.userRole()).isEqualTo("USER"); + assertThat(response.name()).isEqualTo(user.getName()); + assertThat(response.nickName()).isEqualTo("tester"); + assertThat(response.profilePictureUrl()).isEqualTo(user.getPicture()); + assertThat(response.OAuthProvider()).isEqualTo("GOOGLE"); + assertThat(response.studentId()).isEqualTo("20240001"); + assertThat(response.department()).isEqualTo("소프트웨어전공"); + } + } + + @Nested + @DisplayName("닉네임 사용 가능 여부") + class IsNicknameAvailable { + + @Test + @DisplayName("닉네임이 중복이면 false를 반환한다") + void 닉네임중복_false_반환() { + // Given + Long userId = 1L; + User user = createTestUser(userId, UserRole.USER); + NicknameVerifyRequest request = new NicknameVerifyRequest("tester"); + given(userRepository.findById(userId)).willReturn(Optional.of(user)); + given(userRepository.existsByNickname(request.nickname())).willReturn(true); + + // When + boolean result = userService.isNicknameAvailable(request, userId); + + // Then + assertThat(result).isFalse(); + } + } + + @Nested + @DisplayName("프로필 업데이트") + class UpdateUserProfile { + + @Test + @DisplayName("프로필 업데이트 시 사용자 정보가 변경된다") + void 프로필업데이트_정상처리() { + // Given + Long userId = 1L; + User user = createTestUser(userId, UserRole.USER); + ProfileUpdateRequest request = new ProfileUpdateRequest( + "https://image.test/profile.png", + "public-id", + "newname" + ); + + given(userRepository.findById(userId)).willReturn(Optional.of(user)); + + // When + userService.updateUserProfile(request, userId); + + // Then + assertThat(user.getPicture()).isEqualTo("https://image.test/profile.png"); + assertThat(user.getPublicId()).isEqualTo("public-id"); + assertThat(user.getNickname()).isEqualTo("newname"); + } + } + + @Nested + @DisplayName("로그아웃") + class Logout { + + @Test + @DisplayName("로그아웃 시 리프레시 토큰이 삭제된다") + void 로그아웃_정상처리() { + // Given + Long userId = 1L; + User user = createTestUser(userId, UserRole.USER); + given(userRepository.findById(userId)).willReturn(Optional.of(user)); + + // When + userService.logout(userId); + + // Then + verify(refreshTokenRepository).deleteByUserId(userId); + verify(fcmService).removeFcmToken(userId); + } + + @Test + @DisplayName("사용자가 없으면 USER_NOT_FOUND 예외가 발생한다") + void 사용자없음_예외발생() { + // Given + Long userId = 1L; + given(userRepository.findById(userId)).willReturn(Optional.empty()); + + // When & Then + assertThatThrownBy(() -> userService.logout(userId)) + .isInstanceOf(BusinessException.class) + .hasFieldOrPropertyWithValue("exceptionType", ExceptionType.USER_NOT_FOUND); + } + } + + @Nested + @DisplayName("회원 탈퇴") + class WithdrawUser { + + @Test + @DisplayName("회원 탈퇴 시 사용자와 리프레시 토큰이 삭제된다") + void 회원탈퇴_정상처리() { + // Given + Long userId = 1L; + User user = createTestUser(userId, UserRole.USER); + given(userRepository.findById(userId)).willReturn(Optional.of(user)); + + // When + userService.withdrawUser(userId); + + // Then + verify(refreshTokenRepository).deleteByUserId(userId); + verify(fcmService).removeFcmToken(userId); + verify(userRepository).deleteById(userId); + } + } + + @Nested + @DisplayName("회원 복구") + class RestoreUser { + + @Test + @DisplayName("회원 복구 시 삭제 prefix가 제거되고 토큰이 발급된다") + void 회원복구_정상처리() { + // Given + Long userId = 1L; + User user = createTestUser(userId, UserRole.USER); + setField(user, "nickname", "deleted_nick"); + setField(user, "email", "deleted_user@kumoh.ac.kr"); + setField(user, "schoolEmail", "deleted_school@kumoh.ac.kr"); + setField(user, "studentId", "deleted_20240001"); + setField(user, "deletedAt", LocalDateTime.now()); + + Token token = Token.builder() + .accessToken("access-token") + .refreshToken("refresh-token") + .build(); + + given(userRepository.findById(userId)).willReturn(Optional.of(user)); + given(jwtHandler.createTokens(any(JwtUserClaim.class))).willReturn(token); + + // When + TokenResponse response = userService.restoreUser(userId); + + // Then + assertThat(response.accessToken()).isEqualTo("access-token"); + assertThat(response.refreshToken()).isEqualTo("refresh-token"); + assertThat(user.getNickname()).isEqualTo("nick"); + assertThat(user.getEmail()).isEqualTo("user@kumoh.ac.kr"); + assertThat(user.getSchoolEmail()).isEqualTo("school@kumoh.ac.kr"); + assertThat(user.getStudentId()).isEqualTo("20240001"); + assertThat(user.getDeletedAt()).isNull(); + verify(jwtHandler).createTokens(argThat(claim -> + claim.userId().equals(userId) && !claim.withdrawn() + )); + } + } + + @Nested + @DisplayName("삭제 prefix 제거") + class RemoveDeletedPrefix { + + @Test + @DisplayName("prefix가 있으면 삭제하고 없으면 그대로 반환한다") + void prefix_처리_정상() { + // Given + String prefixed = "deleted_value"; + String plain = "value"; + + // When + String removed = userService.removeDeletedPrefix(prefixed); + String unchanged = userService.removeDeletedPrefix(plain); + + // Then + assertThat(removed).isEqualTo("value"); + assertThat(unchanged).isEqualTo("value"); + } + + @Test + @DisplayName("null 입력이면 null을 반환한다") + void null_입력_반환() { + // When + String result = userService.removeDeletedPrefix(null); + + // Then + assertThat(result).isNull(); + } + } + + private User createTestUser(Long id, UserRole role) { + User user = User.builder() + .name("테스트사용자") + .email("user@kumoh.ac.kr") + .department(Department.SOFTWARE) + .picture("profile.jpg") + .role(role) + .provider(OAuth2Provider.GOOGLE) + .providerId("provider-id") + .build(); + + setField(user, "id", id); + return user; + } + + private void setField(User user, String fieldName, Object value) { + try { + Class current = user.getClass(); + while (current != null) { + try { + java.lang.reflect.Field field = current.getDeclaredField(fieldName); + field.setAccessible(true); + field.set(user, value); + return; + } catch (NoSuchFieldException ignored) { + current = current.getSuperclass(); + } + } + throw new NoSuchFieldException("Field not found: " + fieldName); + } catch (Exception e) { + throw new RuntimeException("Failed to set field for test: " + fieldName, e); + } + } +} diff --git a/src/test/java/com/gpt/geumpumtabackend/unit/wifi/service/CampusWiFiValidationServiceTest.java b/src/test/java/com/gpt/geumpumtabackend/unit/wifi/service/CampusWiFiValidationServiceTest.java new file mode 100644 index 0000000..91d6c76 --- /dev/null +++ b/src/test/java/com/gpt/geumpumtabackend/unit/wifi/service/CampusWiFiValidationServiceTest.java @@ -0,0 +1,149 @@ +package com.gpt.geumpumtabackend.unit.wifi.service; + +import com.gpt.geumpumtabackend.wifi.config.CampusWiFiProperties; +import com.gpt.geumpumtabackend.wifi.dto.WiFiValidationResult; +import com.gpt.geumpumtabackend.wifi.service.CampusWiFiValidationService; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +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; +import org.springframework.test.context.ActiveProfiles; + +import java.util.List; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@ActiveProfiles("unit-test") +@DisplayName("CampusWiFiValidationService 단위 테스트") +class CampusWiFiValidationServiceTest { + + @Mock + private CampusWiFiProperties wifiProperties; + + @InjectMocks + private CampusWiFiValidationService wifiValidationService; + + @Nested + @DisplayName("캠퍼스 WiFi 검증 로직") + class ValidateCampusWiFi { + + @Test + @DisplayName("유효한_네트워크_정보가_매칭되면_검증_성공") + void 매칭성공() { + // Given + String gatewayIp = "192.168.1.1"; + String clientIp = "192.168.1.100"; + + CampusWiFiProperties.WiFiNetwork network = mock(CampusWiFiProperties.WiFiNetwork.class); + given(wifiProperties.networks()).willReturn(List.of(network)); + + given(network.active()).willReturn(true); + given(network.isValidGatewayIP(gatewayIp)).willReturn(true); + given(network.isValidIP(clientIp)).willReturn(true); + + // When + WiFiValidationResult result = wifiValidationService.validateCampusWiFi(gatewayIp, clientIp); + + // Then + assertThat(result.isValid()).isTrue(); + assertThat(result.getMessage()).isEqualTo("캠퍼스 네트워크입니다"); + } + + @Test + @DisplayName("매칭되는_네트워크가_없으면_검증_실패") + void 매칭실패() { + // Given + String gatewayIp = "192.168.1.1"; + String clientIp = "192.168.1.100"; + + CampusWiFiProperties.WiFiNetwork network = mock(CampusWiFiProperties.WiFiNetwork.class); + given(wifiProperties.networks()).willReturn(List.of(network)); + + given(network.active()).willReturn(true); + given(network.isValidGatewayIP(gatewayIp)).willReturn(false); // 게이트웨이 불일치 + + // When + WiFiValidationResult result = wifiValidationService.validateCampusWiFi(gatewayIp, clientIp); + + // Then + assertThat(result.isValid()).isFalse(); + assertThat(result.getMessage()).isEqualTo("캠퍼스 네트워크가 아닙니다"); + } + + @Test + @DisplayName("게이트웨이_IP는_매칭되지만_클라이언트_IP_범위가_다르면_검증_실패") + void 게이트웨이매칭_클라이언트불일치() { + // Given + String gatewayIp = "192.168.1.1"; + String clientIp = "192.168.2.100"; // 다른 대역 + + CampusWiFiProperties.WiFiNetwork network = mock(CampusWiFiProperties.WiFiNetwork.class); + given(wifiProperties.networks()).willReturn(List.of(network)); + + given(network.active()).willReturn(true); + given(network.isValidGatewayIP(gatewayIp)).willReturn(true); + given(network.isValidIP(clientIp)).willReturn(false); // IP 범위 불일치 + + // When + WiFiValidationResult result = wifiValidationService.validateCampusWiFi(gatewayIp, clientIp); + + // Then + assertThat(result.isValid()).isFalse(); + assertThat(result.getMessage()).isEqualTo("캠퍼스 네트워크가 아닙니다"); + } + + @Test + @DisplayName("활성화되지_않은_네트워크는_검증에서_제외된다") + void 비활성화네트워크제외() { + // Given + String gatewayIp = "192.168.1.1"; + String clientIp = "192.168.1.100"; + + CampusWiFiProperties.WiFiNetwork inactiveNetwork = mock(CampusWiFiProperties.WiFiNetwork.class); + given(wifiProperties.networks()).willReturn(List.of(inactiveNetwork)); + + given(inactiveNetwork.active()).willReturn(false); // 비활성화 + + // When + WiFiValidationResult result = wifiValidationService.validateCampusWiFi(gatewayIp, clientIp); + + // Then + assertThat(result.isValid()).isFalse(); + verify(inactiveNetwork, never()).isValidGatewayIP(any()); // 비활성화 네트워크는 체크 안함 + } + + @Test + @DisplayName("여러_네트워크_중_하나라도_매칭되면_검증_성공") + void 여러네트워크중_하나매칭() { + // Given + String gatewayIp = "172.30.64.1"; + String clientIp = "172.30.64.100"; + + CampusWiFiProperties.WiFiNetwork network1 = mock(CampusWiFiProperties.WiFiNetwork.class); + CampusWiFiProperties.WiFiNetwork network2 = mock(CampusWiFiProperties.WiFiNetwork.class); + given(wifiProperties.networks()).willReturn(List.of(network1, network2)); + + // network1은 불일치 + given(network1.active()).willReturn(true); + given(network1.isValidGatewayIP(gatewayIp)).willReturn(false); + + // network2는 매칭 + given(network2.active()).willReturn(true); + given(network2.isValidGatewayIP(gatewayIp)).willReturn(true); + given(network2.isValidIP(clientIp)).willReturn(true); + + // When + WiFiValidationResult result = wifiValidationService.validateCampusWiFi(gatewayIp, clientIp); + + // Then + assertThat(result.isValid()).isTrue(); + assertThat(result.getMessage()).isEqualTo("캠퍼스 네트워크입니다"); + } + } +} diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml index 6b90e2c..50cc961 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -3,6 +3,22 @@ spring: activate: on-profile: test + + + datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + # URL will be dynamically set by BaseIntegrationTest via @DynamicPropertySource + hikari: + maximum-pool-size: 10 + minimum-idle: 2 + connection-timeout: 10000 # 10초로 단축 + idle-timeout: 300000 # 5분으로 단축 + max-lifetime: 600000 # 10분으로 단축 + connection-test-query: SELECT 1 + validation-timeout: 3000 # 3초로 단축 + leak-detection-threshold: 60000 + initialization-fail-timeout: 10000 # 초기화 실패 타임아웃 10초 + mail: host: dummy-naver.com #smtp 서버 주소 port: 123 # 메일 인증서버 포트 @@ -59,36 +75,28 @@ spring: user-info-uri: https://kapi.kakao.com/v2/user/me user-name-attribute: id - datasource: - url: jdbc:h2:mem:testdb;MODE=MySQL;DB_CLOSE_DELAY=-1;DATABASE_TO_UPPER=false - driver-class-name: org.h2.Driver - username: sa - password: - - data: - redis: - host: dummy-redis-host - port: 122 - password: dummy-redis-password - repositories: - enabled: false jpa: - generate-ddl: false hibernate: ddl-auto: create-drop properties: hibernate: - dialect: org.hibernate.dialect.MySQL8Dialect + dialect: org.hibernate.dialect.MySQLDialect format_sql: true + show-sql: false open-in-view: false + data: + redis: + repositories: + enabled: true + sql: init: mode: never geumpumta: jwt: - secret-key: secret-key + secret-key: test-secret-key-for-jwt-token-generation-must-be-at-least-256-bits-long access-token-expire-in: 1209600 refresh-token-expire-in: 1209600 @@ -97,4 +105,23 @@ apple: client-id: dummy-client-id key-id: dummy-key-id audience: dummy-audience - private-key: dummy-private-key \ No newline at end of file + private-key: dummy-private-key + +cloudinary: + cloud-name: test-cloud-name + api-key: test-api-key + api-secret: test-api-secret + +# 테스트용 WiFi 설정 (prefix 수정: campus.wifi) +campus: + wifi: + networks: + - name: "kumoh-lab-test" + gateway-ips: + - "172.30.64.1" + ip-ranges: + - "172.30.64.0/18" # 실제 설정과 동일하게 수정 + active: true + description: "캠퍼스 테스트 네트워크" + validation: + cache-ttl-minutes: 5 # 테스트용 짧은 TTL \ No newline at end of file diff --git a/src/test/resources/application-unit-test.yml b/src/test/resources/application-unit-test.yml new file mode 100644 index 0000000..0224c01 --- /dev/null +++ b/src/test/resources/application-unit-test.yml @@ -0,0 +1,120 @@ +spring: + config: + activate: + on-profile: unit-test + + # 스케줄링 비활성화 (단위 테스트) + task: + scheduling: + enabled: false + + # H2 Database for unit tests + datasource: + url: jdbc:h2:mem:unit_testdb;MODE=MySQL;DB_CLOSE_DELAY=-1;DATABASE_TO_UPPER=false + driver-class-name: org.h2.Driver + username: sa + password: + + # Redis 완전 비활성화 (단위테스트) + data: + redis: + repositories: + enabled: false + + autoconfigure: + exclude: + - org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration + - org.springframework.boot.autoconfigure.data.redis.RedisRepositoriesAutoConfiguration + + # Mail 설정 (단위테스트용) + mail: + host: dummy-naver.com + port: 123 + username: dummyUsername + password: dummyPassword + default-encoding: UTF-8 + properties: + mail: + smtp: + auth: true + starttls: + enable: false + connectiontimeout: 5000 + timeout: 5000 + writetimeout: 5000 + ssl: + enable: true + + jpa: + hibernate: + ddl-auto: create-drop + properties: + hibernate: + dialect: org.hibernate.dialect.H2Dialect + format_sql: true + open-in-view: false + + # OAuth2 설정 (단위테스트용) + security: + oauth2: + client: + registration: + google: + client-id: unit-test-google-client-id + client-secret: unit-test-google-client-secret + authorization-grant-type: authorization_code + redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}" + scope: + - profile + - email + provider: google + kakao: + client-id: unit-test-kakao-client-id + client-secret: unit-test-kakao-client-secret + client-name: Kakao + authorization-grant-type: authorization_code + client-authentication-method: client_secret_post + redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}" + scope: + - account_email + - profile_nickname + - profile_image + provider: kakao + + provider: + google: + authorization-uri: https://accounts.google.com/o/oauth2/v2/auth + token-uri: https://oauth2.googleapis.com/token + user-info-uri: https://www.googleapis.com/oauth2/v3/userinfo + user-name-attribute: sub + kakao: + authorization-uri: https://kauth.kakao.com/oauth/authorize + token-uri: https://kauth.kakao.com/oauth/token + user-info-uri: https://kapi.kakao.com/v2/user/me + user-name-attribute: id + +# JWT 설정 (단위테스트용) +geumpumta: + jwt: + secret-key: unit-test-secret-key-for-jwt-token-generation-must-be-at-least-256-bits-long + access-token-expire-in: 1209600 + refresh-token-expire-in: 1209600 + +# Cloudinary 설정 (단위테스트용) +cloudinary: + cloud-name: unit-test-cloud-name + api-key: unit-test-api-key + api-secret: unit-test-api-secret + +# Apple OAuth 설정 (단위테스트용) +apple: + team-id: unit-test-team-id + client-id: unit-test-client-id + key-id: unit-test-key-id + audience: unit-test-audience + private-key: unit-test-private-key + +# WiFi 설정 비활성화 (단위테스트에서는 Mock 사용) +campus: + wifi: + networks: [] \ No newline at end of file