Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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,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;
}
67 changes: 67 additions & 0 deletions src/main/java/com/gpt/geumpumtabackend/rank/domain/Season.java
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
Loading
Loading