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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -38,7 +36,6 @@
public class JobPostingAiController {

private final JobPostingAiService jobPostingAiService;
private final JobPostingIngestService jobPostingIngestService;
private final JobPostingAsyncFacadeService jobPostingAsyncFacadeService;
private final UserService userService;

Expand Down Expand Up @@ -75,123 +72,25 @@ public ApiResponse<JobPostingExtractResponse> 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),
examples = @ExampleObject(value = """
{
"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
}
Expand All @@ -200,23 +99,7 @@ public ApiResponse<JobPostingExtractResponse> extractJobPostingFromMultipart(
)
})
@PostMapping(value = "/ingest", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ApiResponse<JobPostingIngestResponse> 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<JobPostingAsyncSubmitResponse> submitIngestJobPostingAsync(
public ApiResponse<JobPostingAsyncSubmitResponse> ingestJobPosting(
@AuthenticationPrincipal UserDetailsImpl userDetails,
@ModelAttribute JobPostingIngestMultipartRequest request
) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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);
}
Expand Down Expand Up @@ -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();
}
}
Original file line number Diff line number Diff line change
@@ -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<byte[]> imageBytesCaptor = ArgumentCaptor.forClass(byte[].class);
ArgumentCaptor<String> 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();
}
}
Loading