Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
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);
}
254 changes: 254 additions & 0 deletions src/main/java/com/sports/server/command/nl/application/NlService.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,254 @@
package com.sports.server.command.nl.application;
Copy link
Contributor

@Jin409 Jin409 Mar 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  ---                                                                                                                                                                                                                              
  전체 구조 개요                                                                                                                                                                                                                   
   
  2단계 API로 구성됩니다. process(미리보기) → execute(실제 등록) 순서로 호출합니다.                                                                                                                                                
                                                                                                                                                                                                                                 
  ---
  1단계: POST /nl/process — 미리보기

  요청 형태:
  {
    "leagueId": 186,
    "teamId": 1,
    "history": [],          // 이전 대화 히스토리 (multi-turn 지원)
    "message": "홍길동 202600001 10\n김철수 202600002 7"
  }

  흐름:

  [클라이언트] 자연어 텍스트 전송
        ↓
  [NlController.process()]
        ↓
  [NlService.process()]
      ① 권한 검증: league 멤버인지, team이 해당 league 소속인지 확인
        ↓
      ② NlGeminiClient.parsePlayers(message, history) 호출
           - Gemini API에 System Prompt + 대화 히스토리 + 현재 메시지 전송
           - Function Calling 모드로 강제 호출 (mode: "ANY")
           - Gemini가 parse_players({players: [...]}) 형태로 응답
        ↓
      ③ Gemini가 Function Call을 안 했으면 → 텍스트 메시지 그대로 반환
           (예: "선수 정보를 입력해주세요.")
        ↓
      ④ 원본 텍스트에서 9자리 숫자 직접 추출 (정규식)
           → Gemini가 학번을 "보정"하는 hallucination 방지용 대조 집합
        ↓
      ⑤ 파싱된 선수 목록 순회하며 각각 상태 분류:
           - 원본에 없는 학번 / 9자리 아님    → parseFailedLines에 추가
           - 입력 내 중복 학번               → DUPLICATE_IN_INPUT
           - DB에 없음                       → NEW
           - DB에 있지만 팀에 없음            → EXISTS (existingPlayerId 포함)
           - DB에 있고 이미 팀 소속           → ALREADY_IN_TEAM
        ↓
      ⑥ 미리보기 응답 반환

  응답 형태:
  {
    "displayMessage": "정치외교학과 DPS에 2명의 선수를 등록합니다. 확인해주세요.",
    "preview": {
      "type": "REGISTER_PLAYERS_BULK",
      "teamId": 1,
      "teamName": "정치외교학과 DPS",
      "players": [
        { "name": "홍길동", "studentNumber": "202600001", "jerseyNumber": 10, "status": "NEW", "existingPlayerId": null },
        { "name": "김철수", "studentNumber": "202600002", "jerseyNumber": 7,  "status": "EXISTS", "existingPlayerId": 42 }
      ],
      "summary": { "total": 2, "newPlayers": 1, "existingPlayers": 1, "alreadyInTeam": 0 },
      "parseFailedLines": []
    }
  }

  ---
  2단계: POST /nl/execute — 실제 등록

  클라이언트가 미리보기를 사용자에게 보여주고, 확인 후 이 API를 호출합니다.

  요청 형태:
  {
    "leagueId": 186,
    "teamId": 1,
    "players": [
      { "name": "홍길동", "studentNumber": "202600001", "jerseyNumber": 10 },
      { "name": "김철수", "studentNumber": "202600002", "jerseyNumber": 7 }
    ]
  }

  흐름:

  [클라이언트] 확인된 선수 목록 전송
        ↓
  [NlService.execute()]
      ① 동일한 권한 검증
        ↓
      ② 학번 목록으로 DB 일괄 조회 (N+1 방지)
        ↓
      ③ 선수 목록 순회:
           - 중복 학번 → skipped++
           - DB에 없음  → PlayerService.register() 로 신규 생성 → created++
           - DB에 있고 이미 팀 소속 → skipped++
           - DB에 있고 팀 미소속  → 기존 ID 사용
           → teamPlayerRegisters 리스트에 (playerId, jerseyNumber) 추가 → assigned++
        ↓
      ④ TeamService.addPlayersToTeam() 으로 일괄 팀 배정
        ↓
      ⑤ 결과 반환

  응답 형태:
  {
    "displayMessage": "정치외교학과 DPS에 2명의 선수가 등록되었습니다.",
    "result": { "created": 1, "assigned": 2, "skipped": 0 }
  }

  ---
  Gemini 연동 핵심 포인트

  NlGeminiClient가 Gemini API와 통신하는 방식입니다.

  요청 구조:
    systemInstruction: "너는 선수 등록 어시스턴트야..." (고정)
    contents: [ ...history, { role: "user", parts: [message] } ]
    tools: [ parse_players 함수 스키마 ]
    toolConfig: { mode: "ANY" }  ← 반드시 함수 호출하도록 강제

  응답 구조 (GeminiFunctionCallResponse):
    candidates[0].content.parts[0].functionCall
      .name: "parse_players"
      .args: { players: [ {name, studentNumber, jerseyNumber}, ... ] }

  mode: "ANY" 덕분에 Gemini는 항상 parse_players를 호출해야 합니다. 만약 텍스트로만 응답하면 hasFunctionCall() == false가 되어 에러 메시지를 반환합니다.

  ---
  학번 원본 대조 검증의 역할

  입력: "홍길동 20260001 10"   ← 8자리 (오타)
                                 ↓
  Gemini가 "202600001"로 보정해서 반환할 수 있음 (hallucination)
                                 ↓
  원본에서 9자리 숫자 추출 → {}  (비어있음)
                                 ↓
  "202600001" ∉ 원본 집합 → parseFailedLines에 추가

  LLM이 임의로 학번을 만들어내는 것을 원본 텍스트와 대조해서 차단하는 안전장치입니다.

이런 식으로 플로우를 정리해서 문서화의 측면에서.. PR 본문에 남겨주심 좋을 것 같아요


Copy link
Contributor

Choose a reason for hiding this comment

The 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) {
Copy link
Contributor

Choose a reason for hiding this comment

The 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())
);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-medium medium

The execute method registers new players using names and student numbers provided in the request. These values are typically derived from the LLM output in the previous process step. The application does not sanitize the player's name before passing it to playerService.register, which stores it in the database. If an attacker (or a malicious manager) provides a name containing a script, it will be stored and potentially executed when other users view the player's information in the application.

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) {
Copy link
Contributor

Choose a reason for hiding this comment

The 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
) {
}
}
24 changes: 24 additions & 0 deletions src/main/java/com/sports/server/command/nl/dto/NlParseResult.java
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
) {
}
Loading