Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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 {
}
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ public enum ExceptionType {
// Study
STUDY_SESSION_NOT_FOUND(NOT_FOUND,"ST001","해당 공부 세션을 찾을 수 없습니다."),
ALREADY_STUDY_SESSION(CONFLICT, "ST002", "세션은 하나만 가능합니다."),
INVALID_END_TIME(CONFLICT,"ST003","유효하지 않은 종료시간입니다."),

// WiFi
WIFI_NOT_CAMPUS_NETWORK(FORBIDDEN, "W001", "캠퍼스 네트워크가 아닙니다"),
Expand All @@ -56,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;
Expand Down
173 changes: 173 additions & 0 deletions src/main/java/com/gpt/geumpumtabackend/rank/api/SeasonRankApi.java
Original file line number Diff line number Diff line change
@@ -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<ResponseBody<SeasonRankingResponse>> 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<ResponseBody<SeasonRankingResponse>> 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<ResponseBody<SeasonRankingResponse>> 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<ResponseBody<SeasonRankingResponse>> getEndedSeasonDepartmentRanking(
@Parameter(hidden = true) Long userId,
@Parameter(
description = "시즌 ID",
example = "1"
)
@PathVariable Long seasonId,
@Parameter(
description = "학과 이름",
example = "COMPUTER_ENGINEERING",
required = true
)
@RequestParam Department department
);
}
Original file line number Diff line number Diff line change
@@ -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<ResponseBody<SeasonRankingResponse>> getCurrentSeasonRanking(Long userId) {
SeasonRankingResponse response = seasonRankService.getCurrentSeasonRanking();
return ResponseEntity.ok(ResponseUtil.createSuccessResponse(response));
}

@GetMapping("/current/department")
@PreAuthorize("isAuthenticated() AND hasRole('USER')")
@AssignUserId
public ResponseEntity<ResponseBody<SeasonRankingResponse>> 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<ResponseBody<SeasonRankingResponse>> 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<ResponseBody<SeasonRankingResponse>> getEndedSeasonDepartmentRanking(
Long userId,
@PathVariable Long seasonId,
@RequestParam Department department
) {
SeasonRankingResponse response = seasonRankService.getEndedSeasonDepartmentRanking(seasonId, department);
return ResponseEntity.ok(ResponseUtil.createSuccessResponse(response));
}
}
17 changes: 17 additions & 0 deletions src/main/java/com/gpt/geumpumtabackend/rank/domain/RankType.java
Original file line number Diff line number Diff line change
@@ -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;
}
Loading