From 4aa9f3acc0acd6646fc08b64af6216b43edc3fbb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 17 Mar 2026 03:46:11 +0000 Subject: [PATCH 1/3] Initial plan From 9f1e72d5e0ac6cf727ccc1c4d4c9eef3174662e9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 17 Mar 2026 03:54:14 +0000 Subject: [PATCH 2/3] feat: add daily scenario generation scheduler at midnight KST Co-authored-by: dev-yunseong <88422717+dev-yunseong@users.noreply.github.com> --- .../mainserver/MainServerApplication.kt | 2 + .../scheduler/ScenarioDailyScheduler.kt | 110 ++++++++++++++ .../scheduler/ScenarioDailySchedulerTest.kt | 137 ++++++++++++++++++ 3 files changed, 249 insertions(+) create mode 100644 src/main/kotlin/com/maechuri/mainserver/scenario/scheduler/ScenarioDailyScheduler.kt create mode 100644 src/test/kotlin/com/maechuri/mainserver/scenario/scheduler/ScenarioDailySchedulerTest.kt diff --git a/src/main/kotlin/com/maechuri/mainserver/MainServerApplication.kt b/src/main/kotlin/com/maechuri/mainserver/MainServerApplication.kt index ade3848..9c9ccea 100644 --- a/src/main/kotlin/com/maechuri/mainserver/MainServerApplication.kt +++ b/src/main/kotlin/com/maechuri/mainserver/MainServerApplication.kt @@ -4,9 +4,11 @@ import com.maechuri.mainserver.storage.config.RemoveBgProperties import org.springframework.boot.autoconfigure.SpringBootApplication import org.springframework.boot.context.properties.EnableConfigurationProperties import org.springframework.boot.runApplication +import org.springframework.scheduling.annotation.EnableScheduling @SpringBootApplication @EnableConfigurationProperties(RemoveBgProperties::class) +@EnableScheduling class MainServerApplication fun main(args: Array) { diff --git a/src/main/kotlin/com/maechuri/mainserver/scenario/scheduler/ScenarioDailyScheduler.kt b/src/main/kotlin/com/maechuri/mainserver/scenario/scheduler/ScenarioDailyScheduler.kt new file mode 100644 index 0000000..a38c0c5 --- /dev/null +++ b/src/main/kotlin/com/maechuri/mainserver/scenario/scheduler/ScenarioDailyScheduler.kt @@ -0,0 +1,110 @@ +package com.maechuri.mainserver.scenario.scheduler + +import com.maechuri.mainserver.admin.AdminService +import com.maechuri.mainserver.scenario.client.AiClient +import com.maechuri.mainserver.scenario.dto.ScenarioCreateStatus +import com.maechuri.mainserver.scenario.service.ScenarioGenerationService +import com.maechuri.mainserver.storage.service.ImageGenerationService +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import mu.KotlinLogging +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.scheduling.annotation.Scheduled +import org.springframework.stereotype.Component +import java.time.LocalDate + +private val log = KotlinLogging.logger {} + +@Component +class ScenarioDailyScheduler( + private val scenarioGenerationService: ScenarioGenerationService, + @Qualifier("scenario_ai_client") private val aiClient: AiClient, + private val imageGenerationService: ImageGenerationService, + private val adminService: AdminService, +) { + + companion object { + const val MAX_ATTEMPTS = 5 + const val POLL_INTERVAL_MS = 30_000L + const val MAX_POLL_COUNT = 240 // 240 × 30 s = 2 hours + } + + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + @Scheduled(cron = "0 0 0 * * *", zone = "Asia/Seoul") + fun scheduleScenarioGeneration() { + scope.launch { + generateScenarioWithRetry() + } + } + + internal suspend fun generateScenarioWithRetry() { + val targetDate = LocalDate.now().plusDays(1) + for (attempt in 1..MAX_ATTEMPTS) { + log.info { "Scenario generation attempt $attempt/$MAX_ATTEMPTS (target date: $targetDate)" } + val success = tryGenerateScenario(targetDate) + if (success) { + log.info { "Scenario generation succeeded on attempt $attempt" } + return + } + log.warn { "Scenario generation attempt $attempt failed" } + } + log.error { "Scenario generation failed after $MAX_ATTEMPTS attempts" } + } + + private suspend fun tryGenerateScenario(targetDate: LocalDate): Boolean { + return try { + val response = scenarioGenerationService.startGeneration("random") + log.info { "Scenario generation started, key=${response.key}" } + pollUntilComplete(response.key, targetDate) + } catch (e: Exception) { + log.error(e) { "Scenario generation threw an exception" } + false + } + } + + private suspend fun pollUntilComplete(key: String, targetDate: LocalDate): Boolean { + repeat(MAX_POLL_COUNT) { + delay(POLL_INTERVAL_MS) + val status = try { + aiClient.getScenarioCreateTask(key) + } catch (e: Exception) { + log.error(e) { "Failed to poll status for key=$key" } + return false + } + log.info { "Poll result for key=$key: status=${status.status}" } + when (status.status) { + ScenarioCreateStatus.COMPLETED -> { + val scenarioId = status.scenarioId ?: run { + log.error { "Scenario completed but scenarioId is null for key=$key" } + return false + } + return onScenarioCompleted(scenarioId, targetDate) + } + ScenarioCreateStatus.FAILED -> { + log.warn { "AI server reported FAILED for key=$key: ${status.error}" } + return false + } + else -> { /* PENDING or PROCESSING – keep polling */ } + } + } + log.error { "Polling timed out after $MAX_POLL_COUNT attempts for key=$key" } + return false + } + + private suspend fun onScenarioCompleted(scenarioId: Long, targetDate: LocalDate): Boolean { + return try { + log.info { "Generating images for scenario $scenarioId" } + imageGenerationService.generateImagesForScenario(scenarioId) + adminService.updateScenarioDate(scenarioId, targetDate) + log.info { "Scenario $scenarioId scheduled for $targetDate" } + true + } catch (e: Exception) { + log.error(e) { "Failed to finalize scenario $scenarioId" } + false + } + } +} diff --git a/src/test/kotlin/com/maechuri/mainserver/scenario/scheduler/ScenarioDailySchedulerTest.kt b/src/test/kotlin/com/maechuri/mainserver/scenario/scheduler/ScenarioDailySchedulerTest.kt new file mode 100644 index 0000000..5cc52b0 --- /dev/null +++ b/src/test/kotlin/com/maechuri/mainserver/scenario/scheduler/ScenarioDailySchedulerTest.kt @@ -0,0 +1,137 @@ +package com.maechuri.mainserver.scenario.scheduler + +import com.maechuri.mainserver.admin.AdminService +import com.maechuri.mainserver.scenario.client.AiClient +import com.maechuri.mainserver.scenario.dto.ScenarioCreateResponse +import com.maechuri.mainserver.scenario.dto.ScenarioCreateStatus +import com.maechuri.mainserver.scenario.dto.ScenarioStatusResponse +import com.maechuri.mainserver.scenario.service.ScenarioGenerationService +import com.maechuri.mainserver.storage.service.ImageGenerationService +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +class ScenarioDailySchedulerTest { + + private val scenarioGenerationService: ScenarioGenerationService = mock() + private val aiClient: AiClient = mock() + private val imageGenerationService: ImageGenerationService = mock() + private val adminService: AdminService = mock() + + private val scheduler = ScenarioDailyScheduler( + scenarioGenerationService, aiClient, imageGenerationService, adminService + ) + + private fun createResponse(key: String = "test-key") = + ScenarioCreateResponse(key = key, message = "ok", status = ScenarioCreateStatus.PENDING, theme = "random") + + private fun statusResponse(status: ScenarioCreateStatus, scenarioId: Long? = null, error: String? = null) = + ScenarioStatusResponse(key = "test-key", status = status, theme = "random", scenarioId = scenarioId, error = error) + + @Test + fun `generateScenarioWithRetry succeeds on first attempt`() = runTest { + whenever(scenarioGenerationService.startGeneration(any())).thenReturn(createResponse()) + whenever(aiClient.getScenarioCreateTask(any())) + .thenReturn(statusResponse(ScenarioCreateStatus.COMPLETED, scenarioId = 1L)) + + scheduler.generateScenarioWithRetry() + + verify(imageGenerationService, times(1)).generateImagesForScenario(1L) + verify(adminService, times(1)).updateScenarioDate(any(), any()) + } + + @Test + fun `generateScenarioWithRetry retries on FAILED and succeeds on second attempt`() = runTest { + val firstKey = "key-1" + val secondKey = "key-2" + + whenever(scenarioGenerationService.startGeneration(any())) + .thenReturn(createResponse(firstKey)) + .thenReturn(createResponse(secondKey)) + whenever(aiClient.getScenarioCreateTask(firstKey)) + .thenReturn(statusResponse(ScenarioCreateStatus.FAILED)) + whenever(aiClient.getScenarioCreateTask(secondKey)) + .thenReturn(statusResponse(ScenarioCreateStatus.COMPLETED, scenarioId = 2L)) + + scheduler.generateScenarioWithRetry() + + verify(scenarioGenerationService, times(2)).startGeneration(any()) + verify(imageGenerationService, times(1)).generateImagesForScenario(2L) + verify(adminService, times(1)).updateScenarioDate(any(), any()) + } + + @Test + fun `generateScenarioWithRetry stops after MAX_ATTEMPTS`() = runTest { + whenever(scenarioGenerationService.startGeneration(any())).thenReturn(createResponse()) + whenever(aiClient.getScenarioCreateTask(any())) + .thenReturn(statusResponse(ScenarioCreateStatus.FAILED)) + + scheduler.generateScenarioWithRetry() + + verify(scenarioGenerationService, times(ScenarioDailyScheduler.MAX_ATTEMPTS)).startGeneration(any()) + verify(imageGenerationService, never()).generateImagesForScenario(any()) + verify(adminService, never()).updateScenarioDate(any(), any()) + } + + @Test + fun `generateScenarioWithRetry returns false when startGeneration throws`() = runTest { + whenever(scenarioGenerationService.startGeneration(any())) + .thenThrow(RuntimeException("AI server unavailable")) + + scheduler.generateScenarioWithRetry() + + verify(imageGenerationService, never()).generateImagesForScenario(any()) + verify(adminService, never()).updateScenarioDate(any(), any()) + verify(scenarioGenerationService, times(ScenarioDailyScheduler.MAX_ATTEMPTS)).startGeneration(any()) + } + + @Test + fun `generateScenarioWithRetry returns false when scenarioId is null on COMPLETED`() = runTest { + whenever(scenarioGenerationService.startGeneration(any())).thenReturn(createResponse()) + whenever(aiClient.getScenarioCreateTask(any())) + .thenReturn(statusResponse(ScenarioCreateStatus.COMPLETED, scenarioId = null)) + + scheduler.generateScenarioWithRetry() + + verify(imageGenerationService, never()).generateImagesForScenario(any()) + verify(adminService, never()).updateScenarioDate(any(), any()) + } + + @Test + fun `generateScenarioWithRetry stops polling after MAX_POLL_COUNT and retries`() = runTest { + whenever(scenarioGenerationService.startGeneration(any())).thenReturn(createResponse()) + // Always return PROCESSING so the poll loop times out + whenever(aiClient.getScenarioCreateTask(any())) + .thenReturn(statusResponse(ScenarioCreateStatus.PROCESSING)) + + scheduler.generateScenarioWithRetry() + + verify(scenarioGenerationService, times(ScenarioDailyScheduler.MAX_ATTEMPTS)).startGeneration(any()) + verify(imageGenerationService, never()).generateImagesForScenario(any()) + verify(adminService, never()).updateScenarioDate(any(), any()) + } + + @Test + fun `generateScenarioWithRetry retries when polling throws exception`() = runTest { + val firstKey = "key-1" + val secondKey = "key-2" + + whenever(scenarioGenerationService.startGeneration(any())) + .thenReturn(createResponse(firstKey)) + .thenReturn(createResponse(secondKey)) + whenever(aiClient.getScenarioCreateTask(firstKey)) + .thenThrow(RuntimeException("network error")) + whenever(aiClient.getScenarioCreateTask(secondKey)) + .thenReturn(statusResponse(ScenarioCreateStatus.COMPLETED, scenarioId = 3L)) + + scheduler.generateScenarioWithRetry() + + verify(scenarioGenerationService, times(2)).startGeneration(any()) + verify(imageGenerationService, times(1)).generateImagesForScenario(3L) + } +} From d06da9ec255d96b9f0f42095d727f659652134cb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 17 Mar 2026 04:00:54 +0000 Subject: [PATCH 3/3] feat: skip if tomorrow scenario exists; trigger generation on server startup Co-authored-by: dev-yunseong <88422717+dev-yunseong@users.noreply.github.com> --- .../scheduler/ScenarioDailyScheduler.kt | 17 +++++++ .../scheduler/ScenarioDailySchedulerTest.kt | 51 ++++++++++++++++++- 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/com/maechuri/mainserver/scenario/scheduler/ScenarioDailyScheduler.kt b/src/main/kotlin/com/maechuri/mainserver/scenario/scheduler/ScenarioDailyScheduler.kt index a38c0c5..bc4b1ee 100644 --- a/src/main/kotlin/com/maechuri/mainserver/scenario/scheduler/ScenarioDailyScheduler.kt +++ b/src/main/kotlin/com/maechuri/mainserver/scenario/scheduler/ScenarioDailyScheduler.kt @@ -3,6 +3,7 @@ package com.maechuri.mainserver.scenario.scheduler import com.maechuri.mainserver.admin.AdminService import com.maechuri.mainserver.scenario.client.AiClient import com.maechuri.mainserver.scenario.dto.ScenarioCreateStatus +import com.maechuri.mainserver.scenario.repository.ScenarioRepository import com.maechuri.mainserver.scenario.service.ScenarioGenerationService import com.maechuri.mainserver.storage.service.ImageGenerationService import kotlinx.coroutines.CoroutineScope @@ -10,8 +11,11 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import kotlinx.coroutines.reactor.awaitSingleOrNull import mu.KotlinLogging import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.boot.context.event.ApplicationReadyEvent +import org.springframework.context.event.EventListener import org.springframework.scheduling.annotation.Scheduled import org.springframework.stereotype.Component import java.time.LocalDate @@ -24,6 +28,7 @@ class ScenarioDailyScheduler( @Qualifier("scenario_ai_client") private val aiClient: AiClient, private val imageGenerationService: ImageGenerationService, private val adminService: AdminService, + private val scenarioRepository: ScenarioRepository, ) { companion object { @@ -34,6 +39,13 @@ class ScenarioDailyScheduler( private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + @EventListener(ApplicationReadyEvent::class) + fun onApplicationReady() { + scope.launch { + generateScenarioWithRetry() + } + } + @Scheduled(cron = "0 0 0 * * *", zone = "Asia/Seoul") fun scheduleScenarioGeneration() { scope.launch { @@ -43,6 +55,11 @@ class ScenarioDailyScheduler( internal suspend fun generateScenarioWithRetry() { val targetDate = LocalDate.now().plusDays(1) + val existing = scenarioRepository.findByDate(targetDate).awaitSingleOrNull() + if (existing != null) { + log.info { "Scenario for $targetDate already exists (id=${existing.scenarioId}), skipping generation" } + return + } for (attempt in 1..MAX_ATTEMPTS) { log.info { "Scenario generation attempt $attempt/$MAX_ATTEMPTS (target date: $targetDate)" } val success = tryGenerateScenario(targetDate) diff --git a/src/test/kotlin/com/maechuri/mainserver/scenario/scheduler/ScenarioDailySchedulerTest.kt b/src/test/kotlin/com/maechuri/mainserver/scenario/scheduler/ScenarioDailySchedulerTest.kt index 5cc52b0..3862512 100644 --- a/src/test/kotlin/com/maechuri/mainserver/scenario/scheduler/ScenarioDailySchedulerTest.kt +++ b/src/test/kotlin/com/maechuri/mainserver/scenario/scheduler/ScenarioDailySchedulerTest.kt @@ -5,6 +5,9 @@ import com.maechuri.mainserver.scenario.client.AiClient import com.maechuri.mainserver.scenario.dto.ScenarioCreateResponse import com.maechuri.mainserver.scenario.dto.ScenarioCreateStatus import com.maechuri.mainserver.scenario.dto.ScenarioStatusResponse +import com.maechuri.mainserver.scenario.entity.Difficulty +import com.maechuri.mainserver.scenario.entity.Scenario +import com.maechuri.mainserver.scenario.repository.ScenarioRepository import com.maechuri.mainserver.scenario.service.ScenarioGenerationService import com.maechuri.mainserver.storage.service.ImageGenerationService import kotlinx.coroutines.test.runTest @@ -15,6 +18,11 @@ import org.mockito.kotlin.never import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever +import reactor.core.publisher.Mono +import java.sql.Time +import java.sql.Timestamp +import java.time.LocalDate +import java.time.LocalTime class ScenarioDailySchedulerTest { @@ -22,9 +30,10 @@ class ScenarioDailySchedulerTest { private val aiClient: AiClient = mock() private val imageGenerationService: ImageGenerationService = mock() private val adminService: AdminService = mock() + private val scenarioRepository: ScenarioRepository = mock() private val scheduler = ScenarioDailyScheduler( - scenarioGenerationService, aiClient, imageGenerationService, adminService + scenarioGenerationService, aiClient, imageGenerationService, adminService, scenarioRepository ) private fun createResponse(key: String = "test-key") = @@ -33,8 +42,42 @@ class ScenarioDailySchedulerTest { private fun statusResponse(status: ScenarioCreateStatus, scenarioId: Long? = null, error: String? = null) = ScenarioStatusResponse(key = "test-key", status = status, theme = "random", scenarioId = scenarioId, error = error) + private fun scenarioEntity(id: Long = 1L, date: LocalDate? = null) = Scenario( + scenarioId = id, + difficulty = Difficulty.easy, + theme = "Theme", + tone = "Tone", + language = "ko", + incidentType = "Type", + incidentSummary = "Summary", + incidentTimeStart = Time.valueOf(LocalTime.NOON), + incidentTimeEnd = Time.valueOf(LocalTime.MIDNIGHT), + primaryObject = "Object", + crimeTimeStart = Time.valueOf(LocalTime.NOON), + crimeTimeEnd = Time.valueOf(LocalTime.MIDNIGHT), + crimeMethod = "Method", + noSupernatural = true, + noTimeTravel = true, + createdAt = Timestamp(System.currentTimeMillis()), + incidentLocationId = null, + crimeLocationId = null, + date = date, + ) + + @Test + fun `generateScenarioWithRetry skips when scenario for tomorrow already exists`() = runTest { + whenever(scenarioRepository.findByDate(any())).thenReturn(Mono.just(scenarioEntity(date = LocalDate.now().plusDays(1)))) + + scheduler.generateScenarioWithRetry() + + verify(scenarioGenerationService, never()).startGeneration(any()) + verify(imageGenerationService, never()).generateImagesForScenario(any()) + verify(adminService, never()).updateScenarioDate(any(), any()) + } + @Test fun `generateScenarioWithRetry succeeds on first attempt`() = runTest { + whenever(scenarioRepository.findByDate(any())).thenReturn(Mono.empty()) whenever(scenarioGenerationService.startGeneration(any())).thenReturn(createResponse()) whenever(aiClient.getScenarioCreateTask(any())) .thenReturn(statusResponse(ScenarioCreateStatus.COMPLETED, scenarioId = 1L)) @@ -50,6 +93,7 @@ class ScenarioDailySchedulerTest { val firstKey = "key-1" val secondKey = "key-2" + whenever(scenarioRepository.findByDate(any())).thenReturn(Mono.empty()) whenever(scenarioGenerationService.startGeneration(any())) .thenReturn(createResponse(firstKey)) .thenReturn(createResponse(secondKey)) @@ -67,6 +111,7 @@ class ScenarioDailySchedulerTest { @Test fun `generateScenarioWithRetry stops after MAX_ATTEMPTS`() = runTest { + whenever(scenarioRepository.findByDate(any())).thenReturn(Mono.empty()) whenever(scenarioGenerationService.startGeneration(any())).thenReturn(createResponse()) whenever(aiClient.getScenarioCreateTask(any())) .thenReturn(statusResponse(ScenarioCreateStatus.FAILED)) @@ -80,6 +125,7 @@ class ScenarioDailySchedulerTest { @Test fun `generateScenarioWithRetry returns false when startGeneration throws`() = runTest { + whenever(scenarioRepository.findByDate(any())).thenReturn(Mono.empty()) whenever(scenarioGenerationService.startGeneration(any())) .thenThrow(RuntimeException("AI server unavailable")) @@ -92,6 +138,7 @@ class ScenarioDailySchedulerTest { @Test fun `generateScenarioWithRetry returns false when scenarioId is null on COMPLETED`() = runTest { + whenever(scenarioRepository.findByDate(any())).thenReturn(Mono.empty()) whenever(scenarioGenerationService.startGeneration(any())).thenReturn(createResponse()) whenever(aiClient.getScenarioCreateTask(any())) .thenReturn(statusResponse(ScenarioCreateStatus.COMPLETED, scenarioId = null)) @@ -104,6 +151,7 @@ class ScenarioDailySchedulerTest { @Test fun `generateScenarioWithRetry stops polling after MAX_POLL_COUNT and retries`() = runTest { + whenever(scenarioRepository.findByDate(any())).thenReturn(Mono.empty()) whenever(scenarioGenerationService.startGeneration(any())).thenReturn(createResponse()) // Always return PROCESSING so the poll loop times out whenever(aiClient.getScenarioCreateTask(any())) @@ -121,6 +169,7 @@ class ScenarioDailySchedulerTest { val firstKey = "key-1" val secondKey = "key-2" + whenever(scenarioRepository.findByDate(any())).thenReturn(Mono.empty()) whenever(scenarioGenerationService.startGeneration(any())) .thenReturn(createResponse(firstKey)) .thenReturn(createResponse(secondKey))