diff --git a/src/main/java/com/be/sportizebe/domain/match/controller/MatchController.java b/src/main/java/com/be/sportizebe/domain/match/controller/MatchController.java index df53c77..5256d2e 100644 --- a/src/main/java/com/be/sportizebe/domain/match/controller/MatchController.java +++ b/src/main/java/com/be/sportizebe/domain/match/controller/MatchController.java @@ -19,6 +19,7 @@ import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.PathVariable; @@ -37,7 +38,7 @@ public class MatchController { private final MatchService matchService; - @Operation(summary = "매칭 생성") + @Operation(summary = "매칭 생성 (관리자 전용 기능)", description = "현재는 매칭 더미데이터 넣는 용도") @PostMapping public ResponseEntity> createMatch( @AuthenticationPrincipal UserAuthInfo userAuthInfo, @@ -68,7 +69,17 @@ public ResponseEntity> getMatchDetail( return ResponseEntity.ok(BaseResponse.success("매칭 상세 조회 성공", response)); } - @Operation(summary = "내 주변 매칭 목록 조회") + @Operation(summary = "매칭 취소") + @DeleteMapping("/{matchId}/leave") + public ResponseEntity> leaveMatch( + @PathVariable Long matchId, + @AuthenticationPrincipal UserAuthInfo userAuthInfo + ) { + matchService.leaveMatch(matchId, userAuthInfo.getId()); + return ResponseEntity.ok(BaseResponse.success("매칭 취소 성공", null)); + } + + @Operation(summary = "내 주변 매칭 목록 조회", description = "매칭상태가 OPEN인 매칭들만 보여준다.") @GetMapping("/near") public ResponseEntity>> getNearMatches( @ParameterObject @Valid @ModelAttribute MatchNearRequest request diff --git a/src/main/java/com/be/sportizebe/domain/match/dto/request/MatchCreateRequest.java b/src/main/java/com/be/sportizebe/domain/match/dto/request/MatchCreateRequest.java index 90c4725..4617f43 100644 --- a/src/main/java/com/be/sportizebe/domain/match/dto/request/MatchCreateRequest.java +++ b/src/main/java/com/be/sportizebe/domain/match/dto/request/MatchCreateRequest.java @@ -16,6 +16,13 @@ public record MatchCreateRequest( @Schema(description = "최대 참여 인원 수", example = "10") @Min(2) @Max(20) - Integer maxMembers + Integer maxMembers, + + @Schema(description = "몇 분동안 진행되는 매칭인지", example = "120") + int durationMinutes, + + @Schema(description = "매칭 참여 요금", example = "4000") + @Min(value = 1000, message = "참가비는 1000원 이상이어야 합니다.") + int entryFee ) {} \ No newline at end of file diff --git a/src/main/java/com/be/sportizebe/domain/match/dto/request/MatchNearRequest.java b/src/main/java/com/be/sportizebe/domain/match/dto/request/MatchNearRequest.java index 07ff6a2..64210e1 100644 --- a/src/main/java/com/be/sportizebe/domain/match/dto/request/MatchNearRequest.java +++ b/src/main/java/com/be/sportizebe/domain/match/dto/request/MatchNearRequest.java @@ -1,6 +1,7 @@ package com.be.sportizebe.domain.match.dto.request; import com.be.sportizebe.common.enums.SportType; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.*; import lombok.Getter; import lombok.Setter; @@ -9,19 +10,23 @@ @Setter public class MatchNearRequest { + @Schema(description = "위도", example = "37.2662", requiredMode = Schema.RequiredMode.REQUIRED) @NotNull(message = "위도(lat)는 필수입니다") @DecimalMin(value = "-90.0", message = "위도는 -90.0 이상이어야 합니다") @DecimalMax(value = "90.0", message = "위도는 90.0 이하여야 합니다") private Double lat; + @Schema(description = "경도", example = "127.0006", requiredMode = Schema.RequiredMode.REQUIRED) @NotNull(message = "경도(lng)는 필수입니다") @DecimalMin(value = "-180.0", message = "경도는 -180.0 이상이어야 합니다") @DecimalMax(value = "180.0", message = "경도는 180.0 이하여야 합니다") private Double lng; + @Schema(description = "반경(미터)", example = "1000", defaultValue = "1000") @Min(value = 100, message = "반경은 최소 100m 이상이어야 합니다") @Max(value = 10000, message = "반경은 최대 10km까지 가능합니다") private Integer radiusM = 1000; + @Schema(description = "종목 필터(선택)", example = "BASKETBALL", nullable = true) private SportType sportsName; } diff --git a/src/main/java/com/be/sportizebe/domain/match/dto/response/MatchNearResponse.java b/src/main/java/com/be/sportizebe/domain/match/dto/response/MatchNearResponse.java index 0fc1db1..450c56c 100644 --- a/src/main/java/com/be/sportizebe/domain/match/dto/response/MatchNearResponse.java +++ b/src/main/java/com/be/sportizebe/domain/match/dto/response/MatchNearResponse.java @@ -12,7 +12,10 @@ public record MatchNearResponse( int curMembers, int maxMembers, MatchStatus status, - int distanceM + int distanceM, + int durationMinutes, + int entryFee + ) { public static MatchNearResponse from(MatchNearProjection p) { return new MatchNearResponse( @@ -23,7 +26,10 @@ public static MatchNearResponse from(MatchNearProjection p) { p.getCurMembers(), p.getMaxMembers(), MatchStatus.valueOf(p.getStatus()), - (int) Math.round(p.getDistanceM()) + (int) Math.round(p.getDistanceM()), + p.getDurationMinutes(), + p.getEntryFee() + ); } } diff --git a/src/main/java/com/be/sportizebe/domain/match/entity/MatchParticipant.java b/src/main/java/com/be/sportizebe/domain/match/entity/MatchParticipant.java index 99bbeac..2d8eb13 100644 --- a/src/main/java/com/be/sportizebe/domain/match/entity/MatchParticipant.java +++ b/src/main/java/com/be/sportizebe/domain/match/entity/MatchParticipant.java @@ -59,4 +59,10 @@ public void leave() { public boolean isJoined() { return this.status == MatchParticipantStatus.JOINED; } + + public void rejoin() { + this.status = MatchParticipantStatus.JOINED; + this.joinedAt = LocalDateTime.now(); + this.leftAt = null; + } } \ No newline at end of file diff --git a/src/main/java/com/be/sportizebe/domain/match/entity/MatchParticipantStatus.java b/src/main/java/com/be/sportizebe/domain/match/entity/MatchParticipantStatus.java index cc46c60..4e41e5d 100644 --- a/src/main/java/com/be/sportizebe/domain/match/entity/MatchParticipantStatus.java +++ b/src/main/java/com/be/sportizebe/domain/match/entity/MatchParticipantStatus.java @@ -1,5 +1,6 @@ package com.be.sportizebe.domain.match.entity; public enum MatchParticipantStatus { - JOINED, LEFT + JOINED, + LEFT } diff --git a/src/main/java/com/be/sportizebe/domain/match/entity/MatchRoom.java b/src/main/java/com/be/sportizebe/domain/match/entity/MatchRoom.java index 0cad30c..49c4954 100644 --- a/src/main/java/com/be/sportizebe/domain/match/entity/MatchRoom.java +++ b/src/main/java/com/be/sportizebe/domain/match/entity/MatchRoom.java @@ -39,6 +39,13 @@ public class MatchRoom extends BaseTimeEntity { @Column(nullable = false) private MatchStatus status; + @Column(nullable = false) + private int durationMinutes; + + @Column(nullable = false) + private int entryFee; + + @OneToMany(mappedBy = "matchRoom", cascade = CascadeType.ALL, orphanRemoval = true) @Builder.Default private List participants = new ArrayList<>(); @@ -54,6 +61,18 @@ public static MatchRoom create(MatchCreateRequest request) { .curMembers(0) .maxMembers(request.maxMembers()) .status(MatchStatus.OPEN) + .durationMinutes(request.durationMinutes()) + .entryFee(request.entryFee()) .build(); } + public void join() { + this.curMembers++; + if (isFull()) this.status = MatchStatus.FULL; + } + public void leave() { + this.curMembers--; + if (this.status == MatchStatus.FULL) this.status = MatchStatus.OPEN; + } + + } \ No newline at end of file diff --git a/src/main/java/com/be/sportizebe/domain/match/exception/MatchErrorCode.java b/src/main/java/com/be/sportizebe/domain/match/exception/MatchErrorCode.java index 0c9db63..d02e750 100644 --- a/src/main/java/com/be/sportizebe/domain/match/exception/MatchErrorCode.java +++ b/src/main/java/com/be/sportizebe/domain/match/exception/MatchErrorCode.java @@ -23,6 +23,12 @@ public enum MatchErrorCode implements BaseErrorCode { HttpStatus.BAD_REQUEST, "MATCH_400_ALREADY_JOINED", "이미 해당 매칭에 참가 중입니다." + ), + + NOT_JOINED( + HttpStatus.BAD_REQUEST, + "MATCH_400_NOT_JOINED", + "해당 매칭에 참가 중이지 않습니다." ); private final HttpStatus status; diff --git a/src/main/java/com/be/sportizebe/domain/match/repository/MatchParticipantRepository.java b/src/main/java/com/be/sportizebe/domain/match/repository/MatchParticipantRepository.java index 3292af9..85be183 100644 --- a/src/main/java/com/be/sportizebe/domain/match/repository/MatchParticipantRepository.java +++ b/src/main/java/com/be/sportizebe/domain/match/repository/MatchParticipantRepository.java @@ -7,6 +7,7 @@ import org.springframework.data.jpa.repository.JpaRepository; import java.util.List; +import java.util.Optional; public interface MatchParticipantRepository extends JpaRepository { @@ -18,4 +19,10 @@ public interface MatchParticipantRepository extends JpaRepository findAllByMatchRoomAndStatus(MatchRoom matchRoom, MatchParticipantStatus status); + + // 특정 유저의 특정 매칭방 참가 이력 단건 조회 (상태 포함) + Optional findByMatchRoomAndUserAndStatus(MatchRoom matchRoom, User user, MatchParticipantStatus status); + + // 특정 유저의 특정 매칭방 참가 이력 단건 조회 (상태 무관) + Optional findByMatchRoomAndUser(MatchRoom matchRoom, User user); } \ No newline at end of file diff --git a/src/main/java/com/be/sportizebe/domain/match/repository/MatchRoomRepository.java b/src/main/java/com/be/sportizebe/domain/match/repository/MatchRoomRepository.java index 4fa1970..0bc13b7 100644 --- a/src/main/java/com/be/sportizebe/domain/match/repository/MatchRoomRepository.java +++ b/src/main/java/com/be/sportizebe/domain/match/repository/MatchRoomRepository.java @@ -19,6 +19,9 @@ public interface MatchRoomRepository extends JpaRepository { mr.cur_members AS curMembers, mr.max_members AS maxMembers, mr.status AS status, + mr.duration_minutes AS durationMinutes, + mr.entry_fee AS entryFee, + ST_Distance( sf.location, ST_SetSRID(ST_MakePoint(:lng, :lat), 4326)::geography diff --git a/src/main/java/com/be/sportizebe/domain/match/repository/projection/MatchNearProjection.java b/src/main/java/com/be/sportizebe/domain/match/repository/projection/MatchNearProjection.java index e3ff283..5908ba2 100644 --- a/src/main/java/com/be/sportizebe/domain/match/repository/projection/MatchNearProjection.java +++ b/src/main/java/com/be/sportizebe/domain/match/repository/projection/MatchNearProjection.java @@ -9,4 +9,6 @@ public interface MatchNearProjection { Integer getMaxMembers(); String getStatus(); Double getDistanceM(); + Integer getDurationMinutes(); + Integer getEntryFee(); } diff --git a/src/main/java/com/be/sportizebe/domain/match/service/MatchService.java b/src/main/java/com/be/sportizebe/domain/match/service/MatchService.java index 1d53c99..e39bd34 100644 --- a/src/main/java/com/be/sportizebe/domain/match/service/MatchService.java +++ b/src/main/java/com/be/sportizebe/domain/match/service/MatchService.java @@ -15,6 +15,8 @@ public interface MatchService { void joinMatch(Long matchId, Long userId); // 매칭방 참여(정원 체크 + 중복 참가 체크 + 참가자 저장) + void leaveMatch(Long matchId, Long userId); // 매칭 취소(참가자 상태 LEFT + 인원 감소 + 상태 복구) + MatchDetailResponse getMatchDetail(Long matchId, Long userId); // 매칭방 상세 조회(방 정보 + 유저 기준 정보) List getNearMatches(MatchNearRequest request); // 내 주변 매칭 목록 조회 diff --git a/src/main/java/com/be/sportizebe/domain/match/service/MatchServiceImpl.java b/src/main/java/com/be/sportizebe/domain/match/service/MatchServiceImpl.java index a62440f..5bcac78 100644 --- a/src/main/java/com/be/sportizebe/domain/match/service/MatchServiceImpl.java +++ b/src/main/java/com/be/sportizebe/domain/match/service/MatchServiceImpl.java @@ -19,6 +19,7 @@ import org.springframework.transaction.annotation.Transactional; import com.be.sportizebe.domain.match.dto.request.MatchCreateRequest; import java.util.List; +import java.util.Optional; @Service @RequiredArgsConstructor @@ -76,8 +77,41 @@ public void joinMatch(Long matchId, Long userId) { throw new CustomException(MatchErrorCode.ALREADY_JOINED); } - // 5) 참가자 엔티티 생성 후 저장 - matchParticipantRepository.save(new MatchParticipant(matchRoom, user)); + // 5) 기존 참가 이력 있으면 재활성화, 없으면 신규 생성 + Optional existing = + matchParticipantRepository.findByMatchRoomAndUser(matchRoom, user); + + if (existing.isPresent()) { + existing.get().rejoin(); + } else { + matchParticipantRepository.save(new MatchParticipant(matchRoom, user)); + } + + // 6) 매칭방 인원 증가 및 상태 갱신 (DDD) + matchRoom.join(); + } + + @Override + public void leaveMatch(Long matchId, Long userId) { + + // 1) 매칭방 존재 확인 + MatchRoom matchRoom = matchRoomRepository.findById(matchId) + .orElseThrow(() -> new CustomException(MatchErrorCode.MATCH_NOT_FOUND)); + + // 2) 유저 존재 확인 + User user = userRepository.findById(userId) + .orElseThrow(() -> new CustomException(UserErrorCode.USER_NOT_FOUND)); + + // 3) JOINED 상태 참가자 조회 + MatchParticipant participant = matchParticipantRepository + .findByMatchRoomAndUserAndStatus(matchRoom, user, MatchParticipantStatus.JOINED) + .orElseThrow(() -> new CustomException(MatchErrorCode.NOT_JOINED)); + + // 4) 참가자 상태 변경 (DDD) + participant.leave(); + + // 5) 매칭방 인원 감소 및 상태 복구 (DDD) + matchRoom.leave(); } @Override