|
| 1 | +package com.sports.server.command.nl.application; |
| 2 | + |
| 3 | +import com.sports.server.command.league.domain.League; |
| 4 | +import com.sports.server.command.league.domain.LeagueTeamRepository; |
| 5 | +import com.sports.server.command.member.domain.Member; |
| 6 | +import com.sports.server.command.nl.domain.PlayerStatus; |
| 7 | +import com.sports.server.command.nl.dto.*; |
| 8 | +import com.sports.server.command.nl.dto.NlParseResult.ParsedPlayer; |
| 9 | +import com.sports.server.command.nl.dto.NlProcessResponse.*; |
| 10 | +import com.sports.server.command.nl.exception.NlErrorMessages; |
| 11 | +import com.sports.server.command.player.application.PlayerService; |
| 12 | +import com.sports.server.command.player.domain.Player; |
| 13 | +import com.sports.server.command.player.domain.PlayerRepository; |
| 14 | +import com.sports.server.command.player.dto.PlayerRequest; |
| 15 | +import com.sports.server.command.team.application.TeamService; |
| 16 | +import com.sports.server.command.team.domain.Team; |
| 17 | +import com.sports.server.command.team.domain.TeamPlayerRepository; |
| 18 | +import com.sports.server.command.team.dto.TeamRequest; |
| 19 | +import com.sports.server.common.application.EntityUtils; |
| 20 | +import com.sports.server.common.application.PermissionValidator; |
| 21 | +import com.sports.server.common.exception.BadRequestException; |
| 22 | +import com.sports.server.common.util.StudentNumber; |
| 23 | +import lombok.RequiredArgsConstructor; |
| 24 | +import org.springframework.stereotype.Service; |
| 25 | +import org.springframework.transaction.annotation.Transactional; |
| 26 | + |
| 27 | +import java.util.*; |
| 28 | +import java.util.regex.Matcher; |
| 29 | +import java.util.regex.Pattern; |
| 30 | +import java.util.stream.Collectors; |
| 31 | + |
| 32 | +@Service |
| 33 | +@RequiredArgsConstructor |
| 34 | +public class NlService { |
| 35 | + |
| 36 | + private final NlClient nlClient; |
| 37 | + private final PlayerRepository playerRepository; |
| 38 | + private final TeamPlayerRepository teamPlayerRepository; |
| 39 | + private final LeagueTeamRepository leagueTeamRepository; |
| 40 | + private final EntityUtils entityUtils; |
| 41 | + private final PlayerService playerService; |
| 42 | + private final TeamService teamService; |
| 43 | + |
| 44 | + private static final Pattern NINE_DIGIT_PATTERN = Pattern.compile("(?<!\\d)\\d{9}(?!\\d)"); |
| 45 | + private static final Pattern VALID_NAME_PATTERN = Pattern.compile("^[가-힣a-zA-Z\\s]{1,50}$"); |
| 46 | + |
| 47 | + @Transactional(readOnly = true) |
| 48 | + public NlProcessResponse process(NlProcessRequest request, Member member) { |
| 49 | + League league = entityUtils.getEntity(request.leagueId(), League.class); |
| 50 | + PermissionValidator.checkPermission(league, member); |
| 51 | + Team team = entityUtils.getEntity(request.teamId(), Team.class); |
| 52 | + validateTeamBelongsToLeague(league, team); |
| 53 | + |
| 54 | + NlParseResult parseResult = nlClient.parsePlayers(request.message(), request.history()); |
| 55 | + |
| 56 | + if (!parseResult.parsed()) { |
| 57 | + if (parseResult.textMessage() != null) { |
| 58 | + return new NlProcessResponse(parseResult.textMessage(), null); |
| 59 | + } |
| 60 | + return new NlProcessResponse(NlErrorMessages.PARSE_FAILED, null); |
| 61 | + } |
| 62 | + |
| 63 | + if (parseResult.players().isEmpty()) { |
| 64 | + return new NlProcessResponse(NlErrorMessages.NO_PLAYER_INFO, null); |
| 65 | + } |
| 66 | + |
| 67 | + return buildPreview(request, team, parseResult.players()); |
| 68 | + } |
| 69 | + |
| 70 | + @Transactional |
| 71 | + public NlExecuteResponse execute(NlExecuteRequest request, Member member) { |
| 72 | + League league = entityUtils.getEntity(request.leagueId(), League.class); |
| 73 | + PermissionValidator.checkPermission(league, member); |
| 74 | + Team team = entityUtils.getEntity(request.teamId(), Team.class); |
| 75 | + validateTeamBelongsToLeague(league, team); |
| 76 | + |
| 77 | + ExecuteContext context = buildExecuteContext(request); |
| 78 | + processPlayersForExecution(request.players(), context); |
| 79 | + |
| 80 | + if (!context.teamPlayerRegisters.isEmpty()) { |
| 81 | + teamService.addPlayersToTeam(request.teamId(), context.teamPlayerRegisters); |
| 82 | + } |
| 83 | + |
| 84 | + String displayMessage = String.format("%s에 %d명의 선수가 등록되었습니다.", team.getName(), context.assigned); |
| 85 | + return new NlExecuteResponse(displayMessage, new NlExecuteResponse.Result(context.created, context.assigned, context.skipped)); |
| 86 | + } |
| 87 | + |
| 88 | + private ExecuteContext buildExecuteContext(NlExecuteRequest request) { |
| 89 | + Set<Long> teamPlayerIdSet = new HashSet<>( |
| 90 | + teamPlayerRepository.findPlayerIdsByTeamId(request.teamId()) |
| 91 | + ); |
| 92 | + List<String> studentNumbers = request.players().stream() |
| 93 | + .map(NlExecuteRequest.PlayerData::studentNumber) |
| 94 | + .toList(); |
| 95 | + Map<String, Player> existingPlayerMap = findExistingPlayerMap(studentNumbers); |
| 96 | + return new ExecuteContext(teamPlayerIdSet, existingPlayerMap); |
| 97 | + } |
| 98 | + |
| 99 | + private void processPlayersForExecution(List<NlExecuteRequest.PlayerData> players, ExecuteContext context) { |
| 100 | + for (NlExecuteRequest.PlayerData playerData : players) { |
| 101 | + if (!isValidName(playerData.name()) || !context.seenStudentNumbers.add(playerData.studentNumber())) { |
| 102 | + context.skipped++; |
| 103 | + continue; |
| 104 | + } |
| 105 | + |
| 106 | + Long playerId = getOrCreatePlayerId(playerData, context); |
| 107 | + if (playerId == null) { |
| 108 | + context.skipped++; |
| 109 | + continue; |
| 110 | + } |
| 111 | + |
| 112 | + if (!context.assignedPlayerIds.add(playerId)) { |
| 113 | + context.skipped++; |
| 114 | + continue; |
| 115 | + } |
| 116 | + |
| 117 | + context.teamPlayerRegisters.add(new TeamRequest.TeamPlayerRegister(playerId, playerData.jerseyNumber())); |
| 118 | + context.assigned++; |
| 119 | + } |
| 120 | + } |
| 121 | + |
| 122 | + private Long getOrCreatePlayerId(NlExecuteRequest.PlayerData playerData, ExecuteContext context) { |
| 123 | + Player existingPlayer = context.existingPlayerMap.get(playerData.studentNumber()); |
| 124 | + |
| 125 | + if (existingPlayer != null) { |
| 126 | + if (context.teamPlayerIdSet.contains(existingPlayer.getId())) { |
| 127 | + return null; |
| 128 | + } |
| 129 | + return existingPlayer.getId(); |
| 130 | + } |
| 131 | + |
| 132 | + Long playerId = playerService.register( |
| 133 | + new PlayerRequest.Register(playerData.name(), playerData.studentNumber()) |
| 134 | + ); |
| 135 | + context.created++; |
| 136 | + return playerId; |
| 137 | + } |
| 138 | + |
| 139 | + private static class ExecuteContext { |
| 140 | + final Set<Long> teamPlayerIdSet; |
| 141 | + final Map<String, Player> existingPlayerMap; |
| 142 | + final Set<String> seenStudentNumbers = new HashSet<>(); |
| 143 | + final Set<Long> assignedPlayerIds = new HashSet<>(); |
| 144 | + final List<TeamRequest.TeamPlayerRegister> teamPlayerRegisters = new ArrayList<>(); |
| 145 | + int created = 0; |
| 146 | + int assigned = 0; |
| 147 | + int skipped = 0; |
| 148 | + |
| 149 | + ExecuteContext(Set<Long> teamPlayerIdSet, Map<String, Player> existingPlayerMap) { |
| 150 | + this.teamPlayerIdSet = teamPlayerIdSet; |
| 151 | + this.existingPlayerMap = existingPlayerMap; |
| 152 | + } |
| 153 | + } |
| 154 | + |
| 155 | + private NlProcessResponse buildPreview(NlProcessRequest request, Team team, List<ParsedPlayer> parsedPlayers) { |
| 156 | + Set<String> originalNineDigits = extractNineDigitNumbers(request.message()); |
| 157 | + Set<Long> teamPlayerIdSet = new HashSet<>(teamPlayerRepository.findPlayerIdsByTeamId(request.teamId())); |
| 158 | + Map<String, Player> existingPlayerMap = findExistingPlayerMap( |
| 159 | + parsedPlayers.stream().map(ParsedPlayer::studentNumber).filter(Objects::nonNull).toList() |
| 160 | + ); |
| 161 | + |
| 162 | + List<PlayerPreview> playerPreviews = new ArrayList<>(); |
| 163 | + List<FailedLine> failedLines = new ArrayList<>(); |
| 164 | + classifyParsedPlayers(parsedPlayers, originalNineDigits, teamPlayerIdSet, existingPlayerMap, playerPreviews, failedLines); |
| 165 | + |
| 166 | + Summary summary = buildSummary(playerPreviews); |
| 167 | + int registrableCount = summary.newPlayers() + summary.existingPlayers(); |
| 168 | + String displayMessage = String.format("%s에 %d명의 선수를 등록합니다. 확인해주세요.", team.getName(), registrableCount); |
| 169 | + |
| 170 | + Preview preview = new Preview( |
| 171 | + "REGISTER_PLAYERS_BULK", request.teamId(), team.getName(), |
| 172 | + playerPreviews, summary, failedLines |
| 173 | + ); |
| 174 | + return new NlProcessResponse(displayMessage, preview); |
| 175 | + } |
| 176 | + |
| 177 | + private void classifyParsedPlayers(List<ParsedPlayer> parsedPlayers, Set<String> originalNineDigits, |
| 178 | + Set<Long> teamPlayerIdSet, Map<String, Player> existingPlayerMap, |
| 179 | + List<PlayerPreview> playerPreviews, List<FailedLine> failedLines) { |
| 180 | + Set<String> seenStudentNumbers = new HashSet<>(); |
| 181 | + |
| 182 | + for (int i = 0; i < parsedPlayers.size(); i++) { |
| 183 | + ParsedPlayer parsed = parsedPlayers.get(i); |
| 184 | + |
| 185 | + if (isInvalidStudentNumber(parsed, originalNineDigits)) { |
| 186 | + failedLines.add(buildFailedLine(i, parsed)); |
| 187 | + continue; |
| 188 | + } |
| 189 | + |
| 190 | + if (!isValidName(parsed.name())) { |
| 191 | + failedLines.add(new FailedLine(i + 1, parsed.studentNumber(), NlErrorMessages.INVALID_PLAYER_NAME)); |
| 192 | + continue; |
| 193 | + } |
| 194 | + |
| 195 | + if (seenStudentNumbers.contains(parsed.studentNumber())) { |
| 196 | + playerPreviews.add(toPlayerPreview(parsed, PlayerStatus.DUPLICATE_IN_INPUT, null)); |
| 197 | + continue; |
| 198 | + } |
| 199 | + seenStudentNumbers.add(parsed.studentNumber()); |
| 200 | + |
| 201 | + Player existingPlayer = existingPlayerMap.get(parsed.studentNumber()); |
| 202 | + playerPreviews.add(classifyPlayer(parsed, existingPlayer, teamPlayerIdSet)); |
| 203 | + } |
| 204 | + } |
| 205 | + |
| 206 | + private boolean isInvalidStudentNumber(ParsedPlayer parsed, Set<String> originalNineDigits) { |
| 207 | + return StudentNumber.isInvalid(parsed.studentNumber()) |
| 208 | + || !originalNineDigits.contains(parsed.studentNumber()); |
| 209 | + } |
| 210 | + |
| 211 | + private FailedLine buildFailedLine(int index, ParsedPlayer parsed) { |
| 212 | + String reason = StudentNumber.isInvalid(parsed.studentNumber()) |
| 213 | + ? NlErrorMessages.STUDENT_NUMBER_INVALID |
| 214 | + : NlErrorMessages.STUDENT_NUMBER_NOT_IN_ORIGINAL; |
| 215 | + return new FailedLine(index + 1, parsed.studentNumber(), reason); |
| 216 | + } |
| 217 | + |
| 218 | + private PlayerPreview classifyPlayer(ParsedPlayer parsed, Player existingPlayer, Set<Long> teamPlayerIdSet) { |
| 219 | + if (existingPlayer == null) { |
| 220 | + return toPlayerPreview(parsed, PlayerStatus.NEW, null); |
| 221 | + } |
| 222 | + if (teamPlayerIdSet.contains(existingPlayer.getId())) { |
| 223 | + return toPlayerPreview(parsed, PlayerStatus.ALREADY_IN_TEAM, existingPlayer.getId()); |
| 224 | + } |
| 225 | + return toPlayerPreview(parsed, PlayerStatus.EXISTS, existingPlayer.getId()); |
| 226 | + } |
| 227 | + |
| 228 | + private PlayerPreview toPlayerPreview(ParsedPlayer parsed, PlayerStatus status, Long existingPlayerId) { |
| 229 | + return new PlayerPreview( |
| 230 | + parsed.name(), parsed.studentNumber(), parsed.jerseyNumber(), |
| 231 | + status, existingPlayerId |
| 232 | + ); |
| 233 | + } |
| 234 | + |
| 235 | + private Summary buildSummary(List<PlayerPreview> playerPreviews) { |
| 236 | + int newCount = (int) playerPreviews.stream().filter(p -> p.status() == PlayerStatus.NEW).count(); |
| 237 | + int existsCount = (int) playerPreviews.stream().filter(p -> p.status() == PlayerStatus.EXISTS).count(); |
| 238 | + int alreadyInTeamCount = (int) playerPreviews.stream().filter(p -> p.status() == PlayerStatus.ALREADY_IN_TEAM).count(); |
| 239 | + return new Summary(playerPreviews.size(), newCount, existsCount, alreadyInTeamCount); |
| 240 | + } |
| 241 | + |
| 242 | + private Map<String, Player> findExistingPlayerMap(List<String> studentNumbers) { |
| 243 | + return playerRepository.findByStudentNumberIn(studentNumbers) |
| 244 | + .stream() |
| 245 | + .collect(Collectors.toMap(Player::getStudentNumber, p -> p)); |
| 246 | + } |
| 247 | + |
| 248 | + private boolean isValidName(String name) { |
| 249 | + return name != null && VALID_NAME_PATTERN.matcher(name).matches(); |
| 250 | + } |
| 251 | + |
| 252 | + private void validateTeamBelongsToLeague(League league, Team team) { |
| 253 | + leagueTeamRepository.findByLeagueAndTeam(league, team) |
| 254 | + .orElseThrow(() -> new BadRequestException(NlErrorMessages.TEAM_NOT_IN_LEAGUE)); |
| 255 | + } |
| 256 | + |
| 257 | + private Set<String> extractNineDigitNumbers(String text) { |
| 258 | + Set<String> numbers = new HashSet<>(); |
| 259 | + Matcher matcher = NINE_DIGIT_PATTERN.matcher(text); |
| 260 | + while (matcher.find()) { |
| 261 | + numbers.add(matcher.group()); |
| 262 | + } |
| 263 | + return numbers; |
| 264 | + } |
| 265 | +} |
0 commit comments