From dfd6c8ad256a215cc003b744bbcb34f8df0e2d3b Mon Sep 17 00:00:00 2001 From: wooh Date: Tue, 19 May 2026 14:20:51 +0900 Subject: [PATCH] =?UTF-8?q?[Feat]=20=EB=AA=A8=EC=9D=98=20=EA=B3=B5?= =?UTF-8?q?=EA=B3=A0=20=EC=83=9D=EC=84=B1=20API=20=EC=B6=94=EA=B0=80=20(#3?= =?UTF-8?q?8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 중분류/소분류 선택만으로 모의 공고 초안을 생성하는 API 추가 - POST /api/job-postings/mock/generate 엔드포인트 구현 - mock generate 전용 요청/응답 DTO 추가 - 소분류 기준 기존 공고를 조회해 AI 프롬프트 참고 자료로 활용 - 기존 공고가 없을 경우 중분류/소분류명 기반 fallback 공고 생성 - detailClassificationId 존재 여부 및 중분류-소분류 소속 관계 검증 추가 - mock generate 응답 필드를 저장 DTO와 맞춰 requirement, preferred로 정리 - 기존 공고 생성 API에서 companySize가 null일 때 발생할 수 있는 NPE 방지 - 모의 공고 생성 및 companySize null 방어 테스트 추가 --- .../controller/JobPostingController.java | 16 ++ .../JobPostingMockGenerateRequest.java | 12 ++ .../JobPostingMockGenerateResponse.java | 11 + .../service/JobPostingAiService.java | 198 +++++++++++++++++- .../service/JobPostingAiServiceTest.java | 162 ++++++++++++++ 5 files changed, 397 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/request/JobPostingMockGenerateRequest.java create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/response/JobPostingMockGenerateResponse.java create mode 100644 src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAiServiceTest.java diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/controller/JobPostingController.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/controller/JobPostingController.java index b0b1fd7..8abe15a 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/controller/JobPostingController.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/controller/JobPostingController.java @@ -2,8 +2,10 @@ 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.JobPostingMockGenerateRequest; import com.jobdri.jobdri_api.domain.jobposting.dto.request.JobPostingUpdateRequest; import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingGenerateResponse; +import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingMockGenerateResponse; import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingResponse; import com.jobdri.jobdri_api.domain.jobposting.service.JobPostingAiService; import com.jobdri.jobdri_api.domain.jobposting.service.JobPostingService; @@ -43,6 +45,20 @@ public ApiResponse generateJobPosting( ); } + @Operation( + summary = "모의 공고 생성", + description = "선택한 직무 중분류/소분류를 기반으로 기존 공고를 참고하여 가상의 모의 공고를 생성합니다." + ) + @PostMapping("/mock/generate") + public ApiResponse generateMockJobPosting( + @Valid @RequestBody JobPostingMockGenerateRequest request + ) { + return ApiResponse.onSuccess( + "모의 공고 생성에 성공했습니다.", + jobPostingAiService.generateMockJobPosting(request) + ); + } + @Operation(summary = "채용 공고 저장", description = "생성되었거나 직접 작성한 채용 공고를 DB에 저장합니다.") @PostMapping public ApiResponse createJobPosting( diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/request/JobPostingMockGenerateRequest.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/request/JobPostingMockGenerateRequest.java new file mode 100644 index 0000000..74b1280 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/request/JobPostingMockGenerateRequest.java @@ -0,0 +1,12 @@ +package com.jobdri.jobdri_api.domain.jobposting.dto.request; + +import jakarta.validation.constraints.NotNull; + +public record JobPostingMockGenerateRequest( + @NotNull(message = "중분류 ID는 필수입니다.") + Long middleClassificationId, + + @NotNull(message = "소분류 ID는 필수입니다.") + Long detailClassificationId +) { +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/response/JobPostingMockGenerateResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/response/JobPostingMockGenerateResponse.java new file mode 100644 index 0000000..3bfc476 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/response/JobPostingMockGenerateResponse.java @@ -0,0 +1,11 @@ +package com.jobdri.jobdri_api.domain.jobposting.dto.response; + +public record JobPostingMockGenerateResponse( + String companyName, + String jobTitle, + String task, + String requirement, + String preferred, + String summary +) { +} 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 b734d8c..5a1de57 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 @@ -4,10 +4,14 @@ import com.jobdri.jobdri_api.domain.classification.repository.DetailClassificationRepository; import com.jobdri.jobdri_api.domain.jobposting.dto.request.JobPostingExtractMultipartRequest; 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; 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.JobPostingMockGenerateResponse; +import com.jobdri.jobdri_api.domain.jobposting.entity.JobPosting; +import com.jobdri.jobdri_api.domain.jobposting.repository.JobPostingRepository; import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; import com.openai.client.OpenAIClient; @@ -32,6 +36,7 @@ public class JobPostingAiService { private final OpenAIClient openAIClient; private final DetailClassificationRepository detailClassificationRepository; + private final JobPostingRepository jobPostingRepository; @Value("${openai.model.job-posting-extractor:gpt-4o-mini}") private String extractionModel; @@ -72,6 +77,34 @@ public JobPostingGenerateResponse generateJobPosting(JobPostingGenerateRequest r } } + public JobPostingMockGenerateResponse generateMockJobPosting(JobPostingMockGenerateRequest request) { + DetailClassification detailClassification = findDetailClassification(request.detailClassificationId()); + validateMiddleClassification(request, detailClassification); + + List referencePostings = jobPostingRepository.findAllByDetailClassificationId( + request.detailClassificationId() + ); + + var params = ResponseCreateParams.builder() + .model(extractionModel) + .input(buildMockGenerationPrompt(request, detailClassification, referencePostings)) + .temperature(0.7) + .text(JobPostingMockGenerateResponse.class) + .build(); + + try { + StructuredResponse response = openAIClient.responses().create(params); + JobPostingMockGenerateResponse generated = extractStructuredContent( + response, + JobPostingMockGenerateResponse.class + ); + return normalizeMockGeneratedResponse(generated, detailClassification); + } catch (Exception e) { + log.error("모의 공고 생성 OpenAI API 호출 오류: {}", e.getMessage(), e); + return createFallbackMockGeneratedResponse(detailClassification, referencePostings); + } + } + public JobPostingClassificationResultResponse classifyDetailClassification( JobPostingExtractResponse extracted, List candidates @@ -357,6 +390,8 @@ private JobPostingExtractResponse createFallbackResponse(String rawText) { } private String buildGenerationPrompt(JobPostingGenerateRequest request, DetailClassification detailClassification) { + String companySize = request.companySize() == null ? "미지정" : request.companySize().name(); + return """ 아래 정보를 바탕으로 한국어 채용 공고 초안을 작성해주세요. 출력은 반드시 JSON 객체 하나만 반환하세요. @@ -408,8 +443,8 @@ private String buildGenerationPrompt(JobPostingGenerateRequest request, DetailCl [원하는 톤] %s """.formatted( - request.companyName(), - request.companySize().name(), + defaultString(request.companyName()), + companySize, detailClassification.getDetailName(), defaultString(request.jobTitleHint()), defaultString(request.hiringSummary()), @@ -421,6 +456,84 @@ private String buildGenerationPrompt(JobPostingGenerateRequest request, DetailCl ); } + private String buildMockGenerationPrompt( + JobPostingMockGenerateRequest request, + DetailClassification detailClassification, + List referencePostings + ) { + String middleName = detailClassification.getMiddleClassification().getMiddleName(); + String detailName = detailClassification.getDetailName(); + String referenceText = buildReferencePostingText(referencePostings); + + return """ + 아래 직무 분류를 바탕으로 한국어 모의 채용 공고 초안을 작성해주세요. + 사용자는 회사명을 입력하지 않았으므로 companyName은 반드시 "가상 기업"으로 작성하세요. + 실제 DB 저장용이 아니라 프론트에서 확인할 초안이므로, 출력은 반드시 JSON 객체 하나만 반환하세요. + 설명 문장, 마크다운, 코드블럭은 포함하지 마세요. + + { + "companyName": "string", + "jobTitle": "string", + "task": "string", + "requirement": "string", + "preferred": "string", + "summary": "string" + } + + 작성 규칙: + 1. jobTitle은 소분류 직무명을 기반으로 자연스러운 직무명으로 작성하세요. + 2. task는 신입/주니어가 수행할 수 있는 주요 업무를 중심으로 작성하세요. + 3. requirement는 필수 자격 요건만 정리하세요. + 4. preferred는 우대 사항만 정리하세요. + 5. summary는 2~3문장으로 포지션 소개를 작성하세요. + 6. 참고 공고가 있으면 표현과 직무 맥락만 참고하고, 특정 회사 고유 정보는 만들지 마세요. + 7. 참고 공고가 없으면 중분류/소분류명만 기반으로 일반적인 신입/주니어용 공고를 작성하세요. + + [중분류 ID] + %d + + [중분류 직무] + %s + + [소분류 ID] + %d + + [소분류 직무] + %s + + [같은 소분류의 기존 공고 참고 자료] + %s + """.formatted( + request.middleClassificationId(), + middleName, + request.detailClassificationId(), + detailName, + referenceText + ); + } + + private String buildReferencePostingText(List referencePostings) { + if (referencePostings == null || referencePostings.isEmpty()) { + return "참고 가능한 기존 공고가 없습니다."; + } + + return referencePostings.stream() + .limit(5) + .map(jobPosting -> """ + - 주요 업무: + %s + - 자격 요건: + %s + - 우대 사항: + %s + """.formatted( + defaultString(jobPosting.getTask()), + defaultString(jobPosting.getRequirement()), + defaultString(jobPosting.getPreferred()) + )) + .collect(Collectors.joining("\n")); + } + private JobPostingGenerateResponse normalizeGeneratedResponse(JobPostingGenerateResponse response, JobPostingGenerateRequest request) { if (response == null) { throw new GeneralException( @@ -444,6 +557,61 @@ private JobPostingGenerateResponse normalizeGeneratedResponse(JobPostingGenerate ); } + private JobPostingMockGenerateResponse normalizeMockGeneratedResponse( + JobPostingMockGenerateResponse response, + DetailClassification detailClassification + ) { + if (response == null) { + throw new GeneralException( + GeneralErrorCode.INTERNAL_SERVER_ERROR, + "AI 모의 공고 생성 응답이 비어 있습니다." + ); + } + + String companyName = response.companyName(); + if (companyName == null || companyName.isBlank()) { + companyName = "가상 기업"; + } + + String jobTitle = response.jobTitle(); + if (jobTitle == null || jobTitle.isBlank()) { + jobTitle = detailClassification.getDetailName(); + } + + return new JobPostingMockGenerateResponse( + companyName, + jobTitle, + defaultString(response.task()), + defaultString(response.requirement()), + defaultString(response.preferred()), + defaultString(response.summary()) + ); + } + + private DetailClassification findDetailClassification(Long detailClassificationId) { + return detailClassificationRepository.findById(detailClassificationId) + .orElseThrow(() -> new GeneralException( + GeneralErrorCode.CLASSIFICATION_NOT_FOUND, + "해당 소분류를 찾을 수 없습니다. detailClassificationId=" + detailClassificationId + )); + } + + private void validateMiddleClassification( + JobPostingMockGenerateRequest request, + DetailClassification detailClassification + ) { + Long actualMiddleClassificationId = detailClassification.getMiddleClassification().getId(); + if (!actualMiddleClassificationId.equals(request.middleClassificationId())) { + throw new GeneralException( + GeneralErrorCode.CLASSIFICATION_NOT_FOUND, + "해당 소분류가 중분류에 속하지 않습니다. middleClassificationId=" + + request.middleClassificationId() + + ", detailClassificationId=" + + request.detailClassificationId() + ); + } + } + private JobPostingClassificationResultResponse normalizeClassificationResponse( JobPostingClassificationResultResponse response, List candidates @@ -488,6 +656,32 @@ private JobPostingGenerateResponse createFallbackGeneratedResponse(JobPostingGen ); } + private JobPostingMockGenerateResponse createFallbackMockGeneratedResponse( + DetailClassification detailClassification, + List referencePostings + ) { + JobPosting referencePosting = referencePostings == null || referencePostings.isEmpty() + ? null + : referencePostings.getFirst(); + String middleName = detailClassification.getMiddleClassification().getMiddleName(); + String detailName = detailClassification.getDetailName(); + + return new JobPostingMockGenerateResponse( + "가상 기업", + detailName, + referencePosting == null + ? "%s 직무의 기본 업무를 수행하며, 서비스 개발과 운영 과정에 참여합니다.".formatted(detailName) + : defaultString(referencePosting.getTask()), + referencePosting == null + ? "%s 분야에 대한 기본 이해와 협업 역량을 갖춘 분을 찾습니다.".formatted(detailName) + : defaultString(referencePosting.getRequirement()), + referencePosting == null + ? "관련 프로젝트 경험 또는 %s 분야 학습 경험이 있으면 좋습니다.".formatted(middleName) + : defaultString(referencePosting.getPreferred()), + "%s/%s 직무 기반으로 생성된 신입 및 주니어 대상 모의 공고입니다.".formatted(middleName, detailName) + ); + } + private JobPostingClassificationResultResponse fallbackClassification( List candidates ) { 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 new file mode 100644 index 0000000..7c2eeeb --- /dev/null +++ b/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAiServiceTest.java @@ -0,0 +1,162 @@ +package com.jobdri.jobdri_api.domain.jobposting.service; + +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.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.jobposting.dto.request.JobPostingGenerateRequest; +import com.jobdri.jobdri_api.domain.jobposting.dto.request.JobPostingMockGenerateRequest; +import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingGenerateResponse; +import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingMockGenerateResponse; +import com.jobdri.jobdri_api.domain.jobposting.entity.JobPosting; +import com.jobdri.jobdri_api.domain.jobposting.repository.JobPostingRepository; +import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; +import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; +import com.openai.client.OpenAIClient; +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.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class JobPostingAiServiceTest { + + @Mock + private OpenAIClient openAIClient; + + @Mock + private DetailClassificationRepository detailClassificationRepository; + + @Mock + private JobPostingRepository jobPostingRepository; + + private JobPostingAiService jobPostingAiService; + + @BeforeEach + void setUp() { + jobPostingAiService = new JobPostingAiService( + openAIClient, + detailClassificationRepository, + jobPostingRepository + ); + ReflectionTestUtils.setField(jobPostingAiService, "extractionModel", "gpt-4o-mini"); + } + + @Test + @DisplayName("존재하지 않는 소분류 ID로 모의 공고 생성 시 예외를 던진다") + void generateMockJobPostingThrowsWhenDetailClassificationNotFound() { + when(detailClassificationRepository.findById(999L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> jobPostingAiService.generateMockJobPosting( + new JobPostingMockGenerateRequest(10L, 999L) + )) + .isInstanceOf(GeneralException.class) + .extracting("code") + .isEqualTo(GeneralErrorCode.CLASSIFICATION_NOT_FOUND); + } + + @Test + @DisplayName("소분류가 요청 중분류 하위가 아니면 예외를 던진다") + void generateMockJobPostingThrowsWhenDetailDoesNotBelongToMiddle() { + DetailClassification detailClassification = createDetailClassification(10L, 100L, "백엔드", "Java/Spring"); + when(detailClassificationRepository.findById(100L)).thenReturn(Optional.of(detailClassification)); + + assertThatThrownBy(() -> jobPostingAiService.generateMockJobPosting( + new JobPostingMockGenerateRequest(11L, 100L) + )) + .isInstanceOf(GeneralException.class) + .extracting("code") + .isEqualTo(GeneralErrorCode.CLASSIFICATION_NOT_FOUND); + } + + @Test + @DisplayName("기존 공고가 없으면 분류명 기반 fallback 모의 공고를 생성한다") + void generateMockJobPostingUsesFallbackWhenNoReferencePostings() { + DetailClassification detailClassification = createDetailClassification(10L, 100L, "백엔드", "Java/Spring"); + when(detailClassificationRepository.findById(100L)).thenReturn(Optional.of(detailClassification)); + when(jobPostingRepository.findAllByDetailClassificationId(100L)).thenReturn(List.of()); + + JobPostingMockGenerateResponse response = jobPostingAiService.generateMockJobPosting( + new JobPostingMockGenerateRequest(10L, 100L) + ); + + assertThat(response.companyName()).isEqualTo("가상 기업"); + assertThat(response.jobTitle()).isEqualTo("Java/Spring"); + assertThat(response.task()).contains("Java/Spring"); + assertThat(response.summary()).contains("백엔드", "Java/Spring"); + } + + @Test + @DisplayName("기존 공고가 있으면 fallback에서도 참고 공고 내용을 반영한다") + void generateMockJobPostingUsesReferencePostingFallback() { + DetailClassification detailClassification = createDetailClassification(10L, 100L, "데이터", "데이터 분석"); + JobPosting referencePosting = JobPosting.create( + Company.create("참고 기업", CompanySize.MEDIUM), + detailClassification, + "기존 주요 업무", + "기존 자격 요건", + "기존 우대 사항" + ); + when(detailClassificationRepository.findById(100L)).thenReturn(Optional.of(detailClassification)); + when(jobPostingRepository.findAllByDetailClassificationId(100L)).thenReturn(List.of(referencePosting)); + + JobPostingMockGenerateResponse response = jobPostingAiService.generateMockJobPosting( + new JobPostingMockGenerateRequest(10L, 100L) + ); + + assertThat(response.companyName()).isEqualTo("가상 기업"); + assertThat(response.task()).isEqualTo("기존 주요 업무"); + assertThat(response.requirement()).isEqualTo("기존 자격 요건"); + assertThat(response.preferred()).isEqualTo("기존 우대 사항"); + } + + @Test + @DisplayName("기존 공고 생성에서 companySize가 null이어도 NPE 없이 fallback 응답을 반환한다") + void generateJobPostingDoesNotThrowWhenCompanySizeIsNull() { + DetailClassification detailClassification = createDetailClassification(10L, 100L, "백엔드", "Java/Spring"); + when(detailClassificationRepository.findById(100L)).thenReturn(Optional.of(detailClassification)); + JobPostingGenerateRequest request = new JobPostingGenerateRequest( + "테스트 기업", + null, + 100L, + "채용 요약", + "Java, Spring", + "주요 업무", + "자격 요건", + "우대 사항", + "담백하게", + "백엔드 개발자" + ); + + JobPostingGenerateResponse response = jobPostingAiService.generateJobPosting(request); + + assertThat(response.companyName()).isEqualTo("테스트 기업"); + assertThat(response.jobTitle()).isEqualTo("백엔드 개발자"); + } + + private DetailClassification createDetailClassification( + Long middleClassificationId, + Long detailClassificationId, + String middleName, + String detailName + ) { + Classification classification = Classification.create("개발"); + MiddleClassification middleClassification = classification.addMiddleClassification(middleName); + DetailClassification detailClassification = middleClassification.addDetailClassification(detailName); + ReflectionTestUtils.setField(middleClassification, "id", middleClassificationId); + ReflectionTestUtils.setField(detailClassification, "id", detailClassificationId); + return detailClassification; + } +}