diff --git a/.gitignore b/.gitignore index bc8cacfd..0e68d554 100644 --- a/.gitignore +++ b/.gitignore @@ -65,6 +65,7 @@ awscliv2.zip # Spring Boot local config src/main/resources/application-local.yml +src/main/resources/application.yml # copyPrivateConfig 태스크가 MR_config/local에서 복사해 오는 빌드 산출물 (실제 비밀키 포함 가능) src/main/resources/application.yml diff --git a/src/main/java/com/mr/domain/backingTrack/controller/BackingTrackController.java b/src/main/java/com/mr/domain/backingTrack/controller/BackingTrackController.java new file mode 100644 index 00000000..488406c0 --- /dev/null +++ b/src/main/java/com/mr/domain/backingTrack/controller/BackingTrackController.java @@ -0,0 +1,48 @@ +package com.mr.domain.backingTrack.controller; + +import com.mr.domain.backingTrack.dto.req.BackingTrackSaveRequestDTO; +import com.mr.domain.backingTrack.dto.res.BackingTrackCreateResponseDTO; +import com.mr.domain.backingTrack.dto.res.BackingTrackUpdateResponseDTO; +import com.mr.domain.backingTrack.service.BackingTrackService; +import com.mr.global.apipayload.ApiResponse; +import com.mr.global.security.principal.CustomUserDetails; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/backing-tracks") +public class BackingTrackController { + + private final BackingTrackService backingTrackService; + + @PostMapping + public ApiResponse createBackingTrack( + @AuthenticationPrincipal CustomUserDetails userDetails, + @Valid @RequestBody BackingTrackSaveRequestDTO.SaveDTO request + ) { + BackingTrackCreateResponseDTO.CreateResultDTO result = + backingTrackService.createBackingTrack(userDetails.getUserId(), request); + + return ApiResponse.onSuccess(result); + } + + @PutMapping("/{backingTrackId}") + public ApiResponse updateBackingTrack( + @AuthenticationPrincipal CustomUserDetails userDetails, + @PathVariable Long backingTrackId, + @Valid @RequestBody BackingTrackSaveRequestDTO.SaveDTO request + ) { + Long userId = userDetails.getUserId(); + BackingTrackUpdateResponseDTO.UpdateResultDTO result = + backingTrackService.updateBackingTrack(userId, backingTrackId, request); + return ApiResponse.onSuccess(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 new file mode 100644 index 00000000..af548306 --- /dev/null +++ b/src/main/java/com/mr/domain/backingTrack/dto/req/BackingTrackSaveRequestDTO.java @@ -0,0 +1,77 @@ +package com.mr.domain.backingTrack.dto.req; + +import com.mr.domain.backingTrack.entity.enums.AccessLevel; +import com.mr.domain.backingTrack.entity.enums.Level; +import com.mr.domain.backingTrack.entity.enums.ScaleType; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +import java.util.List; +import java.util.Map; + +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 = "오디오 파일 URL은 255자 이내여야 합니다.") + String audioFileUrl, + + @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 + ) {} +} diff --git a/src/main/java/com/mr/domain/backingTrack/dto/res/BackingTrackCreateResponseDTO.java b/src/main/java/com/mr/domain/backingTrack/dto/res/BackingTrackCreateResponseDTO.java new file mode 100644 index 00000000..ff26bc7c --- /dev/null +++ b/src/main/java/com/mr/domain/backingTrack/dto/res/BackingTrackCreateResponseDTO.java @@ -0,0 +1,21 @@ +package com.mr.domain.backingTrack.dto.res; + +import com.fasterxml.jackson.annotation.JsonFormat; + +import java.time.LocalDateTime; + +public class BackingTrackCreateResponseDTO { + + public record CreateResultDTO( + Long backingTrackId, + String title, + + // 날짜 포맷 + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss") + LocalDateTime createdAt + ) { + public static CreateResultDTO of(Long backingTrackId, String title, LocalDateTime createdAt) { + return new CreateResultDTO(backingTrackId, title, createdAt); + } + } +} diff --git a/src/main/java/com/mr/domain/backingTrack/dto/res/BackingTrackUpdateResponseDTO.java b/src/main/java/com/mr/domain/backingTrack/dto/res/BackingTrackUpdateResponseDTO.java new file mode 100644 index 00000000..1c54cce0 --- /dev/null +++ b/src/main/java/com/mr/domain/backingTrack/dto/res/BackingTrackUpdateResponseDTO.java @@ -0,0 +1,21 @@ +package com.mr.domain.backingTrack.dto.res; + +import com.fasterxml.jackson.annotation.JsonFormat; + +import java.time.LocalDateTime; + +public class BackingTrackUpdateResponseDTO { + + public record UpdateResultDTO( + Long backingTrackId, + String title, + + // 날짜 포맷 + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss") + LocalDateTime updatedAt + ) { + public static BackingTrackUpdateResponseDTO.UpdateResultDTO of(Long backingTrackId, String title, LocalDateTime updatedAt) { + return new BackingTrackUpdateResponseDTO.UpdateResultDTO(backingTrackId, title, updatedAt); + } + } +} 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 d8bf5a73..5eb8af1b 100644 --- a/src/main/java/com/mr/domain/backingTrack/entity/BackingTrack.java +++ b/src/main/java/com/mr/domain/backingTrack/entity/BackingTrack.java @@ -1,22 +1,33 @@ package com.mr.domain.backingTrack.entity; +import com.fasterxml.jackson.databind.JsonNode; import com.mr.domain.backingTrack.entity.enums.AccessLevel; import com.mr.domain.backingTrack.entity.enums.Level; import com.mr.domain.backingTrack.entity.enums.ScaleType; +import com.mr.domain.user.entity.User; import com.mr.global.entity.BaseTimeDeletedEntity; +import jakarta.persistence.CascadeType; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.EnumType; import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import jakarta.persistence.Index; import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToMany; import jakarta.persistence.Table; import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +import java.util.ArrayList; +import java.util.List; @Getter @Entity @@ -36,11 +47,12 @@ public class BackingTrack extends BaseTimeDeletedEntity { private Long id; // 유저 아이디 - @Column(name = "user_id", nullable = false) - private Long userId; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; // 학원 아이디 - @Column(name = "academy_id") + @Column(name = "academy_id", nullable = false) private Long academyId; // 트랙 이름 @@ -76,8 +88,9 @@ public class BackingTrack extends BaseTimeDeletedEntity { @Column(name = "audio_file_url", length = 255) private String audioFileUrl; - @Column(name = "midi_file_url", columnDefinition = "JSON") - private String midiFileUrl; + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "midi_data", columnDefinition = "jsonb") + private JsonNode midiData; // 재생 수 @Column(name = "play_count", nullable = false) @@ -93,13 +106,17 @@ public class BackingTrack extends BaseTimeDeletedEntity { @Column(name = "level", nullable = false) private Level level; + // 양방향 매핑 추가, 영속성 전이랑 고아 객체 제거 + @OneToMany(mappedBy = "backingTrack", cascade = CascadeType.ALL, orphanRemoval = true) + private List chordProgressions = new ArrayList<>(); + @Builder(access = lombok.AccessLevel.PRIVATE) - private BackingTrack(Long userId, Long academyId, String title, String genre, + private BackingTrack(User user, Long academyId, String title, String genre, String keySignature, ScaleType scaleType, String timeSignature, Integer bpm, Integer playtimeSec, String audioFileUrl, - String midiFileUrl, Integer playCount, + JsonNode midiData, Integer playCount, AccessLevel accessLevel, Level level) { - this.userId = userId; + this.user = user; this.academyId = academyId; this.title = title; this.genre = genre; @@ -109,18 +126,18 @@ private BackingTrack(Long userId, Long academyId, String title, String genre, this.bpm = bpm; this.playtimeSec = playtimeSec; this.audioFileUrl = audioFileUrl; - this.midiFileUrl = midiFileUrl; + this.midiData = midiData; this.playCount = playCount != null ? playCount : 0; this.accessLevel = accessLevel != null ? accessLevel : AccessLevel.PRIVATE; this.level = level != null ? level : Level.BASIC; } - public static BackingTrack create(Long userId, 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, - String midiFileUrl, AccessLevel accessLevel, Level level) { + JsonNode midiData, AccessLevel accessLevel, Level level) { return BackingTrack.builder() - .userId(userId) + .user(user) .academyId(academyId) .title(title) .genre(genre) @@ -130,7 +147,7 @@ public static BackingTrack create(Long userId, Long academyId, String title, Str .bpm(bpm) .playtimeSec(playtimeSec) .audioFileUrl(audioFileUrl) - .midiFileUrl(midiFileUrl) + .midiData(midiData) .playCount(0) .accessLevel(accessLevel) .level(level) @@ -139,7 +156,7 @@ public static BackingTrack create(Long userId, Long academyId, String title, Str public void updateTrackInfo(String title, String genre, String keySignature, ScaleType scaleType, String timeSignature, Integer bpm, - Integer playtimeSec, AccessLevel accessLevel, Level level) { + Integer playtimeSec, String audioFileUrl, AccessLevel accessLevel, Level level) { this.title = title; this.genre = genre; this.keySignature = keySignature; @@ -147,6 +164,7 @@ 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; } @@ -157,4 +175,9 @@ public void changeAccessLevel(AccessLevel accessLevel) { this.accessLevel = accessLevel; } } + + // 코드 진행 추가 + public void addChordProgression(ChordProgression chordProgression) { + this.chordProgressions.add(chordProgression); + } } diff --git a/src/main/java/com/mr/domain/backingTrack/entity/ChordProgression.java b/src/main/java/com/mr/domain/backingTrack/entity/ChordProgression.java index 402ca24d..1170b3e0 100644 --- a/src/main/java/com/mr/domain/backingTrack/entity/ChordProgression.java +++ b/src/main/java/com/mr/domain/backingTrack/entity/ChordProgression.java @@ -33,7 +33,7 @@ public class ChordProgression{ @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name = "chord_progression_id") - private Long chordProgressionId; + private Long id; // 백킹트랙 아이디 @ManyToOne(fetch = FetchType.LAZY) @@ -61,12 +61,17 @@ private ChordProgression(BackingTrack backingTrack, Integer sequenceNo, Integer } public static ChordProgression create(BackingTrack backingTrack, Integer sequenceNo, Integer measureNo, String chordName) { - return ChordProgression.builder() + ChordProgression chordProgression = ChordProgression.builder() .backingTrack(backingTrack) .sequenceNo(sequenceNo) .measureNo(measureNo) .chordName(chordName) .build(); + // 양방향 매핑 추가 하고 연결하게 + if (backingTrack != null) { + backingTrack.addChordProgression(chordProgression); + } + return chordProgression; } public void updateChordInfo(Integer sequenceNo, Integer measureNo, String chordName) { diff --git a/src/main/java/com/mr/domain/backingTrack/exception/BackingTrackErrorStatus.java b/src/main/java/com/mr/domain/backingTrack/exception/BackingTrackErrorStatus.java new file mode 100644 index 00000000..f5f4355a --- /dev/null +++ b/src/main/java/com/mr/domain/backingTrack/exception/BackingTrackErrorStatus.java @@ -0,0 +1,46 @@ +package com.mr.domain.backingTrack.exception; + +import com.mr.global.apipayload.code.BaseCode; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum BackingTrackErrorStatus implements BaseCode { + + // [400] Validation Errors + TITLE_REQUIRED(HttpStatus.BAD_REQUEST, "BACKING_TRACK_400_01", "백킹트랙 제목은 필수입니다."), + TITLE_TOO_LONG(HttpStatus.BAD_REQUEST, "BACKING_TRACK_400_02", "백킹트랙 제목은 50자 이내여야 합니다."), + GENRE_REQUIRED(HttpStatus.BAD_REQUEST, "BACKING_TRACK_400_03", "장르는 필수입니다."), + UNSUPPORTED_GENRE(HttpStatus.BAD_REQUEST, "BACKING_TRACK_400_04", "지원하지 않는 장르입니다."), + KEY_REQUIRED(HttpStatus.BAD_REQUEST, "BACKING_TRACK_400_05", "Key 정보는 필수입니다."), + INVALID_KEY(HttpStatus.BAD_REQUEST, "BACKING_TRACK_400_06", "Key 정보가 올바르지 않습니다."), + SCALE_REQUIRED(HttpStatus.BAD_REQUEST, "BACKING_TRACK_400_07", "조성은 필수입니다."), + INVALID_SCALE(HttpStatus.BAD_REQUEST, "BACKING_TRACK_400_08", "조성 값이 올바르지 않습니다."), + TIME_SIGNATURE_REQUIRED(HttpStatus.BAD_REQUEST, "BACKING_TRACK_400_09", "박자는 필수입니다."), + INVALID_TIME_SIGNATURE(HttpStatus.BAD_REQUEST, "BACKING_TRACK_400_10", "박자 형식이 올바르지 않습니다."), + BPM_REQUIRED(HttpStatus.BAD_REQUEST, "BACKING_TRACK_400_11", "BPM은 필수입니다."), + INVALID_BPM(HttpStatus.BAD_REQUEST, "BACKING_TRACK_400_12", "BPM 값이 올바르지 않습니다."), + PLAYTIME_REQUIRED(HttpStatus.BAD_REQUEST, "BACKING_TRACK_400_13", "재생 시간은 필수입니다."), + INVALID_PLAYTIME(HttpStatus.BAD_REQUEST, "BACKING_TRACK_400_14", "재생 시간은 60초 이상 600초 이하여야 합니다."), + ACCESS_LEVEL_REQUIRED(HttpStatus.BAD_REQUEST, "BACKING_TRACK_400_17", "공개 범위는 필수입니다."), + INVALID_ACCESS_LEVEL(HttpStatus.BAD_REQUEST, "BACKING_TRACK_400_18", "공개 범위 값이 올바르지 않습니다."), + LEVEL_REQUIRED(HttpStatus.BAD_REQUEST, "BACKING_TRACK_400_19", "난이도는 필수입니다."), + INVALID_LEVEL(HttpStatus.BAD_REQUEST, "BACKING_TRACK_400_20", "난이도 값이 올바르지 않습니다."), + DUPLICATE_CHORD_POSITION(HttpStatus.BAD_REQUEST, "BACKING_TRACK_400_21", "동일한 마디(measureNo)와 순서(sequenceNo)를 가진 코드 진행이 중복으로 존재합니다."), + INVALID_CHORD_SEQUENCE(HttpStatus.BAD_REQUEST, "BACKING_TRACK_400_22", "마디의 허용된 코드 순서 범위를 초과했습니다."), + + // [403] 권한 에러 + FORBIDDEN_UPDATE(HttpStatus.FORBIDDEN, "BACKING_TRACK_403_01", "백킹트랙 수정 권한이 없습니다."), + + // [404] 리소스 없음 + BACKING_TRACK_NOT_FOUND(HttpStatus.NOT_FOUND, "BACKING_TRACK_404_01", "존재하지 않는 백킹트랙입니다."), + + // [500] 서버 에러 + INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "BACKING_TRACK_500_01", "백킹트랙 생성 중 서버 오류가 발생했습니다."); + + private final HttpStatus status; + private final String code; + private final String message; +} diff --git a/src/main/java/com/mr/domain/backingTrack/repository/BackingTrackRepository.java b/src/main/java/com/mr/domain/backingTrack/repository/BackingTrackRepository.java new file mode 100644 index 00000000..145ba303 --- /dev/null +++ b/src/main/java/com/mr/domain/backingTrack/repository/BackingTrackRepository.java @@ -0,0 +1,11 @@ +package com.mr.domain.backingTrack.repository; + +import com.mr.domain.backingTrack.entity.BackingTrack; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface BackingTrackRepository extends JpaRepository { + + Optional findByIdAndDeletedAtIsNull(Long id); +} diff --git a/src/main/java/com/mr/domain/backingTrack/repository/ChordProgressionRepository.java b/src/main/java/com/mr/domain/backingTrack/repository/ChordProgressionRepository.java new file mode 100644 index 00000000..962cb9c3 --- /dev/null +++ b/src/main/java/com/mr/domain/backingTrack/repository/ChordProgressionRepository.java @@ -0,0 +1,7 @@ +package com.mr.domain.backingTrack.repository; + +import com.mr.domain.backingTrack.entity.ChordProgression; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ChordProgressionRepository extends JpaRepository { +} diff --git a/src/main/java/com/mr/domain/backingTrack/service/BackingTrackService.java b/src/main/java/com/mr/domain/backingTrack/service/BackingTrackService.java new file mode 100644 index 00000000..06c970c7 --- /dev/null +++ b/src/main/java/com/mr/domain/backingTrack/service/BackingTrackService.java @@ -0,0 +1,151 @@ +package com.mr.domain.backingTrack.service; + +import com.mr.domain.backingTrack.dto.req.BackingTrackSaveRequestDTO; +import com.mr.domain.backingTrack.dto.res.BackingTrackCreateResponseDTO; +import com.mr.domain.backingTrack.dto.res.BackingTrackUpdateResponseDTO; +import com.mr.domain.backingTrack.entity.BackingTrack; +import com.mr.domain.backingTrack.entity.ChordProgression; +import com.mr.domain.backingTrack.exception.BackingTrackErrorStatus; +import com.mr.domain.backingTrack.repository.BackingTrackRepository; +import com.mr.domain.user.entity.User; +import com.mr.domain.user.exception.UserErrorStatus; +import com.mr.domain.user.repository.UserRepository; +import com.mr.global.apipayload.exception.GeneralException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class BackingTrackService { + + private final BackingTrackRepository backingTrackRepository; + private final UserRepository userRepository; + + private static final Long TEMP_DEFAULT_ACADEMY_ID = 1L; // MVP 임시 학원 ID + + // 백킹트랙 생성 + @Transactional + public BackingTrackCreateResponseDTO.CreateResultDTO createBackingTrack( + Long userId, + BackingTrackSaveRequestDTO.SaveDTO request + ){ + User user = userRepository.findById(userId) + .orElseThrow(()-> new GeneralException(UserErrorStatus.USER_NOT_FOUND)); + + validateChordDuplicates(request.chordProgression()); + validateChordSequence(request.timeSignature(), request.chordProgression()); + + BackingTrack backingTrack = BackingTrack.create( + user, + TEMP_DEFAULT_ACADEMY_ID, + request.title(), + request.genre(), + request.keySignature(), + request.scaleType(), + request.timeSignature(), + request.bpm(), + request.playtimeSec(), + request.audioFileUrl(), + null, // midi 데이터는 생성 시 null로 초기화 (mvp후 별도 API나 이벤트로 업데이트) + request.accessLevel(), + request.level() + ); + + request.chordProgression().forEach(chordDTO -> { + ChordProgression chord = ChordProgression.create( + backingTrack, + chordDTO.sequenceNo(), + chordDTO.measureNo(), + chordDTO.chordName() + ); + }); + + BackingTrack savedTrack = backingTrackRepository.save(backingTrack); + + return BackingTrackCreateResponseDTO.CreateResultDTO.of( + savedTrack.getId(), + savedTrack.getTitle(), + savedTrack.getCreatedAt() + ); + } + + // 백킹트랙 수정 + @Transactional + public BackingTrackUpdateResponseDTO.UpdateResultDTO updateBackingTrack( + Long userId, + Long backingTrackId, + BackingTrackSaveRequestDTO.SaveDTO request + ) { + validateChordDuplicates(request.chordProgression()); + validateChordSequence(request.timeSignature(), request.chordProgression()); + + BackingTrack backingTrack = backingTrackRepository.findByIdAndDeletedAtIsNull(backingTrackId) + .orElseThrow(() -> new GeneralException(BackingTrackErrorStatus.BACKING_TRACK_NOT_FOUND)); + + // 수정 권한 검증 (작성자 본인 확인) + if (!backingTrack.getUser().getUserId().equals(userId)) { + throw new GeneralException(BackingTrackErrorStatus.FORBIDDEN_UPDATE); + } + + // 엔티티 데이터 업데이트 + backingTrack.updateTrackInfo( + request.title(), + request.genre(), + request.keySignature(), + request.scaleType(), + request.timeSignature(), + request.bpm(), + request.playtimeSec(), + request.audioFileUrl(), + request.accessLevel(), + request.level() + ); + + // 기존 코드 진행 비우고 새로운 리스트로 교체 + backingTrack.getChordProgressions().clear(); + + backingTrackRepository.flush(); // 완전히 비우도록 + + request.chordProgression().forEach(chordDTO -> { + ChordProgression chord = ChordProgression.create( + backingTrack, + chordDTO.sequenceNo(), + chordDTO.measureNo(), + chordDTO.chordName() + ); + }); + + return BackingTrackUpdateResponseDTO.UpdateResultDTO.of( + backingTrack.getId(), + backingTrack.getTitle(), + backingTrack.getUpdatedAt() + ); + } + + private void validateChordDuplicates(List chordProgressions) { + boolean hasDuplicates = chordProgressions.stream() + .map(chord -> chord.measureNo() + "-" + chord.sequenceNo()) + .distinct() + .count() != chordProgressions.size(); + + if (hasDuplicates) { + throw new GeneralException(BackingTrackErrorStatus.DUPLICATE_CHORD_POSITION); + } + } + + private void validateChordSequence(String timeSignature, List chordProgressions) { + // 4/4 박이라면 분자인 4를 추출 + int maxSequencePerMeasure = Integer.parseInt(timeSignature.split("/")[0]); + + for (BackingTrackSaveRequestDTO.ChordProgressionDTO chord : chordProgressions) { + // 만약 4/4박자인데 sequenceNo가 5 이상으로 들어오면 예외 발생 + if (chord.sequenceNo() > maxSequencePerMeasure) { + throw new GeneralException(BackingTrackErrorStatus.INVALID_CHORD_SEQUENCE); + } + } + } +} diff --git a/src/main/java/com/mr/domain/learning/dto/req/LearningProgressUpdateRequestDTO.java b/src/main/java/com/mr/domain/learning/dto/req/LearningProgressUpdateRequestDTO.java new file mode 100644 index 00000000..1c12aaf8 --- /dev/null +++ b/src/main/java/com/mr/domain/learning/dto/req/LearningProgressUpdateRequestDTO.java @@ -0,0 +1,4 @@ +package com.mr.domain.learning.dto.req; + +public class LearningProgressUpdateRequestDTO { +} diff --git a/src/main/java/com/mr/domain/learning/dto/res/LearningProgressUpdateResponseDTO.java b/src/main/java/com/mr/domain/learning/dto/res/LearningProgressUpdateResponseDTO.java new file mode 100644 index 00000000..7c692997 --- /dev/null +++ b/src/main/java/com/mr/domain/learning/dto/res/LearningProgressUpdateResponseDTO.java @@ -0,0 +1,4 @@ +package com.mr.domain.learning.dto.res; + +public class LearningProgressUpdateResponseDTO { +} diff --git a/src/test/java/com/mr/domain/backingTrack/controller/BackingTrackControllerTest.java b/src/test/java/com/mr/domain/backingTrack/controller/BackingTrackControllerTest.java new file mode 100644 index 00000000..c80df6d5 --- /dev/null +++ b/src/test/java/com/mr/domain/backingTrack/controller/BackingTrackControllerTest.java @@ -0,0 +1,139 @@ +package com.mr.domain.backingTrack.controller; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.willThrow; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.mr.domain.backingTrack.dto.req.BackingTrackSaveRequestDTO; +import com.mr.domain.backingTrack.dto.res.BackingTrackCreateResponseDTO; +import com.mr.domain.backingTrack.dto.res.BackingTrackUpdateResponseDTO; +import com.mr.domain.backingTrack.entity.enums.AccessLevel; +import com.mr.domain.backingTrack.entity.enums.Level; +import com.mr.domain.backingTrack.entity.enums.ScaleType; +import com.mr.domain.backingTrack.exception.BackingTrackErrorStatus; +import com.mr.domain.backingTrack.service.BackingTrackService; +import com.mr.domain.user.entity.enums.UserRole; +import com.mr.global.apipayload.exception.GeneralException; +import com.mr.global.apipayload.handler.GlobalExceptionHandler; +import com.mr.global.security.principal.CustomUserDetails; +import java.time.LocalDateTime; +import java.util.List; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.core.MethodParameter; +import org.springframework.http.MediaType; +import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder; +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.method.annotation.AuthenticationPrincipalArgumentResolver; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +@ExtendWith(MockitoExtension.class) +class BackingTrackControllerTest { + + private MockMvc mockMvc; + private ObjectMapper objectMapper; + + @Mock + private BackingTrackService backingTrackService; + + private BackingTrackSaveRequestDTO.SaveDTO validRequest; + + @BeforeEach + void setUp() { + objectMapper = Jackson2ObjectMapperBuilder.json() + .findModulesViaServiceLoader(true) + .build(); + + // 💡 Security Principal을 읽어오도록 ArgumentResolver 등록 + mockMvc = MockMvcBuilders.standaloneSetup(new BackingTrackController(backingTrackService)) + .setControllerAdvice(new GlobalExceptionHandler()) + .setCustomArgumentResolvers(new AuthenticationPrincipalArgumentResolver()) + .setMessageConverters(new MappingJackson2HttpMessageConverter(objectMapper)) + .build(); + + CustomUserDetails userDetails = new CustomUserDetails(1L, UserRole.ROLE_STUDENT); + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities()) + ); + + validRequest = new BackingTrackSaveRequestDTO.SaveDTO( + "테스트 백킹트랙", "Jazz", "C", ScaleType.MAJOR, "4/4", 120, 180, "http://audio.url", + AccessLevel.PUBLIC, Level.BASIC, + List.of(new BackingTrackSaveRequestDTO.ChordProgressionDTO(1, 1, "CM7")) + ); + } + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + } + + @Test + @DisplayName("POST /api/backing-tracks - 백킹트랙 생성 성공") + void createBackingTrack_success() throws Exception { + BackingTrackCreateResponseDTO.CreateResultDTO response = new BackingTrackCreateResponseDTO.CreateResultDTO( + 1L, "테스트 백킹트랙", LocalDateTime.of(2026, 7, 25, 10, 0) + ); + given(backingTrackService.createBackingTrack(anyLong(), any())).willReturn(response); + + mockMvc.perform(post("/api/backing-tracks") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(validRequest))) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.isSuccess").value(true)) + .andExpect(jsonPath("$.data.backingTrackId").value(1L)) + .andExpect(jsonPath("$.data.title").value("테스트 백킹트랙")); + } + + @Test + @DisplayName("PUT /api/backing-tracks/{id} - 백킹트랙 수정 성공") + void updateBackingTrack_success() throws Exception { + BackingTrackUpdateResponseDTO.UpdateResultDTO response = new BackingTrackUpdateResponseDTO.UpdateResultDTO( + 1L, "테스트 백킹트랙", LocalDateTime.of(2026, 7, 25, 12, 0) + ); + given(backingTrackService.updateBackingTrack(anyLong(), anyLong(), any())).willReturn(response); + + mockMvc.perform(put("/api/backing-tracks/{backingTrackId}", 1L) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(validRequest))) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.isSuccess").value(true)) + .andExpect(jsonPath("$.data.backingTrackId").value(1L)); + } + + @Test + @DisplayName("PUT /api/backing-tracks/{id} - 권한이 없는 사용자면 403") + void updateBackingTrack_forbidden() throws Exception { + willThrow(new GeneralException(BackingTrackErrorStatus.FORBIDDEN_UPDATE)) + .given(backingTrackService).updateBackingTrack(anyLong(), anyLong(), any()); + + mockMvc.perform(put("/api/backing-tracks/{backingTrackId}", 1L) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(validRequest))) + .andDo(print()) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.isSuccess").value(false)) + .andExpect(jsonPath("$.code").value(BackingTrackErrorStatus.FORBIDDEN_UPDATE.getCode())); + } +} \ No newline at end of file diff --git a/src/test/java/com/mr/domain/backingTrack/service/BackingTrackServiceTest.java b/src/test/java/com/mr/domain/backingTrack/service/BackingTrackServiceTest.java new file mode 100644 index 00000000..3c3f2cf2 --- /dev/null +++ b/src/test/java/com/mr/domain/backingTrack/service/BackingTrackServiceTest.java @@ -0,0 +1,174 @@ +package com.mr.domain.backingTrack.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +import com.mr.domain.backingTrack.dto.req.BackingTrackSaveRequestDTO; +import com.mr.domain.backingTrack.dto.res.BackingTrackCreateResponseDTO; +import com.mr.domain.backingTrack.dto.res.BackingTrackUpdateResponseDTO; +import com.mr.domain.backingTrack.entity.BackingTrack; +import com.mr.domain.backingTrack.entity.enums.AccessLevel; +import com.mr.domain.backingTrack.entity.enums.Level; +import com.mr.domain.backingTrack.entity.enums.ScaleType; +import com.mr.domain.backingTrack.exception.BackingTrackErrorStatus; +import com.mr.domain.backingTrack.repository.BackingTrackRepository; +import com.mr.domain.user.entity.User; +import com.mr.domain.user.repository.UserRepository; +import com.mr.global.apipayload.exception.GeneralException; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class BackingTrackServiceTest { + + @Mock + private BackingTrackRepository backingTrackRepository; + + @Mock + private UserRepository userRepository; + + private BackingTrackService backingTrackService; + + @BeforeEach + void setUp() { + backingTrackService = new BackingTrackService(backingTrackRepository, userRepository); + } + + private User createMockUser(Long userId) { + User mockUser = mock(User.class); + lenient().when(mockUser.getUserId()).thenReturn(userId); + return mockUser; + } + + private BackingTrackSaveRequestDTO.SaveDTO createValidRequest() { + return new BackingTrackSaveRequestDTO.SaveDTO( + "새로운 트랙", "Jazz", "C", ScaleType.MAJOR, "4/4", 120, 180, "http://audio.url", + AccessLevel.PUBLIC, Level.BASIC, + // 💡 확실하게 1개만 들어가도록 설정 + List.of(new BackingTrackSaveRequestDTO.ChordProgressionDTO(1, 1, "CM7")) + ); + } + + @Test + @DisplayName("createBackingTrack - 정상적으로 백킹트랙을 생성한다") + void createBackingTrack_success() { + // given + Long userId = 1L; + User user = createMockUser(userId); + BackingTrackSaveRequestDTO.SaveDTO request = createValidRequest(); + + given(userRepository.findById(userId)).willReturn(Optional.of(user)); + + given(backingTrackRepository.save(any(BackingTrack.class))).willAnswer(invocation -> { + BackingTrack saved = invocation.getArgument(0); + ReflectionTestUtils.setField(saved, "id", 100L); + return saved; + }); + + // when + BackingTrackCreateResponseDTO.CreateResultDTO response = backingTrackService.createBackingTrack(userId, request); + + // then + ArgumentCaptor captor = ArgumentCaptor.forClass(BackingTrack.class); + + // 💡 verify 구문 안에 captor.capture()가 들어가도록 수정 + verify(backingTrackRepository).save(captor.capture()); + + BackingTrack capturedTrack = captor.getValue(); + assertThat(capturedTrack.getTitle()).isEqualTo("새로운 트랙"); + assertThat(capturedTrack.getChordProgressions()).hasSize(1); + + assertThat(response.backingTrackId()).isEqualTo(100L); + assertThat(response.title()).isEqualTo("새로운 트랙"); + } + + @Test + @DisplayName("updateBackingTrack - 작성자 본인이면 정상적으로 업데이트한다") + void updateBackingTrack_success() { + // given + Long userId = 1L; + Long trackId = 100L; + User user = createMockUser(userId); + BackingTrackSaveRequestDTO.SaveDTO request = createValidRequest(); + + BackingTrack existingTrack = BackingTrack.create( + user, 1L, "기존 제목", "Pop", "G", ScaleType.MAJOR, "4/4", 100, 200, null, null, AccessLevel.PRIVATE, Level.BASIC + ); + ReflectionTestUtils.setField(existingTrack, "id", trackId); + + given(backingTrackRepository.findByIdAndDeletedAtIsNull(trackId)).willReturn(Optional.of(existingTrack)); + + // when + BackingTrackUpdateResponseDTO.UpdateResultDTO response = backingTrackService.updateBackingTrack(userId, trackId, request); + + // then + assertThat(existingTrack.getTitle()).isEqualTo("새로운 트랙"); + + assertThat(existingTrack.getChordProgressions()).hasSize(1); + + assertThat(response.backingTrackId()).isEqualTo(trackId); + assertThat(response.title()).isEqualTo("새로운 트랙"); + } + + @Test + @DisplayName("updateBackingTrack - 작성자가 다르면 예외가 발생한다") + void updateBackingTrack_fail_forbidden() { + Long requesterId = 2L; + Long ownerId = 1L; + Long trackId = 100L; + + User owner = createMockUser(ownerId); + BackingTrackSaveRequestDTO.SaveDTO request = createValidRequest(); + + BackingTrack existingTrack = BackingTrack.create( + owner, 1L, "기존 제목", "Pop", "G", ScaleType.MAJOR, "4/4", 100, 200, null, null, AccessLevel.PRIVATE, Level.BASIC + ); + ReflectionTestUtils.setField(existingTrack, "id", trackId); + + given(backingTrackRepository.findByIdAndDeletedAtIsNull(trackId)).willReturn(Optional.of(existingTrack)); + + GeneralException exception = assertThrows(GeneralException.class, + () -> backingTrackService.updateBackingTrack(requesterId, trackId, request) + ); + + assertThat(exception.getErrorReason().code()) + .isEqualTo(BackingTrackErrorStatus.FORBIDDEN_UPDATE.getCode()); + } + + @Test + @DisplayName("validateChordDuplicates - 중복된 코드 위치가 있으면 예외가 발생한다") + void validateChordDuplicates_fail() { + Long userId = 1L; + User user = createMockUser(userId); + + BackingTrackSaveRequestDTO.SaveDTO invalidRequest = new BackingTrackSaveRequestDTO.SaveDTO( + "새로운 트랙", "Jazz", "C", ScaleType.MAJOR, "4/4", 120, 180, null, AccessLevel.PUBLIC, Level.BASIC, + List.of( + new BackingTrackSaveRequestDTO.ChordProgressionDTO(1, 1, "CM7"), + new BackingTrackSaveRequestDTO.ChordProgressionDTO(1, 1, "Am7") // 중복 + ) + ); + + given(userRepository.findById(userId)).willReturn(Optional.of(user)); + + GeneralException exception = assertThrows(GeneralException.class, + () -> backingTrackService.createBackingTrack(userId, invalidRequest) + ); + + assertThat(exception.getErrorReason().code()) + .isEqualTo(BackingTrackErrorStatus.DUPLICATE_CHORD_POSITION.getCode()); + } +} \ No newline at end of file