diff --git a/build.gradle b/build.gradle index 8484c3d..f1df19a 100644 --- a/build.gradle +++ b/build.gradle @@ -29,6 +29,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-data-redis' + implementation 'org.springframework.boot:spring-boot-starter-webflux' compileOnly 'org.projectlombok:lombok' developmentOnly 'org.springframework.boot:spring-boot-devtools' developmentOnly 'org.springframework.boot:spring-boot-docker-compose' @@ -44,7 +45,9 @@ dependencies { runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.3.0' - + implementation group: 'com.alphacephei', name: 'vosk', version: '0.3.45' + implementation 'org.springframework.boot:spring-boot-starter-webflux' + implementation 'com.alphacephei:vosk:0.3.38' } tasks.named('test') { diff --git a/src/main/java/com/example/skillboost/auth/config/SecurityConfig.java b/src/main/java/com/example/skillboost/auth/config/SecurityConfig.java index 51365a1..e7678aa 100644 --- a/src/main/java/com/example/skillboost/auth/config/SecurityConfig.java +++ b/src/main/java/com/example/skillboost/auth/config/SecurityConfig.java @@ -7,6 +7,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; @@ -35,9 +36,13 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { // 요청 권한 설정 .authorizeHttpRequests(auth -> auth + .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() .requestMatchers( "/", "/api/auth/**", + "/api/review/**", + "/api/coding/**", + "/api/interview/**", "/oauth2/**", "/login/oauth2/**", "/swagger-ui/**", @@ -45,8 +50,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { "/v3/api-docs/**", "/swagger-resources/**", "/webjars/**", - "/favicon.ico", - "/api/coding/**" + "/favicon.ico" ).permitAll() .anyRequest().authenticated() ) diff --git a/src/main/java/com/example/skillboost/codereview/github/GithubFile.java b/src/main/java/com/example/skillboost/codeReview/GithubFile.java similarity index 69% rename from src/main/java/com/example/skillboost/codereview/github/GithubFile.java rename to src/main/java/com/example/skillboost/codeReview/GithubFile.java index fa2e5b7..37c8b4b 100644 --- a/src/main/java/com/example/skillboost/codereview/github/GithubFile.java +++ b/src/main/java/com/example/skillboost/codeReview/GithubFile.java @@ -1,5 +1,4 @@ -// src/main/java/com/example/skillboost/codereview/github/GithubFile.java -package com.example.skillboost.codereview.github; +package com.example.skillboost.codeReview; import lombok.Getter; import lombok.NoArgsConstructor; @@ -7,7 +6,6 @@ @Getter @NoArgsConstructor public class GithubFile { - private String path; private String content; diff --git a/src/main/java/com/example/skillboost/codeReview/controller/CodeReviewController.java b/src/main/java/com/example/skillboost/codeReview/controller/CodeReviewController.java new file mode 100644 index 0000000..3ad53df --- /dev/null +++ b/src/main/java/com/example/skillboost/codeReview/controller/CodeReviewController.java @@ -0,0 +1,93 @@ +package com.example.skillboost.codeReview.controller; + +import com.example.skillboost.codeReview.service.CodeReviewService; +import com.example.skillboost.codeReview.GithubFile; +import com.example.skillboost.codeReview.service.GithubService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/review") +public class CodeReviewController { + + @Autowired + private GithubService githubService; + + @Autowired + private CodeReviewService codeReviewService; + + @PostMapping + public ResponseEntity reviewCode( + @RequestParam("code") String code, + @RequestParam(value = "comment", required = false) String comment, + @RequestParam(value = "repo_url", required = false) String repoUrl, + @RequestParam(value = "branch", defaultValue = "main") String branch + ) { + try { + System.out.println("=".repeat(60)); + System.out.println(" 코드 리뷰 요청 받음"); + System.out.println(" - 코드 길이: " + code.length() + "자"); + System.out.println(" - 코멘트: " + (comment != null ? comment : "(없음)")); + System.out.println(" - Repo URL: " + (repoUrl != null ? repoUrl : "(없음)")); + + // 1. GitHub repo 코드 가져오기 (repo_url이 있을 때만) + List repoContext = null; + if (repoUrl != null && !repoUrl.isEmpty()) { + System.out.println("\n GitHub Repository 분석 시작..."); + long startTime = System.currentTimeMillis(); + + repoContext = githubService.fetchRepoCode(repoUrl, branch); + + long elapsed = System.currentTimeMillis() - startTime; + System.out.println(" " + repoContext.size() + "개 파일 로드 완료 (" + elapsed + "ms)"); + + // 파일 목록 출력 (처음 10개만) + System.out.println("\n 로드된 파일 샘플:"); + int count = 0; + for (GithubFile file : repoContext) { + if (count++ >= 10) break; + System.out.println(" - " + file.getPath() + " (" + file.getContent().length() + "자)"); + } + if (repoContext.size() > 10) { + System.out.println(" ... 외 " + (repoContext.size() - 10) + "개 파일"); + } + } + + // 2. AI 리뷰 생성 + System.out.println("\n AI 리뷰 생성 중..."); + String reviewResult = codeReviewService.reviewWithContext(code, comment, repoContext); + + // 3. 응답 생성 + Map response = new HashMap<>(); + response.put("review", reviewResult); + response.put("context_files_count", repoContext != null ? repoContext.size() : 0); + response.put("repo_url", repoUrl != null ? repoUrl : ""); + response.put("success", true); + + System.out.println(" 리뷰 완료! (리뷰 길이: " + reviewResult.length() + "자)"); + System.out.println("=".repeat(60) + "\n"); + + return ResponseEntity.ok(response); + + } catch (IllegalArgumentException e) { + System.err.println(" 잘못된 요청: " + e.getMessage()); + Map errorResponse = new HashMap<>(); + errorResponse.put("success", false); + errorResponse.put("error", e.getMessage()); + return ResponseEntity.badRequest().body(errorResponse); + + } catch (Exception e) { + System.err.println(" 서버 오류: " + e.getMessage()); + e.printStackTrace(); + Map errorResponse = new HashMap<>(); + errorResponse.put("success", false); + errorResponse.put("error", "서버 오류: " + e.getMessage()); + return ResponseEntity.internalServerError().body(errorResponse); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/example/skillboost/codeReview/controller/GithubController.java b/src/main/java/com/example/skillboost/codeReview/controller/GithubController.java new file mode 100644 index 0000000..bd7c039 --- /dev/null +++ b/src/main/java/com/example/skillboost/codeReview/controller/GithubController.java @@ -0,0 +1,25 @@ +package com.example.skillboost.codeReview.controller; + +import com.example.skillboost.codeReview.GithubFile; +import com.example.skillboost.codeReview.service.GithubService; +import org.springframework.web.bind.annotation.*; +import java.util.List; + +@RestController +@RequestMapping("/api/github") +public class GithubController { + + private final GithubService githubService; + + public GithubController(GithubService githubService) { + this.githubService = githubService; + } + + @GetMapping("/repo") + public List getRepoContents( + @RequestParam String repoUrl, + @RequestParam(defaultValue = "main") String branch + ) { + return githubService.fetchRepoCode(repoUrl, branch); + } +} diff --git a/src/main/java/com/example/skillboost/codeReview/service/CodeReviewService.java b/src/main/java/com/example/skillboost/codeReview/service/CodeReviewService.java new file mode 100644 index 0000000..ce159ab --- /dev/null +++ b/src/main/java/com/example/skillboost/codeReview/service/CodeReviewService.java @@ -0,0 +1,175 @@ +package com.example.skillboost.codeReview.service; + +import com.example.skillboost.codeReview.GithubFile; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.reactive.function.client.WebClient; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Service +public class CodeReviewService { + + private final WebClient webClient; + + @Value("${gemini.api.key}") + private String geminiApiKey; + + @Value("${gemini.model:gemini-2.5-flash}") + private String geminiModel; + + public CodeReviewService(WebClient.Builder webClientBuilder) { + this.webClient = webClientBuilder + .baseUrl("https://generativelanguage.googleapis.com") + .build(); + } + + public String reviewWithContext(String targetCode, String comment, List repoContext) { + String prompt = buildPrompt(targetCode, comment, repoContext); + + System.out.println("생성된 프롬프트 길이: " + prompt.length() + "자"); + + if (geminiApiKey == null || geminiApiKey.isEmpty()) { + System.out.println("Gemini API 키가 없습니다. Mock 리뷰를 생성합니다."); + return generateMockReview(repoContext != null ? repoContext.size() : 0); + } + + return callGemini(prompt); + } + + private String buildPrompt(String targetCode, String comment, List repoContext) { + StringBuilder prompt = new StringBuilder(); + prompt.append("당신은 경험 많은 시니어 개발자입니다. 전체 구조를 이해하고, 코드 품질을 향상시키는 리뷰를 제공합니다.\n\n"); + + if (repoContext != null && !repoContext.isEmpty()) { + prompt.append("=== 프로젝트 전체 구조 ===\n\n"); + prompt.append("총 ").append(repoContext.size()).append("개의 파일로 구성된 프로젝트입니다.\n\n"); + prompt.append("파일 목록:\n"); + int fileListCount = 0; + for (GithubFile file : repoContext) { + if (fileListCount++ >= 50) break; + prompt.append(" - ").append(file.getPath()).append("\n"); + } + if (repoContext.size() > 50) { + prompt.append(" ... 외 ").append(repoContext.size() - 50).append("개 파일\n"); + } + prompt.append("\n"); + + prompt.append("=== 주요 파일 내용 (샘플) ===\n\n"); + int contentCount = 0; + for (GithubFile file : repoContext) { + if (contentCount++ >= 5) break; + prompt.append("#### ").append(file.getPath()).append("\n"); + prompt.append("```\n"); + String content = file.getContent(); + if (content.length() > 1500) content = content.substring(0, 1500) + "\n... (생략)"; + prompt.append(content).append("\n```\n\n"); + } + + prompt.append("=== 프로젝트 분석 ===\n"); + prompt.append(analyzeProjectStructure(repoContext)).append("\n\n"); + } + + prompt.append("=== 리뷰 대상 코드 ===\n\n```\n").append(targetCode).append("\n```\n\n"); + if (comment != null && !comment.isEmpty()) { + prompt.append("=== 개발자의 질문/고민 ===\n").append(comment).append("\n\n"); + } + + prompt.append("=== 리뷰 요청사항 ===\n\n"); + prompt.append("전체 구조와 코드 스타일을 고려하여 다음 관점에서 상세한 피드백을 제공해주세요:\n"); + prompt.append("1. 아키텍처 일관성\n2. 네이밍 컨벤션\n3. 코드 품질\n4. 잠재적 문제\n5. 개선 제안\n\n"); + prompt.append("리뷰는 친절하고 구체적인 예시를 포함해 작성해주세요."); + + return prompt.toString(); + } + + private String analyzeProjectStructure(List files) { + StringBuilder analysis = new StringBuilder(); + Map extensions = files.stream() + .collect(Collectors.groupingBy( + file -> { + String path = file.getPath(); + int dotIndex = path.lastIndexOf('.'); + return dotIndex > 0 ? path.substring(dotIndex) : "기타"; + }, + Collectors.counting() + )); + + analysis.append("- 주요 언어/파일 타입: ") + .append(extensions.entrySet().stream() + .sorted((a, b) -> Long.compare(b.getValue(), a.getValue())) + .limit(5) + .map(e -> e.getKey() + " (" + e.getValue() + "개)") + .collect(Collectors.joining(", "))) + .append("\n"); + + boolean hasController = files.stream().anyMatch(f -> f.getPath().toLowerCase().contains("controller")); + boolean hasService = files.stream().anyMatch(f -> f.getPath().toLowerCase().contains("service")); + boolean hasRepository = files.stream().anyMatch(f -> f.getPath().toLowerCase().contains("repository")); + boolean hasComponent = files.stream().anyMatch(f -> f.getPath().toLowerCase().contains("component")); + + if (hasController && hasService && hasRepository) { + analysis.append("- 아키텍처: Layered Architecture (Controller-Service-Repository 패턴)\n"); + } else if (hasComponent) { + analysis.append("- 아키텍처: Component 기반 구조\n"); + } + + return analysis.toString(); + } + + private String callGemini(String prompt) { + try { + Map requestBody = Map.of( + "contents", List.of( + Map.of("parts", List.of(Map.of("text", prompt))) + ) + ); + + Map response = webClient.post() + .uri(uriBuilder -> uriBuilder + .path("/v1/models/{model}:generateContent") + .queryParam("key", geminiApiKey) + .build(geminiModel)) + .header("Content-Type", "application/json") + .bodyValue(requestBody) + .retrieve() + .bodyToMono(Map.class) + .block(); + + List> candidates = (List>) response.get("candidates"); + if (candidates != null && !candidates.isEmpty()) { + Map content = (Map) candidates.get(0).get("content"); + List> parts = (List>) content.get("parts"); + if (parts != null && !parts.isEmpty()) { + return (String) parts.get(0).get("text"); + } + } + + throw new RuntimeException("Gemini API 응답 형식이 올바르지 않습니다."); + + } catch (Exception e) { + System.err.println("Gemini API 호출 실패: " + e.getMessage()); + e.printStackTrace(); + return generateMockReview(0); + } + } + + + private String generateMockReview(int fileCount) { + StringBuilder mock = new StringBuilder(); + mock.append("# AI 코드 리뷰 결과\n\n"); + if (fileCount > 0) { + mock.append("**").append(fileCount).append("개의 프로젝트 파일**을 분석했습니다.\n\n"); + } + mock.append("## 긍정적인 부분\n- 코드가 깔끔하고 읽기 쉽습니다\n- 기본 구조가 잘 갖춰져 있습니다\n"); + mock.append("## 개선이 필요한 부분\n1. 에러 처리 보강\n2. 변수명 명확화\n3. 주석 보강\n"); + mock.append("## 개선 제안 예시\n```java\nint userCount = getUserCount();\n```\n"); + if (fileCount > 0) { + mock.append("## 프로젝트 구조 관점\n전체 프로젝트 패턴과 비교해 네이밍 컨벤션 일관성 유지 필요\n"); + } + mock.append("## 총평\n전체적으로 좋은 코드입니다.\n"); + return mock.toString(); + } +} diff --git a/src/main/java/com/example/skillboost/codeReview/service/GithubService.java b/src/main/java/com/example/skillboost/codeReview/service/GithubService.java new file mode 100644 index 0000000..7ff55df --- /dev/null +++ b/src/main/java/com/example/skillboost/codeReview/service/GithubService.java @@ -0,0 +1,97 @@ +package com.example.skillboost.codeReview.service; + +import com.example.skillboost.codeReview.GithubFile; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +@Service +public class GithubService { + + private final WebClient webClient; + + @Value("${github.token:}") + private String githubToken; + + public GithubService(WebClient.Builder builder) { + this.webClient = builder.baseUrl("https://api.github.com").build(); + } + + public List fetchRepoCode(String repoUrl, String branch) { + String[] parts = repoUrl.replace("https://github.com/", "").split("/"); + if (parts.length < 2) throw new IllegalArgumentException("잘못된 GitHub URL 형식입니다."); + + String owner = parts[0]; + String repo = parts[1]; + String treeUrl = String.format("/repos/%s/%s/git/trees/%s?recursive=1", owner, repo, branch); + + // 전체 트리 조회 + Map response = webClient.get() + .uri(treeUrl) + .headers(h -> { + if (!githubToken.isEmpty()) + h.setBearerAuth(githubToken); + }) + .retrieve() + .bodyToMono(Map.class) + .block(); + + List> tree = (List>) response.get("tree"); + List files = new ArrayList<>(); + + // 텍스트 파일만 필터링 + for (Map file : tree) { + if ("blob".equals(file.get("type"))) { + String path = (String) file.get("path"); + + if (!isTextFile(path)) continue; + + String rawUrl = String.format( + "https://raw.githubusercontent.com/%s/%s/%s/%s", + owner, repo, branch, path + ); + + String content = fetchFileContent(rawUrl); + files.add(new GithubFile(path, content)); + } + } + + return files; + } + + // 개별 파일 내용 불러오기 + private String fetchFileContent(String rawUrl) { + try { + return webClient.get() + .uri(rawUrl) + .headers(h -> { + if (!githubToken.isEmpty()) + h.setBearerAuth(githubToken); + }) + .retrieve() + .bodyToMono(String.class) + .onErrorResume(e -> Mono.just("")) // 오류 발생 시 빈 문자열 반환 + .block(); + } catch (Exception e) { + return ""; + } + } + + // 텍스트 파일 확장자 필터 + private static final List TEXT_EXTENSIONS = List.of( + ".java", ".kt", ".xml", ".json", ".yml", ".yaml", + ".md", ".gradle", ".gitignore", ".txt", ".properties", ".csv" + ); + + private boolean isTextFile(String path) { + for (String ext : TEXT_EXTENSIONS) { + if (path.toLowerCase().endsWith(ext)) return true; + } + return false; + } +} diff --git a/src/main/java/com/example/skillboost/codereview/controller/CodeReviewController.java b/src/main/java/com/example/skillboost/codereview/controller/CodeReviewController.java deleted file mode 100644 index 622af4f..0000000 --- a/src/main/java/com/example/skillboost/codereview/controller/CodeReviewController.java +++ /dev/null @@ -1,24 +0,0 @@ -package com.example.skillboost.codereview.controller; - -import com.example.skillboost.codereview.dto.CodeReviewRequest; -import com.example.skillboost.codereview.dto.CodeReviewResponse; -import com.example.skillboost.codereview.service.CodeReviewService; -import lombok.RequiredArgsConstructor; -import org.springframework.http.MediaType; -import org.springframework.web.bind.annotation.*; - -@RestController -@RequestMapping("/api/review") -@RequiredArgsConstructor -public class CodeReviewController { - - private final CodeReviewService codeReviewService; - - @PostMapping( - consumes = MediaType.APPLICATION_JSON_VALUE, - produces = MediaType.APPLICATION_JSON_VALUE - ) - public CodeReviewResponse review(@RequestBody CodeReviewRequest request) { - return codeReviewService.review(request); - } -} diff --git a/src/main/java/com/example/skillboost/codereview/dto/CodeReviewRequest.java b/src/main/java/com/example/skillboost/codereview/dto/CodeReviewRequest.java deleted file mode 100644 index e413c1d..0000000 --- a/src/main/java/com/example/skillboost/codereview/dto/CodeReviewRequest.java +++ /dev/null @@ -1,59 +0,0 @@ -// src/main/java/com/example/skillboost/codereview/dto/CodeReviewRequest.java -package com.example.skillboost.codereview.dto; - -public class CodeReviewRequest { - - private String code; - private String comment; - - // 🔹 레포지터리 기반 리뷰용 필드 - private String repoUrl; // 예: https://github.com/Junseung-Ock/java-calculator-7 - private String branch; // 기본값: main - - public CodeReviewRequest() { - } - - public CodeReviewRequest(String code, String comment) { - this.code = code; - this.comment = comment; - } - - public CodeReviewRequest(String code, String comment, String repoUrl, String branch) { - this.code = code; - this.comment = comment; - this.repoUrl = repoUrl; - this.branch = branch; - } - - public String getCode() { - return code; - } - - public void setCode(String code) { - this.code = code; - } - - public String getComment() { - return comment; - } - - public void setComment(String comment) { - this.comment = comment; - } - - public String getRepoUrl() { - return repoUrl; - } - - public void setRepoUrl(String repoUrl) { - this.repoUrl = repoUrl; - } - - public String getBranch() { - return branch; - } - - public void setBranch(String branch) { - this.branch = branch; - } -} diff --git a/src/main/java/com/example/skillboost/codereview/dto/CodeReviewResponse.java b/src/main/java/com/example/skillboost/codereview/dto/CodeReviewResponse.java deleted file mode 100644 index 6fb48fd..0000000 --- a/src/main/java/com/example/skillboost/codereview/dto/CodeReviewResponse.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.example.skillboost.codereview.dto; - -import java.util.ArrayList; -import java.util.List; - -public class CodeReviewResponse { - - private String review; - private List questions = new ArrayList<>(); - - public CodeReviewResponse() {} - - public CodeReviewResponse(String review, List questions) { - this.review = review; - this.questions = questions; - } - - public String getReview() { return review; } - public void setReview(String review) { this.review = review; } - - public List getQuestions() { return questions; } - public void setQuestions(List questions) { this.questions = questions; } -} diff --git a/src/main/java/com/example/skillboost/codereview/github/GithubController.java b/src/main/java/com/example/skillboost/codereview/github/GithubController.java deleted file mode 100644 index 0ccb95e..0000000 --- a/src/main/java/com/example/skillboost/codereview/github/GithubController.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.example.skillboost.codereview.github; - -public class GithubController { -} diff --git a/src/main/java/com/example/skillboost/codereview/github/GithubService.java b/src/main/java/com/example/skillboost/codereview/github/GithubService.java deleted file mode 100644 index 0b81c55..0000000 --- a/src/main/java/com/example/skillboost/codereview/github/GithubService.java +++ /dev/null @@ -1,114 +0,0 @@ -// src/main/java/com/example/skillboost/codereview/github/GithubService.java -package com.example.skillboost.codereview.github; - -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.http.*; -import org.springframework.stereotype.Service; -import org.springframework.web.client.RestTemplate; - -import java.util.*; - -@Slf4j -@Service -public class GithubService { - - private final RestTemplate restTemplate = new RestTemplate(); - - @Value("${github.token:}") - private String githubToken; - - private static final List TEXT_EXTENSIONS = List.of( - ".java", ".kt", ".xml", ".json", ".yml", ".yaml", - ".md", ".gradle", ".gitignore", ".txt", ".properties", ".csv" - ); - - public List fetchRepoCode(String repoUrl, String branch) { - if (repoUrl == null || !repoUrl.contains("github.com/")) { - throw new IllegalArgumentException("잘못된 GitHub URL 형식입니다."); - } - - try { - String[] parts = repoUrl.replace("https://github.com/", "") - .replace("http://github.com/", "") - .split("/"); - if (parts.length < 2) throw new IllegalArgumentException("잘못된 GitHub URL 형식입니다."); - - String owner = parts[0]; - String repo = parts[1]; - - String treeUrl = String.format( - "https://api.github.com/repos/%s/%s/git/trees/%s?recursive=1", - owner, repo, branch - ); - - log.info("[GithubService] tree 호출: {}", treeUrl); - - HttpHeaders headers = new HttpHeaders(); - if (githubToken != null && !githubToken.isEmpty()) { - headers.setBearerAuth(githubToken); - } - HttpEntity entity = new HttpEntity<>(headers); - - ResponseEntity resp = restTemplate.exchange( - treeUrl, HttpMethod.GET, entity, Map.class - ); - - Map body = resp.getBody(); - if (body == null || !body.containsKey("tree")) { - return Collections.emptyList(); - } - - List> tree = (List>) body.get("tree"); - List files = new ArrayList<>(); - - for (Map file : tree) { - if (!"blob".equals(file.get("type"))) continue; - - String path = (String) file.get("path"); - if (!isTextFile(path)) continue; - - String rawUrl = String.format( - "https://raw.githubusercontent.com/%s/%s/%s/%s", - owner, repo, branch, path - ); - - String content = fetchFileContent(rawUrl); - files.add(new GithubFile(path, content)); - } - - log.info("[GithubService] {} 개 파일 로드 완료", files.size()); - return files; - - } catch (Exception e) { - log.error("[GithubService] 레포지터리 로드 실패: {}", e.getMessage()); - return Collections.emptyList(); - } - } - - private String fetchFileContent(String rawUrl) { - try { - HttpHeaders headers = new HttpHeaders(); - if (githubToken != null && !githubToken.isEmpty()) { - headers.setBearerAuth(githubToken); - } - HttpEntity entity = new HttpEntity<>(headers); - - ResponseEntity resp = restTemplate.exchange( - rawUrl, HttpMethod.GET, entity, String.class - ); - return resp.getBody() != null ? resp.getBody() : ""; - } catch (Exception e) { - log.warn("[GithubService] 파일 읽기 실패: {} ({})", rawUrl, e.getMessage()); - return ""; - } - } - - private boolean isTextFile(String path) { - String lower = path.toLowerCase(); - for (String ext : TEXT_EXTENSIONS) { - if (lower.endsWith(ext)) return true; - } - return false; - } -} diff --git a/src/main/java/com/example/skillboost/codereview/llm/GeminiCodeReviewClient.java b/src/main/java/com/example/skillboost/codereview/llm/GeminiCodeReviewClient.java deleted file mode 100644 index a3e8145..0000000 --- a/src/main/java/com/example/skillboost/codereview/llm/GeminiCodeReviewClient.java +++ /dev/null @@ -1,245 +0,0 @@ -// src/main/java/com/example/skillboost/codereview/llm/GeminiCodeReviewClient.java -package com.example.skillboost.codereview.llm; - -import com.example.skillboost.codereview.dto.CodeReviewResponse; -import com.example.skillboost.codereview.github.GithubFile; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.http.*; -import org.springframework.stereotype.Component; -import org.springframework.web.client.RestTemplate; - -import java.util.*; - -@Component -public class GeminiCodeReviewClient { - - private final RestTemplate restTemplate; - private final ObjectMapper objectMapper; - private final String apiKey; - private final String model; - - public GeminiCodeReviewClient( - @Value("${gemini.api.key}") String apiKey, - @Value("${gemini.model}") String model - ) { - this.apiKey = apiKey; - this.model = model; - this.restTemplate = new RestTemplate(); - this.objectMapper = new ObjectMapper(); - } - - // 🔹 코드만 사용하는 기존 모드 (호환용) - public CodeReviewResponse requestReview(String code, String comment) { - return requestReview(code, comment, null); - } - - // 🔹 레포지터리 컨텍스트까지 함께 넘기는 확장 버전 - public CodeReviewResponse requestReview(String code, String comment, List repoContext) { - try { - String url = "https://generativelanguage.googleapis.com/v1beta/models/" - + model + ":generateContent?key=" + apiKey; - - String prompt = buildPrompt(code, comment, repoContext); - - Map textPart = new HashMap<>(); - textPart.put("text", prompt); - - Map content = new HashMap<>(); - content.put("parts", Collections.singletonList(textPart)); - - Map requestBody = new HashMap<>(); - requestBody.put("contents", Collections.singletonList(content)); - - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.APPLICATION_JSON); - - HttpEntity> entity = new HttpEntity<>(requestBody, headers); - - ResponseEntity response = restTemplate.postForEntity(url, entity, String.class); - String body = response.getBody(); - - if (!response.getStatusCode().is2xxSuccessful() || body == null) { - CodeReviewResponse fallback = new CodeReviewResponse(); - fallback.setReview("AI 코드 리뷰 요청에 실패했습니다. 상태코드: " + response.getStatusCode()); - fallback.setQuestions(Collections.emptyList()); - return fallback; - } - - return parseGeminiResponse(body); - - } catch (Exception e) { - CodeReviewResponse fallback = new CodeReviewResponse(); - fallback.setReview("AI 코드 리뷰 중 오류가 발생했습니다: " + e.getMessage()); - fallback.setQuestions(Collections.emptyList()); - return fallback; - } - } - - /** - * 코드 + (선택) GitHub 레포지터리 컨텍스트(README, 파일구조, 일부 코드)를 포함한 프롬프트 - */ - private String buildPrompt(String code, String comment, List repoContext) { - String userRequirement = (comment != null && !comment.trim().isEmpty()) - ? comment.trim() - : "특별한 추가 요구사항은 없습니다. 핵심만 간결하게 리뷰해줘."; - - StringBuilder sb = new StringBuilder(); - - // 1) 레포지터리 전체 맥락 - if (repoContext != null && !repoContext.isEmpty()) { - sb.append("이 코드는 GitHub 레포지터리 전체 맥락 안에 있는 일부 코드입니다.\n") - .append("레포지터리의 README와 파일 구조, 주요 코드 파일을 참고해서 '요구사항을 만족하는지'와 '아키텍처 적절성'까지 함께 리뷰해 주세요.\n\n"); - - // README 찾기 - GithubFile readme = repoContext.stream() - .filter(f -> f.getPath().equalsIgnoreCase("README.md") - || f.getPath().toLowerCase().endsWith("/readme.md")) - .findFirst() - .orElse(null); - - if (readme != null && readme.getContent() != null) { - String readmeContent = readme.getContent(); - if (readmeContent.length() > 2000) { - readmeContent = readmeContent.substring(0, 2000) + "\n... (생략)"; - } - - sb.append("=== README (요구사항 기준) ===\n"); - sb.append(readmeContent).append("\n\n"); - } - - // 파일 목록 (최대 40개) - sb.append("=== 프로젝트 파일 구조 (일부) ===\n"); - repoContext.stream() - .limit(40) - .forEach(f -> sb.append("- ").append(f.getPath()).append("\n")); - if (repoContext.size() > 40) { - sb.append("... 외 ").append(repoContext.size() - 40).append("개 파일 더 있음\n"); - } - sb.append("\n"); - - // 주요 코드 샘플 (java 위주 최대 5개) - sb.append("=== 주요 코드 샘플 (일부) ===\n"); - repoContext.stream() - .filter(f -> f.getPath().endsWith(".java")) - .limit(5) - .forEach(f -> { - sb.append("#### ").append(f.getPath()).append("\n"); - String c = f.getContent(); - if (c != null && c.length() > 1200) { - c = c.substring(0, 1200) + "\n... (생략)"; - } - sb.append(c == null ? "" : c).append("\n\n"); - }); - - sb.append("위 정보를 참고하여, 아래 사용자가 제공한 코드가 이 레포지터리/README 요구사항과 잘 맞는지 검토해 주세요.\n\n"); - } - - // 2) 여기부터는 JSON 형식 / 출력 규칙 안내 (기존 로직 유지) - sb.append(""" - 너는 숙련된 시니어 백엔드 개발자이자 코드 리뷰어야. - 아래 코드를 분석해서 반드시 **JSON 형식 하나만** 출력해. - - ⚠️ 모든 출력은 반드시 한국어로 작성해. - 마크다운 금지(**, ```, # 등) - JSON 외 텍스트 출력 금지. - - 🔒 출력 형식 규칙 - - review 항목은: - - 모든 줄을 '□ ' 로 시작 - - 한 줄은 핵심 한 문장 - - 항목 사이에는 빈 줄(\\n\\n) 있어야 함 - - - questions 항목은: - - 배열 형태 - - 각 질문은 한국어 한 문장 - - 번호(1. 2.)는 넣지 말 것 - - JSON 예시: - - { - "review": "□ 핵심 피드백입니다.\\n\\n□ 또 다른 핵심 피드백입니다.", - "questions": [ - "이 코드에서 개선할 수 있는 부분은 무엇인가요?", - "예외 처리를 추가한다면 어떤 케이스를 고려하겠습니까?" - ] - } - - 사용자가 요청한 요구사항: - """).append("\n") - .append(userRequirement).append("\n\n") - .append("리뷰할 코드:\n") - .append(code); - - return sb.toString(); - } - - private CodeReviewResponse parseGeminiResponse(String body) throws Exception { - JsonNode root = objectMapper.readTree(body); - - JsonNode candidates = root.path("candidates"); - if (!candidates.isArray() || candidates.isEmpty()) { - CodeReviewResponse resp = new CodeReviewResponse(); - resp.setReview("AI 응답이 비어 있습니다."); - resp.setQuestions(Collections.emptyList()); - return resp; - } - - JsonNode textNode = candidates.get(0) - .path("content") - .path("parts") - .get(0) - .path("text"); - - String rawText = textNode.asText(""); - if (rawText.isEmpty()) { - CodeReviewResponse resp = new CodeReviewResponse(); - resp.setReview("AI 응답 텍스트를 찾지 못했습니다."); - resp.setQuestions(Collections.emptyList()); - return resp; - } - - String cleaned = stripCodeFence(rawText); - - try { - JsonNode json = objectMapper.readTree(cleaned); - - String review = json.path("review").asText(""); - if (review.isEmpty()) review = cleaned; - - List questions = new ArrayList<>(); - JsonNode qNode = json.path("questions"); - if (qNode.isArray()) { - for (JsonNode q : qNode) questions.add(q.asText()); - } - - CodeReviewResponse resp = new CodeReviewResponse(); - resp.setReview(review); - resp.setQuestions(questions); - return resp; - - } catch (Exception e) { - CodeReviewResponse resp = new CodeReviewResponse(); - resp.setReview(cleaned); - resp.setQuestions(Collections.emptyList()); - return resp; - } - } - - private String stripCodeFence(String text) { - if (text == null) return ""; - String trimmed = text.trim(); - - if (!trimmed.startsWith("```")) return trimmed; - - int firstNewline = trimmed.indexOf('\n'); - int lastFence = trimmed.lastIndexOf("```"); - - if (firstNewline != -1 && lastFence != -1 && lastFence > firstNewline) { - return trimmed.substring(firstNewline + 1, lastFence).trim(); - } - - return trimmed; - } -} diff --git a/src/main/java/com/example/skillboost/codereview/service/CodeReviewService.java b/src/main/java/com/example/skillboost/codereview/service/CodeReviewService.java deleted file mode 100644 index c8eb152..0000000 --- a/src/main/java/com/example/skillboost/codereview/service/CodeReviewService.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.example.skillboost.codereview.service; - -import com.example.skillboost.codereview.dto.CodeReviewRequest; -import com.example.skillboost.codereview.dto.CodeReviewResponse; - -public interface CodeReviewService { - - CodeReviewResponse review(CodeReviewRequest request); -} diff --git a/src/main/java/com/example/skillboost/codereview/service/CodeReviewServiceImpl.java b/src/main/java/com/example/skillboost/codereview/service/CodeReviewServiceImpl.java deleted file mode 100644 index 1edc828..0000000 --- a/src/main/java/com/example/skillboost/codereview/service/CodeReviewServiceImpl.java +++ /dev/null @@ -1,44 +0,0 @@ -// src/main/java/com/example/skillboost/codereview/service/CodeReviewServiceImpl.java -package com.example.skillboost.codereview.service; - -import com.example.skillboost.codereview.dto.CodeReviewRequest; -import com.example.skillboost.codereview.dto.CodeReviewResponse; -import com.example.skillboost.codereview.github.GithubFile; -import com.example.skillboost.codereview.github.GithubService; -import com.example.skillboost.codereview.llm.GeminiCodeReviewClient; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.util.StringUtils; - -import java.util.Collections; -import java.util.List; - -@Service -@RequiredArgsConstructor -public class CodeReviewServiceImpl implements CodeReviewService { - - private final GeminiCodeReviewClient geminiCodeReviewClient; - private final GithubService githubService; - - @Override - public CodeReviewResponse review(CodeReviewRequest request) { - if (request == null || !StringUtils.hasText(request.getCode())) { - throw new IllegalArgumentException("코드가 비어 있습니다."); - } - - String code = request.getCode(); - String comment = request.getComment(); - String repoUrl = request.getRepoUrl(); - String branch = StringUtils.hasText(request.getBranch()) ? request.getBranch() : "main"; - - List repoContext = Collections.emptyList(); - - // 🔹 repoUrl 이 있으면 GitHub 레포 전체 읽어오기 - if (StringUtils.hasText(repoUrl)) { - repoContext = githubService.fetchRepoCode(repoUrl, branch); - } - - // 🔹 코드 + (있다면) 레포 컨텍스트 기반으로 Gemini에 리뷰 요청 - return geminiCodeReviewClient.requestReview(code, comment, repoContext); - } -} diff --git a/src/main/java/com/example/skillboost/codingtest/controller/SubmissionController.java b/src/main/java/com/example/skillboost/codingtest/controller/SubmissionController.java index d16b355..7d94538 100644 --- a/src/main/java/com/example/skillboost/codingtest/controller/SubmissionController.java +++ b/src/main/java/com/example/skillboost/codingtest/controller/SubmissionController.java @@ -12,7 +12,6 @@ @RestController @RequestMapping("/api/coding") @RequiredArgsConstructor -@CrossOrigin(origins = "*") public class SubmissionController { private final GradingService gradingService; diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index 0e1dc01..0e5ccb5 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -42,4 +42,5 @@ springdoc: gemini: model: ${GEMINI_MODEL} api: - key: ${GEMINI_KEY} \ No newline at end of file + key: ${GEMINI_KEY} + diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 2fa9d7b..7289430 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -36,4 +36,4 @@ jwt: gemini: model: ${GEMINI_MODEL} api: - key: ${GEMINI_KEY} + key: ${GEMINI_KEY} \ No newline at end of file diff --git a/src/test/java/com/example/skillboost/SkillBoostApplicationTests.java b/src/test/java/com/example/skillboost/SkillBoostApplicationTests.java index babf589..8039cb1 100644 --- a/src/test/java/com/example/skillboost/SkillBoostApplicationTests.java +++ b/src/test/java/com/example/skillboost/SkillBoostApplicationTests.java @@ -2,7 +2,9 @@ import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +@ActiveProfiles("test") @SpringBootTest class SkillBoostApplicationTests {