From 1fe48146801325b0a29ce61098e7a4b7184f2b94 Mon Sep 17 00:00:00 2001 From: shinae1023 Date: Wed, 20 May 2026 16:35:24 +0900 Subject: [PATCH] =?UTF-8?q?[Refactor]=20=EB=8F=99=EA=B8=B0=20api=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20(#38)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/JobPostingAiController.java | 133 +--------------- .../service/JobPostingIngestService.java | 23 +++ .../service/JobPostingIngestServiceTest.java | 145 ++++++++++++++++++ 3 files changed, 176 insertions(+), 125 deletions(-) create mode 100644 src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingIngestServiceTest.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 db6fe4f..3cad853 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 @@ -6,10 +6,8 @@ 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; -import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingIngestResponse; import com.jobdri.jobdri_api.domain.jobposting.service.JobPostingAiService; import com.jobdri.jobdri_api.domain.jobposting.service.JobPostingAsyncFacadeService; -import com.jobdri.jobdri_api.domain.jobposting.service.JobPostingIngestService; 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; @@ -38,7 +36,6 @@ public class JobPostingAiController { private final JobPostingAiService jobPostingAiService; - private final JobPostingIngestService jobPostingIngestService; private final JobPostingAsyncFacadeService jobPostingAsyncFacadeService; private final UserService userService; @@ -75,13 +72,13 @@ public ApiResponse extractJobPostingFromMultipart( } @Operation( - summary = "채용 공고 추출부터 분류, 생성, 저장까지 일괄 처리", - description = "이미지 또는 텍스트 공고를 추출하고, trigram 후보 검색과 AI 재분류를 거쳐 최종 소분류를 선택한 뒤 공고를 생성하고 저장합니다." + summary = "채용 공고 비동기 일괄 처리 접수", + description = "이미지 또는 텍스트 공고를 비동기로 추출, 분류, 생성, 저장합니다. 응답으로 받은 taskId로 상태를 조회할 수 있습니다." ) @ApiResponses(value = { @io.swagger.v3.oas.annotations.responses.ApiResponse( responseCode = "200", - description = "분류 confidence가 충분하여 저장까지 완료된 경우", + description = "비동기 작업이 정상 접수된 경우", content = @Content( mediaType = "application/json", schema = @Schema(implementation = ApiResponse.class), @@ -89,109 +86,11 @@ public ApiResponse extractJobPostingFromMultipart( { "isSuccess": true, "code": "COMMON2000", - "message": "채용 공고 추출 및 저장에 성공했습니다.", + "message": "채용 공고 비동기 작업 접수에 성공했습니다.", "result": { - "savedToDatabase": true, - "message": "채용 공고 추출 및 저장에 성공했습니다.", - "extracted": { - "companyName": "삼성전자", - "jobTitle": "백엔드 개발자", - "task": "백엔드 서비스 개발 및 운영", - "requirements": "Java/Spring 기반 개발 경험", - "preferredQualifications": "대용량 트래픽 처리 경험", - "rawText": "채용 공고 원문 내용", - "confidence": 0.92 - }, - "candidates": [ - { - "detailClassificationId": 101, - "detailClassificationName": "Java/Spring", - "middleClassificationName": "백엔드", - "bigClassificationName": "개발", - "score": 0.91 - } - ], - "classification": { - "detailClassificationId": 101, - "detailClassificationName": "Java/Spring", - "middleClassificationName": "백엔드", - "bigClassificationName": "개발", - "reason": "Spring Boot, JPA, API 개발 맥락이 가장 강합니다.", - "confidence": 0.87 - }, - "generated": { - "companyName": "삼성전자", - "jobTitle": "Java/Spring 백엔드 개발자", - "task": "백엔드 서비스 개발 및 운영\\nAPI 설계 및 성능 개선", - "requirements": "Java/Spring 기반 개발 경험\\nRDB 사용 경험", - "preferredQualifications": "대용량 트래픽 처리 경험\\nRedis 사용 경험", - "summary": "서비스 백엔드 개발과 운영을 담당할 인재를 찾습니다." - }, - "saved": { - "jobPostingId": 10, - "companyId": 3, - "companyName": "삼성전자", - "detailClassificationId": 101, - "detailClassificationName": "Java/Spring", - "task": "백엔드 서비스 개발 및 운영\\nAPI 설계 및 성능 개선", - "requirement": "Java/Spring 기반 개발 경험\\nRDB 사용 경험", - "preferred": "대용량 트래픽 처리 경험\\nRedis 사용 경험" - } - }, - "error": null - } - """) - ) - ), - @io.swagger.v3.oas.annotations.responses.ApiResponse( - responseCode = "200", - description = "분류 confidence가 낮아 저장을 보류한 경우", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiResponse.class), - examples = @ExampleObject(value = """ - { - "isSuccess": true, - "code": "COMMON2000", - "message": "채용 공고 추출 및 저장에 성공했습니다.", - "result": { - "savedToDatabase": false, - "message": "소분류 분류 confidence가 낮아 저장을 보류했습니다.", - "extracted": { - "companyName": "어떤회사", - "jobTitle": "개발자", - "task": "서비스 개발", - "requirements": "개발 경험", - "preferredQualifications": "우대 사항", - "rawText": "채용 공고 원문 내용", - "confidence": 0.79 - }, - "candidates": [ - { - "detailClassificationId": 101, - "detailClassificationName": "Java/Spring", - "middleClassificationName": "백엔드", - "bigClassificationName": "개발", - "score": 0.62 - }, - { - "detailClassificationId": 102, - "detailClassificationName": "Node.js", - "middleClassificationName": "백엔드", - "bigClassificationName": "개발", - "score": 0.58 - } - ], - "classification": { - "detailClassificationId": 101, - "detailClassificationName": "Java/Spring", - "middleClassificationName": "백엔드", - "bigClassificationName": "개발", - "reason": "후보 간 차이가 크지 않습니다.", - "confidence": 0.49 - }, - "generated": null, - "saved": null + "taskId": "f7f4eac0-b241-4d40-bf39-5b10c8a53943", + "status": "PENDING", + "message": "채용 공고 비동기 작업이 접수되었습니다." }, "error": null } @@ -200,23 +99,7 @@ public ApiResponse extractJobPostingFromMultipart( ) }) @PostMapping(value = "/ingest", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) - public ApiResponse ingestJobPosting( - @AuthenticationPrincipal UserDetailsImpl userDetails, - @ModelAttribute JobPostingIngestMultipartRequest request - ) { - var user = validateAuthenticatedUser(userDetails); - return ApiResponse.onSuccess( - "채용 공고 추출 및 저장에 성공했습니다.", - jobPostingIngestService.ingestAndCreate(user, request) - ); - } - - @Operation( - summary = "채용 공고 비동기 일괄 처리 접수", - description = "이미지 또는 텍스트 공고를 비동기로 추출, 분류, 생성, 저장합니다. 응답으로 받은 taskId로 상태를 조회할 수 있습니다." - ) - @PostMapping(value = "/ingest/async", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) - public ApiResponse submitIngestJobPostingAsync( + public ApiResponse ingestJobPosting( @AuthenticationPrincipal UserDetailsImpl userDetails, @ModelAttribute JobPostingIngestMultipartRequest request ) { 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 60b57e7..f4f7a82 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 @@ -17,7 +17,9 @@ 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 @@ -39,6 +41,8 @@ public JobPostingIngestResponse ingestAndCreate(User user, JobPostingIngestMulti .userId(user.getId()) .rawText(request.rawText()) .sourceUrl(request.sourceUrl()) + .imageBytes(readBytes(request.image())) + .imageContentType(readContentType(request.image())) .build(); return ingestAndCreate(command); } @@ -128,4 +132,23 @@ 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/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingIngestServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingIngestServiceTest.java new file mode 100644 index 0000000..8a594b1 --- /dev/null +++ b/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingIngestServiceTest.java @@ -0,0 +1,145 @@ +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.response.JobPostingClassificationCandidateResponse; +import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingClassificationResultResponse; +import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingExtractResponse; +import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingGenerateResponse; +import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingIngestResponse; +import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingResponse; +import com.jobdri.jobdri_api.domain.user.entity.User; +import com.jobdri.jobdri_api.domain.user.service.UserService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +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; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class JobPostingIngestServiceTest { + + @Mock + private JobPostingAiService jobPostingAiService; + + @Mock + private JobPostingClassificationService jobPostingClassificationService; + + @Mock + private JobPostingService jobPostingService; + + @Mock + private UserService userService; + + @InjectMocks + private JobPostingIngestService jobPostingIngestService; + + private User user; + + @BeforeEach + void setUp() { + user = User.signup("테스트 사용자", "ingest@example.com", "encoded-password"); + ReflectionTestUtils.setField(user, "id", 1L); + ReflectionTestUtils.setField(jobPostingIngestService, "classificationConfidenceThreshold", 0.65); + } + + @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( + "채용 공고 원문", + "https://example.com/job-posting", + image + ); + + JobPostingExtractResponse extracted = new JobPostingExtractResponse( + "해커스 교육그룹", + "클라우드 엔지니어", + "클라우드 운영", + "경력", + "", + "채용 공고 원문", + 0.9 + ); + JobPostingClassificationCandidateResponse candidate = new JobPostingClassificationCandidateResponse( + 1L, + "백엔드 개발", + "AI·개발·데이터", + "개발·데이터", + 0.8 + ); + JobPostingClassificationResultResponse classification = new JobPostingClassificationResultResponse( + 1L, + "백엔드 개발", + "AI·개발·데이터", + "개발·데이터", + "가장 적합한 소분류입니다.", + 0.9 + ); + JobPostingGenerateResponse generated = new JobPostingGenerateResponse( + "해커스 교육그룹", + "클라우드 엔지니어", + "정제된 주요 업무", + "정제된 자격 요건", + "정제된 우대 사항", + "요약" + ); + JobPostingResponse saved = JobPostingResponse.builder() + .jobPostingId(10L) + .userId(1L) + .companyId(2L) + .companyName("해커스 교육그룹") + .detailClassificationId(1L) + .detailClassificationName("백엔드 개발") + .task("정제된 주요 업무") + .requirement("정제된 자격 요건") + .preferred("정제된 우대 사항") + .build(); + + when(jobPostingAiService.extractJobPosting(any(), any(byte[].class), any(), any())) + .thenReturn(extracted); + when(jobPostingClassificationService.findCandidates(extracted, 5)) + .thenReturn(List.of(candidate)); + when(jobPostingAiService.classifyDetailClassification(extracted, List.of(candidate))) + .thenReturn(classification); + when(jobPostingAiService.generateJobPosting(any())) + .thenReturn(generated); + when(userService.getUser(1L)).thenReturn(user); + when(jobPostingService.createJobPosting(eq(user), any(JobPostingCreateRequest.class))) + .thenReturn(saved); + + JobPostingIngestResponse response = jobPostingIngestService.ingestAndCreate(user, request); + + ArgumentCaptor imageBytesCaptor = ArgumentCaptor.forClass(byte[].class); + ArgumentCaptor contentTypeCaptor = ArgumentCaptor.forClass(String.class); + verify(jobPostingAiService).extractJobPosting( + eq("채용 공고 원문"), + imageBytesCaptor.capture(), + contentTypeCaptor.capture(), + eq("https://example.com/job-posting") + ); + + assertThat(imageBytesCaptor.getValue()).containsExactly(1, 2, 3); + assertThat(contentTypeCaptor.getValue()).isEqualTo("image/png"); + assertThat(response.isSavedToDatabase()).isTrue(); + } +}