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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -28,5 +28,11 @@ GOOGLE_CLIENT_SECRET=change-me
APP_OAUTH2_REDIRECT_URI=http://localhost:3000/oauth2/redirect
OPENAI_API_KEY=change-me
JOB_POSTING_CLASSIFICATION_CONFIDENCE_THRESHOLD=0.65
JOB_POSTING_ASYNC_CORE_POOL_SIZE=2
JOB_POSTING_ASYNC_MAX_POOL_SIZE=4
JOB_POSTING_ASYNC_QUEUE_CAPACITY=20
MAIL_ASYNC_CORE_POOL_SIZE=1
MAIL_ASYNC_MAX_POOL_SIZE=2
MAIL_ASYNC_QUEUE_CAPACITY=50

MANAGEMENT_HEALTH_SHOW_DETAILS=always
6 changes: 6 additions & 0 deletions .env.production.example
Original file line number Diff line number Diff line change
Expand Up @@ -32,5 +32,11 @@ GOOGLE_CLIENT_SECRET=change-me
APP_OAUTH2_REDIRECT_URI=https://your-frontend-domain/oauth2/redirect
OPENAI_API_KEY=change-me
JOB_POSTING_CLASSIFICATION_CONFIDENCE_THRESHOLD=0.65
JOB_POSTING_ASYNC_CORE_POOL_SIZE=2
JOB_POSTING_ASYNC_MAX_POOL_SIZE=4
JOB_POSTING_ASYNC_QUEUE_CAPACITY=20
MAIL_ASYNC_CORE_POOL_SIZE=1
MAIL_ASYNC_MAX_POOL_SIZE=2
MAIL_ASYNC_QUEUE_CAPACITY=50

MANAGEMENT_HEALTH_SHOW_DETAILS=never
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ public class AsyncEmailSender {
@Value("${mail.from:${spring.mail.username:}}")
private String fromAddress;

@Async
@Async("mailAsyncExecutor")
@Retryable(
retryFor = MailException.class,
maxAttempts = 3,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,12 @@
import com.jobdri.jobdri_api.domain.jobposting.dto.request.JobPostingExtractRequest;
import com.jobdri.jobdri_api.domain.jobposting.dto.request.JobPostingIngestMultipartRequest;
import com.jobdri.jobdri_api.domain.jobposting.dto.request.JobPostingExtractMultipartRequest;
import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingAsyncStatusResponse;
import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingAsyncSubmitResponse;
import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingExtractResponse;
import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingIngestResponse;
import com.jobdri.jobdri_api.domain.jobposting.service.JobPostingAiService;
import com.jobdri.jobdri_api.domain.jobposting.service.JobPostingAsyncFacadeService;
import com.jobdri.jobdri_api.domain.jobposting.service.JobPostingIngestService;
import com.jobdri.jobdri_api.global.apiPayload.ApiResponse;
import io.swagger.v3.oas.annotations.Operation;
Expand All @@ -22,6 +25,8 @@
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;

@RestController
@RequiredArgsConstructor
Expand All @@ -31,6 +36,7 @@ public class JobPostingAiController {

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

@Operation(
summary = "채용 공고 정보 추출",
Expand Down Expand Up @@ -195,4 +201,32 @@ public ApiResponse<JobPostingIngestResponse> ingestJobPosting(
jobPostingIngestService.ingestAndCreate(request)
);
}

@Operation(
summary = "채용 공고 비동기 일괄 처리 접수",
description = "이미지 또는 텍스트 공고를 비동기로 추출, 분류, 생성, 저장합니다. 응답으로 받은 taskId로 상태를 조회할 수 있습니다."
)
@PostMapping(value = "/ingest/async", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ApiResponse<JobPostingAsyncSubmitResponse> submitIngestJobPostingAsync(
@ModelAttribute JobPostingIngestMultipartRequest request
) {
return ApiResponse.onSuccess(
"채용 공고 비동기 작업 접수에 성공했습니다.",
jobPostingAsyncFacadeService.submit(request)
);
}

@Operation(
summary = "채용 공고 비동기 작업 상태 조회",
description = "taskId로 비동기 작업 상태와 결과를 조회합니다."
)
@GetMapping("/ingest/async/{taskId}")
public ApiResponse<JobPostingAsyncStatusResponse> getIngestJobPostingAsyncStatus(
@PathVariable String taskId
) {
return ApiResponse.onSuccess(
"채용 공고 비동기 작업 상태 조회에 성공했습니다.",
jobPostingAsyncFacadeService.getTask(taskId)
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.jobdri.jobdri_api.domain.jobposting.dto.request;

import com.jobdri.jobdri_api.domain.company.entity.CompanySize;
import lombok.Builder;
import lombok.Getter;

@Getter
@Builder
public class JobPostingIngestCommand {

private String rawText;
private String sourceUrl;
private byte[] imageBytes;
private String imageContentType;
private CompanySize companySize;
private String tone;
private Integer candidateLimit;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.jobdri.jobdri_api.domain.jobposting.dto.response;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;

import java.time.LocalDateTime;

@Getter
@Builder
@AllArgsConstructor
public class JobPostingAsyncStatusResponse {

private String taskId;
private String status;
private String message;
private String error;
private LocalDateTime createdAt;
private LocalDateTime startedAt;
private LocalDateTime completedAt;
private JobPostingIngestResponse result;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.jobdri.jobdri_api.domain.jobposting.dto.response;

import lombok.AllArgsConstructor;
import lombok.Getter;

@Getter
@AllArgsConstructor
public class JobPostingAsyncSubmitResponse {

private String taskId;
private String status;
private String message;
}
Original file line number Diff line number Diff line change
Expand Up @@ -105,18 +105,18 @@ public JobPostingExtractResponse extractJobPosting(JobPostingExtractMultipartReq
return extractJobPosting(request.getRawText(), request.getImage(), request.getSourceUrl());
}

public JobPostingExtractResponse extractJobPosting(String rawText, MultipartFile imageFile, String sourceUrl) {
validateInput(rawText, imageFile);
public JobPostingExtractResponse extractJobPosting(String rawText, byte[] imageBytes, String imageContentType, String sourceUrl) {
validateInput(rawText, imageBytes);

List<ResponseInputContent> contents = new ArrayList<>();
contents.add(ResponseInputContent.ofInputText(
com.openai.models.responses.ResponseInputText.builder()
.text(buildPrompt(rawText, sourceUrl, imageFile != null))
.text(buildPrompt(rawText, sourceUrl, imageBytes != null && imageBytes.length > 0))
.build()
));

if (imageFile != null && !imageFile.isEmpty()) {
contents.add(ResponseInputContent.ofInputImage(buildImageContent(imageFile)));
if (imageBytes != null && imageBytes.length > 0) {
contents.add(ResponseInputContent.ofInputImage(buildImageContent(imageBytes, imageContentType)));
}

var params = ResponseCreateParams.builder()
Expand All @@ -136,16 +136,23 @@ public JobPostingExtractResponse extractJobPosting(String rawText, MultipartFile
try {
StructuredResponse<JobPostingExtractResponse> response = openAIClient.responses().create(params);
JobPostingExtractResponse extracted = extractStructuredContent(response, JobPostingExtractResponse.class);

normalizeResponse(extracted, rawText);
return extracted;

} catch (Exception e) {
log.error("채용 공고 추출 OpenAI API 호출 오류: {}", e.getMessage(), e);
return createFallbackResponse(rawText);
}
}

public JobPostingExtractResponse extractJobPosting(String rawText, MultipartFile imageFile, String sourceUrl) {
return extractJobPosting(
rawText,
imageFile == null || imageFile.isEmpty() ? null : readImageBytes(imageFile),
imageFile == null || imageFile.isEmpty() ? null : imageFile.getContentType(),
sourceUrl
);
}

private String buildPrompt(String rawText, String sourceUrl, boolean hasImage) {
String normalizedRawText = rawText == null ? "" : rawText;
String normalizedSourceUrl = sourceUrl == null ? "" : sourceUrl;
Expand Down Expand Up @@ -244,20 +251,18 @@ private String buildClassificationPrompt(
}

private ResponseInputImage buildImageContent(MultipartFile imageFile) {
validateImage(imageFile);
return buildImageContent(readImageBytes(imageFile), imageFile.getContentType());
}

try {
String contentType = imageFile.getContentType();
String base64 = Base64.getEncoder().encodeToString(imageFile.getBytes());
String dataUrl = "data:%s;base64,%s".formatted(contentType, base64);

return ResponseInputImage.builder()
.imageUrl(dataUrl)
.detail(ResponseInputImage.Detail.HIGH)
.build();
} catch (IOException e) {
throw new GeneralException(GeneralErrorCode.INVALID_PARAMETER, "이미지 파일을 읽을 수 없습니다.");
}
private ResponseInputImage buildImageContent(byte[] imageBytes, String imageContentType) {
validateImage(imageContentType);
String base64 = Base64.getEncoder().encodeToString(imageBytes);
String dataUrl = "data:%s;base64,%s".formatted(imageContentType, base64);

return ResponseInputImage.builder()
.imageUrl(dataUrl)
.detail(ResponseInputImage.Detail.HIGH)
.build();
}

private <T> T extractStructuredContent(StructuredResponse<T> response, Class<T> responseType) {
Expand Down Expand Up @@ -285,8 +290,23 @@ private void validateInput(String rawText, MultipartFile imageFile) {
}
}

private void validateInput(String rawText, byte[] imageBytes) {
boolean hasRawText = rawText != null && !rawText.isBlank();
boolean hasImage = imageBytes != null && imageBytes.length > 0;

if (!hasRawText && !hasImage) {
throw new GeneralException(
GeneralErrorCode.INVALID_PARAMETER,
"rawText 또는 image 중 하나는 반드시 포함되어야 합니다."
);
}
}

private void validateImage(MultipartFile imageFile) {
String contentType = imageFile.getContentType();
validateImage(imageFile.getContentType());
}

private void validateImage(String contentType) {
if (contentType == null || !SUPPORTED_IMAGE_TYPES.contains(contentType.toLowerCase())) {
throw new GeneralException(
GeneralErrorCode.INVALID_PARAMETER,
Expand All @@ -295,6 +315,16 @@ private void validateImage(MultipartFile imageFile) {
}
}

private byte[] readImageBytes(MultipartFile imageFile) {
validateImage(imageFile);

try {
return imageFile.getBytes();
} catch (IOException e) {
throw new GeneralException(GeneralErrorCode.INVALID_PARAMETER, "이미지 파일을 읽을 수 없습니다.");
}
}

private void normalizeResponse(JobPostingExtractResponse response, String rawText) {
if (response == null) {
throw new GeneralException(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package com.jobdri.jobdri_api.domain.jobposting.service;

import com.jobdri.jobdri_api.domain.jobposting.dto.request.JobPostingIngestCommand;
import com.jobdri.jobdri_api.domain.jobposting.dto.request.JobPostingIngestMultipartRequest;
import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingAsyncStatusResponse;
import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingAsyncSubmitResponse;
import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode;
import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException;
import lombok.RequiredArgsConstructor;
import org.springframework.core.task.TaskRejectedException;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;

import java.io.IOException;

@Service
@RequiredArgsConstructor
public class JobPostingAsyncFacadeService {

private final JobPostingAsyncTaskService jobPostingAsyncTaskService;
private final JobPostingAsyncProcessor jobPostingAsyncProcessor;

public JobPostingAsyncSubmitResponse submit(JobPostingIngestMultipartRequest request) {
String taskId = jobPostingAsyncTaskService.createPendingTask();
JobPostingIngestCommand command = snapshot(request);

try {
jobPostingAsyncProcessor.process(taskId, command);
return new JobPostingAsyncSubmitResponse(
taskId,
"PENDING",
"채용 공고 비동기 작업이 접수되었습니다."
);
} catch (TaskRejectedException e) {
throw new GeneralException(
GeneralErrorCode.SERVICE_UNAVAILABLE,
"현재 비동기 작업이 많아 요청을 처리할 수 없습니다. 잠시 후 다시 시도해주세요."
);
}
}

public JobPostingAsyncStatusResponse getTask(String taskId) {
return jobPostingAsyncTaskService.getTask(taskId);
}

private JobPostingIngestCommand snapshot(JobPostingIngestMultipartRequest request) {
return JobPostingIngestCommand.builder()
.rawText(request.getRawText())
.sourceUrl(request.getSourceUrl())
.imageBytes(readBytes(request.getImage()))
.imageContentType(readContentType(request.getImage()))
.companySize(request.getCompanySize())
.tone(request.getTone())
.candidateLimit(request.getCandidateLimit())
.build();
}

private byte[] readBytes(MultipartFile image) {
if (image == null || image.isEmpty()) {
return null;
}

try {
return image.getBytes();
} catch (IOException e) {
throw new GeneralException(GeneralErrorCode.INVALID_PARAMETER, "이미지 파일을 읽을 수 없습니다.");
}
}

private String readContentType(MultipartFile image) {
if (image == null || image.isEmpty()) {
return null;
}
return image.getContentType();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package com.jobdri.jobdri_api.domain.jobposting.service;

import com.jobdri.jobdri_api.domain.jobposting.dto.request.JobPostingIngestCommand;
import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingIngestResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;

@Slf4j
@Service
@RequiredArgsConstructor
public class JobPostingAsyncProcessor {

private final JobPostingAsyncTaskService jobPostingAsyncTaskService;
private final JobPostingIngestService jobPostingIngestService;

@Async("jobPostingAsyncExecutor")
public void process(String taskId, JobPostingIngestCommand command) {
jobPostingAsyncTaskService.markRunning(taskId);

try {
JobPostingIngestResponse result = jobPostingIngestService.ingestAndCreate(command);
jobPostingAsyncTaskService.markSuccess(taskId, result);
} catch (Exception e) {
log.error("채용 공고 비동기 처리 실패: taskId={}", taskId, e);
jobPostingAsyncTaskService.markFailed(taskId, e.getMessage());
}
}
}
Loading
Loading