From 4b47e918551e41864fcd444b5d3e43fce2b76ae7 Mon Sep 17 00:00:00 2001 From: Juhye0k Date: Tue, 13 Jan 2026 22:32:15 +0900 Subject: [PATCH 01/11] =?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 9a6e2d4d97a6a90f7a3d66ac97bce971a4d008a2 Mon Sep 17 00:00:00 2001 From: Juhye0k Date: Sun, 18 Jan 2026 13:04:46 +0900 Subject: [PATCH 02/11] =?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 03/11] =?UTF-8?q?feat=20:=20=EC=8B=9C=EC=A6=8C=20=EB=9E=AD?= =?UTF-8?q?=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 04/11] =?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 05/11] =?UTF-8?q?refactor=20:=20batch=EB=A5=BC=20=EC=9C=84?= =?UTF-8?q?=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 06/11] =?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 07/11] =?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 08/11] 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 09/11] =?UTF-8?q?refactor=20:=20ranking=20null=20=EC=A0=84?= =?UTF-8?q?=EB=8B=AC=20=EC=8B=9C=20=EB=B9=88=EB=A6=AC=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=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 10/11] =?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 11/11] =?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