-
Notifications
You must be signed in to change notification settings - Fork 2
feat: NL 기반 선수 벌크 등록 API 구현 #431
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 2 commits
737ce18
e94950f
640282b
caeed00
18cde1b
7334989
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,250 @@ | ||
| package com.sports.server.command.nl.application; | ||
|
|
||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 전체적으로 메서드 하나가 너무 길고 메서드별로 분리가 안 돼 있어서 가독성이 너무 떨어져요 ㅠㅠ 신경 써주심 좋을 것 같아요! |
||
| import com.sports.server.command.league.domain.League; | ||
| import com.sports.server.command.league.domain.LeagueTeamRepository; | ||
| import com.sports.server.command.member.domain.Member; | ||
| import com.sports.server.command.nl.dto.NlExecuteRequest; | ||
| import com.sports.server.command.nl.dto.NlExecuteResponse; | ||
| import com.sports.server.command.nl.dto.NlProcessRequest; | ||
| import com.sports.server.command.nl.dto.NlProcessResponse; | ||
| import com.sports.server.command.nl.dto.NlProcessResponse.*; | ||
| import com.sports.server.command.nl.domain.PlayerStatus; | ||
| import com.sports.server.command.nl.infra.GeminiFunctionCallResponse; | ||
| import com.sports.server.command.nl.infra.NlGeminiClient; | ||
| import com.sports.server.command.player.domain.Player; | ||
| import com.sports.server.command.player.domain.PlayerRepository; | ||
| import com.sports.server.command.player.dto.PlayerRequest; | ||
| import com.sports.server.command.team.domain.Team; | ||
| import com.sports.server.command.team.domain.TeamPlayerRepository; | ||
| import com.sports.server.command.team.dto.TeamRequest; | ||
| import com.sports.server.command.team.application.TeamService; | ||
| import com.sports.server.command.player.application.PlayerService; | ||
| import com.sports.server.command.nl.exception.NlErrorMessages; | ||
| import com.sports.server.common.application.EntityUtils; | ||
| import com.sports.server.common.application.PermissionValidator; | ||
| import com.sports.server.common.exception.BadRequestException; | ||
| import com.sports.server.common.util.StudentNumber; | ||
| import lombok.RequiredArgsConstructor; | ||
| import org.springframework.stereotype.Service; | ||
| import org.springframework.transaction.annotation.Transactional; | ||
|
|
||
| import java.util.*; | ||
| import java.util.regex.Matcher; | ||
| import java.util.regex.Pattern; | ||
| import java.util.stream.Collectors; | ||
|
|
||
|
|
||
| @Service | ||
| @RequiredArgsConstructor | ||
| public class NlService { | ||
|
|
||
| private final NlGeminiClient nlGeminiClient; | ||
|
||
| private final PlayerRepository playerRepository; | ||
| private final TeamPlayerRepository teamPlayerRepository; | ||
| private final LeagueTeamRepository leagueTeamRepository; | ||
| private final EntityUtils entityUtils; | ||
| private final PlayerService playerService; | ||
| private final TeamService teamService; | ||
|
|
||
| private static final Pattern NINE_DIGIT_PATTERN = Pattern.compile("(?<!\\d)\\d{9}(?!\\d)"); | ||
|
|
||
| @Transactional(readOnly = true) | ||
| public NlProcessResponse process(NlProcessRequest request, Member member) { | ||
| League league = entityUtils.getEntity(request.leagueId(), League.class); | ||
| PermissionValidator.checkPermission(league, member); | ||
| Team team = entityUtils.getEntity(request.teamId(), Team.class); | ||
| validateTeamBelongsToLeague(league, team); | ||
|
|
||
| // 1. Gemini Function Calling으로 텍스트 파싱 | ||
| GeminiFunctionCallResponse geminiResponse = nlGeminiClient.parsePlayers( | ||
| request.message(), request.history() | ||
| ); | ||
|
|
||
| if (!geminiResponse.hasFunctionCall()) { | ||
| return new NlProcessResponse( | ||
| geminiResponse.getText().isEmpty() | ||
| ? NlErrorMessages.PARSE_FAILED | ||
| : geminiResponse.getText(), | ||
| null | ||
| ); | ||
|
||
| } | ||
|
|
||
| // 2. Function Call 결과에서 선수 목록 추출 | ||
| Map<String, Object> args = geminiResponse.getFunctionCall().args(); | ||
| @SuppressWarnings("unchecked") | ||
| List<Map<String, Object>> parsedPlayers = (List<Map<String, Object>>) args.get("players"); | ||
|
|
||
|
||
| if (parsedPlayers == null || parsedPlayers.isEmpty()) { | ||
| return new NlProcessResponse(NlErrorMessages.NO_PLAYER_INFO, null); | ||
| } | ||
|
|
||
| // 3. 원본 텍스트에서 9자리 숫자 추출 (학번 대조 검증용) | ||
| Set<String> originalNineDigits = extractNineDigitNumbers(request.message()); | ||
|
|
||
| // 4. 각 선수 검증 + status 분류 | ||
| List<PlayerPreview> playerPreviews = new ArrayList<>(); | ||
| List<FailedLine> failedLines = new ArrayList<>(); | ||
| Set<String> seenStudentNumbers = new HashSet<>(); | ||
|
|
||
| // 팀에 이미 소속된 선수 ID 조회 | ||
| List<Long> existingPlayerIdsInTeam = teamPlayerRepository.findPlayerIdsByTeamId(request.teamId()); | ||
| Set<Long> teamPlayerIdSet = new HashSet<>(existingPlayerIdsInTeam); | ||
|
|
||
| // 파싱된 학번으로 DB 일괄 조회 | ||
| List<String> parsedStudentNumbers = parsedPlayers.stream() | ||
| .map(p -> (String) p.get("studentNumber")) | ||
| .filter(Objects::nonNull) | ||
| .toList(); | ||
| Map<String, Player> existingPlayerMap = playerRepository.findByStudentNumberIn(parsedStudentNumbers) | ||
| .stream() | ||
| .collect(Collectors.toMap(Player::getStudentNumber, p -> p)); | ||
|
|
||
| for (int i = 0; i < parsedPlayers.size(); i++) { | ||
| Map<String, Object> parsed = parsedPlayers.get(i); | ||
| String name = (String) parsed.get("name"); | ||
| String studentNumber = (String) parsed.get("studentNumber"); | ||
| Integer jerseyNumber = parsed.get("jerseyNumber") instanceof Number n ? n.intValue() : null; | ||
|
|
||
| // 학번 형식 + 원본 대조 검증 | ||
| if (StudentNumber.isInvalid(studentNumber) || !originalNineDigits.contains(studentNumber)) { | ||
| failedLines.add(new FailedLine( | ||
| i + 1, | ||
| name != null ? name + " " + studentNumber : "라인 " + (i + 1), | ||
| StudentNumber.isInvalid(studentNumber) | ||
| ? NlErrorMessages.STUDENT_NUMBER_INVALID | ||
| : NlErrorMessages.STUDENT_NUMBER_NOT_IN_ORIGINAL | ||
| )); | ||
|
||
| continue; | ||
| } | ||
|
|
||
| // 입력 내 중복 체크 | ||
| if (seenStudentNumbers.contains(studentNumber)) { | ||
| playerPreviews.add(new PlayerPreview(name, studentNumber, jerseyNumber, PlayerStatus.DUPLICATE_IN_INPUT, null)); | ||
| continue; | ||
| } | ||
| seenStudentNumbers.add(studentNumber); | ||
|
|
||
| // DB 검증 | ||
| Player existingPlayer = existingPlayerMap.get(studentNumber); | ||
| if (existingPlayer == null) { | ||
| playerPreviews.add(new PlayerPreview(name, studentNumber, jerseyNumber, PlayerStatus.NEW, null)); | ||
| } else if (teamPlayerIdSet.contains(existingPlayer.getId())) { | ||
| playerPreviews.add(new PlayerPreview(name, studentNumber, jerseyNumber, PlayerStatus.ALREADY_IN_TEAM, existingPlayer.getId())); | ||
| } else { | ||
| playerPreviews.add(new PlayerPreview(name, studentNumber, jerseyNumber, PlayerStatus.EXISTS, existingPlayer.getId())); | ||
| } | ||
| } | ||
|
|
||
| // 5. Summary 생성 | ||
| int newCount = (int) playerPreviews.stream().filter(p -> p.status() == PlayerStatus.NEW).count(); | ||
| int existsCount = (int) playerPreviews.stream().filter(p -> p.status() == PlayerStatus.EXISTS).count(); | ||
| int alreadyInTeamCount = (int) playerPreviews.stream().filter(p -> p.status() == PlayerStatus.ALREADY_IN_TEAM).count(); | ||
|
|
||
| Summary summary = new Summary(playerPreviews.size(), newCount, existsCount, alreadyInTeamCount); | ||
|
|
||
| String teamName = team.getName(); | ||
| String displayMessage = String.format("%s에 %d명의 선수를 등록합니다. 확인해주세요.", teamName, newCount + existsCount); | ||
|
|
||
| Preview preview = new Preview( | ||
| "REGISTER_PLAYERS_BULK", | ||
| request.teamId(), | ||
| teamName, | ||
| playerPreviews, | ||
| summary, | ||
| failedLines | ||
| ); | ||
|
|
||
| return new NlProcessResponse(displayMessage, preview); | ||
| } | ||
|
|
||
| @Transactional | ||
| public NlExecuteResponse execute(NlExecuteRequest request, Member member) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. execute 쪽도 메서드 분리가 필요해 보여요~! |
||
| League league = entityUtils.getEntity(request.leagueId(), League.class); | ||
| PermissionValidator.checkPermission(league, member); | ||
| Team team = entityUtils.getEntity(request.teamId(), Team.class); | ||
| validateTeamBelongsToLeague(league, team); | ||
|
|
||
| Set<Long> teamPlayerIdSet = new HashSet<>( | ||
| teamPlayerRepository.findPlayerIdsByTeamId(request.teamId()) | ||
| ); | ||
|
|
||
| // 학번으로 기존 선수 일괄 조회 (N+1 방지) | ||
| List<String> studentNumbers = request.players().stream() | ||
| .map(NlExecuteRequest.PlayerData::studentNumber) | ||
| .toList(); | ||
| Map<String, Player> existingPlayerMap = playerRepository.findByStudentNumberIn(studentNumbers) | ||
| .stream() | ||
| .collect(Collectors.toMap(Player::getStudentNumber, p -> p)); | ||
|
|
||
| int created = 0; | ||
| int assigned = 0; | ||
| int skipped = 0; | ||
|
|
||
| Set<String> seenStudentNumbers = new HashSet<>(); | ||
| Set<Long> assignedPlayerIds = new HashSet<>(); | ||
| List<TeamRequest.TeamPlayerRegister> teamPlayerRegisters = new ArrayList<>(); | ||
|
|
||
| for (NlExecuteRequest.PlayerData playerData : request.players()) { | ||
| // 학번 중복 입력 방지 | ||
| if (!seenStudentNumbers.add(playerData.studentNumber())) { | ||
| skipped++; | ||
| continue; | ||
| } | ||
|
|
||
| Player existingPlayer = existingPlayerMap.get(playerData.studentNumber()); | ||
|
|
||
| Long playerId; | ||
| if (existingPlayer != null) { | ||
| playerId = existingPlayer.getId(); | ||
|
|
||
| // 이미 팀에 소속된 선수는 스킵 | ||
| if (teamPlayerIdSet.contains(playerId)) { | ||
| skipped++; | ||
| continue; | ||
| } | ||
| } else { | ||
| // 신규 선수 생성 | ||
| playerId = playerService.register( | ||
| new PlayerRequest.Register(playerData.name(), playerData.studentNumber()) | ||
| ); | ||
|
||
| created++; | ||
| } | ||
|
|
||
| // 중복 playerId 방지 | ||
| if (!assignedPlayerIds.add(playerId)) { | ||
| skipped++; | ||
| continue; | ||
| } | ||
|
|
||
| teamPlayerRegisters.add(new TeamRequest.TeamPlayerRegister( | ||
| playerId, | ||
| playerData.jerseyNumber() | ||
| )); | ||
| assigned++; | ||
| } | ||
|
|
||
| // 팀에 선수 일괄 배정 | ||
| if (!teamPlayerRegisters.isEmpty()) { | ||
| teamService.addPlayersToTeam(request.teamId(), teamPlayerRegisters); | ||
| } | ||
|
|
||
| String teamName = team.getName(); | ||
| String displayMessage = String.format("%s에 %d명의 선수가 등록되었습니다.", teamName, assigned); | ||
|
|
||
| return new NlExecuteResponse(displayMessage, new NlExecuteResponse.Result(created, assigned, skipped)); | ||
| } | ||
|
|
||
| private void validateTeamBelongsToLeague(League league, Team team) { | ||
| leagueTeamRepository.findByLeagueAndTeam(league, team) | ||
| .orElseThrow(() -> new BadRequestException(NlErrorMessages.TEAM_NOT_IN_LEAGUE)); | ||
| } | ||
|
|
||
| private Set<String> extractNineDigitNumbers(String text) { | ||
| Set<String> numbers = new HashSet<>(); | ||
| Matcher matcher = NINE_DIGIT_PATTERN.matcher(text); | ||
| while (matcher.find()) { | ||
| numbers.add(matcher.group()); | ||
| } | ||
| return numbers; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| package com.sports.server.command.nl.domain; | ||
|
|
||
| public enum PlayerStatus { | ||
| NEW, | ||
| EXISTS, | ||
| ALREADY_IN_TEAM, | ||
| DUPLICATE_IN_INPUT | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| package com.sports.server.command.nl.dto; | ||
|
|
||
| import java.util.List; | ||
|
|
||
| public record NlExecuteRequest( | ||
| Long leagueId, | ||
| Long teamId, | ||
| List<PlayerData> players | ||
| ) { | ||
| public record PlayerData( | ||
| String name, | ||
| String studentNumber, | ||
| Integer jerseyNumber | ||
| ) { | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| package com.sports.server.command.nl.dto; | ||
|
|
||
| public record NlExecuteResponse( | ||
| String displayMessage, | ||
| Result result | ||
| ) { | ||
| public record Result( | ||
| int created, | ||
| int assigned, | ||
| int skipped | ||
| ) { | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| package com.sports.server.command.nl.dto; | ||
|
|
||
| import java.util.List; | ||
| import java.util.Map; | ||
|
|
||
| public record NlProcessRequest( | ||
| Long leagueId, | ||
| Long teamId, | ||
| List<Map<String, String>> history, | ||
| String message | ||
| ) { | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,44 @@ | ||
| package com.sports.server.command.nl.dto; | ||
|
|
||
| import com.sports.server.command.nl.domain.PlayerStatus; | ||
|
|
||
| import java.util.List; | ||
|
|
||
| public record NlProcessResponse( | ||
| String displayMessage, | ||
| Preview preview | ||
| ) { | ||
| public record Preview( | ||
| String type, | ||
| Long teamId, | ||
| String teamName, | ||
| List<PlayerPreview> players, | ||
| Summary summary, | ||
| List<FailedLine> parseFailedLines | ||
| ) { | ||
| } | ||
|
|
||
| public record PlayerPreview( | ||
| String name, | ||
| String studentNumber, | ||
| Integer jerseyNumber, | ||
| PlayerStatus status, | ||
| Long existingPlayerId | ||
| ) { | ||
| } | ||
|
|
||
| public record Summary( | ||
| int total, | ||
| int newPlayers, | ||
| int existingPlayers, | ||
| int alreadyInTeam | ||
| ) { | ||
| } | ||
|
|
||
| public record FailedLine( | ||
| int index, | ||
| String text, | ||
| String reason | ||
| ) { | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| package com.sports.server.command.nl.exception; | ||
|
|
||
| public class NlErrorMessages { | ||
| public static final String TEAM_NOT_IN_LEAGUE = "해당 팀은 이 리그에 소속되어 있지 않습니다."; | ||
| public static final String PARSE_FAILED = "선수 정보를 파싱할 수 없습니다. 이름과 학번(9자리)을 포함하여 다시 입력해주세요."; | ||
| public static final String NO_PLAYER_INFO = "선수 정보를 찾을 수 없습니다. 다시 입력해주세요."; | ||
| public static final String STUDENT_NUMBER_INVALID = "학번이 9자리가 아닙니다"; | ||
| public static final String STUDENT_NUMBER_NOT_IN_ORIGINAL = "원본 텍스트에서 해당 학번을 찾을 수 없습니다"; | ||
| } |
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
이런 식으로 플로우를 정리해서 문서화의 측면에서.. PR 본문에 남겨주심 좋을 것 같아요