From 0f90502bd4aef248f6521ab5d8f38fb09e0c3b5d Mon Sep 17 00:00:00 2001 From: Seo sangmin <139242512+smpringles24@users.noreply.github.com> Date: Mon, 9 Mar 2026 18:35:20 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(game-rythm8beat):=208=EB=B0=95?= =?UTF-8?q?=EC=9E=90=20=EA=B2=8C=EC=9E=84=20=EB=9E=AD=ED=82=B9=20=EB=9D=BC?= =?UTF-8?q?=EC=9A=B0=ED=8A=B8=20=EA=B5=AC=ED=98=84=20(#308)=20(#310)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: sm_mac_mini --- .../controller/Rythm8beatScoreController.java | 48 ++++++++++++++ .../message/Rythm8beatScoreMessage.java | 7 +++ .../dto/request/Rythm8beatScoreRequest.java | 30 +++++++++ .../response/Rythm8beatRankItemResponse.java | 12 ++++ .../response/Rythm8beatRankingResponse.java | 12 ++++ .../domain/game/entity/Rythm8beatScore.java | 48 ++++++++++++++ .../repository/Rythm8beatScoreRepository.java | 18 ++++++ .../game/service/Rythm8beatScoreService.java | 62 +++++++++++++++++++ .../gdgoc/global/security/SecurityConfig.java | 3 +- .../V20260309__create_game_scores.sql | 10 +++ 10 files changed, 249 insertions(+), 1 deletion(-) create mode 100644 src/main/java/inha/gdgoc/domain/game/controller/Rythm8beatScoreController.java create mode 100644 src/main/java/inha/gdgoc/domain/game/controller/message/Rythm8beatScoreMessage.java create mode 100644 src/main/java/inha/gdgoc/domain/game/dto/request/Rythm8beatScoreRequest.java create mode 100644 src/main/java/inha/gdgoc/domain/game/dto/response/Rythm8beatRankItemResponse.java create mode 100644 src/main/java/inha/gdgoc/domain/game/dto/response/Rythm8beatRankingResponse.java create mode 100644 src/main/java/inha/gdgoc/domain/game/entity/Rythm8beatScore.java create mode 100644 src/main/java/inha/gdgoc/domain/game/repository/Rythm8beatScoreRepository.java create mode 100644 src/main/java/inha/gdgoc/domain/game/service/Rythm8beatScoreService.java create mode 100644 src/main/resources/db/migration/V20260309__create_game_scores.sql diff --git a/src/main/java/inha/gdgoc/domain/game/controller/Rythm8beatScoreController.java b/src/main/java/inha/gdgoc/domain/game/controller/Rythm8beatScoreController.java new file mode 100644 index 0000000..9fe9c6e --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/game/controller/Rythm8beatScoreController.java @@ -0,0 +1,48 @@ +package inha.gdgoc.domain.game.controller; + +import static inha.gdgoc.domain.game.controller.message.Rythm8beatScoreMessage.RANKING_RETRIEVED; +import static inha.gdgoc.domain.game.controller.message.Rythm8beatScoreMessage.SCORES_RESET; +import static inha.gdgoc.domain.game.controller.message.Rythm8beatScoreMessage.SCORE_SUBMITTED; + +import inha.gdgoc.domain.game.dto.request.Rythm8beatScoreRequest; +import inha.gdgoc.domain.game.dto.response.Rythm8beatRankingResponse; +import inha.gdgoc.domain.game.service.Rythm8beatScoreService; +import inha.gdgoc.global.dto.response.ApiResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +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.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RequestMapping("/api/v1/game/rythm8beat") +@RestController +@RequiredArgsConstructor +public class Rythm8beatScoreController { + + private final Rythm8beatScoreService rythm8beatScoreService; + + @PostMapping("/scores") + public ResponseEntity> submitScore( + @Valid @RequestBody Rythm8beatScoreRequest request) { + rythm8beatScoreService.submitScore(request); + return ResponseEntity.ok(ApiResponse.ok(SCORE_SUBMITTED)); + } + + @GetMapping("/ranking") + public ResponseEntity> getRanking( + @RequestParam(required = false) String phoneNumber) { + Rythm8beatRankingResponse response = rythm8beatScoreService.getRanking(phoneNumber); + return ResponseEntity.ok(ApiResponse.ok(RANKING_RETRIEVED, response)); + } + + @DeleteMapping("/scores/all") + public ResponseEntity> resetAll() { + rythm8beatScoreService.resetAll(); + return ResponseEntity.ok(ApiResponse.ok(SCORES_RESET)); + } +} diff --git a/src/main/java/inha/gdgoc/domain/game/controller/message/Rythm8beatScoreMessage.java b/src/main/java/inha/gdgoc/domain/game/controller/message/Rythm8beatScoreMessage.java new file mode 100644 index 0000000..b3e04ea --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/game/controller/message/Rythm8beatScoreMessage.java @@ -0,0 +1,7 @@ +package inha.gdgoc.domain.game.controller.message; + +public class Rythm8beatScoreMessage { + public static final String SCORE_SUBMITTED = "점수가 등록되었습니다."; + public static final String RANKING_RETRIEVED = "랭킹을 조회했습니다."; + public static final String SCORES_RESET = "모든 점수가 초기화되었습니다."; +} diff --git a/src/main/java/inha/gdgoc/domain/game/dto/request/Rythm8beatScoreRequest.java b/src/main/java/inha/gdgoc/domain/game/dto/request/Rythm8beatScoreRequest.java new file mode 100644 index 0000000..76088e7 --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/game/dto/request/Rythm8beatScoreRequest.java @@ -0,0 +1,30 @@ +package inha.gdgoc.domain.game.dto.request; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class Rythm8beatScoreRequest { + + @NotBlank + @Pattern(regexp = "^01[0-9]{8,9}$", message = "올바른 휴대폰 번호 형식이 아닙니다.") + private String phoneNumber; + + @NotBlank + @Size(max = 20) + private String nickname; + + @NotNull + @Min(0) + @Max(10000) + private Integer score; + + private Integer stageReached; +} diff --git a/src/main/java/inha/gdgoc/domain/game/dto/response/Rythm8beatRankItemResponse.java b/src/main/java/inha/gdgoc/domain/game/dto/response/Rythm8beatRankItemResponse.java new file mode 100644 index 0000000..ac4b035 --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/game/dto/response/Rythm8beatRankItemResponse.java @@ -0,0 +1,12 @@ +package inha.gdgoc.domain.game.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class Rythm8beatRankItemResponse { + private int rank; + private String nickname; + private int score; +} diff --git a/src/main/java/inha/gdgoc/domain/game/dto/response/Rythm8beatRankingResponse.java b/src/main/java/inha/gdgoc/domain/game/dto/response/Rythm8beatRankingResponse.java new file mode 100644 index 0000000..913b5e6 --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/game/dto/response/Rythm8beatRankingResponse.java @@ -0,0 +1,12 @@ +package inha.gdgoc.domain.game.dto.response; + +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class Rythm8beatRankingResponse { + private List top3; + private Rythm8beatRankItemResponse userRank; +} diff --git a/src/main/java/inha/gdgoc/domain/game/entity/Rythm8beatScore.java b/src/main/java/inha/gdgoc/domain/game/entity/Rythm8beatScore.java new file mode 100644 index 0000000..87f6bac --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/game/entity/Rythm8beatScore.java @@ -0,0 +1,48 @@ +package inha.gdgoc.domain.game.entity; + +import inha.gdgoc.global.entity.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "game_scores") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Getter +@Builder +public class Rythm8beatScore extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + + @Column(name = "phone_number", nullable = false, unique = true, length = 20) + private String phoneNumber; + + @Column(name = "nickname", nullable = false, length = 20) + private String nickname; + + @Column(name = "score", nullable = false) + private int score; + + @Column(name = "stage_reached", nullable = false) + private int stageReached; + + public void updateIfHigherScore(String nickname, int newScore, int newStageReached) { + if (this.score < newScore) { + this.nickname = nickname; + this.score = newScore; + this.stageReached = newStageReached; + } + } +} diff --git a/src/main/java/inha/gdgoc/domain/game/repository/Rythm8beatScoreRepository.java b/src/main/java/inha/gdgoc/domain/game/repository/Rythm8beatScoreRepository.java new file mode 100644 index 0000000..7286bdf --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/game/repository/Rythm8beatScoreRepository.java @@ -0,0 +1,18 @@ +package inha.gdgoc.domain.game.repository; + +import inha.gdgoc.domain.game.entity.Rythm8beatScore; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface Rythm8beatScoreRepository extends JpaRepository { + + Optional findByPhoneNumber(String phoneNumber); + + List findTop3ByOrderByScoreDescUpdatedAtAsc(); + + @Query("SELECT COUNT(r) FROM Rythm8beatScore r WHERE r.score > :score") + long countByScoreGreaterThan(@Param("score") int score); +} diff --git a/src/main/java/inha/gdgoc/domain/game/service/Rythm8beatScoreService.java b/src/main/java/inha/gdgoc/domain/game/service/Rythm8beatScoreService.java new file mode 100644 index 0000000..a0642b2 --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/game/service/Rythm8beatScoreService.java @@ -0,0 +1,62 @@ +package inha.gdgoc.domain.game.service; + +import inha.gdgoc.domain.game.dto.request.Rythm8beatScoreRequest; +import inha.gdgoc.domain.game.dto.response.Rythm8beatRankItemResponse; +import inha.gdgoc.domain.game.dto.response.Rythm8beatRankingResponse; +import inha.gdgoc.domain.game.entity.Rythm8beatScore; +import inha.gdgoc.domain.game.repository.Rythm8beatScoreRepository; +import java.util.List; +import java.util.stream.IntStream; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional +public class Rythm8beatScoreService { + + private final Rythm8beatScoreRepository rythm8beatScoreRepository; + + public void submitScore(Rythm8beatScoreRequest request) { + rythm8beatScoreRepository.findByPhoneNumber(request.getPhoneNumber()) + .ifPresentOrElse( + entity -> entity.updateIfHigherScore( + request.getNickname(), + request.getScore(), + request.getStageReached() != null ? request.getStageReached() : 1 + ), + () -> rythm8beatScoreRepository.save(Rythm8beatScore.builder() + .phoneNumber(request.getPhoneNumber()) + .nickname(request.getNickname()) + .score(request.getScore()) + .stageReached(request.getStageReached() != null ? request.getStageReached() : 1) + .build()) + ); + } + + @Transactional(readOnly = true) + public Rythm8beatRankingResponse getRanking(String phoneNumber) { + List top3 = rythm8beatScoreRepository.findTop3ByOrderByScoreDescUpdatedAtAsc(); + + List top3Response = IntStream.range(0, top3.size()) + .mapToObj(i -> new Rythm8beatRankItemResponse(i + 1, top3.get(i).getNickname(), top3.get(i).getScore())) + .toList(); + + Rythm8beatRankItemResponse userRank = null; + if (phoneNumber != null && !phoneNumber.isBlank()) { + userRank = rythm8beatScoreRepository.findByPhoneNumber(phoneNumber) + .map(gs -> { + long rank = rythm8beatScoreRepository.countByScoreGreaterThan(gs.getScore()) + 1; + return new Rythm8beatRankItemResponse((int) rank, gs.getNickname(), gs.getScore()); + }) + .orElse(null); + } + + return new Rythm8beatRankingResponse(top3Response, userRank); + } + + public void resetAll() { + rythm8beatScoreRepository.deleteAll(); + } +} diff --git a/src/main/java/inha/gdgoc/global/security/SecurityConfig.java b/src/main/java/inha/gdgoc/global/security/SecurityConfig.java index 7f624e8..1691bb5 100644 --- a/src/main/java/inha/gdgoc/global/security/SecurityConfig.java +++ b/src/main/java/inha/gdgoc/global/security/SecurityConfig.java @@ -100,7 +100,8 @@ public CorsConfigurationSource corsConfigurationSource() { "https://www.gdgocinha.com", "https://typing-game-alpha-umber.vercel.app", "https://api.gdgocinha.com", - "https://*.gdgocinha.com" + "https://*.gdgocinha.com", + "https://smpringles24.github.io" )); config.setAllowedMethods(List.of("GET","POST","PUT","DELETE","OPTIONS","PATCH")); config.setAllowedHeaders(List.of("Origin","X-Requested-With","Content-Type","Accept","Authorization")); diff --git a/src/main/resources/db/migration/V20260309__create_game_scores.sql b/src/main/resources/db/migration/V20260309__create_game_scores.sql new file mode 100644 index 0000000..0ca4422 --- /dev/null +++ b/src/main/resources/db/migration/V20260309__create_game_scores.sql @@ -0,0 +1,10 @@ +CREATE TABLE IF NOT EXISTS game_scores ( + id BIGSERIAL PRIMARY KEY, + phone_number VARCHAR(20) NOT NULL UNIQUE, + nickname VARCHAR(20) NOT NULL, + score INT NOT NULL DEFAULT 0, + stage_reached INT NOT NULL DEFAULT 1, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS idx_game_scores_score ON game_scores(score DESC); From 6965317de7cd8eb14c2d21bb65681787248f490d Mon Sep 17 00:00:00 2001 From: JM Kim <106949557+CSE-Shaco@users.noreply.github.com> Date: Wed, 11 Mar 2026 12:48:43 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20MBTI=20=ED=8C=80=EB=A7=A4=EC=B9=AD?= =?UTF-8?q?=EC=97=90=20=EB=A6=AC=EB=93=9C=C2=B7=EC=98=A4=EA=B1=B0=EB=82=98?= =?UTF-8?q?=EC=9D=B4=EC=A0=80=20=EC=A0=9C=EC=99=B8=20=EB=A1=9C=EC=A7=81=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 --- .../dto/request/MbtiTeamMatchRequest.java | 2 +- .../dto/response/MbtiTeamMatchResponse.java | 3 +- .../admin/game/service/MbtiAdminService.java | 196 +++++++++++++----- .../user/repository/UserRepository.java | 1 + 4 files changed, 153 insertions(+), 49 deletions(-) diff --git a/src/main/java/inha/gdgoc/domain/admin/game/dto/request/MbtiTeamMatchRequest.java b/src/main/java/inha/gdgoc/domain/admin/game/dto/request/MbtiTeamMatchRequest.java index 3d6d2ea..36f9672 100644 --- a/src/main/java/inha/gdgoc/domain/admin/game/dto/request/MbtiTeamMatchRequest.java +++ b/src/main/java/inha/gdgoc/domain/admin/game/dto/request/MbtiTeamMatchRequest.java @@ -12,7 +12,7 @@ public record MbtiTeamMatchRequest( @Min(2) @Max(10) Integer teamSize ) { public int resolvedTeamSize() { - return teamSize == null ? 4 : teamSize; + return teamSize == null ? 6 : teamSize; } public record Candidate( diff --git a/src/main/java/inha/gdgoc/domain/admin/game/dto/response/MbtiTeamMatchResponse.java b/src/main/java/inha/gdgoc/domain/admin/game/dto/response/MbtiTeamMatchResponse.java index 6971b00..3d151b8 100644 --- a/src/main/java/inha/gdgoc/domain/admin/game/dto/response/MbtiTeamMatchResponse.java +++ b/src/main/java/inha/gdgoc/domain/admin/game/dto/response/MbtiTeamMatchResponse.java @@ -21,7 +21,8 @@ public record Team( public record Member( String name, String studentId, - String mbtiType + String mbtiType, + boolean hasMbtiResult ) { } diff --git a/src/main/java/inha/gdgoc/domain/admin/game/service/MbtiAdminService.java b/src/main/java/inha/gdgoc/domain/admin/game/service/MbtiAdminService.java index cc41ab0..d0e8f94 100644 --- a/src/main/java/inha/gdgoc/domain/admin/game/service/MbtiAdminService.java +++ b/src/main/java/inha/gdgoc/domain/admin/game/service/MbtiAdminService.java @@ -5,9 +5,10 @@ import inha.gdgoc.domain.admin.game.dto.response.MbtiTeamMatchResponse; import inha.gdgoc.domain.game.entity.MbtiResult; import inha.gdgoc.domain.game.repository.MbtiResultRepository; +import inha.gdgoc.domain.user.enums.UserRole; +import inha.gdgoc.domain.user.repository.UserRepository; import java.util.ArrayDeque; import java.util.ArrayList; -import java.util.Collection; import java.util.Comparator; import java.util.Deque; import java.util.HashMap; @@ -24,10 +25,28 @@ @RequiredArgsConstructor @Service public class MbtiAdminService { - - private static final String NO_RESULT_REASON = "NO_MBTI_RESULT"; + private static final String EXCLUDED_PRIVILEGED_ROLE_REASON = "EXCLUDED_PRIVILEGED_ROLE"; + private static final Map> TEAMMATE_COMPATIBILITY = Map.ofEntries( + Map.entry("LPTI", List.of("CPTF", "CPUI", "LSTF")), + Map.entry("LPTF", List.of("LSTF", "CPTF", "LPUI")), + Map.entry("LSTI", List.of("LPTF", "CPUF", "LPUF")), + Map.entry("LSTF", List.of("LPTI", "CPTF", "LPUF")), + Map.entry("CPTI", List.of("LSTF", "LPTI", "LPUI")), + Map.entry("CPTF", List.of("LPTI", "LSTF", "LSUI")), + Map.entry("CSTI", List.of("CPUI", "LPTF", "LPUI")), + Map.entry("CSTF", List.of("LSTF", "CPTF", "CPUF")), + Map.entry("LPUI", List.of("CPTF", "LSTF", "CSUI")), + Map.entry("LPUF", List.of("LSTI", "LSTF", "CPUF")), + Map.entry("LSUI", List.of("LPTF", "CPTI", "LPUI")), + Map.entry("LSUF", List.of("LPTF", "CPTF", "CPUF")), + Map.entry("CPUI", List.of("LPTI", "LSTI", "CSTI")), + Map.entry("CPUF", List.of("LSTI", "LPUF", "CPTF")), + Map.entry("CSUI", List.of("LPUI", "LSTF", "CPTF")), + Map.entry("CSUF", List.of("CPTF", "LSTF", "CPUF")) + ); private final MbtiResultRepository mbtiResultRepository; + private final UserRepository userRepository; @Transactional(readOnly = true) public Page searchResults(String keyword, Pageable pageable) { @@ -71,6 +90,12 @@ public MbtiTeamMatchResponse matchTeams(MbtiTeamMatchRequest request) { List studentIds = uniqueCandidates.stream() .map(MbtiTeamMatchRequest.Candidate::studentId) .toList(); + Map roleByStudentId = userRepository.findByStudentIdIn(studentIds).stream() + .collect( + LinkedHashMap::new, + (acc, user) -> acc.putIfAbsent(user.getStudentId(), user.getUserRole()), + Map::putAll + ); Map resultMap = mbtiResultRepository.findByStudentIdIn(studentIds).stream() .collect( @@ -80,15 +105,27 @@ public MbtiTeamMatchResponse matchTeams(MbtiTeamMatchRequest request) { ); List matchedMembers = new ArrayList<>(); + List unmatchedMembers = new ArrayList<>(); List unmatched = new ArrayList<>(); for (MbtiTeamMatchRequest.Candidate candidate : uniqueCandidates) { + UserRole userRole = roleByStudentId.get(candidate.studentId()); + if (userRole == UserRole.LEAD || userRole == UserRole.ORGANIZER) { + unmatched.add(new MbtiTeamMatchResponse.UnmatchedCandidate( + candidate.name(), + candidate.studentId(), + EXCLUDED_PRIVILEGED_ROLE_REASON + )); + continue; + } + MbtiResult matched = resultMap.get(candidate.studentId()); if (matched == null) { - unmatched.add(new MbtiTeamMatchResponse.UnmatchedCandidate( + unmatchedMembers.add(new MbtiTeamMatchResponse.Member( candidate.name(), candidate.studentId(), - NO_RESULT_REASON + null, + false )); continue; } @@ -96,39 +133,77 @@ public MbtiTeamMatchResponse matchTeams(MbtiTeamMatchRequest request) { matchedMembers.add(new MbtiTeamMatchResponse.Member( candidate.name().isEmpty() ? matched.getName() : candidate.name(), candidate.studentId(), - matched.getMbtiType() + matched.getMbtiType(), + true )); } int teamSize = request.resolvedTeamSize(); - List teams = buildBalancedTeams(matchedMembers, teamSize); + TeamBuildResult buildResult = buildBalancedTeams(matchedMembers, unmatchedMembers, teamSize); return new MbtiTeamMatchResponse( rawCandidates.size(), uniqueCandidates.size(), - matchedMembers.size(), + buildResult.assignedCount(), unmatched.size(), teamSize, - teams.size(), - teams, + buildResult.teams().size(), + buildResult.teams(), unmatched ); } - private List buildBalancedTeams( - List members, + private TeamBuildResult buildBalancedTeams( + List matchedMembers, + List unmatchedMembers, int teamSize ) { - if (members.isEmpty()) { - return List.of(); + int totalMembers = matchedMembers.size() + unmatchedMembers.size(); + if (totalMembers == 0) { + return new TeamBuildResult(List.of()); } - int teamCount = (int) Math.ceil((double) members.size() / teamSize); + int teamCount = (int) Math.ceil((double) totalMembers / teamSize); + List buckets = new ArrayList<>(); for (int i = 0; i < teamCount; i += 1) { - buckets.add(new TeamBucket(i + 1)); + int baseSize = totalMembers / teamCount + (i < totalMembers % teamCount ? 1 : 0); + int unmatchedTarget = unmatchedMembers.size() / teamCount + (i < unmatchedMembers.size() % teamCount ? 1 : 0); + unmatchedTarget = Math.min(unmatchedTarget, baseSize); + buckets.add(new TeamBucket(i + 1, unmatchedTarget, baseSize - unmatchedTarget)); + } + + for (MbtiTeamMatchResponse.Member member : unmatchedMembers) { + TeamBucket bucket = buckets.stream() + .filter(TeamBucket::canAcceptUnmatched) + .min(Comparator.comparingInt(TeamBucket::size).thenComparingInt(TeamBucket::teamNumber)) + .orElseThrow(); + + bucket.addUnmatched(member); } + List orderedMatched = buildCompatibilitySeedOrder(matchedMembers); + + for (MbtiTeamMatchResponse.Member member : orderedMatched) { + TeamBucket bucket = buckets.stream() + .filter(TeamBucket::canAcceptMatched) + .max( + Comparator.comparingInt((TeamBucket team) -> team.compatibilityScoreFor(member)) + .thenComparing(Comparator.comparingInt(TeamBucket::size).reversed()) + .thenComparing(Comparator.comparingInt(TeamBucket::teamNumber).reversed()) + ) + .orElseThrow(); + + bucket.addMatched(member); + } + + List teams = buckets.stream().map(TeamBucket::toResponse).toList(); + return new TeamBuildResult(teams); + } + + private List buildCompatibilitySeedOrder( + List members + ) { Map> grouped = members.stream() .filter(Objects::nonNull) .collect( @@ -142,29 +217,7 @@ private List buildBalancedTeams( .map(list -> (Deque) new ArrayDeque<>(list)) .toList(); - List ordered = interleaveByType(queues); - - for (MbtiTeamMatchResponse.Member member : ordered) { - TeamBucket bucket = buckets.stream() - .min( - Comparator.comparingInt(TeamBucket::size) - .thenComparingInt(team -> team.countType(member.mbtiType())) - .thenComparingInt(TeamBucket::teamNumber) - ) - .orElseThrow(); - - bucket.add(member); - } - - return buckets.stream() - .map(TeamBucket::toResponse) - .toList(); - } - - private List interleaveByType( - Collection> queues - ) { - List result = new ArrayList<>(); + List ordered = new ArrayList<>(); boolean hasRemaining = true; while (hasRemaining) { @@ -175,14 +228,34 @@ private List interleaveByType( continue; } - result.add(member); + ordered.add(member); if (!queue.isEmpty()) { hasRemaining = true; } } } - return result; + return ordered; + } + + private static int pairCompatibilityScore(String sourceType, String targetType) { + if (sourceType == null || targetType == null) { + return 0; + } + + List sourceMatches = TEAMMATE_COMPATIBILITY.getOrDefault(sourceType, List.of()); + List targetMatches = TEAMMATE_COMPATIBILITY.getOrDefault(targetType, List.of()); + + boolean sourcePrefersTarget = sourceMatches.contains(targetType); + boolean targetPrefersSource = targetMatches.contains(sourceType); + + if (sourcePrefersTarget && targetPrefersSource) { + return 3; + } + if (sourcePrefersTarget || targetPrefersSource) { + return 1; + } + return 0; } private String normalize(String value) { @@ -191,16 +264,24 @@ private String normalize(String value) { private static final class TeamBucket { private final int teamNumber; + private final int unmatchedTarget; + private final int matchedTarget; private final List members = new ArrayList<>(); - private final Map typeCounts = new HashMap<>(); + private int unmatchedCount; - private TeamBucket(int teamNumber) { + private TeamBucket(int teamNumber, int unmatchedTarget, int matchedTarget) { this.teamNumber = teamNumber; + this.unmatchedTarget = unmatchedTarget; + this.matchedTarget = matchedTarget; + } + + private void addMatched(MbtiTeamMatchResponse.Member member) { + members.add(member); } - private void add(MbtiTeamMatchResponse.Member member) { + private void addUnmatched(MbtiTeamMatchResponse.Member member) { members.add(member); - typeCounts.merge(member.mbtiType(), 1, Integer::sum); + unmatchedCount += 1; } private int size() { @@ -211,12 +292,33 @@ private int teamNumber() { return teamNumber; } - private int countType(String mbtiType) { - return typeCounts.getOrDefault(mbtiType, 0); + private boolean canAcceptUnmatched() { + return unmatchedCount < unmatchedTarget && size() < unmatchedTarget + matchedTarget; + } + + private boolean canAcceptMatched() { + return matchedCount() < matchedTarget && size() < unmatchedTarget + matchedTarget; + } + + private int matchedCount() { + return size() - unmatchedCount; + } + + private int compatibilityScoreFor(MbtiTeamMatchResponse.Member candidate) { + return members.stream() + .filter(MbtiTeamMatchResponse.Member::hasMbtiResult) + .mapToInt(member -> pairCompatibilityScore(candidate.mbtiType(), member.mbtiType())) + .sum(); } private MbtiTeamMatchResponse.Team toResponse() { return new MbtiTeamMatchResponse.Team(teamNumber, List.copyOf(members)); } } + + private record TeamBuildResult(List teams) { + private int assignedCount() { + return teams.stream().mapToInt(team -> team.members().size()).sum(); + } + } } diff --git a/src/main/java/inha/gdgoc/domain/user/repository/UserRepository.java b/src/main/java/inha/gdgoc/domain/user/repository/UserRepository.java index bacf83a..4e7e3ec 100644 --- a/src/main/java/inha/gdgoc/domain/user/repository/UserRepository.java +++ b/src/main/java/inha/gdgoc/domain/user/repository/UserRepository.java @@ -22,6 +22,7 @@ public interface UserRepository extends JpaRepository, UserRepositor Optional findByOauthSubject(String oauthSubject); boolean existsByStudentId(String studentId); + List findByStudentIdIn(Collection studentIds); boolean existsByPhoneNumber(String phoneNumber); boolean existsByNameAndEmail(String name, String email); boolean existsByEmail(String email);