Skip to content
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
e9658df
[Feat] #24 dto 작성
sky-0131 Jul 20, 2026
f5a3ea7
[Feat] feat/#24 repository
sky-0131 Jul 21, 2026
20d5ef3
[Feat] #24 QueryDSL 설정 및 레포지토리 구현 추가
sky-0131 Jul 21, 2026
bde24dc
[Feat] #24 JobService, JobPostingConverter에 탐색, 검색, 상세 조회 로직 추가
sky-0131 Jul 22, 2026
cf82ee6
[Feat] #24 JobController에 탐색, 검색, 상세 조회 앤드포인트 추가
sky-0131 Jul 22, 2026
be4f71b
Merge branch 'develop' into feat/#24
sky-0131 Jul 23, 2026
2d8e97b
[Fix] #24 Merge 충돌 해결
sky-0131 Jul 23, 2026
b32457e
[Feat] #24 service 수정 및 테스트를 위한 db 자동 연결, 기술 스택 enum 임시 생성
sky-0131 Jul 25, 2026
a34779a
[Fix] #24 스킬 태그 검색 조건에 매핑 적용
sky-0131 Jul 25, 2026
b26c076
[Fix] #24 page 0 검색 오류 해결
sky-0131 Jul 25, 2026
c2d11fa
[Fix] #24 검색, 탐색 오류 수정
sky-0131 Jul 26, 2026
b218646
[Fix] #24 API 앤드포인트 경로
sky-0131 Jul 26, 2026
950d0c2
[Fix] 공고 상세 조회 Swagger 설명 수정
sky-0131 Jul 26, 2026
9d59ae5
[Refactor] #24 공고 조회 API 표준 응답 포맷 적용, SuccessStatus 추가
sky-0131 Jul 26, 2026
3f567d8
[Refactor] #24 API 공통 응답 포맷 적용 추가 및 과부하 방지 page 개수 고정
sky-0131 Jul 26, 2026
a2ff3e9
[Fix] #24 JobDetailResponse 빌더 내 누락된 필드 매핑 추가
sky-0131 Jul 26, 2026
ec93c23
[Fix] #24 URL 매핑 누락, QueryDSL 페이징 정렬, 초기 데이터 적재 예외 처리 해결
sky-0131 Jul 27, 2026
71f629f
[Fix] #24 전역 ObjectMapper 오염 방지
sky-0131 Jul 27, 2026
5dfcc3c
[Fix] #24 초기 데이터 적재 시, 필수 필드 검증 및 동시 적재 예외처리
sky-0131 Jul 27, 2026
c23974d
[Fix] #24 충돌 해결
sky-0131 Jul 27, 2026
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
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,8 @@ application-prod.yml
## Claude ##
.claude/
.DS_Store


## Python ##
__pycache__/
*.pyc
13 changes: 10 additions & 3 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,6 @@ repositories {

dependencies {

implementation platform('software.amazon.awssdk:bom:2.48.4')

implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server'
Expand All @@ -38,7 +36,9 @@ dependencies {
implementation 'org.postgresql:postgresql'

// AWS S3 (Presigned URL 업로드)
implementation 'software.amazon.awssdk:s3'
implementation 'software.amazon.awssdk:s3:2.25.0'
implementation 'org.springframework.boot:spring-boot-starter-cache'
implementation 'org.springframework.boot:spring-boot-starter-data-redis'

implementation 'org.springframework.boot:spring-boot-starter-cache'
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
Expand All @@ -50,8 +50,15 @@ dependencies {
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
testAnnotationProcessor 'org.projectlombok:lombok'

// QueryDSL 설정
implementation 'com.querydsl:querydsl-jpa:5.1.0:jakarta'
annotationProcessor "com.querydsl:querydsl-apt:5.1.0:jakarta"
annotationProcessor "jakarta.annotation:jakarta.annotation-api"
annotationProcessor "jakarta.persistence:jakarta.persistence-api"

// JSONL 파일 파싱을 위해 사용한 ObjectMapper용 Jackson 라이브러리
implementation 'org.springframework.boot:spring-boot-starter-web'

}

tasks.named('test') {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.data.web.config.EnableSpringDataWebSupport;
import org.springframework.scheduling.annotation.EnableScheduling;

@EnableSpringDataWebSupport(pageSerializationMode = EnableSpringDataWebSupport.PageSerializationMode.VIA_DTO)
@SpringBootApplication
@EnableCaching
@EnableScheduling
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,20 @@
package com.leets7th.job_is_be.domain.job.controller;

import com.leets7th.job_is_be.domain.job.dto.JobDetailResponse;
import com.leets7th.job_is_be.domain.job.dto.JobSearchRequest;
import com.leets7th.job_is_be.domain.job.dto.JobSummaryResponse;
import com.leets7th.job_is_be.domain.job.service.JobService;
import com.leets7th.job_is_be.global.response.ApiResponse;
import com.leets7th.job_is_be.global.status.SuccessStatus;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springdoc.core.annotations.ParameterObject;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.data.web.PageableDefault;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

Expand Down Expand Up @@ -33,4 +44,26 @@ public ResponseEntity<ApiResponse<Void>> unsaveJob(
jobService.unsaveJob(userId, jobId);
return ApiResponse.success(SuccessStatus.JOB_UNSAVE_SUCCESS);
}

// 공고 탐색 및 검색
@Operation(summary = "공고 탐색 및 검색", description = "필터 조건(직군, 지역, 경력 등)과 키워드를 기반으로 공고 목록을 페이징 조회합니다.")
@GetMapping("/search")
public ResponseEntity<Page<JobSummaryResponse>> searchJobs(
@Valid @ModelAttribute @ParameterObject JobSearchRequest condition,
@PageableDefault(page = 0, size = 24, sort = {"createdAt", "id"}, direction = Sort.Direction.DESC)
@ParameterObject Pageable pageable
Comment thread
coderabbitai[bot] marked this conversation as resolved.
) {
Page<JobSummaryResponse> response = jobService.searchJobs(condition, pageable);
return ResponseEntity.ok(response);
}

//공고 상세 조회
@Operation(summary = "공고 상세 조회", description = "공고 ID를 통해 특정 공고의 상세 정보를 조회합니다.")
@GetMapping("/{jobId}")
public ResponseEntity<JobDetailResponse> getJobDetail(
@Parameter(description = "공고 ID") @PathVariable Long jobId
) {
JobDetailResponse response = jobService.getJobDetail(jobId);
return ResponseEntity.ok(response);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
import com.leets7th.job_is_be.domain.job.dto.CareerLevelResponse;
import com.leets7th.job_is_be.domain.job.dto.JobCategoryResponse;
import com.leets7th.job_is_be.domain.job.dto.RegionResponse;
import com.leets7th.job_is_be.domain.job.dto.TechStackResponse;
import com.leets7th.job_is_be.domain.job.enums.TechStackType;
import com.leets7th.job_is_be.domain.job.service.MetadataQueryService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
Expand All @@ -12,6 +14,7 @@
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.Arrays;
import java.util.List;

@Tag(name = "Metadata", description = "지역, 직무, 경력 수준 카테고리 메타데이터 조회 API")
Expand Down Expand Up @@ -41,4 +44,13 @@ public ResponseEntity<List<JobCategoryResponse>> getAllJobCategories() {
public ResponseEntity<List<CareerLevelResponse>> getAllCareerLevels() {
return ResponseEntity.ok(metadataQueryService.getAllCareerLevels());
}

@Operation(summary = "기술 스택 목록 조회", description = "시스템에 정의된 전체 기술 스택 메타데이터 목록을 조회합니다.")
@GetMapping("/tech-stacks")
public ResponseEntity<List<TechStackResponse>> getAllTechStacks() {
List<TechStackResponse> response = Arrays.stream(TechStackType.values())
.map(TechStackResponse::from)
.toList();
return ResponseEntity.ok(response);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package com.leets7th.job_is_be.domain.job.converter;

import com.leets7th.job_is_be.domain.job.dto.JobDetailResponse;
import com.leets7th.job_is_be.domain.job.dto.JobSummaryResponse;
import com.leets7th.job_is_be.domain.job.entity.Job;
import com.leets7th.job_is_be.domain.job.entity.JobPosting;
import com.leets7th.job_is_be.domain.job.enums.JobStatus;
Expand Down Expand Up @@ -75,4 +77,22 @@ private String resolveCareerLevel(JobPosting posting) {
}
return "경력무관";
}

public JobSummaryResponse toSummaryResponse(Job job) {
return JobSummaryResponse.builder()
.id(job.getId())
.companyName(job.getCompany() != null ? job.getCompany().getName() : null)
.position(job.getTitle())
.careerLevel(job.getCareerLevel())
.employmentType(job.getEmploymentType())
.remoteAvailable(job.isRemoteAvailable())
.dueTime(job.getDeadlineAt())
.thumbnailUrl(null)
.skillTags(job.getSkillTags())
.build();
}

public JobDetailResponse toDetailResponse(Job job) {
return JobDetailResponse.from(job);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package com.leets7th.job_is_be.domain.job.dto;

import com.leets7th.job_is_be.domain.job.entity.Job;
import lombok.Builder;
import java.time.OffsetDateTime;
import java.util.List;

@Builder
public record JobDetailResponse (
Long id,
String companyName,
String position,
String careerLevel,
String employmentType,
boolean remoteAvailable,
String sourceUrl,
OffsetDateTime dueTime,

String intro,
String mainTasks,
String requirements,
String preferredPoints,
String benefits,

Integer employeeCount,
String companyType,
String industry,
String stockStatus,

List<String> skillTags,
String locationFull
) {
public static JobDetailResponse from(Job job) {
return JobDetailResponse.builder()
.id(job.getId())
.companyName(job.getCompany() != null ? job.getCompany().getName() : null)
.position(job.getTitle())
.careerLevel(job.getCareerLevel())
.employmentType(job.getEmploymentType())
.remoteAvailable(job.isRemoteAvailable())
.sourceUrl(job.getSourceUrl())
.dueTime(job.getDeadlineAt())
.mainTasks(job.getMainTasks())
.requirements(job.getRequirements())
.preferredPoints(job.getPreferredPoints())
.skillTags(job.getSkillTags())
.locationFull(job.getLocationFull())
.build();
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package com.leets7th.job_is_be.domain.job.dto;

import com.leets7th.job_is_be.domain.job.enums.TechStackType;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Builder;

import java.util.List;

@Builder
public record JobSearchRequest(
String keyword,
String categoryChild,

@Schema(description = "스킬 태그 목록")
List<TechStackType> skillTags,

@Schema(description = "지역 목록", allowableValues = {"서울", "경기", "인천", "부산"})
List<String> regions
// ex) 최신 등록순: createdAt,desc
// ex) 마감일 임박순 deadlineAt, asc
) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package com.leets7th.job_is_be.domain.job.dto;

import com.leets7th.job_is_be.domain.job.entity.Job;
import com.leets7th.job_is_be.domain.job.enums.TechStackType;
import lombok.Builder;
import java.time.OffsetDateTime;
import java.util.List;

@Builder
public record JobSummaryResponse(
Long id,
String companyName,
String position,
String careerLevel,
String employmentType,
boolean remoteAvailable,
OffsetDateTime dueTime,
String thumbnailUrl,
List<String> skillTags
) {
public static JobSummaryResponse from(Job job) {
// 이미 List<String>이므로 별도 스트림 변환 없이 그대로 할당 가능
List<String> tags = job.getSkillTags() != null ? job.getSkillTags() : List.of();

return JobSummaryResponse.builder()
.id(job.getId())
.companyName(job.getCompany() != null ? job.getCompany().getName() : null)
.position(job.getTitle())
.careerLevel(job.getCareerLevel())
.employmentType(job.getEmploymentType())
.remoteAvailable(job.isRemoteAvailable())
.dueTime(job.getDeadlineAt())
.thumbnailUrl(null)
.skillTags(tags)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
.build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.leets7th.job_is_be.domain.job.dto;

import com.leets7th.job_is_be.domain.job.enums.TechStackType;

public record TechStackResponse(
String code,
String name
) {
public static TechStackResponse from(TechStackType type) {
return new TechStackResponse(type.name(), type.getValue());
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.leets7th.job_is_be.domain.job.entity;

import com.fasterxml.jackson.databind.JsonNode;
import com.leets7th.job_is_be.global.base.BaseEntity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
Expand Down Expand Up @@ -83,14 +84,14 @@ public class Company extends BaseEntity {

@JdbcTypeCode(SqlTypes.JSON)
@Column(name = "raw_jobkorea")
private String rawJobkorea;
private JsonNode rawJobkorea;

@Builder
public Company(String name, String normalizedName, String registrationNumber,
Long wantedCompanyId, Long jobkoreaGnoRef, Integer employeeCount, String companyType,
String industry, String stockStatus, String hqAddress, String homepage,
String description, String enrichmentStatus, Boolean nameMatch, String rejectedName,
OffsetDateTime enrichedAt, String rawJobkorea, String source, String logoUrl) {
OffsetDateTime enrichedAt, JsonNode rawJobkorea, String source, String logoUrl) {
this.name = name;
this.normalizedName = normalizedName;
this.registrationNumber = registrationNumber;
Expand Down Expand Up @@ -121,7 +122,6 @@ public Company(String name, String normalizedName, String registrationNumber,
this.description = description;
this.enrichmentStatus = enrichmentStatus;
this.rejectedName = rejectedName;
this.rawJobkorea = rawJobkorea;
this.source = source;
this.logoUrl = logoUrl;
}
Expand Down
14 changes: 12 additions & 2 deletions src/main/java/com/leets7th/job_is_be/domain/job/entity/Job.java
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package com.leets7th.job_is_be.domain.job.entity;

import com.fasterxml.jackson.annotation.JsonAlias;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.leets7th.job_is_be.domain.job.enums.JobStatus;
import com.leets7th.job_is_be.global.base.BaseEntity;
import jakarta.persistence.*;
Expand All @@ -17,7 +19,13 @@
* 채용 공고
*/
@Entity
@Table(name = "jobs", uniqueConstraints = @UniqueConstraint(name = "uk_jobs_source_external_id", columnNames = {"source", "external_id"}))
@Table(
name = "jobs",
uniqueConstraints = @UniqueConstraint(name = "uk_jobs_source_external_id", columnNames = {"source", "external_id"}),
indexes = {
@Index(name = "idx_created_at_id", columnList = "created_at DESC, id DESC")
}
)
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Job extends BaseEntity {
Expand All @@ -26,7 +34,7 @@ public class Job extends BaseEntity {
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@ManyToOne(fetch = FetchType.LAZY)
@ManyToOne(fetch = FetchType.LAZY, cascade = CascadeType.PERSIST)
@JoinColumn(name = "company_id")
private Company company;

Expand All @@ -38,6 +46,7 @@ public class Job extends BaseEntity {
@JoinColumn(name = "region_id")
private Region region;

@JsonAlias({"job_title", "position", "title"})
@Column(nullable = false, columnDefinition = "TEXT")
private String title;

Expand Down Expand Up @@ -102,6 +111,7 @@ public class Job extends BaseEntity {
@Column(name = "reward_total")
private String rewardTotal; // 추천 보상금

@JsonProperty("thumbnail_url")
@Column(name = "thumbnail_url", columnDefinition = "TEXT")
private String thumbnailUrl; // 카드 썸네일 URL

Expand Down
Loading