Skip to content
Open
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
26 changes: 24 additions & 2 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -1,5 +1,27 @@
.gitattributes text eol=lf

# Preserve existing wrapper behavior
/gradlew text eol=lf
*.bat text eol=crlf
*.jar binary
/backend/mvnw text eol=lf
*.bat text eol=crlf
*.cmd text eol=crlf
*.jar binary

# Source files stay LF across platforms
*.java text eol=lf
*.kt text eol=lf
*.groovy text eol=lf
*.xml text eol=lf
*.sql text eol=lf
*.properties text eol=lf
*.yml text eol=lf
*.yaml text eol=lf
*.sh text eol=lf
*.js text eol=lf
*.ts text eol=lf
*.vue text eol=lf
*.css text eol=lf
*.scss text eol=lf
*.html text eol=lf
*.json text eol=lf
*.md text eol=lf
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ build/
.env.*.local
.envrc
*-local.properties
*.pem


##########################################################
Expand Down
19 changes: 19 additions & 0 deletions backend/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,25 @@
<artifactId>bucket4j-core</artifactId>
<version>8.3.0</version>
</dependency>

<!-- JWT for GitHub App Authentication -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.12.5</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.12.5</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.12.5</version>
<scope>runtime</scope>
</dependency>


</dependencies>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,30 @@

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.ssafy.dash.algorithm.domain.AlgorithmRecord;
import com.ssafy.dash.algorithm.domain.AlgorithmRecordRepository;
import com.ssafy.dash.ai.infrastructure.client.AiServerClient;
import com.ssafy.dash.ai.infrastructure.client.dto.request.CodeReviewRequest;
import com.ssafy.dash.ai.infrastructure.client.dto.response.CodeReviewResponse;
import com.ssafy.dash.ai.infrastructure.client.dto.response.AiCounterExampleResponse;
import com.ssafy.dash.ai.domain.CodeAnalysisResult;
import com.ssafy.dash.ai.infrastructure.CodeAnalysisResultMapper;
import com.ssafy.dash.user.domain.User;
import com.ssafy.dash.user.domain.UserRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.annotation.Propagation;

import java.time.LocalDateTime;
import java.util.NoSuchElementException;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;

/**
* 코드 리뷰 서비스
Expand All @@ -28,6 +38,9 @@ public class CodeReviewService {
private final AiServerClient aiClient;
private final CodeAnalysisResultMapper resultMapper;
private final ObjectMapper objectMapper;
private final AlgorithmRecordRepository algorithmRecordRepository;
private final UserRepository userRepository;
private final ConcurrentMap<Long, Object> analyzeLocks = new ConcurrentHashMap<>();

/**
* 코드 분석 요청 및 결과 저장
Expand All @@ -37,6 +50,8 @@ public CodeAnalysisResult analyzeAndSave(Long algorithmRecordId, String code, St
String problemNumber, String platform, String problemTitle) {
log.info("Analyzing code for record: {}", algorithmRecordId);

Optional<CodeAnalysisResult> existing = resultMapper.findByAlgorithmRecordId(algorithmRecordId);

// 1. AI 서버에 분석 요청
CodeReviewRequest request = CodeReviewRequest.builder()
.code(code)
Expand All @@ -50,6 +65,7 @@ public CodeAnalysisResult analyzeAndSave(Long algorithmRecordId, String code, St

// 2. 응답을 엔티티로 변환
CodeAnalysisResult result = convertToEntity(algorithmRecordId, response);
existing.ifPresent(previous -> copyCounterExampleFields(previous, result));

// 3. 기존 분석 결과가 있으면 삭제 후 새로 저장
resultMapper.deleteByAlgorithmRecordId(algorithmRecordId);
Expand All @@ -59,6 +75,43 @@ public CodeAnalysisResult analyzeAndSave(Long algorithmRecordId, String code, St
return result;
}

@Transactional(propagation = Propagation.REQUIRES_NEW)
public CodeAnalysisResult analyzeOnDemand(Long algorithmRecordId, Long requesterUserId, boolean force) {
if (algorithmRecordId == null) {
throw new IllegalArgumentException("algorithmRecordId는 필수입니다.");
}
if (requesterUserId == null) {
throw new AccessDeniedException("로그인이 필요합니다.");
}

AlgorithmRecord record = requireAuthorizedRecord(algorithmRecordId, requesterUserId);
Object lock = analyzeLocks.computeIfAbsent(algorithmRecordId, key -> new Object());

try {
synchronized (lock) {
Optional<CodeAnalysisResult> existing = resultMapper.findByAlgorithmRecordId(algorithmRecordId);
if (existing.isPresent() && !force && hasReviewContent(existing.get())) {
return existing.get();
}

try {
return analyzeAndSave(
algorithmRecordId,
record.getCode(),
record.getLanguage(),
record.getProblemNumber(),
record.getPlatform(),
record.getTitle());
} catch (DuplicateKeyException duplicate) {
return resultMapper.findByAlgorithmRecordId(algorithmRecordId)
.orElseThrow(() -> duplicate);
}
}
} finally {
analyzeLocks.remove(algorithmRecordId, lock);
}
}

/**
* 반례 결과 저장
*/
Expand Down Expand Up @@ -98,6 +151,11 @@ public Optional<CodeAnalysisResult> getAnalysisResult(Long algorithmRecordId) {
return resultMapper.findByAlgorithmRecordId(algorithmRecordId);
}

public Optional<CodeAnalysisResult> getAnalysisResultAuthorized(Long algorithmRecordId, Long requesterUserId) {
requireAuthorizedRecord(algorithmRecordId, requesterUserId);
return resultMapper.findByAlgorithmRecordId(algorithmRecordId);
}

/**
* AI 응답을 엔티티로 변환
*/
Expand Down Expand Up @@ -176,4 +234,54 @@ private String toJson(Object obj) {
return null;
}
}

private AlgorithmRecord requireAuthorizedRecord(Long algorithmRecordId, Long requesterUserId) {
AlgorithmRecord record = algorithmRecordRepository.findById(algorithmRecordId)
.orElseThrow(() -> new NoSuchElementException("해당 풀이 기록을 찾을 수 없습니다: " + algorithmRecordId));

if (Objects.equals(record.getUserId(), requesterUserId)) {
return record;
}

User requester = userRepository.findById(requesterUserId)
.orElseThrow(() -> new AccessDeniedException("로그인이 필요합니다."));

if ("ROLE_ADMIN".equals(requester.getRole())) {
return record;
}

if (requester.getStudyId() != null && Objects.equals(requester.getStudyId(), record.getStudyId())) {
return record;
}

throw new AccessDeniedException("이 기록의 AI 분석을 볼 권한이 없습니다.");
}

private boolean hasReviewContent(CodeAnalysisResult result) {
return result != null && (
hasText(result.getSummary()) ||
hasText(result.getTimeComplexity()) ||
hasText(result.getSpaceComplexity()) ||
hasText(result.getComplexityExplanation()) ||
hasText(result.getPatterns()) ||
hasText(result.getAlgorithmIntuition()) ||
hasText(result.getPitfalls()) ||
hasText(result.getImprovements()) ||
hasText(result.getKeyBlocks()) ||
hasText(result.getFullResponse()) ||
result.isRefactorProvided() ||
hasText(result.getRefactorCode()) ||
hasText(result.getRefactorExplanation()));
}

private boolean hasText(String value) {
return value != null && !value.isBlank();
}

private void copyCounterExampleFields(CodeAnalysisResult source, CodeAnalysisResult target) {
target.setCounterExampleInput(source.getCounterExampleInput());
target.setCounterExampleExpected(source.getCounterExampleExpected());
target.setCounterExamplePredicted(source.getCounterExamplePredicted());
target.setCounterExampleReason(source.getCounterExampleReason());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,21 @@
import com.ssafy.dash.ai.presentation.dto.request.CodeReviewRequest;
import com.ssafy.dash.ai.presentation.dto.request.HintChatRequestDto;
import com.ssafy.dash.ai.presentation.dto.response.HintChatResponseDto;
import com.ssafy.dash.oauth.presentation.security.CustomOAuth2User;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;

import java.util.List;
import java.util.NoSuchElementException;

/**
* AI API 컨트롤러
Expand All @@ -42,23 +50,49 @@ public class AiController {

@Operation(summary = "코드 분석 요청", description = "알고리즘 풀이 코드를 AI로 분석합니다")
@PostMapping("/review")
public ResponseEntity<CodeAnalysisResult> analyzeCode(@RequestBody CodeReviewRequest request) {
CodeAnalysisResult result = codeReviewService.analyzeAndSave(
request.algorithmRecordId(),
request.code(),
request.language(),
request.problemNumber(),
request.platform(),
request.problemTitle());
return ResponseEntity.ok(result);
public ResponseEntity<CodeAnalysisResult> analyzeCode(
@Parameter(hidden = true) @AuthenticationPrincipal OAuth2User principal,
@RequestBody CodeReviewRequest request) {
if (!(principal instanceof CustomOAuth2User customUser)) {
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "로그인이 필요합니다.");
}
if (request.algorithmRecordId() == null) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "algorithmRecordId는 필수입니다.");
}

try {
CodeAnalysisResult result = codeReviewService.analyzeOnDemand(
request.algorithmRecordId(),
customUser.getUserId(),
Boolean.TRUE.equals(request.force()));
return ResponseEntity.ok(result);
} catch (NoSuchElementException e) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, e.getMessage(), e);
} catch (AccessDeniedException e) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, e.getMessage(), e);
} catch (IllegalArgumentException e) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, e.getMessage(), e);
}
}

@Operation(summary = "분석 결과 조회", description = "저장된 분석 결과를 조회합니다")
@GetMapping("/review/{algorithmRecordId}")
public ResponseEntity<CodeAnalysisResult> getAnalysisResult(@PathVariable Long algorithmRecordId) {
return codeReviewService.getAnalysisResult(algorithmRecordId)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
public ResponseEntity<CodeAnalysisResult> getAnalysisResult(
@Parameter(hidden = true) @AuthenticationPrincipal OAuth2User principal,
@PathVariable Long algorithmRecordId) {
if (!(principal instanceof CustomOAuth2User customUser)) {
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "로그인이 필요합니다.");
}

try {
return codeReviewService.getAnalysisResultAuthorized(algorithmRecordId, customUser.getUserId())
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
} catch (NoSuchElementException e) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, e.getMessage(), e);
} catch (AccessDeniedException e) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, e.getMessage(), e);
}
}

@Operation(summary = "AI 튜터 대화", description = "맞은 문제/틀린 문제 모두 지원하는 대화형 AI 튜터")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,6 @@ public record CodeReviewRequest(
String language,
String problemNumber,
String platform,
String problemTitle) {
String problemTitle,
Boolean force) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ public enum ErrorCode {
VALIDATION_FAILED(HttpStatus.BAD_REQUEST, "VALIDATION_FAILED", "요청 값이 올바르지 않습니다."),
RESOURCE_NOT_FOUND(HttpStatus.NOT_FOUND, "RESOURCE_NOT_FOUND", "요청한 리소스를 찾을 수 없습니다."),
UNAUTHORIZED_ACCESS(HttpStatus.FORBIDDEN, "UNAUTHORIZED_ACCESS", "접근 권한이 없습니다."),
GITHUB_APP_NOT_INSTALLED(HttpStatus.FORBIDDEN, "GITHUB_APP_NOT_INSTALLED",
"해당 리포지토리에 GitHub App이 설치되어 있지 않거나 권한이 없습니다."),
TOO_MANY_REQUESTS(HttpStatus.TOO_MANY_REQUESTS, "TOO_MANY_REQUESTS", "요청 횟수가 초과되었습니다. 잠시 후 다시 시도해주세요."),
INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "INTERNAL_SERVER_ERROR", "예상치 못한 오류가 발생했습니다.");

Expand All @@ -36,5 +38,5 @@ public String getCode() {
public String getMessage() {
return message;
}

}
Loading
Loading