From 67225fdc1d741c8699c49129a047d8b3c1414465 Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 9 Jun 2026 11:50:56 +0900 Subject: [PATCH] =?UTF-8?q?feat(ranking):=20=EA=B4=80=EB=A6=AC=EC=9E=90=20?= =?UTF-8?q?=EC=8A=A4=EB=83=85=EC=83=B7=20job=20API=EB=A5=BC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...dminCreatorRankingSnapshotJobController.kt | 54 ++++++ .../AdminCreatorRankingSnapshotJobService.kt | 40 ++++ ...CreatorRankingSnapshotJobControllerTest.kt | 172 ++++++++++++++++++ 3 files changed, 266 insertions(+) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/ranking/creator/AdminCreatorRankingSnapshotJobController.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/ranking/creator/AdminCreatorRankingSnapshotJobService.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/ranking/creator/AdminCreatorRankingSnapshotJobControllerTest.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/ranking/creator/AdminCreatorRankingSnapshotJobController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/ranking/creator/AdminCreatorRankingSnapshotJobController.kt new file mode 100644 index 00000000..b82b9c0e --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/ranking/creator/AdminCreatorRankingSnapshotJobController.kt @@ -0,0 +1,54 @@ +package kr.co.vividnext.sodalive.v2.admin.ranking.creator + +import kr.co.vividnext.sodalive.common.ApiResponse +import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingSnapshotJobStatus +import org.springframework.format.annotation.DateTimeFormat +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.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController +import java.time.LocalDateTime + +@RestController +@RequestMapping("/admin/rankings/creators/snapshot-jobs") +@PreAuthorize("hasRole('ADMIN')") +class AdminCreatorRankingSnapshotJobController( + private val service: AdminCreatorRankingSnapshotJobService +) { + @PostMapping + fun createManualJob( + @RequestBody request: AdminCreatorRankingSnapshotJobRequest + ): ApiResponse { + return ApiResponse.ok(service.createManualJob(request)) + } + + @GetMapping + fun getJobs( + @RequestParam + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) + aggregationStartAtUtc: LocalDateTime, + @RequestParam + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) + aggregationEndAtUtc: LocalDateTime, + @RequestParam(required = false) + statuses: List? + ): ApiResponse> { + return ApiResponse.ok( + service.getJobs( + aggregationStartAtUtc = aggregationStartAtUtc, + aggregationEndAtUtc = aggregationEndAtUtc, + statuses = statuses ?: CreatorRankingSnapshotJobStatus.values().toList() + ) + ) + } + + @PostMapping("/{jobId}/retry") + fun retry(@PathVariable jobId: Long): ApiResponse { + service.retry(jobId) + return ApiResponse.ok(Unit) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/ranking/creator/AdminCreatorRankingSnapshotJobService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/ranking/creator/AdminCreatorRankingSnapshotJobService.kt new file mode 100644 index 00000000..14aa4015 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/ranking/creator/AdminCreatorRankingSnapshotJobService.kt @@ -0,0 +1,40 @@ +package kr.co.vividnext.sodalive.v2.admin.ranking.creator + +import kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingSnapshotJobService +import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingSnapshotJobStatus +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDateTime + +@Service +@Transactional(readOnly = true) +class AdminCreatorRankingSnapshotJobService( + private val jobService: CreatorRankingSnapshotJobService +) { + @Transactional + fun createManualJob(request: AdminCreatorRankingSnapshotJobRequest): AdminCreatorRankingSnapshotJobResponse { + return AdminCreatorRankingSnapshotJobResponse.from( + jobService.createManualJob( + aggregationStartAtUtc = request.aggregationStartAtUtc, + aggregationEndAtUtc = request.aggregationEndAtUtc + ) + ) + } + + fun getJobs( + aggregationStartAtUtc: LocalDateTime, + aggregationEndAtUtc: LocalDateTime, + statuses: List = CreatorRankingSnapshotJobStatus.values().toList() + ): List { + return jobService.findJobs( + aggregationStartAtUtc = aggregationStartAtUtc, + aggregationEndAtUtc = aggregationEndAtUtc, + statuses = statuses + ).map(AdminCreatorRankingSnapshotJobResponse::from) + } + + @Transactional + fun retry(jobId: Long) { + jobService.retryFailedJob(jobId) + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/ranking/creator/AdminCreatorRankingSnapshotJobControllerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/ranking/creator/AdminCreatorRankingSnapshotJobControllerTest.kt new file mode 100644 index 00000000..10981af4 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/ranking/creator/AdminCreatorRankingSnapshotJobControllerTest.kt @@ -0,0 +1,172 @@ +package kr.co.vividnext.sodalive.v2.admin.ranking.creator + +import kr.co.vividnext.sodalive.common.CountryContext +import kr.co.vividnext.sodalive.i18n.LangContext +import kr.co.vividnext.sodalive.i18n.SodaMessageSource +import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingSnapshotJobRecord +import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingSnapshotJobStatus +import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingSnapshotJobTrigger +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.mockito.Mockito +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest +import org.springframework.boot.test.context.TestConfiguration +import org.springframework.boot.test.mock.mockito.MockBean +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Import +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity +import org.springframework.security.config.annotation.web.builders.HttpSecurity +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.anonymous +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user +import org.springframework.security.web.SecurityFilterChain +import org.springframework.security.web.authentication.HttpStatusEntryPoint +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import java.time.LocalDateTime +import javax.servlet.http.HttpServletResponse + +@WebMvcTest(AdminCreatorRankingSnapshotJobController::class) +@Import(AdminCreatorRankingSnapshotJobControllerTest.TestSecurityConfig::class) +class AdminCreatorRankingSnapshotJobControllerTest @Autowired constructor( + private val mockMvc: MockMvc +) { + @MockBean + private lateinit var service: AdminCreatorRankingSnapshotJobService + + @MockBean + private lateinit var countryContext: CountryContext + + @MockBean + private lateinit var langContext: LangContext + + @MockBean + private lateinit var sodaMessageSource: SodaMessageSource + + @TestConfiguration + @EnableGlobalMethodSecurity(prePostEnabled = true) + class TestSecurityConfig { + @Bean + fun securityFilterChain(http: HttpSecurity): SecurityFilterChain { + return http + .csrf().disable() + .authorizeRequests() + .antMatchers("/admin/rankings/creators/snapshot-jobs/**").hasRole("ADMIN") + .anyRequest().permitAll() + .and() + .exceptionHandling() + .authenticationEntryPoint(HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)) + .accessDeniedHandler { _, response, _ -> response.sendError(HttpServletResponse.SC_FORBIDDEN) } + .and() + .build() + } + } + + @Test + @DisplayName("관리자는 날짜 범위로 크리에이터 랭킹 수동 스냅샷 job을 생성한다") + fun shouldCreateManualSnapshotJobForAdmin() { + val response = AdminCreatorRankingSnapshotJobResponse.from(manualJob(status = CreatorRankingSnapshotJobStatus.PENDING)) + Mockito.`when`( + service.createManualJob( + AdminCreatorRankingSnapshotJobRequest( + aggregationStartAtUtc = LocalDateTime.of(2026, 5, 31, 15, 0), + aggregationEndAtUtc = LocalDateTime.of(2026, 6, 7, 15, 0) + ) + ) + ).thenReturn(response) + + mockMvc.perform( + post("/admin/rankings/creators/snapshot-jobs") + .with(user("admin").roles("ADMIN")) + .contentType(MediaType.APPLICATION_JSON) + .content( + """ + { + "aggregationStartAtUtc": "2026-05-31T15:00:00", + "aggregationEndAtUtc": "2026-06-07T15:00:00" + } + """.trimIndent() + ) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.id").value(1L)) + .andExpect(jsonPath("$.data.trigger").value("MANUAL")) + .andExpect(jsonPath("$.data.status").value("PENDING")) + .andExpect(jsonPath("$.data.retryable").value(false)) + } + + @Test + @DisplayName("관리자는 크리에이터 랭킹 스냅샷 job 목록을 조회한다") + fun shouldListSnapshotJobsForAdmin() { + Mockito.`when`( + service.getJobs( + aggregationStartAtUtc = LocalDateTime.of(2026, 5, 31, 15, 0), + aggregationEndAtUtc = LocalDateTime.of(2026, 6, 7, 15, 0), + statuses = listOf(CreatorRankingSnapshotJobStatus.FAILED) + ) + ).thenReturn(listOf(AdminCreatorRankingSnapshotJobResponse.from(manualJob(CreatorRankingSnapshotJobStatus.FAILED)))) + + mockMvc.perform( + get("/admin/rankings/creators/snapshot-jobs") + .param("aggregationStartAtUtc", "2026-05-31T15:00:00") + .param("aggregationEndAtUtc", "2026-06-07T15:00:00") + .param("statuses", "FAILED") + .with(user("admin").roles("ADMIN")) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data[0].status").value("FAILED")) + .andExpect(jsonPath("$.data[0].lastError").value("aggregate failed")) + .andExpect(jsonPath("$.data[0].retryable").value(true)) + } + + @Test + @DisplayName("관리자는 실패한 크리에이터 랭킹 스냅샷 job 재시도를 요청한다") + fun shouldRetryFailedSnapshotJobForAdmin() { + mockMvc.perform( + post("/admin/rankings/creators/snapshot-jobs/1/retry") + .with(user("admin").roles("ADMIN")) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + } + + @Test + @DisplayName("비관리자는 크리에이터 랭킹 스냅샷 job 관리자 API에 접근할 수 없다") + fun shouldRejectNonAdmin() { + mockMvc.perform( + post("/admin/rankings/creators/snapshot-jobs") + .with(user("user").roles("USER")) + .contentType(MediaType.APPLICATION_JSON) + .content("{}") + ) + .andExpect(status().isForbidden) + + mockMvc.perform( + post("/admin/rankings/creators/snapshot-jobs") + .with(anonymous()) + .contentType(MediaType.APPLICATION_JSON) + .content("{}") + ) + .andExpect(status().isUnauthorized) + } + + private fun manualJob(status: CreatorRankingSnapshotJobStatus): CreatorRankingSnapshotJobRecord { + return CreatorRankingSnapshotJobRecord( + id = 1L, + aggregationStartAtUtc = LocalDateTime.of(2026, 5, 31, 15, 0), + aggregationEndAtUtc = LocalDateTime.of(2026, 6, 7, 15, 0), + trigger = CreatorRankingSnapshotJobTrigger.MANUAL, + status = status, + lastError = if (status == CreatorRankingSnapshotJobStatus.FAILED) "aggregate failed" else null, + processingStartedAt = null, + processedAt = null + ) + } +}