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
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -27,5 +27,6 @@ GOOGLE_CLIENT_ID=change-me.apps.googleusercontent.com
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

MANAGEMENT_HEALTH_SHOW_DETAILS=always
1 change: 1 addition & 0 deletions .env.production.example
Original file line number Diff line number Diff line change
Expand Up @@ -31,5 +31,6 @@ GOOGLE_CLIENT_ID=change-me.apps.googleusercontent.com
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

MANAGEMENT_HEALTH_SHOW_DETAILS=never
Original file line number Diff line number Diff line change
@@ -1,12 +1,37 @@
package com.jobdri.jobdri_api.domain.classification.repository;

import com.jobdri.jobdri_api.domain.classification.entity.DetailClassification;
import com.jobdri.jobdri_api.domain.jobposting.repository.JobPostingClassificationCandidateProjection;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

import java.util.List;
import java.util.Optional;

public interface DetailClassificationRepository extends JpaRepository<DetailClassification, Long> {
List<DetailClassification> findAllByMiddleClassificationId(Long middleClassificationId);
Optional<DetailClassification> findByDetailName(String detailName);

@Query(value = """
SELECT
dc.id AS detailClassificationId,
dc.detail_name AS detailClassificationName,
mc.middle_name AS middleClassificationName,
c.big_name AS bigClassificationName,
GREATEST(
word_similarity(lower(dc.detail_name), lower(:query)),
word_similarity(lower(mc.middle_name), lower(:query)),
word_similarity(lower(concat(c.big_name, ' ', mc.middle_name, ' ', dc.detail_name)), lower(:query))
) AS score
FROM detail_classifications dc
JOIN middle_classifications mc ON dc.middle_classification_id = mc.id
JOIN classifications c ON mc.classification_id = c.id
ORDER BY score DESC, dc.id ASC
LIMIT :limit
""", nativeQuery = true)
List<JobPostingClassificationCandidateProjection> findTopCandidatesByTrigram(
@Param("query") String query,
@Param("limit") int limit
);
}
Original file line number Diff line number Diff line change
@@ -1,11 +1,18 @@
package com.jobdri.jobdri_api.domain.jobposting.controller;

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.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.JobPostingIngestService;
import com.jobdri.jobdri_api.global.apiPayload.ApiResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.ExampleObject;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
Expand All @@ -23,6 +30,7 @@
public class JobPostingAiController {

private final JobPostingAiService jobPostingAiService;
private final JobPostingIngestService jobPostingIngestService;

@Operation(
summary = "채용 공고 정보 추출",
Expand Down Expand Up @@ -51,4 +59,140 @@ public ApiResponse<JobPostingExtractResponse> extractJobPostingFromMultipart(
jobPostingAiService.extractJobPosting(request)
);
}

@Operation(
summary = "채용 공고 추출부터 분류, 생성, 저장까지 일괄 처리",
description = "이미지 또는 텍스트 공고를 추출하고, trigram 후보 검색과 AI 재분류를 거쳐 최종 소분류를 선택한 뒤 공고를 생성하고 저장합니다."
)
@ApiResponses(value = {
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "200",
description = "분류 confidence가 충분하여 저장까지 완료된 경우",
content = @Content(
mediaType = "application/json",
schema = @Schema(implementation = ApiResponse.class),
examples = @ExampleObject(value = """
{
"isSuccess": true,
"code": "COMMON2000",
"message": "채용 공고 추출 및 저장에 성공했습니다.",
"result": {
"savedToDatabase": true,
"message": "채용 공고 추출 및 저장에 성공했습니다.",
"extracted": {
"companyName": "삼성전자",
"jobTitle": "백엔드 개발자",
"task": "백엔드 서비스 개발 및 운영",
"requirements": "Java/Spring 기반 개발 경험",
"preferredQualifications": "대용량 트래픽 처리 경험",
"rawText": "채용 공고 원문 내용",
"confidence": 0.92
},
"candidates": [
{
"detailClassificationId": 101,
"detailClassificationName": "Java/Spring",
"middleClassificationName": "백엔드",
"bigClassificationName": "개발",
"score": 0.91
}
],
"classification": {
"detailClassificationId": 101,
"detailClassificationName": "Java/Spring",
"middleClassificationName": "백엔드",
"bigClassificationName": "개발",
"reason": "Spring Boot, JPA, API 개발 맥락이 가장 강합니다.",
"confidence": 0.87
},
"generated": {
"companyName": "삼성전자",
"jobTitle": "Java/Spring 백엔드 개발자",
"task": "백엔드 서비스 개발 및 운영\\nAPI 설계 및 성능 개선",
"requirements": "Java/Spring 기반 개발 경험\\nRDB 사용 경험",
"preferredQualifications": "대용량 트래픽 처리 경험\\nRedis 사용 경험",
"summary": "서비스 백엔드 개발과 운영을 담당할 인재를 찾습니다."
},
"saved": {
"jobPostingId": 10,
"companyId": 3,
"companyName": "삼성전자",
"companySize": "ENTERPRISE",
"detailClassificationId": 101,
"detailClassificationName": "Java/Spring",
"task": "백엔드 서비스 개발 및 운영\\nAPI 설계 및 성능 개선",
"requirement": "Java/Spring 기반 개발 경험\\nRDB 사용 경험",
"preferred": "대용량 트래픽 처리 경험\\nRedis 사용 경험"
}
},
"error": null
}
""")
)
),
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "200",
description = "분류 confidence가 낮아 저장을 보류한 경우",
content = @Content(
mediaType = "application/json",
schema = @Schema(implementation = ApiResponse.class),
examples = @ExampleObject(value = """
{
"isSuccess": true,
"code": "COMMON2000",
"message": "채용 공고 추출 및 저장에 성공했습니다.",
"result": {
"savedToDatabase": false,
"message": "소분류 분류 confidence가 낮아 저장을 보류했습니다.",
"extracted": {
"companyName": "어떤회사",
"jobTitle": "개발자",
"task": "서비스 개발",
"requirements": "개발 경험",
"preferredQualifications": "우대 사항",
"rawText": "채용 공고 원문 내용",
"confidence": 0.79
},
"candidates": [
{
"detailClassificationId": 101,
"detailClassificationName": "Java/Spring",
"middleClassificationName": "백엔드",
"bigClassificationName": "개발",
"score": 0.62
},
{
"detailClassificationId": 102,
"detailClassificationName": "Node.js",
"middleClassificationName": "백엔드",
"bigClassificationName": "개발",
"score": 0.58
}
],
"classification": {
"detailClassificationId": 101,
"detailClassificationName": "Java/Spring",
"middleClassificationName": "백엔드",
"bigClassificationName": "개발",
"reason": "후보 간 차이가 크지 않습니다.",
"confidence": 0.49
},
"generated": null,
"saved": null
},
"error": null
}
""")
)
)
})
@PostMapping(value = "/ingest", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ApiResponse<JobPostingIngestResponse> ingestJobPosting(
@ModelAttribute JobPostingIngestMultipartRequest request
) {
return ApiResponse.onSuccess(
"채용 공고 추출 및 저장에 성공했습니다.",
jobPostingIngestService.ingestAndCreate(request)
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
package com.jobdri.jobdri_api.domain.jobposting.controller;

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.JobPostingUpdateRequest;
import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingGenerateResponse;
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;
import com.jobdri.jobdri_api.global.apiPayload.ApiResponse;
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.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;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.PutMapping;

import java.util.List;

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/job-postings")
@Tag(name = "JobPosting", description = "채용 공고 생성/저장/조회 API")
public class JobPostingController {

private final JobPostingAiService jobPostingAiService;
private final JobPostingService jobPostingService;

@Operation(summary = "채용 공고 초안 생성", description = "회사 정보와 직무 정보를 바탕으로 AI가 공고 본문 초안을 생성합니다.")
@PostMapping("/generate")
public ApiResponse<JobPostingGenerateResponse> generateJobPosting(
@Valid @RequestBody JobPostingGenerateRequest request
) {
return ApiResponse.onSuccess(
"채용 공고 초안 생성에 성공했습니다.",
jobPostingAiService.generateJobPosting(request)
);
}

@Operation(summary = "채용 공고 저장", description = "생성되었거나 직접 작성한 채용 공고를 DB에 저장합니다.")
@PostMapping
public ApiResponse<JobPostingResponse> createJobPosting(
@Valid @RequestBody JobPostingCreateRequest request
) {
return ApiResponse.onSuccess(
"채용 공고 저장에 성공했습니다.",
jobPostingService.createJobPosting(request)
);
}

@Operation(summary = "채용 공고 수정", description = "기존 채용 공고를 수정합니다. 회사명이 없으면 회사를 새로 생성합니다.")
@PutMapping("/{jobPostingId}")
public ApiResponse<JobPostingResponse> updateJobPosting(
@PathVariable Long jobPostingId,
@Valid @RequestBody JobPostingUpdateRequest request
) {
return ApiResponse.onSuccess(
"채용 공고 수정에 성공했습니다.",
jobPostingService.updateJobPosting(jobPostingId, request)
);
}

@Operation(summary = "채용 공고 단건 조회", description = "채용 공고 ID로 단건 조회합니다.")
@GetMapping("/{jobPostingId}")
public ApiResponse<JobPostingResponse> getJobPosting(@PathVariable Long jobPostingId) {
return ApiResponse.onSuccess(
"채용 공고 조회에 성공했습니다.",
jobPostingService.getJobPosting(jobPostingId)
);
}

@Operation(summary = "채용 공고 목록 조회", description = "전체 공고 또는 회사별 공고 목록을 조회합니다.")
@GetMapping
public ApiResponse<List<JobPostingResponse>> getJobPostings(
@RequestParam(required = false) Long companyId
) {
List<JobPostingResponse> result = companyId == null
? jobPostingService.getAllJobPostings()
: jobPostingService.getJobPostingsByCompany(companyId);

return ApiResponse.onSuccess("채용 공고 목록 조회에 성공했습니다.", result);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package com.jobdri.jobdri_api.domain.jobposting.dto.request;

import com.jobdri.jobdri_api.domain.company.entity.CompanySize;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;

public record JobPostingCreateRequest(
@NotBlank(message = "회사명은 필수입니다.")
String companyName,

@NotNull(message = "회사 규모는 필수입니다.")
CompanySize companySize,

@NotNull(message = "소분류 ID는 필수입니다.")
Long detailClassificationId,

@NotBlank(message = "주요 업무는 필수입니다.")
String task,

@NotBlank(message = "자격 요건은 필수입니다.")
String requirement,

@NotBlank(message = "우대 사항은 필수입니다.")
String preferred
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package com.jobdri.jobdri_api.domain.jobposting.dto.request;

import com.jobdri.jobdri_api.domain.company.entity.CompanySize;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;

public record JobPostingGenerateRequest(
@NotBlank(message = "회사명은 필수입니다.")
String companyName,

@NotNull(message = "회사 규모는 필수입니다.")
CompanySize companySize,

@NotNull(message = "소분류 ID는 필수입니다.")
Long detailClassificationId,

String hiringSummary,
String techStack,
String mainResponsibilities,
String requirements,
String preferredQualifications,
String tone,
String jobTitleHint
) {
}
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.Getter;
import lombok.Setter;
import org.springframework.web.multipart.MultipartFile;

@Getter
@Setter
public class JobPostingIngestMultipartRequest {

private String rawText;
private String sourceUrl;
private MultipartFile image;
private CompanySize companySize;
private String tone;
private Integer candidateLimit;
}
Loading
Loading