Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/ISSUE_TEMPLATE/bug_report.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
title: '[FIX] '
labels: ''
assignees: ''

Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,12 @@
---
name: Default request
name: Feat request
about: Suggest an idea for this project
title: ''
title: '[FEAT] '
labels: ''
assignees: ''

---

# 투두 리스트
- [ ] To-do 1
- [ ] To-do 2
- [ ] To-do 3

# 참고 사항
12 changes: 12 additions & 0 deletions .github/ISSUE_TEMPLATE/refactor-report.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
name: Refactor request
about: Suggest an idea for this project
title: '[REFACTOR] '
labels: ''
assignees: ''

---

# 투두 리스트

# 참고 사항
5 changes: 2 additions & 3 deletions .github/PULL_REQUEST_TEMPLATE.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
closed #

# 작업 내용
- 작업 내용 1
- 작업 내용 2
- 작업 내용 3

# 스크린샷

Expand Down
3 changes: 3 additions & 0 deletions api/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ dependencies {

implementation 'org.springframework.kafka:spring-kafka'

// PDF 텍스트 추출
implementation 'org.apache.pdfbox:pdfbox:3.0.3'

testImplementation 'com.h2database:h2:2.2.224'
testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc'
}
Expand Down
68 changes: 68 additions & 0 deletions api/src/docs/asciidoc/index.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -575,3 +575,71 @@ include::{snippetsDir}/resume-evaluation/http-response.adoc[]
include::{snippetsDir}/resume-evaluation/response-body.adoc[]
include::{snippetsDir}/resume-evaluation/response-fields.adoc[]
include::{snippetsDir}/resume-evaluation/curl-request.adoc[]

=== 이력서 평가 비동기 제출 (파일 업로드)

include::{snippetsDir}/resume-evaluation-async-submit/http-request.adoc[]
include::{snippetsDir}/resume-evaluation-async-submit/request-headers.adoc[]
include::{snippetsDir}/resume-evaluation-async-submit/request-parts.adoc[]
include::{snippetsDir}/resume-evaluation-async-submit/http-response.adoc[]
include::{snippetsDir}/resume-evaluation-async-submit/response-body.adoc[]
include::{snippetsDir}/resume-evaluation-async-submit/response-fields.adoc[]
include::{snippetsDir}/resume-evaluation-async-submit/curl-request.adoc[]

=== 저장된 이력서 기반 평가 비동기 제출

include::{snippetsDir}/resume-evaluation-saved-async-submit/http-request.adoc[]
include::{snippetsDir}/resume-evaluation-saved-async-submit/request-headers.adoc[]
include::{snippetsDir}/resume-evaluation-saved-async-submit/request-parts.adoc[]
include::{snippetsDir}/resume-evaluation-saved-async-submit/http-response.adoc[]
include::{snippetsDir}/resume-evaluation-saved-async-submit/response-body.adoc[]
include::{snippetsDir}/resume-evaluation-saved-async-submit/response-fields.adoc[]
include::{snippetsDir}/resume-evaluation-saved-async-submit/curl-request.adoc[]

=== 저장된 이력서 기반 평가 포트폴리오 없이 제출

include::{snippetsDir}/resume-evaluation-saved-async-submit-without-portfolio/http-request.adoc[]
include::{snippetsDir}/resume-evaluation-saved-async-submit-without-portfolio/request-headers.adoc[]
include::{snippetsDir}/resume-evaluation-saved-async-submit-without-portfolio/request-parts.adoc[]
include::{snippetsDir}/resume-evaluation-saved-async-submit-without-portfolio/http-response.adoc[]
include::{snippetsDir}/resume-evaluation-saved-async-submit-without-portfolio/response-body.adoc[]
include::{snippetsDir}/resume-evaluation-saved-async-submit-without-portfolio/response-fields.adoc[]
include::{snippetsDir}/resume-evaluation-saved-async-submit-without-portfolio/curl-request.adoc[]

=== 이력서 평가 상태 조회 (대기중)

include::{snippetsDir}/resume-evaluation-state-pending/http-request.adoc[]
include::{snippetsDir}/resume-evaluation-state-pending/path-parameters.adoc[]
include::{snippetsDir}/resume-evaluation-state-pending/http-response.adoc[]
include::{snippetsDir}/resume-evaluation-state-pending/response-body.adoc[]
include::{snippetsDir}/resume-evaluation-state-pending/response-fields.adoc[]
include::{snippetsDir}/resume-evaluation-state-pending/curl-request.adoc[]

=== 이력서 평가 상태 조회 (완료)

include::{snippetsDir}/resume-evaluation-state-completed/http-request.adoc[]
include::{snippetsDir}/resume-evaluation-state-completed/path-parameters.adoc[]
include::{snippetsDir}/resume-evaluation-state-completed/http-response.adoc[]
include::{snippetsDir}/resume-evaluation-state-completed/response-body.adoc[]
include::{snippetsDir}/resume-evaluation-state-completed/response-fields.adoc[]
include::{snippetsDir}/resume-evaluation-state-completed/curl-request.adoc[]

=== 이력서 평가 히스토리 조회

include::{snippetsDir}/resume-evaluation-history/http-request.adoc[]
include::{snippetsDir}/resume-evaluation-history/request-headers.adoc[]
include::{snippetsDir}/resume-evaluation-history/query-parameters.adoc[]
include::{snippetsDir}/resume-evaluation-history/http-response.adoc[]
include::{snippetsDir}/resume-evaluation-history/response-body.adoc[]
include::{snippetsDir}/resume-evaluation-history/response-fields.adoc[]
include::{snippetsDir}/resume-evaluation-history/curl-request.adoc[]

=== 이력서 평가 상세 조회

include::{snippetsDir}/resume-evaluation-detail/http-request.adoc[]
include::{snippetsDir}/resume-evaluation-detail/request-headers.adoc[]
include::{snippetsDir}/resume-evaluation-detail/path-parameters.adoc[]
include::{snippetsDir}/resume-evaluation-detail/http-response.adoc[]
include::{snippetsDir}/resume-evaluation-detail/response-body.adoc[]
include::{snippetsDir}/resume-evaluation-detail/response-fields.adoc[]
include::{snippetsDir}/resume-evaluation-detail/curl-request.adoc[]
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,20 @@ public ThreadPoolTaskExecutor gptCallbackExecutor() {
return executor;
}

@Bean("resumeEvaluationExecutor")
public ThreadPoolTaskExecutor resumeEvaluationExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(100);
executor.setMaxPoolSize(100);
executor.setQueueCapacity(1000);
executor.setWaitForTasksToCompleteOnShutdown(true);
executor.setAwaitTerminationSeconds(30);
executor.setThreadNamePrefix("Async-Resume-Eval-");
executor.initialize();
executor.getThreadPoolExecutor().prestartAllCoreThreads();
return executor;
}

@Override
public Executor getAsyncExecutor() {
return taskExecutor();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
package com.samhap.kokomen.global.service;

import com.samhap.kokomen.global.annotation.ExecutionTimer;
import com.samhap.kokomen.global.constant.AwsConstant;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import software.amazon.awssdk.core.ResponseBytes;
import software.amazon.awssdk.core.sync.RequestBody;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.model.GetObjectRequest;
import software.amazon.awssdk.services.s3.model.GetObjectResponse;
import software.amazon.awssdk.services.s3.model.HeadObjectRequest;
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
import software.amazon.awssdk.services.s3.model.S3Exception;
Expand All @@ -14,7 +18,7 @@
@Service
public class S3Service {

private static final String S3_BUCKET_NAME = "kokomen";
private static final String S3_BUCKET_NAME = "kokomen-new";

private final S3Client s3Client;

Expand Down Expand Up @@ -45,4 +49,26 @@ public boolean exists(String key) {
throw e;
}
}

public byte[] downloadFile(String key) {
GetObjectRequest request = GetObjectRequest.builder()
.bucket(S3_BUCKET_NAME)
.key(key)
.build();

ResponseBytes<GetObjectResponse> response = s3Client.getObjectAsBytes(request);
return response.asByteArray();
}

public byte[] downloadFileFromUrl(String cdnUrl) {
String key = extractKeyFromCdnUrl(cdnUrl);
return downloadFile(key);
}

private String extractKeyFromCdnUrl(String cdnUrl) {
if (cdnUrl.startsWith(AwsConstant.CLOUD_FRONT_DOMAIN_URL)) {
return cdnUrl.substring(AwsConstant.CLOUD_FRONT_DOMAIN_URL.length());
}
throw new IllegalArgumentException("Invalid CDN URL: " + cdnUrl);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,31 @@

import com.samhap.kokomen.global.annotation.Authentication;
import com.samhap.kokomen.global.dto.MemberAuth;
import com.samhap.kokomen.global.exception.BadRequestException;
import com.samhap.kokomen.resume.domain.CareerMaterialsType;
import com.samhap.kokomen.resume.service.CareerMaterialsFacadeService;
import com.samhap.kokomen.resume.service.dto.CareerMaterialsResponse;
import com.samhap.kokomen.resume.service.dto.ResumeEvaluationAsyncRequest;
import com.samhap.kokomen.resume.service.dto.ResumeEvaluationDetailResponse;
import com.samhap.kokomen.resume.service.dto.ResumeEvaluationHistoryResponses;
import com.samhap.kokomen.resume.service.dto.ResumeEvaluationStateResponse;
import com.samhap.kokomen.resume.service.dto.ResumeEvaluationSubmitResponse;
import com.samhap.kokomen.resume.service.dto.ResumeSaveRequest;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Pageable;
import org.springframework.data.web.PageableDefault;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
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.RequestPart;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;

@RequiredArgsConstructor
@RequestMapping("/api/v1/resumes")
Expand All @@ -39,4 +51,58 @@ public ResponseEntity<CareerMaterialsResponse> getCareerMaterials(
) {
return ResponseEntity.ok(careerMaterialsFacadeService.getCareerMaterials(type, memberAuth));
}

@PostMapping(value = "/evaluations", consumes = {"multipart/form-data"})
public ResponseEntity<ResumeEvaluationSubmitResponse> submitResumeEvaluationAsync(
@RequestPart(value = "resume", required = false) MultipartFile resume,
@RequestPart(value = "portfolio", required = false) MultipartFile portfolio,
@RequestPart(value = "resume_id", required = false) String resumeIdStr,
@RequestPart(value = "portfolio_id", required = false) String portfolioIdStr,
@RequestPart(value = "job_position") String jobPosition,
@RequestPart(value = "job_description", required = false) String jobDescription,
@RequestPart(value = "job_career") String jobCareer,
@Authentication(required = false) MemberAuth memberAuth
) {
Long resumeId = parseIdOrNull(resumeIdStr);
Long portfolioId = parseIdOrNull(portfolioIdStr);
ResumeEvaluationAsyncRequest request = new ResumeEvaluationAsyncRequest(
resume, portfolio, resumeId, portfolioId, jobPosition, jobDescription, jobCareer);
return ResponseEntity.status(HttpStatus.ACCEPTED)
.body(careerMaterialsFacadeService.submitResumeEvaluationAsync(request, memberAuth));
}

private Long parseIdOrNull(String fileId) {
if (fileId == null || fileId.isBlank()) {
return null;
}
try {
return Long.parseLong(fileId.trim());
} catch (NumberFormatException e) {
throw new BadRequestException("잘못된 파일 id 형식입니다: " + fileId);
}
}

@GetMapping("/evaluations/{evaluationId}/state")
public ResponseEntity<ResumeEvaluationStateResponse> findResumeEvaluationState(
@PathVariable String evaluationId,
@Authentication(required = false) MemberAuth memberAuth
) {
return ResponseEntity.ok(careerMaterialsFacadeService.findResumeEvaluationState(evaluationId, memberAuth));
}

@GetMapping("/evaluations")
public ResponseEntity<ResumeEvaluationHistoryResponses> findResumeEvaluationHistory(
@Authentication MemberAuth memberAuth,
@PageableDefault(size = 20) Pageable pageable
) {
return ResponseEntity.ok(careerMaterialsFacadeService.findResumeEvaluationHistory(memberAuth, pageable));
}

@GetMapping("/evaluations/{evaluationId}")
public ResponseEntity<ResumeEvaluationDetailResponse> findResumeEvaluationDetail(
@PathVariable Long evaluationId,
@Authentication MemberAuth memberAuth
) {
return ResponseEntity.ok(careerMaterialsFacadeService.findResumeEvaluationDetail(evaluationId, memberAuth));
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.samhap.kokomen.resume.domain;

import com.samhap.kokomen.global.constant.AwsConstant;
import java.util.UUID;
import lombok.Getter;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
Expand All @@ -22,21 +23,21 @@ public CareerMaterialsPathResolver(
this.portfolioS3Path = portfolioS3Path;
}

public String resolveResumeCdnPath(Long memberId, String title) {
return AwsConstant.CLOUD_FRONT_DOMAIN_URL + resumeS3Path + memberId + FOLDER_DELIMITER + title
public String resolveResumeCdnPath(Long memberId, String s3Key) {
return AwsConstant.CLOUD_FRONT_DOMAIN_URL + resumeS3Path + memberId + FOLDER_DELIMITER + s3Key
+ PDF_FILE_EXTENSION;
}
Comment on lines +26 to 29

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

resolveResumeCdnPath 메소드의 구현에 버그가 있습니다. s3Key 파라미터는 resolveResumeS3Key 메소드에서 생성된 전체 S3 객체 키(resumes/123/some-title-uuid.pdf와 같은 형식)를 전달받습니다. 하지만 현재 구현은 이 전체 키에 다시 경로와 확장자를 덧붙여 잘못된 CDN URL을 생성합니다. 예를 들어, https://.../resumes/123/resumes/123/my-resume-uuid.pdf.pdf 와 같은 잘못된 URL이 만들어질 수 있습니다. s3Key가 이미 전체 경로이므로, CloudFront 도메인만 앞에 붙여주면 올바른 URL이 생성됩니다. 이 수정으로 memberId 파라미터가 사용되지 않게 되는데, 추후 리팩토링 시 시그니처에서 제거하는 것을 고려해 보세요.

    public String resolveResumeCdnPath(Long memberId, String s3Key) {
        return AwsConstant.CLOUD_FRONT_DOMAIN_URL + s3Key;
    }


public String resolveResumeS3Key(Long memberId, String title) {
return resumeS3Path + memberId + FOLDER_DELIMITER + title + PDF_FILE_EXTENSION;
return resumeS3Path + memberId + FOLDER_DELIMITER + title + "-" + UUID.randomUUID() + PDF_FILE_EXTENSION;
}

public String resolvePortfolioCdnPath(Long memberId, String title) {
return AwsConstant.CLOUD_FRONT_DOMAIN_URL + portfolioS3Path + memberId + FOLDER_DELIMITER + title
public String resolvePortfolioCdnPath(Long memberId, String s3Key) {
return AwsConstant.CLOUD_FRONT_DOMAIN_URL + portfolioS3Path + memberId + FOLDER_DELIMITER + s3Key
+ PDF_FILE_EXTENSION;
}
Comment on lines +35 to 38

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

resolvePortfolioCdnPath 메소드 또한 resolveResumeCdnPath와 동일한 버그를 가지고 있습니다. s3Key 파라미터에 전체 S3 객체 키가 전달되므로, CloudFront 도메인만 앞에 붙여서 CDN URL을 생성해야 합니다.

    public String resolvePortfolioCdnPath(Long memberId, String s3Key) {
        return AwsConstant.CLOUD_FRONT_DOMAIN_URL + s3Key;
    }


public String resolvePortfolioS3Key(Long memberId, String title) {
return portfolioS3Path + memberId + FOLDER_DELIMITER + title + PDF_FILE_EXTENSION;
return portfolioS3Path + memberId + FOLDER_DELIMITER + title + "-" + UUID.randomUUID() + PDF_FILE_EXTENSION;
}
}
Loading