From c4037b85696b38bfaf0dab9ca6b5bb470769eb49 Mon Sep 17 00:00:00 2001 From: p1001q Date: Wed, 12 Aug 2026 15:48:50 +0900 Subject: [PATCH 1/8] =?UTF-8?q?fix:=20=ED=95=99=EC=8A=B5=20=EB=AA=A8?= =?UTF-8?q?=EB=B2=94=20=EC=97=B0=EC=A3=BC=20S3=20=EC=9E=AC=EC=83=9D=20URL?= =?UTF-8?q?=20=EB=B0=9C=EA=B8=89=20(#168)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../res/LearningStepDetailResponseDTO.java | 9 +++--- .../learning/entity/PlayingExample.java | 17 +++++----- .../learning/service/LearningService.java | 18 ++++++++++- .../mr/global/file/s3/enums/S3FileType.java | 3 +- .../global/file/s3/service/S3FileService.java | 27 ++++++++++++++-- .../file/s3/service/S3ObjectKeyGenerator.java | 8 +++++ ...g_example_audio_file_url_to_object_key.sql | 31 +++++++++++++++++++ 7 files changed, 96 insertions(+), 17 deletions(-) create mode 100644 src/main/resources/db/migration/V9__migrate_playing_example_audio_file_url_to_object_key.sql diff --git a/src/main/java/com/mr/domain/learning/dto/res/LearningStepDetailResponseDTO.java b/src/main/java/com/mr/domain/learning/dto/res/LearningStepDetailResponseDTO.java index f193560b..c9294308 100644 --- a/src/main/java/com/mr/domain/learning/dto/res/LearningStepDetailResponseDTO.java +++ b/src/main/java/com/mr/domain/learning/dto/res/LearningStepDetailResponseDTO.java @@ -45,7 +45,8 @@ public record StepDetailResultDTO( List chordExamples ) { public static StepDetailResultDTO of(Learning learning, LearningStep step, - PlayingExample playingExample, List chordExamples) { + PlayingExample playingExample, String audioUrl, + List chordExamples) { return new StepDetailResultDTO( learning.getId(), step.getId(), @@ -55,7 +56,7 @@ public static StepDetailResultDTO of(Learning learning, LearningStep step, step.getTitle(), step.getContent(), step.getPracticeTip(), - playingExample != null ? ModelPerformance.from(playingExample) : null, + playingExample != null ? ModelPerformance.from(playingExample, audioUrl) : null, chordExamples.stream().map(ChordExampleItem::from).toList() ); } @@ -75,11 +76,11 @@ public record ModelPerformance( @Schema(description = "재생 시간(초)", example = "154") Integer durationSeconds ) { - public static ModelPerformance from(PlayingExample playingExample) { + public static ModelPerformance from(PlayingExample playingExample, String audioUrl) { return new ModelPerformance( playingExample.getTitle(), playingExample.getDescription(), - playingExample.getAudioFileUrl(), + audioUrl, playingExample.getPlayingSeconds() != null ? playingExample.getPlayingSeconds().intValue() : null ); } diff --git a/src/main/java/com/mr/domain/learning/entity/PlayingExample.java b/src/main/java/com/mr/domain/learning/entity/PlayingExample.java index bbf462f7..183ee729 100644 --- a/src/main/java/com/mr/domain/learning/entity/PlayingExample.java +++ b/src/main/java/com/mr/domain/learning/entity/PlayingExample.java @@ -44,9 +44,8 @@ public class PlayingExample extends BaseCreatedEntity { @Column(name = "midi_data", nullable = false, columnDefinition = "JSON") private String midiData; - // 오디오 파일 - @Column(name = "audio_file_url", nullable = false, length = 255) - private String audioFileUrl; + @Column(name = "audio_object_key", nullable = false, length = 255) + private String audioObjectKey; // bpm @Column(name = "bpm") @@ -66,12 +65,12 @@ public class PlayingExample extends BaseCreatedEntity { @Builder(access = AccessLevel.PRIVATE) private PlayingExample(LearningStep learningStep, String title, String midiData, - String audioFileUrl, Integer bpm, String keySignature, + String audioObjectKey, Integer bpm, String keySignature, String description, Long playingSeconds) { this.learningStep = learningStep; this.title = title; this.midiData = midiData; - this.audioFileUrl = audioFileUrl; + this.audioObjectKey = audioObjectKey; this.bpm = bpm; this.keySignature = keySignature; this.description = description; @@ -79,13 +78,13 @@ private PlayingExample(LearningStep learningStep, String title, String midiData, } public static PlayingExample create(LearningStep learningStep, String title, String midiData, - String audioFileUrl, Integer bpm, String keySignature, + String audioObjectKey, Integer bpm, String keySignature, String description, Long playingSeconds) { return PlayingExample.builder() .learningStep(learningStep) .title(title) .midiData(midiData) - .audioFileUrl(audioFileUrl) + .audioObjectKey(audioObjectKey) .bpm(bpm) .keySignature(keySignature) .description(description) @@ -93,12 +92,12 @@ public static PlayingExample create(LearningStep learningStep, String title, Str .build(); } - public void updatePlayingExample(String title, String midiData, String audioFileUrl, + public void updatePlayingExample(String title, String midiData, String audioObjectKey, Integer bpm, String keySignature, String description, Long playingSeconds) { this.title = title; this.midiData = midiData; - this.audioFileUrl = audioFileUrl; + this.audioObjectKey = audioObjectKey; this.bpm = bpm; this.keySignature = keySignature; this.description = description; diff --git a/src/main/java/com/mr/domain/learning/service/LearningService.java b/src/main/java/com/mr/domain/learning/service/LearningService.java index 55b6b609..fbf99ee9 100644 --- a/src/main/java/com/mr/domain/learning/service/LearningService.java +++ b/src/main/java/com/mr/domain/learning/service/LearningService.java @@ -27,6 +27,8 @@ import com.mr.domain.user.repository.UserRepository; import com.mr.global.apipayload.exception.GeneralException; import com.mr.global.event.NotificationEvent; +import com.mr.global.file.s3.enums.S3FileType; +import com.mr.global.file.s3.service.S3FileService; import lombok.RequiredArgsConstructor; import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; @@ -60,6 +62,7 @@ public class LearningService { private final LearningRepository learningRepository; private final PlayingExampleRepository playingExampleRepository; private final ChordExampleRepository chordExampleRepository; + private final S3FileService s3FileService; // 임시 작명 private final UserRepository userRepository; private final ApplicationEventPublisher eventPublisher; @@ -214,7 +217,20 @@ public LearningStepDetailResponseDTO.StepDetailResultDTO getStepDetail(Long lear PlayingExample playingExample = playingExampleRepository.findByLearningStep_Id(learningStepId).orElse(null); List chordExamples = chordExampleRepository.findByLearningStep_Id(learningStepId); - return LearningStepDetailResponseDTO.StepDetailResultDTO.of(learning, learningStep, playingExample, chordExamples); + String audioUrl = playingExample == null + ? null + : s3FileService.createPresignedDownload( + S3FileType.PLAYING_EXAMPLE, + playingExample.getAudioObjectKey() + ); + + return LearningStepDetailResponseDTO.StepDetailResultDTO.of( + learning, + learningStep, + playingExample, + audioUrl, + chordExamples + ); } // 실전 반주법 패키지(ACCOMPANIMENT) 전체보기 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 index 068a224b..3120ccf6 100644 --- a/src/main/java/com/mr/global/file/s3/enums/S3FileType.java +++ b/src/main/java/com/mr/global/file/s3/enums/S3FileType.java @@ -8,7 +8,8 @@ public enum S3FileType { RECORDING("recordings"), - BACKING_TRACK("backing-tracks"); + BACKING_TRACK("backing-tracks"), + PLAYING_EXAMPLE("playing_example"); 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 b0674871..2bead5e7 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 @@ -151,6 +151,20 @@ public String createPresignedDownload(Long ownerId, S3FileType fileType, String validateOwnerId(ownerId); validateObjectKey(ownerId, fileType, objectKey); + return presignDownload(objectKey); + } + + /** + * 사용자 소유자가 없는 공용 콘텐츠의 조회용 Presigned GET URL을 발급합니다. + */ + public String createPresignedDownload(S3FileType fileType, String objectKey) { + validateObjectKey(fileType, objectKey); + + return presignDownload(objectKey); + } + + private String presignDownload(String objectKey) { + GetObjectRequest getObjectRequest = GetObjectRequest.builder() .bucket(s3Properties.bucket()) @@ -177,8 +191,7 @@ public String createPresignedDownload(Long ownerId, S3FileType fileType, String } catch (SdkException exception) { log.error( - "S3 Presigned GET URL 발급에 실패했습니다. ownerId={}, objectKey={}", - ownerId, + "S3 Presigned GET URL 발급에 실패했습니다. objectKey={}", objectKey, exception ); @@ -303,4 +316,14 @@ private void validateObjectKey(Long ownerId, S3FileType fileType, String objectK throw new GeneralException(S3ErrorStatus.INVALID_OBJECT_KEY); } } + + private void validateObjectKey(S3FileType fileType, String objectKey) { + if (objectKey == null || objectKey.isBlank()) { + throw new GeneralException(S3ErrorStatus.INVALID_OBJECT_KEY); + } + + if (!objectKeyGenerator.belongsToFileType(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 5af66bf6..071bb0f8 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 @@ -72,6 +72,14 @@ public boolean belongsToOwner(Long ownerId, S3FileType fileType, String objectKe return objectKey.startsWith(expectedPrefix); } + public boolean belongsToFileType(S3FileType fileType, String objectKey) { + if (fileType == null || objectKey == null) { + return false; + } + + return objectKey.startsWith(fileType.getPrefix() + "/"); + } + private String createShortUuid() { return UUID.randomUUID() .toString() diff --git a/src/main/resources/db/migration/V9__migrate_playing_example_audio_file_url_to_object_key.sql b/src/main/resources/db/migration/V9__migrate_playing_example_audio_file_url_to_object_key.sql new file mode 100644 index 00000000..7a007736 --- /dev/null +++ b/src/main/resources/db/migration/V9__migrate_playing_example_audio_file_url_to_object_key.sql @@ -0,0 +1,31 @@ +-- 1. 컬럼명 변경 +ALTER TABLE playing_example + RENAME COLUMN audio_file_url TO audio_object_key; + +-- 2. 기존 URL 값에서 S3 Object Key만 추출 +UPDATE playing_example +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이나 잘못된 prefix가 남아 있으면 마이그레이션을 중단 +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM playing_example + WHERE audio_object_key IS NULL + OR btrim(audio_object_key) = '' + OR audio_object_key LIKE 'http%' + OR audio_object_key LIKE '%?%' + OR audio_object_key LIKE '%X-Amz%' + OR audio_object_key NOT LIKE 'playing_example/%' + ) THEN + RAISE EXCEPTION 'playing_example audio_object_key migration validation failed'; + END IF; +END +$$; From fbe2d90008d061c58d18803104ad13fc6285aee5 Mon Sep 17 00:00:00 2001 From: p1001q Date: Wed, 12 Aug 2026 16:03:00 +0900 Subject: [PATCH 2/8] =?UTF-8?q?test:=20=ED=95=99=EC=8A=B5=20=EB=AA=A8?= =?UTF-8?q?=EB=B2=94=20=EC=97=B0=EC=A3=BC=20S3=20=EC=9E=AC=EC=83=9D=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EB=B3=B4=EC=99=84=20(#168)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../learning/service/LearningServiceTest.java | 17 ++++- .../RecordingObjectKeyGeneratorTest.java | 29 ++++++++- .../file/s3/service/S3FileServiceTest.java | 63 ++++++++++++++++++- 3 files changed, 106 insertions(+), 3 deletions(-) diff --git a/src/test/java/com/mr/domain/learning/service/LearningServiceTest.java b/src/test/java/com/mr/domain/learning/service/LearningServiceTest.java index 517c3231..4320e9a0 100644 --- a/src/test/java/com/mr/domain/learning/service/LearningServiceTest.java +++ b/src/test/java/com/mr/domain/learning/service/LearningServiceTest.java @@ -24,6 +24,8 @@ import com.mr.domain.learning.dto.req.LearningResultSaveRequestDTO; import com.mr.global.apipayload.exception.GeneralException; import com.mr.global.event.NotificationEvent; +import com.mr.global.file.s3.enums.S3FileType; +import com.mr.global.file.s3.service.S3FileService; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -42,6 +44,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyList; import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.mock; @@ -63,6 +66,8 @@ class LearningServiceTest { @Mock private ChordExampleRepository chordExampleRepository; @Mock + private S3FileService s3FileService; + @Mock private UserRepository userRepository; @Mock private ApplicationEventPublisher eventPublisher; @@ -247,10 +252,14 @@ void getStepDetail_success_includesModelPerformanceAndChordExamples() { when(step.getPracticeTip()).thenReturn("연습 팁"); PlayingExample playingExample = mock(PlayingExample.class); + String audioObjectKey = "playing_example/11th.mp3"; + String presignedAudioUrl = "https://example.com/11th.mp3?X-Amz-Signature=test"; when(playingExample.getTitle()).thenReturn("11th Tension Notes Practice"); when(playingExample.getDescription()).thenReturn("프로 연주자의 응용 사례"); - when(playingExample.getAudioFileUrl()).thenReturn("https://cdn.example.com/audio/11th.mp3"); + when(playingExample.getAudioObjectKey()).thenReturn(audioObjectKey); when(playingExample.getPlayingSeconds()).thenReturn(154L); + when(s3FileService.createPresignedDownload(S3FileType.PLAYING_EXAMPLE, audioObjectKey)) + .thenReturn(presignedAudioUrl); ChordExample chordExample = mock(ChordExample.class); when(chordExample.getChordName()).thenReturn("Cmaj7"); @@ -267,12 +276,14 @@ void getStepDetail_success_includesModelPerformanceAndChordExamples() { assertThat(result.stepTitle()).isEqualTo("11th 텐션 노트 활용하기"); assertThat(result.modelPerformance()).isNotNull(); + assertThat(result.modelPerformance().audioUrl()).isEqualTo(presignedAudioUrl); assertThat(result.modelPerformance().durationSeconds()).isEqualTo(154); assertThat(result.chordExamples()).hasSize(1); assertThat(result.chordExamples().get(0).chordName()).isEqualTo("Cmaj7"); assertThat(result.chordExamples().get(0).noteNumbers()).containsExactly(60, 64, 67, 70, 77); assertThatThrownBy(() -> result.chordExamples().get(0).noteNumbers().add(1)) .isInstanceOf(UnsupportedOperationException.class); + verify(s3FileService).createPresignedDownload(S3FileType.PLAYING_EXAMPLE, audioObjectKey); } @Test @@ -299,6 +310,10 @@ void getStepDetail_noModelPerformance_returnsNull() { assertThat(result.modelPerformance()).isNull(); assertThat(result.chordExamples()).isEmpty(); + verify(s3FileService, never()).createPresignedDownload( + any(S3FileType.class), + anyString() + ); } @Test 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 aa37419b..ca1bc7b7 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 @@ -328,4 +328,31 @@ void blankObjectKey() { .isFalse(); } } -} \ No newline at end of file + + @Nested + @DisplayName("Object Key 파일 타입 검증") + class ValidateObjectKeyFileType { + + @Test + @DisplayName("파일 타입 prefix와 일치하면 true를 반환한다") + void belongsToFileType() { + boolean result = objectKeyGenerator.belongsToFileType( + S3FileType.PLAYING_EXAMPLE, + "playing_example/triads_step1.mp3" + ); + + assertThat(result).isTrue(); + } + + @Test + @DisplayName("파일 타입 prefix와 일치하지 않으면 false를 반환한다") + void doesNotBelongToFileType() { + boolean result = objectKeyGenerator.belongsToFileType( + S3FileType.PLAYING_EXAMPLE, + "backing-tracks/1/triads_step1.mp3" + ); + + assertThat(result).isFalse(); + } + } +} 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 560a9b84..a5f68656 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 @@ -60,6 +60,9 @@ class S3FileServiceTest { private static final String OTHER_OWNER_OBJECT_KEY = "recordings/2/2026-08-05/174500_test.mp3"; + private static final String PLAYING_EXAMPLE_OBJECT_KEY = + "playing_example/triads_step1.mp3"; + private static final String FILE_URL = "https://" + BUCKET @@ -1060,6 +1063,64 @@ void createPresignedDownloadUrl_success() ); } + @Test + @DisplayName("공용 콘텐츠 Object Key이면 Presigned GET URL을 발급한다") + void createPresignedDownload_sharedContent_success() + throws Exception { + String downloadUrl = + "https://example.com/triads_step1.mp3?X-Amz-Signature=test"; + + when(objectKeyGenerator.belongsToFileType( + S3FileType.PLAYING_EXAMPLE, + PLAYING_EXAMPLE_OBJECT_KEY + )).thenReturn(true); + + when(s3Presigner.presignGetObject( + any(GetObjectPresignRequest.class) + )).thenReturn(presignedGetObjectRequest); + + when(presignedGetObjectRequest.url()) + .thenReturn(URI.create(downloadUrl).toURL()); + + String response = s3FileService.createPresignedDownload( + S3FileType.PLAYING_EXAMPLE, + PLAYING_EXAMPLE_OBJECT_KEY + ); + + assertThat(response).isEqualTo(downloadUrl); + + ArgumentCaptor captor = + ArgumentCaptor.forClass(GetObjectPresignRequest.class); + + verify(s3Presigner).presignGetObject(captor.capture()); + assertThat(captor.getValue().getObjectRequest().key()) + .isEqualTo(PLAYING_EXAMPLE_OBJECT_KEY); + verify(objectKeyGenerator).belongsToFileType( + S3FileType.PLAYING_EXAMPLE, + PLAYING_EXAMPLE_OBJECT_KEY + ); + } + + @Test + @DisplayName("공용 콘텐츠 prefix와 다른 Object Key이면 Presigned GET URL을 발급하지 않는다") + void createPresignedDownload_sharedContent_invalidPrefix() { + when(objectKeyGenerator.belongsToFileType( + S3FileType.PLAYING_EXAMPLE, + OBJECT_KEY + )).thenReturn(false); + + assertGeneralException( + () -> s3FileService.createPresignedDownload( + S3FileType.PLAYING_EXAMPLE, + OBJECT_KEY + ), + S3ErrorStatus.INVALID_OBJECT_KEY + ); + + verify(s3Presigner, never()) + .presignGetObject(any(GetObjectPresignRequest.class)); + } + @Test @DisplayName("다른 소유자의 Object Key이면 Presigned GET URL을 발급하지 않는다") void createPresignedDownload_invalidObjectKey() { @@ -1146,4 +1207,4 @@ private void assertGeneralException( .isEqualTo(expectedErrorStatus); }); } -} \ No newline at end of file +} From 97aad18d9b1afef23397e0debaefd142d29f5d94 Mon Sep 17 00:00:00 2001 From: p1001q Date: Wed, 12 Aug 2026 17:31:47 +0900 Subject: [PATCH 3/8] =?UTF-8?q?fix:=20=EA=B3=B5=EC=9A=A9=20S3=20=EB=8B=A4?= =?UTF-8?q?=EC=9A=B4=EB=A1=9C=EB=93=9C=20=EC=86=8C=EC=9C=A0=EC=9E=90=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=20=EC=9A=B0=ED=9A=8C=20=EC=B0=A8=EB=8B=A8=20?= =?UTF-8?q?(#168)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mr/global/file/s3/enums/S3FileType.java | 7 ++++--- .../global/file/s3/service/S3FileService.java | 5 ++++- .../RecordingObjectKeyGeneratorTest.java | 8 ++++---- .../file/s3/service/S3FileServiceTest.java | 19 +++++++++++++++++-- 4 files changed, 29 insertions(+), 10 deletions(-) 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 index 3120ccf6..1a53f5b0 100644 --- a/src/main/java/com/mr/global/file/s3/enums/S3FileType.java +++ b/src/main/java/com/mr/global/file/s3/enums/S3FileType.java @@ -7,9 +7,10 @@ @RequiredArgsConstructor public enum S3FileType { - RECORDING("recordings"), - BACKING_TRACK("backing-tracks"), - PLAYING_EXAMPLE("playing_example"); + RECORDING("recordings", true), + BACKING_TRACK("backing-tracks", true), + PLAYING_EXAMPLE("playing_example", false); private final String prefix; + private final boolean ownerScoped; } 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 2bead5e7..85cfef02 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 @@ -158,13 +158,16 @@ public String createPresignedDownload(Long ownerId, S3FileType fileType, String * 사용자 소유자가 없는 공용 콘텐츠의 조회용 Presigned GET URL을 발급합니다. */ public String createPresignedDownload(S3FileType fileType, String objectKey) { + if (fileType == null || fileType.isOwnerScoped()) { + throw new GeneralException(S3ErrorStatus.INVALID_OBJECT_KEY); + } + validateObjectKey(fileType, objectKey); return presignDownload(objectKey); } private String presignDownload(String objectKey) { - GetObjectRequest getObjectRequest = GetObjectRequest.builder() .bucket(s3Properties.bucket()) 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 ca1bc7b7..880b04ad 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 @@ -334,8 +334,8 @@ void blankObjectKey() { class ValidateObjectKeyFileType { @Test - @DisplayName("파일 타입 prefix와 일치하면 true를 반환한다") - void belongsToFileType() { + @DisplayName("belongsToFileType - 파일 타입 prefix와 일치하면 true를 반환한다") + void belongsToFileType_matchingPrefix_returnsTrue() { boolean result = objectKeyGenerator.belongsToFileType( S3FileType.PLAYING_EXAMPLE, "playing_example/triads_step1.mp3" @@ -345,8 +345,8 @@ void belongsToFileType() { } @Test - @DisplayName("파일 타입 prefix와 일치하지 않으면 false를 반환한다") - void doesNotBelongToFileType() { + @DisplayName("belongsToFileType - 파일 타입 prefix와 일치하지 않으면 false를 반환한다") + void belongsToFileType_mismatchedPrefix_returnsFalse() { boolean result = objectKeyGenerator.belongsToFileType( S3FileType.PLAYING_EXAMPLE, "backing-tracks/1/triads_step1.mp3" 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 a5f68656..ce9390f8 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 @@ -1064,7 +1064,7 @@ void createPresignedDownloadUrl_success() } @Test - @DisplayName("공용 콘텐츠 Object Key이면 Presigned GET URL을 발급한다") + @DisplayName("createPresignedDownload - 공용 콘텐츠 Object Key이면 Presigned GET URL을 발급한다") void createPresignedDownload_sharedContent_success() throws Exception { String downloadUrl = @@ -1102,7 +1102,7 @@ void createPresignedDownload_sharedContent_success() } @Test - @DisplayName("공용 콘텐츠 prefix와 다른 Object Key이면 Presigned GET URL을 발급하지 않는다") + @DisplayName("createPresignedDownload - 공용 콘텐츠 prefix와 다른 Object Key이면 Presigned GET URL을 발급하지 않는다") void createPresignedDownload_sharedContent_invalidPrefix() { when(objectKeyGenerator.belongsToFileType( S3FileType.PLAYING_EXAMPLE, @@ -1121,6 +1121,21 @@ void createPresignedDownload_sharedContent_invalidPrefix() { .presignGetObject(any(GetObjectPresignRequest.class)); } + @Test + @DisplayName("createPresignedDownload - 소유자 경로 파일 타입이면 Presigned GET URL을 발급하지 않는다") + void createPresignedDownload_ownerScopedFileType_throwsInvalidObjectKey() { + assertGeneralException( + () -> s3FileService.createPresignedDownload( + S3FileType.RECORDING, + OBJECT_KEY + ), + S3ErrorStatus.INVALID_OBJECT_KEY + ); + + verify(s3Presigner, never()) + .presignGetObject(any(GetObjectPresignRequest.class)); + } + @Test @DisplayName("다른 소유자의 Object Key이면 Presigned GET URL을 발급하지 않는다") void createPresignedDownload_invalidObjectKey() { From 8696ee4ba2c7acde228506a7c18bd784c8198fa0 Mon Sep 17 00:00:00 2001 From: p1001q Date: Wed, 12 Aug 2026 18:55:14 +0900 Subject: [PATCH 4/8] =?UTF-8?q?refactor:=20=ED=95=99=EC=8A=B5=20=EB=B0=8F?= =?UTF-8?q?=20S3=20=EC=A3=BC=EC=84=9D=EC=9D=84=20=EC=9D=98=EB=8F=84=20?= =?UTF-8?q?=EC=A4=91=EC=8B=AC=EC=9C=BC=EB=A1=9C=20=EC=A0=95=EB=A6=AC=20(#1?= =?UTF-8?q?68)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/mr/domain/learning/entity/PlayingExample.java | 6 ------ .../com/mr/domain/learning/service/LearningService.java | 9 --------- .../com/mr/global/file/s3/service/S3FileService.java | 1 - .../mr/global/file/s3/service/S3ObjectKeyGenerator.java | 5 +---- 4 files changed, 1 insertion(+), 20 deletions(-) diff --git a/src/main/java/com/mr/domain/learning/entity/PlayingExample.java b/src/main/java/com/mr/domain/learning/entity/PlayingExample.java index 183ee729..2c6824b5 100644 --- a/src/main/java/com/mr/domain/learning/entity/PlayingExample.java +++ b/src/main/java/com/mr/domain/learning/entity/PlayingExample.java @@ -35,11 +35,9 @@ public class PlayingExample extends BaseCreatedEntity { @JoinColumn(name = "learning_step_id", nullable = false, unique = true) private LearningStep learningStep; - // 제목 @Column(name = "title", nullable = false, length = 100) private String title; - // 미디 파일 데이터 @JdbcTypeCode(SqlTypes.JSON) @Column(name = "midi_data", nullable = false, columnDefinition = "JSON") private String midiData; @@ -47,19 +45,15 @@ public class PlayingExample extends BaseCreatedEntity { @Column(name = "audio_object_key", nullable = false, length = 255) private String audioObjectKey; - // bpm @Column(name = "bpm") private Integer bpm; - // key @Column(name = "key_signature", length = 20) private String keySignature; - // 설명 @Column(name = "description", columnDefinition = "TEXT") private String description; - // 재생 시간 (초 단위 저장) @Column(name = "playing_seconds") private Long playingSeconds; diff --git a/src/main/java/com/mr/domain/learning/service/LearningService.java b/src/main/java/com/mr/domain/learning/service/LearningService.java index fbf99ee9..f5821962 100644 --- a/src/main/java/com/mr/domain/learning/service/LearningService.java +++ b/src/main/java/com/mr/domain/learning/service/LearningService.java @@ -52,7 +52,6 @@ @Transactional(readOnly = true) public class LearningService { - // 추천 학습 카드 최대 개수 private static final int RECOMMENDED_LEARNING_LIMIT = 2; private static final List RECOMMENDATION_DIFFICULTY_ORDER = List.of(LearningDifficulty.BEGINNER, LearningDifficulty.INTERMEDIATE, LearningDifficulty.ADVANCED); @@ -63,11 +62,9 @@ public class LearningService { private final PlayingExampleRepository playingExampleRepository; private final ChordExampleRepository chordExampleRepository; private final S3FileService s3FileService; - // 임시 작명 private final UserRepository userRepository; private final ApplicationEventPublisher eventPublisher; - // 학습 결과 저장 @Transactional public LearningResultResponseDTO.SaveResultResultDTO saveResult( Long userId, @@ -150,7 +147,6 @@ public LearningProgressResponseDTO.ProgressResultDTO getLearningProgress( return LearningProgressResponseDTO.ProgressResultDTO.of(learningId, progressRate); } - // 학습 단계별 연습 실행 정보 조회 public LearningPracticeDataResponseDTO.PracticeDataResultDTO getPracticeData( Long learningId, Long learningStepId @@ -164,7 +160,6 @@ public LearningPracticeDataResponseDTO.PracticeDataResultDTO getPracticeData( return LearningPracticeDataResponseDTO.PracticeDataResultDTO.from(playingExample); } - // 학습 주제(THEORY) 전체보기 public LearningTheoryListResponseDTO.TheoryListResultDTO getTheoryList(Long userId, String difficulty) { LearningDifficulty parsedDifficulty = parseDifficulty(difficulty); ensureUserExists(userId); @@ -175,7 +170,6 @@ public LearningTheoryListResponseDTO.TheoryListResultDTO getTheoryList(Long user return LearningTheoryListResponseDTO.TheoryListResultDTO.from(learnings); } - // 학습 커리큘럼 조회 public LearningCurriculumResponseDTO.CurriculumResultDTO getCurriculum(Long userId, Long learningId) { ensureUserExists(userId); @@ -209,7 +203,6 @@ private LearningCurriculumResponseDTO.StepItem toStepItem(LearningStep step, Use return LearningCurriculumResponseDTO.StepItem.of(step, status, score); } - // 학습 단계별 조회 public LearningStepDetailResponseDTO.StepDetailResultDTO getStepDetail(Long learningId, Long learningStepId) { Learning learning = getActiveLearningOrThrow(learningId); LearningStep learningStep = getLearningStepOrThrow(learning, learningStepId); @@ -233,7 +226,6 @@ public LearningStepDetailResponseDTO.StepDetailResultDTO getStepDetail(Long lear ); } - // 실전 반주법 패키지(ACCOMPANIMENT) 전체보기 public LearningAccompanimentListResponseDTO.AccompanimentListResultDTO getAccompanimentList(Long userId) { ensureUserExists(userId); @@ -245,7 +237,6 @@ public LearningAccompanimentListResponseDTO.AccompanimentListResultDTO getAccomp return LearningAccompanimentListResponseDTO.AccompanimentListResultDTO.of(items); } - // 학습 홈 조회 public LearningHomeResponseDTO.HomeResultDTO getHome(Long userId) { ensureUserExists(userId); 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 85cfef02..6c30aa88 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 @@ -145,7 +145,6 @@ public ValidatedFile validateUploadedFile(Long ownerId, S3FileType fileType, Str ); } - // 파일 조회용 Presigned GET URL을 발급 public String createPresignedDownload(Long ownerId, S3FileType fileType, String objectKey ) { validateOwnerId(ownerId); 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 071bb0f8..d70167ae 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 @@ -46,7 +46,6 @@ public String generate( Instant now = Instant.now(); - String generatedFileName = "%s_%s.%s".formatted( TIME_FORMATTER.format(now), @@ -92,16 +91,14 @@ private String resolveExtension( String contentType ) { - // contentType 정규화 및 대표 확장자 추출 String extensionByContentType = resolveExtensionByContentType(contentType); - // originalFileName에 확장자가 있는 경우, contentType 기준 확장자와 일치하는지 검증 (선택적) + // 파일명 확장자와 Content-Type이 서로 다른 요청을 거부해 메타데이터 불일치를 막는다. String fileExtension = extractExtension(originalFileName); if (fileExtension != null && !fileExtension.equalsIgnoreCase(extensionByContentType)) { throw new GeneralException(S3ErrorStatus.UNSUPPORTED_FILE_EXTENSION); } - // 최종적으로 contentType 기반의 올바른 대표 확장자 반환 return extensionByContentType; } From 02c78a54d843e9d5b086531dd7df864536d4e90f Mon Sep 17 00:00:00 2001 From: p1001q Date: Wed, 12 Aug 2026 20:06:55 +0900 Subject: [PATCH 5/8] =?UTF-8?q?docs:=20=ED=95=99=EC=8A=B5=20=EB=AA=A8?= =?UTF-8?q?=EB=B2=94=20=EC=97=B0=EC=A3=BC=20Presigned=20URL=20=EB=AA=85?= =?UTF-8?q?=EC=84=B8=20=EB=B0=98=EC=98=81=20(#168)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mr/domain/learning/controller/LearningController.java | 2 +- .../learning/dto/res/LearningStepDetailResponseDTO.java | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/mr/domain/learning/controller/LearningController.java b/src/main/java/com/mr/domain/learning/controller/LearningController.java index d59dbafe..4fd02655 100644 --- a/src/main/java/com/mr/domain/learning/controller/LearningController.java +++ b/src/main/java/com/mr/domain/learning/controller/LearningController.java @@ -87,7 +87,7 @@ public ApiResponse getCurricu @Operation( summary = "학습 단계별 조회 API", - description = "단계별 이론 설명, 연습 팁, 모범 연주 예시(있으면), 코드 예시를 조회합니다." + description = "단계별 이론 설명, 연습 팁, 모범 연주 예시(있으면 Presigned GET URL 포함), 코드 예시를 조회합니다." ) @GetMapping("/{learningId}/steps/{learningStepId}") public ApiResponse getStepDetail( diff --git a/src/main/java/com/mr/domain/learning/dto/res/LearningStepDetailResponseDTO.java b/src/main/java/com/mr/domain/learning/dto/res/LearningStepDetailResponseDTO.java index c9294308..2e367a06 100644 --- a/src/main/java/com/mr/domain/learning/dto/res/LearningStepDetailResponseDTO.java +++ b/src/main/java/com/mr/domain/learning/dto/res/LearningStepDetailResponseDTO.java @@ -70,7 +70,10 @@ public record ModelPerformance( @Schema(description = "예시 설명", example = "프로 연주자의 응용 사례") String description, - @Schema(description = "화면에서 재생할 오디오 URL", example = "https://cdn.example.com/audio/11th-tension-example.mp3") + @Schema( + description = "비공개 S3 모범 연주 재생용 Presigned GET URL. 현재 설정 기준 10분 후 만료", + example = "https://example-bucket.s3.ap-northeast-2.amazonaws.com/playing_example/triads_step1.mp3?X-Amz-Expires=600&X-Amz-Signature=example" + ) String audioUrl, @Schema(description = "재생 시간(초)", example = "154") From e3589b39aaf680d8c31e7d3851bcd69daf5a6d4b Mon Sep 17 00:00:00 2001 From: p1001q Date: Wed, 12 Aug 2026 21:49:06 +0900 Subject: [PATCH 6/8] =?UTF-8?q?fix:=20S3=20=EC=82=AC=EC=9A=A9=EC=9E=90=20?= =?UTF-8?q?=EC=86=8C=EC=9C=A0=20=ED=8C=8C=EC=9D=BC=20=ED=83=80=EC=9E=85=20?= =?UTF-8?q?=EA=B2=BD=EA=B3=84=20=EA=B2=80=EC=A6=9D=20=EA=B0=95=ED=99=94=20?= =?UTF-8?q?(#168)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/file/s3/service/S3FileService.java | 9 +++ .../file/s3/service/S3FileServiceTest.java | 77 +++++++++++++++++++ 2 files changed, 86 insertions(+) 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 6c30aa88..c0e59e15 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 @@ -48,6 +48,7 @@ public PresignedUrlUpload createPresignedUpload( ){ validateOwnerId(ownerId); + validateOwnerScopedFileType(fileType); validateUploadCommand(command); String normalizedContentType = ContentTypeUtils.normalize(command.contentType()); @@ -119,6 +120,7 @@ public PresignedUrlUpload createPresignedUpload( public ValidatedFile validateUploadedFile(Long ownerId, S3FileType fileType, String objectKey ) { validateOwnerId(ownerId); + validateOwnerScopedFileType(fileType); validateObjectKey(ownerId, fileType, objectKey); HeadObjectResponse headObject = getHeadObject(objectKey); @@ -148,6 +150,7 @@ public ValidatedFile validateUploadedFile(Long ownerId, S3FileType fileType, Str public String createPresignedDownload(Long ownerId, S3FileType fileType, String objectKey ) { validateOwnerId(ownerId); + validateOwnerScopedFileType(fileType); validateObjectKey(ownerId, fileType, objectKey); return presignDownload(objectKey); @@ -257,6 +260,12 @@ private void validateOwnerId(Long ownerId) { } } + private void validateOwnerScopedFileType(S3FileType fileType) { + if (fileType == null || !fileType.isOwnerScoped()) { + throw new GeneralException(S3ErrorStatus.INVALID_OBJECT_KEY); + } + } + private void validateUploadCommand( FileUploadCommand command ) { 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 ce9390f8..d1f5a39c 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 @@ -353,6 +353,37 @@ void createPresignedUpload_invalidOwnerId() { ); } + @Test + @DisplayName("공용 콘텐츠 파일 타입이면 사용자 소유 경로의 Presigned PUT URL을 발급하지 않는다") + void createPresignedUpload_sharedContentFileType() { + FileUploadCommand command = + new FileUploadCommand( + "triads_step1.mp3", + CONTENT_TYPE, + FILE_SIZE + ); + + assertGeneralException( + () -> s3FileService.createPresignedUpload( + OWNER_ID, + S3FileType.PLAYING_EXAMPLE, + command + ), + S3ErrorStatus.INVALID_OBJECT_KEY + ); + + verify(objectKeyGenerator, never()) + .generate( + anyLong(), + any(S3FileType.class), + anyString(), + anyString() + ); + + verify(s3Presigner, never()) + .presignPutObject(any(PutObjectPresignRequest.class)); + } + @Test @DisplayName("업로드 명령이 null이면 예외가 발생한다") void createPresignedUpload_nullCommand() { @@ -649,6 +680,29 @@ void validateUploadedFile_invalidOwnerId() { ); } + @Test + @DisplayName("공용 콘텐츠 파일 타입이면 사용자 소유 경로에서 업로드 객체를 검증하지 않는다") + void validateUploadedFile_sharedContentFileType() { + assertGeneralException( + () -> s3FileService.validateUploadedFile( + OWNER_ID, + S3FileType.PLAYING_EXAMPLE, + PLAYING_EXAMPLE_OBJECT_KEY + ), + S3ErrorStatus.INVALID_OBJECT_KEY + ); + + verify(objectKeyGenerator, never()) + .belongsToOwner( + anyLong(), + any(S3FileType.class), + anyString() + ); + + verify(s3Client, never()) + .headObject(any(HeadObjectRequest.class)); + } + @Test @DisplayName("Object Key가 null이면 예외가 발생한다") void validateUploadedFile_nullObjectKey() { @@ -1136,6 +1190,29 @@ void createPresignedDownload_ownerScopedFileType_throwsInvalidObjectKey() { .presignGetObject(any(GetObjectPresignRequest.class)); } + @Test + @DisplayName("createPresignedDownload - 공용 콘텐츠 파일 타입이면 사용자 소유 경로에서 Presigned GET URL을 발급하지 않는다") + void createPresignedDownload_sharedContentFileType_ownerPath() { + assertGeneralException( + () -> s3FileService.createPresignedDownload( + OWNER_ID, + S3FileType.PLAYING_EXAMPLE, + PLAYING_EXAMPLE_OBJECT_KEY + ), + S3ErrorStatus.INVALID_OBJECT_KEY + ); + + verify(objectKeyGenerator, never()) + .belongsToOwner( + anyLong(), + any(S3FileType.class), + anyString() + ); + + verify(s3Presigner, never()) + .presignGetObject(any(GetObjectPresignRequest.class)); + } + @Test @DisplayName("다른 소유자의 Object Key이면 Presigned GET URL을 발급하지 않는다") void createPresignedDownload_invalidObjectKey() { From cfa4024f63ba5b21cebb034db06be69a76691c41 Mon Sep 17 00:00:00 2001 From: p1001q Date: Thu, 13 Aug 2026 09:12:31 +0900 Subject: [PATCH 7/8] =?UTF-8?q?fix:=20=EB=AA=A8=EB=B2=94=20=EC=97=B0?= =?UTF-8?q?=EC=A3=BC=20Object=20Key=20=EC=84=A0=ED=96=89=20=EC=8A=AC?= =?UTF-8?q?=EB=9E=98=EC=8B=9C=20=EC=A0=9C=EA=B1=B0=20(#168)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...playing_example_audio_file_url_to_object_key.sql | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/main/resources/db/migration/V9__migrate_playing_example_audio_file_url_to_object_key.sql b/src/main/resources/db/migration/V9__migrate_playing_example_audio_file_url_to_object_key.sql index 7a007736..30543845 100644 --- a/src/main/resources/db/migration/V9__migrate_playing_example_audio_file_url_to_object_key.sql +++ b/src/main/resources/db/migration/V9__migrate_playing_example_audio_file_url_to_object_key.sql @@ -4,11 +4,14 @@ ALTER TABLE playing_example -- 2. 기존 URL 값에서 S3 Object Key만 추출 UPDATE playing_example -SET audio_object_key = regexp_replace( - audio_object_key, - '^(https?://[^/]+/)?([^?#]*).*$', - '\2' - ) +SET audio_object_key = ltrim( + regexp_replace( + audio_object_key, + '^(https?://[^/]+/)?([^?#]*).*$', + '\2' + ), + '/' +) WHERE audio_object_key IS NOT NULL AND btrim(audio_object_key) <> ''; From 02690d5237b42479c1d1b042616e5626a3ee2f41 Mon Sep 17 00:00:00 2001 From: p1001q Date: Thu, 13 Aug 2026 09:13:05 +0900 Subject: [PATCH 8/8] =?UTF-8?q?docs:=20Presigned=20URL=20=EB=A7=8C?= =?UTF-8?q?=EB=A3=8C=20=EC=84=A4=EB=AA=85=20=EC=9D=BC=EB=B0=98=ED=99=94=20?= =?UTF-8?q?(#168)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/learning/dto/res/LearningStepDetailResponseDTO.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/mr/domain/learning/dto/res/LearningStepDetailResponseDTO.java b/src/main/java/com/mr/domain/learning/dto/res/LearningStepDetailResponseDTO.java index 2e367a06..72c8920a 100644 --- a/src/main/java/com/mr/domain/learning/dto/res/LearningStepDetailResponseDTO.java +++ b/src/main/java/com/mr/domain/learning/dto/res/LearningStepDetailResponseDTO.java @@ -71,7 +71,7 @@ public record ModelPerformance( String description, @Schema( - description = "비공개 S3 모범 연주 재생용 Presigned GET URL. 현재 설정 기준 10분 후 만료", + description = "비공개 S3 모범 연주 재생용 Presigned GET URL. 일정 시간 후 만료", example = "https://example-bucket.s3.ap-northeast-2.amazonaws.com/playing_example/triads_step1.mp3?X-Amz-Expires=600&X-Amz-Signature=example" ) String audioUrl,