diff --git a/infra/db/ERD.md b/infra/db/ERD.md index 3329d76..91d8ef7 100644 --- a/infra/db/ERD.md +++ b/infra/db/ERD.md @@ -36,6 +36,7 @@ erDiagram varchar(36) uuid FK bigint melody_id FK bigint parent_generation_id FK "nullable" + bigint root_generation_id "index, 원본은 자기 자신" varchar(30) name varchar(30) genre "nullable" varchar(50) atmosphere "nullable" @@ -45,6 +46,7 @@ erDiagram double duration_seconds "nullable" varchar(20) status datetime created_at + datetime deleted_at "nullable" } ``` @@ -88,6 +90,7 @@ AI 음악 생성 결과. 수정(재생성)하면 원본을 바꾸지 않고 `par | uuid | 생성한 사용자 (`member.uuid`) | | melody_id | 생성에 사용한 멜로디 (`melody_score.melody_id`) | | parent_generation_id | 수정 원본 (`music_generation.generation_id`). 최초 생성이면 NULL | +| root_generation_id | 같은 곡의 버전 묶음 기준. 최초 생성곡은 자기 자신의 `generation_id`, 수정본은 부모의 값을 물려받음. FK는 두지 않음 (인덱스만) | | name | 곡 제목 (기본값 "나의 허밍곡") | | genre | 장르 | | atmosphere | 분위기 (API의 `mood`) | @@ -97,3 +100,10 @@ AI 음악 생성 결과. 수정(재생성)하면 원본을 바꾸지 않고 `par | duration_seconds | 곡 길이 (초). 요청 시에는 요청한 길이, AI 완료 콜백 이후에는 AI가 보고한 실제 길이 | | status | `PROCESSING` / `COMPLETED` / `FAILED` / `CANCELED` | | created_at | 요청 시각 | +| deleted_at | 버전 되돌리기로 무효화된 시각 (soft delete). NULL이면 유효 | + +**버전 정책** +- 유효한 버전 = 같은 `root_generation_id` 중 `status = COMPLETED AND deleted_at IS NULL` +- 버전 번호(`version_no`)는 컬럼 없이 유효한 버전을 `generation_id` 오름차순으로 센 값 (1부터) +- 버전 N으로 되돌리면 같은 root에서 `generation_id`가 N보다 큰 행이 모두 soft delete 됨 +- `root_generation_id`가 생기기 전에 만든 곡은 배포 후 백필로 자기 자신의 `generation_id`를 채움 (`UPDATE music_generation SET root_generation_id = generation_id WHERE root_generation_id IS NULL;`, 기존 곡에 수정본이 없을 때만 유효) 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 d350ff5..9fd6862 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 @@ -38,11 +38,11 @@ public ApiResponse getMySongs( Page songPage; if (genre != null && !genre.isBlank()) { - songPage = musicGenerationRepository.findByMemberAndGenreAndStatus( + songPage = musicGenerationRepository.findByMemberAndGenreAndStatusAndDeletedAtIsNull( userDetails.getMember(), genre, GenerationStatus.COMPLETED, pageable ); } else { - songPage = musicGenerationRepository.findByMemberAndStatus( + songPage = musicGenerationRepository.findByMemberAndStatusAndDeletedAtIsNull( userDetails.getMember(), GenerationStatus.COMPLETED, pageable ); } diff --git a/src/main/java/com/humix/api/domain/member/controller/MeControllerDocs.java b/src/main/java/com/humix/api/domain/member/controller/MeControllerDocs.java index 5e93c8d..b3ef77a 100644 --- a/src/main/java/com/humix/api/domain/member/controller/MeControllerDocs.java +++ b/src/main/java/com/humix/api/domain/member/controller/MeControllerDocs.java @@ -13,7 +13,9 @@ @RequestMapping("/api/v1/me") public interface MeControllerDocs { - @Operation(summary = "내 생성곡 목록 페이징 조회 API", description = "사용자가 생성한 곡 목록을 장르 조건에 맞게 페이징하여 조회합니다.") + @Operation(summary = "내 생성곡 목록 페이징 조회 API", description = "사용자가 생성한 곡 목록을 장르 조건에 맞게 페이징하여 조회합니다.\n\n" + + "- 완료(COMPLETED)된 곡만 조회되며, 버전 되돌리기로 삭제된 버전은 제외됩니다. (`total_count`, `total_pages`에도 반영)\n" + + "- 같은 곡의 수정본도 버전마다 별도 항목으로 조회됩니다.") @ApiResponses({ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "성공") }) diff --git a/src/main/java/com/humix/api/domain/musicGeneration/controller/GenerationController.java b/src/main/java/com/humix/api/domain/musicGeneration/controller/GenerationController.java index b65b87b..8661825 100644 --- a/src/main/java/com/humix/api/domain/musicGeneration/controller/GenerationController.java +++ b/src/main/java/com/humix/api/domain/musicGeneration/controller/GenerationController.java @@ -55,4 +55,26 @@ public ApiResponse modifySongPrompt( MusicGenerationDTO.TaskAcceptedResponse result = musicGenerationService.modifySongPrompt(userDetails, songId, request); return ApiResponse.onSuccess(GeneralSuccessCode.ACCEPTED, result); } + + // 특정 버전으로 되돌리기 (이후 버전 soft delete) + @PostMapping("/{generation_id}/revert") + @Override + public ApiResponse revertToVersion( + @AuthenticationPrincipal CustomUserDetails userDetails, + @PathVariable("generation_id") Long generationId) { + + MusicGenerationDTO.RevertResponse result = musicGenerationService.revertToVersion(userDetails, generationId); + return ApiResponse.onSuccess(GeneralSuccessCode.OK, result); + } + + // 원본(root) 곡의 유효한 버전 목록 조회 + @GetMapping("/{root_generation_id}/versions") + @Override + public ApiResponse getVersions( + @AuthenticationPrincipal CustomUserDetails userDetails, + @PathVariable("root_generation_id") Long rootGenerationId) { + + MusicGenerationDTO.VersionListResponse result = musicGenerationService.getVersions(userDetails, rootGenerationId); + return ApiResponse.onSuccess(GeneralSuccessCode.OK, result); + } } \ No newline at end of file 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 2883b0e..84887da 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 @@ -194,7 +194,7 @@ ApiResponse generateSong( content = @Content(schema = @Schema(implementation = ApiResponse.class), examples = @ExampleObject(name = "검증 실패", value = "{\"isSuccess\":false, \"code\":\"COMMON400\", \"message\":\"잘못된 요청입니다.\", \"result\":{\"prompt\":\"prompt는 필수입니다.\"}}"))), - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "원본 곡 없음", + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "수정 대상 곡 없음 (없는 ID 또는 버전 되돌리기로 삭제된 버전)", content = @Content(schema = @Schema(implementation = ApiResponse.class), examples = @ExampleObject(name = "원본 곡 없음", value = "{\"isSuccess\":false, \"code\":\"GENERATION4000\", \"message\":\"해당하는 생성곡이 존재하지 않습니다.\", \"result\":null}"))) @@ -204,4 +204,117 @@ ApiResponse modifySongPrompt( @AuthenticationPrincipal CustomUserDetails userDetails, @PathVariable("song_id") Long songId, @org.springframework.web.bind.annotation.RequestBody MusicGenerationDTO.SongModificationRequest request); + + @Operation( + summary = "버전 되돌리기 API", + description = "지정한 버전으로 되돌립니다. 같은 곡(root)에서 해당 버전보다 **뒤에 만들어진 버전은 모두 삭제(soft delete)** 되어 " + + "버전 목록과 내 곡 목록에서 사라집니다. (예: 버전 2로 되돌리면 3, 4 삭제)\n\n" + + "- `generation_id` (path): 되돌릴 기준 버전의 ID. 완료(COMPLETED)된 본인 곡이어야 합니다.\n" + + "- 같은 곡에 진행 중(PROCESSING)인 작업이 있으면 409를 반환합니다. 완료되거나 취소된 뒤 다시 시도하세요." + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "되돌리기 성공", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ApiResponse.class), + examples = @ExampleObject( + value = "{\n" + + " \"isSuccess\": true,\n" + + " \"code\": \"COMMON200\",\n" + + " \"message\": \"성공입니다.\",\n" + + " \"result\": {\n" + + " \"generation_id\": 42,\n" + + " \"deleted_count\": 2\n" + + " }\n" + + "}" + ) + ) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "401", + description = "로그인 필요", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + value = "{\"isSuccess\": false, \"code\": \"AUTH4000\", \"message\": \"로그인이 필요한 기능입니다.\", \"result\": null}" + ) + ) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "되돌릴 수 있는 곡 없음 (없는 ID, 이미 삭제됨, 미완료, 타인의 곡)", + content = @Content(schema = @Schema(implementation = ApiResponse.class), + examples = @ExampleObject(name = "곡 없음", + value = "{\"isSuccess\":false, \"code\":\"GENERATION4000\", \"message\":\"해당하는 생성곡이 존재하지 않습니다.\", \"result\":null}"))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "같은 곡에 진행 중인 작업이 있음", + content = @Content(schema = @Schema(implementation = ApiResponse.class), + examples = @ExampleObject(name = "진행 중인 작업 있음", + value = "{\"isSuccess\":false, \"code\":\"GENERATION4001\", \"message\":\"같은 곡의 다른 버전을 생성 중입니다. 완료되거나 취소된 후 다시 시도해주세요.\", \"result\":null}"))) + }) + @PostMapping("/{generation_id}/revert") + ApiResponse revertToVersion( + @AuthenticationPrincipal CustomUserDetails userDetails, + @PathVariable("generation_id") Long generationId); + + @Operation( + summary = "버전 목록 조회 API", + description = "같은 곡의 유효한 버전(완료 + 삭제되지 않음)을 생성 순서(오래된 순)로 반환합니다.\n\n" + + "- `root_generation_id` (path): 원본(최초 생성곡)의 ID. 수정본의 ID가 아니라 원본의 ID를 넘겨야 합니다.\n" + + "- `version_no`: 목록 순서로 계산한 버전 번호 (1부터). 실패/취소된 작업은 번호를 차지하지 않습니다.\n" + + "- 본인 곡만 조회할 수 있습니다." + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "조회 성공", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ApiResponse.class), + examples = @ExampleObject( + value = "{\n" + + " \"isSuccess\": true,\n" + + " \"code\": \"COMMON200\",\n" + + " \"message\": \"성공입니다.\",\n" + + " \"result\": {\n" + + " \"root_generation_id\": 40,\n" + + " \"versions\": [\n" + + " {\n" + + " \"generation_id\": 40,\n" + + " \"version_no\": 1,\n" + + " \"audio_url\": \"https://humix-bucket.s3.amazonaws.com/audio/abc123_variation.wav\",\n" + + " \"duration_seconds\": 30,\n" + + " \"created_at\": \"2026-06-21T18:00:00\"\n" + + " },\n" + + " {\n" + + " \"generation_id\": 42,\n" + + " \"version_no\": 2,\n" + + " \"audio_url\": \"https://humix-bucket.s3.amazonaws.com/audio/def456_variation.wav\",\n" + + " \"duration_seconds\": 30,\n" + + " \"created_at\": \"2026-06-21T18:05:00\"\n" + + " }\n" + + " ]\n" + + " }\n" + + "}" + ) + ) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "401", + description = "로그인 필요", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + value = "{\"isSuccess\": false, \"code\": \"AUTH4000\", \"message\": \"로그인이 필요한 기능입니다.\", \"result\": null}" + ) + ) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "곡 없음 (없는 ID, 삭제됨, 타인의 곡, 원본이 아닌 수정본의 ID)", + content = @Content(schema = @Schema(implementation = ApiResponse.class), + examples = @ExampleObject(name = "곡 없음", + value = "{\"isSuccess\":false, \"code\":\"GENERATION4000\", \"message\":\"해당하는 생성곡이 존재하지 않습니다.\", \"result\":null}"))) + }) + @GetMapping("/{root_generation_id}/versions") + ApiResponse getVersions( + @AuthenticationPrincipal CustomUserDetails userDetails, + @PathVariable("root_generation_id") Long rootGenerationId); } \ No newline at end of file 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 387f3a4..9452350 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 @@ -61,6 +61,52 @@ public record TaskCancelResponse( String status ) {} + // 버전 되돌리기 응답 Response Body + public record RevertResponse( + @Schema(description = "되돌린 기준 버전의 ID (이 버전이 마지막 버전이 됨)", example = "2") + @JsonProperty("generation_id") Long generationId, + + @Schema(description = "함께 삭제(soft delete)된 이후 버전 수", example = "2") + @JsonProperty("deleted_count") int deletedCount + ) {} + + // 버전 목록의 개별 버전 정보 + public record VersionItemResponse( + @Schema(description = "버전의 generation ID", example = "42") + @JsonProperty("generation_id") Long generationId, + + @Schema(description = "버전 번호 (유효한 버전을 생성 순서로 센 값, 1부터)", example = "1") + @JsonProperty("version_no") int versionNo, + + @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") Double durationSeconds, + + @Schema(description = "생성 일시", example = "2026-06-21T18:00:00") + @JsonProperty("created_at") LocalDateTime createdAt + ) { + public static VersionItemResponse of(MusicGeneration musicGeneration, int versionNo) { + return new VersionItemResponse( + musicGeneration.getId(), + versionNo, + musicGeneration.getResultS3Url(), + musicGeneration.getDurationSeconds(), + musicGeneration.getCreatedAt() + ); + } + } + + // 버전 목록 조회 Response Body + public record VersionListResponse( + @Schema(description = "버전 묶음의 기준(원본) generation ID", example = "40") + @JsonProperty("root_generation_id") Long rootGenerationId, + + @Schema(description = "유효한 버전 목록 (생성 순서)") + List versions + ) {} + // 생성곡 프롬프트 수정 요청 Request Body public record SongModificationRequest( @Schema(description = "수정 방향 프롬프트 (예: 더 밝게, 템포를 빠르게 등)", example = "더 밝고 경쾌하게 수정해줘", 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 48623dd..c7df55e 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 @@ -11,7 +11,7 @@ import java.time.LocalDateTime; @Entity -@Table(name = "music_generation") +@Table(name = "music_generation", indexes = @Index(name = "idx_music_generation_root", columnList = "root_generation_id")) @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) @EntityListeners(AuditingEntityListener.class) @@ -35,6 +35,11 @@ public class MusicGeneration { @JoinColumn(name = "parent_generation_id") private MusicGeneration parentGeneration; + // 같은 곡의 버전 묶음 기준. 원본은 자기 자신의 ID, 수정본은 부모의 root를 물려받는다. + // 이 컬럼이 생기기 전에 만든 곡은 NULL이며, 배포 후 백필 SQL로 자기 자신의 ID를 채운다 (infra/db/ERD.md 참고). + @Column(name = "root_generation_id") + private Long rootGenerationId; + @Column(name = "name", length = 30, nullable = false) private String name; @@ -66,6 +71,10 @@ public class MusicGeneration { @Column(name = "created_at", updatable = false) private LocalDateTime createdAt; + // 되돌리기로 무효화된 버전의 soft delete 시각. NULL이면 유효 + @Column(name = "deleted_at") + private LocalDateTime deletedAt; + @Builder public MusicGeneration(Member member, MelodyScore melodyScore, MusicGeneration parentGeneration, String name, String genre, String atmosphere, String prompt, String taskId, @@ -73,6 +82,7 @@ public MusicGeneration(Member member, MelodyScore melodyScore, MusicGeneration p this.member = member; this.melodyScore = melodyScore; this.parentGeneration = parentGeneration; + this.rootGenerationId = parentGeneration != null ? parentGeneration.getRootGenerationId() : null; this.name = name != null ? name : "나의 허밍곡"; // Default 값 설정 this.genre = genre; this.atmosphere = atmosphere; @@ -82,6 +92,11 @@ public MusicGeneration(Member member, MelodyScore melodyScore, MusicGeneration p this.status = GenerationStatus.PROCESSING; // 초기 상태 설정 } + // 최초 생성곡을 자기 자신의 root로 지정. IDENTITY 전략이라 save() 이후에 ID가 확정되므로 별도 호출이 필요하다. + public void markAsRoot() { + this.rootGenerationId = this.id; + } + // 곡 이름 수정 메서드 public void updateName(String name) { this.name = name; diff --git a/src/main/java/com/humix/api/domain/musicGeneration/repository/MusicGenerationRepository.java b/src/main/java/com/humix/api/domain/musicGeneration/repository/MusicGenerationRepository.java index d1ef02f..5793dad 100644 --- a/src/main/java/com/humix/api/domain/musicGeneration/repository/MusicGenerationRepository.java +++ b/src/main/java/com/humix/api/domain/musicGeneration/repository/MusicGenerationRepository.java @@ -6,10 +6,31 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.LocalDateTime; +import java.util.List; import java.util.Optional; public interface MusicGenerationRepository extends JpaRepository { Optional findByTaskId(String taskId); - Page findByMemberAndStatus(Member member, GenerationStatus status, Pageable pageable); - Page findByMemberAndGenreAndStatus(Member member, String genre, GenerationStatus status, Pageable pageable); + Optional findByIdAndDeletedAtIsNull(Long id); + Optional findByIdAndStatusAndDeletedAtIsNull(Long id, GenerationStatus status); + Page findByMemberAndStatusAndDeletedAtIsNull(Member member, GenerationStatus status, Pageable pageable); + Page findByMemberAndGenreAndStatusAndDeletedAtIsNull(Member member, String genre, GenerationStatus status, Pageable pageable); + + // 버전 목록: 같은 root의 유효한(완료 + soft delete 아님) 버전을 생성 순서대로 + List findByRootGenerationIdAndStatusAndDeletedAtIsNullOrderByIdAsc(Long rootGenerationId, GenerationStatus status); + + boolean existsByRootGenerationIdAndStatusAndDeletedAtIsNull(Long rootGenerationId, GenerationStatus status); + + // 되돌리기: 기준 버전 이후(id가 더 큰) 같은 root의 버전을 모두 soft delete하고 삭제 건수를 반환 + @Modifying(clearAutomatically = true) + @Query("update MusicGeneration g set g.deletedAt = :deletedAt " + + "where g.rootGenerationId = :rootGenerationId and g.id > :generationId and g.deletedAt is null") + int softDeleteVersionsAfter(@Param("rootGenerationId") Long rootGenerationId, + @Param("generationId") Long generationId, + @Param("deletedAt") LocalDateTime deletedAt); } 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 90557dc..afa987c 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 @@ -28,6 +28,7 @@ import java.io.IOException; import java.time.Duration; +import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; import java.util.Map; @@ -132,6 +133,7 @@ public MusicGenerationDTO.TaskAcceptedResponse generateSong(CustomUserDetails us musicGeneration.updateTaskId(taskId); musicGeneration.updateDuration((double) generationDurationSeconds); musicGenerationRepository.save(musicGeneration); + musicGeneration.markAsRoot(); // 최초 생성곡은 자기 자신이 버전 묶음의 root // 6. AI 서버로 비동기 작곡 요청 String callbackUrl = backendUrl + "/api/v1/internal/tasks/" + taskId + "/completion"; @@ -177,7 +179,7 @@ public MusicGenerationDTO.TaskAcceptedResponse modifySongPrompt(CustomUserDetail } // 1. 기존 완성곡(Parent) 로드 - MusicGeneration parentGeneration = musicGenerationRepository.findById(songId) + MusicGeneration parentGeneration = musicGenerationRepository.findByIdAndDeletedAtIsNull(songId) .orElseThrow(() -> new GeneralException(GeneralErrorCode.GENERATION_NOT_FOUND)); MelodyScore melodyScore = parentGeneration.getMelodyScore(); @@ -231,6 +233,64 @@ public MusicGenerationDTO.TaskAcceptedResponse modifySongPrompt(CustomUserDetail return new MusicGenerationDTO.TaskAcceptedResponse(taskId); } + /** + * 특정 버전으로 되돌린다: 같은 root에서 해당 버전보다 뒤(id가 더 큰)의 버전을 모두 soft delete. + * 같은 root에 진행 중인 작업이 있으면 되돌리기 중에 결과가 들어와 버전이 꼬이므로 거부한다. + */ + @Transactional + public MusicGenerationDTO.RevertResponse revertToVersion(CustomUserDetails userDetails, Long generationId) { + requireLogin(userDetails); + + MusicGeneration target = musicGenerationRepository + .findByIdAndStatusAndDeletedAtIsNull(generationId, GenerationStatus.COMPLETED) + .filter(generation -> isOwner(userDetails, generation)) + .orElseThrow(() -> new GeneralException(GeneralErrorCode.GENERATION_NOT_FOUND)); + Long rootId = target.getRootGenerationId(); + + if (musicGenerationRepository.existsByRootGenerationIdAndStatusAndDeletedAtIsNull(rootId, GenerationStatus.PROCESSING)) { + throw new GeneralException(GeneralErrorCode.GENERATION_IN_PROGRESS); + } + + int deletedCount = musicGenerationRepository.softDeleteVersionsAfter(rootId, target.getId(), LocalDateTime.now()); + log.info("[MusicGeneration] Reverted to generationId: {} (rootId: {}), soft deleted {} versions", generationId, rootId, deletedCount); + + return new MusicGenerationDTO.RevertResponse(target.getId(), deletedCount); + } + + /** + * 원본(root) 곡의 유효한 버전(완료 + soft delete 아님) 목록을 생성 순서대로 반환한다. + * 경로의 ID는 원본(최초 생성곡)의 ID여야 하며, 수정본의 ID이거나 타인의 곡이면 404. + */ + public MusicGenerationDTO.VersionListResponse getVersions(CustomUserDetails userDetails, Long rootGenerationId) { + requireLogin(userDetails); + + musicGenerationRepository.findByIdAndDeletedAtIsNull(rootGenerationId) + .filter(generation -> rootGenerationId.equals(generation.getRootGenerationId())) + .filter(generation -> isOwner(userDetails, generation)) + .orElseThrow(() -> new GeneralException(GeneralErrorCode.GENERATION_NOT_FOUND)); + + List versions = musicGenerationRepository + .findByRootGenerationIdAndStatusAndDeletedAtIsNullOrderByIdAsc(rootGenerationId, GenerationStatus.COMPLETED); + + // versionNo는 컬럼 없이 조회 순서로 계산한다 (되돌리기로 번호가 비는 일이 없도록) + List items = new ArrayList<>(); + for (int i = 0; i < versions.size(); i++) { + items.add(MusicGenerationDTO.VersionItemResponse.of(versions.get(i), i + 1)); + } + return new MusicGenerationDTO.VersionListResponse(rootGenerationId, items); + } + + private void requireLogin(CustomUserDetails userDetails) { + if (userDetails == null || userDetails.getMember() == null) { + throw new GeneralException(GeneralErrorCode.LOGIN_REQUIRED); + } + } + + // 타인의 곡은 존재 여부를 드러내지 않도록 호출부에서 404로 처리한다 + private boolean isOwner(CustomUserDetails userDetails, MusicGeneration generation) { + return generation.getMember().getUuid().equals(userDetails.getMember().getUuid()); + } + public SseEmitter subscribeTaskStream(String taskId) { log.info("[MusicGeneration] SSE subscribe request received for taskId: {}", taskId); 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 60bf509..d962bad 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 @@ -32,6 +32,7 @@ public enum GeneralErrorCode implements BaseErrorCode{ // GENERATION 관련 에러 GENERATION_NOT_FOUND(HttpStatus.NOT_FOUND, "GENERATION4000", "해당하는 생성곡이 존재하지 않습니다."), + GENERATION_IN_PROGRESS(HttpStatus.CONFLICT, "GENERATION4001", "같은 곡의 다른 버전을 생성 중입니다. 완료되거나 취소된 후 다시 시도해주세요."), // TASK 관련 에러 TASK_NOT_FOUND(HttpStatus.NOT_FOUND, "TASK4000", "해당 태스크 ID의 작업 정보가 존재하지 않습니다."), diff --git a/src/main/java/com/humix/api/global/security/config/SecurityConfig.java b/src/main/java/com/humix/api/global/security/config/SecurityConfig.java index ab6538d..6816055 100644 --- a/src/main/java/com/humix/api/global/security/config/SecurityConfig.java +++ b/src/main/java/com/humix/api/global/security/config/SecurityConfig.java @@ -69,7 +69,8 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { // 🚨 특정 도메인은 무조건 인증(로그인) 필요하게 막기 .requestMatchers("/api/v1/upload/**").authenticated() .requestMatchers("/api/v1/hummings/**").authenticated() - .requestMatchers(HttpMethod.POST, "/api/v1/generation/songs", "/api/v1/generation/songs/*/modifications").authenticated() + .requestMatchers(HttpMethod.POST, "/api/v1/generation/songs", "/api/v1/generation/songs/*/modifications", "/api/v1/generation/songs/*/revert").authenticated() + .requestMatchers(HttpMethod.GET, "/api/v1/generation/songs/*/versions").authenticated() // 나머지는 다 인증 불필요 .anyRequest().permitAll() diff --git a/src/test/java/com/humix/api/domain/musicGeneration/GenerationVersionTest.java b/src/test/java/com/humix/api/domain/musicGeneration/GenerationVersionTest.java new file mode 100644 index 0000000..bc9338f --- /dev/null +++ b/src/test/java/com/humix/api/domain/musicGeneration/GenerationVersionTest.java @@ -0,0 +1,360 @@ +package com.humix.api.domain.musicGeneration; + +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 com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.AfterAll; +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.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.time.LocalDateTime; +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.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class GenerationVersionTest { + + // 생성/수정 API가 RunPod로 보내는 요청을 받아주는 가짜 서버 (실제 네트워크 호출 방지) + private static final HttpServer FAKE_RUNPOD = startFakeRunpod(); + + private static HttpServer startFakeRunpod() { + try { + HttpServer server = HttpServer.create(new InetSocketAddress("localhost", 0), 0); + server.createContext("/", exchange -> { + exchange.sendResponseHeaders(200, -1); + exchange.close(); + }); + server.start(); + return server; + } catch (IOException e) { + throw new IllegalStateException(e); + } + } + + @DynamicPropertySource + static void runpodProperties(DynamicPropertyRegistry registry) { + registry.add("runpod.endpoint-url", () -> "http://localhost:" + FAKE_RUNPOD.getAddress().getPort()); + } + + @AfterAll + static void stopFakeRunpod() { + FAKE_RUNPOD.stop(0); + } + + @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 bearer; + private Member member; + private Humming humming; + private MelodyScore melody; + + @BeforeEach + void setUp() throws Exception { + // 테스트끼리 데이터가 섞이지 않도록 매번 새 사용자로 시작한다 (member.uuid는 36자) + String deviceId = UUID.randomUUID().toString(); + bearer = login(deviceId); + member = memberRepository.findById(deviceId).orElseThrow(); + 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 되돌리기는_기준_버전_이후만_삭제하고_목록에는_남은_버전만_보인다() throws Exception { + MusicGeneration v1 = version(member, null, GenerationStatus.COMPLETED); + MusicGeneration v2 = version(member, v1, GenerationStatus.COMPLETED); + MusicGeneration v3 = version(member, v2, GenerationStatus.COMPLETED); + MusicGeneration v4 = version(member, v3, GenerationStatus.COMPLETED); + + revert(bearer, v2.getId()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.result.generation_id").value(v2.getId())) + .andExpect(jsonPath("$.result.deleted_count").value(2)); + + assertThat(deletedAt(v1)).isNull(); + assertThat(deletedAt(v2)).isNull(); + assertThat(deletedAt(v3)).isNotNull(); + assertThat(deletedAt(v4)).isNotNull(); + + versions(bearer, v1.getId()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.result.root_generation_id").value(v1.getId())) + .andExpect(jsonPath("$.result.versions.length()").value(2)) + .andExpect(jsonPath("$.result.versions[0].generation_id").value(v1.getId())) + .andExpect(jsonPath("$.result.versions[0].version_no").value(1)) + .andExpect(jsonPath("$.result.versions[1].generation_id").value(v2.getId())) + .andExpect(jsonPath("$.result.versions[1].version_no").value(2)); + } + + @Test + void 되돌린_뒤_새_버전이_생기면_번호가_이어진다() throws Exception { + MusicGeneration v1 = version(member, null, GenerationStatus.COMPLETED); + MusicGeneration v2 = version(member, v1, GenerationStatus.COMPLETED); + MusicGeneration v3 = version(member, v2, GenerationStatus.COMPLETED); + + revert(bearer, v1.getId()).andExpect(status().isOk()); + // 삭제된 v2, v3 대신 v1에서 새로 수정본을 만든 상황 + MusicGeneration v4 = version(member, v1, GenerationStatus.COMPLETED); + + versions(bearer, v1.getId()) + .andExpect(jsonPath("$.result.versions.length()").value(2)) + .andExpect(jsonPath("$.result.versions[1].generation_id").value(v4.getId())) + .andExpect(jsonPath("$.result.versions[1].version_no").value(2)); + assertThat(deletedAt(v2)).isNotNull(); + assertThat(deletedAt(v3)).isNotNull(); + } + + @Test + void 마지막_버전으로_되돌리면_삭제_0건() throws Exception { + MusicGeneration v1 = version(member, null, GenerationStatus.COMPLETED); + MusicGeneration v2 = version(member, v1, GenerationStatus.COMPLETED); + + revert(bearer, v2.getId()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.result.deleted_count").value(0)); + assertThat(deletedAt(v1)).isNull(); + assertThat(deletedAt(v2)).isNull(); + } + + @Test + void 같은_root에_진행중인_작업이_있으면_409이고_아무것도_삭제하지_않는다() throws Exception { + MusicGeneration v1 = version(member, null, GenerationStatus.COMPLETED); + MusicGeneration v2 = version(member, v1, GenerationStatus.COMPLETED); + MusicGeneration v3 = version(member, v2, GenerationStatus.PROCESSING); + + revert(bearer, v1.getId()) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("GENERATION4001")); + + assertThat(deletedAt(v2)).isNull(); + assertThat(deletedAt(v3)).isNull(); + } + + @Test + void 다른_곡의_진행중인_작업은_되돌리기를_막지_않는다() throws Exception { + MusicGeneration a1 = version(member, null, GenerationStatus.COMPLETED); + MusicGeneration a2 = version(member, a1, GenerationStatus.COMPLETED); + version(member, null, GenerationStatus.PROCESSING); // 다른 root + + revert(bearer, a1.getId()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.result.deleted_count").value(1)); + assertThat(deletedAt(a2)).isNotNull(); + } + + @Test + void 이미_삭제된_버전이나_미완료_버전으로는_되돌릴_수_없다() throws Exception { + MusicGeneration v1 = version(member, null, GenerationStatus.COMPLETED); + MusicGeneration v2 = version(member, v1, GenerationStatus.COMPLETED); + MusicGeneration failed = version(member, v1, GenerationStatus.FAILED); + + revert(bearer, failed.getId()) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("GENERATION4000")); + + revert(bearer, v1.getId()).andExpect(status().isOk()); + revert(bearer, v2.getId()) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("GENERATION4000")); + } + + @Test + void 없는_곡이나_타인의_곡은_404() throws Exception { + Member other = memberRepository.save(Member.builder().uuid(UUID.randomUUID().toString()).build()); + MusicGeneration othersSong = version(other, null, GenerationStatus.COMPLETED); + MusicGeneration othersLater = version(other, othersSong, GenerationStatus.COMPLETED); + + revert(bearer, 999_999L) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("GENERATION4000")); + revert(bearer, othersSong.getId()) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("GENERATION4000")); + versions(bearer, othersSong.getId()) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("GENERATION4000")); + + assertThat(deletedAt(othersLater)).isNull(); + } + + @Test + void 로그인_없이는_401() throws Exception { + mockMvc.perform(post("/api/v1/generation/songs/1/revert")) + .andExpect(status().isUnauthorized()); + mockMvc.perform(get("/api/v1/generation/songs/1/versions")) + .andExpect(status().isUnauthorized()); + } + + // ---------- 버전 목록 ---------- + + @Test + void 버전_목록은_완료된_버전만_생성_순서로_보여주고_번호는_연속이다() throws Exception { + MusicGeneration v1 = version(member, null, GenerationStatus.COMPLETED); + version(member, v1, GenerationStatus.FAILED); + version(member, v1, GenerationStatus.CANCELED); + version(member, v1, GenerationStatus.PROCESSING); + MusicGeneration v5 = version(member, v1, GenerationStatus.COMPLETED); + + versions(bearer, v1.getId()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.result.versions.length()").value(2)) + .andExpect(jsonPath("$.result.versions[0].generation_id").value(v1.getId())) + .andExpect(jsonPath("$.result.versions[0].version_no").value(1)) + .andExpect(jsonPath("$.result.versions[0].audio_url").exists()) + .andExpect(jsonPath("$.result.versions[1].generation_id").value(v5.getId())) + .andExpect(jsonPath("$.result.versions[1].version_no").value(2)); + } + + @Test + void 버전_목록은_원본_ID만_받고_수정본_ID나_없는_ID는_404() throws Exception { + MusicGeneration v1 = version(member, null, GenerationStatus.COMPLETED); + MusicGeneration v2 = version(member, v1, GenerationStatus.COMPLETED); + + versions(bearer, v1.getId()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.result.root_generation_id").value(v1.getId())); + versions(bearer, v2.getId()) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("GENERATION4000")); + versions(bearer, 999_999L) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("GENERATION4000")); + } + + // ---------- 기존 조회/수정 API 반영 ---------- + + @Test + void 내_곡_목록에서_삭제된_버전은_빠진다() throws Exception { + MusicGeneration v1 = version(member, null, GenerationStatus.COMPLETED); + MusicGeneration v2 = version(member, v1, GenerationStatus.COMPLETED); + version(member, v2, GenerationStatus.COMPLETED); + + mockMvc.perform(get("/api/v1/me/songs").header("Authorization", bearer)) + .andExpect(jsonPath("$.result.total_count").value(3)); + + revert(bearer, v1.getId()).andExpect(status().isOk()); + + mockMvc.perform(get("/api/v1/me/songs").header("Authorization", bearer)) + .andExpect(jsonPath("$.result.total_count").value(1)) + .andExpect(jsonPath("$.result.contents[0].song_id").value(v1.getId())); + } + + @Test + void 삭제된_버전은_수정_요청_대상이_될_수_없다() throws Exception { + MusicGeneration v1 = version(member, null, GenerationStatus.COMPLETED); + MusicGeneration v2 = version(member, v1, GenerationStatus.COMPLETED); + revert(bearer, v1.getId()).andExpect(status().isOk()); + + mockMvc.perform(post("/api/v1/generation/songs/{id}/modifications", v2.getId()) + .header("Authorization", bearer) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"prompt\":\"더 밝게\"}")) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("GENERATION4000")); + } + + // ---------- root 설정 ---------- + + @Test + void 생성은_자기_자신을_root로_수정은_부모의_root를_물려받는다() throws Exception { + String createBody = mockMvc.perform(post("/api/v1/generation/songs") + .header("Authorization", bearer) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"humming_id\":" + humming.getId() + ",\"title\":\"t\",\"genre\":\"pop\",\"mood\":\"happy\"}")) + .andExpect(jsonPath("$.code").value("COMMON202")) + .andReturn().getResponse().getContentAsString(); + MusicGeneration root = musicGenerationRepository + .findByTaskId(objectMapper.readTree(createBody).at("/result/task_id").asText()).orElseThrow(); + assertThat(root.getRootGenerationId()).isEqualTo(root.getId()); + + root.updateStatus(GenerationStatus.COMPLETED, "https://example.com/root.wav"); + musicGenerationRepository.save(root); + + String modifyBody = mockMvc.perform(post("/api/v1/generation/songs/{id}/modifications", root.getId()) + .header("Authorization", bearer) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"prompt\":\"더 밝게\"}")) + .andExpect(jsonPath("$.code").value("COMMON202")) + .andReturn().getResponse().getContentAsString(); + MusicGeneration child = musicGenerationRepository + .findByTaskId(objectMapper.readTree(modifyBody).at("/result/task_id").asText()).orElseThrow(); + assertThat(child.getRootGenerationId()).isEqualTo(root.getId()); + } + + // ---------- helpers ---------- + + private String login(String deviceId) throws Exception { + String body = mockMvc.perform(post("/api/v1/auth/guest-login") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"device_id\":\"" + deviceId + "\"}")) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + return "Bearer " + objectMapper.readTree(body).at("/result/access_token").asText(); + } + + private ResultActions revert(String bearer, Long generationId) throws Exception { + return mockMvc.perform(post("/api/v1/generation/songs/{id}/revert", generationId) + .header("Authorization", bearer)); + } + + private ResultActions versions(String bearer, Long generationId) throws Exception { + return mockMvc.perform(get("/api/v1/generation/songs/{id}/versions", generationId) + .header("Authorization", bearer)); + } + + private LocalDateTime deletedAt(MusicGeneration generation) { + return musicGenerationRepository.findById(generation.getId()).orElseThrow().getDeletedAt(); + } + + // parent가 null이면 최초 생성곡(자기 자신이 root) + private MusicGeneration version(Member owner, MusicGeneration parent, GenerationStatus status) { + MusicGeneration generation = musicGenerationRepository.save(MusicGeneration.builder() + .member(owner).melodyScore(melody).parentGeneration(parent) + .name("곡").genre("pop").atmosphere("happy") + .build()); + if (parent == null) { + generation.markAsRoot(); + } + if (status != GenerationStatus.PROCESSING) { + generation.updateStatus(status, + status == GenerationStatus.COMPLETED ? "https://example.com/" + generation.getId() + ".wav" : null); + } + return musicGenerationRepository.save(generation); + } +}