Skip to content

Commit eed5090

Browse files
authored
Merge pull request #55 from JobDri-Developer/fix/#38-job-extract
[Refactor] 동기 api 삭제 (#38)
2 parents 8c8be8a + 1fe4814 commit eed5090

3 files changed

Lines changed: 176 additions & 125 deletions

File tree

src/main/java/com/jobdri/jobdri_api/domain/jobposting/controller/JobPostingAiController.java

Lines changed: 8 additions & 125 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,8 @@
66
import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingAsyncStatusResponse;
77
import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingAsyncSubmitResponse;
88
import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingExtractResponse;
9-
import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingIngestResponse;
109
import com.jobdri.jobdri_api.domain.jobposting.service.JobPostingAiService;
1110
import com.jobdri.jobdri_api.domain.jobposting.service.JobPostingAsyncFacadeService;
12-
import com.jobdri.jobdri_api.domain.jobposting.service.JobPostingIngestService;
1311
import com.jobdri.jobdri_api.domain.user.service.UserService;
1412
import com.jobdri.jobdri_api.global.apiPayload.ApiResponse;
1513
import com.jobdri.jobdri_api.global.security.UserDetailsImpl;
@@ -38,7 +36,6 @@
3836
public class JobPostingAiController {
3937

4038
private final JobPostingAiService jobPostingAiService;
41-
private final JobPostingIngestService jobPostingIngestService;
4239
private final JobPostingAsyncFacadeService jobPostingAsyncFacadeService;
4340
private final UserService userService;
4441

@@ -75,123 +72,25 @@ public ApiResponse<JobPostingExtractResponse> extractJobPostingFromMultipart(
7572
}
7673

7774
@Operation(
78-
summary = "채용 공고 추출부터 분류, 생성, 저장까지 일괄 처리",
79-
description = "이미지 또는 텍스트 공고를 추출하고, trigram 후보 검색과 AI 재분류를 거쳐 최종 소분류를 선택한 뒤 공고를 생성하고 저장합니다."
75+
summary = "채용 공고 비동기 일괄 처리 접수",
76+
description = "이미지 또는 텍스트 공고를 비동기로 추출, 분류, 생성, 저장합니다. 응답으로 받은 taskId로 상태를 조회할 수 있습니다."
8077
)
8178
@ApiResponses(value = {
8279
@io.swagger.v3.oas.annotations.responses.ApiResponse(
8380
responseCode = "200",
84-
description = "분류 confidence가 충분하여 저장까지 완료된 경우",
81+
description = "비동기 작업이 정상 접수된 경우",
8582
content = @Content(
8683
mediaType = "application/json",
8784
schema = @Schema(implementation = ApiResponse.class),
8885
examples = @ExampleObject(value = """
8986
{
9087
"isSuccess": true,
9188
"code": "COMMON2000",
92-
"message": "채용 공고 추출 및 저장에 성공했습니다.",
89+
"message": "채용 공고 비동기 작업 접수에 성공했습니다.",
9390
"result": {
94-
"savedToDatabase": true,
95-
"message": "채용 공고 추출 및 저장에 성공했습니다.",
96-
"extracted": {
97-
"companyName": "삼성전자",
98-
"jobTitle": "백엔드 개발자",
99-
"task": "백엔드 서비스 개발 및 운영",
100-
"requirements": "Java/Spring 기반 개발 경험",
101-
"preferredQualifications": "대용량 트래픽 처리 경험",
102-
"rawText": "채용 공고 원문 내용",
103-
"confidence": 0.92
104-
},
105-
"candidates": [
106-
{
107-
"detailClassificationId": 101,
108-
"detailClassificationName": "Java/Spring",
109-
"middleClassificationName": "백엔드",
110-
"bigClassificationName": "개발",
111-
"score": 0.91
112-
}
113-
],
114-
"classification": {
115-
"detailClassificationId": 101,
116-
"detailClassificationName": "Java/Spring",
117-
"middleClassificationName": "백엔드",
118-
"bigClassificationName": "개발",
119-
"reason": "Spring Boot, JPA, API 개발 맥락이 가장 강합니다.",
120-
"confidence": 0.87
121-
},
122-
"generated": {
123-
"companyName": "삼성전자",
124-
"jobTitle": "Java/Spring 백엔드 개발자",
125-
"task": "백엔드 서비스 개발 및 운영\\nAPI 설계 및 성능 개선",
126-
"requirements": "Java/Spring 기반 개발 경험\\nRDB 사용 경험",
127-
"preferredQualifications": "대용량 트래픽 처리 경험\\nRedis 사용 경험",
128-
"summary": "서비스 백엔드 개발과 운영을 담당할 인재를 찾습니다."
129-
},
130-
"saved": {
131-
"jobPostingId": 10,
132-
"companyId": 3,
133-
"companyName": "삼성전자",
134-
"detailClassificationId": 101,
135-
"detailClassificationName": "Java/Spring",
136-
"task": "백엔드 서비스 개발 및 운영\\nAPI 설계 및 성능 개선",
137-
"requirement": "Java/Spring 기반 개발 경험\\nRDB 사용 경험",
138-
"preferred": "대용량 트래픽 처리 경험\\nRedis 사용 경험"
139-
}
140-
},
141-
"error": null
142-
}
143-
""")
144-
)
145-
),
146-
@io.swagger.v3.oas.annotations.responses.ApiResponse(
147-
responseCode = "200",
148-
description = "분류 confidence가 낮아 저장을 보류한 경우",
149-
content = @Content(
150-
mediaType = "application/json",
151-
schema = @Schema(implementation = ApiResponse.class),
152-
examples = @ExampleObject(value = """
153-
{
154-
"isSuccess": true,
155-
"code": "COMMON2000",
156-
"message": "채용 공고 추출 및 저장에 성공했습니다.",
157-
"result": {
158-
"savedToDatabase": false,
159-
"message": "소분류 분류 confidence가 낮아 저장을 보류했습니다.",
160-
"extracted": {
161-
"companyName": "어떤회사",
162-
"jobTitle": "개발자",
163-
"task": "서비스 개발",
164-
"requirements": "개발 경험",
165-
"preferredQualifications": "우대 사항",
166-
"rawText": "채용 공고 원문 내용",
167-
"confidence": 0.79
168-
},
169-
"candidates": [
170-
{
171-
"detailClassificationId": 101,
172-
"detailClassificationName": "Java/Spring",
173-
"middleClassificationName": "백엔드",
174-
"bigClassificationName": "개발",
175-
"score": 0.62
176-
},
177-
{
178-
"detailClassificationId": 102,
179-
"detailClassificationName": "Node.js",
180-
"middleClassificationName": "백엔드",
181-
"bigClassificationName": "개발",
182-
"score": 0.58
183-
}
184-
],
185-
"classification": {
186-
"detailClassificationId": 101,
187-
"detailClassificationName": "Java/Spring",
188-
"middleClassificationName": "백엔드",
189-
"bigClassificationName": "개발",
190-
"reason": "후보 간 차이가 크지 않습니다.",
191-
"confidence": 0.49
192-
},
193-
"generated": null,
194-
"saved": null
91+
"taskId": "f7f4eac0-b241-4d40-bf39-5b10c8a53943",
92+
"status": "PENDING",
93+
"message": "채용 공고 비동기 작업이 접수되었습니다."
19594
},
19695
"error": null
19796
}
@@ -200,23 +99,7 @@ public ApiResponse<JobPostingExtractResponse> extractJobPostingFromMultipart(
20099
)
201100
})
202101
@PostMapping(value = "/ingest", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
203-
public ApiResponse<JobPostingIngestResponse> ingestJobPosting(
204-
@AuthenticationPrincipal UserDetailsImpl userDetails,
205-
@ModelAttribute JobPostingIngestMultipartRequest request
206-
) {
207-
var user = validateAuthenticatedUser(userDetails);
208-
return ApiResponse.onSuccess(
209-
"채용 공고 추출 및 저장에 성공했습니다.",
210-
jobPostingIngestService.ingestAndCreate(user, request)
211-
);
212-
}
213-
214-
@Operation(
215-
summary = "채용 공고 비동기 일괄 처리 접수",
216-
description = "이미지 또는 텍스트 공고를 비동기로 추출, 분류, 생성, 저장합니다. 응답으로 받은 taskId로 상태를 조회할 수 있습니다."
217-
)
218-
@PostMapping(value = "/ingest/async", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
219-
public ApiResponse<JobPostingAsyncSubmitResponse> submitIngestJobPostingAsync(
102+
public ApiResponse<JobPostingAsyncSubmitResponse> ingestJobPosting(
220103
@AuthenticationPrincipal UserDetailsImpl userDetails,
221104
@ModelAttribute JobPostingIngestMultipartRequest request
222105
) {

src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingIngestService.java

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,9 @@
1717
import lombok.RequiredArgsConstructor;
1818
import org.springframework.beans.factory.annotation.Value;
1919
import org.springframework.stereotype.Service;
20+
import org.springframework.web.multipart.MultipartFile;
2021

22+
import java.io.IOException;
2123
import java.util.List;
2224

2325
@Service
@@ -39,6 +41,8 @@ public JobPostingIngestResponse ingestAndCreate(User user, JobPostingIngestMulti
3941
.userId(user.getId())
4042
.rawText(request.rawText())
4143
.sourceUrl(request.sourceUrl())
44+
.imageBytes(readBytes(request.image()))
45+
.imageContentType(readContentType(request.image()))
4246
.build();
4347
return ingestAndCreate(command);
4448
}
@@ -128,4 +132,23 @@ private User resolveUser(JobPostingIngestCommand command) {
128132
}
129133
return userService.getUser(command.getUserId());
130134
}
135+
136+
private byte[] readBytes(MultipartFile image) {
137+
if (image == null || image.isEmpty()) {
138+
return null;
139+
}
140+
141+
try {
142+
return image.getBytes();
143+
} catch (IOException e) {
144+
throw new GeneralException(GeneralErrorCode.INVALID_PARAMETER, "이미지 파일을 읽을 수 없습니다.");
145+
}
146+
}
147+
148+
private String readContentType(MultipartFile image) {
149+
if (image == null || image.isEmpty()) {
150+
return null;
151+
}
152+
return image.getContentType();
153+
}
131154
}
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
package com.jobdri.jobdri_api.domain.jobposting.service;
2+
3+
import com.jobdri.jobdri_api.domain.jobposting.dto.request.JobPostingCreateRequest;
4+
import com.jobdri.jobdri_api.domain.jobposting.dto.request.JobPostingIngestMultipartRequest;
5+
import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingClassificationCandidateResponse;
6+
import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingClassificationResultResponse;
7+
import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingExtractResponse;
8+
import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingGenerateResponse;
9+
import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingIngestResponse;
10+
import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingResponse;
11+
import com.jobdri.jobdri_api.domain.user.entity.User;
12+
import com.jobdri.jobdri_api.domain.user.service.UserService;
13+
import org.junit.jupiter.api.BeforeEach;
14+
import org.junit.jupiter.api.DisplayName;
15+
import org.junit.jupiter.api.Test;
16+
import org.junit.jupiter.api.extension.ExtendWith;
17+
import org.mockito.ArgumentCaptor;
18+
import org.mockito.InjectMocks;
19+
import org.mockito.Mock;
20+
import org.mockito.junit.jupiter.MockitoExtension;
21+
import org.springframework.mock.web.MockMultipartFile;
22+
import org.springframework.test.util.ReflectionTestUtils;
23+
24+
import java.util.List;
25+
26+
import static org.assertj.core.api.Assertions.assertThat;
27+
import static org.mockito.ArgumentMatchers.any;
28+
import static org.mockito.ArgumentMatchers.eq;
29+
import static org.mockito.Mockito.verify;
30+
import static org.mockito.Mockito.when;
31+
32+
@ExtendWith(MockitoExtension.class)
33+
class JobPostingIngestServiceTest {
34+
35+
@Mock
36+
private JobPostingAiService jobPostingAiService;
37+
38+
@Mock
39+
private JobPostingClassificationService jobPostingClassificationService;
40+
41+
@Mock
42+
private JobPostingService jobPostingService;
43+
44+
@Mock
45+
private UserService userService;
46+
47+
@InjectMocks
48+
private JobPostingIngestService jobPostingIngestService;
49+
50+
private User user;
51+
52+
@BeforeEach
53+
void setUp() {
54+
user = User.signup("테스트 사용자", "ingest@example.com", "encoded-password");
55+
ReflectionTestUtils.setField(user, "id", 1L);
56+
ReflectionTestUtils.setField(jobPostingIngestService, "classificationConfidenceThreshold", 0.65);
57+
}
58+
59+
@Test
60+
@DisplayName("동기 ingest는 multipart 이미지와 content type을 추출 단계로 전달한다")
61+
void ingestAndCreatePassesMultipartImageToExtract() {
62+
MockMultipartFile image = new MockMultipartFile(
63+
"image",
64+
"posting.png",
65+
"image/png",
66+
new byte[]{1, 2, 3}
67+
);
68+
JobPostingIngestMultipartRequest request = new JobPostingIngestMultipartRequest(
69+
"채용 공고 원문",
70+
"https://example.com/job-posting",
71+
image
72+
);
73+
74+
JobPostingExtractResponse extracted = new JobPostingExtractResponse(
75+
"해커스 교육그룹",
76+
"클라우드 엔지니어",
77+
"클라우드 운영",
78+
"경력",
79+
"",
80+
"채용 공고 원문",
81+
0.9
82+
);
83+
JobPostingClassificationCandidateResponse candidate = new JobPostingClassificationCandidateResponse(
84+
1L,
85+
"백엔드 개발",
86+
"AI·개발·데이터",
87+
"개발·데이터",
88+
0.8
89+
);
90+
JobPostingClassificationResultResponse classification = new JobPostingClassificationResultResponse(
91+
1L,
92+
"백엔드 개발",
93+
"AI·개발·데이터",
94+
"개발·데이터",
95+
"가장 적합한 소분류입니다.",
96+
0.9
97+
);
98+
JobPostingGenerateResponse generated = new JobPostingGenerateResponse(
99+
"해커스 교육그룹",
100+
"클라우드 엔지니어",
101+
"정제된 주요 업무",
102+
"정제된 자격 요건",
103+
"정제된 우대 사항",
104+
"요약"
105+
);
106+
JobPostingResponse saved = JobPostingResponse.builder()
107+
.jobPostingId(10L)
108+
.userId(1L)
109+
.companyId(2L)
110+
.companyName("해커스 교육그룹")
111+
.detailClassificationId(1L)
112+
.detailClassificationName("백엔드 개발")
113+
.task("정제된 주요 업무")
114+
.requirement("정제된 자격 요건")
115+
.preferred("정제된 우대 사항")
116+
.build();
117+
118+
when(jobPostingAiService.extractJobPosting(any(), any(byte[].class), any(), any()))
119+
.thenReturn(extracted);
120+
when(jobPostingClassificationService.findCandidates(extracted, 5))
121+
.thenReturn(List.of(candidate));
122+
when(jobPostingAiService.classifyDetailClassification(extracted, List.of(candidate)))
123+
.thenReturn(classification);
124+
when(jobPostingAiService.generateJobPosting(any()))
125+
.thenReturn(generated);
126+
when(userService.getUser(1L)).thenReturn(user);
127+
when(jobPostingService.createJobPosting(eq(user), any(JobPostingCreateRequest.class)))
128+
.thenReturn(saved);
129+
130+
JobPostingIngestResponse response = jobPostingIngestService.ingestAndCreate(user, request);
131+
132+
ArgumentCaptor<byte[]> imageBytesCaptor = ArgumentCaptor.forClass(byte[].class);
133+
ArgumentCaptor<String> contentTypeCaptor = ArgumentCaptor.forClass(String.class);
134+
verify(jobPostingAiService).extractJobPosting(
135+
eq("채용 공고 원문"),
136+
imageBytesCaptor.capture(),
137+
contentTypeCaptor.capture(),
138+
eq("https://example.com/job-posting")
139+
);
140+
141+
assertThat(imageBytesCaptor.getValue()).containsExactly(1, 2, 3);
142+
assertThat(contentTypeCaptor.getValue()).isEqualTo("image/png");
143+
assertThat(response.isSavedToDatabase()).isTrue();
144+
}
145+
}

0 commit comments

Comments
 (0)