From 3cf0d46dad7e5b8c5e07baf6672a34d3aea2fe5d Mon Sep 17 00:00:00 2001
From: Obin <161419163+kon28289@users.noreply.github.com>
Date: Mon, 22 Dec 2025 11:57:12 +0900
Subject: [PATCH 001/135] Create README.md
---
README.md | 1 +
1 file changed, 1 insertion(+)
create mode 100644 README.md
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..d8f3d7e
--- /dev/null
+++ b/README.md
@@ -0,0 +1 @@
+[](https://deepwiki.com/Geumpumta/backend)
From e5021200f98f27b62653f026dc858538e681cd95 Mon Sep 17 00:00:00 2001
From: Obin <161419163+kon28289@users.noreply.github.com>
Date: Mon, 22 Dec 2025 14:36:17 +0900
Subject: [PATCH 002/135] Update README.md
---
README.md | 67 +++++++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 67 insertions(+)
diff --git a/README.md b/README.md
index d8f3d7e..f346ad7 100644
--- a/README.md
+++ b/README.md
@@ -1 +1,68 @@
+
+# Geumpumta Backend
[](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
+
+## 아키텍처
+
+
+## 패키지 구조
+```
+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) |
From 920748f0d57399e6e862a42420f7795d9c4b628f Mon Sep 17 00:00:00 2001
From: Juhye0k
Date: Sat, 3 Jan 2026 12:13:23 +0900
Subject: [PATCH 003/135] =?UTF-8?q?feat=20:=20repository=20=ED=85=8C?=
=?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=BB=A8=ED=85=8C=EC=9D=B4=EB=84=88=20tru?=
=?UTF-8?q?ncate=20=20=EC=84=A4=EC=A0=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../config/BaseIntegrationTest.java | 44 +++++++++++++++++++
1 file changed, 44 insertions(+)
create mode 100644 src/test/java/com/gpt/geumpumtabackend/integration/config/BaseIntegrationTest.java
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..e48252f
--- /dev/null
+++ b/src/test/java/com/gpt/geumpumtabackend/integration/config/BaseIntegrationTest.java
@@ -0,0 +1,44 @@
+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.context.annotation.Import;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.test.context.ActiveProfiles;
+import org.testcontainers.junit.jupiter.Testcontainers;
+
+import java.util.List;
+
+@SpringBootTest
+@ActiveProfiles("test")
+@Testcontainers
+@Import(TestContainerConfig.class)
+public abstract class BaseIntegrationTest {
+
+ @Autowired
+ private JdbcTemplate jdbcTemplate;
+
+ @AfterEach
+ void cleanUp() {
+ truncateAllTables();
+ }
+
+ private void truncateAllTables() {
+ // 외래 키 제약 조건 비활성화
+ jdbcTemplate.execute("SET FOREIGN_KEY_CHECKS = 0");
+
+ // 모든 테이블 목록 조회 및 TRUNCATE
+ List 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);
+ }
+
+ // 외래 키 제약 조건 재활성화
+ jdbcTemplate.execute("SET FOREIGN_KEY_CHECKS = 1");
+ }
+}
\ No newline at end of file
From 994e02310286636f14b0600aff53fc300497e54f Mon Sep 17 00:00:00 2001
From: Juhye0k
Date: Sat, 3 Jan 2026 12:13:38 +0900
Subject: [PATCH 004/135] =?UTF-8?q?feat=20:=20testconfig=20=EC=84=A4?=
=?UTF-8?q?=EC=A0=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../config/TestContainerConfig.java | 44 +++++++++++++++++++
1 file changed, 44 insertions(+)
create mode 100644 src/test/java/com/gpt/geumpumtabackend/integration/config/TestContainerConfig.java
diff --git a/src/test/java/com/gpt/geumpumtabackend/integration/config/TestContainerConfig.java b/src/test/java/com/gpt/geumpumtabackend/integration/config/TestContainerConfig.java
new file mode 100644
index 0000000..ae1e3db
--- /dev/null
+++ b/src/test/java/com/gpt/geumpumtabackend/integration/config/TestContainerConfig.java
@@ -0,0 +1,44 @@
+package com.gpt.geumpumtabackend.integration.config;
+
+import org.springframework.boot.test.context.TestConfiguration;
+import org.springframework.context.annotation.Bean;
+import org.springframework.test.context.DynamicPropertyRegistry;
+import org.springframework.test.context.DynamicPropertySource;
+import org.testcontainers.containers.MySQLContainer;
+import org.testcontainers.junit.jupiter.Container;
+
+@TestConfiguration
+public class TestContainerConfig {
+
+ @Container
+ static MySQLContainer> mysqlContainer = new MySQLContainer<>("mysql:8.0")
+ .withDatabaseName("test_geumpumta")
+ .withUsername("test")
+ .withPassword("test")
+ .withReuse(true); // 컨테이너 재사용으로 성능 향상
+
+ static {
+ mysqlContainer.start();
+ }
+
+ @DynamicPropertySource
+ static void configureProperties(DynamicPropertyRegistry registry) {
+ 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");
+
+ // JPA 설정
+ registry.add("spring.jpa.hibernate.ddl-auto", () -> "create-drop");
+ registry.add("spring.jpa.database-platform", () -> "org.hibernate.dialect.MySQL8Dialect");
+ registry.add("spring.jpa.show-sql", () -> "false");
+
+ // Redis 비활성화 (통합테스트에서는 사용하지 않음)
+ registry.add("spring.data.redis.repositories.enabled", () -> "false");
+ }
+
+ @Bean
+ public MySQLContainer> mySQLContainer() {
+ return mysqlContainer;
+ }
+}
\ No newline at end of file
From 6451a8227965c7c3af07b0475b45d5af2ead3b13 Mon Sep 17 00:00:00 2001
From: Juhye0k
Date: Mon, 5 Jan 2026 21:16:37 +0900
Subject: [PATCH 005/135] =?UTF-8?q?feat=20:=20test-yml=20=EC=84=A4?=
=?UTF-8?q?=EC=A0=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/test/resources/application-test.yml | 42 ++++++++++++++++---------
1 file changed, 27 insertions(+), 15 deletions(-)
diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml
index 6b90e2c..1f39f28 100644
--- a/src/test/resources/application-test.yml
+++ b/src/test/resources/application-test.yml
@@ -3,6 +3,12 @@ spring:
activate:
on-profile: test
+ datasource:
+ driver-class-name: org.testcontainers.jdbc.ContainerDatabaseDriver
+ url: jdbc:tc:mysql:8.0:///geumpumta-test
+ username: root
+ password:
+
mail:
host: dummy-naver.com #smtp 서버 주소
port: 123 # 메일 인증서버 포트
@@ -59,29 +65,21 @@ 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
format_sql: true
+ show-sql: false
open-in-view: false
+ data:
+ redis:
+ repositories:
+ enabled: true
+
sql:
init:
mode: never
@@ -97,4 +95,18 @@ 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
+
+# 테스트용 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
From 8e900c591330b47c9da85d4dd35358ca4ba8be7a Mon Sep 17 00:00:00 2001
From: Juhye0k
Date: Mon, 5 Jan 2026 21:17:52 +0900
Subject: [PATCH 006/135] =?UTF-8?q?refactor=20:=20=ED=95=98=ED=8A=B8?=
=?UTF-8?q?=EB=B9=84=ED=8A=B8=20=EB=A1=9C=EC=A7=81=20=EC=82=AD=EC=A0=9C?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../study/api/StudySessionApi.java | 41 -------------------
.../controller/StudySessionController.java | 10 -----
.../study/service/StudySessionService.java | 40 +-----------------
3 files changed, 1 insertion(+), 90 deletions(-)
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..bf051ff 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;
@@ -137,43 +135,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/controller/StudySessionController.java b/src/main/java/com/gpt/geumpumtabackend/study/controller/StudySessionController.java
index 4f052f1..da29cc0 100644
--- a/src/main/java/com/gpt/geumpumtabackend/study/controller/StudySessionController.java
+++ b/src/main/java/com/gpt/geumpumtabackend/study/controller/StudySessionController.java
@@ -58,14 +58,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/service/StudySessionService.java b/src/main/java/com/gpt/geumpumtabackend/study/service/StudySessionService.java
index 493c558..88828d4 100644
--- a/src/main/java/com/gpt/geumpumtabackend/study/service/StudySessionService.java
+++ b/src/main/java/com/gpt/geumpumtabackend/study/service/StudySessionService.java
@@ -1,12 +1,9 @@
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.domain.StudySession;
-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.repository.StudySessionRepository;
@@ -14,13 +11,10 @@
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.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
-
-import java.time.Duration;
import java.time.LocalDate;
import java.time.LocalDateTime;
@@ -32,7 +26,7 @@ public class StudySessionService {
private final StudySessionRepository studySessionRepository;
private final UserRepository userRepository;
private final CampusWiFiValidationService wifiValidationService;
- private static final Integer MAX_FOCUS_TIME = 3;
+
/*
메인 홈
*/
@@ -80,38 +74,6 @@ public void endStudySession(StudyEndRequest endRequest, Long userId) {
studysession.endStudySession(endTime);
}
-
- /*
- 하트비트 처리
- */
- @Transactional
- public HeartBeatResponse updateHeartBeat(HeartBeatRequest heartBeatRequest, Long userId) {
- Long sessionId = heartBeatRequest.sessionId();
-
- // Wi-Fi 검증 (캐시 우선 사용)
- WiFiValidationResult validationResult = wifiValidationService.validateFromCache(
- heartBeatRequest.gatewayIp(), heartBeatRequest.clientIp()
- );
-
- if (!validationResult.isValid()) {
- log.warn("Heartbeat Wi-Fi validation failed for user {}, session {}: {}",
- userId, sessionId, validationResult.getMessage());
- throw mapWiFiValidationException(validationResult);
- }
-
- // 유효하면 해당 세션의 lastHeartBeatAt 시간을 now()로 갱신한다.
- StudySession studySession = studySessionRepository.findByIdAndUser_Id(sessionId, userId)
- .orElseThrow(()->new BusinessException(ExceptionType.STUDY_SESSION_NOT_FOUND));
-
- 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,"정상 세션");
- }
-
private BusinessException mapWiFiValidationException(WiFiValidationResult result) {
return switch (result.getStatus()) {
case INVALID -> new BusinessException(ExceptionType.WIFI_NOT_CAMPUS_NETWORK);
From 9ea084c8ded318e99554a140947707ba221b4822 Mon Sep 17 00:00:00 2001
From: Juhye0k
Date: Mon, 5 Jan 2026 21:20:17 +0900
Subject: [PATCH 007/135] =?UTF-8?q?refactor=20:=20RedisConfig=20=EC=A3=BC?=
=?UTF-8?q?=EC=9E=85=20=ED=85=8C=EC=8A=A4=ED=8A=B8=EC=BD=94=EB=93=9C?=
=?UTF-8?q?=EB=A5=BC=20=EC=9C=84=ED=95=B4=20=EC=88=98=EC=A0=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../global/config/redis/RedisConfig.java | 47 +++++++++----------
1 file changed, 23 insertions(+), 24 deletions(-)
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;
}
}
-
-
From ef12a367409c18590e76c827575323d82d5adc1b Mon Sep 17 00:00:00 2001
From: Juhye0k
Date: Mon, 5 Jan 2026 21:20:53 +0900
Subject: [PATCH 008/135] =?UTF-8?q?refactor=20:=20=EB=9E=AD=ED=82=B9=20?=
=?UTF-8?q?=EC=82=B0=EC=A0=95=20sql=20=EC=88=98=EC=A0=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../study/repository/StudySessionRepository.java | 10 +++++-----
1 file changed, 5 insertions(+), 5 deletions(-)
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..5d49789 100644
--- a/src/main/java/com/gpt/geumpumtabackend/study/repository/StudySessionRepository.java
+++ b/src/main/java/com/gpt/geumpumtabackend/study/repository/StudySessionRepository.java
@@ -48,7 +48,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 +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
@@ -73,7 +73,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
@@ -153,7 +153,7 @@ List calculateFinalizedPeriodRanking(
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
@@ -165,7 +165,7 @@ 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
From ddea8b354bf492d42d28458d508720976d6b1774 Mon Sep 17 00:00:00 2001
From: Juhye0k
Date: Mon, 5 Jan 2026 21:21:17 +0900
Subject: [PATCH 009/135] =?UTF-8?q?refactor=20:=20testContainer=20?=
=?UTF-8?q?=EB=9D=BC=EC=9D=B4=EB=B8=8C=EB=9F=AC=EB=A6=AC=20=EC=B6=94?=
=?UTF-8?q?=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
build.gradle | 7 +++++--
1 file changed, 5 insertions(+), 2 deletions(-)
diff --git a/build.gradle b/build.gradle
index a4a6796..f90e0af 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'
@@ -64,6 +62,11 @@ dependencies {
// Jwt
implementation 'com.nimbusds:nimbus-jose-jwt:9.37.4'
+
+ // TestContainers
+ testImplementation 'org.testcontainers:junit-jupiter:1.19.3'
+ testImplementation 'org.testcontainers:mysql:1.19.3'
+ testImplementation 'org.testcontainers:testcontainers:1.19.3'
}
tasks.named('test') {
From 4260c84507610e1363194b638e7c6631daac118c Mon Sep 17 00:00:00 2001
From: Juhye0k
Date: Mon, 5 Jan 2026 21:27:19 +0900
Subject: [PATCH 010/135] =?UTF-8?q?refactor=20:=20=ED=95=84=EC=9A=94?=
=?UTF-8?q?=EC=97=86=EB=8A=94=20=EC=BD=94=EB=93=9C=20=EC=A0=9C=EA=B1=B0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../rank/dto/PersonalRankingTemp.java | 5 +--
.../controller/StatisticsController.java | 1 -
.../config/TestContainerConfig.java | 44 -------------------
3 files changed, 2 insertions(+), 48 deletions(-)
delete mode 100644 src/test/java/com/gpt/geumpumtabackend/integration/config/TestContainerConfig.java
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..3a36581 100644
--- a/src/main/java/com/gpt/geumpumtabackend/rank/dto/PersonalRankingTemp.java
+++ b/src/main/java/com/gpt/geumpumtabackend/rank/dto/PersonalRankingTemp.java
@@ -12,14 +12,13 @@ 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) {
this.userId = userId;
this.nickname = nickname;
- this.totalMillis = totalMillis;
- this.ranking = ranking;
this.imageUrl = imageUrl;
this.department = department; // 원본값 그대로 저장
+ this.totalMillis = totalMillis;
+ this.ranking = ranking;
}
// Department enum 값을 한국어로 변환하는 메서드
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/test/java/com/gpt/geumpumtabackend/integration/config/TestContainerConfig.java b/src/test/java/com/gpt/geumpumtabackend/integration/config/TestContainerConfig.java
deleted file mode 100644
index ae1e3db..0000000
--- a/src/test/java/com/gpt/geumpumtabackend/integration/config/TestContainerConfig.java
+++ /dev/null
@@ -1,44 +0,0 @@
-package com.gpt.geumpumtabackend.integration.config;
-
-import org.springframework.boot.test.context.TestConfiguration;
-import org.springframework.context.annotation.Bean;
-import org.springframework.test.context.DynamicPropertyRegistry;
-import org.springframework.test.context.DynamicPropertySource;
-import org.testcontainers.containers.MySQLContainer;
-import org.testcontainers.junit.jupiter.Container;
-
-@TestConfiguration
-public class TestContainerConfig {
-
- @Container
- static MySQLContainer> mysqlContainer = new MySQLContainer<>("mysql:8.0")
- .withDatabaseName("test_geumpumta")
- .withUsername("test")
- .withPassword("test")
- .withReuse(true); // 컨테이너 재사용으로 성능 향상
-
- static {
- mysqlContainer.start();
- }
-
- @DynamicPropertySource
- static void configureProperties(DynamicPropertyRegistry registry) {
- 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");
-
- // JPA 설정
- registry.add("spring.jpa.hibernate.ddl-auto", () -> "create-drop");
- registry.add("spring.jpa.database-platform", () -> "org.hibernate.dialect.MySQL8Dialect");
- registry.add("spring.jpa.show-sql", () -> "false");
-
- // Redis 비활성화 (통합테스트에서는 사용하지 않음)
- registry.add("spring.data.redis.repositories.enabled", () -> "false");
- }
-
- @Bean
- public MySQLContainer> mySQLContainer() {
- return mysqlContainer;
- }
-}
\ No newline at end of file
From 23002684996774f8cfcdad70c3c30357473bf513 Mon Sep 17 00:00:00 2001
From: Juhye0k
Date: Mon, 5 Jan 2026 21:27:28 +0900
Subject: [PATCH 011/135] =?UTF-8?q?refactor=20:=20=ED=86=B5=ED=95=A9?=
=?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20truncate=20=EC=84=A4=EC=A0=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../config/BaseIntegrationTest.java | 124 ++++++++++++++++--
1 file changed, 110 insertions(+), 14 deletions(-)
diff --git a/src/test/java/com/gpt/geumpumtabackend/integration/config/BaseIntegrationTest.java b/src/test/java/com/gpt/geumpumtabackend/integration/config/BaseIntegrationTest.java
index e48252f..bcaf48e 100644
--- a/src/test/java/com/gpt/geumpumtabackend/integration/config/BaseIntegrationTest.java
+++ b/src/test/java/com/gpt/geumpumtabackend/integration/config/BaseIntegrationTest.java
@@ -1,44 +1,140 @@
package com.gpt.geumpumtabackend.integration.config;
import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
-import org.springframework.context.annotation.Import;
+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.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 jakarta.persistence.EntityManager;
+import jakarta.persistence.PersistenceContext;
+import org.springframework.transaction.annotation.Transactional;
import java.util.List;
-@SpringBootTest
+@SpringBootTest(
+ properties = {
+ "spring.test.database.replace=NONE",
+ "spring.jpa.hibernate.ddl-auto=create-drop"
+ }
+)
@ActiveProfiles("test")
@Testcontainers
-@Import(TestContainerConfig.class)
public abstract class BaseIntegrationTest {
+ @Container
+ static final MySQLContainer> mysqlContainer = new MySQLContainer<>("mysql:8.0")
+ .withDatabaseName("test_geumpumta")
+ .withUsername("test")
+ .withPassword("test");
+
+ @Container
+ static final GenericContainer> redisContainer = new GenericContainer<>(DockerImageName.parse("redis:7.0-alpine"))
+ .withExposedPorts(6379);
+
+ @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].ssid", () -> "KUMOH_TEST");
+ registry.add("campus-wifi.networks[0].gateway-ip", () -> "192.168.1.1");
+ registry.add("campus-wifi.networks[0].ip-ranges[0]", () -> "192.168.1.0/24");
+ registry.add("campus-wifi.networks[0].active", () -> "true");
+ }
+
@Autowired
private JdbcTemplate jdbcTemplate;
+ @Autowired
+ private RedisTemplate redisTemplate;
+
+ @PersistenceContext
+ private EntityManager entityManager;
+
+ @BeforeEach
+ @Transactional
+ void ensureTablesExist() {
+ // EntityManager 초기화를 강제로 실행하여 Hibernate DDL 실행 보장
+ try {
+ entityManager.createNativeQuery("SELECT 1").getSingleResult();
+ System.out.println("EntityManager initialized - Hibernate DDL should be executed");
+
+ // 테이블 존재 확인
+ jdbcTemplate.queryForObject("SELECT COUNT(*) FROM user LIMIT 1", Integer.class);
+ System.out.println("User table exists - DDL executed successfully");
+ } catch (Exception e) {
+ System.out.println("Table check failed: " + e.getMessage());
+ throw new RuntimeException("Tables not created properly", e);
+ }
+ }
+
@AfterEach
void cleanUp() {
truncateAllTables();
+ cleanRedisCache();
}
private void truncateAllTables() {
- // 외래 키 제약 조건 비활성화
- jdbcTemplate.execute("SET FOREIGN_KEY_CHECKS = 0");
+ try {
+ String dbProductName = jdbcTemplate.getDataSource().getConnection().getMetaData().getDatabaseProductName();
+ boolean isH2 = "H2".equalsIgnoreCase(dbProductName);
- // 모든 테이블 목록 조회 및 TRUNCATE
- List tableNames = jdbcTemplate.queryForList(
- "SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_TYPE = 'BASE TABLE'",
- String.class
- );
+ // 외래 키 제약 조건 비활성화
+ if (isH2) {
+ jdbcTemplate.execute("SET REFERENTIAL_INTEGRITY FALSE");
+ } else {
+ jdbcTemplate.execute("SET FOREIGN_KEY_CHECKS = 0");
+ }
- for (String tableName : tableNames) {
- jdbcTemplate.execute("TRUNCATE TABLE " + tableName);
+ 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);
}
+ }
- // 외래 키 제약 조건 재활성화
- jdbcTemplate.execute("SET FOREIGN_KEY_CHECKS = 1");
+ private void cleanRedisCache() {
+ // Redis의 모든 캐시 데이터 삭제
+ redisTemplate.getConnectionFactory().getConnection().flushAll();
}
}
\ No newline at end of file
From 11cd26131bc29864cd2af4fd2ed44ec7d5e2645d Mon Sep 17 00:00:00 2001
From: Juhye0k
Date: Mon, 5 Jan 2026 22:00:34 +0900
Subject: [PATCH 012/135] =?UTF-8?q?feat=20:=20=EB=9E=AD=ED=82=B9,=20?=
=?UTF-8?q?=EA=B3=B5=EB=B6=80=EC=8B=9C=EA=B0=84=20=EB=8B=A8=EC=9C=84?=
=?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=9E=91=EC=84=B1?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../service/DepartmentRankServiceTest.java | 325 +++++++++++++
.../rank/service/PersonalRankServiceTest.java | 435 ++++++++++++++++++
.../service/StudySessionServiceTest.java | 288 ++++++++++++
.../CampusWiFiValidationServiceTest.java | 191 ++++++++
4 files changed, 1239 insertions(+)
create mode 100644 src/test/java/com/gpt/geumpumtabackend/unit/rank/service/DepartmentRankServiceTest.java
create mode 100644 src/test/java/com/gpt/geumpumtabackend/unit/rank/service/PersonalRankServiceTest.java
create mode 100644 src/test/java/com/gpt/geumpumtabackend/unit/study/service/StudySessionServiceTest.java
create mode 100644 src/test/java/com/gpt/geumpumtabackend/unit/wifi/service/CampusWiFiValidationServiceTest.java
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..b78d3ed
--- /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;
+ }
+ }
+}
\ No newline at end of file
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..048131e
--- /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;
+
+ // 100명의 랭킹 데이터 생성
+ 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..bc2b77d
--- /dev/null
+++ b/src/test/java/com/gpt/geumpumtabackend/unit/study/service/StudySessionServiceTest.java
@@ -0,0 +1,288 @@
+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.domain.StudySession;
+import com.gpt.geumpumtabackend.study.dto.request.StudyStartRequest;
+import com.gpt.geumpumtabackend.study.dto.response.StudyStartResponse;
+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.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;
+
+ @InjectMocks
+ private StudySessionService studySessionService;
+
+ @Nested
+ @DisplayName("공부 세션 시작")
+ class StartStudySession {
+
+ @Test
+ @DisplayName("Wi-Fi 검증 성공 시 세션이 정상 시작된다")
+ void startStudySession_WiFi검증성공_세션시작성공() {
+ // 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.validateFromCache(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).validateFromCache(gatewayIp, clientIp);
+ verify(userRepository).findById(userId);
+ verify(studySessionRepository).save(any(StudySession.class));
+ }
+
+ @Test
+ @DisplayName("Wi-Fi 검증 실패(INVALID) 시 WIFI_NOT_CAMPUS_NETWORK 예외가 발생한다")
+ void startStudySession_WiFi검증실패_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.validateFromCache(gatewayIp, clientIp))
+ .willReturn(WiFiValidationResult.invalid("캠퍼스 네트워크가 아닙니다"));
+
+ // When & Then
+ assertThatThrownBy(() -> studySessionService.startStudySession(request, userId))
+ .isInstanceOf(BusinessException.class)
+ .hasFieldOrPropertyWithValue("exceptionType", ExceptionType.WIFI_NOT_CAMPUS_NETWORK);
+
+ verify(wifiValidationService).validateFromCache(gatewayIp, clientIp);
+ verify(userRepository, never()).findById(anyLong());
+ verify(studySessionRepository, never()).save(any());
+ }
+
+ @Test
+ @DisplayName("Wi-Fi 검증 에러(ERROR) 시 WIFI_VALIDATION_ERROR 예외가 발생한다")
+ void startStudySession_WiFi검증에러_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.validateFromCache(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 startStudySession_존재하지않는사용자_예외발생() {
+ // 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.validateFromCache(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("긴 세션(12시간)도 올바르게 계산된다")
+ void 긴_세션도_올바르게_계산된다() {
+ // Given
+ LocalDateTime startTime = LocalDateTime.of(2024, 1, 1, 9, 0);
+ LocalDateTime endTime = LocalDateTime.of(2024, 1, 1, 21, 0);
+ User testUser = createTestUser(1L, "테스트사용자", Department.SOFTWARE);
+
+ StudySession session = new StudySession();
+
+ // When
+ session.startStudySession(startTime, testUser);
+ session.endStudySession(endTime);
+
+ // Then
+ assertThat(session.getTotalMillis()).isEqualTo(43200000L); // 12시간 = 12 * 60 * 60 * 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();
+ }
+ }
+
+ // 테스트 데이터 생성 헬퍼 메서드
+ 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 StudySession createTestStudySession(Long id, User user, LocalDateTime startTime) {
+ StudySession session = new StudySession();
+ session.startStudySession(startTime, user);
+ // id는 실제로는 JPA가 설정하지만 테스트를 위해 reflection 사용하거나
+ // 별도의 테스트용 생성자/메서드를 만들어 설정
+ return session;
+ }
+}
\ No newline at end of file
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..8b83202
--- /dev/null
+++ b/src/test/java/com/gpt/geumpumtabackend/unit/wifi/service/CampusWiFiValidationServiceTest.java
@@ -0,0 +1,191 @@
+package com.gpt.geumpumtabackend.unit.wifi.service;
+
+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.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.test.context.ActiveProfiles;
+
+import static org.assertj.core.api.Assertions.*;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.Mockito.*;
+
+@ExtendWith(MockitoExtension.class)
+@ActiveProfiles("unit-test") // 단위테스트 프로필 사용 (Redis 비활성화)
+@DisplayName("CampusWiFiValidationService 단위 테스트 (Mock)")
+class CampusWiFiValidationServiceTest {
+
+ @Mock
+ private CampusWiFiValidationService wifiValidationService;
+
+ @Nested
+ @DisplayName("캐시에서 WiFi 검증 (Mock)")
+ class ValidateFromCache {
+
+ @Test
+ @DisplayName("유효한_캠퍼스_네트워크_검증_성공")
+ void validateFromCache_유효한캠퍼스네트워크_검증성공() {
+ // Given
+ String gatewayIp = "192.168.1.1";
+ String clientIp = "192.168.1.100";
+ given(wifiValidationService.validateFromCache(gatewayIp, clientIp))
+ .willReturn(WiFiValidationResult.valid("캠퍼스 네트워크입니다"));
+
+ // When
+ WiFiValidationResult result = wifiValidationService.validateFromCache(gatewayIp, clientIp);
+
+ // Then
+ assertThat(result).isNotNull();
+ assertThat(result.isValid()).isTrue();
+ assertThat(result.getStatus()).isEqualTo(WiFiValidationResult.ValidationStatus.VALID);
+ assertThat(result.getMessage()).isEqualTo("캠퍼스 네트워크입니다");
+
+ verify(wifiValidationService).validateFromCache(gatewayIp, clientIp);
+ }
+
+ @Test
+ @DisplayName("무효한_네트워크_검증_실패")
+ void validateFromCache_무효한네트워크_검증실패() {
+ // Given
+ String gatewayIp = "192.168.10.1";
+ String clientIp = "192.168.10.100";
+ given(wifiValidationService.validateFromCache(gatewayIp, clientIp))
+ .willReturn(WiFiValidationResult.invalid("캠퍼스 네트워크가 아닙니다"));
+
+ // When
+ WiFiValidationResult result = wifiValidationService.validateFromCache(gatewayIp, clientIp);
+
+ // Then
+ assertThat(result).isNotNull();
+ assertThat(result.isValid()).isFalse();
+ assertThat(result.getStatus()).isEqualTo(WiFiValidationResult.ValidationStatus.INVALID);
+ assertThat(result.getMessage()).isEqualTo("캠퍼스 네트워크가 아닙니다");
+ }
+
+ @Test
+ @DisplayName("검증_오류_시_에러_결과_반환")
+ void validateFromCache_검증오류_에러결과반환() {
+ // Given
+ String gatewayIp = "error.test.ip";
+ String clientIp = "192.168.1.100";
+ given(wifiValidationService.validateFromCache(gatewayIp, clientIp))
+ .willReturn(WiFiValidationResult.error("Wi-Fi 검증 중 오류가 발생했습니다"));
+
+ // When
+ WiFiValidationResult result = wifiValidationService.validateFromCache(gatewayIp, clientIp);
+
+ // Then
+ assertThat(result).isNotNull();
+ assertThat(result.isValid()).isFalse();
+ assertThat(result.getStatus()).isEqualTo(WiFiValidationResult.ValidationStatus.ERROR);
+ assertThat(result.getMessage()).contains("Wi-Fi 검증 중 오류가 발생했습니다");
+ }
+ }
+
+ @Nested
+ @DisplayName("캠퍼스 WiFi 검증 (Mock)")
+ class ValidateCampusWiFi {
+
+ @Test
+ @DisplayName("유효한_캠퍼스_네트워크_검증_성공")
+ void validateCampusWiFi_유효한캠퍼스네트워크_검증성공() {
+ // Given
+ String gatewayIp = "192.168.1.1";
+ String clientIp = "192.168.1.100";
+ given(wifiValidationService.validateCampusWiFi(gatewayIp, clientIp))
+ .willReturn(WiFiValidationResult.valid("캠퍼스 네트워크입니다"));
+
+ // When
+ WiFiValidationResult result = wifiValidationService.validateCampusWiFi(gatewayIp, clientIp);
+
+ // Then
+ assertThat(result).isNotNull();
+ assertThat(result.isValid()).isTrue();
+ assertThat(result.getStatus()).isEqualTo(WiFiValidationResult.ValidationStatus.VALID);
+ assertThat(result.getMessage()).isEqualTo("캠퍼스 네트워크입니다");
+
+ verify(wifiValidationService).validateCampusWiFi(gatewayIp, clientIp);
+ }
+
+ @Test
+ @DisplayName("잘못된_게이트웨이_IP로_검증_실패")
+ void validateCampusWiFi_잘못된게이트웨이IP_검증실패() {
+ // Given
+ String gatewayIp = "192.168.10.1"; // 잘못된 게이트웨이
+ String clientIp = "192.168.1.100";
+ given(wifiValidationService.validateCampusWiFi(gatewayIp, clientIp))
+ .willReturn(WiFiValidationResult.invalid("캠퍼스 네트워크가 아닙니다"));
+
+ // When
+ WiFiValidationResult result = wifiValidationService.validateCampusWiFi(gatewayIp, clientIp);
+
+ // Then
+ assertThat(result).isNotNull();
+ assertThat(result.isValid()).isFalse();
+ assertThat(result.getStatus()).isEqualTo(WiFiValidationResult.ValidationStatus.INVALID);
+ assertThat(result.getMessage()).isEqualTo("캠퍼스 네트워크가 아닙니다");
+ }
+
+ @Test
+ @DisplayName("검증_중_예외_발생시_에러_결과_반환")
+ void validateCampusWiFi_예외발생_에러결과반환() {
+ // Given
+ String gatewayIp = "error.test.ip";
+ String clientIp = "192.168.1.100";
+ given(wifiValidationService.validateCampusWiFi(gatewayIp, clientIp))
+ .willReturn(WiFiValidationResult.error("Wi-Fi 검증 중 오류가 발생했습니다"));
+
+ // When
+ WiFiValidationResult result = wifiValidationService.validateCampusWiFi(gatewayIp, clientIp);
+
+ // Then
+ assertThat(result).isNotNull();
+ assertThat(result.isValid()).isFalse();
+ assertThat(result.getStatus()).isEqualTo(WiFiValidationResult.ValidationStatus.ERROR);
+ assertThat(result.getMessage()).contains("Wi-Fi 검증 중 오류가 발생했습니다");
+ }
+ }
+
+ @Nested
+ @DisplayName("다양한_시나리오_테스트")
+ class VariousScenarios {
+
+ @Test
+ @DisplayName("다양한_캠퍼스_IP_대역_검증")
+ void 다양한_캠퍼스IP대역_검증() {
+ // Given
+ given(wifiValidationService.validateFromCache("192.168.1.1", "192.168.1.50"))
+ .willReturn(WiFiValidationResult.valid("캠퍼스 네트워크입니다"));
+ given(wifiValidationService.validateFromCache("172.30.64.1", "172.30.64.100"))
+ .willReturn(WiFiValidationResult.valid("캠퍼스 네트워크입니다"));
+
+ // When & Then
+ WiFiValidationResult result1 = wifiValidationService.validateFromCache("192.168.1.1", "192.168.1.50");
+ assertThat(result1.isValid()).isTrue();
+
+ WiFiValidationResult result2 = wifiValidationService.validateFromCache("172.30.64.1", "172.30.64.100");
+ assertThat(result2.isValid()).isTrue();
+ }
+
+ @Test
+ @DisplayName("NULL_또는_빈_입력값_처리")
+ void NULL_또는_빈입력값_처리() {
+ // Given
+ given(wifiValidationService.validateFromCache(isNull(), anyString()))
+ .willReturn(WiFiValidationResult.error("잘못된 입력값입니다"));
+ given(wifiValidationService.validateFromCache(eq(""), anyString()))
+ .willReturn(WiFiValidationResult.error("잘못된 입력값입니다"));
+
+ // When & Then
+ WiFiValidationResult result1 = wifiValidationService.validateFromCache(null, "192.168.1.100");
+ assertThat(result1.getStatus()).isEqualTo(WiFiValidationResult.ValidationStatus.ERROR);
+
+ WiFiValidationResult result2 = wifiValidationService.validateFromCache("", "192.168.1.100");
+ assertThat(result2.getStatus()).isEqualTo(WiFiValidationResult.ValidationStatus.ERROR);
+ }
+ }
+}
\ No newline at end of file
From d8bf11a92e949b4520d87ff63f88f0325c4b162f Mon Sep 17 00:00:00 2001
From: Juhye0k
Date: Mon, 5 Jan 2026 22:00:58 +0900
Subject: [PATCH 013/135] =?UTF-8?q?feat=20:=20=EB=9E=AD=ED=82=B9,=20?=
=?UTF-8?q?=EA=B3=B5=EB=B6=80=EC=8B=9C=EA=B0=84=20=ED=86=B5=ED=95=A9?=
=?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=9E=91=EC=84=B1?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../DepartmentRankServiceIntegrationTest.java | 423 ++++++++++++++++++
.../service/StudySessionIntegrationTest.java | 153 +++++++
2 files changed, 576 insertions(+)
create mode 100644 src/test/java/com/gpt/geumpumtabackend/integration/rank/service/DepartmentRankServiceIntegrationTest.java
create mode 100644 src/test/java/com/gpt/geumpumtabackend/integration/study/service/StudySessionIntegrationTest.java
diff --git a/src/test/java/com/gpt/geumpumtabackend/integration/rank/service/DepartmentRankServiceIntegrationTest.java b/src/test/java/com/gpt/geumpumtabackend/integration/rank/service/DepartmentRankServiceIntegrationTest.java
new file mode 100644
index 0000000..26e7297
--- /dev/null
+++ b/src/test/java/com/gpt/geumpumtabackend/integration/rank/service/DepartmentRankServiceIntegrationTest.java
@@ -0,0 +1,423 @@
+package com.gpt.geumpumtabackend.integration.rank.service;
+
+import com.gpt.geumpumtabackend.integration.config.BaseIntegrationTest;
+import com.gpt.geumpumtabackend.rank.domain.DepartmentRanking;
+import com.gpt.geumpumtabackend.rank.domain.RankingType;
+import com.gpt.geumpumtabackend.rank.dto.response.DepartmentRankingEntryResponse;
+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.domain.StudySession;
+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.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 java.time.LocalDate;
+import java.time.LocalDateTime;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.within;
+
+@DisplayName("DepartmentRankService 통합 테스트")
+class DepartmentRankServiceIntegrationTest extends BaseIntegrationTest {
+
+ @Autowired
+ private DepartmentRankService departmentRankService;
+
+ @Autowired
+ private UserRepository userRepository;
+
+ @Autowired
+ private StudySessionRepository studySessionRepository;
+
+ @Autowired
+ private DepartmentRankingRepository departmentRankingRepository;
+
+ private User softwareUser1;
+ private User softwareUser2;
+ private User computerUser1;
+ private User electronicUser1;
+
+ @BeforeEach
+ void setUp() {
+ // 테스트 사용자 생성
+ softwareUser1 = createAndSaveUser("소프트웨어1", "software1@kumoh.ac.kr", Department.SOFTWARE);
+ softwareUser2 = createAndSaveUser("소프트웨어2", "software2@kumoh.ac.kr", Department.SOFTWARE);
+ computerUser1 = createAndSaveUser("컴퓨터공학1", "computer1@kumoh.ac.kr", Department.COMPUTER_ENGINEERING);
+ electronicUser1 = createAndSaveUser("전자공학1", "electronic1@kumoh.ac.kr", Department.ELECTRONIC_SYSTEMS);
+ }
+
+ @Nested
+ @DisplayName("현재 진행중인 학과 랭킹 일간 조회")
+ class GetCurrentDailyDepartmentRanking {
+
+ @Test
+ @DisplayName("랭킹에_없는_학과_사용자는_꼴찌_다음_순위가_된다")
+ void 랭킹에_없는_학과_사용자는_꼴찌_다음_순위가_된다() {
+ // Given
+ LocalDateTime today = LocalDateTime.now().withHour(12).withMinute(0).withSecond(0).withNano(0);
+
+ // 소프트웨어전공만 학습 기록 생성
+ createCompletedStudySession(softwareUser1, today.minusHours(1), today);
+
+ // When
+ DepartmentRankingResponse response = departmentRankService.getCurrentDailyDepartmentRanking(electronicUser1.getId());
+
+ // Then
+ assertThat(response.topRanks()).hasSize(1);
+ assertThat(response.topRanks().get(0).departmentName()).isEqualTo("소프트웨어전공");
+
+ // 내 학과 랭킹은 fallback으로 2등 (1 + 1)
+ assertThat(response.myDepartmentRanking()).isNotNull();
+ assertThat(response.myDepartmentRanking().departmentName()).isEqualTo("전자시스템전공");
+ assertThat(response.myDepartmentRanking().rank()).isEqualTo(2L);
+ assertThat(response.myDepartmentRanking().totalMillis()).isEqualTo(0L);
+ }
+
+ @Test
+ @DisplayName("아무도_학습하지_않은_날에는_빈_랭킹과_1등_fallback이_반환된다")
+ void 아무도_학습하지_않은_날에는_빈_랭킹과_1등_fallback이_반환된다() {
+ // Given - 아무 학습 기록 없음
+
+ // When
+ DepartmentRankingResponse response = departmentRankService.getCurrentDailyDepartmentRanking(softwareUser1.getId());
+
+ // Then
+ assertThat(response.topRanks()).isEmpty();
+
+ // 내 학과 랭킹은 1등 (0 + 1)
+ assertThat(response.myDepartmentRanking()).isNotNull();
+ assertThat(response.myDepartmentRanking().departmentName()).isEqualTo("소프트웨어전공");
+ assertThat(response.myDepartmentRanking().rank()).isEqualTo(1L);
+ assertThat(response.myDepartmentRanking().totalMillis()).isEqualTo(0L);
+ }
+
+ @Test
+ @DisplayName("완료된_세션만_집계된다")
+ void 완료된_세션만_집계된다() {
+ // Given - 현재 시간 기준으로 정확한 시간 설정
+ LocalDateTime now = LocalDateTime.now().withNano(0); // 나노초 제거
+ LocalDateTime oneHourAgo = now.minusHours(1);
+ LocalDateTime twoHoursAgo = now.minusHours(2);
+ LocalDateTime thirtyMinAgo = now.minusMinutes(30);
+
+ // 소프트웨어전공: 완료 60분 + 완료 60분 = 120분 (진행중 세션 제거)
+ createCompletedStudySession(softwareUser1, twoHoursAgo, oneHourAgo); // 60분
+ createCompletedStudySession(softwareUser2, oneHourAgo, now); // 60분
+
+ // 컴퓨터공학전공: 완료 30분만
+ createCompletedStudySession(computerUser1, oneHourAgo, thirtyMinAgo); // 30분
+
+ // When
+ DepartmentRankingResponse response = departmentRankService.getCurrentDailyDepartmentRanking(softwareUser1.getId());
+
+ // Then
+ assertThat(response.topRanks()).hasSize(2);
+
+ // 1등: 소프트웨어전공 (120분 = 7,200,000ms)
+ DepartmentRankingEntryResponse rank1 = response.topRanks().get(0);
+ assertThat(rank1.departmentName()).isEqualTo("소프트웨어전공");
+ assertThat(rank1.totalMillis()).isCloseTo(7200000L, within(1000L)); // 1초 오차 허용
+
+ // 2등: 컴퓨터공학전공 (30분 = 1,800,000ms)
+ DepartmentRankingEntryResponse rank2 = response.topRanks().get(1);
+ assertThat(rank2.departmentName()).isEqualTo("컴퓨터공학전공");
+ assertThat(rank2.totalMillis()).isEqualTo(1800000L);
+ }
+ }
+
+ @Nested
+ @DisplayName("완료된 학과 랭킹 일간 조회")
+ class GetCompletedDailyDepartmentRanking {
+
+ @Test
+ @DisplayName("과거_날짜의_학습_세션으로_랭킹이_재계산된다")
+ void 과거_날짜의_학습_세션으로_랭킹이_재계산된다() {
+ // Given - 어제 날짜로 학습 세션 생성
+ LocalDate yesterday = LocalDate.now().minusDays(1);
+ LocalDateTime yesterdayStart = yesterday.atTime(9, 0);
+ LocalDateTime yesterdayEnd1 = yesterday.atTime(11, 0); // 120분
+ LocalDateTime yesterdayEnd2 = yesterday.atTime(10, 30); // 90분
+
+ // 소프트웨어전공: 120분
+ createCompletedStudySession(softwareUser1, yesterdayStart, yesterdayEnd1);
+
+ // 컴퓨터공학전공: 90분
+ createCompletedStudySession(computerUser1, yesterdayStart, yesterdayEnd2);
+
+ // When - 어제 날짜로 완료된 랭킹 조회
+ DepartmentRankingResponse response = departmentRankService.getCompletedDailyDepartmentRanking(
+ softwareUser1.getId(),
+ yesterday.atStartOfDay()
+ );
+
+ // Then - 쿼리가 실시간 재계산하여 결과 반환
+ assertThat(response.topRanks()).hasSize(2);
+
+ // 1등: 소프트웨어전공 (120분)
+ DepartmentRankingEntryResponse rank1 = response.topRanks().get(0);
+ assertThat(rank1.departmentName()).isEqualTo("소프트웨어전공");
+ assertThat(rank1.totalMillis()).isEqualTo(7200000L);
+ assertThat(rank1.rank()).isEqualTo(1L);
+
+ // 2등: 컴퓨터공학전공 (90분)
+ DepartmentRankingEntryResponse rank2 = response.topRanks().get(1);
+ assertThat(rank2.departmentName()).isEqualTo("컴퓨터공학전공");
+ assertThat(rank2.totalMillis()).isEqualTo(5400000L);
+ assertThat(rank2.rank()).isEqualTo(2L);
+
+ // 내 학과 랭킹 확인
+ assertThat(response.myDepartmentRanking()).isNotNull();
+ assertThat(response.myDepartmentRanking().departmentName()).isEqualTo("소프트웨어전공");
+ assertThat(response.myDepartmentRanking().rank()).isEqualTo(1L);
+ }
+
+ @Test
+ @DisplayName("완료된_랭킹에_없는_학과는_fallback_랭킹이_생성된다")
+ void 완료된_랭킹에_없는_학과는_fallback_랭킹이_생성된다() {
+ // Given - 어제 날짜로 소프트웨어전공만 학습
+ LocalDate yesterday = LocalDate.now().minusDays(1);
+ LocalDateTime yesterdayStart = yesterday.atTime(9, 0);
+ LocalDateTime yesterdayEnd = yesterday.atTime(10, 0);
+
+ createCompletedStudySession(softwareUser1, yesterdayStart, yesterdayEnd);
+
+ // When - 전자공학과 사용자가 조회 (학습 기록 없음)
+ DepartmentRankingResponse response = departmentRankService.getCompletedDailyDepartmentRanking(
+ electronicUser1.getId(),
+ yesterday.atStartOfDay()
+ );
+
+ // Then
+ assertThat(response.topRanks()).hasSize(1);
+ assertThat(response.topRanks().get(0).departmentName()).isEqualTo("소프트웨어전공");
+
+ // 내 학과는 fallback으로 2등
+ assertThat(response.myDepartmentRanking()).isNotNull();
+ assertThat(response.myDepartmentRanking().departmentName()).isEqualTo("전자시스템전공");
+ assertThat(response.myDepartmentRanking().rank()).isEqualTo(2L);
+ assertThat(response.myDepartmentRanking().totalMillis()).isEqualTo(0L);
+ }
+
+ @Test
+ @DisplayName("학습_기록이_없는_날에는_빈_결과와_fallback이_반환된다")
+ void 학습_기록이_없는_날에는_빈_결과와_fallback이_반환된다() {
+ // Given - 일주일 전 날짜 (학습 기록 없음)
+ LocalDate pastDate = LocalDate.now().minusDays(7);
+
+ // When
+ DepartmentRankingResponse response = departmentRankService.getCompletedDailyDepartmentRanking(
+ softwareUser1.getId(),
+ pastDate.atStartOfDay()
+ );
+
+ // Then
+ assertThat(response.topRanks()).isEmpty();
+
+ // 내 학과는 fallback으로 1등
+ assertThat(response.myDepartmentRanking()).isNotNull();
+ assertThat(response.myDepartmentRanking().departmentName()).isEqualTo("소프트웨어전공");
+ assertThat(response.myDepartmentRanking().rank()).isEqualTo(1L);
+ assertThat(response.myDepartmentRanking().totalMillis()).isEqualTo(0L);
+ }
+
+ @Test
+ @DisplayName("날짜_경계_시간의_학습_세션도_정확히_집계된다")
+ void 날짜_경계_시간의_학습_세션도_정확히_집계된다() {
+ // Given - 자정을 걸치는 학습 세션
+ LocalDate yesterday = LocalDate.now().minusDays(1);
+ LocalDateTime beforeMidnight = yesterday.atTime(23, 30); // 어제 23:30
+ LocalDateTime afterMidnight = yesterday.plusDays(1).atTime(0, 30); // 오늘 00:30
+
+ // 어제 23:30 ~ 오늘 00:30 학습 (총 60분, 어제 30분 + 오늘 30분)
+ createCompletedStudySession(softwareUser1, beforeMidnight, afterMidnight);
+
+ // When - 어제 날짜로 조회
+ DepartmentRankingResponse response = departmentRankService.getCompletedDailyDepartmentRanking(
+ softwareUser1.getId(),
+ yesterday.atStartOfDay()
+ );
+
+ // Then - 어제 부분(30분)만 집계되어야 함
+ assertThat(response.topRanks()).hasSize(1);
+ DepartmentRankingEntryResponse rank = response.topRanks().get(0);
+ assertThat(rank.departmentName()).isEqualTo("소프트웨어전공");
+ // 어제 23:30 ~ 24:00 = 30분 = 1,800,000ms
+ assertThat(rank.totalMillis()).isEqualTo(1800000L);
+ }
+
+ @Test
+ @DisplayName("저장된_DepartmentRanking과_실시간_재계산이_병합된다")
+ void 저장된_DepartmentRanking과_실시간_재계산이_병합된다() {
+ // Given - 어제 날짜
+ LocalDate yesterday = LocalDate.now().minusDays(1);
+ LocalDateTime yesterdayStart = yesterday.atTime(9, 0);
+ LocalDateTime yesterdayEnd = yesterday.atTime(11, 0); // 120분
+
+ // 소프트웨어전공 학습 세션 생성 (120분)
+ createCompletedStudySession(softwareUser1, yesterdayStart, yesterdayEnd);
+
+ // 전자공학과는 학습 세션 없지만, 저장된 랭킹 데이터 생성
+ // (실제로는 스케줄러가 생성하지만, 테스트를 위해 수동 생성)
+ // 참고: 현재 쿼리는 실시간 재계산을 우선하므로 이 데이터는 무시될 수 있음
+ DepartmentRanking electronicRanking = DepartmentRanking.builder()
+ .department(Department.ELECTRONIC_SYSTEMS)
+ .totalMillis(3600000L) // 60분
+ .rank(2L)
+ .rankingType(RankingType.DAILY)
+ .calculatedAt(yesterday.atStartOfDay())
+ .build();
+ departmentRankingRepository.save(electronicRanking);
+
+ // When
+ DepartmentRankingResponse response = departmentRankService.getCompletedDailyDepartmentRanking(
+ softwareUser1.getId(),
+ yesterday.atStartOfDay()
+ );
+
+ // Then - 실시간 재계산이 우선되므로 소프트웨어전공만 나타남
+ // (전자공학과는 user는 있지만 study_session이 없어서 0으로 계산되고,
+ // 0 > 0 이므로 topRanks에 포함되지 않음)
+ assertThat(response.topRanks()).hasSize(1);
+ assertThat(response.topRanks().get(0).departmentName()).isEqualTo("소프트웨어전공");
+ assertThat(response.topRanks().get(0).totalMillis()).isEqualTo(7200000L);
+ }
+ }
+
+ @Nested
+ @DisplayName("학과별_상위_30명_기준_집계")
+ class DepartmentTop30Calculation {
+
+ @Test
+ @DisplayName("학과별로_상위_30명의_학습시간만_합산된다")
+ void 학과별로_상위_30명의_학습시간만_합산된다() {
+ // Given - 소프트웨어전공 35명 생성 (30명 제한 검증)
+ LocalDateTime today = LocalDateTime.now().withHour(12).withMinute(0);
+ LocalDateTime oneHourAgo = today.minusHours(1);
+
+ // 35명의 사용자 생성 및 각각 다른 학습 시간 부여
+ // 1등: 350분, 2등: 340분, ..., 30등: 60분, 31등: 50분, ..., 35등: 10분
+ for (int i = 1; i <= 35; i++) {
+ User user = createAndSaveUser("소프트웨어" + i, "sw" + i + "@kumoh.ac.kr", Department.SOFTWARE);
+ LocalDateTime sessionStart = oneHourAgo;
+ LocalDateTime sessionEnd = oneHourAgo.plusMinutes((36 - i) * 10);
+ createCompletedStudySession(user, sessionStart, sessionEnd);
+ }
+
+ // When
+ DepartmentRankingResponse response = departmentRankService.getCurrentDailyDepartmentRanking(softwareUser1.getId());
+
+ // Then - 0ms 학과는 필터링되어 제외됨
+ assertThat(response.topRanks()).hasSize(1);
+
+ // 1등: 소프트웨어전공
+ DepartmentRankingEntryResponse rank1 = response.topRanks().get(0);
+ assertThat(rank1.departmentName()).isEqualTo("소프트웨어전공");
+
+ // 상위 30명만 합산: (350 + 340 + 330 + ... + 70 + 60)분
+ // 등차수열 합: (첫항 + 끝항) * 항수 / 2 = (350 + 60) * 30 / 2 = 6150분
+ long expectedMillis = 6150L * 60 * 1000; // 6150분 = 369,000,000ms
+ assertThat(rank1.totalMillis()).isEqualTo(expectedMillis);
+
+ // 31~35등(50분, 40분, 30분, 20분, 10분)은 제외되어야 함
+ // 만약 전체 합산이면: (350 + 340 + ... + 20 + 10) = 6300분 = 378,000,000ms
+ // 따라서 실제 결과는 378,000,000ms보다 작아야 함
+ assertThat(rank1.totalMillis()).isLessThan(378000000L);
+ }
+
+ @Test
+ @DisplayName("학과별_30명_미만인_경우_전체_인원이_집계된다")
+ void 학과별_30명_미만인_경우_전체_인원이_집계된다() {
+ // Given - 소프트웨어전공 10명만 생성
+ LocalDateTime today = LocalDateTime.now().withHour(12).withMinute(0);
+ LocalDateTime oneHourAgo = today.minusHours(1);
+
+ // 10명의 사용자 생성 (각각 100분, 90분, ..., 10분)
+ for (int i = 1; i <= 10; i++) {
+ User user = createAndSaveUser("소프트웨어소수" + i, "sw-small" + i + "@kumoh.ac.kr", Department.SOFTWARE);
+ LocalDateTime sessionStart = oneHourAgo;
+ LocalDateTime sessionEnd = oneHourAgo.plusMinutes((11 - i) * 10);
+ createCompletedStudySession(user, sessionStart, sessionEnd);
+ }
+
+ // When
+ DepartmentRankingResponse response = departmentRankService.getCurrentDailyDepartmentRanking(softwareUser1.getId());
+
+ // Then - 0ms 학과는 필터링되어 제외됨
+ assertThat(response.topRanks()).hasSize(1);
+
+ // 1등: 소프트웨어전공
+ DepartmentRankingEntryResponse rank1 = response.topRanks().get(0);
+ assertThat(rank1.departmentName()).isEqualTo("소프트웨어전공");
+
+ // 전체 10명 합산: (100 + 90 + ... + 20 + 10) = 550분
+ long expectedMillis = 550L * 60 * 1000; // 550분 = 33,000,000ms
+ assertThat(rank1.totalMillis()).isEqualTo(expectedMillis);
+ }
+
+ @Test
+ @DisplayName("여러_학과가_각각_상위_30명_기준으로_집계된다")
+ void 여러_학과가_각각_상위_30명_기준으로_집계된다() {
+ // Given
+ LocalDateTime today = LocalDateTime.now().withHour(12).withMinute(0);
+ LocalDateTime oneHourAgo = today.minusHours(1);
+
+ // 소프트웨어전공: 35명 (상위 30명만 집계)
+ for (int i = 1; i <= 35; i++) {
+ User user = createAndSaveUser("SW멀티" + i, "sw-multi" + i + "@kumoh.ac.kr", Department.SOFTWARE);
+ createCompletedStudySession(user, oneHourAgo, oneHourAgo.plusMinutes(100)); // 각 100분
+ }
+
+ // 컴퓨터공학전공: 20명 (전체 집계)
+ for (int i = 1; i <= 20; i++) {
+ User user = createAndSaveUser("컴공멀티" + i, "ce-multi" + i + "@kumoh.ac.kr", Department.COMPUTER_ENGINEERING);
+ createCompletedStudySession(user, oneHourAgo, oneHourAgo.plusMinutes(80)); // 각 80분
+ }
+
+ // When
+ DepartmentRankingResponse response = departmentRankService.getCurrentDailyDepartmentRanking(softwareUser1.getId());
+
+ // Then - 0ms 학과는 필터링되어 제외됨
+ assertThat(response.topRanks()).hasSize(2);
+
+ // 1등: 소프트웨어전공 (30명 * 100분 = 3000분)
+ DepartmentRankingEntryResponse rank1 = response.topRanks().get(0);
+ assertThat(rank1.departmentName()).isEqualTo("소프트웨어전공");
+ assertThat(rank1.totalMillis()).isEqualTo(3000L * 60 * 1000); // 180,000,000ms
+
+ // 2등: 컴퓨터공학전공 (20명 * 80분 = 1600분)
+ DepartmentRankingEntryResponse rank2 = response.topRanks().get(1);
+ assertThat(rank2.departmentName()).isEqualTo("컴퓨터공학전공");
+ assertThat(rank2.totalMillis()).isEqualTo(1600L * 60 * 1000); // 96,000,000ms
+ }
+ }
+
+ // 테스트 헬퍼 메서드들
+ private User createAndSaveUser(String name, String email, Department department) {
+ User user = User.builder()
+ .name(name)
+ .email(email)
+ .department(department)
+ .picture("profile.jpg")
+ .role(UserRole.USER)
+ .provider(com.gpt.geumpumtabackend.global.oauth.user.OAuth2Provider.GOOGLE)
+ .providerId("test-provider-id-" + email)
+ .build();
+ return userRepository.save(user);
+ }
+
+ private void createCompletedStudySession(User user, LocalDateTime startTime, LocalDateTime endTime) {
+ StudySession session = new StudySession();
+ session.startStudySession(startTime, user);
+ session.endStudySession(endTime);
+ studySessionRepository.save(session);
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/com/gpt/geumpumtabackend/integration/study/service/StudySessionIntegrationTest.java b/src/test/java/com/gpt/geumpumtabackend/integration/study/service/StudySessionIntegrationTest.java
new file mode 100644
index 0000000..16d7c0d
--- /dev/null
+++ b/src/test/java/com/gpt/geumpumtabackend/integration/study/service/StudySessionIntegrationTest.java
@@ -0,0 +1,153 @@
+package com.gpt.geumpumtabackend.integration.study.service;
+
+import com.gpt.geumpumtabackend.global.exception.BusinessException;
+import com.gpt.geumpumtabackend.integration.config.BaseIntegrationTest;
+import com.gpt.geumpumtabackend.study.dto.request.StudyStartRequest;
+import com.gpt.geumpumtabackend.study.dto.response.StudyStartResponse;
+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.domain.UserRole;
+import com.gpt.geumpumtabackend.user.repository.UserRepository;
+import com.gpt.geumpumtabackend.wifi.service.CampusWiFiValidationService;
+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 static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+@DisplayName("StudySessionService 통합 테스트")
+class StudySessionIntegrationTest extends BaseIntegrationTest {
+
+ @Autowired
+ private StudySessionService studySessionService;
+
+ @Autowired
+ private UserRepository userRepository;
+
+ @Autowired
+ private CampusWiFiValidationService wifiValidationService;
+
+ private User testUser;
+
+ @BeforeEach
+ void setUp() {
+ // 테스트 사용자 생성
+ testUser = createAndSaveUser("통합테스트사용자", "integration@kumoh.ac.kr", Department.SOFTWARE);
+ }
+
+ @Test
+ @DisplayName("유효한_캠퍼스_네트워크에서_학습_세션이_시작된다")
+ void 유효한_캠퍼스_네트워크에서_학습_세션이_시작된다() {
+ // Given - application-test.yml에 설정된 유효한 캠퍼스 IP 사용
+ // gateway: 172.30.64.1, ip-range: 172.30.64.0/18
+ String gatewayIp = "172.30.64.1";
+ String clientIp = "172.30.64.100";
+ StudyStartRequest request = new StudyStartRequest(gatewayIp, clientIp);
+
+ // When - 학습 세션 시작 (실제 WiFi 검증 통과)
+ StudyStartResponse response1 = studySessionService.startStudySession(request, testUser.getId());
+
+ // Then - 학습 세션이 성공적으로 시작됨
+ assertThat(response1).isNotNull();
+ assertThat(response1.studySessionId()).isNotNull();
+
+ // When - 두 번째 호출도 정상 동작
+ StudyStartResponse response2 = studySessionService.startStudySession(request, testUser.getId());
+
+ // Then - 새로운 학습 세션이 생성됨
+ assertThat(response2).isNotNull();
+ assertThat(response2.studySessionId()).isNotNull();
+ assertThat(response2.studySessionId()).isNotEqualTo(response1.studySessionId());
+ }
+
+ @Test
+ @DisplayName("캠퍼스가_아닌_네트워크에서는_학습_세션_시작이_실패한다")
+ void 캠퍼스가_아닌_네트워크에서는_학습_세션_시작이_실패한다() {
+ // Given - 유효하지 않은 네트워크 IP
+ String gatewayIp = "192.168.10.1";
+ String clientIp = "192.168.10.100";
+ StudyStartRequest request = new StudyStartRequest(gatewayIp, clientIp);
+
+ // When & Then - WiFi 검증 실패로 예외 발생
+ assertThatThrownBy(() -> studySessionService.startStudySession(request, testUser.getId()))
+ .isInstanceOf(BusinessException.class);
+ }
+
+ @Test
+ @DisplayName("유효한_게이트웨이이지만_클라이언트_IP가_범위를_벗어나면_실패한다")
+ void 유효한_게이트웨이이지만_클라이언트_IP가_범위를_벗어나면_실패한다() {
+ // Given - 유효한 게이트웨이, 하지만 IP 범위 밖의 클라이언트 IP
+ String gatewayIp = "172.30.64.1";
+ String clientIp = "192.168.1.100"; // 172.30.64.0/18 범위 밖
+ StudyStartRequest request = new StudyStartRequest(gatewayIp, clientIp);
+
+ // When & Then - 클라이언트 IP 검증 실패
+ assertThatThrownBy(() -> studySessionService.startStudySession(request, testUser.getId()))
+ .isInstanceOf(BusinessException.class);
+ }
+
+ @Test
+ @DisplayName("WiFi_검증_결과가_Redis에_캐시된다")
+ void WiFi_검증_결과가_Redis에_캐시된다() {
+ // Given
+ String gatewayIp = "172.30.64.1";
+ String clientIp = "172.30.64.100";
+
+ // When - 첫 번째 검증 (캐시 미스, 실제 검증 수행)
+ var result1 = wifiValidationService.validateFromCache(gatewayIp, clientIp);
+
+ // Then - 유효한 결과 반환
+ assertThat(result1.isValid()).isTrue();
+ assertThat(result1.getMessage()).isEqualTo("캠퍼스 네트워크입니다");
+
+ // When - 두 번째 검증 (캐시 히트)
+ var result2 = wifiValidationService.validateFromCache(gatewayIp, clientIp);
+
+ // Then - 캐시된 결과 반환 (캐시 문구 포함)
+ assertThat(result2.isValid()).isTrue();
+ assertThat(result2.getMessage()).isEqualTo("캠퍼스 네트워크입니다 (캐시)");
+ }
+
+ @Test
+ @DisplayName("IP_범위의_경계값도_정확히_검증된다")
+ void IP_범위의_경계값도_정확히_검증된다() {
+ // Given - 172.30.64.0/18 범위의 경계값 테스트
+ String gatewayIp = "172.30.64.1";
+
+ // 범위 내 첫 번째 IP
+ String validIp1 = "172.30.64.1";
+ // 범위 내 마지막 IP (172.30.127.254)
+ String validIp2 = "172.30.127.254";
+
+ // When & Then - 범위 내 IP는 통과
+ var result1 = wifiValidationService.validateFromCache(gatewayIp, validIp1);
+ assertThat(result1.isValid()).isTrue();
+
+ var result2 = wifiValidationService.validateFromCache(gatewayIp, validIp2);
+ assertThat(result2.isValid()).isTrue();
+
+ // Given - 범위 밖 IP
+ String invalidIp = "172.30.128.1"; // /18 범위 초과
+
+ // When & Then - 범위 밖 IP는 실패
+ var result3 = wifiValidationService.validateFromCache(gatewayIp, invalidIp);
+ assertThat(result3.isValid()).isFalse();
+ }
+
+ // 헬퍼 메서드
+ private User createAndSaveUser(String name, String email, Department department) {
+ User user = User.builder()
+ .name(name)
+ .email(email)
+ .department(department)
+ .picture("profile.jpg")
+ .role(UserRole.USER)
+ .provider(com.gpt.geumpumtabackend.global.oauth.user.OAuth2Provider.GOOGLE)
+ .providerId("test-provider-id-" + email)
+ .build();
+ return userRepository.save(user);
+ }
+}
\ No newline at end of file
From df566977306bbc5eaa5bcc51fd314619efb7ff51 Mon Sep 17 00:00:00 2001
From: Juhye0k
Date: Mon, 5 Jan 2026 22:01:37 +0900
Subject: [PATCH 014/135] =?UTF-8?q?feat=20:=20=ED=85=8C=EC=8A=A4=ED=8A=B8?=
=?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=84=A4=EC=A0=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
build.gradle | 3 ++
.../rank/service/DepartmentRankService.java | 18 ++++++----
.../config/BaseIntegrationTest.java | 12 +++++--
.../unit/config/BaseUnitTest.java | 13 +++++++
.../unit/config/TestWiFiMockConfig.java | 35 +++++++++++++++++++
5 files changed, 72 insertions(+), 9 deletions(-)
create mode 100644 src/test/java/com/gpt/geumpumtabackend/unit/config/BaseUnitTest.java
create mode 100644 src/test/java/com/gpt/geumpumtabackend/unit/config/TestWiFiMockConfig.java
diff --git a/build.gradle b/build.gradle
index f90e0af..dd1a241 100644
--- a/build.gradle
+++ b/build.gradle
@@ -71,4 +71,7 @@ dependencies {
tasks.named('test') {
useJUnitPlatform()
+
+ // Mockito inline mock maker를 Java agent로 설정 (Java 21 호환)
+ jvmArgs "-XX:+EnableDynamicAgentLoading"
}
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..b04532e 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,30 @@ 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);
+ // 공부 시간이 0보다 큰 학과만 topRankings에 추가
+ if (temp.getTotalMillis() != null && temp.getTotalMillis() > 0) {
+ 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/test/java/com/gpt/geumpumtabackend/integration/config/BaseIntegrationTest.java b/src/test/java/com/gpt/geumpumtabackend/integration/config/BaseIntegrationTest.java
index bcaf48e..c9acf76 100644
--- a/src/test/java/com/gpt/geumpumtabackend/integration/config/BaseIntegrationTest.java
+++ b/src/test/java/com/gpt/geumpumtabackend/integration/config/BaseIntegrationTest.java
@@ -95,7 +95,11 @@ void cleanUp() {
private void truncateAllTables() {
try {
- String dbProductName = jdbcTemplate.getDataSource().getConnection().getMetaData().getDatabaseProductName();
+ // Connection을 try-with-resources로 자동 close
+ String dbProductName;
+ try (var connection = jdbcTemplate.getDataSource().getConnection()) {
+ dbProductName = connection.getMetaData().getDatabaseProductName();
+ }
boolean isH2 = "H2".equalsIgnoreCase(dbProductName);
// 외래 키 제약 조건 비활성화
@@ -134,7 +138,9 @@ private void truncateAllTables() {
}
private void cleanRedisCache() {
- // Redis의 모든 캐시 데이터 삭제
- redisTemplate.getConnectionFactory().getConnection().flushAll();
+ // Redis의 모든 캐시 데이터 삭제 (Connection을 try-with-resources로 자동 close)
+ try (var connection = redisTemplate.getConnectionFactory().getConnection()) {
+ connection.serverCommands().flushAll();
+ }
}
}
\ No newline at end of file
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..d1530b9
--- /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.validateFromCache("192.168.1.1", anyString()))
+ .thenReturn(WiFiValidationResult.valid("캠퍼스 네트워크입니다 (Mock)"));
+
+ // 캠퍼스가 아닌 네트워크 (192.168.10.x)
+ when(mock.validateFromCache("192.168.10.1", anyString()))
+ .thenReturn(WiFiValidationResult.invalid("캠퍼스 네트워크가 아닙니다 (Mock)"));
+
+ // 에러 시뮬레이션용 (특정 IP에서 에러 발생)
+ when(mock.validateFromCache("error.test.ip", anyString()))
+ .thenReturn(WiFiValidationResult.error("Redis 연결 실패 (Mock)"));
+
+ return mock;
+ }
+}
\ No newline at end of file
From 4557dc9b22c26809c8575c65dc626e604c16c09b Mon Sep 17 00:00:00 2001
From: Juhye0k
Date: Mon, 5 Jan 2026 22:51:37 +0900
Subject: [PATCH 015/135] =?UTF-8?q?feat=20:=20=ED=85=8C=EC=8A=A4=ED=8A=B8?=
=?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=84=A4=EC=A0=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/test/resources/application-unit-test.yml | 42 ++++++++++++++++++++
1 file changed, 42 insertions(+)
create mode 100644 src/test/resources/application-unit-test.yml
diff --git a/src/test/resources/application-unit-test.yml b/src/test/resources/application-unit-test.yml
new file mode 100644
index 0000000..95bff0e
--- /dev/null
+++ b/src/test/resources/application-unit-test.yml
@@ -0,0 +1,42 @@
+spring:
+ config:
+ activate:
+ on-profile: unit-test
+
+ # 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
+
+ jpa:
+ hibernate:
+ ddl-auto: create-drop
+ properties:
+ hibernate:
+ dialect: org.hibernate.dialect.H2Dialect
+ format_sql: true
+ open-in-view: false
+
+# JWT 설정 (단위테스트용)
+geumpumta:
+ jwt:
+ secret-key: unit-test-secret-key
+ access-token-expire-in: 1209600
+ refresh-token-expire-in: 1209600
+
+# WiFi 설정 비활성화 (단위테스트에서는 Mock 사용)
+campus-wifi:
+ networks: []
\ No newline at end of file
From 7e88e91e478fe17611e3e92926701b3153ee8669 Mon Sep 17 00:00:00 2001
From: Juhye0k
Date: Mon, 5 Jan 2026 22:59:21 +0900
Subject: [PATCH 016/135] =?UTF-8?q?feat=20:=20=EC=BB=A8=ED=85=8C=EC=9D=B4?=
=?UTF-8?q?=EB=84=88=20=EC=84=A4=EC=A0=95=20=EC=B6=A9=EB=8F=8C=20=ED=95=B4?=
=?UTF-8?q?=EA=B2=B0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../config/BaseIntegrationTest.java | 22 +++++++++++++++++--
src/test/resources/application-test.yml | 6 ++---
2 files changed, 22 insertions(+), 6 deletions(-)
diff --git a/src/test/java/com/gpt/geumpumtabackend/integration/config/BaseIntegrationTest.java b/src/test/java/com/gpt/geumpumtabackend/integration/config/BaseIntegrationTest.java
index c9acf76..3f61167 100644
--- a/src/test/java/com/gpt/geumpumtabackend/integration/config/BaseIntegrationTest.java
+++ b/src/test/java/com/gpt/geumpumtabackend/integration/config/BaseIntegrationTest.java
@@ -21,6 +21,15 @@
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 test execution
+ * - @DynamicPropertySource overrides application-test.yml datasource settings
+ */
@SpringBootTest(
properties = {
"spring.test.database.replace=NONE",
@@ -35,11 +44,20 @@ public abstract class BaseIntegrationTest {
static final MySQLContainer> mysqlContainer = new MySQLContainer<>("mysql:8.0")
.withDatabaseName("test_geumpumta")
.withUsername("test")
- .withPassword("test");
+ .withPassword("test")
+ .withReuse(true)
+ .withCommand("--default-authentication-plugin=mysql_native_password");
@Container
static final GenericContainer> redisContainer = new GenericContainer<>(DockerImageName.parse("redis:7.0-alpine"))
- .withExposedPorts(6379);
+ .withExposedPorts(6379)
+ .withReuse(true);
+
+ static {
+ // Ensure containers are started before Spring context loads
+ mysqlContainer.start();
+ redisContainer.start();
+ }
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml
index 1f39f28..e6acf1c 100644
--- a/src/test/resources/application-test.yml
+++ b/src/test/resources/application-test.yml
@@ -4,10 +4,8 @@ spring:
on-profile: test
datasource:
- driver-class-name: org.testcontainers.jdbc.ContainerDatabaseDriver
- url: jdbc:tc:mysql:8.0:///geumpumta-test
- username: root
- password:
+ driver-class-name: com.mysql.cj.jdbc.Driver
+ # URL will be dynamically set by BaseIntegrationTest via @DynamicPropertySource
mail:
host: dummy-naver.com #smtp 서버 주소
From 7f3d02b60a84698736cdd6a7f1cd91d0390133a5 Mon Sep 17 00:00:00 2001
From: Juhye0k
Date: Mon, 5 Jan 2026 23:10:30 +0900
Subject: [PATCH 017/135] =?UTF-8?q?chore=20:=20=EC=BD=94=EB=93=9C=EB=9E=98?=
=?UTF-8?q?=EB=B9=97=20=EC=98=A4=EB=A5=98=20=ED=95=B4=EA=B2=B0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../controller/StudySessionController.java | 2 --
.../study/domain/StudySession.java | 7 ------
.../study/dto/request/HeartBeatRequest.java | 22 -------------------
.../study/dto/response/HeartBeatResponse.java | 4 ----
.../repository/StudySessionRepository.java | 4 ----
5 files changed, 39 deletions(-)
delete mode 100644 src/main/java/com/gpt/geumpumtabackend/study/dto/request/HeartBeatRequest.java
delete mode 100644 src/main/java/com/gpt/geumpumtabackend/study/dto/response/HeartBeatResponse.java
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 da29cc0..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;
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..8dbd92b 100644
--- a/src/main/java/com/gpt/geumpumtabackend/study/domain/StudySession.java
+++ b/src/main/java/com/gpt/geumpumtabackend/study/domain/StudySession.java
@@ -32,9 +32,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;
@@ -46,8 +43,4 @@ public void endStudySession(LocalDateTime endTime) {
status = StudyStatus.FINISHED;
this.totalMillis = Duration.between(this.startTime, this.endTime).toMillis();
}
-
- public void updateHeartBeatAt(LocalDateTime heartBeatAt) {
- this.heartBeatAt = heartBeatAt;
- }
}
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/repository/StudySessionRepository.java b/src/main/java/com/gpt/geumpumtabackend/study/repository/StudySessionRepository.java
index 5d49789..c1d351d 100644
--- a/src/main/java/com/gpt/geumpumtabackend/study/repository/StudySessionRepository.java
+++ b/src/main/java/com/gpt/geumpumtabackend/study/repository/StudySessionRepository.java
@@ -32,10 +32,6 @@ 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);
-
/*
현재 진행중인 기간의 공부 시간 연산
*/
From febe868a95b49b148b3d36fbc42900e7f89f0a76 Mon Sep 17 00:00:00 2001
From: Juhye0k
Date: Mon, 5 Jan 2026 23:24:59 +0900
Subject: [PATCH 018/135] =?UTF-8?q?chore=20:=20=EC=BB=A8=ED=85=8C=EC=9D=B4?=
=?UTF-8?q?=EB=84=88=20=EB=9D=BC=EC=9D=B4=ED=94=84=EC=82=AC=EC=9D=B4?=
=?UTF-8?q?=ED=81=B4=20=EC=98=A4=EB=A5=98?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../config/BaseIntegrationTest.java | 31 -------------------
1 file changed, 31 deletions(-)
diff --git a/src/test/java/com/gpt/geumpumtabackend/integration/config/BaseIntegrationTest.java b/src/test/java/com/gpt/geumpumtabackend/integration/config/BaseIntegrationTest.java
index 3f61167..f250967 100644
--- a/src/test/java/com/gpt/geumpumtabackend/integration/config/BaseIntegrationTest.java
+++ b/src/test/java/com/gpt/geumpumtabackend/integration/config/BaseIntegrationTest.java
@@ -1,7 +1,6 @@
package com.gpt.geumpumtabackend.integration.config;
import org.junit.jupiter.api.AfterEach;
-import org.junit.jupiter.api.BeforeEach;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.data.redis.core.RedisTemplate;
@@ -15,10 +14,6 @@
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.utility.DockerImageName;
-import jakarta.persistence.EntityManager;
-import jakarta.persistence.PersistenceContext;
-import org.springframework.transaction.annotation.Transactional;
-
import java.util.List;
/**
@@ -53,12 +48,6 @@ public abstract class BaseIntegrationTest {
.withExposedPorts(6379)
.withReuse(true);
- static {
- // Ensure containers are started before Spring context loads
- mysqlContainer.start();
- redisContainer.start();
- }
-
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
// MySQL 설정
@@ -85,26 +74,6 @@ static void configureProperties(DynamicPropertyRegistry registry) {
@Autowired
private RedisTemplate redisTemplate;
- @PersistenceContext
- private EntityManager entityManager;
-
- @BeforeEach
- @Transactional
- void ensureTablesExist() {
- // EntityManager 초기화를 강제로 실행하여 Hibernate DDL 실행 보장
- try {
- entityManager.createNativeQuery("SELECT 1").getSingleResult();
- System.out.println("EntityManager initialized - Hibernate DDL should be executed");
-
- // 테이블 존재 확인
- jdbcTemplate.queryForObject("SELECT COUNT(*) FROM user LIMIT 1", Integer.class);
- System.out.println("User table exists - DDL executed successfully");
- } catch (Exception e) {
- System.out.println("Table check failed: " + e.getMessage());
- throw new RuntimeException("Tables not created properly", e);
- }
- }
-
@AfterEach
void cleanUp() {
truncateAllTables();
From 78117c3a36821494a1745c6342a06caadb3cfa33 Mon Sep 17 00:00:00 2001
From: Juhye0k
Date: Mon, 5 Jan 2026 23:34:42 +0900
Subject: [PATCH 019/135] =?UTF-8?q?chore=20:=20=EB=8D=B0=EC=9D=B4=ED=84=B0?=
=?UTF-8?q?=20=EC=A0=95=EB=A6=AC=20=ED=8A=B8=EB=9E=9C=EC=9E=AD=EC=85=98=20?=
=?UTF-8?q?=EC=A0=81=EC=9A=A9?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../integration/config/BaseIntegrationTest.java | 2 ++
1 file changed, 2 insertions(+)
diff --git a/src/test/java/com/gpt/geumpumtabackend/integration/config/BaseIntegrationTest.java b/src/test/java/com/gpt/geumpumtabackend/integration/config/BaseIntegrationTest.java
index f250967..02d7def 100644
--- a/src/test/java/com/gpt/geumpumtabackend/integration/config/BaseIntegrationTest.java
+++ b/src/test/java/com/gpt/geumpumtabackend/integration/config/BaseIntegrationTest.java
@@ -8,6 +8,7 @@
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;
@@ -75,6 +76,7 @@ static void configureProperties(DynamicPropertyRegistry registry) {
private RedisTemplate redisTemplate;
@AfterEach
+ @Transactional
void cleanUp() {
truncateAllTables();
cleanRedisCache();
From c791688978d94a8cb1640821966c03a9b734e3d2 Mon Sep 17 00:00:00 2001
From: Juhye0k
Date: Mon, 5 Jan 2026 23:49:50 +0900
Subject: [PATCH 020/135] =?UTF-8?q?chore=20:=20test=20=EC=84=A4=EC=A0=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../geumpumtabackend/GeumpumtaBackendApplicationTests.java | 7 ++-----
1 file changed, 2 insertions(+), 5 deletions(-)
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() {
From 124c7ae2fb31384ec6859ea9362e60082f9f99b8 Mon Sep 17 00:00:00 2001
From: Juhye0k
Date: Tue, 6 Jan 2026 09:33:02 +0900
Subject: [PATCH 021/135] =?UTF-8?q?chore=20:=20github=20ci/cd=20=ED=99=98?=
=?UTF-8?q?=EA=B2=BD=20=EC=BB=A8=ED=85=8C=EC=9D=B4=EB=84=88=20=EC=98=A4?=
=?UTF-8?q?=EB=A5=98=20=EC=88=98=EC=A0=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.github/workflows/dev-ci.yml | 3 +++
.github/workflows/prod-ci.yml | 3 +++
.../integration/config/BaseIntegrationTest.java | 4 +---
3 files changed, 7 insertions(+), 3 deletions(-)
diff --git a/.github/workflows/dev-ci.yml b/.github/workflows/dev-ci.yml
index 56f42b6..7209598 100644
--- a/.github/workflows/dev-ci.yml
+++ b/.github/workflows/dev-ci.yml
@@ -51,6 +51,9 @@ jobs:
- name: Build with Gradle
run: ./gradlew clean build
+ env:
+ TESTCONTAINERS_RYUK_DISABLED: false
+ TESTCONTAINERS_CHECKS_DISABLE: false
- name: Publish Test Report
uses: mikepenz/action-junit-report@v5
diff --git a/.github/workflows/prod-ci.yml b/.github/workflows/prod-ci.yml
index 9a0110c..20c2fee 100644
--- a/.github/workflows/prod-ci.yml
+++ b/.github/workflows/prod-ci.yml
@@ -47,6 +47,9 @@ jobs:
- name: Build with Gradle
run: ./gradlew clean build
+ env:
+ TESTCONTAINERS_RYUK_DISABLED: false
+ TESTCONTAINERS_CHECKS_DISABLE: false
- name: Publish Test Report
uses: mikepenz/action-junit-report@v5
diff --git a/src/test/java/com/gpt/geumpumtabackend/integration/config/BaseIntegrationTest.java b/src/test/java/com/gpt/geumpumtabackend/integration/config/BaseIntegrationTest.java
index 02d7def..a23fb32 100644
--- a/src/test/java/com/gpt/geumpumtabackend/integration/config/BaseIntegrationTest.java
+++ b/src/test/java/com/gpt/geumpumtabackend/integration/config/BaseIntegrationTest.java
@@ -41,13 +41,11 @@ public abstract class BaseIntegrationTest {
.withDatabaseName("test_geumpumta")
.withUsername("test")
.withPassword("test")
- .withReuse(true)
.withCommand("--default-authentication-plugin=mysql_native_password");
@Container
static final GenericContainer> redisContainer = new GenericContainer<>(DockerImageName.parse("redis:7.0-alpine"))
- .withExposedPorts(6379)
- .withReuse(true);
+ .withExposedPorts(6379);
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
From 01be81241fc9122a0c59a7f5fbb6c914cedab17f Mon Sep 17 00:00:00 2001
From: Juhye0k
Date: Tue, 6 Jan 2026 10:22:36 +0900
Subject: [PATCH 022/135] =?UTF-8?q?chore=20:=20Ryuk=20=EC=BB=A8=ED=85=8C?=
=?UTF-8?q?=EC=9D=B4=EB=84=88=20=EC=83=9D=EC=84=B1=20=EC=98=A4=EB=A5=98?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.github/workflows/dev-ci.yml | 3 +--
.github/workflows/prod-ci.yml | 3 +--
build.gradle | 3 +++
.../integration/config/BaseIntegrationTest.java | 3 ++-
4 files changed, 7 insertions(+), 5 deletions(-)
diff --git a/.github/workflows/dev-ci.yml b/.github/workflows/dev-ci.yml
index 7209598..5bb6cc9 100644
--- a/.github/workflows/dev-ci.yml
+++ b/.github/workflows/dev-ci.yml
@@ -52,8 +52,7 @@ jobs:
- name: Build with Gradle
run: ./gradlew clean build
env:
- TESTCONTAINERS_RYUK_DISABLED: false
- TESTCONTAINERS_CHECKS_DISABLE: false
+ TESTCONTAINERS_RYUK_DISABLED: true
- name: Publish Test Report
uses: mikepenz/action-junit-report@v5
diff --git a/.github/workflows/prod-ci.yml b/.github/workflows/prod-ci.yml
index 20c2fee..639d478 100644
--- a/.github/workflows/prod-ci.yml
+++ b/.github/workflows/prod-ci.yml
@@ -48,8 +48,7 @@ jobs:
- name: Build with Gradle
run: ./gradlew clean build
env:
- TESTCONTAINERS_RYUK_DISABLED: false
- TESTCONTAINERS_CHECKS_DISABLE: false
+ TESTCONTAINERS_RYUK_DISABLED: true
- name: Publish Test Report
uses: mikepenz/action-junit-report@v5
diff --git a/build.gradle b/build.gradle
index dd1a241..38d2000 100644
--- a/build.gradle
+++ b/build.gradle
@@ -74,4 +74,7 @@ tasks.named('test') {
// Mockito inline mock maker를 Java agent로 설정 (Java 21 호환)
jvmArgs "-XX:+EnableDynamicAgentLoading"
+
+ // TestContainers 설정 (CI 환경 고려)
+ systemProperty 'testcontainers.reuse.enable', System.getProperty('testcontainers.reuse.enable', 'false')
}
diff --git a/src/test/java/com/gpt/geumpumtabackend/integration/config/BaseIntegrationTest.java b/src/test/java/com/gpt/geumpumtabackend/integration/config/BaseIntegrationTest.java
index a23fb32..37f2834 100644
--- a/src/test/java/com/gpt/geumpumtabackend/integration/config/BaseIntegrationTest.java
+++ b/src/test/java/com/gpt/geumpumtabackend/integration/config/BaseIntegrationTest.java
@@ -23,8 +23,9 @@
* Configuration approach:
* - Uses programmatic TestContainers management (@Container)
* - Containers are shared across all test classes (static)
- * - Container reuse enabled for faster test execution
+ * - Container reuse disabled for CI/CD compatibility
* - @DynamicPropertySource overrides application-test.yml datasource settings
+ * - Ryuk disabled in CI environment (TESTCONTAINERS_RYUK_DISABLED=true)
*/
@SpringBootTest(
properties = {
From 646f4d7224809da84266bac150c7ecf2eb0a6c1b Mon Sep 17 00:00:00 2001
From: Juhye0k
Date: Tue, 6 Jan 2026 11:08:04 +0900
Subject: [PATCH 023/135] =?UTF-8?q?chore=20:=20=EC=BB=A8=ED=85=8C=EC=9D=B4?=
=?UTF-8?q?=EB=84=88=20=EC=83=9D=EC=84=B1=20=EC=98=A4=EB=A5=98=20=ED=95=B4?=
=?UTF-8?q?=EA=B2=B0=20=EC=8B=9C=EB=8F=84=201?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.github/workflows/dev-ci.yml | 2 --
.github/workflows/prod-ci.yml | 2 --
build.gradle | 3 ---
.../integration/config/BaseIntegrationTest.java | 11 +++++++----
4 files changed, 7 insertions(+), 11 deletions(-)
diff --git a/.github/workflows/dev-ci.yml b/.github/workflows/dev-ci.yml
index 5bb6cc9..56f42b6 100644
--- a/.github/workflows/dev-ci.yml
+++ b/.github/workflows/dev-ci.yml
@@ -51,8 +51,6 @@ jobs:
- name: Build with Gradle
run: ./gradlew clean build
- env:
- TESTCONTAINERS_RYUK_DISABLED: true
- name: Publish Test Report
uses: mikepenz/action-junit-report@v5
diff --git a/.github/workflows/prod-ci.yml b/.github/workflows/prod-ci.yml
index 639d478..9a0110c 100644
--- a/.github/workflows/prod-ci.yml
+++ b/.github/workflows/prod-ci.yml
@@ -47,8 +47,6 @@ jobs:
- name: Build with Gradle
run: ./gradlew clean build
- env:
- TESTCONTAINERS_RYUK_DISABLED: true
- name: Publish Test Report
uses: mikepenz/action-junit-report@v5
diff --git a/build.gradle b/build.gradle
index 38d2000..dd1a241 100644
--- a/build.gradle
+++ b/build.gradle
@@ -74,7 +74,4 @@ tasks.named('test') {
// Mockito inline mock maker를 Java agent로 설정 (Java 21 호환)
jvmArgs "-XX:+EnableDynamicAgentLoading"
-
- // TestContainers 설정 (CI 환경 고려)
- systemProperty 'testcontainers.reuse.enable', System.getProperty('testcontainers.reuse.enable', 'false')
}
diff --git a/src/test/java/com/gpt/geumpumtabackend/integration/config/BaseIntegrationTest.java b/src/test/java/com/gpt/geumpumtabackend/integration/config/BaseIntegrationTest.java
index 37f2834..1019168 100644
--- a/src/test/java/com/gpt/geumpumtabackend/integration/config/BaseIntegrationTest.java
+++ b/src/test/java/com/gpt/geumpumtabackend/integration/config/BaseIntegrationTest.java
@@ -15,6 +15,7 @@
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.utility.DockerImageName;
+import java.time.Duration;
import java.util.List;
/**
@@ -23,9 +24,9 @@
* Configuration approach:
* - Uses programmatic TestContainers management (@Container)
* - Containers are shared across all test classes (static)
- * - Container reuse disabled for CI/CD compatibility
+ * - 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
- * - Ryuk disabled in CI environment (TESTCONTAINERS_RYUK_DISABLED=true)
*/
@SpringBootTest(
properties = {
@@ -42,11 +43,13 @@ public abstract class BaseIntegrationTest {
.withDatabaseName("test_geumpumta")
.withUsername("test")
.withPassword("test")
- .withCommand("--default-authentication-plugin=mysql_native_password");
+ .withCommand("--default-authentication-plugin=mysql_native_password")
+ .withStartupTimeout(Duration.ofSeconds(90));
@Container
static final GenericContainer> redisContainer = new GenericContainer<>(DockerImageName.parse("redis:7.0-alpine"))
- .withExposedPorts(6379);
+ .withExposedPorts(6379)
+ .withStartupTimeout(Duration.ofSeconds(60));
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
From e6bcc17d80a21b6bb2a8bc9a06ed9e14936c2d51 Mon Sep 17 00:00:00 2001
From: juhyeok
Date: Tue, 6 Jan 2026 16:55:40 +0900
Subject: [PATCH 024/135] Revise CI workflow for testing and Docker setup
Updated CI workflow to include testing and Docker sanity check.
---
.github/workflows/dev-ci.yml | 98 ++++++++++++++++++++++++++----------
1 file changed, 71 insertions(+), 27 deletions(-)
diff --git a/.github/workflows/dev-ci.yml b/.github/workflows/dev-ci.yml
index 56f42b6..c92cd18 100644
--- a/.github/workflows/dev-ci.yml
+++ b/.github/workflows/dev-ci.yml
@@ -1,10 +1,10 @@
-name: Dev - CI (Build & Push)
+name: Dev - CI (Test, Build & Push)
on:
push:
- branches: [ "dev" ]
+ branches: ["dev"]
pull_request:
- branches: [ "dev" ]
+ branches: ["dev"]
permissions:
contents: read
@@ -12,24 +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 }}
-
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -40,8 +31,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 +40,74 @@ 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
+ 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 +136,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
From eff72acc66a6e04731c6a0d57f0bee67a748ea4d Mon Sep 17 00:00:00 2001
From: Juhye0k
Date: Wed, 7 Jan 2026 11:05:22 +0900
Subject: [PATCH 025/135] =?UTF-8?q?chore=20:=20=EC=BB=A8=ED=85=8C=EC=9D=B4?=
=?UTF-8?q?=EB=84=88=20=EC=83=9D=EC=84=B1=20=EC=98=A4=EB=A5=98=20=ED=95=B4?=
=?UTF-8?q?=EA=B2=B0=20=EC=8B=9C=EB=8F=84=202?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.github/workflows/dev-ci.yml | 102 +++++++++++++-----
.../config/BaseIntegrationTest.java | 6 +-
src/test/resources/application-test.yml | 9 ++
3 files changed, 87 insertions(+), 30 deletions(-)
diff --git a/.github/workflows/dev-ci.yml b/.github/workflows/dev-ci.yml
index 56f42b6..35a2713 100644
--- a/.github/workflows/dev-ci.yml
+++ b/.github/workflows/dev-ci.yml
@@ -1,10 +1,10 @@
-name: Dev - CI (Build & Push)
+name: Dev - CI (Test, Build & Push)
on:
push:
- branches: [ "dev" ]
+ branches: ["dev"]
pull_request:
- branches: [ "dev" ]
+ branches: ["dev"]
permissions:
contents: read
@@ -12,24 +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 }}
-
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -40,8 +31,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 +40,76 @@ 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:
+ TESTCONTAINERS_REUSE_ENABLE: false
+ 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 +138,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
@@ -99,4 +145,4 @@ jobs:
uses: actions/upload-artifact@v4
with:
name: image-tag
- path: image_tag.txt
+ path: image_tag.txt
\ No newline at end of file
diff --git a/src/test/java/com/gpt/geumpumtabackend/integration/config/BaseIntegrationTest.java b/src/test/java/com/gpt/geumpumtabackend/integration/config/BaseIntegrationTest.java
index 1019168..4cc755d 100644
--- a/src/test/java/com/gpt/geumpumtabackend/integration/config/BaseIntegrationTest.java
+++ b/src/test/java/com/gpt/geumpumtabackend/integration/config/BaseIntegrationTest.java
@@ -44,12 +44,14 @@ public abstract class BaseIntegrationTest {
.withUsername("test")
.withPassword("test")
.withCommand("--default-authentication-plugin=mysql_native_password")
- .withStartupTimeout(Duration.ofSeconds(90));
+ .withStartupTimeout(Duration.ofSeconds(90))
+ .withReuse(true);
@Container
static final GenericContainer> redisContainer = new GenericContainer<>(DockerImageName.parse("redis:7.0-alpine"))
.withExposedPorts(6379)
- .withStartupTimeout(Duration.ofSeconds(60));
+ .withStartupTimeout(Duration.ofSeconds(60))
+ .withReuse(true);
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml
index e6acf1c..2131927 100644
--- a/src/test/resources/application-test.yml
+++ b/src/test/resources/application-test.yml
@@ -6,6 +6,15 @@ spring:
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: 30000
+ idle-timeout: 600000
+ max-lifetime: 1800000
+ connection-test-query: SELECT 1
+ validation-timeout: 5000
+ leak-detection-threshold: 60000
mail:
host: dummy-naver.com #smtp 서버 주소
From 1efb329601ce544ab1dbd497f129c30c8798b70d Mon Sep 17 00:00:00 2001
From: Juhye0k
Date: Wed, 7 Jan 2026 12:55:59 +0900
Subject: [PATCH 026/135] =?UTF-8?q?chore=20:=20=EC=BB=A8=ED=85=8C=EC=9D=B4?=
=?UTF-8?q?=EB=84=88=20=EC=83=9D=EC=84=B1=20=EC=98=A4=EB=A5=98=20=ED=95=B4?=
=?UTF-8?q?=EA=B2=B0=20=EC=8B=9C=EB=8F=84=203?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/test/resources/testcontainers.properties | 3 +++
1 file changed, 3 insertions(+)
create mode 100644 src/test/resources/testcontainers.properties
diff --git a/src/test/resources/testcontainers.properties b/src/test/resources/testcontainers.properties
new file mode 100644
index 0000000..48b7275
--- /dev/null
+++ b/src/test/resources/testcontainers.properties
@@ -0,0 +1,3 @@
+# Enable container reuse for faster local development
+# CI environments will ignore this setting
+testcontainers.reuse.enable=true
From 6be60d447d6b8969ef52a6d3cd1172812d07d162 Mon Sep 17 00:00:00 2001
From: Juhye0k
Date: Wed, 7 Jan 2026 12:59:07 +0900
Subject: [PATCH 027/135] =?UTF-8?q?chore=20:=20=EC=BB=A8=ED=85=8C=EC=9D=B4?=
=?UTF-8?q?=EB=84=88=20=EC=83=9D=EC=84=B1=20=EC=98=A4=EB=A5=98=20=ED=95=B4?=
=?UTF-8?q?=EA=B2=B0=20=EC=8B=9C=EB=8F=84=203?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.github/workflows/dev-ci.yml | 6 ++++++
1 file changed, 6 insertions(+)
diff --git a/.github/workflows/dev-ci.yml b/.github/workflows/dev-ci.yml
index bbe4596..ce95510 100644
--- a/.github/workflows/dev-ci.yml
+++ b/.github/workflows/dev-ci.yml
@@ -51,6 +51,12 @@ jobs:
docker pull mysql:8.0
docker pull redis:7.0-alpine
+ # TestContainers 설정 파일 생성 (컨테이너 재사용 활성화)
+ - name: Setup TestContainers configuration
+ run: |
+ mkdir -p ~/.testcontainers
+ echo "testcontainers.reuse.enable=true" > ~/.testcontainers.properties
+
- name: Run tests
run: ./gradlew clean test --no-daemon --info
From c627e6d2422f8fb94204b5e042acae948897380a Mon Sep 17 00:00:00 2001
From: Juhye0k
Date: Wed, 7 Jan 2026 14:22:19 +0900
Subject: [PATCH 028/135] =?UTF-8?q?chore=20:=20=ED=86=B5=ED=95=A9=ED=85=8C?=
=?UTF-8?q?=EC=8A=A4=ED=8A=B8=20Controller=20=EB=A1=9C=EC=A7=81=20?=
=?UTF-8?q?=EC=B6=94=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.github/workflows/prod-ci.yml | 18 +
.../config/BaseIntegrationTest.java | 10 +-
...partmentRankControllerIntegrationTest.java | 338 ++++++++++++++
.../DepartmentRankServiceIntegrationTest.java | 423 ------------------
...StudySessionControllerIntegrationTest.java | 290 ++++++++++++
.../service/StudySessionIntegrationTest.java | 153 -------
src/test/resources/application-test.yml | 7 +-
7 files changed, 658 insertions(+), 581 deletions(-)
create mode 100644 src/test/java/com/gpt/geumpumtabackend/integration/rank/controller/DepartmentRankControllerIntegrationTest.java
delete mode 100644 src/test/java/com/gpt/geumpumtabackend/integration/rank/service/DepartmentRankServiceIntegrationTest.java
create mode 100644 src/test/java/com/gpt/geumpumtabackend/integration/study/controller/StudySessionControllerIntegrationTest.java
delete mode 100644 src/test/java/com/gpt/geumpumtabackend/integration/study/service/StudySessionIntegrationTest.java
diff --git a/.github/workflows/prod-ci.yml b/.github/workflows/prod-ci.yml
index 9a0110c..6d4b658 100644
--- a/.github/workflows/prod-ci.yml
+++ b/.github/workflows/prod-ci.yml
@@ -45,6 +45,24 @@ 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
+
+ # TestContainers 설정 파일 생성 (컨테이너 재사용 활성화)
+ - name: Setup TestContainers configuration
+ run: |
+ mkdir -p ~/.testcontainers
+ echo "testcontainers.reuse.enable=true" > ~/.testcontainers.properties
+
- name: Build with Gradle
run: ./gradlew clean build
diff --git a/src/test/java/com/gpt/geumpumtabackend/integration/config/BaseIntegrationTest.java b/src/test/java/com/gpt/geumpumtabackend/integration/config/BaseIntegrationTest.java
index 4cc755d..0f0ed4d 100644
--- a/src/test/java/com/gpt/geumpumtabackend/integration/config/BaseIntegrationTest.java
+++ b/src/test/java/com/gpt/geumpumtabackend/integration/config/BaseIntegrationTest.java
@@ -67,10 +67,12 @@ static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.data.redis.password", () -> "");
// WiFi 검증을 위한 테스트 설정
- registry.add("campus-wifi.networks[0].ssid", () -> "KUMOH_TEST");
- registry.add("campus-wifi.networks[0].gateway-ip", () -> "192.168.1.1");
- registry.add("campus-wifi.networks[0].ip-ranges[0]", () -> "192.168.1.0/24");
- registry.add("campus-wifi.networks[0].active", () -> "true");
+ 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
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..a81bf5d
--- /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("아무도_학습하지_않은_날에는_빈_랭킹을_반환한다")
+ void 아무도_학습하지_않은_날에는_빈_랭킹을_반환한다() 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").isEmpty())
+ .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/rank/service/DepartmentRankServiceIntegrationTest.java b/src/test/java/com/gpt/geumpumtabackend/integration/rank/service/DepartmentRankServiceIntegrationTest.java
deleted file mode 100644
index 26e7297..0000000
--- a/src/test/java/com/gpt/geumpumtabackend/integration/rank/service/DepartmentRankServiceIntegrationTest.java
+++ /dev/null
@@ -1,423 +0,0 @@
-package com.gpt.geumpumtabackend.integration.rank.service;
-
-import com.gpt.geumpumtabackend.integration.config.BaseIntegrationTest;
-import com.gpt.geumpumtabackend.rank.domain.DepartmentRanking;
-import com.gpt.geumpumtabackend.rank.domain.RankingType;
-import com.gpt.geumpumtabackend.rank.dto.response.DepartmentRankingEntryResponse;
-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.domain.StudySession;
-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.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 java.time.LocalDate;
-import java.time.LocalDateTime;
-
-import static org.assertj.core.api.Assertions.assertThat;
-import static org.assertj.core.api.Assertions.within;
-
-@DisplayName("DepartmentRankService 통합 테스트")
-class DepartmentRankServiceIntegrationTest extends BaseIntegrationTest {
-
- @Autowired
- private DepartmentRankService departmentRankService;
-
- @Autowired
- private UserRepository userRepository;
-
- @Autowired
- private StudySessionRepository studySessionRepository;
-
- @Autowired
- private DepartmentRankingRepository departmentRankingRepository;
-
- private User softwareUser1;
- private User softwareUser2;
- private User computerUser1;
- private User electronicUser1;
-
- @BeforeEach
- void setUp() {
- // 테스트 사용자 생성
- softwareUser1 = createAndSaveUser("소프트웨어1", "software1@kumoh.ac.kr", Department.SOFTWARE);
- softwareUser2 = createAndSaveUser("소프트웨어2", "software2@kumoh.ac.kr", Department.SOFTWARE);
- computerUser1 = createAndSaveUser("컴퓨터공학1", "computer1@kumoh.ac.kr", Department.COMPUTER_ENGINEERING);
- electronicUser1 = createAndSaveUser("전자공학1", "electronic1@kumoh.ac.kr", Department.ELECTRONIC_SYSTEMS);
- }
-
- @Nested
- @DisplayName("현재 진행중인 학과 랭킹 일간 조회")
- class GetCurrentDailyDepartmentRanking {
-
- @Test
- @DisplayName("랭킹에_없는_학과_사용자는_꼴찌_다음_순위가_된다")
- void 랭킹에_없는_학과_사용자는_꼴찌_다음_순위가_된다() {
- // Given
- LocalDateTime today = LocalDateTime.now().withHour(12).withMinute(0).withSecond(0).withNano(0);
-
- // 소프트웨어전공만 학습 기록 생성
- createCompletedStudySession(softwareUser1, today.minusHours(1), today);
-
- // When
- DepartmentRankingResponse response = departmentRankService.getCurrentDailyDepartmentRanking(electronicUser1.getId());
-
- // Then
- assertThat(response.topRanks()).hasSize(1);
- assertThat(response.topRanks().get(0).departmentName()).isEqualTo("소프트웨어전공");
-
- // 내 학과 랭킹은 fallback으로 2등 (1 + 1)
- assertThat(response.myDepartmentRanking()).isNotNull();
- assertThat(response.myDepartmentRanking().departmentName()).isEqualTo("전자시스템전공");
- assertThat(response.myDepartmentRanking().rank()).isEqualTo(2L);
- assertThat(response.myDepartmentRanking().totalMillis()).isEqualTo(0L);
- }
-
- @Test
- @DisplayName("아무도_학습하지_않은_날에는_빈_랭킹과_1등_fallback이_반환된다")
- void 아무도_학습하지_않은_날에는_빈_랭킹과_1등_fallback이_반환된다() {
- // Given - 아무 학습 기록 없음
-
- // When
- DepartmentRankingResponse response = departmentRankService.getCurrentDailyDepartmentRanking(softwareUser1.getId());
-
- // Then
- assertThat(response.topRanks()).isEmpty();
-
- // 내 학과 랭킹은 1등 (0 + 1)
- assertThat(response.myDepartmentRanking()).isNotNull();
- assertThat(response.myDepartmentRanking().departmentName()).isEqualTo("소프트웨어전공");
- assertThat(response.myDepartmentRanking().rank()).isEqualTo(1L);
- assertThat(response.myDepartmentRanking().totalMillis()).isEqualTo(0L);
- }
-
- @Test
- @DisplayName("완료된_세션만_집계된다")
- void 완료된_세션만_집계된다() {
- // Given - 현재 시간 기준으로 정확한 시간 설정
- LocalDateTime now = LocalDateTime.now().withNano(0); // 나노초 제거
- LocalDateTime oneHourAgo = now.minusHours(1);
- LocalDateTime twoHoursAgo = now.minusHours(2);
- LocalDateTime thirtyMinAgo = now.minusMinutes(30);
-
- // 소프트웨어전공: 완료 60분 + 완료 60분 = 120분 (진행중 세션 제거)
- createCompletedStudySession(softwareUser1, twoHoursAgo, oneHourAgo); // 60분
- createCompletedStudySession(softwareUser2, oneHourAgo, now); // 60분
-
- // 컴퓨터공학전공: 완료 30분만
- createCompletedStudySession(computerUser1, oneHourAgo, thirtyMinAgo); // 30분
-
- // When
- DepartmentRankingResponse response = departmentRankService.getCurrentDailyDepartmentRanking(softwareUser1.getId());
-
- // Then
- assertThat(response.topRanks()).hasSize(2);
-
- // 1등: 소프트웨어전공 (120분 = 7,200,000ms)
- DepartmentRankingEntryResponse rank1 = response.topRanks().get(0);
- assertThat(rank1.departmentName()).isEqualTo("소프트웨어전공");
- assertThat(rank1.totalMillis()).isCloseTo(7200000L, within(1000L)); // 1초 오차 허용
-
- // 2등: 컴퓨터공학전공 (30분 = 1,800,000ms)
- DepartmentRankingEntryResponse rank2 = response.topRanks().get(1);
- assertThat(rank2.departmentName()).isEqualTo("컴퓨터공학전공");
- assertThat(rank2.totalMillis()).isEqualTo(1800000L);
- }
- }
-
- @Nested
- @DisplayName("완료된 학과 랭킹 일간 조회")
- class GetCompletedDailyDepartmentRanking {
-
- @Test
- @DisplayName("과거_날짜의_학습_세션으로_랭킹이_재계산된다")
- void 과거_날짜의_학습_세션으로_랭킹이_재계산된다() {
- // Given - 어제 날짜로 학습 세션 생성
- LocalDate yesterday = LocalDate.now().minusDays(1);
- LocalDateTime yesterdayStart = yesterday.atTime(9, 0);
- LocalDateTime yesterdayEnd1 = yesterday.atTime(11, 0); // 120분
- LocalDateTime yesterdayEnd2 = yesterday.atTime(10, 30); // 90분
-
- // 소프트웨어전공: 120분
- createCompletedStudySession(softwareUser1, yesterdayStart, yesterdayEnd1);
-
- // 컴퓨터공학전공: 90분
- createCompletedStudySession(computerUser1, yesterdayStart, yesterdayEnd2);
-
- // When - 어제 날짜로 완료된 랭킹 조회
- DepartmentRankingResponse response = departmentRankService.getCompletedDailyDepartmentRanking(
- softwareUser1.getId(),
- yesterday.atStartOfDay()
- );
-
- // Then - 쿼리가 실시간 재계산하여 결과 반환
- assertThat(response.topRanks()).hasSize(2);
-
- // 1등: 소프트웨어전공 (120분)
- DepartmentRankingEntryResponse rank1 = response.topRanks().get(0);
- assertThat(rank1.departmentName()).isEqualTo("소프트웨어전공");
- assertThat(rank1.totalMillis()).isEqualTo(7200000L);
- assertThat(rank1.rank()).isEqualTo(1L);
-
- // 2등: 컴퓨터공학전공 (90분)
- DepartmentRankingEntryResponse rank2 = response.topRanks().get(1);
- assertThat(rank2.departmentName()).isEqualTo("컴퓨터공학전공");
- assertThat(rank2.totalMillis()).isEqualTo(5400000L);
- assertThat(rank2.rank()).isEqualTo(2L);
-
- // 내 학과 랭킹 확인
- assertThat(response.myDepartmentRanking()).isNotNull();
- assertThat(response.myDepartmentRanking().departmentName()).isEqualTo("소프트웨어전공");
- assertThat(response.myDepartmentRanking().rank()).isEqualTo(1L);
- }
-
- @Test
- @DisplayName("완료된_랭킹에_없는_학과는_fallback_랭킹이_생성된다")
- void 완료된_랭킹에_없는_학과는_fallback_랭킹이_생성된다() {
- // Given - 어제 날짜로 소프트웨어전공만 학습
- LocalDate yesterday = LocalDate.now().minusDays(1);
- LocalDateTime yesterdayStart = yesterday.atTime(9, 0);
- LocalDateTime yesterdayEnd = yesterday.atTime(10, 0);
-
- createCompletedStudySession(softwareUser1, yesterdayStart, yesterdayEnd);
-
- // When - 전자공학과 사용자가 조회 (학습 기록 없음)
- DepartmentRankingResponse response = departmentRankService.getCompletedDailyDepartmentRanking(
- electronicUser1.getId(),
- yesterday.atStartOfDay()
- );
-
- // Then
- assertThat(response.topRanks()).hasSize(1);
- assertThat(response.topRanks().get(0).departmentName()).isEqualTo("소프트웨어전공");
-
- // 내 학과는 fallback으로 2등
- assertThat(response.myDepartmentRanking()).isNotNull();
- assertThat(response.myDepartmentRanking().departmentName()).isEqualTo("전자시스템전공");
- assertThat(response.myDepartmentRanking().rank()).isEqualTo(2L);
- assertThat(response.myDepartmentRanking().totalMillis()).isEqualTo(0L);
- }
-
- @Test
- @DisplayName("학습_기록이_없는_날에는_빈_결과와_fallback이_반환된다")
- void 학습_기록이_없는_날에는_빈_결과와_fallback이_반환된다() {
- // Given - 일주일 전 날짜 (학습 기록 없음)
- LocalDate pastDate = LocalDate.now().minusDays(7);
-
- // When
- DepartmentRankingResponse response = departmentRankService.getCompletedDailyDepartmentRanking(
- softwareUser1.getId(),
- pastDate.atStartOfDay()
- );
-
- // Then
- assertThat(response.topRanks()).isEmpty();
-
- // 내 학과는 fallback으로 1등
- assertThat(response.myDepartmentRanking()).isNotNull();
- assertThat(response.myDepartmentRanking().departmentName()).isEqualTo("소프트웨어전공");
- assertThat(response.myDepartmentRanking().rank()).isEqualTo(1L);
- assertThat(response.myDepartmentRanking().totalMillis()).isEqualTo(0L);
- }
-
- @Test
- @DisplayName("날짜_경계_시간의_학습_세션도_정확히_집계된다")
- void 날짜_경계_시간의_학습_세션도_정확히_집계된다() {
- // Given - 자정을 걸치는 학습 세션
- LocalDate yesterday = LocalDate.now().minusDays(1);
- LocalDateTime beforeMidnight = yesterday.atTime(23, 30); // 어제 23:30
- LocalDateTime afterMidnight = yesterday.plusDays(1).atTime(0, 30); // 오늘 00:30
-
- // 어제 23:30 ~ 오늘 00:30 학습 (총 60분, 어제 30분 + 오늘 30분)
- createCompletedStudySession(softwareUser1, beforeMidnight, afterMidnight);
-
- // When - 어제 날짜로 조회
- DepartmentRankingResponse response = departmentRankService.getCompletedDailyDepartmentRanking(
- softwareUser1.getId(),
- yesterday.atStartOfDay()
- );
-
- // Then - 어제 부분(30분)만 집계되어야 함
- assertThat(response.topRanks()).hasSize(1);
- DepartmentRankingEntryResponse rank = response.topRanks().get(0);
- assertThat(rank.departmentName()).isEqualTo("소프트웨어전공");
- // 어제 23:30 ~ 24:00 = 30분 = 1,800,000ms
- assertThat(rank.totalMillis()).isEqualTo(1800000L);
- }
-
- @Test
- @DisplayName("저장된_DepartmentRanking과_실시간_재계산이_병합된다")
- void 저장된_DepartmentRanking과_실시간_재계산이_병합된다() {
- // Given - 어제 날짜
- LocalDate yesterday = LocalDate.now().minusDays(1);
- LocalDateTime yesterdayStart = yesterday.atTime(9, 0);
- LocalDateTime yesterdayEnd = yesterday.atTime(11, 0); // 120분
-
- // 소프트웨어전공 학습 세션 생성 (120분)
- createCompletedStudySession(softwareUser1, yesterdayStart, yesterdayEnd);
-
- // 전자공학과는 학습 세션 없지만, 저장된 랭킹 데이터 생성
- // (실제로는 스케줄러가 생성하지만, 테스트를 위해 수동 생성)
- // 참고: 현재 쿼리는 실시간 재계산을 우선하므로 이 데이터는 무시될 수 있음
- DepartmentRanking electronicRanking = DepartmentRanking.builder()
- .department(Department.ELECTRONIC_SYSTEMS)
- .totalMillis(3600000L) // 60분
- .rank(2L)
- .rankingType(RankingType.DAILY)
- .calculatedAt(yesterday.atStartOfDay())
- .build();
- departmentRankingRepository.save(electronicRanking);
-
- // When
- DepartmentRankingResponse response = departmentRankService.getCompletedDailyDepartmentRanking(
- softwareUser1.getId(),
- yesterday.atStartOfDay()
- );
-
- // Then - 실시간 재계산이 우선되므로 소프트웨어전공만 나타남
- // (전자공학과는 user는 있지만 study_session이 없어서 0으로 계산되고,
- // 0 > 0 이므로 topRanks에 포함되지 않음)
- assertThat(response.topRanks()).hasSize(1);
- assertThat(response.topRanks().get(0).departmentName()).isEqualTo("소프트웨어전공");
- assertThat(response.topRanks().get(0).totalMillis()).isEqualTo(7200000L);
- }
- }
-
- @Nested
- @DisplayName("학과별_상위_30명_기준_집계")
- class DepartmentTop30Calculation {
-
- @Test
- @DisplayName("학과별로_상위_30명의_학습시간만_합산된다")
- void 학과별로_상위_30명의_학습시간만_합산된다() {
- // Given - 소프트웨어전공 35명 생성 (30명 제한 검증)
- LocalDateTime today = LocalDateTime.now().withHour(12).withMinute(0);
- LocalDateTime oneHourAgo = today.minusHours(1);
-
- // 35명의 사용자 생성 및 각각 다른 학습 시간 부여
- // 1등: 350분, 2등: 340분, ..., 30등: 60분, 31등: 50분, ..., 35등: 10분
- for (int i = 1; i <= 35; i++) {
- User user = createAndSaveUser("소프트웨어" + i, "sw" + i + "@kumoh.ac.kr", Department.SOFTWARE);
- LocalDateTime sessionStart = oneHourAgo;
- LocalDateTime sessionEnd = oneHourAgo.plusMinutes((36 - i) * 10);
- createCompletedStudySession(user, sessionStart, sessionEnd);
- }
-
- // When
- DepartmentRankingResponse response = departmentRankService.getCurrentDailyDepartmentRanking(softwareUser1.getId());
-
- // Then - 0ms 학과는 필터링되어 제외됨
- assertThat(response.topRanks()).hasSize(1);
-
- // 1등: 소프트웨어전공
- DepartmentRankingEntryResponse rank1 = response.topRanks().get(0);
- assertThat(rank1.departmentName()).isEqualTo("소프트웨어전공");
-
- // 상위 30명만 합산: (350 + 340 + 330 + ... + 70 + 60)분
- // 등차수열 합: (첫항 + 끝항) * 항수 / 2 = (350 + 60) * 30 / 2 = 6150분
- long expectedMillis = 6150L * 60 * 1000; // 6150분 = 369,000,000ms
- assertThat(rank1.totalMillis()).isEqualTo(expectedMillis);
-
- // 31~35등(50분, 40분, 30분, 20분, 10분)은 제외되어야 함
- // 만약 전체 합산이면: (350 + 340 + ... + 20 + 10) = 6300분 = 378,000,000ms
- // 따라서 실제 결과는 378,000,000ms보다 작아야 함
- assertThat(rank1.totalMillis()).isLessThan(378000000L);
- }
-
- @Test
- @DisplayName("학과별_30명_미만인_경우_전체_인원이_집계된다")
- void 학과별_30명_미만인_경우_전체_인원이_집계된다() {
- // Given - 소프트웨어전공 10명만 생성
- LocalDateTime today = LocalDateTime.now().withHour(12).withMinute(0);
- LocalDateTime oneHourAgo = today.minusHours(1);
-
- // 10명의 사용자 생성 (각각 100분, 90분, ..., 10분)
- for (int i = 1; i <= 10; i++) {
- User user = createAndSaveUser("소프트웨어소수" + i, "sw-small" + i + "@kumoh.ac.kr", Department.SOFTWARE);
- LocalDateTime sessionStart = oneHourAgo;
- LocalDateTime sessionEnd = oneHourAgo.plusMinutes((11 - i) * 10);
- createCompletedStudySession(user, sessionStart, sessionEnd);
- }
-
- // When
- DepartmentRankingResponse response = departmentRankService.getCurrentDailyDepartmentRanking(softwareUser1.getId());
-
- // Then - 0ms 학과는 필터링되어 제외됨
- assertThat(response.topRanks()).hasSize(1);
-
- // 1등: 소프트웨어전공
- DepartmentRankingEntryResponse rank1 = response.topRanks().get(0);
- assertThat(rank1.departmentName()).isEqualTo("소프트웨어전공");
-
- // 전체 10명 합산: (100 + 90 + ... + 20 + 10) = 550분
- long expectedMillis = 550L * 60 * 1000; // 550분 = 33,000,000ms
- assertThat(rank1.totalMillis()).isEqualTo(expectedMillis);
- }
-
- @Test
- @DisplayName("여러_학과가_각각_상위_30명_기준으로_집계된다")
- void 여러_학과가_각각_상위_30명_기준으로_집계된다() {
- // Given
- LocalDateTime today = LocalDateTime.now().withHour(12).withMinute(0);
- LocalDateTime oneHourAgo = today.minusHours(1);
-
- // 소프트웨어전공: 35명 (상위 30명만 집계)
- for (int i = 1; i <= 35; i++) {
- User user = createAndSaveUser("SW멀티" + i, "sw-multi" + i + "@kumoh.ac.kr", Department.SOFTWARE);
- createCompletedStudySession(user, oneHourAgo, oneHourAgo.plusMinutes(100)); // 각 100분
- }
-
- // 컴퓨터공학전공: 20명 (전체 집계)
- for (int i = 1; i <= 20; i++) {
- User user = createAndSaveUser("컴공멀티" + i, "ce-multi" + i + "@kumoh.ac.kr", Department.COMPUTER_ENGINEERING);
- createCompletedStudySession(user, oneHourAgo, oneHourAgo.plusMinutes(80)); // 각 80분
- }
-
- // When
- DepartmentRankingResponse response = departmentRankService.getCurrentDailyDepartmentRanking(softwareUser1.getId());
-
- // Then - 0ms 학과는 필터링되어 제외됨
- assertThat(response.topRanks()).hasSize(2);
-
- // 1등: 소프트웨어전공 (30명 * 100분 = 3000분)
- DepartmentRankingEntryResponse rank1 = response.topRanks().get(0);
- assertThat(rank1.departmentName()).isEqualTo("소프트웨어전공");
- assertThat(rank1.totalMillis()).isEqualTo(3000L * 60 * 1000); // 180,000,000ms
-
- // 2등: 컴퓨터공학전공 (20명 * 80분 = 1600분)
- DepartmentRankingEntryResponse rank2 = response.topRanks().get(1);
- assertThat(rank2.departmentName()).isEqualTo("컴퓨터공학전공");
- assertThat(rank2.totalMillis()).isEqualTo(1600L * 60 * 1000); // 96,000,000ms
- }
- }
-
- // 테스트 헬퍼 메서드들
- private User createAndSaveUser(String name, String email, Department department) {
- User user = User.builder()
- .name(name)
- .email(email)
- .department(department)
- .picture("profile.jpg")
- .role(UserRole.USER)
- .provider(com.gpt.geumpumtabackend.global.oauth.user.OAuth2Provider.GOOGLE)
- .providerId("test-provider-id-" + email)
- .build();
- return userRepository.save(user);
- }
-
- private void createCompletedStudySession(User user, LocalDateTime startTime, LocalDateTime endTime) {
- StudySession session = new StudySession();
- session.startStudySession(startTime, user);
- session.endStudySession(endTime);
- studySessionRepository.save(session);
- }
-}
\ No newline at end of file
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..0c2f601
--- /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 today = LocalDateTime.now().withHour(10).withMinute(0);
+ LocalDateTime endTime = today.plusHours(2);
+
+ StudySession session = new StudySession();
+ session.startStudySession(today, 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 today = LocalDateTime.now().withHour(10).withMinute(0);
+ LocalDateTime endTime = today.plusHours(3);
+
+ StudySession otherSession = new StudySession();
+ otherSession.startStudySession(today, 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/integration/study/service/StudySessionIntegrationTest.java b/src/test/java/com/gpt/geumpumtabackend/integration/study/service/StudySessionIntegrationTest.java
deleted file mode 100644
index 16d7c0d..0000000
--- a/src/test/java/com/gpt/geumpumtabackend/integration/study/service/StudySessionIntegrationTest.java
+++ /dev/null
@@ -1,153 +0,0 @@
-package com.gpt.geumpumtabackend.integration.study.service;
-
-import com.gpt.geumpumtabackend.global.exception.BusinessException;
-import com.gpt.geumpumtabackend.integration.config.BaseIntegrationTest;
-import com.gpt.geumpumtabackend.study.dto.request.StudyStartRequest;
-import com.gpt.geumpumtabackend.study.dto.response.StudyStartResponse;
-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.domain.UserRole;
-import com.gpt.geumpumtabackend.user.repository.UserRepository;
-import com.gpt.geumpumtabackend.wifi.service.CampusWiFiValidationService;
-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 static org.assertj.core.api.Assertions.assertThat;
-import static org.assertj.core.api.Assertions.assertThatThrownBy;
-
-@DisplayName("StudySessionService 통합 테스트")
-class StudySessionIntegrationTest extends BaseIntegrationTest {
-
- @Autowired
- private StudySessionService studySessionService;
-
- @Autowired
- private UserRepository userRepository;
-
- @Autowired
- private CampusWiFiValidationService wifiValidationService;
-
- private User testUser;
-
- @BeforeEach
- void setUp() {
- // 테스트 사용자 생성
- testUser = createAndSaveUser("통합테스트사용자", "integration@kumoh.ac.kr", Department.SOFTWARE);
- }
-
- @Test
- @DisplayName("유효한_캠퍼스_네트워크에서_학습_세션이_시작된다")
- void 유효한_캠퍼스_네트워크에서_학습_세션이_시작된다() {
- // Given - application-test.yml에 설정된 유효한 캠퍼스 IP 사용
- // gateway: 172.30.64.1, ip-range: 172.30.64.0/18
- String gatewayIp = "172.30.64.1";
- String clientIp = "172.30.64.100";
- StudyStartRequest request = new StudyStartRequest(gatewayIp, clientIp);
-
- // When - 학습 세션 시작 (실제 WiFi 검증 통과)
- StudyStartResponse response1 = studySessionService.startStudySession(request, testUser.getId());
-
- // Then - 학습 세션이 성공적으로 시작됨
- assertThat(response1).isNotNull();
- assertThat(response1.studySessionId()).isNotNull();
-
- // When - 두 번째 호출도 정상 동작
- StudyStartResponse response2 = studySessionService.startStudySession(request, testUser.getId());
-
- // Then - 새로운 학습 세션이 생성됨
- assertThat(response2).isNotNull();
- assertThat(response2.studySessionId()).isNotNull();
- assertThat(response2.studySessionId()).isNotEqualTo(response1.studySessionId());
- }
-
- @Test
- @DisplayName("캠퍼스가_아닌_네트워크에서는_학습_세션_시작이_실패한다")
- void 캠퍼스가_아닌_네트워크에서는_학습_세션_시작이_실패한다() {
- // Given - 유효하지 않은 네트워크 IP
- String gatewayIp = "192.168.10.1";
- String clientIp = "192.168.10.100";
- StudyStartRequest request = new StudyStartRequest(gatewayIp, clientIp);
-
- // When & Then - WiFi 검증 실패로 예외 발생
- assertThatThrownBy(() -> studySessionService.startStudySession(request, testUser.getId()))
- .isInstanceOf(BusinessException.class);
- }
-
- @Test
- @DisplayName("유효한_게이트웨이이지만_클라이언트_IP가_범위를_벗어나면_실패한다")
- void 유효한_게이트웨이이지만_클라이언트_IP가_범위를_벗어나면_실패한다() {
- // Given - 유효한 게이트웨이, 하지만 IP 범위 밖의 클라이언트 IP
- String gatewayIp = "172.30.64.1";
- String clientIp = "192.168.1.100"; // 172.30.64.0/18 범위 밖
- StudyStartRequest request = new StudyStartRequest(gatewayIp, clientIp);
-
- // When & Then - 클라이언트 IP 검증 실패
- assertThatThrownBy(() -> studySessionService.startStudySession(request, testUser.getId()))
- .isInstanceOf(BusinessException.class);
- }
-
- @Test
- @DisplayName("WiFi_검증_결과가_Redis에_캐시된다")
- void WiFi_검증_결과가_Redis에_캐시된다() {
- // Given
- String gatewayIp = "172.30.64.1";
- String clientIp = "172.30.64.100";
-
- // When - 첫 번째 검증 (캐시 미스, 실제 검증 수행)
- var result1 = wifiValidationService.validateFromCache(gatewayIp, clientIp);
-
- // Then - 유효한 결과 반환
- assertThat(result1.isValid()).isTrue();
- assertThat(result1.getMessage()).isEqualTo("캠퍼스 네트워크입니다");
-
- // When - 두 번째 검증 (캐시 히트)
- var result2 = wifiValidationService.validateFromCache(gatewayIp, clientIp);
-
- // Then - 캐시된 결과 반환 (캐시 문구 포함)
- assertThat(result2.isValid()).isTrue();
- assertThat(result2.getMessage()).isEqualTo("캠퍼스 네트워크입니다 (캐시)");
- }
-
- @Test
- @DisplayName("IP_범위의_경계값도_정확히_검증된다")
- void IP_범위의_경계값도_정확히_검증된다() {
- // Given - 172.30.64.0/18 범위의 경계값 테스트
- String gatewayIp = "172.30.64.1";
-
- // 범위 내 첫 번째 IP
- String validIp1 = "172.30.64.1";
- // 범위 내 마지막 IP (172.30.127.254)
- String validIp2 = "172.30.127.254";
-
- // When & Then - 범위 내 IP는 통과
- var result1 = wifiValidationService.validateFromCache(gatewayIp, validIp1);
- assertThat(result1.isValid()).isTrue();
-
- var result2 = wifiValidationService.validateFromCache(gatewayIp, validIp2);
- assertThat(result2.isValid()).isTrue();
-
- // Given - 범위 밖 IP
- String invalidIp = "172.30.128.1"; // /18 범위 초과
-
- // When & Then - 범위 밖 IP는 실패
- var result3 = wifiValidationService.validateFromCache(gatewayIp, invalidIp);
- assertThat(result3.isValid()).isFalse();
- }
-
- // 헬퍼 메서드
- private User createAndSaveUser(String name, String email, Department department) {
- User user = User.builder()
- .name(name)
- .email(email)
- .department(department)
- .picture("profile.jpg")
- .role(UserRole.USER)
- .provider(com.gpt.geumpumtabackend.global.oauth.user.OAuth2Provider.GOOGLE)
- .providerId("test-provider-id-" + email)
- .build();
- return userRepository.save(user);
- }
-}
\ No newline at end of file
diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml
index 2131927..2cc092f 100644
--- a/src/test/resources/application-test.yml
+++ b/src/test/resources/application-test.yml
@@ -93,7 +93,7 @@ spring:
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
@@ -104,6 +104,11 @@ apple:
audience: dummy-audience
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:
From ad8f2c6f118f90b57dc43eba224704cd45b6646a Mon Sep 17 00:00:00 2001
From: Juhye0k
Date: Wed, 7 Jan 2026 16:29:08 +0900
Subject: [PATCH 029/135] =?UTF-8?q?chore=20:=20testcontainer=20=EC=97=90?=
=?UTF-8?q?=EB=9F=AC=20=ED=95=B4=EA=B2=B0=204?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/test/resources/application-unit-test.yml | 79 +++++++++++++++++++-
src/test/resources/testcontainers.properties | 3 -
2 files changed, 76 insertions(+), 6 deletions(-)
delete mode 100644 src/test/resources/testcontainers.properties
diff --git a/src/test/resources/application-unit-test.yml b/src/test/resources/application-unit-test.yml
index 95bff0e..a094d13 100644
--- a/src/test/resources/application-unit-test.yml
+++ b/src/test/resources/application-unit-test.yml
@@ -21,6 +21,25 @@ spring:
- 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
@@ -30,13 +49,67 @@ spring:
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
+ 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
+campus:
+ wifi:
+ networks: []
\ No newline at end of file
diff --git a/src/test/resources/testcontainers.properties b/src/test/resources/testcontainers.properties
deleted file mode 100644
index 48b7275..0000000
--- a/src/test/resources/testcontainers.properties
+++ /dev/null
@@ -1,3 +0,0 @@
-# Enable container reuse for faster local development
-# CI environments will ignore this setting
-testcontainers.reuse.enable=true
From 6988dbd764b3f1af7dab064436f90966043f08cd Mon Sep 17 00:00:00 2001
From: Juhye0k
Date: Wed, 7 Jan 2026 18:19:53 +0900
Subject: [PATCH 030/135] =?UTF-8?q?chore=20:=20testcontainer=20=EC=97=90?=
=?UTF-8?q?=EB=9F=AC=20=ED=95=B4=EA=B2=B0=20=EC=8B=9C=EB=8F=84=205?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.github/workflows/dev-ci.yml | 6 ---
.../config/BaseIntegrationTest.java | 38 ++++++++++++++-----
src/test/resources/application-test.yml | 9 +++--
3 files changed, 33 insertions(+), 20 deletions(-)
diff --git a/.github/workflows/dev-ci.yml b/.github/workflows/dev-ci.yml
index ce95510..bbe4596 100644
--- a/.github/workflows/dev-ci.yml
+++ b/.github/workflows/dev-ci.yml
@@ -51,12 +51,6 @@ jobs:
docker pull mysql:8.0
docker pull redis:7.0-alpine
- # TestContainers 설정 파일 생성 (컨테이너 재사용 활성화)
- - name: Setup TestContainers configuration
- run: |
- mkdir -p ~/.testcontainers
- echo "testcontainers.reuse.enable=true" > ~/.testcontainers.properties
-
- name: Run tests
run: ./gradlew clean test --no-daemon --info
diff --git a/src/test/java/com/gpt/geumpumtabackend/integration/config/BaseIntegrationTest.java b/src/test/java/com/gpt/geumpumtabackend/integration/config/BaseIntegrationTest.java
index 0f0ed4d..5cb784c 100644
--- a/src/test/java/com/gpt/geumpumtabackend/integration/config/BaseIntegrationTest.java
+++ b/src/test/java/com/gpt/geumpumtabackend/integration/config/BaseIntegrationTest.java
@@ -43,15 +43,23 @@ public abstract class BaseIntegrationTest {
.withDatabaseName("test_geumpumta")
.withUsername("test")
.withPassword("test")
- .withCommand("--default-authentication-plugin=mysql_native_password")
- .withStartupTimeout(Duration.ofSeconds(90))
- .withReuse(true);
+ .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(60))
- .withReuse(true);
+ .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) {
@@ -82,10 +90,14 @@ static void configureProperties(DynamicPropertyRegistry registry) {
private RedisTemplate redisTemplate;
@AfterEach
- @Transactional
void cleanUp() {
- truncateAllTables();
- cleanRedisCache();
+ try {
+ truncateAllTables();
+ cleanRedisCache();
+ } catch (Exception e) {
+ // 테스트 실패 시 cleanup도 실패할 수 있으므로 무시
+ System.err.println("Cleanup failed, but continuing: " + e.getMessage());
+ }
}
private void truncateAllTables() {
@@ -134,8 +146,14 @@ private void truncateAllTables() {
private void cleanRedisCache() {
// Redis의 모든 캐시 데이터 삭제 (Connection을 try-with-resources로 자동 close)
- try (var connection = redisTemplate.getConnectionFactory().getConnection()) {
- connection.serverCommands().flushAll();
+ 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/resources/application-test.yml b/src/test/resources/application-test.yml
index 2cc092f..b269c9c 100644
--- a/src/test/resources/application-test.yml
+++ b/src/test/resources/application-test.yml
@@ -9,12 +9,13 @@ spring:
hikari:
maximum-pool-size: 10
minimum-idle: 2
- connection-timeout: 30000
- idle-timeout: 600000
- max-lifetime: 1800000
+ connection-timeout: 10000 # 10초로 단축
+ idle-timeout: 300000 # 5분으로 단축
+ max-lifetime: 600000 # 10분으로 단축
connection-test-query: SELECT 1
- validation-timeout: 5000
+ validation-timeout: 3000 # 3초로 단축
leak-detection-threshold: 60000
+ initialization-fail-timeout: 10000 # 초기화 실패 타임아웃 10초
mail:
host: dummy-naver.com #smtp 서버 주소
From 199f3bf1a41aced01eb29d394d5e50992bbfa741 Mon Sep 17 00:00:00 2001
From: Juhye0k
Date: Wed, 7 Jan 2026 18:28:24 +0900
Subject: [PATCH 031/135] =?UTF-8?q?chore=20:=20testcontainer=20=EC=97=90?=
=?UTF-8?q?=EB=9F=AC=20=ED=95=B4=EA=B2=B0=20=EC=8B=9C=EB=8F=84=206?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../study/controller/StudySessionControllerIntegrationTest.java | 1 +
1 file changed, 1 insertion(+)
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
index 0c2f601..e07407c 100644
--- a/src/test/java/com/gpt/geumpumtabackend/integration/study/controller/StudySessionControllerIntegrationTest.java
+++ b/src/test/java/com/gpt/geumpumtabackend/integration/study/controller/StudySessionControllerIntegrationTest.java
@@ -34,6 +34,7 @@
@DisplayName("StudySession Controller 통합 테스트")
@AutoConfigureMockMvc
+@org.springframework.test.annotation.DirtiesContext(classMode = org.springframework.test.annotation.DirtiesContext.ClassMode.AFTER_CLASS)
class StudySessionControllerIntegrationTest extends BaseIntegrationTest {
@Autowired
From 70e71d7582a6af729ee523199619925db0010106 Mon Sep 17 00:00:00 2001
From: Juhye0k
Date: Wed, 7 Jan 2026 18:35:16 +0900
Subject: [PATCH 032/135] =?UTF-8?q?chore=20:=20testcontainer=20=EC=97=90?=
=?UTF-8?q?=EB=9F=AC=20=ED=95=B4=EA=B2=B0=20=EC=8B=9C=EB=8F=84=207?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../rank/controller/DepartmentRankControllerIntegrationTest.java | 1 +
1 file changed, 1 insertion(+)
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
index a81bf5d..1fe6407 100644
--- a/src/test/java/com/gpt/geumpumtabackend/integration/rank/controller/DepartmentRankControllerIntegrationTest.java
+++ b/src/test/java/com/gpt/geumpumtabackend/integration/rank/controller/DepartmentRankControllerIntegrationTest.java
@@ -31,6 +31,7 @@
@DisplayName("DepartmentRank Controller 통합 테스트")
@AutoConfigureMockMvc
+@org.springframework.test.annotation.DirtiesContext(classMode = org.springframework.test.annotation.DirtiesContext.ClassMode.AFTER_CLASS)
class DepartmentRankControllerIntegrationTest extends BaseIntegrationTest {
@Autowired
From 9bd556632258da062ac051b18fc4702dc0d4cf11 Mon Sep 17 00:00:00 2001
From: Juhye0k
Date: Wed, 7 Jan 2026 18:40:35 +0900
Subject: [PATCH 033/135] =?UTF-8?q?chore=20:=20testcontainer=20=EC=97=90?=
=?UTF-8?q?=EB=9F=AC=20=ED=95=B4=EA=B2=B0=20=EC=8B=9C=EB=8F=84=208?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../geumpumtabackend/integration/config/BaseIntegrationTest.java | 1 +
.../rank/controller/DepartmentRankControllerIntegrationTest.java | 1 -
.../study/controller/StudySessionControllerIntegrationTest.java | 1 -
3 files changed, 1 insertion(+), 2 deletions(-)
diff --git a/src/test/java/com/gpt/geumpumtabackend/integration/config/BaseIntegrationTest.java b/src/test/java/com/gpt/geumpumtabackend/integration/config/BaseIntegrationTest.java
index 5cb784c..ec1320f 100644
--- a/src/test/java/com/gpt/geumpumtabackend/integration/config/BaseIntegrationTest.java
+++ b/src/test/java/com/gpt/geumpumtabackend/integration/config/BaseIntegrationTest.java
@@ -36,6 +36,7 @@
)
@ActiveProfiles("test")
@Testcontainers
+@org.springframework.test.annotation.DirtiesContext(classMode = org.springframework.test.annotation.DirtiesContext.ClassMode.AFTER_CLASS)
public abstract class BaseIntegrationTest {
@Container
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
index 1fe6407..a81bf5d 100644
--- a/src/test/java/com/gpt/geumpumtabackend/integration/rank/controller/DepartmentRankControllerIntegrationTest.java
+++ b/src/test/java/com/gpt/geumpumtabackend/integration/rank/controller/DepartmentRankControllerIntegrationTest.java
@@ -31,7 +31,6 @@
@DisplayName("DepartmentRank Controller 통합 테스트")
@AutoConfigureMockMvc
-@org.springframework.test.annotation.DirtiesContext(classMode = org.springframework.test.annotation.DirtiesContext.ClassMode.AFTER_CLASS)
class DepartmentRankControllerIntegrationTest extends BaseIntegrationTest {
@Autowired
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
index e07407c..0c2f601 100644
--- a/src/test/java/com/gpt/geumpumtabackend/integration/study/controller/StudySessionControllerIntegrationTest.java
+++ b/src/test/java/com/gpt/geumpumtabackend/integration/study/controller/StudySessionControllerIntegrationTest.java
@@ -34,7 +34,6 @@
@DisplayName("StudySession Controller 통합 테스트")
@AutoConfigureMockMvc
-@org.springframework.test.annotation.DirtiesContext(classMode = org.springframework.test.annotation.DirtiesContext.ClassMode.AFTER_CLASS)
class StudySessionControllerIntegrationTest extends BaseIntegrationTest {
@Autowired
From 1b653e4fff172c5d4c03f822addbd94909f2677f Mon Sep 17 00:00:00 2001
From: Juhye0k
Date: Wed, 7 Jan 2026 18:56:45 +0900
Subject: [PATCH 034/135] =?UTF-8?q?chore=20:=20testcontainer=20=EC=97=90?=
=?UTF-8?q?=EB=9F=AC=20=ED=95=B4=EA=B2=B0=20=EC=8B=9C=EB=8F=84=209?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../StudySessionControllerIntegrationTest.java | 14 +++++++-------
1 file changed, 7 insertions(+), 7 deletions(-)
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
index 0c2f601..8b62150 100644
--- a/src/test/java/com/gpt/geumpumtabackend/integration/study/controller/StudySessionControllerIntegrationTest.java
+++ b/src/test/java/com/gpt/geumpumtabackend/integration/study/controller/StudySessionControllerIntegrationTest.java
@@ -171,12 +171,12 @@ class GetTodayStudySession {
@Test
@DisplayName("오늘의_공부_기록을_조회한다")
void 오늘의_공부_기록을_조회한다() throws Exception {
- // Given - 오늘 공부 기록 생성
- LocalDateTime today = LocalDateTime.now().withHour(10).withMinute(0);
- LocalDateTime endTime = today.plusHours(2);
+ // Given - 오늘 공부 기록 생성 (현재 시각 이전으로 설정)
+ LocalDateTime endTime = LocalDateTime.now().minusHours(1); // 1시간 전에 종료
+ LocalDateTime startTime = endTime.minusHours(2); // 3시간 전에 시작
StudySession session = new StudySession();
- session.startStudySession(today, testUser);
+ session.startStudySession(startTime, testUser);
session.endStudySession(endTime);
studySessionRepository.save(session);
@@ -217,11 +217,11 @@ class GetTodayStudySession {
.build();
otherUser = userRepository.save(otherUser);
- LocalDateTime today = LocalDateTime.now().withHour(10).withMinute(0);
- LocalDateTime endTime = today.plusHours(3);
+ LocalDateTime endTime = LocalDateTime.now().minusHours(1);
+ LocalDateTime startTime = endTime.minusHours(3);
StudySession otherSession = new StudySession();
- otherSession.startStudySession(today, otherUser);
+ otherSession.startStudySession(startTime, otherUser);
otherSession.endStudySession(endTime);
studySessionRepository.save(otherSession);
From c6915ec3af901fb5b9c99773b407659369528ecd Mon Sep 17 00:00:00 2001
From: Juhye0k
Date: Wed, 7 Jan 2026 19:07:54 +0900
Subject: [PATCH 035/135] =?UTF-8?q?chore=20:=20=EB=A9=94=EC=86=8C=EB=93=9C?=
=?UTF-8?q?=20=ED=95=9C=EA=B8=80=EB=A1=9C=20=ED=86=B5=EC=9D=BC?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../study/service/StudySessionServiceTest.java | 16 ++++------------
1 file changed, 4 insertions(+), 12 deletions(-)
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
index bc2b77d..9de3de0 100644
--- a/src/test/java/com/gpt/geumpumtabackend/unit/study/service/StudySessionServiceTest.java
+++ b/src/test/java/com/gpt/geumpumtabackend/unit/study/service/StudySessionServiceTest.java
@@ -52,7 +52,7 @@ class StartStudySession {
@Test
@DisplayName("Wi-Fi 검증 성공 시 세션이 정상 시작된다")
- void startStudySession_WiFi검증성공_세션시작성공() {
+ void 검증성공_세션시작성공() {
// Given
Long userId = 1L;
String gatewayIp = "192.168.1.1";
@@ -88,7 +88,7 @@ class StartStudySession {
@Test
@DisplayName("Wi-Fi 검증 실패(INVALID) 시 WIFI_NOT_CAMPUS_NETWORK 예외가 발생한다")
- void startStudySession_WiFi검증실패_INVALID_예외발생() {
+ void 검증실패_INVALID_예외발생() {
// Given
Long userId = 1L;
String gatewayIp = "192.168.10.1"; // 잘못된 게이트웨이
@@ -112,7 +112,7 @@ class StartStudySession {
@Test
@DisplayName("Wi-Fi 검증 에러(ERROR) 시 WIFI_VALIDATION_ERROR 예외가 발생한다")
- void startStudySession_WiFi검증에러_ERROR_예외발생() {
+ void 검증에러_ERROR_예외발생() {
// Given
Long userId = 1L;
String gatewayIp = "192.168.1.1";
@@ -132,7 +132,7 @@ class StartStudySession {
@Test
@DisplayName("존재하지 않는 사용자 ID로 세션 시작 시 USER_NOT_FOUND 예외가 발생한다")
- void startStudySession_존재하지않는사용자_예외발생() {
+ void 존재하지않는사용자_예외발생() {
// Given
Long userId = 999L;
String gatewayIp = "192.168.1.1";
@@ -277,12 +277,4 @@ private User createTestUser(Long id, String name, Department department) {
return user;
}
-
- private StudySession createTestStudySession(Long id, User user, LocalDateTime startTime) {
- StudySession session = new StudySession();
- session.startStudySession(startTime, user);
- // id는 실제로는 JPA가 설정하지만 테스트를 위해 reflection 사용하거나
- // 별도의 테스트용 생성자/메서드를 만들어 설정
- return session;
- }
}
\ No newline at end of file
From 456cb03b8051fed543fb87ec74c67b660790b151 Mon Sep 17 00:00:00 2001
From: Juhye0k
Date: Wed, 7 Jan 2026 20:32:49 +0900
Subject: [PATCH 036/135] =?UTF-8?q?chore=20:=20=EC=BD=94=EB=93=9C=EB=9E=98?=
=?UTF-8?q?=EB=B9=97=20=EB=B0=98=EC=98=81?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.github/workflows/prod-ci.yml | 6 -
.../controller/DepartmentRankController.java | 7 +-
.../geumpumtabackend/user/domain/User.java | 2 +-
.../service/DepartmentRankServiceTest.java | 370 +++++++++---------
.../rank/service/PersonalRankServiceTest.java | 2 +-
.../CampusWiFiValidationServiceTest.java | 183 ++++-----
src/test/resources/application-test.yml | 2 +-
7 files changed, 260 insertions(+), 312 deletions(-)
diff --git a/.github/workflows/prod-ci.yml b/.github/workflows/prod-ci.yml
index 6d4b658..cb55ffc 100644
--- a/.github/workflows/prod-ci.yml
+++ b/.github/workflows/prod-ci.yml
@@ -57,12 +57,6 @@ jobs:
docker pull mysql:8.0
docker pull redis:7.0-alpine
- # TestContainers 설정 파일 생성 (컨테이너 재사용 활성화)
- - name: Setup TestContainers configuration
- run: |
- mkdir -p ~/.testcontainers
- echo "testcontainers.reuse.enable=true" > ~/.testcontainers.properties
-
- name: Build with Gradle
run: ./gradlew clean build
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/user/domain/User.java b/src/main/java/com/gpt/geumpumtabackend/user/domain/User.java
index 6628d77..2244029 100644
--- a/src/main/java/com/gpt/geumpumtabackend/user/domain/User.java
+++ b/src/main/java/com/gpt/geumpumtabackend/user/domain/User.java
@@ -14,7 +14,7 @@
@NoArgsConstructor
@Getter
@SQLDelete(sql = """
- UPDATE user
+ UPDATE `user`
SET deleted_at = NOW(),
email = CONCAT('deleted_', email),
school_email= CONCAT('deleted_', school_email),
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
index b78d3ed..a9ddec6 100644
--- a/src/test/java/com/gpt/geumpumtabackend/unit/rank/service/DepartmentRankServiceTest.java
+++ b/src/test/java/com/gpt/geumpumtabackend/unit/rank/service/DepartmentRankServiceTest.java
@@ -104,222 +104,222 @@ class GetCurrentDailyDepartmentRanking {
verify(userRepository).findById(userId);
}
+ }
- @Nested
- @DisplayName("완료된 일간 학과 랭킹 조회")
- class GetCompletedDailyDepartmentRanking {
+ @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);
+ @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)
- );
+ 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);
+ given(userRepository.findById(userId)).willReturn(Optional.of(testUser));
+ given(departmentRankingRepository.getFinishedDepartmentRanking(startDay, RankingType.DAILY.name()))
+ .willReturn(mockDepartmentRankingData);
- // When
- DepartmentRankingResponse response = departmentRankService.getCompletedDailyDepartmentRanking(userId, startDay);
+ // 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("소프트웨어전공");
+ // 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());
- }
+ verify(departmentRankingRepository).getFinishedDepartmentRanking(startDay, RankingType.DAILY.name());
}
+ }
- @Nested
- @DisplayName("현재 주간 학과 랭킹 조회")
- class GetCurrentWeeklyDepartmentRanking {
+ @Nested
+ @DisplayName("현재 주간 학과 랭킹 조회")
+ class GetCurrentWeeklyDepartmentRanking {
- @Test
- @DisplayName("현재 주간 학과 랭킹 조회 시 월요일부터 일요일까지의 기간으로 계산된다")
- void getCurrentWeekly_정상조회_주간기간계산() {
- // Given
- Long userId = 1L;
- User testUser = createTestUser(userId, "김철수", Department.SOFTWARE);
+ @Test
+ @DisplayName("현재 주간 학과 랭킹 조회 시 월요일부터 일요일까지의 기간으로 계산된다")
+ void getCurrentWeekly_정상조회_주간기간계산() {
+ // Given
+ Long userId = 1L;
+ User testUser = createTestUser(userId, "김철수", Department.SOFTWARE);
- List mockDepartmentRankingData = List.of(
- createMockDepartmentRankingTemp("SOFTWARE", 100800000L, 1L)
- );
+ List mockDepartmentRankingData = List.of(
+ createMockDepartmentRankingTemp("SOFTWARE", 100800000L, 1L)
+ );
- given(userRepository.findById(userId)).willReturn(Optional.of(testUser));
- given(studySessionRepository.calculateCurrentDepartmentRanking(any(), any(), any()))
- .willReturn(mockDepartmentRankingData);
+ given(userRepository.findById(userId)).willReturn(Optional.of(testUser));
+ given(studySessionRepository.calculateCurrentDepartmentRanking(any(), any(), any()))
+ .willReturn(mockDepartmentRankingData);
- // When
- DepartmentRankingResponse response = departmentRankService.getCurrentWeeklyDepartmentRanking(userId);
+ // When
+ DepartmentRankingResponse response = departmentRankService.getCurrentWeeklyDepartmentRanking(userId);
- // Then
- assertThat(response).isNotNull();
- assertThat(response.topRanks()).hasSize(1);
- assertThat(response.myDepartmentRanking().departmentName()).isEqualTo("소프트웨어전공");
+ // Then
+ assertThat(response).isNotNull();
+ assertThat(response.topRanks()).hasSize(1);
+ assertThat(response.myDepartmentRanking().departmentName()).isEqualTo("소프트웨어전공");
- verify(studySessionRepository).calculateCurrentDepartmentRanking(any(), any(), any());
- }
+ verify(studySessionRepository).calculateCurrentDepartmentRanking(any(), any(), any());
}
+ }
- @Nested
- @DisplayName("현재 월간 학과 랭킹 조회")
- class GetCurrentMonthlyDepartmentRanking {
+ @Nested
+ @DisplayName("현재 월간 학과 랭킹 조회")
+ class GetCurrentMonthlyDepartmentRanking {
- @Test
- @DisplayName("현재 월간 학과 랭킹 조회 시 해당 월의 첫날부터 마지막날까지의 기간으로 계산된다")
- void getCurrentMonthly_정상조회_월간기간계산() {
- // Given
- Long userId = 1L;
- User testUser = createTestUser(userId, "김철수", Department.SOFTWARE);
+ @Test
+ @DisplayName("현재 월간 학과 랭킹 조회 시 해당 월의 첫날부터 마지막날까지의 기간으로 계산된다")
+ void getCurrentMonthly_정상조회_월간기간계산() {
+ // Given
+ Long userId = 1L;
+ User testUser = createTestUser(userId, "김철수", Department.SOFTWARE);
- List mockDepartmentRankingData = List.of(
- createMockDepartmentRankingTemp("SOFTWARE", 432000000L, 1L)
- );
+ List mockDepartmentRankingData = List.of(
+ createMockDepartmentRankingTemp("SOFTWARE", 432000000L, 1L)
+ );
- given(userRepository.findById(userId)).willReturn(Optional.of(testUser));
- given(studySessionRepository.calculateCurrentDepartmentRanking(any(), any(), any()))
- .willReturn(mockDepartmentRankingData);
+ given(userRepository.findById(userId)).willReturn(Optional.of(testUser));
+ given(studySessionRepository.calculateCurrentDepartmentRanking(any(), any(), any()))
+ .willReturn(mockDepartmentRankingData);
- // When
- DepartmentRankingResponse response = departmentRankService.getCurrentMonthlyDepartmentRanking(userId);
+ // When
+ DepartmentRankingResponse response = departmentRankService.getCurrentMonthlyDepartmentRanking(userId);
- // Then
- assertThat(response).isNotNull();
- assertThat(response.topRanks()).hasSize(1);
- assertThat(response.myDepartmentRanking().departmentName()).isEqualTo("소프트웨어전공");
+ // Then
+ assertThat(response).isNotNull();
+ assertThat(response.topRanks()).hasSize(1);
+ assertThat(response.myDepartmentRanking().departmentName()).isEqualTo("소프트웨어전공");
- verify(studySessionRepository).calculateCurrentDepartmentRanking(any(), any(), any());
- }
+ 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); // 정확히 매칭된 것만
- }
+ @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);
}
- // 테스트 데이터 생성 헬퍼 메서드
- 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;
+ @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);
}
- 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);
+ @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);
- // getDepartmentName만 모킹 (DepartmentRankingEntryResponse.of()에서 실제 사용됨)
- String koreanName = Department.valueOf(department).getKoreanName();
- given(mock.getDepartmentName()).willReturn(koreanName);
+ // Then
+ assertThat(response.myDepartmentRanking()).isNotNull();
+ assertThat(response.myDepartmentRanking().departmentName()).isEqualTo("소프트웨어전공");
+ assertThat(response.myDepartmentRanking().rank()).isEqualTo(1L); // 정확히 매칭된 것만
+ }
+ }
- return mock;
+ // 테스트 데이터 생성 헬퍼 메서드
+ 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;
}
-}
\ No newline at end of file
+}
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
index 048131e..b95484e 100644
--- a/src/test/java/com/gpt/geumpumtabackend/unit/rank/service/PersonalRankServiceTest.java
+++ b/src/test/java/com/gpt/geumpumtabackend/unit/rank/service/PersonalRankServiceTest.java
@@ -275,7 +275,7 @@ class RankingFallbackLogic {
// Given
Long notInRankingUserId = 999L;
- // 100명의 랭킹 데이터 생성
+ // 3명의 랭킹 데이터 생성
List largeRankings = List.of(
createMockPersonalRankingTemp(1L, "TOP1", "p1.jpg", "SOFTWARE", 1000000L, 1L),
createMockPersonalRankingTemp(50L, "MIDDLE", "p50.jpg", "COMPUTER_ENGINEERING", 500000L, 50L),
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
index 8b83202..97ee059 100644
--- a/src/test/java/com/gpt/geumpumtabackend/unit/wifi/service/CampusWiFiValidationServiceTest.java
+++ b/src/test/java/com/gpt/geumpumtabackend/unit/wifi/service/CampusWiFiValidationServiceTest.java
@@ -1,191 +1,144 @@
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.data.redis.core.RedisTemplate;
+import org.springframework.data.redis.core.ValueOperations;
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") // 단위테스트 프로필 사용 (Redis 비활성화)
-@DisplayName("CampusWiFiValidationService 단위 테스트 (Mock)")
+@ActiveProfiles("unit-test")
+@DisplayName("CampusWiFiValidationService 단위 테스트")
class CampusWiFiValidationServiceTest {
@Mock
+ private CampusWiFiProperties wifiProperties;
+
+ @Mock
+ private RedisTemplate redisTemplate;
+
+ @Mock
+ private ValueOperations valueOperations;
+
+ @InjectMocks
private CampusWiFiValidationService wifiValidationService;
@Nested
- @DisplayName("캐시에서 WiFi 검증 (Mock)")
+ @DisplayName("캐시에서 WiFi 검증")
class ValidateFromCache {
@Test
- @DisplayName("유효한_캠퍼스_네트워크_검증_성공")
- void validateFromCache_유효한캠퍼스네트워크_검증성공() {
+ @DisplayName("캐시에_값이_있으면_캐시_결과를_반환한다_VALID")
+ void 캐시있음_성공() {
// Given
String gatewayIp = "192.168.1.1";
String clientIp = "192.168.1.100";
- given(wifiValidationService.validateFromCache(gatewayIp, clientIp))
- .willReturn(WiFiValidationResult.valid("캠퍼스 네트워크입니다"));
-
- // When
- WiFiValidationResult result = wifiValidationService.validateFromCache(gatewayIp, clientIp);
-
- // Then
- assertThat(result).isNotNull();
- assertThat(result.isValid()).isTrue();
- assertThat(result.getStatus()).isEqualTo(WiFiValidationResult.ValidationStatus.VALID);
- assertThat(result.getMessage()).isEqualTo("캠퍼스 네트워크입니다");
+ String cacheKey = "campus_wifi_validation:" + gatewayIp + ":" + clientIp;
- verify(wifiValidationService).validateFromCache(gatewayIp, clientIp);
- }
-
- @Test
- @DisplayName("무효한_네트워크_검증_실패")
- void validateFromCache_무효한네트워크_검증실패() {
- // Given
- String gatewayIp = "192.168.10.1";
- String clientIp = "192.168.10.100";
- given(wifiValidationService.validateFromCache(gatewayIp, clientIp))
- .willReturn(WiFiValidationResult.invalid("캠퍼스 네트워크가 아닙니다"));
+ given(redisTemplate.opsForValue()).willReturn(valueOperations);
+ given(valueOperations.get(cacheKey)).willReturn("true");
// When
WiFiValidationResult result = wifiValidationService.validateFromCache(gatewayIp, clientIp);
// Then
- assertThat(result).isNotNull();
- assertThat(result.isValid()).isFalse();
- assertThat(result.getStatus()).isEqualTo(WiFiValidationResult.ValidationStatus.INVALID);
- assertThat(result.getMessage()).isEqualTo("캠퍼스 네트워크가 아닙니다");
+ assertThat(result.isValid()).isTrue();
+ assertThat(result.getMessage()).contains("(캐시)");
+ verify(valueOperations).get(cacheKey);
+ verify(wifiProperties, never()).networks();
}
@Test
- @DisplayName("검증_오류_시_에러_결과_반환")
- void validateFromCache_검증오류_에러결과반환() {
+ @DisplayName("캐시에_값이_없으면_실제_검증을_수행한다")
+ void 캐시없음_실제검증수행() {
// Given
- String gatewayIp = "error.test.ip";
+ String gatewayIp = "192.168.1.1";
String clientIp = "192.168.1.100";
- given(wifiValidationService.validateFromCache(gatewayIp, clientIp))
- .willReturn(WiFiValidationResult.error("Wi-Fi 검증 중 오류가 발생했습니다"));
+ String cacheKey = "campus_wifi_validation:" + gatewayIp + ":" + clientIp;
+
+ given(redisTemplate.opsForValue()).willReturn(valueOperations);
+ given(valueOperations.get(cacheKey)).willReturn(null);
+
+ // 실제 검증 로직을 위한 설정
+ CampusWiFiProperties.WiFiNetwork network = mock(CampusWiFiProperties.WiFiNetwork.class);
+ given(wifiProperties.networks()).willReturn(List.of(network));
+ given(wifiProperties.validation()).willReturn(new CampusWiFiProperties.ValidationConfig(5));
+ given(network.active()).willReturn(true);
+ given(network.isValidGatewayIP(gatewayIp)).willReturn(true);
+ given(network.isValidIP(clientIp)).willReturn(true);
// When
WiFiValidationResult result = wifiValidationService.validateFromCache(gatewayIp, clientIp);
// Then
- assertThat(result).isNotNull();
- assertThat(result.isValid()).isFalse();
- assertThat(result.getStatus()).isEqualTo(WiFiValidationResult.ValidationStatus.ERROR);
- assertThat(result.getMessage()).contains("Wi-Fi 검증 중 오류가 발생했습니다");
+ assertThat(result.isValid()).isTrue();
+ verify(valueOperations).set(eq(cacheKey), eq("true"), any());
}
}
@Nested
- @DisplayName("캠퍼스 WiFi 검증 (Mock)")
+ @DisplayName("캠퍼스 WiFi 검증 로직")
class ValidateCampusWiFi {
@Test
- @DisplayName("유효한_캠퍼스_네트워크_검증_성공")
- void validateCampusWiFi_유효한캠퍼스네트워크_검증성공() {
+ @DisplayName("유효한_네트워크_정보가_매칭되면_검증_성공")
+ void 매칭성공() {
// Given
String gatewayIp = "192.168.1.1";
String clientIp = "192.168.1.100";
- given(wifiValidationService.validateCampusWiFi(gatewayIp, clientIp))
- .willReturn(WiFiValidationResult.valid("캠퍼스 네트워크입니다"));
+
+ CampusWiFiProperties.WiFiNetwork network = mock(CampusWiFiProperties.WiFiNetwork.class);
+ given(wifiProperties.networks()).willReturn(List.of(network));
+ given(wifiProperties.validation()).willReturn(new CampusWiFiProperties.ValidationConfig(5));
+ given(redisTemplate.opsForValue()).willReturn(valueOperations);
+
+ 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).isNotNull();
assertThat(result.isValid()).isTrue();
- assertThat(result.getStatus()).isEqualTo(WiFiValidationResult.ValidationStatus.VALID);
assertThat(result.getMessage()).isEqualTo("캠퍼스 네트워크입니다");
-
- verify(wifiValidationService).validateCampusWiFi(gatewayIp, clientIp);
}
@Test
- @DisplayName("잘못된_게이트웨이_IP로_검증_실패")
- void validateCampusWiFi_잘못된게이트웨이IP_검증실패() {
+ @DisplayName("매칭되는_네트워크가_없으면_검증_실패")
+ void 매칭실패() {
// Given
- String gatewayIp = "192.168.10.1"; // 잘못된 게이트웨이
+ String gatewayIp = "192.168.1.1";
String clientIp = "192.168.1.100";
- given(wifiValidationService.validateCampusWiFi(gatewayIp, clientIp))
- .willReturn(WiFiValidationResult.invalid("캠퍼스 네트워크가 아닙니다"));
-
- // When
- WiFiValidationResult result = wifiValidationService.validateCampusWiFi(gatewayIp, clientIp);
-
- // Then
- assertThat(result).isNotNull();
- assertThat(result.isValid()).isFalse();
- assertThat(result.getStatus()).isEqualTo(WiFiValidationResult.ValidationStatus.INVALID);
- assertThat(result.getMessage()).isEqualTo("캠퍼스 네트워크가 아닙니다");
- }
+
+ CampusWiFiProperties.WiFiNetwork network = mock(CampusWiFiProperties.WiFiNetwork.class);
+ given(wifiProperties.networks()).willReturn(List.of(network));
+ given(wifiProperties.validation()).willReturn(new CampusWiFiProperties.ValidationConfig(5));
+ given(redisTemplate.opsForValue()).willReturn(valueOperations);
- @Test
- @DisplayName("검증_중_예외_발생시_에러_결과_반환")
- void validateCampusWiFi_예외발생_에러결과반환() {
- // Given
- String gatewayIp = "error.test.ip";
- String clientIp = "192.168.1.100";
- given(wifiValidationService.validateCampusWiFi(gatewayIp, clientIp))
- .willReturn(WiFiValidationResult.error("Wi-Fi 검증 중 오류가 발생했습니다"));
+ given(network.active()).willReturn(true);
+ given(network.isValidGatewayIP(gatewayIp)).willReturn(false); // 게이트웨이 불일치
// When
WiFiValidationResult result = wifiValidationService.validateCampusWiFi(gatewayIp, clientIp);
// Then
- assertThat(result).isNotNull();
assertThat(result.isValid()).isFalse();
- assertThat(result.getStatus()).isEqualTo(WiFiValidationResult.ValidationStatus.ERROR);
- assertThat(result.getMessage()).contains("Wi-Fi 검증 중 오류가 발생했습니다");
- }
- }
-
- @Nested
- @DisplayName("다양한_시나리오_테스트")
- class VariousScenarios {
-
- @Test
- @DisplayName("다양한_캠퍼스_IP_대역_검증")
- void 다양한_캠퍼스IP대역_검증() {
- // Given
- given(wifiValidationService.validateFromCache("192.168.1.1", "192.168.1.50"))
- .willReturn(WiFiValidationResult.valid("캠퍼스 네트워크입니다"));
- given(wifiValidationService.validateFromCache("172.30.64.1", "172.30.64.100"))
- .willReturn(WiFiValidationResult.valid("캠퍼스 네트워크입니다"));
-
- // When & Then
- WiFiValidationResult result1 = wifiValidationService.validateFromCache("192.168.1.1", "192.168.1.50");
- assertThat(result1.isValid()).isTrue();
-
- WiFiValidationResult result2 = wifiValidationService.validateFromCache("172.30.64.1", "172.30.64.100");
- assertThat(result2.isValid()).isTrue();
- }
-
- @Test
- @DisplayName("NULL_또는_빈_입력값_처리")
- void NULL_또는_빈입력값_처리() {
- // Given
- given(wifiValidationService.validateFromCache(isNull(), anyString()))
- .willReturn(WiFiValidationResult.error("잘못된 입력값입니다"));
- given(wifiValidationService.validateFromCache(eq(""), anyString()))
- .willReturn(WiFiValidationResult.error("잘못된 입력값입니다"));
-
- // When & Then
- WiFiValidationResult result1 = wifiValidationService.validateFromCache(null, "192.168.1.100");
- assertThat(result1.getStatus()).isEqualTo(WiFiValidationResult.ValidationStatus.ERROR);
-
- WiFiValidationResult result2 = wifiValidationService.validateFromCache("", "192.168.1.100");
- assertThat(result2.getStatus()).isEqualTo(WiFiValidationResult.ValidationStatus.ERROR);
+ assertThat(result.getMessage()).isEqualTo("캠퍼스 네트워크가 아닙니다");
}
}
-}
\ No newline at end of file
+}
diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml
index b269c9c..8b98d63 100644
--- a/src/test/resources/application-test.yml
+++ b/src/test/resources/application-test.yml
@@ -78,7 +78,7 @@ spring:
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
From 1f7a55ea2db0a298cb2b10c171928c77bf9566da Mon Sep 17 00:00:00 2001
From: juhyeok
Date: Wed, 7 Jan 2026 20:34:55 +0900
Subject: [PATCH 037/135] Update .github/workflows/dev-ci.yml
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
---
.github/workflows/dev-ci.yml | 1 +
1 file changed, 1 insertion(+)
diff --git a/.github/workflows/dev-ci.yml b/.github/workflows/dev-ci.yml
index bbe4596..140a35b 100644
--- a/.github/workflows/dev-ci.yml
+++ b/.github/workflows/dev-ci.yml
@@ -20,6 +20,7 @@ jobs:
test:
name: Test (Gradle + Testcontainers)
runs-on: ubuntu-latest
+ timeout-minutes: 30
steps:
- name: Checkout
From 60718c6892e0c39c7a45149497a581d7e439b565 Mon Sep 17 00:00:00 2001
From: Juhye0k
Date: Thu, 8 Jan 2026 13:24:19 +0900
Subject: [PATCH 038/135] =?UTF-8?q?chore=20:=20=ED=95=84=EC=9A=94=EC=97=86?=
=?UTF-8?q?=EB=8A=94=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20(12=EC=8B=9C=EA=B0=84?=
=?UTF-8?q?=20=EA=B3=B5=EB=B6=80=20=EC=8B=9C=EA=B0=84=20=EC=B8=A1=EC=A0=95?=
=?UTF-8?q?)=20=EC=82=AD=EC=A0=9C?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../study/service/StudySessionServiceTest.java | 18 ------------------
1 file changed, 18 deletions(-)
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
index 9de3de0..b5c2a2f 100644
--- a/src/test/java/com/gpt/geumpumtabackend/unit/study/service/StudySessionServiceTest.java
+++ b/src/test/java/com/gpt/geumpumtabackend/unit/study/service/StudySessionServiceTest.java
@@ -198,24 +198,6 @@ class StudySessionCalculation {
assertThat(session.getTotalMillis()).isEqualTo(1000L); // 1초 = 1000ms
}
- @Test
- @DisplayName("긴 세션(12시간)도 올바르게 계산된다")
- void 긴_세션도_올바르게_계산된다() {
- // Given
- LocalDateTime startTime = LocalDateTime.of(2024, 1, 1, 9, 0);
- LocalDateTime endTime = LocalDateTime.of(2024, 1, 1, 21, 0);
- User testUser = createTestUser(1L, "테스트사용자", Department.SOFTWARE);
-
- StudySession session = new StudySession();
-
- // When
- session.startStudySession(startTime, testUser);
- session.endStudySession(endTime);
-
- // Then
- assertThat(session.getTotalMillis()).isEqualTo(43200000L); // 12시간 = 12 * 60 * 60 * 1000ms
- }
-
@Test
@DisplayName("자정을 넘어가는 세션도 올바르게 계산된다")
void 자정을_넘어가는_세션도_올바르게_계산된다() {
From 28dbc4f7b3be5683c0327c793821a491e38bb05a Mon Sep 17 00:00:00 2001
From: Juhye0k
Date: Tue, 13 Jan 2026 17:26:30 +0900
Subject: [PATCH 039/135] =?UTF-8?q?refactor=20:=20=EB=84=A4=ED=8A=B8?=
=?UTF-8?q?=EC=9B=8C=ED=81=AC=20=EA=B2=80=EC=A6=9D=20Redis=20->=20?=
=?UTF-8?q?=EB=A1=9C=EC=BB=AC=20=EC=BA=90=EC=8B=9C=20=EC=A0=84=ED=99=98?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
build.gradle | 4 +
.../service/CampusWiFiValidationService.java | 71 +++--------
.../config/BaseIntegrationTest.java | 1 -
.../CampusWiFiValidationServiceTest.java | 119 +++++++++---------
4 files changed, 84 insertions(+), 111 deletions(-)
diff --git a/build.gradle b/build.gradle
index dd1a241..ea3586b 100644
--- a/build.gradle
+++ b/build.gradle
@@ -57,6 +57,10 @@ 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'
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/test/java/com/gpt/geumpumtabackend/integration/config/BaseIntegrationTest.java b/src/test/java/com/gpt/geumpumtabackend/integration/config/BaseIntegrationTest.java
index ec1320f..bd00577 100644
--- a/src/test/java/com/gpt/geumpumtabackend/integration/config/BaseIntegrationTest.java
+++ b/src/test/java/com/gpt/geumpumtabackend/integration/config/BaseIntegrationTest.java
@@ -96,7 +96,6 @@ void cleanUp() {
truncateAllTables();
cleanRedisCache();
} catch (Exception e) {
- // 테스트 실패 시 cleanup도 실패할 수 있으므로 무시
System.err.println("Cleanup failed, but continuing: " + e.getMessage());
}
}
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
index 97ee059..91d6c76 100644
--- a/src/test/java/com/gpt/geumpumtabackend/unit/wifi/service/CampusWiFiValidationServiceTest.java
+++ b/src/test/java/com/gpt/geumpumtabackend/unit/wifi/service/CampusWiFiValidationServiceTest.java
@@ -10,8 +10,6 @@
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
-import org.springframework.data.redis.core.RedisTemplate;
-import org.springframework.data.redis.core.ValueOperations;
import org.springframework.test.context.ActiveProfiles;
import java.util.List;
@@ -28,117 +26,124 @@ class CampusWiFiValidationServiceTest {
@Mock
private CampusWiFiProperties wifiProperties;
- @Mock
- private RedisTemplate redisTemplate;
-
- @Mock
- private ValueOperations valueOperations;
-
@InjectMocks
private CampusWiFiValidationService wifiValidationService;
@Nested
- @DisplayName("캐시에서 WiFi 검증")
- class ValidateFromCache {
+ @DisplayName("캠퍼스 WiFi 검증 로직")
+ class ValidateCampusWiFi {
@Test
- @DisplayName("캐시에_값이_있으면_캐시_결과를_반환한다_VALID")
- void 캐시있음_성공() {
+ @DisplayName("유효한_네트워크_정보가_매칭되면_검증_성공")
+ void 매칭성공() {
// Given
String gatewayIp = "192.168.1.1";
String clientIp = "192.168.1.100";
- String cacheKey = "campus_wifi_validation:" + gatewayIp + ":" + clientIp;
-
- given(redisTemplate.opsForValue()).willReturn(valueOperations);
- given(valueOperations.get(cacheKey)).willReturn("true");
+
+ 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.validateFromCache(gatewayIp, clientIp);
+ WiFiValidationResult result = wifiValidationService.validateCampusWiFi(gatewayIp, clientIp);
// Then
assertThat(result.isValid()).isTrue();
- assertThat(result.getMessage()).contains("(캐시)");
- verify(valueOperations).get(cacheKey);
- verify(wifiProperties, never()).networks();
+ assertThat(result.getMessage()).isEqualTo("캠퍼스 네트워크입니다");
}
@Test
- @DisplayName("캐시에_값이_없으면_실제_검증을_수행한다")
- void 캐시없음_실제검증수행() {
+ @DisplayName("매칭되는_네트워크가_없으면_검증_실패")
+ void 매칭실패() {
// Given
String gatewayIp = "192.168.1.1";
String clientIp = "192.168.1.100";
- String cacheKey = "campus_wifi_validation:" + gatewayIp + ":" + clientIp;
- given(redisTemplate.opsForValue()).willReturn(valueOperations);
- given(valueOperations.get(cacheKey)).willReturn(null);
-
- // 실제 검증 로직을 위한 설정
CampusWiFiProperties.WiFiNetwork network = mock(CampusWiFiProperties.WiFiNetwork.class);
given(wifiProperties.networks()).willReturn(List.of(network));
- given(wifiProperties.validation()).willReturn(new CampusWiFiProperties.ValidationConfig(5));
+
given(network.active()).willReturn(true);
- given(network.isValidGatewayIP(gatewayIp)).willReturn(true);
- given(network.isValidIP(clientIp)).willReturn(true);
+ given(network.isValidGatewayIP(gatewayIp)).willReturn(false); // 게이트웨이 불일치
// When
- WiFiValidationResult result = wifiValidationService.validateFromCache(gatewayIp, clientIp);
+ WiFiValidationResult result = wifiValidationService.validateCampusWiFi(gatewayIp, clientIp);
// Then
- assertThat(result.isValid()).isTrue();
- verify(valueOperations).set(eq(cacheKey), eq("true"), any());
+ assertThat(result.isValid()).isFalse();
+ assertThat(result.getMessage()).isEqualTo("캠퍼스 네트워크가 아닙니다");
}
- }
-
- @Nested
- @DisplayName("캠퍼스 WiFi 검증 로직")
- class ValidateCampusWiFi {
@Test
- @DisplayName("유효한_네트워크_정보가_매칭되면_검증_성공")
- void 매칭성공() {
+ @DisplayName("게이트웨이_IP는_매칭되지만_클라이언트_IP_범위가_다르면_검증_실패")
+ void 게이트웨이매칭_클라이언트불일치() {
// Given
String gatewayIp = "192.168.1.1";
- String clientIp = "192.168.1.100";
-
+ String clientIp = "192.168.2.100"; // 다른 대역
+
CampusWiFiProperties.WiFiNetwork network = mock(CampusWiFiProperties.WiFiNetwork.class);
given(wifiProperties.networks()).willReturn(List.of(network));
- given(wifiProperties.validation()).willReturn(new CampusWiFiProperties.ValidationConfig(5));
- given(redisTemplate.opsForValue()).willReturn(valueOperations);
given(network.active()).willReturn(true);
given(network.isValidGatewayIP(gatewayIp)).willReturn(true);
- given(network.isValidIP(clientIp)).willReturn(true);
+ given(network.isValidIP(clientIp)).willReturn(false); // IP 범위 불일치
// When
WiFiValidationResult result = wifiValidationService.validateCampusWiFi(gatewayIp, clientIp);
// Then
- assertThat(result.isValid()).isTrue();
- assertThat(result.getMessage()).isEqualTo("캠퍼스 네트워크입니다");
+ assertThat(result.isValid()).isFalse();
+ assertThat(result.getMessage()).isEqualTo("캠퍼스 네트워크가 아닙니다");
}
@Test
- @DisplayName("매칭되는_네트워크가_없으면_검증_실패")
- void 매칭실패() {
+ @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(wifiProperties.validation()).willReturn(new CampusWiFiProperties.ValidationConfig(5));
- given(redisTemplate.opsForValue()).willReturn(valueOperations);
- given(network.active()).willReturn(true);
- given(network.isValidGatewayIP(gatewayIp)).willReturn(false); // 게이트웨이 불일치
+ 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();
- assertThat(result.getMessage()).isEqualTo("캠퍼스 네트워크가 아닙니다");
+ 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("캠퍼스 네트워크입니다");
}
}
}
From 24c947120f945da5c8f0387ccdd813d8fe18c897 Mon Sep 17 00:00:00 2001
From: Juhye0k
Date: Tue, 13 Jan 2026 17:26:44 +0900
Subject: [PATCH 040/135] =?UTF-8?q?refactor=20:=20=EA=B3=B5=EB=B6=80=20?=
=?UTF-8?q?=EC=84=B8=EC=85=98=201=EA=B0=9C=20=EC=9C=A0=EC=A7=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../global/exception/ExceptionType.java | 1 +
.../repository/StudySessionRepository.java | 2 +
.../study/service/StudySessionService.java | 46 +++++++++++--------
3 files changed, 31 insertions(+), 18 deletions(-)
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..2484c3e 100644
--- a/src/main/java/com/gpt/geumpumtabackend/global/exception/ExceptionType.java
+++ b/src/main/java/com/gpt/geumpumtabackend/global/exception/ExceptionType.java
@@ -40,6 +40,7 @@ public enum ExceptionType {
// Study
STUDY_SESSION_NOT_FOUND(NOT_FOUND,"ST001","해당 공부 세션을 찾을 수 없습니다."),
+ ALREADY_STUDY_SESSION(CONFLICT, "ST002", "세션은 하나만 가능합니다."),
// WiFi
WIFI_NOT_CAMPUS_NETWORK(FORBIDDEN, "W001", "캠퍼스 네트워크가 아닙니다"),
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 c1d351d..9e9de07 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,7 @@
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.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
@@ -21,6 +22,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) " +
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 88828d4..e822085 100644
--- a/src/main/java/com/gpt/geumpumtabackend/study/service/StudySessionService.java
+++ b/src/main/java/com/gpt/geumpumtabackend/study/service/StudySessionService.java
@@ -2,6 +2,7 @@
import com.gpt.geumpumtabackend.global.exception.BusinessException;
import com.gpt.geumpumtabackend.global.exception.ExceptionType;
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.dto.response.StudySessionResponse;
@@ -42,24 +43,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);
}
@@ -81,4 +66,29 @@ private BusinessException mapWiFiValidationException(WiFiValidationResult result
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("Wi-Fi validation failed for user {}: {}", userId, validationResult.getMessage());
+ throw mapWiFiValidationException(validationResult);
+ }
+ }
+ public StudySession makeStudySession(Long userId){
+ // 현재 진행 중인 세션(STARTED 상태)만 체크
+ studySessionRepository.findByUser_IdAndStatus(userId, StudyStatus.STARTED)
+ .ifPresent(session -> {
+ throw new BusinessException(ExceptionType.ALREADY_STUDY_SESSION);
+ });
+
+ 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);
+ }
}
From 08eb38b2e6ca543abc377de5c40b87f81f113206 Mon Sep 17 00:00:00 2001
From: Juhye0k
Date: Tue, 13 Jan 2026 18:33:21 +0900
Subject: [PATCH 041/135] =?UTF-8?q?chore=20:=20=ED=85=8C=EC=8A=A4=ED=8A=B8?=
=?UTF-8?q?=20=EB=A9=94=EC=86=8C=EB=93=9C=20=EC=98=AC=EB=B0=94=EB=A5=B4?=
=?UTF-8?q?=EA=B2=8C=20=EC=84=A4=EC=A0=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../unit/config/TestWiFiMockConfig.java | 14 ++++++------
.../service/StudySessionServiceTest.java | 22 +++++++++----------
2 files changed, 18 insertions(+), 18 deletions(-)
diff --git a/src/test/java/com/gpt/geumpumtabackend/unit/config/TestWiFiMockConfig.java b/src/test/java/com/gpt/geumpumtabackend/unit/config/TestWiFiMockConfig.java
index d1530b9..656733e 100644
--- a/src/test/java/com/gpt/geumpumtabackend/unit/config/TestWiFiMockConfig.java
+++ b/src/test/java/com/gpt/geumpumtabackend/unit/config/TestWiFiMockConfig.java
@@ -17,19 +17,19 @@ public class TestWiFiMockConfig {
@Primary
public CampusWiFiValidationService mockWiFiValidationService() {
CampusWiFiValidationService mock = mock(CampusWiFiValidationService.class);
-
+
// 기본적으로 캠퍼스 네트워크로 인식하도록 설정 (192.168.1.x)
- when(mock.validateFromCache("192.168.1.1", anyString()))
+ when(mock.validateCampusWiFi("192.168.1.1", anyString()))
.thenReturn(WiFiValidationResult.valid("캠퍼스 네트워크입니다 (Mock)"));
-
+
// 캠퍼스가 아닌 네트워크 (192.168.10.x)
- when(mock.validateFromCache("192.168.10.1", anyString()))
+ when(mock.validateCampusWiFi("192.168.10.1", anyString()))
.thenReturn(WiFiValidationResult.invalid("캠퍼스 네트워크가 아닙니다 (Mock)"));
-
+
// 에러 시뮬레이션용 (특정 IP에서 에러 발생)
- when(mock.validateFromCache("error.test.ip", anyString()))
+ 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/study/service/StudySessionServiceTest.java b/src/test/java/com/gpt/geumpumtabackend/unit/study/service/StudySessionServiceTest.java
index b5c2a2f..f1676b3 100644
--- a/src/test/java/com/gpt/geumpumtabackend/unit/study/service/StudySessionServiceTest.java
+++ b/src/test/java/com/gpt/geumpumtabackend/unit/study/service/StudySessionServiceTest.java
@@ -67,7 +67,7 @@ class StartStudySession {
given(mockSession.getId()).willReturn(100L);
// Mock 설정
- given(wifiValidationService.validateFromCache(gatewayIp, clientIp))
+ given(wifiValidationService.validateCampusWiFi(gatewayIp, clientIp))
.willReturn(WiFiValidationResult.valid("캠퍼스 네트워크입니다"));
given(userRepository.findById(userId))
.willReturn(Optional.of(testUser));
@@ -80,8 +80,8 @@ class StartStudySession {
// Then
assertThat(response).isNotNull();
assertThat(response.studySessionId()).isEqualTo(100L);
-
- verify(wifiValidationService).validateFromCache(gatewayIp, clientIp);
+
+ verify(wifiValidationService).validateCampusWiFi(gatewayIp, clientIp);
verify(userRepository).findById(userId);
verify(studySessionRepository).save(any(StudySession.class));
}
@@ -96,16 +96,16 @@ class StartStudySession {
LocalDateTime startTime = LocalDateTime.now();
StudyStartRequest request = new StudyStartRequest(gatewayIp, clientIp);
-
- given(wifiValidationService.validateFromCache(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).validateFromCache(gatewayIp, clientIp);
+
+ verify(wifiValidationService).validateCampusWiFi(gatewayIp, clientIp);
verify(userRepository, never()).findById(anyLong());
verify(studySessionRepository, never()).save(any());
}
@@ -120,8 +120,8 @@ class StartStudySession {
LocalDateTime startTime = LocalDateTime.now();
StudyStartRequest request = new StudyStartRequest(gatewayIp, clientIp);
-
- given(wifiValidationService.validateFromCache(gatewayIp, clientIp))
+
+ given(wifiValidationService.validateCampusWiFi(gatewayIp, clientIp))
.willReturn(WiFiValidationResult.error("Redis 연결 실패"));
// When & Then
@@ -140,8 +140,8 @@ class StartStudySession {
LocalDateTime startTime = LocalDateTime.now();
StudyStartRequest request = new StudyStartRequest(gatewayIp, clientIp);
-
- given(wifiValidationService.validateFromCache(gatewayIp, clientIp))
+
+ given(wifiValidationService.validateCampusWiFi(gatewayIp, clientIp))
.willReturn(WiFiValidationResult.valid("캠퍼스 네트워크입니다"));
given(userRepository.findById(userId))
.willReturn(Optional.empty());
From 4b47e918551e41864fcd444b5d3e43fce2b76ae7 Mon Sep 17 00:00:00 2001
From: Juhye0k
Date: Tue, 13 Jan 2026 22:32:15 +0900
Subject: [PATCH 042/135] =?UTF-8?q?refactor=20:=20=EA=B3=B5=EB=B6=80=20?=
=?UTF-8?q?=EC=A2=85=EB=A3=8C=EC=8B=9C=EA=B0=84=20=EC=8B=9C=EC=9E=91?=
=?UTF-8?q?=EC=8B=9C=EA=B0=84=EB=B3=B4=EB=8B=A4=20=EC=9E=91=EC=A7=80=20?=
=?UTF-8?q?=EC=95=8A=EA=B2=8C=20=EC=98=88=EC=99=B8=20=EC=84=A4=EC=A0=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../gpt/geumpumtabackend/global/exception/ExceptionType.java | 1 +
.../com/gpt/geumpumtabackend/study/domain/StudySession.java | 4 ++++
2 files changed, 5 insertions(+)
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 a52e70a..6ee242d 100644
--- a/src/main/java/com/gpt/geumpumtabackend/global/exception/ExceptionType.java
+++ b/src/main/java/com/gpt/geumpumtabackend/global/exception/ExceptionType.java
@@ -38,6 +38,7 @@ public enum ExceptionType {
// Study
STUDY_SESSION_NOT_FOUND(NOT_FOUND,"ST001","해당 공부 세션을 찾을 수 없습니다."),
+ INVALID_END_TIME(CONFLICT,"ST002","유효하지 않은 종료시간입니다."),
// WiFi
WIFI_NOT_CAMPUS_NETWORK(FORBIDDEN, "W001", "캠퍼스 네트워크가 아닙니다"),
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..74e87d3 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;
@@ -42,6 +44,8 @@ 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();
From 21814a72ff2230115cdcc4454e954d2f074cb5b2 Mon Sep 17 00:00:00 2001
From: kon28289
Date: Fri, 16 Jan 2026 15:22:11 +0900
Subject: [PATCH 043/135] =?UTF-8?q?refactor=20:=20=ED=86=B5=EA=B3=84=20?=
=?UTF-8?q?=EC=BD=94=EB=93=9C=20=EB=B6=84=EB=A6=AC?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../repository/StatisticsRepository.java | 320 ++++++++++++++++++
.../repository/StudySessionRepository.java | 307 -----------------
2 files changed, 320 insertions(+), 307 deletions(-)
create mode 100644 src/main/java/com/gpt/geumpumtabackend/statistics/repository/StatisticsRepository.java
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..db2bbbf
--- /dev/null
+++ b/src/main/java/com/gpt/geumpumtabackend/statistics/repository/StatisticsRepository.java
@@ -0,0 +1,320 @@
+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
+ ),
+ /* 월 전체 일자를 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(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 (
+ /* 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(: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_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/study/repository/StudySessionRepository.java b/src/main/java/com/gpt/geumpumtabackend/study/repository/StudySessionRepository.java
index 9e9de07..5a883d7 100644
--- a/src/main/java/com/gpt/geumpumtabackend/study/repository/StudySessionRepository.java
+++ b/src/main/java/com/gpt/geumpumtabackend/study/repository/StudySessionRepository.java
@@ -225,311 +225,4 @@ 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
- );
}
From 44dae601032fcfbc317bad1c5f7348271186d386 Mon Sep 17 00:00:00 2001
From: kon28289
Date: Fri, 16 Jan 2026 15:23:03 +0900
Subject: [PATCH 044/135] =?UTF-8?q?chore:=20=EC=8B=9C=EA=B0=84=20=EB=8B=A8?=
=?UTF-8?q?=EC=9C=84=EB=A5=BC=20ms=EB=A1=9C=20=EB=B3=80=EA=B2=BD=ED=95=98?=
=?UTF-8?q?=EC=97=AC=20dto=20=EB=AA=85=20=EB=B3=80=EA=B2=BD?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../statistics/dto/DayMaxFocusAndFullTimeStatistics.java | 6 +++---
.../geumpumtabackend/statistics/dto/MonthlyStatistics.java | 6 +++---
.../statistics/dto/TwoHourSlotStatistics.java | 2 +-
.../geumpumtabackend/statistics/dto/WeeklyStatistics.java | 4 ++--
4 files changed, 9 insertions(+), 9 deletions(-)
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), 소수점 버림
}
From 23cc9380fc7fa27da03c4cd3d8df85c76cb82c52 Mon Sep 17 00:00:00 2001
From: kon28289
Date: Fri, 16 Jan 2026 15:23:37 +0900
Subject: [PATCH 045/135] =?UTF-8?q?chore:=20=EC=9D=98=EC=A1=B4=ED=95=98?=
=?UTF-8?q?=EB=8A=94=20repository=20=EB=B3=80=EA=B2=BD?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../statistics/service/StatisticsService.java | 14 +++++++-------
1 file changed, 7 insertions(+), 7 deletions(-)
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);
}
}
From 18b2c66aa34eb75899e36be6d05fcb5fd6ea0179 Mon Sep 17 00:00:00 2001
From: kon28289
Date: Sat, 17 Jan 2026 12:03:53 +0900
Subject: [PATCH 046/135] =?UTF-8?q?test:=20userService=20=EB=8B=A8?=
=?UTF-8?q?=EC=9C=84=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=9E=91=EC=84=B1?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../unit/user/service/UserServiceTest.java | 439 ++++++++++++++++++
1 file changed, 439 insertions(+)
create mode 100644 src/test/java/com/gpt/geumpumtabackend/unit/user/service/UserServiceTest.java
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..9f0c561
--- /dev/null
+++ b/src/test/java/com/gpt/geumpumtabackend/unit/user/service/UserServiceTest.java
@@ -0,0 +1,439 @@
+package com.gpt.geumpumtabackend.unit.user.service;
+
+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.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;
+
+ @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();
+
+ 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);
+
+ // When
+ TokenResponse response = userService.completeRegistration(request, userId);
+
+ // Then
+ assertThat(response.accessToken()).isEqualTo("access-token");
+ assertThat(response.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()
+ ));
+ }
+
+ @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);
+ }
+
+ @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(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);
+ }
+ }
+}
From 8257e55b387f754eb3a3fdd1a582d6bd0b583ce6 Mon Sep 17 00:00:00 2001
From: kon28289
Date: Sat, 17 Jan 2026 12:07:15 +0900
Subject: [PATCH 047/135] =?UTF-8?q?chore:=20=EC=9D=B4=EB=A9=94=EC=9D=BC=20?=
=?UTF-8?q?=EB=B0=8F=20=ED=95=99=EB=B2=88=20=EC=A4=91=EB=B3=B5=20=EC=8B=9C?=
=?UTF-8?q?=20409=20=EC=98=88=EC=99=B8=EB=A5=BC=20=EB=B0=98=ED=99=98?=
=?UTF-8?q?=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../global/exception/ExceptionType.java | 2 +-
.../user/repository/UserRepository.java | 5 +++++
.../geumpumtabackend/user/service/UserService.java | 12 +++++++++++-
3 files changed, 17 insertions(+), 2 deletions(-)
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 2484c3e..668aeaf 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", "이미 사용중인 학번입니다."),
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..3130741 100644
--- a/src/main/java/com/gpt/geumpumtabackend/user/repository/UserRepository.java
+++ b/src/main/java/com/gpt/geumpumtabackend/user/repository/UserRepository.java
@@ -2,6 +2,7 @@
import com.gpt.geumpumtabackend.global.oauth.user.OAuth2Provider;
import com.gpt.geumpumtabackend.user.domain.User;
+import jakarta.validation.constraints.Pattern;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
@@ -19,4 +20,8 @@ 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);
}
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..d5203d9 100644
--- a/src/main/java/com/gpt/geumpumtabackend/user/service/UserService.java
+++ b/src/main/java/com/gpt/geumpumtabackend/user/service/UserService.java
@@ -55,11 +55,11 @@ public void generateRandomNickname(User user){
user.setInitialNickname(nickname);
}
- // TODO : 데이터 중복 검증 추가하기
@Transactional
public TokenResponse completeRegistration(CompleteRegistrationRequest request, Long userId) {
User user = userRepository.findById(userId)
.orElseThrow(()->new BusinessException(ExceptionType.USER_NOT_FOUND));
+ validateDuplication(request);
user.completeRegistration(request);
generateRandomNickname(user);
@@ -69,6 +69,16 @@ public TokenResponse completeRegistration(CompleteRegistrationRequest request, L
return TokenResponse.to(token);
}
+ 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) {
User user = userRepository.findById(userId)
.orElseThrow(()->new BusinessException(ExceptionType.USER_NOT_FOUND));
From 9a6e2d4d97a6a90f7a3d66ac97bce971a4d008a2 Mon Sep 17 00:00:00 2001
From: Juhye0k
Date: Sun, 18 Jan 2026 13:04:46 +0900
Subject: [PATCH 048/135] =?UTF-8?q?feat=20:=20Spring=20Retry=20=EB=9D=BC?=
=?UTF-8?q?=EC=9D=B4=EB=B8=8C=EB=9F=AC=EB=A6=AC=20=EC=B6=94=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
build.gradle | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/build.gradle b/build.gradle
index ea3586b..d1928b4 100644
--- a/build.gradle
+++ b/build.gradle
@@ -67,6 +67,10 @@ dependencies {
// 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.19.3'
testImplementation 'org.testcontainers:mysql:1.19.3'
From f672b0b57be770efb43b200740e0551366ec3939 Mon Sep 17 00:00:00 2001
From: Juhye0k
Date: Sun, 18 Jan 2026 13:22:29 +0900
Subject: [PATCH 049/135] =?UTF-8?q?feat=20:=20=EC=8B=9C=EC=A6=8C=20?=
=?UTF-8?q?=EB=9E=AD=ED=82=B9=20=EC=84=A4=EA=B3=84?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../global/config/retry/RetryConfig.java | 14 +
.../rank/api/SeasonRankApi.java | 173 ++++++++++++
.../rank/controller/SeasonRankController.java | 63 +++++
.../rank/domain/RankType.java | 17 ++
.../geumpumtabackend/rank/domain/Season.java | 67 +++++
.../rank/domain/SeasonRankingSnapshot.java | 59 ++++
.../rank/domain/SeasonStatus.java | 16 ++
.../rank/domain/SeasonType.java | 24 ++
.../rank/dto/PersonalRankingTemp.java | 22 +-
.../dto/response/SeasonRankingResponse.java | 32 +++
.../SeasonRankingSnapshotRepository.java | 24 ++
.../rank/repository/SeasonRepository.java | 22 ++
.../RankingSchedulerService.java | 28 +-
.../scheduler/SeasonTransitionScheduler.java | 54 ++++
.../rank/service/SeasonRankService.java | 251 ++++++++++++++++++
.../rank/service/SeasonService.java | 139 ++++++++++
.../service/SeasonSnapshotBatchService.java | 67 +++++
.../rank/service/SeasonSnapshotService.java | 167 ++++++++++++
18 files changed, 1220 insertions(+), 19 deletions(-)
create mode 100644 src/main/java/com/gpt/geumpumtabackend/global/config/retry/RetryConfig.java
create mode 100644 src/main/java/com/gpt/geumpumtabackend/rank/api/SeasonRankApi.java
create mode 100644 src/main/java/com/gpt/geumpumtabackend/rank/controller/SeasonRankController.java
create mode 100644 src/main/java/com/gpt/geumpumtabackend/rank/domain/RankType.java
create mode 100644 src/main/java/com/gpt/geumpumtabackend/rank/domain/Season.java
create mode 100644 src/main/java/com/gpt/geumpumtabackend/rank/domain/SeasonRankingSnapshot.java
create mode 100644 src/main/java/com/gpt/geumpumtabackend/rank/domain/SeasonStatus.java
create mode 100644 src/main/java/com/gpt/geumpumtabackend/rank/domain/SeasonType.java
create mode 100644 src/main/java/com/gpt/geumpumtabackend/rank/dto/response/SeasonRankingResponse.java
create mode 100644 src/main/java/com/gpt/geumpumtabackend/rank/repository/SeasonRankingSnapshotRepository.java
create mode 100644 src/main/java/com/gpt/geumpumtabackend/rank/repository/SeasonRepository.java
rename src/main/java/com/gpt/geumpumtabackend/rank/{service => scheduler}/RankingSchedulerService.java (89%)
create mode 100644 src/main/java/com/gpt/geumpumtabackend/rank/scheduler/SeasonTransitionScheduler.java
create mode 100644 src/main/java/com/gpt/geumpumtabackend/rank/service/SeasonRankService.java
create mode 100644 src/main/java/com/gpt/geumpumtabackend/rank/service/SeasonService.java
create mode 100644 src/main/java/com/gpt/geumpumtabackend/rank/service/SeasonSnapshotBatchService.java
create mode 100644 src/main/java/com/gpt/geumpumtabackend/rank/service/SeasonSnapshotService.java
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/rank/api/SeasonRankApi.java b/src/main/java/com/gpt/geumpumtabackend/rank/api/SeasonRankApi.java
new file mode 100644
index 0000000..6dd73a6
--- /dev/null
+++ b/src/main/java/com/gpt/geumpumtabackend/rank/api/SeasonRankApi.java
@@ -0,0 +1,173 @@
+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.SeasonRankingResponse;
+import com.gpt.geumpumtabackend.user.domain.Department;
+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;
+import org.springframework.web.bind.annotation.RequestParam;
+
+@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 = """
+ 현재 활성 중인 시즌의 특정 학과 랭킹을 조회합니다.
+
+ 📊 **랭킹 계산:**
+ - 해당 학과 학생들만 필터링
+ - 완료된 월간 랭킹 합산
+ - 현재 진행 중인 월의 일간 랭킹 합산
+ - 오늘 실시간 학습 세션 합산
+ """
+ )
+ @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/department")
+ @AssignUserId
+ @PreAuthorize("isAuthenticated() and hasRole('USER')")
+ ResponseEntity> getCurrentSeasonDepartmentRanking(
+ @Parameter(hidden = true) Long userId,
+ @Parameter(
+ description = "학과 이름",
+ example = "COMPUTER_ENGINEERING",
+ required = true
+ )
+ @RequestParam Department department
+ );
+
+ @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 = """
+ 종료된 시즌의 특정 학과 최종 랭킹을 조회합니다.
+
+ 💾 **스냅샷 기반:**
+ - 시즌 종료 시점에 생성된 학과별 확정 랭킹 스냅샷
+ - 시즌 종료 후 변경되지 않는 영구 기록
+ """
+ )
+ @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}/department")
+ @AssignUserId
+ @PreAuthorize("isAuthenticated() and hasRole('USER')")
+ ResponseEntity> getEndedSeasonDepartmentRanking(
+ @Parameter(hidden = true) Long userId,
+ @Parameter(
+ description = "시즌 ID",
+ example = "1"
+ )
+ @PathVariable Long seasonId,
+ @Parameter(
+ description = "학과 이름",
+ example = "COMPUTER_ENGINEERING",
+ required = true
+ )
+ @RequestParam Department department
+ );
+}
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..debfb0a
--- /dev/null
+++ b/src/main/java/com/gpt/geumpumtabackend/rank/controller/SeasonRankController.java
@@ -0,0 +1,63 @@
+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.SeasonRankingResponse;
+import com.gpt.geumpumtabackend.rank.service.SeasonRankService;
+import com.gpt.geumpumtabackend.user.domain.Department;
+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,
+ @RequestParam Department department
+ ) {
+ SeasonRankingResponse response = seasonRankService.getCurrentSeasonDepartmentRanking(department);
+ 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,
+ @RequestParam Department department
+ ) {
+ SeasonRankingResponse response = seasonRankService.getEndedSeasonDepartmentRanking(seasonId, department);
+ 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 3a36581..c1bc0bb 100644
--- a/src/main/java/com/gpt/geumpumtabackend/rank/dto/PersonalRankingTemp.java
+++ b/src/main/java/com/gpt/geumpumtabackend/rank/dto/PersonalRankingTemp.java
@@ -20,7 +20,27 @@ public PersonalRankingTemp(Long userId, String nickname, String imageUrl, String
this.totalMillis = totalMillis;
this.ranking = ranking;
}
-
+
+ // JPQL에서 Department enum을 직접 전달받는 생성자
+ public PersonalRankingTemp(Long userId, String nickname, String imageUrl, Department department, Long totalMillis, Long ranking) {
+ this.userId = userId;
+ this.nickname = nickname;
+ this.imageUrl = imageUrl;
+ this.department = department != null ? department.name() : null;
+ this.totalMillis = totalMillis;
+ this.ranking = ranking;
+ }
+
+ // JPQL 리터럴 0L이 int로 추론될 때를 위한 생성자
+ public PersonalRankingTemp(Long userId, String nickname, String imageUrl, Department department, Long totalMillis, int ranking) {
+ this.userId = userId;
+ this.nickname = nickname;
+ this.imageUrl = imageUrl;
+ this.department = department != null ? department.name() : null;
+ this.totalMillis = totalMillis;
+ this.ranking = (long) ranking;
+ }
+
// Department enum 값을 한국어로 변환하는 메서드
public String getDepartmentKoreanName() {
if (department == null) return null;
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..4e90cf2
--- /dev/null
+++ b/src/main/java/com/gpt/geumpumtabackend/rank/dto/response/SeasonRankingResponse.java
@@ -0,0 +1,32 @@
+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 rankingEntries = rankings.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/SeasonRankingSnapshotRepository.java b/src/main/java/com/gpt/geumpumtabackend/rank/repository/SeasonRankingSnapshotRepository.java
new file mode 100644
index 0000000..fc042c0
--- /dev/null
+++ b/src/main/java/com/gpt/geumpumtabackend/rank/repository/SeasonRankingSnapshotRepository.java
@@ -0,0 +1,24 @@
+package com.gpt.geumpumtabackend.rank.repository;
+
+import com.gpt.geumpumtabackend.rank.domain.RankType;
+import com.gpt.geumpumtabackend.rank.domain.SeasonRankingSnapshot;
+import com.gpt.geumpumtabackend.user.domain.Department;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import java.util.List;
+
+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);
+}
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..c38c8ff
--- /dev/null
+++ b/src/main/java/com/gpt/geumpumtabackend/rank/repository/SeasonRepository.java
@@ -0,0 +1,22 @@
+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("""
+ SELECT s FROM Season s
+ WHERE s.startDate <= :date
+ AND s.endDate >= :date
+ ORDER BY s.createdAt DESC
+ LIMIT 1
+ """)
+ Optional findByDateRange(@Param("date") LocalDate date);
+}
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..63c8ca4
--- /dev/null
+++ b/src/main/java/com/gpt/geumpumtabackend/rank/scheduler/SeasonTransitionScheduler.java
@@ -0,0 +1,54 @@
+package com.gpt.geumpumtabackend.rank.scheduler;
+
+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 CacheManager cacheManager;
+
+
+
+ @Scheduled(cron = "0 5 0 * * *")
+ public void processSeasonTransition() {
+ LocalDate today = LocalDate.now();
+
+ try {
+ Season activeSeason = seasonService.getActiveSeasonNoCache();
+
+ if (!today.equals(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);
+ } catch (Exception e) {
+ log.error("[SEASON_TRANSITION_ERROR] Failed", e);
+ // TODO: 슬랙/이메일 알림
+ }
+ }
+}
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..7c8e3ca
--- /dev/null
+++ b/src/main/java/com/gpt/geumpumtabackend/rank/service/SeasonRankService.java
@@ -0,0 +1,251 @@
+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.dto.response.SeasonRankingResponse;
+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;
+import java.util.stream.IntStream;
+
+
+@Service
+@RequiredArgsConstructor
+@Transactional(readOnly = true)
+@Slf4j
+public class SeasonRankService {
+
+ private final UserRankingRepository userRankingRepository;
+ 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 SeasonRankingResponse getCurrentSeasonDepartmentRanking(Department department) {
+ 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
+ .calculateSeasonDepartmentRankingFromMonthlyRankings(
+ seasonStart.atStartOfDay(),
+ currentMonthStart.atStartOfDay(),
+ department
+ );
+ allData.addAll(completedMonths);
+ }
+
+ if (today.isAfter(currentMonthStart)) {
+ List currentMonth = userRankingRepository
+ .calculateCurrentMonthDepartmentRankingFromDailyRankings(
+ currentMonthStart.atStartOfDay(),
+ today.atStartOfDay(),
+ department
+ );
+ allData.addAll(currentMonth);
+ }
+
+ LocalDateTime todayEnd = today.plusDays(1).atStartOfDay();
+ List todayRanking = studySessionRepository
+ .calculateCurrentPeriodDepartmentRanking(
+ today.atStartOfDay(),
+ todayEnd,
+ LocalDateTime.now(),
+ department.name()
+ );
+ allData.addAll(todayRanking);
+
+ List finalRankings = mergeAndRank(allData);
+
+ return SeasonRankingResponse.of(activeSeason, finalRankings);
+ }
+
+
+ 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 SeasonRankingResponse getEndedSeasonDepartmentRanking(Long seasonId, Department department) {
+ 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
+ .findBySeasonIdAndRankTypeAndDepartment(seasonId, RankType.DEPARTMENT, department);
+
+ List rankings = convertSnapshotsToRankings(snapshots);
+
+ return SeasonRankingResponse.of(season, rankings);
+ }
+
+
+ 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 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;
+ }
+}
From d8847fb0157ed7c7ee98b453285c8ca36a8a8041 Mon Sep 17 00:00:00 2001
From: Juhye0k
Date: Sun, 18 Jan 2026 13:23:10 +0900
Subject: [PATCH 050/135] =?UTF-8?q?refactor=20:=20=EA=B3=B5=EB=B6=80=20?=
=?UTF-8?q?=EC=8B=9C=EA=B0=84=20=EC=A1=B0=ED=9A=8C=20=EC=8B=9C=20=ED=99=9C?=
=?UTF-8?q?=EC=84=B1=ED=99=94=EB=90=9C=20=EC=84=B8=EC=85=98=20=EC=97=AC?=
=?UTF-8?q?=EB=B6=80=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=B6=94=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../repository/UserRankingRepository.java | 96 ++++++++++++++++++-
.../dto/response/StudySessionResponse.java | 6 +-
.../repository/StudySessionRepository.java | 52 +++++++++-
.../study/service/StudySessionService.java | 3 +-
4 files changed, 149 insertions(+), 8 deletions(-)
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/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/repository/StudySessionRepository.java b/src/main/java/com/gpt/geumpumtabackend/study/repository/StudySessionRepository.java
index 9e9de07..682eacb 100644
--- a/src/main/java/com/gpt/geumpumtabackend/study/repository/StudySessionRepository.java
+++ b/src/main/java/com/gpt/geumpumtabackend/study/repository/StudySessionRepository.java
@@ -84,8 +84,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
+ );
/*
랭킹 집계 시 공부 시간
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 e822085..e12a93e 100644
--- a/src/main/java/com/gpt/geumpumtabackend/study/service/StudySessionService.java
+++ b/src/main/java/com/gpt/geumpumtabackend/study/service/StudySessionService.java
@@ -34,8 +34,9 @@ public class StudySessionService {
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);
}
/*
From eadd25f3be7227f5964463af2d4c784a96c2f795 Mon Sep 17 00:00:00 2001
From: Juhye0k
Date: Sun, 18 Jan 2026 13:24:58 +0900
Subject: [PATCH 051/135] =?UTF-8?q?refactor=20:=20batch=EB=A5=BC=20?=
=?UTF-8?q?=EC=9C=84=ED=95=9C=20db=20url=20=EC=88=98=EC=A0=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/main/resources/application-dev.yml | 2 +-
src/main/resources/application-local.yml | 2 +-
src/main/resources/application-prod.yml | 2 +-
3 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml
index f70cdb2..72bf066 100644
--- a/src/main/resources/application-dev.yml
+++ b/src/main/resources/application-dev.yml
@@ -11,7 +11,7 @@ spring:
- security/application-cloudinary.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-local.yml b/src/main/resources/application-local.yml
index 27dcf0f..4bd58fe 100644
--- a/src/main/resources/application-local.yml
+++ b/src/main/resources/application-local.yml
@@ -11,7 +11,7 @@ spring:
- security/application-cloudinary.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..c33e632 100644
--- a/src/main/resources/application-prod.yml
+++ b/src/main/resources/application-prod.yml
@@ -11,7 +11,7 @@ spring:
- security/application-cloudinary.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
From b594db181db5bb42b1df4b19172f92c9110ddc14 Mon Sep 17 00:00:00 2001
From: Juhye0k
Date: Sun, 18 Jan 2026 13:25:05 +0900
Subject: [PATCH 052/135] =?UTF-8?q?refactor=20:=20=EC=98=88=EC=99=B8=20?=
=?UTF-8?q?=ED=83=80=EC=9E=85=20=EC=B6=94=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../geumpumtabackend/global/exception/ExceptionType.java | 7 +++++++
1 file changed, 7 insertions(+)
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 ef77aee..1cd92e9 100644
--- a/src/main/java/com/gpt/geumpumtabackend/global/exception/ExceptionType.java
+++ b/src/main/java/com/gpt/geumpumtabackend/global/exception/ExceptionType.java
@@ -57,6 +57,13 @@ public enum ExceptionType {
// board
BOARD_NOT_FOUND(BAD_REQUEST, "B001", "존재하지 않는 게시물입니다."),
+ // 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", "시즌 종료일은 시작일보다 이후여야 합니다"),
+
;
private final HttpStatus status;
From 21f0e0ede4a7809476f3639c4080d18521a87e17 Mon Sep 17 00:00:00 2001
From: Juhye0k
Date: Sun, 18 Jan 2026 18:48:23 +0900
Subject: [PATCH 053/135] =?UTF-8?q?refactor=20:=20=ED=85=8C=EC=8A=A4?=
=?UTF-8?q?=ED=8A=B8=20=ED=99=98=EA=B2=BD=20=EC=8A=A4=EC=BC=80=EC=A4=84?=
=?UTF-8?q?=EB=9F=AC=20=EB=B9=84=ED=99=9C=EC=84=B1=ED=99=94?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/test/resources/application-test.yml | 5 +++++
src/test/resources/application-unit-test.yml | 5 +++++
2 files changed, 10 insertions(+)
diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml
index 8b98d63..ff1bcaf 100644
--- a/src/test/resources/application-test.yml
+++ b/src/test/resources/application-test.yml
@@ -3,6 +3,11 @@ spring:
activate:
on-profile: test
+ # 스케줄링 비활성화 (테스트 환경)
+ task:
+ scheduling:
+ enabled: false
+
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
# URL will be dynamically set by BaseIntegrationTest via @DynamicPropertySource
diff --git a/src/test/resources/application-unit-test.yml b/src/test/resources/application-unit-test.yml
index a094d13..0224c01 100644
--- a/src/test/resources/application-unit-test.yml
+++ b/src/test/resources/application-unit-test.yml
@@ -3,6 +3,11 @@ spring:
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
From 53f7e88b6fa45999a99be114069d750e403ffef9 Mon Sep 17 00:00:00 2001
From: juhyeok
Date: Mon, 19 Jan 2026 09:41:52 +0900
Subject: [PATCH 054/135] Update
src/main/java/com/gpt/geumpumtabackend/rank/scheduler/SeasonTransitionScheduler.java
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
---
.../rank/scheduler/SeasonTransitionScheduler.java | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/main/java/com/gpt/geumpumtabackend/rank/scheduler/SeasonTransitionScheduler.java b/src/main/java/com/gpt/geumpumtabackend/rank/scheduler/SeasonTransitionScheduler.java
index 63c8ca4..c77aade 100644
--- a/src/main/java/com/gpt/geumpumtabackend/rank/scheduler/SeasonTransitionScheduler.java
+++ b/src/main/java/com/gpt/geumpumtabackend/rank/scheduler/SeasonTransitionScheduler.java
@@ -29,7 +29,7 @@ public void processSeasonTransition() {
try {
Season activeSeason = seasonService.getActiveSeasonNoCache();
- if (!today.equals(activeSeason.getEndDate().plusDays(1))) {
+ if (today.isBefore(activeSeason.getEndDate().plusDays(1))) {
return;
}
From d69b884c058b4f131e56a02bf78bdf1b8c5d05b9 Mon Sep 17 00:00:00 2001
From: Juhye0k
Date: Mon, 19 Jan 2026 09:45:10 +0900
Subject: [PATCH 055/135] =?UTF-8?q?refactor=20:=20ranking=20null=20?=
=?UTF-8?q?=EC=A0=84=EB=8B=AC=20=EC=8B=9C=20=EB=B9=88=EB=A6=AC=EC=8A=A4?=
=?UTF-8?q?=ED=8A=B8=EB=A1=9C=20=EB=B0=A9=EC=A7=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../rank/dto/response/SeasonRankingResponse.java | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
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
index 4e90cf2..1774a7d 100644
--- a/src/main/java/com/gpt/geumpumtabackend/rank/dto/response/SeasonRankingResponse.java
+++ b/src/main/java/com/gpt/geumpumtabackend/rank/dto/response/SeasonRankingResponse.java
@@ -17,7 +17,8 @@ public record SeasonRankingResponse(
) {
public static SeasonRankingResponse of(Season season, List rankings) {
- List rankingEntries = rankings.stream()
+ List safeRankings = (rankings == null) ? List.of() : rankings;
+ List rankingEntries = safeRankings.stream()
.map(PersonalRankingEntryResponse::of)
.collect(Collectors.toList());
From 9969bb3451bcaeb38f9fa6fd69176b7fde321fb3 Mon Sep 17 00:00:00 2001
From: Juhye0k
Date: Mon, 19 Jan 2026 09:49:22 +0900
Subject: [PATCH 056/135] =?UTF-8?q?refactor=20:=20jpql=20limit=20=EB=AF=B8?=
=?UTF-8?q?=EC=A7=80=EC=9B=90=EC=9C=BC=EB=A1=9C=20=EC=9D=B8=ED=95=9C=20nat?=
=?UTF-8?q?ive=20query=20=EC=82=AC=EC=9A=A9?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../rank/repository/SeasonRepository.java | 12 +++++-------
1 file changed, 5 insertions(+), 7 deletions(-)
diff --git a/src/main/java/com/gpt/geumpumtabackend/rank/repository/SeasonRepository.java b/src/main/java/com/gpt/geumpumtabackend/rank/repository/SeasonRepository.java
index c38c8ff..d942d50 100644
--- a/src/main/java/com/gpt/geumpumtabackend/rank/repository/SeasonRepository.java
+++ b/src/main/java/com/gpt/geumpumtabackend/rank/repository/SeasonRepository.java
@@ -11,12 +11,10 @@
public interface SeasonRepository extends JpaRepository {
- @Query("""
- SELECT s FROM Season s
- WHERE s.startDate <= :date
- AND s.endDate >= :date
- ORDER BY s.createdAt DESC
- LIMIT 1
- """)
+ @Query(value = """
+ SELECT *
+ FROM season
+ WHERE start_date <= :date AND end_date >= :date
+ """, nativeQuery = true)
Optional findByDateRange(@Param("date") LocalDate date);
}
From ab06d43ccece05d0df1d28f8c38033690a50d6ab Mon Sep 17 00:00:00 2001
From: Juhye0k
Date: Mon, 19 Jan 2026 10:53:59 +0900
Subject: [PATCH 057/135] =?UTF-8?q?faet:=20:=20CacheConfig=20=EC=84=A4?=
=?UTF-8?q?=EC=A0=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../global/config/cache/CacheConfig.java | 30 +++++++++++++++++++
src/test/resources/application-test.yml | 5 +---
2 files changed, 31 insertions(+), 4 deletions(-)
create mode 100644 src/main/java/com/gpt/geumpumtabackend/global/config/cache/CacheConfig.java
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/test/resources/application-test.yml b/src/test/resources/application-test.yml
index ff1bcaf..50cc961 100644
--- a/src/test/resources/application-test.yml
+++ b/src/test/resources/application-test.yml
@@ -3,10 +3,7 @@ spring:
activate:
on-profile: test
- # 스케줄링 비활성화 (테스트 환경)
- task:
- scheduling:
- enabled: false
+
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
From b9c09725e48119bf4b21b6fb53501fea5b90d4ed Mon Sep 17 00:00:00 2001
From: Juhye0k
Date: Mon, 2 Feb 2026 15:53:50 +0900
Subject: [PATCH 058/135] =?UTF-8?q?feat=20:=20claude.md=20=EB=AC=B8?=
=?UTF-8?q?=EC=84=9C=ED=99=94?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
CLAUDE.md | 169 ++++++++++++++++++
.../com/gpt/geumpumtabackend/rank/CLAUDE.md | 128 +++++++++++++
.../com/gpt/geumpumtabackend/study/CLAUDE.md | 116 ++++++++++++
.../com/gpt/geumpumtabackend/wifi/CLAUDE.md | 96 ++++++++++
4 files changed, 509 insertions(+)
create mode 100644 CLAUDE.md
create mode 100644 src/main/java/com/gpt/geumpumtabackend/rank/CLAUDE.md
create mode 100644 src/main/java/com/gpt/geumpumtabackend/study/CLAUDE.md
create mode 100644 src/main/java/com/gpt/geumpumtabackend/wifi/CLAUDE.md
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..91a8582
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,169 @@
+# CLAUDE.md
+
+This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
+
+## Project Overview
+
+**Geumpumta (열정품은타이머)** — 금오공과대학교 학생 대상 공부 시간 관리 애플리케이션 백엔드. Spring Boot 기반으로, 학습 세션 추적, 대학 인증(캠퍼스 Wi-Fi + 이메일), 시즌/랭킹 시스템을 제공한다.
+
+## 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"
+```
+
+## Tech Stack
+
+- **Java 21**, **Spring Boot 3.5.6**, **Gradle**
+- **Spring Security** + OAuth2 Client (Kakao, Google, Apple)
+- **Spring Data JPA** + MySQL 8
+- **Spring Data Redis** + Caffeine Cache
+- **JWT**: JJWT 0.12.6 + Nimbus JOSE JWT 9.37.4
+- **API Docs**: SpringDoc OpenAPI 2.8.14
+- **Image**: Cloudinary 1.39.0
+- **Network**: Apache Commons Net 3.11.1 (IP range validation)
+- **Retry**: Spring Retry + Spring Aspects
+- **Test**: JUnit 5, Mockito, TestContainers (MySQL)
+
+## Project Structure
+
+```
+src/main/java/com/gpt/geumpumtabackend/
+├── GeumpumtaBackendApplication.java
+│
+├── global/ # 공통 인프라
+│ ├── aop/ # @AssignUserId AOP
+│ ├── base/ # BaseEntity (createdAt, updatedAt, deletedAt)
+│ ├── config/ # cache, 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/ # 게시판
+├── image/ # 이미지 업로드 (Cloudinary)
+├── rank/ # 랭킹 시스템
+│ ├── domain/ # UserRanking, DepartmentRanking, Season, SeasonRankingSnapshot
+│ ├── scheduler/ # RankingSchedulerService, SeasonTransitionScheduler
+│ └── service/ # Personal/Department/SeasonRank, SeasonSnapshot services
+├── statistics/ # 통계 (일간/주간/월간, 잔디)
+├── study/ # 학습 세션 (시작/종료, Wi-Fi 검증)
+├── token/ # JWT 토큰 관리 (발급/갱신)
+├── user/ # 사용자, 이메일 인증, 프로필
+└── wifi/ # 캠퍼스 Wi-Fi 검증
+```
+
+각 도메인 모듈은 `api/ → controller/ → service/ → repository/ → domain/ → dto/` 계층 구조를 따른다.
+
+## Architecture Patterns
+
+### Layered Architecture
+`Controller → Service → Repository → Entity` 순서. Controller는 HTTP 처리만, Service에 비즈니스 로직 집중.
+
+### AOP User Context Injection
+```java
+@PreAuthorize("isAuthenticated() and hasRole('USER')")
+@AssignUserId // JWT에서 userId 자동 주입
+public ResponseEntity endpoint(Long userId) { ... }
+```
+
+### Standardized Response
+```java
+ResponseUtil.createSuccessResponse(data);
+ResponseUtil.createFailureResponse(ExceptionType.ERROR_TYPE);
+```
+
+### Exception Handling
+- `GlobalExceptionHandler` (`@RestControllerAdvice`)에서 전역 처리
+- `ExceptionType` enum으로 에러 코드/메시지 관리
+- 도메인 예외는 `BusinessException` 상속
+
+### Soft Delete
+모든 엔티티가 `BaseEntity`를 상속 → `createdAt`, `updatedAt`, `deletedAt` 필드 자동 관리.
+
+## Configuration
+
+### Profiles
+| Profile | DB | DDL Mode | 용도 |
+|---------|-----|----------|------|
+| `local` | MySQL localhost:3311 | create-drop | 로컬 개발 |
+| `dev` | Docker MySQL | update | 개발 서버 |
+| `prod` | Production DB | validate | 운영 |
+| `test` | TestContainers MySQL | - | 통합 테스트 |
+| `unit-test` | H2 | - | 단위 테스트 |
+
+### Sensitive Config (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`
+
+## Key Business Logic
+
+### Study Session
+1. **시작**: Wi-Fi 검증 → `StudySession` 생성 (서버 타임스탬프, `STARTED`)
+2. **종료**: 서버에서 `endTime` 계산, `FINISHED` 상태, `Duration.between().toMillis()`로 시간 산출
+3. 클라이언트 타임스탬프 사용하지 않음 — 모든 시간은 서버에서 관리
+
+### Ranking System
+- **UserRanking**: 개인 공부 시간 랭킹
+- **DepartmentRanking**: 학과별 집계 랭킹
+- **RankingType**: DAILY, WEEKLY, MONTHLY
+- **Season**: 시즌제 운영, `SeasonRankingSnapshot`으로 이력 관리
+- `RankingSchedulerService`가 주기적으로 랭킹 재계산
+
+### Wi-Fi Validation
+캠퍼스 Wi-Fi gateway IP/client IP 검증. Redis 캐싱 적용.
+
+### Authentication Flow
+OAuth2 로그인 (Kakao/Google/Apple) → 대학 이메일 인증 (@kumoh.ac.kr) → JWT 발급 (14일)
+
+## Testing
+
+### Unit Tests (`src/test/java/.../unit/`)
+- JUnit 5 + Mockito + AssertJ
+- `BaseUnitTest` 기반
+- 대상: StudySession 시간 계산, 랭킹 로직, Wi-Fi 검증, 시즌 서비스
+
+### Integration Tests (`src/test/java/.../integration/`)
+- TestContainers MySQL 8.0 (`withReuse(true)`)
+- `BaseIntegrationTest` 기반, TRUNCATE로 데이터 초기화
+- 대상: Controller 엔드투엔드 테스트
+
+## CI/CD
+
+### GitHub Actions
+- **CI**: Java 21 빌드, Redis 서비스 연동, 테스트, 아티팩트 업로드
+- **CD**: Docker 멀티 아키텍처 빌드 (AMD64/ARM64), GHCR 푸시, self-hosted 배포
+
+### Docker
+- Base image: `amd64/openjdk:21-jdk-slim`
+- `docker-compose.yml`: MySQL 8.4.0 + Redis Alpine (로컬 개발용)
+
+## URL Pattern
+
+```
+/api/v1/{domain}/*
+```
+
+## Development Checklist
+
+1. 도메인 모듈 구조 준수 (`api/ → controller/ → service/ → repository/ → domain/ → dto/`)
+2. 인증 필요한 엔드포인트에 `@AssignUserId` 사용
+3. `@Transactional` 적절히 적용
+4. `ResponseUtil`로 응답 표준화
+5. 새 에러는 `ExceptionType` enum에 추가
+6. `security/` 디렉토리 파일 커밋 금지
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/study/CLAUDE.md b/src/main/java/com/gpt/geumpumtabackend/study/CLAUDE.md
new file mode 100644
index 0000000..6484c92
--- /dev/null
+++ b/src/main/java/com/gpt/geumpumtabackend/study/CLAUDE.md
@@ -0,0 +1,116 @@
+# 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
+```
+
+### 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/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 설정만 변경하면 됨 (코드 수정 불필요)
From c9e5c52b3e6c7e3ede8f01c885be5cee36c71d92 Mon Sep 17 00:00:00 2001
From: kon28289
Date: Wed, 4 Feb 2026 13:37:52 +0900
Subject: [PATCH 059/135] =?UTF-8?q?perf:=20=EC=9B=94=20=EB=B2=94=EC=9C=84?=
=?UTF-8?q?=20=EC=84=B8=EC=85=98=20=EC=84=A0=ED=95=84=ED=84=B0=EB=A7=81?=
=?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=9B=94=EA=B0=84=20=ED=86=B5=EA=B3=84=20?=
=?UTF-8?q?=EC=BF=BC=EB=A6=AC=20=EC=84=B1=EB=8A=A5=20=EA=B0=9C=EC=84=A0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../repository/StatisticsRepository.java | 190 ++++++++++--------
1 file changed, 105 insertions(+), 85 deletions(-)
diff --git a/src/main/java/com/gpt/geumpumtabackend/statistics/repository/StatisticsRepository.java b/src/main/java/com/gpt/geumpumtabackend/statistics/repository/StatisticsRepository.java
index db2bbbf..fa11b4c 100644
--- a/src/main/java/com/gpt/geumpumtabackend/statistics/repository/StatisticsRepository.java
+++ b/src/main/java/com/gpt/geumpumtabackend/statistics/repository/StatisticsRepository.java
@@ -156,97 +156,117 @@ WeeklyStatistics getWeeklyStatistics(
);
@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
- ),
- /* 일자별 공부 총합(ms) */
- 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 (
- /* 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)
+ 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 (
From 502e4655032fbf9bed687f850c4ec6a62f9b5934 Mon Sep 17 00:00:00 2001
From: Juhye0k
Date: Sun, 15 Feb 2026 18:18:49 +0900
Subject: [PATCH 060/135] =?UTF-8?q?feat=20:=20=EC=B5=9C=EB=8C=80=20?=
=?UTF-8?q?=EA=B3=B5=EB=B6=80=20=EC=A7=91=EC=A4=91=EC=8B=9C=EA=B0=84=20?=
=?UTF-8?q?=EC=84=A4=EA=B3=84?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../global/exception/ExceptionType.java | 5 ++++
.../com/gpt/geumpumtabackend/study/CLAUDE.md | 10 +++++++
.../study/domain/StudySession.java | 5 ++++
.../repository/StudySessionRepository.java | 3 +++
.../study/service/StudySessionService.java | 27 ++++++++++++++++++-
.../geumpumtabackend/user/domain/User.java | 14 +++++++++-
.../user/service/UserService.java | 4 +++
src/main/resources/application.yml | 3 +++
8 files changed, 69 insertions(+), 2 deletions(-)
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 1cd92e9..0cff9ab 100644
--- a/src/main/java/com/gpt/geumpumtabackend/global/exception/ExceptionType.java
+++ b/src/main/java/com/gpt/geumpumtabackend/global/exception/ExceptionType.java
@@ -64,6 +64,11 @@ public enum ExceptionType {
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 토큰이 없습니다."),
+
;
private final HttpStatus status;
diff --git a/src/main/java/com/gpt/geumpumtabackend/study/CLAUDE.md b/src/main/java/com/gpt/geumpumtabackend/study/CLAUDE.md
index 6484c92..24e864f 100644
--- a/src/main/java/com/gpt/geumpumtabackend/study/CLAUDE.md
+++ b/src/main/java/com/gpt/geumpumtabackend/study/CLAUDE.md
@@ -36,6 +36,16 @@ study/
시작 요청 → Wi-Fi 검증 → 중복 세션 확인 → StudySession 생성 (STARTED, 서버 시간)
↓
종료 요청 → 세션 조회 → endTime 설정 (서버 시간) → totalMillis 계산 → FINISHED
+
+```
+
+### 최대 공부시간
+```
+만약 사용자가 3시간 공부했다 -> 종료시켜야함
+
+1. 서버에서 스케줄러를 통해 3시간 이상 진행중인 공부세션이 있는지 체크한다.
+2. 만약 있다면, end_time을 공부시작 시간으로부터 3시간 이후로 설정한다.
+3. FCM에 메세지를 보내서 클라이언트가
```
### StudySession 엔티티
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 651b654..d29bb11 100644
--- a/src/main/java/com/gpt/geumpumtabackend/study/domain/StudySession.java
+++ b/src/main/java/com/gpt/geumpumtabackend/study/domain/StudySession.java
@@ -47,4 +47,9 @@ public void endStudySession(LocalDateTime endTime) {
status = StudyStatus.FINISHED;
this.totalMillis = Duration.between(this.startTime, this.endTime).toMillis();
}
+ public void endMaxFocusStudySession(LocalDateTime startTime, int maxFocusTime) {
+ this.endTime = startTime.plusHours(maxFocusTime);
+ status = StudyStatus.FINISHED;
+ this.totalMillis = Duration.between(this.startTime, this.endTime).toMillis();
+ }
}
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 682eacb..81e356f 100644
--- a/src/main/java/com/gpt/geumpumtabackend/study/repository/StudySessionRepository.java
+++ b/src/main/java/com/gpt/geumpumtabackend/study/repository/StudySessionRepository.java
@@ -34,6 +34,9 @@ Long sumCompletedStudySessionByUserId(
@Param("startOfDay") LocalDateTime startOfDay,
@Param("endOfDay") LocalDateTime endOfDay);
+
+ List findAllByStatusAndStartTimeBefore(StudyStatus status, LocalDateTime now);
+
/*
현재 진행중인 기간의 공부 시간 연산
*/
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 e12a93e..8e29684 100644
--- a/src/main/java/com/gpt/geumpumtabackend/study/service/StudySessionService.java
+++ b/src/main/java/com/gpt/geumpumtabackend/study/service/StudySessionService.java
@@ -1,6 +1,8 @@
package com.gpt.geumpumtabackend.study.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.study.config.StudyProperties;
import com.gpt.geumpumtabackend.study.domain.StudySession;
import com.gpt.geumpumtabackend.study.domain.StudyStatus;
import com.gpt.geumpumtabackend.study.dto.request.StudyEndRequest;
@@ -18,6 +20,7 @@
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.time.LocalDateTime;
+import java.util.List;
@Service
@RequiredArgsConstructor
@@ -27,7 +30,8 @@ public class StudySessionService {
private final StudySessionRepository studySessionRepository;
private final UserRepository userRepository;
private final CampusWiFiValidationService wifiValidationService;
-
+ private final FcmService fcmService;
+ private final StudyProperties studyProperties;
/*
메인 홈
*/
@@ -92,4 +96,25 @@ public StudySession makeStudySession(Long userId){
newStudySession.startStudySession(startTime, user);
return studySessionRepository.save(newStudySession);
}
+
+ @Transactional
+ public void finishMaxFocusStudySession() {
+ int maxFocusHours = studyProperties.getMaxFocusHours();
+ LocalDateTime cutoffTime = LocalDateTime.now().minusHours(maxFocusHours);
+
+ List expiredSessions = studySessionRepository.findAllByStatusAndStartTimeBefore(
+ StudyStatus.STARTED, cutoffTime
+ );
+ for (StudySession expiredSession : expiredSessions) {
+ expiredSession.endMaxFocusStudySession(expiredSession.getStartTime(), maxFocusHours);
+
+ // FCM 알림 전송
+ try {
+ fcmService.sendMaxFocusNotification(expiredSession.getUser(), maxFocusHours);
+ } catch (Exception e) {
+ log.error("Failed to send FCM notification for session {}", expiredSession.getId(), e);
+ // 알림 실패해도 세션 종료는 계속 진행
+ }
+ }
+ }
}
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 2244029..1acfb78 100644
--- a/src/main/java/com/gpt/geumpumtabackend/user/domain/User.java
+++ b/src/main/java/com/gpt/geumpumtabackend/user/domain/User.java
@@ -19,7 +19,8 @@
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,9 @@ public class User extends BaseEntity {
@Enumerated(value = EnumType.STRING)
private Department department;
+ @Column(length = 255)
+ private String fcmToken;
+
@Builder
public User(String email, UserRole role, String name, String picture, OAuth2Provider provider, String providerId, Department department) {
this.email = email;
@@ -94,4 +98,12 @@ 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;
+ }
}
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..75b8a51 100644
--- a/src/main/java/com/gpt/geumpumtabackend/user/service/UserService.java
+++ b/src/main/java/com/gpt/geumpumtabackend/user/service/UserService.java
@@ -1,6 +1,7 @@
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;
@@ -30,6 +31,7 @@ public class UserService {
private final UserRepository userRepository;
private final RefreshTokenRepository refreshTokenRepository;
private final JwtHandler jwtHandler;
+ private final FcmService fcmService;
private static final Random RANDOM = new Random();
private static final List ADJECTIVES = List.of(
@@ -96,6 +98,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 +107,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/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
From 4a8b052682184ea08151c7daaeffe23a741622a0 Mon Sep 17 00:00:00 2001
From: Juhye0k
Date: Sun, 15 Feb 2026 18:19:09 +0900
Subject: [PATCH 061/135] =?UTF-8?q?feat=20:=20FCM=20=EB=9D=BC=EC=9D=B4?=
=?UTF-8?q?=EB=B8=8C=EB=9F=AC=EB=A6=AC?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
build.gradle | 3 +++
1 file changed, 3 insertions(+)
diff --git a/build.gradle b/build.gradle
index d1928b4..f184fbd 100644
--- a/build.gradle
+++ b/build.gradle
@@ -75,6 +75,9 @@ dependencies {
testImplementation 'org.testcontainers:junit-jupiter:1.19.3'
testImplementation 'org.testcontainers:mysql:1.19.3'
testImplementation 'org.testcontainers:testcontainers:1.19.3'
+
+ // FCM
+ implementation 'com.google.firebase:firebase-admin:9.7.1'
}
tasks.named('test') {
From a8153e0c3135bf931ad92c75976059d53f563894 Mon Sep 17 00:00:00 2001
From: Juhye0k
Date: Sun, 15 Feb 2026 18:24:40 +0900
Subject: [PATCH 062/135] =?UTF-8?q?chore=20:=20CLAUDE=20=EB=AC=B8=EC=84=9C?=
=?UTF-8?q?=ED=99=94=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.ai/TESTING.md | 249 ++++++++++++++++++++++++++++++
.ai/USE-CASES.md | 349 ++++++++++++++++++++++++++++++++++++++++++
.claude/settings.json | 37 +++++
3 files changed, 635 insertions(+)
create mode 100644 .ai/TESTING.md
create mode 100644 .ai/USE-CASES.md
create mode 100644 .claude/settings.json
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*)"
+ ]
+ }
+}
From 26e38956bdf03c77fe563feffd0c3584cfd5c7e5 Mon Sep 17 00:00:00 2001
From: Juhye0k
Date: Sun, 15 Feb 2026 18:25:29 +0900
Subject: [PATCH 063/135] =?UTF-8?q?feat=20:FCM=20=EC=84=A4=EC=A0=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../fcm/controller/FcmController.java | 31 ++++++
.../fcm/dto/FcmMessageDto.java | 16 +++
.../fcm/dto/request/FcmTokenRequest.java | 12 +++
.../fcm/service/FcmService.java | 98 +++++++++++++++++++
.../global/config/fcm/FcmConfig.java | 46 +++++++++
.../global/config/fcm/FcmProperties.java | 13 +++
.../study/config/StudyProperties.java | 17 ++++
.../scheduler/MaxFocusStudyScheduler.java | 25 +++++
8 files changed, 258 insertions(+)
create mode 100644 src/main/java/com/gpt/geumpumtabackend/fcm/controller/FcmController.java
create mode 100644 src/main/java/com/gpt/geumpumtabackend/fcm/dto/FcmMessageDto.java
create mode 100644 src/main/java/com/gpt/geumpumtabackend/fcm/dto/request/FcmTokenRequest.java
create mode 100644 src/main/java/com/gpt/geumpumtabackend/fcm/service/FcmService.java
create mode 100644 src/main/java/com/gpt/geumpumtabackend/global/config/fcm/FcmConfig.java
create mode 100644 src/main/java/com/gpt/geumpumtabackend/global/config/fcm/FcmProperties.java
create mode 100644 src/main/java/com/gpt/geumpumtabackend/study/config/StudyProperties.java
create mode 100644 src/main/java/com/gpt/geumpumtabackend/study/scheduler/MaxFocusStudyScheduler.java
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..dca2ddd
--- /dev/null
+++ b/src/main/java/com/gpt/geumpumtabackend/fcm/controller/FcmController.java
@@ -0,0 +1,31 @@
+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.response.ResponseBody;
+import com.gpt.geumpumtabackend.global.response.ResponseUtil;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+@RequestMapping("/api/v1/fcm")
+@RequiredArgsConstructor
+public class FcmController implements FcmApi {
+
+ private final FcmService fcmService;
+
+ @Override
+ public ResponseEntity> registerFcmToken(FcmTokenRequest request, Long userId) {
+ fcmService.registerFcmToken(userId, request.fcmToken());
+ return ResponseEntity.ok(ResponseUtil.createSuccessResponse());
+ }
+
+ @Override
+ 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/FcmService.java b/src/main/java/com/gpt/geumpumtabackend/fcm/service/FcmService.java
new file mode 100644
index 0000000..0809cc1
--- /dev/null
+++ b/src/main/java/com/gpt/geumpumtabackend/fcm/service/FcmService.java
@@ -0,0 +1,98 @@
+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.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.Map;
+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.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+@Service
+@RequiredArgsConstructor
+@Slf4j
+@Transactional(readOnly = true)
+public class FcmService {
+
+ private final UserRepository userRepository;
+
+ @Transactional
+ public void registerFcmToken(Long userId, String fcmToken) {
+ if (fcmToken == null || fcmToken.isBlank()) {
+ throw new BusinessException(ExceptionType.FCM_INVALID_TOKEN);
+ }
+
+ 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();
+ }
+
+ @Retryable(
+ retryFor = FirebaseMessagingException.class,
+ maxAttempts = 3,
+ backoff = @Backoff(delay = 1000, multiplier = 2)
+ )
+ public void sendMessage(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());
+ }
+
+ FirebaseMessaging.getInstance().send(messageBuilder.build());
+ }
+
+ @Recover
+ public void sendMessageRecover(FirebaseMessagingException e, FcmMessageDto messageDto) {
+ log.error("FCM send failed after 3 retries for token {}", messageDto.getToken(), e);
+ throw new BusinessException(ExceptionType.FCM_SEND_FAILED);
+ }
+
+ 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 {
+ sendMessage(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/fcm/FcmConfig.java b/src/main/java/com/gpt/geumpumtabackend/global/config/fcm/FcmConfig.java
new file mode 100644
index 0000000..c5b554c
--- /dev/null
+++ b/src/main/java/com/gpt/geumpumtabackend/global/config/fcm/FcmConfig.java
@@ -0,0 +1,46 @@
+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 java.io.IOException;
+import java.io.InputStream;
+
+@Configuration
+@RequiredArgsConstructor
+@EnableConfigurationProperties(FcmProperties.class)
+@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/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/scheduler/MaxFocusStudyScheduler.java b/src/main/java/com/gpt/geumpumtabackend/study/scheduler/MaxFocusStudyScheduler.java
new file mode 100644
index 0000000..84a92ca
--- /dev/null
+++ b/src/main/java/com/gpt/geumpumtabackend/study/scheduler/MaxFocusStudyScheduler.java
@@ -0,0 +1,25 @@
+package com.gpt.geumpumtabackend.study.scheduler;
+
+import com.gpt.geumpumtabackend.study.service.StudySessionService;
+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;
+
+
+ @Scheduled(fixedRate = 1000)
+ public void checkAndFinishMaxFocusSessions() {
+ try {
+ studySessionService.finishMaxFocusStudySession();
+ } catch (Exception e) {
+ log.error("[MAX_FOCUS_SCHEDULER] Failed to check max focus sessions", e);
+ }
+ }
+}
From eebb7c6a7bf6e77a75b90b33760fe1e6a8db709f Mon Sep 17 00:00:00 2001
From: Juhye0k
Date: Sun, 15 Feb 2026 18:25:43 +0900
Subject: [PATCH 064/135] =?UTF-8?q?chore=20:=20=ED=81=B4=EB=A1=9C=EB=93=9C?=
=?UTF-8?q?=20=EB=AC=B8=EC=84=9C?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.ai/ARCHITECTURE.md | 250 ++++++++++++++++++++++++++++++++++++++++++++
AGENT.md | 190 +++++++++++++++++++++++++++++++++
2 files changed, 440 insertions(+)
create mode 100644 .ai/ARCHITECTURE.md
create mode 100644 AGENT.md
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/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 패턴을 따라 구현해줘."
+```
+
+### 나쁜 프롬프트
+
+```
+"랭킹 기능 만들어줘" → 범위 불명확, 기존 코드 무시 가능
+"모든 테스트 작성해줘" → 범위 과대, 우선순위 없음
+"이 코드 고쳐줘" (코드 미첨부) → 대상 불명확
+```
+
+### 프롬프트 체크리스트
+
+```
+□ 대상 파일/메서드가 명시되어 있는가
+□ 참고할 문서나 기존 패턴을 지정했는가
+□ 기대 결과가 구체적인가
+□ 범위가 한 작업 단위로 적절한가
+```
From 70c3e9b04eae05e5f902ce1db8a12f2babc14756 Mon Sep 17 00:00:00 2001
From: Juhye0k
Date: Sun, 15 Feb 2026 18:26:04 +0900
Subject: [PATCH 065/135] =?UTF-8?q?feat=20:=20=EC=B5=9C=EB=8C=80=20?=
=?UTF-8?q?=EC=A7=91=EC=A4=91=EC=8B=9C=EA=B0=84=20Swagger=20=EC=9E=91?=
=?UTF-8?q?=EC=84=B1?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../gpt/geumpumtabackend/fcm/api/FcmApi.java | 98 +++++++++++++++++++
1 file changed, 98 insertions(+)
create mode 100644 src/main/java/com/gpt/geumpumtabackend/fcm/api/FcmApi.java
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..1c0558d
--- /dev/null
+++ b/src/main/java/com/gpt/geumpumtabackend/fcm/api/FcmApi.java
@@ -0,0 +1,98 @@
+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
+
+
+ ## 서버 → 클라이언트 푸시 알림
+
+ 서버가 자동으로 전송하는 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`이 알림창에 표시됩니다. 사용자가 앱에 복귀하면 `GET /api/v1/study`를 호출하여 세션 상태(`isStudying`)를 확인합니다.
+ - **FCM 수신 실패 대비**: 앱이 포그라운드로 전환될 때마다 `GET /api/v1/study`를 호출하여 `isStudying=false`이면 타이머를 중지하는 폴백 로직을 구현해야 합니다.
+ """)
+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("/register")
+ @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
+ );
+}
From bac0fd87fa3fe63eeddd2c7a6f9859a384e60f8c Mon Sep 17 00:00:00 2001
From: Juhye0k
Date: Mon, 16 Feb 2026 16:33:47 +0900
Subject: [PATCH 066/135] =?UTF-8?q?refactor=20:=20=EC=8B=9C=EC=A6=8C=20?=
=?UTF-8?q?=ED=95=99=EA=B3=BC=EB=9E=AD=ED=82=B9=20=EC=88=98=EC=A0=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../rank/api/SeasonRankApi.java | 52 +++-----
.../rank/controller/SeasonRankController.java | 16 ++-
.../DepartmentRankingRepository.java | 32 +++++
.../SeasonRankingSnapshotRepository.java | 17 +++
.../rank/service/SeasonRankService.java | 116 ++++++++++++++----
.../study/api/StudySessionApi.java | 23 ++--
6 files changed, 184 insertions(+), 72 deletions(-)
diff --git a/src/main/java/com/gpt/geumpumtabackend/rank/api/SeasonRankApi.java b/src/main/java/com/gpt/geumpumtabackend/rank/api/SeasonRankApi.java
index 6dd73a6..0368605 100644
--- a/src/main/java/com/gpt/geumpumtabackend/rank/api/SeasonRankApi.java
+++ b/src/main/java/com/gpt/geumpumtabackend/rank/api/SeasonRankApi.java
@@ -6,8 +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.rank.dto.response.SeasonDepartmentRankingResponse;
import com.gpt.geumpumtabackend.rank.dto.response.SeasonRankingResponse;
-import com.gpt.geumpumtabackend.user.domain.Department;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Content;
@@ -18,7 +18,6 @@
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
-import org.springframework.web.bind.annotation.RequestParam;
@Tag(name = "시즌 랭킹 API", description = """
학기별 시즌 랭킹을 제공합니다.
@@ -65,22 +64,22 @@ ResponseEntity> getCurrentSeasonRanking(
);
@Operation(
- summary = "현재 시즌 학과별 랭킹 조회",
+ summary = "현재 시즌 학과별 집계 랭킹 조회",
description = """
- 현재 활성 중인 시즌의 특정 학과 랭킹을 조회합니다.
+ 현재 활성 중인 시즌의 학과별 집계 랭킹을 조회합니다.
📊 **랭킹 계산:**
- - 해당 학과 학생들만 필터링
- - 완료된 월간 랭킹 합산
- - 현재 진행 중인 월의 일간 랭킹 합산
- - 오늘 실시간 학습 세션 합산
+ - 학과별 상위 30명의 공부 시간 합산
+ - 완료된 월간 학과 랭킹 합산
+ - 현재 진행 중인 월의 일간 학과 랭킹 합산
+ - 오늘 실시간 학과 랭킹 합산
"""
)
- @ApiResponse(content = @Content(schema = @Schema(implementation = SeasonRankingResponse.class)))
+ @ApiResponse(content = @Content(schema = @Schema(implementation = SeasonDepartmentRankingResponse.class)))
@SwaggerApiResponses(
success = @SwaggerApiSuccessResponse(
- response = SeasonRankingResponse.class,
- description = "현재 시즌 학과별 랭킹 조회 성공"),
+ response = SeasonDepartmentRankingResponse.class,
+ description = "현재 시즌 학과별 집계 랭킹 조회 성공"),
errors = {
@SwaggerApiFailedResponse(ExceptionType.NEED_AUTHORIZED),
@SwaggerApiFailedResponse(ExceptionType.SEASON_NOT_FOUND)
@@ -89,14 +88,8 @@ ResponseEntity> getCurrentSeasonRanking(
@GetMapping("/current/department")
@AssignUserId
@PreAuthorize("isAuthenticated() and hasRole('USER')")
- ResponseEntity> getCurrentSeasonDepartmentRanking(
- @Parameter(hidden = true) Long userId,
- @Parameter(
- description = "학과 이름",
- example = "COMPUTER_ENGINEERING",
- required = true
- )
- @RequestParam Department department
+ ResponseEntity> getCurrentSeasonDepartmentRanking(
+ @Parameter(hidden = true) Long userId
);
@Operation(
@@ -133,20 +126,21 @@ ResponseEntity> getEndedSeasonRanking(
);
@Operation(
- summary = "종료된 시즌 학과별 랭킹 조회",
+ summary = "종료된 시즌 학과별 집계 랭킹 조회",
description = """
- 종료된 시즌의 특정 학과 최종 랭킹을 조회합니다.
+ 종료된 시즌의 학과별 집계 최종 랭킹을 조회합니다.
💾 **스냅샷 기반:**
- 시즌 종료 시점에 생성된 학과별 확정 랭킹 스냅샷
+ - 학과별 상위 30명의 공부 시간 합산
- 시즌 종료 후 변경되지 않는 영구 기록
"""
)
- @ApiResponse(content = @Content(schema = @Schema(implementation = SeasonRankingResponse.class)))
+ @ApiResponse(content = @Content(schema = @Schema(implementation = SeasonDepartmentRankingResponse.class)))
@SwaggerApiResponses(
success = @SwaggerApiSuccessResponse(
- response = SeasonRankingResponse.class,
- description = "종료된 시즌 학과별 랭킹 조회 성공"),
+ response = SeasonDepartmentRankingResponse.class,
+ description = "종료된 시즌 학과별 집계 랭킹 조회 성공"),
errors = {
@SwaggerApiFailedResponse(ExceptionType.NEED_AUTHORIZED),
@SwaggerApiFailedResponse(ExceptionType.SEASON_NOT_FOUND),
@@ -156,18 +150,12 @@ ResponseEntity> getEndedSeasonRanking(
@GetMapping("/{seasonId}/department")
@AssignUserId
@PreAuthorize("isAuthenticated() and hasRole('USER')")
- ResponseEntity> getEndedSeasonDepartmentRanking(
+ ResponseEntity> getEndedSeasonDepartmentRanking(
@Parameter(hidden = true) Long userId,
@Parameter(
description = "시즌 ID",
example = "1"
)
- @PathVariable Long seasonId,
- @Parameter(
- description = "학과 이름",
- example = "COMPUTER_ENGINEERING",
- required = true
- )
- @RequestParam Department department
+ @PathVariable Long seasonId
);
}
diff --git a/src/main/java/com/gpt/geumpumtabackend/rank/controller/SeasonRankController.java b/src/main/java/com/gpt/geumpumtabackend/rank/controller/SeasonRankController.java
index debfb0a..d492201 100644
--- a/src/main/java/com/gpt/geumpumtabackend/rank/controller/SeasonRankController.java
+++ b/src/main/java/com/gpt/geumpumtabackend/rank/controller/SeasonRankController.java
@@ -4,9 +4,9 @@
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 com.gpt.geumpumtabackend.user.domain.Department;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
@@ -30,11 +30,10 @@ public ResponseEntity> getCurrentSeasonRanki
@GetMapping("/current/department")
@PreAuthorize("isAuthenticated() AND hasRole('USER')")
@AssignUserId
- public ResponseEntity> getCurrentSeasonDepartmentRanking(
- Long userId,
- @RequestParam Department department
+ public ResponseEntity> getCurrentSeasonDepartmentRanking(
+ Long userId
) {
- SeasonRankingResponse response = seasonRankService.getCurrentSeasonDepartmentRanking(department);
+ SeasonDepartmentRankingResponse response = seasonRankService.getCurrentSeasonDepartmentRanking(userId);
return ResponseEntity.ok(ResponseUtil.createSuccessResponse(response));
}
@@ -52,12 +51,11 @@ public ResponseEntity> getEndedSeasonRanking
@GetMapping("/{seasonId}/department")
@PreAuthorize("isAuthenticated() AND hasRole('USER')")
@AssignUserId
- public ResponseEntity> getEndedSeasonDepartmentRanking(
+ public ResponseEntity> getEndedSeasonDepartmentRanking(
Long userId,
- @PathVariable Long seasonId,
- @RequestParam Department department
+ @PathVariable Long seasonId
) {
- SeasonRankingResponse response = seasonRankService.getEndedSeasonDepartmentRanking(seasonId, department);
+ SeasonDepartmentRankingResponse response = seasonRankService.getEndedSeasonDepartmentRanking(seasonId, userId);
return ResponseEntity.ok(ResponseUtil.createSuccessResponse(response));
}
}
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
index fc042c0..d9a38b0 100644
--- a/src/main/java/com/gpt/geumpumtabackend/rank/repository/SeasonRankingSnapshotRepository.java
+++ b/src/main/java/com/gpt/geumpumtabackend/rank/repository/SeasonRankingSnapshotRepository.java
@@ -2,8 +2,11 @@
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;
@@ -21,4 +24,18 @@ List findBySeasonIdAndRankTypeAndDepartment(
);
int countBySeasonId(Long seasonId);
+
+
+ @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/service/SeasonRankService.java b/src/main/java/com/gpt/geumpumtabackend/rank/service/SeasonRankService.java
index 7c8e3ca..bfcaeb4 100644
--- a/src/main/java/com/gpt/geumpumtabackend/rank/service/SeasonRankService.java
+++ b/src/main/java/com/gpt/geumpumtabackend/rank/service/SeasonRankService.java
@@ -3,8 +3,12 @@
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;
@@ -21,7 +25,6 @@
import java.time.LocalDateTime;
import java.util.*;
import java.util.stream.Collectors;
-import java.util.stream.IntStream;
@Service
@@ -31,6 +34,7 @@
public class SeasonRankService {
private final UserRankingRepository userRankingRepository;
+ private final DepartmentRankingRepository departmentRankingRepository;
private final StudySessionRepository studySessionRepository;
private final SeasonService seasonService;
private final SeasonRepository seasonRepository;
@@ -80,48 +84,45 @@ public SeasonRankingResponse getCurrentSeasonRanking() {
}
- public SeasonRankingResponse getCurrentSeasonDepartmentRanking(Department department) {
+ 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<>();
+ List allData = new ArrayList<>();
if (currentMonthStart.isAfter(seasonStart)) {
- List completedMonths = userRankingRepository
- .calculateSeasonDepartmentRankingFromMonthlyRankings(
+ List completedMonths = departmentRankingRepository
+ .calculateSeasonFromMonthlyDepartmentRankings(
seasonStart.atStartOfDay(),
- currentMonthStart.atStartOfDay(),
- department
+ currentMonthStart.atStartOfDay()
);
allData.addAll(completedMonths);
}
if (today.isAfter(currentMonthStart)) {
- List currentMonth = userRankingRepository
- .calculateCurrentMonthDepartmentRankingFromDailyRankings(
+ List currentMonth = departmentRankingRepository
+ .calculateCurrentMonthFromDailyDepartmentRankings(
currentMonthStart.atStartOfDay(),
- today.atStartOfDay(),
- department
+ today.atStartOfDay()
);
allData.addAll(currentMonth);
}
LocalDateTime todayEnd = today.plusDays(1).atStartOfDay();
- List todayRanking = studySessionRepository
- .calculateCurrentPeriodDepartmentRanking(
+ List todayRanking = studySessionRepository
+ .calculateCurrentDepartmentRanking(
today.atStartOfDay(),
todayEnd,
- LocalDateTime.now(),
- department.name()
+ LocalDateTime.now()
);
allData.addAll(todayRanking);
- List finalRankings = mergeAndRank(allData);
+ List finalRankings = mergeAndRankDepartments(allData);
- return SeasonRankingResponse.of(activeSeason, finalRankings);
+ return buildSeasonDepartmentRankingResponse(activeSeason, finalRankings, userId);
}
@@ -142,7 +143,7 @@ public SeasonRankingResponse getEndedSeasonRanking(Long seasonId) {
}
- public SeasonRankingResponse getEndedSeasonDepartmentRanking(Long seasonId, Department department) {
+ public SeasonDepartmentRankingResponse getEndedSeasonDepartmentRanking(Long seasonId, Long userId) {
Season season = seasonRepository.findById(seasonId)
.orElseThrow(() -> new BusinessException(ExceptionType.SEASON_NOT_FOUND));
@@ -150,12 +151,12 @@ public SeasonRankingResponse getEndedSeasonDepartmentRanking(Long seasonId, Depa
throw new BusinessException(ExceptionType.SEASON_NOT_ENDED);
}
- List snapshots = snapshotRepository
- .findBySeasonIdAndRankTypeAndDepartment(seasonId, RankType.DEPARTMENT, department);
+ List aggregated = snapshotRepository
+ .aggregateDepartmentRankingBySeasonId(seasonId);
- List rankings = convertSnapshotsToRankings(snapshots);
+ List finalRankings = mergeAndRankDepartments(aggregated);
- return SeasonRankingResponse.of(season, rankings);
+ return buildSeasonDepartmentRankingResponse(season, finalRankings, userId);
}
@@ -193,6 +194,77 @@ private List convertSnapshotsToRankings(List mergeAndRankDepartments(List