From 4195525b1dcf5748bd3e3e0274600fe04853dca2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=95=88=ED=9B=88=EA=B8=B0?= Date: Tue, 10 Feb 2026 01:41:13 +0900 Subject: [PATCH 1/6] =?UTF-8?q?=F0=9F=94=A7=20Settings:=20=EB=A1=9C?= =?UTF-8?q?=EC=BB=AC=20=EB=AA=A8=EB=8B=88=ED=84=B0=EB=A7=81=EC=9D=84=20?= =?UTF-8?q?=EC=9C=84=ED=95=9C=20Prometheus=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker/docker-compose.local.yml | 43 ++++++++++++++++++++++++++++++++- docker/prometheus.local.yml | 7 ++++++ 2 files changed, 49 insertions(+), 1 deletion(-) create mode 100644 docker/prometheus.local.yml diff --git a/docker/docker-compose.local.yml b/docker/docker-compose.local.yml index dc5445a..d229395 100644 --- a/docker/docker-compose.local.yml +++ b/docker/docker-compose.local.yml @@ -37,6 +37,47 @@ services: interval: 10s timeout: 3s retries: 5 + redis-exporter: + image: oliver006/redis_exporter:v1.62.0 + container_name: sportize-redis-exporter-local + restart: unless-stopped + ports: + - "9121:9121" + environment: + REDIS_ADDR: redis://sportize-redis-local:6379 + REDIS_PASSWORD: password + depends_on: + - redis + prometheus: + image: prom/prometheus:v2.52.0 + container_name: sportize-prometheus-local + restart: unless-stopped + ports: + - "9090:9090" + volumes: + - ./prometheus.local.yml:/etc/prometheus/prometheus.yml:ro + - sportize_prometheus_data_local:/prometheus + depends_on: + - redis-exporter + grafana: + image: grafana/grafana:10.4.2 + container_name: sportize-grafana-local + restart: unless-stopped + ports: + - "3000:3000" + environment: + GF_SECURITY_ADMIN_USER: admin + GF_SECURITY_ADMIN_PASSWORD: admin + GF_USERS_ALLOW_SIGN_UP: "false" + TZ: Asia/Seoul + volumes: + - sportize_grafana_data_local:/var/lib/grafana + depends_on: + - prometheus + + volumes: sportize_pgdata_local: - sportize_redisdata_local: \ No newline at end of file + sportize_redisdata_local: + sportize_prometheus_data_local: + sportize_grafana_data_local: \ No newline at end of file diff --git a/docker/prometheus.local.yml b/docker/prometheus.local.yml new file mode 100644 index 0000000..435612e --- /dev/null +++ b/docker/prometheus.local.yml @@ -0,0 +1,7 @@ +global: + scrape_interval: 5s + +scrape_configs: + - job_name: "redis-exporter" + static_configs: + - targets: ["redis-exporter:9121"] \ No newline at end of file From 3f087174f5e5053d4a0a40d3d2092932511f3de5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=95=88=ED=9B=88=EA=B8=B0?= Date: Tue, 10 Feb 2026 03:12:32 +0900 Subject: [PATCH 2/6] =?UTF-8?q?=E2=9C=A8Feat:=20=EB=A7=A4=EC=B9=AD=20?= =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8=20=EA=B8=B0=EB=B3=B8=20=EA=B5=AC?= =?UTF-8?q?=EC=A1=B0=20=EC=B6=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../match/controller/MatchController.java | 51 +++++++++++++++ .../match/dto/request/MatchCreateRequest.java | 18 ++++++ .../dto/response/MatchDetailResponse.java | 32 ++++++++++ .../match/dto/response/MatchResponse.java | 21 +++++++ .../domain/match/entity/MatchParticipant.java | 43 +++++++++++++ .../match/entity/MatchParticipantStatus.java | 6 ++ .../domain/match/entity/MatchRoom.java | 34 ++++++++++ .../MatchParticipantRepository.java | 28 +++++++++ .../match/repository/MatchRoomRepository.java | 6 ++ .../domain/match/service/MatchService.java | 5 ++ .../match/service/MatchServiceImpl.java | 63 +++++++++++++++++++ 11 files changed, 307 insertions(+) create mode 100644 src/main/java/com/be/sportizebe/domain/match/controller/MatchController.java create mode 100644 src/main/java/com/be/sportizebe/domain/match/dto/request/MatchCreateRequest.java create mode 100644 src/main/java/com/be/sportizebe/domain/match/dto/response/MatchDetailResponse.java create mode 100644 src/main/java/com/be/sportizebe/domain/match/dto/response/MatchResponse.java create mode 100644 src/main/java/com/be/sportizebe/domain/match/entity/MatchParticipant.java create mode 100644 src/main/java/com/be/sportizebe/domain/match/entity/MatchParticipantStatus.java create mode 100644 src/main/java/com/be/sportizebe/domain/match/entity/MatchRoom.java create mode 100644 src/main/java/com/be/sportizebe/domain/match/repository/MatchParticipantRepository.java create mode 100644 src/main/java/com/be/sportizebe/domain/match/repository/MatchRoomRepository.java create mode 100644 src/main/java/com/be/sportizebe/domain/match/service/MatchService.java create mode 100644 src/main/java/com/be/sportizebe/domain/match/service/MatchServiceImpl.java 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 new file mode 100644 index 0000000..de70723 --- /dev/null +++ b/src/main/java/com/be/sportizebe/domain/match/controller/MatchController.java @@ -0,0 +1,51 @@ +package com.be.sportizebe.domain.match.controller; + +import com.be.sportizebe.domain.match.dto.request.MatchCreateRequest; +import com.be.sportizebe.domain.match.dto.response.MatchDetailResponse; +import com.be.sportizebe.domain.match.service.MatchService; +import com.be.sportizebe.domain.user.entity.User; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/matches") +@Tag(name = "match", description = "운동 매칭 관련 API") +public class MatchController { + + private final MatchService matchService; + + @Operation(summary = "매칭방 생성") + @PostMapping + public ResponseEntity> createMatch( + @RequestBody MatchCreateRequest request + ) { + MatchResponse response = matchService.createMatch(request); + return ResponseEntity.status(HttpStatus.CREATED) + .body(BaseResponse.success("매칭방 생성 성공", response)); + } + + @Operation(summary = "매칭 참여") + @PostMapping("/{matchId}/join") + public ResponseEntity> joinMatch( + @PathVariable Long matchId, + @AuthenticationPrincipal UserAuthInfo userAuthInfo + ) { + matchService.joinMatch(matchId, userAuthInfo.getId()); + return ResponseEntity.ok(BaseResponse.success("매칭 참여 성공", null)); + } + + @Operation(summary = "매칭 상세 조회") + @GetMapping("/{matchId}") + public ResponseEntity> getMatchDetail( + @PathVariable Long matchId, + @AuthenticationPrincipal UserAuthInfo userAuthInfo + ) { + MatchDetailResponse response = + matchService.getMatchDetail(matchId, userAuthInfo.getId()); + return ResponseEntity.ok(BaseResponse.success("매칭 상세 조회 성공", response)); + } +} \ No newline at end of file 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 new file mode 100644 index 0000000..32bbd1f --- /dev/null +++ b/src/main/java/com/be/sportizebe/domain/match/dto/request/MatchCreateRequest.java @@ -0,0 +1,18 @@ +package com.be.sportizebe.domain.match.dto.request; + +import com.be.sportizebe.domain.user.entity.SportType; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "매칭 생성 요청 정보") +public record MatchCreateRequest( + + @Schema(description = "스포츠 종류", example = "SOCCER") + SportType sportType, + + @Schema(description = "체육시설 ID", example = "123") + Long facilityId, + + @Schema(description = "최대 참여 인원 수", example = "10") + Integer maxMembers + +) {} \ No newline at end of file diff --git a/src/main/java/com/be/sportizebe/domain/match/dto/response/MatchDetailResponse.java b/src/main/java/com/be/sportizebe/domain/match/dto/response/MatchDetailResponse.java new file mode 100644 index 0000000..cce8410 --- /dev/null +++ b/src/main/java/com/be/sportizebe/domain/match/dto/response/MatchDetailResponse.java @@ -0,0 +1,32 @@ +package com.be.sportizebe.domain.match.dto.response; + +import com.be.sportizebe.domain.user.entity.SportType; +import io.swagger.v3.oas.annotations.media.Schema; + +import java.util.List; + +@Schema(description = "매칭 상세 응답 정보") +public record MatchDetailResponse( + + @Schema(description = "매칭방 ID", example = "42") + Long matchId, + + @Schema(description = "스포츠 종류", example = "BADMINTON") + SportType sportType, + + @Schema(description = "체육시설 ID", example = "987") + Long facilityId, + + @Schema(description = "최대 참여 가능 인원", example = "12") + Integer maxMembers, + + @Schema(description = "현재 참여 중인 인원 수", example = "7") + Integer currentMemberCount, + + @Schema(description = "참여한 유저 ID 리스트", example = "[101,102,103]") + List participantIds, + + @Schema(description = "요청 유저가 참여 중인지 여부", example = "true") + boolean joined + +) {} \ No newline at end of file diff --git a/src/main/java/com/be/sportizebe/domain/match/dto/response/MatchResponse.java b/src/main/java/com/be/sportizebe/domain/match/dto/response/MatchResponse.java new file mode 100644 index 0000000..12a5dc2 --- /dev/null +++ b/src/main/java/com/be/sportizebe/domain/match/dto/response/MatchResponse.java @@ -0,0 +1,21 @@ +package com.be.sportizebe.domain.match.dto.response; + +import com.be.sportizebe.domain.user.entity.SportType; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "매칭 응답 정보") +public record MatchResponse( + + @Schema(description = "매칭방 ID", example = "1") + Long matchId, + + @Schema(description = "스포츠 종류", example = "BASKETBALL") + SportType sportType, + + @Schema(description = "체육시설 ID", example = "321") + Long facilityId, + + @Schema(description = "최대 참여 인원", example = "8") + Integer maxMembers + +) {} 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 new file mode 100644 index 0000000..99a4394 --- /dev/null +++ b/src/main/java/com/be/sportizebe/domain/match/entity/MatchParticipant.java @@ -0,0 +1,43 @@ +package com.be.sportizebe.domain.match.entity; + +import com.be.sportizebe.domain.user.entity.User; +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDateTime; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table( + uniqueConstraints = { + @UniqueConstraint(columnNames = {"match_room_id", "user_id"}) + } +) +public class MatchParticipant { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "match_room_id", nullable = false) + private MatchRoom matchRoom; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private MatchParticipantStatus status; + + private LocalDateTime joinedAt; + + public MatchParticipant(MatchRoom matchRoom, User user) { + this.matchRoom = matchRoom; + this.user = user; + this.status = MatchParticipantStatus.JOINED; + this.joinedAt = LocalDateTime.now(); + } +} \ 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 new file mode 100644 index 0000000..e19b297 --- /dev/null +++ b/src/main/java/com/be/sportizebe/domain/match/entity/MatchParticipantStatus.java @@ -0,0 +1,6 @@ +package com.be.sportizebe.domain.match.entity; + +public enum MatchParticipantStatus { + 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 new file mode 100644 index 0000000..318c69d --- /dev/null +++ b/src/main/java/com/be/sportizebe/domain/match/entity/MatchRoom.java @@ -0,0 +1,34 @@ +package com.be.sportizebe.domain.match.entity; + +import com.be.sportizebe.domain.user.entity.SportType; +import com.be.sportizebe.global.common.BaseTimeEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class MatchRoom extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private SportType sportType; + + @Column(nullable = false) + private Long facilityId; + + @Column(nullable = false) + private Integer maxMembers; + + public MatchRoom(SportType sportType, Long facilityId, Integer maxMembers) { + this.sportType = sportType; + this.facilityId = facilityId; + this.maxMembers = maxMembers; + } +} \ No newline at end of file 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 new file mode 100644 index 0000000..a85fdd4 --- /dev/null +++ b/src/main/java/com/be/sportizebe/domain/match/repository/MatchParticipantRepository.java @@ -0,0 +1,28 @@ +package com.be.sportizebe.domain.match.repository; + +import com.be.sportizebe.domain.match.entity.MatchParticipant; +import com.be.sportizebe.domain.match.entity.MatchParticipantStatus; +import com.be.sportizebe.domain.match.entity.MatchRoom; +import com.be.sportizebe.domain.user.entity.User; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface MatchParticipantRepository extends JpaRepository { + + // 해당 매칭 + 유저 + 상태가 이미 존재하는지 확인 + boolean existsByMatchRoomAndUserAndStatus( + MatchRoom matchRoom, User user, MatchParticipantStatus status + ); + + // 매칭방에 특정 상태인 참가자 수 카운트 + long countByMatchRoomAndStatus( + MatchRoom matchRoom, MatchParticipantStatus status + ); + + // 주어진 방에 참여중인 참가자 리스트 전체 조회 + List findAllByMatchRoomAndStatus( + MatchRoom matchRoom, + MatchParticipantStatus status + ); +} \ 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 new file mode 100644 index 0000000..980444a --- /dev/null +++ b/src/main/java/com/be/sportizebe/domain/match/repository/MatchRoomRepository.java @@ -0,0 +1,6 @@ +package com.be.sportizebe.domain.match.repository; +import com.be.sportizebe.domain.match.entity.MatchRoom; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface MatchRoomRepository extends JpaRepository { +} 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 new file mode 100644 index 0000000..cf39bc9 --- /dev/null +++ b/src/main/java/com/be/sportizebe/domain/match/service/MatchService.java @@ -0,0 +1,5 @@ +package com.be.sportizebe.domain.match.service; + +public class MatchService { + +} 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 new file mode 100644 index 0000000..0ccba1b --- /dev/null +++ b/src/main/java/com/be/sportizebe/domain/match/service/MatchServiceImpl.java @@ -0,0 +1,63 @@ +package com.be.sportizebe.domain.match.service; + +import com.be.sportizebe.domain.match.dto.request.MatchCreateRequest; +import com.be.sportizebe.domain.match.dto.response.MatchDetailResponse; +import com.be.sportizebe.domain.match.entity.*; +import com.be.sportizebe.domain.match.repository.*; +import com.be.sportizebe.domain.user.entity.User; +import com.be.sportizebe.global.exception.GlobalErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional +public class MatchServiceImpl implements MatchService { + + private final MatchRoomRepository matchRoomRepository; + private final MatchParticipantRepository matchParticipantRepository; + + @Override + public Long createMatch(MatchCreateRequest request, User host) { + MatchRoom matchRoom = new MatchRoom( + request.sportType(), request.facilityId(), request.maxMembers(), host + ); + + matchRoomRepository.save(matchRoom); + + // host 자동 참여 + matchParticipantRepository.save(new MatchParticipant(matchRoom, host)); + return matchRoom.getId(); + } + + @Override + public void joinMatch(Long matchId, User user) { + MatchRoom matchRoom = matchRoomRepository.findById(matchId) + .orElseThrow(() -> new SportizeException(GlobalErrorCode.NOT_FOUND_MATCH)); + + long count = matchParticipantRepository.countByMatchRoomAndStatus( + matchRoom, MatchParticipantStatus.JOINED + ); + + if (count >= matchRoom.getMaxMembers()) { + throw new SportizeException(GlobalErrorCode.MATCH_FULL); + } + + boolean exists = matchParticipantRepository.existsByMatchRoomAndUserAndStatus( + matchRoom, user, MatchParticipantStatus.JOINED + ); + + if (exists) throw new SportizeException(GlobalErrorCode.ALREADY_JOINED); + + matchParticipantRepository.save(new MatchParticipant(matchRoom, user)); + } + + @Override + public MatchDetailResponse getMatchDetail(Long matchId, User user) { + MatchRoom matchRoom = matchRoomRepository.findById(matchId) + .orElseThrow(() -> new SportizeException(GlobalErrorCode.NOT_FOUND_MATCH)); + + return MatchDetailResponse.of(matchRoom, user); + } +} \ No newline at end of file From 050d6910610b478b039753dcf72d4b391c04e4ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=95=88=ED=9B=88=EA=B8=B0?= Date: Thu, 12 Feb 2026 16:19:46 +0900 Subject: [PATCH 3/6] =?UTF-8?q?:sparkles:Feat:=20=EB=A7=A4=EC=B9=AD(match)?= =?UTF-8?q?=20=EB=8F=84=EB=A9=94=EC=9D=B8=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../match/controller/MatchController.java | 27 +++++-- .../match/dto/request/MatchCreateRequest.java | 5 +- .../dto/response/MatchDetailResponse.java | 33 ++++++++- .../response/MatchParticipantResponse.java | 33 +++++++++ .../match/dto/response/MatchResponse.java | 29 ++++++-- .../domain/match/entity/MatchParticipant.java | 25 ++++++- .../match/entity/MatchParticipantStatus.java | 1 - .../domain/match/entity/MatchRoom.java | 47 +++++++++--- .../domain/match/entity/MatchStatus.java | 7 ++ .../match/exception/MatchErrorCode.java | 37 ++++++++++ .../MatchParticipantRepository.java | 13 +--- .../domain/match/service/MatchService.java | 13 +++- .../match/service/MatchServiceImpl.java | 72 +++++++++++++------ 13 files changed, 279 insertions(+), 63 deletions(-) create mode 100644 src/main/java/com/be/sportizebe/domain/match/dto/response/MatchParticipantResponse.java create mode 100644 src/main/java/com/be/sportizebe/domain/match/entity/MatchStatus.java create mode 100644 src/main/java/com/be/sportizebe/domain/match/exception/MatchErrorCode.java 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 de70723..449dc2b 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 @@ -2,13 +2,26 @@ import com.be.sportizebe.domain.match.dto.request.MatchCreateRequest; import com.be.sportizebe.domain.match.dto.response.MatchDetailResponse; +import com.be.sportizebe.domain.match.dto.response.MatchResponse; import com.be.sportizebe.domain.match.service.MatchService; -import com.be.sportizebe.domain.user.entity.User; +import com.be.sportizebe.global.cache.dto.UserAuthInfo; +import com.be.sportizebe.global.response.BaseResponse; + import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; + +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.web.bind.annotation.*; +import org.springframework.web.bind.annotation.GetMapping; +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 @@ -18,16 +31,16 @@ public class MatchController { private final MatchService matchService; - @Operation(summary = "매칭방 생성") + @Operation(summary = "매칭 생성") @PostMapping public ResponseEntity> createMatch( - @RequestBody MatchCreateRequest request + @AuthenticationPrincipal UserAuthInfo userAuthInfo, + @RequestBody @Valid MatchCreateRequest request ) { - MatchResponse response = matchService.createMatch(request); + MatchResponse response = matchService.createMatch(userAuthInfo.getId(), request); return ResponseEntity.status(HttpStatus.CREATED) - .body(BaseResponse.success("매칭방 생성 성공", response)); + .body(BaseResponse.success("매칭 생성 성공", response)); } - @Operation(summary = "매칭 참여") @PostMapping("/{matchId}/join") public ResponseEntity> joinMatch( 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 32bbd1f..b966e1a 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 @@ -2,17 +2,20 @@ import com.be.sportizebe.domain.user.entity.SportType; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; @Schema(description = "매칭 생성 요청 정보") public record MatchCreateRequest( @Schema(description = "스포츠 종류", example = "SOCCER") - SportType sportType, + SportType sportsName, @Schema(description = "체육시설 ID", example = "123") Long facilityId, @Schema(description = "최대 참여 인원 수", example = "10") + @Min(2) @Max(20) Integer maxMembers ) {} \ No newline at end of file diff --git a/src/main/java/com/be/sportizebe/domain/match/dto/response/MatchDetailResponse.java b/src/main/java/com/be/sportizebe/domain/match/dto/response/MatchDetailResponse.java index cce8410..30fd85c 100644 --- a/src/main/java/com/be/sportizebe/domain/match/dto/response/MatchDetailResponse.java +++ b/src/main/java/com/be/sportizebe/domain/match/dto/response/MatchDetailResponse.java @@ -1,6 +1,9 @@ package com.be.sportizebe.domain.match.dto.response; +import com.be.sportizebe.domain.match.entity.MatchParticipantStatus; +import com.be.sportizebe.domain.match.entity.MatchRoom; import com.be.sportizebe.domain.user.entity.SportType; +import com.be.sportizebe.domain.user.entity.User; import io.swagger.v3.oas.annotations.media.Schema; import java.util.List; @@ -11,8 +14,8 @@ public record MatchDetailResponse( @Schema(description = "매칭방 ID", example = "42") Long matchId, - @Schema(description = "스포츠 종류", example = "BADMINTON") - SportType sportType, + @Schema(description = "스포츠 종류", example = "SOCCER") + SportType sportsName, @Schema(description = "체육시설 ID", example = "987") Long facilityId, @@ -29,4 +32,28 @@ public record MatchDetailResponse( @Schema(description = "요청 유저가 참여 중인지 여부", example = "true") boolean joined -) {} \ No newline at end of file +) { + public static MatchDetailResponse of( + MatchRoom matchRoom, + User user + ) { + // JOINED 상태인 참가자만 추출 + List participantIds = matchRoom.getParticipants().stream() + .filter(p -> p.getStatus() == MatchParticipantStatus.JOINED) + .map(p -> p.getUser().getId()) + .toList(); + + // 요청 유저가 참가 중인지 여부 판단 + boolean joined = participantIds.contains(user.getId()); + + return new MatchDetailResponse( + matchRoom.getId(), + matchRoom.getSportsName(), + matchRoom.getFacilityId(), + matchRoom.getMaxMembers(), + participantIds.size(), + participantIds, + joined + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/be/sportizebe/domain/match/dto/response/MatchParticipantResponse.java b/src/main/java/com/be/sportizebe/domain/match/dto/response/MatchParticipantResponse.java new file mode 100644 index 0000000..970cfe3 --- /dev/null +++ b/src/main/java/com/be/sportizebe/domain/match/dto/response/MatchParticipantResponse.java @@ -0,0 +1,33 @@ +package com.be.sportizebe.domain.match.dto.response; + +import com.be.sportizebe.domain.match.entity.MatchParticipant; +import com.be.sportizebe.domain.match.entity.MatchParticipantStatus; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(title = "MatchParticipantResponse", description = "매칭 참여자 응답") +public record MatchParticipantResponse( + // 매칭방에 참여한 사용자 정보 응답용 DTO + // 매칭 상세 조회 API에서 참여자 목록(List)으로 사용됨 + + @Schema(description = "사용자 ID", example = "10") + Long userId, + + @Schema(description = "사용자 닉네임", example = "닉네임") + String nickname, + + @Schema(description = "프로필 이미지 URL", example = "https://example.com/profile.png") + String profileImageUrl, + + @Schema(description = "참여 상태 (JOINED / LEFT)", example = "JOINED") + MatchParticipantStatus status + +) { +public static MatchParticipantResponse from(MatchParticipant p) { + return new MatchParticipantResponse( + p.getUser().getId(), + p.getUser().getNickname(), + p.getUser().getProfileImage(), + p.getStatus() + ); +} + } diff --git a/src/main/java/com/be/sportizebe/domain/match/dto/response/MatchResponse.java b/src/main/java/com/be/sportizebe/domain/match/dto/response/MatchResponse.java index 12a5dc2..9036d31 100644 --- a/src/main/java/com/be/sportizebe/domain/match/dto/response/MatchResponse.java +++ b/src/main/java/com/be/sportizebe/domain/match/dto/response/MatchResponse.java @@ -1,21 +1,42 @@ package com.be.sportizebe.domain.match.dto.response; +import com.be.sportizebe.domain.match.entity.MatchRoom; +import com.be.sportizebe.domain.match.entity.MatchStatus; import com.be.sportizebe.domain.user.entity.SportType; import io.swagger.v3.oas.annotations.media.Schema; @Schema(description = "매칭 응답 정보") public record MatchResponse( - + // 참여자 목록이 안 들어감 + // 이유는? -> 참여자까지 다 포함하면 무거워짐 / n+1 문제 발생 + // 매칭 생성 API, 매칭 목록 조회 API에 쓰임 @Schema(description = "매칭방 ID", example = "1") Long matchId, @Schema(description = "스포츠 종류", example = "BASKETBALL") - SportType sportType, + SportType sportsName, @Schema(description = "체육시설 ID", example = "321") Long facilityId, + @Schema(description ="현재 인원", example = "1") + int curMembers, + @Schema(description = "최대 참여 인원", example = "8") - Integer maxMembers + Integer maxMembers, + + @Schema(description ="모집 상태", example = "OPEN") + MatchStatus status -) {} +) { + public static MatchResponse from(MatchRoom matchRoom) { + return new MatchResponse( + matchRoom.getId(), + matchRoom.getSportsName(), + matchRoom.getFacilityId(), + matchRoom.getCurMembers(), + matchRoom.getMaxMembers(), + matchRoom.getStatus() + ); + } +} 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 99a4394..99bbeac 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 @@ -1,6 +1,7 @@ package com.be.sportizebe.domain.match.entity; import com.be.sportizebe.domain.user.entity.User; +import com.be.sportizebe.global.common.BaseTimeEntity; import jakarta.persistence.*; import lombok.*; @@ -8,36 +9,54 @@ @Entity @Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Builder +@AllArgsConstructor +@NoArgsConstructor @Table( + name = "match_participants", uniqueConstraints = { - @UniqueConstraint(columnNames = {"match_room_id", "user_id"}) + @UniqueConstraint(name = "uk_match_room_user", columnNames = {"match_room_id", "user_id"}) } ) -public class MatchParticipant { +public class MatchParticipant extends BaseTimeEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; + // ERD: Match Participants.id (match room FK) @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "match_room_id", nullable = false) private MatchRoom matchRoom; + // ERD: Match Participants.id2 (user FK) @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "user_id", nullable = false) private User user; + // ERD: isStatus @Enumerated(EnumType.STRING) @Column(nullable = false) private MatchParticipantStatus status; + @Column(nullable = false) private LocalDateTime joinedAt; + private LocalDateTime leftAt; + public MatchParticipant(MatchRoom matchRoom, User user) { this.matchRoom = matchRoom; this.user = user; this.status = MatchParticipantStatus.JOINED; this.joinedAt = LocalDateTime.now(); } + + public void leave() { + this.status = MatchParticipantStatus.LEFT; + this.leftAt = LocalDateTime.now(); + } + + public boolean isJoined() { + return this.status == MatchParticipantStatus.JOINED; + } } \ 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 e19b297..cc46c60 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 @@ -2,5 +2,4 @@ public enum MatchParticipantStatus { 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 318c69d..bed63f0 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 @@ -1,34 +1,59 @@ package com.be.sportizebe.domain.match.entity; +import com.be.sportizebe.domain.match.dto.request.MatchCreateRequest; import com.be.sportizebe.domain.user.entity.SportType; import com.be.sportizebe.global.common.BaseTimeEntity; import jakarta.persistence.*; -import lombok.AccessLevel; -import lombok.Getter; -import lombok.NoArgsConstructor; +import lombok.*; + +import java.util.ArrayList; +import java.util.List; @Entity @Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -public class MatchRoom extends BaseEntity { +@Builder +@AllArgsConstructor +@NoArgsConstructor +@Table(name = "match_rooms") +public class MatchRoom extends BaseTimeEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; + // ERD: sportsName @Enumerated(EnumType.STRING) @Column(nullable = false) - private SportType sportType; + private SportType sportsName; @Column(nullable = false) private Long facilityId; @Column(nullable = false) - private Integer maxMembers; + private int curMembers; + + @Column(nullable = false) + private int maxMembers; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private MatchStatus status; + + @OneToMany(mappedBy = "matchRoom", cascade = CascadeType.ALL, orphanRemoval = true) + @Builder.Default + private List participants = new ArrayList<>(); + + public boolean isFull() { + return this.curMembers >= this.maxMembers; + } - public MatchRoom(SportType sportType, Long facilityId, Integer maxMembers) { - this.sportType = sportType; - this.facilityId = facilityId; - this.maxMembers = maxMembers; + public static MatchRoom create(MatchCreateRequest request) { + return MatchRoom.builder() + .sportsName(request.sportsName()) + .facilityId(request.facilityId()) + .curMembers(0) + .maxMembers(request.maxMembers()) + .status(MatchStatus.OPEN) + .build(); } } \ No newline at end of file diff --git a/src/main/java/com/be/sportizebe/domain/match/entity/MatchStatus.java b/src/main/java/com/be/sportizebe/domain/match/entity/MatchStatus.java new file mode 100644 index 0000000..794cace --- /dev/null +++ b/src/main/java/com/be/sportizebe/domain/match/entity/MatchStatus.java @@ -0,0 +1,7 @@ +package com.be.sportizebe.domain.match.entity; + +public enum MatchStatus { + OPEN, // 참여 가능 + FULL, // 정원 마감 + CLOSED // 운영상 종료(옵션) +} 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 new file mode 100644 index 0000000..0c9db63 --- /dev/null +++ b/src/main/java/com/be/sportizebe/domain/match/exception/MatchErrorCode.java @@ -0,0 +1,37 @@ +package com.be.sportizebe.domain.match.exception; + +import com.be.sportizebe.global.exception.model.BaseErrorCode; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +public enum MatchErrorCode implements BaseErrorCode { + + MATCH_NOT_FOUND( + HttpStatus.NOT_FOUND, + "MATCH_404", + "매칭방을 찾을 수 없습니다." + ), + + MATCH_FULL( + HttpStatus.BAD_REQUEST, + "MATCH_400_FULL", + "매칭방 정원이 가득 찼습니다." + ), + + ALREADY_JOINED( + HttpStatus.BAD_REQUEST, + "MATCH_400_ALREADY_JOINED", + "이미 해당 매칭에 참가 중입니다." + ); + + private final HttpStatus status; + private final String code; + private final String message; + + MatchErrorCode(HttpStatus status, String code, String message) { + this.status = status; + this.code = code; + this.message = message; + } +} \ No newline at end of file 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 a85fdd4..3292af9 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 @@ -11,18 +11,11 @@ public interface MatchParticipantRepository extends JpaRepository { // 해당 매칭 + 유저 + 상태가 이미 존재하는지 확인 - boolean existsByMatchRoomAndUserAndStatus( - MatchRoom matchRoom, User user, MatchParticipantStatus status - ); + boolean existsByMatchRoomAndUserAndStatus(MatchRoom matchRoom, User user, MatchParticipantStatus status); // 매칭방에 특정 상태인 참가자 수 카운트 - long countByMatchRoomAndStatus( - MatchRoom matchRoom, MatchParticipantStatus status - ); + long countByMatchRoomAndStatus(MatchRoom matchRoom, MatchParticipantStatus status); // 주어진 방에 참여중인 참가자 리스트 전체 조회 - List findAllByMatchRoomAndStatus( - MatchRoom matchRoom, - MatchParticipantStatus status - ); + List findAllByMatchRoomAndStatus(MatchRoom matchRoom, MatchParticipantStatus status); } \ No newline at end of file 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 cf39bc9..bb5ba3c 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 @@ -1,5 +1,16 @@ package com.be.sportizebe.domain.match.service; -public class MatchService { +import com.be.sportizebe.domain.match.dto.request.MatchCreateRequest; +import com.be.sportizebe.domain.match.dto.response.MatchDetailResponse; +import com.be.sportizebe.domain.match.dto.response.MatchResponse; +import com.be.sportizebe.domain.user.entity.User; + +public interface MatchService { + + MatchResponse createMatch(Long userId, MatchCreateRequest request); // 매칭 생성 + + void joinMatch(Long matchId, Long userId); // 매칭방 참여(정원 체크 + 중복 참가 체크 + 참가자 저장) + + MatchDetailResponse getMatchDetail(Long matchId, Long userId); // 매칭방 상세 조회(방 정보 + 유저 기준 정보) } 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 0ccba1b..e33d00a 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 @@ -1,14 +1,21 @@ package com.be.sportizebe.domain.match.service; -import com.be.sportizebe.domain.match.dto.request.MatchCreateRequest; import com.be.sportizebe.domain.match.dto.response.MatchDetailResponse; -import com.be.sportizebe.domain.match.entity.*; -import com.be.sportizebe.domain.match.repository.*; +import com.be.sportizebe.domain.match.dto.response.MatchResponse; +import com.be.sportizebe.domain.match.entity.MatchParticipant; +import com.be.sportizebe.domain.match.entity.MatchParticipantStatus; +import com.be.sportizebe.domain.match.entity.MatchRoom; +import com.be.sportizebe.domain.match.exception.MatchErrorCode; +import com.be.sportizebe.domain.match.repository.MatchParticipantRepository; +import com.be.sportizebe.domain.match.repository.MatchRoomRepository; import com.be.sportizebe.domain.user.entity.User; -import com.be.sportizebe.global.exception.GlobalErrorCode; +import com.be.sportizebe.domain.user.exception.UserErrorCode; +import com.be.sportizebe.domain.user.repository.UserRepository; +import com.be.sportizebe.global.exception.CustomException; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import com.be.sportizebe.domain.match.dto.request.MatchCreateRequest; @Service @RequiredArgsConstructor @@ -17,47 +24,68 @@ public class MatchServiceImpl implements MatchService { private final MatchRoomRepository matchRoomRepository; private final MatchParticipantRepository matchParticipantRepository; + private final UserRepository userRepository; @Override - public Long createMatch(MatchCreateRequest request, User host) { - MatchRoom matchRoom = new MatchRoom( - request.sportType(), request.facilityId(), request.maxMembers(), host - ); + // 실제로는 관리자용 메서드인데 더미 넣으려고 만듦 + public MatchResponse createMatch(Long userId, MatchCreateRequest request) { + + // 1) 유저 존재 확인 + User user = userRepository.findById(userId) + .orElseThrow(() -> new CustomException(UserErrorCode.USER_NOT_FOUND)); + + // 2) 매칭방 생성 (엔티티 팩토리 메서드 사용) + MatchRoom matchRoom = MatchRoom.create(request); + MatchRoom savedRoom = matchRoomRepository.save(matchRoom); - matchRoomRepository.save(matchRoom); + // 3) 생성자 자동 참여 처리 + matchParticipantRepository.save(new MatchParticipant(savedRoom, user)); - // host 자동 참여 - matchParticipantRepository.save(new MatchParticipant(matchRoom, host)); - return matchRoom.getId(); + return MatchResponse.from(savedRoom); } @Override - public void joinMatch(Long matchId, User user) { + public void joinMatch(Long matchId, Long userId) { + + // 1) 매칭방 존재 확인 MatchRoom matchRoom = matchRoomRepository.findById(matchId) - .orElseThrow(() -> new SportizeException(GlobalErrorCode.NOT_FOUND_MATCH)); + .orElseThrow(() -> new CustomException(MatchErrorCode.MATCH_NOT_FOUND)); + + // 2) 유저 존재 확인 + User user = userRepository.findById(userId) + .orElseThrow(() -> new CustomException(UserErrorCode.USER_NOT_FOUND)); - long count = matchParticipantRepository.countByMatchRoomAndStatus( + // 3) 현재 참가자 수(JOINED 상태만) 조회 후 정원 초과 체크 + long joinedCount = matchParticipantRepository.countByMatchRoomAndStatus( matchRoom, MatchParticipantStatus.JOINED ); - if (count >= matchRoom.getMaxMembers()) { - throw new SportizeException(GlobalErrorCode.MATCH_FULL); + if (joinedCount >= matchRoom.getMaxMembers()) { + throw new CustomException(MatchErrorCode.MATCH_FULL); } - boolean exists = matchParticipantRepository.existsByMatchRoomAndUserAndStatus( + // 4) 동일 유저가 이미 참가(JOINED) 중인지 중복 체크 + boolean alreadyJoined = matchParticipantRepository.existsByMatchRoomAndUserAndStatus( matchRoom, user, MatchParticipantStatus.JOINED ); - if (exists) throw new SportizeException(GlobalErrorCode.ALREADY_JOINED); + if (alreadyJoined) { + throw new CustomException(MatchErrorCode.ALREADY_JOINED); + } + // 5) 참가자 엔티티 생성 후 저장 matchParticipantRepository.save(new MatchParticipant(matchRoom, user)); } @Override - public MatchDetailResponse getMatchDetail(Long matchId, User user) { + @Transactional(readOnly = true) + public MatchDetailResponse getMatchDetail(Long matchId, Long userId) { + // 1) 매칭방 존재 확인 MatchRoom matchRoom = matchRoomRepository.findById(matchId) - .orElseThrow(() -> new SportizeException(GlobalErrorCode.NOT_FOUND_MATCH)); - + .orElseThrow(() -> new CustomException(MatchErrorCode.MATCH_NOT_FOUND)); + User user = userRepository.findById(userId) + .orElseThrow(() -> new CustomException(UserErrorCode.USER_NOT_FOUND)); + // 2) 응답 DTO 생성 (matchRoom + user 기준 정보 포함) return MatchDetailResponse.of(matchRoom, user); } } \ No newline at end of file From 95f838ff6e6420a8288eea9b81b82a32c3ece831 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=95=88=ED=9B=88=EA=B8=B0?= Date: Thu, 12 Feb 2026 16:59:28 +0900 Subject: [PATCH 4/6] =?UTF-8?q?:sparkles:Feat:=20=EC=B2=B4=EC=9C=A1?= =?UTF-8?q?=EC=8B=9C=EC=84=A4=20=EB=93=B1=EB=A1=9D=20API=20=EB=B0=8F=20?= =?UTF-8?q?=EB=8B=A8=20=EA=B1=B4=20=EC=A1=B0=ED=9A=8C=20API=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/SportsFacilityController.java | 14 +++++++ .../dto/request/FacilityCreateRequest.java | 38 ++++++++++++++++++ .../dto/response/FacilityResponse.java | 31 ++++++++++++++ .../facility/mapper/FacilityMapper.java | 16 ++++++++ .../repository/SportsFacilityRepository.java | 3 +- .../service/SportsFacilityService.java | 6 +++ .../service/SportsFacilityServiceImpl.java | 40 ++++++++++++++++++- 7 files changed, 146 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/be/sportizebe/domain/facility/dto/request/FacilityCreateRequest.java create mode 100644 src/main/java/com/be/sportizebe/domain/facility/dto/response/FacilityResponse.java diff --git a/src/main/java/com/be/sportizebe/domain/facility/controller/SportsFacilityController.java b/src/main/java/com/be/sportizebe/domain/facility/controller/SportsFacilityController.java index 58a024d..52c1690 100644 --- a/src/main/java/com/be/sportizebe/domain/facility/controller/SportsFacilityController.java +++ b/src/main/java/com/be/sportizebe/domain/facility/controller/SportsFacilityController.java @@ -1,10 +1,12 @@ // src/main/java/com/be/sportizebe/domain/facility/controller/SportsFacilityController.java package com.be.sportizebe.domain.facility.controller; +import com.be.sportizebe.domain.facility.dto.request.FacilityCreateRequest; import com.be.sportizebe.domain.facility.dto.request.FacilityMarkerRequest; import com.be.sportizebe.domain.facility.dto.request.FacilityNearRequest; import com.be.sportizebe.domain.facility.dto.response.FacilityMarkerResponse; import com.be.sportizebe.domain.facility.dto.response.FacilityNearResponse; +import com.be.sportizebe.domain.facility.dto.response.FacilityResponse; import com.be.sportizebe.domain.facility.service.SportsFacilityService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; @@ -40,4 +42,16 @@ public List markers( ) { return sportsFacilityService.getMarkers(request); } + + @Operation(summary = "체육시설 등록", description = "체육시설을 등록합니다. (관리자/개발자용)") + @PostMapping + public FacilityResponse create(@Valid @RequestBody FacilityCreateRequest request) { + return sportsFacilityService.create(request); + } + + @Operation(summary = "체육시설 단건 조회", description = "facilityId로 체육시설을 단건 조회합니다.") + @GetMapping("/{facilityId}") + public FacilityResponse getById(@PathVariable Long facilityId) { + return sportsFacilityService.getById(facilityId); + } } \ No newline at end of file diff --git a/src/main/java/com/be/sportizebe/domain/facility/dto/request/FacilityCreateRequest.java b/src/main/java/com/be/sportizebe/domain/facility/dto/request/FacilityCreateRequest.java new file mode 100644 index 0000000..1fc2c96 --- /dev/null +++ b/src/main/java/com/be/sportizebe/domain/facility/dto/request/FacilityCreateRequest.java @@ -0,0 +1,38 @@ +package com.be.sportizebe.domain.facility.dto.request; + +import com.be.sportizebe.domain.facility.entity.FacilityType; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.DecimalMax; +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +@Schema(description = "체육시설 등록 요청") +public record FacilityCreateRequest( + + @Schema(description = "체육시설 이름", example = "OO 풋살장") + @NotBlank + String facilityName, + + @Schema(description = "시설 소개", example = "샤워실/주차장 있음") + String introduce, + + @Schema(description = "썸네일 이미지 URL", example = "https://example.com/facility/thumbnail.jpg") + String thumbnailUrl, + + @Schema(description = "시설 종목 타입", example = "SOCCER") + @NotNull + FacilityType facilityType, + + @Schema(description = "위도", example = "37.563") + @DecimalMin("-90.0") + @DecimalMax("90.0") + double lat, + + @Schema(description = "경도", example = "126.982") + @DecimalMin("-180.0") + @DecimalMax("180.0") + double lng + +) { +} \ No newline at end of file diff --git a/src/main/java/com/be/sportizebe/domain/facility/dto/response/FacilityResponse.java b/src/main/java/com/be/sportizebe/domain/facility/dto/response/FacilityResponse.java new file mode 100644 index 0000000..d6e8965 --- /dev/null +++ b/src/main/java/com/be/sportizebe/domain/facility/dto/response/FacilityResponse.java @@ -0,0 +1,31 @@ +package com.be.sportizebe.domain.facility.dto.response; + +import com.be.sportizebe.domain.facility.entity.FacilityType; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "체육시설 단건 조회 응답") +public record FacilityResponse( + + @Schema(description = "체육시설 ID", example = "123") + Long id, + + @Schema(description = "체육시설 이름", example = "OO 풋살장") + String facilityName, + + @Schema(description = "시설 소개", example = "잔디 상태가 좋아요") + String introduce, + + @Schema(description = "썸네일 이미지 URL", example = "https://example.com/facility/thumbnail.jpg") + String thumbnailUrl, + + @Schema(description = "시설 종목 타입", example = "SOCCER") + FacilityType facilityType, + + @Schema(description = "위도", example = "37.563") + double lat, + + @Schema(description = "경도", example = "126.982") + double lng + +) { +} \ No newline at end of file diff --git a/src/main/java/com/be/sportizebe/domain/facility/mapper/FacilityMapper.java b/src/main/java/com/be/sportizebe/domain/facility/mapper/FacilityMapper.java index 02c8709..3973c32 100644 --- a/src/main/java/com/be/sportizebe/domain/facility/mapper/FacilityMapper.java +++ b/src/main/java/com/be/sportizebe/domain/facility/mapper/FacilityMapper.java @@ -2,6 +2,8 @@ import com.be.sportizebe.domain.facility.dto.response.FacilityMarkerResponse; import com.be.sportizebe.domain.facility.dto.response.FacilityNearResponse; +import com.be.sportizebe.domain.facility.dto.response.FacilityResponse; +import com.be.sportizebe.domain.facility.entity.SportsFacility; import com.be.sportizebe.domain.facility.repository.projection.FacilityMarkerProjection; import com.be.sportizebe.domain.facility.repository.projection.FacilityNearProjection; @@ -27,4 +29,18 @@ static FacilityMarkerResponse toMarkerResponse(FacilityMarkerProjection p){ .lng(p.getLng()) .build(); } + static FacilityResponse toFacilityResponse(SportsFacility sf) { + double lat = sf.getLocation().getY(); + double lng = sf.getLocation().getX(); + + return new FacilityResponse( + sf.getId(), + sf.getFacilityName(), + sf.getIntroduce(), + sf.getThumbnailUrl(), + sf.getFacilityType(), + lat, + lng + ); + } } diff --git a/src/main/java/com/be/sportizebe/domain/facility/repository/SportsFacilityRepository.java b/src/main/java/com/be/sportizebe/domain/facility/repository/SportsFacilityRepository.java index 2684be6..48cfc88 100644 --- a/src/main/java/com/be/sportizebe/domain/facility/repository/SportsFacilityRepository.java +++ b/src/main/java/com/be/sportizebe/domain/facility/repository/SportsFacilityRepository.java @@ -4,6 +4,7 @@ import com.be.sportizebe.domain.facility.entity.SportsFacility; import com.be.sportizebe.domain.facility.repository.projection.FacilityMarkerProjection; import com.be.sportizebe.domain.facility.repository.projection.FacilityNearProjection; +import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.Repository; import org.springframework.data.repository.query.Param; @@ -11,7 +12,7 @@ import java.util.List; // Java에서 거리를 계산하는게 아니라, DB가 돌린 결과 "숫자"를 받아오는 거다. -public interface SportsFacilityRepository extends Repository { +public interface SportsFacilityRepository extends JpaRepository { // 주변 가까운 체육시설 조회 쿼리 @Query(value = """ SELECT diff --git a/src/main/java/com/be/sportizebe/domain/facility/service/SportsFacilityService.java b/src/main/java/com/be/sportizebe/domain/facility/service/SportsFacilityService.java index e4795d7..3e53b73 100644 --- a/src/main/java/com/be/sportizebe/domain/facility/service/SportsFacilityService.java +++ b/src/main/java/com/be/sportizebe/domain/facility/service/SportsFacilityService.java @@ -1,9 +1,11 @@ package com.be.sportizebe.domain.facility.service; +import com.be.sportizebe.domain.facility.dto.request.FacilityCreateRequest; import com.be.sportizebe.domain.facility.dto.request.FacilityMarkerRequest; import com.be.sportizebe.domain.facility.dto.request.FacilityNearRequest; import com.be.sportizebe.domain.facility.dto.response.FacilityMarkerResponse; import com.be.sportizebe.domain.facility.dto.response.FacilityNearResponse; +import com.be.sportizebe.domain.facility.dto.response.FacilityResponse; import java.util.List; @@ -16,4 +18,8 @@ public interface SportsFacilityService { List getMarkers(FacilityMarkerRequest request); // 지도 중심 좌표 기준 반경 내 체육시설 마커 목록 조회 // @Param: request 지도 중심 좌표, 반경, 종목 등의 조회 조건 // @return: 지도 마커용 체육시설 목록 + + FacilityResponse create(FacilityCreateRequest request); // 체육시설 생성 + + FacilityResponse getById(Long facilityId); // 체육시설 단 건 조회 } \ No newline at end of file diff --git a/src/main/java/com/be/sportizebe/domain/facility/service/SportsFacilityServiceImpl.java b/src/main/java/com/be/sportizebe/domain/facility/service/SportsFacilityServiceImpl.java index ab319ef..985214c 100644 --- a/src/main/java/com/be/sportizebe/domain/facility/service/SportsFacilityServiceImpl.java +++ b/src/main/java/com/be/sportizebe/domain/facility/service/SportsFacilityServiceImpl.java @@ -1,12 +1,19 @@ package com.be.sportizebe.domain.facility.service; +import com.be.sportizebe.domain.facility.dto.request.FacilityCreateRequest; import com.be.sportizebe.domain.facility.dto.request.FacilityMarkerRequest; import com.be.sportizebe.domain.facility.dto.request.FacilityNearRequest; import com.be.sportizebe.domain.facility.dto.response.FacilityMarkerResponse; import com.be.sportizebe.domain.facility.dto.response.FacilityNearResponse; +import com.be.sportizebe.domain.facility.dto.response.FacilityResponse; +import com.be.sportizebe.domain.facility.entity.SportsFacility; import com.be.sportizebe.domain.facility.mapper.FacilityMapper; import com.be.sportizebe.domain.facility.repository.SportsFacilityRepository; import lombok.RequiredArgsConstructor; +import org.locationtech.jts.geom.Coordinate; +import org.locationtech.jts.geom.GeometryFactory; +import org.locationtech.jts.geom.Point; +import org.locationtech.jts.geom.PrecisionModel; import org.springframework.cache.annotation.Cacheable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -15,10 +22,13 @@ @Service @RequiredArgsConstructor -@Transactional(readOnly = true) +@Transactional public class SportsFacilityServiceImpl implements SportsFacilityService { private final SportsFacilityRepository sportsFacilityRepository; + private static final GeometryFactory GEOMETRY_FACTORY = + new GeometryFactory(new PrecisionModel(), 4326); + @Override @Cacheable( @@ -57,4 +67,32 @@ public List getMarkers(FacilityMarkerRequest request) { .map(FacilityMapper::toMarkerResponse) .toList(); } + + + @Override + public FacilityResponse create(FacilityCreateRequest request) { + // Point(x=lng, y=lat) 순서 주의 + Point point = GEOMETRY_FACTORY.createPoint(new Coordinate(request.lng(), request.lat())); + point.setSRID(4326); + + SportsFacility facility = SportsFacility.builder() + .facilityName(request.facilityName()) + .introduce(request.introduce()) + .thumbnailUrl(request.thumbnailUrl()) + .facilityType(request.facilityType()) + .location(point) + .build(); + + SportsFacility saved = sportsFacilityRepository.save(facility); + return FacilityMapper.toFacilityResponse(saved); + } + + @Override + @Transactional(readOnly = true) + public FacilityResponse getById(Long facilityId) { + SportsFacility facility = sportsFacilityRepository.findById(facilityId) + .orElseThrow(() -> new IllegalArgumentException("시설이 존재하지 않습니다. id=" + facilityId)); + + return FacilityMapper.toFacilityResponse(facility); + } } \ No newline at end of file From beadb1622661c4366cfddac97855af1071e1e79a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=95=88=ED=9B=88=EA=B8=B0?= Date: Fri, 20 Feb 2026 17:08:43 +0900 Subject: [PATCH 5/6] =?UTF-8?q?=20=20=20=E2=9C=A8Feat:=20=EB=82=B4=20?= =?UTF-8?q?=EC=A3=BC=EB=B3=80=20=EB=A7=A4=EC=B9=AD=20=EB=AA=A9=EB=A1=9D=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + CLAUDE.md | 132 ++++++++++++++++++ .../match/controller/MatchController.java | 15 ++ .../match/dto/request/MatchNearRequest.java | 27 ++++ .../match/dto/response/MatchNearResponse.java | 29 ++++ .../match/repository/MatchRoomRepository.java | 37 +++++ .../projection/MatchNearProjection.java | 12 ++ .../domain/match/service/MatchService.java | 6 + .../match/service/MatchServiceImpl.java | 17 +++ .../domain/user/entity/SportType.java | 4 +- 10 files changed, 279 insertions(+), 1 deletion(-) create mode 100644 CLAUDE.md create mode 100644 src/main/java/com/be/sportizebe/domain/match/dto/request/MatchNearRequest.java create mode 100644 src/main/java/com/be/sportizebe/domain/match/dto/response/MatchNearResponse.java create mode 100644 src/main/java/com/be/sportizebe/domain/match/repository/projection/MatchNearProjection.java diff --git a/.gitignore b/.gitignore index 630165c..8139281 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ build/ !gradle/wrapper/gradle-wrapper.jar !**/src/main/**/build/ !**/src/test/**/build/ +docs/ ### STS ### .apt_generated diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..fa6cc79 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,132 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Tech Stack + +- **Java 21 / Spring Boot 4.0.1** (Gradle) +- **PostgreSQL 16 + PostGIS** (공간 쿼리), **Redis 7** (캐시) +- **Spring Security + JWT** (Stateless, `Authorization: Bearer `) +- **WebSocket / STOMP** (`/ws-stomp` 엔드포인트) +- **AWS S3** (프로필·클럽·게시글 이미지) +- **Swagger UI** → `http://localhost:8080/swagger-ui/index.html` + +--- + +## Commands + +```bash +# 로컬 인프라 실행 (PostgreSQL+PostGIS, Redis, Prometheus, Grafana) +docker compose -f docker/docker-compose.local.yml up -d + +# 빌드 +./gradlew build + +# 로컬 프로필로 실행 (application-local.properties 사용) +./gradlew bootRun --args='--spring.profiles.active=local' + +# 테스트 전체 실행 +./gradlew test + +# 단일 테스트 클래스 실행 +./gradlew test --tests "com.be.sportizebe.domain.user.service.UserServiceImplTest" +``` + +### 로컬 환경 변수 (application-local.properties가 읽는 값) + +`application-local.properties`는 환경 변수로 주입받는 값이 있다. +IDE나 `.env` 파일에 아래를 설정해야 앱이 기동된다. + +``` +POSTGRES_USER=angora +POSTGRES_PASSWORD=password +AWS_ACCESS_KEY=... +AWS_SECRET_KEY=... +``` + +--- + +## Project Structure + +``` +src/main/java/com/be/sportizebe/ +├── domain/ # 비즈니스 도메인 (아래 참고) +│ ├── auth/ +│ ├── user/ +│ ├── club/ +│ ├── post/ +│ ├── comment/ +│ ├── like/ +│ ├── match/ +│ ├── facility/ +│ ├── notification/ +│ └── chat/ +└── global/ # 공통 인프라 + ├── cache/ # Redis 캐시 설정, UserCacheService, UserAuthInfo DTO + ├── config/ # CORS, S3, Redis, Swagger, Jackson 설정 + ├── exception/ # CustomException, GlobalExceptionHandler, BaseErrorCode + ├── jwt/ # JwtProvider, JwtAuthenticationFilter + ├── response/ # BaseResponse (공통 응답 래퍼) + ├── s3/ # S3Service (upload/delete), PathName enum + └── security/ # SecurityConfig, CustomUserDetailService +``` + +각 도메인은 `controller / dto / entity / repository / service / exception` 패키지로 분리된다. + +--- + +## Key Architecture Decisions + +### 인증 흐름 +`JwtAuthenticationFilter` → `UserCacheService.findUserAuthInfoById()` → Redis 캐시 (TTL 5분, 미스 시 DB 조회) +`@AuthenticationPrincipal UserAuthInfo`로 컨트롤러에서 꺼낸다. `User` 엔티티가 아닌 `UserAuthInfo` (Serializable DTO)를 principal로 사용하는 이유는 JPA 연관관계로 인한 직렬화 문제를 방지하기 위함이다. + +### 보안 규칙 (SecurityConfig) +- `GET /api/**` — 인증 없이 허용 +- `POST /api/auth/**`, `POST /api/users/signup` — 인증 없이 허용 +- `/ws-stomp/**` — 인증 없이 허용 +- 그 외 — `authenticated()` + +### 캐시 전략 (Redis) +| 캐시명 | 키 | TTL | 무효화 시점 | +|--------|----|-----|------------| +| `userAuthInfo` | `userId` | 5분 | 프로필/이미지 수정 | +| `postList` | property+pageable | 5분 | 게시글 생성/수정/삭제 | +| `commentList` | `postId` | 5분 | 댓글 생성/삭제 | +| `commentCount` | `postId` | 5분 | 댓글 생성/삭제 | +| `likeCount` / `likeStatus` | `targetType:targetId` | 5분 | 좋아요 토글 | +| `facilityNear` / `facilityMarkers` | 좌표+반경 | 5분 | — | + +### 공간 쿼리 (Facility) +`SportsFacilityRepository`는 PostGIS Native SQL (`ST_DWithin`, `ST_Distance`) 사용. +`SportsFacility.location`은 JTS `Point` (SRID 4326). `hibernate-spatial` 의존성 필요. + +### WebSocket / STOMP 채팅 +- 연결: `ws://host/ws-stomp` +- 메시지 발행: `SEND /pub/chat.send | chat.join | chat.leave` +- 구독: `SUBSCRIBE /sub/chat/rooms/{roomId}` +- 실시간 알림 구독: `SUBSCRIBE /sub/notifications/{userId}` +- 채팅방 종류: `GROUP` (동호회 생성 시 자동 생성), `NOTE` (게시글 기반 1:1 쪽지) + +### 파일 업로드 (S3) +`S3Service.uploadFile(PathName, MultipartFile)` → S3 버킷의 `{PathName}/{UUID}.{ext}` 경로에 저장. +`PathName` enum: `PROFILE`, `CLUB`, `POST`. + +### 응답 규격 +모든 REST 응답은 `BaseResponse` 래퍼를 사용한다: +```json +{ "success": true, "message": "...", "data": { ... } } +``` +에러는 `GlobalExceptionHandler`가 `CustomException(BaseErrorCode)` 를 잡아 동일 포맷으로 반환한다. + +--- + +## Git Convention + +커밋 접두사 (이모지 + 태그): +- `✨ Feat:` 새 기능 | `🐛 Fix:` 버그 수정 | `♻️ Refactor:` 리팩토링 +- `🔧 Settings:` 설정 변경 | `📝 Docs:` 문서 | `🔥 Remove:` 파일 삭제 +- `⏪️ Revert:` 롤백 | `🚀 Deploy:` 배포 + +브랜치: `feature/{description}` → `develop` → `main` +서브모듈 업데이트 시 커밋 메시지: `"submodule push"` / `"submodule latest"` 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 449dc2b..df53c77 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 @@ -1,7 +1,9 @@ package com.be.sportizebe.domain.match.controller; import com.be.sportizebe.domain.match.dto.request.MatchCreateRequest; +import com.be.sportizebe.domain.match.dto.request.MatchNearRequest; import com.be.sportizebe.domain.match.dto.response.MatchDetailResponse; +import com.be.sportizebe.domain.match.dto.response.MatchNearResponse; import com.be.sportizebe.domain.match.dto.response.MatchResponse; import com.be.sportizebe.domain.match.service.MatchService; import com.be.sportizebe.global.cache.dto.UserAuthInfo; @@ -13,16 +15,20 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import org.springdoc.core.annotations.ParameterObject; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; 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; +import java.util.List; + @RestController @RequiredArgsConstructor @RequestMapping("/api/matches") @@ -61,4 +67,13 @@ public ResponseEntity> getMatchDetail( matchService.getMatchDetail(matchId, userAuthInfo.getId()); return ResponseEntity.ok(BaseResponse.success("매칭 상세 조회 성공", response)); } + + @Operation(summary = "내 주변 매칭 목록 조회") + @GetMapping("/near") + public ResponseEntity>> getNearMatches( + @ParameterObject @Valid @ModelAttribute MatchNearRequest request + ) { + List response = matchService.getNearMatches(request); + return ResponseEntity.ok(BaseResponse.success("주변 매칭 목록 조회 성공", response)); + } } \ 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 new file mode 100644 index 0000000..ee4c551 --- /dev/null +++ b/src/main/java/com/be/sportizebe/domain/match/dto/request/MatchNearRequest.java @@ -0,0 +1,27 @@ +package com.be.sportizebe.domain.match.dto.request; + +import com.be.sportizebe.domain.user.entity.SportType; +import jakarta.validation.constraints.*; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class MatchNearRequest { + + @NotNull(message = "위도(lat)는 필수입니다") + @DecimalMin(value = "-90.0", message = "위도는 -90.0 이상이어야 합니다") + @DecimalMax(value = "90.0", message = "위도는 90.0 이하여야 합니다") + private Double lat; + + @NotNull(message = "경도(lng)는 필수입니다") + @DecimalMin(value = "-180.0", message = "경도는 -180.0 이상이어야 합니다") + @DecimalMax(value = "180.0", message = "경도는 180.0 이하여야 합니다") + private Double lng; + + @Min(value = 100, message = "반경은 최소 100m 이상이어야 합니다") + @Max(value = 10000, message = "반경은 최대 10km까지 가능합니다") + private Integer radiusM = 1000; + + 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 new file mode 100644 index 0000000..22aac61 --- /dev/null +++ b/src/main/java/com/be/sportizebe/domain/match/dto/response/MatchNearResponse.java @@ -0,0 +1,29 @@ +package com.be.sportizebe.domain.match.dto.response; + +import com.be.sportizebe.domain.match.entity.MatchStatus; +import com.be.sportizebe.domain.match.repository.projection.MatchNearProjection; +import com.be.sportizebe.domain.user.entity.SportType; + +public record MatchNearResponse( + Long matchId, + SportType sportsName, + Long facilityId, + String facilityName, + int curMembers, + int maxMembers, + MatchStatus status, + int distanceM +) { + public static MatchNearResponse from(MatchNearProjection p) { + return new MatchNearResponse( + p.getMatchId(), + SportType.valueOf(p.getSportsName()), + p.getFacilityId(), + p.getFacilityName(), + p.getCurMembers(), + p.getMaxMembers(), + MatchStatus.valueOf(p.getStatus()), + (int) Math.round(p.getDistanceM()) + ); + } +} 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 980444a..4fa1970 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 @@ -1,6 +1,43 @@ package com.be.sportizebe.domain.match.repository; + import com.be.sportizebe.domain.match.entity.MatchRoom; +import com.be.sportizebe.domain.match.repository.projection.MatchNearProjection; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; public interface MatchRoomRepository extends JpaRepository { + + @Query(value = """ + SELECT + mr.id AS matchId, + mr.sports_name AS sportsName, + mr.facility_id AS facilityId, + sf.facility_name AS facilityName, + mr.cur_members AS curMembers, + mr.max_members AS maxMembers, + mr.status AS status, + ST_Distance( + sf.location, + ST_SetSRID(ST_MakePoint(:lng, :lat), 4326)::geography + ) AS distanceM + FROM match_rooms mr + JOIN sports_facilities sf ON mr.facility_id = sf.id + WHERE ST_DWithin( + sf.location, + ST_SetSRID(ST_MakePoint(:lng, :lat), 4326)::geography, + :radiusM + ) + AND mr.status = 'OPEN' + AND (:sportsName IS NULL OR mr.sports_name = :sportsName) + ORDER BY distanceM + """, nativeQuery = true) + List findNear( + @Param("lat") double lat, + @Param("lng") double lng, + @Param("radiusM") int radiusM, + @Param("sportsName") String sportsName + ); } 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 new file mode 100644 index 0000000..e3ff283 --- /dev/null +++ b/src/main/java/com/be/sportizebe/domain/match/repository/projection/MatchNearProjection.java @@ -0,0 +1,12 @@ +package com.be.sportizebe.domain.match.repository.projection; + +public interface MatchNearProjection { + Long getMatchId(); + String getSportsName(); + Long getFacilityId(); + String getFacilityName(); + Integer getCurMembers(); + Integer getMaxMembers(); + String getStatus(); + Double getDistanceM(); +} 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 bb5ba3c..1d53c99 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 @@ -1,10 +1,14 @@ package com.be.sportizebe.domain.match.service; import com.be.sportizebe.domain.match.dto.request.MatchCreateRequest; +import com.be.sportizebe.domain.match.dto.request.MatchNearRequest; import com.be.sportizebe.domain.match.dto.response.MatchDetailResponse; +import com.be.sportizebe.domain.match.dto.response.MatchNearResponse; import com.be.sportizebe.domain.match.dto.response.MatchResponse; import com.be.sportizebe.domain.user.entity.User; +import java.util.List; + public interface MatchService { MatchResponse createMatch(Long userId, MatchCreateRequest request); // 매칭 생성 @@ -13,4 +17,6 @@ public interface MatchService { 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 e33d00a..a62440f 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 @@ -1,6 +1,8 @@ package com.be.sportizebe.domain.match.service; +import com.be.sportizebe.domain.match.dto.request.MatchNearRequest; import com.be.sportizebe.domain.match.dto.response.MatchDetailResponse; +import com.be.sportizebe.domain.match.dto.response.MatchNearResponse; import com.be.sportizebe.domain.match.dto.response.MatchResponse; import com.be.sportizebe.domain.match.entity.MatchParticipant; import com.be.sportizebe.domain.match.entity.MatchParticipantStatus; @@ -16,6 +18,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import com.be.sportizebe.domain.match.dto.request.MatchCreateRequest; +import java.util.List; @Service @RequiredArgsConstructor @@ -88,4 +91,18 @@ public MatchDetailResponse getMatchDetail(Long matchId, Long userId) { // 2) 응답 DTO 생성 (matchRoom + user 기준 정보 포함) return MatchDetailResponse.of(matchRoom, user); } + + @Override + @Transactional(readOnly = true) + public List getNearMatches(MatchNearRequest request) { + String sportsName = request.getSportsName() == null + ? null : request.getSportsName().name(); + + return matchRoomRepository.findNear( + request.getLat(), request.getLng(), + request.getRadiusM(), sportsName) + .stream() + .map(MatchNearResponse::from) + .toList(); + } } \ No newline at end of file diff --git a/src/main/java/com/be/sportizebe/domain/user/entity/SportType.java b/src/main/java/com/be/sportizebe/domain/user/entity/SportType.java index c4f4baa..ce99779 100644 --- a/src/main/java/com/be/sportizebe/domain/user/entity/SportType.java +++ b/src/main/java/com/be/sportizebe/domain/user/entity/SportType.java @@ -2,5 +2,7 @@ public enum SportType { SOCCER, - BASKETBALL + BASKETBALL, + BADMINTON, + } From 61c37d5677a983ca04b5db10516ccc27f4f8d7d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=95=88=ED=9B=88=EA=B8=B0?= Date: Fri, 20 Feb 2026 18:23:46 +0900 Subject: [PATCH 6/6] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=20SportType?= =?UTF-8?q?=20=ED=8C=A8=ED=82=A4=EC=A7=80=20=EB=B3=80=EA=B2=BD=EC=97=90=20?= =?UTF-8?q?=EB=94=B0=EB=A5=B8=20import=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 +++ .../domain/match/dto/request/MatchCreateRequest.java | 2 +- .../sportizebe/domain/match/dto/request/MatchNearRequest.java | 2 +- .../domain/match/dto/response/MatchDetailResponse.java | 2 +- .../domain/match/dto/response/MatchNearResponse.java | 2 +- .../be/sportizebe/domain/match/dto/response/MatchResponse.java | 2 +- .../java/com/be/sportizebe/domain/match/entity/MatchRoom.java | 2 +- 7 files changed, 9 insertions(+), 6 deletions(-) diff --git a/.gitignore b/.gitignore index 8139281..871f511 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,6 @@ src/main/resources/*.yml ### VS Code ### .vscode/ + +# 기타 +CLAUDE.md 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 b966e1a..90c4725 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 @@ -1,6 +1,6 @@ package com.be.sportizebe.domain.match.dto.request; -import com.be.sportizebe.domain.user.entity.SportType; +import com.be.sportizebe.common.enums.SportType; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; 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 ee4c551..07ff6a2 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,6 @@ package com.be.sportizebe.domain.match.dto.request; -import com.be.sportizebe.domain.user.entity.SportType; +import com.be.sportizebe.common.enums.SportType; import jakarta.validation.constraints.*; import lombok.Getter; import lombok.Setter; diff --git a/src/main/java/com/be/sportizebe/domain/match/dto/response/MatchDetailResponse.java b/src/main/java/com/be/sportizebe/domain/match/dto/response/MatchDetailResponse.java index 30fd85c..080a103 100644 --- a/src/main/java/com/be/sportizebe/domain/match/dto/response/MatchDetailResponse.java +++ b/src/main/java/com/be/sportizebe/domain/match/dto/response/MatchDetailResponse.java @@ -2,7 +2,7 @@ import com.be.sportizebe.domain.match.entity.MatchParticipantStatus; import com.be.sportizebe.domain.match.entity.MatchRoom; -import com.be.sportizebe.domain.user.entity.SportType; +import com.be.sportizebe.common.enums.SportType; import com.be.sportizebe.domain.user.entity.User; import io.swagger.v3.oas.annotations.media.Schema; 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 22aac61..0fc1db1 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 @@ -2,7 +2,7 @@ import com.be.sportizebe.domain.match.entity.MatchStatus; import com.be.sportizebe.domain.match.repository.projection.MatchNearProjection; -import com.be.sportizebe.domain.user.entity.SportType; +import com.be.sportizebe.common.enums.SportType; public record MatchNearResponse( Long matchId, diff --git a/src/main/java/com/be/sportizebe/domain/match/dto/response/MatchResponse.java b/src/main/java/com/be/sportizebe/domain/match/dto/response/MatchResponse.java index 9036d31..e75eec2 100644 --- a/src/main/java/com/be/sportizebe/domain/match/dto/response/MatchResponse.java +++ b/src/main/java/com/be/sportizebe/domain/match/dto/response/MatchResponse.java @@ -2,7 +2,7 @@ import com.be.sportizebe.domain.match.entity.MatchRoom; import com.be.sportizebe.domain.match.entity.MatchStatus; -import com.be.sportizebe.domain.user.entity.SportType; +import com.be.sportizebe.common.enums.SportType; import io.swagger.v3.oas.annotations.media.Schema; @Schema(description = "매칭 응답 정보") 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 bed63f0..0cad30c 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 @@ -1,7 +1,7 @@ package com.be.sportizebe.domain.match.entity; import com.be.sportizebe.domain.match.dto.request.MatchCreateRequest; -import com.be.sportizebe.domain.user.entity.SportType; +import com.be.sportizebe.common.enums.SportType; import com.be.sportizebe.global.common.BaseTimeEntity; import jakarta.persistence.*; import lombok.*;