From 5ed9b4eb9cc1c62e1b731644bbda8d571c660f0e Mon Sep 17 00:00:00 2001 From: onlyoneprivate Date: Mon, 10 Aug 2026 21:13:14 +0900 Subject: [PATCH 1/6] =?UTF-8?q?refactor:=20S3=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=ED=83=80=EC=9E=85=EB=B3=84=20Object=20Key=20=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=20=EA=B5=AC=EC=A1=B0=20=EA=B0=9C=EC=84=A0=20(#184)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mr/global/file/s3/enums/S3FileType.java | 14 +++++ .../global/file/s3/service/S3FileService.java | 22 +++---- .../file/s3/service/S3ObjectKeyGenerator.java | 11 ++-- .../RecordingObjectKeyGeneratorTest.java | 13 +++++ .../file/s3/service/S3FileServiceTest.java | 57 ++++++++++++++++++- 5 files changed, 97 insertions(+), 20 deletions(-) create mode 100644 src/main/java/com/mr/global/file/s3/enums/S3FileType.java diff --git a/src/main/java/com/mr/global/file/s3/enums/S3FileType.java b/src/main/java/com/mr/global/file/s3/enums/S3FileType.java new file mode 100644 index 00000000..068a224b --- /dev/null +++ b/src/main/java/com/mr/global/file/s3/enums/S3FileType.java @@ -0,0 +1,14 @@ +package com.mr.global.file.s3.enums; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum S3FileType { + + RECORDING("recordings"), + BACKING_TRACK("backing-tracks"); + + private final String prefix; +} diff --git a/src/main/java/com/mr/global/file/s3/service/S3FileService.java b/src/main/java/com/mr/global/file/s3/service/S3FileService.java index 1d7ea5c0..b0674871 100644 --- a/src/main/java/com/mr/global/file/s3/service/S3FileService.java +++ b/src/main/java/com/mr/global/file/s3/service/S3FileService.java @@ -5,6 +5,7 @@ import com.mr.global.file.s3.dto.FileUploadCommand; import com.mr.global.file.s3.dto.ValidatedFile; import com.mr.global.file.s3.dto.PresignedUrlUpload; +import com.mr.global.file.s3.enums.S3FileType; import com.mr.global.file.s3.exception.S3ErrorStatus; import com.mr.global.file.s3.util.ContentTypeUtils; import lombok.RequiredArgsConstructor; @@ -43,7 +44,7 @@ public class S3FileService { * Presigned PUT URL을 발급합니다. */ public PresignedUrlUpload createPresignedUpload( - Long ownerId, FileUploadCommand command + Long ownerId, S3FileType fileType, FileUploadCommand command ){ validateOwnerId(ownerId); @@ -54,6 +55,7 @@ public PresignedUrlUpload createPresignedUpload( String objectKey = objectKeyGenerator.generate( ownerId, + fileType, command.originalFileName(), normalizedContentType ); @@ -114,12 +116,10 @@ public PresignedUrlUpload createPresignedUpload( * - 실제 파일 크기가 허용 범위인지 * - 실제 Content-Type이 허용된 형식인지 */ - public ValidatedFile validateUploadedFile( - Long ownerId, - String objectKey + public ValidatedFile validateUploadedFile(Long ownerId, S3FileType fileType, String objectKey ) { validateOwnerId(ownerId); - validateObjectKey(ownerId, objectKey); + validateObjectKey(ownerId, fileType, objectKey); HeadObjectResponse headObject = getHeadObject(objectKey); @@ -146,12 +146,10 @@ public ValidatedFile validateUploadedFile( } // 파일 조회용 Presigned GET URL을 발급 - public String createPresignedDownload( - Long ownerId, - String objectKey + public String createPresignedDownload(Long ownerId, S3FileType fileType, String objectKey ) { validateOwnerId(ownerId); - validateObjectKey(ownerId, objectKey); + validateObjectKey(ownerId, fileType, objectKey); GetObjectRequest getObjectRequest = GetObjectRequest.builder() @@ -292,9 +290,7 @@ private void validateAllowedContentType(String contentType) { } } - private void validateObjectKey( - Long ownerId, - String objectKey + private void validateObjectKey(Long ownerId, S3FileType fileType, String objectKey ) { if (objectKey == null || objectKey.isBlank()) { @@ -303,7 +299,7 @@ private void validateObjectKey( ); } - if (!objectKeyGenerator.belongsToOwner(ownerId, objectKey)) { + if (!objectKeyGenerator.belongsToOwner(ownerId, fileType, objectKey)) { throw new GeneralException(S3ErrorStatus.INVALID_OBJECT_KEY); } } diff --git a/src/main/java/com/mr/global/file/s3/service/S3ObjectKeyGenerator.java b/src/main/java/com/mr/global/file/s3/service/S3ObjectKeyGenerator.java index 4f242c08..dfb2c4a0 100644 --- a/src/main/java/com/mr/global/file/s3/service/S3ObjectKeyGenerator.java +++ b/src/main/java/com/mr/global/file/s3/service/S3ObjectKeyGenerator.java @@ -2,6 +2,7 @@ import com.mr.global.apipayload.exception.GeneralException; import com.mr.global.file.s3.config.S3Properties; +import com.mr.global.file.s3.enums.S3FileType; import com.mr.global.file.s3.exception.S3ErrorStatus; import com.mr.global.file.s3.util.ContentTypeUtils; import org.springframework.stereotype.Component; @@ -31,9 +32,11 @@ public S3ObjectKeyGenerator(S3Properties s3Properties) { } // 사용자별 S3 Object Key를 생성 - // 생성 예시: recordings/1/2026-08-05/152310_a1b2c3.webm + // 생성 예시1 : recordings/1/2026-08-05/152310_a1b2c3.webm + // 생성 예시2 : backing-tracks/1/2026-08-05/152310_a1b2c3.webm public String generate( Long userId, + S3FileType fileType, String originalFileName, String contentType ) { @@ -52,19 +55,19 @@ public String generate( ); return "%s/%d/%s/%s".formatted( - s3Properties.keyPrefix(), + fileType.getPrefix(), userId, now.format(DATE_FORMATTER), generatedFileName ); } - public boolean belongsToOwner(Long ownerId, String objectKey) { + public boolean belongsToOwner(Long ownerId, S3FileType fileType, String objectKey) { if (ownerId == null || objectKey == null) { return false; } - String expectedPrefix = s3Properties.keyPrefix() + "/" + ownerId + "/"; + String expectedPrefix = fileType.getPrefix() + "/" + ownerId + "/"; return objectKey.startsWith(expectedPrefix); } diff --git a/src/test/java/com/mr/global/file/s3/service/RecordingObjectKeyGeneratorTest.java b/src/test/java/com/mr/global/file/s3/service/RecordingObjectKeyGeneratorTest.java index 4c1174c3..aa37419b 100644 --- a/src/test/java/com/mr/global/file/s3/service/RecordingObjectKeyGeneratorTest.java +++ b/src/test/java/com/mr/global/file/s3/service/RecordingObjectKeyGeneratorTest.java @@ -9,6 +9,7 @@ import java.time.format.DateTimeFormatter; import java.util.Set; +import com.mr.global.file.s3.enums.S3FileType; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; @@ -24,6 +25,7 @@ class RecordingObjectKeyGeneratorTest { private static final ZoneId KOREA_ZONE_ID = ZoneId.of("Asia/Seoul"); private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + private static final S3FileType FILE_TYPE = S3FileType.RECORDING; private S3ObjectKeyGenerator objectKeyGenerator; @@ -66,6 +68,7 @@ void generateMp3ObjectKey() { // when String objectKey = objectKeyGenerator.generate( userId, + FILE_TYPE, "recording.mp3", "audio/mpeg" ); @@ -123,6 +126,7 @@ void generateWavObjectKey() { // when String objectKey = objectKeyGenerator.generate( userId, + FILE_TYPE, "recording.wav", "audio/wav" ); @@ -147,6 +151,7 @@ void generateMp3ExtensionFromContentType() { // when String objectKey = objectKeyGenerator.generate( userId, + FILE_TYPE, "recording", "audio/mpeg" ); @@ -170,6 +175,7 @@ void generateWavExtensionFromContentType() { // when String objectKey = objectKeyGenerator.generate( userId, + FILE_TYPE, "recording", "audio/x-wav" ); @@ -191,6 +197,7 @@ void generateUnsupportedFileExtension() { assertThatThrownBy(() -> objectKeyGenerator.generate( 1L, + FILE_TYPE, "recording.m4a", "audio/mpeg" ) @@ -215,6 +222,7 @@ void generateUnsupportedContentType() { assertThatThrownBy(() -> objectKeyGenerator.generate( 1L, + FILE_TYPE, "recording", "audio/mp4" ) @@ -250,6 +258,7 @@ void belongsToUser() { boolean result = objectKeyGenerator.belongsToOwner( userId, + FILE_TYPE, objectKey ); @@ -271,6 +280,7 @@ void doesNotBelongToUser() { boolean result = objectKeyGenerator.belongsToOwner( userId, + FILE_TYPE, objectKey ); @@ -285,6 +295,7 @@ void nullUserId() { assertThat( objectKeyGenerator.belongsToOwner( null, + FILE_TYPE, "recordings/1/2026-08-02/test.mp3" ) ) @@ -297,6 +308,7 @@ void nullObjectKey() { assertThat( objectKeyGenerator.belongsToOwner( 1L, + FILE_TYPE, null ) ) @@ -309,6 +321,7 @@ void blankObjectKey() { assertThat( objectKeyGenerator.belongsToOwner( 1L, + FILE_TYPE, " " ) ) diff --git a/src/test/java/com/mr/global/file/s3/service/S3FileServiceTest.java b/src/test/java/com/mr/global/file/s3/service/S3FileServiceTest.java index 8f523605..560a9b84 100644 --- a/src/test/java/com/mr/global/file/s3/service/S3FileServiceTest.java +++ b/src/test/java/com/mr/global/file/s3/service/S3FileServiceTest.java @@ -15,12 +15,12 @@ import com.mr.global.file.s3.dto.FileUploadCommand; import com.mr.global.file.s3.dto.PresignedUrlUpload; import com.mr.global.file.s3.dto.ValidatedFile; +import com.mr.global.file.s3.enums.S3FileType; import com.mr.global.file.s3.exception.S3ErrorStatus; import java.net.URI; import java.time.Duration; import java.util.Map; import java.util.Set; -import java.util.function.Consumer; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; @@ -31,9 +31,7 @@ import org.mockito.junit.jupiter.MockitoExtension; import software.amazon.awssdk.core.exception.SdkClientException; import software.amazon.awssdk.services.s3.S3Client; -import software.amazon.awssdk.services.s3.S3Utilities; import software.amazon.awssdk.services.s3.model.DeleteObjectRequest; -import software.amazon.awssdk.services.s3.model.GetUrlRequest; import software.amazon.awssdk.services.s3.model.HeadObjectRequest; import software.amazon.awssdk.services.s3.model.HeadObjectResponse; import software.amazon.awssdk.services.s3.model.S3Exception; @@ -79,6 +77,8 @@ class S3FileServiceTest { private static final long MAX_FILE_SIZE = 30L * 1_024 * 1_024; + private static final S3FileType FILE_TYPE = S3FileType.RECORDING; + @Mock private S3Client s3Client; @@ -144,6 +144,7 @@ void createPresignedUpload_success() throws Exception { when(objectKeyGenerator.generate( OWNER_ID, + FILE_TYPE, command.originalFileName(), CONTENT_TYPE )).thenReturn(OBJECT_KEY); @@ -161,6 +162,7 @@ void createPresignedUpload_success() throws Exception { PresignedUrlUpload response = s3FileService.createPresignedUpload( OWNER_ID, + FILE_TYPE, command ); @@ -229,6 +231,7 @@ void createPresignedUpload_normalizesContentType() when(objectKeyGenerator.generate( OWNER_ID, + FILE_TYPE, command.originalFileName(), NORMALIZED_WEBM_CONTENT_TYPE )).thenReturn(OBJECT_KEY); @@ -246,6 +249,7 @@ void createPresignedUpload_normalizesContentType() PresignedUrlUpload response = s3FileService.createPresignedUpload( OWNER_ID, + FILE_TYPE, command ); @@ -261,6 +265,7 @@ void createPresignedUpload_normalizesContentType() verify(objectKeyGenerator) .generate( OWNER_ID, + FILE_TYPE, command.originalFileName(), NORMALIZED_WEBM_CONTENT_TYPE ); @@ -295,6 +300,7 @@ void createPresignedUpload_nullOwnerId() { assertGeneralException( () -> s3FileService.createPresignedUpload( null, + FILE_TYPE, command ), S3ErrorStatus.INVALID_OBJECT_KEY @@ -303,6 +309,7 @@ void createPresignedUpload_nullOwnerId() { verify(objectKeyGenerator, never()) .generate( anyLong(), + any(S3FileType.class), anyString(), anyString() ); @@ -328,6 +335,7 @@ void createPresignedUpload_invalidOwnerId() { assertGeneralException( () -> s3FileService.createPresignedUpload( 0L, + FILE_TYPE, command ), S3ErrorStatus.INVALID_OBJECT_KEY @@ -336,6 +344,7 @@ void createPresignedUpload_invalidOwnerId() { verify(objectKeyGenerator, never()) .generate( anyLong(), + any(S3FileType.class), anyString(), anyString() ); @@ -348,6 +357,7 @@ void createPresignedUpload_nullCommand() { assertGeneralException( () -> s3FileService.createPresignedUpload( OWNER_ID, + FILE_TYPE, null ), S3ErrorStatus.INVALID_FILE_SIZE @@ -356,6 +366,7 @@ void createPresignedUpload_nullCommand() { verify(objectKeyGenerator, never()) .generate( anyLong(), + any(S3FileType.class), anyString(), anyString() ); @@ -376,6 +387,7 @@ void createPresignedUpload_invalidFileSize() { assertGeneralException( () -> s3FileService.createPresignedUpload( OWNER_ID, + FILE_TYPE, command ), S3ErrorStatus.INVALID_FILE_SIZE @@ -384,6 +396,7 @@ void createPresignedUpload_invalidFileSize() { verify(objectKeyGenerator, never()) .generate( anyLong(), + any(S3FileType.class), anyString(), anyString() ); @@ -409,6 +422,7 @@ void createPresignedUpload_fileSizeExceeded() { assertGeneralException( () -> s3FileService.createPresignedUpload( OWNER_ID, + FILE_TYPE, command ), S3ErrorStatus.FILE_SIZE_EXCEEDED @@ -417,6 +431,7 @@ void createPresignedUpload_fileSizeExceeded() { verify(objectKeyGenerator, never()) .generate( anyLong(), + any(S3FileType.class), anyString(), anyString() ); @@ -442,6 +457,7 @@ void createPresignedUpload_unsupportedContentType() { assertGeneralException( () -> s3FileService.createPresignedUpload( OWNER_ID, + FILE_TYPE, command ), S3ErrorStatus.UNSUPPORTED_CONTENT_TYPE @@ -450,6 +466,7 @@ void createPresignedUpload_unsupportedContentType() { verify(objectKeyGenerator, never()) .generate( anyLong(), + any(S3FileType.class), anyString(), anyString() ); @@ -473,6 +490,7 @@ void createPresignedUpload_sdkException() { when(objectKeyGenerator.generate( OWNER_ID, + FILE_TYPE, command.originalFileName(), CONTENT_TYPE )).thenReturn(OBJECT_KEY); @@ -489,6 +507,7 @@ void createPresignedUpload_sdkException() { assertGeneralException( () -> s3FileService.createPresignedUpload( OWNER_ID, + FILE_TYPE, command ), S3ErrorStatus.PRESIGNED_URL_CREATE_FAILED @@ -512,6 +531,7 @@ void validateUploadedFile_success() throws Exception { when(objectKeyGenerator.belongsToOwner( OWNER_ID, + FILE_TYPE, OBJECT_KEY )).thenReturn(true); @@ -523,6 +543,7 @@ void validateUploadedFile_success() throws Exception { ValidatedFile response = s3FileService.validateUploadedFile( OWNER_ID, + FILE_TYPE, OBJECT_KEY ); @@ -556,6 +577,7 @@ void validateUploadedFile_success() throws Exception { verify(objectKeyGenerator) .belongsToOwner( OWNER_ID, + FILE_TYPE, OBJECT_KEY ); } @@ -575,6 +597,7 @@ void validateUploadedFile_normalizesContentType() when(objectKeyGenerator.belongsToOwner( OWNER_ID, + FILE_TYPE, OBJECT_KEY )).thenReturn(true); @@ -586,6 +609,7 @@ void validateUploadedFile_normalizesContentType() ValidatedFile response = s3FileService.validateUploadedFile( OWNER_ID, + FILE_TYPE, OBJECT_KEY ); @@ -603,6 +627,7 @@ void validateUploadedFile_invalidOwnerId() { assertGeneralException( () -> s3FileService.validateUploadedFile( null, + FILE_TYPE, OBJECT_KEY ), S3ErrorStatus.INVALID_OBJECT_KEY @@ -611,6 +636,7 @@ void validateUploadedFile_invalidOwnerId() { verify(objectKeyGenerator, never()) .belongsToOwner( anyLong(), + any(S3FileType.class), anyString() ); @@ -627,6 +653,7 @@ void validateUploadedFile_nullObjectKey() { assertGeneralException( () -> s3FileService.validateUploadedFile( OWNER_ID, + FILE_TYPE, null ), S3ErrorStatus.INVALID_OBJECT_KEY @@ -635,6 +662,7 @@ void validateUploadedFile_nullObjectKey() { verify(objectKeyGenerator, never()) .belongsToOwner( anyLong(), + any(S3FileType.class), anyString() ); @@ -651,6 +679,7 @@ void validateUploadedFile_blankObjectKey() { assertGeneralException( () -> s3FileService.validateUploadedFile( OWNER_ID, + FILE_TYPE, " " ), S3ErrorStatus.INVALID_OBJECT_KEY @@ -659,6 +688,7 @@ void validateUploadedFile_blankObjectKey() { verify(objectKeyGenerator, never()) .belongsToOwner( anyLong(), + any(S3FileType.class), anyString() ); } @@ -669,6 +699,7 @@ void validateUploadedFile_invalidObjectKey() { // given when(objectKeyGenerator.belongsToOwner( OWNER_ID, + FILE_TYPE, OTHER_OWNER_OBJECT_KEY )).thenReturn(false); @@ -676,6 +707,7 @@ void validateUploadedFile_invalidObjectKey() { assertGeneralException( () -> s3FileService.validateUploadedFile( OWNER_ID, + FILE_TYPE, OTHER_OWNER_OBJECT_KEY ), S3ErrorStatus.INVALID_OBJECT_KEY @@ -699,6 +731,7 @@ void validateUploadedFile_objectNotFound() { when(objectKeyGenerator.belongsToOwner( OWNER_ID, + FILE_TYPE, OBJECT_KEY )).thenReturn(true); @@ -710,6 +743,7 @@ void validateUploadedFile_objectNotFound() { assertGeneralException( () -> s3FileService.validateUploadedFile( OWNER_ID, + FILE_TYPE, OBJECT_KEY ), S3ErrorStatus.OBJECT_NOT_FOUND @@ -728,6 +762,7 @@ void validateUploadedFile_s3Exception() { when(objectKeyGenerator.belongsToOwner( OWNER_ID, + FILE_TYPE, OBJECT_KEY )).thenReturn(true); @@ -739,6 +774,7 @@ void validateUploadedFile_s3Exception() { assertGeneralException( () -> s3FileService.validateUploadedFile( OWNER_ID, + FILE_TYPE, OBJECT_KEY ), S3ErrorStatus.OBJECT_VALIDATION_FAILED @@ -751,6 +787,7 @@ void validateUploadedFile_sdkException() { // given when(objectKeyGenerator.belongsToOwner( OWNER_ID, + FILE_TYPE, OBJECT_KEY )).thenReturn(true); @@ -766,6 +803,7 @@ void validateUploadedFile_sdkException() { assertGeneralException( () -> s3FileService.validateUploadedFile( OWNER_ID, + FILE_TYPE, OBJECT_KEY ), S3ErrorStatus.OBJECT_VALIDATION_FAILED @@ -784,6 +822,7 @@ void validateUploadedFile_invalidFileSize() { when(objectKeyGenerator.belongsToOwner( OWNER_ID, + FILE_TYPE, OBJECT_KEY )).thenReturn(true); @@ -795,6 +834,7 @@ void validateUploadedFile_invalidFileSize() { assertGeneralException( () -> s3FileService.validateUploadedFile( OWNER_ID, + FILE_TYPE, OBJECT_KEY ), S3ErrorStatus.INVALID_FILE_SIZE @@ -817,6 +857,7 @@ void validateUploadedFile_fileSizeExceeded() { when(objectKeyGenerator.belongsToOwner( OWNER_ID, + FILE_TYPE, OBJECT_KEY )).thenReturn(true); @@ -828,6 +869,7 @@ void validateUploadedFile_fileSizeExceeded() { assertGeneralException( () -> s3FileService.validateUploadedFile( OWNER_ID, + FILE_TYPE, OBJECT_KEY ), S3ErrorStatus.FILE_SIZE_EXCEEDED @@ -848,6 +890,7 @@ void validateUploadedFile_unsupportedContentType() { when(objectKeyGenerator.belongsToOwner( OWNER_ID, + FILE_TYPE, OBJECT_KEY )).thenReturn(true); @@ -859,6 +902,7 @@ void validateUploadedFile_unsupportedContentType() { assertGeneralException( () -> s3FileService.validateUploadedFile( OWNER_ID, + FILE_TYPE, OBJECT_KEY ), S3ErrorStatus.UNSUPPORTED_CONTENT_TYPE @@ -957,6 +1001,7 @@ void createPresignedDownloadUrl_success() when(objectKeyGenerator.belongsToOwner( OWNER_ID, + FILE_TYPE, OBJECT_KEY )).thenReturn(true); @@ -973,6 +1018,7 @@ void createPresignedDownloadUrl_success() String response = s3FileService.createPresignedDownload( OWNER_ID, + FILE_TYPE, OBJECT_KEY ); @@ -1009,6 +1055,7 @@ void createPresignedDownloadUrl_success() verify(objectKeyGenerator) .belongsToOwner( OWNER_ID, + FILE_TYPE, OBJECT_KEY ); } @@ -1019,6 +1066,7 @@ void createPresignedDownload_invalidObjectKey() { // given when(objectKeyGenerator.belongsToOwner( OWNER_ID, + FILE_TYPE, OTHER_OWNER_OBJECT_KEY )).thenReturn(false); @@ -1026,6 +1074,7 @@ void createPresignedDownload_invalidObjectKey() { assertGeneralException( () -> s3FileService.createPresignedDownload( OWNER_ID, + FILE_TYPE, OTHER_OWNER_OBJECT_KEY ), S3ErrorStatus.INVALID_OBJECT_KEY @@ -1043,6 +1092,7 @@ void createPresignedDownload_sdkException() { // given when(objectKeyGenerator.belongsToOwner( OWNER_ID, + FILE_TYPE, OBJECT_KEY )).thenReturn(true); @@ -1058,6 +1108,7 @@ void createPresignedDownload_sdkException() { assertGeneralException( () -> s3FileService.createPresignedDownload( OWNER_ID, + FILE_TYPE, OBJECT_KEY ), S3ErrorStatus.PRESIGNED_URL_CREATE_FAILED From 850a531bef9e92744effe808d6d55b686efbbd2f Mon Sep 17 00:00:00 2001 From: onlyoneprivate Date: Mon, 10 Aug 2026 21:43:37 +0900 Subject: [PATCH 2/6] =?UTF-8?q?feat:=20=EB=B0=B1=ED=82=B9=ED=8A=B8?= =?UTF-8?q?=EB=9E=99=20=EC=98=A4=EB=94=94=EC=98=A4=20Presigned=20URL=20?= =?UTF-8?q?=EC=97=85=EB=A1=9C=EB=93=9C=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(#184)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/BackingTrackController.java | 25 +++++++++++ .../dto/req/BackingTrackSaveRequestDTO.java | 4 +- .../dto/req/BackingTrackUploadUrlRequest.java | 27 ++++++++++++ .../res/BackingTrackUploadUrlResponse.java | 24 +++++++++++ .../backingtrack/entity/BackingTrack.java | 15 ++++--- .../service/BackingTrackService.java | 41 ++++++++++++++++--- ...ing_track_audio_file_url_to_object_key.sql | 32 +++++++++++++++ 7 files changed, 153 insertions(+), 15 deletions(-) create mode 100644 src/main/java/com/mr/domain/backingtrack/dto/req/BackingTrackUploadUrlRequest.java create mode 100644 src/main/java/com/mr/domain/backingtrack/dto/res/BackingTrackUploadUrlResponse.java create mode 100644 src/main/resources/db/migration/V7__migrate_backing_track_audio_file_url_to_object_key.sql diff --git a/src/main/java/com/mr/domain/backingtrack/controller/BackingTrackController.java b/src/main/java/com/mr/domain/backingtrack/controller/BackingTrackController.java index de1fb286..5e9268d0 100644 --- a/src/main/java/com/mr/domain/backingtrack/controller/BackingTrackController.java +++ b/src/main/java/com/mr/domain/backingtrack/controller/BackingTrackController.java @@ -2,12 +2,14 @@ import com.mr.domain.backingtrack.dto.req.BackingTrackListRequestDTO; import com.mr.domain.backingtrack.dto.req.BackingTrackSaveRequestDTO; +import com.mr.domain.backingtrack.dto.req.BackingTrackUploadUrlRequest; import com.mr.domain.backingtrack.dto.req.PlayCountIncreaseRequestDTO; import com.mr.domain.backingtrack.dto.res.BackingTrackCreateResponseDTO; import com.mr.domain.backingtrack.dto.res.BackingTrackDetailResponseDTO; import com.mr.domain.backingtrack.dto.res.BackingTrackListResponseDTO; import com.mr.domain.backingtrack.dto.res.BackingTrackRecommendedResponseDTO; import com.mr.domain.backingtrack.dto.res.BackingTrackUpdateResponseDTO; +import com.mr.domain.backingtrack.dto.res.BackingTrackUploadUrlResponse; import com.mr.domain.backingtrack.dto.res.PlayCountIncreaseResponseDTO; import com.mr.domain.backingtrack.service.BackingTrackService; import com.mr.global.apipayload.ApiResponse; @@ -53,6 +55,29 @@ public ApiResponse createBackingT return ApiResponse.onSuccess(result); } + @Operation( + summary = "백킹트랙 오디오 파일 업로드 URL 발급", + description = """ + 백킹트랙 생성 시 첨부할 오디오 파일을 S3에 직접 업로드하기 위한 + Presigned PUT URL을 발급합니다. + + 파일 업로드 후 반환된 objectKey를 + 백킹트랙 생성 API의 audioObjectKey 필드로 전달합니다. + """ + ) + @PostMapping("/audio-upload-url") + public ApiResponse createAudioUploadUrl( + @AuthenticationPrincipal CustomUserDetails userDetails, + @Valid @RequestBody BackingTrackUploadUrlRequest request + ) { + return ApiResponse.onSuccess( + backingTrackService.createAudioUploadUrl( + userDetails.getUserId(), + request + ) + ); + } + @Operation( summary = "백킹트랙 수정", description = "트랙의 생성자가 본인일 때, 트랙의 정보를 수정" diff --git a/src/main/java/com/mr/domain/backingtrack/dto/req/BackingTrackSaveRequestDTO.java b/src/main/java/com/mr/domain/backingtrack/dto/req/BackingTrackSaveRequestDTO.java index 2046b7fd..2689b63d 100644 --- a/src/main/java/com/mr/domain/backingtrack/dto/req/BackingTrackSaveRequestDTO.java +++ b/src/main/java/com/mr/domain/backingtrack/dto/req/BackingTrackSaveRequestDTO.java @@ -46,8 +46,8 @@ public record SaveDTO( @Max(value = 600, message = "재생 시간은 최대 600초(10분) 이하여야 합니다.") Integer playtimeSec, - @Size(max = 255, message = "오디오 파일 URL은 255자 이내여야 합니다.") - String audioFileUrl, + @Size(max = 255, message = "오디오 파일 Object Key는 255자 이내여야 합니다.") + String audioObjectKey, @NotNull(message = "공개 범위는 필수입니다.") AccessLevel accessLevel, diff --git a/src/main/java/com/mr/domain/backingtrack/dto/req/BackingTrackUploadUrlRequest.java b/src/main/java/com/mr/domain/backingtrack/dto/req/BackingTrackUploadUrlRequest.java new file mode 100644 index 00000000..202128f5 --- /dev/null +++ b/src/main/java/com/mr/domain/backingtrack/dto/req/BackingTrackUploadUrlRequest.java @@ -0,0 +1,27 @@ +package com.mr.domain.backingtrack.dto.req; + +import com.mr.global.file.s3.dto.FileUploadCommand; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; + +public record BackingTrackUploadUrlRequest( + + @NotBlank(message = "파일명은 필수입니다.") + String fileName, + + @NotBlank(message = "Content-Type은 필수입니다.") + String contentType, + + @NotNull(message = "파일 크기는 필수입니다.") + @Positive(message = "파일 크기는 0보다 커야 합니다.") + Long fileSize +) { + public FileUploadCommand toCommand() { + return new FileUploadCommand( + fileName, + contentType, + fileSize + ); + } +} diff --git a/src/main/java/com/mr/domain/backingtrack/dto/res/BackingTrackUploadUrlResponse.java b/src/main/java/com/mr/domain/backingtrack/dto/res/BackingTrackUploadUrlResponse.java new file mode 100644 index 00000000..35fa70cb --- /dev/null +++ b/src/main/java/com/mr/domain/backingtrack/dto/res/BackingTrackUploadUrlResponse.java @@ -0,0 +1,24 @@ +package com.mr.domain.backingtrack.dto.res; + +import com.mr.global.file.s3.dto.PresignedUrlUpload; + +import java.time.Instant; +import java.util.Map; + +public record BackingTrackUploadUrlResponse( + String objectKey, + String uploadUrl, + Instant expiresAt, + Map requiredHeaders +) { + public static BackingTrackUploadUrlResponse from( + PresignedUrlUpload presignedUpload + ) { + return new BackingTrackUploadUrlResponse( + presignedUpload.objectKey(), + presignedUpload.uploadUrl(), + presignedUpload.expiresAt(), + presignedUpload.requiredHeaders() + ); + } +} diff --git a/src/main/java/com/mr/domain/backingtrack/entity/BackingTrack.java b/src/main/java/com/mr/domain/backingtrack/entity/BackingTrack.java index 253f4ee4..4ccb774f 100644 --- a/src/main/java/com/mr/domain/backingtrack/entity/BackingTrack.java +++ b/src/main/java/com/mr/domain/backingtrack/entity/BackingTrack.java @@ -87,8 +87,8 @@ public class BackingTrack extends BaseTimeDeletedEntity { private Integer playtimeSec; // 오디오 파일 - @Column(name = "audio_file_url", length = 255) - private String audioFileUrl; + @Column(name = "audio_object_key", length = 255) + private String audioObjectKey; @JdbcTypeCode(SqlTypes.JSON) @Column(name = "midi_data", columnDefinition = "jsonb") @@ -115,7 +115,7 @@ public class BackingTrack extends BaseTimeDeletedEntity { @Builder(access = lombok.AccessLevel.PRIVATE) private BackingTrack(User user, Long academyId, String title, String genre, String keySignature, ScaleType scaleType, String timeSignature, - Integer bpm, Integer playtimeSec, String audioFileUrl, + Integer bpm, Integer playtimeSec, String audioObjectKey, JsonNode midiData, Integer playCount, AccessLevel accessLevel, Level level) { this.user = user; @@ -127,7 +127,7 @@ private BackingTrack(User user, Long academyId, String title, String genre, this.timeSignature = timeSignature; this.bpm = bpm; this.playtimeSec = playtimeSec; - this.audioFileUrl = audioFileUrl; + this.audioObjectKey = audioObjectKey; this.midiData = midiData; this.playCount = playCount != null ? playCount : 0; this.accessLevel = accessLevel != null ? accessLevel : AccessLevel.PRIVATE; @@ -136,7 +136,7 @@ private BackingTrack(User user, Long academyId, String title, String genre, public static BackingTrack create(User user, Long academyId, String title, String genre, String keySignature, ScaleType scaleType, String timeSignature, - Integer bpm, Integer playtimeSec, String audioFileUrl, + Integer bpm, Integer playtimeSec, String audioObjectKey, JsonNode midiData, AccessLevel accessLevel, Level level) { return BackingTrack.builder() .user(user) @@ -148,7 +148,7 @@ public static BackingTrack create(User user, Long academyId, String title, Strin .timeSignature(timeSignature) .bpm(bpm) .playtimeSec(playtimeSec) - .audioFileUrl(audioFileUrl) + .audioObjectKey(audioObjectKey) .midiData(midiData) .playCount(0) .accessLevel(accessLevel) @@ -158,7 +158,7 @@ public static BackingTrack create(User user, Long academyId, String title, Strin public void updateTrackInfo(String title, String genre, String keySignature, ScaleType scaleType, String timeSignature, Integer bpm, - Integer playtimeSec, String audioFileUrl, AccessLevel accessLevel, Level level) { + Integer playtimeSec, AccessLevel accessLevel, Level level) { this.title = title; this.genre = genre; this.keySignature = keySignature; @@ -166,7 +166,6 @@ public void updateTrackInfo(String title, String genre, String keySignature, this.timeSignature = timeSignature; this.bpm = bpm; this.playtimeSec = playtimeSec; - this.audioFileUrl = audioFileUrl; if (accessLevel != null) this.accessLevel = accessLevel; if (level != null) this.level = level; } diff --git a/src/main/java/com/mr/domain/backingtrack/service/BackingTrackService.java b/src/main/java/com/mr/domain/backingtrack/service/BackingTrackService.java index 2f33ebfc..2bd30954 100644 --- a/src/main/java/com/mr/domain/backingtrack/service/BackingTrackService.java +++ b/src/main/java/com/mr/domain/backingtrack/service/BackingTrackService.java @@ -6,12 +6,14 @@ import com.mr.domain.analysis.repository.AnalysisRepository; import com.mr.domain.backingtrack.dto.req.BackingTrackListRequestDTO; import com.mr.domain.backingtrack.dto.req.BackingTrackSaveRequestDTO; +import com.mr.domain.backingtrack.dto.req.BackingTrackUploadUrlRequest; import com.mr.domain.backingtrack.dto.req.PlayCountIncreaseRequestDTO; import com.mr.domain.backingtrack.dto.res.BackingTrackCreateResponseDTO; import com.mr.domain.backingtrack.dto.res.BackingTrackDetailResponseDTO; import com.mr.domain.backingtrack.dto.res.BackingTrackListResponseDTO; import com.mr.domain.backingtrack.dto.res.BackingTrackRecommendedResponseDTO; import com.mr.domain.backingtrack.dto.res.BackingTrackUpdateResponseDTO; +import com.mr.domain.backingtrack.dto.res.BackingTrackUploadUrlResponse; import com.mr.domain.backingtrack.dto.res.PlayCountIncreaseResponseDTO; import com.mr.domain.backingtrack.entity.BackingTrack; import com.mr.domain.backingtrack.entity.ChordProgression; @@ -22,12 +24,12 @@ import com.mr.domain.user.exception.UserErrorStatus; import com.mr.domain.user.repository.UserRepository; import com.mr.global.apipayload.exception.GeneralException; +import com.mr.global.file.s3.enums.S3FileType; +import com.mr.global.file.s3.service.S3FileService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Sort; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -45,6 +47,7 @@ public class BackingTrackService { private final BackingTrackRepository backingTrackRepository; private final UserRepository userRepository; private final AnalysisRepository analysisRepository; + private final S3FileService s3FileService; private static final Long TEMP_DEFAULT_ACADEMY_ID = 1L; // MVP 임시 학원 ID private static final int PAGE_SIZE = 9; @@ -61,6 +64,15 @@ public BackingTrackCreateResponseDTO.CreateResultDTO createBackingTrack( User user = userRepository.findById(userId) .orElseThrow(()-> new GeneralException(UserErrorStatus.USER_NOT_FOUND)); + String audioObjectKey = + request.audioObjectKey() == null || request.audioObjectKey().isBlank() + ? null + : request.audioObjectKey(); + + if (audioObjectKey != null) { + s3FileService.validateUploadedFile(userId, S3FileType.BACKING_TRACK, audioObjectKey); + } + BackingTrack backingTrack = BackingTrack.create( user, TEMP_DEFAULT_ACADEMY_ID, @@ -71,7 +83,7 @@ public BackingTrackCreateResponseDTO.CreateResultDTO createBackingTrack( request.timeSignature(), request.bpm(), request.playtimeSec(), - request.audioFileUrl(), + request.audioObjectKey(), null, // midi 데이터는 생성 시 null로 초기화 (mvp후 별도 API나 이벤트로 업데이트) request.accessLevel(), request.level() @@ -96,6 +108,17 @@ public BackingTrackCreateResponseDTO.CreateResultDTO createBackingTrack( ); } + @Transactional(readOnly = true) + public BackingTrackUploadUrlResponse createAudioUploadUrl( + Long userId, BackingTrackUploadUrlRequest request + ) { + return BackingTrackUploadUrlResponse.from(s3FileService.createPresignedUpload( + userId, + S3FileType.BACKING_TRACK, + request.toCommand() + )); + } + // 백킹트랙 수정 @Transactional public BackingTrackUpdateResponseDTO.UpdateResultDTO updateBackingTrack( @@ -121,7 +144,6 @@ public BackingTrackUpdateResponseDTO.UpdateResultDTO updateBackingTrack( request.timeSignature(), request.bpm(), request.playtimeSec(), - request.audioFileUrl(), request.accessLevel(), request.level() ); @@ -279,6 +301,15 @@ public BackingTrackDetailResponseDTO.DetailResponseDTO getBackingTrackDetail( String creatorName = track.getUser().getNickname(); + String audioFileUrl = null; + if (track.getAudioObjectKey() != null && !track.getAudioObjectKey().isBlank()) { + audioFileUrl = s3FileService.createPresignedDownload( + track.getUser().getUserId(), + S3FileType.BACKING_TRACK, + track.getAudioObjectKey() + ); + } + return BackingTrackDetailResponseDTO.DetailResponseDTO.of( track.getId(), track.getTitle(), @@ -290,7 +321,7 @@ public BackingTrackDetailResponseDTO.DetailResponseDTO getBackingTrackDetail( track.getPlaytimeSec(), track.getLevel().name(), creatorName, - track.getAudioFileUrl(), + audioFileUrl, chordDetails ); } diff --git a/src/main/resources/db/migration/V7__migrate_backing_track_audio_file_url_to_object_key.sql b/src/main/resources/db/migration/V7__migrate_backing_track_audio_file_url_to_object_key.sql new file mode 100644 index 00000000..1e43242a --- /dev/null +++ b/src/main/resources/db/migration/V7__migrate_backing_track_audio_file_url_to_object_key.sql @@ -0,0 +1,32 @@ +-- 1. 컬럼명 변경 +ALTER TABLE backing_track + RENAME COLUMN audio_file_url TO audio_object_key; + +-- 2. 기존 URL 값이 있다면 S3 Object Key만 추출 +UPDATE backing_track +SET audio_object_key = regexp_replace( + audio_object_key, + '^(https?://[^/]+/)?([^?#]*).*$', + '\2' + ) +WHERE audio_object_key IS NOT NULL + AND btrim(audio_object_key) <> ''; + +-- 3. 검증: URL/쿼리스트링이 남아 있으면 예외 발생 +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM backing_track + WHERE audio_object_key IS NOT NULL + AND ( + audio_object_key LIKE '%?%' + OR audio_object_key LIKE 'http%' + OR audio_object_key LIKE '%X-Amz%' + OR btrim(audio_object_key) = '' + ) + ) THEN + RAISE EXCEPTION 'audio_object_key migration validation failed'; +END IF; +END +$$; \ No newline at end of file From cfeec5ffb4e2bef5fc2c28418e30f5f2772a4dca Mon Sep 17 00:00:00 2001 From: onlyoneprivate Date: Mon, 10 Aug 2026 21:44:33 +0900 Subject: [PATCH 3/6] =?UTF-8?q?refactor:=20=EB=B0=B1=ED=82=B9=ED=8A=B8?= =?UTF-8?q?=EB=9E=99=20=EC=98=A4=EB=94=94=EC=98=A4=20=EC=A1=B0=ED=9A=8C?= =?UTF-8?q?=EC=97=90=20Presigned=20URL=20=EC=A0=81=EC=9A=A9=20(#184)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/res/AnalysisResultResponseDTO.java | 5 +- .../analysis/service/AnalysisService.java | 23 +++++++- .../dto/res/HistoryDetailResponseDTO.java | 6 ++- .../history/service/HistoryService.java | 21 +++++++- .../dto/res/AnalysisContextResponse.java | 6 ++- .../playing/dto/res/PlayingStartResponse.java | 9 ++-- .../playing/service/PlayingService.java | 38 ++++++++++++-- .../analysis/service/AnalysisServiceTest.java | 10 ++-- .../history/service/HistoryServiceTest.java | 15 ++++-- .../playing/service/PlayingServiceTest.java | 52 +++++++++++++++++-- 10 files changed, 158 insertions(+), 27 deletions(-) diff --git a/src/main/java/com/mr/domain/analysis/dto/res/AnalysisResultResponseDTO.java b/src/main/java/com/mr/domain/analysis/dto/res/AnalysisResultResponseDTO.java index aa59eefb..3a41abde 100644 --- a/src/main/java/com/mr/domain/analysis/dto/res/AnalysisResultResponseDTO.java +++ b/src/main/java/com/mr/domain/analysis/dto/res/AnalysisResultResponseDTO.java @@ -42,7 +42,8 @@ public static AnalysisResultResponseDTO from( Analysis analysis, AnalysisReport analysisReport, JsonNode rawResult, - String recordingFileUrl + String recordingFileUrl, + String backingTrackAudioFileUrl ) { Playing playing = analysis.getPlaying(); BackingTrack backingTrack = playing.getBackingTrack(); @@ -55,7 +56,7 @@ public static AnalysisResultResponseDTO from( playing.getBpm(), playing.getEndedAt(), recordingFileUrl, - backingTrack != null ? backingTrack.getAudioFileUrl() : null, + backingTrackAudioFileUrl, analysis.getStatus(), analysis.getStartBar(), analysis.getEndBar(), diff --git a/src/main/java/com/mr/domain/analysis/service/AnalysisService.java b/src/main/java/com/mr/domain/analysis/service/AnalysisService.java index 496bd7e5..c80b05fd 100644 --- a/src/main/java/com/mr/domain/analysis/service/AnalysisService.java +++ b/src/main/java/com/mr/domain/analysis/service/AnalysisService.java @@ -16,11 +16,13 @@ import com.mr.domain.analysis.factory.AnalysisRequestFactory; import com.mr.domain.analysis.repository.AnalysisReportRepository; import com.mr.domain.analysis.repository.AnalysisRepository; +import com.mr.domain.backingtrack.entity.BackingTrack; import com.mr.domain.playing.entity.Playing; import com.mr.domain.playing.entity.enums.PlayingStatus; import com.mr.domain.playing.exception.PlayingErrorStatus; import com.mr.domain.playing.repository.PlayingRepository; import com.mr.global.apipayload.exception.GeneralException; +import com.mr.global.file.s3.enums.S3FileType; import com.mr.global.file.s3.service.S3FileService; import java.util.List; import java.util.Objects; @@ -120,14 +122,33 @@ public AnalysisResultResponseDTO getAnalysisResult( String recordingFileUrl = s3FileService.createPresignedDownload( userId, + S3FileType.RECORDING, analysis.getPlaying().getRecordingObjectKey() ); + BackingTrack backingTrack = + analysis.getPlaying().getBackingTrack(); + + String backingTrackAudioFileUrl = null; + + if (backingTrack != null + && backingTrack.getAudioObjectKey() != null + && !backingTrack.getAudioObjectKey().isBlank()) { + + backingTrackAudioFileUrl = + s3FileService.createPresignedDownload( + backingTrack.getUser().getUserId(), + S3FileType.BACKING_TRACK, + backingTrack.getAudioObjectKey() + ); + } + return AnalysisResultResponseDTO.from( analysis, analysisReport, rawResult, - recordingFileUrl + recordingFileUrl, + backingTrackAudioFileUrl ); } diff --git a/src/main/java/com/mr/domain/history/dto/res/HistoryDetailResponseDTO.java b/src/main/java/com/mr/domain/history/dto/res/HistoryDetailResponseDTO.java index 576ca7bb..7f88bfc1 100644 --- a/src/main/java/com/mr/domain/history/dto/res/HistoryDetailResponseDTO.java +++ b/src/main/java/com/mr/domain/history/dto/res/HistoryDetailResponseDTO.java @@ -31,7 +31,9 @@ public record HistoryDetailResponseDTO( ) { public static HistoryDetailResponseDTO from( - Playing playing, List analyses, String recordingFileUrl, BarMetrics barMetrics) { + Playing playing, List analyses, String recordingFileUrl, + String backingTrackAudioFileUrl, BarMetrics barMetrics) + { BackingTrack backingTrack = playing.getBackingTrack(); return new HistoryDetailResponseDTO( @@ -45,7 +47,7 @@ public static HistoryDetailResponseDTO from( toDurationMinutes(playing.getDurationSec()), playing.getDurationSec(), recordingFileUrl, - backingTrack != null ? backingTrack.getAudioFileUrl() : null, + backingTrackAudioFileUrl, playing.getMidiData().stream().map(MidiEvent::from).toList(), backingTrack != null ? backingTrack.getMidiData() : null, barMetrics != null ? barMetrics.totalBars() : null, diff --git a/src/main/java/com/mr/domain/history/service/HistoryService.java b/src/main/java/com/mr/domain/history/service/HistoryService.java index 97009226..bbc144ed 100644 --- a/src/main/java/com/mr/domain/history/service/HistoryService.java +++ b/src/main/java/com/mr/domain/history/service/HistoryService.java @@ -5,6 +5,7 @@ import com.mr.domain.analysis.repository.AnalysisRepository; import com.mr.domain.analysis.service.AnalysisBarCalculator; import com.mr.domain.analysis.service.AnalysisBarCalculator.BarMetrics; +import com.mr.domain.backingtrack.entity.BackingTrack; import com.mr.domain.history.dto.req.HistoryPeriod; import com.mr.domain.history.dto.res.HistoryDetailResponseDTO; import com.mr.domain.history.dto.res.HistoryListResponseDTO; @@ -14,6 +15,7 @@ import com.mr.domain.playing.entity.enums.PlayingStatus; import com.mr.domain.playing.repository.PlayingRepository; import com.mr.global.apipayload.exception.GeneralException; +import com.mr.global.file.s3.enums.S3FileType; import com.mr.global.file.s3.service.S3FileService; import com.mr.global.util.RelativeDateFormatter; import java.time.LocalDateTime; @@ -77,11 +79,28 @@ public HistoryDetailResponseDTO getHistoryDetail(Long userId, Long playingId) { String recordingFileUrl = s3FileService.createPresignedDownload( userId, + S3FileType.RECORDING, playing.getRecordingObjectKey() ); + BackingTrack backingTrack = playing.getBackingTrack(); + + String backingTrackAudioFileUrl = null; + + if (backingTrack != null + && backingTrack.getAudioObjectKey() != null + && !backingTrack.getAudioObjectKey().isBlank()) { + + backingTrackAudioFileUrl = + s3FileService.createPresignedDownload( + backingTrack.getUser().getUserId(), + S3FileType.BACKING_TRACK, + backingTrack.getAudioObjectKey() + ); + } + return HistoryDetailResponseDTO.from( - playing, analyses, recordingFileUrl, resolveBarMetrics(playing)); + playing, analyses, recordingFileUrl, backingTrackAudioFileUrl, resolveBarMetrics(playing)); } // 백킹트랙 정보 불완전 시 조회 실패 대신 마디 관련 필드만 null 처리 diff --git a/src/main/java/com/mr/domain/playing/dto/res/AnalysisContextResponse.java b/src/main/java/com/mr/domain/playing/dto/res/AnalysisContextResponse.java index c82d40ad..7991183c 100644 --- a/src/main/java/com/mr/domain/playing/dto/res/AnalysisContextResponse.java +++ b/src/main/java/com/mr/domain/playing/dto/res/AnalysisContextResponse.java @@ -25,7 +25,9 @@ public record AnalysisContextResponse( Integer totalBars ) { - public static AnalysisContextResponse from(Playing playing, int totalBars, String recordingFileUrl) { + public static AnalysisContextResponse from( + Playing playing, int totalBars, String recordingFileUrl, String backingTrackAudioFileUrl) + { BackingTrack backingTrack = playing.getBackingTrack(); return new AnalysisContextResponse( @@ -39,7 +41,7 @@ public static AnalysisContextResponse from(Playing playing, int totalBars, Strin toDurationMinutes(playing.getDurationSec()), playing.getDurationSec(), recordingFileUrl, - backingTrack.getAudioFileUrl(), + backingTrackAudioFileUrl, playing.getMidiData().stream().map(MidiEvent::from).toList(), backingTrack.getMidiData(), totalBars diff --git a/src/main/java/com/mr/domain/playing/dto/res/PlayingStartResponse.java b/src/main/java/com/mr/domain/playing/dto/res/PlayingStartResponse.java index 27709119..62d232cd 100644 --- a/src/main/java/com/mr/domain/playing/dto/res/PlayingStartResponse.java +++ b/src/main/java/com/mr/domain/playing/dto/res/PlayingStartResponse.java @@ -16,11 +16,12 @@ public record PlayingStartResponse( LocalDateTime startedAt ) { - public static PlayingStartResponse from (Playing playing) { + public static PlayingStartResponse from (Playing playing, String backingTrackAudioFileUrl) { return new PlayingStartResponse( playing.getId(), playing.getStatus(), - BackingTrackResponse.from(playing.getBackingTrack()), + BackingTrackResponse.from( + playing.getBackingTrack(), backingTrackAudioFileUrl), playing.getStartedAt() ); } @@ -38,11 +39,11 @@ public record BackingTrackResponse( List chordProgression ) { - public static BackingTrackResponse from(BackingTrack backingTrack) { + public static BackingTrackResponse from(BackingTrack backingTrack, String audioFileUrl) { return new BackingTrackResponse( backingTrack.getId(), backingTrack.getTitle(), - backingTrack.getAudioFileUrl(), + audioFileUrl, backingTrack.getGenre(), backingTrack.getKeySignature(), backingTrack.getScaleType(), diff --git a/src/main/java/com/mr/domain/playing/service/PlayingService.java b/src/main/java/com/mr/domain/playing/service/PlayingService.java index 6db2cc2b..61ab9ee9 100644 --- a/src/main/java/com/mr/domain/playing/service/PlayingService.java +++ b/src/main/java/com/mr/domain/playing/service/PlayingService.java @@ -21,6 +21,7 @@ import com.mr.global.apipayload.exception.GeneralException; import com.mr.global.event.PlayingCompletedEvent; import com.mr.global.file.s3.dto.ValidatedFile; +import com.mr.global.file.s3.enums.S3FileType; import com.mr.global.file.s3.service.S3FileService; import com.mr.global.event.NotificationEvent; import lombok.RequiredArgsConstructor; @@ -73,7 +74,20 @@ public PlayingStartResponse startPlaying( playing.start(); Playing savedPlaying = playingRepository.save(playing); - return PlayingStartResponse.from(savedPlaying); + String backingTrackAudioFileUrl = null; + + if (backingTrack.getAudioObjectKey() != null + && !backingTrack.getAudioObjectKey().isBlank()) { + + backingTrackAudioFileUrl = + s3FileService.createPresignedDownload( + backingTrack.getUser().getUserId(), + S3FileType.BACKING_TRACK, + backingTrack.getAudioObjectKey() + ); + } + + return PlayingStartResponse.from(savedPlaying, backingTrackAudioFileUrl); } @@ -91,6 +105,7 @@ public RecordingUploadUrlResponse createRecordingUploadUrl( return RecordingUploadUrlResponse.from(s3FileService.createPresignedUpload( userId, + S3FileType.RECORDING, request.toCommand()) ); } @@ -102,7 +117,7 @@ public MidiEventSaveResponse saveMidiEvents( // S3 네트워크 통신은 DB 트랜잭션 밖에서 수행 ValidatedFile recording = - s3FileService.validateUploadedFile(userId, request.recordingObjectKey()); + s3FileService.validateUploadedFile(userId, S3FileType.RECORDING, request.recordingObjectKey()); List midiEvents = request.events() .stream() @@ -153,6 +168,7 @@ public PlayingDetailResponse getPlayingDetail(Long userId, Long playingId) { String recordingFileUrl = s3FileService.createPresignedDownload( userId, + S3FileType.RECORDING, playing.getRecordingObjectKey() ); @@ -175,11 +191,27 @@ public AnalysisContextResponse getAnalysisContext(Long userId, Long playingId) { String recordingFileUrl = s3FileService.createPresignedDownload( userId, + S3FileType.RECORDING, playing.getRecordingObjectKey() ); + BackingTrack backingTrack = playing.getBackingTrack(); + + String backingTrackAudioFileUrl = null; + + if (backingTrack.getAudioObjectKey() != null + && !backingTrack.getAudioObjectKey().isBlank()) { + + backingTrackAudioFileUrl = + s3FileService.createPresignedDownload( + backingTrack.getUser().getUserId(), + S3FileType.BACKING_TRACK, + backingTrack.getAudioObjectKey() + ); + } + int totalBars = analysisBarCalculator.calculate(playing).totalBars(); - return AnalysisContextResponse.from(playing, totalBars, recordingFileUrl); + return AnalysisContextResponse.from(playing, totalBars, recordingFileUrl, backingTrackAudioFileUrl); } @Transactional diff --git a/src/test/java/com/mr/domain/analysis/service/AnalysisServiceTest.java b/src/test/java/com/mr/domain/analysis/service/AnalysisServiceTest.java index c66bc5f8..a4987ddb 100644 --- a/src/test/java/com/mr/domain/analysis/service/AnalysisServiceTest.java +++ b/src/test/java/com/mr/domain/analysis/service/AnalysisServiceTest.java @@ -32,6 +32,7 @@ import com.mr.domain.playing.repository.PlayingRepository; import com.mr.domain.user.entity.User; import com.mr.global.apipayload.exception.GeneralException; +import com.mr.global.file.s3.enums.S3FileType; import com.mr.global.file.s3.service.S3FileService; import com.mr.global.client.ai.AiAnalysisRequest; import java.math.BigDecimal; @@ -54,6 +55,7 @@ class AnalysisServiceTest { private static final String RECORDING_OBJECT_KEY = "recordings/1/2026-08-06/120000_abcdef.webm"; private static final String RECORDING_FILE_URL = "https://example.com/presigned-recording.webm"; + private static final String BACKING_TRACK_OBJECT_KEY = "backing-tracks/1/2026-08-06/120000_abcdef.mp3"; @Mock private AnalysisRepository analysisRepository; @@ -100,7 +102,8 @@ private Analysis completedAnalysis(Long userId, ScaleType scaleType) { lenient().when(backingTrack.getGenre()).thenReturn("jazz"); lenient().when(backingTrack.getKeySignature()).thenReturn("C"); lenient().when(backingTrack.getScaleType()).thenReturn(scaleType); - lenient().when(backingTrack.getAudioFileUrl()).thenReturn("https://example.com/backing-track.mp3"); + lenient().when(backingTrack.getUser()).thenReturn(user); + lenient().when(backingTrack.getAudioObjectKey()).thenReturn(BACKING_TRACK_OBJECT_KEY); Analysis analysis = Analysis.createPending(user, playing, 1, 8, "{}"); LocalDateTime now = LocalDateTime.of(2026, 7, 31, 12, 0); @@ -149,14 +152,15 @@ void getAnalysisResult_success_returnsLatestSuccessReport() { given(analysisRepository.findById(analysisId)).willReturn(Optional.of(analysis)); given(analysisReportRepository.findFirstByAnalysisIdAndLlmStatusOrderByCreatedAtDesc(anyLong(), any())) .willReturn(Optional.of(report)); - given(s3FileService.createPresignedDownload(userId, RECORDING_OBJECT_KEY)).willReturn(RECORDING_FILE_URL); + given(s3FileService.createPresignedDownload(userId, S3FileType.RECORDING, RECORDING_OBJECT_KEY)).willReturn(RECORDING_FILE_URL); + given(s3FileService.createPresignedDownload(userId, S3FileType.BACKING_TRACK, BACKING_TRACK_OBJECT_KEY)).willReturn("https://example.com/backing-track.mp3"); AnalysisResultResponseDTO response = analysisService.getAnalysisResult(userId, analysisId); ArgumentCaptor llmStatusCaptor = ArgumentCaptor.forClass(LlmStatus.class); verify(analysisReportRepository) .findFirstByAnalysisIdAndLlmStatusOrderByCreatedAtDesc(eq(analysisId), llmStatusCaptor.capture()); - verify(s3FileService).createPresignedDownload(userId, RECORDING_OBJECT_KEY); + verify(s3FileService).createPresignedDownload(userId, S3FileType.RECORDING, RECORDING_OBJECT_KEY); assertThat(llmStatusCaptor.getValue()).isEqualTo(LlmStatus.SUCCESS); assertThat(response.report()).isNotNull(); diff --git a/src/test/java/com/mr/domain/history/service/HistoryServiceTest.java b/src/test/java/com/mr/domain/history/service/HistoryServiceTest.java index 8d42efcd..2b7db8e7 100644 --- a/src/test/java/com/mr/domain/history/service/HistoryServiceTest.java +++ b/src/test/java/com/mr/domain/history/service/HistoryServiceTest.java @@ -27,6 +27,7 @@ import com.mr.domain.playing.repository.PlayingRepository; import com.mr.domain.user.entity.User; import com.mr.global.apipayload.exception.GeneralException; +import com.mr.global.file.s3.enums.S3FileType; import com.mr.global.file.s3.service.S3FileService; import java.time.LocalDateTime; import java.util.List; @@ -276,14 +277,20 @@ void getHistoryDetail_notCompleted_throws409() { @DisplayName("getHistoryDetail - 정상 조회 시 상태 무관하게 모든 분석을 반환한다") void getHistoryDetail_success_returnsAllAnalysesRegardlessOfStatus() { Playing playing = mockPlaying(1L, 1L, PlayingStatus.COMPLETED, LocalDateTime.now()); + User backingTrackOwner = mock(User.class); BackingTrack backingTrack = mock(BackingTrack.class); String recordingObjectKey = "recordings/1/2026-08-06/test.webm"; String recordingFileUrl = "https://example.com/presigned-recording.webm"; - String backingTrackAudioFileUrl = backingTrack.toString(); + String backingTrackObjectKey ="backing-tracks/2/2026-08-06/test.mp3"; + String backingTrackAudioFileUrl = "https://example.com/presigned-backing-track.mp3"; given(playing.getRecordingObjectKey()).willReturn(recordingObjectKey); - given(s3FileService.createPresignedDownload(1L, recordingObjectKey)).willReturn(recordingFileUrl); + given(s3FileService.createPresignedDownload(1L, S3FileType.RECORDING, recordingObjectKey)).willReturn(recordingFileUrl); given(playing.getBackingTrack()).willReturn(backingTrack); - given(backingTrack.getAudioFileUrl()).willReturn(backingTrackAudioFileUrl); + given(backingTrack.getUser()).willReturn(backingTrackOwner); + given(backingTrackOwner.getUserId()).willReturn(2L); + given(backingTrack.getAudioObjectKey()).willReturn(backingTrackObjectKey); + given(s3FileService.createPresignedDownload(2L, S3FileType.BACKING_TRACK, backingTrackObjectKey + )).willReturn(backingTrackAudioFileUrl); given(backingTrack.getTimeSignature()).willReturn("4/4"); given(backingTrack.getPlaytimeSec()).willReturn(120); given(playingRepository.findByIdWithBackingTrack(1L)).willReturn(Optional.of(playing)); @@ -298,7 +305,7 @@ void getHistoryDetail_success_returnsAllAnalysesRegardlessOfStatus() { HistoryDetailResponseDTO response = historyService.getHistoryDetail(1L, 1L); - verify(s3FileService).createPresignedDownload(1L, recordingObjectKey); + verify(s3FileService).createPresignedDownload(1L, S3FileType.RECORDING, recordingObjectKey); assertThat(response.recordingFileUrl()).isEqualTo(recordingFileUrl); assertThat(response.backingTrackAudioFileUrl()).isEqualTo(backingTrackAudioFileUrl); diff --git a/src/test/java/com/mr/domain/playing/service/PlayingServiceTest.java b/src/test/java/com/mr/domain/playing/service/PlayingServiceTest.java index 20786b4c..6d2a5788 100644 --- a/src/test/java/com/mr/domain/playing/service/PlayingServiceTest.java +++ b/src/test/java/com/mr/domain/playing/service/PlayingServiceTest.java @@ -28,6 +28,7 @@ import com.mr.global.file.s3.dto.FileUploadCommand; import com.mr.global.file.s3.dto.ValidatedFile; import com.mr.global.file.s3.dto.PresignedUrlUpload; +import com.mr.global.file.s3.enums.S3FileType; import com.mr.global.file.s3.service.S3FileService; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -78,6 +79,10 @@ class PlayingServiceTest { private static final String RECORDING_FILE_URL = "https://test-bucket.s3.ap-northeast-2.amazonaws.com/" + RECORDING_OBJECT_KEY; + private static final String BACKING_TRACK_OBJECT_KEY = + "backing-tracks/1/2026-08-02/150000_a1b2c3.mp3"; + private static final String BACKING_TRACK_FILE_URL = + "https://example.com/presigned-backing-track.mp3"; @Mock private PlayingRepository playingRepository; @@ -148,6 +153,7 @@ void saveMidiEvents_success() { when(s3FileService.validateUploadedFile( userId, + S3FileType.RECORDING, RECORDING_OBJECT_KEY )).thenReturn(recordingResponse); @@ -196,6 +202,7 @@ void saveMidiEvents_success() { verify(s3FileService) .validateUploadedFile( userId, + S3FileType.RECORDING, RECORDING_OBJECT_KEY ); @@ -226,6 +233,7 @@ void saveMidiEvents_accessDenied() { when(s3FileService.validateUploadedFile( userId, + S3FileType.RECORDING, RECORDING_OBJECT_KEY )).thenReturn(recordingResponse); @@ -264,6 +272,7 @@ void saveMidiEvents_accessDenied() { verify(s3FileService) .validateUploadedFile( userId, + S3FileType.RECORDING, RECORDING_OBJECT_KEY ); @@ -297,6 +306,7 @@ void saveMidiEvents_convertsRequestToEntity() { when(s3FileService.validateUploadedFile( userId, + S3FileType.RECORDING, RECORDING_OBJECT_KEY )).thenReturn(recordingResponse); @@ -347,6 +357,7 @@ void saveMidiEvents_convertsRequestToEntity() { verify(s3FileService) .validateUploadedFile( userId, + S3FileType.RECORDING, RECORDING_OBJECT_KEY ); @@ -376,6 +387,7 @@ void saveMidiEvents_notInProgress() { when(s3FileService.validateUploadedFile( userId, + S3FileType.RECORDING, RECORDING_OBJECT_KEY )).thenReturn(recordingResponse); @@ -414,6 +426,7 @@ void saveMidiEvents_notInProgress() { verify(s3FileService) .validateUploadedFile( userId, + S3FileType.RECORDING, RECORDING_OBJECT_KEY ); @@ -462,6 +475,7 @@ void saveMidiEvents_nullPlayingId() { verify(s3FileService, never()) .validateUploadedFile( anyLong(), + any(S3FileType.class), anyString() ); @@ -512,6 +526,7 @@ void saveMidiEvents_playingNotFound() { when(s3FileService.validateUploadedFile( userId, + S3FileType.RECORDING, RECORDING_OBJECT_KEY )).thenReturn(recordingResponse); @@ -541,6 +556,7 @@ void saveMidiEvents_playingNotFound() { verify(s3FileService) .validateUploadedFile( userId, + S3FileType.RECORDING, RECORDING_OBJECT_KEY ); @@ -631,8 +647,20 @@ void startPlaying_success() { given(backingTrack.getTitle()) .willReturn("테스트 백킹트랙"); - given(backingTrack.getAudioFileUrl()) - .willReturn("https://example.com/backing-track.mp3"); + given(backingTrack.getAudioObjectKey()) + .willReturn(BACKING_TRACK_OBJECT_KEY); + + given(backingTrack.getUser()) + .willReturn(user); + + given(user.getUserId()) + .willReturn(userId); + + given(s3FileService.createPresignedDownload( + userId, + S3FileType.BACKING_TRACK, + BACKING_TRACK_OBJECT_KEY + )).willReturn(BACKING_TRACK_FILE_URL); given(backingTrack.getChordProgressions()) .willReturn(List.of()); @@ -809,8 +837,20 @@ private BackingTrack mockBackingTrack() { given(backingTrack.getTitle()) .willReturn("Blues Backing Track"); - given(backingTrack.getAudioFileUrl()) - .willReturn("https://example.com/backing-track.mp3"); + given(backingTrack.getAudioObjectKey()) + .willReturn(BACKING_TRACK_OBJECT_KEY); + + given(backingTrack.getUser()) + .willReturn(user); + + given(user.getUserId()) + .willReturn(userId); + + given(s3FileService.createPresignedDownload( + userId, + S3FileType.BACKING_TRACK, + BACKING_TRACK_OBJECT_KEY + )).willReturn(BACKING_TRACK_FILE_URL); given(backingTrack.getGenre()) .willReturn("BLUES"); @@ -1206,6 +1246,7 @@ void createRecordingUploadUrl_success() { when(s3FileService.createPresignedUpload( userId, + S3FileType.RECORDING, command )).thenReturn(presignedUpload); @@ -1231,7 +1272,7 @@ void createRecordingUploadUrl_success() { .validateInProgress(); verify(s3FileService) - .createPresignedUpload(userId, command); + .createPresignedUpload(userId, S3FileType.RECORDING, command); } @Test @@ -1288,6 +1329,7 @@ void createRecordingUploadUrl_notInProgress() { verify(s3FileService, never()) .createPresignedUpload( anyLong(), + any(S3FileType.class), any(FileUploadCommand.class) ); } From 1c55f9afc459782008b3c4526805463b4d0e7767 Mon Sep 17 00:00:00 2001 From: onlyoneprivate Date: Mon, 10 Aug 2026 23:01:56 +0900 Subject: [PATCH 4/6] =?UTF-8?q?fix:=20=EB=B0=B1=ED=82=B9=ED=8A=B8=EB=9E=99?= =?UTF-8?q?=20=EB=A7=88=EC=9D=B4=EA=B7=B8=EB=A0=88=EC=9D=B4=EC=85=98=20Obj?= =?UTF-8?q?ect=20Key=20prefix=20=EA=B2=80=EC=A6=9D=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?(#184)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../V7__migrate_backing_track_audio_file_url_to_object_key.sql | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/resources/db/migration/V7__migrate_backing_track_audio_file_url_to_object_key.sql b/src/main/resources/db/migration/V7__migrate_backing_track_audio_file_url_to_object_key.sql index 1e43242a..5ed98448 100644 --- a/src/main/resources/db/migration/V7__migrate_backing_track_audio_file_url_to_object_key.sql +++ b/src/main/resources/db/migration/V7__migrate_backing_track_audio_file_url_to_object_key.sql @@ -24,6 +24,7 @@ BEGIN OR audio_object_key LIKE 'http%' OR audio_object_key LIKE '%X-Amz%' OR btrim(audio_object_key) = '' + OR audio_object_key NOT LIKE 'backing-tracks/%' ) ) THEN RAISE EXCEPTION 'audio_object_key migration validation failed'; From 3468827979b1adfff6a19a34ba183529c1eecb91 Mon Sep 17 00:00:00 2001 From: onlyoneprivate Date: Tue, 11 Aug 2026 01:03:48 +0900 Subject: [PATCH 5/6] =?UTF-8?q?fix:=20=EB=B0=B1=ED=82=B9=ED=8A=B8=EB=9E=99?= =?UTF-8?q?=20=EC=98=A4=EB=94=94=EC=98=A4=20Object=20Key=20=EC=A0=95?= =?UTF-8?q?=EA=B7=9C=ED=99=94=20=EA=B0=92=20=EC=A0=80=EC=9E=A5=20(#184)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/mr/domain/backingtrack/service/BackingTrackService.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/mr/domain/backingtrack/service/BackingTrackService.java b/src/main/java/com/mr/domain/backingtrack/service/BackingTrackService.java index 2bd30954..d91c36c5 100644 --- a/src/main/java/com/mr/domain/backingtrack/service/BackingTrackService.java +++ b/src/main/java/com/mr/domain/backingtrack/service/BackingTrackService.java @@ -83,7 +83,7 @@ public BackingTrackCreateResponseDTO.CreateResultDTO createBackingTrack( request.timeSignature(), request.bpm(), request.playtimeSec(), - request.audioObjectKey(), + audioObjectKey, null, // midi 데이터는 생성 시 null로 초기화 (mvp후 별도 API나 이벤트로 업데이트) request.accessLevel(), request.level() From 5b9189dd7bc70497ddddaea493ce3e3a0935291b Mon Sep 17 00:00:00 2001 From: onlyoneprivate Date: Tue, 11 Aug 2026 01:25:01 +0900 Subject: [PATCH 6/6] =?UTF-8?q?refactor:=20=EB=B0=B1=ED=82=B9=ED=8A=B8?= =?UTF-8?q?=EB=9E=99=20=EC=83=9D=EC=84=B1=20=EB=B0=8F=20=EC=88=98=EC=A0=95?= =?UTF-8?q?=20=EC=9A=94=EC=B2=AD=20DTO=20=EB=B6=84=EB=A6=AC=20(#184)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/BackingTrackController.java | 4 +- .../dto/req/BackingTrackSaveRequestDTO.java | 166 ++++++++++++------ .../service/BackingTrackService.java | 4 +- .../BackingTrackControllerTest.java | 35 +++- 4 files changed, 140 insertions(+), 69 deletions(-) diff --git a/src/main/java/com/mr/domain/backingtrack/controller/BackingTrackController.java b/src/main/java/com/mr/domain/backingtrack/controller/BackingTrackController.java index 5e9268d0..acfb0c03 100644 --- a/src/main/java/com/mr/domain/backingtrack/controller/BackingTrackController.java +++ b/src/main/java/com/mr/domain/backingtrack/controller/BackingTrackController.java @@ -47,7 +47,7 @@ public class BackingTrackController { @PostMapping public ApiResponse createBackingTrack( @AuthenticationPrincipal CustomUserDetails userDetails, - @Valid @RequestBody BackingTrackSaveRequestDTO.SaveDTO request + @Valid @RequestBody BackingTrackSaveRequestDTO.CreateDTO request ) { BackingTrackCreateResponseDTO.CreateResultDTO result = backingTrackService.createBackingTrack(userDetails.getUserId(), request); @@ -86,7 +86,7 @@ public ApiResponse createAudioUploadUrl( public ApiResponse updateBackingTrack( @AuthenticationPrincipal CustomUserDetails userDetails, @PathVariable @Min(value = 1, message = "BACKING_TRACK_400_23") Long backingTrackId, - @Valid @RequestBody BackingTrackSaveRequestDTO.SaveDTO request + @Valid @RequestBody BackingTrackSaveRequestDTO.UpdateDTO request ) { Long userId = userDetails.getUserId(); BackingTrackUpdateResponseDTO.UpdateResultDTO result = diff --git a/src/main/java/com/mr/domain/backingtrack/dto/req/BackingTrackSaveRequestDTO.java b/src/main/java/com/mr/domain/backingtrack/dto/req/BackingTrackSaveRequestDTO.java index 2689b63d..fd93f172 100644 --- a/src/main/java/com/mr/domain/backingtrack/dto/req/BackingTrackSaveRequestDTO.java +++ b/src/main/java/com/mr/domain/backingtrack/dto/req/BackingTrackSaveRequestDTO.java @@ -16,61 +16,111 @@ public class BackingTrackSaveRequestDTO { - public record SaveDTO( - @NotBlank(message = "백킹트랙 제목은 필수입니다.") - @Size(max = 50, message = "백킹트랙 제목은 50자 이내여야 합니다.") - String title, - - @NotBlank(message = "장르는 필수입니다.") - @Size(max = 50, message = "장르는 50자 이내여야 합니다.") - String genre, - - @NotBlank(message = "Key 정보는 필수입니다.") - @Size(max = 20, message = "Key 정보는 20자 이내여야 합니다.") - String keySignature, - - @NotNull(message = "조성은 필수입니다.") - ScaleType scaleType, - - @NotBlank(message = "박자는 필수입니다.") - @Pattern(regexp = "^([1-9]|1[0-6])/(2|4|8|16)$", message = "음악적으로 유효한 박자 형식이어야 합니다. (예: 4/4, 3/4, 6/8)") - String timeSignature, - - @NotNull(message = "BPM은 필수입니다.") - @Min(value = 50, message = "BPM은 50 이상이어야 합니다.") - @Max(value = 200, message = "BPM은 200 이하여야 합니다.") - Integer bpm, - - @NotNull(message = "재생 시간은 필수입니다.") - @Min(value = 60, message = "재생 시간은 최소 60초(1분) 이상이어야 합니다.") - @Max(value = 600, message = "재생 시간은 최대 600초(10분) 이하여야 합니다.") - Integer playtimeSec, - - @Size(max = 255, message = "오디오 파일 Object Key는 255자 이내여야 합니다.") - String audioObjectKey, - - @NotNull(message = "공개 범위는 필수입니다.") - AccessLevel accessLevel, - - @NotNull(message = "난이도는 필수입니다.") - Level level, - - @Valid - @NotEmpty(message = "코드 진행 정보는 필수입니다.") - List chordProgression - ) {} - - public record ChordProgressionDTO( - @NotNull(message = "마디 번호는 필수입니다.") - @Min(value = 1, message = "마디 번호는 1 이상이어야 합니다.") - Integer measureNo, - - @NotNull(message = "코드 순서는 필수입니다.") - @Min(value = 1, message = "코드 순서는 1 이상이어야 합니다.") - Integer sequenceNo, - - @NotBlank(message = "코드명은 필수입니다.") - @Size(max = 30, message = "코드명은 30자 이내여야 합니다.") - String chordName - ) {} -} + public record CreateDTO( + @NotBlank(message = "백킹트랙 제목은 필수입니다.") + @Size(max = 50, message = "백킹트랙 제목은 50자 이내여야 합니다.") + String title, + + @NotBlank(message = "장르는 필수입니다.") + @Size(max = 50, message = "장르는 50자 이내여야 합니다.") + String genre, + + @NotBlank(message = "Key 정보는 필수입니다.") + @Size(max = 20, message = "Key 정보는 20자 이내여야 합니다.") + String keySignature, + + @NotNull(message = "조성은 필수입니다.") + ScaleType scaleType, + + @NotBlank(message = "박자는 필수입니다.") + @Pattern( + regexp = "^([1-9]|1[0-6])/(2|4|8|16)$", + message = "음악적으로 유효한 박자 형식이어야 합니다. (예: 4/4, 3/4, 6/8)" + ) + String timeSignature, + + @NotNull(message = "BPM은 필수입니다.") + @Min(value = 50, message = "BPM은 50 이상이어야 합니다.") + @Max(value = 200, message = "BPM은 200 이하여야 합니다.") + Integer bpm, + + @NotNull(message = "재생 시간은 필수입니다.") + @Min(value = 60, message = "재생 시간은 최소 60초(1분) 이상이어야 합니다.") + @Max(value = 600, message = "재생 시간은 최대 600초(10분) 이하여야 합니다.") + Integer playtimeSec, + + @Size(max = 255, message = "오디오 파일 Object Key는 255자 이내여야 합니다.") + String audioObjectKey, + + @NotNull(message = "공개 범위는 필수입니다.") + AccessLevel accessLevel, + + @NotNull(message = "난이도는 필수입니다.") + Level level, + + @Valid + @NotEmpty(message = "코드 진행 정보는 필수입니다.") + List chordProgression + ) { + } + + public record UpdateDTO( + @NotBlank(message = "백킹트랙 제목은 필수입니다.") + @Size(max = 50, message = "백킹트랙 제목은 50자 이내여야 합니다.") + String title, + + @NotBlank(message = "장르는 필수입니다.") + @Size(max = 50, message = "장르는 50자 이내여야 합니다.") + String genre, + + @NotBlank(message = "Key 정보는 필수입니다.") + @Size(max = 20, message = "Key 정보는 20자 이내여야 합니다.") + String keySignature, + + @NotNull(message = "조성은 필수입니다.") + ScaleType scaleType, + + @NotBlank(message = "박자는 필수입니다.") + @Pattern( + regexp = "^([1-9]|1[0-6])/(2|4|8|16)$", + message = "음악적으로 유효한 박자 형식이어야 합니다. (예: 4/4, 3/4, 6/8)" + ) + String timeSignature, + + @NotNull(message = "BPM은 필수입니다.") + @Min(value = 50, message = "BPM은 50 이상이어야 합니다.") + @Max(value = 200, message = "BPM은 200 이하여야 합니다.") + Integer bpm, + + @NotNull(message = "재생 시간은 필수입니다.") + @Min(value = 60, message = "재생 시간은 최소 60초(1분) 이상이어야 합니다.") + @Max(value = 600, message = "재생 시간은 최대 600초(10분) 이하여야 합니다.") + Integer playtimeSec, + + @NotNull(message = "공개 범위는 필수입니다.") + AccessLevel accessLevel, + + @NotNull(message = "난이도는 필수입니다.") + Level level, + + @Valid + @NotEmpty(message = "코드 진행 정보는 필수입니다.") + List chordProgression + ) { + } + + public record ChordProgressionDTO( + @NotNull(message = "마디 번호는 필수입니다.") + @Min(value = 1, message = "마디 번호는 1 이상이어야 합니다.") + Integer measureNo, + + @NotNull(message = "코드 순서는 필수입니다.") + @Min(value = 1, message = "코드 순서는 1 이상이어야 합니다.") + Integer sequenceNo, + + @NotBlank(message = "코드명은 필수입니다.") + @Size(max = 30, message = "코드명은 30자 이내여야 합니다.") + String chordName + ) { + } +} \ No newline at end of file diff --git a/src/main/java/com/mr/domain/backingtrack/service/BackingTrackService.java b/src/main/java/com/mr/domain/backingtrack/service/BackingTrackService.java index d91c36c5..9e29a38a 100644 --- a/src/main/java/com/mr/domain/backingtrack/service/BackingTrackService.java +++ b/src/main/java/com/mr/domain/backingtrack/service/BackingTrackService.java @@ -56,7 +56,7 @@ public class BackingTrackService { @Transactional public BackingTrackCreateResponseDTO.CreateResultDTO createBackingTrack( Long userId, - BackingTrackSaveRequestDTO.SaveDTO request + BackingTrackSaveRequestDTO.CreateDTO request ){ validateChordDuplicates(request.chordProgression()); validateChordSequence(request.timeSignature(), request.chordProgression()); @@ -124,7 +124,7 @@ public BackingTrackUploadUrlResponse createAudioUploadUrl( public BackingTrackUpdateResponseDTO.UpdateResultDTO updateBackingTrack( Long userId, Long backingTrackId, - BackingTrackSaveRequestDTO.SaveDTO request + BackingTrackSaveRequestDTO.UpdateDTO request ) { validateChordDuplicates(request.chordProgression()); validateChordSequence(request.timeSignature(), request.chordProgression()); diff --git a/src/test/java/com/mr/domain/backingtrack/controller/BackingTrackControllerTest.java b/src/test/java/com/mr/domain/backingtrack/controller/BackingTrackControllerTest.java index 7d8b48bd..0602be78 100644 --- a/src/test/java/com/mr/domain/backingtrack/controller/BackingTrackControllerTest.java +++ b/src/test/java/com/mr/domain/backingtrack/controller/BackingTrackControllerTest.java @@ -89,7 +89,7 @@ class CreateBackingTrack { @Test @DisplayName("유효한 요청이면 백킹트랙을 생성한다") void createBackingTrack_success() throws Exception { - BackingTrackSaveRequestDTO.SaveDTO request = createValidSaveRequest(); + BackingTrackSaveRequestDTO.CreateDTO request = createValidSaveRequest(); BackingTrackCreateResponseDTO.CreateResultDTO response = BackingTrackCreateResponseDTO.CreateResultDTO.of( @@ -213,7 +213,7 @@ class UpdateBackingTrack { @Test @DisplayName("유효한 요청이면 백킹트랙을 수정한다") void updateBackingTrack_success() throws Exception { - BackingTrackSaveRequestDTO.SaveDTO request = createValidSaveRequest(); + BackingTrackSaveRequestDTO.UpdateDTO request = createValidUpdateRequest(); BackingTrackUpdateResponseDTO.UpdateResultDTO response = BackingTrackUpdateResponseDTO.UpdateResultDTO.of( @@ -236,7 +236,7 @@ void updateBackingTrack_success() throws Exception { @Test @DisplayName("backingTrackId가 0 이하이면 요청에 실패한다") void updateBackingTrack_invalidId() throws Exception { - BackingTrackSaveRequestDTO.SaveDTO request = createValidSaveRequest(); + BackingTrackSaveRequestDTO.UpdateDTO request = createValidUpdateRequest(); mockMvc.perform( put("/api/backing-tracks/{backingTrackId}", 0L) @@ -252,7 +252,7 @@ void updateBackingTrack_invalidId() throws Exception { @Test @DisplayName("수정 권한이 없으면 403을 반환한다") void updateBackingTrack_forbidden() throws Exception { - BackingTrackSaveRequestDTO.SaveDTO request = createValidSaveRequest(); + BackingTrackSaveRequestDTO.UpdateDTO request = createValidUpdateRequest(); given(backingTrackService.updateBackingTrack(anyLong(), anyLong(), any())) .willThrow(new GeneralException(BackingTrackErrorStatus.FORBIDDEN_UPDATE)); @@ -269,7 +269,7 @@ void updateBackingTrack_forbidden() throws Exception { @Test @DisplayName("존재하지 않는 트랙이면 404를 반환한다") void updateBackingTrack_notFound() throws Exception { - BackingTrackSaveRequestDTO.SaveDTO request = createValidSaveRequest(); + BackingTrackSaveRequestDTO.UpdateDTO request = createValidUpdateRequest(); given(backingTrackService.updateBackingTrack(anyLong(), anyLong(), any())) .willThrow(new GeneralException(BackingTrackErrorStatus.BACKING_TRACK_NOT_FOUND)); @@ -529,8 +529,8 @@ void getRecommendedTracks_empty() throws Exception { } } - private BackingTrackSaveRequestDTO.SaveDTO createValidSaveRequest() { - return new BackingTrackSaveRequestDTO.SaveDTO( + private BackingTrackSaveRequestDTO.CreateDTO createValidSaveRequest() { + return new BackingTrackSaveRequestDTO.CreateDTO( "테스트 트랙", "Jazz", "C", @@ -544,4 +544,25 @@ private BackingTrackSaveRequestDTO.SaveDTO createValidSaveRequest() { List.of(new BackingTrackSaveRequestDTO.ChordProgressionDTO(1, 1, "Cmaj7")) ); } + + private BackingTrackSaveRequestDTO.UpdateDTO createValidUpdateRequest() { + return new BackingTrackSaveRequestDTO.UpdateDTO( + "테스트 트랙", + "Jazz", + "C", + ScaleType.MAJOR, + "4/4", + 120, + 180, + AccessLevel.PUBLIC, + Level.BASIC, + List.of( + new BackingTrackSaveRequestDTO.ChordProgressionDTO( + 1, + 1, + "Cmaj7" + ) + ) + ); + } } \ No newline at end of file