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
Original file line number Diff line number Diff line change
Expand Up @@ -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, "코스 상세 조회에 성공했습니다."),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ public interface RecordRepository extends Repository<Record, Long> {

Optional<Record> 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<Record> findAllByPublicCourseIsNotNull();

long countByRunnectUser(RunnectUser runnectUser);

List<Record> findByIdIn(Collection<Long> ids);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<String, String, String> = mock(HashOperations::class.java) as HashOperations<String, String, String>
whenever(stringRedisTemplate.opsForHash<String, String>()).thenReturn(hashOps)
// 첫 번째 기록만 실제로 더 빠른 기록으로 갱신되고, 두 번째는 갱신되지 않는 상황을 시뮬레이션
whenever(stringRedisTemplate.execute(any<RedisCallback<Boolean>>()))
.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)
Expand Down
Loading