Skip to content

Commit 050d691

Browse files
안훈기안훈기
authored andcommitted
✨Feat: 매칭(match) 도메인 기능 구현
1 parent 3f08717 commit 050d691

File tree

13 files changed

+279
-63
lines changed

13 files changed

+279
-63
lines changed

src/main/java/com/be/sportizebe/domain/match/controller/MatchController.java

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,26 @@
22

33
import com.be.sportizebe.domain.match.dto.request.MatchCreateRequest;
44
import com.be.sportizebe.domain.match.dto.response.MatchDetailResponse;
5+
import com.be.sportizebe.domain.match.dto.response.MatchResponse;
56
import com.be.sportizebe.domain.match.service.MatchService;
6-
import com.be.sportizebe.domain.user.entity.User;
7+
import com.be.sportizebe.global.cache.dto.UserAuthInfo;
8+
import com.be.sportizebe.global.response.BaseResponse;
9+
710
import io.swagger.v3.oas.annotations.Operation;
811
import io.swagger.v3.oas.annotations.tags.Tag;
12+
13+
import jakarta.validation.Valid;
914
import lombok.RequiredArgsConstructor;
15+
16+
import org.springframework.http.HttpStatus;
17+
import org.springframework.http.ResponseEntity;
1018
import org.springframework.security.core.annotation.AuthenticationPrincipal;
11-
import org.springframework.web.bind.annotation.*;
19+
import org.springframework.web.bind.annotation.GetMapping;
20+
import org.springframework.web.bind.annotation.PathVariable;
21+
import org.springframework.web.bind.annotation.PostMapping;
22+
import org.springframework.web.bind.annotation.RequestBody;
23+
import org.springframework.web.bind.annotation.RequestMapping;
24+
import org.springframework.web.bind.annotation.RestController;
1225

1326
@RestController
1427
@RequiredArgsConstructor
@@ -18,16 +31,16 @@ public class MatchController {
1831

1932
private final MatchService matchService;
2033

21-
@Operation(summary = "매칭방 생성")
34+
@Operation(summary = "매칭 생성")
2235
@PostMapping
2336
public ResponseEntity<BaseResponse<MatchResponse>> createMatch(
24-
@RequestBody MatchCreateRequest request
37+
@AuthenticationPrincipal UserAuthInfo userAuthInfo,
38+
@RequestBody @Valid MatchCreateRequest request
2539
) {
26-
MatchResponse response = matchService.createMatch(request);
40+
MatchResponse response = matchService.createMatch(userAuthInfo.getId(), request);
2741
return ResponseEntity.status(HttpStatus.CREATED)
28-
.body(BaseResponse.success("매칭방 생성 성공", response));
42+
.body(BaseResponse.success("매칭 생성 성공", response));
2943
}
30-
3144
@Operation(summary = "매칭 참여")
3245
@PostMapping("/{matchId}/join")
3346
public ResponseEntity<BaseResponse<Void>> joinMatch(

src/main/java/com/be/sportizebe/domain/match/dto/request/MatchCreateRequest.java

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,20 @@
22

33
import com.be.sportizebe.domain.user.entity.SportType;
44
import io.swagger.v3.oas.annotations.media.Schema;
5+
import jakarta.validation.constraints.Max;
6+
import jakarta.validation.constraints.Min;
57

68
@Schema(description = "매칭 생성 요청 정보")
79
public record MatchCreateRequest(
810

911
@Schema(description = "스포츠 종류", example = "SOCCER")
10-
SportType sportType,
12+
SportType sportsName,
1113

1214
@Schema(description = "체육시설 ID", example = "123")
1315
Long facilityId,
1416

1517
@Schema(description = "최대 참여 인원 수", example = "10")
18+
@Min(2) @Max(20)
1619
Integer maxMembers
1720

1821
) {}

src/main/java/com/be/sportizebe/domain/match/dto/response/MatchDetailResponse.java

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
package com.be.sportizebe.domain.match.dto.response;
22

3+
import com.be.sportizebe.domain.match.entity.MatchParticipantStatus;
4+
import com.be.sportizebe.domain.match.entity.MatchRoom;
35
import com.be.sportizebe.domain.user.entity.SportType;
6+
import com.be.sportizebe.domain.user.entity.User;
47
import io.swagger.v3.oas.annotations.media.Schema;
58

69
import java.util.List;
@@ -11,8 +14,8 @@ public record MatchDetailResponse(
1114
@Schema(description = "매칭방 ID", example = "42")
1215
Long matchId,
1316

14-
@Schema(description = "스포츠 종류", example = "BADMINTON")
15-
SportType sportType,
17+
@Schema(description = "스포츠 종류", example = "SOCCER")
18+
SportType sportsName,
1619

1720
@Schema(description = "체육시설 ID", example = "987")
1821
Long facilityId,
@@ -29,4 +32,28 @@ public record MatchDetailResponse(
2932
@Schema(description = "요청 유저가 참여 중인지 여부", example = "true")
3033
boolean joined
3134

32-
) {}
35+
) {
36+
public static MatchDetailResponse of(
37+
MatchRoom matchRoom,
38+
User user
39+
) {
40+
// JOINED 상태인 참가자만 추출
41+
List<Long> participantIds = matchRoom.getParticipants().stream()
42+
.filter(p -> p.getStatus() == MatchParticipantStatus.JOINED)
43+
.map(p -> p.getUser().getId())
44+
.toList();
45+
46+
// 요청 유저가 참가 중인지 여부 판단
47+
boolean joined = participantIds.contains(user.getId());
48+
49+
return new MatchDetailResponse(
50+
matchRoom.getId(),
51+
matchRoom.getSportsName(),
52+
matchRoom.getFacilityId(),
53+
matchRoom.getMaxMembers(),
54+
participantIds.size(),
55+
participantIds,
56+
joined
57+
);
58+
}
59+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
package com.be.sportizebe.domain.match.dto.response;
2+
3+
import com.be.sportizebe.domain.match.entity.MatchParticipant;
4+
import com.be.sportizebe.domain.match.entity.MatchParticipantStatus;
5+
import io.swagger.v3.oas.annotations.media.Schema;
6+
7+
@Schema(title = "MatchParticipantResponse", description = "매칭 참여자 응답")
8+
public record MatchParticipantResponse(
9+
// 매칭방에 참여한 사용자 정보 응답용 DTO
10+
// 매칭 상세 조회 API에서 참여자 목록(List)으로 사용됨
11+
12+
@Schema(description = "사용자 ID", example = "10")
13+
Long userId,
14+
15+
@Schema(description = "사용자 닉네임", example = "닉네임")
16+
String nickname,
17+
18+
@Schema(description = "프로필 이미지 URL", example = "https://example.com/profile.png")
19+
String profileImageUrl,
20+
21+
@Schema(description = "참여 상태 (JOINED / LEFT)", example = "JOINED")
22+
MatchParticipantStatus status
23+
24+
) {
25+
public static MatchParticipantResponse from(MatchParticipant p) {
26+
return new MatchParticipantResponse(
27+
p.getUser().getId(),
28+
p.getUser().getNickname(),
29+
p.getUser().getProfileImage(),
30+
p.getStatus()
31+
);
32+
}
33+
}
Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,42 @@
11
package com.be.sportizebe.domain.match.dto.response;
22

3+
import com.be.sportizebe.domain.match.entity.MatchRoom;
4+
import com.be.sportizebe.domain.match.entity.MatchStatus;
35
import com.be.sportizebe.domain.user.entity.SportType;
46
import io.swagger.v3.oas.annotations.media.Schema;
57

68
@Schema(description = "매칭 응답 정보")
79
public record MatchResponse(
8-
10+
// 참여자 목록이 안 들어감
11+
// 이유는? -> 참여자까지 다 포함하면 무거워짐 / n+1 문제 발생
12+
// 매칭 생성 API, 매칭 목록 조회 API에 쓰임
913
@Schema(description = "매칭방 ID", example = "1")
1014
Long matchId,
1115

1216
@Schema(description = "스포츠 종류", example = "BASKETBALL")
13-
SportType sportType,
17+
SportType sportsName,
1418

1519
@Schema(description = "체육시설 ID", example = "321")
1620
Long facilityId,
1721

22+
@Schema(description ="현재 인원", example = "1")
23+
int curMembers,
24+
1825
@Schema(description = "최대 참여 인원", example = "8")
19-
Integer maxMembers
26+
Integer maxMembers,
27+
28+
@Schema(description ="모집 상태", example = "OPEN")
29+
MatchStatus status
2030

21-
) {}
31+
) {
32+
public static MatchResponse from(MatchRoom matchRoom) {
33+
return new MatchResponse(
34+
matchRoom.getId(),
35+
matchRoom.getSportsName(),
36+
matchRoom.getFacilityId(),
37+
matchRoom.getCurMembers(),
38+
matchRoom.getMaxMembers(),
39+
matchRoom.getStatus()
40+
);
41+
}
42+
}
Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,43 +1,62 @@
11
package com.be.sportizebe.domain.match.entity;
22

33
import com.be.sportizebe.domain.user.entity.User;
4+
import com.be.sportizebe.global.common.BaseTimeEntity;
45
import jakarta.persistence.*;
56
import lombok.*;
67

78
import java.time.LocalDateTime;
89

910
@Entity
1011
@Getter
11-
@NoArgsConstructor(access = AccessLevel.PROTECTED)
12+
@Builder
13+
@AllArgsConstructor
14+
@NoArgsConstructor
1215
@Table(
16+
name = "match_participants",
1317
uniqueConstraints = {
14-
@UniqueConstraint(columnNames = {"match_room_id", "user_id"})
18+
@UniqueConstraint(name = "uk_match_room_user", columnNames = {"match_room_id", "user_id"})
1519
}
1620
)
17-
public class MatchParticipant {
21+
public class MatchParticipant extends BaseTimeEntity {
1822

1923
@Id
2024
@GeneratedValue(strategy = GenerationType.IDENTITY)
2125
private Long id;
2226

27+
// ERD: Match Participants.id (match room FK)
2328
@ManyToOne(fetch = FetchType.LAZY)
2429
@JoinColumn(name = "match_room_id", nullable = false)
2530
private MatchRoom matchRoom;
2631

32+
// ERD: Match Participants.id2 (user FK)
2733
@ManyToOne(fetch = FetchType.LAZY)
2834
@JoinColumn(name = "user_id", nullable = false)
2935
private User user;
3036

37+
// ERD: isStatus
3138
@Enumerated(EnumType.STRING)
3239
@Column(nullable = false)
3340
private MatchParticipantStatus status;
3441

42+
@Column(nullable = false)
3543
private LocalDateTime joinedAt;
3644

45+
private LocalDateTime leftAt;
46+
3747
public MatchParticipant(MatchRoom matchRoom, User user) {
3848
this.matchRoom = matchRoom;
3949
this.user = user;
4050
this.status = MatchParticipantStatus.JOINED;
4151
this.joinedAt = LocalDateTime.now();
4252
}
53+
54+
public void leave() {
55+
this.status = MatchParticipantStatus.LEFT;
56+
this.leftAt = LocalDateTime.now();
57+
}
58+
59+
public boolean isJoined() {
60+
return this.status == MatchParticipantStatus.JOINED;
61+
}
4362
}

src/main/java/com/be/sportizebe/domain/match/entity/MatchParticipantStatus.java

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,4 @@
22

33
public enum MatchParticipantStatus {
44
JOINED, LEFT
5-
65
}
Lines changed: 36 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,59 @@
11
package com.be.sportizebe.domain.match.entity;
22

3+
import com.be.sportizebe.domain.match.dto.request.MatchCreateRequest;
34
import com.be.sportizebe.domain.user.entity.SportType;
45
import com.be.sportizebe.global.common.BaseTimeEntity;
56
import jakarta.persistence.*;
6-
import lombok.AccessLevel;
7-
import lombok.Getter;
8-
import lombok.NoArgsConstructor;
7+
import lombok.*;
8+
9+
import java.util.ArrayList;
10+
import java.util.List;
911

1012
@Entity
1113
@Getter
12-
@NoArgsConstructor(access = AccessLevel.PROTECTED)
13-
public class MatchRoom extends BaseEntity {
14+
@Builder
15+
@AllArgsConstructor
16+
@NoArgsConstructor
17+
@Table(name = "match_rooms")
18+
public class MatchRoom extends BaseTimeEntity {
1419

1520
@Id
1621
@GeneratedValue(strategy = GenerationType.IDENTITY)
1722
private Long id;
1823

24+
// ERD: sportsName
1925
@Enumerated(EnumType.STRING)
2026
@Column(nullable = false)
21-
private SportType sportType;
27+
private SportType sportsName;
2228

2329
@Column(nullable = false)
2430
private Long facilityId;
2531

2632
@Column(nullable = false)
27-
private Integer maxMembers;
33+
private int curMembers;
34+
35+
@Column(nullable = false)
36+
private int maxMembers;
37+
38+
@Enumerated(EnumType.STRING)
39+
@Column(nullable = false)
40+
private MatchStatus status;
41+
42+
@OneToMany(mappedBy = "matchRoom", cascade = CascadeType.ALL, orphanRemoval = true)
43+
@Builder.Default
44+
private List<MatchParticipant> participants = new ArrayList<>();
45+
46+
public boolean isFull() {
47+
return this.curMembers >= this.maxMembers;
48+
}
2849

29-
public MatchRoom(SportType sportType, Long facilityId, Integer maxMembers) {
30-
this.sportType = sportType;
31-
this.facilityId = facilityId;
32-
this.maxMembers = maxMembers;
50+
public static MatchRoom create(MatchCreateRequest request) {
51+
return MatchRoom.builder()
52+
.sportsName(request.sportsName())
53+
.facilityId(request.facilityId())
54+
.curMembers(0)
55+
.maxMembers(request.maxMembers())
56+
.status(MatchStatus.OPEN)
57+
.build();
3358
}
3459
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
package com.be.sportizebe.domain.match.entity;
2+
3+
public enum MatchStatus {
4+
OPEN, // 참여 가능
5+
FULL, // 정원 마감
6+
CLOSED // 운영상 종료(옵션)
7+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
package com.be.sportizebe.domain.match.exception;
2+
3+
import com.be.sportizebe.global.exception.model.BaseErrorCode;
4+
import lombok.Getter;
5+
import org.springframework.http.HttpStatus;
6+
7+
@Getter
8+
public enum MatchErrorCode implements BaseErrorCode {
9+
10+
MATCH_NOT_FOUND(
11+
HttpStatus.NOT_FOUND,
12+
"MATCH_404",
13+
"매칭방을 찾을 수 없습니다."
14+
),
15+
16+
MATCH_FULL(
17+
HttpStatus.BAD_REQUEST,
18+
"MATCH_400_FULL",
19+
"매칭방 정원이 가득 찼습니다."
20+
),
21+
22+
ALREADY_JOINED(
23+
HttpStatus.BAD_REQUEST,
24+
"MATCH_400_ALREADY_JOINED",
25+
"이미 해당 매칭에 참가 중입니다."
26+
);
27+
28+
private final HttpStatus status;
29+
private final String code;
30+
private final String message;
31+
32+
MatchErrorCode(HttpStatus status, String code, String message) {
33+
this.status = status;
34+
this.code = code;
35+
this.message = message;
36+
}
37+
}

0 commit comments

Comments
 (0)