Skip to content

Commit 4031134

Browse files
authored
Merge pull request #431 from hufscheer/feature/nl-bulk-player-register
feat: NL 기반 선수 벌크 등록 API 구현
2 parents ca54451 + 7334989 commit 4031134

21 files changed

Lines changed: 1309 additions & 4 deletions

src/main/java/com/sports/server/auth/config/SecurityConfig.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,8 @@ SecurityFilterChain filterChain(HttpSecurity http, MvcRequestMatcher.Builder mvc
6161
mvc.pattern(HttpMethod.POST, "/players"),
6262
mvc.pattern(HttpMethod.PATCH, "/players/{playerId}"),
6363
mvc.pattern(HttpMethod.DELETE, "/players/{playerId}"),
64-
mvc.pattern(HttpMethod.PATCH, "/cheer-talks/**")
64+
mvc.pattern(HttpMethod.PATCH, "/cheer-talks/**"),
65+
mvc.pattern(HttpMethod.POST, "/nl/**")
6566
)
6667
.authenticated()
6768
.anyRequest().permitAll()
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package com.sports.server.command.nl.application;
2+
3+
import com.sports.server.command.nl.dto.NlParseResult;
4+
5+
import java.util.List;
6+
import java.util.Map;
7+
8+
public interface NlClient {
9+
NlParseResult parsePlayers(String message, List<Map<String, String>> history);
10+
}
Lines changed: 265 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,265 @@
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+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
package com.sports.server.command.nl.domain;
2+
3+
public enum PlayerStatus {
4+
NEW,
5+
EXISTS,
6+
ALREADY_IN_TEAM,
7+
DUPLICATE_IN_INPUT
8+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package com.sports.server.command.nl.dto;
2+
3+
import jakarta.validation.Valid;
4+
import jakarta.validation.constraints.NotEmpty;
5+
import jakarta.validation.constraints.NotNull;
6+
7+
import java.util.List;
8+
9+
public record NlExecuteRequest(
10+
@NotNull Long leagueId,
11+
@NotNull Long teamId,
12+
@NotEmpty @Valid List<PlayerData> players
13+
) {
14+
public record PlayerData(
15+
@NotNull String name,
16+
@NotNull String studentNumber,
17+
Integer jerseyNumber
18+
) {
19+
}
20+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package com.sports.server.command.nl.dto;
2+
3+
public record NlExecuteResponse(
4+
String displayMessage,
5+
Result result
6+
) {
7+
public record Result(
8+
int created,
9+
int assigned,
10+
int skipped
11+
) {
12+
}
13+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
package com.sports.server.command.nl.dto;
2+
3+
import java.util.List;
4+
5+
public record NlParseResult(
6+
boolean parsed,
7+
String textMessage,
8+
List<ParsedPlayer> players
9+
) {
10+
public record ParsedPlayer(
11+
String name,
12+
String studentNumber,
13+
Integer jerseyNumber
14+
) {
15+
}
16+
17+
public static NlParseResult ofText(String message) {
18+
return new NlParseResult(false, message, List.of());
19+
}
20+
21+
public static NlParseResult ofPlayers(List<ParsedPlayer> players) {
22+
return new NlParseResult(true, null, players);
23+
}
24+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
package com.sports.server.command.nl.dto;
2+
3+
import jakarta.validation.constraints.NotBlank;
4+
import jakarta.validation.constraints.NotNull;
5+
import jakarta.validation.constraints.Size;
6+
7+
import java.util.List;
8+
import java.util.Map;
9+
10+
public record NlProcessRequest(
11+
@NotNull Long leagueId,
12+
@NotNull Long teamId,
13+
List<Map<String, String>> history,
14+
@NotBlank @Size(max = 5000) String message
15+
) {
16+
}

0 commit comments

Comments
 (0)