From 807fbdf8245489c4109f8e023cd88fdbfe2035c7 Mon Sep 17 00:00:00 2001 From: jmj Date: Sat, 23 May 2026 09:23:49 +0900 Subject: [PATCH 1/6] =?UTF-8?q?feat(resume):=20=EC=97=85=EB=A1=9C=EB=93=9C?= =?UTF-8?q?=20API=20+=20=EB=B6=84=EC=84=9D=20=ED=8A=B8=EB=A6=AC=EA=B1=B0?= =?UTF-8?q?=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=ED=9D=90=EB=A6=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - POST /api/resumes (multipart) — 파일 검증 → S3(MinIO) PUT → DB row INSERT(PENDING) → ResumeUploadedEvent 발행 - GET /api/resumes — 사용자별 목록 - GET /api/resumes/{id} — 단건 - DELETE /api/resumes/{id} — soft delete 도메인 간 결합 분리: - resume → document 직접 호출 대신 ResumeUploadedEvent(record) 발행 - document.application.ResumeAnalysisEventListener 가 받아 AnalysisRequestService 호출 → 기존 AFTER_COMMIT 분기로 analyze.resume RabbitMQ 발행 흐름 재사용 - ArchUnit top_level_domain_slices_are_free_of_cycles 통과 (document → resume 단방향 유지) SSE: - AnalysisCallbackService 가 publishToDocument 에 더해 publishToUser 도 호출 → 프론트가 documentId 사전 인지 없이 /api/stream/me 로 분석 완료/실패 수신 가능 --- .../application/AnalysisCallbackService.java | 15 +++ .../ResumeAnalysisEventListener.java | 22 +++++ .../resume/application/ResumeService.java | 98 +++++++++++++++++++ .../resume/application/dto/ResumeResult.java | 30 ++++++ .../application/dto/ResumeUploadCommand.java | 11 +++ .../event/ResumeUploadedEvent.java | 9 ++ .../stackup/stackup/resume/domain/Resume.java | 15 +++ .../resume/presentation/ResumeController.java | 77 +++++++++++++++ .../presentation/dto/ResumeResponse.java | 30 ++++++ 9 files changed, 307 insertions(+) create mode 100644 backend/src/main/java/com/stackup/stackup/document/application/ResumeAnalysisEventListener.java create mode 100644 backend/src/main/java/com/stackup/stackup/resume/application/ResumeService.java create mode 100644 backend/src/main/java/com/stackup/stackup/resume/application/dto/ResumeResult.java create mode 100644 backend/src/main/java/com/stackup/stackup/resume/application/dto/ResumeUploadCommand.java create mode 100644 backend/src/main/java/com/stackup/stackup/resume/application/event/ResumeUploadedEvent.java create mode 100644 backend/src/main/java/com/stackup/stackup/resume/presentation/ResumeController.java create mode 100644 backend/src/main/java/com/stackup/stackup/resume/presentation/dto/ResumeResponse.java diff --git a/backend/src/main/java/com/stackup/stackup/document/application/AnalysisCallbackService.java b/backend/src/main/java/com/stackup/stackup/document/application/AnalysisCallbackService.java index b7717705..bb102402 100644 --- a/backend/src/main/java/com/stackup/stackup/document/application/AnalysisCallbackService.java +++ b/backend/src/main/java/com/stackup/stackup/document/application/AnalysisCallbackService.java @@ -105,6 +105,21 @@ private void applyFailed(AnalyzedDocument doc, AnalysisCallbackPayload payload) private void publishSse(AnalyzedDocument doc, AnalysisCallbackPayload payload) { SseEventType type = doc.getRepository() != null ? SseEventType.REPO_STATE : SseEventType.DOC_STATE; sseEventPublisher.publishToDocument(doc.getId(), type, payload); + // 프론트가 documentId 를 모르고도 /api/stream/me 로 분석 진행을 받을 수 있게 user 채널에도 push. + Long userId = resolveOwnerUserId(doc); + if (userId != null) { + sseEventPublisher.publishToUser(userId, type, payload); + } + } + + private Long resolveOwnerUserId(AnalyzedDocument doc) { + if (doc.getResume() != null && doc.getResume().getUser() != null) { + return doc.getResume().getUser().getId(); + } + if (doc.getRepository() != null && doc.getRepository().getUser() != null) { + return doc.getRepository().getUser().getId(); + } + return null; } private boolean isProcessed(String messageId) { diff --git a/backend/src/main/java/com/stackup/stackup/document/application/ResumeAnalysisEventListener.java b/backend/src/main/java/com/stackup/stackup/document/application/ResumeAnalysisEventListener.java new file mode 100644 index 00000000..8564d367 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/document/application/ResumeAnalysisEventListener.java @@ -0,0 +1,22 @@ +package com.stackup.stackup.document.application; + +import com.stackup.stackup.resume.application.event.ResumeUploadedEvent; +import lombok.RequiredArgsConstructor; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; + +// resume 도메인이 발행한 ResumeUploadedEvent 를 받아 분석 트리거. +// 도메인 간 직접 호출(resume → document)을 피하기 위한 분리. document → resume 단방향만 유지. +@Component +@RequiredArgsConstructor +public class ResumeAnalysisEventListener { + + private final AnalysisRequestService analysisRequestService; + + @EventListener + public void on(ResumeUploadedEvent event) { + // 동일 트랜잭션 안에서 실행. AnalysisRequestService 내부에서 다시 @TransactionalEventListener(AFTER_COMMIT) + // 로 분기되어 트랜잭션 커밋 후 RabbitMQ 발행. + analysisRequestService.requestResumeAnalysis(event.userId(), event.resumeId()); + } +} diff --git a/backend/src/main/java/com/stackup/stackup/resume/application/ResumeService.java b/backend/src/main/java/com/stackup/stackup/resume/application/ResumeService.java new file mode 100644 index 00000000..ab282eb9 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/resume/application/ResumeService.java @@ -0,0 +1,98 @@ +package com.stackup.stackup.resume.application; + +import com.stackup.stackup.common.exception.ApiErrorCode; +import com.stackup.stackup.common.exception.DomainException; +import com.stackup.stackup.common.storage.ObjectStorageClient; +import com.stackup.stackup.resume.application.dto.ResumeResult; +import com.stackup.stackup.resume.application.dto.ResumeUploadCommand; +import com.stackup.stackup.resume.application.event.ResumeUploadedEvent; +import com.stackup.stackup.resume.domain.Resume; +import com.stackup.stackup.resume.domain.ResumeFileType; +import com.stackup.stackup.resume.domain.ResumeRepository; +import com.stackup.stackup.user.domain.User; +import com.stackup.stackup.user.domain.UserRepository; +import java.util.List; +import java.util.Locale; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ResumeService { + + private static final long MAX_FILE_SIZE_BYTES = 20L * 1024 * 1024; // 20MB (compose multipart 한도와 동일) + private static final String PDF_CONTENT_TYPE = "application/pdf"; + private static final String KEY_PREFIX = "resumes/raw"; + + private final ResumeRepository resumeRepository; + private final UserRepository userRepository; + private final ObjectStorageClient storage; + private final ApplicationEventPublisher events; + + @Transactional + public ResumeResult upload(Long userId, ResumeUploadCommand command) { + validate(command); + + User user = userRepository.findByIdAndDeletedFalse(userId) + .orElseThrow(() -> new DomainException(ApiErrorCode.USER_NOT_FOUND)); + + String key = buildKey(userId); + storage.put(key, command.content(), command.size(), PDF_CONTENT_TYPE); + + Resume resume = resumeRepository.save( + Resume.create(user, command.originalFilename(), key, ResumeFileType.PDF, command.size()) + ); + + // document 도메인 listener 가 동일 트랜잭션 안에서 AnalyzedDocument(PROCESSING) 생성 + AFTER_COMMIT 으로 analyze.resume 발행. + events.publishEvent(new ResumeUploadedEvent(userId, resume.getId())); + return ResumeResult.of(resume); + } + + public List list(Long userId) { + return resumeRepository.findByUser_IdAndDeletedFalse(userId).stream() + .map(ResumeResult::of) + .toList(); + } + + public ResumeResult get(Long userId, Long resumeId) { + return ResumeResult.of(loadOwned(userId, resumeId)); + } + + @Transactional + public void delete(Long userId, Long resumeId) { + Resume resume = loadOwned(userId, resumeId); + // 객체(S3 본문) 삭제는 후속 정책(보존 vs 즉시 cleanup)에 따라. 우선 row soft delete. + resumeRepository.delete(resume); + } + + private Resume loadOwned(Long userId, Long resumeId) { + return resumeRepository.findByIdAndUser_IdAndDeletedFalse(resumeId, userId) + .orElseThrow(() -> new DomainException(ApiErrorCode.RESUME_NOT_FOUND)); + } + + private void validate(ResumeUploadCommand command) { + if (command.size() <= 0) { + throw new DomainException(ApiErrorCode.RESUME_EMPTY_FILE); + } + if (command.size() > MAX_FILE_SIZE_BYTES) { + throw new DomainException(ApiErrorCode.RESUME_FILE_TOO_LARGE); + } + String name = command.originalFilename(); + if (name == null || !name.toLowerCase(Locale.ROOT).endsWith(".pdf")) { + throw new DomainException(ApiErrorCode.RESUME_INVALID_FILE_TYPE); + } + String contentType = command.contentType(); + if (contentType != null && !contentType.equalsIgnoreCase(PDF_CONTENT_TYPE)) { + throw new DomainException(ApiErrorCode.RESUME_INVALID_FILE_TYPE); + } + } + + private String buildKey(Long userId) { + // resumes/raw/{userId}/{uuid}.pdf — docs/storage.md §2 컨벤션 + return KEY_PREFIX + "/" + userId + "/" + UUID.randomUUID() + ".pdf"; + } +} diff --git a/backend/src/main/java/com/stackup/stackup/resume/application/dto/ResumeResult.java b/backend/src/main/java/com/stackup/stackup/resume/application/dto/ResumeResult.java new file mode 100644 index 00000000..aa745cbb --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/resume/application/dto/ResumeResult.java @@ -0,0 +1,30 @@ +package com.stackup.stackup.resume.application.dto; + +import com.stackup.stackup.resume.domain.Resume; +import com.stackup.stackup.resume.domain.ResumeFileType; +import com.stackup.stackup.resume.domain.ResumeStatus; +import java.time.Instant; + +public record ResumeResult( + Long id, + String originalFilename, + String filePath, + ResumeFileType fileType, + Long fileSize, + ResumeStatus status, + Instant createdAt, + Instant updatedAt +) { + public static ResumeResult of(Resume resume) { + return new ResumeResult( + resume.getId(), + resume.getOriginalFilename(), + resume.getFilePath(), + resume.getFileType(), + resume.getFileSize(), + resume.getStatus(), + resume.getCreatedAt(), + resume.getUpdatedAt() + ); + } +} diff --git a/backend/src/main/java/com/stackup/stackup/resume/application/dto/ResumeUploadCommand.java b/backend/src/main/java/com/stackup/stackup/resume/application/dto/ResumeUploadCommand.java new file mode 100644 index 00000000..72756d5b --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/resume/application/dto/ResumeUploadCommand.java @@ -0,0 +1,11 @@ +package com.stackup.stackup.resume.application.dto; + +import java.io.InputStream; + +public record ResumeUploadCommand( + String originalFilename, + String contentType, + long size, + InputStream content +) { +} diff --git a/backend/src/main/java/com/stackup/stackup/resume/application/event/ResumeUploadedEvent.java b/backend/src/main/java/com/stackup/stackup/resume/application/event/ResumeUploadedEvent.java new file mode 100644 index 00000000..4c238ddc --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/resume/application/event/ResumeUploadedEvent.java @@ -0,0 +1,9 @@ +package com.stackup.stackup.resume.application.event; + +// 이력서 업로드 직후 발행. document 도메인이 listener 로 받아 분석 트리거. +// resume 도메인이 document 도메인을 직접 의존하지 않도록 분리하기 위한 매개체. +public record ResumeUploadedEvent( + Long userId, + Long resumeId +) { +} diff --git a/backend/src/main/java/com/stackup/stackup/resume/domain/Resume.java b/backend/src/main/java/com/stackup/stackup/resume/domain/Resume.java index d86197b8..a7e1bec7 100644 --- a/backend/src/main/java/com/stackup/stackup/resume/domain/Resume.java +++ b/backend/src/main/java/com/stackup/stackup/resume/domain/Resume.java @@ -54,6 +54,21 @@ public class Resume extends BaseSoftDeleteEntity { @Enumerated(EnumType.STRING) private ResumeStatus status = ResumeStatus.PENDING; + private Resume(User user, String originalFilename, String filePath, ResumeFileType fileType, Long fileSize) { + this.user = user; + this.originalFilename = originalFilename; + this.filePath = filePath; + this.fileType = fileType; + this.fileSize = fileSize; + } + + public static Resume create(User user, String originalFilename, String filePath, ResumeFileType fileType, Long fileSize) { + if (user == null) { + throw new IllegalArgumentException("user must not be null"); + } + return new Resume(user, originalFilename, filePath, fileType, fileSize); + } + public void markAnalyzing() { this.status = ResumeStatus.ANALYZING; } diff --git a/backend/src/main/java/com/stackup/stackup/resume/presentation/ResumeController.java b/backend/src/main/java/com/stackup/stackup/resume/presentation/ResumeController.java new file mode 100644 index 00000000..a8509dc6 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/resume/presentation/ResumeController.java @@ -0,0 +1,77 @@ +package com.stackup.stackup.resume.presentation; + +import com.stackup.stackup.common.exception.ApiErrorCode; +import com.stackup.stackup.common.exception.DomainException; +import com.stackup.stackup.common.security.UserPrincipal; +import com.stackup.stackup.resume.application.ResumeService; +import com.stackup.stackup.resume.application.dto.ResumeUploadCommand; +import com.stackup.stackup.resume.presentation.dto.ResumeResponse; +import java.io.IOException; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +@RestController +@RequestMapping("/api/resumes") +@RequiredArgsConstructor +public class ResumeController { + + private final ResumeService resumeService; + + @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @ResponseStatus(HttpStatus.CREATED) + public ResumeResponse upload( + @AuthenticationPrincipal UserPrincipal principal, + @RequestParam("file") MultipartFile file + ) { + if (file == null || file.isEmpty()) { + throw new DomainException(ApiErrorCode.RESUME_EMPTY_FILE); + } + try { + ResumeUploadCommand command = new ResumeUploadCommand( + file.getOriginalFilename(), + file.getContentType(), + file.getSize(), + file.getInputStream() + ); + return ResumeResponse.from(resumeService.upload(principal.userId(), command)); + } catch (IOException e) { + throw new DomainException(ApiErrorCode.SYS_INTERNAL_ERROR); + } + } + + @GetMapping + public List list(@AuthenticationPrincipal UserPrincipal principal) { + return resumeService.list(principal.userId()).stream() + .map(ResumeResponse::from) + .toList(); + } + + @GetMapping("/{resumeId}") + public ResumeResponse get( + @AuthenticationPrincipal UserPrincipal principal, + @PathVariable Long resumeId + ) { + return ResumeResponse.from(resumeService.get(principal.userId(), resumeId)); + } + + @DeleteMapping("/{resumeId}") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void delete( + @AuthenticationPrincipal UserPrincipal principal, + @PathVariable Long resumeId + ) { + resumeService.delete(principal.userId(), resumeId); + } +} diff --git a/backend/src/main/java/com/stackup/stackup/resume/presentation/dto/ResumeResponse.java b/backend/src/main/java/com/stackup/stackup/resume/presentation/dto/ResumeResponse.java new file mode 100644 index 00000000..b272d2b4 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/resume/presentation/dto/ResumeResponse.java @@ -0,0 +1,30 @@ +package com.stackup.stackup.resume.presentation.dto; + +import com.stackup.stackup.resume.application.dto.ResumeResult; +import com.stackup.stackup.resume.domain.ResumeFileType; +import com.stackup.stackup.resume.domain.ResumeStatus; +import java.time.Instant; + +public record ResumeResponse( + Long id, + String originalFilename, + String filePath, + ResumeFileType fileType, + Long fileSize, + ResumeStatus status, + Instant createdAt, + Instant updatedAt +) { + public static ResumeResponse from(ResumeResult result) { + return new ResumeResponse( + result.id(), + result.originalFilename(), + result.filePath(), + result.fileType(), + result.fileSize(), + result.status(), + result.createdAt(), + result.updatedAt() + ); + } +} From 1406b3d39cea3a7fd6abe95f6c6789db945d55a8 Mon Sep 17 00:00:00 2001 From: jmj Date: Sat, 23 May 2026 09:33:28 +0900 Subject: [PATCH 2/6] =?UTF-8?q?feat(github,document):=20=EB=A0=88=ED=8F=AC?= =?UTF-8?q?=20=EB=93=B1=EB=A1=9D=20API=20+=20=EB=B6=84=EC=84=9D=20?= =?UTF-8?q?=EA=B2=B0=EA=B3=BC=20=EC=A1=B0=ED=9A=8C=20API=20(US-12)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## github 도메인 — 레포 등록 진입점 - POST /api/repositories — 후보 중 선택 등록. GitHub API 로 메타 재확인 후 INSERT. 중복(REPO_ALREADY_REGISTERED), 미존재(REPO_NOT_FOUND), 비공개 접근불가(REPO_PRIVATE_NO_ACCESS) 분기. - GET /api/repositories — 등록된 레포 목록 - GET /api/repositories/github?page=&perPage= — GitHub 의 사용자 레포 후보 페이지 (alreadyRegistered 플래그) - GET /api/repositories/{id} — 단건 - DELETE /api/repositories/{id} — soft delete 추가 인프라: - GithubApiHttpClient.getRepository, listUserRepositories (RestClient HttpExchange) - GithubApiClient: 위 두 메서드 + 4xx 상세 분기 (404 → REPO_NOT_FOUND, 403 → REPO_PRIVATE_NO_ACCESS) - GithubRepository.create 정적 팩토리 - GithubRepositoryRepository: findByUser_IdAndGithubRepoIdAndDeletedFalse, findByUser_IdAndGithubRepoIdInAndDeletedFalse (후보 목록의 alreadyRegistered 계산용) - application/event/RepositoryRegisteredEvent + document/application/RepositoryAnalysisEventListener → resume 와 동일한 이벤트 디커플 패턴 (github → document 직접 의존 회피) ## document 도메인 — US-12 결과 조회 - GET /api/documents — 사용자 보유 전체 (filter: resumeId | repositoryId 옵션) - GET /api/documents/{id} — 상세 + presigned S3 다운로드 URL (10분 TTL) - AnalyzedDocumentQueryService: tech_stack JSON → List 역직렬화 - AnalyzedDocumentRepository: JPQL 4개 추가 — findActive{ByOwner,ByIdAndOwner, ByResumeIdAndOwner,ByRepositoryIdAndOwner} (deleted=false + owner 검증 단일 쿼리) 빌드/테스트: ./gradlew test BUILD SUCCESSFUL (ArchUnit 포함) --- .../AnalyzedDocumentQueryService.java | 79 +++++++++++ .../RepositoryAnalysisEventListener.java | 20 +++ .../dto/AnalyzedDocumentResult.java | 56 ++++++++ .../domain/AnalyzedDocumentRepository.java | 38 +++++ .../AnalyzedDocumentController.java | 40 ++++++ .../dto/AnalyzedDocumentResponse.java | 44 ++++++ .../application/GithubRepositoryService.java | 133 ++++++++++++++++++ .../dto/CandidateRepositoryResult.java | 12 ++ .../dto/GithubRepositoryResult.java | 33 +++++ .../dto/RegisterRepositoryCommand.java | 7 + .../event/RepositoryRegisteredEvent.java | 8 ++ .../github/domain/GithubRepository.java | 20 +++ .../domain/GithubRepositoryRepository.java | 5 + .../infrastructure/GithubApiClient.java | 63 +++++++++ .../infrastructure/GithubApiHttpClient.java | 19 +++ .../dto/GithubRepoResponse.java | 21 +++ .../GithubRepositoryController.java | 73 ++++++++++ .../dto/CandidateRepositoryResponse.java | 25 ++++ .../dto/RegisterRepositoryRequest.java | 14 ++ .../dto/RegisteredRepositoryResponse.java | 33 +++++ 20 files changed, 743 insertions(+) create mode 100644 backend/src/main/java/com/stackup/stackup/document/application/AnalyzedDocumentQueryService.java create mode 100644 backend/src/main/java/com/stackup/stackup/document/application/RepositoryAnalysisEventListener.java create mode 100644 backend/src/main/java/com/stackup/stackup/document/application/dto/AnalyzedDocumentResult.java create mode 100644 backend/src/main/java/com/stackup/stackup/document/presentation/AnalyzedDocumentController.java create mode 100644 backend/src/main/java/com/stackup/stackup/document/presentation/dto/AnalyzedDocumentResponse.java create mode 100644 backend/src/main/java/com/stackup/stackup/github/application/GithubRepositoryService.java create mode 100644 backend/src/main/java/com/stackup/stackup/github/application/dto/CandidateRepositoryResult.java create mode 100644 backend/src/main/java/com/stackup/stackup/github/application/dto/GithubRepositoryResult.java create mode 100644 backend/src/main/java/com/stackup/stackup/github/application/dto/RegisterRepositoryCommand.java create mode 100644 backend/src/main/java/com/stackup/stackup/github/application/event/RepositoryRegisteredEvent.java create mode 100644 backend/src/main/java/com/stackup/stackup/github/infrastructure/dto/GithubRepoResponse.java create mode 100644 backend/src/main/java/com/stackup/stackup/github/presentation/GithubRepositoryController.java create mode 100644 backend/src/main/java/com/stackup/stackup/github/presentation/dto/CandidateRepositoryResponse.java create mode 100644 backend/src/main/java/com/stackup/stackup/github/presentation/dto/RegisterRepositoryRequest.java create mode 100644 backend/src/main/java/com/stackup/stackup/github/presentation/dto/RegisteredRepositoryResponse.java diff --git a/backend/src/main/java/com/stackup/stackup/document/application/AnalyzedDocumentQueryService.java b/backend/src/main/java/com/stackup/stackup/document/application/AnalyzedDocumentQueryService.java new file mode 100644 index 00000000..4ae784c1 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/document/application/AnalyzedDocumentQueryService.java @@ -0,0 +1,79 @@ +package com.stackup.stackup.document.application; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.json.JsonMapper; +import com.stackup.stackup.common.exception.ApiErrorCode; +import com.stackup.stackup.common.exception.DomainException; +import com.stackup.stackup.common.storage.ObjectStorageClient; +import com.stackup.stackup.common.storage.StorageException; +import com.stackup.stackup.document.application.dto.AnalyzedDocumentResult; +import com.stackup.stackup.document.domain.AnalyzedDocument; +import com.stackup.stackup.document.domain.AnalyzedDocumentRepository; +import java.net.URI; +import java.time.Duration; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AnalyzedDocumentQueryService { + + private static final Logger log = LoggerFactory.getLogger(AnalyzedDocumentQueryService.class); + private static final JsonMapper JSON = JsonMapper.builder().build(); + private static final TypeReference> TECH_STACK_TYPE = new TypeReference<>() {}; + private static final Duration DOWNLOAD_URL_TTL = Duration.ofMinutes(10); + + private final AnalyzedDocumentRepository documentRepository; + private final ObjectStorageClient storage; + + public List listForUser(Long userId, Long resumeId, Long repositoryId) { + List rows; + if (resumeId != null) { + rows = documentRepository.findActiveByResumeIdAndOwner(resumeId, userId); + } else if (repositoryId != null) { + rows = documentRepository.findActiveByRepositoryIdAndOwner(repositoryId, userId); + } else { + rows = documentRepository.findActiveByOwner(userId); + } + return rows.stream() + .map(doc -> AnalyzedDocumentResult.of(doc, parseTechStack(doc.getTechStack()), null)) + .toList(); + } + + public AnalyzedDocumentResult getForUser(Long userId, Long documentId) { + AnalyzedDocument doc = documentRepository.findActiveByIdAndOwner(documentId, userId) + .orElseThrow(() -> new DomainException(ApiErrorCode.DOC_NOT_FOUND)); + URI downloadUrl = presignedDownloadUrl(doc.getDocumentPath()); + return AnalyzedDocumentResult.of(doc, parseTechStack(doc.getTechStack()), downloadUrl); + } + + private List parseTechStack(String json) { + if (json == null || json.isBlank()) { + return List.of(); + } + try { + return JSON.readValue(json, TECH_STACK_TYPE); + } catch (JsonProcessingException e) { + log.warn("techStack parse failed, return empty. raw={}", json, e); + return List.of(); + } + } + + private URI presignedDownloadUrl(String key) { + if (key == null || key.isBlank()) { + return null; + } + try { + return storage.createPresignedGetUrl(key, DOWNLOAD_URL_TTL); + } catch (StorageException e) { + log.warn("presigned URL creation failed. key={}", key, e); + return null; + } + } +} diff --git a/backend/src/main/java/com/stackup/stackup/document/application/RepositoryAnalysisEventListener.java b/backend/src/main/java/com/stackup/stackup/document/application/RepositoryAnalysisEventListener.java new file mode 100644 index 00000000..3ff4f1b3 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/document/application/RepositoryAnalysisEventListener.java @@ -0,0 +1,20 @@ +package com.stackup.stackup.document.application; + +import com.stackup.stackup.github.application.event.RepositoryRegisteredEvent; +import lombok.RequiredArgsConstructor; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; + +// github 도메인 RepositoryRegisteredEvent 수신 → 분석 트리거. +// github 가 document 를 직접 의존하지 않도록 분리. +@Component +@RequiredArgsConstructor +public class RepositoryAnalysisEventListener { + + private final AnalysisRequestService analysisRequestService; + + @EventListener + public void on(RepositoryRegisteredEvent event) { + analysisRequestService.requestRepositoryAnalysis(event.userId(), event.repositoryId()); + } +} diff --git a/backend/src/main/java/com/stackup/stackup/document/application/dto/AnalyzedDocumentResult.java b/backend/src/main/java/com/stackup/stackup/document/application/dto/AnalyzedDocumentResult.java new file mode 100644 index 00000000..a9404d97 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/document/application/dto/AnalyzedDocumentResult.java @@ -0,0 +1,56 @@ +package com.stackup.stackup.document.application.dto; + +import com.stackup.stackup.document.domain.AnalysisStatus; +import com.stackup.stackup.document.domain.AnalyzedDocument; +import com.stackup.stackup.document.domain.DocumentStatus; +import java.net.URI; +import java.time.Instant; +import java.util.List; + +public record AnalyzedDocumentResult( + Long id, + String sourceType, // "RESUME" | "REPOSITORY" + Long sourceId, + String documentPath, // S3 키 (raw 경로) + URI documentDownloadUrl, // presigned (detail 응답에서만 채움) + String summary, + List techStack, + int embeddingChunkCount, + AnalysisStatus analysisStatus, + String errorCode, + String errorMessage, + DocumentStatus status, + Instant createdAt, + Instant updatedAt +) { + public static AnalyzedDocumentResult of(AnalyzedDocument doc, List techStack, URI downloadUrl) { + return new AnalyzedDocumentResult( + doc.getId(), + resolveSourceType(doc), + resolveSourceId(doc), + doc.getDocumentPath(), + downloadUrl, + doc.getSummary(), + techStack, + doc.getEmbeddingChunkCount(), + doc.getAnalysisStatus(), + doc.getErrorCode(), + doc.getErrorMessage(), + doc.getStatus(), + doc.getCreatedAt(), + doc.getUpdatedAt() + ); + } + + private static String resolveSourceType(AnalyzedDocument doc) { + if (doc.getResume() != null) return "RESUME"; + if (doc.getRepository() != null) return "REPOSITORY"; + return null; + } + + private static Long resolveSourceId(AnalyzedDocument doc) { + if (doc.getResume() != null) return doc.getResume().getId(); + if (doc.getRepository() != null) return doc.getRepository().getId(); + return null; + } +} diff --git a/backend/src/main/java/com/stackup/stackup/document/domain/AnalyzedDocumentRepository.java b/backend/src/main/java/com/stackup/stackup/document/domain/AnalyzedDocumentRepository.java index 981382f6..14a230a3 100644 --- a/backend/src/main/java/com/stackup/stackup/document/domain/AnalyzedDocumentRepository.java +++ b/backend/src/main/java/com/stackup/stackup/document/domain/AnalyzedDocumentRepository.java @@ -3,6 +3,8 @@ import java.util.List; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; public interface AnalyzedDocumentRepository extends JpaRepository { @@ -14,4 +16,40 @@ Optional findByIdAndResume_User_IdOrIdAndRepository_User_Id( Long repositoryDocumentId, Long repositoryUserId ); + + @Query(""" + SELECT d FROM AnalyzedDocument d + WHERE d.deleted = false + AND ((d.resume IS NOT NULL AND d.resume.user.id = :userId) + OR (d.repository IS NOT NULL AND d.repository.user.id = :userId)) + ORDER BY d.id DESC + """) + List findActiveByOwner(@Param("userId") Long userId); + + @Query(""" + SELECT d FROM AnalyzedDocument d + WHERE d.id = :id + AND d.deleted = false + AND ((d.resume IS NOT NULL AND d.resume.user.id = :userId) + OR (d.repository IS NOT NULL AND d.repository.user.id = :userId)) + """) + Optional findActiveByIdAndOwner(@Param("id") Long id, @Param("userId") Long userId); + + @Query(""" + SELECT d FROM AnalyzedDocument d + WHERE d.resume.id = :resumeId + AND d.resume.user.id = :userId + AND d.deleted = false + ORDER BY d.id DESC + """) + List findActiveByResumeIdAndOwner(@Param("resumeId") Long resumeId, @Param("userId") Long userId); + + @Query(""" + SELECT d FROM AnalyzedDocument d + WHERE d.repository.id = :repositoryId + AND d.repository.user.id = :userId + AND d.deleted = false + ORDER BY d.id DESC + """) + List findActiveByRepositoryIdAndOwner(@Param("repositoryId") Long repositoryId, @Param("userId") Long userId); } diff --git a/backend/src/main/java/com/stackup/stackup/document/presentation/AnalyzedDocumentController.java b/backend/src/main/java/com/stackup/stackup/document/presentation/AnalyzedDocumentController.java new file mode 100644 index 00000000..31e9851a --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/document/presentation/AnalyzedDocumentController.java @@ -0,0 +1,40 @@ +package com.stackup.stackup.document.presentation; + +import com.stackup.stackup.common.security.UserPrincipal; +import com.stackup.stackup.document.application.AnalyzedDocumentQueryService; +import com.stackup.stackup.document.presentation.dto.AnalyzedDocumentResponse; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/documents") +@RequiredArgsConstructor +public class AnalyzedDocumentController { + + private final AnalyzedDocumentQueryService queryService; + + @GetMapping + public List list( + @AuthenticationPrincipal UserPrincipal principal, + @RequestParam(required = false) Long resumeId, + @RequestParam(required = false) Long repositoryId + ) { + return queryService.listForUser(principal.userId(), resumeId, repositoryId).stream() + .map(AnalyzedDocumentResponse::from) + .toList(); + } + + @GetMapping("/{documentId}") + public AnalyzedDocumentResponse get( + @AuthenticationPrincipal UserPrincipal principal, + @PathVariable Long documentId + ) { + return AnalyzedDocumentResponse.from(queryService.getForUser(principal.userId(), documentId)); + } +} diff --git a/backend/src/main/java/com/stackup/stackup/document/presentation/dto/AnalyzedDocumentResponse.java b/backend/src/main/java/com/stackup/stackup/document/presentation/dto/AnalyzedDocumentResponse.java new file mode 100644 index 00000000..f9f7be0d --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/document/presentation/dto/AnalyzedDocumentResponse.java @@ -0,0 +1,44 @@ +package com.stackup.stackup.document.presentation.dto; + +import com.stackup.stackup.document.application.dto.AnalyzedDocumentResult; +import com.stackup.stackup.document.domain.AnalysisStatus; +import com.stackup.stackup.document.domain.DocumentStatus; +import java.net.URI; +import java.time.Instant; +import java.util.List; + +public record AnalyzedDocumentResponse( + Long id, + String sourceType, + Long sourceId, + String documentPath, + URI documentDownloadUrl, + String summary, + List techStack, + int embeddingChunkCount, + AnalysisStatus analysisStatus, + String errorCode, + String errorMessage, + DocumentStatus status, + Instant createdAt, + Instant updatedAt +) { + public static AnalyzedDocumentResponse from(AnalyzedDocumentResult r) { + return new AnalyzedDocumentResponse( + r.id(), + r.sourceType(), + r.sourceId(), + r.documentPath(), + r.documentDownloadUrl(), + r.summary(), + r.techStack(), + r.embeddingChunkCount(), + r.analysisStatus(), + r.errorCode(), + r.errorMessage(), + r.status(), + r.createdAt(), + r.updatedAt() + ); + } +} diff --git a/backend/src/main/java/com/stackup/stackup/github/application/GithubRepositoryService.java b/backend/src/main/java/com/stackup/stackup/github/application/GithubRepositoryService.java new file mode 100644 index 00000000..2ee2d624 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/github/application/GithubRepositoryService.java @@ -0,0 +1,133 @@ +package com.stackup.stackup.github.application; + +import com.stackup.stackup.common.exception.ApiErrorCode; +import com.stackup.stackup.common.exception.DomainException; +import com.stackup.stackup.github.application.dto.CandidateRepositoryResult; +import com.stackup.stackup.github.application.dto.GithubRepositoryResult; +import com.stackup.stackup.github.application.dto.RegisterRepositoryCommand; +import com.stackup.stackup.github.application.event.RepositoryRegisteredEvent; +import com.stackup.stackup.github.domain.GithubRepository; +import com.stackup.stackup.github.domain.GithubRepositoryRepository; +import com.stackup.stackup.github.infrastructure.GithubApiClient; +import com.stackup.stackup.github.infrastructure.dto.GithubRepoResponse; +import com.stackup.stackup.user.domain.User; +import com.stackup.stackup.user.domain.UserRepository; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class GithubRepositoryService { + + private static final int CANDIDATE_DEFAULT_PER_PAGE = 30; + private static final int CANDIDATE_MAX_PER_PAGE = 100; + + private final GithubRepositoryRepository repositoryRepository; + private final UserRepository userRepository; + private final InternalGithubTokenService tokenService; + private final GithubApiClient githubApiClient; + private final ApplicationEventPublisher events; + + @Transactional + public GithubRepositoryResult register(Long userId, RegisterRepositoryCommand command) { + validate(command); + + User user = userRepository.findByIdAndDeletedFalse(userId) + .orElseThrow(() -> new DomainException(ApiErrorCode.USER_NOT_FOUND)); + + if (repositoryRepository.findByUser_IdAndGithubRepoIdAndDeletedFalse(userId, command.githubRepoId()).isPresent()) { + throw new DomainException(ApiErrorCode.REPO_ALREADY_REGISTERED); + } + + // GitHub API 로 메타 재확인 (사용자 토큰 권한 검증 겸 최신 default_branch 확보) + String accessToken = tokenService.fetchPlainAccessToken(userId); + GithubRepoResponse meta = githubApiClient.getRepository(accessToken, command.fullName()); + + if (meta.id() == null || !meta.id().equals(command.githubRepoId())) { + // 클라이언트가 보낸 id 와 fullName 이 GitHub 상에서 불일치 — 조작/오래된 캐시 + throw new DomainException(ApiErrorCode.VALIDATION_ERROR); + } + + GithubRepository repo = repositoryRepository.save(GithubRepository.create( + user, + meta.id(), + meta.name(), + meta.fullName(), + meta.htmlUrl(), + meta.defaultBranch() + )); + + events.publishEvent(new RepositoryRegisteredEvent(userId, repo.getId())); + return GithubRepositoryResult.of(repo); + } + + public List listRegistered(Long userId) { + return repositoryRepository.findByUser_IdAndDeletedFalse(userId).stream() + .map(GithubRepositoryResult::of) + .toList(); + } + + public GithubRepositoryResult get(Long userId, Long repositoryId) { + return GithubRepositoryResult.of(loadOwned(userId, repositoryId)); + } + + @Transactional + public void delete(Long userId, Long repositoryId) { + GithubRepository repo = loadOwned(userId, repositoryId); + repositoryRepository.delete(repo); + } + + public List listCandidates(Long userId, int page, int perPage) { + int actualPerPage = (perPage <= 0) ? CANDIDATE_DEFAULT_PER_PAGE : Math.min(perPage, CANDIDATE_MAX_PER_PAGE); + int actualPage = Math.max(page, 1); + + String accessToken = tokenService.fetchPlainAccessToken(userId); + List ghRepos = githubApiClient.listUserRepositories(accessToken, actualPage, actualPerPage); + if (ghRepos.isEmpty()) { + return List.of(); + } + + Set ghRepoIds = new HashSet<>(); + for (GithubRepoResponse r : ghRepos) { + if (r.id() != null) { + ghRepoIds.add(r.id()); + } + } + Set registeredIds = new HashSet<>(); + for (GithubRepository r : repositoryRepository.findByUser_IdAndGithubRepoIdInAndDeletedFalse(userId, ghRepoIds)) { + registeredIds.add(r.getGithubRepoId()); + } + + return ghRepos.stream() + .filter(r -> r.id() != null) + .map(r -> new CandidateRepositoryResult( + r.id(), + r.name(), + r.fullName(), + r.htmlUrl(), + r.defaultBranch(), + Boolean.TRUE.equals(r.isPrivate()), + registeredIds.contains(r.id()) + )) + .toList(); + } + + private GithubRepository loadOwned(Long userId, Long repositoryId) { + return repositoryRepository.findByIdAndUser_IdAndDeletedFalse(repositoryId, userId) + .orElseThrow(() -> new DomainException(ApiErrorCode.REPO_NOT_FOUND)); + } + + private void validate(RegisterRepositoryCommand command) { + if (command == null || command.githubRepoId() == null + || command.fullName() == null || command.fullName().isBlank() + || !command.fullName().contains("/")) { + throw new DomainException(ApiErrorCode.VALIDATION_ERROR); + } + } +} diff --git a/backend/src/main/java/com/stackup/stackup/github/application/dto/CandidateRepositoryResult.java b/backend/src/main/java/com/stackup/stackup/github/application/dto/CandidateRepositoryResult.java new file mode 100644 index 00000000..4f156000 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/github/application/dto/CandidateRepositoryResult.java @@ -0,0 +1,12 @@ +package com.stackup.stackup.github.application.dto; + +public record CandidateRepositoryResult( + Long githubRepoId, + String name, + String fullName, + String htmlUrl, + String defaultBranch, + boolean isPrivate, + boolean alreadyRegistered +) { +} diff --git a/backend/src/main/java/com/stackup/stackup/github/application/dto/GithubRepositoryResult.java b/backend/src/main/java/com/stackup/stackup/github/application/dto/GithubRepositoryResult.java new file mode 100644 index 00000000..4e07452a --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/github/application/dto/GithubRepositoryResult.java @@ -0,0 +1,33 @@ +package com.stackup.stackup.github.application.dto; + +import com.stackup.stackup.github.domain.GithubRepository; +import com.stackup.stackup.github.domain.RepositoryStatus; +import java.time.Instant; + +public record GithubRepositoryResult( + Long id, + Long githubRepoId, + String repoName, + String repoFullName, + String repoUrl, + String defaultBranch, + RepositoryStatus status, + Instant lastSyncedAt, + Instant createdAt, + Instant updatedAt +) { + public static GithubRepositoryResult of(GithubRepository repo) { + return new GithubRepositoryResult( + repo.getId(), + repo.getGithubRepoId(), + repo.getRepoName(), + repo.getRepoFullName(), + repo.getRepoUrl(), + repo.getDefaultBranch(), + repo.getStatus(), + repo.getLastSyncedAt(), + repo.getCreatedAt(), + repo.getUpdatedAt() + ); + } +} diff --git a/backend/src/main/java/com/stackup/stackup/github/application/dto/RegisterRepositoryCommand.java b/backend/src/main/java/com/stackup/stackup/github/application/dto/RegisterRepositoryCommand.java new file mode 100644 index 00000000..5d1c71c1 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/github/application/dto/RegisterRepositoryCommand.java @@ -0,0 +1,7 @@ +package com.stackup.stackup.github.application.dto; + +public record RegisterRepositoryCommand( + Long githubRepoId, + String fullName +) { +} diff --git a/backend/src/main/java/com/stackup/stackup/github/application/event/RepositoryRegisteredEvent.java b/backend/src/main/java/com/stackup/stackup/github/application/event/RepositoryRegisteredEvent.java new file mode 100644 index 00000000..9514c896 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/github/application/event/RepositoryRegisteredEvent.java @@ -0,0 +1,8 @@ +package com.stackup.stackup.github.application.event; + +// 레포 등록 직후 발행. document 도메인이 listener 로 받아 분석 트리거. +public record RepositoryRegisteredEvent( + Long userId, + Long repositoryId +) { +} diff --git a/backend/src/main/java/com/stackup/stackup/github/domain/GithubRepository.java b/backend/src/main/java/com/stackup/stackup/github/domain/GithubRepository.java index c589806d..3fdeb687 100644 --- a/backend/src/main/java/com/stackup/stackup/github/domain/GithubRepository.java +++ b/backend/src/main/java/com/stackup/stackup/github/domain/GithubRepository.java @@ -65,6 +65,26 @@ public class GithubRepository extends BaseSoftDeleteEntity { @Column(name = "last_synced_at") private Instant lastSyncedAt; + private GithubRepository(User user, Long githubRepoId, String repoName, String repoFullName, + String repoUrl, String defaultBranch) { + this.user = user; + this.githubRepoId = githubRepoId; + this.repoName = repoName; + this.repoFullName = repoFullName; + this.repoUrl = repoUrl; + if (defaultBranch != null && !defaultBranch.isBlank()) { + this.defaultBranch = defaultBranch; + } + } + + public static GithubRepository create(User user, Long githubRepoId, String repoName, + String repoFullName, String repoUrl, String defaultBranch) { + if (user == null) { + throw new IllegalArgumentException("user must not be null"); + } + return new GithubRepository(user, githubRepoId, repoName, repoFullName, repoUrl, defaultBranch); + } + public void markAnalyzing() { this.status = RepositoryStatus.ANALYZING; } diff --git a/backend/src/main/java/com/stackup/stackup/github/domain/GithubRepositoryRepository.java b/backend/src/main/java/com/stackup/stackup/github/domain/GithubRepositoryRepository.java index 8301dc61..b7c2514a 100644 --- a/backend/src/main/java/com/stackup/stackup/github/domain/GithubRepositoryRepository.java +++ b/backend/src/main/java/com/stackup/stackup/github/domain/GithubRepositoryRepository.java @@ -2,6 +2,7 @@ import java.util.List; import java.util.Optional; +import java.util.Set; import org.springframework.data.jpa.repository.JpaRepository; public interface GithubRepositoryRepository extends JpaRepository { @@ -9,4 +10,8 @@ public interface GithubRepositoryRepository extends JpaRepository findByUser_IdAndDeletedFalse(Long userId); Optional findByIdAndUser_IdAndDeletedFalse(Long id, Long userId); + + Optional findByUser_IdAndGithubRepoIdAndDeletedFalse(Long userId, Long githubRepoId); + + List findByUser_IdAndGithubRepoIdInAndDeletedFalse(Long userId, Set githubRepoIds); } diff --git a/backend/src/main/java/com/stackup/stackup/github/infrastructure/GithubApiClient.java b/backend/src/main/java/com/stackup/stackup/github/infrastructure/GithubApiClient.java index a5d948da..de3a84e6 100644 --- a/backend/src/main/java/com/stackup/stackup/github/infrastructure/GithubApiClient.java +++ b/backend/src/main/java/com/stackup/stackup/github/infrastructure/GithubApiClient.java @@ -3,10 +3,14 @@ import com.stackup.stackup.common.exception.ApiErrorCode; import com.stackup.stackup.common.exception.DomainException; import com.stackup.stackup.github.infrastructure.dto.GithubEmailResponse; +import com.stackup.stackup.github.infrastructure.dto.GithubRepoResponse; import com.stackup.stackup.github.infrastructure.dto.GithubUserResponse; import java.util.Arrays; +import java.util.List; import java.util.Optional; +import org.springframework.http.HttpStatusCode; import org.springframework.stereotype.Component; +import org.springframework.web.client.HttpClientErrorException; import org.springframework.web.client.RestClientException; @Component @@ -50,6 +54,65 @@ public Optional getPrimaryVerifiedEmail(String githubAccessToken) { } } + public GithubRepoResponse getRepository(String accessToken, String fullName) { + String[] parts = parseFullName(fullName); + try { + GithubRepoResponse response = githubApiHttpClient.getRepository(bearer(accessToken), parts[0], parts[1]); + if (response == null || response.id() == null || response.fullName() == null) { + throw repoApiFailed(); + } + return response; + } catch (HttpClientErrorException exception) { + HttpStatusCode status = exception.getStatusCode(); + if (status.value() == 404) { + throw new DomainException(ApiErrorCode.REPO_NOT_FOUND); + } + if (status.value() == 403) { + throw new DomainException(ApiErrorCode.REPO_PRIVATE_NO_ACCESS); + } + throw repoApiFailed(exception); + } catch (RestClientException exception) { + throw repoApiFailed(exception); + } + } + + public List listUserRepositories(String accessToken, int page, int perPage) { + try { + GithubRepoResponse[] response = githubApiHttpClient.listUserRepositories( + bearer(accessToken), perPage, page, "updated", "owner" + ); + if (response == null) { + return List.of(); + } + return Arrays.asList(response); + } catch (RestClientException exception) { + throw repoApiFailed(exception); + } + } + + private String[] parseFullName(String fullName) { + if (fullName == null) { + throw new DomainException(ApiErrorCode.VALIDATION_ERROR); + } + int slash = fullName.indexOf('/'); + if (slash <= 0 || slash == fullName.length() - 1) { + throw new DomainException(ApiErrorCode.VALIDATION_ERROR); + } + return new String[]{fullName.substring(0, slash), fullName.substring(slash + 1)}; + } + + private DomainException repoApiFailed() { + return new DomainException(ApiErrorCode.REPO_GITHUB_API_FAILED); + } + + private DomainException repoApiFailed(Throwable cause) { + return new DomainException( + ApiErrorCode.REPO_GITHUB_API_FAILED, + ApiErrorCode.REPO_GITHUB_API_FAILED.getDefaultMessage(), + cause + ); + } + private String bearer(String accessToken) { return "Bearer " + accessToken; } diff --git a/backend/src/main/java/com/stackup/stackup/github/infrastructure/GithubApiHttpClient.java b/backend/src/main/java/com/stackup/stackup/github/infrastructure/GithubApiHttpClient.java index 66f5fe95..f427e2a1 100644 --- a/backend/src/main/java/com/stackup/stackup/github/infrastructure/GithubApiHttpClient.java +++ b/backend/src/main/java/com/stackup/stackup/github/infrastructure/GithubApiHttpClient.java @@ -1,8 +1,11 @@ package com.stackup.stackup.github.infrastructure; import com.stackup.stackup.github.infrastructure.dto.GithubEmailResponse; +import com.stackup.stackup.github.infrastructure.dto.GithubRepoResponse; import com.stackup.stackup.github.infrastructure.dto.GithubUserResponse; +import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.service.annotation.GetExchange; public interface GithubApiHttpClient { @@ -12,4 +15,20 @@ public interface GithubApiHttpClient { @GetExchange("/user/emails") GithubEmailResponse[] getUserEmails(@RequestHeader("Authorization") String authorization); + + @GetExchange("/repos/{owner}/{repo}") + GithubRepoResponse getRepository( + @RequestHeader("Authorization") String authorization, + @PathVariable("owner") String owner, + @PathVariable("repo") String repo + ); + + @GetExchange("/user/repos") + GithubRepoResponse[] listUserRepositories( + @RequestHeader("Authorization") String authorization, + @RequestParam("per_page") int perPage, + @RequestParam("page") int page, + @RequestParam("sort") String sort, + @RequestParam("affiliation") String affiliation + ); } diff --git a/backend/src/main/java/com/stackup/stackup/github/infrastructure/dto/GithubRepoResponse.java b/backend/src/main/java/com/stackup/stackup/github/infrastructure/dto/GithubRepoResponse.java new file mode 100644 index 00000000..285ff8ee --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/github/infrastructure/dto/GithubRepoResponse.java @@ -0,0 +1,21 @@ +package com.stackup.stackup.github.infrastructure.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public record GithubRepoResponse( + Long id, + String name, + + @JsonProperty("full_name") + String fullName, + + @JsonProperty("html_url") + String htmlUrl, + + @JsonProperty("default_branch") + String defaultBranch, + + @JsonProperty("private") + Boolean isPrivate +) { +} diff --git a/backend/src/main/java/com/stackup/stackup/github/presentation/GithubRepositoryController.java b/backend/src/main/java/com/stackup/stackup/github/presentation/GithubRepositoryController.java new file mode 100644 index 00000000..44ce7673 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/github/presentation/GithubRepositoryController.java @@ -0,0 +1,73 @@ +package com.stackup.stackup.github.presentation; + +import com.stackup.stackup.common.security.UserPrincipal; +import com.stackup.stackup.github.application.GithubRepositoryService; +import com.stackup.stackup.github.presentation.dto.CandidateRepositoryResponse; +import com.stackup.stackup.github.presentation.dto.RegisterRepositoryRequest; +import com.stackup.stackup.github.presentation.dto.RegisteredRepositoryResponse; +import jakarta.validation.Valid; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/repositories") +@RequiredArgsConstructor +public class GithubRepositoryController { + + private final GithubRepositoryService service; + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public RegisteredRepositoryResponse register( + @AuthenticationPrincipal UserPrincipal principal, + @Valid @RequestBody RegisterRepositoryRequest request + ) { + return RegisteredRepositoryResponse.from(service.register(principal.userId(), request.toCommand())); + } + + @GetMapping + public List list(@AuthenticationPrincipal UserPrincipal principal) { + return service.listRegistered(principal.userId()).stream() + .map(RegisteredRepositoryResponse::from) + .toList(); + } + + @GetMapping("/github") + public List listCandidates( + @AuthenticationPrincipal UserPrincipal principal, + @RequestParam(defaultValue = "1") int page, + @RequestParam(name = "perPage", defaultValue = "30") int perPage + ) { + return service.listCandidates(principal.userId(), page, perPage).stream() + .map(CandidateRepositoryResponse::from) + .toList(); + } + + @GetMapping("/{repositoryId}") + public RegisteredRepositoryResponse get( + @AuthenticationPrincipal UserPrincipal principal, + @PathVariable Long repositoryId + ) { + return RegisteredRepositoryResponse.from(service.get(principal.userId(), repositoryId)); + } + + @DeleteMapping("/{repositoryId}") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void delete( + @AuthenticationPrincipal UserPrincipal principal, + @PathVariable Long repositoryId + ) { + service.delete(principal.userId(), repositoryId); + } +} diff --git a/backend/src/main/java/com/stackup/stackup/github/presentation/dto/CandidateRepositoryResponse.java b/backend/src/main/java/com/stackup/stackup/github/presentation/dto/CandidateRepositoryResponse.java new file mode 100644 index 00000000..2ae8ad41 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/github/presentation/dto/CandidateRepositoryResponse.java @@ -0,0 +1,25 @@ +package com.stackup.stackup.github.presentation.dto; + +import com.stackup.stackup.github.application.dto.CandidateRepositoryResult; + +public record CandidateRepositoryResponse( + Long githubRepoId, + String name, + String fullName, + String htmlUrl, + String defaultBranch, + boolean isPrivate, + boolean alreadyRegistered +) { + public static CandidateRepositoryResponse from(CandidateRepositoryResult result) { + return new CandidateRepositoryResponse( + result.githubRepoId(), + result.name(), + result.fullName(), + result.htmlUrl(), + result.defaultBranch(), + result.isPrivate(), + result.alreadyRegistered() + ); + } +} diff --git a/backend/src/main/java/com/stackup/stackup/github/presentation/dto/RegisterRepositoryRequest.java b/backend/src/main/java/com/stackup/stackup/github/presentation/dto/RegisterRepositoryRequest.java new file mode 100644 index 00000000..33de7bed --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/github/presentation/dto/RegisterRepositoryRequest.java @@ -0,0 +1,14 @@ +package com.stackup.stackup.github.presentation.dto; + +import com.stackup.stackup.github.application.dto.RegisterRepositoryCommand; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +public record RegisterRepositoryRequest( + @NotNull Long githubRepoId, + @NotBlank String fullName +) { + public RegisterRepositoryCommand toCommand() { + return new RegisterRepositoryCommand(githubRepoId, fullName); + } +} diff --git a/backend/src/main/java/com/stackup/stackup/github/presentation/dto/RegisteredRepositoryResponse.java b/backend/src/main/java/com/stackup/stackup/github/presentation/dto/RegisteredRepositoryResponse.java new file mode 100644 index 00000000..9a4e4780 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/github/presentation/dto/RegisteredRepositoryResponse.java @@ -0,0 +1,33 @@ +package com.stackup.stackup.github.presentation.dto; + +import com.stackup.stackup.github.application.dto.GithubRepositoryResult; +import com.stackup.stackup.github.domain.RepositoryStatus; +import java.time.Instant; + +public record RegisteredRepositoryResponse( + Long id, + Long githubRepoId, + String repoName, + String repoFullName, + String repoUrl, + String defaultBranch, + RepositoryStatus status, + Instant lastSyncedAt, + Instant createdAt, + Instant updatedAt +) { + public static RegisteredRepositoryResponse from(GithubRepositoryResult result) { + return new RegisteredRepositoryResponse( + result.id(), + result.githubRepoId(), + result.repoName(), + result.repoFullName(), + result.repoUrl(), + result.defaultBranch(), + result.status(), + result.lastSyncedAt(), + result.createdAt(), + result.updatedAt() + ); + } +} From 176fb95a9437beb0a6aade6c67a8d6099eb5556d Mon Sep 17 00:00:00 2001 From: jmj Date: Sat, 23 May 2026 13:04:38 +0900 Subject: [PATCH 3/6] =?UTF-8?q?feat(security):=20SSE=20stream-token=20quer?= =?UTF-8?q?y=20param=20=EC=9D=B8=EC=A6=9D=20=ED=95=84=ED=84=B0=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 브라우저 EventSource 가 Authorization 헤더를 못 보내는 한계 해소. - StreamTokenAuthenticationFilter: /api/stream/** 경로 한정으로 ?token= query parameter 를 StreamTokenProvider 로 검증 (scope=SSE_CONNECT 강제) - SecurityConfig: 위 필터를 JwtAuthenticationFilter 앞에 등록 - 일반 경로는 그대로 통과 → Authorization 헤더 경로(JWT) 영향 없음 흐름: 1. 프론트가 POST /api/auth/stream-token (JWT 헤더) → STREAM 토큰 발급 2. new EventSource("/api/stream/me?token={STREAM}") 3. 필터가 query token 검증 → UserPrincipal 주입 → SSE 통과 --- .../common/security/SecurityConfig.java | 4 ++ .../StreamTokenAuthenticationFilter.java | 69 +++++++++++++++++++ 2 files changed, 73 insertions(+) create mode 100644 backend/src/main/java/com/stackup/stackup/common/security/StreamTokenAuthenticationFilter.java diff --git a/backend/src/main/java/com/stackup/stackup/common/security/SecurityConfig.java b/backend/src/main/java/com/stackup/stackup/common/security/SecurityConfig.java index 7bb5619a..5d86ac26 100644 --- a/backend/src/main/java/com/stackup/stackup/common/security/SecurityConfig.java +++ b/backend/src/main/java/com/stackup/stackup/common/security/SecurityConfig.java @@ -30,15 +30,18 @@ public class SecurityConfig { private final JwtAuthenticationFilter jwtAuthenticationFilter; private final InternalApiKeyAuthenticationFilter internalApiKeyAuthenticationFilter; + private final StreamTokenAuthenticationFilter streamTokenAuthenticationFilter; private final CorsProperties corsProperties; public SecurityConfig( JwtAuthenticationFilter jwtAuthenticationFilter, InternalApiKeyAuthenticationFilter internalApiKeyAuthenticationFilter, + StreamTokenAuthenticationFilter streamTokenAuthenticationFilter, CorsProperties corsProperties ) { this.jwtAuthenticationFilter = jwtAuthenticationFilter; this.internalApiKeyAuthenticationFilter = internalApiKeyAuthenticationFilter; + this.streamTokenAuthenticationFilter = streamTokenAuthenticationFilter; this.corsProperties = corsProperties; } @@ -71,6 +74,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .anyRequest().permitAll() ) .addFilterBefore(internalApiKeyAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) + .addFilterBefore(streamTokenAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) .build(); } diff --git a/backend/src/main/java/com/stackup/stackup/common/security/StreamTokenAuthenticationFilter.java b/backend/src/main/java/com/stackup/stackup/common/security/StreamTokenAuthenticationFilter.java new file mode 100644 index 00000000..bb2f263e --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/common/security/StreamTokenAuthenticationFilter.java @@ -0,0 +1,69 @@ +package com.stackup.stackup.common.security; + +import com.stackup.stackup.common.exception.ApiErrorCode; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.List; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +// /api/stream/** 경로에 한해 ?token= query parameter 로 인증을 처리한다. +// 브라우저 EventSource 가 Authorization 헤더를 못 보내므로 stream-token 흐름을 받치는 전용 필터. +// 검증된 토큰은 STREAM scope 만 인정 — 일반 access token 으로는 동작하지 않음. +@Component +public class StreamTokenAuthenticationFilter extends OncePerRequestFilter { + + private static final Logger log = LoggerFactory.getLogger(StreamTokenAuthenticationFilter.class); + private static final String STREAM_PATH_PREFIX = "/api/stream/"; + private static final String TOKEN_PARAM = "token"; + + private final StreamTokenProvider streamTokenProvider; + + public StreamTokenAuthenticationFilter(StreamTokenProvider streamTokenProvider) { + this.streamTokenProvider = streamTokenProvider; + } + + @Override + protected void doFilterInternal( + HttpServletRequest request, + HttpServletResponse response, + FilterChain chain + ) throws ServletException, IOException { + if (!request.getRequestURI().startsWith(STREAM_PATH_PREFIX)) { + chain.doFilter(request, response); + return; + } + String token = request.getParameter(TOKEN_PARAM); + if (token == null || token.isBlank()) { + // query token 없으면 일반 JWT 필터가 Authorization 헤더로 처리하도록 통과 + chain.doFilter(request, response); + return; + } + + try { + Long userId = streamTokenProvider.getUserId(token); + UserPrincipal principal = new UserPrincipal( + userId, null, List.of(new SimpleGrantedAuthority("ROLE_USER")) + ); + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken(principal, token, principal.getAuthorities()); + SecurityContextHolder.getContext().setAuthentication(authentication); + } catch (RuntimeException exception) { + request.setAttribute( + JwtAuthenticationFilter.AUTH_ERROR_CODE_ATTRIBUTE, + ApiErrorCode.AUTH_INVALID_TOKEN + ); + SecurityContextHolder.clearContext(); + log.warn("stream-token authentication failed. uri={}", request.getRequestURI()); + } + chain.doFilter(request, response); + } +} From 907690c876db783f5aa221a00b2baecd3ea6d32e Mon Sep 17 00:00:00 2001 From: jmj Date: Sat, 23 May 2026 13:14:57 +0900 Subject: [PATCH 4/6] =?UTF-8?q?docs(security):=20=EC=A3=BC=EC=84=9D=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/security/StreamTokenAuthenticationFilter.java | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/backend/src/main/java/com/stackup/stackup/common/security/StreamTokenAuthenticationFilter.java b/backend/src/main/java/com/stackup/stackup/common/security/StreamTokenAuthenticationFilter.java index bb2f263e..d5893319 100644 --- a/backend/src/main/java/com/stackup/stackup/common/security/StreamTokenAuthenticationFilter.java +++ b/backend/src/main/java/com/stackup/stackup/common/security/StreamTokenAuthenticationFilter.java @@ -15,9 +15,7 @@ import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; -// /api/stream/** 경로에 한해 ?token= query parameter 로 인증을 처리한다. -// 브라우저 EventSource 가 Authorization 헤더를 못 보내므로 stream-token 흐름을 받치는 전용 필터. -// 검증된 토큰은 STREAM scope 만 인정 — 일반 access token 으로는 동작하지 않음. +// 인증 처리 전용 @Component public class StreamTokenAuthenticationFilter extends OncePerRequestFilter { From 9bdcfd326a8e72c2e47a9e3c4ff10736cdd2316f Mon Sep 17 00:00:00 2001 From: jmj Date: Sat, 23 May 2026 13:20:40 +0900 Subject: [PATCH 5/6] =?UTF-8?q?docs(openapi):=20=EC=8B=A0=EA=B7=9C=20?= =?UTF-8?q?=EC=BB=A8=ED=8A=B8=EB=A1=A4=EB=9F=AC=205=EA=B0=9C=20(+=20Intern?= =?UTF-8?q?al=203=EA=B0=9C)=20=EC=97=90=20@Tag/@Operation/@ApiResponse=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AnalyzedDocumentController.java | 24 ++++++++++ .../InternalAnalysisTriggerController.java | 16 ++++++- .../InternalDocumentController.java | 20 +++++++-- .../GithubRepositoryController.java | 45 +++++++++++++++++++ .../InternalGithubTokenController.java | 16 ++++++- .../resume/presentation/ResumeController.java | 32 +++++++++++++ 6 files changed, 147 insertions(+), 6 deletions(-) diff --git a/backend/src/main/java/com/stackup/stackup/document/presentation/AnalyzedDocumentController.java b/backend/src/main/java/com/stackup/stackup/document/presentation/AnalyzedDocumentController.java index 31e9851a..b99191b0 100644 --- a/backend/src/main/java/com/stackup/stackup/document/presentation/AnalyzedDocumentController.java +++ b/backend/src/main/java/com/stackup/stackup/document/presentation/AnalyzedDocumentController.java @@ -3,6 +3,10 @@ import com.stackup.stackup.common.security.UserPrincipal; import com.stackup.stackup.document.application.AnalyzedDocumentQueryService; import com.stackup.stackup.document.presentation.dto.AnalyzedDocumentResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.security.core.annotation.AuthenticationPrincipal; @@ -12,6 +16,7 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +@Tag(name = "Documents", description = "분석 문서(이력서/레포 공통) 조회. 상세 조회 시 분석 마크다운의 presigned S3 URL 포함.") @RestController @RequestMapping("/api/documents") @RequiredArgsConstructor @@ -19,6 +24,15 @@ public class AnalyzedDocumentController { private final AnalyzedDocumentQueryService queryService; + @Operation( + operationId = "listAnalyzedDocuments", + summary = "내 분석 문서 목록 (filter: resumeId 또는 repositoryId)", + description = "filter 미지정 시 사용자 보유 전체. resumeId/repositoryId 둘 다 지정 시 resumeId 가 우선." + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "분석 문서 목록 (documentDownloadUrl 은 비어있음)"), + @ApiResponse(responseCode = "401", description = "인증 실패") + }) @GetMapping public List list( @AuthenticationPrincipal UserPrincipal principal, @@ -30,6 +44,16 @@ public List list( .toList(); } + @Operation( + operationId = "getAnalyzedDocument", + summary = "분석 문서 상세 + presigned MD 다운로드 URL", + description = "documentDownloadUrl 은 10분 TTL presigned. analysis_status 가 ANALYZED 인 경우에만 의미 있음." + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "분석 문서 상세"), + @ApiResponse(responseCode = "401", description = "인증 실패"), + @ApiResponse(responseCode = "404", description = "분석 문서 없음") + }) @GetMapping("/{documentId}") public AnalyzedDocumentResponse get( @AuthenticationPrincipal UserPrincipal principal, diff --git a/backend/src/main/java/com/stackup/stackup/document/presentation/InternalAnalysisTriggerController.java b/backend/src/main/java/com/stackup/stackup/document/presentation/InternalAnalysisTriggerController.java index d13efedc..473ac782 100644 --- a/backend/src/main/java/com/stackup/stackup/document/presentation/InternalAnalysisTriggerController.java +++ b/backend/src/main/java/com/stackup/stackup/document/presentation/InternalAnalysisTriggerController.java @@ -2,6 +2,10 @@ import com.stackup.stackup.document.application.AnalysisRequestService; import com.stackup.stackup.document.application.AnalysisRequestService.AnalysisHandle; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.constraints.NotNull; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.PathVariable; @@ -10,7 +14,7 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -// 운영환경에서는 AnalysisRequestService 직접 호출 +@Tag(name = "Internal: Analysis Triggers", description = "X-Internal-API-Key 필요. e2e 검증/디버그용 강제 트리거. 실제 사용자 흐름은 POST /api/resumes, POST /api/repositories 가 자동 트리거.") @RestController @RequestMapping("/api/internal/analyses") @RequiredArgsConstructor @@ -18,6 +22,11 @@ public class InternalAnalysisTriggerController { private final AnalysisRequestService service; + @Operation(operationId = "internalTriggerResumeAnalysis", summary = "이력서 분석 강제 트리거") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "트리거 성공"), + @ApiResponse(responseCode = "401", description = "X-Internal-API-Key 인증 실패") + }) @PostMapping("/resume/{resumeId}") public AnalysisHandle triggerResume( @PathVariable Long resumeId, @@ -26,6 +35,11 @@ public AnalysisHandle triggerResume( return service.requestResumeAnalysis(request.userId(), resumeId); } + @Operation(operationId = "internalTriggerRepositoryAnalysis", summary = "GitHub 레포 분석 강제 트리거") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "트리거 성공"), + @ApiResponse(responseCode = "401", description = "X-Internal-API-Key 인증 실패") + }) @PostMapping("/repository/{repositoryId}") public AnalysisHandle triggerRepository( @PathVariable Long repositoryId, diff --git a/backend/src/main/java/com/stackup/stackup/document/presentation/InternalDocumentController.java b/backend/src/main/java/com/stackup/stackup/document/presentation/InternalDocumentController.java index 45eeac62..f6b8d6f1 100644 --- a/backend/src/main/java/com/stackup/stackup/document/presentation/InternalDocumentController.java +++ b/backend/src/main/java/com/stackup/stackup/document/presentation/InternalDocumentController.java @@ -4,6 +4,10 @@ import com.stackup.stackup.common.exception.DomainException; import com.stackup.stackup.document.application.DocumentEmbeddingService; import com.stackup.stackup.document.application.dto.EmbeddingUpsertCommand; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.NotNull; @@ -16,10 +20,7 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -/** - * AI 서버 전용. /api/internal/documents/{documentId}/embeddings. - * 청크 + 임베딩을 pgvector 에 idempotent upsert. - */ +@Tag(name = "Internal: Document Embeddings", description = "X-Internal-API-Key 필요. AI 서버가 분석 결과 청크/임베딩을 pgvector 에 idempotent upsert 할 때 호출.") @RestController @RequestMapping("/api/internal/documents") @RequiredArgsConstructor @@ -27,6 +28,17 @@ public class InternalDocumentController { private final DocumentEmbeddingService embeddingService; + @Operation( + operationId = "internalUpsertDocumentEmbeddings", + summary = "분석 문서 청크 + 임베딩 upsert", + description = "(document_id, chunk_index) 기준 idempotent. 벡터 차원이 dim 과 다르면 400." + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "upsert 성공 (응답에 upsert 된 개수 포함)"), + @ApiResponse(responseCode = "400", description = "임베딩 페이로드 검증 실패 (dim 불일치 등)"), + @ApiResponse(responseCode = "401", description = "X-Internal-API-Key 인증 실패"), + @ApiResponse(responseCode = "404", description = "documentId 에 해당하는 analyzed_documents 없음") + }) @PutMapping("/{documentId}/embeddings") public UpsertResponse upsert( @PathVariable Long documentId, diff --git a/backend/src/main/java/com/stackup/stackup/github/presentation/GithubRepositoryController.java b/backend/src/main/java/com/stackup/stackup/github/presentation/GithubRepositoryController.java index 44ce7673..cb52aabb 100644 --- a/backend/src/main/java/com/stackup/stackup/github/presentation/GithubRepositoryController.java +++ b/backend/src/main/java/com/stackup/stackup/github/presentation/GithubRepositoryController.java @@ -5,6 +5,10 @@ import com.stackup.stackup.github.presentation.dto.CandidateRepositoryResponse; import com.stackup.stackup.github.presentation.dto.RegisterRepositoryRequest; import com.stackup.stackup.github.presentation.dto.RegisteredRepositoryResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import java.util.List; import lombok.RequiredArgsConstructor; @@ -20,6 +24,7 @@ import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; +@Tag(name = "Repositories", description = "GitHub 레포 등록/관리. 등록 시 분석 파이프라인이 자동 트리거되며 결과는 /api/stream/me (REPO_STATE) 로 통지됨.") @RestController @RequestMapping("/api/repositories") @RequiredArgsConstructor @@ -27,6 +32,19 @@ public class GithubRepositoryController { private final GithubRepositoryService service; + @Operation( + operationId = "registerRepository", + summary = "GitHub 레포 등록 + 분석 트리거", + description = "사용자의 GitHub 토큰으로 메타를 재확인 후 INSERT. id/fullName 불일치(VALIDATION_ERROR), 비공개 접근 불가(REPO_PRIVATE_NO_ACCESS), 존재하지 않음(REPO_NOT_FOUND), 중복(REPO_ALREADY_REGISTERED) 분기." + ) + @ApiResponses({ + @ApiResponse(responseCode = "201", description = "등록 + 분석 트리거 성공"), + @ApiResponse(responseCode = "400", description = "id ↔ fullName 불일치 / 잘못된 요청"), + @ApiResponse(responseCode = "401", description = "인증 실패"), + @ApiResponse(responseCode = "403", description = "비공개 레포 접근 불가"), + @ApiResponse(responseCode = "404", description = "GitHub에 해당 레포 없음"), + @ApiResponse(responseCode = "409", description = "이미 등록된 레포") + }) @PostMapping @ResponseStatus(HttpStatus.CREATED) public RegisteredRepositoryResponse register( @@ -36,6 +54,11 @@ public RegisteredRepositoryResponse register( return RegisteredRepositoryResponse.from(service.register(principal.userId(), request.toCommand())); } + @Operation(operationId = "listRegisteredRepositories", summary = "등록된 레포 목록") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "사용자가 등록한 레포 목록"), + @ApiResponse(responseCode = "401", description = "인증 실패") + }) @GetMapping public List list(@AuthenticationPrincipal UserPrincipal principal) { return service.listRegistered(principal.userId()).stream() @@ -43,6 +66,16 @@ public List list(@AuthenticationPrincipal UserPrin .toList(); } + @Operation( + operationId = "listCandidateRepositories", + summary = "GitHub 후보 레포 목록 (페이지)", + description = "사용자의 GitHub 토큰으로 owner 레포를 updated 순으로 페이지 조회. 각 항목에 alreadyRegistered 플래그." + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "GitHub 후보 페이지"), + @ApiResponse(responseCode = "401", description = "인증 실패"), + @ApiResponse(responseCode = "502", description = "GitHub API 호출 실패") + }) @GetMapping("/github") public List listCandidates( @AuthenticationPrincipal UserPrincipal principal, @@ -54,6 +87,12 @@ public List listCandidates( .toList(); } + @Operation(operationId = "getRepository", summary = "등록 레포 단건 조회") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "레포 메타"), + @ApiResponse(responseCode = "401", description = "인증 실패"), + @ApiResponse(responseCode = "404", description = "레포 없음") + }) @GetMapping("/{repositoryId}") public RegisteredRepositoryResponse get( @AuthenticationPrincipal UserPrincipal principal, @@ -62,6 +101,12 @@ public RegisteredRepositoryResponse get( return RegisteredRepositoryResponse.from(service.get(principal.userId(), repositoryId)); } + @Operation(operationId = "deleteRepository", summary = "등록 레포 soft delete") + @ApiResponses({ + @ApiResponse(responseCode = "204", description = "삭제 완료"), + @ApiResponse(responseCode = "401", description = "인증 실패"), + @ApiResponse(responseCode = "404", description = "레포 없음") + }) @DeleteMapping("/{repositoryId}") @ResponseStatus(HttpStatus.NO_CONTENT) public void delete( diff --git a/backend/src/main/java/com/stackup/stackup/github/presentation/InternalGithubTokenController.java b/backend/src/main/java/com/stackup/stackup/github/presentation/InternalGithubTokenController.java index a82450d3..66d302f2 100644 --- a/backend/src/main/java/com/stackup/stackup/github/presentation/InternalGithubTokenController.java +++ b/backend/src/main/java/com/stackup/stackup/github/presentation/InternalGithubTokenController.java @@ -1,13 +1,17 @@ package com.stackup.stackup.github.presentation; import com.stackup.stackup.github.application.InternalGithubTokenService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -// AI 서버 전용 +@Tag(name = "Internal: GitHub Token Delegation", description = "X-Internal-API-Key 필요. AI 서버가 레포 분석 시점에 사용자 GitHub 토큰을 짧게 위임받기 위해 호출.") @RestController @RequestMapping("/api/internal/users") @RequiredArgsConstructor @@ -15,6 +19,16 @@ public class InternalGithubTokenController { private final InternalGithubTokenService tokenService; + @Operation( + operationId = "internalFetchGithubToken", + summary = "사용자 GitHub access token 평문 반환", + description = "GithubTokenCipher 로 복호화한 평문 토큰을 응답. 메모리/로그/이벤트 envelope 등 외부 영속/전송 금지." + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "토큰 반환"), + @ApiResponse(responseCode = "401", description = "X-Internal-API-Key 인증 실패"), + @ApiResponse(responseCode = "404", description = "사용자 없음 또는 토큰 미존재") + }) @GetMapping("/{userId}/github-token") public GithubTokenResponse fetchGithubToken(@PathVariable Long userId) { return new GithubTokenResponse(tokenService.fetchPlainAccessToken(userId)); diff --git a/backend/src/main/java/com/stackup/stackup/resume/presentation/ResumeController.java b/backend/src/main/java/com/stackup/stackup/resume/presentation/ResumeController.java index a8509dc6..4a08960e 100644 --- a/backend/src/main/java/com/stackup/stackup/resume/presentation/ResumeController.java +++ b/backend/src/main/java/com/stackup/stackup/resume/presentation/ResumeController.java @@ -6,6 +6,10 @@ import com.stackup.stackup.resume.application.ResumeService; import com.stackup.stackup.resume.application.dto.ResumeUploadCommand; import com.stackup.stackup.resume.presentation.dto.ResumeResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; import java.io.IOException; import java.util.List; import lombok.RequiredArgsConstructor; @@ -22,6 +26,7 @@ import org.springframework.web.bind.annotation.RestController; import org.springframework.web.multipart.MultipartFile; +@Tag(name = "Resumes", description = "이력서 업로드/관리. 업로드 시 분석 파이프라인이 자동 트리거되며 결과는 /api/stream/me (DOC_STATE) 로 통지됨.") @RestController @RequestMapping("/api/resumes") @RequiredArgsConstructor @@ -29,6 +34,16 @@ public class ResumeController { private final ResumeService resumeService; + @Operation( + operationId = "uploadResume", + summary = "이력서 PDF 업로드 + 분석 트리거", + description = "multipart/form-data 로 PDF 파일을 받아 S3 저장 → DB row 생성(status=PENDING) → AI 분석 자동 발행. 최대 20MB, application/pdf 만 허용." + ) + @ApiResponses({ + @ApiResponse(responseCode = "201", description = "업로드 + 분석 트리거 성공"), + @ApiResponse(responseCode = "400", description = "빈 파일 / 사이즈 초과 / 비-PDF"), + @ApiResponse(responseCode = "401", description = "인증 실패") + }) @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @ResponseStatus(HttpStatus.CREATED) public ResumeResponse upload( @@ -51,6 +66,11 @@ public ResumeResponse upload( } } + @Operation(operationId = "listResumes", summary = "내 이력서 목록") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "사용자 소유 이력서 목록"), + @ApiResponse(responseCode = "401", description = "인증 실패") + }) @GetMapping public List list(@AuthenticationPrincipal UserPrincipal principal) { return resumeService.list(principal.userId()).stream() @@ -58,6 +78,12 @@ public List list(@AuthenticationPrincipal UserPrincipal principa .toList(); } + @Operation(operationId = "getResume", summary = "이력서 단건 조회") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "이력서 메타"), + @ApiResponse(responseCode = "401", description = "인증 실패"), + @ApiResponse(responseCode = "404", description = "이력서 없음") + }) @GetMapping("/{resumeId}") public ResumeResponse get( @AuthenticationPrincipal UserPrincipal principal, @@ -66,6 +92,12 @@ public ResumeResponse get( return ResumeResponse.from(resumeService.get(principal.userId(), resumeId)); } + @Operation(operationId = "deleteResume", summary = "이력서 soft delete") + @ApiResponses({ + @ApiResponse(responseCode = "204", description = "삭제 완료"), + @ApiResponse(responseCode = "401", description = "인증 실패"), + @ApiResponse(responseCode = "404", description = "이력서 없음") + }) @DeleteMapping("/{resumeId}") @ResponseStatus(HttpStatus.NO_CONTENT) public void delete( From 8bc93937edba96695378d3966486e5d6214e1e8b Mon Sep 17 00:00:00 2001 From: jmj Date: Sat, 23 May 2026 13:46:18 +0900 Subject: [PATCH 6/6] =?UTF-8?q?chore(deploy):=20backend=20=EC=9E=90?= =?UTF-8?q?=EB=8F=99=20=EB=B0=B0=ED=8F=AC=20=E2=80=94=20compose=20?= =?UTF-8?q?=EC=A0=95=EC=8B=9D=20=EC=B6=94=EA=B0=80=20+=20workflow=20paths/?= =?UTF-8?q?build/healthy/secrets=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dev 푸시 시 backend 도 함께 자동 빌드·재시작. ## docker-compose.yml - backend 서비스 정식 추가 (build context ./backend, port 38010, depends_on postgres/rabbitmq/minio healthy) - 환경변수 ${VAR:-default} 패턴 — 비밀값은 .env / GitHub Secrets 로 주입, 미설정 시 application-local.yml 의 dev default 적용 - healthcheck: curl /actuator/health (보강된 Dockerfile 의 curl 사용) - AI 의 CORE_INTERNAL_BASE_URL 기본값을 http://host.docker.internal:38010 → http://backend:38010 으로 변경 (같은 compose 네트워크) ## backend/Dockerfile - eclipse-temurin:21-jre-jammy 에 curl 설치 — compose healthcheck 용 ## .github/workflows/deploy-app.yml - paths 트리거에 backend/** 추가 - timeout 20m → 30m (Spring 부팅 시간 여유) - Secrets 다중 주입: JWT_SECRET, ENCRYPTION_KEY, CORE_INTERNAL_API_KEY, GH_OAUTH_CLIENT_ID/SECRET (LLM_API_KEY 기존 패턴을 upsert_env 헬퍼로 일반화. 값 비면 skip → .env 기존 값 유지) - docker compose up -d --build 에 backend 포함 - healthy 대기 루프에 backend 추가 - 5/22 임시 docker-compose.override.yml 명시 제거 (backend 가 정식 compose 로 들어와 불필요) ## .env.example - backend 비밀값 dev 기본값 명시 (JWT_SECRET, ENCRYPTION_KEY, CORE_INTERNAL_API_KEY) → 운영은 반드시 GitHub Secrets 로 덮어쓸 것을 주석으로 안내 ## 필요한 GitHub Secrets (Settings → Secrets and variables → Actions) 필수가 아닌, 운영에서 dev default 를 교체하고 싶을 때만 설정: - JWT_SECRET, ENCRYPTION_KEY, CORE_INTERNAL_API_KEY (모두 base64) - LLM_API_KEY - GH_OAUTH_CLIENT_ID, GH_OAUTH_CLIENT_SECRET --- .env.example | 11 ++++++ .github/workflows/deploy-app.yml | 63 +++++++++++++++++++++++--------- backend/Dockerfile | 4 ++ docker-compose.yml | 49 ++++++++++++++++++++++++- 4 files changed, 107 insertions(+), 20 deletions(-) diff --git a/.env.example b/.env.example index 55503b27..080aebd8 100644 --- a/.env.example +++ b/.env.example @@ -34,6 +34,17 @@ REALTIME_LOG_LEVEL=info # Backend Core (Spring) BACKEND_PORT=38010 +SPRING_PROFILES_ACTIVE=local +# JWT/암호화는 base64 32바이트. 운영은 반드시 GitHub Secrets(JWT_SECRET, ENCRYPTION_KEY)로 덮어쓸 것. +JWT_SECRET=c3RhY2t1cC1zcHJpbnQxLWxvY2FsLWp3dC1zZWNyZXQtMzItYnl0ZXMtbG9uZw== +ENCRYPTION_KEY=c3RhY2t1cC1zcHJpbnQxLWVuY3J5cHRpb24ta2V5QUI= +# AI 서버와 공유. 운영은 GitHub Secrets(CORE_INTERNAL_API_KEY)로 덮어쓸 것. +CORE_INTERNAL_API_KEY=local-development-internal-api-key +# GitHub OAuth (운영은 GitHub Secrets(GH_OAUTH_CLIENT_ID/SECRET)로 덮어쓸 것) +GITHUB_OAUTH_CLIENT_ID= +GITHUB_OAUTH_CLIENT_SECRET= +GITHUB_OAUTH_REDIRECT_URI=http://localhost:5173/auth/callback +CORS_ALLOWED_ORIGINS=* # NGINX FRONTEND_PORT=38000 diff --git a/.github/workflows/deploy-app.yml b/.github/workflows/deploy-app.yml index 3420e543..6f207f24 100644 --- a/.github/workflows/deploy-app.yml +++ b/.github/workflows/deploy-app.yml @@ -1,8 +1,7 @@ name: STACK-UP deploy ana-server -# dev 브랜치가 업데이트되면 AI서버와 실시간서버를 빌드하고 배포 -# 상우 원격컴에서만 작동하니 클라우드로 옮기면 삭제해야 함 -# 백엔드 코어랑 프론트는 아직 도커에 정의되지 않았으니.. 그때 이어서 해야 함 +# dev 브랜치 업데이트 시 AI / RealTime / Backend(Core) 를 빌드해 self-hosted runner 에서 재시작. +# 상우 원격컴 전용 — 클라우드 이관 시 워크플로우 재설계 필요. on: push: @@ -10,6 +9,7 @@ on: paths: - "ai/**" - "realtime/**" + - "backend/**" - "docker-compose.yml" - "infra/**" - ".github/workflows/deploy-app.yml" @@ -22,7 +22,7 @@ concurrency: jobs: deploy: runs-on: self-hosted - timeout-minutes: 20 + timeout-minutes: 30 env: DEPLOY_DIR: /home/stackup/stackup @@ -42,41 +42,68 @@ jobs: --exclude='**/.venv' \ --exclude='**/node_modules' \ "$GITHUB_WORKSPACE/" "$DEPLOY_DIR/" + # 5/22 시점 임시 만든 docker-compose.override.yml 은 backend 가 정식 compose 로 들어와서 불필요. + # rsync --delete 가 자동 제거하지만, 안전하게 명시적 제거. + rm -f "$DEPLOY_DIR/docker-compose.override.yml" - - name: Ensure .env exists and contains current LLM_API_KEY + - name: Ensure .env exists and inject Secrets env: + # GitHub Repository Secrets. 비어있으면 application-local.yml / docker-compose.yml 의 dev default 가 적용됨. LLM_API_KEY: ${{ secrets.LLM_API_KEY }} + JWT_SECRET: ${{ secrets.JWT_SECRET }} + ENCRYPTION_KEY: ${{ secrets.ENCRYPTION_KEY }} + CORE_INTERNAL_API_KEY: ${{ secrets.CORE_INTERNAL_API_KEY }} + GITHUB_OAUTH_CLIENT_ID: ${{ secrets.GH_OAUTH_CLIENT_ID }} + GITHUB_OAUTH_CLIENT_SECRET: ${{ secrets.GH_OAUTH_CLIENT_SECRET }} run: | cd "$DEPLOY_DIR" if [ ! -f .env ]; then cp .env.example .env fi - # Write LLM_API_KEY via a temp file to keep the secret off the - # process command line. - tmp=$(mktemp) - trap 'rm -f "$tmp"' EXIT - awk -v key="$LLM_API_KEY" ' - BEGIN { written = 0 } - /^LLM_API_KEY=/ { print "LLM_API_KEY=" key; written = 1; next } - { print } - END { if (!written) print "LLM_API_KEY=" key } - ' .env > "$tmp" - mv "$tmp" .env chmod 600 .env + # 같은 키를 .env 에 upsert. 값이 비어있으면 skip (기존 값 유지). + upsert_env() { + local key="$1" + local value="$2" + if [ -z "$value" ]; then + echo " skip $key (secret 미설정)" + return 0 + fi + local tmp + tmp=$(mktemp) + awk -v k="$key" -v v="$value" ' + BEGIN { written = 0 } + $0 ~ ("^" k "=") { print k "=" v; written = 1; next } + { print } + END { if (!written) print k "=" v } + ' .env > "$tmp" + mv "$tmp" .env + chmod 600 .env + echo " upserted $key" + } + + upsert_env "LLM_API_KEY" "$LLM_API_KEY" + upsert_env "JWT_SECRET" "$JWT_SECRET" + upsert_env "ENCRYPTION_KEY" "$ENCRYPTION_KEY" + upsert_env "CORE_INTERNAL_API_KEY" "$CORE_INTERNAL_API_KEY" + upsert_env "GITHUB_OAUTH_CLIENT_ID" "$GITHUB_OAUTH_CLIENT_ID" + upsert_env "GITHUB_OAUTH_CLIENT_SECRET" "$GITHUB_OAUTH_CLIENT_SECRET" + - name: Build and restart app services run: | cd "$DEPLOY_DIR" - docker compose up -d --build ai realtime + docker compose up -d --build ai realtime backend - name: Wait for healthy run: | cd "$DEPLOY_DIR" set +e rc=0 - for svc in ai realtime; do + for svc in ai realtime backend; do container="stackup-$svc" ok=0 + # backend 는 Spring 부팅이 길어 36*5=180s 까지 대기. for i in $(seq 1 36); do h=$(docker inspect --format '{{ .State.Health.Status }}' "$container" 2>/dev/null || echo "missing") echo " [$i] $container: $h" diff --git a/backend/Dockerfile b/backend/Dockerfile index 4bb53c0e..50cdf309 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -8,6 +8,10 @@ RUN ./gradlew bootJar -x test --no-daemon # Run stage FROM eclipse-temurin:21-jre-jammy WORKDIR /app +# curl 은 docker-compose 의 healthcheck (/actuator/health) 에서 사용. +RUN apt-get update \ + && apt-get install -y --no-install-recommends curl \ + && rm -rf /var/lib/apt/lists/* COPY --from=build /home/gradle/src/build/libs/stackup-0.0.1-SNAPSHOT.jar app.jar EXPOSE 38010 diff --git a/docker-compose.yml b/docker-compose.yml index a0c82547..b08c4ce1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -87,8 +87,8 @@ services: LLM_API_KEY: ${LLM_API_KEY:-} LLM_BASE_URL: ${LLM_BASE_URL:-https://factchat-cloud.mindlogic.ai/v1/gateway} LLM_PRO_MODEL: ${LLM_PRO_MODEL:-gemini-3.1-pro-preview} - # AI → Core 내부 API - CORE_INTERNAL_BASE_URL: ${CORE_INTERNAL_BASE_URL:-http://host.docker.internal:38010} + # AI → Core 내부 API. 같은 compose 네트워크의 backend 서비스로 직접 라우팅. + CORE_INTERNAL_BASE_URL: ${CORE_INTERNAL_BASE_URL:-http://backend:38010} CORE_INTERNAL_API_KEY: ${CORE_INTERNAL_API_KEY:-local-development-internal-api-key} # 임베딩 EMBEDDING_PROVIDER: ${EMBEDDING_PROVIDER:-mock} @@ -128,6 +128,51 @@ services: timeout: 5s retries: 5 + backend: + build: + context: ./backend + container_name: stackup-backend + ports: + - "${BACKEND_PORT:-38010}:38010" + environment: + SPRING_PROFILES_ACTIVE: ${SPRING_PROFILES_ACTIVE:-local} + SERVER_PORT: "38010" + POSTGRES_HOST: postgres + POSTGRES_PORT: "38040" + POSTGRES_DB: ${POSTGRES_DB:-stackup} + POSTGRES_USER: ${POSTGRES_USER:-stackup} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-stackup} + RABBITMQ_HOST: rabbitmq + RABBITMQ_PORT: "38050" + RABBITMQ_USER: ${RABBITMQ_USER:-stackup} + RABBITMQ_PASSWORD: ${RABBITMQ_PASSWORD:-stackup} + # 비밀값은 .env 또는 GitHub Secrets 로 주입. 값이 비면 application-local.yml 의 dev default 적용. + JWT_SECRET: ${JWT_SECRET:-} + ENCRYPTION_KEY: ${ENCRYPTION_KEY:-} + CORE_INTERNAL_API_KEY: ${CORE_INTERNAL_API_KEY:-local-development-internal-api-key} + GITHUB_OAUTH_CLIENT_ID: ${GITHUB_OAUTH_CLIENT_ID:-} + GITHUB_OAUTH_CLIENT_SECRET: ${GITHUB_OAUTH_CLIENT_SECRET:-} + GITHUB_OAUTH_REDIRECT_URI: ${GITHUB_OAUTH_REDIRECT_URI:-http://localhost:5173/auth/callback} + S3_ENDPOINT: http://minio:38060 + S3_ACCESS_KEY: ${MINIO_ROOT_USER:-minioadmin} + S3_SECRET_KEY: ${MINIO_ROOT_PASSWORD:-minioadmin} + S3_BUCKET: ${MINIO_BUCKET:-stackup} + S3_REGION: ${S3_REGION:-us-east-1} + S3_PATH_STYLE: "true" + CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-*} + depends_on: + postgres: + condition: service_healthy + rabbitmq: + condition: service_healthy + minio: + condition: service_healthy + healthcheck: + test: ["CMD-SHELL", "curl -sf http://localhost:38010/actuator/health >/dev/null || exit 1"] + interval: 10s + timeout: 5s + retries: 15 + volumes: postgres_data: rabbitmq_data: