-
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 5 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,10 @@ | ||
| package com.sports.server.command.nl.application; | ||
|
|
||
| import com.sports.server.command.nl.dto.NlParseResult; | ||
|
|
||
| import java.util.List; | ||
| import java.util.Map; | ||
|
|
||
| public interface NlClient { | ||
| NlParseResult parsePlayers(String message, List<Map<String, String>> history); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,254 @@ | ||
| 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.domain.PlayerStatus; | ||
| import com.sports.server.command.nl.dto.*; | ||
| import com.sports.server.command.nl.dto.NlParseResult.ParsedPlayer; | ||
| import com.sports.server.command.nl.dto.NlProcessResponse.*; | ||
| import com.sports.server.command.nl.exception.NlErrorMessages; | ||
| import com.sports.server.command.player.application.PlayerService; | ||
| 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.application.TeamService; | ||
| 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.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 NlClient nlClient; | ||
| 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)"); | ||
| private static final Pattern VALID_NAME_PATTERN = Pattern.compile("^[가-힣a-zA-Z\\s]{1,50}$"); | ||
|
|
||
| @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); | ||
|
|
||
| NlParseResult parseResult = nlClient.parsePlayers(request.message(), request.history()); | ||
|
|
||
| if (!parseResult.parsed()) { | ||
| if (parseResult.textMessage() != null) { | ||
| return new NlProcessResponse(parseResult.textMessage(), null); | ||
| } | ||
| return new NlProcessResponse(NlErrorMessages.PARSE_FAILED, null); | ||
| } | ||
|
|
||
| if (parseResult.players().isEmpty()) { | ||
| return new NlProcessResponse(NlErrorMessages.NO_PLAYER_INFO, null); | ||
| } | ||
|
|
||
| return buildPreview(request, team, parseResult.players()); | ||
| } | ||
|
|
||
| @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()) | ||
| ); | ||
|
|
||
| List<String> studentNumbers = request.players().stream() | ||
| .map(NlExecuteRequest.PlayerData::studentNumber) | ||
| .toList(); | ||
| Map<String, Player> existingPlayerMap = findExistingPlayerMap(studentNumbers); | ||
|
|
||
| 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 (!isValidName(playerData.name())) { | ||
| skipped++; | ||
| continue; | ||
| } | ||
|
|
||
| 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++; | ||
| } | ||
|
|
||
| if (!assignedPlayerIds.add(playerId)) { | ||
| skipped++; | ||
| continue; | ||
| } | ||
|
|
||
| teamPlayerRegisters.add(new TeamRequest.TeamPlayerRegister( | ||
| playerId, | ||
| playerData.jerseyNumber() | ||
| )); | ||
| assigned++; | ||
| } | ||
|
|
||
| if (!teamPlayerRegisters.isEmpty()) { | ||
| teamService.addPlayersToTeam(request.teamId(), teamPlayerRegisters); | ||
| } | ||
|
|
||
| String displayMessage = String.format("%s에 %d명의 선수가 등록되었습니다.", team.getName(), assigned); | ||
| return new NlExecuteResponse(displayMessage, new NlExecuteResponse.Result(created, assigned, skipped)); | ||
| } | ||
|
|
||
| private NlProcessResponse buildPreview(NlProcessRequest request, Team team, List<ParsedPlayer> parsedPlayers) { | ||
|
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. 여기도 내부에 메서드 더 쪼갤 수 있어 보여요 |
||
| Set<String> originalNineDigits = extractNineDigitNumbers(request.message()); | ||
| Set<Long> teamPlayerIdSet = new HashSet<>(teamPlayerRepository.findPlayerIdsByTeamId(request.teamId())); | ||
| List<String> studentNumbers = parsedPlayers.stream() | ||
| .map(ParsedPlayer::studentNumber) | ||
| .filter(Objects::nonNull) | ||
| .toList(); | ||
| Map<String, Player> existingPlayerMap = findExistingPlayerMap(studentNumbers); | ||
|
|
||
| List<PlayerPreview> playerPreviews = new ArrayList<>(); | ||
| List<FailedLine> failedLines = new ArrayList<>(); | ||
| Set<String> seenStudentNumbers = new HashSet<>(); | ||
|
|
||
| for (int i = 0; i < parsedPlayers.size(); i++) { | ||
| ParsedPlayer parsed = parsedPlayers.get(i); | ||
|
|
||
| if (isInvalidStudentNumber(parsed, originalNineDigits)) { | ||
| failedLines.add(buildFailedLine(i, parsed)); | ||
| continue; | ||
| } | ||
|
|
||
| if (!isValidName(parsed.name())) { | ||
| failedLines.add(new FailedLine(i + 1, parsed.studentNumber(), NlErrorMessages.INVALID_PLAYER_NAME)); | ||
| continue; | ||
| } | ||
|
|
||
| if (seenStudentNumbers.contains(parsed.studentNumber())) { | ||
| playerPreviews.add(toPlayerPreview(parsed, PlayerStatus.DUPLICATE_IN_INPUT, null)); | ||
| continue; | ||
| } | ||
| seenStudentNumbers.add(parsed.studentNumber()); | ||
|
|
||
| Player existingPlayer = existingPlayerMap.get(parsed.studentNumber()); | ||
| PlayerPreview preview = classifyPlayer(parsed, existingPlayer, teamPlayerIdSet); | ||
| playerPreviews.add(preview); | ||
| } | ||
|
|
||
| Summary summary = buildSummary(playerPreviews); | ||
| int registrableCount = summary.newPlayers() + summary.existingPlayers(); | ||
| String displayMessage = String.format("%s에 %d명의 선수를 등록합니다. 확인해주세요.", team.getName(), registrableCount); | ||
|
|
||
| Preview preview = new Preview( | ||
| "REGISTER_PLAYERS_BULK", | ||
| request.teamId(), | ||
| team.getName(), | ||
| playerPreviews, | ||
| summary, | ||
| failedLines | ||
| ); | ||
|
|
||
| return new NlProcessResponse(displayMessage, preview); | ||
| } | ||
|
|
||
| private boolean isInvalidStudentNumber(ParsedPlayer parsed, Set<String> originalNineDigits) { | ||
| return StudentNumber.isInvalid(parsed.studentNumber()) | ||
| || !originalNineDigits.contains(parsed.studentNumber()); | ||
| } | ||
|
|
||
| private FailedLine buildFailedLine(int index, ParsedPlayer parsed) { | ||
| String reason = StudentNumber.isInvalid(parsed.studentNumber()) | ||
| ? NlErrorMessages.STUDENT_NUMBER_INVALID | ||
| : NlErrorMessages.STUDENT_NUMBER_NOT_IN_ORIGINAL; | ||
| return new FailedLine(index + 1, parsed.studentNumber(), reason); | ||
| } | ||
|
|
||
| private PlayerPreview classifyPlayer(ParsedPlayer parsed, Player existingPlayer, Set<Long> teamPlayerIdSet) { | ||
| if (existingPlayer == null) { | ||
| return toPlayerPreview(parsed, PlayerStatus.NEW, null); | ||
| } | ||
| if (teamPlayerIdSet.contains(existingPlayer.getId())) { | ||
| return toPlayerPreview(parsed, PlayerStatus.ALREADY_IN_TEAM, existingPlayer.getId()); | ||
| } | ||
| return toPlayerPreview(parsed, PlayerStatus.EXISTS, existingPlayer.getId()); | ||
| } | ||
|
|
||
| private PlayerPreview toPlayerPreview(ParsedPlayer parsed, PlayerStatus status, Long existingPlayerId) { | ||
| return new PlayerPreview( | ||
| parsed.name(), parsed.studentNumber(), parsed.jerseyNumber(), | ||
| status, existingPlayerId | ||
| ); | ||
| } | ||
|
|
||
| private Summary buildSummary(List<PlayerPreview> playerPreviews) { | ||
| 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(); | ||
| return new Summary(playerPreviews.size(), newCount, existsCount, alreadyInTeamCount); | ||
| } | ||
|
|
||
| private Map<String, Player> findExistingPlayerMap(List<String> studentNumbers) { | ||
| return playerRepository.findByStudentNumberIn(studentNumbers) | ||
| .stream() | ||
| .collect(Collectors.toMap(Player::getStudentNumber, p -> p)); | ||
| } | ||
|
|
||
| private boolean isValidName(String name) { | ||
| return name != null && VALID_NAME_PATTERN.matcher(name).matches(); | ||
| } | ||
|
|
||
| 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,20 @@ | ||
| package com.sports.server.command.nl.dto; | ||
|
|
||
| import jakarta.validation.Valid; | ||
| import jakarta.validation.constraints.NotEmpty; | ||
| import jakarta.validation.constraints.NotNull; | ||
|
|
||
| import java.util.List; | ||
|
|
||
| public record NlExecuteRequest( | ||
| @NotNull Long leagueId, | ||
| @NotNull Long teamId, | ||
| @NotEmpty @Valid List<PlayerData> players | ||
| ) { | ||
| public record PlayerData( | ||
| @NotNull String name, | ||
| @NotNull 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,24 @@ | ||
| package com.sports.server.command.nl.dto; | ||
|
|
||
| import java.util.List; | ||
|
|
||
| public record NlParseResult( | ||
| boolean parsed, | ||
| String textMessage, | ||
| List<ParsedPlayer> players | ||
| ) { | ||
| public record ParsedPlayer( | ||
| String name, | ||
| String studentNumber, | ||
| Integer jerseyNumber | ||
| ) { | ||
| } | ||
|
|
||
| public static NlParseResult ofText(String message) { | ||
| return new NlParseResult(false, message, List.of()); | ||
| } | ||
|
|
||
| public static NlParseResult ofPlayers(List<ParsedPlayer> players) { | ||
| return new NlParseResult(true, null, players); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| package com.sports.server.command.nl.dto; | ||
|
|
||
| import jakarta.validation.constraints.NotBlank; | ||
| import jakarta.validation.constraints.NotNull; | ||
| import jakarta.validation.constraints.Size; | ||
|
|
||
| import java.util.List; | ||
| import java.util.Map; | ||
|
|
||
| public record NlProcessRequest( | ||
| @NotNull Long leagueId, | ||
| @NotNull Long teamId, | ||
| List<Map<String, String>> history, | ||
| @NotBlank @Size(max = 5000) String message | ||
| ) { | ||
| } |
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 본문에 남겨주심 좋을 것 같아요