Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
5 changes: 4 additions & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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') {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -35,18 +36,21 @@ 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/**",
"/swagger-ui.html",
"/v3/api-docs/**",
"/swagger-resources/**",
"/webjars/**",
"/favicon.ico",
"/api/coding/**"
"/favicon.ico"
).permitAll()
.anyRequest().authenticated()
)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,11 @@
// 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;

@Getter
@NoArgsConstructor
public class GithubFile {

private String path;
private String content;

Expand Down
Original file line number Diff line number Diff line change
@@ -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<GithubFile> 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<String, Object> 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<String, Object> 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<String, Object> errorResponse = new HashMap<>();
errorResponse.put("success", false);
errorResponse.put("error", "서버 오류: " + e.getMessage());
return ResponseEntity.internalServerError().body(errorResponse);
}
}
}
Original file line number Diff line number Diff line change
@@ -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<GithubFile> getRepoContents(
@RequestParam String repoUrl,
@RequestParam(defaultValue = "main") String branch
) {
return githubService.fetchRepoCode(repoUrl, branch);
}
}
Original file line number Diff line number Diff line change
@@ -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<GithubFile> 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<GithubFile> 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<GithubFile> files) {
StringBuilder analysis = new StringBuilder();
Map<String, Long> 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<String, Object> requestBody = Map.of(
"contents", List.of(
Map.of("parts", List.of(Map.of("text", prompt)))
)
);

Map<String, Object> 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<Map<String, Object>> candidates = (List<Map<String, Object>>) response.get("candidates");
if (candidates != null && !candidates.isEmpty()) {
Map<String, Object> content = (Map<String, Object>) candidates.get(0).get("content");
List<Map<String, Object>> parts = (List<Map<String, Object>>) 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();
}
}
Loading