From 351e8beca5ecce063c1022c55db6d8eaa2889ff0 Mon Sep 17 00:00:00 2001 From: shinae1023 Date: Fri, 22 May 2026 21:23:19 +0900 Subject: [PATCH 1/9] =?UTF-8?q?[Refactor]=20jd=20=EC=B6=94=EC=B6=9C=20?= =?UTF-8?q?=EC=8B=9C=20sourceUrl=20=EC=A0=9C=EA=B1=B0=20(#27)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 3 + .../JobPostingExtractMultipartRequest.java | 2 +- .../dto/request/JobPostingIngestCommand.java | 1 - .../JobPostingIngestMultipartRequest.java | 2 +- .../service/JobPostingAiService.java | 19 ++- .../service/JobPostingAsyncFacadeService.java | 1 - .../service/JobPostingIngestService.java | 4 +- .../jobdri_api/global/config/s3/S3Config.java | 35 ++++++ .../global/config/s3/S3Uploader.java | 115 ++++++++++++++++++ 9 files changed, 163 insertions(+), 19 deletions(-) create mode 100644 src/main/java/com/jobdri/jobdri_api/global/config/s3/S3Config.java create mode 100644 src/main/java/com/jobdri/jobdri_api/global/config/s3/S3Uploader.java diff --git a/build.gradle b/build.gradle index 80620c7..c3ee721 100644 --- a/build.gradle +++ b/build.gradle @@ -49,6 +49,9 @@ dependencies { //openai implementation 'com.openai:openai-java:4.35.0' + //S3 + implementation 'io.awspring.cloud:spring-cloud-aws-starter-s3:3.1.1' + compileOnly 'org.projectlombok:lombok' developmentOnly 'org.springframework.boot:spring-boot-devtools' diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/request/JobPostingExtractMultipartRequest.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/request/JobPostingExtractMultipartRequest.java index 04fc410..a7ac75e 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/request/JobPostingExtractMultipartRequest.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/request/JobPostingExtractMultipartRequest.java @@ -2,6 +2,6 @@ import org.springframework.web.multipart.MultipartFile; -public record JobPostingExtractMultipartRequest(String rawText, String sourceUrl, MultipartFile image) { +public record JobPostingExtractMultipartRequest(String rawText, MultipartFile image) { } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/request/JobPostingIngestCommand.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/request/JobPostingIngestCommand.java index f697afb..519a96d 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/request/JobPostingIngestCommand.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/request/JobPostingIngestCommand.java @@ -9,7 +9,6 @@ public class JobPostingIngestCommand { private Long userId; private String rawText; - private String sourceUrl; private byte[] imageBytes; private String imageContentType; } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/request/JobPostingIngestMultipartRequest.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/request/JobPostingIngestMultipartRequest.java index d9c067a..d6a533c 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/request/JobPostingIngestMultipartRequest.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/request/JobPostingIngestMultipartRequest.java @@ -2,6 +2,6 @@ import org.springframework.web.multipart.MultipartFile; -public record JobPostingIngestMultipartRequest(String rawText, String sourceUrl, MultipartFile image) { +public record JobPostingIngestMultipartRequest(String rawText, MultipartFile image) { } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAiService.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAiService.java index 8a00ba8..9fa7b94 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAiService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAiService.java @@ -157,16 +157,16 @@ public JobPostingClassificationResultResponse classifyDetailClassification( } public JobPostingExtractResponse extractJobPosting(JobPostingExtractMultipartRequest request) { - return extractJobPosting(request.rawText(), request.image(), request.sourceUrl()); + return extractJobPosting(request.rawText(), request.image()); } - public JobPostingExtractResponse extractJobPosting(String rawText, byte[] imageBytes, String imageContentType, String sourceUrl) { + public JobPostingExtractResponse extractJobPosting(String rawText, byte[] imageBytes, String imageContentType) { validateInput(rawText, imageBytes); List contents = new ArrayList<>(); contents.add(ResponseInputContent.ofInputText( com.openai.models.responses.ResponseInputText.builder() - .text(buildPrompt(rawText, sourceUrl, imageBytes != null && imageBytes.length > 0)) + .text(buildPrompt(rawText, imageBytes != null && imageBytes.length > 0)) .build() )); @@ -198,18 +198,16 @@ public JobPostingExtractResponse extractJobPosting(String rawText, byte[] imageB } } - public JobPostingExtractResponse extractJobPosting(String rawText, MultipartFile imageFile, String sourceUrl) { + public JobPostingExtractResponse extractJobPosting(String rawText, MultipartFile imageFile) { return extractJobPosting( rawText, imageFile == null || imageFile.isEmpty() ? null : readImageBytes(imageFile), - imageFile == null || imageFile.isEmpty() ? null : imageFile.getContentType(), - sourceUrl + imageFile == null || imageFile.isEmpty() ? null : imageFile.getContentType() ); } - private String buildPrompt(String rawText, String sourceUrl, boolean hasImage) { + private String buildPrompt(String rawText, boolean hasImage) { String normalizedRawText = rawText == null ? "" : rawText; - String normalizedSourceUrl = sourceUrl == null ? "" : sourceUrl; return """ 이 %s는 채용 공고입니다. @@ -236,12 +234,9 @@ private String buildPrompt(String rawText, String sourceUrl, boolean hasImage) { 5. confidence는 추출 결과 전체에 대한 신뢰도를 0~1 사이 실수로 반환하세요. 6. JSON 외의 다른 텍스트는 절대 출력하지 마세요. - [원본 URL] - %s - [채용 공고 텍스트] %s - """.formatted(hasImage ? "이미지 또는 텍스트" : "텍스트", normalizedSourceUrl, normalizedRawText); + """.formatted(hasImage ? "이미지 또는 텍스트" : "텍스트", normalizedRawText); } private String buildClassificationPrompt( diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncFacadeService.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncFacadeService.java index f252864..66e9e69 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncFacadeService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncFacadeService.java @@ -51,7 +51,6 @@ private JobPostingIngestCommand snapshot(User user, JobPostingIngestMultipartReq return JobPostingIngestCommand.builder() .userId(user.getId()) .rawText(request.rawText()) - .sourceUrl(request.sourceUrl()) .imageBytes(readBytes(request.image())) .imageContentType(readContentType(request.image())) .build(); diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingIngestService.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingIngestService.java index f4f7a82..bd09c8e 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingIngestService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingIngestService.java @@ -40,7 +40,6 @@ public JobPostingIngestResponse ingestAndCreate(User user, JobPostingIngestMulti JobPostingIngestCommand command = JobPostingIngestCommand.builder() .userId(user.getId()) .rawText(request.rawText()) - .sourceUrl(request.sourceUrl()) .imageBytes(readBytes(request.image())) .imageContentType(readContentType(request.image())) .build(); @@ -52,8 +51,7 @@ public JobPostingIngestResponse ingestAndCreate(JobPostingIngestCommand command) JobPostingExtractResponse extracted = jobPostingAiService.extractJobPosting( command.getRawText(), command.getImageBytes(), - command.getImageContentType(), - command.getSourceUrl() + command.getImageContentType() ); List candidates = diff --git a/src/main/java/com/jobdri/jobdri_api/global/config/s3/S3Config.java b/src/main/java/com/jobdri/jobdri_api/global/config/s3/S3Config.java new file mode 100644 index 0000000..447aae7 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/global/config/s3/S3Config.java @@ -0,0 +1,35 @@ +package com.jobdri.jobdri_api.global.config.s3; + + +import io.lettuce.core.StaticCredentialsProvider; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import javax.swing.plaf.synth.Region; + +@Configuration +public class S3Config { + // application.yml (또는 .properties) 에서 설정한 값들을 가져옵니다. + @Value("${spring.cloud.aws.credentials.access-key}") + private String accessKey; + + @Value("${spring.cloud.aws.credentials.secret-key}") + private String secretKey; + + @Value("${spring.cloud.aws.region.static}") + private String region; + + // AmazonS3Client 객체를 생성하여 Bean으로 등록합니다. + @Bean + public S3Client s3Client() { + return S3Client.builder() + .region(Region.AP_NORTHEAST_2) + .credentialsProvider( + StaticCredentialsProvider.create( + AwsBasicCredentials.create(accessKey, secretKey) + ) + ) + .build(); + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/global/config/s3/S3Uploader.java b/src/main/java/com/jobdri/jobdri_api/global/config/s3/S3Uploader.java new file mode 100644 index 0000000..d1123fe --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/global/config/s3/S3Uploader.java @@ -0,0 +1,115 @@ +package com.jobdri.jobdri_api.global.config.s3; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.DeleteObjectRequest; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.util.Optional; +import java.util.UUID; + +@Slf4j +@Service +@RequiredArgsConstructor +public class S3Uploader { + private final S3Client s3Client; + + @Value("${spring.cloud.aws.s3.bucket}") + private String bucket; + + // MultipartFile을 전달받아 S3에 업로드 + public String upload(MultipartFile multipartFile, String dirName) throws IOException { + File uploadFile = convert(multipartFile) + .orElseThrow(() -> new IllegalArgumentException("MultipartFile -> File 전환 실패")); + return upload(uploadFile, dirName); + } + + // S3에 업로드할 때도 고유한 파일 이름을 사용하도록 수정합니다. + private String upload(File uploadFile, String dirName) { + // convert 메소드에서 생성한 고유한 이름을 그대로 사용합니다. + String fileName = dirName + "/" + uploadFile.getName(); + String uploadImageUrl = putS3(uploadFile, fileName); + removeNewFile(uploadFile); + return uploadImageUrl; + } + + // AWS SDK v2에 맞게 PutObjectRequest를 builder 패턴으로 변경 + private String putS3(File uploadFile, String fileName) { + PutObjectRequest putObjectRequest = PutObjectRequest.builder() + .bucket(bucket) + .key(fileName) + .build(); + + try { + s3Client.putObject(putObjectRequest, RequestBody.fromFile(uploadFile)); + return s3Client.utilities().getUrl(builder -> builder.bucket(bucket).key(fileName)).toString(); + } catch (Exception e) { + throw new RuntimeException("Failed to upload file to S3", e); + } + } + + // AWS SDK v2에 맞게 DeleteObjectRequest를 builder 패턴으로 변경 + public void deleteFile(String fileUrl) { + try { + // S3 URL에서 파일 이름(키)만 추출 + java.net.URL url = new java.net.URL(fileUrl); + String fileName = url.getPath().substring(1); // 맨 앞의 '/' 제거 + + DeleteObjectRequest deleteObjectRequest = DeleteObjectRequest.builder() + .bucket(bucket) + .key(fileName) // 예: 'images/abc.jpg' + .build(); + + s3Client.deleteObject(deleteObjectRequest); + } catch (Exception e) { + log.error("S3 파일 삭제에 실패했습니다. URL: {}", fileUrl, e); + } + } + + private void removeNewFile(File targetFile) { + if (targetFile.delete()) { + log.info("파일이 삭제되었습니다."); + } else { + log.info("파일이 삭제되지 못했습니다."); + } + } + + // MultipartFile을 File로 변환하고, 파일명에 UUID를 적용 + private Optional convert(MultipartFile file) throws IOException { + if (file.isEmpty()) { + return Optional.empty(); + } + + String originalFilename = file.getOriginalFilename(); + String storedFileName = UUID.randomUUID() + "." + extractExt(originalFilename); + File convertFile = new File(System.getProperty("java.io.tmpdir"), storedFileName); + + if (convertFile.createNewFile()) { + try (FileOutputStream fos = new FileOutputStream(convertFile)) { + fos.write(file.getBytes()); + } + return Optional.of(convertFile); + } + return Optional.empty(); + } + + // 파일의 확장자를 추출하는 헬퍼 메소드 + private String extractExt(String originalFilename) { + if (originalFilename != null) { + int pos = originalFilename.lastIndexOf("."); + if (pos != -1 && pos < originalFilename.length() - 1) { + return originalFilename.substring(pos + 1); + } + } + return ""; + } + +} From 827e91db8f230cf62906931309993676616a3e85 Mon Sep 17 00:00:00 2001 From: shinae1023 Date: Fri, 22 May 2026 21:41:18 +0900 Subject: [PATCH 2/9] =?UTF-8?q?[Feat]=20S3=20objectKey=20=EB=8F=84?= =?UTF-8?q?=EC=9E=85=20(#63)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/JobPostingAiController.java | 28 +---- .../JobPostingExtractMultipartRequest.java | 7 -- .../dto/request/JobPostingExtractRequest.java | 15 ++- .../dto/request/JobPostingIngestCommand.java | 3 +- .../JobPostingIngestMultipartRequest.java | 7 -- .../dto/request/JobPostingIngestRequest.java | 18 +++ .../service/JobPostingAiService.java | 106 +++++------------- .../service/JobPostingAsyncFacadeService.java | 31 +---- .../service/JobPostingIngestService.java | 31 +---- .../jobdri_api/global/config/s3/S3Config.java | 29 +++-- .../global/config/s3/S3ObjectUrlService.java | 37 ++++++ .../global/config/s3/S3Uploader.java | 2 +- .../service/JobPostingAiServiceTest.java | 7 +- .../service/JobPostingIngestServiceTest.java | 30 ++--- src/test/resources/application-test.yaml | 9 ++ 15 files changed, 153 insertions(+), 207 deletions(-) delete mode 100644 src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/request/JobPostingExtractMultipartRequest.java delete mode 100644 src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/request/JobPostingIngestMultipartRequest.java create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/request/JobPostingIngestRequest.java create mode 100644 src/main/java/com/jobdri/jobdri_api/global/config/s3/S3ObjectUrlService.java diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/controller/JobPostingAiController.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/controller/JobPostingAiController.java index 3cad853..0923820 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/controller/JobPostingAiController.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/controller/JobPostingAiController.java @@ -1,8 +1,7 @@ package com.jobdri.jobdri_api.domain.jobposting.controller; import com.jobdri.jobdri_api.domain.jobposting.dto.request.JobPostingExtractRequest; -import com.jobdri.jobdri_api.domain.jobposting.dto.request.JobPostingIngestMultipartRequest; -import com.jobdri.jobdri_api.domain.jobposting.dto.request.JobPostingExtractMultipartRequest; +import com.jobdri.jobdri_api.domain.jobposting.dto.request.JobPostingIngestRequest; import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingAsyncStatusResponse; import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingAsyncSubmitResponse; import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingExtractResponse; @@ -23,7 +22,6 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.GetMapping; @@ -41,28 +39,12 @@ public class JobPostingAiController { @Operation( summary = "채용 공고 정보 추출", - description = "채용 공고 원문 텍스트를 기반으로 회사명, 직무명, 주요 업무, 자격 요건, 우대 사항을 AI로 추출합니다." + description = "채용 공고 원문 텍스트 또는 업로드된 이미지 object key를 기반으로 회사명, 직무명, 주요 업무, 자격 요건, 우대 사항을 AI로 추출합니다." ) @PostMapping(value = "/extract", consumes = MediaType.APPLICATION_JSON_VALUE) - public ApiResponse extractJobPostingFromText( + public ApiResponse extractJobPosting( @AuthenticationPrincipal UserDetailsImpl userDetails, @Valid @RequestBody JobPostingExtractRequest request - ) { - validateAuthenticatedUser(userDetails); - return ApiResponse.onSuccess( - "채용 공고 추출에 성공했습니다.", - jobPostingAiService.extractJobPosting(request.rawText()) - ); - } - - @Operation( - summary = "채용 공고 정보 추출(이미지 또는 텍스트)", - description = "프론트에서 캡처한 채용 공고 이미지 파일과 선택적 텍스트, 원본 URL을 함께 보내면 AI가 구조화된 채용 공고 정보를 추출합니다." - ) - @PostMapping(value = "/extract", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) - public ApiResponse extractJobPostingFromMultipart( - @AuthenticationPrincipal UserDetailsImpl userDetails, - @ModelAttribute JobPostingExtractMultipartRequest request ) { validateAuthenticatedUser(userDetails); return ApiResponse.onSuccess( @@ -98,10 +80,10 @@ public ApiResponse extractJobPostingFromMultipart( ) ) }) - @PostMapping(value = "/ingest", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @PostMapping(value = "/ingest", consumes = MediaType.APPLICATION_JSON_VALUE) public ApiResponse ingestJobPosting( @AuthenticationPrincipal UserDetailsImpl userDetails, - @ModelAttribute JobPostingIngestMultipartRequest request + @Valid @RequestBody JobPostingIngestRequest request ) { var user = validateAuthenticatedUser(userDetails); return ApiResponse.onSuccess( diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/request/JobPostingExtractMultipartRequest.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/request/JobPostingExtractMultipartRequest.java deleted file mode 100644 index a7ac75e..0000000 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/request/JobPostingExtractMultipartRequest.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.jobdri.jobdri_api.domain.jobposting.dto.request; - -import org.springframework.web.multipart.MultipartFile; - -public record JobPostingExtractMultipartRequest(String rawText, MultipartFile image) { - -} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/request/JobPostingExtractRequest.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/request/JobPostingExtractRequest.java index 1bbe645..662f19a 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/request/JobPostingExtractRequest.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/request/JobPostingExtractRequest.java @@ -1,9 +1,18 @@ package com.jobdri.jobdri_api.domain.jobposting.dto.request; -import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.AssertTrue; public record JobPostingExtractRequest( - @NotBlank(message = "채용 공고 원문은 필수입니다.") - String rawText + String rawText, + String imageObjectKey ) { + + @AssertTrue(message = "rawText 또는 imageObjectKey 중 하나는 반드시 포함되어야 합니다.") + public boolean hasInput() { + return hasText(rawText) || hasText(imageObjectKey); + } + + private boolean hasText(String value) { + return value != null && !value.isBlank(); + } } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/request/JobPostingIngestCommand.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/request/JobPostingIngestCommand.java index 519a96d..41dacfb 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/request/JobPostingIngestCommand.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/request/JobPostingIngestCommand.java @@ -9,6 +9,5 @@ public class JobPostingIngestCommand { private Long userId; private String rawText; - private byte[] imageBytes; - private String imageContentType; + private String imageObjectKey; } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/request/JobPostingIngestMultipartRequest.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/request/JobPostingIngestMultipartRequest.java deleted file mode 100644 index d6a533c..0000000 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/request/JobPostingIngestMultipartRequest.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.jobdri.jobdri_api.domain.jobposting.dto.request; - -import org.springframework.web.multipart.MultipartFile; - -public record JobPostingIngestMultipartRequest(String rawText, MultipartFile image) { - -} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/request/JobPostingIngestRequest.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/request/JobPostingIngestRequest.java new file mode 100644 index 0000000..e33bd53 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/request/JobPostingIngestRequest.java @@ -0,0 +1,18 @@ +package com.jobdri.jobdri_api.domain.jobposting.dto.request; + +import jakarta.validation.constraints.AssertTrue; + +public record JobPostingIngestRequest( + String rawText, + String imageObjectKey +) { + + @AssertTrue(message = "rawText 또는 imageObjectKey 중 하나는 반드시 포함되어야 합니다.") + public boolean hasInput() { + return hasText(rawText) || hasText(imageObjectKey); + } + + private boolean hasText(String value) { + return value != null && !value.isBlank(); + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAiService.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAiService.java index 9fa7b94..6576b57 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAiService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAiService.java @@ -3,7 +3,7 @@ import com.jobdri.jobdri_api.domain.classification.entity.DetailClassification; import com.jobdri.jobdri_api.domain.classification.repository.DetailClassificationRepository; import com.jobdri.jobdri_api.domain.company.entity.Company; -import com.jobdri.jobdri_api.domain.jobposting.dto.request.JobPostingExtractMultipartRequest; +import com.jobdri.jobdri_api.domain.jobposting.dto.request.JobPostingExtractRequest; import com.jobdri.jobdri_api.domain.jobposting.dto.request.JobPostingGenerateRequest; import com.jobdri.jobdri_api.domain.jobposting.dto.request.JobPostingMockGenerateRequest; import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingClassificationCandidateResponse; @@ -14,6 +14,7 @@ import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingMockQuestionResponse; import com.jobdri.jobdri_api.domain.jobposting.entity.JobPosting; import com.jobdri.jobdri_api.domain.jobposting.repository.JobPostingRepository; +import com.jobdri.jobdri_api.global.config.s3.S3ObjectUrlService; import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; import com.openai.client.OpenAIClient; @@ -22,13 +23,9 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; -import org.springframework.web.multipart.MultipartFile; -import java.io.IOException; import java.util.ArrayList; import java.util.List; -import java.util.Set; -import java.util.Base64; import java.util.stream.Collectors; @Service @@ -41,20 +38,17 @@ public class JobPostingAiService { private final OpenAIClient openAIClient; private final DetailClassificationRepository detailClassificationRepository; private final JobPostingRepository jobPostingRepository; + private final S3ObjectUrlService s3ObjectUrlService; @Value("${openai.model.job-posting-extractor:gpt-4o-mini}") private String extractionModel; - private static final Set SUPPORTED_IMAGE_TYPES = Set.of( - "image/png", - "image/jpeg", - "image/jpg", - "image/webp", - "image/gif" - ); - public JobPostingExtractResponse extractJobPosting(String rawText) { - return extractJobPosting(rawText, null, null); + return extractJobPosting(rawText, null); + } + + public JobPostingExtractResponse extractJobPosting(JobPostingExtractRequest request) { + return extractJobPosting(request.rawText(), request.imageObjectKey()); } public JobPostingGenerateResponse generateJobPosting(JobPostingGenerateRequest request) { @@ -156,22 +150,21 @@ public JobPostingClassificationResultResponse classifyDetailClassification( } } - public JobPostingExtractResponse extractJobPosting(JobPostingExtractMultipartRequest request) { - return extractJobPosting(request.rawText(), request.image()); - } - - public JobPostingExtractResponse extractJobPosting(String rawText, byte[] imageBytes, String imageContentType) { - validateInput(rawText, imageBytes); + public JobPostingExtractResponse extractJobPosting(String rawText, String imageObjectKey) { + validateInput(rawText, imageObjectKey); + String imageUrl = hasText(imageObjectKey) + ? s3ObjectUrlService.createPresignedGetUrl(imageObjectKey) + : null; List contents = new ArrayList<>(); contents.add(ResponseInputContent.ofInputText( com.openai.models.responses.ResponseInputText.builder() - .text(buildPrompt(rawText, imageBytes != null && imageBytes.length > 0)) + .text(buildPrompt(rawText, imageUrl != null)) .build() )); - if (imageBytes != null && imageBytes.length > 0) { - contents.add(ResponseInputContent.ofInputImage(buildImageContent(imageBytes, imageContentType))); + if (imageUrl != null) { + contents.add(ResponseInputContent.ofInputImage(buildImageContent(imageUrl))); } var params = ResponseCreateParams.builder() @@ -198,14 +191,6 @@ public JobPostingExtractResponse extractJobPosting(String rawText, byte[] imageB } } - public JobPostingExtractResponse extractJobPosting(String rawText, MultipartFile imageFile) { - return extractJobPosting( - rawText, - imageFile == null || imageFile.isEmpty() ? null : readImageBytes(imageFile), - imageFile == null || imageFile.isEmpty() ? null : imageFile.getContentType() - ); - } - private String buildPrompt(String rawText, boolean hasImage) { String normalizedRawText = rawText == null ? "" : rawText; @@ -299,17 +284,9 @@ private String buildClassificationPrompt( ); } - private ResponseInputImage buildImageContent(MultipartFile imageFile) { - return buildImageContent(readImageBytes(imageFile), imageFile.getContentType()); - } - - private ResponseInputImage buildImageContent(byte[] imageBytes, String imageContentType) { - validateImage(imageContentType); - String base64 = Base64.getEncoder().encodeToString(imageBytes); - String dataUrl = "data:%s;base64,%s".formatted(imageContentType, base64); - + private ResponseInputImage buildImageContent(String imageUrl) { return ResponseInputImage.builder() - .imageUrl(dataUrl) + .imageUrl(imageUrl) .detail(ResponseInputImage.Detail.HIGH) .build(); } @@ -327,53 +304,18 @@ private T extractStructuredContent(StructuredResponse response, Class )); } - private void validateInput(String rawText, MultipartFile imageFile) { - boolean hasRawText = rawText != null && !rawText.isBlank(); - boolean hasImage = imageFile != null && !imageFile.isEmpty(); - - if (!hasRawText && !hasImage) { - throw new GeneralException( - GeneralErrorCode.INVALID_PARAMETER, - "rawText 또는 image 중 하나는 반드시 포함되어야 합니다." - ); - } - } - - private void validateInput(String rawText, byte[] imageBytes) { - boolean hasRawText = rawText != null && !rawText.isBlank(); - boolean hasImage = imageBytes != null && imageBytes.length > 0; + private void validateInput(String rawText, String imageObjectKey) { + boolean hasRawText = hasText(rawText); + boolean hasImage = hasText(imageObjectKey); if (!hasRawText && !hasImage) { throw new GeneralException( GeneralErrorCode.INVALID_PARAMETER, - "rawText 또는 image 중 하나는 반드시 포함되어야 합니다." + "rawText 또는 imageObjectKey 중 하나는 반드시 포함되어야 합니다." ); } } - private void validateImage(MultipartFile imageFile) { - validateImage(imageFile.getContentType()); - } - - private void validateImage(String contentType) { - if (contentType == null || !SUPPORTED_IMAGE_TYPES.contains(contentType.toLowerCase())) { - throw new GeneralException( - GeneralErrorCode.INVALID_PARAMETER, - "지원하는 이미지 형식은 png, jpg, jpeg, webp, gif 입니다." - ); - } - } - - private byte[] readImageBytes(MultipartFile imageFile) { - validateImage(imageFile); - - try { - return imageFile.getBytes(); - } catch (IOException e) { - throw new GeneralException(GeneralErrorCode.INVALID_PARAMETER, "이미지 파일을 읽을 수 없습니다."); - } - } - private JobPostingExtractResponse normalizeResponse(JobPostingExtractResponse response, String rawText) { if (response == null) { throw new GeneralException( @@ -826,6 +768,10 @@ private String defaultString(String value) { return value == null ? "" : value; } + private boolean hasText(String value) { + return value != null && !value.isBlank(); + } + private List defaultStringList(List values) { return values == null ? List.of() : values; } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncFacadeService.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncFacadeService.java index 66e9e69..55d00e5 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncFacadeService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncFacadeService.java @@ -1,7 +1,7 @@ package com.jobdri.jobdri_api.domain.jobposting.service; import com.jobdri.jobdri_api.domain.jobposting.dto.request.JobPostingIngestCommand; -import com.jobdri.jobdri_api.domain.jobposting.dto.request.JobPostingIngestMultipartRequest; +import com.jobdri.jobdri_api.domain.jobposting.dto.request.JobPostingIngestRequest; import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingAsyncStatusResponse; import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingAsyncSubmitResponse; import com.jobdri.jobdri_api.domain.user.entity.User; @@ -11,9 +11,6 @@ import lombok.RequiredArgsConstructor; import org.springframework.core.task.TaskRejectedException; import org.springframework.stereotype.Service; -import org.springframework.web.multipart.MultipartFile; - -import java.io.IOException; @Service @RequiredArgsConstructor @@ -23,7 +20,7 @@ public class JobPostingAsyncFacadeService { private final JobPostingAsyncProcessor jobPostingAsyncProcessor; private final UserService userService; - public JobPostingAsyncSubmitResponse submit(User user, JobPostingIngestMultipartRequest request) { + public JobPostingAsyncSubmitResponse submit(User user, JobPostingIngestRequest request) { User validatedUser = userService.validateUser(user); String taskId = jobPostingAsyncTaskService.createPendingTask(); JobPostingIngestCommand command = snapshot(validatedUser, request); @@ -47,31 +44,11 @@ public JobPostingAsyncStatusResponse getTask(String taskId) { return jobPostingAsyncTaskService.getTask(taskId); } - private JobPostingIngestCommand snapshot(User user, JobPostingIngestMultipartRequest request) { + private JobPostingIngestCommand snapshot(User user, JobPostingIngestRequest request) { return JobPostingIngestCommand.builder() .userId(user.getId()) .rawText(request.rawText()) - .imageBytes(readBytes(request.image())) - .imageContentType(readContentType(request.image())) + .imageObjectKey(request.imageObjectKey()) .build(); } - - private byte[] readBytes(MultipartFile image) { - if (image == null || image.isEmpty()) { - return null; - } - - try { - return image.getBytes(); - } catch (IOException e) { - throw new GeneralException(GeneralErrorCode.INVALID_PARAMETER, "이미지 파일을 읽을 수 없습니다."); - } - } - - private String readContentType(MultipartFile image) { - if (image == null || image.isEmpty()) { - return null; - } - return image.getContentType(); - } } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingIngestService.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingIngestService.java index bd09c8e..d7e0a9d 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingIngestService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingIngestService.java @@ -3,7 +3,7 @@ import com.jobdri.jobdri_api.domain.jobposting.dto.request.JobPostingCreateRequest; import com.jobdri.jobdri_api.domain.jobposting.dto.request.JobPostingGenerateRequest; import com.jobdri.jobdri_api.domain.jobposting.dto.request.JobPostingIngestCommand; -import com.jobdri.jobdri_api.domain.jobposting.dto.request.JobPostingIngestMultipartRequest; +import com.jobdri.jobdri_api.domain.jobposting.dto.request.JobPostingIngestRequest; import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingClassificationCandidateResponse; import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingClassificationResultResponse; import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingExtractResponse; @@ -17,9 +17,6 @@ import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; -import org.springframework.web.multipart.MultipartFile; - -import java.io.IOException; import java.util.List; @Service @@ -36,12 +33,11 @@ public class JobPostingIngestService { private final JobPostingService jobPostingService; private final UserService userService; - public JobPostingIngestResponse ingestAndCreate(User user, JobPostingIngestMultipartRequest request) { + public JobPostingIngestResponse ingestAndCreate(User user, JobPostingIngestRequest request) { JobPostingIngestCommand command = JobPostingIngestCommand.builder() .userId(user.getId()) .rawText(request.rawText()) - .imageBytes(readBytes(request.image())) - .imageContentType(readContentType(request.image())) + .imageObjectKey(request.imageObjectKey()) .build(); return ingestAndCreate(command); } @@ -50,8 +46,7 @@ public JobPostingIngestResponse ingestAndCreate(JobPostingIngestCommand command) JobPostingExtractResponse extracted = jobPostingAiService.extractJobPosting( command.getRawText(), - command.getImageBytes(), - command.getImageContentType() + command.getImageObjectKey() ); List candidates = @@ -131,22 +126,4 @@ private User resolveUser(JobPostingIngestCommand command) { return userService.getUser(command.getUserId()); } - private byte[] readBytes(MultipartFile image) { - if (image == null || image.isEmpty()) { - return null; - } - - try { - return image.getBytes(); - } catch (IOException e) { - throw new GeneralException(GeneralErrorCode.INVALID_PARAMETER, "이미지 파일을 읽을 수 없습니다."); - } - } - - private String readContentType(MultipartFile image) { - if (image == null || image.isEmpty()) { - return null; - } - return image.getContentType(); - } } diff --git a/src/main/java/com/jobdri/jobdri_api/global/config/s3/S3Config.java b/src/main/java/com/jobdri/jobdri_api/global/config/s3/S3Config.java index 447aae7..2206f70 100644 --- a/src/main/java/com/jobdri/jobdri_api/global/config/s3/S3Config.java +++ b/src/main/java/com/jobdri/jobdri_api/global/config/s3/S3Config.java @@ -1,30 +1,43 @@ package com.jobdri.jobdri_api.global.config.s3; - -import io.lettuce.core.StaticCredentialsProvider; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; -import javax.swing.plaf.synth.Region; @Configuration public class S3Config { // application.yml (또는 .properties) 에서 설정한 값들을 가져옵니다. - @Value("${spring.cloud.aws.credentials.access-key}") + @Value("${spring.cloud.aws.credentials.access-key:dummy-access-key}") private String accessKey; - @Value("${spring.cloud.aws.credentials.secret-key}") + @Value("${spring.cloud.aws.credentials.secret-key:dummy-secret-key}") private String secretKey; - @Value("${spring.cloud.aws.region.static}") + @Value("${spring.cloud.aws.region.static:ap-northeast-2}") private String region; - // AmazonS3Client 객체를 생성하여 Bean으로 등록합니다. @Bean public S3Client s3Client() { return S3Client.builder() - .region(Region.AP_NORTHEAST_2) + .region(Region.of(region)) + .credentialsProvider( + StaticCredentialsProvider.create( + AwsBasicCredentials.create(accessKey, secretKey) + ) + ) + .build(); + } + + @Bean + public S3Presigner s3Presigner() { + return S3Presigner.builder() + .region(Region.of(region)) .credentialsProvider( StaticCredentialsProvider.create( AwsBasicCredentials.create(accessKey, secretKey) diff --git a/src/main/java/com/jobdri/jobdri_api/global/config/s3/S3ObjectUrlService.java b/src/main/java/com/jobdri/jobdri_api/global/config/s3/S3ObjectUrlService.java new file mode 100644 index 0000000..8206e8f --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/global/config/s3/S3ObjectUrlService.java @@ -0,0 +1,37 @@ +package com.jobdri.jobdri_api.global.config.s3; + +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import software.amazon.awssdk.services.s3.model.GetObjectRequest; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; +import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest; + +import java.time.Duration; + +@Service +@RequiredArgsConstructor +public class S3ObjectUrlService { + + private final S3Presigner s3Presigner; + + @Value("${spring.cloud.aws.s3.bucket:dummy-bucket}") + private String bucket; + + @Value("${spring.cloud.aws.s3.presign-get-expiration-minutes:10}") + private long presignGetExpirationMinutes; + + public String createPresignedGetUrl(String objectKey) { + GetObjectRequest getObjectRequest = GetObjectRequest.builder() + .bucket(bucket) + .key(objectKey) + .build(); + + GetObjectPresignRequest presignRequest = GetObjectPresignRequest.builder() + .signatureDuration(Duration.ofMinutes(presignGetExpirationMinutes)) + .getObjectRequest(getObjectRequest) + .build(); + + return s3Presigner.presignGetObject(presignRequest).url().toString(); + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/global/config/s3/S3Uploader.java b/src/main/java/com/jobdri/jobdri_api/global/config/s3/S3Uploader.java index d1123fe..952cded 100644 --- a/src/main/java/com/jobdri/jobdri_api/global/config/s3/S3Uploader.java +++ b/src/main/java/com/jobdri/jobdri_api/global/config/s3/S3Uploader.java @@ -22,7 +22,7 @@ public class S3Uploader { private final S3Client s3Client; - @Value("${spring.cloud.aws.s3.bucket}") + @Value("${spring.cloud.aws.s3.bucket:dummy-bucket}") private String bucket; // MultipartFile을 전달받아 S3에 업로드 diff --git a/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAiServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAiServiceTest.java index 71eb4d7..78eac91 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAiServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAiServiceTest.java @@ -13,6 +13,7 @@ import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingMockQuestionResponse; import com.jobdri.jobdri_api.domain.jobposting.entity.JobPosting; import com.jobdri.jobdri_api.domain.jobposting.repository.JobPostingRepository; +import com.jobdri.jobdri_api.global.config.s3.S3ObjectUrlService; import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; import com.openai.client.OpenAIClient; @@ -47,6 +48,9 @@ class JobPostingAiServiceTest { @Mock private JobPostingRepository jobPostingRepository; + @Mock + private S3ObjectUrlService s3ObjectUrlService; + private JobPostingAiService jobPostingAiService; @BeforeEach @@ -54,7 +58,8 @@ void setUp() { jobPostingAiService = new JobPostingAiService( openAIClient, detailClassificationRepository, - jobPostingRepository + jobPostingRepository, + s3ObjectUrlService ); ReflectionTestUtils.setField(TEST_COMPANY, "id", 1L); ReflectionTestUtils.setField(TEST_USER, "id", 1L); diff --git a/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingIngestServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingIngestServiceTest.java index 8a594b1..840059e 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingIngestServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingIngestServiceTest.java @@ -1,7 +1,7 @@ package com.jobdri.jobdri_api.domain.jobposting.service; import com.jobdri.jobdri_api.domain.jobposting.dto.request.JobPostingCreateRequest; -import com.jobdri.jobdri_api.domain.jobposting.dto.request.JobPostingIngestMultipartRequest; +import com.jobdri.jobdri_api.domain.jobposting.dto.request.JobPostingIngestRequest; import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingClassificationCandidateResponse; import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingClassificationResultResponse; import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingExtractResponse; @@ -18,7 +18,6 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.mock.web.MockMultipartFile; import org.springframework.test.util.ReflectionTestUtils; import java.util.List; @@ -57,18 +56,11 @@ void setUp() { } @Test - @DisplayName("동기 ingest는 multipart 이미지와 content type을 추출 단계로 전달한다") - void ingestAndCreatePassesMultipartImageToExtract() { - MockMultipartFile image = new MockMultipartFile( - "image", - "posting.png", - "image/png", - new byte[]{1, 2, 3} - ); - JobPostingIngestMultipartRequest request = new JobPostingIngestMultipartRequest( + @DisplayName("동기 ingest는 image object key를 추출 단계로 전달한다") + void ingestAndCreatePassesImageObjectKeyToExtract() { + JobPostingIngestRequest request = new JobPostingIngestRequest( "채용 공고 원문", - "https://example.com/job-posting", - image + "job-postings/1/posting.png" ); JobPostingExtractResponse extracted = new JobPostingExtractResponse( @@ -115,7 +107,7 @@ void ingestAndCreatePassesMultipartImageToExtract() { .preferred("정제된 우대 사항") .build(); - when(jobPostingAiService.extractJobPosting(any(), any(byte[].class), any(), any())) + when(jobPostingAiService.extractJobPosting(any(), any())) .thenReturn(extracted); when(jobPostingClassificationService.findCandidates(extracted, 5)) .thenReturn(List.of(candidate)); @@ -129,17 +121,13 @@ void ingestAndCreatePassesMultipartImageToExtract() { JobPostingIngestResponse response = jobPostingIngestService.ingestAndCreate(user, request); - ArgumentCaptor imageBytesCaptor = ArgumentCaptor.forClass(byte[].class); - ArgumentCaptor contentTypeCaptor = ArgumentCaptor.forClass(String.class); + ArgumentCaptor imageObjectKeyCaptor = ArgumentCaptor.forClass(String.class); verify(jobPostingAiService).extractJobPosting( eq("채용 공고 원문"), - imageBytesCaptor.capture(), - contentTypeCaptor.capture(), - eq("https://example.com/job-posting") + imageObjectKeyCaptor.capture() ); - assertThat(imageBytesCaptor.getValue()).containsExactly(1, 2, 3); - assertThat(contentTypeCaptor.getValue()).isEqualTo("image/png"); + assertThat(imageObjectKeyCaptor.getValue()).isEqualTo("job-postings/1/posting.png"); assertThat(response.isSavedToDatabase()).isTrue(); } } diff --git a/src/test/resources/application-test.yaml b/src/test/resources/application-test.yaml index 999e156..e62ce09 100644 --- a/src/test/resources/application-test.yaml +++ b/src/test/resources/application-test.yaml @@ -2,6 +2,15 @@ spring: sql: init: mode: never + cloud: + aws: + credentials: + access-key: test-access-key + secret-key: test-secret-key + region: + static: ap-northeast-2 + s3: + bucket: test-bucket datasource: url: jdbc:h2:mem:jobdri-test;MODE=PostgreSQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE username: sa From 3884260ee85c791590c92252921a8ad91e208985 Mon Sep 17 00:00:00 2001 From: shinae1023 Date: Fri, 22 May 2026 21:45:42 +0900 Subject: [PATCH 3/9] [Feat] s3 presigned url (#63) --- .../controller/JobPostingAiController.java | 4 +- .../JobPostingUploadController.java | 43 +++++++++ .../JobPostingImageUploadPresignRequest.java | 12 +++ .../JobPostingImageUploadPresignResponse.java | 8 ++ .../service/JobPostingAiService.java | 13 ++- .../JobPostingImageStorageService.java | 96 +++++++++++++++++++ .../service/JobPostingIngestService.java | 1 + .../global/config/s3/S3ObjectUrlService.java | 17 ++++ .../service/JobPostingAiServiceTest.java | 6 +- .../service/JobPostingIngestServiceTest.java | 3 +- 10 files changed, 190 insertions(+), 13 deletions(-) create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/jobposting/controller/JobPostingUploadController.java create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/request/JobPostingImageUploadPresignRequest.java create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/response/JobPostingImageUploadPresignResponse.java create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingImageStorageService.java diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/controller/JobPostingAiController.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/controller/JobPostingAiController.java index 0923820..b89683a 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/controller/JobPostingAiController.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/controller/JobPostingAiController.java @@ -46,10 +46,10 @@ public ApiResponse extractJobPosting( @AuthenticationPrincipal UserDetailsImpl userDetails, @Valid @RequestBody JobPostingExtractRequest request ) { - validateAuthenticatedUser(userDetails); + var user = validateAuthenticatedUser(userDetails); return ApiResponse.onSuccess( "채용 공고 추출에 성공했습니다.", - jobPostingAiService.extractJobPosting(request) + jobPostingAiService.extractJobPosting(user.getId(), request) ); } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/controller/JobPostingUploadController.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/controller/JobPostingUploadController.java new file mode 100644 index 0000000..083d0dd --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/controller/JobPostingUploadController.java @@ -0,0 +1,43 @@ +package com.jobdri.jobdri_api.domain.jobposting.controller; + +import com.jobdri.jobdri_api.domain.jobposting.dto.request.JobPostingImageUploadPresignRequest; +import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingImageUploadPresignResponse; +import com.jobdri.jobdri_api.domain.jobposting.service.JobPostingImageStorageService; +import com.jobdri.jobdri_api.domain.user.service.UserService; +import com.jobdri.jobdri_api.global.apiPayload.ApiResponse; +import com.jobdri.jobdri_api.global.security.UserDetailsImpl; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +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.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/job-postings/images") +@Tag(name = "JobPosting Upload", description = "채용 공고 이미지 업로드 API") +public class JobPostingUploadController { + + private final JobPostingImageStorageService jobPostingImageStorageService; + private final UserService userService; + + @Operation( + summary = "채용 공고 이미지 업로드용 presigned PUT URL 발급", + description = "로그인 사용자가 S3에 직접 이미지를 업로드할 수 있도록 presigned PUT URL과 object key를 발급합니다." + ) + @PostMapping("/presign-upload") + public ApiResponse createPresignedUploadUrl( + @AuthenticationPrincipal UserDetailsImpl userDetails, + @Valid @RequestBody JobPostingImageUploadPresignRequest request + ) { + var user = userService.validateUser(userDetails == null ? null : userDetails.getUser()); + return ApiResponse.onSuccess( + "채용 공고 이미지 업로드 URL 발급에 성공했습니다.", + jobPostingImageStorageService.createUploadPresignUrl(user.getId(), request) + ); + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/request/JobPostingImageUploadPresignRequest.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/request/JobPostingImageUploadPresignRequest.java new file mode 100644 index 0000000..6fd6236 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/request/JobPostingImageUploadPresignRequest.java @@ -0,0 +1,12 @@ +package com.jobdri.jobdri_api.domain.jobposting.dto.request; + +import jakarta.validation.constraints.NotBlank; + +public record JobPostingImageUploadPresignRequest( + @NotBlank(message = "파일명은 필수입니다.") + String fileName, + + @NotBlank(message = "Content-Type은 필수입니다.") + String contentType +) { +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/response/JobPostingImageUploadPresignResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/response/JobPostingImageUploadPresignResponse.java new file mode 100644 index 0000000..517fd20 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/response/JobPostingImageUploadPresignResponse.java @@ -0,0 +1,8 @@ +package com.jobdri.jobdri_api.domain.jobposting.dto.response; + +public record JobPostingImageUploadPresignResponse( + String objectKey, + String uploadUrl, + long expiresInMinutes +) { +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAiService.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAiService.java index 6576b57..38071cc 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAiService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAiService.java @@ -14,7 +14,6 @@ import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingMockQuestionResponse; import com.jobdri.jobdri_api.domain.jobposting.entity.JobPosting; import com.jobdri.jobdri_api.domain.jobposting.repository.JobPostingRepository; -import com.jobdri.jobdri_api.global.config.s3.S3ObjectUrlService; import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; import com.openai.client.OpenAIClient; @@ -38,17 +37,17 @@ public class JobPostingAiService { private final OpenAIClient openAIClient; private final DetailClassificationRepository detailClassificationRepository; private final JobPostingRepository jobPostingRepository; - private final S3ObjectUrlService s3ObjectUrlService; + private final JobPostingImageStorageService jobPostingImageStorageService; @Value("${openai.model.job-posting-extractor:gpt-4o-mini}") private String extractionModel; public JobPostingExtractResponse extractJobPosting(String rawText) { - return extractJobPosting(rawText, null); + return extractJobPosting(null, rawText, null); } - public JobPostingExtractResponse extractJobPosting(JobPostingExtractRequest request) { - return extractJobPosting(request.rawText(), request.imageObjectKey()); + public JobPostingExtractResponse extractJobPosting(Long userId, JobPostingExtractRequest request) { + return extractJobPosting(userId, request.rawText(), request.imageObjectKey()); } public JobPostingGenerateResponse generateJobPosting(JobPostingGenerateRequest request) { @@ -150,10 +149,10 @@ public JobPostingClassificationResultResponse classifyDetailClassification( } } - public JobPostingExtractResponse extractJobPosting(String rawText, String imageObjectKey) { + public JobPostingExtractResponse extractJobPosting(Long userId, String rawText, String imageObjectKey) { validateInput(rawText, imageObjectKey); String imageUrl = hasText(imageObjectKey) - ? s3ObjectUrlService.createPresignedGetUrl(imageObjectKey) + ? jobPostingImageStorageService.createReadableImageUrl(userId, imageObjectKey) : null; List contents = new ArrayList<>(); diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingImageStorageService.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingImageStorageService.java new file mode 100644 index 0000000..ef81798 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingImageStorageService.java @@ -0,0 +1,96 @@ +package com.jobdri.jobdri_api.domain.jobposting.service; + +import com.jobdri.jobdri_api.domain.jobposting.dto.request.JobPostingImageUploadPresignRequest; +import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingImageUploadPresignResponse; +import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; +import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; +import com.jobdri.jobdri_api.global.config.s3.S3ObjectUrlService; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.util.Locale; +import java.util.Map; +import java.util.UUID; + +@Service +public class JobPostingImageStorageService { + + private static final String BASE_DIR = "job-postings"; + private static final Map CONTENT_TYPE_TO_EXTENSION = Map.of( + "image/png", "png", + "image/jpeg", "jpg", + "image/jpg", "jpg", + "image/webp", "webp", + "image/gif", "gif" + ); + + private final S3ObjectUrlService s3ObjectUrlService; + private final long presignPutExpirationMinutes; + + public JobPostingImageStorageService( + S3ObjectUrlService s3ObjectUrlService, + @Value("${spring.cloud.aws.s3.presign-put-expiration-minutes:5}") long presignPutExpirationMinutes + ) { + this.s3ObjectUrlService = s3ObjectUrlService; + this.presignPutExpirationMinutes = presignPutExpirationMinutes; + } + + public JobPostingImageUploadPresignResponse createUploadPresignUrl( + Long userId, + JobPostingImageUploadPresignRequest request + ) { + String normalizedContentType = normalizeContentType(request.contentType()); + String extension = CONTENT_TYPE_TO_EXTENSION.get(normalizedContentType); + + if (extension == null) { + throw new GeneralException( + GeneralErrorCode.INVALID_PARAMETER, + "지원하는 이미지 형식은 png, jpg, jpeg, webp, gif 입니다." + ); + } + + String objectKey = buildObjectKey(userId, extension); + String uploadUrl = s3ObjectUrlService.createPresignedPutUrl( + objectKey, + normalizedContentType, + presignPutExpirationMinutes + ); + + return new JobPostingImageUploadPresignResponse( + objectKey, + uploadUrl, + presignPutExpirationMinutes + ); + } + + public String createReadableImageUrl(Long userId, String objectKey) { + validateOwnership(userId, objectKey); + return s3ObjectUrlService.createPresignedGetUrl(objectKey); + } + + public void validateOwnership(Long userId, String objectKey) { + if (objectKey == null || objectKey.isBlank()) { + return; + } + + String expectedPrefix = buildUserPrefix(userId); + if (!objectKey.startsWith(expectedPrefix)) { + throw new GeneralException( + GeneralErrorCode.FORBIDDEN, + "본인이 업로드한 채용 공고 이미지만 사용할 수 있습니다." + ); + } + } + + private String buildObjectKey(Long userId, String extension) { + return buildUserPrefix(userId) + UUID.randomUUID() + "." + extension; + } + + private String buildUserPrefix(Long userId) { + return BASE_DIR + "/" + userId + "/"; + } + + private String normalizeContentType(String contentType) { + return contentType == null ? "" : contentType.trim().toLowerCase(Locale.ROOT); + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingIngestService.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingIngestService.java index d7e0a9d..a215cae 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingIngestService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingIngestService.java @@ -45,6 +45,7 @@ public JobPostingIngestResponse ingestAndCreate(User user, JobPostingIngestReque public JobPostingIngestResponse ingestAndCreate(JobPostingIngestCommand command) { JobPostingExtractResponse extracted = jobPostingAiService.extractJobPosting( + command.getUserId(), command.getRawText(), command.getImageObjectKey() ); diff --git a/src/main/java/com/jobdri/jobdri_api/global/config/s3/S3ObjectUrlService.java b/src/main/java/com/jobdri/jobdri_api/global/config/s3/S3ObjectUrlService.java index 8206e8f..bbbffed 100644 --- a/src/main/java/com/jobdri/jobdri_api/global/config/s3/S3ObjectUrlService.java +++ b/src/main/java/com/jobdri/jobdri_api/global/config/s3/S3ObjectUrlService.java @@ -4,8 +4,10 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import software.amazon.awssdk.services.s3.model.GetObjectRequest; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; import software.amazon.awssdk.services.s3.presigner.S3Presigner; import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest; +import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest; import java.time.Duration; @@ -34,4 +36,19 @@ public String createPresignedGetUrl(String objectKey) { return s3Presigner.presignGetObject(presignRequest).url().toString(); } + + public String createPresignedPutUrl(String objectKey, String contentType, long expiresInMinutes) { + PutObjectRequest putObjectRequest = PutObjectRequest.builder() + .bucket(bucket) + .key(objectKey) + .contentType(contentType) + .build(); + + PutObjectPresignRequest presignRequest = PutObjectPresignRequest.builder() + .signatureDuration(Duration.ofMinutes(expiresInMinutes)) + .putObjectRequest(putObjectRequest) + .build(); + + return s3Presigner.presignPutObject(presignRequest).url().toString(); + } } diff --git a/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAiServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAiServiceTest.java index 78eac91..7de1202 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAiServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAiServiceTest.java @@ -13,7 +13,7 @@ import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingMockQuestionResponse; import com.jobdri.jobdri_api.domain.jobposting.entity.JobPosting; import com.jobdri.jobdri_api.domain.jobposting.repository.JobPostingRepository; -import com.jobdri.jobdri_api.global.config.s3.S3ObjectUrlService; +import com.jobdri.jobdri_api.domain.jobposting.service.JobPostingImageStorageService; import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; import com.openai.client.OpenAIClient; @@ -49,7 +49,7 @@ class JobPostingAiServiceTest { private JobPostingRepository jobPostingRepository; @Mock - private S3ObjectUrlService s3ObjectUrlService; + private JobPostingImageStorageService jobPostingImageStorageService; private JobPostingAiService jobPostingAiService; @@ -59,7 +59,7 @@ void setUp() { openAIClient, detailClassificationRepository, jobPostingRepository, - s3ObjectUrlService + jobPostingImageStorageService ); ReflectionTestUtils.setField(TEST_COMPANY, "id", 1L); ReflectionTestUtils.setField(TEST_USER, "id", 1L); diff --git a/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingIngestServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingIngestServiceTest.java index 840059e..121894b 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingIngestServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingIngestServiceTest.java @@ -107,7 +107,7 @@ void ingestAndCreatePassesImageObjectKeyToExtract() { .preferred("정제된 우대 사항") .build(); - when(jobPostingAiService.extractJobPosting(any(), any())) + when(jobPostingAiService.extractJobPosting(any(), any(), any())) .thenReturn(extracted); when(jobPostingClassificationService.findCandidates(extracted, 5)) .thenReturn(List.of(candidate)); @@ -123,6 +123,7 @@ void ingestAndCreatePassesImageObjectKeyToExtract() { ArgumentCaptor imageObjectKeyCaptor = ArgumentCaptor.forClass(String.class); verify(jobPostingAiService).extractJobPosting( + eq(1L), eq("채용 공고 원문"), imageObjectKeyCaptor.capture() ); From 6235ee145404d936243378bcf20dee4ed139176e Mon Sep 17 00:00:00 2001 From: shinae1023 Date: Fri, 22 May 2026 22:04:54 +0900 Subject: [PATCH 4/9] refactor: switch job posting image flow to S3 object keys and presigned uploads (#63) --- ops/s3/README.md | 17 +++++++++ ops/s3/job-posting-image-cors.json | 25 +++++++++++++ ops/s3/job-posting-image-lifecycle.json | 17 +++++++++ .../JobPostingImageStorageService.java | 37 ++++++++++++++++++- .../global/config/s3/S3ObjectUrlService.java | 4 ++ 5 files changed, 99 insertions(+), 1 deletion(-) create mode 100644 ops/s3/README.md create mode 100644 ops/s3/job-posting-image-cors.json create mode 100644 ops/s3/job-posting-image-lifecycle.json diff --git a/ops/s3/README.md b/ops/s3/README.md new file mode 100644 index 0000000..6df4841 --- /dev/null +++ b/ops/s3/README.md @@ -0,0 +1,17 @@ +# S3 setup for job posting images + +## 1. Apply bucket CORS + +```bash +aws s3api put-bucket-cors \ + --bucket "$S3_BUCKET" \ + --cors-configuration file://ops/s3/job-posting-image-cors.json +``` + +## 2. Apply lifecycle policy + +```bash +aws s3api put-bucket-lifecycle-configuration \ + --bucket "$S3_BUCKET" \ + --lifecycle-configuration file://ops/s3/job-posting-image-lifecycle.json +``` diff --git a/ops/s3/job-posting-image-cors.json b/ops/s3/job-posting-image-cors.json new file mode 100644 index 0000000..582d34f --- /dev/null +++ b/ops/s3/job-posting-image-cors.json @@ -0,0 +1,25 @@ +[ + { + "AllowedHeaders": [ + "*" + ], + "AllowedMethods": [ + "PUT", + "GET", + "HEAD" + ], + "AllowedOrigins": [ + "https://jobdri.site", + "https://www.jobdri.site", + "https://job-dri.vercel.app", + "http://localhost:3000", + "http://localhost:5173" + ], + "ExposeHeaders": [ + "ETag", + "x-amz-request-id", + "x-amz-id-2" + ], + "MaxAgeSeconds": 3000 + } +] diff --git a/ops/s3/job-posting-image-lifecycle.json b/ops/s3/job-posting-image-lifecycle.json new file mode 100644 index 0000000..5d42658 --- /dev/null +++ b/ops/s3/job-posting-image-lifecycle.json @@ -0,0 +1,17 @@ +{ + "Rules": [ + { + "ID": "DeleteTemporaryJobPostingImages", + "Status": "Enabled", + "Filter": { + "Prefix": "job-postings/" + }, + "Expiration": { + "Days": 1 + }, + "AbortIncompleteMultipartUpload": { + "DaysAfterInitiation": 1 + } + } + ] +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingImageStorageService.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingImageStorageService.java index ef81798..d28e02f 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingImageStorageService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingImageStorageService.java @@ -7,6 +7,9 @@ import com.jobdri.jobdri_api.global.config.s3.S3ObjectUrlService; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.HeadObjectRequest; +import software.amazon.awssdk.services.s3.model.HeadObjectResponse; import java.util.Locale; import java.util.Map; @@ -25,14 +28,20 @@ public class JobPostingImageStorageService { ); private final S3ObjectUrlService s3ObjectUrlService; + private final S3Client s3Client; private final long presignPutExpirationMinutes; + private final long maxImageSizeBytes; public JobPostingImageStorageService( S3ObjectUrlService s3ObjectUrlService, - @Value("${spring.cloud.aws.s3.presign-put-expiration-minutes:5}") long presignPutExpirationMinutes + S3Client s3Client, + @Value("${spring.cloud.aws.s3.presign-put-expiration-minutes:5}") long presignPutExpirationMinutes, + @Value("${job-posting.image-upload.max-size-bytes:5242880}") long maxImageSizeBytes ) { this.s3ObjectUrlService = s3ObjectUrlService; + this.s3Client = s3Client; this.presignPutExpirationMinutes = presignPutExpirationMinutes; + this.maxImageSizeBytes = maxImageSizeBytes; } public JobPostingImageUploadPresignResponse createUploadPresignUrl( @@ -65,6 +74,7 @@ public JobPostingImageUploadPresignResponse createUploadPresignUrl( public String createReadableImageUrl(Long userId, String objectKey) { validateOwnership(userId, objectKey); + validateUploadedObject(objectKey); return s3ObjectUrlService.createPresignedGetUrl(objectKey); } @@ -93,4 +103,29 @@ private String buildUserPrefix(Long userId) { private String normalizeContentType(String contentType) { return contentType == null ? "" : contentType.trim().toLowerCase(Locale.ROOT); } + + private void validateUploadedObject(String objectKey) { + HeadObjectResponse headObject = s3Client.headObject( + HeadObjectRequest.builder() + .bucket(s3ObjectUrlService.getBucket()) + .key(objectKey) + .build() + ); + + String normalizedContentType = normalizeContentType(headObject.contentType()); + if (!CONTENT_TYPE_TO_EXTENSION.containsKey(normalizedContentType)) { + throw new GeneralException( + GeneralErrorCode.INVALID_PARAMETER, + "지원하는 이미지 형식은 png, jpg, jpeg, webp, gif 입니다." + ); + } + + Long contentLength = headObject.contentLength(); + if (contentLength != null && contentLength > maxImageSizeBytes) { + throw new GeneralException( + GeneralErrorCode.INVALID_PARAMETER, + "이미지 파일 크기가 허용 범위를 초과했습니다." + ); + } + } } diff --git a/src/main/java/com/jobdri/jobdri_api/global/config/s3/S3ObjectUrlService.java b/src/main/java/com/jobdri/jobdri_api/global/config/s3/S3ObjectUrlService.java index bbbffed..199c62d 100644 --- a/src/main/java/com/jobdri/jobdri_api/global/config/s3/S3ObjectUrlService.java +++ b/src/main/java/com/jobdri/jobdri_api/global/config/s3/S3ObjectUrlService.java @@ -51,4 +51,8 @@ public String createPresignedPutUrl(String objectKey, String contentType, long e return s3Presigner.presignPutObject(presignRequest).url().toString(); } + + public String getBucket() { + return bucket; + } } From 1d4281d1e3f7b0760689fb8268e98baa6c3bc7b7 Mon Sep 17 00:00:00 2001 From: shinae1023 Date: Fri, 22 May 2026 22:14:04 +0900 Subject: [PATCH 5/9] [Feat] add presigned S3 upload flow for job posting images (#63) --- src/main/resources/application-dev.yaml | 13 +++++++++++++ src/main/resources/application-prod.yaml | 13 +++++++++++++ src/test/resources/application-test.yaml | 6 ++++++ 3 files changed, 32 insertions(+) diff --git a/src/main/resources/application-dev.yaml b/src/main/resources/application-dev.yaml index 3a0975f..60136ff 100644 --- a/src/main/resources/application-dev.yaml +++ b/src/main/resources/application-dev.yaml @@ -49,6 +49,17 @@ spring: - email - profile redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}" + cloud: + aws: + credentials: + access-key: ${AWS_ACCESS_KEY_ID:dummy-access-key} + secret-key: ${AWS_SECRET_ACCESS_KEY:dummy-secret-key} + region: + static: ${AWS_REGION:ap-northeast-2} + s3: + bucket: ${S3_BUCKET:dummy-bucket} + presign-get-expiration-minutes: ${S3_PRESIGN_GET_EXPIRATION_MINUTES:10} + presign-put-expiration-minutes: ${S3_PRESIGN_PUT_EXPIRATION_MINUTES:5} mail: from: ${MAIL_FROM:${MAIL_USERNAME:}} @@ -76,6 +87,8 @@ openai: job-posting: ingest: classification-confidence-threshold: ${JOB_POSTING_CLASSIFICATION_CONFIDENCE_THRESHOLD:0.65} + image-upload: + max-size-bytes: ${JOB_POSTING_IMAGE_UPLOAD_MAX_SIZE_BYTES:5242880} async: job-posting: diff --git a/src/main/resources/application-prod.yaml b/src/main/resources/application-prod.yaml index 48e5ca1..b20ffc4 100644 --- a/src/main/resources/application-prod.yaml +++ b/src/main/resources/application-prod.yaml @@ -49,6 +49,17 @@ spring: - email - profile redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}" + cloud: + aws: + credentials: + access-key: ${AWS_ACCESS_KEY_ID} + secret-key: ${AWS_SECRET_ACCESS_KEY} + region: + static: ${AWS_REGION} + s3: + bucket: ${S3_BUCKET} + presign-get-expiration-minutes: ${S3_PRESIGN_GET_EXPIRATION_MINUTES:10} + presign-put-expiration-minutes: ${S3_PRESIGN_PUT_EXPIRATION_MINUTES:5} mail: from: ${MAIL_FROM:${MAIL_USERNAME:}} @@ -88,6 +99,8 @@ openai: job-posting: ingest: classification-confidence-threshold: ${JOB_POSTING_CLASSIFICATION_CONFIDENCE_THRESHOLD:0.65} + image-upload: + max-size-bytes: ${JOB_POSTING_IMAGE_UPLOAD_MAX_SIZE_BYTES:5242880} async: job-posting: diff --git a/src/test/resources/application-test.yaml b/src/test/resources/application-test.yaml index e62ce09..ea6145c 100644 --- a/src/test/resources/application-test.yaml +++ b/src/test/resources/application-test.yaml @@ -11,6 +11,8 @@ spring: static: ap-northeast-2 s3: bucket: test-bucket + presign-get-expiration-minutes: 10 + presign-put-expiration-minutes: 5 datasource: url: jdbc:h2:mem:jobdri-test;MODE=PostgreSQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE username: sa @@ -70,3 +72,7 @@ jwt: expiration: access-token: 3600000 refresh-token: 1209600000 + +job-posting: + image-upload: + max-size-bytes: 5242880 From 629d669f50efa66480af4e936ab340f15c1ec008 Mon Sep 17 00:00:00 2001 From: wooh Date: Fri, 22 May 2026 20:43:13 +0900 Subject: [PATCH 6/9] =?UTF-8?q?[Feat]=20=EC=9E=90=EC=86=8C=EC=84=9C=20?= =?UTF-8?q?=EB=B6=84=EC=84=9D=20API=20=EA=B5=AC=ED=98=84=20(#24)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 자소서 분석 실행 API 추가 - 자소서 분석 결과 조회 API 추가 - 저장된 문항 답변과 공고 정보를 기반으로 LLM 분석 프롬프트 구성 - LLM JSON 응답을 Analysis 및 QuestionAnalysis 엔티티로 저장 - 재분석 시 기존 분석 결과와 문항 분석 결과를 교체하도록 처리 - 분석 완료 시 MockApply 상태를 COMPLETED로 변경 - 원문에 존재하지 않는 분석 sentence는 저장하지 않도록 검증 - start/end index를 서버에서 answer 기준으로 계산 - 점수를 0~100 범위로 보정 - 분석 결과 응답 DTO 및 LLM 응답 DTO 추가 - 분석 결과 없음 예외 코드 추가 - 로컬 실행 기본 프로필을 dev로 설정하고 dev JWT 기본값 추가 - 자소서 분석 서비스 테스트 추가 --- .../controller/AnalysisController.java | 57 ++++ .../analysis/dto/llm/AnalysisLlmResponse.java | 20 ++ .../response/AnalysisQuestionResponse.java | 24 ++ .../dto/response/AnalysisResponse.java | 36 +++ .../response/QuestionAnalysisResponse.java | 23 ++ .../domain/analysis/entity/Analysis.java | 4 +- .../QuestionAnalysisRepository.java | 2 + .../analysis/service/AnalysisAiClient.java | 163 ++++++++++ .../analysis/service/AnalysisService.java | 202 +++++++++++++ .../domain/mockapply/entity/MockApply.java | 4 + .../apiPayload/code/GeneralErrorCode.java | 1 + src/main/resources/application-dev.yaml | 2 +- src/main/resources/application.yaml | 2 +- .../analysis/service/AnalysisServiceTest.java | 282 ++++++++++++++++++ 14 files changed, 817 insertions(+), 5 deletions(-) create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/analysis/controller/AnalysisController.java create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/llm/AnalysisLlmResponse.java create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/AnalysisQuestionResponse.java create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/AnalysisResponse.java create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/QuestionAnalysisResponse.java create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAiClient.java create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisService.java create mode 100644 src/test/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisServiceTest.java diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/controller/AnalysisController.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/controller/AnalysisController.java new file mode 100644 index 0000000..d5e552d --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/controller/AnalysisController.java @@ -0,0 +1,57 @@ +package com.jobdri.jobdri_api.domain.analysis.controller; + +import com.jobdri.jobdri_api.domain.analysis.dto.response.AnalysisResponse; +import com.jobdri.jobdri_api.domain.analysis.service.AnalysisService; +import com.jobdri.jobdri_api.global.apiPayload.ApiResponse; +import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; +import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; +import com.jobdri.jobdri_api.global.security.UserDetailsImpl; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +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.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/mock-applies/{mockApplyId}/analysis") +@Tag(name = "Analysis", description = "자소서 분석 API") +public class AnalysisController { + + private final AnalysisService analysisService; + + @Operation(summary = "자소서 분석 실행", description = "저장된 문항 답변과 공고 정보를 기반으로 자소서를 분석하고 결과를 저장합니다.") + @PostMapping + public ApiResponse analyze( + @AuthenticationPrincipal UserDetailsImpl userDetails, + @PathVariable Long mockApplyId + ) { + return ApiResponse.onSuccess( + "자소서 분석이 완료되었습니다.", + analysisService.analyze(getAuthenticatedUser(userDetails), mockApplyId) + ); + } + + @Operation(summary = "자소서 분석 결과 조회", description = "저장된 자소서 분석 결과를 조회합니다.") + @GetMapping + public ApiResponse getAnalysis( + @AuthenticationPrincipal UserDetailsImpl userDetails, + @PathVariable Long mockApplyId + ) { + return ApiResponse.onSuccess( + "자소서 분석 결과 조회에 성공했습니다.", + analysisService.getAnalysis(getAuthenticatedUser(userDetails), mockApplyId) + ); + } + + private com.jobdri.jobdri_api.domain.user.entity.User getAuthenticatedUser(UserDetailsImpl userDetails) { + if (userDetails == null || userDetails.getUser() == null) { + throw new GeneralException(GeneralErrorCode.MISSING_AUTH_INFO, "인증 정보가 누락되었습니다."); + } + return userDetails.getUser(); + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/llm/AnalysisLlmResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/llm/AnalysisLlmResponse.java new file mode 100644 index 0000000..0cb253e --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/llm/AnalysisLlmResponse.java @@ -0,0 +1,20 @@ +package com.jobdri.jobdri_api.domain.analysis.dto.llm; + +import java.util.List; + +public record AnalysisLlmResponse( + Integer score, + Integer jobFit, + Integer impact, + Integer completeness, + String feedback, + List questionAnalyses +) { + public record QuestionAnalysisItem( + Long questionId, + String sentence, + String reason, + String improvement + ) { + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/AnalysisQuestionResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/AnalysisQuestionResponse.java new file mode 100644 index 0000000..00a477f --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/AnalysisQuestionResponse.java @@ -0,0 +1,24 @@ +package com.jobdri.jobdri_api.domain.analysis.dto.response; + +import com.jobdri.jobdri_api.domain.analysis.entity.Question; + +import java.util.List; + +public record AnalysisQuestionResponse( + Long questionId, + String questionContent, + String answer, + List analyses +) { + public static AnalysisQuestionResponse of( + Question question, + List analyses + ) { + return new AnalysisQuestionResponse( + question.getId(), + question.getContent(), + question.getAnswer(), + analyses + ); + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/AnalysisResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/AnalysisResponse.java new file mode 100644 index 0000000..077fcdb --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/AnalysisResponse.java @@ -0,0 +1,36 @@ +package com.jobdri.jobdri_api.domain.analysis.dto.response; + +import com.jobdri.jobdri_api.domain.analysis.entity.Analysis; +import com.jobdri.jobdri_api.domain.mockapply.entity.MockApplyStatus; + +import java.util.List; + +public record AnalysisResponse( + Long mockApplyId, + Long analysisId, + MockApplyStatus status, + int score, + int jobFit, + int impact, + int completeness, + String feedback, + List questions +) { + public static AnalysisResponse of( + Analysis analysis, + MockApplyStatus status, + List questions + ) { + return new AnalysisResponse( + analysis.getMockApply().getId(), + analysis.getId(), + status, + analysis.getScore(), + analysis.getJobFit(), + analysis.getImpact(), + analysis.getCompleteness(), + analysis.getFeedback(), + questions + ); + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/QuestionAnalysisResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/QuestionAnalysisResponse.java new file mode 100644 index 0000000..c4c6f97 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/QuestionAnalysisResponse.java @@ -0,0 +1,23 @@ +package com.jobdri.jobdri_api.domain.analysis.dto.response; + +import com.jobdri.jobdri_api.domain.analysis.entity.QuestionAnalysis; + +public record QuestionAnalysisResponse( + Long questionAnalysisId, + String sentence, + String reason, + String improvement, + int start, + int end +) { + public static QuestionAnalysisResponse from(QuestionAnalysis questionAnalysis) { + return new QuestionAnalysisResponse( + questionAnalysis.getId(), + questionAnalysis.getSentence(), + questionAnalysis.getReason(), + questionAnalysis.getImprovement(), + questionAnalysis.getStart(), + questionAnalysis.getEnd() + ); + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/entity/Analysis.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/entity/Analysis.java index 4e0d0e6..ba6852a 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/entity/Analysis.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/entity/Analysis.java @@ -50,7 +50,7 @@ public static Analysis create( int completeness, String feedback ) { - Analysis analysis = Analysis.builder() + return Analysis.builder() .mockApply(mockApply) .score(score) .jobFit(jobFit) @@ -58,7 +58,5 @@ public static Analysis create( .completeness(completeness) .feedback(feedback) .build(); - mockApply.assignAnalysis(analysis); - return analysis; } } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/repository/QuestionAnalysisRepository.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/repository/QuestionAnalysisRepository.java index 695c72f..b32013f 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/repository/QuestionAnalysisRepository.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/repository/QuestionAnalysisRepository.java @@ -8,4 +8,6 @@ public interface QuestionAnalysisRepository extends JpaRepository { List findAllByQuestionId(Long questionId); List findAllByAnalysisId(Long analysisId); + List findAllByAnalysisIdOrderByQuestionIdAscIdAsc(Long analysisId); + void deleteAllByAnalysisId(Long analysisId); } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAiClient.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAiClient.java new file mode 100644 index 0000000..54afd62 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAiClient.java @@ -0,0 +1,163 @@ +package com.jobdri.jobdri_api.domain.analysis.service; + +import com.jobdri.jobdri_api.domain.analysis.dto.llm.AnalysisLlmResponse; +import com.jobdri.jobdri_api.domain.analysis.entity.Question; +import com.jobdri.jobdri_api.domain.jobposting.entity.JobPosting; +import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; +import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; +import com.openai.client.OpenAIClient; +import com.openai.models.responses.ResponseCreateParams; +import com.openai.models.responses.StructuredResponse; +import com.openai.models.responses.StructuredResponseOutputMessage; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Component +@Slf4j +@RequiredArgsConstructor +public class AnalysisAiClient { + + private final OpenAIClient openAIClient; + + @Value("${openai.model.cover-letter-analysis:gpt-4o-mini}") + private String analysisModel; + + public AnalysisLlmResponse analyze(JobPosting jobPosting, List questions) { + var params = ResponseCreateParams.builder() + .model(analysisModel) + .input(buildPrompt(jobPosting, questions)) + .temperature(0.2) + .text(AnalysisLlmResponse.class) + .build(); + + try { + StructuredResponse response = openAIClient.responses().create(params); + return extractStructuredContent(response); + } catch (GeneralException e) { + throw e; + } catch (Exception e) { + log.error("자소서 분석 OpenAI API 호출 오류: {}", e.getMessage(), e); + throw new GeneralException( + GeneralErrorCode.SERVICE_UNAVAILABLE, + "자소서 분석 AI 호출에 실패했습니다." + ); + } + } + + private String buildPrompt(JobPosting jobPosting, List questions) { + String questionText = questions.stream() + .map(question -> """ + - questionId: %d + question: %s + answer: %s + """.formatted( + question.getId(), + defaultString(question.getContent()), + defaultString(question.getAnswer()) + )) + .reduce("", (left, right) -> left + "\n" + right); + + return """ + [시스템 지시] + 너는 한국 채용 담당자이자 자기소개서 평가 전문가다. + 반드시 JSON만 출력한다. + 자소서 원문에 없는 sentence를 만들지 않는다. + sentence는 반드시 해당 question의 answer에 포함된 정확한 부분 문자열이어야 한다. + + [출력 형식] + { + "score": 64, + "jobFit": 70, + "impact": 55, + "completeness": 67, + "feedback": "한 줄 피드백", + "questionAnalyses": [ + { + "questionId": 1, + "sentence": "자소서 답변 안에 실제 존재하는 정확한 부분 문자열", + "reason": "문제 이유", + "improvement": "개선 예시 문장" + } + ] + } + + [평가 절차] + 1. JD의 주요 업무, 자격 요건, 우대 사항을 읽고 핵심 역량을 정리한다. + 2. 각 문항 답변이 JD와 얼마나 연결되는지 평가한다. + 3. 주장, 경험, 성과가 구체적 근거로 입증되는지 평가한다. + 4. 질문에 맞게 답했는지, 문장 흐름과 완성도가 충분한지 평가한다. + 5. 보완이 필요한 원문 문장을 최대 2~3개만 추출한다. + + [점수 기준] + - 85~100: 매우 우수 + - 70~84: 양호 + - 55~69: 개선 필요 + - 40~54: 대폭 수정 필요 + - 40 미만: 직무/JD와 거의 무관 + + [세부 기준] + - jobFit: JD와 직무 역량 매칭 + - impact: 성과 구체성, 수치, 결과 + - completeness: 문장 완성도, 논리 흐름, 질문 적합성 + + [상태 라벨 참고] + - proven: 구체적 경험/수치로 충분히 입증됨 + - mentioned: 관련 내용을 언급은 했지만 구체 근거가 부족함 + - missing: 자소서에서 아예 다루지 않음 + - fabricated: 주장은 하지만 신뢰할 수 있는 근거가 부족함 + + [약점 유형 참고] + unsupported_claim, vague_evidence, exaggeration, missing_outcome + + [채용 공고] + 회사명: %s + 직무명: %s + 주요 업무: + %s + + 자격 요건: + %s + + 우대 사항: + %s + + [자소서 문항과 답변] + %s + + [중요 규칙] + - JSON 외 텍스트, 마크다운, 코드블럭을 출력하지 않는다. + - questionAnalyses의 questionId는 입력된 questionId 중 하나만 사용한다. + - sentence는 answer에 포함된 정확한 substring만 사용한다. + - start/end index는 출력하지 않는다. 서버가 Java에서 계산한다. + - 원문 매칭이 불확실하면 questionAnalyses에 포함하지 않는다. + """.formatted( + defaultString(jobPosting.getCompany().getName()), + defaultString(jobPosting.getDetailClassification().getDetailName()), + defaultString(jobPosting.getTask()), + defaultString(jobPosting.getRequirement()), + defaultString(jobPosting.getPreferred()), + questionText + ); + } + + private AnalysisLlmResponse extractStructuredContent(StructuredResponse response) { + return response.output().stream() + .filter(item -> item.message().isPresent()) + .flatMap(item -> item.asMessage().content().stream()) + .filter(content -> content.outputText().isPresent()) + .map(StructuredResponseOutputMessage.Content::asOutputText) + .findFirst() + .orElseThrow(() -> new GeneralException( + GeneralErrorCode.INTERNAL_SERVER_ERROR, + "AI 응답에서 자소서 분석 결과를 찾을 수 없습니다." + )); + } + + private String defaultString(String value) { + return value == null ? "" : value; + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisService.java new file mode 100644 index 0000000..96c8dbc --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisService.java @@ -0,0 +1,202 @@ +package com.jobdri.jobdri_api.domain.analysis.service; + +import com.jobdri.jobdri_api.domain.analysis.dto.llm.AnalysisLlmResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.response.AnalysisQuestionResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.response.AnalysisResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.response.QuestionAnalysisResponse; +import com.jobdri.jobdri_api.domain.analysis.entity.Analysis; +import com.jobdri.jobdri_api.domain.analysis.entity.Question; +import com.jobdri.jobdri_api.domain.analysis.entity.QuestionAnalysis; +import com.jobdri.jobdri_api.domain.analysis.repository.AnalysisRepository; +import com.jobdri.jobdri_api.domain.analysis.repository.QuestionAnalysisRepository; +import com.jobdri.jobdri_api.domain.analysis.repository.QuestionRepository; +import com.jobdri.jobdri_api.domain.mockapply.entity.MockApply; +import com.jobdri.jobdri_api.domain.mockapply.entity.MockApplyStatus; +import com.jobdri.jobdri_api.domain.mockapply.repository.MockApplyRepository; +import com.jobdri.jobdri_api.domain.user.entity.User; +import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; +import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AnalysisService { + + private final MockApplyRepository mockApplyRepository; + private final QuestionRepository questionRepository; + private final AnalysisRepository analysisRepository; + private final QuestionAnalysisRepository questionAnalysisRepository; + private final AnalysisAiClient analysisAiClient; + + @Transactional + public AnalysisResponse analyze(User user, Long mockApplyId) { + MockApply mockApply = getOwnedMockApply(user, mockApplyId); + List questions = questionRepository.findAllByMockApplyIdOrderByIdAsc(mockApply.getId()); + List answeredQuestions = questions.stream() + .filter(question -> StringUtils.hasText(question.getAnswer())) + .toList(); + + if (answeredQuestions.isEmpty()) { + throw new GeneralException( + GeneralErrorCode.INVALID_PARAMETER, + "분석할 자소서 답변이 1개 이상 필요합니다." + ); + } + + AnalysisLlmResponse llmResponse = analysisAiClient.analyze(mockApply.getJobPosting(), answeredQuestions); + replaceExistingAnalysis(mockApply); + + Analysis analysis = analysisRepository.save(Analysis.create( + mockApply, + clampScore(llmResponse.score()), + clampScore(llmResponse.jobFit()), + clampScore(llmResponse.impact()), + clampScore(llmResponse.completeness()), + normalizeFeedback(llmResponse.feedback()) + )); + + List questionAnalyses = buildQuestionAnalyses(analysis, answeredQuestions, llmResponse); + questionAnalysisRepository.saveAll(questionAnalyses); + mockApply.updateStatus(MockApplyStatus.COMPLETED); + + return getAnalysis(user, mockApplyId); + } + + public AnalysisResponse getAnalysis(User user, Long mockApplyId) { + MockApply mockApply = getOwnedMockApply(user, mockApplyId); + Analysis analysis = analysisRepository.findByMockApplyId(mockApply.getId()) + .orElseThrow(() -> new GeneralException( + GeneralErrorCode.ANALYSIS_NOT_FOUND, + "해당 모의 서류 지원의 분석 결과를 찾을 수 없습니다. mockApplyId=" + mockApplyId + )); + List questions = questionRepository.findAllByMockApplyIdOrderByIdAsc(mockApply.getId()); + List questionAnalyses = + questionAnalysisRepository.findAllByAnalysisIdOrderByQuestionIdAscIdAsc(analysis.getId()); + + return toResponse(mockApply, analysis, questions, questionAnalyses); + } + + private void replaceExistingAnalysis(MockApply mockApply) { + Optional existingAnalysis = analysisRepository.findByMockApplyId(mockApply.getId()); + if (existingAnalysis.isEmpty()) { + return; + } + + Analysis analysis = existingAnalysis.get(); + mockApply.clearAnalysis(); + questionAnalysisRepository.deleteAllByAnalysisId(analysis.getId()); + analysisRepository.delete(analysis); + analysisRepository.flush(); + } + + private List buildQuestionAnalyses( + Analysis analysis, + List questions, + AnalysisLlmResponse llmResponse + ) { + Map questionMap = questions.stream() + .collect(Collectors.toMap(Question::getId, Function.identity())); + List result = new ArrayList<>(); + + if (llmResponse.questionAnalyses() == null) { + return result; + } + + for (AnalysisLlmResponse.QuestionAnalysisItem item : llmResponse.questionAnalyses()) { + if (item == null || item.questionId() == null || !StringUtils.hasText(item.sentence())) { + continue; + } + + Question question = questionMap.get(item.questionId()); + if (question == null) { + continue; + } + + String answer = question.getAnswer(); + String sentence = item.sentence(); + int start = answer.indexOf(sentence); + if (start < 0) { + continue; + } + + result.add(QuestionAnalysis.create( + question, + analysis, + sentence, + defaultString(item.reason()), + defaultString(item.improvement()), + start, + start + sentence.length() + )); + } + + return result; + } + + private AnalysisResponse toResponse( + MockApply mockApply, + Analysis analysis, + List questions, + List questionAnalyses + ) { + Map> analysesByQuestionId = questionAnalyses.stream() + .collect(Collectors.groupingBy( + questionAnalysis -> questionAnalysis.getQuestion().getId(), + Collectors.mapping(QuestionAnalysisResponse::from, Collectors.toList()) + )); + + List questionResponses = questions.stream() + .sorted(Comparator.comparing(Question::getId)) + .map(question -> AnalysisQuestionResponse.of( + question, + analysesByQuestionId.getOrDefault(question.getId(), List.of()) + )) + .toList(); + + return AnalysisResponse.of(analysis, mockApply.getStatus(), questionResponses); + } + + private MockApply getOwnedMockApply(User user, Long mockApplyId) { + MockApply mockApply = mockApplyRepository.findById(mockApplyId) + .orElseThrow(() -> new GeneralException( + GeneralErrorCode.MOCK_APPLY_NOT_FOUND, + "해당 모의 서류 지원을 찾을 수 없습니다. mockApplyId=" + mockApplyId + )); + + if (!mockApply.getUser().getId().equals(user.getId())) { + throw new GeneralException(GeneralErrorCode.FORBIDDEN, "해당 모의 서류 지원에 접근할 수 없습니다."); + } + + return mockApply; + } + + private int clampScore(Integer score) { + if (score == null) { + return 0; + } + return Math.max(0, Math.min(100, score)); + } + + private String normalizeFeedback(String feedback) { + if (StringUtils.hasText(feedback)) { + return feedback; + } + return "자소서 분석 결과를 확인해주세요."; + } + + private String defaultString(String value) { + return value == null ? "" : value; + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/mockapply/entity/MockApply.java b/src/main/java/com/jobdri/jobdri_api/domain/mockapply/entity/MockApply.java index 9a8eab9..fe1e4b5 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/mockapply/entity/MockApply.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/mockapply/entity/MockApply.java @@ -72,4 +72,8 @@ public Question addQuestion(String content, int limit, String answer) { public void assignAnalysis(Analysis analysis) { this.analysis = analysis; } + + public void clearAnalysis() { + this.analysis = null; + } } diff --git a/src/main/java/com/jobdri/jobdri_api/global/apiPayload/code/GeneralErrorCode.java b/src/main/java/com/jobdri/jobdri_api/global/apiPayload/code/GeneralErrorCode.java index 76f87d1..9a5cda2 100644 --- a/src/main/java/com/jobdri/jobdri_api/global/apiPayload/code/GeneralErrorCode.java +++ b/src/main/java/com/jobdri/jobdri_api/global/apiPayload/code/GeneralErrorCode.java @@ -42,6 +42,7 @@ public enum GeneralErrorCode implements BaseErrorCode { // 모의 서류 지원 에러 MOCK_APPLY_NOT_FOUND(HttpStatus.NOT_FOUND, "MOCK_APPLY_4041", "모의 서류 지원을 찾을 수 없습니다."), QUESTION_NOT_FOUND(HttpStatus.NOT_FOUND, "QUESTION_4041", "문항을 찾을 수 없습니다."), + ANALYSIS_NOT_FOUND(HttpStatus.NOT_FOUND, "ANALYSIS_4041", "자소서 분석 결과를 찾을 수 없습니다."), // 유저 에러 USER_NOT_FOUND(HttpStatus.NOT_FOUND, "USER_4041", "유저를 찾을 수 없습니다."); diff --git a/src/main/resources/application-dev.yaml b/src/main/resources/application-dev.yaml index 60136ff..170de28 100644 --- a/src/main/resources/application-dev.yaml +++ b/src/main/resources/application-dev.yaml @@ -73,7 +73,7 @@ server: jwt: secret: - key: ${JWT_SECRET_KEY} + key: ${JWT_SECRET_KEY:am9iZHJpLWxvY2FsLXNlY3JldC1rZXktZm9yLWRldmVsb3BtZW50LTIwMjYtam9iZHJp} expiration: access-token: ${JWT_ACCESS_TOKEN_EXPIRATION:3600000} refresh-token: ${JWT_REFRESH_TOKEN_EXPIRATION:1209600000} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index f9333d9..d2c0e0a 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -1,3 +1,3 @@ spring: profiles: - active: prod # 본인이 테스트할 환경에 따라서 바꾸기 \ No newline at end of file + active: ${SPRING_PROFILES_ACTIVE:dev} diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisServiceTest.java new file mode 100644 index 0000000..64ea3e6 --- /dev/null +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisServiceTest.java @@ -0,0 +1,282 @@ +package com.jobdri.jobdri_api.domain.analysis.service; + +import com.jobdri.jobdri_api.domain.analysis.dto.llm.AnalysisLlmResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.response.AnalysisResponse; +import com.jobdri.jobdri_api.domain.analysis.entity.Analysis; +import com.jobdri.jobdri_api.domain.analysis.entity.Question; +import com.jobdri.jobdri_api.domain.analysis.repository.AnalysisRepository; +import com.jobdri.jobdri_api.domain.analysis.repository.QuestionAnalysisRepository; +import com.jobdri.jobdri_api.domain.analysis.repository.QuestionRepository; +import com.jobdri.jobdri_api.domain.classification.entity.Classification; +import com.jobdri.jobdri_api.domain.classification.entity.DetailClassification; +import com.jobdri.jobdri_api.domain.classification.entity.MiddleClassification; +import com.jobdri.jobdri_api.domain.classification.repository.ClassificationRepository; +import com.jobdri.jobdri_api.domain.classification.repository.DetailClassificationRepository; +import com.jobdri.jobdri_api.domain.company.entity.Company; +import com.jobdri.jobdri_api.domain.company.entity.CompanySize; +import com.jobdri.jobdri_api.domain.company.repository.CompanyRepository; +import com.jobdri.jobdri_api.domain.jobposting.entity.JobPosting; +import com.jobdri.jobdri_api.domain.jobposting.repository.JobPostingRepository; +import com.jobdri.jobdri_api.domain.mockapply.entity.ApplyType; +import com.jobdri.jobdri_api.domain.mockapply.entity.MockApply; +import com.jobdri.jobdri_api.domain.mockapply.entity.MockApplyStatus; +import com.jobdri.jobdri_api.domain.mockapply.repository.MockApplyRepository; +import com.jobdri.jobdri_api.domain.user.entity.User; +import com.jobdri.jobdri_api.domain.user.repository.UserRepository; +import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; +import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +@SpringBootTest +@ActiveProfiles("test") +@Transactional +class AnalysisServiceTest { + + @Autowired + private AnalysisService analysisService; + + @Autowired + private AnalysisRepository analysisRepository; + + @Autowired + private QuestionAnalysisRepository questionAnalysisRepository; + + @Autowired + private QuestionRepository questionRepository; + + @Autowired + private MockApplyRepository mockApplyRepository; + + @Autowired + private JobPostingRepository jobPostingRepository; + + @Autowired + private CompanyRepository companyRepository; + + @Autowired + private ClassificationRepository classificationRepository; + + @Autowired + private DetailClassificationRepository detailClassificationRepository; + + @Autowired + private UserRepository userRepository; + + @MockBean + private AnalysisAiClient analysisAiClient; + + @Test + @DisplayName("자소서 분석을 실행하고 결과와 문항 분석을 저장한다") + void analyzeSavesAnalysis() { + User user = saveUser("analysis-save@example.com"); + MockApply mockApply = saveMockApply(user); + Question question = saveQuestion(mockApply, "지원 직무 경험을 작성해주세요.", "Spring Boot API를 개발했습니다."); + when(analysisAiClient.analyze(any(), any())).thenReturn(new AnalysisLlmResponse( + 120, + 82, + 71, + 80, + "직무 경험은 좋지만 성과 근거 보완이 필요합니다.", + List.of( + new AnalysisLlmResponse.QuestionAnalysisItem( + question.getId(), + "Spring Boot API를 개발했습니다.", + "성과 지표가 없어 구체성이 약합니다.", + "Spring Boot API를 개발해 응답 시간을 20% 개선했습니다." + ) + ) + )); + + AnalysisResponse response = analysisService.analyze(user, mockApply.getId()); + + assertThat(response.status()).isEqualTo(MockApplyStatus.COMPLETED); + assertThat(response.score()).isEqualTo(100); + assertThat(response.jobFit()).isEqualTo(82); + assertThat(response.impact()).isEqualTo(71); + assertThat(response.completeness()).isEqualTo(80); + assertThat(response.questions()).hasSize(1); + assertThat(response.questions().get(0).analyses()).hasSize(1); + assertThat(response.questions().get(0).analyses().get(0).start()).isEqualTo(0); + assertThat(response.questions().get(0).analyses().get(0).end()) + .isEqualTo("Spring Boot API를 개발했습니다.".length()); + assertThat(mockApply.getStatus()).isEqualTo(MockApplyStatus.COMPLETED); + assertThat(analysisRepository.findByMockApplyId(mockApply.getId())).isPresent(); + } + + @Test + @DisplayName("답변이 없는 경우 분석을 실행할 수 없다") + void analyzeThrowsWhenNoAnswers() { + User user = saveUser("analysis-empty-answer@example.com"); + MockApply mockApply = saveMockApply(user); + saveQuestion(mockApply, "지원 동기", ""); + + assertThatThrownBy(() -> analysisService.analyze(user, mockApply.getId())) + .isInstanceOf(GeneralException.class) + .extracting("code") + .isEqualTo(GeneralErrorCode.INVALID_PARAMETER); + } + + @Test + @DisplayName("다른 사용자의 지원서는 분석할 수 없다") + void analyzeThrowsWhenUserDoesNotOwnMockApply() { + User owner = saveUser("analysis-owner@example.com"); + User other = saveUser("analysis-other@example.com"); + MockApply mockApply = saveMockApply(owner); + saveQuestion(mockApply, "지원 동기", "백엔드 개발 경험이 있습니다."); + + assertThatThrownBy(() -> analysisService.analyze(other, mockApply.getId())) + .isInstanceOf(GeneralException.class) + .extracting("code") + .isEqualTo(GeneralErrorCode.FORBIDDEN); + } + + @Test + @DisplayName("LLM sentence가 원문에 없으면 문항 분석 저장에서 제외한다") + void analyzeSkipsSentenceNotInAnswer() { + User user = saveUser("analysis-skip-sentence@example.com"); + MockApply mockApply = saveMockApply(user); + Question question = saveQuestion(mockApply, "문제 해결 경험", "장애 로그를 분석해 원인을 찾았습니다."); + when(analysisAiClient.analyze(any(), any())).thenReturn(new AnalysisLlmResponse( + 64, + 70, + 55, + 67, + "원문 매칭 실패 문장은 제외됩니다.", + List.of( + new AnalysisLlmResponse.QuestionAnalysisItem( + question.getId(), + "답변에 없는 문장입니다.", + "원문에 없습니다.", + "원문 기반 문장으로 개선해야 합니다." + ) + ) + )); + + AnalysisResponse response = analysisService.analyze(user, mockApply.getId()); + + assertThat(response.questions().get(0).analyses()).isEmpty(); + Analysis analysis = analysisRepository.findByMockApplyId(mockApply.getId()).orElseThrow(); + assertThat(questionAnalysisRepository.findAllByAnalysisId(analysis.getId())).isEmpty(); + } + + @Test + @DisplayName("재분석 시 기존 분석과 문항 분석을 새 결과로 교체한다") + void analyzeReplacesExistingAnalysis() { + User user = saveUser("analysis-replace@example.com"); + MockApply mockApply = saveMockApply(user); + Question question = saveQuestion(mockApply, "성과 경험", "가입 완료율을 개선했습니다. API 응답 속도를 개선했습니다."); + when(analysisAiClient.analyze(any(), any())) + .thenReturn(new AnalysisLlmResponse( + 60, + 61, + 62, + 63, + "첫 번째 분석", + List.of(new AnalysisLlmResponse.QuestionAnalysisItem( + question.getId(), + "가입 완료율을 개선했습니다.", + "수치가 부족합니다.", + "가입 완료율을 12% 개선했습니다." + )) + )) + .thenReturn(new AnalysisLlmResponse( + 88, + 89, + 90, + 91, + "두 번째 분석", + List.of(new AnalysisLlmResponse.QuestionAnalysisItem( + question.getId(), + "API 응답 속도를 개선했습니다.", + "성과 기준이 더 필요합니다.", + "API 응답 속도를 300ms 단축했습니다." + )) + )); + + AnalysisResponse first = analysisService.analyze(user, mockApply.getId()); + AnalysisResponse second = analysisService.analyze(user, mockApply.getId()); + + assertThat(second.analysisId()).isNotEqualTo(first.analysisId()); + assertThat(second.score()).isEqualTo(88); + assertThat(second.feedback()).isEqualTo("두 번째 분석"); + assertThat(analysisRepository.findByMockApplyId(mockApply.getId()).orElseThrow().getScore()).isEqualTo(88); + assertThat(questionAnalysisRepository.findAllByAnalysisId(second.analysisId())).hasSize(1); + assertThat(questionAnalysisRepository.findAllByAnalysisId(first.analysisId())).isEmpty(); + } + + @Test + @DisplayName("저장된 분석 결과를 조회한다") + void getAnalysis() { + User user = saveUser("analysis-get@example.com"); + MockApply mockApply = saveMockApply(user); + Question question = saveQuestion(mockApply, "지원 동기", "서비스 개선 경험이 있습니다."); + when(analysisAiClient.analyze(any(), any())).thenReturn(new AnalysisLlmResponse( + 75, + 76, + 77, + 78, + "저장된 분석 결과입니다.", + List.of(new AnalysisLlmResponse.QuestionAnalysisItem( + question.getId(), + "서비스 개선 경험이 있습니다.", + "구체성이 조금 부족합니다.", + "서비스 개선 경험으로 전환율을 10% 높였습니다." + )) + )); + AnalysisResponse saved = analysisService.analyze(user, mockApply.getId()); + + AnalysisResponse response = analysisService.getAnalysis(user, mockApply.getId()); + + assertThat(response.analysisId()).isEqualTo(saved.analysisId()); + assertThat(response.score()).isEqualTo(75); + assertThat(response.questions()).hasSize(1); + assertThat(response.questions().get(0).analyses()).hasSize(1); + } + + private User saveUser(String email) { + return userRepository.save(User.signup("테스트 사용자", email, "encoded-password")); + } + + private MockApply saveMockApply(User user) { + JobPosting jobPosting = saveJobPosting(user); + return mockApplyRepository.save(MockApply.create(user, jobPosting, ApplyType.ACTUAL)); + } + + private Question saveQuestion(MockApply mockApply, String content, String answer) { + return questionRepository.save(Question.create(mockApply, content, 1000, answer)); + } + + private JobPosting saveJobPosting(User user) { + Company company = companyRepository.save(Company.create("분석 테스트 기업", CompanySize.MEDIUM)); + DetailClassification detailClassification = saveDetailClassification(); + return jobPostingRepository.save(JobPosting.create( + user, + company, + detailClassification, + "주요 업무", + "자격 요건", + "우대 사항" + )); + } + + private DetailClassification saveDetailClassification() { + Classification classification = Classification.create("분석 테스트 대분류 " + System.nanoTime()); + MiddleClassification middleClassification = classification.addMiddleClassification("분석 테스트 중분류"); + DetailClassification detailClassification = middleClassification.addDetailClassification("분석 테스트 소분류"); + classificationRepository.save(classification); + return detailClassificationRepository.findById(detailClassification.getId()).orElseThrow(); + } +} From a29704f7abf3acd23ee9ede5e2b59c1cfa176ed0 Mon Sep 17 00:00:00 2001 From: wooh Date: Fri, 22 May 2026 21:21:22 +0900 Subject: [PATCH 7/9] =?UTF-8?q?[Feat]=20=EC=9E=90=EC=86=8C=EC=84=9C=20?= =?UTF-8?q?=EB=B6=84=EC=84=9D=20=EB=AC=B8=EC=9E=A5=20=EC=83=81=ED=83=9C?= =?UTF-8?q?=EA=B0=92=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 첨삭 문장 상태 enum 추가 - 분석 LLM 응답에 status 필드 반영 - 문항 분석 엔티티와 응답 DTO에 status 추가 - status 값 소문자 응답 및 기본값 보정 처리 - 분석 서비스 테스트에 status 검증 추가 --- .../analysis/dto/llm/AnalysisLlmResponse.java | 1 + .../dto/response/QuestionAnalysisResponse.java | 10 ++++++++++ .../domain/analysis/entity/QuestionAnalysis.java | 7 +++++++ .../analysis/entity/QuestionAnalysisStatus.java | 8 ++++++++ .../domain/analysis/service/AnalysisAiClient.java | 2 ++ .../domain/analysis/service/AnalysisService.java | 14 ++++++++++++++ .../analysis/service/AnalysisServiceTest.java | 7 +++++++ 7 files changed, 49 insertions(+) create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/analysis/entity/QuestionAnalysisStatus.java diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/llm/AnalysisLlmResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/llm/AnalysisLlmResponse.java index 0cb253e..d8a77d8 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/llm/AnalysisLlmResponse.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/llm/AnalysisLlmResponse.java @@ -13,6 +13,7 @@ public record AnalysisLlmResponse( public record QuestionAnalysisItem( Long questionId, String sentence, + String status, String reason, String improvement ) { diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/QuestionAnalysisResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/QuestionAnalysisResponse.java index c4c6f97..92a9e92 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/QuestionAnalysisResponse.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/QuestionAnalysisResponse.java @@ -1,10 +1,12 @@ package com.jobdri.jobdri_api.domain.analysis.dto.response; import com.jobdri.jobdri_api.domain.analysis.entity.QuestionAnalysis; +import com.jobdri.jobdri_api.domain.analysis.entity.QuestionAnalysisStatus; public record QuestionAnalysisResponse( Long questionAnalysisId, String sentence, + String status, String reason, String improvement, int start, @@ -14,10 +16,18 @@ public static QuestionAnalysisResponse from(QuestionAnalysis questionAnalysis) { return new QuestionAnalysisResponse( questionAnalysis.getId(), questionAnalysis.getSentence(), + statusValue(questionAnalysis.getStatus()), questionAnalysis.getReason(), questionAnalysis.getImprovement(), questionAnalysis.getStart(), questionAnalysis.getEnd() ); } + + private static String statusValue(QuestionAnalysisStatus status) { + if (status == null) { + return QuestionAnalysisStatus.MENTIONED.name().toLowerCase(); + } + return status.name().toLowerCase(); + } } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/entity/QuestionAnalysis.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/entity/QuestionAnalysis.java index 5641065..ab393b8 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/entity/QuestionAnalysis.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/entity/QuestionAnalysis.java @@ -32,6 +32,11 @@ public class QuestionAnalysis { @Column(nullable = false, columnDefinition = "TEXT") private String improvement; + @Enumerated(EnumType.STRING) + @Column(nullable = false, columnDefinition = "varchar(255) default 'MENTIONED'") + @Builder.Default + private QuestionAnalysisStatus status = QuestionAnalysisStatus.MENTIONED; + @Column(name = "start_index", nullable = false) private int start; @@ -44,6 +49,7 @@ public static QuestionAnalysis create( String sentence, String reason, String improvement, + QuestionAnalysisStatus status, int start, int end ) { @@ -53,6 +59,7 @@ public static QuestionAnalysis create( .sentence(sentence) .reason(reason) .improvement(improvement) + .status(status) .start(start) .end(end) .build(); diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/entity/QuestionAnalysisStatus.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/entity/QuestionAnalysisStatus.java new file mode 100644 index 0000000..2c99de5 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/entity/QuestionAnalysisStatus.java @@ -0,0 +1,8 @@ +package com.jobdri.jobdri_api.domain.analysis.entity; + +public enum QuestionAnalysisStatus { + PROVEN, + MENTIONED, + MISSING, + FABRICATED +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAiClient.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAiClient.java index 54afd62..3318d87 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAiClient.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAiClient.java @@ -79,6 +79,7 @@ private String buildPrompt(JobPosting jobPosting, List questions) { { "questionId": 1, "sentence": "자소서 답변 안에 실제 존재하는 정확한 부분 문자열", + "status": "mentioned", "reason": "문제 이유", "improvement": "개선 예시 문장" } @@ -131,6 +132,7 @@ private String buildPrompt(JobPosting jobPosting, List questions) { [중요 규칙] - JSON 외 텍스트, 마크다운, 코드블럭을 출력하지 않는다. - questionAnalyses의 questionId는 입력된 questionId 중 하나만 사용한다. + - questionAnalyses의 status는 proven, mentioned, missing, fabricated 중 하나만 사용한다. - sentence는 answer에 포함된 정확한 substring만 사용한다. - start/end index는 출력하지 않는다. 서버가 Java에서 계산한다. - 원문 매칭이 불확실하면 questionAnalyses에 포함하지 않는다. diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisService.java index 96c8dbc..a787cda 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisService.java @@ -7,6 +7,7 @@ import com.jobdri.jobdri_api.domain.analysis.entity.Analysis; import com.jobdri.jobdri_api.domain.analysis.entity.Question; import com.jobdri.jobdri_api.domain.analysis.entity.QuestionAnalysis; +import com.jobdri.jobdri_api.domain.analysis.entity.QuestionAnalysisStatus; import com.jobdri.jobdri_api.domain.analysis.repository.AnalysisRepository; import com.jobdri.jobdri_api.domain.analysis.repository.QuestionAnalysisRepository; import com.jobdri.jobdri_api.domain.analysis.repository.QuestionRepository; @@ -137,6 +138,7 @@ private List buildQuestionAnalyses( sentence, defaultString(item.reason()), defaultString(item.improvement()), + normalizeStatus(item.status()), start, start + sentence.length() )); @@ -199,4 +201,16 @@ private String normalizeFeedback(String feedback) { private String defaultString(String value) { return value == null ? "" : value; } + + private QuestionAnalysisStatus normalizeStatus(String status) { + if (!StringUtils.hasText(status)) { + return QuestionAnalysisStatus.MENTIONED; + } + + try { + return QuestionAnalysisStatus.valueOf(status.trim().toUpperCase()); + } catch (IllegalArgumentException e) { + return QuestionAnalysisStatus.MENTIONED; + } + } } diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisServiceTest.java index 64ea3e6..fb9852b 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisServiceTest.java @@ -94,6 +94,7 @@ void analyzeSavesAnalysis() { new AnalysisLlmResponse.QuestionAnalysisItem( question.getId(), "Spring Boot API를 개발했습니다.", + "mentioned", "성과 지표가 없어 구체성이 약합니다.", "Spring Boot API를 개발해 응답 시간을 20% 개선했습니다." ) @@ -109,6 +110,7 @@ void analyzeSavesAnalysis() { assertThat(response.completeness()).isEqualTo(80); assertThat(response.questions()).hasSize(1); assertThat(response.questions().get(0).analyses()).hasSize(1); + assertThat(response.questions().get(0).analyses().get(0).status()).isEqualTo("mentioned"); assertThat(response.questions().get(0).analyses().get(0).start()).isEqualTo(0); assertThat(response.questions().get(0).analyses().get(0).end()) .isEqualTo("Spring Boot API를 개발했습니다.".length()); @@ -159,6 +161,7 @@ void analyzeSkipsSentenceNotInAnswer() { new AnalysisLlmResponse.QuestionAnalysisItem( question.getId(), "답변에 없는 문장입니다.", + "fabricated", "원문에 없습니다.", "원문 기반 문장으로 개선해야 합니다." ) @@ -188,6 +191,7 @@ void analyzeReplacesExistingAnalysis() { List.of(new AnalysisLlmResponse.QuestionAnalysisItem( question.getId(), "가입 완료율을 개선했습니다.", + "mentioned", "수치가 부족합니다.", "가입 완료율을 12% 개선했습니다." )) @@ -201,6 +205,7 @@ void analyzeReplacesExistingAnalysis() { List.of(new AnalysisLlmResponse.QuestionAnalysisItem( question.getId(), "API 응답 속도를 개선했습니다.", + "proven", "성과 기준이 더 필요합니다.", "API 응답 속도를 300ms 단축했습니다." )) @@ -212,6 +217,7 @@ void analyzeReplacesExistingAnalysis() { assertThat(second.analysisId()).isNotEqualTo(first.analysisId()); assertThat(second.score()).isEqualTo(88); assertThat(second.feedback()).isEqualTo("두 번째 분석"); + assertThat(second.questions().get(0).analyses().get(0).status()).isEqualTo("proven"); assertThat(analysisRepository.findByMockApplyId(mockApply.getId()).orElseThrow().getScore()).isEqualTo(88); assertThat(questionAnalysisRepository.findAllByAnalysisId(second.analysisId())).hasSize(1); assertThat(questionAnalysisRepository.findAllByAnalysisId(first.analysisId())).isEmpty(); @@ -232,6 +238,7 @@ void getAnalysis() { List.of(new AnalysisLlmResponse.QuestionAnalysisItem( question.getId(), "서비스 개선 경험이 있습니다.", + "mentioned", "구체성이 조금 부족합니다.", "서비스 개선 경험으로 전환율을 10% 높였습니다." )) From 888cd0278f9cefdb3844ab603954b440c6d09a80 Mon Sep 17 00:00:00 2001 From: wooh Date: Fri, 22 May 2026 21:28:08 +0900 Subject: [PATCH 8/9] =?UTF-8?q?[Feat]=20=EB=AC=B8=ED=95=AD=20=ED=9B=84?= =?UTF-8?q?=EB=B3=B4=20=EC=9D=91=EB=8B=B5=EC=97=90=20ID=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 - 문항 후보 조회 응답에 questionId 필드 추가 - 기본 문항 후보에 고정 ID 부여 - 문항 후보 조회 테스트에 ID 검증 추가 --- .../dto/response/QuestionCandidateResponse.java | 1 + .../domain/analysis/service/QuestionService.java | 13 +++++++------ .../analysis/service/QuestionServiceTest.java | 3 +++ 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/QuestionCandidateResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/QuestionCandidateResponse.java index 644b113..3c024e2 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/QuestionCandidateResponse.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/QuestionCandidateResponse.java @@ -1,6 +1,7 @@ package com.jobdri.jobdri_api.domain.analysis.dto.response; public record QuestionCandidateResponse( + Long questionId, String content, int charLimit, boolean selected diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/QuestionService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/QuestionService.java index fd6ef28..e0ebba9 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/QuestionService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/QuestionService.java @@ -35,11 +35,11 @@ public class QuestionService { private static final int DEFAULT_CHAR_LIMIT = 1000; private static final List DEFAULT_CANDIDATES = List.of( - new QuestionCandidate("지원 동기와 입사 후 목표를 작성해주세요.", 700), - new QuestionCandidate("지원 직무와 관련된 경험 또는 역량을 구체적으로 작성해주세요.", 1000), - new QuestionCandidate("문제를 해결했던 경험과 그 과정에서의 역할을 작성해주세요.", 1000), - new QuestionCandidate("협업 과정에서 갈등을 해결했던 경험을 작성해주세요.", 800), - new QuestionCandidate("가장 성취감을 느꼈던 프로젝트와 성과를 작성해주세요.", 1000) + new QuestionCandidate(1L, "지원 동기와 입사 후 목표를 작성해주세요.", 700), + new QuestionCandidate(2L, "지원 직무와 관련된 경험 또는 역량을 구체적으로 작성해주세요.", 1000), + new QuestionCandidate(3L, "문제를 해결했던 경험과 그 과정에서의 역할을 작성해주세요.", 1000), + new QuestionCandidate(4L, "협업 과정에서 갈등을 해결했던 경험을 작성해주세요.", 800), + new QuestionCandidate(5L, "가장 성취감을 느꼈던 프로젝트와 성과를 작성해주세요.", 1000) ); private final MockApplyRepository mockApplyRepository; @@ -53,6 +53,7 @@ public List getQuestionCandidates(User user, Long moc return DEFAULT_CANDIDATES.stream() .map(candidate -> new QuestionCandidateResponse( + candidate.id(), candidate.content(), candidate.charLimit(), selectedContents.contains(candidate.content()) @@ -165,6 +166,6 @@ private String normalizeAnswer(String answer) { return ""; } - private record QuestionCandidate(String content, int charLimit) { + private record QuestionCandidate(Long id, String content, int charLimit) { } } diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/QuestionServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/QuestionServiceTest.java index 23d758b..3275f83 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/QuestionServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/QuestionServiceTest.java @@ -122,6 +122,9 @@ void getQuestionCandidatesMarksSelectedQuestion() { List candidates = questionService.getQuestionCandidates(user, mockApply.getId()); assertThat(candidates).hasSize(5); + assertThat(candidates) + .extracting(QuestionCandidateResponse::questionId) + .containsExactly(1L, 2L, 3L, 4L, 5L); assertThat(candidates.get(0).selected()).isTrue(); assertThat(candidates.get(1).selected()).isFalse(); } From 7f21c9cfa368d73cd604201402289a870bbb2ae5 Mon Sep 17 00:00:00 2001 From: shinae1023 Date: Fri, 22 May 2026 22:31:56 +0900 Subject: [PATCH 9/9] [Fix] harden S3 upload configuration and temp object handling (#63) --- build.gradle | 8 +++- ops/s3/job-posting-image-lifecycle.json | 2 +- .../JobPostingImageStorageService.java | 42 +++++++++++++++---- .../jobdri_api/global/config/s3/S3Config.java | 24 ++--------- .../global/config/s3/S3ObjectUrlService.java | 17 +++++++- .../global/config/s3/S3Uploader.java | 8 ++-- 6 files changed, 67 insertions(+), 34 deletions(-) diff --git a/build.gradle b/build.gradle index c3ee721..45f7bea 100644 --- a/build.gradle +++ b/build.gradle @@ -17,6 +17,12 @@ repositories { mavenCentral() } +dependencyManagement { + imports { + mavenBom 'io.awspring.cloud:spring-cloud-aws-dependencies:3.4.2' + } +} + dependencies { //redis implementation 'org.springframework.boot:spring-boot-starter-data-jpa' @@ -50,7 +56,7 @@ dependencies { implementation 'com.openai:openai-java:4.35.0' //S3 - implementation 'io.awspring.cloud:spring-cloud-aws-starter-s3:3.1.1' + implementation 'io.awspring.cloud:spring-cloud-aws-starter-s3' compileOnly 'org.projectlombok:lombok' diff --git a/ops/s3/job-posting-image-lifecycle.json b/ops/s3/job-posting-image-lifecycle.json index 5d42658..606b5ba 100644 --- a/ops/s3/job-posting-image-lifecycle.json +++ b/ops/s3/job-posting-image-lifecycle.json @@ -4,7 +4,7 @@ "ID": "DeleteTemporaryJobPostingImages", "Status": "Enabled", "Filter": { - "Prefix": "job-postings/" + "Prefix": "job-postings/tmp/" }, "Expiration": { "Days": 1 diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingImageStorageService.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingImageStorageService.java index d28e02f..9cb1941 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingImageStorageService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingImageStorageService.java @@ -10,6 +10,7 @@ import software.amazon.awssdk.services.s3.S3Client; import software.amazon.awssdk.services.s3.model.HeadObjectRequest; import software.amazon.awssdk.services.s3.model.HeadObjectResponse; +import software.amazon.awssdk.services.s3.model.S3Exception; import java.util.Locale; import java.util.Map; @@ -18,7 +19,7 @@ @Service public class JobPostingImageStorageService { - private static final String BASE_DIR = "job-postings"; + private static final String BASE_DIR = "job-postings/tmp"; private static final Map CONTENT_TYPE_TO_EXTENSION = Map.of( "image/png", "png", "image/jpeg", "jpg", @@ -73,6 +74,9 @@ public JobPostingImageUploadPresignResponse createUploadPresignUrl( } public String createReadableImageUrl(Long userId, String objectKey) { + if (objectKey == null || objectKey.isBlank()) { + return null; + } validateOwnership(userId, objectKey); validateUploadedObject(objectKey); return s3ObjectUrlService.createPresignedGetUrl(objectKey); @@ -105,12 +109,36 @@ private String normalizeContentType(String contentType) { } private void validateUploadedObject(String objectKey) { - HeadObjectResponse headObject = s3Client.headObject( - HeadObjectRequest.builder() - .bucket(s3ObjectUrlService.getBucket()) - .key(objectKey) - .build() - ); + if (objectKey == null || objectKey.isBlank()) { + return; + } + + HeadObjectResponse headObject; + try { + headObject = s3Client.headObject( + HeadObjectRequest.builder() + .bucket(s3ObjectUrlService.getBucket()) + .key(objectKey) + .build() + ); + } catch (S3Exception e) { + if (e.statusCode() == 404) { + throw new GeneralException( + GeneralErrorCode.INVALID_PARAMETER, + "업로드된 이미지를 찾을 수 없습니다. objectKey=" + objectKey + ); + } + if (e.statusCode() == 403) { + throw new GeneralException( + GeneralErrorCode.FORBIDDEN, + "업로드된 이미지에 접근할 수 없습니다. objectKey=" + objectKey + ); + } + throw new GeneralException( + GeneralErrorCode.INTERNAL_SERVER_ERROR, + "업로드된 이미지 검증 중 오류가 발생했습니다. objectKey=" + objectKey + ); + } String normalizedContentType = normalizeContentType(headObject.contentType()); if (!CONTENT_TYPE_TO_EXTENSION.containsKey(normalizedContentType)) { diff --git a/src/main/java/com/jobdri/jobdri_api/global/config/s3/S3Config.java b/src/main/java/com/jobdri/jobdri_api/global/config/s3/S3Config.java index 2206f70..a762988 100644 --- a/src/main/java/com/jobdri/jobdri_api/global/config/s3/S3Config.java +++ b/src/main/java/com/jobdri/jobdri_api/global/config/s3/S3Config.java @@ -3,22 +3,14 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; -import software.amazon.awssdk.services.s3.S3Client; -import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider; import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; import software.amazon.awssdk.services.s3.presigner.S3Presigner; @Configuration public class S3Config { - // application.yml (또는 .properties) 에서 설정한 값들을 가져옵니다. - @Value("${spring.cloud.aws.credentials.access-key:dummy-access-key}") - private String accessKey; - - @Value("${spring.cloud.aws.credentials.secret-key:dummy-secret-key}") - private String secretKey; - @Value("${spring.cloud.aws.region.static:ap-northeast-2}") private String region; @@ -26,11 +18,7 @@ public class S3Config { public S3Client s3Client() { return S3Client.builder() .region(Region.of(region)) - .credentialsProvider( - StaticCredentialsProvider.create( - AwsBasicCredentials.create(accessKey, secretKey) - ) - ) + .credentialsProvider(DefaultCredentialsProvider.create()) .build(); } @@ -38,11 +26,7 @@ public S3Client s3Client() { public S3Presigner s3Presigner() { return S3Presigner.builder() .region(Region.of(region)) - .credentialsProvider( - StaticCredentialsProvider.create( - AwsBasicCredentials.create(accessKey, secretKey) - ) - ) + .credentialsProvider(DefaultCredentialsProvider.create()) .build(); } } diff --git a/src/main/java/com/jobdri/jobdri_api/global/config/s3/S3ObjectUrlService.java b/src/main/java/com/jobdri/jobdri_api/global/config/s3/S3ObjectUrlService.java index 199c62d..59fc323 100644 --- a/src/main/java/com/jobdri/jobdri_api/global/config/s3/S3ObjectUrlService.java +++ b/src/main/java/com/jobdri/jobdri_api/global/config/s3/S3ObjectUrlService.java @@ -30,7 +30,10 @@ public String createPresignedGetUrl(String objectKey) { .build(); GetObjectPresignRequest presignRequest = GetObjectPresignRequest.builder() - .signatureDuration(Duration.ofMinutes(presignGetExpirationMinutes)) + .signatureDuration(Duration.ofMinutes(validateExpirationMinutes( + presignGetExpirationMinutes, + "spring.cloud.aws.s3.presign-get-expiration-minutes" + ))) .getObjectRequest(getObjectRequest) .build(); @@ -45,7 +48,10 @@ public String createPresignedPutUrl(String objectKey, String contentType, long e .build(); PutObjectPresignRequest presignRequest = PutObjectPresignRequest.builder() - .signatureDuration(Duration.ofMinutes(expiresInMinutes)) + .signatureDuration(Duration.ofMinutes(validateExpirationMinutes( + expiresInMinutes, + "presigned PUT expiration minutes" + ))) .putObjectRequest(putObjectRequest) .build(); @@ -55,4 +61,11 @@ public String createPresignedPutUrl(String objectKey, String contentType, long e public String getBucket() { return bucket; } + + private long validateExpirationMinutes(long expirationMinutes, String propertyName) { + if (expirationMinutes < 1 || expirationMinutes > 10080) { + throw new IllegalArgumentException(propertyName + " must be between 1 and 10080 minutes."); + } + return expirationMinutes; + } } diff --git a/src/main/java/com/jobdri/jobdri_api/global/config/s3/S3Uploader.java b/src/main/java/com/jobdri/jobdri_api/global/config/s3/S3Uploader.java index 952cded..1b898f6 100644 --- a/src/main/java/com/jobdri/jobdri_api/global/config/s3/S3Uploader.java +++ b/src/main/java/com/jobdri/jobdri_api/global/config/s3/S3Uploader.java @@ -36,9 +36,11 @@ public String upload(MultipartFile multipartFile, String dirName) throws IOExcep private String upload(File uploadFile, String dirName) { // convert 메소드에서 생성한 고유한 이름을 그대로 사용합니다. String fileName = dirName + "/" + uploadFile.getName(); - String uploadImageUrl = putS3(uploadFile, fileName); - removeNewFile(uploadFile); - return uploadImageUrl; + try { + return putS3(uploadFile, fileName); + } finally { + removeNewFile(uploadFile); + } } // AWS SDK v2에 맞게 PutObjectRequest를 builder 패턴으로 변경