Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ public ApiResponse<LearningCurriculumResponseDTO.CurriculumResultDTO> getCurricu

@Operation(
summary = "학습 단계별 조회 API",
description = "단계별 이론 설명, 연습 팁, 모범 연주 예시(있으면), 코드 예시를 조회합니다."
description = "단계별 이론 설명, 연습 팁, 모범 연주 예시(있으면 Presigned GET URL 포함), 코드 예시를 조회합니다."
)
@GetMapping("/{learningId}/steps/{learningStepId}")
public ApiResponse<LearningStepDetailResponseDTO.StepDetailResultDTO> getStepDetail(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,8 @@ public record StepDetailResultDTO(
List<ChordExampleItem> chordExamples
) {
public static StepDetailResultDTO of(Learning learning, LearningStep step,
PlayingExample playingExample, List<ChordExample> chordExamples) {
PlayingExample playingExample, String audioUrl,
List<ChordExample> chordExamples) {
return new StepDetailResultDTO(
learning.getId(),
step.getId(),
Expand All @@ -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()
);
}
Expand All @@ -69,17 +70,20 @@ 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. 일정 시간 후 만료",
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")
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
);
}
Expand Down
23 changes: 8 additions & 15 deletions src/main/java/com/mr/domain/learning/entity/PlayingExample.java
Original file line number Diff line number Diff line change
Expand Up @@ -35,70 +35,63 @@ 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;

// 오디오 파일
@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")
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;

@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;
this.playingSeconds = playingSeconds;
}

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)
.playingSeconds(playingSeconds)
.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;
Expand Down
27 changes: 17 additions & 10 deletions src/main/java/com/mr/domain/learning/service/LearningService.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -50,7 +52,6 @@
@Transactional(readOnly = true)
public class LearningService {

// 추천 학습 카드 최대 개수
private static final int RECOMMENDED_LEARNING_LIMIT = 2;
private static final List<LearningDifficulty> RECOMMENDATION_DIFFICULTY_ORDER =
List.of(LearningDifficulty.BEGINNER, LearningDifficulty.INTERMEDIATE, LearningDifficulty.ADVANCED);
Expand All @@ -60,11 +61,10 @@ 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;

// 학습 결과 저장
@Transactional
public LearningResultResponseDTO.SaveResultResultDTO saveResult(
Long userId,
Expand Down Expand Up @@ -147,7 +147,6 @@ public LearningProgressResponseDTO.ProgressResultDTO getLearningProgress(
return LearningProgressResponseDTO.ProgressResultDTO.of(learningId, progressRate);
}

// 학습 단계별 연습 실행 정보 조회
public LearningPracticeDataResponseDTO.PracticeDataResultDTO getPracticeData(
Long learningId,
Long learningStepId
Expand All @@ -161,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);
Expand All @@ -172,7 +170,6 @@ public LearningTheoryListResponseDTO.TheoryListResultDTO getTheoryList(Long user
return LearningTheoryListResponseDTO.TheoryListResultDTO.from(learnings);
}

// 학습 커리큘럼 조회
public LearningCurriculumResponseDTO.CurriculumResultDTO getCurriculum(Long userId, Long learningId) {
ensureUserExists(userId);

Expand Down Expand Up @@ -206,18 +203,29 @@ 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);

PlayingExample playingExample = playingExampleRepository.findByLearningStep_Id(learningStepId).orElse(null);
List<ChordExample> 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) 전체보기
public LearningAccompanimentListResponseDTO.AccompanimentListResultDTO getAccompanimentList(Long userId) {
ensureUserExists(userId);

Expand All @@ -229,7 +237,6 @@ public LearningAccompanimentListResponseDTO.AccompanimentListResultDTO getAccomp
return LearningAccompanimentListResponseDTO.AccompanimentListResultDTO.of(items);
}

// 학습 홈 조회
public LearningHomeResponseDTO.HomeResultDTO getHome(Long userId) {
ensureUserExists(userId);

Expand Down
6 changes: 4 additions & 2 deletions src/main/java/com/mr/global/file/s3/enums/S3FileType.java
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@
@RequiredArgsConstructor
public enum S3FileType {

RECORDING("recordings"),
BACKING_TRACK("backing-tracks");
RECORDING("recordings", true),
BACKING_TRACK("backing-tracks", true),
PLAYING_EXAMPLE("playing_example", false);

private final String prefix;
private final boolean ownerScoped;
}
40 changes: 37 additions & 3 deletions src/main/java/com/mr/global/file/s3/service/S3FileService.java
Comment thread
p1001q marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ public PresignedUrlUpload createPresignedUpload(
){

validateOwnerId(ownerId);
validateOwnerScopedFileType(fileType);
validateUploadCommand(command);

String normalizedContentType = ContentTypeUtils.normalize(command.contentType());
Expand Down Expand Up @@ -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);
Expand All @@ -145,12 +147,29 @@ public ValidatedFile validateUploadedFile(Long ownerId, S3FileType fileType, Str
);
}

// 파일 조회용 Presigned GET URL을 발급
public String createPresignedDownload(Long ownerId, S3FileType fileType, String objectKey
) {
validateOwnerId(ownerId);
validateOwnerScopedFileType(fileType);
validateObjectKey(ownerId, fileType, objectKey);

return presignDownload(objectKey);
}

/**
* 사용자 소유자가 없는 공용 콘텐츠의 조회용 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())
Expand All @@ -177,8 +196,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
);
Expand Down Expand Up @@ -242,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
) {
Expand Down Expand Up @@ -303,4 +327,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);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,6 @@ public String generate(
Instant now =
Instant.now();


String generatedFileName =
"%s_%s.%s".formatted(
TIME_FORMATTER.format(now),
Expand All @@ -72,6 +71,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()
Expand All @@ -84,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;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
-- 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 = ltrim(
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
$$;
Loading