Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package inha.gdgoc.domain.admin.game.controller;

import inha.gdgoc.domain.admin.game.dto.request.MbtiTeamMatchRequest;
import inha.gdgoc.domain.admin.game.dto.response.MbtiAdminResultRowResponse;
import inha.gdgoc.domain.admin.game.dto.response.MbtiTeamMatchResponse;
import inha.gdgoc.domain.admin.game.service.MbtiAdminService;
import inha.gdgoc.global.dto.response.ApiResponse;
import inha.gdgoc.global.dto.response.PageMeta;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
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;

@RequiredArgsConstructor
@RestController
@RequestMapping("/api/v1/admin/game/mbti")
public class MbtiAdminController {

private static final String CORE_OR_HIGHER_RULE =
"@accessGuard.check(authentication,"
+ " T(inha.gdgoc.global.security.AccessGuard$AccessCondition).atLeast("
+ "T(inha.gdgoc.domain.user.enums.UserRole).CORE))";

private final MbtiAdminService mbtiAdminService;

@PreAuthorize(CORE_OR_HIGHER_RULE)
@GetMapping("/results")
public ResponseEntity<ApiResponse<Page<MbtiAdminResultRowResponse>, PageMeta>> listResults(
@RequestParam(required = false) String q,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "100") int size,
@RequestParam(defaultValue = "updatedAt") String sort,
@RequestParam(defaultValue = "DESC") String dir
) {
Sort.Direction direction = "ASC".equalsIgnoreCase(dir) ? Sort.Direction.ASC : Sort.Direction.DESC;
Pageable pageable = PageRequest.of(page, size, Sort.by(direction, sort));
Page<MbtiAdminResultRowResponse> result = mbtiAdminService.searchResults(q, pageable);

return ResponseEntity.ok(ApiResponse.ok("MBTI_RESULT_LIST_RETRIEVED", result, PageMeta.of(result)));
}

@PreAuthorize(CORE_OR_HIGHER_RULE)
@PostMapping("/team-matching")
public ResponseEntity<ApiResponse<MbtiTeamMatchResponse, Void>> matchTeams(
@Valid @RequestBody MbtiTeamMatchRequest request
) {
MbtiTeamMatchResponse response = mbtiAdminService.matchTeams(request);
return ResponseEntity.ok(ApiResponse.ok("MBTI_TEAM_MATCHING_COMPLETED", response));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package inha.gdgoc.domain.admin.game.dto.request;

import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.Valid;
import java.util.List;

public record MbtiTeamMatchRequest(
@NotEmpty List<@Valid Candidate> candidates,
@Min(2) @Max(10) Integer teamSize
) {
public int resolvedTeamSize() {
return teamSize == null ? 4 : teamSize;
}

public record Candidate(
@NotBlank String name,
@NotBlank String studentId
) {
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package inha.gdgoc.domain.admin.game.dto.response;

import inha.gdgoc.domain.game.entity.MbtiResult;
import java.time.Instant;

public record MbtiAdminResultRowResponse(
Long id,
String name,
String studentId,
String mbtiType,
Instant updatedAt,
Instant createdAt
) {
public static MbtiAdminResultRowResponse from(MbtiResult entity) {
return new MbtiAdminResultRowResponse(
entity.getId(),
entity.getName(),
entity.getStudentId(),
entity.getMbtiType(),
entity.getUpdatedAt(),
entity.getCreatedAt()
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package inha.gdgoc.domain.admin.game.dto.response;

import java.util.List;

public record MbtiTeamMatchResponse(
int totalCandidates,
int uniqueCandidates,
int matchedCount,
int unmatchedCount,
int teamSize,
int teamCount,
List<Team> teams,
List<UnmatchedCandidate> unmatchedCandidates
) {
public record Team(
int teamNumber,
List<Member> members
) {
}

public record Member(
String name,
String studentId,
String mbtiType
) {
}

public record UnmatchedCandidate(
String name,
String studentId,
String reason
) {
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
package inha.gdgoc.domain.admin.game.service;

import inha.gdgoc.domain.admin.game.dto.request.MbtiTeamMatchRequest;
import inha.gdgoc.domain.admin.game.dto.response.MbtiAdminResultRowResponse;
import inha.gdgoc.domain.admin.game.dto.response.MbtiTeamMatchResponse;
import inha.gdgoc.domain.game.entity.MbtiResult;
import inha.gdgoc.domain.game.repository.MbtiResultRepository;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Comparator;
import java.util.Deque;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@RequiredArgsConstructor
@Service
public class MbtiAdminService {

private static final String NO_RESULT_REASON = "NO_MBTI_RESULT";

private final MbtiResultRepository mbtiResultRepository;

@Transactional(readOnly = true)
public Page<MbtiAdminResultRowResponse> searchResults(String keyword, Pageable pageable) {
String query = keyword == null ? "" : keyword.trim();
if (query.isEmpty()) {
return mbtiResultRepository.findAll(pageable).map(MbtiAdminResultRowResponse::from);
}

return mbtiResultRepository
.findByNameContainingIgnoreCaseOrStudentIdContainingIgnoreCaseOrMbtiTypeContainingIgnoreCase(
query,
query,
query,
pageable
)
.map(MbtiAdminResultRowResponse::from);
}

@Transactional(readOnly = true)
public MbtiTeamMatchResponse matchTeams(MbtiTeamMatchRequest request) {
List<MbtiTeamMatchRequest.Candidate> rawCandidates = request.candidates();
Map<String, MbtiTeamMatchRequest.Candidate> uniqueByStudentId = new LinkedHashMap<>();

for (MbtiTeamMatchRequest.Candidate candidate : rawCandidates) {
if (candidate == null) {
continue;
}

String studentId = normalize(candidate.studentId());
if (studentId.isEmpty()) {
continue;
}

uniqueByStudentId.putIfAbsent(
studentId,
new MbtiTeamMatchRequest.Candidate(normalize(candidate.name()), studentId)
);
}

List<MbtiTeamMatchRequest.Candidate> uniqueCandidates = new ArrayList<>(uniqueByStudentId.values());
List<String> studentIds = uniqueCandidates.stream()
.map(MbtiTeamMatchRequest.Candidate::studentId)
.toList();

Map<String, MbtiResult> resultMap = mbtiResultRepository.findByStudentIdIn(studentIds).stream()
.collect(
LinkedHashMap::new,
(acc, row) -> acc.putIfAbsent(row.getStudentId(), row),
Map::putAll
);

List<MbtiTeamMatchResponse.Member> matchedMembers = new ArrayList<>();
List<MbtiTeamMatchResponse.UnmatchedCandidate> unmatched = new ArrayList<>();

for (MbtiTeamMatchRequest.Candidate candidate : uniqueCandidates) {
MbtiResult matched = resultMap.get(candidate.studentId());
if (matched == null) {
unmatched.add(new MbtiTeamMatchResponse.UnmatchedCandidate(
candidate.name(),
candidate.studentId(),
NO_RESULT_REASON
));
continue;
}

matchedMembers.add(new MbtiTeamMatchResponse.Member(
candidate.name().isEmpty() ? matched.getName() : candidate.name(),
candidate.studentId(),
matched.getMbtiType()
));
}

int teamSize = request.resolvedTeamSize();
List<MbtiTeamMatchResponse.Team> teams = buildBalancedTeams(matchedMembers, teamSize);

return new MbtiTeamMatchResponse(
rawCandidates.size(),
uniqueCandidates.size(),
matchedMembers.size(),
unmatched.size(),
teamSize,
teams.size(),
teams,
unmatched
);
}

private List<MbtiTeamMatchResponse.Team> buildBalancedTeams(
List<MbtiTeamMatchResponse.Member> members,
int teamSize
) {
if (members.isEmpty()) {
return List.of();
}

int teamCount = (int) Math.ceil((double) members.size() / teamSize);
List<TeamBucket> buckets = new ArrayList<>();
for (int i = 0; i < teamCount; i += 1) {
buckets.add(new TeamBucket(i + 1));
}

Map<String, List<MbtiTeamMatchResponse.Member>> grouped = members.stream()
.filter(Objects::nonNull)
.collect(
LinkedHashMap::new,
(acc, member) -> acc.computeIfAbsent(member.mbtiType(), key -> new ArrayList<>()).add(member),
Map::putAll
);

List<Deque<MbtiTeamMatchResponse.Member>> queues = grouped.values().stream()
.sorted(Comparator.comparingInt((List<MbtiTeamMatchResponse.Member> list) -> list.size()).reversed())
.map(list -> (Deque<MbtiTeamMatchResponse.Member>) new ArrayDeque<>(list))
.toList();

List<MbtiTeamMatchResponse.Member> 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<MbtiTeamMatchResponse.Member> interleaveByType(
Collection<Deque<MbtiTeamMatchResponse.Member>> queues
) {
List<MbtiTeamMatchResponse.Member> result = new ArrayList<>();
boolean hasRemaining = true;

while (hasRemaining) {
hasRemaining = false;
for (Deque<MbtiTeamMatchResponse.Member> queue : queues) {
MbtiTeamMatchResponse.Member member = queue.pollFirst();
if (member == null) {
continue;
}

result.add(member);
if (!queue.isEmpty()) {
hasRemaining = true;
}
}
}

return result;
}

private String normalize(String value) {
return value == null ? "" : value.trim();
}

private static final class TeamBucket {
private final int teamNumber;
private final List<MbtiTeamMatchResponse.Member> members = new ArrayList<>();
private final Map<String, Integer> typeCounts = new HashMap<>();

private TeamBucket(int teamNumber) {
this.teamNumber = teamNumber;
}

private void add(MbtiTeamMatchResponse.Member member) {
members.add(member);
typeCounts.merge(member.mbtiType(), 1, Integer::sum);
}

private int size() {
return members.size();
}

private int teamNumber() {
return teamNumber;
}

private int countType(String mbtiType) {
return typeCounts.getOrDefault(mbtiType, 0);
}

private MbtiTeamMatchResponse.Team toResponse() {
return new MbtiTeamMatchResponse.Team(teamNumber, List.copyOf(members));
}
}
}
Loading
Loading