From 36cf50701d28ef6be9796f1f7fa15a9b81c1736c Mon Sep 17 00:00:00 2001 From: onlyoneprivate Date: Tue, 14 Jul 2026 23:41:07 +0900 Subject: [PATCH 01/10] =?UTF-8?q?refactor:=20MIDI=20=EC=9D=B4=EB=B2=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=A0=80=EC=9E=A5=20=EA=B5=AC=EC=A1=B0=EB=A5=BC=20?= =?UTF-8?q?Playing=20JSONB=20=EB=B0=A9=EC=8B=9D=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=20(#21)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../{MidiEvent.java => MidiEventData.java} | 62 ++++--------------- .../com/mr/domain/playing/entity/Playing.java | 46 +++++++++++++- .../playing/exception/PlayingErrorStatus.java | 2 + 3 files changed, 60 insertions(+), 50 deletions(-) rename src/main/java/com/mr/domain/playing/entity/{MidiEvent.java => MidiEventData.java} (51%) diff --git a/src/main/java/com/mr/domain/playing/entity/MidiEvent.java b/src/main/java/com/mr/domain/playing/entity/MidiEventData.java similarity index 51% rename from src/main/java/com/mr/domain/playing/entity/MidiEvent.java rename to src/main/java/com/mr/domain/playing/entity/MidiEventData.java index 9494e32a..aafb1f75 100644 --- a/src/main/java/com/mr/domain/playing/entity/MidiEvent.java +++ b/src/main/java/com/mr/domain/playing/entity/MidiEventData.java @@ -3,67 +3,43 @@ import com.mr.domain.playing.entity.enums.MidiType; import com.mr.domain.playing.exception.PlayingErrorStatus; import com.mr.global.apipayload.exception.GeneralException; -import jakarta.persistence.*; import lombok.AccessLevel; -import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; -@Entity @Getter -@Table( - name = "midi_event", - indexes = { - @Index(name = "idx_midi_event_playing_timestamp", - columnList = "playing_id, timestamp_ms") - } -) @NoArgsConstructor(access = AccessLevel.PROTECTED) -public class MidiEvent { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - @Column(name = "midi_event_id") - private Long id; - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "playing_id", nullable = false) - private Playing playing; +public class MidiEventData { - @Enumerated(EnumType.STRING) - @Column(name = "type", nullable = false) private MidiType type; - - @Column(name = "pitch", nullable = false) private Integer pitch; - - @Column(name = "velocity", nullable = false) private Integer velocity; - - @Column(name = "timestamp_ms", nullable = false) private Long timestampMs; - @Builder(access = AccessLevel.PRIVATE) - private MidiEvent( - Playing playing, MidiType type, Integer pitch, Integer velocity, Long timestampMs + private MidiEventData( + MidiType type, + Integer pitch, + Integer velocity, + Long timestampMs ) { - validatePlaying(playing); validateMidiType(type); validatePitch(pitch); validateVelocity(velocity); validateTimestampMs(timestampMs); - this.playing = playing; this.type = type; this.pitch = pitch; this.velocity = velocity; this.timestampMs = timestampMs; } - private static void validatePlaying(Playing playing) { - if (playing == null) { - throw new GeneralException(PlayingErrorStatus.MISSING_PLAYING); - } + public static MidiEventData create( + MidiType type, + Integer pitch, + Integer velocity, + Long timestampMs + ) { + return new MidiEventData(type, pitch, velocity, timestampMs); } private static void validateMidiType(MidiType type) { @@ -89,16 +65,4 @@ private static void validateTimestampMs(Long timestampMs) { throw new GeneralException(PlayingErrorStatus.INVALID_TIMESTAMP); } } - - public static MidiEvent create( - Playing playing, MidiType type, Integer pitch, Integer velocity, Long timestampMs - ) { - return MidiEvent.builder() - .playing(playing) - .type(type) - .pitch(pitch) - .velocity(velocity) - .timestampMs(timestampMs) - .build(); - } } diff --git a/src/main/java/com/mr/domain/playing/entity/Playing.java b/src/main/java/com/mr/domain/playing/entity/Playing.java index f8cd4e68..b8a475ad 100644 --- a/src/main/java/com/mr/domain/playing/entity/Playing.java +++ b/src/main/java/com/mr/domain/playing/entity/Playing.java @@ -5,13 +5,26 @@ import com.mr.domain.playing.exception.PlayingErrorStatus; import com.mr.global.apipayload.exception.GeneralException; import com.mr.global.entity.BaseCreatedDeletedEntity; -import jakarta.persistence.*; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; import lombok.AccessLevel; import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Objects; @Entity @Table(name = "playing") @@ -67,6 +80,10 @@ public class Playing extends BaseCreatedDeletedEntity { @Column(name = "is_public", nullable = false) private boolean isPublic; + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "midi_data", columnDefinition = "jsonb", nullable = false) + private List midiData = new ArrayList<>(); + @Builder(access = AccessLevel.PRIVATE) private Playing( Long userId, @@ -88,6 +105,7 @@ private Playing( this.bpm = resolveBpm(bpm); this.metronomeEnabled = metronomeEnabled; this.isPublic = isPublic; + this.midiData = new ArrayList<>(); } private static void validateUserId(Long userId) { @@ -142,4 +160,30 @@ public static Playing createBackingTrack( .isPublic(false) .build(); } + + public void saveMidiData(List midiData) { + validateMidiData(midiData); + + this.midiData = new ArrayList<>(midiData); + this.midiData.sort(Comparator.comparingLong(MidiEventData::getTimestampMs)); + + } + + private static void validateMidiData(List midiData) { + if (midiData == null || midiData.isEmpty()) { + throw new GeneralException(PlayingErrorStatus.EMPTY_MIDI_EVENTS); + } + + if (midiData.stream().anyMatch(Objects::isNull)) { + throw new GeneralException(PlayingErrorStatus.INVALID_MIDI_EVENT); + } + } + + public List getMidiData() { + + if (this.midiData == null) { + return List.of(); + } + return List.copyOf(this.midiData); + } } diff --git a/src/main/java/com/mr/domain/playing/exception/PlayingErrorStatus.java b/src/main/java/com/mr/domain/playing/exception/PlayingErrorStatus.java index 5ac33811..347d6397 100644 --- a/src/main/java/com/mr/domain/playing/exception/PlayingErrorStatus.java +++ b/src/main/java/com/mr/domain/playing/exception/PlayingErrorStatus.java @@ -17,6 +17,8 @@ public enum PlayingErrorStatus implements BaseCode { INVALID_PITCH_RANGE(HttpStatus.BAD_REQUEST, "PLAYING_400_06", "피치 값은 0~127 사이의 값이어야 합니다."), INVALID_VELOCITY_RANGE(HttpStatus.BAD_REQUEST, "PLAYING_400_07", "강도는 0~127 사이의 값이어야 합니다."), INVALID_TIMESTAMP(HttpStatus.BAD_REQUEST, "PLAYING_400_08", "MIDI 이벤트 타임스탬프는 0 이상이어야 합니다."), + EMPTY_MIDI_EVENTS(HttpStatus.BAD_REQUEST, "PLAYING_400_09", "저장할 MIDI 이벤트가 없습니다."), + INVALID_MIDI_EVENT(HttpStatus.BAD_REQUEST,"PLAYING_400_10", "MIDI 이벤트 목록에 유효하지 않은 값이 포함되어 있습니다."), ; private final HttpStatus status; From af1a11b229992d5f4272dceddfb565743e122cea Mon Sep 17 00:00:00 2001 From: onlyoneprivate Date: Tue, 14 Jul 2026 23:42:11 +0900 Subject: [PATCH 02/10] =?UTF-8?q?refactor:=20Playing=20=EC=97=94=ED=8B=B0?= =?UTF-8?q?=ED=8B=B0=EC=9D=98=20=EB=A9=94=ED=8A=B8=EB=A1=9C=EB=86=88=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=ED=95=84=EB=93=9C=20=EC=A0=9C=EA=B1=B0=20?= =?UTF-8?q?(#21)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/mr/domain/playing/entity/Playing.java | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/src/main/java/com/mr/domain/playing/entity/Playing.java b/src/main/java/com/mr/domain/playing/entity/Playing.java index b8a475ad..93f36069 100644 --- a/src/main/java/com/mr/domain/playing/entity/Playing.java +++ b/src/main/java/com/mr/domain/playing/entity/Playing.java @@ -60,9 +60,6 @@ public class Playing extends BaseCreatedDeletedEntity { @Column(name = "bpm", nullable = false) private Integer bpm; - @Column(name = "metronome_enabled", nullable = false) - private boolean metronomeEnabled; - // 실제 연주가 시작된 시간 @Column(name = "started_at") private LocalDateTime startedAt; @@ -91,7 +88,6 @@ private Playing( PlayingMode mode, PlayingStatus status, Integer bpm, - boolean metronomeEnabled, boolean isPublic ) { @@ -103,7 +99,6 @@ private Playing( this.mode = mode; this.status = status; this.bpm = resolveBpm(bpm); - this.metronomeEnabled = metronomeEnabled; this.isPublic = isPublic; this.midiData = new ArrayList<>(); } @@ -133,22 +128,20 @@ private static int resolveBpm(Integer bpm) { // 자유 연주 생성 public static Playing createFreePlay( - Long userId, Integer bpm, boolean metronomeEnabled + Long userId, Integer bpm ) { return Playing.builder() .userId(userId) .mode(PlayingMode.FREE_PLAY) .status(PlayingStatus.READY) .bpm(bpm) - .metronomeEnabled(metronomeEnabled) .isPublic(false) .build(); } // 백킹트랙 연주 생성 public static Playing createBackingTrack( - Long userId, Long backingTrackId, - Integer bpm, boolean metronomeEnabled + Long userId, Long backingTrackId, Integer bpm ) { return Playing.builder() .userId(userId) @@ -156,7 +149,6 @@ public static Playing createBackingTrack( .mode(PlayingMode.BACKING_TRACK) .status(PlayingStatus.READY) .bpm(bpm) - .metronomeEnabled(metronomeEnabled) .isPublic(false) .build(); } From 8b3808be3ffb21d1114625c0d752f458ce2234a9 Mon Sep 17 00:00:00 2001 From: onlyoneprivate Date: Wed, 15 Jul 2026 00:49:42 +0900 Subject: [PATCH 03/10] =?UTF-8?q?fix:=20MIDI=20=EB=8D=B0=EC=9D=B4=ED=84=B0?= =?UTF-8?q?=20=EC=97=AD=EC=A7=81=EB=A0=AC=ED=99=94=20=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?=EC=9A=B0=ED=9A=8C=20=EB=AC=B8=EC=A0=9C=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?(#21)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/playing/entity/MidiEventData.java | 26 ++++++++++--------- 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/src/main/java/com/mr/domain/playing/entity/MidiEventData.java b/src/main/java/com/mr/domain/playing/entity/MidiEventData.java index aafb1f75..5b5f7e6e 100644 --- a/src/main/java/com/mr/domain/playing/entity/MidiEventData.java +++ b/src/main/java/com/mr/domain/playing/entity/MidiEventData.java @@ -1,20 +1,21 @@ package com.mr.domain.playing.entity; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; import com.mr.domain.playing.entity.enums.MidiType; import com.mr.domain.playing.exception.PlayingErrorStatus; import com.mr.global.apipayload.exception.GeneralException; -import lombok.AccessLevel; import lombok.Getter; -import lombok.NoArgsConstructor; @Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) public class MidiEventData { - private MidiType type; - private Integer pitch; - private Integer velocity; - private Long timestampMs; + private final MidiType type; + private final Integer pitch; + private final Integer velocity; + + @JsonProperty("timestamp_ms") + private final Long timestampMs; private MidiEventData( MidiType type, @@ -33,11 +34,12 @@ private MidiEventData( this.timestampMs = timestampMs; } - public static MidiEventData create( - MidiType type, - Integer pitch, - Integer velocity, - Long timestampMs + @JsonCreator + public static MidiEventData of( + @JsonProperty("type") MidiType type, + @JsonProperty("pitch") Integer pitch, + @JsonProperty("velocity") Integer velocity, + @JsonProperty("timestamp_ms") Long timestampMs ) { return new MidiEventData(type, pitch, velocity, timestampMs); } From 119a6df1f77566ec86d64f8e20c715041101d8f8 Mon Sep 17 00:00:00 2001 From: onlyoneprivate Date: Fri, 24 Jul 2026 13:28:31 +0200 Subject: [PATCH 04/10] =?UTF-8?q?refactor:=20=EC=97=B0=EC=A3=BC=20?= =?UTF-8?q?=EC=99=84=EB=A3=8C=20=EB=B0=8F=20MIDI=20=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20=EA=B2=80=EC=A6=9D=20=EB=A1=9C=EC=A7=81=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0=20(#21)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/mr/domain/playing/entity/Playing.java | 112 +++++++++++++++--- .../playing/exception/PlayingErrorStatus.java | 9 ++ 2 files changed, 104 insertions(+), 17 deletions(-) diff --git a/src/main/java/com/mr/domain/playing/entity/Playing.java b/src/main/java/com/mr/domain/playing/entity/Playing.java index 93f36069..480e7354 100644 --- a/src/main/java/com/mr/domain/playing/entity/Playing.java +++ b/src/main/java/com/mr/domain/playing/entity/Playing.java @@ -13,6 +13,7 @@ import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import jakarta.persistence.Table; +import jakarta.persistence.Version; import lombok.AccessLevel; import lombok.Builder; import lombok.Getter; @@ -20,6 +21,7 @@ import org.hibernate.annotations.JdbcTypeCode; import org.hibernate.type.SqlTypes; +import java.time.Duration; import java.time.LocalDateTime; import java.util.ArrayList; import java.util.Comparator; @@ -35,12 +37,21 @@ public class Playing extends BaseCreatedDeletedEntity { private static final int DEFAULT_BPM = 120; private static final int MIN_BPM = 50; private static final int MAX_BPM = 200; + private static final int MAX_DURATION_SEC = 600; + private static final long MAX_DURATION_MS = MAX_DURATION_SEC * 1000L; + + private static final long MIDI_TIMESTAMP_TOLERANCE_MS = 500L; // 실제 연주 시간과 MIDI 이벤트 수집 종료 시점 간 허용 오차 + private static final int MAX_MIDI_EVENT_COUNT = 100_000; @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name = "playing_id") private Long id; + @Version + @Column(name = "version", nullable = false) + private Long version; + // TODO: 유저 ID 연관 관계 설정 예정 @Column(name = "user_id", nullable = false) private Long userId; @@ -92,6 +103,8 @@ private Playing( ) { validateUserId(userId); + validateMode(mode); + validateStatus(status); validateBackingTrack(mode, backingTrackId); this.userId = userId; @@ -111,11 +124,29 @@ private static void validateUserId(Long userId) { private static void validateBackingTrack( PlayingMode mode, Long backingTrackId) { - if (mode == PlayingMode.BACKING_TRACK && backingTrackId == null) { + + // 자유 연주 미지원 (BACKING_TRACK이 포함된 연주만 가능) + if (mode != PlayingMode.BACKING_TRACK) { + throw new GeneralException(PlayingErrorStatus.UNSUPPORTED_PLAYING_MODE); + } + + if (backingTrackId == null) { throw new GeneralException(PlayingErrorStatus.MISSING_BACKING_TRACK_ID); } } + private static void validateMode(PlayingMode mode) { + if (mode == null) { + throw new GeneralException(PlayingErrorStatus.MISSING_PLAYING_MODE); + } + } + + private static void validateStatus(PlayingStatus status) { + if (status == null) { + throw new GeneralException(PlayingErrorStatus.MISSING_PLAYING_STATUS); + } + } + private static int resolveBpm(Integer bpm) { int resolvedBpm = bpm == null ? DEFAULT_BPM : bpm; @@ -126,19 +157,6 @@ private static int resolveBpm(Integer bpm) { return resolvedBpm; } - // 자유 연주 생성 - public static Playing createFreePlay( - Long userId, Integer bpm - ) { - return Playing.builder() - .userId(userId) - .mode(PlayingMode.FREE_PLAY) - .status(PlayingStatus.READY) - .bpm(bpm) - .isPublic(false) - .build(); - } - // 백킹트랙 연주 생성 public static Playing createBackingTrack( Long userId, Long backingTrackId, Integer bpm @@ -153,12 +171,43 @@ public static Playing createBackingTrack( .build(); } - public void saveMidiData(List midiData) { + // 연주 완료 시 전체 MIDI 데이터를 저장하고 완료 상태로 전환 + public void completeWithMidiData( + List midiData + ) { + validateCompletableStatus(); validateMidiData(midiData); - this.midiData = new ArrayList<>(midiData); - this.midiData.sort(Comparator.comparingLong(MidiEventData::getTimestampMs)); + LocalDateTime completedAt = LocalDateTime.now(); + long savedDurationMs = calculateDurationMs(this.startedAt, completedAt); + + // 실제 연주 허용 시간 = min(실제 연주 시간, 10분) + 오차 범위(500ms) + long allowedTimestampMs = Math.min(savedDurationMs + MIDI_TIMESTAMP_TOLERANCE_MS, + MAX_DURATION_MS); + List sortedMidiData = midiData.stream() + .filter(event -> event.getTimestampMs() <= allowedTimestampMs) + .sorted(Comparator.comparingLong(MidiEventData::getTimestampMs)) + .toList(); + + validateMidiData(sortedMidiData); + + LocalDateTime maxEndedAt = this.startedAt.plusSeconds(MAX_DURATION_SEC); + + this.midiData = new ArrayList<>(sortedMidiData); + this.endedAt = completedAt.isAfter(maxEndedAt) ? maxEndedAt : completedAt; + this.duration = Math.toIntExact(savedDurationMs / 1_000L); + this.status = PlayingStatus.COMPLETED; + } + + private void validateCompletableStatus() { + if (this.status != PlayingStatus.IN_PROGRESS) { + throw new GeneralException(PlayingErrorStatus.INVALID_PLAYING_STATUS); + } + + if (startedAt == null) { + throw new GeneralException(PlayingErrorStatus.MISSING_PLAYING_START_TIME); + } } private static void validateMidiData(List midiData) { @@ -166,11 +215,27 @@ private static void validateMidiData(List midiData) { throw new GeneralException(PlayingErrorStatus.EMPTY_MIDI_EVENTS); } + if (midiData.size() > MAX_MIDI_EVENT_COUNT) { + throw new GeneralException(PlayingErrorStatus.EXCEEDED_MIDI_EVENT_COUNT); + } + if (midiData.stream().anyMatch(Objects::isNull)) { throw new GeneralException(PlayingErrorStatus.INVALID_MIDI_EVENT); } } + private static long calculateDurationMs( + LocalDateTime startedAt, LocalDateTime completedAt + ) { + long durationMs = Duration.between(startedAt, completedAt).toMillis(); + + if (durationMs < 0) { + throw new GeneralException(PlayingErrorStatus.INVALID_PLAYING_DURATION); + } + + return Math.min(durationMs, MAX_DURATION_MS); + } + public List getMidiData() { if (this.midiData == null) { @@ -178,4 +243,17 @@ public List getMidiData() { } return List.copyOf(this.midiData); } + + public void start() { + validateStartableStatus(); + + this.status = PlayingStatus.IN_PROGRESS; + this.startedAt = LocalDateTime.now(); + } + + private void validateStartableStatus() { + if (status != PlayingStatus.READY) { + throw new GeneralException(PlayingErrorStatus.INVALID_PLAYING_STATUS); + } + } } diff --git a/src/main/java/com/mr/domain/playing/exception/PlayingErrorStatus.java b/src/main/java/com/mr/domain/playing/exception/PlayingErrorStatus.java index 347d6397..d6b26447 100644 --- a/src/main/java/com/mr/domain/playing/exception/PlayingErrorStatus.java +++ b/src/main/java/com/mr/domain/playing/exception/PlayingErrorStatus.java @@ -19,6 +19,15 @@ public enum PlayingErrorStatus implements BaseCode { INVALID_TIMESTAMP(HttpStatus.BAD_REQUEST, "PLAYING_400_08", "MIDI 이벤트 타임스탬프는 0 이상이어야 합니다."), EMPTY_MIDI_EVENTS(HttpStatus.BAD_REQUEST, "PLAYING_400_09", "저장할 MIDI 이벤트가 없습니다."), INVALID_MIDI_EVENT(HttpStatus.BAD_REQUEST,"PLAYING_400_10", "MIDI 이벤트 목록에 유효하지 않은 값이 포함되어 있습니다."), + MISSING_PLAYING_MODE(HttpStatus.BAD_REQUEST, "PLAYING_400_11", "연주 모드는 필수 입력값입니다."), + MISSING_PLAYING_STATUS(HttpStatus.BAD_REQUEST, "PLAYING_400_12", "연주 상태는 필수 입력값입니다."), + UNSUPPORTED_PLAYING_MODE(HttpStatus.BAD_REQUEST, "PLAYING_400_13", "현재 지원하지 않는 연주 모드입니다."), + EXCEEDED_MIDI_EVENT_COUNT(HttpStatus.BAD_REQUEST, "PLAYING_400_14", "MIDI 이벤트 개수가 허용 범위를 초과했습니다."), + + INVALID_PLAYING_STATUS(HttpStatus.CONFLICT, "PLAYING_409_01", "현재 연주 상태에서는 요청한 작업을 수행할 수 없습니다."), + MISSING_PLAYING_START_TIME(HttpStatus.CONFLICT, "PLAYING_409_02", "연주 시작 시간이 기록되지 않았습니다."), + INVALID_PLAYING_DURATION(HttpStatus.CONFLICT, "PLAYING_409_03", "연주 종료 시간이 시작 시간보다 이전일 수 없습니다."), + ; private final HttpStatus status; From fdddd45799c8e4a4b3e441e51a440b3d6d8a93f9 Mon Sep 17 00:00:00 2001 From: onlyoneprivate Date: Fri, 24 Jul 2026 13:53:31 +0200 Subject: [PATCH 05/10] =?UTF-8?q?feat:=20MIDI=20=EC=A0=80=EC=9E=A5=20?= =?UTF-8?q?=EB=B0=8F=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EC=88=9C=EC=84=9C=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?(#21)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/playing/entity/MidiEventData.java | 16 +++++++++- .../com/mr/domain/playing/entity/Playing.java | 31 +++++++++++++++---- .../playing/exception/PlayingErrorStatus.java | 2 ++ 3 files changed, 42 insertions(+), 7 deletions(-) diff --git a/src/main/java/com/mr/domain/playing/entity/MidiEventData.java b/src/main/java/com/mr/domain/playing/entity/MidiEventData.java index 5b5f7e6e..3a2af67c 100644 --- a/src/main/java/com/mr/domain/playing/entity/MidiEventData.java +++ b/src/main/java/com/mr/domain/playing/entity/MidiEventData.java @@ -10,6 +10,10 @@ @Getter public class MidiEventData { + // 동일 timestamp 내 순서 값 + @JsonProperty("sequence") + private final Integer sequence; + private final MidiType type; private final Integer pitch; private final Integer velocity; @@ -18,16 +22,19 @@ public class MidiEventData { private final Long timestampMs; private MidiEventData( + Integer sequence, MidiType type, Integer pitch, Integer velocity, Long timestampMs ) { + validateSequence(sequence); validateMidiType(type); validatePitch(pitch); validateVelocity(velocity); validateTimestampMs(timestampMs); + this.sequence = sequence; this.type = type; this.pitch = pitch; this.velocity = velocity; @@ -36,12 +43,19 @@ private MidiEventData( @JsonCreator public static MidiEventData of( + @JsonProperty("sequence") Integer sequence, @JsonProperty("type") MidiType type, @JsonProperty("pitch") Integer pitch, @JsonProperty("velocity") Integer velocity, @JsonProperty("timestamp_ms") Long timestampMs ) { - return new MidiEventData(type, pitch, velocity, timestampMs); + return new MidiEventData(sequence, type, pitch, velocity, timestampMs); + } + + private static void validateSequence(Integer sequence) { + if (sequence == null || sequence < 0) { + throw new GeneralException(PlayingErrorStatus.INVALID_MIDI_SEQUENCE); + } } private static void validateMidiType(MidiType type) { diff --git a/src/main/java/com/mr/domain/playing/entity/Playing.java b/src/main/java/com/mr/domain/playing/entity/Playing.java index 480e7354..e7dc9e07 100644 --- a/src/main/java/com/mr/domain/playing/entity/Playing.java +++ b/src/main/java/com/mr/domain/playing/entity/Playing.java @@ -23,10 +23,7 @@ import java.time.Duration; import java.time.LocalDateTime; -import java.util.ArrayList; -import java.util.Comparator; -import java.util.List; -import java.util.Objects; +import java.util.*; @Entity @Table(name = "playing") @@ -181,13 +178,14 @@ public void completeWithMidiData( LocalDateTime completedAt = LocalDateTime.now(); long savedDurationMs = calculateDurationMs(this.startedAt, completedAt); - // 실제 연주 허용 시간 = min(실제 연주 시간, 10분) + 오차 범위(500ms) + // 실제 연주 시간에 500ms의 오차를 허용하되, 최대 10분까지만 저장 long allowedTimestampMs = Math.min(savedDurationMs + MIDI_TIMESTAMP_TOLERANCE_MS, MAX_DURATION_MS); List sortedMidiData = midiData.stream() .filter(event -> event.getTimestampMs() <= allowedTimestampMs) - .sorted(Comparator.comparingLong(MidiEventData::getTimestampMs)) + .sorted(Comparator.comparingLong(MidiEventData::getTimestampMs) + .thenComparingInt(MidiEventData::getSequence)) .toList(); validateMidiData(sortedMidiData); @@ -222,6 +220,27 @@ private static void validateMidiData(List midiData) { if (midiData.stream().anyMatch(Objects::isNull)) { throw new GeneralException(PlayingErrorStatus.INVALID_MIDI_EVENT); } + + Set seenOrders = new HashSet<>(); + + for (MidiEventData event : midiData) { + MidiEventOrder order = new MidiEventOrder( + event.getTimestampMs(), + event.getSequence() + ); + + if (!seenOrders.add(order)) { + throw new GeneralException( + PlayingErrorStatus.DUPLICATE_MIDI_SEQUENCE + ); + } + } + } + + private record MidiEventOrder( + Long timestampMs, + Integer sequence + ) { } private static long calculateDurationMs( diff --git a/src/main/java/com/mr/domain/playing/exception/PlayingErrorStatus.java b/src/main/java/com/mr/domain/playing/exception/PlayingErrorStatus.java index d6b26447..e445c7e8 100644 --- a/src/main/java/com/mr/domain/playing/exception/PlayingErrorStatus.java +++ b/src/main/java/com/mr/domain/playing/exception/PlayingErrorStatus.java @@ -23,6 +23,8 @@ public enum PlayingErrorStatus implements BaseCode { MISSING_PLAYING_STATUS(HttpStatus.BAD_REQUEST, "PLAYING_400_12", "연주 상태는 필수 입력값입니다."), UNSUPPORTED_PLAYING_MODE(HttpStatus.BAD_REQUEST, "PLAYING_400_13", "현재 지원하지 않는 연주 모드입니다."), EXCEEDED_MIDI_EVENT_COUNT(HttpStatus.BAD_REQUEST, "PLAYING_400_14", "MIDI 이벤트 개수가 허용 범위를 초과했습니다."), + INVALID_MIDI_SEQUENCE(HttpStatus.BAD_REQUEST, "PLAYING_400_15", "MIDI 이벤트 순서(sequence) 값이 유효하지 않습니다."), + DUPLICATE_MIDI_SEQUENCE(HttpStatus.BAD_REQUEST, "PLAYING_400_16", "중복된 MIDI sequence 값이 존재합니다."), INVALID_PLAYING_STATUS(HttpStatus.CONFLICT, "PLAYING_409_01", "현재 연주 상태에서는 요청한 작업을 수행할 수 없습니다."), MISSING_PLAYING_START_TIME(HttpStatus.CONFLICT, "PLAYING_409_02", "연주 시작 시간이 기록되지 않았습니다."), From 9ae4ccd614a7673994a94040c7acf048635a4766 Mon Sep 17 00:00:00 2001 From: onlyoneprivate Date: Fri, 24 Jul 2026 13:56:06 +0200 Subject: [PATCH 06/10] =?UTF-8?q?refactor:=20duration=20=ED=95=84=EB=93=9C?= =?UTF-8?q?=EB=AA=85=EC=9D=84=20durationSec=EC=9C=BC=EB=A1=9C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20(#21)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/mr/domain/playing/entity/Playing.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/mr/domain/playing/entity/Playing.java b/src/main/java/com/mr/domain/playing/entity/Playing.java index e7dc9e07..860bd9cf 100644 --- a/src/main/java/com/mr/domain/playing/entity/Playing.java +++ b/src/main/java/com/mr/domain/playing/entity/Playing.java @@ -79,8 +79,8 @@ public class Playing extends BaseCreatedDeletedEntity { @Column(name = "recording_file_url", length = 255) private String recordingFileUrl; - @Column(name = "duration") - private Integer duration; + @Column(name = "duration_sec") + private Integer durationSec; @Column(name = "is_public", nullable = false) private boolean isPublic; @@ -194,7 +194,7 @@ public void completeWithMidiData( this.midiData = new ArrayList<>(sortedMidiData); this.endedAt = completedAt.isAfter(maxEndedAt) ? maxEndedAt : completedAt; - this.duration = Math.toIntExact(savedDurationMs / 1_000L); + this.durationSec = Math.toIntExact(savedDurationMs / 1_000L); this.status = PlayingStatus.COMPLETED; } From c9ab86fd140688195ea140d415264d634e226092 Mon Sep 17 00:00:00 2001 From: onlyoneprivate Date: Fri, 24 Jul 2026 13:58:59 +0200 Subject: [PATCH 07/10] =?UTF-8?q?refactor:=20MIDI=20=ED=95=84=ED=84=B0?= =?UTF-8?q?=EB=A7=81=20=ED=9B=84=20=EB=B9=88=20=EB=8D=B0=EC=9D=B4=ED=84=B0?= =?UTF-8?q?=20=EA=B2=80=EC=A6=9D=20=EC=B6=94=EA=B0=80=20(#21)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/mr/domain/playing/entity/Playing.java | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/mr/domain/playing/entity/Playing.java b/src/main/java/com/mr/domain/playing/entity/Playing.java index 860bd9cf..fb572f85 100644 --- a/src/main/java/com/mr/domain/playing/entity/Playing.java +++ b/src/main/java/com/mr/domain/playing/entity/Playing.java @@ -184,11 +184,16 @@ public void completeWithMidiData( List sortedMidiData = midiData.stream() .filter(event -> event.getTimestampMs() <= allowedTimestampMs) - .sorted(Comparator.comparingLong(MidiEventData::getTimestampMs) + .sorted( + Comparator.comparingLong(MidiEventData::getTimestampMs) .thenComparingInt(MidiEventData::getSequence)) .toList(); - validateMidiData(sortedMidiData); + if (sortedMidiData.isEmpty()) { + throw new GeneralException( + PlayingErrorStatus.EMPTY_MIDI_EVENTS + ); + } LocalDateTime maxEndedAt = this.startedAt.plusSeconds(MAX_DURATION_SEC); From 24b79ed471a95dcf7e05c4440b48c2d918621c09 Mon Sep 17 00:00:00 2001 From: onlyoneprivate Date: Fri, 24 Jul 2026 19:02:05 +0200 Subject: [PATCH 08/10] =?UTF-8?q?style:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20=EC=99=80=EC=9D=BC=EB=93=9C=EC=B9=B4=EB=93=9C=20imp?= =?UTF-8?q?ort=20=EC=A0=9C=EA=B1=B0=20(#21)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/mr/domain/playing/entity/Playing.java | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/mr/domain/playing/entity/Playing.java b/src/main/java/com/mr/domain/playing/entity/Playing.java index fb572f85..717e4c00 100644 --- a/src/main/java/com/mr/domain/playing/entity/Playing.java +++ b/src/main/java/com/mr/domain/playing/entity/Playing.java @@ -23,7 +23,12 @@ import java.time.Duration; import java.time.LocalDateTime; -import java.util.*; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashSet; +import java.util.List; +import java.util.Objects; +import java.util.Set; @Entity @Table(name = "playing") From 615fc678ca8f54d0ebefdf8000dc6d67f1873039 Mon Sep 17 00:00:00 2001 From: onlyoneprivate Date: Fri, 24 Jul 2026 19:10:00 +0200 Subject: [PATCH 09/10] =?UTF-8?q?refactor:=20Playing=EC=9D=98=20User=20?= =?UTF-8?q?=EB=B0=8F=20BackingTrack=20=EC=97=B0=EA=B4=80=EA=B4=80=EA=B3=84?= =?UTF-8?q?=20=EC=84=A4=EC=A0=95=20(#21)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/mr/domain/playing/entity/Playing.java | 43 +++++++++++-------- 1 file changed, 24 insertions(+), 19 deletions(-) diff --git a/src/main/java/com/mr/domain/playing/entity/Playing.java b/src/main/java/com/mr/domain/playing/entity/Playing.java index 717e4c00..589329b6 100644 --- a/src/main/java/com/mr/domain/playing/entity/Playing.java +++ b/src/main/java/com/mr/domain/playing/entity/Playing.java @@ -1,17 +1,22 @@ package com.mr.domain.playing.entity; +import com.mr.domain.backingTrack.entity.BackingTrack; import com.mr.domain.playing.entity.enums.PlayingMode; import com.mr.domain.playing.entity.enums.PlayingStatus; import com.mr.domain.playing.exception.PlayingErrorStatus; +import com.mr.domain.user.entity.User; import com.mr.global.apipayload.exception.GeneralException; import com.mr.global.entity.BaseCreatedDeletedEntity; 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.JoinColumn; +import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; import jakarta.persistence.Version; import lombok.AccessLevel; @@ -54,13 +59,13 @@ public class Playing extends BaseCreatedDeletedEntity { @Column(name = "version", nullable = false) private Long version; - // TODO: 유저 ID 연관 관계 설정 예정 - @Column(name = "user_id", nullable = false) - private Long userId; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; - // TODO: 백킹트랙 ID 연관 관계 설정 예정 - @Column(name = "backing_track_id") - private Long backingTrackId; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "backing_track_id") + private BackingTrack backingTrack; @Enumerated(EnumType.STRING) @Column(name = "mode", nullable = false) @@ -96,21 +101,21 @@ public class Playing extends BaseCreatedDeletedEntity { @Builder(access = AccessLevel.PRIVATE) private Playing( - Long userId, - Long backingTrackId, + User user, + BackingTrack backingTrack, PlayingMode mode, PlayingStatus status, Integer bpm, boolean isPublic ) { - validateUserId(userId); + validateUser(user); validateMode(mode); validateStatus(status); - validateBackingTrack(mode, backingTrackId); + validateBackingTrack(mode, backingTrack); - this.userId = userId; - this.backingTrackId = backingTrackId; + this.user = user; + this.backingTrack = backingTrack; this.mode = mode; this.status = status; this.bpm = resolveBpm(bpm); @@ -118,21 +123,21 @@ private Playing( this.midiData = new ArrayList<>(); } - private static void validateUserId(Long userId) { - if (userId == null) { + private static void validateUser(User user) { + if (user == null) { throw new GeneralException(PlayingErrorStatus.MISSING_USER_ID); } } private static void validateBackingTrack( - PlayingMode mode, Long backingTrackId) { + PlayingMode mode, BackingTrack backingTrack) { // 자유 연주 미지원 (BACKING_TRACK이 포함된 연주만 가능) if (mode != PlayingMode.BACKING_TRACK) { throw new GeneralException(PlayingErrorStatus.UNSUPPORTED_PLAYING_MODE); } - if (backingTrackId == null) { + if (backingTrack == null) { throw new GeneralException(PlayingErrorStatus.MISSING_BACKING_TRACK_ID); } } @@ -161,11 +166,11 @@ private static int resolveBpm(Integer bpm) { // 백킹트랙 연주 생성 public static Playing createBackingTrack( - Long userId, Long backingTrackId, Integer bpm + User user, BackingTrack backingTrack, Integer bpm ) { return Playing.builder() - .userId(userId) - .backingTrackId(backingTrackId) + .user(user) + .backingTrack(backingTrack) .mode(PlayingMode.BACKING_TRACK) .status(PlayingStatus.READY) .bpm(bpm) From 5d7359816fd32e62196c6bdf2aa29537ae0919c0 Mon Sep 17 00:00:00 2001 From: onlyoneprivate Date: Fri, 24 Jul 2026 19:14:58 +0200 Subject: [PATCH 10/10] =?UTF-8?q?refactor:=20Duration=EC=9D=84=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=ED=95=B4=20=EC=8B=9C=EA=B0=84=20=EB=B3=80=ED=99=98=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EA=B0=9C=EC=84=A0=20(#21)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/mr/domain/playing/entity/Playing.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/mr/domain/playing/entity/Playing.java b/src/main/java/com/mr/domain/playing/entity/Playing.java index 589329b6..870c3edd 100644 --- a/src/main/java/com/mr/domain/playing/entity/Playing.java +++ b/src/main/java/com/mr/domain/playing/entity/Playing.java @@ -209,7 +209,7 @@ public void completeWithMidiData( this.midiData = new ArrayList<>(sortedMidiData); this.endedAt = completedAt.isAfter(maxEndedAt) ? maxEndedAt : completedAt; - this.durationSec = Math.toIntExact(savedDurationMs / 1_000L); + this.durationSec = Math.toIntExact(Duration.ofMillis(savedDurationMs).toSeconds()); this.status = PlayingStatus.COMPLETED; }