diff --git a/src/main/java/com/sports/server/auth/config/SecurityConfig.java b/src/main/java/com/sports/server/auth/config/SecurityConfig.java index dda6d0768..e5f2860b0 100644 --- a/src/main/java/com/sports/server/auth/config/SecurityConfig.java +++ b/src/main/java/com/sports/server/auth/config/SecurityConfig.java @@ -61,7 +61,8 @@ SecurityFilterChain filterChain(HttpSecurity http, MvcRequestMatcher.Builder mvc mvc.pattern(HttpMethod.POST, "/players"), mvc.pattern(HttpMethod.PATCH, "/players/{playerId}"), mvc.pattern(HttpMethod.DELETE, "/players/{playerId}"), - mvc.pattern(HttpMethod.PATCH, "/cheer-talks/**") + mvc.pattern(HttpMethod.PATCH, "/cheer-talks/**"), + mvc.pattern(HttpMethod.POST, "/nl/**") ) .authenticated() .anyRequest().permitAll() diff --git a/src/main/java/com/sports/server/command/nl/application/NlClient.java b/src/main/java/com/sports/server/command/nl/application/NlClient.java new file mode 100644 index 000000000..ec3e3117e --- /dev/null +++ b/src/main/java/com/sports/server/command/nl/application/NlClient.java @@ -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> history); +} diff --git a/src/main/java/com/sports/server/command/nl/application/NlService.java b/src/main/java/com/sports/server/command/nl/application/NlService.java new file mode 100644 index 000000000..c2869157a --- /dev/null +++ b/src/main/java/com/sports/server/command/nl/application/NlService.java @@ -0,0 +1,265 @@ +package com.sports.server.command.nl.application; + +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("(? teamPlayerIdSet = new HashSet<>( + teamPlayerRepository.findPlayerIdsByTeamId(request.teamId()) + ); + List studentNumbers = request.players().stream() + .map(NlExecuteRequest.PlayerData::studentNumber) + .toList(); + Map existingPlayerMap = findExistingPlayerMap(studentNumbers); + return new ExecuteContext(teamPlayerIdSet, existingPlayerMap); + } + + private void processPlayersForExecution(List players, ExecuteContext context) { + for (NlExecuteRequest.PlayerData playerData : players) { + if (!isValidName(playerData.name()) || !context.seenStudentNumbers.add(playerData.studentNumber())) { + context.skipped++; + continue; + } + + Long playerId = getOrCreatePlayerId(playerData, context); + if (playerId == null) { + context.skipped++; + continue; + } + + if (!context.assignedPlayerIds.add(playerId)) { + context.skipped++; + continue; + } + + context.teamPlayerRegisters.add(new TeamRequest.TeamPlayerRegister(playerId, playerData.jerseyNumber())); + context.assigned++; + } + } + + private Long getOrCreatePlayerId(NlExecuteRequest.PlayerData playerData, ExecuteContext context) { + Player existingPlayer = context.existingPlayerMap.get(playerData.studentNumber()); + + if (existingPlayer != null) { + if (context.teamPlayerIdSet.contains(existingPlayer.getId())) { + return null; + } + return existingPlayer.getId(); + } + + Long playerId = playerService.register( + new PlayerRequest.Register(playerData.name(), playerData.studentNumber()) + ); + context.created++; + return playerId; + } + + private static class ExecuteContext { + final Set teamPlayerIdSet; + final Map existingPlayerMap; + final Set seenStudentNumbers = new HashSet<>(); + final Set assignedPlayerIds = new HashSet<>(); + final List teamPlayerRegisters = new ArrayList<>(); + int created = 0; + int assigned = 0; + int skipped = 0; + + ExecuteContext(Set teamPlayerIdSet, Map existingPlayerMap) { + this.teamPlayerIdSet = teamPlayerIdSet; + this.existingPlayerMap = existingPlayerMap; + } + } + + private NlProcessResponse buildPreview(NlProcessRequest request, Team team, List parsedPlayers) { + Set originalNineDigits = extractNineDigitNumbers(request.message()); + Set teamPlayerIdSet = new HashSet<>(teamPlayerRepository.findPlayerIdsByTeamId(request.teamId())); + Map existingPlayerMap = findExistingPlayerMap( + parsedPlayers.stream().map(ParsedPlayer::studentNumber).filter(Objects::nonNull).toList() + ); + + List playerPreviews = new ArrayList<>(); + List failedLines = new ArrayList<>(); + classifyParsedPlayers(parsedPlayers, originalNineDigits, teamPlayerIdSet, existingPlayerMap, playerPreviews, failedLines); + + 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 void classifyParsedPlayers(List parsedPlayers, Set originalNineDigits, + Set teamPlayerIdSet, Map existingPlayerMap, + List playerPreviews, List failedLines) { + Set 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()); + playerPreviews.add(classifyPlayer(parsed, existingPlayer, teamPlayerIdSet)); + } + } + + private boolean isInvalidStudentNumber(ParsedPlayer parsed, Set 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 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 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 findExistingPlayerMap(List 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 extractNineDigitNumbers(String text) { + Set numbers = new HashSet<>(); + Matcher matcher = NINE_DIGIT_PATTERN.matcher(text); + while (matcher.find()) { + numbers.add(matcher.group()); + } + return numbers; + } +} diff --git a/src/main/java/com/sports/server/command/nl/domain/PlayerStatus.java b/src/main/java/com/sports/server/command/nl/domain/PlayerStatus.java new file mode 100644 index 000000000..c35375432 --- /dev/null +++ b/src/main/java/com/sports/server/command/nl/domain/PlayerStatus.java @@ -0,0 +1,8 @@ +package com.sports.server.command.nl.domain; + +public enum PlayerStatus { + NEW, + EXISTS, + ALREADY_IN_TEAM, + DUPLICATE_IN_INPUT +} diff --git a/src/main/java/com/sports/server/command/nl/dto/NlExecuteRequest.java b/src/main/java/com/sports/server/command/nl/dto/NlExecuteRequest.java new file mode 100644 index 000000000..e400e1b2c --- /dev/null +++ b/src/main/java/com/sports/server/command/nl/dto/NlExecuteRequest.java @@ -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 players +) { + public record PlayerData( + @NotNull String name, + @NotNull String studentNumber, + Integer jerseyNumber + ) { + } +} diff --git a/src/main/java/com/sports/server/command/nl/dto/NlExecuteResponse.java b/src/main/java/com/sports/server/command/nl/dto/NlExecuteResponse.java new file mode 100644 index 000000000..271d6d352 --- /dev/null +++ b/src/main/java/com/sports/server/command/nl/dto/NlExecuteResponse.java @@ -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 + ) { + } +} diff --git a/src/main/java/com/sports/server/command/nl/dto/NlParseResult.java b/src/main/java/com/sports/server/command/nl/dto/NlParseResult.java new file mode 100644 index 000000000..6dc4818df --- /dev/null +++ b/src/main/java/com/sports/server/command/nl/dto/NlParseResult.java @@ -0,0 +1,24 @@ +package com.sports.server.command.nl.dto; + +import java.util.List; + +public record NlParseResult( + boolean parsed, + String textMessage, + List 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 players) { + return new NlParseResult(true, null, players); + } +} diff --git a/src/main/java/com/sports/server/command/nl/dto/NlProcessRequest.java b/src/main/java/com/sports/server/command/nl/dto/NlProcessRequest.java new file mode 100644 index 000000000..242ba59c5 --- /dev/null +++ b/src/main/java/com/sports/server/command/nl/dto/NlProcessRequest.java @@ -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> history, + @NotBlank @Size(max = 5000) String message +) { +} diff --git a/src/main/java/com/sports/server/command/nl/dto/NlProcessResponse.java b/src/main/java/com/sports/server/command/nl/dto/NlProcessResponse.java new file mode 100644 index 000000000..0dcc9b96a --- /dev/null +++ b/src/main/java/com/sports/server/command/nl/dto/NlProcessResponse.java @@ -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 players, + Summary summary, + List 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 studentNumber, + String reason + ) { + } +} diff --git a/src/main/java/com/sports/server/command/nl/exception/NlErrorMessages.java b/src/main/java/com/sports/server/command/nl/exception/NlErrorMessages.java new file mode 100644 index 000000000..d3f3e4d9b --- /dev/null +++ b/src/main/java/com/sports/server/command/nl/exception/NlErrorMessages.java @@ -0,0 +1,10 @@ +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 = "원본 텍스트에서 해당 학번을 찾을 수 없습니다"; + public static final String INVALID_PLAYER_NAME = "선수 이름이 유효하지 않습니다"; +} diff --git a/src/main/java/com/sports/server/command/nl/infra/GeminiFunctionCallArgs.java b/src/main/java/com/sports/server/command/nl/infra/GeminiFunctionCallArgs.java new file mode 100644 index 000000000..34e318ecf --- /dev/null +++ b/src/main/java/com/sports/server/command/nl/infra/GeminiFunctionCallArgs.java @@ -0,0 +1,10 @@ +package com.sports.server.command.nl.infra; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.sports.server.command.nl.dto.NlParseResult.ParsedPlayer; + +import java.util.List; + +@JsonIgnoreProperties(ignoreUnknown = true) +record GeminiFunctionCallArgs(List players) { +} diff --git a/src/main/java/com/sports/server/command/nl/infra/GeminiFunctionCallResponse.java b/src/main/java/com/sports/server/command/nl/infra/GeminiFunctionCallResponse.java new file mode 100644 index 000000000..3eafb04a5 --- /dev/null +++ b/src/main/java/com/sports/server/command/nl/infra/GeminiFunctionCallResponse.java @@ -0,0 +1,54 @@ +package com.sports.server.command.nl.infra; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.databind.ObjectMapper; + +import java.util.List; +import java.util.Map; + +@JsonIgnoreProperties(ignoreUnknown = true) +public record GeminiFunctionCallResponse(List candidates) { + + @JsonIgnoreProperties(ignoreUnknown = true) + public record Candidate(Content content) { + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public record Content(List parts) { + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public record Part(String text, FunctionCall functionCall) { + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public record FunctionCall(String name, Map args) { + } + + public boolean hasFunctionCall() { + if (candidates == null || candidates.isEmpty()) return false; + List parts = candidates.get(0).content().parts(); + if (parts == null || parts.isEmpty()) return false; + return parts.get(0).functionCall() != null; + } + + public FunctionCall getFunctionCall() { + if (candidates == null || candidates.isEmpty()) return null; + List parts = candidates.get(0).content().parts(); + if (parts == null || parts.isEmpty()) return null; + return parts.get(0).functionCall(); + } + + public String getText() { + if (candidates == null || candidates.isEmpty()) return ""; + List parts = candidates.get(0).content().parts(); + if (parts == null || parts.isEmpty()) return ""; + return parts.get(0).text() != null ? parts.get(0).text() : ""; + } + + public T getArgsAs(ObjectMapper objectMapper, Class type) { + FunctionCall fc = getFunctionCall(); + if (fc == null) return null; + return objectMapper.convertValue(fc.args(), type); + } +} diff --git a/src/main/java/com/sports/server/command/nl/infra/NlGeminiClient.java b/src/main/java/com/sports/server/command/nl/infra/NlGeminiClient.java new file mode 100644 index 000000000..73c951772 --- /dev/null +++ b/src/main/java/com/sports/server/command/nl/infra/NlGeminiClient.java @@ -0,0 +1,124 @@ +package com.sports.server.command.nl.infra; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sports.server.command.nl.application.NlClient; +import com.sports.server.command.nl.dto.NlParseResult; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.client.WebClient; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +@Component +@RequiredArgsConstructor +public class NlGeminiClient implements NlClient { + + private final WebClient geminiWebClient; + private final ObjectMapper objectMapper; + + @Value("${gemini.api.key}") + private String apiKey; + + @Value("${gemini.api.nl-prompt}") + private String systemPrompt; + + private static final Map FUNCTION_SCHEMA = Map.of( + "name", "parse_players", + "description", "텍스트에서 추출한 선수 정보 목록", + "parameters", Map.of( + "type", "OBJECT", + "properties", Map.of( + "players", Map.of( + "type", "ARRAY", + "items", Map.of( + "type", "OBJECT", + "properties", Map.of( + "name", Map.of("type", "STRING", "description", "선수 이름"), + "studentNumber", Map.of("type", "STRING", "description", "9자리 학번"), + "jerseyNumber", Map.of("type", "INTEGER", "description", "등번호 (1~99)") + ), + "required", List.of("name", "studentNumber") + ) + ) + ), + "required", List.of("players") + ) + ); + + @Override + public NlParseResult parsePlayers(String message, List> history) { + GeminiFunctionCallResponse response = callGeminiApi(message, history); + return toParseResult(response); + } + + private GeminiFunctionCallResponse callGeminiApi(String message, List> history) { + List> contents = buildContents(message, history); + + Map body = Map.of( + "systemInstruction", Map.of( + "parts", List.of(Map.of("text", systemPrompt)) + ), + "contents", contents, + "tools", List.of(Map.of( + "functionDeclarations", List.of(FUNCTION_SCHEMA) + )), + "toolConfig", Map.of( + "functionCallingConfig", Map.of("mode", "ANY") + ) + ); + + return geminiWebClient.post() + .header("x-goog-api-key", apiKey) + .bodyValue(body) + .retrieve() + .bodyToMono(GeminiFunctionCallResponse.class) + .block(Duration.ofSeconds(30)); + } + + private NlParseResult toParseResult(GeminiFunctionCallResponse response) { + if (!response.hasFunctionCall()) { + String text = response.getText(); + return NlParseResult.ofText(text.isEmpty() ? null : text); + } + + GeminiFunctionCallArgs args = response.getArgsAs(objectMapper, GeminiFunctionCallArgs.class); + if (args == null || args.players() == null || args.players().isEmpty()) { + return NlParseResult.ofPlayers(List.of()); + } + + return NlParseResult.ofPlayers(args.players()); + } + + private static final Map ALLOWED_ROLES = Map.of( + "user", "user" + ); + + private List> buildContents(String message, List> history) { + List> contents = new ArrayList<>(); + + if (history != null) { + for (Map entry : history) { + String role = entry.get("role"); + String content = entry.get("content"); + String geminiRole = ALLOWED_ROLES.get(role); + if (geminiRole != null && content != null) { + contents.add(Map.of( + "role", geminiRole, + "parts", List.of(Map.of("text", content)) + )); + } + } + } + + contents.add(Map.of( + "role", "user", + "parts", List.of(Map.of("text", message)) + )); + + return contents; + } +} diff --git a/src/main/java/com/sports/server/command/nl/presentation/NlController.java b/src/main/java/com/sports/server/command/nl/presentation/NlController.java new file mode 100644 index 000000000..da7de8301 --- /dev/null +++ b/src/main/java/com/sports/server/command/nl/presentation/NlController.java @@ -0,0 +1,33 @@ +package com.sports.server.command.nl.presentation; + +import com.sports.server.command.member.domain.Member; +import com.sports.server.command.nl.application.NlService; +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 jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +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.RestController; + +@RestController +@RequestMapping("/nl") +@RequiredArgsConstructor +public class NlController { + + private final NlService nlService; + + @PostMapping("/process") + public ResponseEntity process(@Valid @RequestBody NlProcessRequest request, Member member) { + return ResponseEntity.ok(nlService.process(request, member)); + } + + @PostMapping("/execute") + public ResponseEntity execute(@Valid @RequestBody NlExecuteRequest request, Member member) { + return ResponseEntity.ok(nlService.execute(request, member)); + } +} diff --git a/src/main/java/com/sports/server/command/player/domain/PlayerRepository.java b/src/main/java/com/sports/server/command/player/domain/PlayerRepository.java index a10385eee..5a6ea0c76 100644 --- a/src/main/java/com/sports/server/command/player/domain/PlayerRepository.java +++ b/src/main/java/com/sports/server/command/player/domain/PlayerRepository.java @@ -4,6 +4,7 @@ import org.springframework.data.repository.Repository; import java.util.List; +import java.util.Optional; public interface PlayerRepository extends Repository { void save(Player player); @@ -13,4 +14,8 @@ public interface PlayerRepository extends Repository { boolean existsByStudentNumber(String studentNumber); List findAllById(Iterable ids); + + Optional findByStudentNumber(String studentNumber); + + List findByStudentNumberIn(List studentNumbers); } diff --git a/src/main/resources/application-ci.yml b/src/main/resources/application-ci.yml index ee2aa9585..50fd0b04d 100644 --- a/src/main/resources/application-ci.yml +++ b/src/main/resources/application-ci.yml @@ -50,4 +50,5 @@ gemini: api: key: gemini-key url: gemini-url - prompt: prompt \ No newline at end of file + prompt: prompt + nl-prompt: nl-prompt \ No newline at end of file diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index ecb4b1d03..6d2489f7b 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -49,4 +49,26 @@ gemini: api: key: gemini-key url: gemini-url - prompt: prompt \ No newline at end of file + prompt: prompt + nl-prompt: | + 너는 스포츠 리그 관리 시스템의 선수 등록 어시스턴트야. + 사용자가 입력한 텍스트에서 선수 정보를 추출하는 것이 너의 역할이야. + + 각 선수에 대해 다음 정보를 추출해: + - name: 선수 이름 (한글) + - studentNumber: 학번 (정확히 9자리 숫자) + - jerseyNumber: 등번호 (1~99 사이 숫자, 없으면 생략) + + 입력 텍스트는 다양한 형태일 수 있어: + - 공백/탭/쉼표로 구분된 형태 + - 괄호나 슬래시가 포함된 형태 + - 순서가 뒤바뀐 형태 (학번이 먼저 올 수도 있음) + - 등번호가 없는 경우도 있음 + + 중요 규칙: + - studentNumber는 반드시 원본 텍스트에 존재하는 9자리 연속 숫자여야 해 + - 9자리가 아닌 숫자를 학번으로 추측하거나 보정하지 마 + - 빈 줄이나 의미 없는 텍스트는 무시해 + - 파싱할 수 있는 선수 정보만 추출해 + + 반드시 parse_players 함수를 호출해서 응답해. \ No newline at end of file diff --git a/src/main/resources/be-config b/src/main/resources/be-config index d87f343bf..4ef7a5283 160000 --- a/src/main/resources/be-config +++ b/src/main/resources/be-config @@ -1 +1 @@ -Subproject commit d87f343bf13338d0766cdb84fcb0e9de9bef5e52 +Subproject commit 4ef7a528303a356ac251cda9b6ee01ba2d272628 diff --git a/src/test/java/com/sports/server/command/nl/acceptance/NlAcceptanceTest.java b/src/test/java/com/sports/server/command/nl/acceptance/NlAcceptanceTest.java new file mode 100644 index 000000000..284c25984 --- /dev/null +++ b/src/test/java/com/sports/server/command/nl/acceptance/NlAcceptanceTest.java @@ -0,0 +1,200 @@ +package com.sports.server.command.nl.acceptance; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.sports.server.command.nl.application.NlClient; +import com.sports.server.command.nl.dto.NlParseResult; +import com.sports.server.command.nl.dto.NlParseResult.ParsedPlayer; +import com.sports.server.support.AcceptanceTest; +import io.restassured.RestAssured; +import io.restassured.response.ExtractableResponse; +import io.restassured.response.Response; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.test.context.jdbc.Sql; + +import java.util.List; +import java.util.Map; + +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.given; + +@Sql(scripts = "/league-fixture.sql") +public class NlAcceptanceTest extends AcceptanceTest { + + @MockBean + private NlClient nlClient; + + @BeforeEach + void configureAuth() { + configureMockJwtForEmail(MOCK_EMAIL); + } + + @Test + void 선수_정보를_파싱하여_프리뷰를_반환한다() { + // given + given(nlClient.parsePlayers(anyString(), anyList())) + .willReturn(NlParseResult.ofPlayers(List.of( + new ParsedPlayer("홍길동", "202600001", 10), + new ParsedPlayer("김철수", "202600002", 7) + ))); + + Map request = Map.of( + "leagueId", 1, + "teamId", 1, + "history", List.of(), + "message", "홍길동 202600001 10\n김철수 202600002 7" + ); + + // when + ExtractableResponse response = RestAssured.given().log().all() + .when() + .cookie(COOKIE_NAME, mockToken) + .contentType(MediaType.APPLICATION_JSON_VALUE) + .body(request) + .post("/nl/process") + .then().log().all() + .extract(); + + // then + assertThat(response.statusCode()).isEqualTo(HttpStatus.OK.value()); + assertThat(response.jsonPath().getString("preview.type")).isEqualTo("REGISTER_PLAYERS_BULK"); + assertThat(response.jsonPath().getList("preview.players")).hasSize(2); + assertThat(response.jsonPath().getString("preview.players[0].status")).isEqualTo("NEW"); + } + + @Test + void 신규_선수를_등록하고_팀에_배정한다() { + // given + Map request = Map.of( + "leagueId", 1, + "teamId", 1, + "players", List.of( + Map.of("name", "홍길동", "studentNumber", "202600001", "jerseyNumber", 10) + ) + ); + + // when + ExtractableResponse response = RestAssured.given().log().all() + .when() + .cookie(COOKIE_NAME, mockToken) + .contentType(MediaType.APPLICATION_JSON_VALUE) + .body(request) + .post("/nl/execute") + .then().log().all() + .extract(); + + // then + assertThat(response.statusCode()).isEqualTo(HttpStatus.OK.value()); + assertThat(response.jsonPath().getInt("result.created")).isEqualTo(1); + assertThat(response.jsonPath().getInt("result.assigned")).isEqualTo(1); + } + + @Test + void 인증_없이_호출하면_실패한다() { + // given + Map request = Map.of( + "leagueId", 1, + "teamId", 1, + "history", List.of(), + "message", "홍길동 202600001 10" + ); + + // when + ExtractableResponse response = RestAssured.given().log().all() + .when() + .contentType(MediaType.APPLICATION_JSON_VALUE) + .body(request) + .post("/nl/process") + .then().log().all() + .extract(); + + // then + assertThat(response.statusCode()).isEqualTo(HttpStatus.UNAUTHORIZED.value()); + } + + @Test + void 이미_팀에_소속된_선수는_스킵한다() { + // given: team 1에는 player 3(이현제, 202202001)이 이미 소속 + Map request = Map.of( + "leagueId", 1, + "teamId", 1, + "players", List.of( + Map.of("name", "이현제", "studentNumber", "202202001", "jerseyNumber", 9) + ) + ); + + // when + ExtractableResponse response = RestAssured.given().log().all() + .when() + .cookie(COOKIE_NAME, mockToken) + .contentType(MediaType.APPLICATION_JSON_VALUE) + .body(request) + .post("/nl/execute") + .then().log().all() + .extract(); + + // then + assertThat(response.statusCode()).isEqualTo(HttpStatus.OK.value()); + assertThat(response.jsonPath().getInt("result.created")).isEqualTo(0); + assertThat(response.jsonPath().getInt("result.skipped")).isEqualTo(1); + } + + @Test + void 기존_선수를_다른_팀에_배정한다() { + // given: player 1(진승희, 202101001)은 team 3에 소속, team 1에는 미소속 + given(nlClient.parsePlayers(anyString(), anyList())) + .willReturn(NlParseResult.ofPlayers(List.of( + new ParsedPlayer("진승희", "202101001", 5) + ))); + + Map processRequest = Map.of( + "leagueId", 1, + "teamId", 1, + "history", List.of(), + "message", "진승희 202101001 5" + ); + + // when - process + ExtractableResponse processResponse = RestAssured.given().log().all() + .when() + .cookie(COOKIE_NAME, mockToken) + .contentType(MediaType.APPLICATION_JSON_VALUE) + .body(processRequest) + .post("/nl/process") + .then().log().all() + .extract(); + + // then - EXISTS로 분류 + assertThat(processResponse.statusCode()).isEqualTo(HttpStatus.OK.value()); + assertThat(processResponse.jsonPath().getString("preview.players[0].status")).isEqualTo("EXISTS"); + + // when - execute + Map executeRequest = Map.of( + "leagueId", 1, + "teamId", 1, + "players", List.of( + Map.of("name", "진승희", "studentNumber", "202101001", "jerseyNumber", 5) + ) + ); + + ExtractableResponse executeResponse = RestAssured.given().log().all() + .when() + .cookie(COOKIE_NAME, mockToken) + .contentType(MediaType.APPLICATION_JSON_VALUE) + .body(executeRequest) + .post("/nl/execute") + .then().log().all() + .extract(); + + // then - 생성 없이 배정만 + assertThat(executeResponse.statusCode()).isEqualTo(HttpStatus.OK.value()); + assertThat(executeResponse.jsonPath().getInt("result.created")).isEqualTo(0); + assertThat(executeResponse.jsonPath().getInt("result.assigned")).isEqualTo(1); + } + +} diff --git a/src/test/java/com/sports/server/command/nl/application/NlServiceTest.java b/src/test/java/com/sports/server/command/nl/application/NlServiceTest.java new file mode 100644 index 000000000..4d23e422c --- /dev/null +++ b/src/test/java/com/sports/server/command/nl/application/NlServiceTest.java @@ -0,0 +1,371 @@ +package com.sports.server.command.nl.application; + +import com.sports.server.command.league.domain.League; +import com.sports.server.command.league.domain.LeagueTeam; +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.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.team.application.TeamService; +import com.sports.server.command.team.domain.Team; +import com.sports.server.command.team.domain.TeamPlayerRepository; +import com.sports.server.common.application.EntityUtils; +import com.sports.server.common.exception.BadRequestException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class NlServiceTest { + + @InjectMocks + private NlService nlService; + + @Mock + private NlClient nlClient; + + @Mock + private PlayerRepository playerRepository; + + @Mock + private TeamPlayerRepository teamPlayerRepository; + + @Mock + private LeagueTeamRepository leagueTeamRepository; + + @Mock + private EntityUtils entityUtils; + + @Mock + private PlayerService playerService; + + @Mock + private TeamService teamService; + + private Team mockTeam; + private League mockLeague; + private Member mockMember; + + @BeforeEach + void setUp() { + mockTeam = mock(Team.class); + mockLeague = mock(League.class); + mockMember = mock(Member.class); + lenient().when(mockTeam.getName()).thenReturn("정치외교학과 DPS"); + lenient().when(mockLeague.isManagedBy(mockMember)).thenReturn(true); + lenient().when(entityUtils.getEntity(186L, League.class)).thenReturn(mockLeague); + lenient().when(leagueTeamRepository.findByLeagueAndTeam(mockLeague, mockTeam)) + .thenReturn(Optional.of(mock(LeagueTeam.class))); + } + + @Nested + @DisplayName("process") + class Process { + + @Test + @DisplayName("정상적인 텍스트를 파싱하여 프리뷰를 반환한다") + void 정상_파싱_프리뷰_반환() { + // given + NlProcessRequest request = new NlProcessRequest( + 186L, 1L, List.of(), + "홍길동 202600001 10\n김철수 202600002 7" + ); + + given(entityUtils.getEntity(1L, Team.class)).willReturn(mockTeam); + given(nlClient.parsePlayers(anyString(), anyList())) + .willReturn(NlParseResult.ofPlayers(List.of( + new ParsedPlayer("홍길동", "202600001", 10), + new ParsedPlayer("김철수", "202600002", 7) + ))); + given(playerRepository.findByStudentNumberIn(anyList())).willReturn(List.of()); + given(teamPlayerRepository.findPlayerIdsByTeamId(1L)).willReturn(List.of()); + + // when + NlProcessResponse response = nlService.process(request, mockMember); + + // then + assertThat(response.preview()).isNotNull(); + assertThat(response.preview().players()).hasSize(2); + assertThat(response.preview().players().get(0).status()).isEqualTo(PlayerStatus.NEW); + assertThat(response.preview().players().get(1).status()).isEqualTo(PlayerStatus.NEW); + assertThat(response.preview().summary().newPlayers()).isEqualTo(2); + } + + @Test + @DisplayName("이미 DB에 존재하는 선수는 EXISTS로 분류한다") + void 기존_선수_EXISTS_분류() { + // given + NlProcessRequest request = new NlProcessRequest( + 186L, 1L, List.of(), + "홍길동 202600001 10" + ); + + Player existingPlayer = mock(Player.class); + given(existingPlayer.getStudentNumber()).willReturn("202600001"); + given(existingPlayer.getId()).willReturn(42L); + + given(entityUtils.getEntity(1L, Team.class)).willReturn(mockTeam); + given(nlClient.parsePlayers(anyString(), anyList())) + .willReturn(NlParseResult.ofPlayers(List.of( + new ParsedPlayer("홍길동", "202600001", 10) + ))); + given(playerRepository.findByStudentNumberIn(anyList())).willReturn(List.of(existingPlayer)); + given(teamPlayerRepository.findPlayerIdsByTeamId(1L)).willReturn(List.of()); + + // when + NlProcessResponse response = nlService.process(request, mockMember); + + // then + assertThat(response.preview().players().get(0).status()).isEqualTo(PlayerStatus.EXISTS); + assertThat(response.preview().players().get(0).existingPlayerId()).isEqualTo(42L); + } + + @Test + @DisplayName("이미 팀에 소속된 선수는 ALREADY_IN_TEAM으로 분류한다") + void 팀_소속_선수_ALREADY_IN_TEAM_분류() { + // given + NlProcessRequest request = new NlProcessRequest( + 186L, 1L, List.of(), + "홍길동 202600001 10" + ); + + Player existingPlayer = mock(Player.class); + given(existingPlayer.getStudentNumber()).willReturn("202600001"); + given(existingPlayer.getId()).willReturn(42L); + + given(entityUtils.getEntity(1L, Team.class)).willReturn(mockTeam); + given(nlClient.parsePlayers(anyString(), anyList())) + .willReturn(NlParseResult.ofPlayers(List.of( + new ParsedPlayer("홍길동", "202600001", 10) + ))); + given(playerRepository.findByStudentNumberIn(anyList())).willReturn(List.of(existingPlayer)); + given(teamPlayerRepository.findPlayerIdsByTeamId(1L)).willReturn(List.of(42L)); + + // when + NlProcessResponse response = nlService.process(request, mockMember); + + // then + assertThat(response.preview().players().get(0).status()).isEqualTo(PlayerStatus.ALREADY_IN_TEAM); + } + + @Test + @DisplayName("입력 내 학번이 중복되면 DUPLICATE_IN_INPUT으로 분류한다") + void 입력_내_학번_중복() { + // given + NlProcessRequest request = new NlProcessRequest( + 186L, 1L, List.of(), + "홍길동 202600001 10\n김철수 202600001 7" + ); + + given(entityUtils.getEntity(1L, Team.class)).willReturn(mockTeam); + given(nlClient.parsePlayers(anyString(), anyList())) + .willReturn(NlParseResult.ofPlayers(List.of( + new ParsedPlayer("홍길동", "202600001", 10), + new ParsedPlayer("김철수", "202600001", 7) + ))); + given(playerRepository.findByStudentNumberIn(anyList())).willReturn(List.of()); + given(teamPlayerRepository.findPlayerIdsByTeamId(1L)).willReturn(List.of()); + + // when + NlProcessResponse response = nlService.process(request, mockMember); + + // then + assertThat(response.preview().players().get(0).status()).isEqualTo(PlayerStatus.NEW); + assertThat(response.preview().players().get(1).status()).isEqualTo(PlayerStatus.DUPLICATE_IN_INPUT); + } + + @Test + @DisplayName("학번이 원본 텍스트에 없으면 파싱 실패로 처리한다") + void 학번_원본_대조_실패() { + // given + NlProcessRequest request = new NlProcessRequest( + 186L, 1L, List.of(), + "홍길동 20260001 10" // 8자리 — 원본에 9자리 없음 + ); + + given(entityUtils.getEntity(1L, Team.class)).willReturn(mockTeam); + given(nlClient.parsePlayers(anyString(), anyList())) + .willReturn(NlParseResult.ofPlayers(List.of( + new ParsedPlayer("홍길동", "202600001", 10) // LLM이 9자리로 보정 + ))); + given(playerRepository.findByStudentNumberIn(anyList())).willReturn(List.of()); + given(teamPlayerRepository.findPlayerIdsByTeamId(1L)).willReturn(List.of()); + + // when + NlProcessResponse response = nlService.process(request, mockMember); + + // then + assertThat(response.preview().players()).isEmpty(); + assertThat(response.preview().parseFailedLines()).hasSize(1); + assertThat(response.preview().parseFailedLines().get(0).reason()) + .contains(NlErrorMessages.STUDENT_NUMBER_NOT_IN_ORIGINAL); + } + + @Test + @DisplayName("파싱에 실패하면 텍스트 메시지를 반환한다") + void 파싱_실패시_텍스트_메시지() { + // given + NlProcessRequest request = new NlProcessRequest( + 186L, 1L, List.of(), "안녕하세요" + ); + + given(entityUtils.getEntity(1L, Team.class)).willReturn(mockTeam); + given(nlClient.parsePlayers(anyString(), anyList())) + .willReturn(NlParseResult.ofText("선수 정보를 입력해주세요.")); + + // when + NlProcessResponse response = nlService.process(request, mockMember); + + // then + assertThat(response.preview()).isNull(); + assertThat(response.displayMessage()).isEqualTo("선수 정보를 입력해주세요."); + } + } + + @Nested + @DisplayName("execute") + class Execute { + + @Test + @DisplayName("신규 선수를 생성하고 팀에 배정한다") + void 신규_선수_생성_및_팀_배정() { + // given + NlExecuteRequest request = new NlExecuteRequest(186L, 1L, List.of( + new NlExecuteRequest.PlayerData("홍길동", "202600001", 10) + )); + + given(entityUtils.getEntity(1L, Team.class)).willReturn(mockTeam); + given(teamPlayerRepository.findPlayerIdsByTeamId(1L)).willReturn(List.of()); + given(playerRepository.findByStudentNumberIn(anyList())).willReturn(List.of()); + given(playerService.register(any())).willReturn(100L); + + // when + NlExecuteResponse response = nlService.execute(request, mockMember); + + // then + assertThat(response.result().created()).isEqualTo(1); + assertThat(response.result().assigned()).isEqualTo(1); + verify(playerService).register(any()); + verify(teamService).addPlayersToTeam(eq(1L), anyList()); + } + + @Test + @DisplayName("기존 선수는 생성하지 않고 팀에만 배정한다") + void 기존_선수_팀_배정만() { + // given + NlExecuteRequest request = new NlExecuteRequest(186L, 1L, List.of( + new NlExecuteRequest.PlayerData("김철수", "202600002", 7) + )); + + Player existingPlayer = mock(Player.class); + given(existingPlayer.getId()).willReturn(42L); + given(existingPlayer.getStudentNumber()).willReturn("202600002"); + + given(entityUtils.getEntity(1L, Team.class)).willReturn(mockTeam); + given(teamPlayerRepository.findPlayerIdsByTeamId(1L)).willReturn(List.of()); + given(playerRepository.findByStudentNumberIn(anyList())).willReturn(List.of(existingPlayer)); + + // when + NlExecuteResponse response = nlService.execute(request, mockMember); + + // then + assertThat(response.result().created()).isEqualTo(0); + assertThat(response.result().assigned()).isEqualTo(1); + verify(playerService, never()).register(any()); + verify(teamService).addPlayersToTeam(eq(1L), anyList()); + } + + @Test + @DisplayName("이미 팀에 소속된 선수는 스킵한다") + void 이미_소속된_선수_스킵() { + // given + NlExecuteRequest request = new NlExecuteRequest(186L, 1L, List.of( + new NlExecuteRequest.PlayerData("이영희", "202600003", 5) + )); + + Player existingPlayer = mock(Player.class); + given(existingPlayer.getId()).willReturn(42L); + given(existingPlayer.getStudentNumber()).willReturn("202600003"); + + given(entityUtils.getEntity(1L, Team.class)).willReturn(mockTeam); + given(teamPlayerRepository.findPlayerIdsByTeamId(1L)).willReturn(List.of(42L)); + given(playerRepository.findByStudentNumberIn(anyList())).willReturn(List.of(existingPlayer)); + + // when + NlExecuteResponse response = nlService.execute(request, mockMember); + + // then + assertThat(response.result().created()).isEqualTo(0); + assertThat(response.result().assigned()).isEqualTo(0); + assertThat(response.result().skipped()).isEqualTo(1); + verify(teamService, never()).addPlayersToTeam(anyLong(), anyList()); + } + } + + @Nested + @DisplayName("validation") + class Validation { + + @Test + @DisplayName("팀이 리그에 소속되지 않으면 예외가 발생한다") + void 팀_리그_불일치_예외() { + // given + NlProcessRequest request = new NlProcessRequest( + 186L, 1L, List.of(), "홍길동 202600001 10" + ); + + Team otherTeam = mock(Team.class); + given(entityUtils.getEntity(1L, Team.class)).willReturn(otherTeam); + given(leagueTeamRepository.findByLeagueAndTeam(mockLeague, otherTeam)) + .willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> nlService.process(request, mockMember)) + .isInstanceOf(BadRequestException.class) + .hasMessageContaining(NlErrorMessages.TEAM_NOT_IN_LEAGUE); + } + + @Test + @DisplayName("execute에서 중복 학번 입력은 첫 번째만 처리하고 나머지는 스킵한다") + void execute_중복_학번_스킵() { + // given + NlExecuteRequest request = new NlExecuteRequest(186L, 1L, List.of( + new NlExecuteRequest.PlayerData("홍길동", "202600001", 10), + new NlExecuteRequest.PlayerData("김철수", "202600001", 7) + )); + + given(entityUtils.getEntity(1L, Team.class)).willReturn(mockTeam); + given(teamPlayerRepository.findPlayerIdsByTeamId(1L)).willReturn(List.of()); + given(playerRepository.findByStudentNumberIn(anyList())).willReturn(List.of()); + given(playerService.register(any())).willReturn(100L); + + // when + NlExecuteResponse response = nlService.execute(request, mockMember); + + // then + assertThat(response.result().created()).isEqualTo(1); + assertThat(response.result().assigned()).isEqualTo(1); + assertThat(response.result().skipped()).isEqualTo(1); + verify(playerService, times(1)).register(any()); + } + } +} diff --git a/src/test/java/com/sports/server/command/nl/infra/NlGeminiClientManualTest.java b/src/test/java/com/sports/server/command/nl/infra/NlGeminiClientManualTest.java new file mode 100644 index 000000000..b1ddadf77 --- /dev/null +++ b/src/test/java/com/sports/server/command/nl/infra/NlGeminiClientManualTest.java @@ -0,0 +1,74 @@ +package com.sports.server.command.nl.infra; + +import com.sports.server.command.nl.dto.NlParseResult; +import com.sports.server.command.nl.dto.NlParseResult.ParsedPlayer; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@ActiveProfiles("local") +@Disabled("Gemini 외부 API 호출 테스트 - 수동 실행") +class NlGeminiClientManualTest { + + @Autowired + private NlGeminiClient nlGeminiClient; + + @Test + @DisplayName("정형 텍스트 파싱 - 공백 구분") + void 정형_텍스트_공백_구분() { + NlParseResult result = nlGeminiClient.parsePlayers( + "홍길동 202600001 10\n김철수 202600002 7\n이영희 202600003 5", List.of()); + + assertThat(result.parsed()).isTrue(); + assertThat(result.players()).hasSizeGreaterThanOrEqualTo(3); + } + + @Test + @DisplayName("비정형 텍스트 파싱 - 괄호/쉼표 혼용") + void 비정형_텍스트_파싱() { + NlParseResult result = nlGeminiClient.parsePlayers( + "홍길동(202600001) 10번, 김철수 202600002번 7, 이영희 / 202600003 / 5번", List.of()); + + assertThat(result.parsed()).isTrue(); + assertThat(result.players()).isNotEmpty(); + } + + @Test + @DisplayName("탭 구분 텍스트 파싱 - 엑셀 복붙") + void 탭_구분_텍스트_파싱() { + NlParseResult result = nlGeminiClient.parsePlayers( + "홍길동\t202600001\t10\n김철수\t202600002\t7\n이영희\t202600003\t5", List.of()); + + assertThat(result.parsed()).isTrue(); + assertThat(result.players()).hasSizeGreaterThanOrEqualTo(3); + } + + @Test + @DisplayName("등번호 없는 텍스트 파싱") + void 등번호_없는_텍스트() { + NlParseResult result = nlGeminiClient.parsePlayers( + "홍길동 202600001\n김철수 202600002", List.of()); + + assertThat(result.parsed()).isTrue(); + assertThat(result.players()).hasSize(2); + assertThat(result.players().get(0).jerseyNumber()).isNull(); + } + + @Test + @DisplayName("순서 뒤바뀐 텍스트 파싱") + void 순서_뒤바뀐_텍스트() { + NlParseResult result = nlGeminiClient.parsePlayers( + "202600001 홍길동 10\n202600002 김철수 7", List.of()); + + assertThat(result.parsed()).isTrue(); + assertThat(result.players()).hasSize(2); + } +}