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 8abe15a..9cb7639 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 @@ -6,8 +6,11 @@ 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.JobPostingMockQuestionResponse; 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.MockQuestionCacheService; +import com.jobdri.jobdri_api.domain.jobposting.service.MockJobPostingGenerationService; import com.jobdri.jobdri_api.domain.jobposting.service.JobPostingService; import com.jobdri.jobdri_api.global.apiPayload.ApiResponse; import io.swagger.v3.oas.annotations.Operation; @@ -32,6 +35,8 @@ public class JobPostingController { private final JobPostingAiService jobPostingAiService; + private final MockJobPostingGenerationService mockJobPostingGenerationService; + private final MockQuestionCacheService mockQuestionCacheService; private final JobPostingService jobPostingService; @Operation(summary = "채용 공고 초안 생성", description = "회사 정보와 직무 정보를 바탕으로 AI가 공고 본문 초안을 생성합니다.") @@ -55,7 +60,21 @@ public ApiResponse generateMockJobPosting( ) { return ApiResponse.onSuccess( "모의 공고 생성에 성공했습니다.", - jobPostingAiService.generateMockJobPosting(request) + mockJobPostingGenerationService.generate(request) + ); + } + + @Operation( + summary = "모의 공고 추천 질문 조회", + description = "선택한 회사/직무 기준으로 모의 공고 추천 질문을 조회합니다. 질문은 직무 기준 캐시를 재사용합니다." + ) + @PostMapping("/mock/questions") + public ApiResponse getMockRecommendedQuestions( + @Valid @RequestBody JobPostingMockGenerateRequest request + ) { + return ApiResponse.onSuccess( + "모의 공고 추천 질문 조회에 성공했습니다.", + new JobPostingMockQuestionResponse(mockQuestionCacheService.getRecommendedQuestions(request)) ); } 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 index 74b1280..9a7c68c 100644 --- 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 @@ -3,6 +3,9 @@ import jakarta.validation.constraints.NotNull; public record JobPostingMockGenerateRequest( + @NotNull(message = "회사 ID는 필수입니다.") + Long companyId, + @NotNull(message = "중분류 ID는 필수입니다.") Long middleClassificationId, 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 index 3bfc476..af841a3 100644 --- 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 @@ -1,11 +1,14 @@ package com.jobdri.jobdri_api.domain.jobposting.dto.response; +import java.util.List; + public record JobPostingMockGenerateResponse( String companyName, String jobTitle, String task, String requirement, String preferred, - String summary + String summary, + List recommendedQuestions ) { } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/response/JobPostingMockQuestionResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/response/JobPostingMockQuestionResponse.java new file mode 100644 index 0000000..88da212 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/response/JobPostingMockQuestionResponse.java @@ -0,0 +1,8 @@ +package com.jobdri.jobdri_api.domain.jobposting.dto.response; + +import java.util.List; + +public record JobPostingMockQuestionResponse( + List recommendedQuestions +) { +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/entity/MockQuestionCache.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/entity/MockQuestionCache.java new file mode 100644 index 0000000..6d62e52 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/entity/MockQuestionCache.java @@ -0,0 +1,72 @@ +package com.jobdri.jobdri_api.domain.jobposting.entity; + +import com.jobdri.jobdri_api.domain.classification.entity.DetailClassification; +import jakarta.persistence.CollectionTable; +import jakarta.persistence.Column; +import jakarta.persistence.ElementCollection; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OrderColumn; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.ArrayList; +import java.util.List; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Builder(access = AccessLevel.PRIVATE) +@Table( + name = "mock_question_caches", + uniqueConstraints = @UniqueConstraint( + name = "uk_mock_question_cache_detail_version", + columnNames = {"detail_classification_id", "prompt_version"} + ) +) +public class MockQuestionCache { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "detail_classification_id", nullable = false) + private DetailClassification detailClassification; + + @Column(name = "prompt_version", nullable = false, length = 50) + private String promptVersion; + + @Builder.Default + @ElementCollection + @CollectionTable( + name = "mock_question_cache_items", + joinColumns = @JoinColumn(name = "mock_question_cache_id") + ) + @OrderColumn(name = "question_order") + @Column(name = "question_content", nullable = false, columnDefinition = "TEXT") + private List questions = new ArrayList<>(); + + public static MockQuestionCache create( + DetailClassification detailClassification, + String promptVersion, + List questions + ) { + return MockQuestionCache.builder() + .detailClassification(detailClassification) + .promptVersion(promptVersion) + .questions(new ArrayList<>(questions)) + .build(); + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/repository/JobPostingRepository.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/repository/JobPostingRepository.java index 0363692..dfd86d6 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/repository/JobPostingRepository.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/repository/JobPostingRepository.java @@ -1,11 +1,37 @@ package com.jobdri.jobdri_api.domain.jobposting.repository; import com.jobdri.jobdri_api.domain.jobposting.entity.JobPosting; +import org.springframework.data.jpa.repository.Query; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.repository.query.Param; import java.util.List; public interface JobPostingRepository extends JpaRepository { List findAllByCompanyId(Long companyId); - List findAllByDetailClassificationId(Long detailClassificationId); + List findTop5ByDetailClassificationIdOrderByIdDesc(Long detailClassificationId); + List findTop5ByCompanyIdOrderByIdDesc(Long companyId); + + @Query(value = """ + SELECT jp.* + FROM job_postings jp + WHERE jp.detail_classification_id = :detailClassificationId + OR (:companyId IS NOT NULL AND jp.company_id = :companyId) + ORDER BY + CASE + WHEN :companyId IS NOT NULL + AND jp.company_id = :companyId + AND jp.detail_classification_id = :detailClassificationId THEN 3 + WHEN jp.detail_classification_id = :detailClassificationId THEN 2 + WHEN :companyId IS NOT NULL + AND jp.company_id = :companyId THEN 1 + ELSE 0 + END DESC, + jp.id DESC + LIMIT 5 + """, nativeQuery = true) + List findTop5ReferencePostings( + @Param("companyId") Long companyId, + @Param("detailClassificationId") Long detailClassificationId + ); } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/repository/MockQuestionCacheRepository.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/repository/MockQuestionCacheRepository.java new file mode 100644 index 0000000..28c5e7a --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/repository/MockQuestionCacheRepository.java @@ -0,0 +1,10 @@ +package com.jobdri.jobdri_api.domain.jobposting.repository; + +import com.jobdri.jobdri_api.domain.jobposting.entity.MockQuestionCache; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface MockQuestionCacheRepository extends JpaRepository { + Optional findByDetailClassification_IdAndPromptVersion(Long detailClassificationId, String promptVersion); +} 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 5a1de57..8a00ba8 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 @@ -2,6 +2,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.JobPostingGenerateRequest; import com.jobdri.jobdri_api.domain.jobposting.dto.request.JobPostingMockGenerateRequest; @@ -10,6 +11,7 @@ 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.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.apiPayload.code.GeneralErrorCode; @@ -34,6 +36,8 @@ @RequiredArgsConstructor public class JobPostingAiService { + private static final int MAX_REFERENCE_FIELD_LENGTH = 400; + private final OpenAIClient openAIClient; private final DetailClassificationRepository detailClassificationRepository; private final JobPostingRepository jobPostingRepository; @@ -77,17 +81,15 @@ public JobPostingGenerateResponse generateJobPosting(JobPostingGenerateRequest r } } - public JobPostingMockGenerateResponse generateMockJobPosting(JobPostingMockGenerateRequest request) { + public JobPostingMockGenerateResponse generateMockJobPosting(JobPostingMockGenerateRequest request, Company company) { DetailClassification detailClassification = findDetailClassification(request.detailClassificationId()); validateMiddleClassification(request, detailClassification); - List referencePostings = jobPostingRepository.findAllByDetailClassificationId( - request.detailClassificationId() - ); + List referencePostings = findMockReferencePostings(request, company); var params = ResponseCreateParams.builder() .model(extractionModel) - .input(buildMockGenerationPrompt(request, detailClassification, referencePostings)) + .input(buildMockGenerationPrompt(request, company, detailClassification, referencePostings)) .temperature(0.7) .text(JobPostingMockGenerateResponse.class) .build(); @@ -98,10 +100,36 @@ public JobPostingMockGenerateResponse generateMockJobPosting(JobPostingMockGener response, JobPostingMockGenerateResponse.class ); - return normalizeMockGeneratedResponse(generated, detailClassification); + return normalizeMockGeneratedResponse(generated, company, detailClassification); } catch (Exception e) { log.error("모의 공고 생성 OpenAI API 호출 오류: {}", e.getMessage(), e); - return createFallbackMockGeneratedResponse(detailClassification, referencePostings); + return createFallbackMockGeneratedResponse(company, detailClassification, referencePostings); + } + } + + public JobPostingMockQuestionResponse generateMockRecommendedQuestions(JobPostingMockGenerateRequest request) { + DetailClassification detailClassification = findDetailClassification(request.detailClassificationId()); + validateMiddleClassification(request, detailClassification); + + List referencePostings = findMockReferencePostings(request, null); + + var params = ResponseCreateParams.builder() + .model(extractionModel) + .input(buildMockQuestionPrompt(request, detailClassification, referencePostings)) + .temperature(0.4) + .text(JobPostingMockQuestionResponse.class) + .build(); + + try { + StructuredResponse response = openAIClient.responses().create(params); + JobPostingMockQuestionResponse generated = extractStructuredContent( + response, + JobPostingMockQuestionResponse.class + ); + return normalizeMockQuestionResponse(generated, detailClassification); + } catch (Exception e) { + log.error("모의 공고 추천 질문 생성 OpenAI API 호출 오류: {}", e.getMessage(), e); + return createFallbackMockQuestionResponse(detailClassification); } } @@ -458,6 +486,7 @@ private String buildGenerationPrompt(JobPostingGenerateRequest request, DetailCl private String buildMockGenerationPrompt( JobPostingMockGenerateRequest request, + Company company, DetailClassification detailClassification, List referencePostings ) { @@ -467,7 +496,7 @@ private String buildMockGenerationPrompt( return """ 아래 직무 분류를 바탕으로 한국어 모의 채용 공고 초안을 작성해주세요. - 사용자는 회사명을 입력하지 않았으므로 companyName은 반드시 "가상 기업"으로 작성하세요. + companyName은 반드시 아래 제공된 회사명으로 작성하세요. 실제 DB 저장용이 아니라 프론트에서 확인할 초안이므로, 출력은 반드시 JSON 객체 하나만 반환하세요. 설명 문장, 마크다운, 코드블럭은 포함하지 마세요. @@ -489,6 +518,60 @@ private String buildMockGenerationPrompt( 6. 참고 공고가 있으면 표현과 직무 맥락만 참고하고, 특정 회사 고유 정보는 만들지 마세요. 7. 참고 공고가 없으면 중분류/소분류명만 기반으로 일반적인 신입/주니어용 공고를 작성하세요. + [회사명] + %s + + [중분류 ID] + %d + + [중분류 직무] + %s + + [소분류 ID] + %d + + [소분류 직무] + %s + + [같은 소분류의 기존 공고 참고 자료] + %s + """.formatted( + company.getName(), + request.middleClassificationId(), + middleName, + request.detailClassificationId(), + detailName, + referenceText + ); + } + + private String buildMockQuestionPrompt( + JobPostingMockGenerateRequest request, + DetailClassification detailClassification, + List referencePostings + ) { + String middleName = detailClassification.getMiddleClassification().getMiddleName(); + String detailName = detailClassification.getDetailName(); + String referenceText = buildReferencePostingText(referencePostings); + + return """ + 아래 직무 분류와 참고 공고를 바탕으로, 모의 지원자에게 제시할 추천 질문 5개를 작성해주세요. + 출력은 반드시 JSON 객체 하나만 반환하세요. + 설명 문장, 마크다운, 코드블럭은 포함하지 마세요. + + { + "recommendedQuestions": [ + "string" + ] + } + + 작성 규칙: + 1. 질문은 자기소개서 또는 지원 동기 작성을 돕는 면접/지원서형 질문으로 작성하세요. + 2. 질문은 신입/주니어 지원자 기준으로 너무 과도하게 어렵지 않게 작성하세요. + 3. 질문은 서로 중복되지 않게 작성하세요. + 4. 참고 공고가 있으면 직무 맥락과 자주 요구되는 역량을 반영하세요. + 5. 참고 공고가 없으면 중분류/소분류명만 기반으로 일반적인 직무 질문을 작성하세요. + [중분류 ID] %d @@ -518,7 +601,6 @@ private String buildReferencePostingText(List referencePostings) { } return referencePostings.stream() - .limit(5) .map(jobPosting -> """ - 주요 업무: %s @@ -527,13 +609,20 @@ private String buildReferencePostingText(List referencePostings) { - 우대 사항: %s """.formatted( - defaultString(jobPosting.getTask()), - defaultString(jobPosting.getRequirement()), - defaultString(jobPosting.getPreferred()) + truncateForPrompt(jobPosting.getTask()), + truncateForPrompt(jobPosting.getRequirement()), + truncateForPrompt(jobPosting.getPreferred()) )) .collect(Collectors.joining("\n")); } + private List findMockReferencePostings(JobPostingMockGenerateRequest request, Company company) { + return jobPostingRepository.findTop5ReferencePostings( + company == null ? null : company.getId(), + request.detailClassificationId() + ); + } + private JobPostingGenerateResponse normalizeGeneratedResponse(JobPostingGenerateResponse response, JobPostingGenerateRequest request) { if (response == null) { throw new GeneralException( @@ -559,6 +648,7 @@ private JobPostingGenerateResponse normalizeGeneratedResponse(JobPostingGenerate private JobPostingMockGenerateResponse normalizeMockGeneratedResponse( JobPostingMockGenerateResponse response, + Company company, DetailClassification detailClassification ) { if (response == null) { @@ -570,7 +660,7 @@ private JobPostingMockGenerateResponse normalizeMockGeneratedResponse( String companyName = response.companyName(); if (companyName == null || companyName.isBlank()) { - companyName = "가상 기업"; + companyName = company.getName(); } String jobTitle = response.jobTitle(); @@ -584,10 +674,36 @@ private JobPostingMockGenerateResponse normalizeMockGeneratedResponse( defaultString(response.task()), defaultString(response.requirement()), defaultString(response.preferred()), - defaultString(response.summary()) + defaultString(response.summary()), + defaultStringList(response.recommendedQuestions()) ); } + private JobPostingMockQuestionResponse normalizeMockQuestionResponse( + JobPostingMockQuestionResponse response, + DetailClassification detailClassification + ) { + if (response == null) { + throw new GeneralException( + GeneralErrorCode.INTERNAL_SERVER_ERROR, + "AI 모의 공고 추천 질문 응답이 비어 있습니다." + ); + } + + List questions = defaultStringList(response.recommendedQuestions()).stream() + .map(String::trim) + .filter(question -> !question.isBlank()) + .distinct() + .limit(5) + .toList(); + + if (!questions.isEmpty()) { + return new JobPostingMockQuestionResponse(questions); + } + + return createFallbackMockQuestionResponse(detailClassification); + } + private DetailClassification findDetailClassification(Long detailClassificationId) { return detailClassificationRepository.findById(detailClassificationId) .orElseThrow(() -> new GeneralException( @@ -657,6 +773,7 @@ private JobPostingGenerateResponse createFallbackGeneratedResponse(JobPostingGen } private JobPostingMockGenerateResponse createFallbackMockGeneratedResponse( + Company company, DetailClassification detailClassification, List referencePostings ) { @@ -667,7 +784,7 @@ private JobPostingMockGenerateResponse createFallbackMockGeneratedResponse( String detailName = detailClassification.getDetailName(); return new JobPostingMockGenerateResponse( - "가상 기업", + company.getName(), detailName, referencePosting == null ? "%s 직무의 기본 업무를 수행하며, 서비스 개발과 운영 과정에 참여합니다.".formatted(detailName) @@ -678,10 +795,24 @@ private JobPostingMockGenerateResponse createFallbackMockGeneratedResponse( referencePosting == null ? "관련 프로젝트 경험 또는 %s 분야 학습 경험이 있으면 좋습니다.".formatted(middleName) : defaultString(referencePosting.getPreferred()), - "%s/%s 직무 기반으로 생성된 신입 및 주니어 대상 모의 공고입니다.".formatted(middleName, detailName) + "%s/%s 직무 기반으로 생성된 신입 및 주니어 대상 모의 공고입니다.".formatted(middleName, detailName), + List.of() ); } + private JobPostingMockQuestionResponse createFallbackMockQuestionResponse(DetailClassification detailClassification) { + String middleName = detailClassification.getMiddleClassification().getMiddleName(); + String detailName = detailClassification.getDetailName(); + + return new JobPostingMockQuestionResponse(List.of( + "%s 직무에 지원한 이유와 가장 관심 있는 업무를 설명해주세요.".formatted(detailName), + "%s 관련 프로젝트나 학습 경험이 있다면 구체적으로 소개해주세요.".formatted(detailName), + "%s 업무를 수행할 때 본인의 강점이 무엇이라고 생각하는지 설명해주세요.".formatted(middleName), + "%s 직무에서 협업이 중요한 이유와 본인의 협업 경험을 말씀해주세요.".formatted(detailName), + "%s 분야 역량을 기르기 위해 최근에 노력한 점을 설명해주세요.".formatted(middleName) + )); + } + private JobPostingClassificationResultResponse fallbackClassification( List candidates ) { @@ -699,4 +830,16 @@ private JobPostingClassificationResultResponse fallbackClassification( private String defaultString(String value) { return value == null ? "" : value; } + + private List defaultStringList(List values) { + return values == null ? List.of() : values; + } + + private String truncateForPrompt(String value) { + String normalized = defaultString(value).trim(); + if (normalized.length() <= MAX_REFERENCE_FIELD_LENGTH) { + return normalized; + } + return normalized.substring(0, MAX_REFERENCE_FIELD_LENGTH) + "..."; + } } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/MockJobPostingGenerationService.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/MockJobPostingGenerationService.java new file mode 100644 index 0000000..7c603be --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/MockJobPostingGenerationService.java @@ -0,0 +1,40 @@ +package com.jobdri.jobdri_api.domain.jobposting.service; + +import com.jobdri.jobdri_api.domain.company.entity.Company; +import com.jobdri.jobdri_api.domain.company.repository.CompanyRepository; +import com.jobdri.jobdri_api.domain.jobposting.dto.request.JobPostingMockGenerateRequest; +import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingMockGenerateResponse; +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; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class MockJobPostingGenerationService { + + private final CompanyRepository companyRepository; + private final JobPostingAiService jobPostingAiService; + private final MockQuestionCacheService mockQuestionCacheService; + + public JobPostingMockGenerateResponse generate(JobPostingMockGenerateRequest request) { + Company company = companyRepository.findById(request.companyId()) + .orElseThrow(() -> new GeneralException( + GeneralErrorCode.COMPANY_NOT_FOUND, + "해당 회사를 찾을 수 없습니다. companyId=" + request.companyId() + )); + + JobPostingMockGenerateResponse generatedPosting = jobPostingAiService.generateMockJobPosting(request, company); + return new JobPostingMockGenerateResponse( + generatedPosting.companyName(), + generatedPosting.jobTitle(), + generatedPosting.task(), + generatedPosting.requirement(), + generatedPosting.preferred(), + generatedPosting.summary(), + mockQuestionCacheService.getRecommendedQuestions(request) + ); + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/MockQuestionCacheService.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/MockQuestionCacheService.java new file mode 100644 index 0000000..ae4fe0e --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/MockQuestionCacheService.java @@ -0,0 +1,58 @@ +package com.jobdri.jobdri_api.domain.jobposting.service; + +import com.jobdri.jobdri_api.domain.classification.entity.DetailClassification; +import com.jobdri.jobdri_api.domain.classification.repository.DetailClassificationRepository; +import com.jobdri.jobdri_api.domain.jobposting.dto.request.JobPostingMockGenerateRequest; +import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingMockQuestionResponse; +import com.jobdri.jobdri_api.domain.jobposting.entity.MockQuestionCache; +import com.jobdri.jobdri_api.domain.jobposting.repository.MockQuestionCacheRepository; +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 java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional +public class MockQuestionCacheService { + + static final String PROMPT_VERSION = "v1"; + + private final MockQuestionCacheRepository mockQuestionCacheRepository; + private final DetailClassificationRepository detailClassificationRepository; + private final JobPostingAiService jobPostingAiService; + + public List getRecommendedQuestions(JobPostingMockGenerateRequest request) { + return mockQuestionCacheRepository + .findByDetailClassification_IdAndPromptVersion(request.detailClassificationId(), PROMPT_VERSION) + .map(MockQuestionCache::getQuestions) + .orElseGet(() -> createAndCacheQuestions(request)); + } + + public List createAndCacheQuestions(JobPostingMockGenerateRequest request) { + return mockQuestionCacheRepository + .findByDetailClassification_IdAndPromptVersion(request.detailClassificationId(), PROMPT_VERSION) + .map(MockQuestionCache::getQuestions) + .orElseGet(() -> { + DetailClassification detailClassification = detailClassificationRepository.findById(request.detailClassificationId()) + .orElseThrow(() -> new GeneralException( + GeneralErrorCode.CLASSIFICATION_NOT_FOUND, + "해당 소분류를 찾을 수 없습니다. detailClassificationId=" + request.detailClassificationId() + )); + + JobPostingMockQuestionResponse generated = + jobPostingAiService.generateMockRecommendedQuestions(request); + MockQuestionCache saved = mockQuestionCacheRepository.save( + MockQuestionCache.create( + detailClassification, + PROMPT_VERSION, + generated.recommendedQuestions() + ) + ); + return saved.getQuestions(); + }); + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/mockapply/controller/MockApplyController.java b/src/main/java/com/jobdri/jobdri_api/domain/mockapply/controller/MockApplyController.java index 3eeb5c1..9a06b0a 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/mockapply/controller/MockApplyController.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/mockapply/controller/MockApplyController.java @@ -3,6 +3,7 @@ import com.jobdri.jobdri_api.domain.mockapply.dto.request.MockApplyCreateActualRequest; import com.jobdri.jobdri_api.domain.mockapply.dto.request.MockApplyCreateMockRequest; import com.jobdri.jobdri_api.domain.mockapply.dto.response.MockApplyCreateResponse; +import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingResponse; import com.jobdri.jobdri_api.domain.mockapply.service.MockApplyService; import com.jobdri.jobdri_api.global.apiPayload.ApiResponse; import com.jobdri.jobdri_api.global.security.UserDetailsImpl; @@ -15,6 +16,8 @@ import jakarta.validation.Valid; 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.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; @@ -106,13 +109,14 @@ public ApiResponse createActualApply( ), @io.swagger.v3.oas.annotations.responses.ApiResponse( responseCode = "404", - description = "회사 또는 소분류 없음", + description = "회사 또는 직무 분류 없음", content = @Content( mediaType = "application/json", schema = @Schema(implementation = ApiResponse.class), examples = { @ExampleObject(name = "company_not_found", value = "{\"isSuccess\":false,\"code\":\"COMPANY_4041\",\"message\":\"해당 회사를 찾을 수 없습니다. companyId=999\",\"result\":null,\"error\":\"해당 회사를 찾을 수 없습니다. companyId=999\"}"), - @ExampleObject(name = "classification_not_found", value = "{\"isSuccess\":false,\"code\":\"CLASSIFICATION_4041\",\"message\":\"해당 소분류를 찾을 수 없습니다. detailClassificationId=999\",\"result\":null,\"error\":\"해당 소분류를 찾을 수 없습니다. detailClassificationId=999\"}") + @ExampleObject(name = "classification_not_found", value = "{\"isSuccess\":false,\"code\":\"CLASSIFICATION_4041\",\"message\":\"해당 소분류를 찾을 수 없습니다. detailClassificationId=999\",\"result\":null,\"error\":\"해당 소분류를 찾을 수 없습니다. detailClassificationId=999\"}"), + @ExampleObject(name = "middle_detail_mismatch", value = "{\"isSuccess\":false,\"code\":\"CLASSIFICATION_4041\",\"message\":\"해당 소분류가 중분류에 속하지 않습니다. middleClassificationId=999, detailClassificationId=1\",\"result\":null,\"error\":\"해당 소분류가 중분류에 속하지 않습니다. middleClassificationId=999, detailClassificationId=1\"}") } ) ) @@ -127,4 +131,19 @@ public ApiResponse createMockApply( mockApplyService.createMockApply(userDetails.getUser(), request) ); } + + @Operation( + summary = "모의 서류 지원의 생성 공고 조회", + description = "mockApplyId에 연결된 생성 공고를 조회합니다." + ) + @GetMapping("/{mockApplyId}/job-posting") + public ApiResponse getMockApplyJobPosting( + @AuthenticationPrincipal UserDetailsImpl userDetails, + @PathVariable Long mockApplyId + ) { + return ApiResponse.onSuccess( + "모의 공고 조회에 성공했습니다.", + mockApplyService.getMockApplyJobPosting(userDetails.getUser(), mockApplyId) + ); + } } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/mockapply/dto/request/MockApplyCreateMockRequest.java b/src/main/java/com/jobdri/jobdri_api/domain/mockapply/dto/request/MockApplyCreateMockRequest.java index 2cc169e..1ba940c 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/mockapply/dto/request/MockApplyCreateMockRequest.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/mockapply/dto/request/MockApplyCreateMockRequest.java @@ -1,18 +1,19 @@ package com.jobdri.jobdri_api.domain.mockapply.dto.request; +import com.jobdri.jobdri_api.domain.jobposting.dto.request.JobPostingMockGenerateRequest; import jakarta.validation.constraints.NotNull; public record MockApplyCreateMockRequest( @NotNull(message = "회사 ID는 필수입니다.") Long companyId, - @NotNull(message = "소분류 ID는 필수입니다.") - Long detailClassificationId, - - String task, + @NotNull(message = "중분류 ID는 필수입니다.") + Long middleClassificationId, - String requirement, - - String preferred + @NotNull(message = "소분류 ID는 필수입니다.") + Long detailClassificationId ) { + public JobPostingMockGenerateRequest toJobPostingMockGenerateRequest() { + return new JobPostingMockGenerateRequest(companyId, middleClassificationId, detailClassificationId); + } } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplyService.java b/src/main/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplyService.java index 59314eb..2237a36 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplyService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplyService.java @@ -1,11 +1,14 @@ package com.jobdri.jobdri_api.domain.mockapply.service; -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.company.repository.CompanyRepository; +import com.jobdri.jobdri_api.domain.jobposting.dto.request.JobPostingCreateRequest; +import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingResponse; +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.domain.jobposting.service.JobPostingService; +import com.jobdri.jobdri_api.domain.jobposting.service.MockJobPostingGenerationService; import com.jobdri.jobdri_api.domain.mockapply.dto.request.MockApplyCreateMockRequest; import com.jobdri.jobdri_api.domain.mockapply.dto.response.MockApplyCreateResponse; import com.jobdri.jobdri_api.domain.mockapply.entity.ApplyType; @@ -17,17 +20,16 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import org.springframework.util.StringUtils; @Service @RequiredArgsConstructor @Transactional(readOnly = true) public class MockApplyService { - private final MockApplyRepository mockApplyRepository; private final JobPostingRepository jobPostingRepository; - private final DetailClassificationRepository detailClassificationRepository; private final CompanyRepository companyRepository; + private final MockJobPostingGenerationService mockJobPostingGenerationService; + private final JobPostingService jobPostingService; @Transactional public MockApplyCreateResponse createActualApply(User user, Long jobPostingId) { @@ -49,29 +51,44 @@ public MockApplyCreateResponse createMockApply(User user, MockApplyCreateMockReq "해당 회사를 찾을 수 없습니다. companyId=" + request.companyId() )); - DetailClassification detailClassification = detailClassificationRepository.findById(request.detailClassificationId()) - .orElseThrow(() -> new GeneralException( - GeneralErrorCode.CLASSIFICATION_NOT_FOUND, - "해당 소분류를 찾을 수 없습니다. detailClassificationId=" + request.detailClassificationId() - )); + JobPostingMockGenerateResponse generated = + mockJobPostingGenerationService.generate(request.toJobPostingMockGenerateRequest()); - JobPosting jobPosting = JobPosting.create( - company, - detailClassification, - normalizeText(request.task()), - normalizeText(request.requirement()), - normalizeText(request.preferred()) + JobPostingCreateRequest createRequest = new JobPostingCreateRequest( + company.getName(), + company.getSize(), + request.detailClassificationId(), + generated.task(), + generated.requirement(), + generated.preferred() ); - JobPosting savedJobPosting = jobPostingRepository.save(jobPosting); + Long savedJobPostingId = jobPostingService.createJobPosting(createRequest).getJobPostingId(); + JobPosting savedJobPosting = jobPostingRepository.findById(savedJobPostingId) + .orElseThrow(() -> new GeneralException( + GeneralErrorCode.JOB_POSTING_NOT_FOUND, + "생성된 모의 공고를 찾을 수 없습니다. jobPostingId=" + savedJobPostingId + )); MockApply mockApply = MockApply.create(user, savedJobPosting, ApplyType.MOCK); return MockApplyCreateResponse.from(mockApplyRepository.save(mockApply)); } - private String normalizeText(String value) { - if (StringUtils.hasText(value)) { - return value; + public JobPostingResponse getMockApplyJobPosting(User user, Long mockApplyId) { + MockApply mockApply = getOwnedMockApply(user, mockApplyId); + return JobPostingResponse.from(mockApply.getJobPosting()); + } + + 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 ""; + + return mockApply; } } diff --git a/src/main/resources/schema.sql b/src/main/resources/schema.sql index 588aec0..9d821bb 100644 --- a/src/main/resources/schema.sql +++ b/src/main/resources/schema.sql @@ -1 +1,10 @@ CREATE EXTENSION IF NOT EXISTS pg_trgm; + +CREATE INDEX IF NOT EXISTS idx_job_postings_company_detail + ON job_postings (company_id, detail_classification_id); + +CREATE INDEX IF NOT EXISTS idx_job_postings_detail + ON job_postings (detail_classification_id); + +CREATE INDEX IF NOT EXISTS idx_job_postings_company + ON job_postings (company_id); 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 7c2eeeb..63797be 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 @@ -10,6 +10,7 @@ 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.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.apiPayload.code.GeneralErrorCode; @@ -33,6 +34,8 @@ @ExtendWith(MockitoExtension.class) class JobPostingAiServiceTest { + private static final Company TEST_COMPANY = Company.create("선택 기업", CompanySize.MEDIUM); + @Mock private OpenAIClient openAIClient; @@ -51,6 +54,7 @@ void setUp() { detailClassificationRepository, jobPostingRepository ); + ReflectionTestUtils.setField(TEST_COMPANY, "id", 1L); ReflectionTestUtils.setField(jobPostingAiService, "extractionModel", "gpt-4o-mini"); } @@ -60,7 +64,8 @@ void generateMockJobPostingThrowsWhenDetailClassificationNotFound() { when(detailClassificationRepository.findById(999L)).thenReturn(Optional.empty()); assertThatThrownBy(() -> jobPostingAiService.generateMockJobPosting( - new JobPostingMockGenerateRequest(10L, 999L) + new JobPostingMockGenerateRequest(1L, 10L, 999L), + TEST_COMPANY )) .isInstanceOf(GeneralException.class) .extracting("code") @@ -74,7 +79,8 @@ void generateMockJobPostingThrowsWhenDetailDoesNotBelongToMiddle() { when(detailClassificationRepository.findById(100L)).thenReturn(Optional.of(detailClassification)); assertThatThrownBy(() -> jobPostingAiService.generateMockJobPosting( - new JobPostingMockGenerateRequest(11L, 100L) + new JobPostingMockGenerateRequest(1L, 11L, 100L), + TEST_COMPANY )) .isInstanceOf(GeneralException.class) .extracting("code") @@ -86,13 +92,14 @@ void generateMockJobPostingThrowsWhenDetailDoesNotBelongToMiddle() { void generateMockJobPostingUsesFallbackWhenNoReferencePostings() { DetailClassification detailClassification = createDetailClassification(10L, 100L, "백엔드", "Java/Spring"); when(detailClassificationRepository.findById(100L)).thenReturn(Optional.of(detailClassification)); - when(jobPostingRepository.findAllByDetailClassificationId(100L)).thenReturn(List.of()); + when(jobPostingRepository.findTop5ReferencePostings(1L, 100L)).thenReturn(List.of()); JobPostingMockGenerateResponse response = jobPostingAiService.generateMockJobPosting( - new JobPostingMockGenerateRequest(10L, 100L) + new JobPostingMockGenerateRequest(1L, 10L, 100L), + TEST_COMPANY ); - assertThat(response.companyName()).isEqualTo("가상 기업"); + assertThat(response.companyName()).isEqualTo("선택 기업"); assertThat(response.jobTitle()).isEqualTo("Java/Spring"); assertThat(response.task()).contains("Java/Spring"); assertThat(response.summary()).contains("백엔드", "Java/Spring"); @@ -110,16 +117,90 @@ void generateMockJobPostingUsesReferencePostingFallback() { "기존 우대 사항" ); when(detailClassificationRepository.findById(100L)).thenReturn(Optional.of(detailClassification)); - when(jobPostingRepository.findAllByDetailClassificationId(100L)).thenReturn(List.of(referencePosting)); + when(jobPostingRepository.findTop5ReferencePostings(1L, 100L)).thenReturn(List.of(referencePosting)); JobPostingMockGenerateResponse response = jobPostingAiService.generateMockJobPosting( - new JobPostingMockGenerateRequest(10L, 100L) + new JobPostingMockGenerateRequest(1L, 10L, 100L), + TEST_COMPANY ); - assertThat(response.companyName()).isEqualTo("가상 기업"); + assertThat(response.companyName()).isEqualTo("선택 기업"); assertThat(response.task()).isEqualTo("기존 주요 업무"); assertThat(response.requirement()).isEqualTo("기존 자격 요건"); assertThat(response.preferred()).isEqualTo("기존 우대 사항"); + assertThat(response.recommendedQuestions()).isEmpty(); + } + + @Test + @DisplayName("같은 회사와 소분류 공고가 있으면 그 공고를 우선 참고한다") + void generateMockJobPostingPrefersCompanyAndDetailReferences() { + DetailClassification detailClassification = createDetailClassification(10L, 100L, "데이터", "데이터 분석"); + JobPosting companySpecificPosting = JobPosting.create( + Company.create("선택 기업", CompanySize.MEDIUM), + detailClassification, + "회사 맞춤 주요 업무", + "회사 맞춤 자격 요건", + "회사 맞춤 우대 사항" + ); + when(detailClassificationRepository.findById(100L)).thenReturn(Optional.of(detailClassification)); + when(jobPostingRepository.findTop5ReferencePostings(1L, 100L)) + .thenReturn(List.of(companySpecificPosting)); + + JobPostingMockGenerateResponse response = jobPostingAiService.generateMockJobPosting( + new JobPostingMockGenerateRequest(1L, 10L, 100L), + TEST_COMPANY + ); + + assertThat(response.task()).isEqualTo("회사 맞춤 주요 업무"); + assertThat(response.requirement()).isEqualTo("회사 맞춤 자격 요건"); + assertThat(response.preferred()).isEqualTo("회사 맞춤 우대 사항"); + } + + @Test + @DisplayName("추천 질문 생성 실패 시 소분류 기반 fallback 질문을 반환한다") + void generateMockRecommendedQuestionsUsesFallback() { + DetailClassification detailClassification = createDetailClassification(10L, 100L, "백엔드", "Java/Spring"); + when(detailClassificationRepository.findById(100L)).thenReturn(Optional.of(detailClassification)); + when(jobPostingRepository.findTop5ReferencePostings(null, 100L)).thenReturn(List.of()); + + JobPostingMockQuestionResponse response = jobPostingAiService.generateMockRecommendedQuestions( + new JobPostingMockGenerateRequest(1L, 10L, 100L) + ); + + assertThat(response.recommendedQuestions()).hasSize(5); + assertThat(response.recommendedQuestions().getFirst()).contains("Java/Spring"); + } + + @Test + @DisplayName("점수화된 참고 공고 목록의 첫 공고를 우선 사용한다") + void generateMockJobPostingUsesTopScoredReferenceFirst() { + DetailClassification detailClassification = createDetailClassification(10L, 100L, "백엔드", "Java/Spring"); + JobPosting topScoredPosting = JobPosting.create( + Company.create("선택 기업", CompanySize.MEDIUM), + detailClassification, + "회사 기반 주요 업무", + "회사 기반 자격 요건", + "회사 기반 우대 사항" + ); + JobPosting lowerPriorityPosting = JobPosting.create( + Company.create("다른 기업", CompanySize.MEDIUM), + detailClassification, + "직무 기반 주요 업무", + "직무 기반 자격 요건", + "직무 기반 우대 사항" + ); + when(detailClassificationRepository.findById(100L)).thenReturn(Optional.of(detailClassification)); + when(jobPostingRepository.findTop5ReferencePostings(1L, 100L)) + .thenReturn(List.of(topScoredPosting, lowerPriorityPosting)); + + JobPostingMockGenerateResponse response = jobPostingAiService.generateMockJobPosting( + new JobPostingMockGenerateRequest(1L, 10L, 100L), + TEST_COMPANY + ); + + assertThat(response.task()).isEqualTo("회사 기반 주요 업무"); + assertThat(response.requirement()).isEqualTo("회사 기반 자격 요건"); + assertThat(response.preferred()).isEqualTo("회사 기반 우대 사항"); } @Test diff --git a/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/MockQuestionCacheServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/MockQuestionCacheServiceTest.java new file mode 100644 index 0000000..e3ac261 --- /dev/null +++ b/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/MockQuestionCacheServiceTest.java @@ -0,0 +1,103 @@ +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.jobposting.dto.request.JobPostingMockGenerateRequest; +import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingMockQuestionResponse; +import com.jobdri.jobdri_api.domain.jobposting.entity.MockQuestionCache; +import com.jobdri.jobdri_api.domain.jobposting.repository.MockQuestionCacheRepository; +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.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class MockQuestionCacheServiceTest { + + @Mock + private MockQuestionCacheRepository mockQuestionCacheRepository; + + @Mock + private DetailClassificationRepository detailClassificationRepository; + + @Mock + private JobPostingAiService jobPostingAiService; + + private MockQuestionCacheService mockQuestionCacheService; + + @BeforeEach + void setUp() { + mockQuestionCacheService = new MockQuestionCacheService( + mockQuestionCacheRepository, + detailClassificationRepository, + jobPostingAiService + ); + } + + @Test + @DisplayName("캐시가 있으면 AI 호출 없이 추천 질문을 반환한다") + void getRecommendedQuestionsUsesCache() { + DetailClassification detailClassification = createDetailClassification(10L, 100L, "백엔드", "Java/Spring"); + MockQuestionCache cache = MockQuestionCache.create( + detailClassification, + MockQuestionCacheService.PROMPT_VERSION, + List.of("질문 1", "질문 2") + ); + when(mockQuestionCacheRepository.findByDetailClassification_IdAndPromptVersion(100L, MockQuestionCacheService.PROMPT_VERSION)) + .thenReturn(Optional.of(cache)); + + List questions = mockQuestionCacheService.getRecommendedQuestions( + new JobPostingMockGenerateRequest(1L, 10L, 100L) + ); + + assertThat(questions).containsExactly("질문 1", "질문 2"); + verify(jobPostingAiService, never()).generateMockRecommendedQuestions(org.mockito.ArgumentMatchers.any()); + } + + @Test + @DisplayName("캐시가 없으면 AI 생성 결과를 저장하고 반환한다") + void createAndCacheQuestionsWhenCacheMissing() { + DetailClassification detailClassification = createDetailClassification(10L, 100L, "백엔드", "Java/Spring"); + JobPostingMockGenerateRequest request = new JobPostingMockGenerateRequest(1L, 10L, 100L); + JobPostingMockQuestionResponse aiResponse = new JobPostingMockQuestionResponse(List.of("질문 A", "질문 B")); + + when(mockQuestionCacheRepository.findByDetailClassification_IdAndPromptVersion(100L, MockQuestionCacheService.PROMPT_VERSION)) + .thenReturn(Optional.empty()); + when(detailClassificationRepository.findById(100L)).thenReturn(Optional.of(detailClassification)); + when(jobPostingAiService.generateMockRecommendedQuestions(request)).thenReturn(aiResponse); + when(mockQuestionCacheRepository.save(org.mockito.ArgumentMatchers.any(MockQuestionCache.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + List questions = mockQuestionCacheService.createAndCacheQuestions(request); + + assertThat(questions).containsExactly("질문 A", "질문 B"); + verify(mockQuestionCacheRepository).save(org.mockito.ArgumentMatchers.any(MockQuestionCache.class)); + } + + 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; + } +} diff --git a/src/test/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplyServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplyServiceTest.java index dc0e572..6a4593a 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplyServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplyServiceTest.java @@ -8,8 +8,10 @@ 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.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.domain.jobposting.service.MockJobPostingGenerationService; import com.jobdri.jobdri_api.domain.mockapply.dto.request.MockApplyCreateMockRequest; import com.jobdri.jobdri_api.domain.mockapply.dto.response.MockApplyCreateResponse; import com.jobdri.jobdri_api.domain.mockapply.entity.ApplyType; @@ -24,11 +26,16 @@ 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") @@ -56,6 +63,9 @@ class MockApplyServiceTest { @Autowired private UserRepository userRepository; + @MockBean + private MockJobPostingGenerationService mockJobPostingGenerationService; + @Test @DisplayName("기존 공고를 기준으로 ACTUAL 타입 모의 서류 지원을 생성한다") void createActualApply() { @@ -79,13 +89,22 @@ void createMockApply() { User user = saveUser("mock-apply@example.com"); Company company = companyRepository.save(Company.create("선택 기업", CompanySize.MEDIUM)); DetailClassification detailClassification = saveDetailClassification("프론트엔드 개발"); + Long middleClassificationId = detailClassification.getMiddleClassification().getId(); MockApplyCreateMockRequest request = new MockApplyCreateMockRequest( company.getId(), - detailClassification.getId(), - null, - "", - "React 경험 우대" + middleClassificationId, + detailClassification.getId() ); + when(mockJobPostingGenerationService.generate(any())) + .thenReturn(new JobPostingMockGenerateResponse( + "선택 기업", + "프론트엔드 개발자", + "웹 프론트엔드 개발 및 운영", + "HTML/CSS/JavaScript 기본기", + "React 경험 우대", + "프론트엔드 직무 대상 모의 공고입니다.", + List.of("질문 1", "질문 2") + )); MockApplyCreateResponse response = mockApplyService.createMockApply(user, request); @@ -99,11 +118,24 @@ void createMockApply() { assertThat(jobPosting.getCompany().getName()).isEqualTo("선택 기업"); assertThat(jobPosting.getCompany().getSize()).isEqualTo(CompanySize.MEDIUM); assertThat(jobPosting.getDetailClassification().getId()).isEqualTo(detailClassification.getId()); - assertThat(jobPosting.getTask()).isEmpty(); - assertThat(jobPosting.getRequirement()).isEmpty(); + assertThat(jobPosting.getTask()).isEqualTo("웹 프론트엔드 개발 및 운영"); + assertThat(jobPosting.getRequirement()).isEqualTo("HTML/CSS/JavaScript 기본기"); assertThat(jobPosting.getPreferred()).isEqualTo("React 경험 우대"); } + @Test + @DisplayName("mockApplyId로 생성된 모의 공고를 조회한다") + void getMockApplyJobPosting() { + User user = saveUser("mock-job-posting@example.com"); + JobPosting jobPosting = saveJobPosting("백엔드 개발"); + MockApply mockApply = mockApplyRepository.save(MockApply.create(user, jobPosting, ApplyType.MOCK)); + + var response = mockApplyService.getMockApplyJobPosting(user, mockApply.getId()); + + assertThat(response.getJobPostingId()).isEqualTo(jobPosting.getId()); + assertThat(response.getCompanyName()).isEqualTo(jobPosting.getCompany().getName()); + } + @Test @DisplayName("존재하지 않는 공고 ID로 ACTUAL 타입 지원 생성 시 예외를 던진다") void createActualApplyThrowsWhenJobPostingNotFound() { @@ -120,7 +152,12 @@ void createActualApplyThrowsWhenJobPostingNotFound() { void createMockApplyThrowsWhenDetailClassificationNotFound() { User user = saveUser("missing-detail-classification@example.com"); Company company = companyRepository.save(Company.create("선택 기업", CompanySize.MEDIUM)); - MockApplyCreateMockRequest request = new MockApplyCreateMockRequest(company.getId(), 9999L, null, null, null); + MockApplyCreateMockRequest request = new MockApplyCreateMockRequest(company.getId(), 1L, 9999L); + when(mockJobPostingGenerationService.generate(any())) + .thenThrow(new GeneralException( + GeneralErrorCode.CLASSIFICATION_NOT_FOUND, + "해당 소분류를 찾을 수 없습니다. detailClassificationId=9999" + )); assertThatThrownBy(() -> mockApplyService.createMockApply(user, request)) .isInstanceOf(GeneralException.class) @@ -132,14 +169,7 @@ void createMockApplyThrowsWhenDetailClassificationNotFound() { @DisplayName("존재하지 않는 회사 ID로 MOCK 타입 지원 생성 시 예외를 던진다") void createMockApplyThrowsWhenCompanyNotFound() { User user = saveUser("missing-company@example.com"); - DetailClassification detailClassification = saveDetailClassification("데이터 분석"); - MockApplyCreateMockRequest request = new MockApplyCreateMockRequest( - 9999L, - detailClassification.getId(), - null, - null, - null - ); + MockApplyCreateMockRequest request = new MockApplyCreateMockRequest(9999L, 1L, 1L); assertThatThrownBy(() -> mockApplyService.createMockApply(user, request)) .isInstanceOf(GeneralException.class) @@ -147,6 +177,39 @@ void createMockApplyThrowsWhenCompanyNotFound() { .isEqualTo(GeneralErrorCode.COMPANY_NOT_FOUND); } + @Test + @DisplayName("소분류가 중분류에 속하지 않으면 MOCK 타입 지원 생성 시 예외를 던진다") + void createMockApplyThrowsWhenMiddleClassificationMismatched() { + User user = saveUser("middle-mismatch@example.com"); + Company company = companyRepository.save(Company.create("선택 기업", CompanySize.MEDIUM)); + DetailClassification detailClassification = saveDetailClassification("데이터 분석"); + MockApplyCreateMockRequest request = new MockApplyCreateMockRequest(company.getId(), 9999L, detailClassification.getId()); + when(mockJobPostingGenerationService.generate(any())) + .thenThrow(new GeneralException( + GeneralErrorCode.CLASSIFICATION_NOT_FOUND, + "해당 소분류가 중분류에 속하지 않습니다. middleClassificationId=9999, detailClassificationId=" + detailClassification.getId() + )); + + assertThatThrownBy(() -> mockApplyService.createMockApply(user, request)) + .isInstanceOf(GeneralException.class) + .extracting("code") + .isEqualTo(GeneralErrorCode.CLASSIFICATION_NOT_FOUND); + } + + @Test + @DisplayName("다른 사용자의 mockApplyId로 공고 조회 시 예외를 던진다") + void getMockApplyJobPostingThrowsWhenForbidden() { + User owner = saveUser("owner@example.com"); + User otherUser = saveUser("other@example.com"); + JobPosting jobPosting = saveJobPosting("데이터 분석"); + MockApply mockApply = mockApplyRepository.save(MockApply.create(owner, jobPosting, ApplyType.MOCK)); + + assertThatThrownBy(() -> mockApplyService.getMockApplyJobPosting(otherUser, mockApply.getId())) + .isInstanceOf(GeneralException.class) + .extracting("code") + .isEqualTo(GeneralErrorCode.FORBIDDEN); + } + private User saveUser(String email) { return userRepository.save(User.signup("테스트 사용자", email, "encoded-password")); }