From 8829df5bb6eb4e2613ddcc1245e06d5369af0166 Mon Sep 17 00:00:00 2001 From: Kim Seoyeon Date: Mon, 12 Oct 2026 18:57:46 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20SSE=20complete=20=EA=B2=B0=EA=B3=BC?= =?UTF-8?q?=EC=97=90=20root=5Fgeneration=5Fid=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 프론트가 버전 목록·되돌리기 API를 쓰려면 root ID가 필요한데, 지금은 최초 생성 때 받은 song_id를 직접 보관해야 했다. 재생성 도중 새로고침해서 마지막 task_id만 남으면 root를 알 방법이 없었다. task_id로 SSE를 다시 구독하면 root까지 얻도록 complete 이벤트의 result에 root_generation_id를 추가한다. - CompletionResult 에 root_generation_id 추가 (최초 생성곡은 song_id와 같은 값, 재생성본은 원본의 ID, 백필 전 기존 곡은 null) - 이미 완료된 작업 구독 / 구독 중 AI 완료 콜백 수신, 두 경로 모두 반영 - Swagger의 SSE 설명 갱신. complete 이벤트는 성공(COMPLETED)만 전송하고 실패·취소는 progress 이벤트로 전달된다는 기존 설명 오류도 바로잡음 - SSE complete 이벤트를 검증하는 테스트 추가 (4개) Co-Authored-By: Claude Sonnet 5.5 --- .../controller/GenerationControllerDocs.java | 8 +- .../dto/MusicGenerationDTO.java | 4 + .../service/MusicGenerationService.java | 2 + .../TaskStreamCompletionTest.java | 172 ++++++++++++++++++ 4 files changed, 183 insertions(+), 3 deletions(-) create mode 100644 src/test/java/com/humix/api/domain/musicGeneration/TaskStreamCompletionTest.java diff --git a/src/main/java/com/humix/api/domain/musicGeneration/controller/GenerationControllerDocs.java b/src/main/java/com/humix/api/domain/musicGeneration/controller/GenerationControllerDocs.java index 1e99cc5..71c707c 100644 --- a/src/main/java/com/humix/api/domain/musicGeneration/controller/GenerationControllerDocs.java +++ b/src/main/java/com/humix/api/domain/musicGeneration/controller/GenerationControllerDocs.java @@ -95,10 +95,12 @@ ApiResponse generateSong( description = "Task ID를 통해 비동기 작업 진행률과 완료 결과를 Server-Sent Events(SSE) 스트림으로 받습니다.\n\n" + "**이벤트 종류:**\n" + "- `connect`: 최초 연결 성공 시 전송\n" + - "- `progress`: 진행률 업데이트 (status: PROCESSING, progress: 0~90)\n" + - "- `complete`: 작업 완료 시 전송 (status: COMPLETED / FAILED)\n\n" + + "- `progress`: 진행률 업데이트 및 실패·취소 통보 (status: PROCESSING / FAILED / CANCELED, progress: 0~90)\n" + + "- `complete`: 작업이 **성공적으로 완료**됐을 때만 전송 (status: COMPLETED). 실패·취소는 `progress` 이벤트로 옵니다.\n\n" + "**progress 이벤트 예시:** `{\"task_id\": \"task_xxx\", \"status\": \"PROCESSING\", \"progress\": 50}`\n\n" + - "**complete 이벤트 예시:** `{\"status\": \"COMPLETED\", \"result\": {\"task_id\": \"task_xxx\", \"song_id\": 42, \"audio_url\": \"https://...\", \"duration_seconds\": 30.0}}`" + "**complete 이벤트 예시:** `{\"status\": \"COMPLETED\", \"result\": {\"task_id\": \"task_xxx\", \"song_id\": 42, \"root_generation_id\": 40, \"audio_url\": \"https://...\", \"duration_seconds\": 30.0}}`\n\n" + + "`root_generation_id`는 같은 곡의 버전 묶음 기준(원본) ID입니다. 최초 생성곡은 `song_id`와 같고, 재생성본은 원본의 ID입니다. " + + "버전 목록(`/{root_generation_id}/versions`)과 되돌리기에 쓰며, 새로고침 후에도 `task_id`로 다시 구독하면 얻을 수 있습니다." ) @ApiResponses({ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "스트림 연결 성공"), diff --git a/src/main/java/com/humix/api/domain/musicGeneration/dto/MusicGenerationDTO.java b/src/main/java/com/humix/api/domain/musicGeneration/dto/MusicGenerationDTO.java index 77c21bc..ce68225 100644 --- a/src/main/java/com/humix/api/domain/musicGeneration/dto/MusicGenerationDTO.java +++ b/src/main/java/com/humix/api/domain/musicGeneration/dto/MusicGenerationDTO.java @@ -177,6 +177,10 @@ public record CompletionResult( @Schema(description = "생성된 곡 ID", example = "42") @JsonProperty("song_id") Long songId, + @Schema(description = "같은 곡의 버전 묶음 기준(원본) ID. 최초 생성곡이면 song_id와 같고, 재생성본은 원본의 ID. 버전 목록·되돌리기 API에 사용. 백필 전 기존 곡은 null", + example = "40", nullable = true) + @JsonProperty("root_generation_id") Long rootGenerationId, + @Schema(description = "생성된 오디오 S3 URL", example = "https://humix-bucket.s3.amazonaws.com/audio/abc123_variation.wav") @JsonProperty("audio_url") String audioUrl, diff --git a/src/main/java/com/humix/api/domain/musicGeneration/service/MusicGenerationService.java b/src/main/java/com/humix/api/domain/musicGeneration/service/MusicGenerationService.java index 69b0e30..5742cd8 100644 --- a/src/main/java/com/humix/api/domain/musicGeneration/service/MusicGenerationService.java +++ b/src/main/java/com/humix/api/domain/musicGeneration/service/MusicGenerationService.java @@ -337,6 +337,7 @@ public SseEmitter subscribeTaskStream(String taskId) { MusicGenerationDTO.CompletionResult result = new MusicGenerationDTO.CompletionResult( taskId, musicGeneration.getId(), + musicGeneration.getRootGenerationId(), musicGeneration.getResultS3Url(), musicGeneration.getDurationSeconds() ); @@ -465,6 +466,7 @@ public void completeTask(String taskId, MusicGenerationDTO.AiTaskCompletionReque MusicGenerationDTO.CompletionResult result = new MusicGenerationDTO.CompletionResult( taskId, musicGeneration.getId(), + musicGeneration.getRootGenerationId(), audioUrl, musicGeneration.getDurationSeconds() ); diff --git a/src/test/java/com/humix/api/domain/musicGeneration/TaskStreamCompletionTest.java b/src/test/java/com/humix/api/domain/musicGeneration/TaskStreamCompletionTest.java new file mode 100644 index 0000000..7616bbd --- /dev/null +++ b/src/test/java/com/humix/api/domain/musicGeneration/TaskStreamCompletionTest.java @@ -0,0 +1,172 @@ +package com.humix.api.domain.musicGeneration; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.humix.api.domain.humming.entity.Humming; +import com.humix.api.domain.humming.repository.HummingRepository; +import com.humix.api.domain.melodyScore.entity.MelodyScore; +import com.humix.api.domain.melodyScore.repository.MelodyScoreRepository; +import com.humix.api.domain.member.entity.Member; +import com.humix.api.domain.member.repository.MemberRepository; +import com.humix.api.domain.musicGeneration.entity.MusicGeneration; +import com.humix.api.domain.musicGeneration.repository.MusicGenerationRepository; +import com.humix.api.domain.musicGeneration.type.GenerationStatus; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; + +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** SSE `complete` 이벤트의 result 내용 (#46: root_generation_id). */ +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class TaskStreamCompletionTest { + + @Autowired private MockMvc mockMvc; + @Autowired private ObjectMapper objectMapper; + @Autowired private MemberRepository memberRepository; + @Autowired private HummingRepository hummingRepository; + @Autowired private MelodyScoreRepository melodyScoreRepository; + @Autowired private MusicGenerationRepository musicGenerationRepository; + + private Member member; + private MelodyScore melody; + + @BeforeEach + void setUp() { + member = memberRepository.save(Member.builder().uuid(UUID.randomUUID().toString()).build()); + Humming humming = hummingRepository.save(Humming.builder() + .member(member).s3FileUrl("https://example.com/humming.wav").durationSeconds(10).build()); + melody = melodyScoreRepository.save(MelodyScore.builder() + .humming(humming) + .notesData("[{\"start_time_seconds\":0.0,\"pitch\":60,\"duration_seconds\":1.0}]") + .build()); + } + + // ---------- 이미 완료된 작업을 구독 ---------- + + @Test + void 이미_완료된_최초_생성곡은_root_generation_id가_song_id와_같다() throws Exception { + MusicGeneration root = completedRoot(); + + JsonNode result = subscribeAndReadComplete(root.getTaskId()); + + assertThat(result.path("song_id").asLong()).isEqualTo(root.getId()); + assertThat(result.path("root_generation_id").asLong()).isEqualTo(root.getId()); + assertThat(result.path("task_id").asText()).isEqualTo(root.getTaskId()); + assertThat(result.path("audio_url").asText()).isEqualTo(root.getResultS3Url()); + assertThat(result.path("duration_seconds").asDouble()).isEqualTo(30.0); + } + + @Test + void 이미_완료된_재생성본은_원본의_root_generation_id를_이어받는다() throws Exception { + MusicGeneration root = completedRoot(); + MusicGeneration child = completedChild(root); + + JsonNode result = subscribeAndReadComplete(child.getTaskId()); + + assertThat(result.path("song_id").asLong()).isEqualTo(child.getId()); + assertThat(result.path("root_generation_id").asLong()).isEqualTo(root.getId()); + assertThat(result.path("song_id").asLong()).isNotEqualTo(result.path("root_generation_id").asLong()); + } + + // ---------- 구독 중 AI 완료 콜백 수신 ---------- + + @Test + void 구독_중_콜백으로_완료된_최초_생성곡도_root_generation_id가_song_id와_같다() throws Exception { + MusicGeneration root = processingRoot(); + + JsonNode result = subscribeThenCallback(root.getTaskId()); + + assertThat(result.path("song_id").asLong()).isEqualTo(root.getId()); + assertThat(result.path("root_generation_id").asLong()).isEqualTo(root.getId()); + } + + @Test + void 구독_중_콜백으로_완료된_재생성본은_원본의_root_generation_id를_담는다() throws Exception { + MusicGeneration root = completedRoot(); + MusicGeneration child = processingChild(root); + + JsonNode result = subscribeThenCallback(child.getTaskId()); + + assertThat(result.path("song_id").asLong()).isEqualTo(child.getId()); + assertThat(result.path("root_generation_id").asLong()).isEqualTo(root.getId()); + assertThat(result.path("audio_url").asText()).isEqualTo("https://example.com/callback.wav"); + } + + // ---------- helpers ---------- + + private JsonNode subscribeAndReadComplete(String taskId) throws Exception { + MvcResult stream = mockMvc.perform(get("/api/v1/generation/songs/tasks/{id}/stream", taskId) + .accept(MediaType.TEXT_EVENT_STREAM)) + .andReturn(); + return completeResult(stream.getResponse().getContentAsString()); + } + + // 스트림을 먼저 구독(PROCESSING)해 둔 뒤 AI 완료 콜백을 보내고, 같은 응답에 쌓인 complete 이벤트를 읽는다 + private JsonNode subscribeThenCallback(String taskId) throws Exception { + MvcResult stream = mockMvc.perform(get("/api/v1/generation/songs/tasks/{id}/stream", taskId) + .accept(MediaType.TEXT_EVENT_STREAM)) + .andReturn(); + assertThat(stream.getResponse().getContentAsString()).doesNotContain("event:complete"); + + mockMvc.perform(post("/api/v1/internal/tasks/" + taskId + "/completion") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"generated_audio_url\":\"https://example.com/callback.wav\",\"duration_seconds\":30.0}")) + .andExpect(status().isOk()); + + return completeResult(stream.getResponse().getContentAsString()); + } + + // SSE 본문에서 `event:complete` 다음 `data:` 줄의 JSON을 읽어 result를 반환한다 + private JsonNode completeResult(String sseBody) throws Exception { + int at = sseBody.indexOf("event:complete"); + assertThat(at).as("complete 이벤트가 있어야 한다: %s", sseBody).isGreaterThanOrEqualTo(0); + String data = sseBody.substring(at).lines() + .filter(line -> line.startsWith("data:")) + .findFirst().orElseThrow() + .substring("data:".length()); + JsonNode event = objectMapper.readTree(data); + assertThat(event.path("status").asText()).isEqualTo("COMPLETED"); + return event.path("result"); + } + + private MusicGeneration completedRoot() { + MusicGeneration root = processingRoot(); + root.updateStatus(GenerationStatus.COMPLETED, "https://example.com/" + UUID.randomUUID() + ".wav"); + return musicGenerationRepository.save(root); + } + + private MusicGeneration processingRoot() { + MusicGeneration root = musicGenerationRepository.save(MusicGeneration.builder() + .member(member).melodyScore(melody).name("곡").genre("pop").atmosphere("happy") + .taskId("task_" + UUID.randomUUID()).durationSeconds(30.0).build()); + root.markAsRoot(); + return musicGenerationRepository.save(root); + } + + private MusicGeneration processingChild(MusicGeneration parent) { + return musicGenerationRepository.save(MusicGeneration.builder() + .member(member).melodyScore(melody).parentGeneration(parent) + .name("곡").genre("pop").atmosphere("happy") + .taskId("task_" + UUID.randomUUID()).durationSeconds(30.0).build()); + } + + private MusicGeneration completedChild(MusicGeneration parent) { + MusicGeneration child = processingChild(parent); + child.updateStatus(GenerationStatus.COMPLETED, "https://example.com/" + UUID.randomUUID() + ".wav"); + return musicGenerationRepository.save(child); + } +}