diff --git a/.gitignore b/.gitignore index 630165c..871f511 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 @@ -39,3 +40,6 @@ src/main/resources/*.yml ### VS Code ### .vscode/ + +# 기타 +CLAUDE.md 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/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 diff --git a/src/main/java/com/be/sportizebe/common/enums/SportType.java b/src/main/java/com/be/sportizebe/common/enums/SportType.java index ae22473..e07ed5c 100644 --- a/src/main/java/com/be/sportizebe/common/enums/SportType.java +++ b/src/main/java/com/be/sportizebe/common/enums/SportType.java @@ -2,5 +2,7 @@ public enum SportType { SOCCER, - BASKETBALL + BASKETBALL, + BADMINTON, + } 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 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..df53c77 --- /dev/null +++ b/src/main/java/com/be/sportizebe/domain/match/controller/MatchController.java @@ -0,0 +1,79 @@ +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; +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.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") +@Tag(name = "match", description = "운동 매칭 관련 API") +public class MatchController { + + private final MatchService matchService; + + @Operation(summary = "매칭 생성") + @PostMapping + public ResponseEntity> createMatch( + @AuthenticationPrincipal UserAuthInfo userAuthInfo, + @RequestBody @Valid MatchCreateRequest request + ) { + MatchResponse response = matchService.createMatch(userAuthInfo.getId(), 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)); + } + + @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/MatchCreateRequest.java b/src/main/java/com/be/sportizebe/domain/match/dto/request/MatchCreateRequest.java new file mode 100644 index 0000000..90c4725 --- /dev/null +++ b/src/main/java/com/be/sportizebe/domain/match/dto/request/MatchCreateRequest.java @@ -0,0 +1,21 @@ +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.Max; +import jakarta.validation.constraints.Min; + +@Schema(description = "매칭 생성 요청 정보") +public record MatchCreateRequest( + + @Schema(description = "스포츠 종류", example = "SOCCER") + 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/request/MatchNearRequest.java b/src/main/java/com/be/sportizebe/domain/match/dto/request/MatchNearRequest.java new file mode 100644 index 0000000..07ff6a2 --- /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.common.enums.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/MatchDetailResponse.java b/src/main/java/com/be/sportizebe/domain/match/dto/response/MatchDetailResponse.java new file mode 100644 index 0000000..080a103 --- /dev/null +++ b/src/main/java/com/be/sportizebe/domain/match/dto/response/MatchDetailResponse.java @@ -0,0 +1,59 @@ +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.common.enums.SportType; +import com.be.sportizebe.domain.user.entity.User; +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 = "SOCCER") + SportType sportsName, + + @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 + +) { + 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/MatchNearResponse.java b/src/main/java/com/be/sportizebe/domain/match/dto/response/MatchNearResponse.java new file mode 100644 index 0000000..0fc1db1 --- /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.common.enums.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/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 new file mode 100644 index 0000000..e75eec2 --- /dev/null +++ b/src/main/java/com/be/sportizebe/domain/match/dto/response/MatchResponse.java @@ -0,0 +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.common.enums.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 sportsName, + + @Schema(description = "체육시설 ID", example = "321") + Long facilityId, + + @Schema(description ="현재 인원", example = "1") + int curMembers, + + @Schema(description = "최대 참여 인원", example = "8") + 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 new file mode 100644 index 0000000..99bbeac --- /dev/null +++ b/src/main/java/com/be/sportizebe/domain/match/entity/MatchParticipant.java @@ -0,0 +1,62 @@ +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.*; + +import java.time.LocalDateTime; + +@Entity +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor +@Table( + name = "match_participants", + uniqueConstraints = { + @UniqueConstraint(name = "uk_match_room_user", columnNames = {"match_room_id", "user_id"}) + } +) +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 new file mode 100644 index 0000000..cc46c60 --- /dev/null +++ b/src/main/java/com/be/sportizebe/domain/match/entity/MatchParticipantStatus.java @@ -0,0 +1,5 @@ +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..0cad30c --- /dev/null +++ b/src/main/java/com/be/sportizebe/domain/match/entity/MatchRoom.java @@ -0,0 +1,59 @@ +package com.be.sportizebe.domain.match.entity; + +import com.be.sportizebe.domain.match.dto.request.MatchCreateRequest; +import com.be.sportizebe.common.enums.SportType; +import com.be.sportizebe.global.common.BaseTimeEntity; +import jakarta.persistence.*; +import lombok.*; + +import java.util.ArrayList; +import java.util.List; + +@Entity +@Getter +@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 sportsName; + + @Column(nullable = false) + private Long facilityId; + + @Column(nullable = false) + 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 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 new file mode 100644 index 0000000..3292af9 --- /dev/null +++ b/src/main/java/com/be/sportizebe/domain/match/repository/MatchParticipantRepository.java @@ -0,0 +1,21 @@ +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..4fa1970 --- /dev/null +++ b/src/main/java/com/be/sportizebe/domain/match/repository/MatchRoomRepository.java @@ -0,0 +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 new file mode 100644 index 0000000..1d53c99 --- /dev/null +++ b/src/main/java/com/be/sportizebe/domain/match/service/MatchService.java @@ -0,0 +1,22 @@ +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); // 매칭 생성 + + void joinMatch(Long matchId, Long userId); // 매칭방 참여(정원 체크 + 중복 참가 체크 + 참가자 저장) + + 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 new file mode 100644 index 0000000..a62440f --- /dev/null +++ b/src/main/java/com/be/sportizebe/domain/match/service/MatchServiceImpl.java @@ -0,0 +1,108 @@ +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; +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.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; +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional +public class MatchServiceImpl implements MatchService { + + private final MatchRoomRepository matchRoomRepository; + private final MatchParticipantRepository matchParticipantRepository; + private final UserRepository userRepository; + + @Override + // 실제로는 관리자용 메서드인데 더미 넣으려고 만듦 + 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); + + // 3) 생성자 자동 참여 처리 + matchParticipantRepository.save(new MatchParticipant(savedRoom, user)); + + return MatchResponse.from(savedRoom); + } + + @Override + public void joinMatch(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 상태만) 조회 후 정원 초과 체크 + long joinedCount = matchParticipantRepository.countByMatchRoomAndStatus( + matchRoom, MatchParticipantStatus.JOINED + ); + + if (joinedCount >= matchRoom.getMaxMembers()) { + throw new CustomException(MatchErrorCode.MATCH_FULL); + } + + // 4) 동일 유저가 이미 참가(JOINED) 중인지 중복 체크 + boolean alreadyJoined = matchParticipantRepository.existsByMatchRoomAndUserAndStatus( + matchRoom, user, MatchParticipantStatus.JOINED + ); + + if (alreadyJoined) { + throw new CustomException(MatchErrorCode.ALREADY_JOINED); + } + + // 5) 참가자 엔티티 생성 후 저장 + matchParticipantRepository.save(new MatchParticipant(matchRoom, user)); + } + + @Override + @Transactional(readOnly = true) + public MatchDetailResponse getMatchDetail(Long matchId, Long userId) { + // 1) 매칭방 존재 확인 + MatchRoom matchRoom = matchRoomRepository.findById(matchId) + .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); + } + + @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