diff --git a/src/main/java/org/runnect/server/common/constant/SuccessStatus.java b/src/main/java/org/runnect/server/common/constant/SuccessStatus.java index 81c0d77..911bdff 100644 --- a/src/main/java/org/runnect/server/common/constant/SuccessStatus.java +++ b/src/main/java/org/runnect/server/common/constant/SuccessStatus.java @@ -17,6 +17,7 @@ public enum SuccessStatus { GET_RECORD_SUCCESS(HttpStatus.OK, "활동 기록 조회 성공"), GET_RECORD_RANKING_SUCCESS(HttpStatus.OK, "코스 기록 랭킹 조회 성공"), GET_MY_RECORD_RANKING_SUCCESS(HttpStatus.OK, "내 코스 기록 랭킹 조회 성공"), + BACKFILL_RANKING_SUCCESS(HttpStatus.OK, "코스 기록 랭킹 백필 성공"), GET_COURSE_LIST_BY_USER_SUCCESS(HttpStatus.OK, "내가 그린 코스 리스트 조회에 성공했습니다."), GET_SCRAP_COURSE_BY_USER_SUCCESS(HttpStatus.OK, "스크랩한 코스 조회 성공"), GET_COURSE_DETAIL_SUCCESS(HttpStatus.OK, "코스 상세 조회에 성공했습니다."), diff --git a/src/main/java/org/runnect/server/record/repository/RecordRepository.java b/src/main/java/org/runnect/server/record/repository/RecordRepository.java index fe2c80f..d454179 100644 --- a/src/main/java/org/runnect/server/record/repository/RecordRepository.java +++ b/src/main/java/org/runnect/server/record/repository/RecordRepository.java @@ -23,6 +23,10 @@ public interface RecordRepository extends Repository { Optional findById(Long recordId); + // 코스별 기록 랭킹(Redis) 백필용 — 공개 코스에 연결된 기록만 대상으로 함 + @Query("SELECT r FROM Record r JOIN FETCH r.runnectUser JOIN FETCH r.publicCourse WHERE r.publicCourse IS NOT NULL") + List findAllByPublicCourseIsNotNull(); + long countByRunnectUser(RunnectUser runnectUser); List findByIdIn(Collection ids); diff --git a/src/main/kotlin/org/runnect/server/ranking/controller/RecordRankingController.kt b/src/main/kotlin/org/runnect/server/ranking/controller/RecordRankingController.kt index f60e229..a5510c8 100644 --- a/src/main/kotlin/org/runnect/server/ranking/controller/RecordRankingController.kt +++ b/src/main/kotlin/org/runnect/server/ranking/controller/RecordRankingController.kt @@ -7,6 +7,7 @@ import org.runnect.server.ranking.service.RecordRankingService import org.springframework.http.HttpStatus import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.ResponseStatus @@ -18,6 +19,15 @@ class RecordRankingController( private val recordRankingService: RecordRankingService, ) { + // 배포 시 1회성으로 실행하는 운영용 백필 엔드포인트. 인증 없이 열려있지만 + // ZADD LT라 여러 번 실행해도 안전(idempotent)하다. 값을 반환할 뿐 파괴적 동작은 없다. + @PostMapping("internal/ranking/backfill") + @ResponseStatus(HttpStatus.OK) + fun backfillRanking() = ApiResponseDto.success( + SuccessStatus.BACKFILL_RANKING_SUCCESS, + mapOf("updatedCount" to recordRankingService.backfillAll()), + ) + @GetMapping("course/{courseId}/ranking") @ResponseStatus(HttpStatus.OK) fun getRanking( diff --git a/src/main/kotlin/org/runnect/server/ranking/service/RecordRankingService.kt b/src/main/kotlin/org/runnect/server/ranking/service/RecordRankingService.kt index f05bdea..a0964a3 100644 --- a/src/main/kotlin/org/runnect/server/ranking/service/RecordRankingService.kt +++ b/src/main/kotlin/org/runnect/server/ranking/service/RecordRankingService.kt @@ -48,6 +48,18 @@ class RecordRankingService( return updated } + /** + * 이 랭킹 기능이 배포되기 전에 이미 쌓여있던 완주 기록을 Redis로 백필한다. + * 신규 기능 배포 시 1회성으로 실행하는 운영 작업 — 몇 번을 다시 돌려도 + * ZADD LT가 "더 느린 기록이면 무시"하므로 안전하다(idempotent). + */ + fun backfillAll(): Int { + val records = recordRepository.findAllByPublicCourseIsNotNull() + return records.count { record -> + updateBestRecord(record.publicCourse.id, record.runnectUser.id, record.id, record.time) + } + } + fun getRanking(courseId: Long, limit: Long): RankingListResponse { val zSetOps = stringRedisTemplate.opsForZSet() val totalCount = zSetOps.zCard(rankingKey(courseId)) ?: 0L diff --git a/src/test/kotlin/org/runnect/server/ranking/service/RecordRankingServiceTest.kt b/src/test/kotlin/org/runnect/server/ranking/service/RecordRankingServiceTest.kt index 5fd4630..6d1e177 100644 --- a/src/test/kotlin/org/runnect/server/ranking/service/RecordRankingServiceTest.kt +++ b/src/test/kotlin/org/runnect/server/ranking/service/RecordRankingServiceTest.kt @@ -10,6 +10,7 @@ import org.mockito.junit.jupiter.MockitoExtension import org.mockito.kotlin.any import org.mockito.kotlin.whenever import org.runnect.server.course.entity.Course +import org.runnect.server.publicCourse.entity.PublicCourse import org.runnect.server.record.entity.Record import org.runnect.server.record.repository.RecordRepository import org.runnect.server.user.entity.RunnectUser @@ -115,6 +116,49 @@ class RecordRankingServiceTest { assertThat(myRanking.rank).isNull() } + @Test + fun `backfillAll은 publicCourse가 있는 기록만 대상으로 랭킹을 갱신하고 실제 갱신된 개수를 반환한다`() { + val user1 = buildUser(1L, "런너A") + val user2 = buildUser(2L, "런너B") + val recordWithPublicCourse1 = buildRecordWithPublicCourse(id = 100L, owner = user1, publicCourseId = 1L) + val recordWithPublicCourse2 = buildRecordWithPublicCourse(id = 101L, owner = user2, publicCourseId = 1L) + + whenever(recordRepository.findAllByPublicCourseIsNotNull()) + .thenReturn(listOf(recordWithPublicCourse1, recordWithPublicCourse2)) + + val hashOps: HashOperations = mock(HashOperations::class.java) as HashOperations + whenever(stringRedisTemplate.opsForHash()).thenReturn(hashOps) + // 첫 번째 기록만 실제로 더 빠른 기록으로 갱신되고, 두 번째는 갱신되지 않는 상황을 시뮬레이션 + whenever(stringRedisTemplate.execute(any>())) + .thenReturn(true) + .thenReturn(false) + + val updatedCount = service.backfillAll() + + assertThat(updatedCount).isEqualTo(1) + } + + private fun buildRecordWithPublicCourse(id: Long, owner: RunnectUser, publicCourseId: Long): Record { + val course = buildCourse(id + 1000, owner) + val publicCourse = PublicCourse.builder() + .course(course) + .title("공개 코스") + .description("설명") + .build() + ReflectionTestUtils.setField(publicCourse, "id", publicCourseId) + + val record = Record.builder() + .runnectUser(owner) + .course(course) + .publicCourse(publicCourse) + .title("퇴근길 러닝") + .pace(Time.valueOf("00:05:30")) + .time(Time.valueOf("00:25:00")) + .build() + ReflectionTestUtils.setField(record, "id", id) + return record + } + private fun buildUser(id: Long, nickname: String): RunnectUser { val user = RunnectUser.builder() .nickname(nickname)