From f4d9088594a8bb446005b167e5a2d74e212d8846 Mon Sep 17 00:00:00 2001 From: wooh Date: Fri, 26 Jun 2026 02:37:51 +0900 Subject: [PATCH] =?UTF-8?q?[Feat]=20=ED=81=AC=EB=A1=AC=20=EC=9D=B5?= =?UTF-8?q?=EC=8A=A4=ED=85=90=EC=85=98=20=EA=B3=B5=EA=B3=A0=20=EC=88=98?= =?UTF-8?q?=EC=A7=91=20API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 크롬 익스텐션이 크롤링한 공고 원문을 전달하는 ingest API 추가 - 기존 채용 공고 ingest 로직을 재사용해 공고 저장 처리 - 공고 저장 성공 시 모의 서류 지원을 자동 생성하도록 연계 - sourceUrl, sourceSite를 응답에 포함해 프론트/익스텐션에서 원본 출처를 확인할 수 있도록 구성 - 익스텐션 ingest 성공/저장 보류 케이스 단위 테스트 추가 --- .../JobPostingExtensionController.java | 44 +++++++ .../JobPostingExtensionIngestRequest.java | 12 ++ .../JobPostingExtensionIngestResponse.java | 28 ++++ .../JobPostingExtensionIngestService.java | 41 ++++++ .../JobPostingExtensionIngestServiceTest.java | 121 ++++++++++++++++++ 5 files changed, 246 insertions(+) create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/jobposting/controller/JobPostingExtensionController.java create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/request/JobPostingExtensionIngestRequest.java create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/response/JobPostingExtensionIngestResponse.java create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingExtensionIngestService.java create mode 100644 src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingExtensionIngestServiceTest.java diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/controller/JobPostingExtensionController.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/controller/JobPostingExtensionController.java new file mode 100644 index 0000000..a3f61dc --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/controller/JobPostingExtensionController.java @@ -0,0 +1,44 @@ +package com.jobdri.jobdri_api.domain.jobposting.controller; + +import com.jobdri.jobdri_api.domain.jobposting.dto.request.JobPostingExtensionIngestRequest; +import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingExtensionIngestResponse; +import com.jobdri.jobdri_api.domain.jobposting.service.JobPostingExtensionIngestService; +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; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/job-postings/extension") +@Tag(name = "JobPosting Extension", description = "크롬 익스텐션 채용 공고 연계 API") +public class JobPostingExtensionController { + + private final JobPostingExtensionIngestService jobPostingExtensionIngestService; + private final UserService userService; + + @Operation( + summary = "크롬 익스텐션 공고 수집 및 모의 서류 생성", + description = "크롬 익스텐션이 크롤링한 채용 공고 원문을 기반으로 공고를 저장하고 모의 서류 지원을 생성합니다." + ) + @PostMapping(value = "/ingest", consumes = MediaType.APPLICATION_JSON_VALUE) + public ApiResponse ingestFromExtension( + @AuthenticationPrincipal UserDetailsImpl userDetails, + @Valid @RequestBody JobPostingExtensionIngestRequest request + ) { + var user = userService.validateUser(userDetails == null ? null : userDetails.getUser()); + return ApiResponse.onSuccess( + "크롬 익스텐션 공고 수집에 성공했습니다.", + jobPostingExtensionIngestService.ingest(user, request) + ); + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/request/JobPostingExtensionIngestRequest.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/request/JobPostingExtensionIngestRequest.java new file mode 100644 index 0000000..e4e5bfe --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/request/JobPostingExtensionIngestRequest.java @@ -0,0 +1,12 @@ +package com.jobdri.jobdri_api.domain.jobposting.dto.request; + +import jakarta.validation.constraints.NotBlank; + +public record JobPostingExtensionIngestRequest( + String sourceUrl, + String sourceSite, + + @NotBlank(message = "크롤링한 공고 내용은 필수입니다.") + String rawText +) { +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/response/JobPostingExtensionIngestResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/response/JobPostingExtensionIngestResponse.java new file mode 100644 index 0000000..987d671 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/response/JobPostingExtensionIngestResponse.java @@ -0,0 +1,28 @@ +package com.jobdri.jobdri_api.domain.jobposting.dto.response; + +import com.jobdri.jobdri_api.domain.mockapply.dto.response.MockApplyCreateResponse; + +public record JobPostingExtensionIngestResponse( + String sourceUrl, + String sourceSite, + boolean savedToDatabase, + String message, + JobPostingIngestResponse ingest, + MockApplyCreateResponse mockApply +) { + public static JobPostingExtensionIngestResponse of( + String sourceUrl, + String sourceSite, + JobPostingIngestResponse ingest, + MockApplyCreateResponse mockApply + ) { + return new JobPostingExtensionIngestResponse( + sourceUrl, + sourceSite, + ingest.isSavedToDatabase(), + ingest.getMessage(), + ingest, + mockApply + ); + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingExtensionIngestService.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingExtensionIngestService.java new file mode 100644 index 0000000..0b695c3 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingExtensionIngestService.java @@ -0,0 +1,41 @@ +package com.jobdri.jobdri_api.domain.jobposting.service; + +import com.jobdri.jobdri_api.domain.jobposting.dto.request.JobPostingExtensionIngestRequest; +import com.jobdri.jobdri_api.domain.jobposting.dto.request.JobPostingIngestRequest; +import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingExtensionIngestResponse; +import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingIngestResponse; +import com.jobdri.jobdri_api.domain.mockapply.dto.response.MockApplyCreateResponse; +import com.jobdri.jobdri_api.domain.mockapply.service.MockApplyService; +import com.jobdri.jobdri_api.domain.user.entity.User; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class JobPostingExtensionIngestService { + + private final JobPostingIngestService jobPostingIngestService; + private final MockApplyService mockApplyService; + + public JobPostingExtensionIngestResponse ingest(User user, JobPostingExtensionIngestRequest request) { + JobPostingIngestResponse ingest = jobPostingIngestService.ingestAndCreate( + user, + new JobPostingIngestRequest(request.rawText(), null) + ); + + MockApplyCreateResponse mockApply = null; + if (ingest.isSavedToDatabase() && ingest.getSaved() != null) { + mockApply = mockApplyService.createMockApplyFromJobPosting( + user, + ingest.getSaved().getJobPostingId() + ); + } + + return JobPostingExtensionIngestResponse.of( + request.sourceUrl(), + request.sourceSite(), + ingest, + mockApply + ); + } +} diff --git a/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingExtensionIngestServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingExtensionIngestServiceTest.java new file mode 100644 index 0000000..ffe3e8d --- /dev/null +++ b/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingExtensionIngestServiceTest.java @@ -0,0 +1,121 @@ +package com.jobdri.jobdri_api.domain.jobposting.service; + +import com.jobdri.jobdri_api.domain.jobposting.dto.request.JobPostingExtensionIngestRequest; +import com.jobdri.jobdri_api.domain.jobposting.dto.request.JobPostingIngestRequest; +import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingExtensionIngestResponse; +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.mockapply.dto.response.MockApplyCreateResponse; +import com.jobdri.jobdri_api.domain.mockapply.entity.ApplyType; +import com.jobdri.jobdri_api.domain.mockapply.service.MockApplyService; +import com.jobdri.jobdri_api.domain.user.entity.User; +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.test.util.ReflectionTestUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class JobPostingExtensionIngestServiceTest { + + @Mock + private JobPostingIngestService jobPostingIngestService; + + @Mock + private MockApplyService mockApplyService; + + @InjectMocks + private JobPostingExtensionIngestService jobPostingExtensionIngestService; + + private User user; + + @BeforeEach + void setUp() { + user = User.signup("테스트 사용자", "extension@example.com", "encoded-password"); + ReflectionTestUtils.setField(user, "id", 1L); + } + + @Test + @DisplayName("익스텐션 공고 수집 성공 시 공고 저장 후 모의 서류 지원을 생성한다") + void ingestCreatesMockApplyWhenJobPostingSaved() { + JobPostingExtensionIngestRequest request = new JobPostingExtensionIngestRequest( + "https://www.wanted.co.kr/wd/123", + "WANTED", + "채용 공고 원문" + ); + JobPostingResponse saved = JobPostingResponse.builder() + .jobPostingId(10L) + .userId(1L) + .companyId(2L) + .companyName("테스트 회사") + .detailClassificationId(3L) + .detailClassificationName("백엔드 개발") + .task("주요 업무") + .requirement("자격 요건") + .preferred("우대 사항") + .build(); + JobPostingIngestResponse ingest = new JobPostingIngestResponse( + true, + "저장 성공", + null, + null, + null, + null, + saved + ); + MockApplyCreateResponse mockApply = new MockApplyCreateResponse(10L, 20L, ApplyType.MOCK, 1); + + when(jobPostingIngestService.ingestAndCreate(eq(user), org.mockito.ArgumentMatchers.any(JobPostingIngestRequest.class))) + .thenReturn(ingest); + when(mockApplyService.createMockApplyFromJobPosting(user, 10L)) + .thenReturn(mockApply); + + JobPostingExtensionIngestResponse response = jobPostingExtensionIngestService.ingest(user, request); + + ArgumentCaptor ingestRequestCaptor = ArgumentCaptor.forClass(JobPostingIngestRequest.class); + verify(jobPostingIngestService).ingestAndCreate(eq(user), ingestRequestCaptor.capture()); + assertThat(ingestRequestCaptor.getValue().rawText()).isEqualTo("채용 공고 원문"); + assertThat(ingestRequestCaptor.getValue().imageObjectKey()).isNull(); + assertThat(response.sourceUrl()).isEqualTo("https://www.wanted.co.kr/wd/123"); + assertThat(response.sourceSite()).isEqualTo("WANTED"); + assertThat(response.mockApply()).isEqualTo(mockApply); + } + + @Test + @DisplayName("공고 저장이 보류되면 모의 서류 지원을 생성하지 않는다") + void ingestSkipsMockApplyWhenJobPostingNotSaved() { + JobPostingExtensionIngestRequest request = new JobPostingExtensionIngestRequest( + "https://www.wanted.co.kr/wd/123", + "WANTED", + "채용 공고 원문" + ); + JobPostingIngestResponse ingest = new JobPostingIngestResponse( + false, + "저장 보류", + null, + null, + null, + null, + null + ); + + when(jobPostingIngestService.ingestAndCreate(eq(user), org.mockito.ArgumentMatchers.any(JobPostingIngestRequest.class))) + .thenReturn(ingest); + + JobPostingExtensionIngestResponse response = jobPostingExtensionIngestService.ingest(user, request); + + verify(mockApplyService, never()).createMockApplyFromJobPosting(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any()); + assertThat(response.savedToDatabase()).isFalse(); + assertThat(response.mockApply()).isNull(); + } +}