Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
cc825f4
refactor: MIDI 이벤트 예외 코드 분리 (#20)
on1yoneprivate Jul 29, 2026
366e478
feat: MIDI 이벤트 저장 서비스 구현 (#20)
on1yoneprivate Jul 29, 2026
63bb0b9
feat: MIDI 이벤트 저장 API 추가 (#20)
on1yoneprivate Jul 29, 2026
9421634
test: MIDI 이벤트 저장 서비스·컨트롤러 테스트 추가 (#20)
on1yoneprivate Jul 29, 2026
dbd8708
docs: MIDI 이벤트 저장 API 설명 추가 (#20)
on1yoneprivate Jul 29, 2026
6f72d48
Merge branch 'develop' into feat/#20-midi-event-save
on1yoneprivate Jul 29, 2026
4b7b29d
refactor: MIDI 이벤트 요청 유효성 검증 강화 (#20)
on1yoneprivate Jul 29, 2026
b37a6c3
refactor: Playing 예외 코드 번호 복원 (#20)
on1yoneprivate Jul 29, 2026
ab0857a
Merge branch 'develop' into feat/#20-midi-event-save
on1yoneprivate Jul 30, 2026
65fac67
refactor: MAX_MIDI_EVENT_COUNT 상수 공통화 (#20)
on1yoneprivate Jul 30, 2026
93e7275
refactor: 소프트 삭제된 연주 조회 제외 (#20)
on1yoneprivate Jul 30, 2026
8142561
refactor: 소유권 검증 메서드 및 에러코드 범용화 (#20)
on1yoneprivate Jul 30, 2026
9cac136
refactor: Playing 엔티티 MIDI 저장 로직 분리 (#20)
on1yoneprivate Jul 30, 2026
c98d90c
feat: 연주 완료 요청 재시도 제한 추가 (#20)
on1yoneprivate Jul 30, 2026
7aac8a8
test: MIDI 이벤트 저장 테스트 추가 (#20)
on1yoneprivate Jul 30, 2026
e94371c
Merge branch 'develop' into feat/#20-midi-event-save
on1yoneprivate Jul 30, 2026
f22c795
fix: 연주 접근 권한 오류 코드를 403으로 수정 (#20)
on1yoneprivate Jul 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.mr.domain.playing.constant;

import lombok.AccessLevel;
import lombok.NoArgsConstructor;

@NoArgsConstructor(access = AccessLevel.PRIVATE)
public final class MidiEventConstants {

public static final int MAX_MIDI_EVENT_COUNT = 100_000;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package com.mr.domain.playing.controller;

import com.mr.domain.playing.dto.req.MidiEventSaveRequest;
import com.mr.domain.playing.dto.res.MidiEventSaveResponse;
import com.mr.domain.playing.service.PlayingService;
import com.mr.global.apipayload.ApiResponse;
import com.mr.global.security.principal.CustomUserDetails;
import io.swagger.v3.oas.annotations.Operation;
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.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/playings")
public class PlayingController {

private final PlayingService playingService;

@Operation(
summary = "MIDI 이벤트 저장",
description = "연주 세션에 대한 MIDI 이벤트를 저장하고 연주를 완료 상태로 변경합니다."
)
@PostMapping("/{playingId}/midi-events")
public ApiResponse<MidiEventSaveResponse> saveMidiEvents(
@AuthenticationPrincipal CustomUserDetails userDetails,
@PathVariable Long playingId,
@Valid @RequestBody MidiEventSaveRequest request
){
Long userId = userDetails.getUserId();

MidiEventSaveResponse response = playingService.saveMidiEvents(
userId,
playingId,
request
);

return ApiResponse.onSuccess(response);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package com.mr.domain.playing.dto.req;

import com.mr.domain.playing.entity.enums.MidiType;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.Size;

import java.util.List;

import static com.mr.domain.playing.constant.MidiEventConstants.MAX_MIDI_EVENT_COUNT;

public record MidiEventSaveRequest (
@NotEmpty(message = "MIDI 이벤트 목록은 필수입니다")
@Size(max = MAX_MIDI_EVENT_COUNT, message = "MIDI 이벤트는 최대 100,000개까지 저장할 수 있습니다.")
List<@Valid MidiEventRequest> events
Comment thread
on1yoneprivate marked this conversation as resolved.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

리스트 내부 @Valid 어노테이션이 제대로 작동안될 수도 있어서 @Valid List events 이런식으로 선언하면 어떨까요

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

관련 문서 확인해 보니, Bean Validation 2.0부터 List<@Valid MidiEventRequest>처럼 컨테이너의 타입 인자에 @Valid를 적용하는 방식 권장 + @Valid List 방식도 기존 방식으로 계속 지원되고 있다고 나와서, 우선은 현재 작성한 List<@Valid MidiEventRequest> 형태를 유지해도 괜찮을 것 같다는 개인적인 의견입니다..!
다만 말씀해 주신 것처럼 실제 프로젝트 환경에서 리스트 내부 DTO의 검증이 정상적으로 동작하는지는 테스트 코드 추가해서 확인해 볼게요!

참고 문서: Jakarta Bean Validation

) {

public record MidiEventRequest (

@NotNull(message = "MIDI 이벤트 순서 값은 필수입니다.")
@Min(value = 0, message = "MIDI 이벤트 순서 값은 0 이상이어야 합니다.")
Integer sequence,

@NotNull(message = "MIDI 이벤트 타입은 필수입니다.")
MidiType type,

@NotNull(message = "MIDI 피치 값은 필수입니다.")
@Min(value = 0, message = "MIDI 피치 값은 0 이상이어야 합니다.")
@Max(value = 127, message = "MIDI 피치 값은 127 이하여야 합니다.")
Integer pitch,

@NotNull(message = "MIDI 입력 강도 값은 필수입니다.")
@Min(value = 0, message = "MIDI 입력 강도 값은 0 이상이어야 합니다.")
@Max(value = 127, message = "MIDI 입력 강도 값은 127 이하여야 합니다.")
Integer velocity,

@NotNull(message = "MIDI 이벤트 발생 시간은 필수입니다.")
@Min(value = 0, message = "MIDI 이벤트 발생 시간은 0 이상이어야 합니다.")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR 참고 사항에는 10분을 초과하는 MIDI 이벤트는 저장 대상에서 제외한다고 되어 있는데, 현재 DTO와 서비스 로직에서는 timestampMs >= 0만 검증하고 전체 이벤트를 그대로 저장하는 것으로 보입니다! timestampMs > 600_000인 이벤트를 필터링하려는 의도인지, 요청 자체를 예외 처리하려는 의도인지 명확히 해야 할 것 같습니다...🥹🥹

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

최대 timestampMs는 요청 시점에 알 수 있는 고정값이 아니라 실제 연주 종료 시각을 기준으로 동적으로 결정되므로, DTO에서는 상한 검증을 두지 않았어요! (음수 값만 검증)
→ Playing.completeWithMidiData()에서 실제 연주 시간 + 500ms의 허용 오차를 적용하고, 최대 10분(600_000ms)까지만 저장하도록 필터링하는 비즈니스 로직으로 처리

다만 연주 시간이 정확히 10분에 도달한 경우에 최대 10분 제한으로 인해 추가 500ms의 허용 오차가 적용되지 않는데, 해당 동작이 적절한지에 대해서는 팀원분들 의견도 궁금합니다ㅎㅎ

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

도메인에 대한 이해가 많이 부족하지만.... 저는 지금은 괜찮다구 생각해요!!
추후 QA를 거쳐 디벨롭하면 될 것 같습니동

Long timestampMs
) {
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.mr.domain.playing.dto.res;

public record MidiEventSaveResponse(
Long playingId,
int savedCount
) {

public static MidiEventSaveResponse of(Long playingId, int savedCount) {
return new MidiEventSaveResponse(playingId, savedCount);
}
}
12 changes: 6 additions & 6 deletions src/main/java/com/mr/domain/playing/entity/MidiEventData.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
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.domain.playing.exception.MidiEventErrorStatus;
import com.mr.global.apipayload.exception.GeneralException;
import lombok.Getter;

Expand Down Expand Up @@ -54,31 +54,31 @@ public static MidiEventData of(

private static void validateSequence(Integer sequence) {
if (sequence == null || sequence < 0) {
throw new GeneralException(PlayingErrorStatus.INVALID_MIDI_SEQUENCE);
throw new GeneralException(MidiEventErrorStatus.INVALID_MIDI_SEQUENCE);
}
}

private static void validateMidiType(MidiType type) {
if (type == null) {
throw new GeneralException(PlayingErrorStatus.MISSING_MIDI_TYPE);
throw new GeneralException(MidiEventErrorStatus.INVALID_MIDI_TYPE);
}
}

private static void validatePitch(Integer pitch) {
if (pitch == null || pitch < 0 || pitch > 127) {
throw new GeneralException(PlayingErrorStatus.INVALID_PITCH_RANGE);
throw new GeneralException(MidiEventErrorStatus.INVALID_PITCH_RANGE);
}
}

private static void validateVelocity(Integer velocity) {
if (velocity == null || velocity < 0 || velocity > 127) {
throw new GeneralException(PlayingErrorStatus.INVALID_VELOCITY_RANGE);
throw new GeneralException(MidiEventErrorStatus.INVALID_VELOCITY_RANGE);
}
}

private static void validateTimestampMs(Long timestampMs) {
if (timestampMs == null || timestampMs < 0) {
throw new GeneralException(PlayingErrorStatus.INVALID_TIMESTAMP);
throw new GeneralException(MidiEventErrorStatus.INVALID_TIMESTAMP);
}
}
}
102 changes: 74 additions & 28 deletions src/main/java/com/mr/domain/playing/entity/Playing.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
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.MidiEventErrorStatus;
import com.mr.domain.playing.exception.PlayingErrorStatus;
import com.mr.domain.user.entity.User;
import com.mr.global.apipayload.exception.GeneralException;
Expand Down Expand Up @@ -36,6 +37,8 @@
import java.util.Objects;
import java.util.Set;

import static com.mr.domain.playing.constant.MidiEventConstants.MAX_MIDI_EVENT_COUNT;

@Entity
@Table(
name = "playing",
Expand All @@ -55,7 +58,6 @@ public class Playing extends BaseCreatedDeletedEntity {
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)
Expand Down Expand Up @@ -187,42 +189,32 @@ public static Playing createBackingTrack(

// 연주 완료 시 전체 MIDI 데이터를 저장하고 완료 상태로 전환
public void completeWithMidiData(
List<MidiEventData> midiData
List<MidiEventData> requestedMidiData
) {
validateCompletableStatus();
validateMidiData(midiData);
validateMidiData(requestedMidiData);

LocalDateTime completedAt = LocalDateTime.now();
long savedDurationMs = calculateDurationMs(this.startedAt, completedAt);

// 실제 연주 시간에 500ms의 오차를 허용하되, 최대 10분까지만 저장
long allowedTimestampMs = Math.min(savedDurationMs + MIDI_TIMESTAMP_TOLERANCE_MS,
MAX_DURATION_MS);
List<MidiEventData> normalizedMidiData = normalizeMidiData(requestedMidiData, savedDurationMs);
validateNormalizedMidiData(normalizedMidiData);

List<MidiEventData> sortedMidiData = midiData.stream()
.filter(event -> event.getTimestampMs() <= allowedTimestampMs)
.sorted(
Comparator.comparingLong(MidiEventData::getTimestampMs)
.thenComparingInt(MidiEventData::getSequence))
.toList();
this.midiData = new ArrayList<>(normalizedMidiData);
this.endedAt = calculateEndedAt(completedAt);
this.durationSec = convertToDurationSec(savedDurationMs);
this.status = PlayingStatus.COMPLETED;
}

if (sortedMidiData.isEmpty()) {
throw new GeneralException(
PlayingErrorStatus.EMPTY_MIDI_EVENTS
);
public void validatePlayingOwner(Long userId) {
if (userId == null || this.user == null || !Objects.equals(this.user.getUserId(), userId)) {
throw new GeneralException(PlayingErrorStatus.PLAYING_ACCESS_DENIED);
}

LocalDateTime maxEndedAt = this.startedAt.plusSeconds(MAX_DURATION_SEC);

this.midiData = new ArrayList<>(sortedMidiData);
this.endedAt = completedAt.isAfter(maxEndedAt) ? maxEndedAt : completedAt;
this.durationSec = Math.toIntExact(Duration.ofMillis(savedDurationMs).toSeconds());
this.status = PlayingStatus.COMPLETED;
}

private void validateCompletableStatus() {
if (this.status != PlayingStatus.IN_PROGRESS) {
throw new GeneralException(PlayingErrorStatus.INVALID_PLAYING_STATUS);
throw new GeneralException(MidiEventErrorStatus.PLAYING_NOT_IN_PROGRESS);
}

if (startedAt == null) {
Expand All @@ -232,15 +224,19 @@ private void validateCompletableStatus() {

private static void validateMidiData(List<MidiEventData> midiData) {
if (midiData == null || midiData.isEmpty()) {
throw new GeneralException(PlayingErrorStatus.EMPTY_MIDI_EVENTS);
throw new GeneralException(MidiEventErrorStatus.EMPTY_MIDI_EVENTS);
}

if (midiData.size() > MAX_MIDI_EVENT_COUNT) {
throw new GeneralException(PlayingErrorStatus.EXCEEDED_MIDI_EVENT_COUNT);
throw new GeneralException(MidiEventErrorStatus.EXCEEDED_MIDI_EVENT_COUNT);
}

if (midiData.stream().anyMatch(Objects::isNull)) {
throw new GeneralException(PlayingErrorStatus.INVALID_MIDI_EVENT);
throw new GeneralException(MidiEventErrorStatus.INVALID_MIDI_EVENT);
}

if (midiData.stream().anyMatch(Playing::hasInvalidEventOrder)) {
throw new GeneralException(MidiEventErrorStatus.INVALID_MIDI_EVENT);
}

Set<MidiEventOrder> seenOrders = new HashSet<>();
Expand All @@ -253,12 +249,22 @@ private static void validateMidiData(List<MidiEventData> midiData) {

if (!seenOrders.add(order)) {
throw new GeneralException(
PlayingErrorStatus.DUPLICATE_MIDI_SEQUENCE
MidiEventErrorStatus.DUPLICATE_MIDI_SEQUENCE
);
}
}
}

private static void validateNormalizedMidiData(
List<MidiEventData> normalizedMidiData
) {
if (normalizedMidiData.isEmpty()) {
throw new GeneralException(
MidiEventErrorStatus.EMPTY_MIDI_EVENTS
);
}
}

private record MidiEventOrder(
Long timestampMs,
Integer sequence
Expand All @@ -277,6 +283,25 @@ private static long calculateDurationMs(
return Math.min(durationMs, MAX_DURATION_MS);
}

private LocalDateTime calculateEndedAt(
LocalDateTime completedAt
) {
LocalDateTime maxEndedAt =
this.startedAt.plusSeconds(MAX_DURATION_SEC);

return completedAt.isAfter(maxEndedAt)
? maxEndedAt
: completedAt;
}

private static int convertToDurationSec(
long durationMs
) {
return Math.toIntExact(
Duration.ofMillis(durationMs).toSeconds()
);
}

public List<MidiEventData> getMidiData() {

if (this.midiData == null) {
Expand All @@ -297,4 +322,25 @@ private void validateStartableStatus() {
throw new GeneralException(PlayingErrorStatus.INVALID_PLAYING_STATUS);
}
}

private static List<MidiEventData> normalizeMidiData(
List<MidiEventData> midiData, long savedDurationMs) {
long allowedTimestampMs =
Math.min(savedDurationMs + MIDI_TIMESTAMP_TOLERANCE_MS, MAX_DURATION_MS);

return midiData.stream()
.filter(event -> event.getTimestampMs() <= allowedTimestampMs)
.sorted(Comparator.comparingLong(MidiEventData::getTimestampMs)
.thenComparingInt(MidiEventData::getSequence)
).toList();
}

private static boolean hasInvalidEventOrder(
MidiEventData event
) {
return event.getTimestampMs() == null
|| event.getTimestampMs() < 0
|| event.getSequence() == null
|| event.getSequence() < 0;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package com.mr.domain.playing.exception;

import com.mr.global.apipayload.code.BaseCode;
import lombok.AllArgsConstructor;
import lombok.Getter;
import org.springframework.http.HttpStatus;

@Getter
@AllArgsConstructor
public enum MidiEventErrorStatus implements BaseCode {

INVALID_PLAYING_ID(HttpStatus.BAD_REQUEST, "MIDI_400_01", "연주 ID가 올바르지 않습니다."),
EMPTY_MIDI_EVENTS(HttpStatus.BAD_REQUEST,"MIDI_400_02", "MIDI 이벤트 목록은 필수입니다."),
INVALID_MIDI_TYPE(HttpStatus.BAD_REQUEST, "MIDI_400_03", "MIDI 이벤트 타입이 올바르지 않습니다."),
INVALID_PITCH_RANGE(HttpStatus.BAD_REQUEST, "MIDI_400_04", "피치 값은 0~127 사이의 값이어야 합니다."),
INVALID_VELOCITY_RANGE(HttpStatus.BAD_REQUEST, "MIDI_400_05", "강도는 0~127 사이의 값이어야 합니다."),
INVALID_TIMESTAMP(HttpStatus.BAD_REQUEST, "MIDI_400_06", "MIDI 이벤트 타임스탬프는 0 이상이어야 합니다."),
INVALID_MIDI_EVENT(HttpStatus.BAD_REQUEST,"MIDI_400_07", "MIDI 이벤트 목록에 유효하지 않은 값이 포함되어 있습니다."),
INVALID_MIDI_SEQUENCE(HttpStatus.BAD_REQUEST, "MIDI_400_08", "MIDI 이벤트 순서(sequence) 값이 유효하지 않습니다."),
EXCEEDED_MIDI_EVENT_COUNT(HttpStatus.BAD_REQUEST, "MIDI_400_09", "MIDI 이벤트 개수가 허용 범위를 초과했습니다."),
DUPLICATE_MIDI_SEQUENCE(HttpStatus.BAD_REQUEST, "MIDI_400_10", "동일한 시간에 중복된 MIDI sequence 값이 존재합니다."),

PLAYING_NOT_IN_PROGRESS(HttpStatus.CONFLICT, "MIDI_409_01", "진행 중인 연주 세션에만 MIDI 이벤트를 저장할 수 있습니다."),
MIDI_SAVE_REQUEST_TOO_FREQUENT(HttpStatus.TOO_MANY_REQUESTS, "MIDI_429_01", "연주 완료 요청은 1분에 한 번만 가능합니다."),
;

private final HttpStatus status;
private final String code;
private final String message;
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,18 +14,12 @@ public enum PlayingErrorStatus implements BaseCode {
MISSING_BACKING_TRACK_ID(HttpStatus.BAD_REQUEST, "PLAYING_400_03", "백킹트랙 연주 모드에서는 백킹트랙 ID가 필수입니다."),
MISSING_PLAYING(HttpStatus.BAD_REQUEST, "PLAYING_400_04", "MIDI 이벤트를 기록할 연주 정보가 누락되었습니다."),
MISSING_MIDI_TYPE(HttpStatus.BAD_REQUEST, "PLAYING_400_05", "MIDI Type은 필수 입력 값입니다."),
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 이벤트 목록에 유효하지 않은 값이 포함되어 있습니다."),
// 기존 PLAYING_400_06 ~ PLAYING_400_10은 MidiEventErrorStatus로 분리
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_MIDI_SEQUENCE(HttpStatus.BAD_REQUEST, "PLAYING_400_15", "MIDI 이벤트 순서(sequence) 값이 유효하지 않습니다."),
DUPLICATE_MIDI_SEQUENCE(HttpStatus.BAD_REQUEST, "PLAYING_400_16", "중복된 MIDI sequence 값이 존재합니다."),

PLAYING_ACCESS_DENIED(HttpStatus.FORBIDDEN, "PLAYING_403_01", "해당 연주에 대한 접근 권한이 없습니다."),
PLAYING_NOT_FOUND(HttpStatus.NOT_FOUND, "PLAYING_404_01", "연주 세션을 찾을 수 없습니다."),
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", "연주 종료 시간이 시작 시간보다 이전일 수 없습니다."),
Expand Down
Loading
Loading