From dbb3847d3bec0b88d83e4f356c76067a6b848de8 Mon Sep 17 00:00:00 2001 From: Kim Seoyeon Date: Fri, 9 Oct 2026 16:53:20 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=9D=8C=EC=95=85=20=EC=83=9D=EC=84=B1?= =?UTF-8?q?=20API=EC=97=90=20=ED=94=84=EB=A1=AC=ED=94=84=ED=8A=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EA=B3=A1=20=EA=B8=B8=EC=9D=B4?= =?UTF-8?q?=20=EC=A7=80=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 생성 요청에 선택 필드 prompt(최대 500자) 추가, music_generation.prompt 컬럼 저장 - AI 요청에 prompt, duration_seconds(설정값 generation.duration-seconds, 기본 30) 전송 - AI 완료 콜백에 duration_seconds 추가: 요청 길이와 ±0.1초 초과 차이면 FAILED, 없으면(구 AI 서버) 검증을 건너뛰고 요청한 길이 유지. 길이 비교는 최초 생성만 수행 - music_generation.duration_seconds를 Integer -> Double로 변경, 응답의 duration_seconds는 소수 - 30초 초과 허밍 거부(HUMMING4001, 400): 허밍 저장 API와 생성 요청의 멜로디 끝 시점 검증 - 결과 오디오 업로드용 presigned PUT URL 유효시간 10분 -> 30분 - Swagger, ERD 갱신 및 테스트 추가 Closes #25 Co-Authored-By: Claude Sonnet 5.5 --- infra/db/ERD.md | 6 +- .../member/controller/MeController.java | 2 +- .../controller/GenerationControllerDocs.java | 19 +- .../dto/MusicGenerationDTO.java | 24 +- .../entity/MusicGeneration.java | 13 +- .../service/MusicGenerationService.java | 73 +++- .../controller/UploadControllerDocs.java | 14 +- .../domain/upload/service/UploadService.java | 11 + .../apiPayload/code/GeneralErrorCode.java | 1 + src/main/resources/application.yml | 4 + .../GenerationPromptAndDurationTest.java | 329 ++++++++++++++++++ 11 files changed, 463 insertions(+), 33 deletions(-) create mode 100644 src/test/java/com/humix/api/domain/musicGeneration/GenerationPromptAndDurationTest.java diff --git a/infra/db/ERD.md b/infra/db/ERD.md index 4ee12f7..3329d76 100644 --- a/infra/db/ERD.md +++ b/infra/db/ERD.md @@ -39,9 +39,10 @@ erDiagram varchar(30) name varchar(30) genre "nullable" varchar(50) atmosphere "nullable" + varchar(500) prompt "nullable" varchar(512) result_s3_url "nullable" varchar(100) task_id UK "nullable" - int duration_seconds "nullable" + double duration_seconds "nullable" varchar(20) status datetime created_at } @@ -90,8 +91,9 @@ AI 음악 생성 결과. 수정(재생성)하면 원본을 바꾸지 않고 `par | name | 곡 제목 (기본값 "나의 허밍곡") | | genre | 장르 | | atmosphere | 분위기 (API의 `mood`) | +| prompt | 사용자가 입력한 스타일 프롬프트 (선택, 최대 500자). 재생성 시 재사용 | | result_s3_url | 생성된 오디오 S3 URL. 완료 전에는 NULL | | task_id | 비동기 작업 ID (유니크) | -| duration_seconds | 곡 길이 (초) | +| duration_seconds | 곡 길이 (초). 요청 시에는 요청한 길이, AI 완료 콜백 이후에는 AI가 보고한 실제 길이 | | status | `PROCESSING` / `COMPLETED` / `FAILED` / `CANCELED` | | created_at | 요청 시각 | diff --git a/src/main/java/com/humix/api/domain/member/controller/MeController.java b/src/main/java/com/humix/api/domain/member/controller/MeController.java index 6a64a4d..d350ff5 100644 --- a/src/main/java/com/humix/api/domain/member/controller/MeController.java +++ b/src/main/java/com/humix/api/domain/member/controller/MeController.java @@ -50,7 +50,7 @@ public ApiResponse getMySongs( List songItems = songPage.getContent().stream() .map(song -> { double hummingDuration = song.getMelodyScore().getHumming().getDurationSeconds(); - int duration = song.getDurationSeconds() != null ? song.getDurationSeconds() : 30; + double duration = song.getDurationSeconds() != null ? song.getDurationSeconds() : 30.0; return MusicGenerationDTO.SongItemResponse.of(song, hummingDuration, duration); }) .collect(Collectors.toList()); 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 b054c0a..2883b0e 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 @@ -22,7 +22,9 @@ public interface GenerationControllerDocs { "- `humming_id`: 벡터화가 완료된 허밍의 ID (필수)\n" + "- `title`: 생성할 곡 제목 (생략 시 기본값 '나의 허밍곡' 사용)\n" + "- `genre`: 원하는 장르 (예: pop, jazz, classical, hiphop)\n" + - "- `mood`: 원하는 분위기 (예: upbeat, calm, romantic, sad)") + "- `mood`: 원하는 분위기 (예: upbeat, calm, romantic, sad)\n" + + "- `prompt`: 원하는 스타일을 직접 서술하는 문장 (선택, 최대 500자)\n\n" + + "곡 길이는 서버 설정값(기본 30초)으로 고정되며, 멜로디가 이 길이를 넘으면 `HUMMING4001`(400)로 거부됩니다.") @RequestBody( required = true, content = @Content( @@ -34,7 +36,8 @@ public interface GenerationControllerDocs { " \"humming_id\": 1,\n" + " \"title\": \"나의 첫 번째 곡\",\n" + " \"genre\": \"pop\",\n" + - " \"mood\": \"upbeat\"\n" + + " \"mood\": \"upbeat\",\n" + + " \"prompt\": \"잔잔한 피아노 위주로, 후반부에 스트링이 들어오는 느낌\"\n" + "}" ) ) @@ -69,10 +72,14 @@ public interface GenerationControllerDocs { ) ) ), - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "요청 값 검증 실패", + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "요청 값 검증 실패 또는 허밍 길이 초과", content = @Content(schema = @Schema(implementation = ApiResponse.class), - examples = @ExampleObject(name = "검증 실패", - value = "{\"isSuccess\":false, \"code\":\"COMMON400\", \"message\":\"잘못된 요청입니다.\", \"result\":{\"humming_id\":\"humming_id는 필수입니다.\"}}"))), + examples = { + @ExampleObject(name = "검증 실패", + value = "{\"isSuccess\":false, \"code\":\"COMMON400\", \"message\":\"잘못된 요청입니다.\", \"result\":{\"humming_id\":\"humming_id는 필수입니다.\"}}"), + @ExampleObject(name = "허밍 길이 초과", + value = "{\"isSuccess\":false, \"code\":\"HUMMING4001\", \"message\":\"허밍은 최대 30초까지 가능합니다.\", \"result\":null}") + })), @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "멜로디 악보 없음", content = @Content(schema = @Schema(implementation = ApiResponse.class), examples = @ExampleObject(name = "악보 없음", @@ -91,7 +98,7 @@ ApiResponse generateSong( "- `progress`: 진행률 업데이트 (status: PROCESSING, progress: 0~90)\n" + "- `complete`: 작업 완료 시 전송 (status: COMPLETED / FAILED)\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}}`" + "**complete 이벤트 예시:** `{\"status\": \"COMPLETED\", \"result\": {\"task_id\": \"task_xxx\", \"song_id\": 42, \"audio_url\": \"https://...\", \"duration_seconds\": 30.0}}`" ) @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 75513f2..387f3a4 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 @@ -7,6 +7,7 @@ import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; import java.time.LocalDateTime; import java.util.List; @@ -25,7 +26,11 @@ public record SongCreateRequest( String genre, @Schema(description = "분위기 (예: upbeat, calm, romantic, sad 등)", example = "upbeat") - String mood + String mood, + + @Schema(description = "원하는 스타일을 직접 서술하는 프롬프트 (선택, 최대 500자)", example = "잔잔한 피아노 위주로, 후반부에 스트링이 들어오는 느낌") + @Size(max = 500, message = "prompt는 최대 500자까지 입력할 수 있습니다.") + String prompt ) { //DTO를 MusicGeneration 엔티티로 변환하는 매핑 메서드 public MusicGeneration from(Member member, MelodyScore melodyScore) { @@ -36,6 +41,7 @@ public MusicGeneration from(Member member, MelodyScore melodyScore) { .name(this.title) .genre(this.genre) .atmosphere(this.mood) + .prompt(this.prompt == null || this.prompt.isBlank() ? null : this.prompt) .build(); } } @@ -72,6 +78,7 @@ public MusicGeneration from(Member member, MelodyScore melodyScore, .name(parentGeneration.getName() + " (수정본)") .genre(parentGeneration.getGenre()) .atmosphere(parentGeneration.getAtmosphere()) + .prompt(parentGeneration.getPrompt()) .build(); } } @@ -99,8 +106,8 @@ public record CompletionResult( @Schema(description = "생성된 오디오 S3 URL", example = "https://humix-bucket.s3.amazonaws.com/audio/abc123_variation.wav") @JsonProperty("audio_url") String audioUrl, - @Schema(description = "생성된 오디오 재생 시간 (초)", example = "30") - @JsonProperty("duration_seconds") int durationSeconds + @Schema(description = "생성된 오디오 재생 시간 (초)", example = "30.0") + @JsonProperty("duration_seconds") double durationSeconds ) {} //SSE Event: complete 데이터 구조 @@ -145,8 +152,8 @@ public record SongItemResponse( @Schema(description = "원본 허밍 재생 시간 (초)", example = "12.5") @JsonProperty("humming_duration_seconds") double hummingDurationSeconds, - @Schema(description = "생성된 오디오 재생 시간 (초)", example = "30") - @JsonProperty("duration_seconds") int durationSeconds, + @Schema(description = "생성된 오디오 재생 시간 (초)", example = "30.0") + @JsonProperty("duration_seconds") double durationSeconds, @Schema(description = "생성된 오디오 S3 URL", example = "https://humix-bucket.s3.amazonaws.com/audio/abc123_variation.wav") @JsonProperty("audio_url") String audioUrl, @@ -156,7 +163,7 @@ public record SongItemResponse( ) { //엔티티와 외부 조회 데이터를 조합하여 목록용 단일 객체로 변환하는 메서드 public static SongItemResponse of(MusicGeneration musicGeneration, double hummingDuration, - int durationSeconds) { + double durationSeconds) { return new SongItemResponse( musicGeneration.getId(), musicGeneration.getName(), //엔티티의 name을 title 필드로 매핑 @@ -202,6 +209,9 @@ public static SongListResponse of(long totalCount, int currentPage, //AI 서버 작업 완료 콜백 Request Body public record AiTaskCompletionRequest( @NotBlank(message = "generated_audio_url은 필수입니다.") - @JsonProperty("generated_audio_url") String generatedAudioUrl + @JsonProperty("generated_audio_url") String generatedAudioUrl, + + // AI가 실제로 만든 오디오 길이(초). 실패 콜백이나 아직 이 필드를 보내지 않는 AI 서버에서는 null + @JsonProperty("duration_seconds") Double durationSeconds ) {} } diff --git a/src/main/java/com/humix/api/domain/musicGeneration/entity/MusicGeneration.java b/src/main/java/com/humix/api/domain/musicGeneration/entity/MusicGeneration.java index 7219920..48623dd 100644 --- a/src/main/java/com/humix/api/domain/musicGeneration/entity/MusicGeneration.java +++ b/src/main/java/com/humix/api/domain/musicGeneration/entity/MusicGeneration.java @@ -50,8 +50,13 @@ public class MusicGeneration { @Column(name = "task_id", length = 100, unique = true) private String taskId; + // 사용자가 입력한 스타일 프롬프트 (선택). 재생성 시 재사용한다. + @Column(name = "prompt", length = 500) + private String prompt; + + // 요청 시에는 요청한 길이, AI 완료 콜백 이후에는 AI가 보고한 실제 길이 @Column(name = "duration_seconds") - private Integer durationSeconds; + private Double durationSeconds; @Enumerated(EnumType.STRING) @Column(name = "status", length = 20, nullable = false) @@ -63,13 +68,15 @@ public class MusicGeneration { @Builder public MusicGeneration(Member member, MelodyScore melodyScore, MusicGeneration parentGeneration, - String name, String genre, String atmosphere, String taskId, Integer durationSeconds) { + String name, String genre, String atmosphere, String prompt, String taskId, + Double durationSeconds) { this.member = member; this.melodyScore = melodyScore; this.parentGeneration = parentGeneration; this.name = name != null ? name : "나의 허밍곡"; // Default 값 설정 this.genre = genre; this.atmosphere = atmosphere; + this.prompt = prompt; this.taskId = taskId; this.durationSeconds = durationSeconds; this.status = GenerationStatus.PROCESSING; // 초기 상태 설정 @@ -92,7 +99,7 @@ public void updateTaskId(String taskId) { } // 재생 시간 업데이트 메서드 - public void updateDuration(Integer durationSeconds) { + public void updateDuration(Double durationSeconds) { this.durationSeconds = durationSeconds; } } \ No newline at end of file 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 66a145b..90557dc 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 @@ -1,5 +1,6 @@ package com.humix.api.domain.musicGeneration.service; +import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; @@ -39,6 +40,13 @@ @Transactional(readOnly = true) public class MusicGenerationService { + // AI 서버가 업로드하는 결과 오디오용 presigned PUT URL 유효시간. RunPod 콜드스타트·대기열 + 생성 시간을 감안해 넉넉히 둔다. + private static final Duration PRESIGNED_UPLOAD_EXPIRATION = Duration.ofMinutes(30); + + // 요청/응답 길이 비교 및 허밍 길이 검증에서 허용하는 오차(초). 멜로디가 0.1초 단위로 양자화되기 때문이다. + private static final double DURATION_TOLERANCE_SECONDS = 0.1; + private static final double FLOAT_EPSILON = 1e-9; + private final MusicGenerationRepository musicGenerationRepository; private final MelodyScoreRepository melodyScoreRepository; private final S3Presigner s3Presigner; @@ -60,6 +68,9 @@ public class MusicGenerationService { @Value("${runpod.api-key}") private String runpodApiKey; + @Value("${generation.duration-seconds}") + private int generationDurationSeconds; + // Concurrent map to keep track of active SSE Emitters private final Map emitters = new ConcurrentHashMap<>(); @@ -75,6 +86,8 @@ private record AiGenerationRequest( @JsonProperty("melody_vectors") List melodyVectors, String genre, String mood, + @JsonInclude(JsonInclude.Include.NON_NULL) String prompt, + @JsonProperty("duration_seconds") int durationSeconds, @JsonProperty("callback_url") String callbackUrl, @JsonProperty("presigned_url") String presignedUrl ) {} @@ -102,27 +115,28 @@ public MusicGenerationDTO.TaskAcceptedResponse generateSong(CustomUserDetails us MelodyScore melodyScore = melodyScoreRepository.findByHummingId(request.hummingId()) .orElseThrow(() -> new GeneralException(GeneralErrorCode.MELODY_SCORE_NOT_FOUND)); - // 2. Task ID 생성 + // 2. Melody Vectors 파싱 후 생성 길이를 넘는 멜로디는 AI 서버로 보내기 전에 거부 + List melodyVectors = parseMelodyVectors(melodyScore.getNotesData()); + validateMelodyLength(melodyVectors); + + // 3. Task ID 생성 String taskId = "task_" + UUID.randomUUID().toString(); - // 3. S3 Presigned URL 생성 + // 4. S3 Presigned URL 생성 String uniqueFileName = UUID.randomUUID().toString() + "_variation.wav"; String fileKey = "audio/" + uniqueFileName; String presignedUrl = generatePresignedUrl(fileKey); - // 4. MusicGeneration 엔티티 생성 및 영속화 + // 5. MusicGeneration 엔티티 생성 및 영속화 (길이는 요청한 값으로 먼저 기록하고, 완료 콜백에서 실제 길이로 갱신) MusicGeneration musicGeneration = request.from(userDetails.getMember(), melodyScore); musicGeneration.updateTaskId(taskId); - musicGeneration.updateDuration(30); // Default 30s + musicGeneration.updateDuration((double) generationDurationSeconds); musicGenerationRepository.save(musicGeneration); - // 5. Melody Vectors 파싱 - List melodyVectors = parseMelodyVectors(melodyScore.getNotesData()); - // 6. AI 서버로 비동기 작곡 요청 String callbackUrl = backendUrl + "/api/v1/internal/tasks/" + taskId + "/completion"; - log.info("[MusicGeneration] Requesting song generation for taskId: {}, genre: {}, mood: {}, callback: {}", taskId, request.genre(), request.mood(), callbackUrl); + log.info("[MusicGeneration] Requesting song generation for taskId: {}, genre: {}, mood: {}, duration: {}s, callback: {}", taskId, request.genre(), request.mood(), generationDurationSeconds, callbackUrl); AiGenerationRequest aiRequest = new AiGenerationRequest( "generate", @@ -130,6 +144,8 @@ public MusicGenerationDTO.TaskAcceptedResponse generateSong(CustomUserDetails us melodyVectors, request.genre(), request.mood(), + musicGeneration.getPrompt(), + generationDurationSeconds, callbackUrl, presignedUrl ); @@ -177,7 +193,7 @@ public MusicGenerationDTO.TaskAcceptedResponse modifySongPrompt(CustomUserDetail // 4. 새로운 MusicGeneration 엔티티 생성 및 영속화 MusicGeneration musicGeneration = request.from(userDetails.getMember(), melodyScore, parentGeneration); musicGeneration.updateTaskId(taskId); - musicGeneration.updateDuration(30); // Default 30s + musicGeneration.updateDuration((double) generationDurationSeconds); // 완료 콜백에서 AI가 보고한 실제 길이로 갱신 musicGenerationRepository.save(musicGeneration); // 5. Melody Vectors 파싱 @@ -333,10 +349,19 @@ public void completeTask(String taskId, MusicGenerationDTO.AiTaskCompletionReque String audioUrl = request.generatedAudioUrl(); boolean isFailed = "FAILED".equalsIgnoreCase(audioUrl); + if (!isFailed && isDurationMismatch(musicGeneration, request.durationSeconds())) { + log.warn("[MusicGeneration] Duration mismatch for taskId: {}. requested: {}s, reported: {}s. Marking as FAILED.", + taskId, musicGeneration.getDurationSeconds(), request.durationSeconds()); + isFailed = true; + } + if (isFailed) { musicGeneration.updateStatus(GenerationStatus.FAILED, null); } else { musicGeneration.updateStatus(GenerationStatus.COMPLETED, audioUrl); + if (request.durationSeconds() != null) { + musicGeneration.updateDuration(request.durationSeconds()); + } } SseEmitter emitter = emitters.remove(taskId); @@ -379,7 +404,7 @@ private String generatePresignedUrl(String fileKey) { .build(); PutObjectPresignRequest presignRequest = PutObjectPresignRequest.builder() - .signatureDuration(Duration.ofMinutes(10)) + .signatureDuration(PRESIGNED_UPLOAD_EXPIRATION) .putObjectRequest(putObjectRequest) .build(); @@ -387,6 +412,34 @@ private String generatePresignedUrl(String fileKey) { return presignedRequest.url().toString(); } + // 멜로디의 마지막 음이 끝나는 시점이 생성 길이를 (허용 오차 이상) 넘으면 거부한다. + private void validateMelodyLength(List melodyVectors) { + double melodyEnd = melodyVectors.stream() + .mapToDouble(vector -> vector.onsetSeconds() + vector.durationSeconds()) + .max() + .orElse(0.0); + + if (exceedsTolerance(melodyEnd - generationDurationSeconds)) { + throw new GeneralException(GeneralErrorCode.HUMMING_TOO_LONG); + } + } + + // 최초 생성(generate) 작업만 요청한 길이와 AI가 보고한 길이를 비교한다. + // 보고된 길이가 없으면(구 AI 서버) 비교하지 않고 요청한 길이를 그대로 둔다. + private boolean isDurationMismatch(MusicGeneration musicGeneration, Double reportedDuration) { + if (reportedDuration == null + || musicGeneration.getParentGeneration() != null + || musicGeneration.getDurationSeconds() == null) { + return false; + } + return exceedsTolerance(Math.abs(reportedDuration - musicGeneration.getDurationSeconds())); + } + + // 부동소수점 오차 때문에 경계값(예: 정확히 0.1초 차이)이 잘못 거부되지 않도록 아주 작은 보정값을 둔다. + private static boolean exceedsTolerance(double deviationSeconds) { + return deviationSeconds > DURATION_TOLERANCE_SECONDS + FLOAT_EPSILON; + } + private List parseMelodyVectors(String notesData) { try { List notes = objectMapper.readValue( diff --git a/src/main/java/com/humix/api/domain/upload/controller/UploadControllerDocs.java b/src/main/java/com/humix/api/domain/upload/controller/UploadControllerDocs.java index e54f93b..8eb30ca 100644 --- a/src/main/java/com/humix/api/domain/upload/controller/UploadControllerDocs.java +++ b/src/main/java/com/humix/api/domain/upload/controller/UploadControllerDocs.java @@ -28,13 +28,19 @@ public interface UploadControllerDocs { @PostMapping("/audio/presigned") ApiResponse getPresignedUrl(@RequestBody UploadDTO.AudioPresignedRequest request); - @Operation(summary = "허밍 오디오 메타데이터 저장 API", description = "S3 업로드 완료 후 허밍 파일의 메타데이터를 서버에 저장합니다.") + @Operation(summary = "허밍 오디오 메타데이터 저장 API", + description = "S3 업로드 완료 후 허밍 파일의 메타데이터를 서버에 저장합니다.\n\n" + + "허밍은 최대 30초까지 가능하며, `duration_seconds`가 이를 넘으면 `HUMMING4001`(400)로 거부됩니다.") @ApiResponses({ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "성공"), - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "요청 값 검증 실패", + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "요청 값 검증 실패 또는 허밍 길이 초과", content = @Content(schema = @Schema(implementation = ApiResponse.class), - examples = @ExampleObject(name = "검증 실패", - value = "{\"isSuccess\":false, \"code\":\"COMMON400\", \"message\":\"잘못된 요청입니다.\", \"result\":{\"file_key\":\"file_key는 필수입니다.\"}}"))) + examples = { + @ExampleObject(name = "검증 실패", + value = "{\"isSuccess\":false, \"code\":\"COMMON400\", \"message\":\"잘못된 요청입니다.\", \"result\":{\"file_key\":\"file_key는 필수입니다.\"}}"), + @ExampleObject(name = "허밍 길이 초과", + value = "{\"isSuccess\":false, \"code\":\"HUMMING4001\", \"message\":\"허밍은 최대 30초까지 가능합니다.\", \"result\":null}") + })) }) @PostMapping("/humming") ApiResponse saveHummingInfo(@AuthenticationPrincipal CustomUserDetails userDetails, diff --git a/src/main/java/com/humix/api/domain/upload/service/UploadService.java b/src/main/java/com/humix/api/domain/upload/service/UploadService.java index 3b82c9f..480d45d 100644 --- a/src/main/java/com/humix/api/domain/upload/service/UploadService.java +++ b/src/main/java/com/humix/api/domain/upload/service/UploadService.java @@ -4,6 +4,8 @@ import com.humix.api.domain.humming.entity.Humming; import com.humix.api.domain.humming.repository.HummingRepository; import com.humix.api.domain.upload.dto.UploadDTO; +import com.humix.api.global.apiPayload.code.GeneralErrorCode; +import com.humix.api.global.apiPayload.exception.GeneralException; import com.humix.api.global.security.userdetails.CustomUserDetails; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; @@ -27,6 +29,10 @@ public class UploadService implements UploadInterface { @Value("${cloud.aws.s3.bucket}") private String bucket; + // 생성 길이가 곧 허밍 최대 길이다. 이보다 긴 허밍은 자르거나 늘리지 않고 거부한다. + @Value("${generation.duration-seconds}") + private int maxHummingSeconds; + @Override public UploadDTO.AudioPresignedResponse getPresignedUrl(UploadDTO.AudioPresignedRequest request) { String uniqueFileName = UUID.randomUUID() + "_" + request.audioName(); @@ -56,6 +62,11 @@ public UploadDTO.AudioPresignedResponse getPresignedUrl(UploadDTO.AudioPresigned @Override public HummingDTO.HummingSaveResponse saveHummingInfo(CustomUserDetails userDetails, HummingDTO.HummingSaveRequest request) { + // 1. 클라이언트가 보낸 길이 값 검증 (프론트 녹음 제한의 보조 검증) + if (request.durationSeconds() > maxHummingSeconds) { + throw new GeneralException(GeneralErrorCode.HUMMING_TOO_LONG); + } + // 2. S3 실제 객체 표준 퍼블릭 URL 생성 String s3FileUrl = String.format("https://%s.s3.amazonaws.com/%s", bucket, request.fileKey()); diff --git a/src/main/java/com/humix/api/global/apiPayload/code/GeneralErrorCode.java b/src/main/java/com/humix/api/global/apiPayload/code/GeneralErrorCode.java index 1abe606..60bf509 100644 --- a/src/main/java/com/humix/api/global/apiPayload/code/GeneralErrorCode.java +++ b/src/main/java/com/humix/api/global/apiPayload/code/GeneralErrorCode.java @@ -25,6 +25,7 @@ public enum GeneralErrorCode implements BaseErrorCode{ // HUMMING 관련 에러 HUMMING_NOT_FOUND(HttpStatus.NOT_FOUND, "HUMMING4000", "해당하는 허밍 기록이 존재하지 않습니다."), + HUMMING_TOO_LONG(HttpStatus.BAD_REQUEST, "HUMMING4001", "허밍은 최대 30초까지 가능합니다."), // MELODY 관련 에러 MELODY_SCORE_NOT_FOUND(HttpStatus.NOT_FOUND, "MELODY4000", "해당 허밍의 멜로디 악보가 존재하지 않습니다."), diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 625a5ac..1a20750 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -22,5 +22,9 @@ runpod: endpoint-url: ${RUNPOD_ENDPOINT_URL:https://api.runpod.ai/v2/pw8ukq72bavqde} api-key: ${RUNPOD_API_KEY:} +generation: + # 생성할 곡 길이(초). AI 서버가 허용하는 상한은 30초이고, 허밍 최대 길이 검증에도 같은 값을 쓴다. + duration-seconds: 30 + backend: url: ${BACKEND_SERVER_URL:http://localhost:8080} \ No newline at end of file diff --git a/src/test/java/com/humix/api/domain/musicGeneration/GenerationPromptAndDurationTest.java b/src/test/java/com/humix/api/domain/musicGeneration/GenerationPromptAndDurationTest.java new file mode 100644 index 0000000..19241bc --- /dev/null +++ b/src/test/java/com/humix/api/domain/musicGeneration/GenerationPromptAndDurationTest.java @@ -0,0 +1,329 @@ +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.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Primary; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.mock.http.client.reactive.MockClientHttpRequest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.web.reactive.function.client.ClientResponse; +import org.springframework.web.reactive.function.client.ExchangeStrategies; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; + +import java.util.List; +import java.util.UUID; +import java.util.concurrent.CopyOnWriteArrayList; + +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.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class GenerationPromptAndDurationTest { + + /** AI 서버(RunPod)로 나가는 요청 본문을 실제 호출 없이 가로챈다. */ + @TestConfiguration + static class CapturingWebClientConfig { + static final List AI_REQUEST_BODIES = new CopyOnWriteArrayList<>(); + + @Bean + @Primary + WebClient.Builder capturingWebClientBuilder() { + return WebClient.builder().exchangeFunction(request -> { + MockClientHttpRequest captured = new MockClientHttpRequest(request.method(), request.url()); + return request.writeTo(captured, ExchangeStrategies.withDefaults()) + .then(Mono.defer(captured::getBodyAsString)) + .doOnNext(AI_REQUEST_BODIES::add) + .thenReturn(ClientResponse.create(HttpStatus.OK).build()); + }); + } + } + + @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 String deviceId; + private String bearer; + + @BeforeEach + void login() throws Exception { + CapturingWebClientConfig.AI_REQUEST_BODIES.clear(); + deviceId = UUID.randomUUID().toString(); // member.uuid는 36자 제한 + String body = mockMvc.perform(post("/api/v1/auth/guest-login") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"device_id\":\"" + deviceId + "\"}")) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + bearer = "Bearer " + objectMapper.readTree(body).at("/result/access_token").asText(); + } + + // ---------- 허밍 저장 API: 30초 초과 거부 ---------- + + @Test + void 허밍_저장_31초면_400_HUMMING4001() throws Exception { + postJson("/api/v1/upload/humming", "{\"file_key\":\"audio/a.wav\",\"duration_seconds\":31}") + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("HUMMING4001")) + .andExpect(jsonPath("$.message").value("허밍은 최대 30초까지 가능합니다.")); + } + + @Test + void 허밍_저장_30초는_허용() throws Exception { + postJson("/api/v1/upload/humming", "{\"file_key\":\"audio/a.wav\",\"duration_seconds\":30}") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.result.duration_seconds").value(30)); + } + + // ---------- 곡 생성 API: prompt 검증 ---------- + + @Test + void 곡생성_prompt가_500자를_넘으면_400() throws Exception { + postJson("/api/v1/generation/songs", + "{\"humming_id\":1,\"title\":\"t\",\"genre\":\"pop\",\"mood\":\"happy\",\"prompt\":\"" + + "가".repeat(501) + "\"}") + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("COMMON400")) + .andExpect(jsonPath("$.result.prompt").exists()); + } + + // ---------- 곡 생성 API: 멜로디 길이 검증 ---------- + + @Test + void 곡생성_멜로디가_30초를_넘으면_400_HUMMING4001이고_AI에_요청하지_않는다() throws Exception { + Long hummingId = saveHummingWithNotes( + "[{\"start_time_seconds\":0.0,\"pitch\":60,\"duration_seconds\":30.2}]"); + + postJson("/api/v1/generation/songs", generateBody(hummingId, null)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("HUMMING4001")); + + assertThat(CapturingWebClientConfig.AI_REQUEST_BODIES).isEmpty(); + } + + @Test + void 곡생성_멜로디가_정확히_30점1초에_끝나면_허용() throws Exception { + // 양자화 오차 0.1초 여유 경계. 부동소수점 합(29.6 + 0.5)이어도 거부되면 안 된다. + Long hummingId = saveHummingWithNotes( + "[{\"start_time_seconds\":0.0,\"pitch\":60,\"duration_seconds\":29.6}," + + "{\"start_time_seconds\":29.6,\"pitch\":62,\"duration_seconds\":0.5}]"); + + postJson("/api/v1/generation/songs", generateBody(hummingId, null)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("COMMON202")); + } + + // ---------- 곡 생성 API: AI 요청 본문 ---------- + + @Test + void 곡생성_AI_요청에_prompt와_duration_seconds를_담고_엔티티에_저장한다() throws Exception { + Long hummingId = saveHummingWithNotes(shortNotes()); + + String taskId = startGeneration(hummingId, "잔잔한 피아노 위주로"); + + JsonNode input = onlyAiRequest().path("input"); + assertThat(input.path("action").asText()).isEqualTo("generate"); + assertThat(input.path("task_id").asText()).isEqualTo(taskId); + assertThat(input.path("prompt").asText()).isEqualTo("잔잔한 피아노 위주로"); + assertThat(input.path("duration_seconds").asInt()).isEqualTo(30); + + MusicGeneration saved = musicGenerationRepository.findByTaskId(taskId).orElseThrow(); + assertThat(saved.getPrompt()).isEqualTo("잔잔한 피아노 위주로"); + assertThat(saved.getDurationSeconds()).isEqualTo(30.0); + } + + @Test + void 곡생성_prompt가_없어도_duration_seconds는_항상_보내고_prompt는_생략한다() throws Exception { + Long hummingId = saveHummingWithNotes(shortNotes()); + + String taskId = startGeneration(hummingId, null); + + JsonNode input = onlyAiRequest().path("input"); + assertThat(input.has("prompt")).isFalse(); + assertThat(input.path("duration_seconds").asInt()).isEqualTo(30); + assertThat(musicGenerationRepository.findByTaskId(taskId).orElseThrow().getPrompt()).isNull(); + } + + @Test + void 곡생성_공백_prompt는_없는_것으로_취급한다() throws Exception { + Long hummingId = saveHummingWithNotes(shortNotes()); + + String taskId = startGeneration(hummingId, " "); + + assertThat(onlyAiRequest().path("input").has("prompt")).isFalse(); + assertThat(musicGenerationRepository.findByTaskId(taskId).orElseThrow().getPrompt()).isNull(); + } + + // ---------- 완료 콜백: 길이 검증 ---------- + + @Test + void 완료콜백_길이가_허용오차_이내면_COMPLETED이고_실제_길이를_저장한다() throws Exception { + String taskId = startGeneration(saveHummingWithNotes(shortNotes()), null); + + complete(taskId, "https://example.com/a.wav", 29.95).andExpect(status().isOk()); + + MusicGeneration result = musicGenerationRepository.findByTaskId(taskId).orElseThrow(); + assertThat(result.getStatus()).isEqualTo(GenerationStatus.COMPLETED); + assertThat(result.getResultS3Url()).isEqualTo("https://example.com/a.wav"); + assertThat(result.getDurationSeconds()).isEqualTo(29.95); + + // 내 곡 목록 응답의 duration_seconds는 소수 그대로 내려간다. + mockMvc.perform(get("/api/v1/me/songs").header("Authorization", bearer)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.result.contents[0].duration_seconds").value(29.95)); + } + + @Test + void 완료콜백_정확히_0점1초_차이는_허용한다() throws Exception { + String longer = startGeneration(saveHummingWithNotes(shortNotes()), null); + String shorter = startGeneration(saveHummingWithNotes(shortNotes()), null); + + complete(longer, "https://example.com/l.wav", 30.1).andExpect(status().isOk()); + complete(shorter, "https://example.com/s.wav", 29.9).andExpect(status().isOk()); + + assertThat(statusOf(longer)).isEqualTo(GenerationStatus.COMPLETED); + assertThat(statusOf(shorter)).isEqualTo(GenerationStatus.COMPLETED); + } + + @Test + void 완료콜백_길이가_허용오차를_넘으면_FAILED() throws Exception { + String taskId = startGeneration(saveHummingWithNotes(shortNotes()), null); + + complete(taskId, "https://example.com/a.wav", 29.8).andExpect(status().isOk()); + + MusicGeneration result = musicGenerationRepository.findByTaskId(taskId).orElseThrow(); + assertThat(result.getStatus()).isEqualTo(GenerationStatus.FAILED); + assertThat(result.getResultS3Url()).isNull(); + } + + @Test + void 완료콜백_duration_seconds가_없으면_검증을_건너뛰고_요청한_길이를_유지한다() throws Exception { + // 아직 duration_seconds를 보내지 않는 구 AI 서버와의 호환 (백엔드 먼저 배포) + String taskId = startGeneration(saveHummingWithNotes(shortNotes()), null); + + mockMvc.perform(post("/api/v1/internal/tasks/" + taskId + "/completion") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"generated_audio_url\":\"https://example.com/a.wav\"}")) + .andExpect(status().isOk()); + + MusicGeneration result = musicGenerationRepository.findByTaskId(taskId).orElseThrow(); + assertThat(result.getStatus()).isEqualTo(GenerationStatus.COMPLETED); + assertThat(result.getDurationSeconds()).isEqualTo(30.0); + } + + @Test + void 완료콜백_실패_통보는_duration_seconds_없이도_FAILED() throws Exception { + String taskId = startGeneration(saveHummingWithNotes(shortNotes()), null); + + mockMvc.perform(post("/api/v1/internal/tasks/" + taskId + "/completion") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"generated_audio_url\":\"FAILED\"}")) + .andExpect(status().isOk()); + + assertThat(statusOf(taskId)).isEqualTo(GenerationStatus.FAILED); + } + + @Test + void 수정곡_완료콜백은_길이를_비교하지_않고_보고된_길이를_저장한다() throws Exception { + Long hummingId = saveHummingWithNotes(shortNotes()); + MelodyScore melody = melodyScoreRepository.findByHummingId(hummingId).orElseThrow(); + Member member = memberRepository.findById(deviceId).orElseThrow(); + MusicGeneration parent = musicGenerationRepository.save(MusicGeneration.builder() + .member(member).melodyScore(melody).name("원본").genre("pop").atmosphere("happy") + .prompt("원본 프롬프트").durationSeconds(30.0).build()); + + String body = postJson("/api/v1/generation/songs/" + parent.getId() + "/modifications", + "{\"prompt\":\"더 밝게\"}") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("COMMON202")) + .andReturn().getResponse().getContentAsString(); + String taskId = objectMapper.readTree(body).at("/result/task_id").asText(); + + complete(taskId, "https://example.com/m.wav", 12.3).andExpect(status().isOk()); + + MusicGeneration modified = musicGenerationRepository.findByTaskId(taskId).orElseThrow(); + assertThat(modified.getStatus()).isEqualTo(GenerationStatus.COMPLETED); + assertThat(modified.getDurationSeconds()).isEqualTo(12.3); + assertThat(modified.getPrompt()).isEqualTo("원본 프롬프트"); // 원본의 스타일 프롬프트를 이어받는다 + } + + // ---------- helpers ---------- + + private ResultActions postJson(String url, String json) throws Exception { + return mockMvc.perform(post(url) + .header("Authorization", bearer) + .contentType(MediaType.APPLICATION_JSON) + .content(json)); + } + + private ResultActions complete(String taskId, String audioUrl, double durationSeconds) throws Exception { + return mockMvc.perform(post("/api/v1/internal/tasks/" + taskId + "/completion") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"generated_audio_url\":\"" + audioUrl + "\",\"duration_seconds\":" + durationSeconds + "}")); + } + + private String generateBody(Long hummingId, String prompt) throws Exception { + StringBuilder json = new StringBuilder("{\"humming_id\":" + hummingId + + ",\"title\":\"t\",\"genre\":\"pop\",\"mood\":\"happy\""); + if (prompt != null) { + json.append(",\"prompt\":").append(objectMapper.writeValueAsString(prompt)); + } + return json.append("}").toString(); + } + + private String startGeneration(Long hummingId, String prompt) throws Exception { + String body = postJson("/api/v1/generation/songs", generateBody(hummingId, prompt)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("COMMON202")) + .andReturn().getResponse().getContentAsString(); + return objectMapper.readTree(body).at("/result/task_id").asText(); + } + + private Long saveHummingWithNotes(String notesJson) { + Member member = memberRepository.findById(deviceId).orElseThrow(); + Humming humming = hummingRepository.save(Humming.builder() + .member(member).s3FileUrl("https://example.com/humming.wav").durationSeconds(10).build()); + melodyScoreRepository.save(MelodyScore.builder().humming(humming).notesData(notesJson).build()); + return humming.getId(); + } + + private String shortNotes() { + return "[{\"start_time_seconds\":0.0,\"pitch\":60,\"duration_seconds\":1.0}]"; + } + + private JsonNode onlyAiRequest() throws Exception { + assertThat(CapturingWebClientConfig.AI_REQUEST_BODIES).hasSize(1); + return objectMapper.readTree(CapturingWebClientConfig.AI_REQUEST_BODIES.get(0)); + } + + private GenerationStatus statusOf(String taskId) { + return musicGenerationRepository.findByTaskId(taskId).orElseThrow().getStatus(); + } +}