diff --git a/.gitattributes b/.gitattributes index d2e64cbf..7f07467c 100644 --- a/.gitattributes +++ b/.gitattributes @@ -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 diff --git a/.gitignore b/.gitignore index 2919cd89..48563f89 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,7 @@ build/ .env.*.local .envrc *-local.properties +*.pem ########################################################## diff --git a/backend/pom.xml b/backend/pom.xml index 42dfebf4..dea648df 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -160,6 +160,25 @@ bucket4j-core 8.3.0 + + + + io.jsonwebtoken + jjwt-api + 0.12.5 + + + io.jsonwebtoken + jjwt-impl + 0.12.5 + runtime + + + io.jsonwebtoken + jjwt-jackson + 0.12.5 + runtime + diff --git a/backend/src/main/java/com/ssafy/dash/ai/application/CodeReviewService.java b/backend/src/main/java/com/ssafy/dash/ai/application/CodeReviewService.java index ec86e421..604a53a8 100644 --- a/backend/src/main/java/com/ssafy/dash/ai/application/CodeReviewService.java +++ b/backend/src/main/java/com/ssafy/dash/ai/application/CodeReviewService.java @@ -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; /** * 코드 리뷰 서비스 @@ -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 analyzeLocks = new ConcurrentHashMap<>(); /** * 코드 분석 요청 및 결과 저장 @@ -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 existing = resultMapper.findByAlgorithmRecordId(algorithmRecordId); + // 1. AI 서버에 분석 요청 CodeReviewRequest request = CodeReviewRequest.builder() .code(code) @@ -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); @@ -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 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); + } + } + /** * 반례 결과 저장 */ @@ -98,6 +151,11 @@ public Optional getAnalysisResult(Long algorithmRecordId) { return resultMapper.findByAlgorithmRecordId(algorithmRecordId); } + public Optional getAnalysisResultAuthorized(Long algorithmRecordId, Long requesterUserId) { + requireAuthorizedRecord(algorithmRecordId, requesterUserId); + return resultMapper.findByAlgorithmRecordId(algorithmRecordId); + } + /** * AI 응답을 엔티티로 변환 */ @@ -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()); + } } diff --git a/backend/src/main/java/com/ssafy/dash/ai/presentation/AiController.java b/backend/src/main/java/com/ssafy/dash/ai/presentation/AiController.java index 4fa0d5c5..ecb440be 100644 --- a/backend/src/main/java/com/ssafy/dash/ai/presentation/AiController.java +++ b/backend/src/main/java/com/ssafy/dash/ai/presentation/AiController.java @@ -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 컨트롤러 @@ -42,23 +50,49 @@ public class AiController { @Operation(summary = "코드 분석 요청", description = "알고리즘 풀이 코드를 AI로 분석합니다") @PostMapping("/review") - public ResponseEntity 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 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 getAnalysisResult(@PathVariable Long algorithmRecordId) { - return codeReviewService.getAnalysisResult(algorithmRecordId) - .map(ResponseEntity::ok) - .orElse(ResponseEntity.notFound().build()); + public ResponseEntity 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 튜터") diff --git a/backend/src/main/java/com/ssafy/dash/ai/presentation/dto/request/CodeReviewRequest.java b/backend/src/main/java/com/ssafy/dash/ai/presentation/dto/request/CodeReviewRequest.java index 2b4f08fb..8e31d253 100644 --- a/backend/src/main/java/com/ssafy/dash/ai/presentation/dto/request/CodeReviewRequest.java +++ b/backend/src/main/java/com/ssafy/dash/ai/presentation/dto/request/CodeReviewRequest.java @@ -6,5 +6,6 @@ public record CodeReviewRequest( String language, String problemNumber, String platform, - String problemTitle) { + String problemTitle, + Boolean force) { } diff --git a/backend/src/main/java/com/ssafy/dash/common/exception/ErrorCode.java b/backend/src/main/java/com/ssafy/dash/common/exception/ErrorCode.java index 92f75a0f..adc580f1 100644 --- a/backend/src/main/java/com/ssafy/dash/common/exception/ErrorCode.java +++ b/backend/src/main/java/com/ssafy/dash/common/exception/ErrorCode.java @@ -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", "예상치 못한 오류가 발생했습니다."); @@ -36,5 +38,5 @@ public String getCode() { public String getMessage() { return message; } - + } diff --git a/backend/src/main/java/com/ssafy/dash/github/application/GitHubAppService.java b/backend/src/main/java/com/ssafy/dash/github/application/GitHubAppService.java new file mode 100644 index 00000000..5c78a633 --- /dev/null +++ b/backend/src/main/java/com/ssafy/dash/github/application/GitHubAppService.java @@ -0,0 +1,169 @@ +package com.ssafy.dash.github.application; + +import com.ssafy.dash.github.config.GitHubAppProperties; +import io.jsonwebtoken.Jwts; +import org.springframework.core.io.ResourceLoader; +import org.springframework.stereotype.Service; +import org.springframework.util.FileCopyUtils; + +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.Reader; +import java.nio.charset.StandardCharsets; +import java.security.KeyFactory; +import java.security.NoSuchAlgorithmException; +import java.security.PrivateKey; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.PKCS8EncodedKeySpec; +import java.time.Instant; +import java.util.Base64; + +import java.util.Date; +import java.util.Map; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; + +import org.springframework.http.ResponseEntity; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.RestTemplate; + +@Service +public class GitHubAppService { + + private static final Logger log = LoggerFactory.getLogger(GitHubAppService.class); + + private final GitHubAppProperties properties; + private final ResourceLoader resourceLoader; + private final RestTemplate restTemplate; + + public GitHubAppService(GitHubAppProperties properties, ResourceLoader resourceLoader, RestTemplate restTemplate) { + this.properties = properties; + this.resourceLoader = resourceLoader; + this.restTemplate = restTemplate; + } + + public String generateJwt() { + try { + long now = Instant.now().getEpochSecond(); + // GitHub 서버와의 시계 오차로 인한 인증 실패를 방지하기 위해 발행 시간을 30초 앞당겨 설정 + long iat = now - 30; + // JWT 유효기간: 10분 + long exp = now + (10 * 60); + + return Jwts.builder() + .issuedAt(new Date(iat * 1000)) + .expiration(new Date(exp * 1000)) + .issuer(properties.getId()) + .signWith(loadPrivateKey(), Jwts.SIG.RS256) + .compact(); + } catch (Exception e) { + throw new RuntimeException("Failed to generate GitHub App JWT", e); + } + } + + public String getInstallationAccessToken(long installationId) { + String jwt = generateJwt(); + + HttpHeaders headers = new HttpHeaders(); + headers.set("Authorization", "Bearer " + jwt); + headers.set("Accept", "application/vnd.github+json"); + + HttpEntity entity = new HttpEntity<>(headers); + + try { + ResponseEntity> response = restTemplate.exchange( + "https://api.github.com/app/installations/" + installationId + "/access_tokens", + HttpMethod.POST, + entity, + new org.springframework.core.ParameterizedTypeReference>() { + }); + + return (String) response.getBody().get("token"); + } catch (Exception e) { + throw new RuntimeException("Failed to retrieve installation access token", e); + } + } + + public boolean isAppInstalledOnRepo(String fullName) { + String jwt = generateJwt(); + + HttpHeaders headers = new HttpHeaders(); + headers.set("Authorization", "Bearer " + jwt); + headers.set("Accept", "application/vnd.github+json"); + headers.set("User-Agent", "DashHub-App"); + + HttpEntity entity = new HttpEntity<>(headers); + + try { + log.debug("Checking GitHub App installation for repo: {} using App ID: {}", fullName, properties.getId()); + ResponseEntity> response = restTemplate.exchange( + "https://api.github.com/repos/" + fullName + "/installation", + HttpMethod.GET, + entity, + new org.springframework.core.ParameterizedTypeReference>() { + }); + + log.info("GitHub App installation check for {}: Status {}", fullName, response.getStatusCode()); + return response.getStatusCode().is2xxSuccessful(); + } catch (HttpClientErrorException.NotFound e) { + log.warn("GitHub App (ID: {}) is not installed on repository: {}", properties.getId(), fullName); + return false; + } catch (HttpClientErrorException e) { + log.error("GitHub API error checking installation: {} - {}", e.getStatusCode(), + e.getResponseBodyAsString()); + return false; + } catch (Exception e) { + log.error("Unexpected error checking GitHub App installation for repository: {}", fullName, e); + throw new RuntimeException("Failed to check GitHub App installation", e); + } + } + + private PrivateKey loadPrivateKey() throws IOException, NoSuchAlgorithmException, InvalidKeySpecException { + String privateKeyContent = loadPrivateKeyContent(); + + // PEM 헤더/푸터 제거 및 줄바꿈 제거 + String privateKeyPEM = privateKeyContent + .replace("-----BEGIN RSA PRIVATE KEY-----", "") + .replace("-----END RSA PRIVATE KEY-----", "") + .replace("-----BEGIN PRIVATE KEY-----", "") + .replace("-----END PRIVATE KEY-----", "") + .replaceAll("\\s", ""); + + byte[] encoded = Base64.getDecoder().decode(privateKeyPEM); + + // PKCS#1 형식(BEGIN RSA PRIVATE KEY)인 경우 PKCS#8로 변환 필요 + if (privateKeyContent.contains("RSA PRIVATE KEY")) { + encoded = convertPkcs1ToPkcs8(encoded); + } + + KeyFactory keyFactory = KeyFactory.getInstance("RSA"); + return keyFactory.generatePrivate(new PKCS8EncodedKeySpec(encoded)); + } + + private byte[] convertPkcs1ToPkcs8(byte[] pkcs1Bytes) { + int pkcs1Length = pkcs1Bytes.length; + int totalLength = pkcs1Length + 22; + byte[] pkcs8Header = new byte[] { + 0x30, (byte) 0x82, (byte) ((totalLength >> 8) & 0xff), (byte) (totalLength & 0xff), // Sequence + 0x02, 0x01, 0x00, // Version + 0x30, 0x0d, 0x06, 0x09, 0x2a, (byte) 0x86, 0x48, (byte) 0xf6, (byte) 0xf7, 0x0d, 0x01, 0x01, 0x01, 0x05, + 0x00, // Algorithm (RSA) + 0x04, (byte) 0x82, (byte) ((pkcs1Length >> 8) & 0xff), (byte) (pkcs1Length & 0xff) // Octet String + }; + byte[] pkcs8Bytes = new byte[pkcs8Header.length + pkcs1Bytes.length]; + System.arraycopy(pkcs8Header, 0, pkcs8Bytes, 0, pkcs8Header.length); + System.arraycopy(pkcs1Bytes, 0, pkcs8Bytes, pkcs8Header.length, pkcs1Bytes.length); + return pkcs8Bytes; + } + + private String loadPrivateKeyContent() throws IOException { + try (Reader reader = new InputStreamReader( + resourceLoader.getResource(properties.getPrivateKeyPath()).getInputStream(), + StandardCharsets.UTF_8)) { + return FileCopyUtils.copyToString(reader); + } + } +} diff --git a/backend/src/main/java/com/ssafy/dash/github/application/GitHubPushEventWorker.java b/backend/src/main/java/com/ssafy/dash/github/application/GitHubPushEventWorker.java index f72f4db1..d95926db 100644 --- a/backend/src/main/java/com/ssafy/dash/github/application/GitHubPushEventWorker.java +++ b/backend/src/main/java/com/ssafy/dash/github/application/GitHubPushEventWorker.java @@ -12,8 +12,6 @@ import com.ssafy.dash.github.domain.exception.GitHubClientException; import com.ssafy.dash.github.domain.exception.GitHubFileDownloadException; import com.ssafy.dash.github.domain.exception.GitHubWebhookException; -import com.ssafy.dash.oauth.application.OAuthTokenService; -import com.ssafy.dash.oauth.domain.UserOAuthToken; import com.ssafy.dash.onboarding.domain.Onboarding; import com.ssafy.dash.onboarding.domain.OnboardingRepository; import com.ssafy.dash.user.domain.User; @@ -22,7 +20,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; @@ -50,7 +47,6 @@ public class GitHubPushEventWorker { private final GitHubPushEventRepository pushEventRepository; private final OnboardingRepository onboardingRepository; - private final OAuthTokenService oauthTokenService; private final GitHubClient gitHubClient; private final AlgorithmRecordRepository algorithmRecordRepository; private final ObjectMapper objectMapper; @@ -59,16 +55,15 @@ 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; private final BattleService battleService; private final StudyService studyService; + private final GitHubAppService gitHubAppService; public GitHubPushEventWorker(GitHubPushEventRepository pushEventRepository, OnboardingRepository onboardingRepository, - OAuthTokenService oauthTokenService, GitHubClient gitHubClient, AlgorithmRecordRepository algorithmRecordRepository, ObjectMapper objectMapper, @@ -76,16 +71,15 @@ public GitHubPushEventWorker(GitHubPushEventRepository pushEventRepository, PlatformTransactionManager transactionManager, UserRepository userRepository, AcornService acornService, - CodeReviewService codeReviewService, StudyMissionService studyMissionService, MockExamService mockExamService, DefenseService defenseService, BattleService battleService, StudyService studyService, + GitHubAppService gitHubAppService, @Value("${github.push-worker.max-batch:5}") int maxBatchSize) { this.pushEventRepository = pushEventRepository; this.onboardingRepository = onboardingRepository; - this.oauthTokenService = oauthTokenService; this.gitHubClient = gitHubClient; this.algorithmRecordRepository = algorithmRecordRepository; this.objectMapper = objectMapper; @@ -94,12 +88,12 @@ 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; this.battleService = battleService; this.studyService = studyService; + this.gitHubAppService = gitHubAppService; } @Scheduled(fixedDelayString = "${github.push-worker.fixed-delay:10000}") @@ -110,25 +104,7 @@ public void run() { return; } GitHubPushEvent event = optional.get(); - // 트랜잭션 내에서 레코드 저장 후, 생성된 레코드 목록 반환 - List 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)); } } @@ -147,7 +123,7 @@ List processEvent(GitHubPushEvent event) { Onboarding onboarding = onboardingRepository.findByRepositoryName(event.getRepositoryName()) .orElseThrow(() -> new GitHubWebhookException("해당 저장소의 온보딩 정보가 없습니다.")); - UserOAuthToken token = oauthTokenService.requireValidAccessToken(onboarding.getUserId()); + String accessToken = resolveAccessToken(event, onboarding.getUserId()); List files = readFiles(event.getFilesJson()); boolean processed = false; @@ -155,7 +131,7 @@ List processEvent(GitHubPushEvent event) { if (!file.isProcessable()) { continue; } - AlgorithmRecord record = storeAlgorithmRecord(event, onboarding.getUserId(), token.getAccessToken(), + AlgorithmRecord record = storeAlgorithmRecord(event, onboarding.getUserId(), accessToken, file); createdRecords.add(record); processed = true; @@ -195,6 +171,36 @@ private List readFiles(String filesJson) { } } + private String resolveAccessToken(GitHubPushEvent event, Long userId) { + if (StringUtils.hasText(event.getRawPayload())) { + try { + Long installationId = parseInstallationId(event.getRawPayload()); + if (installationId != null) { + return gitHubAppService.getInstallationAccessToken(installationId); + } + } catch (Exception e) { + log.warn("Failed to parse installation ID from raw payload for event: {}", event.getDeliveryId(), e); + } + } else { + log.warn("No raw payload found for event: {}", event.getDeliveryId()); + } + + throw new GitHubWebhookException( + "GitHub App 권한이 필요합니다. 저장소에 DashHub-App(또는 local APP)을 설치하고 권한을 부여한 후 다시 시도해주세요."); + } + + private Long parseInstallationId(String rawPayload) { + try { + var node = objectMapper.readTree(rawPayload); + if (node.has("installation") && node.get("installation").has("id")) { + return node.get("installation").get("id").asLong(); + } + } catch (JsonProcessingException e) { + log.warn("Error parsing raw payload for installation ID", e); + } + return null; + } + private AlgorithmRecord storeAlgorithmRecord(GitHubPushEvent event, Long userId, String accessToken, diff --git a/backend/src/main/java/com/ssafy/dash/github/config/GitHubAppProperties.java b/backend/src/main/java/com/ssafy/dash/github/config/GitHubAppProperties.java new file mode 100644 index 00000000..8661af33 --- /dev/null +++ b/backend/src/main/java/com/ssafy/dash/github/config/GitHubAppProperties.java @@ -0,0 +1,25 @@ +package com.ssafy.dash.github.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.bind.ConstructorBinding; + +@ConfigurationProperties(prefix = "github.app") +public class GitHubAppProperties { + + private final String id; + private final String privateKeyPath; + + @ConstructorBinding + public GitHubAppProperties(String id, String privateKeyPath) { + this.id = id; + this.privateKeyPath = privateKeyPath; + } + + public String getId() { + return id; + } + + public String getPrivateKeyPath() { + return privateKeyPath; + } +} diff --git a/backend/src/main/java/com/ssafy/dash/github/config/GitHubConfig.java b/backend/src/main/java/com/ssafy/dash/github/config/GitHubConfig.java new file mode 100644 index 00000000..4a12b914 --- /dev/null +++ b/backend/src/main/java/com/ssafy/dash/github/config/GitHubConfig.java @@ -0,0 +1,16 @@ +package com.ssafy.dash.github.config; + +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.client.RestTemplate; + +@Configuration +@EnableConfigurationProperties({ GitHubWebhookProperties.class, GitHubAppProperties.class }) +public class GitHubConfig { + + @Bean + public RestTemplate restTemplate() { + return new RestTemplate(); + } +} diff --git a/backend/src/main/java/com/ssafy/dash/github/config/GitHubWebhookProperties.java b/backend/src/main/java/com/ssafy/dash/github/config/GitHubWebhookProperties.java index 6714f0d9..0919fe91 100644 --- a/backend/src/main/java/com/ssafy/dash/github/config/GitHubWebhookProperties.java +++ b/backend/src/main/java/com/ssafy/dash/github/config/GitHubWebhookProperties.java @@ -3,11 +3,9 @@ import lombok.Getter; import lombok.Setter; import org.springframework.boot.context.properties.ConfigurationProperties; -import org.springframework.stereotype.Component; @Setter @Getter -@Component @ConfigurationProperties(prefix = "github.webhook") public class GitHubWebhookProperties { diff --git a/backend/src/main/java/com/ssafy/dash/github/infrastructure/client/GitHubClientImpl.java b/backend/src/main/java/com/ssafy/dash/github/infrastructure/client/GitHubClientImpl.java index bd606fa5..9e56dccc 100644 --- a/backend/src/main/java/com/ssafy/dash/github/infrastructure/client/GitHubClientImpl.java +++ b/backend/src/main/java/com/ssafy/dash/github/infrastructure/client/GitHubClientImpl.java @@ -110,8 +110,9 @@ public String fetchFileContent(String repositoryFullName, String filePath, Strin validateAccessToken(accessToken); HttpHeaders headers = new HttpHeaders(); - headers.setBearerAuth(accessToken); headers.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON)); + headers.setBearerAuth(accessToken); + HttpEntity requestEntity = new HttpEntity<>(headers); String ref = StringUtils.hasText(reference) ? reference : "main"; @@ -190,7 +191,7 @@ private void validateConfiguration(String accessToken) { private void validateAccessToken(String accessToken) { if (!StringUtils.hasText(accessToken)) { - throw new GitHubWebhookException("GitHub 액세스 토큰이 존재하지 않습니다. 다시 로그인해주세요."); + throw new GitHubWebhookException("GitHub 인증 토큰이 존재하지 않거나 만료되었습니다. 인증 상태를 확인해주세요."); } } diff --git a/backend/src/main/java/com/ssafy/dash/onboarding/application/OnboardingService.java b/backend/src/main/java/com/ssafy/dash/onboarding/application/OnboardingService.java index 527fb0c0..77891bbe 100644 --- a/backend/src/main/java/com/ssafy/dash/onboarding/application/OnboardingService.java +++ b/backend/src/main/java/com/ssafy/dash/onboarding/application/OnboardingService.java @@ -1,15 +1,11 @@ package com.ssafy.dash.onboarding.application; -import com.ssafy.dash.github.application.GitHubWebhookService; -import com.ssafy.dash.github.domain.exception.GitHubWebhookException; -import com.ssafy.dash.oauth.application.OAuthTokenService; -import com.ssafy.dash.oauth.domain.UserOAuthToken; -import com.ssafy.dash.oauth.domain.exception.OAuthAccessTokenUnavailableException; import com.ssafy.dash.onboarding.application.dto.command.RepositorySetupCommand; import com.ssafy.dash.onboarding.application.dto.result.RepositorySetupResult; import com.ssafy.dash.onboarding.domain.Onboarding; import com.ssafy.dash.onboarding.domain.OnboardingRepository; -import com.ssafy.dash.onboarding.domain.exception.WebhookRegistrationException; +import com.ssafy.dash.onboarding.domain.exception.GitHubAppNotInstalledException; +import com.ssafy.dash.github.application.GitHubAppService; import com.ssafy.dash.study.application.StudyService; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -20,21 +16,25 @@ public class OnboardingService { private final OnboardingRepository onboardingRepository; - private final GitHubWebhookService gitHubWebhookService; - private final OAuthTokenService oauthTokenService; private final StudyService studyService; + private final GitHubAppService gitHubAppService; - public OnboardingService(OnboardingRepository onboardingRepository, GitHubWebhookService gitHubWebhookService, - OAuthTokenService oauthTokenService, StudyService studyService) { + public OnboardingService(OnboardingRepository onboardingRepository, StudyService studyService, + GitHubAppService gitHubAppService) { this.onboardingRepository = onboardingRepository; - this.gitHubWebhookService = gitHubWebhookService; - this.oauthTokenService = oauthTokenService; this.studyService = studyService; + this.gitHubAppService = gitHubAppService; } @Transactional public RepositorySetupResult setupRepository(RepositorySetupCommand command) { String repositoryName = command.repositoryName().trim(); + + // GitHub App 설치 여부 사전 검증 + if (!gitHubAppService.isAppInstalledOnRepo(repositoryName)) { + throw new GitHubAppNotInstalledException(repositoryName); + } + Long userId = command.userId(); Onboarding repository = onboardingRepository.findByUserId(userId) .map(existing -> { @@ -44,22 +44,10 @@ public RepositorySetupResult setupRepository(RepositorySetupCommand command) { .orElseGet(() -> Onboarding.create(userId, repositoryName, LocalDateTime.now())); onboardingRepository.save(repository); - UserOAuthToken oauthToken; - try { - oauthToken = oauthTokenService.requireValidAccessToken(userId); - } catch (OAuthAccessTokenUnavailableException ex) { - throw new WebhookRegistrationException(ex.getMessage(), ex); - } - - try { - gitHubWebhookService.ensureWebhook(repositoryName, oauthToken.getAccessToken()); - repository.markWebhookConfigured(true, LocalDateTime.now()); - onboardingRepository.save(repository); - } catch (GitHubWebhookException ex) { - repository.markWebhookConfigured(false, LocalDateTime.now()); - onboardingRepository.save(repository); - throw new WebhookRegistrationException(ex.getMessage(), ex); - } + // GitHub App 레벨에서 웹훅이 관리되므로 리포지토리별로 별도의 웹훅 등록 API 호출이 필요하지 않음. + // 앱이 리포지토리에 성공적으로 설치되었음을 확인했으므로, 웹훅 설정을 완료된 것으로 표시함. + repository.markWebhookConfigured(true, LocalDateTime.now()); + onboardingRepository.save(repository); // Auto-create Personal Study (Personal Lab) for the user studyService.createPersonalStudy(userId); diff --git a/backend/src/main/java/com/ssafy/dash/onboarding/domain/exception/GitHubAppNotInstalledException.java b/backend/src/main/java/com/ssafy/dash/onboarding/domain/exception/GitHubAppNotInstalledException.java new file mode 100644 index 00000000..8a070b9a --- /dev/null +++ b/backend/src/main/java/com/ssafy/dash/onboarding/domain/exception/GitHubAppNotInstalledException.java @@ -0,0 +1,10 @@ +package com.ssafy.dash.onboarding.domain.exception; + +import com.ssafy.dash.common.exception.BusinessException; +import com.ssafy.dash.common.exception.ErrorCode; + +public class GitHubAppNotInstalledException extends BusinessException { + public GitHubAppNotInstalledException(String repositoryName) { + super(ErrorCode.GITHUB_APP_NOT_INSTALLED, "GitHub App is not installed on repository: " + repositoryName); + } +} diff --git a/backend/src/main/java/com/ssafy/dash/onboarding/presentation/OnboardingController.java b/backend/src/main/java/com/ssafy/dash/onboarding/presentation/OnboardingController.java index 8214c2c7..1bfab55d 100644 --- a/backend/src/main/java/com/ssafy/dash/onboarding/presentation/OnboardingController.java +++ b/backend/src/main/java/com/ssafy/dash/onboarding/presentation/OnboardingController.java @@ -35,6 +35,7 @@ import io.swagger.v3.oas.annotations.Parameter; import com.ssafy.dash.analytics.application.SolvedacSyncService; import com.ssafy.dash.analytics.application.dto.request.RegisterHandleRequest; +import com.ssafy.dash.github.application.GitHubAppService; @RestController @RequestMapping("/api/onboarding") @@ -45,14 +46,17 @@ public class OnboardingController { private final SolvedacApiClient solvedacApiClient; private final GitHubClient gitHubClient; private final OAuthTokenService oAuthTokenService; + private final GitHubAppService gitHubAppService; public OnboardingController(OnboardingService onboardingService, SolvedacSyncService solvedacSyncService, - SolvedacApiClient solvedacApiClient, GitHubClient gitHubClient, OAuthTokenService oAuthTokenService) { + SolvedacApiClient solvedacApiClient, GitHubClient gitHubClient, OAuthTokenService oAuthTokenService, + GitHubAppService gitHubAppService) { this.onboardingService = onboardingService; this.solvedacSyncService = solvedacSyncService; this.solvedacApiClient = solvedacApiClient; this.gitHubClient = gitHubClient; this.oAuthTokenService = oAuthTokenService; + this.gitHubAppService = gitHubAppService; } @GetMapping("/solvedac/verify") @@ -119,4 +123,16 @@ public ResponseEntity setupRepository( return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); } + @GetMapping("/repository/check") + @Operation(summary = "GitHub App 설치 여부 확인", description = "특정 저장소에 GitHub App이 올바르게 설치되어 있는지 권한을 확인합니다.") + public ResponseEntity checkAppInstallation( + @Parameter(hidden = true) @AuthenticationPrincipal OAuth2User principal, + @RequestParam String fullName) { + if (principal instanceof CustomOAuth2User) { + boolean isInstalled = gitHubAppService.isAppInstalledOnRepo(fullName); + return ResponseEntity.ok(isInstalled); + } + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + } + } diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index a6b70c90..68c2b5e8 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -32,10 +32,13 @@ mybatis.mapper-locations=classpath:mapper/**/*.xml mybatis.type-aliases-package=com.ssafy.dash.**.domain,com.ssafy.dash.**.dto mybatis.configuration.map-underscore-to-camel-case=true -# OAuth2 Client Configuration +# OAuth2 Client Configuration (Updated for GitHub App) spring.security.oauth2.client.registration.github.client-id=${GITHUB_CLIENT_ID} spring.security.oauth2.client.registration.github.client-secret=${GITHUB_CLIENT_SECRET} -spring.security.oauth2.client.registration.github.scope=read:user,user:email,repo,admin:repo_hook +spring.security.oauth2.client.registration.github.scope=read:user,user:email +# GitHub App Configuration +github.app.id=${GITHUB_APP_ID} +github.app.private-key-path=file:///github-app.pem # GitHub Webhook Configuration github.webhook.events=push diff --git a/docker-compose.yml b/docker-compose.yml index 3778be37..e05eda5e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -38,6 +38,10 @@ services: GITHUB_WEBHOOK_SECRET: ${GITHUB_WEBHOOK_SECRET} GITHUB_WEBHOOK_URL: ${GITHUB_WEBHOOK_URL} YOUTUBE_API_KEY: ${YOUTUBE_API_KEY} + GITHUB_APP_ID: ${GITHUB_APP_ID} + GITHUB_APP_PRIVATE_KEY_PATH: file:///github-app.pem + volumes: + - ./github-app.pem:/github-app.pem depends_on: - mysql - ai @@ -83,6 +87,8 @@ services: image: ghcr.io/${GITHUB_REPOSITORY}/frontend:latest container_name: dash-frontend restart: always + environment: + VITE_GITHUB_APP_NAME: ${VITE_GITHUB_APP_NAME} ports: - "80:80" depends_on: diff --git a/frontend/src/api/onboarding.js b/frontend/src/api/onboarding.js index 8c1bdc92..82ad917b 100644 --- a/frontend/src/api/onboarding.js +++ b/frontend/src/api/onboarding.js @@ -21,4 +21,8 @@ export const onboardingApi = { registerSolvedacRaw(handle) { return http.post('/onboarding/solvedac', { handle }); }, + // Check if GitHub App is installed for a repository + checkAppInstallation(fullName) { + return http.get(`/onboarding/repository/check?fullName=${encodeURIComponent(fullName)}`); + } }; diff --git a/frontend/src/views/dashboard/AnalysisSidebar.vue b/frontend/src/views/dashboard/AnalysisSidebar.vue index 0e9c7b13..4a7034d2 100644 --- a/frontend/src/views/dashboard/AnalysisSidebar.vue +++ b/frontend/src/views/dashboard/AnalysisSidebar.vue @@ -4,8 +4,8 @@
-

분석할 기록을 선택해주세요

-

타임라인에서 항목을 클릭하면
상세 분석이 여기에 표시됩니다.

+

분석할 기록을 선택해 주세요

+

오른쪽 카드에서 항목을 클릭하면
상세 분석을 바로 확인할 수 있습니다.