diff --git a/src/main/java/com/teamEWSN/gitdeun/Recruitment/entity/Recruitment.java b/src/main/java/com/teamEWSN/gitdeun/Recruitment/entity/Recruitment.java index 7888b4e..207d874 100644 --- a/src/main/java/com/teamEWSN/gitdeun/Recruitment/entity/Recruitment.java +++ b/src/main/java/com/teamEWSN/gitdeun/Recruitment/entity/Recruitment.java @@ -65,7 +65,7 @@ public class Recruitment extends AuditedEntity { @Column(name = "team_size_total", nullable = false) private Integer teamSizeTotal; - // 남은 모집 인원(입력 필요) – 신청 시 1 감소, 철회/거절 시 1 증가(복원) + // 남은 모집 인원(입력 필요) – 신청 시 1 감소 @Column(name = "recruit_quota", nullable = false) private Integer recruitQuota; diff --git a/src/main/java/com/teamEWSN/gitdeun/Recruitment/repository/RecruitmentRepositoryImpl.java b/src/main/java/com/teamEWSN/gitdeun/Recruitment/repository/RecruitmentRepositoryImpl.java index 35752b3..71c5d84 100644 --- a/src/main/java/com/teamEWSN/gitdeun/Recruitment/repository/RecruitmentRepositoryImpl.java +++ b/src/main/java/com/teamEWSN/gitdeun/Recruitment/repository/RecruitmentRepositoryImpl.java @@ -3,6 +3,7 @@ import com.querydsl.core.types.OrderSpecifier; import com.querydsl.core.types.dsl.BooleanExpression; import com.querydsl.core.types.dsl.Expressions; +import com.querydsl.core.types.dsl.NumberExpression; import com.querydsl.jpa.impl.JPAQueryFactory; import com.teamEWSN.gitdeun.Recruitment.entity.Recruitment; import com.teamEWSN.gitdeun.Recruitment.entity.RecruitmentField; @@ -119,11 +120,12 @@ private boolean isFullTextSearchAvailable(String keyword) { /** * MySQL Full-Text Search를 사용한 검색 * MATCH ... AGAINST 구문 활용 + * WHERE 절: 점수 > 0 판단식 */ private BooleanExpression titleFullTextSearch(String keyword) { - String booleanQuery = buildBooleanQuery(keyword); + String booleanQuery = buildBooleanQuery(keyword); // 기존 전처리/토크나이저 사용 return Expressions.booleanTemplate( - "MATCH({0}, {1}) AGAINST ({2} IN BOOLEAN MODE)", + "function('match_against_boolean', {0}, {1}, {2}) > 0", recruitment.title, recruitment.content, booleanQuery ); } @@ -137,10 +139,10 @@ private BooleanExpression fallbackContains(String keyword) { .or(recruitment.content.containsIgnoreCase(k)); } - // BOOLEAN MODE 정렬식 + // ORDER BY 절: 점수 desc private OrderSpecifier scoreOrder(String keyword) { - String tpl = "MATCH({0}, {1}) AGAINST ({2} IN BOOLEAN MODE)"; - return Expressions.numberTemplate(Double.class, tpl, + return Expressions.numberTemplate(Double.class, + "function('match_against_boolean', {0}, {1}, {2})", recruitment.title, recruitment.content, buildBooleanQuery(keyword)) .desc(); } diff --git a/src/main/java/com/teamEWSN/gitdeun/Recruitment/service/RecruitmentService.java b/src/main/java/com/teamEWSN/gitdeun/Recruitment/service/RecruitmentService.java index 16a0c12..c8f816f 100644 --- a/src/main/java/com/teamEWSN/gitdeun/Recruitment/service/RecruitmentService.java +++ b/src/main/java/com/teamEWSN/gitdeun/Recruitment/service/RecruitmentService.java @@ -23,6 +23,7 @@ import org.springframework.web.multipart.MultipartFile; import java.time.LocalDateTime; +import java.time.LocalTime; import java.util.*; import java.util.stream.Collectors; @@ -47,10 +48,14 @@ public RecruitmentDetailResponseDto createRecruitment(Long userId, RecruitmentCr User recruiter = userRepository.findById(userId) .orElseThrow(() -> new GlobalException(ErrorCode.USER_NOT_FOUND_BY_ID)); - validateRecruitmentDates(requestDto.getStartAt(), requestDto.getEndAt()); + // 마감일을 해당 날짜의 23:59:59로 설정 + LocalDateTime adjustedEndAt = requestDto.getEndAt().with(LocalTime.of(23, 59, 59)); + + validateRecruitmentDates(requestDto.getStartAt(), adjustedEndAt); Recruitment recruitment = recruitmentMapper.toEntity(requestDto); recruitment.setRecruiter(recruiter); + recruitment.setEndAt(adjustedEndAt); RecruitmentStatus initialStatus = requestDto.getStartAt().isAfter(LocalDateTime.now()) ? RecruitmentStatus.FORTHCOMING : RecruitmentStatus.RECRUITING; diff --git a/src/main/java/com/teamEWSN/gitdeun/common/config/AsyncTransactionConfig.java b/src/main/java/com/teamEWSN/gitdeun/common/config/AsyncTransactionConfig.java new file mode 100644 index 0000000..832fe12 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/config/AsyncTransactionConfig.java @@ -0,0 +1,184 @@ +package com.teamEWSN.gitdeun.common.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.aop.interceptor.AsyncUncaughtExceptionHandler; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.AsyncConfigurer; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; +import org.springframework.transaction.annotation.EnableTransactionManagement; + +import java.lang.reflect.Method; +import java.util.concurrent.Executor; +import java.util.concurrent.ThreadPoolExecutor; + +/** + * 비동기 처리 및 트랜잭션 설정 + * + * 마치 대형 공장의 생산라인을 설계하는 것과 같습니다. + * - ThreadPool: 작업자들의 팀 (적절한 인원수로 효율성 극대화) + * - Transaction: 품질 검사 체크포인트 (문제 발생시 이전 단계로 롤백) + * - Exception Handler: 비상 대응팀 (예외 상황 발생시 적절한 조치) + */ +@Slf4j +@Configuration +@EnableAsync +@EnableTransactionManagement +public class AsyncTransactionConfig implements AsyncConfigurer { + + @Value("${app.async.core-pool-size:10}") + private int corePoolSize; + + @Value("${app.async.max-pool-size:50}") + private int maxPoolSize; + + @Value("${app.async.queue-capacity:100}") + private int queueCapacity; + + @Value("${app.async.keep-alive-seconds:60}") + private int keepAliveSeconds; + + /** + * 마인드맵 전용 비동기 실행자 + * 특징: + * - 코어 풀 크기: 10개 (항상 활성 상태의 스레드) + * - 최대 풀 크기: 50개 (피크 시간 대응) + * - 큐 용량: 100개 (대기 중인 작업 수) + * - 거부 정책: CallerRuns (호출자 스레드에서 직접 실행) + */ + @Bean(name = "mindmapExecutor") + public Executor mindmapTaskExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + + executor.setCorePoolSize(corePoolSize); + executor.setMaxPoolSize(maxPoolSize); + executor.setQueueCapacity(queueCapacity); + executor.setKeepAliveSeconds(keepAliveSeconds); + executor.setThreadNamePrefix("Mindmap-Async-"); + + // 거부 정책: 큐가 가득 찰 때 호출자 스레드에서 실행 + executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); + + // 스레드 풀 초기화 대기 + executor.setWaitForTasksToCompleteOnShutdown(true); + executor.setAwaitTerminationSeconds(30); + + executor.initialize(); + + log.info("마인드맵 비동기 실행자 초기화 완료 - 코어: {}, 최대: {}, 큐: {}", + corePoolSize, maxPoolSize, queueCapacity); + + return executor; + } + + /** + * 일반적인 비동기 작업용 실행자 + * + * 알림 전송, 로그 처리 등 가벼운 작업에 사용 + */ + @Bean(name = "generalExecutor") + public Executor generalTaskExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + + executor.setCorePoolSize(5); + executor.setMaxPoolSize(20); + executor.setQueueCapacity(50); + executor.setKeepAliveSeconds(30); + executor.setThreadNamePrefix("General-Async-"); + + executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); + executor.setWaitForTasksToCompleteOnShutdown(true); + executor.setAwaitTerminationSeconds(20); + + executor.initialize(); + + log.info("일반 비동기 실행자 초기화 완료"); + return executor; + } + + /** + * 기본 비동기 실행자 (AsyncConfigurer 인터페이스 구현) + */ + @Override + public Executor getAsyncExecutor() { + return mindmapTaskExecutor(); + } + + /** + * 비동기 예외 처리기 + * + * 비유: 공장의 안전 관리자 + * 작업 중 예외가 발생하면 즉시 파악하고 기록하여 + * 향후 개선점을 도출할 수 있도록 합니다. + * + * 실제 예시: + * GitHub API 호출 실패, 메모리 부족, 네트워크 타임아웃 등 + * 다양한 예외 상황을 적절히 로깅하고 모니터링합니다. + */ + @Override + public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() { + return new CustomAsyncExceptionHandler(); + } + + /** + * 커스텀 비동기 예외 처리기 + */ + private static class CustomAsyncExceptionHandler implements AsyncUncaughtExceptionHandler { + + @Override + public void handleUncaughtException(Throwable ex, Method method, Object... params) { + String methodName = method.getDeclaringClass().getSimpleName() + "." + method.getName(); + + log.error("비동기 작업 예외 발생 - 메서드: {}, 매개변수: {}", + methodName, formatParams(params), ex); + + // 중요한 예외의 경우 추가 알림 처리 + if (isCriticalException(ex)) { + handleCriticalException(ex, methodName, params); + } + } + + private String formatParams(Object... params) { + if (params == null || params.length == 0) { + return "없음"; + } + + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < params.length; i++) { + if (i > 0) sb.append(", "); + + Object param = params[i]; + if (param == null) { + sb.append("null"); + } else { + // 민감 정보 마스킹 + String paramStr = param.toString(); + if (paramStr.contains("password") || paramStr.contains("token")) { + sb.append("[MASKED]"); + } else { + sb.append(paramStr.length() > 100 ? + paramStr.substring(0, 100) + "..." : paramStr); + } + } + } + return sb.toString(); + } + + private boolean isCriticalException(Throwable ex) { + return ex instanceof OutOfMemoryError || + ex instanceof StackOverflowError || + (ex.getMessage() != null && ex.getMessage().contains("database")); + } + + private void handleCriticalException(Throwable ex, String methodName, Object... params) { + // 치명적 문제 발생 알림 + log.error("⚠️ 치명적 예외 발생 - 즉시 대응 필요: {} in {}", ex.getMessage(), methodName); + + + // TODO: 모니터링 시스템 연동 + } + } +} diff --git a/src/main/java/com/teamEWSN/gitdeun/common/config/MySqlFunctionContributor.java b/src/main/java/com/teamEWSN/gitdeun/common/config/MySqlFunctionContributor.java new file mode 100644 index 0000000..c9ad197 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/config/MySqlFunctionContributor.java @@ -0,0 +1,23 @@ +package com.teamEWSN.gitdeun.common.config; + +import org.hibernate.boot.model.FunctionContributions; +import org.hibernate.boot.model.FunctionContributor; +import org.hibernate.type.BasicType; +import org.hibernate.type.StandardBasicTypes; +import org.springframework.stereotype.Component; + +@Component +public class MySqlFunctionContributor implements FunctionContributor { + @Override + public void contributeFunctions(FunctionContributions fc) { + BasicType doubleType = + fc.getTypeConfiguration().getBasicTypeRegistry().resolve(StandardBasicTypes.DOUBLE); + + // match_against_boolean(title, content, query) -> MATCH(title, content) AGAINST (? IN BOOLEAN MODE) + fc.getFunctionRegistry().registerPattern( + "match_against_boolean", + "match(?1, ?2) against (?3 in boolean mode)", + doubleType + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/common/config/RestTemplateConfig.java b/src/main/java/com/teamEWSN/gitdeun/common/config/RestTemplateConfig.java deleted file mode 100644 index ef3bd56..0000000 --- a/src/main/java/com/teamEWSN/gitdeun/common/config/RestTemplateConfig.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.teamEWSN.gitdeun.common.config; - -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.web.client.RestTemplate; - -@Configuration -public class RestTemplateConfig { - @Bean - RestTemplate restTemplate() { - return new RestTemplate(); - } - -} diff --git a/src/main/java/com/teamEWSN/gitdeun/common/converter/IsoToLocalDateTimeDeserializer.java b/src/main/java/com/teamEWSN/gitdeun/common/converter/IsoToLocalDateTimeDeserializer.java new file mode 100644 index 0000000..b30761c --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/converter/IsoToLocalDateTimeDeserializer.java @@ -0,0 +1,28 @@ +package com.teamEWSN.gitdeun.common.converter; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.*; +import java.io.IOException; +import java.time.*; +import java.time.format.DateTimeParseException; + +public class IsoToLocalDateTimeDeserializer extends JsonDeserializer { + @Override + public LocalDateTime deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { + String s = p.getText(); + if (s == null || s.isBlank()) return null; + + // 1) 오프셋/Z 포함 → UTC LDT로 + try { + return OffsetDateTime.parse(s).atZoneSameInstant(ZoneOffset.UTC).toLocalDateTime(); + } catch (DateTimeParseException ignored) {} + + // 2) Instant 순수 파싱 시도 + try { + return Instant.parse(s).atZone(ZoneOffset.UTC).toLocalDateTime(); + } catch (DateTimeParseException ignored) {} + + // 3) 오프셋 없는 LocalDateTime 포맷(레거시) 방어 + return LocalDateTime.parse(s); + } +} diff --git a/src/main/java/com/teamEWSN/gitdeun/common/exception/ErrorCode.java b/src/main/java/com/teamEWSN/gitdeun/common/exception/ErrorCode.java index c91fe39..f6c4b6a 100644 --- a/src/main/java/com/teamEWSN/gitdeun/common/exception/ErrorCode.java +++ b/src/main/java/com/teamEWSN/gitdeun/common/exception/ErrorCode.java @@ -44,6 +44,21 @@ public enum ErrorCode { // 마인드맵 관련 MINDMAP_NOT_FOUND(HttpStatus.NOT_FOUND, "MINDMAP-001", "요청한 마인드맵을 찾을 수 없습니다."), + MINDMAP_CREATION_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "MINDMAP-002", "마인드맵 생성 중 오류가 발생했습니다."), + + // 마인드맵 요청 검증 관련 + INVALID_USER_ID(HttpStatus.BAD_REQUEST, "VALIDATE-001", "유효하지 않은 사용자 ID입니다."), + REPOSITORY_URL_REQUIRED(HttpStatus.BAD_REQUEST, "VALIDATE-002", "리포지토리 URL은 필수입니다."), + REPOSITORY_URL_TOO_LONG(HttpStatus.BAD_REQUEST, "VALIDATE-003", "리포지토리 URL이 너무 깁니다. (1000자 이내)"), + MALICIOUS_URL_DETECTED(HttpStatus.BAD_REQUEST, "VALIDATE-004", "악의적인 패턴이 포함된 URL입니다."), + UNSUPPORTED_REPOSITORY_URL(HttpStatus.BAD_REQUEST, "VALIDATE-005", "지원하지 않는 리포지토리 URL 형식입니다."), + INVALID_GITHUB_URL(HttpStatus.BAD_REQUEST, "VALIDATE-006", "유효하지 않은 GitHub URL입니다."), + INVALID_REPOSITORY_URL(HttpStatus.BAD_REQUEST, "VALIDATE-007", "유효하지 않은 리포지토리 URL입니다."), + PROMPT_TOO_LONG(HttpStatus.BAD_REQUEST, "VALIDATE-008", "프롬프트가 너무 깁니다. (2000자 이내)"), + PROMPT_TOO_SHORT(HttpStatus.BAD_REQUEST, "VALIDATE-009", "프롬프트가 너무 짧습니다. (3자 이상)"), + MALICIOUS_PROMPT_DETECTED(HttpStatus.BAD_REQUEST, "VALIDATE-010", "악의적인 패턴이 포함된 프롬프트입니다."), + FORBIDDEN_REPOSITORY_PATTERN(HttpStatus.FORBIDDEN, "VALIDATE-011", "분석이 금지된 리포지토리 패턴입니다. (예: test, demo)"), + SYSTEM_PROTECTED_REPOSITORY(HttpStatus.FORBIDDEN, "VALIDATE-012", "시스템 보호 대상으로 지정된 리포지토리입니다."), // 프롬프트 히스토리 관련 (신규 추가) PROMPT_HISTORY_NOT_FOUND(HttpStatus.NOT_FOUND, "PROMPT-001", "요청한 프롬프트 히스토리를 찾을 수 없습니다."), diff --git a/src/main/java/com/teamEWSN/gitdeun/common/fastapi/FastApiClient.java b/src/main/java/com/teamEWSN/gitdeun/common/fastapi/FastApiClient.java index 63fd935..2b36489 100644 --- a/src/main/java/com/teamEWSN/gitdeun/common/fastapi/FastApiClient.java +++ b/src/main/java/com/teamEWSN/gitdeun/common/fastapi/FastApiClient.java @@ -1,16 +1,24 @@ package com.teamEWSN.gitdeun.common.fastapi; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.teamEWSN.gitdeun.common.converter.IsoToLocalDateTimeDeserializer; import com.teamEWSN.gitdeun.common.fastapi.dto.AnalysisResultDto; import com.teamEWSN.gitdeun.common.fastapi.dto.MindmapGraphDto; -import lombok.AllArgsConstructor; import lombok.Getter; +import lombok.Setter; +import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; import org.springframework.web.reactive.function.client.WebClient; import reactor.core.publisher.Mono; import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.Map; +@Slf4j @Component public class FastApiClient { @@ -20,88 +28,166 @@ public FastApiClient(@Qualifier("fastApiWebClient") WebClient webClient) { this.webClient = webClient; } + /** - * GitHub 저장소의 최신 커밋 시간 조회 + * 통합 분석 프로세스 - 3단계로 구성 + * 1. GitHub 저장소 fetch + * 2. 기본 마인드맵 분석 + * 3. 그래프 데이터 조회 및 DTO 구성 */ - public LocalDateTime getRepositoryLastCommitTime(String repoUrl, String authorizationHeader) { - return webClient.get() - .uri(uriBuilder -> uriBuilder - .path("/github/repos/last-commit") - .queryParam("repo_url", repoUrl) - .build()) - .header("Authorization", authorizationHeader) - .retrieve() - .bodyToMono(RepositoryCommitInfo.class) - .map(RepositoryCommitInfo::getLastCommitTime) - .block(); + public AnalysisResultDto analyzeResult(String repoUrl, String prompt, String authorizationHeader) { + String mapId = extractMapId(repoUrl); + log.info("저장소 분석 시작 - mapId: {}", mapId); + + try { + // Step 1: GitHub 저장소를 ArangoDB에 저장 + FetchResponse fetchResult = fetchRepoInfo(repoUrl, authorizationHeader); + log.info("Fetch 완료 - 파일: {}, 파싱: {}", + fetchResult.getFiles_saved(), fetchResult.getFiles_parsed()); + + saveRepoInfo(mapId, authorizationHeader); + + // Step 2: 마인드맵 기본 분석 (AI) + AnalyzeResponse analyzeResult = analyzeAI(repoUrl, prompt, authorizationHeader); + log.info("AI 분석 완료 - 디렉터리: {}", analyzeResult.getDirs_analyzed()); + + // Step 3: 저장소 정보 조회 + RepoInfoResponse repoInfo = getRepoInfo(mapId, authorizationHeader); + + // 모든 데이터를 종합하여 DTO 생성 + return buildAnalysisResultDto(repoInfo); + + } catch (Exception e) { + log.error("저장소 분석 실패: {}", e.getMessage(), e); + return AnalysisResultDto.builder() + .errorMessage("분석 실패: " + e.getMessage()) + .build(); + } } /** - * FastAPI 서버에 리포지토리 프롬프트 기반 마인드맵 분석 + * 마인드맵 새로고침 - refresh-latest 엔드포인트 사용 */ - public AnalysisResultDto analyzeWithPrompt(String repoUrl, String prompt, String authorizationHeader) { - AnalysisRequest requestBody = new AnalysisRequest(repoUrl, prompt); + public AnalysisResultDto refreshMindmap(String repoUrl, String prompt, String authorizationHeader) { + String mapId = extractMapId(repoUrl); + log.info("마인드맵 새로고침 시작 - mapId: {}, 프롬프트 사용: {}", mapId, StringUtils.hasText(prompt)); + + try { + // Step 1: 최신 변경사항만 빠르게 새로고침 + RefreshResponse refreshResult = refreshLatest(mapId, prompt, authorizationHeader); + log.info("새로고침 완료 - 변경 파일: {}, 분석 디렉터리: {}", + refreshResult.getChanged_files(), refreshResult.getDirs_analyzed()); + + // Step 2: 저장소 정보 조회 + RepoInfoResponse repoInfo = getRepoInfo(mapId, authorizationHeader); + + // 새로고침 결과를 DTO로 변환 + return buildRefreshResultDto(repoInfo); + + } catch (Exception e) { + log.error("마인드맵 새로고침 실패: {}", e.getMessage(), e); + return AnalysisResultDto.builder() + .errorMessage("새로고침 실패: " + e.getMessage()) + .build(); + } + } + + // 저장소 파일 fetch + public FetchResponse fetchRepoInfo(String repoUrl, String authHeader) { + Map request = new HashMap<>(); + request.put("repo_url", repoUrl); + return webClient.post() - .uri("/mindmap/analyze-prompt") - .header("Authorization", authorizationHeader) - .body(Mono.just(requestBody), AnalysisRequest.class) + .uri("/github/repos/fetch") + .header("Authorization", authHeader) + .body(Mono.just(request), Map.class) .retrieve() - .bodyToMono(AnalysisResultDto.class) + .bodyToMono(FetchResponse.class) .block(); } + // 저장소 정보 저장 + public void saveRepoInfo(String repoUrl, String authHeader) { + Map request = new HashMap<>(); + request.put("repo_url", repoUrl); + request.put("mode", "DEV"); // 항상 DEV 모드 + + webClient.post() + .uri("/repo/github/repo-info") + .header("Authorization", authHeader) + .body(Mono.just(request), Map.class) + .retrieve() + .bodyToMono(Void.class) + .block(); + } + + // 저장소 정보 조회 + public RepoInfoResponse getRepoInfo(String mapId, String authHeader) { + try { + return webClient.get() + .uri("/repo/{mapId}/info", mapId) + .header("Authorization", authHeader) + .retrieve() + .bodyToMono(RepoInfoResponse.class) + .block(); + } catch (Exception e) { + log.warn("저장소 정보 조회 실패: {}, 기본값 사용", e.getMessage()); + // 조회 실패 시 기본값 반환 + RepoInfoResponse info = new RepoInfoResponse(); + info.setDefaultBranch("main"); + info.setLastCommit(LocalDateTime.now()); + return info; + } + } + /** - * 기본 마인드맵 분석 (프롬프트 X) + * 기본 마인드맵 분석 */ - public AnalysisResultDto analyzeDefault(String repoUrl, String authorizationHeader) { - AnalysisRequest requestBody = new AnalysisRequest(repoUrl, null); + public AnalyzeResponse analyzeAI(String repoUrl, String prompt, String authHeader) { + Map request = new HashMap<>(); + request.put("repo_url", repoUrl); + + // 프롬프트가 있으면 추가, 없으면 기본 분석 + if (StringUtils.hasText(prompt)) { + request.put("prompt", prompt); + } + return webClient.post() .uri("/mindmap/analyze-ai") - .header("Authorization", authorizationHeader) - .body(Mono.just(requestBody), AnalysisRequest.class) + .header("Authorization", authHeader) + .body(Mono.just(request), Map.class) .retrieve() - .bodyToMono(AnalysisResultDto.class) + .bodyToMono(AnalyzeResponse.class) .block(); } /** * ArangoDB에서 마인드맵 그래프 데이터를 조회 */ - public MindmapGraphDto getMindmapGraph(String repoUrl, String authorizationHeader) { + public MindmapGraphDto getGraph(String mapId, String authHeader) { return webClient.get() - .uri(uriBuilder -> uriBuilder - .path("/mindmap/graph") - .queryParam("repo_url", repoUrl) - .build()) - .header("Authorization", authorizationHeader) + .uri("/mindmap/{mapId}/graph", mapId) + .header("Authorization", authHeader) .retrieve() .bodyToMono(MindmapGraphDto.class) .block(); } - /** - * GitHub 저장소 정보 저장을 요청 - */ - public void saveRepoInfo(String repoUrl, String authorizationHeader) { - webClient.post() - .uri("/repo/github/repo-info") - .header("Authorization", authorizationHeader) - .body(Mono.just(new GitRepoRequest(repoUrl)), GitRepoRequest.class) - .retrieve() - .bodyToMono(Void.class) - .block(); - } + // 최신 변경사항 새로고침 + public RefreshResponse refreshLatest(String mapId, String prompt, String authHeader) { + Map request = new HashMap<>(); + if (StringUtils.hasText(prompt)) { + request.put("prompt", prompt); + } + request.put("max_dirs", 10); + request.put("max_files_per_dir", 5); - /** - * GitHub ZIP을 ArangoDB에 저장을 요청 - */ - public void fetchRepo(String repoUrl, String authorizationHeader) { - webClient.post() - .uri("/github/repos/fetch") - .header("Authorization", authorizationHeader) - .body(Mono.just(new GitRepoRequest(repoUrl)), GitRepoRequest.class) + return webClient.post() + .uri("/mindmap/{mapId}/refresh-latest", mapId) + .header("Authorization", authHeader) + .body(Mono.just(request), Map.class) .retrieve() - .bodyToMono(Void.class) + .bodyToMono(RefreshResponse.class) .block(); } @@ -109,36 +195,97 @@ public void fetchRepo(String repoUrl, String authorizationHeader) { * ArangoDB에서 repo_url 기반으로 마인드맵 데이터를 삭제 */ public void deleteMindmapData(String repoUrl, String authorizationHeader) { + String mapId = extractMapId(repoUrl); + webClient.delete() .uri(uriBuilder -> uriBuilder - .path("/mindmap/repo") - .queryParam("repo_url", repoUrl) - .build()) + .path("/mindmap/{mapId}") + .queryParam("also_recommendations", true) + .build(mapId)) .header("Authorization", authorizationHeader) .retrieve() - .bodyToMono(Void.class) + .bodyToMono(DeleteResponse.class) + .doOnSuccess(response -> log.info("삭제 완료 - 노드: {}, 엣지: {}", + response.getNodes_removed(), response.getEdges_removed())) .block(); } - // === Inner Classes === + // === Helper Methods === + + // 저장소명 추출 + private String extractMapId(String repoUrl) { + String[] segments = repoUrl.split("/"); + return segments[segments.length - 1].replaceAll("\\.git$", ""); + } + + private AnalysisResultDto buildAnalysisResultDto( + RepoInfoResponse repoInfo + ) { + return AnalysisResultDto.builder() + .defaultBranch(repoInfo.getDefaultBranch()) + .lastCommit(repoInfo.getLastCommit()) + .errorMessage(null) + .build(); + } + + private AnalysisResultDto buildRefreshResultDto( + RepoInfoResponse repoInfo + ) { + return AnalysisResultDto.builder() + .defaultBranch(repoInfo.getDefaultBranch()) + .lastCommit(repoInfo.getLastCommit()) + .errorMessage(null) + .build(); + } + + // === Response DTOs === @Getter - @AllArgsConstructor - private static class AnalysisRequest { - private String repo_url; - private String prompt; // nullable + @Setter + public static class FetchResponse { + private String repo_id; + private Integer files_saved; + private Integer files_parsed; } @Getter - @AllArgsConstructor - private static class GitRepoRequest { - private String repo_url; + @Setter + public static class AnalyzeResponse { + private String message; + private String repo_id; + private Integer dirs_analyzed; } @Getter - public static class RepositoryCommitInfo { - private LocalDateTime lastCommitTime; - private String lastCommitSha; + @Setter + public static class RefreshResponse { + private String message; + private String map_id; + private String batch_time; + private Integer changed_files; + private Integer dirs_analyzed; + } + + @Getter + @Setter + public static class RepoInfoResponse { + @JsonProperty("default_branch") private String defaultBranch; + + @JsonProperty("last_commit") + @JsonDeserialize(using = IsoToLocalDateTimeDeserializer.class) + private LocalDateTime lastCommit; + } + + @Getter + @Setter + public static class DeleteResponse { + private String message; + private String map_id; + private Integer edges_removed; + private Integer nodes_removed; + private Integer recs_removed; } + + } \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/common/fastapi/dto/AnalysisResultDto.java b/src/main/java/com/teamEWSN/gitdeun/common/fastapi/dto/AnalysisResultDto.java index d476c50..5ed759e 100644 --- a/src/main/java/com/teamEWSN/gitdeun/common/fastapi/dto/AnalysisResultDto.java +++ b/src/main/java/com/teamEWSN/gitdeun/common/fastapi/dto/AnalysisResultDto.java @@ -10,11 +10,10 @@ public class AnalysisResultDto { // Repo 관련 정보 private String defaultBranch; - private LocalDateTime githubLastUpdatedAt; + private LocalDateTime lastCommit; // Mindmap 관련 정보 - private String mapData; // JSON 형태의 마인드맵 데이터 - private String title; // 프롬프트 및 mindmap 정보 요약 + private String summary; // 프롬프트 요약 private String errorMessage; // 실패 시 전달될 에러메세지 // TODO: FastAPI 응답에 맞춰 필드 정의 } diff --git a/src/main/java/com/teamEWSN/gitdeun/common/fastapi/dto/EdgeDto.java b/src/main/java/com/teamEWSN/gitdeun/common/fastapi/dto/EdgeDto.java index e38af6d..cd8085f 100644 --- a/src/main/java/com/teamEWSN/gitdeun/common/fastapi/dto/EdgeDto.java +++ b/src/main/java/com/teamEWSN/gitdeun/common/fastapi/dto/EdgeDto.java @@ -1,9 +1,25 @@ package com.teamEWSN.gitdeun.common.fastapi.dto; +import com.fasterxml.jackson.annotation.JsonProperty; import lombok.Getter; @Getter public class EdgeDto { - private String from; - private String to; + @JsonProperty("from") + private String fromKey; + + @JsonProperty("to") + private String toKey; + + @JsonProperty("edge_type") + private String edgeType; + + // 편의 메서드 + public boolean isContainmentEdge() { + return "contains".equals(edgeType) || edgeType == null; + } + + public boolean isSuggestionEdge() { + return "suggestion".equals(edgeType); + } } diff --git a/src/main/java/com/teamEWSN/gitdeun/common/fastapi/dto/MindmapGraphDto.java b/src/main/java/com/teamEWSN/gitdeun/common/fastapi/dto/MindmapGraphDto.java index 398f2ec..4b55393 100644 --- a/src/main/java/com/teamEWSN/gitdeun/common/fastapi/dto/MindmapGraphDto.java +++ b/src/main/java/com/teamEWSN/gitdeun/common/fastapi/dto/MindmapGraphDto.java @@ -1,11 +1,14 @@ package com.teamEWSN.gitdeun.common.fastapi.dto; +import com.fasterxml.jackson.annotation.JsonProperty; import lombok.Getter; import java.util.List; @Getter public class MindmapGraphDto { - private int count; + @JsonProperty("map_id") + private String mapId; + private Integer count; private List nodes; private List edges; } diff --git a/src/main/java/com/teamEWSN/gitdeun/common/fastapi/dto/NodeDto.java b/src/main/java/com/teamEWSN/gitdeun/common/fastapi/dto/NodeDto.java index c8fd6cb..c313417 100644 --- a/src/main/java/com/teamEWSN/gitdeun/common/fastapi/dto/NodeDto.java +++ b/src/main/java/com/teamEWSN/gitdeun/common/fastapi/dto/NodeDto.java @@ -1,11 +1,33 @@ package com.teamEWSN.gitdeun.common.fastapi.dto; +import com.fasterxml.jackson.annotation.JsonProperty; import lombok.Getter; import java.util.List; @Getter public class NodeDto { private String key; + private String label; - private List related_files; + + @JsonProperty("related_files") + private List relatedFiles; + + @JsonProperty("node_type") + private String nodeType; + + private String mode; + + // 편의 메서드 + public boolean isFileNode() { + return "file".equals(nodeType); + } + + public boolean isSuggestionNode() { + return "suggestion".equals(nodeType); + } + + public int getFileCount() { + return relatedFiles != null ? relatedFiles.size() : 0; + } } \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/common/util/CacheType.java b/src/main/java/com/teamEWSN/gitdeun/common/util/CacheType.java index 122740c..31a1456 100644 --- a/src/main/java/com/teamEWSN/gitdeun/common/util/CacheType.java +++ b/src/main/java/com/teamEWSN/gitdeun/common/util/CacheType.java @@ -7,7 +7,9 @@ @RequiredArgsConstructor public enum CacheType { SERVICE_AUTOCOMPLETE("serviceAutocomplete", 2, 1200), // 자동완성 캐시 - USER_SKILLS("userSkills", 1, 500); // 사용자 개발기술 캐시 + USER_SKILLS("userSkills", 1, 500), // 사용자 개발기술 캐시 + MINDMAP_GRAPH_L1("mindmapGraphL1", 100, 1), // 30분, 최대 100개 + VISIT_HISTORY_SUMMARY("visitHistorySummary", 1000, 1); // 1시간, 최대 1000개 private final String cacheName; private final int expiredAfterWrite; // 시간(hour) 단위 diff --git a/src/main/java/com/teamEWSN/gitdeun/common/webhook/dto/WebhookUpdateDto.java b/src/main/java/com/teamEWSN/gitdeun/common/webhook/dto/WebhookUpdateDto.java index b1b7ba9..385750d 100644 --- a/src/main/java/com/teamEWSN/gitdeun/common/webhook/dto/WebhookUpdateDto.java +++ b/src/main/java/com/teamEWSN/gitdeun/common/webhook/dto/WebhookUpdateDto.java @@ -10,7 +10,6 @@ public class WebhookUpdateDto { // FastAPI 콜백 페이로드와 동일 private String repoUrl; - private String mapData; private String defaultBranch; - private LocalDateTime githubLastUpdatedAt; + private LocalDateTime lastCommit; } diff --git a/src/main/java/com/teamEWSN/gitdeun/invitation/controller/InvitationController.java b/src/main/java/com/teamEWSN/gitdeun/invitation/controller/InvitationController.java index cd70662..5e503e9 100644 --- a/src/main/java/com/teamEWSN/gitdeun/invitation/controller/InvitationController.java +++ b/src/main/java/com/teamEWSN/gitdeun/invitation/controller/InvitationController.java @@ -10,6 +10,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; import org.springframework.data.web.PageableDefault; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; @@ -40,7 +41,7 @@ public ResponseEntity inviteByEmail( public ResponseEntity> getInvitations( @PathVariable Long mapId, @AuthenticationPrincipal CustomUserDetails userDetails, - @PageableDefault(size = 10, sort = "createdAt,desc") Pageable pageable + @PageableDefault(size = 10, sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable ) { return ResponseEntity.ok(invitationService.getInvitationsByMindmap(mapId, userDetails.getId(), pageable)); } diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmap/controller/MindmapController.java b/src/main/java/com/teamEWSN/gitdeun/mindmap/controller/MindmapController.java index c98da74..a09b510 100644 --- a/src/main/java/com/teamEWSN/gitdeun/mindmap/controller/MindmapController.java +++ b/src/main/java/com/teamEWSN/gitdeun/mindmap/controller/MindmapController.java @@ -1,20 +1,26 @@ package com.teamEWSN.gitdeun.mindmap.controller; +import com.teamEWSN.gitdeun.common.exception.GlobalException; +import com.teamEWSN.gitdeun.common.exception.ErrorCode; import com.teamEWSN.gitdeun.common.jwt.CustomUserDetails; import com.teamEWSN.gitdeun.mindmap.dto.*; import com.teamEWSN.gitdeun.mindmap.dto.prompt.MindmapPromptAnalysisDto; import com.teamEWSN.gitdeun.mindmap.dto.prompt.PromptApplyRequestDto; import com.teamEWSN.gitdeun.mindmap.dto.prompt.PromptHistoryResponseDto; import com.teamEWSN.gitdeun.mindmap.dto.prompt.PromptPreviewResponseDto; +import com.teamEWSN.gitdeun.mindmap.dto.request.MindmapCreateRequestDto; +import com.teamEWSN.gitdeun.mindmap.service.MindmapOrchestrationService; import com.teamEWSN.gitdeun.mindmap.service.MindmapService; import com.teamEWSN.gitdeun.mindmap.service.PromptHistoryService; +import com.teamEWSN.gitdeun.mindmapmember.service.MindmapAuthService; +import com.teamEWSN.gitdeun.repo.entity.Repo; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; -import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; @@ -27,24 +33,34 @@ public class MindmapController { private final MindmapService mindmapService; + private final MindmapOrchestrationService mindmapOrchestrationService; + private final MindmapAuthService mindmapAuthService; private final PromptHistoryService promptHistoryService; - // 마인드맵 생성 (FastAPI 분석 기반) - @PostMapping - public ResponseEntity createMindmap( - @RequestHeader("Authorization") String authorizationHeader, - @RequestBody MindmapCreateRequestDto request, - @AuthenticationPrincipal CustomUserDetails userDetails - ) { - MindmapResponseDto responseDto = mindmapService.createMindmap( - request, - userDetails.getId(), - authorizationHeader - ); + // 마인드맵 생성 (FastAPI 비동기 분석 기반) + @PostMapping("/async") + public ResponseEntity createMindmapAsync( + @Valid @RequestBody MindmapCreateRequestDto request, + @AuthenticationPrincipal CustomUserDetails userDetails, + @RequestHeader("Authorization") String authorizationHeader) { + + Long userId = userDetails.getId(); + log.info("마인드맵 비동기 생성 요청 - 사용자: {}, 저장소: {}", userId, request.getRepoUrl()); + + // 비동기 처리 호출 + mindmapOrchestrationService.createMindmap(request, userId, authorizationHeader); + + // 즉시 응답 반환 + MindmapCreationResponseDto response = MindmapCreationResponseDto.builder() + .processId(String.format("mindmap_%d_%d", userId, System.currentTimeMillis())) + .status("PROCESSING") + .message("마인드맵 생성이 시작되었습니다. 완료되면 알림을 보내드립니다.") + .build(); - return ResponseEntity.status(HttpStatus.CREATED).body(responseDto); + return ResponseEntity.accepted().body(response); } + // 마인드맵 상세 조회 @GetMapping("/{mapId}") public ResponseEntity getMindmap( @@ -65,25 +81,37 @@ public ResponseEntity updateMindmapTitle( @AuthenticationPrincipal CustomUserDetails userDetails, @RequestBody MindmapTitleUpdateDto request ) { - MindmapDetailResponseDto responseDto = mindmapService.updateMindmapTitle(mapId, userDetails.getId(), request); + MindmapDetailResponseDto responseDto = mindmapService.updateMindmapTitle( + mapId, + userDetails.getId(), + request); return ResponseEntity.ok(responseDto); } /** - * 마인드맵 새로고침 + * 마인드맵 새로고침 (비동기) + * - 요청을 즉시 반환하고 백그라운드에서 새로고침 진행 */ @PostMapping("/{mapId}/refresh") - public ResponseEntity refreshMindmap( + public ResponseEntity refreshMindmap( @PathVariable Long mapId, @AuthenticationPrincipal CustomUserDetails userDetails, @RequestHeader("Authorization") String authorizationHeader ) { - MindmapDetailResponseDto responseDto = mindmapService.refreshMindmap(mapId, userDetails.getId(), authorizationHeader); - return ResponseEntity.ok(responseDto); + // 권한 검증은 동기적으로 먼저 수행 + if (!mindmapAuthService.hasView(mapId, userDetails.getId())) { + throw new GlobalException(ErrorCode.FORBIDDEN_ACCESS); + } + + // 비동기 서비스 호출 + mindmapOrchestrationService.refreshMindmap(mapId, authorizationHeader); + + // 즉시 202 Accepted 응답 반환 + return ResponseEntity.accepted().build(); } /** - * 마인드맵 삭제 (owner만) + * 마인드맵 삭제 (동기 soft delete + 비동기 후처리) */ @DeleteMapping("/{mapId}") public ResponseEntity deleteMindmap( @@ -91,10 +119,16 @@ public ResponseEntity deleteMindmap( @AuthenticationPrincipal CustomUserDetails userDetails, @RequestHeader("Authorization") String authorizationHeader ) { - mindmapService.deleteMindmap(mapId, userDetails.getId(), authorizationHeader); + // 1. DB soft delete - 즉시 처리 + Repo relatedRepo = mindmapService.deleteMindmap(mapId, userDetails.getId()); + + // 2. ArangoDB 데이터 삭제 (비동기) - '실행 후 잊기' + mindmapOrchestrationService.cleanUpMindmapData(relatedRepo.getGithubRepoUrl(), authorizationHeader); + return ResponseEntity.ok().build(); } + // 프롬프트 기록 관련 /** * 프롬프트 분석 및 미리보기 생성 @@ -122,12 +156,12 @@ public ResponseEntity analyzePromptPreview( public ResponseEntity applyPromptHistory( @PathVariable Long mapId, @AuthenticationPrincipal CustomUserDetails userDetails, - @RequestBody PromptApplyRequestDto request + @RequestBody PromptApplyRequestDto request, + @RequestHeader("Authorization") String authorizationHeader ) { promptHistoryService.applyPromptHistory(mapId, userDetails.getId(), request); - // 적용 후 최신 마인드맵 정보 반환 - MindmapDetailResponseDto responseDto = mindmapService.getMindmap(mapId, userDetails.getId(), ""); + MindmapDetailResponseDto responseDto = mindmapService.getMindmap(mapId, userDetails.getId(), authorizationHeader); return ResponseEntity.ok(responseDto); } diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmap/dto/MindmapCreationResponseDto.java b/src/main/java/com/teamEWSN/gitdeun/mindmap/dto/MindmapCreationResponseDto.java new file mode 100644 index 0000000..915726f --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/mindmap/dto/MindmapCreationResponseDto.java @@ -0,0 +1,13 @@ +package com.teamEWSN.gitdeun.mindmap.dto; + +import lombok.Builder; +import lombok.Getter; + +@Builder +@Getter +public class MindmapCreationResponseDto { + private String processId; + private String status; + private String message; + // private int estimatedCompletionMinutes; +} diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmap/dto/MindmapDetailResponseDto.java b/src/main/java/com/teamEWSN/gitdeun/mindmap/dto/MindmapDetailResponseDto.java index 8251b1c..243f483 100644 --- a/src/main/java/com/teamEWSN/gitdeun/mindmap/dto/MindmapDetailResponseDto.java +++ b/src/main/java/com/teamEWSN/gitdeun/mindmap/dto/MindmapDetailResponseDto.java @@ -15,7 +15,10 @@ public class MindmapDetailResponseDto { private Long mindmapId; private String title; private String branch; - private String mapData; // 핵심 데이터인 마인드맵 JSON + + // 맵 데이터 + private MindmapGraphResponseDto mindmapGraph; + private LocalDateTime createdAt; private LocalDateTime updatedAt; diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmap/dto/MindmapGraphResponseDto.java b/src/main/java/com/teamEWSN/gitdeun/mindmap/dto/MindmapGraphResponseDto.java new file mode 100644 index 0000000..9441a96 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/mindmap/dto/MindmapGraphResponseDto.java @@ -0,0 +1,23 @@ +package com.teamEWSN.gitdeun.mindmap.dto; + +import com.teamEWSN.gitdeun.common.fastapi.dto.EdgeDto; +import com.teamEWSN.gitdeun.common.fastapi.dto.NodeDto; +import lombok.Builder; +import lombok.Getter; +import lombok.Setter; + +import java.util.List; + +@Getter +@Setter +@Builder +public class MindmapGraphResponseDto { + private Boolean success; + private String error; + + // FastAPI 응답 데이터 그대로 전달 + private String graphMapId; + private Integer nodeCount; + private List nodes; + private List edges; +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmap/dto/prompt/PromptPreviewResponseDto.java b/src/main/java/com/teamEWSN/gitdeun/mindmap/dto/prompt/PromptPreviewResponseDto.java index 96e7de9..8fe5bbd 100644 --- a/src/main/java/com/teamEWSN/gitdeun/mindmap/dto/prompt/PromptPreviewResponseDto.java +++ b/src/main/java/com/teamEWSN/gitdeun/mindmap/dto/prompt/PromptPreviewResponseDto.java @@ -1,5 +1,6 @@ package com.teamEWSN.gitdeun.mindmap.dto.prompt; +import com.teamEWSN.gitdeun.mindmap.dto.MindmapGraphResponseDto; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; @@ -13,7 +14,10 @@ public class PromptPreviewResponseDto { private Long historyId; private String prompt; private String title; - private String previewMapData; // 미리보기용 맵 데이터 + + // TODO: 프롬프트 미리보기용 맵 데이터 + private MindmapGraphResponseDto mindmapGraph; + private LocalDateTime createdAt; private Boolean applied; // 현재 적용 상태 } \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmap/dto/MindmapCreateRequestDto.java b/src/main/java/com/teamEWSN/gitdeun/mindmap/dto/request/MindmapCreateRequestDto.java similarity index 66% rename from src/main/java/com/teamEWSN/gitdeun/mindmap/dto/MindmapCreateRequestDto.java rename to src/main/java/com/teamEWSN/gitdeun/mindmap/dto/request/MindmapCreateRequestDto.java index 5d63d96..f905a69 100644 --- a/src/main/java/com/teamEWSN/gitdeun/mindmap/dto/MindmapCreateRequestDto.java +++ b/src/main/java/com/teamEWSN/gitdeun/mindmap/dto/request/MindmapCreateRequestDto.java @@ -1,4 +1,4 @@ -package com.teamEWSN.gitdeun.mindmap.dto; +package com.teamEWSN.gitdeun.mindmap.dto.request; import lombok.Getter; import lombok.NoArgsConstructor; @@ -7,5 +7,5 @@ @NoArgsConstructor public class MindmapCreateRequestDto { private String repoUrl; - private String prompt; + private String title; } diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmap/dto/request/ValidatedMindmapRequest.java b/src/main/java/com/teamEWSN/gitdeun/mindmap/dto/request/ValidatedMindmapRequest.java new file mode 100644 index 0000000..fe9102d --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/mindmap/dto/request/ValidatedMindmapRequest.java @@ -0,0 +1,44 @@ +package com.teamEWSN.gitdeun.mindmap.dto.request; + +import com.teamEWSN.gitdeun.repo.dto.GitHubRepositoryInfo; +import lombok.Builder; +import lombok.Getter; +import org.springframework.util.StringUtils; + +import java.time.LocalDateTime; + +/** + * 검증 완료된 마인드맵 생성 요청 정보 + */ +@Builder +@Getter +public class ValidatedMindmapRequest { + private final GitHubRepositoryInfo repositoryInfo; + private final String processedPrompt; + private final Long userId; + private final LocalDateTime validatedAt; + + /** + * FastAPI 호출용 정규화된 URL 반환 + */ + public String getNormalizedRepositoryUrl() { + return repositoryInfo.getNormalizedUrl(); + } + + /** + * 프롬프트 기반 분석 여부 확인 + */ + public boolean hasPrompt() { + return StringUtils.hasText(processedPrompt); + } + + /** + * 로깅용 간단 정보 반환 + */ + public String getLogSummary() { + return String.format("%s/%s %s", + repositoryInfo.getOwner(), + repositoryInfo.getRepositoryName(), + hasPrompt() ? "(프롬프트 있음)" : "(기본 분석)"); + } +} diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmap/entity/Mindmap.java b/src/main/java/com/teamEWSN/gitdeun/mindmap/entity/Mindmap.java index 838b657..0db7322 100644 --- a/src/main/java/com/teamEWSN/gitdeun/mindmap/entity/Mindmap.java +++ b/src/main/java/com/teamEWSN/gitdeun/mindmap/entity/Mindmap.java @@ -46,12 +46,12 @@ public class Mindmap extends AuditedEntity { @Column(length = 100, nullable = false) private String branch; - @Column(name = "title", length = 255, nullable = false) + @Column(length = 50, nullable = false) private String title; - @JdbcTypeCode(SqlTypes.JSON) + /*@JdbcTypeCode(SqlTypes.JSON) @Column(name = "map_data", columnDefinition = "json", nullable = false) - private String mapData; + private String mapData;*/ // TODO: 멤버수 제한 기능 (유료?) @Builder.Default @@ -66,10 +66,6 @@ public class Mindmap extends AuditedEntity { private List promptHistories = new ArrayList<>(); - public void updateMapData(String newMapData) { - this.mapData = newMapData; - } - public void updateTitle(String newTitle) { this.title = newTitle; } @@ -97,7 +93,6 @@ public void applyPromptHistory(PromptHistory promptHistory) { // 새 프롬프트 적용 promptHistory.applyToMindmap(); - this.mapData = promptHistory.getMapData(); } diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmap/entity/PromptHistory.java b/src/main/java/com/teamEWSN/gitdeun/mindmap/entity/PromptHistory.java index b1eec3f..60b10d8 100644 --- a/src/main/java/com/teamEWSN/gitdeun/mindmap/entity/PromptHistory.java +++ b/src/main/java/com/teamEWSN/gitdeun/mindmap/entity/PromptHistory.java @@ -27,11 +27,7 @@ public class PromptHistory extends CreatedEntity { private String prompt; @Column(length = 50) - private String title; // 분석 결과 요약 (기록 제목) - - @JdbcTypeCode(SqlTypes.JSON) - @Column(name = "map_data", columnDefinition = "json", nullable = false) - private String mapData; // 해당 프롬프트의 분석 결과 데이터 + private String summary; // 분석 결과 요약 (기록 제목) @Builder.Default @Column(name = "applied", nullable = false) diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmap/mapper/MindmapMapper.java b/src/main/java/com/teamEWSN/gitdeun/mindmap/mapper/MindmapMapper.java index 45210fc..9586921 100644 --- a/src/main/java/com/teamEWSN/gitdeun/mindmap/mapper/MindmapMapper.java +++ b/src/main/java/com/teamEWSN/gitdeun/mindmap/mapper/MindmapMapper.java @@ -1,6 +1,7 @@ package com.teamEWSN.gitdeun.mindmap.mapper; import com.teamEWSN.gitdeun.mindmap.dto.MindmapDetailResponseDto; +import com.teamEWSN.gitdeun.mindmap.dto.MindmapGraphResponseDto; import com.teamEWSN.gitdeun.mindmap.dto.MindmapResponseDto; import com.teamEWSN.gitdeun.mindmap.dto.prompt.PromptHistoryResponseDto; import com.teamEWSN.gitdeun.mindmap.entity.Mindmap; @@ -28,4 +29,11 @@ public interface MindmapMapper { @Mapping(source = "appliedPromptHistory", target = "appliedPromptHistory") MindmapDetailResponseDto toDetailResponseDto(Mindmap mindmap); + @Mapping(source = "mindmap.id", target = "mindmapId") + @Mapping(source = "mindmap.promptHistories", target = "promptHistories") + @Mapping(source = "mindmap.appliedPromptHistory", target = "appliedPromptHistory") + @Mapping(source = "graphData", target = "mindmapGraph") + MindmapDetailResponseDto toDetailResponseDto(Mindmap mindmap, + MindmapGraphResponseDto graphData); + } diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmap/mapper/PromptHistoryMapper.java b/src/main/java/com/teamEWSN/gitdeun/mindmap/mapper/PromptHistoryMapper.java index a79d155..f0838cc 100644 --- a/src/main/java/com/teamEWSN/gitdeun/mindmap/mapper/PromptHistoryMapper.java +++ b/src/main/java/com/teamEWSN/gitdeun/mindmap/mapper/PromptHistoryMapper.java @@ -22,7 +22,6 @@ public interface PromptHistoryMapper { * PromptHistory 엔티티 → PromptPreviewResponseDto 변환 */ @Mapping(source = "id", target = "historyId") - @Mapping(source = "mapData", target = "previewMapData") PromptPreviewResponseDto toPreviewResponseDto(PromptHistory promptHistory); /** diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmap/service/MindmapOrchestrationService.java b/src/main/java/com/teamEWSN/gitdeun/mindmap/service/MindmapOrchestrationService.java new file mode 100644 index 0000000..226e748 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/mindmap/service/MindmapOrchestrationService.java @@ -0,0 +1,174 @@ +package com.teamEWSN.gitdeun.mindmap.service; + +import com.teamEWSN.gitdeun.common.exception.ErrorCode; +import com.teamEWSN.gitdeun.common.exception.GlobalException; +import com.teamEWSN.gitdeun.common.fastapi.FastApiClient; +import com.teamEWSN.gitdeun.common.fastapi.dto.AnalysisResultDto; +import com.teamEWSN.gitdeun.mindmap.dto.request.MindmapCreateRequestDto; +import com.teamEWSN.gitdeun.mindmap.dto.request.ValidatedMindmapRequest; +import com.teamEWSN.gitdeun.mindmap.entity.Mindmap; +import com.teamEWSN.gitdeun.mindmap.entity.PromptHistory; +import com.teamEWSN.gitdeun.mindmap.repository.MindmapRepository; +import com.teamEWSN.gitdeun.mindmap.util.MindmapRequestValidator; +import com.teamEWSN.gitdeun.notification.dto.NotificationCreateDto; +import com.teamEWSN.gitdeun.notification.entity.NotificationType; +import com.teamEWSN.gitdeun.notification.service.NotificationService; +import com.teamEWSN.gitdeun.user.entity.User; +import com.teamEWSN.gitdeun.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import java.util.concurrent.CompletableFuture; + +@Slf4j +@Service +@RequiredArgsConstructor +public class MindmapOrchestrationService { + + private final FastApiClient fastApiClient; + private final MindmapService mindmapService; + private final NotificationService notificationService; + private final UserRepository userRepository; + private final MindmapRepository mindmapRepository; + private final MindmapRequestValidator requestValidator; + + /** + * 비동기적으로 마인드맵 생성 과정을 총괄 + */ + @Async("mindmapExecutor") + public void createMindmap(MindmapCreateRequestDto request, Long userId, String authHeader) { + log.info("마인드맵 생성 요청 검증 시작 - 사용자: {}", userId); + ValidatedMindmapRequest validatedRequest = requestValidator.validateAndProcess( + request.getRepoUrl(), + null, + userId + ); + + // 2. FastAPI 통합 분석 요청 - analyzeRepository 메서드 사용 + String normalizedUrl = validatedRequest.getRepositoryInfo().getNormalizedUrl(); + String processedPrompt = validatedRequest.getProcessedPrompt(); + + CompletableFuture.supplyAsync(() -> { + // 1. 요청 검증 및 전처리 + log.info("FastAPI 분석 요청 시작 - URL: {}, 프롬프트 존재: {}", + normalizedUrl, StringUtils.hasText(processedPrompt)); + + // prompt가 null이면 기본 분석, 있으면 프롬프트 포함 분석 + return fastApiClient.analyzeResult( + normalizedUrl, + null, + authHeader + ); + }).thenApply(analysisResult -> { + // 2. 분석 결과를 바탕으로 DB에 마인드맵 정보 저장 (트랜잭션) + log.info("분석 완료, DB 저장 시작 - 사용자: {}", userId); + return mindmapService.saveMindmapFromAnalysis(analysisResult, normalizedUrl, request.getTitle(), userId); + }).whenComplete((mindmap, throwable) -> { + // 3. 최종 결과에 따라 알림 전송 + if (throwable != null) { + handleFailureAndNotify(throwable, userId, normalizedUrl); + } else { + handleSuccessAndNotify(mindmap, userId); + } + }); + } + + @Async("mindmapExecutor") + public void refreshMindmap(Long mapId, String authHeader) { + try { + log.info("비동기 새로고침 시작 - 마인드맵 ID: {}", mapId); + Mindmap mindmap = mindmapRepository.findByIdAndDeletedAtIsNull(mapId) + .orElseThrow(() -> new GlobalException(ErrorCode.MINDMAP_NOT_FOUND)); + + PromptHistory appliedPrompt = mindmap.getAppliedPromptHistory(); + + String repoUrl = mindmap.getRepo().getGithubRepoUrl(); + String prompt = appliedPrompt.getPrompt(); + + // FastAPI 분석 요청 + AnalysisResultDto analysisResult = fastApiClient.refreshMindmap( + repoUrl, + prompt, + authHeader + ); + + // 분석 결과를 DB에 업데이트 (트랜잭션) + mindmapService.updateMindmapFromAnalysis(mapId,authHeader, analysisResult); + log.info("비동기 새로고침 성공 - 마인드맵 ID: {}", mapId); + + } catch (Exception e) { + log.error("비동기 새로고침 실패 - 마인드맵 ID: {}, 원인: {}", mapId, e.getMessage(), e); + } + } + + /** + * 마인드맵 삭제 후 비동기 후처리 (ArangoDB 데이터 삭제 요청) + */ + @Async("mindmapExecutor") + public void cleanUpMindmapData(String repoUrl, String authorizationHeader) { + try { + fastApiClient.deleteMindmapData(repoUrl, authorizationHeader); + log.info("ArangoDB 데이터 비동기 삭제 완료: {}", repoUrl); + } catch (Exception e) { + log.error("ArangoDB 데이터 비동기 삭제 실패 - 저장소: {}, 원인: {}", repoUrl, e.getMessage()); + // TODO: 실패 시 재시도 로직 또는 관리자 알림 등의 후속 처리 구현 가능 + } + } + + private void handleSuccessAndNotify(Mindmap mindmap, Long userId) { + try { + User user = userRepository.findByIdAndDeletedAtIsNull(userId) + .orElseThrow(() -> new GlobalException(ErrorCode.USER_NOT_FOUND_BY_ID)); + + String successMessage = String.format("마인드맵 '%s' 생성이 완료되었습니다.", mindmap.getTitle()); + notificationService.createAndSendNotification( + NotificationCreateDto.actionable( + user, + NotificationType.SYSTEM_UPDATE, + successMessage, + mindmap.getId(), + null + ) + ); + log.info("마인드맵 생성 성공 및 알림 전송 완료 - ID: {}, 사용자: {}", mindmap.getId(), userId); + } catch (Exception e) { + log.error("성공 알림 전송 실패 - 마인드맵 ID: {}, 사용자 ID: {}, 오류: {}", + mindmap.getId(), userId, e.getMessage()); + } + } + + private void handleFailureAndNotify(Throwable throwable, Long userId, String repoUrl) { + final Throwable cause = throwable.getCause() != null ? throwable.getCause() : throwable; + log.error("마인드맵 생성 최종 실패 - 사용자: {}, 저장소: {}, 원인: {}", userId, repoUrl, cause.getMessage()); + + try { + userRepository.findByIdAndDeletedAtIsNull(userId).ifPresent(user -> { + String errorMessage = "마인드맵 생성에 실패했습니다: " + getSimplifiedErrorMessage(cause); + notificationService.createAndSendNotification( + NotificationCreateDto.simple( + user, + NotificationType.SYSTEM_UPDATE, + errorMessage + ) + ); + }); + } catch (Exception e) { + log.error("실패 알림 전송 중 추가 오류 발생: {}", e.getMessage()); + } + } + + private String getSimplifiedErrorMessage(Throwable throwable) { + if (throwable instanceof GlobalException) { + return ((GlobalException) throwable).getErrorCode().getMessage(); + } + String message = throwable.getMessage(); + if (message == null) return "알 수 없는 오류"; + if (message.contains("timeout")) return "처리 시간이 초과되었습니다."; + if (message.contains("404")) return "저장소를 찾을 수 없습니다."; + if (message.contains("403")) return "저장소에 접근할 수 없습니다."; + return "처리 중 오류가 발생했습니다."; + } +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmap/service/MindmapService.java b/src/main/java/com/teamEWSN/gitdeun/mindmap/service/MindmapService.java index 3404d68..c14438a 100644 --- a/src/main/java/com/teamEWSN/gitdeun/mindmap/service/MindmapService.java +++ b/src/main/java/com/teamEWSN/gitdeun/mindmap/service/MindmapService.java @@ -1,16 +1,13 @@ package com.teamEWSN.gitdeun.mindmap.service; -import com.fasterxml.jackson.databind.ObjectMapper; import com.teamEWSN.gitdeun.common.exception.ErrorCode; import com.teamEWSN.gitdeun.common.exception.GlobalException; -import com.teamEWSN.gitdeun.common.fastapi.FastApiClient; import com.teamEWSN.gitdeun.common.fastapi.dto.AnalysisResultDto; -import com.teamEWSN.gitdeun.common.fastapi.dto.MindmapGraphDto; import com.teamEWSN.gitdeun.common.webhook.dto.WebhookUpdateDto; import com.teamEWSN.gitdeun.mindmap.dto.*; import com.teamEWSN.gitdeun.mindmap.entity.Mindmap; -import com.teamEWSN.gitdeun.mindmap.entity.PromptHistory; import com.teamEWSN.gitdeun.mindmap.mapper.MindmapMapper; +import com.teamEWSN.gitdeun.mindmap.util.MindmapGraphCache; import com.teamEWSN.gitdeun.mindmapmember.entity.MindmapMember; import com.teamEWSN.gitdeun.mindmapmember.entity.MindmapRole; import com.teamEWSN.gitdeun.mindmapmember.repository.MindmapMemberRepository; @@ -27,9 +24,7 @@ import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; -import java.time.LocalDateTime; import java.util.List; -import java.util.Optional; @Slf4j @Service @@ -39,62 +34,47 @@ public class MindmapService { private final VisitHistoryService visitHistoryService; private final MindmapSseService mindmapSseService; private final MindmapAuthService mindmapAuthService; - private final PromptHistoryService promptHistoryService; private final MindmapMapper mindmapMapper; private final MindmapRepository mindmapRepository; private final MindmapMemberRepository mindmapMemberRepository; private final RepoRepository repoRepository; private final UserRepository userRepository; - private final FastApiClient fastApiClient; - private final ObjectMapper objectMapper; + private final MindmapGraphCache mindmapGraphCache; - // 마인드맵 생성 + // FastAPI 분석 결과를 받아 마인드맵을 생성하고 DB에 저장 (단일 트랜잭션) @Transactional - public MindmapResponseDto createMindmap(MindmapCreateRequestDto req, Long userId, String authorizationHeader) { + public Mindmap saveMindmapFromAnalysis(AnalysisResultDto analysisResult, String repoUrl, String requestTitle, Long userId) { User user = userRepository.findByIdAndDeletedAtIsNull(userId) .orElseThrow(() -> new GlobalException(ErrorCode.USER_NOT_FOUND_BY_ID)); - String normalizedUrl = normalizeRepoUrl(req.getRepoUrl()); + // 저장소 정보 처리 (기존에 있으면 업데이트, 없으면 생성) + Repo repo = processRepository(repoUrl, analysisResult); - // 1. Repository 처리 - Repo repo = processRepository(normalizedUrl, authorizationHeader); + // AI가 생성한 제목 또는 기본 제목 결정 + String title = determineTitle(requestTitle, user); - // 2. FastAPI를 통해 분석 수행 및 AI 생성 제목과 맵 데이터 획득 - AnalysisResultDto analysisResult = generateMapDataWithAnalysis(normalizedUrl, req.getPrompt(), authorizationHeader); - - // 3. AI가 생성한 제목 사용, 실패 시 기본 제목 - String title = determineAIGeneratedTitle(analysisResult, user); - - // 4. 마인드맵 엔티티 생성 + // 3. 마인드맵 엔티티 생성 및 저장 Mindmap mindmap = Mindmap.builder() .repo(repo) .user(user) .branch(repo.getDefaultBranch()) .title(title) - .mapData(analysisResult.getMapData()) .build(); - mindmapRepository.save(mindmap); - // 5. 초기 프롬프트 히스토리 생성 (프롬프트가 있는 경우) - if (StringUtils.hasText(req.getPrompt())) { - promptHistoryService.createInitialPromptHistory(mindmap, req.getPrompt(), analysisResult.getMapData(), - analysisResult.getTitle()); - } - - // 6. 소유자 등록 및 방문 기록 + // 마인드맵 소유자 멤버로 등록 mindmapMemberRepository.save(MindmapMember.of(mindmap, user, MindmapRole.OWNER)); + + // 방문 기록 생성 visitHistoryService.createVisitHistory(user, mindmap); - log.info("마인드맵 생성 완료 - ID: {}, AI 생성 제목: {}", mindmap.getId(), title); - return mindmapMapper.toResponseDto(mindmap); + log.info("마인드맵 DB 저장 완료 - ID: {}, 제목: {}", mindmap.getId(), title); + return mindmap; } - /** - * 마인드맵 상세 정보 조회 - */ - @Transactional - public MindmapDetailResponseDto getMindmap(Long mapId, Long userId, String authorizationHeader) { + // 마인드맵 상세 정보 조회 + @Transactional(readOnly = true) + public MindmapDetailResponseDto getMindmap(Long mapId, Long userId, String authHeader) { if (!mindmapAuthService.hasView(mapId, userId)) { throw new GlobalException(ErrorCode.FORBIDDEN_ACCESS); } @@ -102,18 +82,18 @@ public MindmapDetailResponseDto getMindmap(Long mapId, Long userId, String autho Mindmap mindmap = mindmapRepository.findByIdAndDeletedAtIsNull(mapId) .orElseThrow(() -> new GlobalException(ErrorCode.MINDMAP_NOT_FOUND)); - syncWithArangoDB(mindmap, authorizationHeader); + // 캐싱된 그래프 데이터 조회 + MindmapGraphResponseDto graphData = mindmapGraphCache.getGraphWithHybridCache( + mindmap.getRepo().getGithubRepoUrl(), + authHeader + ); - return mindmapMapper.toDetailResponseDto(mindmap); + return mindmapMapper.toDetailResponseDto(mindmap, graphData); } - /** - * 마인드맵 제목 수정 - */ + //마인드맵 제목 수정 @Transactional public MindmapDetailResponseDto updateMindmapTitle(Long mapId, Long userId, MindmapTitleUpdateDto req) { - - // EDIT 권한 필요 if (!mindmapAuthService.hasEdit(mapId, userId)) { throw new GlobalException(ErrorCode.FORBIDDEN_ACCESS); } @@ -124,66 +104,41 @@ public MindmapDetailResponseDto updateMindmapTitle(Long mapId, Long userId, Mind mindmap.updateTitle(req.getTitle()); MindmapDetailResponseDto responseDto = mindmapMapper.toDetailResponseDto(mindmap); - - // 제목 변경만 별도 브로드캐스트 mindmapSseService.broadcastTitleChanged(mapId, req.getTitle()); log.info("마인드맵 제목 수정 완료 - ID: {}, 새 제목: {}", mapId, req.getTitle()); return responseDto; } - /** - * 마인드맵 새로고침 - */ + // 분석 결과를 바탕으로 기존 마인드맵을 업데이트 (새로고침) @Transactional - public MindmapDetailResponseDto refreshMindmap(Long mapId, Long userId, String authorizationHeader) { - - // 마인드맵 멤버 확인 - if (!mindmapAuthService.hasView(mapId, userId)) { - throw new GlobalException(ErrorCode.FORBIDDEN_ACCESS); - } - + public MindmapDetailResponseDto updateMindmapFromAnalysis(Long mapId, String authHeader, AnalysisResultDto analysisResult) { Mindmap mindmap = mindmapRepository.findByIdAndDeletedAtIsNull(mapId) .orElseThrow(() -> new GlobalException(ErrorCode.MINDMAP_NOT_FOUND)); - String repoUrl = mindmap.getRepo().getGithubRepoUrl(); - - try { - // 저장소 최신 설정 - fastApiClient.saveRepoInfo(repoUrl, authorizationHeader); - fastApiClient.fetchRepo(repoUrl, authorizationHeader); - - // 현재 적용된 프롬프트 확인 - PromptHistory appliedPrompt = mindmap.getAppliedPromptHistory(); - AnalysisResultDto analysisResult; + mindmap.getRepo().updateWithAnalysis(analysisResult); - if (appliedPrompt != null && StringUtils.hasText(appliedPrompt.getPrompt())) { - analysisResult = fastApiClient.analyzeWithPrompt(repoUrl, appliedPrompt.getPrompt(), authorizationHeader); - } else { - analysisResult = fastApiClient.analyzeDefault(repoUrl, authorizationHeader); - } + // 새로고침 시 그래프 캐시 무효화 + mindmapGraphCache.evictCache(mindmap.getRepo().getGithubRepoUrl()); - mindmap.getRepo().updateWithAnalysis(analysisResult); - mindmap.updateMapData(analysisResult.getMapData()); + MindmapGraphResponseDto graphData = mindmapGraphCache.getGraphWithHybridCache( + mindmap.getRepo().getGithubRepoUrl(), + authHeader + ); - MindmapDetailResponseDto responseDto = mindmapMapper.toDetailResponseDto(mindmap); - mindmapSseService.broadcastUpdate(mapId, responseDto); + MindmapDetailResponseDto responseDto = mindmapMapper.toDetailResponseDto(mindmap, graphData); + mindmapSseService.broadcastUpdate(mapId, responseDto); - return responseDto; - - } catch (Exception e) { - log.error("마인드맵 새로고침 실패: {}", e.getMessage(), e); - throw new RuntimeException("마인드맵 새로고침 중 오류가 발생했습니다: " + e.getMessage()); - } + log.info("마인드맵 새로고침 DB 업데이트 완료 - ID: {}", mapId); + return responseDto; } /** - * 마인드맵 소프트 삭제 + * 마인드맵 소프트 삭제 (DB 작업만 수행) + * @return 비동기 후처리를 위해 관련 Repo 엔티티 반환 */ @Transactional - public void deleteMindmap(Long mapId, Long userId, String authorizationHeader) { - - // Owner만 가능 + public Repo deleteMindmap(Long mapId, Long userId) { if (!mindmapAuthService.isOwner(mapId, userId)) { throw new GlobalException(ErrorCode.FORBIDDEN_ACCESS); } @@ -191,159 +146,69 @@ public void deleteMindmap(Long mapId, Long userId, String authorizationHeader) { Mindmap mindmap = mindmapRepository.findByIdAndDeletedAtIsNull(mapId) .orElseThrow(() -> new GlobalException(ErrorCode.MINDMAP_NOT_FOUND)); - try { - fastApiClient.deleteMindmapData(mindmap.getRepo().getGithubRepoUrl(), authorizationHeader); - log.info("ArangoDB 데이터 삭제 완료: {}", mindmap.getRepo().getGithubRepoUrl()); - } catch (Exception e) { - log.error("ArangoDB 데이터 삭제 실패, 마인드맵 소프트 삭제는 계속 진행: {}", e.getMessage()); - } + // 삭제 시 관련 캐시도 무효화 + mindmapGraphCache.evictCache(mindmap.getRepo().getGithubRepoUrl()); - // 소프트 삭제 수행 mindmap.softDelete(); - log.info("마인드맵 소프트 삭제 완료: {}", mapId); + log.info("마인드맵 소프트 삭제 완료 (DB) - ID: {}", mapId); + + return mindmap.getRepo(); // 후처리를 위해 Repo 반환 } /** - * Webhook을 통한 마인드맵 업데이트 + * TODO: Webhook을 통한 마인드맵 업데이트 */ @Transactional - public void updateMindmapFromWebhook(WebhookUpdateDto dto, String authorizationHeader) { + public void updateMindmapFromWebhook(WebhookUpdateDto dto, String authHeader) { Repo repo = repoRepository.findByGithubRepoUrl(dto.getRepoUrl()) .orElseThrow(() -> new GlobalException(ErrorCode.REPO_NOT_FOUND_BY_URL)); - // 삭제되지 않은 마인드맵만 업데이트 List mindmapsToUpdate = repo.getMindmaps().stream() .filter(mindmap -> !mindmap.isDeleted()) .toList(); repo.updateWithWebhookData(dto); + // Webhook 업데이트 시 관련 캐시 무효화 + mindmapGraphCache.evictCache(repo.getGithubRepoUrl()); + for (Mindmap mindmap : mindmapsToUpdate) { - mindmap.updateMapData(dto.getMapData()); - MindmapDetailResponseDto responseDto = mindmapMapper.toDetailResponseDto(mindmap); - mindmapSseService.broadcastUpdate(mindmap.getId(), responseDto); + // 새로운 그래프 데이터로 업데이트된 응답 생성 + MindmapGraphResponseDto graphData = mindmapGraphCache.getGraphWithHybridCache( + repo.getGithubRepoUrl(), + authHeader + ); + MindmapDetailResponseDto responseDto = mindmapMapper.toDetailResponseDto(mindmap, graphData); + mindmapSseService.broadcastUpdate(mindmap.getId(), responseDto); log.info("Webhook으로 마인드맵 ID {} 업데이트 및 SSE 전송 완료", mindmap.getId()); } } -// === Private Helper Methods === - - private Repo processRepository(String repoUrl, String authHeader) { - Optional existingRepo = repoRepository.findByGithubRepoUrl(repoUrl); - Repo repo; - - if (existingRepo.isPresent()) { - repo = existingRepo.get(); - log.info("기존 저장소 발견: {}", repoUrl); - - if (shouldUpdateRepo(repo, authHeader)) { - log.info("저장소 업데이트 필요: {}", repoUrl); - fastApiClient.saveRepoInfo(repoUrl, authHeader); - fastApiClient.fetchRepo(repoUrl, authHeader); - } - } else { - log.info("새 저장소: {}", repoUrl); - repo = Repo.builder().githubRepoUrl(repoUrl).build(); - fastApiClient.saveRepoInfo(repoUrl, authHeader); - fastApiClient.fetchRepo(repoUrl, authHeader); - } - - return repo; - } - - /** - * FastAPI를 통한 분석 수행 및 AI 생성 제목과 맵 데이터 획득 - */ - private AnalysisResultDto generateMapDataWithAnalysis(String repoUrl, String prompt, String authHeader) { - try { - AnalysisResultDto analysisResult; - if (StringUtils.hasText(prompt)) { - // 프롬프트가 있는 경우 - AI가 맞춤형 분석 및 제목 생성 - analysisResult = fastApiClient.analyzeWithPrompt(repoUrl, prompt, authHeader); - log.info("프롬프트 기반 AI 분석 완료 - 생성된 제목: {}", analysisResult.getTitle()); - } else { - // 프롬프트가 없는 경우 - 기본 분석 (제목 생성 안됨) - analysisResult = fastApiClient.analyzeDefault(repoUrl, authHeader); - log.info("기본 분석 완료 - AI 제목 생성 없음"); - } - - return analysisResult; - } catch (Exception e) { - log.error("마인드맵 데이터 생성 실패: {}", e.getMessage(), e); - - // 분석 실패 시 예외를 다시 던져서 상위에서 처리하도록 함 - // 에러 메시지는 로그와 예외로만 관리 - throw new RuntimeException("FastAPI 분석 실패: " + e.getMessage(), e); - } + // === Private Helper Methods === + + private Repo processRepository(String repoUrl, AnalysisResultDto analysisResult) { + return repoRepository.findByGithubRepoUrl(repoUrl) + .map(repo -> { + repo.updateWithAnalysis(analysisResult); + return repo; + }) + .orElseGet(() -> { + Repo newRepo = Repo.builder() + .githubRepoUrl(repoUrl) + .defaultBranch(analysisResult.getDefaultBranch()) + .githubLastUpdatedAt(analysisResult.getLastCommit()) + .build(); + return repoRepository.save(newRepo); + }); } - /** - * AI 생성 제목 결정 로직 - * 1. 프롬프트 있고 AI 제목 생성 성공 → AI 제목 사용 - * 2. 프롬프트 없거나 AI 제목 생성 실패 → 자동 번호 제목 - */ - private String determineAIGeneratedTitle(AnalysisResultDto analysisResult, User user) { - // AI가 제목을 성공적으로 생성한 경우 - if (analysisResult != null && StringUtils.hasText(analysisResult.getTitle())) { - log.info("AI 생성 제목 사용: {}", analysisResult.getTitle()); - return analysisResult.getTitle(); + private String determineTitle(String title, User user) { + if (StringUtils.hasText(title)) { + return title; } - - // AI 제목 생성 실패 또는 프롬프트 없는 경우 → 자동 번호 제목 long userMindmapCount = mindmapRepository.countByUserAndDeletedAtIsNull(user); - String defaultTitle = "마인드맵 " + (userMindmapCount + 1); - - log.info("기본 제목 사용: {}", defaultTitle); - return defaultTitle; - } - - private boolean shouldUpdateRepo(Repo repo, String authHeader) { - try { - LocalDateTime githubLastCommit = fastApiClient.getRepositoryLastCommitTime(repo.getGithubRepoUrl(), authHeader); - - if (repo.getGithubLastUpdatedAt() == null) { - return true; - } - - return githubLastCommit.isAfter(repo.getGithubLastUpdatedAt()); - } catch (Exception e) { - log.warn("저장소 업데이트 확인 실패: {}", e.getMessage()); - return false; - } - } - - private String getMapDataFromArangoDB(String repoUrl, String authHeader) { - try { - MindmapGraphDto graphData = fastApiClient.getMindmapGraph(repoUrl, authHeader); - return graphData != null ? objectMapper.writeValueAsString(graphData) : "{}"; - } catch (Exception e) { - log.warn("ArangoDB 데이터 조회 실패: {}", e.getMessage()); - return "{}"; - } - } - - private void syncWithArangoDB(Mindmap mindmap, String authHeader) { - try { - String latestMapData = getMapDataFromArangoDB(mindmap.getRepo().getGithubRepoUrl(), authHeader); - if (!latestMapData.equals(mindmap.getMapData())) { - mindmap.updateMapData(latestMapData); - log.info("마인드맵 동기화 완료: {}", mindmap.getId()); - } - } catch (Exception e) { - log.warn("ArangoDB 동기화 실패: {}", e.getMessage()); - } - } - - private String normalizeRepoUrl(String url) { - if (url == null || url.trim().isEmpty()) { - throw new IllegalArgumentException("Repository URL cannot be null or empty"); - } - - return url.trim() - .toLowerCase() - .replaceAll("/$", "") - .replaceAll("\\.git$", ""); + return "마인드맵 " + (userMindmapCount + 1); } } \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmap/service/PromptHistoryService.java b/src/main/java/com/teamEWSN/gitdeun/mindmap/service/PromptHistoryService.java index 4597355..e7c198a 100644 --- a/src/main/java/com/teamEWSN/gitdeun/mindmap/service/PromptHistoryService.java +++ b/src/main/java/com/teamEWSN/gitdeun/mindmap/service/PromptHistoryService.java @@ -49,10 +49,10 @@ public PromptPreviewResponseDto createPromptPreview(Long mapId, Long userId, Min String repoUrl = mindmap.getRepo().getGithubRepoUrl(); try { - AnalysisResultDto analysisResult = fastApiClient.analyzeWithPrompt(repoUrl, req.getPrompt(), authorizationHeader); + AnalysisResultDto analysisResult = fastApiClient.refreshMindmap(repoUrl, req.getPrompt(), authorizationHeader); // FastAPI로부터 받은 analysisSummary 사용 - String summary = analysisResult.getTitle(); + String summary = analysisResult.getSummary(); // analysisSummary가 없거나 비어있는 경우 대체 로직 사용 if (summary == null || summary.trim().isEmpty()) { @@ -62,8 +62,7 @@ public PromptPreviewResponseDto createPromptPreview(Long mapId, Long userId, Min PromptHistory history = PromptHistory.builder() .mindmap(mindmap) .prompt(req.getPrompt()) - .title(summary) - .mapData(analysisResult.getMapData()) + .summary(summary) .applied(false) .build(); @@ -146,16 +145,6 @@ public void applyPromptHistory(Long mapId, Long userId, PromptApplyRequestDto re mindmap.applyPromptHistory(historyToApply); - // 마인드맵 제목도 프롬프트 타이틀로 업데이트 - mindmap.updateTitle(historyToApply.getTitle()); - - log.info("프롬프트 히스토리 적용 완료 - 마인드맵 ID: {}, 히스토리 ID: {}, 제목 변경: {}", - mapId, req.getHistoryId(), historyToApply.getTitle()); - - // 제목 변경 브로드캐스트 추가 - mindmapSseService.broadcastTitleChanged(mapId, historyToApply.getTitle()); - - mindmapSseService.broadcastPromptApplied(mapId, historyToApply.getId()); } @@ -181,24 +170,6 @@ public void deletePromptHistory(Long mapId, Long historyId, Long userId) { log.info("프롬프트 히스토리 삭제 완료 - 히스토리 ID: {}", historyId); } - /** - * 마인드맵 생성 시 초기 프롬프트 히스토리 생성 - */ - public void createInitialPromptHistory(Mindmap mindmap, String prompt, String mapData, String promptTitle) { - if (prompt != null && !prompt.trim().isEmpty()) { - PromptHistory history = PromptHistory.builder() - .mindmap(mindmap) - .prompt(prompt) - .title(promptTitle) - .mapData(mapData) - .applied(true) - .build(); - - promptHistoryRepository.save(history); - log.info("초기 프롬프트 히스토리 생성 완료 - 마인드맵 ID: {}", mindmap.getId()); - } - } - /** * 프롬프트 결과 대체 요약 생성 */ diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmap/util/MindmapGraphCache.java b/src/main/java/com/teamEWSN/gitdeun/mindmap/util/MindmapGraphCache.java new file mode 100644 index 0000000..af39e71 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/mindmap/util/MindmapGraphCache.java @@ -0,0 +1,109 @@ +package com.teamEWSN.gitdeun.mindmap.util; + +import com.teamEWSN.gitdeun.common.fastapi.FastApiClient; +import com.teamEWSN.gitdeun.common.fastapi.dto.MindmapGraphDto; +import com.teamEWSN.gitdeun.mindmap.dto.MindmapGraphResponseDto; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Component; + +import java.time.Duration; + +@Slf4j +@Component +@RequiredArgsConstructor +public class MindmapGraphCache { + + private final RedisTemplate redisTemplate; + private final FastApiClient fastApiClient; + private final MindmapL1Cache mindmapL1Cache; + + // L2 캐시: Redis + public MindmapGraphResponseDto getGraphWithHybridCache(String repoUrl, String authHeader) { + String mapId = extractMapId(repoUrl); + + // 1. L1 캐시 확인 (Caffeine) + MindmapGraphResponseDto l1Result = mindmapL1Cache.getGraphFromL1Cache(mapId); + if (l1Result != null) { + log.debug("마인드맵 그래프 L1 캐시 히트 - mapId: {}", mapId); + return l1Result; + } + + // 2. L2 캐시 확인 (Redis) + String redisKey = "mindmap:graph:" + mapId; + try { + MindmapGraphResponseDto l2Result = (MindmapGraphResponseDto) redisTemplate.opsForValue().get(redisKey); + + if (l2Result != null) { + log.debug("마인드맵 그래프 L2 캐시 히트 - mapId: {}", mapId); + // L1 캐시에 다시 저장 + mindmapL1Cache.cacheToL1(mapId, l2Result); + return l2Result; + } + } catch (Exception e) { + log.warn("Redis 캐시 조회 실패 - mapId: {}, FastAPI 직접 호출로 진행", mapId, e); + } + + // 3. FastAPI 실시간 조회 + try { + log.debug("FastAPI 실시간 조회 시작 - mapId: {}", mapId); + MindmapGraphDto graphDto = fastApiClient.getGraph(mapId, authHeader); + MindmapGraphResponseDto responseDto = convertToResponseDto(graphDto); + + // 4. 양방향 캐싱 + try { + redisTemplate.opsForValue().set(redisKey, responseDto, Duration.ofHours(2)); // L2: 2시간 + log.debug("Redis 캐시 저장 완료 - mapId: {}", mapId); + } catch (Exception e) { + log.warn("Redis 캐시 저장 실패 - mapId: {}", mapId, e); + } + + mindmapL1Cache.cacheToL1(mapId, responseDto); // L1: 30분 (CacheType에서 정의) + + return responseDto; + + } catch (Exception e) { + log.error("FastAPI 그래프 조회 실패 - mapId: {}", mapId, e); + return MindmapGraphResponseDto.builder() + .success(false) + .error("그래프 데이터 조회 실패: " + e.getMessage()) + .graphMapId(mapId) + .nodeCount(0) + .build(); + } + } + + // 캐시 무효화 (마인드맵 새로고침 시) + public void evictCache(String repoUrl) { + String mapId = extractMapId(repoUrl); + String redisKey = "mindmap:graph:" + mapId; + + try { + redisTemplate.delete(redisKey); + log.info("마인드맵 그래프 캐시 무효화 완료 - mapId: {}", mapId); + // L1 캐시(Caffeine) 삭제 + mindmapL1Cache.evictL1Cache(mapId); + } catch (Exception e) { + log.warn("캐시 무효화 실패 - mapId: {}", mapId, e); + } + } + + // === Helper Methods === + + private String extractMapId(String repoUrl) { + String[] segments = repoUrl.split("/"); + return segments[segments.length - 1].replaceAll("\\.git$", ""); + } + + private MindmapGraphResponseDto convertToResponseDto(MindmapGraphDto graphDto) { + return MindmapGraphResponseDto.builder() + .success(true) + .error(null) + .graphMapId(graphDto.getMapId()) + .nodeCount(graphDto.getCount()) + .nodes(graphDto.getNodes()) + .edges(graphDto.getEdges()) + .build(); + } +} diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmap/util/MindmapL1Cache.java b/src/main/java/com/teamEWSN/gitdeun/mindmap/util/MindmapL1Cache.java new file mode 100644 index 0000000..bcc7866 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/mindmap/util/MindmapL1Cache.java @@ -0,0 +1,28 @@ +package com.teamEWSN.gitdeun.mindmap.util; + +import com.teamEWSN.gitdeun.mindmap.dto.MindmapGraphResponseDto; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.CachePut; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.stereotype.Component; + +@Component +public class MindmapL1Cache { + + // L1 캐시에서 데이터를 가져오는 역할만 수행 + // 캐시가 있으면 반환, 없으면 null 반환 + @Cacheable(value = "MINDMAP_GRAPH_L1", key = "#mapId") + public MindmapGraphResponseDto getGraphFromL1Cache(String mapId) { + return null; + } + + // L1 캐시에 데이터를 저장하는 역할만 수행 + @CachePut(value = "MINDMAP_GRAPH_L1", key = "#mapId") + public MindmapGraphResponseDto cacheToL1(String mapId, MindmapGraphResponseDto data) { + return data; + } + + // L1 캐시 데이터 삭제 + @CacheEvict(value = "MINDMAP_GRAPH_L1", key = "#mapId") + public void evictL1Cache(String mapId) {} +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmap/util/MindmapRequestValidator.java b/src/main/java/com/teamEWSN/gitdeun/mindmap/util/MindmapRequestValidator.java new file mode 100644 index 0000000..b9b368d --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/mindmap/util/MindmapRequestValidator.java @@ -0,0 +1,263 @@ +package com.teamEWSN.gitdeun.mindmap.util; + +import com.teamEWSN.gitdeun.common.exception.ErrorCode; +import com.teamEWSN.gitdeun.common.exception.GlobalException; +import com.teamEWSN.gitdeun.mindmap.dto.request.ValidatedMindmapRequest; +import com.teamEWSN.gitdeun.repo.dto.GitHubRepositoryInfo; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +import java.net.URI; +import java.net.URISyntaxException; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/* + * 마인드맵 생성 요청의 1단계 검증 및 전처리를 담당하는 컴포넌트 + * 기본 입력값 검증 → GitHub URL 파싱 및 정규화 → 프롬프트 및 저장소 접근성 사전 검증 → 검증된 요청 객체 반환 + */ +@Slf4j +@Component +public class MindmapRequestValidator { + + // GitHub URL 패턴들 + private static final Pattern GITHUB_REPO_PATTERN = Pattern.compile( + "^https?://github\\.com/([^/]+)/([^/]+)(?:/.*)?$", Pattern.CASE_INSENSITIVE + ); + + private static final Pattern GITHUB_SHORT_PATTERN = Pattern.compile( + "^([^/]+)/([^/]+)$" // "owner/repo" 형태 + ); + + // 지원되는 저장소 크기 제한 (MB) + private static final long MAX_REPO_SIZE_MB = 1000; + + // 프롬프트 길이 제한 + private static final int MAX_PROMPT_LENGTH = 2000; + + // 금지된 저장소명 패턴 (보안상 위험하거나 시스템 리소스 과다 사용) + private static final Pattern FORBIDDEN_REPO_NAMES = Pattern.compile( + ".*(test|example|demo|tutorial|sample).*", Pattern.CASE_INSENSITIVE + ); + + /** + * 마인드맵 생성 요청 종합 검증 + * + * @param repoUrl 저장소 URL (필수) + * @param prompt 사용자 프롬프트 (선택) + * @param userId 요청 사용자 ID + * @return 검증된 요청 정보 + */ + public ValidatedMindmapRequest validateAndProcess(String repoUrl, String prompt, Long userId) { + log.info("마인드맵 생성 요청 검증 시작 - 사용자: {}, URL: {}", userId, repoUrl); + + // === 1. 기본 입력값 검증 === + validateBasicInputs(repoUrl, userId); + + // === 2. GitHub URL 파싱 및 정규화 === + GitHubRepositoryInfo repoInfo = parseAndNormalizeGitHubUrl(repoUrl); + + // === 3. 프롬프트 검증 및 전처리 === + String processedPrompt = validateAndProcessPrompt(prompt); + + // === 4. 저장소 정책 검증 === + validateRepositoryPolicy(repoInfo); + + // === 5. 최종 검증 결과 반환 === + ValidatedMindmapRequest result = ValidatedMindmapRequest.builder() + .repositoryInfo(repoInfo) + .processedPrompt(processedPrompt) + .userId(userId) + .validatedAt(java.time.LocalDateTime.now()) + .build(); + + log.info("요청 검증 완료 - 정규화된 URL: {}, 프롬프트 길이: {}", + repoInfo.getNormalizedUrl(), + processedPrompt != null ? processedPrompt.length() : 0); + + return result; + } + + /** + * 1-1. 기본 입력값 검증 + */ + private void validateBasicInputs(String repoUrl, Long userId) { + if (userId == null || userId <= 0) { + throw new GlobalException(ErrorCode.INVALID_USER_ID); + } + + if (!StringUtils.hasText(repoUrl)) { + throw new GlobalException(ErrorCode.REPOSITORY_URL_REQUIRED); + } + + if (repoUrl.length() > 1000) { + throw new GlobalException(ErrorCode.REPOSITORY_URL_TOO_LONG); + } + } + + /** + * 1-2. GitHub URL 파싱 및 정규화 + */ + private GitHubRepositoryInfo parseAndNormalizeGitHubUrl(String rawUrl) { + String cleanUrl = rawUrl.trim().toLowerCase(); + + // HTTPS 형태 GitHub URL 파싱 + Matcher fullMatcher = GITHUB_REPO_PATTERN.matcher(cleanUrl); + if (fullMatcher.matches()) { + String owner = fullMatcher.group(1); + String repo = fullMatcher.group(2); + return createRepositoryInfo(owner, repo, cleanUrl); + } + + // 단축 형태 "owner/repo" 파싱 + Matcher shortMatcher = GITHUB_SHORT_PATTERN.matcher(cleanUrl); + if (shortMatcher.matches()) { + String owner = shortMatcher.group(1); + String repo = shortMatcher.group(2); + String normalizedUrl = "https://github.com/" + owner + "/" + repo; + return createRepositoryInfo(owner, repo, normalizedUrl); + } + + // 지원하지 않는 URL 형태 + throw new GlobalException(ErrorCode.UNSUPPORTED_REPOSITORY_URL); + } + + /** + * 저장소 정보 객체 생성 및 추가 정규화 + */ + private GitHubRepositoryInfo createRepositoryInfo(String owner, String repo, String normalizedUrl) { + // .git 접미사 제거 + if (repo.endsWith(".git")) { + repo = repo.substring(0, repo.length() - 4); + } + + // 최종 정규화된 URL + String finalUrl = "https://github.com/" + owner + "/" + repo; + + // URL 유효성 검증 (URI 파싱) + try { + URI uri = new URI(finalUrl); + if (!uri.getScheme().equals("https") || !uri.getHost().equals("github.com")) { + throw new GlobalException(ErrorCode.INVALID_GITHUB_URL); + } + } catch (URISyntaxException e) { + throw new GlobalException(ErrorCode.INVALID_REPOSITORY_URL); + } + + return GitHubRepositoryInfo.builder() + .owner(owner) + .repositoryName(repo) + .normalizedUrl(finalUrl) + .originalUrl(normalizedUrl) + .isOrganization(isOrganizationRepository(owner)) + .build(); + } + + /** + * 1-3. 프롬프트 검증 및 전처리 + */ + private String validateAndProcessPrompt(String prompt) { + if (!StringUtils.hasText(prompt)) { + return null; + } + + String trimmed = prompt.trim(); + + // 길이 검증 + if (trimmed.length() > MAX_PROMPT_LENGTH) { + throw new GlobalException(ErrorCode.PROMPT_TOO_LONG); + } + + // 최소 길이 검증 (의미있는 프롬프트인지) + if (trimmed.length() < 3) { + throw new GlobalException(ErrorCode.PROMPT_TOO_SHORT); + } + + // 악성 패턴 검사 (SQL Injection, XSS 등) + if (containsMaliciousPatterns(trimmed)) { + throw new GlobalException(ErrorCode.MALICIOUS_PROMPT_DETECTED); + } + + return trimmed; + /*// 특수문자 정리 + return trimmed.replaceAll("[\\r\\n\\t]+", " ").trim();*/ + } + + /** + * 1-4. 저장소 정책 검증 + */ + private void validateRepositoryPolicy(GitHubRepositoryInfo repoInfo) { + String repoName = repoInfo.getRepositoryName(); + String owner = repoInfo.getOwner(); + + // 금지된 저장소명 패턴 검사 + if (FORBIDDEN_REPO_NAMES.matcher(repoName).matches()) { + log.warn("금지된 저장소 패턴 감지: {}/{}", owner, repoName); + throw new GlobalException(ErrorCode.FORBIDDEN_REPOSITORY_PATTERN); + } + + // 시스템 보호를 위한 특정 저장소 차단 + if (isSystemProtectedRepository(owner, repoName)) { + throw new GlobalException(ErrorCode.SYSTEM_PROTECTED_REPOSITORY); + } + + // 대용량 저장소 사전 검사 (선택적 - 실제 GitHub API 호출 필요) + // validateRepositorySize(repoInfo); // 구현 시 GitHub API 활용 + } + + // === 유틸리티 메서드들 === + + /** + * 악성 패턴 검사 + */ + private boolean containsMaliciousPatterns(String input) { + String lowerInput = input.toLowerCase(); + String[] maliciousPatterns = { + "javascript:", "data:", "vbscript:", + "", + "union select", "drop table", "delete from", + "../", "..\\", "file://", + "localhost", "127.0.0.1", "0.0.0.0" + }; + + for (String pattern : maliciousPatterns) { + if (lowerInput.contains(pattern)) { + return true; + } + } + return false; + } + + /** + * 조직 저장소 여부 판단 (휴리스틱) + */ + private boolean isOrganizationRepository(String owner) { + // 일반적으로 조직명은 소문자, 개인 계정은 대소문자 혼용 + // 완벽하지 않지만 기본적인 구분 가능 + return owner.equals(owner.toLowerCase()) && !owner.contains("_"); + } + + /** + * 시스템 보호 저장소 여부 (보안/성능상 차단해야 할 저장소들) + */ + private boolean isSystemProtectedRepository(String owner, String repo) { + String[] protectedOwners = {"microsoft", "google", "facebook", "apache", "kubernetes"}; + String[] protectedRepos = {"linux", "chromium", "webkit", "llvm", "gcc"}; + + for (String protectedOwner : protectedOwners) { + if (owner.equals(protectedOwner)) { + return true; // 대형 조직의 대용량 저장소들 + } + } + + for (String protectedRepo : protectedRepos) { + if (repo.equals(protectedRepo)) { + return true; // 알려진 대용량 시스템 저장소들 + } + } + + return false; + } + +} diff --git a/src/main/java/com/teamEWSN/gitdeun/notification/controller/NotificationController.java b/src/main/java/com/teamEWSN/gitdeun/notification/controller/NotificationController.java index 0920563..a122231 100644 --- a/src/main/java/com/teamEWSN/gitdeun/notification/controller/NotificationController.java +++ b/src/main/java/com/teamEWSN/gitdeun/notification/controller/NotificationController.java @@ -7,6 +7,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; import org.springframework.data.web.PageableDefault; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; @@ -23,7 +24,7 @@ public class NotificationController { @GetMapping public ResponseEntity> getMyNotifications( @AuthenticationPrincipal CustomUserDetails userDetails, - @PageableDefault(size = 10, sort = "createdAt,desc") Pageable pageable) { + @PageableDefault(size = 10, sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable) { Page notifications = notificationService.getNotifications(userDetails.getId(), pageable); return ResponseEntity.ok(notifications); } diff --git a/src/main/java/com/teamEWSN/gitdeun/notification/service/NotificationSseService.java b/src/main/java/com/teamEWSN/gitdeun/notification/service/NotificationSseService.java index d45ea5c..fe99974 100644 --- a/src/main/java/com/teamEWSN/gitdeun/notification/service/NotificationSseService.java +++ b/src/main/java/com/teamEWSN/gitdeun/notification/service/NotificationSseService.java @@ -10,6 +10,8 @@ import java.util.*; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledThreadPoolExecutor; @Slf4j @Service @@ -19,8 +21,9 @@ public class NotificationSseService { // 한 사용자에 대해 여러 탭/기기의 연결을 허용 private final Map> emitters = new ConcurrentHashMap<>(); - // 1시간 - private static final long TIMEOUT_MS = 60L * 60L * 1000L; + // 타임아웃 설정 + private static final long TIMEOUT_MS = 60L * 60L * 1000L; // 1시간 + /** 클라이언트 구독 */ public SseEmitter subscribe(Long userId) { diff --git a/src/main/java/com/teamEWSN/gitdeun/repo/dto/GitHubRepositoryInfo.java b/src/main/java/com/teamEWSN/gitdeun/repo/dto/GitHubRepositoryInfo.java new file mode 100644 index 0000000..d062ab2 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/repo/dto/GitHubRepositoryInfo.java @@ -0,0 +1,17 @@ +package com.teamEWSN.gitdeun.repo.dto; + +import lombok.Builder; +import lombok.Getter; + +/** + * GitHub 저장소 정보를 담는 불변 객체 + */ +@Builder +@Getter +public class GitHubRepositoryInfo { + private final String owner; // 저장소 소유자 + private final String repositoryName; // 저장소명 + private final String normalizedUrl; // 정규화된 URL + private final String originalUrl; // 원본 입력 URL + private final boolean isOrganization; // 조직 저장소 여부 +} diff --git a/src/main/java/com/teamEWSN/gitdeun/repo/entity/Repo.java b/src/main/java/com/teamEWSN/gitdeun/repo/entity/Repo.java index 4f1abd9..6b2b828 100644 --- a/src/main/java/com/teamEWSN/gitdeun/repo/entity/Repo.java +++ b/src/main/java/com/teamEWSN/gitdeun/repo/entity/Repo.java @@ -8,6 +8,7 @@ import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; +import org.springframework.util.StringUtils; import java.time.LocalDateTime; import java.util.ArrayList; @@ -31,8 +32,8 @@ public class Repo { @Column(name = "default_branch", length = 100) private String defaultBranch; // 기본 브랜치 - @Column(name = "github_last_updated_at") - private LocalDateTime githubLastUpdatedAt; // GitHub 브랜치 최신 커밋 시간 (commit.committer.date) + @Column(name = "last_commit") + private LocalDateTime lastCommit; // GitHub 브랜치 최신 커밋 시간 (commit.committer.date) @OneToMany(mappedBy = "repo", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY) private List mindmaps = new ArrayList<>(); @@ -41,16 +42,39 @@ public class Repo { public Repo(String githubRepoUrl, String defaultBranch, LocalDateTime githubLastUpdatedAt) { this.githubRepoUrl = githubRepoUrl; this.defaultBranch = defaultBranch; - this.githubLastUpdatedAt = githubLastUpdatedAt; + this.lastCommit = githubLastUpdatedAt; } public void updateWithAnalysis(AnalysisResultDto result) { this.defaultBranch = result.getDefaultBranch(); - this.githubLastUpdatedAt = result.getGithubLastUpdatedAt(); + this.lastCommit = result.getLastCommit(); } public void updateWithWebhookData(WebhookUpdateDto dto) { this.defaultBranch = dto.getDefaultBranch(); - this.githubLastUpdatedAt = dto.getGithubLastUpdatedAt(); + this.lastCommit = dto.getLastCommit(); + } + + // 마지막 커밋 시간 업데이트 + public void updateLastCommitTime(LocalDateTime lastCommitTime) { + if (lastCommitTime != null) { + this.lastCommit = lastCommitTime; + } + } + + // 기본 브랜치 업데이트 + public void updateDefaultBranch(String defaultBranch) { + if (StringUtils.hasText(defaultBranch)) { + this.defaultBranch = defaultBranch; + } + } + + + // 저장소가 최신 상태인지 확인 + public boolean isNewerThan(LocalDateTime comparisonTime) { + if (this.lastCommit == null || comparisonTime == null) { + return false; + } + return this.lastCommit.isAfter(comparisonTime); } } \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/repo/service/RepoService.java b/src/main/java/com/teamEWSN/gitdeun/repo/service/RepoService.java index 573bf38..935e547 100644 --- a/src/main/java/com/teamEWSN/gitdeun/repo/service/RepoService.java +++ b/src/main/java/com/teamEWSN/gitdeun/repo/service/RepoService.java @@ -1,6 +1,5 @@ package com.teamEWSN.gitdeun.repo.service; -import com.teamEWSN.gitdeun.common.fastapi.FastApiClient; import com.teamEWSN.gitdeun.common.exception.ErrorCode; import com.teamEWSN.gitdeun.common.exception.GlobalException; import com.teamEWSN.gitdeun.common.fastapi.dto.AnalysisResultDto; @@ -12,7 +11,6 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.time.LocalDateTime; import java.util.Optional; @Service diff --git a/src/main/java/com/teamEWSN/gitdeun/visithistory/controller/VisitHistoryController.java b/src/main/java/com/teamEWSN/gitdeun/visithistory/controller/VisitHistoryController.java index 48f626b..19e62dd 100644 --- a/src/main/java/com/teamEWSN/gitdeun/visithistory/controller/VisitHistoryController.java +++ b/src/main/java/com/teamEWSN/gitdeun/visithistory/controller/VisitHistoryController.java @@ -8,6 +8,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; import org.springframework.data.web.PageableDefault; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; @@ -43,7 +44,7 @@ public SseEmitter streamVisitHistoryUpdates( @GetMapping("/visits") public ResponseEntity> getVisitHistories( @AuthenticationPrincipal CustomUserDetails userDetails, - @PageableDefault(size = 10, sort = "lastVisitedAt,desc") Pageable pageable + @PageableDefault(size = 10, sort = "lastVisitedAt", direction = Sort.Direction.DESC) Pageable pageable ) { Page histories = visitHistoryService.getVisitHistories(userDetails.getId(), pageable); return ResponseEntity.ok(histories); diff --git a/src/main/resources/META-INF/services/org.hibernate.boot.model.FunctionContributor b/src/main/resources/META-INF/services/org.hibernate.boot.model.FunctionContributor new file mode 100644 index 0000000..b7c097c --- /dev/null +++ b/src/main/resources/META-INF/services/org.hibernate.boot.model.FunctionContributor @@ -0,0 +1 @@ +com.teamEWSN.gitdeun.common.config.MySqlFunctionContributor \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index d69a7dd..7a81212 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -25,6 +25,7 @@ spring: order_updates: true jdbc: batch_size: 1000 + time_zone: UTC security: oauth2: client: