Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions infra/db/ERD.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -45,6 +46,7 @@ erDiagram
double duration_seconds "nullable"
varchar(20) status
datetime created_at
datetime deleted_at "nullable"
}
```

Expand Down Expand Up @@ -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`) |
Expand All @@ -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;`, 기존 곡에 수정본이 없을 때만 유효)
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,11 @@ public ApiResponse<MusicGenerationDTO.SongListResponse> getMySongs(
Page<MusicGeneration> 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
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "성공")
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,4 +55,26 @@ public ApiResponse<MusicGenerationDTO.TaskAcceptedResponse> modifySongPrompt(
MusicGenerationDTO.TaskAcceptedResponse result = musicGenerationService.modifySongPrompt(userDetails, songId, request);
return ApiResponse.onSuccess(GeneralSuccessCode.ACCEPTED, result);
}

// 특정 버전으로 되돌리기 (이후 버전 soft delete)
@PostMapping("/{generation_id}/revert")
@Override
public ApiResponse<MusicGenerationDTO.RevertResponse> 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<MusicGenerationDTO.VersionListResponse> getVersions(
@AuthenticationPrincipal CustomUserDetails userDetails,
@PathVariable("root_generation_id") Long rootGenerationId) {

MusicGenerationDTO.VersionListResponse result = musicGenerationService.getVersions(userDetails, rootGenerationId);
return ApiResponse.onSuccess(GeneralSuccessCode.OK, result);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,7 @@ ApiResponse<MusicGenerationDTO.TaskAcceptedResponse> 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}")))
Expand All @@ -204,4 +204,117 @@ ApiResponse<MusicGenerationDTO.TaskAcceptedResponse> 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<MusicGenerationDTO.RevertResponse> 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<MusicGenerationDTO.VersionListResponse> getVersions(
@AuthenticationPrincipal CustomUserDetails userDetails,
@PathVariable("root_generation_id") Long rootGenerationId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<VersionItemResponse> versions
) {}

// 생성곡 프롬프트 수정 요청 Request Body
public record SongModificationRequest(
@Schema(description = "수정 방향 프롬프트 (예: 더 밝게, 템포를 빠르게 등)", example = "더 밝고 경쾌하게 수정해줘",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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;

Expand Down Expand Up @@ -66,13 +71,18 @@ 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,
Double durationSeconds) {
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;
Expand All @@ -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;
Expand Down
Loading
Loading