Skip to content

Commit 0a8a26e

Browse files
unam98alh0409
andauthored
feat: 기존 완주 기록을 랭킹(Redis)으로 백필하는 운영 엔드포인트 추가 (#250)
신규 랭킹 기능 배포 전에 이미 쌓여있던 Record는 updateBestRecord 훅을 거친 적이 없어 Redis 랭킹에 반영되지 않는다. POST /internal/ranking/backfill로 전체 기록을 1회성으로 재계산한다. ZADD LT라 여러 번 실행해도 안전하다. Co-authored-by: 나미 <dnska6657@gmail.com>
1 parent 1e7ded0 commit 0a8a26e

5 files changed

Lines changed: 71 additions & 0 deletions

File tree

src/main/java/org/runnect/server/common/constant/SuccessStatus.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ public enum SuccessStatus {
1717
GET_RECORD_SUCCESS(HttpStatus.OK, "활동 기록 조회 성공"),
1818
GET_RECORD_RANKING_SUCCESS(HttpStatus.OK, "코스 기록 랭킹 조회 성공"),
1919
GET_MY_RECORD_RANKING_SUCCESS(HttpStatus.OK, "내 코스 기록 랭킹 조회 성공"),
20+
BACKFILL_RANKING_SUCCESS(HttpStatus.OK, "코스 기록 랭킹 백필 성공"),
2021
GET_COURSE_LIST_BY_USER_SUCCESS(HttpStatus.OK, "내가 그린 코스 리스트 조회에 성공했습니다."),
2122
GET_SCRAP_COURSE_BY_USER_SUCCESS(HttpStatus.OK, "스크랩한 코스 조회 성공"),
2223
GET_COURSE_DETAIL_SUCCESS(HttpStatus.OK, "코스 상세 조회에 성공했습니다."),

src/main/java/org/runnect/server/record/repository/RecordRepository.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@ public interface RecordRepository extends Repository<Record, Long> {
2323

2424
Optional<Record> findById(Long recordId);
2525

26+
// 코스별 기록 랭킹(Redis) 백필용 — 공개 코스에 연결된 기록만 대상으로 함
27+
@Query("SELECT r FROM Record r JOIN FETCH r.runnectUser JOIN FETCH r.publicCourse WHERE r.publicCourse IS NOT NULL")
28+
List<Record> findAllByPublicCourseIsNotNull();
29+
2630
long countByRunnectUser(RunnectUser runnectUser);
2731

2832
List<Record> findByIdIn(Collection<Long> ids);

src/main/kotlin/org/runnect/server/ranking/controller/RecordRankingController.kt

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import org.runnect.server.ranking.service.RecordRankingService
77
import org.springframework.http.HttpStatus
88
import org.springframework.web.bind.annotation.GetMapping
99
import org.springframework.web.bind.annotation.PathVariable
10+
import org.springframework.web.bind.annotation.PostMapping
1011
import org.springframework.web.bind.annotation.RequestMapping
1112
import org.springframework.web.bind.annotation.RequestParam
1213
import org.springframework.web.bind.annotation.ResponseStatus
@@ -18,6 +19,15 @@ class RecordRankingController(
1819
private val recordRankingService: RecordRankingService,
1920
) {
2021

22+
// 배포 시 1회성으로 실행하는 운영용 백필 엔드포인트. 인증 없이 열려있지만
23+
// ZADD LT라 여러 번 실행해도 안전(idempotent)하다. 값을 반환할 뿐 파괴적 동작은 없다.
24+
@PostMapping("internal/ranking/backfill")
25+
@ResponseStatus(HttpStatus.OK)
26+
fun backfillRanking() = ApiResponseDto.success(
27+
SuccessStatus.BACKFILL_RANKING_SUCCESS,
28+
mapOf("updatedCount" to recordRankingService.backfillAll()),
29+
)
30+
2131
@GetMapping("course/{courseId}/ranking")
2232
@ResponseStatus(HttpStatus.OK)
2333
fun getRanking(

src/main/kotlin/org/runnect/server/ranking/service/RecordRankingService.kt

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,18 @@ class RecordRankingService(
4848
return updated
4949
}
5050

51+
/**
52+
* 이 랭킹 기능이 배포되기 전에 이미 쌓여있던 완주 기록을 Redis로 백필한다.
53+
* 신규 기능 배포 시 1회성으로 실행하는 운영 작업 — 몇 번을 다시 돌려도
54+
* ZADD LT가 "더 느린 기록이면 무시"하므로 안전하다(idempotent).
55+
*/
56+
fun backfillAll(): Int {
57+
val records = recordRepository.findAllByPublicCourseIsNotNull()
58+
return records.count { record ->
59+
updateBestRecord(record.publicCourse.id, record.runnectUser.id, record.id, record.time)
60+
}
61+
}
62+
5163
fun getRanking(courseId: Long, limit: Long): RankingListResponse {
5264
val zSetOps = stringRedisTemplate.opsForZSet()
5365
val totalCount = zSetOps.zCard(rankingKey(courseId)) ?: 0L

src/test/kotlin/org/runnect/server/ranking/service/RecordRankingServiceTest.kt

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import org.mockito.junit.jupiter.MockitoExtension
1010
import org.mockito.kotlin.any
1111
import org.mockito.kotlin.whenever
1212
import org.runnect.server.course.entity.Course
13+
import org.runnect.server.publicCourse.entity.PublicCourse
1314
import org.runnect.server.record.entity.Record
1415
import org.runnect.server.record.repository.RecordRepository
1516
import org.runnect.server.user.entity.RunnectUser
@@ -115,6 +116,49 @@ class RecordRankingServiceTest {
115116
assertThat(myRanking.rank).isNull()
116117
}
117118

119+
@Test
120+
fun `backfillAll은 publicCourse가 있는 기록만 대상으로 랭킹을 갱신하고 실제 갱신된 개수를 반환한다`() {
121+
val user1 = buildUser(1L, "런너A")
122+
val user2 = buildUser(2L, "런너B")
123+
val recordWithPublicCourse1 = buildRecordWithPublicCourse(id = 100L, owner = user1, publicCourseId = 1L)
124+
val recordWithPublicCourse2 = buildRecordWithPublicCourse(id = 101L, owner = user2, publicCourseId = 1L)
125+
126+
whenever(recordRepository.findAllByPublicCourseIsNotNull())
127+
.thenReturn(listOf(recordWithPublicCourse1, recordWithPublicCourse2))
128+
129+
val hashOps: HashOperations<String, String, String> = mock(HashOperations::class.java) as HashOperations<String, String, String>
130+
whenever(stringRedisTemplate.opsForHash<String, String>()).thenReturn(hashOps)
131+
// 첫 번째 기록만 실제로 더 빠른 기록으로 갱신되고, 두 번째는 갱신되지 않는 상황을 시뮬레이션
132+
whenever(stringRedisTemplate.execute(any<RedisCallback<Boolean>>()))
133+
.thenReturn(true)
134+
.thenReturn(false)
135+
136+
val updatedCount = service.backfillAll()
137+
138+
assertThat(updatedCount).isEqualTo(1)
139+
}
140+
141+
private fun buildRecordWithPublicCourse(id: Long, owner: RunnectUser, publicCourseId: Long): Record {
142+
val course = buildCourse(id + 1000, owner)
143+
val publicCourse = PublicCourse.builder()
144+
.course(course)
145+
.title("공개 코스")
146+
.description("설명")
147+
.build()
148+
ReflectionTestUtils.setField(publicCourse, "id", publicCourseId)
149+
150+
val record = Record.builder()
151+
.runnectUser(owner)
152+
.course(course)
153+
.publicCourse(publicCourse)
154+
.title("퇴근길 러닝")
155+
.pace(Time.valueOf("00:05:30"))
156+
.time(Time.valueOf("00:25:00"))
157+
.build()
158+
ReflectionTestUtils.setField(record, "id", id)
159+
return record
160+
}
161+
118162
private fun buildUser(id: Long, nickname: String): RunnectUser {
119163
val user = RunnectUser.builder()
120164
.nickname(nickname)

0 commit comments

Comments
 (0)