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
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
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 @@ -22,7 +22,6 @@
import com.ssafy.dash.defense.application.DefenseService;
import com.ssafy.dash.battle.application.BattleService;
import com.ssafy.dash.acorn.application.AcornService;
import com.ssafy.dash.ai.application.CodeReviewService;
import com.ssafy.dash.study.application.StudyMissionService;
import com.ssafy.dash.study.application.StudyService;
import org.slf4j.Logger;
Expand Down Expand Up @@ -59,7 +58,6 @@ public class GitHubPushEventWorker {
private final TransactionTemplate transactionTemplate;
private final UserRepository userRepository;
private final AcornService acornService;
private final CodeReviewService codeReviewService;
private final StudyMissionService studyMissionService;
private final MockExamService mockExamService;
private final DefenseService defenseService;
Expand All @@ -76,7 +74,6 @@ public GitHubPushEventWorker(GitHubPushEventRepository pushEventRepository,
PlatformTransactionManager transactionManager,
UserRepository userRepository,
AcornService acornService,
CodeReviewService codeReviewService,
StudyMissionService studyMissionService,
MockExamService mockExamService,
DefenseService defenseService,
Expand All @@ -94,7 +91,6 @@ public GitHubPushEventWorker(GitHubPushEventRepository pushEventRepository,
this.transactionTemplate = new TransactionTemplate(transactionManager);
this.userRepository = userRepository;
this.acornService = acornService;
this.codeReviewService = codeReviewService;
this.studyMissionService = studyMissionService;
this.mockExamService = mockExamService;
this.defenseService = defenseService;
Expand All @@ -110,25 +106,7 @@ public void run() {
return;
}
GitHubPushEvent event = optional.get();
// 트랜잭션 내에서 레코드 저장 후, 생성된 레코드 목록 반환
List<AlgorithmRecord> newRecords = transactionTemplate.execute(status -> processEvent(event));

// 트랜잭션 커밋 후 AI 분석 실행 (별도 트랜잭션)
if (newRecords != null) {
for (AlgorithmRecord record : newRecords) {
try {
codeReviewService.analyzeAndSave(
record.getId(),
record.getCode(),
record.getLanguage(),
record.getProblemNumber(),
record.getPlatform(),
record.getTitle());
} catch (Exception e) {
log.error("Auto-analysis failed via worker for record: {}", record.getId(), e);
}
}
}
transactionTemplate.execute(status -> processEvent(event));
}
}

Expand Down
Loading