diff --git a/.gitignore b/.gitignore index fbaaae1..42e3958 100644 --- a/.gitignore +++ b/.gitignore @@ -46,4 +46,4 @@ application-prod.yml !.env.example ## Cluade ## -.claude/ \ No newline at end of file +.cluade/ diff --git a/src/main/java/com/leets7th/job_is_be/JobIsBeApplication.java b/src/main/java/com/leets7th/job_is_be/JobIsBeApplication.java index 49a27a3..9fdd743 100644 --- a/src/main/java/com/leets7th/job_is_be/JobIsBeApplication.java +++ b/src/main/java/com/leets7th/job_is_be/JobIsBeApplication.java @@ -3,9 +3,11 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.cache.annotation.EnableCaching; +import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication @EnableCaching +@EnableScheduling public class JobIsBeApplication { public static void main(String[] args) { diff --git a/src/main/java/com/leets7th/job_is_be/domain/deck/controller/CardController.java b/src/main/java/com/leets7th/job_is_be/domain/deck/controller/CardController.java new file mode 100644 index 0000000..4f2c60a --- /dev/null +++ b/src/main/java/com/leets7th/job_is_be/domain/deck/controller/CardController.java @@ -0,0 +1,31 @@ +package com.leets7th.job_is_be.domain.deck.controller; + +import com.leets7th.job_is_be.domain.deck.dto.CardResponse; +import com.leets7th.job_is_be.domain.deck.service.CardService; +import com.leets7th.job_is_be.global.response.ApiResponse; +import com.leets7th.job_is_be.global.status.SuccessStatus; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RestController +@RequestMapping("/api/decks") +@RequiredArgsConstructor +public class CardController { + + private final CardService cardService; + + @GetMapping("/{deckId}/cards") + public ResponseEntity>> getDeckCards( + //TODO 인증관련 세팅 후 요청자가 해당 deckId의 소유자인지 검증 + @PathVariable Long deckId + ) { + List response = cardService.getDeckCards(deckId); + return ApiResponse.success(SuccessStatus.DECK_CARDS_SUCCESS, response); + } +} diff --git a/src/main/java/com/leets7th/job_is_be/domain/deck/controller/DeckController.java b/src/main/java/com/leets7th/job_is_be/domain/deck/controller/DeckController.java new file mode 100644 index 0000000..bf265a3 --- /dev/null +++ b/src/main/java/com/leets7th/job_is_be/domain/deck/controller/DeckController.java @@ -0,0 +1,32 @@ +package com.leets7th.job_is_be.domain.deck.controller; + +import com.leets7th.job_is_be.domain.deck.dto.CardResponse; +import com.leets7th.job_is_be.domain.deck.service.RecommendationService; +import com.leets7th.job_is_be.global.response.ApiResponse; +import com.leets7th.job_is_be.global.status.SuccessStatus; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RestController +@RequestMapping("/api/decks") +@RequiredArgsConstructor +public class DeckController { + + private final RecommendationService recommendationService; + + // TODO: 실제 추천엔진 붙기 전까지 스웨거로 파이프라인을 수동 트리거해서 확인하기 위한 임시 엔드포인트 + @PostMapping("/generate/{userId}") + public ResponseEntity>> generateTodayDeck( + //TODO 인증관련 세팅 후 userDetail 또는 Authentication 객체에서 userId 가져오기 + @PathVariable Long userId + ) { + List response = recommendationService.generateTodayDeck(userId); + return ApiResponse.success(SuccessStatus.DECK_GENERATE_SUCCESS, response); + } +} diff --git a/src/main/java/com/leets7th/job_is_be/domain/deck/dto/CardResponse.java b/src/main/java/com/leets7th/job_is_be/domain/deck/dto/CardResponse.java index 9b8b804..46185b9 100644 --- a/src/main/java/com/leets7th/job_is_be/domain/deck/dto/CardResponse.java +++ b/src/main/java/com/leets7th/job_is_be/domain/deck/dto/CardResponse.java @@ -6,7 +6,7 @@ import com.leets7th.job_is_be.domain.job.entity.Job; import java.math.BigDecimal; -import java.time.LocalDateTime; +import java.time.OffsetDateTime; import java.util.ArrayList; import java.util.List; @@ -16,7 +16,7 @@ public record CardResponse( Long cardId, Long jobId, - LocalDateTime postedAt, // 공고 올라온 날짜 + OffsetDateTime postedAt, // 공고 올라온 날짜 String jobTitle, // 공고명 String reason, // 추천 이유 BigDecimal fitScore, // 적합도 @@ -24,7 +24,7 @@ public record CardResponse( String jobScope, // 공고 범위 (ex. 클라우드 데이터 플랫폼) String companyLocation, // 회사 위치 List tags, // 태그 (ex. 신입, 서울 강남, 연봉 비공개) - LocalDateTime deadlineAt, // 마감 일자 (NULL이면 상시) + OffsetDateTime deadlineAt, // 마감 일자 (NULL이면 상시) String summary, // 한눈에 요약 Integer position, DeckItemStatus status diff --git a/src/main/java/com/leets7th/job_is_be/domain/deck/entity/Deck.java b/src/main/java/com/leets7th/job_is_be/domain/deck/entity/Deck.java index c7a0695..982f017 100644 --- a/src/main/java/com/leets7th/job_is_be/domain/deck/entity/Deck.java +++ b/src/main/java/com/leets7th/job_is_be/domain/deck/entity/Deck.java @@ -9,7 +9,7 @@ import lombok.NoArgsConstructor; import java.time.LocalDate; -import java.time.LocalDateTime; +import java.time.OffsetDateTime; /** * 사용자별 일일 추천 덱 헤더 @@ -41,10 +41,10 @@ public class Deck extends BaseEntity { private String emptyReason; // no_candidates, onboarding_incomplete, pre_slot (REC-07) @Column(name = "first_opened_at") - private LocalDateTime firstOpenedAt; // 재방문 판별 기준 (REC-06) + private OffsetDateTime firstOpenedAt; // 재방문 판별 기준 (REC-06) @Column(name = "completed_at") - private LocalDateTime completedAt; // 전량 처리 완료 시점 (REC-05) + private OffsetDateTime completedAt; // 전량 처리 완료 시점 (REC-05) @Builder public Deck(User user, LocalDate deckDate) { @@ -52,13 +52,13 @@ public Deck(User user, LocalDate deckDate) { this.deckDate = deckDate; } - public void markFirstOpened(LocalDateTime now) { + public void markFirstOpened(OffsetDateTime now) { if (this.firstOpenedAt == null) { this.firstOpenedAt = now; } } - public void complete(LocalDateTime now) { + public void complete(OffsetDateTime now) { this.completedAt = now; } diff --git a/src/main/java/com/leets7th/job_is_be/domain/deck/service/BriefingService.java b/src/main/java/com/leets7th/job_is_be/domain/deck/service/BriefingService.java index 0a47e4e..5b233d3 100644 --- a/src/main/java/com/leets7th/job_is_be/domain/deck/service/BriefingService.java +++ b/src/main/java/com/leets7th/job_is_be/domain/deck/service/BriefingService.java @@ -17,8 +17,8 @@ import org.springframework.transaction.annotation.Transactional; import java.time.LocalDate; -import java.time.LocalDateTime; import java.time.LocalTime; +import java.time.OffsetDateTime; import java.util.Comparator; import java.util.List; import java.util.Map; @@ -52,12 +52,15 @@ public BriefingResponse getTodayBriefing(Long userId) { throw new GeneralException(ErrorStatus.USER_NOT_FOUND); } - LocalDate today = LocalDate.now(); - String slot = resolveSlot(LocalTime.now()); + + + OffsetDateTime now = OffsetDateTime.now(); + LocalDate today = now.toLocalDate(); + String slot = resolveSlot(now.toLocalTime()); String greeting = resolveGreeting(slot); // 지원가능 건수 — 마감되지 않은 전체 공고 수 조회 - long applicableCount = jobRepository.countApplicable(JobStatus.ACTIVE, LocalDateTime.now()); + long applicableCount = jobRepository.countApplicable(JobStatus.ACTIVE, now); // 오늘의 Deck 조회 Optional deck = deckRepository.findByUserIdAndDeckDate(userId, today); @@ -78,7 +81,7 @@ public List getTodayBriefingStatus(Long userId) { throw new GeneralException(ErrorStatus.USER_NOT_FOUND); } - Deck deck = deckRepository.findByUserIdAndDeckDate(userId, LocalDate.now()) + Deck deck = deckRepository.findByUserIdAndDeckDate(userId, OffsetDateTime.now().toLocalDate()) .orElseThrow(() -> new GeneralException(ErrorStatus.DECK_NOT_FOUND)); return cardService.getDeckCards(deck.getId()); diff --git a/src/main/java/com/leets7th/job_is_be/domain/deck/service/RecommendationService.java b/src/main/java/com/leets7th/job_is_be/domain/deck/service/RecommendationService.java new file mode 100644 index 0000000..20ac194 --- /dev/null +++ b/src/main/java/com/leets7th/job_is_be/domain/deck/service/RecommendationService.java @@ -0,0 +1,74 @@ +package com.leets7th.job_is_be.domain.deck.service; + +import com.leets7th.job_is_be.domain.deck.dto.CardResponse; +import com.leets7th.job_is_be.domain.deck.entity.Card; +import com.leets7th.job_is_be.domain.deck.entity.Deck; +import com.leets7th.job_is_be.domain.deck.repository.CardRepository; +import com.leets7th.job_is_be.domain.deck.repository.DeckRepository; +import com.leets7th.job_is_be.domain.job.entity.Job; +import com.leets7th.job_is_be.domain.job.enums.JobStatus; +import com.leets7th.job_is_be.domain.job.repository.JobRepository; +import com.leets7th.job_is_be.domain.user.entity.User; +import com.leets7th.job_is_be.domain.user.repository.UserRepository; +import com.leets7th.job_is_be.global.exception.GeneralException; +import com.leets7th.job_is_be.global.status.ErrorStatus; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.OffsetDateTime; +import java.util.ArrayList; +import java.util.List; + +/** + * 추천 파이프라인 — 오늘의 Deck을 만들고 Card로 채운다. + * TODO: 실제 추천엔진(recall/precision, 사용자 선호 매칭, fit score 산정)은 아직 없음. + * 그 전까지는 지원 가능한 최신 공고를 그대로 채우는 임시 로직으로 대체. + */ +@Service +@RequiredArgsConstructor +@Transactional +public class RecommendationService { + + private static final int DECK_SIZE = 5; + + private final UserRepository userRepository; + private final JobRepository jobRepository; + private final DeckRepository deckRepository; + private final CardRepository cardRepository; + private final CardService cardService; + + // 스웨거로 파이프라인 동작을 확인하기 위한 수동 트리거 — 오늘 덱이 없으면 생성하고, 있으면 그대로 반환 + public List generateTodayDeck(Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new GeneralException(ErrorStatus.USER_NOT_FOUND)); + + LocalDate today = OffsetDateTime.now().toLocalDate(); + Deck deck = deckRepository.findByUserIdAndDeckDate(userId, today) + .orElseGet(() -> deckRepository.save(Deck.builder().user(user).deckDate(today).build())); + + if (cardRepository.findByDeckId(deck.getId()).isEmpty()) { + fillWithPlaceholderCards(deck); + } + + return cardService.getDeckCards(deck.getId()); + } + + private void fillWithPlaceholderCards(Deck deck) { + List jobs = jobRepository.findApplicableJobs( + JobStatus.ACTIVE, OffsetDateTime.now(), PageRequest.of(0, DECK_SIZE)); + + List cards = new ArrayList<>(); + for (int i = 0; i < jobs.size(); i++) { + cards.add(Card.builder() + .deck(deck) + .job(jobs.get(i)) + .position(i + 1) + .reason("TODO: 추천엔진 연동 전 임시로 채워진 카드입니다") + .build()); + } + cardRepository.saveAll(cards); + } +} diff --git a/src/main/java/com/leets7th/job_is_be/domain/job/converter/JobPostingConverter.java b/src/main/java/com/leets7th/job_is_be/domain/job/converter/JobPostingConverter.java new file mode 100644 index 0000000..16f773b --- /dev/null +++ b/src/main/java/com/leets7th/job_is_be/domain/job/converter/JobPostingConverter.java @@ -0,0 +1,70 @@ +package com.leets7th.job_is_be.domain.job.converter; + +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; +import org.springframework.stereotype.Component; + +import java.time.OffsetDateTime; + +// job_postings 원문(JobPosting) -> 서빙용 Job 매핑. jobCategory/region은 마스터 데이터 정규화가 +// 별도로 필요해 이번 스코프에서는 채우지 않음 +@Component +public class JobPostingConverter { + + public Job createFrom(JobPosting posting) { + Job job = Job.builder() + .company(posting.getCompany()) + .title(posting.getPosition()) + .careerLevel(resolveCareerLevel(posting)) + .employmentType(posting.getEmploymentType()) + .remoteAvailable(Boolean.TRUE.equals(posting.getIsRemote())) + .salaryDisclosed(false) + .source(posting.getSource()) + .externalId(posting.getExternalId()) + .sourceUrl(posting.getSourceUrl()) + .postedAt(posting.getConfirmTime()) + .deadlineAt(posting.getDueTime()) + .build(); + applyStatus(job, resolveStatus(posting)); + return job; + } + + public void updateFrom(Job job, JobPosting posting) { + job.syncFrom(posting.getCompany(), posting.getPosition(), resolveCareerLevel(posting), + posting.getEmploymentType(), Boolean.TRUE.equals(posting.getIsRemote()), + posting.getSourceUrl(), posting.getConfirmTime(), posting.getDueTime(), + resolveStatus(posting)); + } + + private void applyStatus(Job job, JobStatus status) { + if (status == JobStatus.EXPIRED) { + job.expire(); + } else if (status == JobStatus.REMOVED) { + job.markRemoved(); + } + } + + private JobStatus resolveStatus(JobPosting posting) { + if (!"active".equalsIgnoreCase(posting.getStatus())) { + return JobStatus.REMOVED; + } + if (posting.getDueTime() != null && posting.getDueTime().isBefore(OffsetDateTime.now())) { + return JobStatus.EXPIRED; + } + return JobStatus.ACTIVE; + } + + private String resolveCareerLevel(JobPosting posting) { + if (Boolean.TRUE.equals(posting.getIsNewbie()) && posting.getCareerMin() == null) { + return "신입"; + } + if (posting.getCareerMin() != null && posting.getCareerMax() != null) { + return posting.getCareerMin() + "~" + posting.getCareerMax() + "년"; + } + if (posting.getCareerMin() != null) { + return posting.getCareerMin() + "년 이상"; + } + return "경력무관"; + } +} diff --git a/src/main/java/com/leets7th/job_is_be/domain/job/entity/Company.java b/src/main/java/com/leets7th/job_is_be/domain/job/entity/Company.java index d4f72b8..bf3998e 100644 --- a/src/main/java/com/leets7th/job_is_be/domain/job/entity/Company.java +++ b/src/main/java/com/leets7th/job_is_be/domain/job/entity/Company.java @@ -13,7 +13,7 @@ import org.hibernate.annotations.JdbcTypeCode; import org.hibernate.type.SqlTypes; -import java.time.LocalDateTime; +import java.time.OffsetDateTime; // 기업 정보. 크롤러(원티드+잡코리아 메타)가 이 테이블을 직접 적재/갱신하므로 앱은 읽기 전용으로만 사용 @Entity @@ -72,7 +72,7 @@ public class Company { private String rejectedName; @Column(name = "enriched_at") - private LocalDateTime enrichedAt; + private OffsetDateTime enrichedAt; @JdbcTypeCode(SqlTypes.JSON) @Column(name = "raw_jobkorea") @@ -83,7 +83,7 @@ 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, - LocalDateTime enrichedAt, String rawJobkorea) { + OffsetDateTime enrichedAt, String rawJobkorea) { this.name = name; this.normalizedName = normalizedName; this.registrationNumber = registrationNumber; diff --git a/src/main/java/com/leets7th/job_is_be/domain/job/entity/Job.java b/src/main/java/com/leets7th/job_is_be/domain/job/entity/Job.java index 5eb8ecf..741e682 100644 --- a/src/main/java/com/leets7th/job_is_be/domain/job/entity/Job.java +++ b/src/main/java/com/leets7th/job_is_be/domain/job/entity/Job.java @@ -9,7 +9,7 @@ import lombok.Getter; import lombok.NoArgsConstructor; -import java.time.LocalDateTime; +import java.time.OffsetDateTime; /** * 채용 공고 @@ -61,10 +61,10 @@ public class Job extends BaseEntity { private String sourceUrl; @Column(name = "posted_at") - private LocalDateTime postedAt; + private OffsetDateTime postedAt; @Column(name = "deadline_at") - private LocalDateTime deadlineAt; // NULL이면 "상시" 표시 (§4.4) + private OffsetDateTime deadlineAt; // NULL이면 "상시" 표시 (§4.4) @Enumerated(EnumType.STRING) @Column(nullable = false, length = 20) @@ -77,7 +77,7 @@ public class Job extends BaseEntity { public Job(Company company, JobCategory jobCategory, Region region, String title, String careerLevel, String employmentType, boolean remoteAvailable, boolean salaryDisclosed, String source, Long externalId, String sourceUrl, - LocalDateTime postedAt, LocalDateTime deadlineAt, String editorNote) { + OffsetDateTime postedAt, OffsetDateTime deadlineAt, String editorNote) { this.company = company; this.jobCategory = jobCategory; this.region = region; @@ -110,7 +110,7 @@ public boolean isOpenEnded() { // 크롤링 재수집 시 (source, externalId)로 매칭된 기존 공고에 최신 원문 내용을 반영 public void syncFrom(Company company, String title, String careerLevel, String employmentType, boolean remoteAvailable, String sourceUrl, - LocalDateTime postedAt, LocalDateTime deadlineAt, JobStatus status) { + OffsetDateTime postedAt, OffsetDateTime deadlineAt, JobStatus status) { this.company = company; this.title = title; this.careerLevel = careerLevel; diff --git a/src/main/java/com/leets7th/job_is_be/domain/job/entity/JobPosting.java b/src/main/java/com/leets7th/job_is_be/domain/job/entity/JobPosting.java index 75b745c..05e0bbb 100644 --- a/src/main/java/com/leets7th/job_is_be/domain/job/entity/JobPosting.java +++ b/src/main/java/com/leets7th/job_is_be/domain/job/entity/JobPosting.java @@ -16,7 +16,7 @@ import org.hibernate.annotations.JdbcTypeCode; import org.hibernate.type.SqlTypes; -import java.time.LocalDateTime; +import java.time.OffsetDateTime; import java.util.List; // 원티드 공고 원문(크롤러 적재). 앱은 읽기 전용으로만 사용, jobs 테이블로의 동기화는 JobPostingConverter가 담당 @@ -101,10 +101,10 @@ public class JobPosting { private Boolean isRemote; @Column(name = "due_time") - private LocalDateTime dueTime; + private OffsetDateTime dueTime; @Column(name = "confirm_time") - private LocalDateTime confirmTime; + private OffsetDateTime confirmTime; @Column(length = 20) private String status; @@ -137,7 +137,7 @@ public class JobPosting { private String raw; @Column(name = "collected_at") - private LocalDateTime collectedAt; + private OffsetDateTime collectedAt; @Builder public JobPosting(String source, Long externalId, String sourceUrl, Company company, String position, @@ -145,10 +145,10 @@ public JobPosting(String source, Long externalId, String sourceUrl, Company comp String benefits, String categoryParent, List categoryChild, Integer careerMin, Integer careerMax, Boolean isNewbie, Boolean isExpert, String employmentType, String locationCountry, String locationCity, String locationDistrict, String locationFull, - Double geoLat, Double geoLng, Boolean isRemote, LocalDateTime dueTime, - LocalDateTime confirmTime, String status, String hireRounds, List skillTags, + Double geoLat, Double geoLng, Boolean isRemote, OffsetDateTime dueTime, + OffsetDateTime confirmTime, String status, String hireRounds, List skillTags, List skillTagIds, Boolean skillsInferred, String thumbnailUrl, - List imageUrls, String rewardTotal, String raw, LocalDateTime collectedAt) { + List imageUrls, String rewardTotal, String raw, OffsetDateTime collectedAt) { this.source = source; this.externalId = externalId; this.sourceUrl = sourceUrl; diff --git a/src/main/java/com/leets7th/job_is_be/domain/job/repository/JobPostingRepository.java b/src/main/java/com/leets7th/job_is_be/domain/job/repository/JobPostingRepository.java index 26af789..de772b0 100644 --- a/src/main/java/com/leets7th/job_is_be/domain/job/repository/JobPostingRepository.java +++ b/src/main/java/com/leets7th/job_is_be/domain/job/repository/JobPostingRepository.java @@ -1,15 +1,16 @@ package com.leets7th.job_is_be.domain.job.repository; import com.leets7th.job_is_be.domain.job.entity.JobPosting; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; import org.springframework.data.repository.Repository; -import java.util.List; import java.util.Optional; // 크롤러가 적재하는 job_postings 원문 테이블. 앱은 읽기만 하므로 JpaRepository 대신 Repository로 제한 public interface JobPostingRepository extends Repository { - List findAll(); + Slice findAll(Pageable pageable); // (source, externalId)로 Job과 매칭되는 원문 조회. 재수집으로 중복될 수 있어 최신 수집본 1건만 Optional findFirstBySourceAndExternalIdOrderByCollectedAtDesc(String source, Long externalId); diff --git a/src/main/java/com/leets7th/job_is_be/domain/job/repository/JobRepository.java b/src/main/java/com/leets7th/job_is_be/domain/job/repository/JobRepository.java index c6b6d37..9ebe2c6 100644 --- a/src/main/java/com/leets7th/job_is_be/domain/job/repository/JobRepository.java +++ b/src/main/java/com/leets7th/job_is_be/domain/job/repository/JobRepository.java @@ -7,7 +7,7 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; -import java.time.LocalDateTime; +import java.time.OffsetDateTime; import java.util.List; import java.util.Optional; @@ -16,13 +16,13 @@ public interface JobRepository extends JpaRepository { // 지원 가능 건수 — 마감되지 않은(상시 포함) ACTIVE 공고 수 @Query("SELECT COUNT(j) FROM Job j WHERE j.status = :status " + "AND (j.deadlineAt IS NULL OR j.deadlineAt > :now)") - long countApplicable(@Param("status") JobStatus status, @Param("now") LocalDateTime now); + long countApplicable(@Param("status") JobStatus status, @Param("now") OffsetDateTime now); // 지원 가능한 공고 중 최신순 N건 — 추천엔진 붙기 전 임시 후보 소싱용 @Query("SELECT j FROM Job j WHERE j.status = :status " + "AND (j.deadlineAt IS NULL OR j.deadlineAt > :now) " + "ORDER BY j.postedAt DESC") - List findApplicableJobs(@Param("status") JobStatus status, @Param("now") LocalDateTime now, Pageable pageable); + List findApplicableJobs(@Param("status") JobStatus status, @Param("now") OffsetDateTime now, Pageable pageable); Optional findBySourceAndExternalId(String source, Long externalId); diff --git a/src/main/java/com/leets7th/job_is_be/domain/job/service/JobPostingSyncService.java b/src/main/java/com/leets7th/job_is_be/domain/job/service/JobPostingSyncService.java new file mode 100644 index 0000000..9b62929 --- /dev/null +++ b/src/main/java/com/leets7th/job_is_be/domain/job/service/JobPostingSyncService.java @@ -0,0 +1,28 @@ +package com.leets7th.job_is_be.domain.job.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.event.EventListener; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class JobPostingSyncService { + + private final JobSyncPageProcessor pageProcessor; + + @EventListener(ApplicationReadyEvent.class) + public void syncOnStartup() { + sync(); + } + + // job_postings에는 동기화 여부를 나타내는 플래그가 없어, 매 실행마다 전체를 훑어 + // (source, externalId) 기준으로 upsert한다 + @Scheduled(cron = "0 0 * * * *") + public void sync() { + int page = 0; + while (pageProcessor.processPage(page++)) { + } + } +} diff --git a/src/main/java/com/leets7th/job_is_be/domain/job/service/JobSyncPageProcessor.java b/src/main/java/com/leets7th/job_is_be/domain/job/service/JobSyncPageProcessor.java new file mode 100644 index 0000000..bf817d8 --- /dev/null +++ b/src/main/java/com/leets7th/job_is_be/domain/job/service/JobSyncPageProcessor.java @@ -0,0 +1,35 @@ +package com.leets7th.job_is_be.domain.job.service; + +import com.leets7th.job_is_be.domain.job.converter.JobPostingConverter; +import com.leets7th.job_is_be.domain.job.entity.JobPosting; +import com.leets7th.job_is_be.domain.job.repository.JobPostingRepository; +import com.leets7th.job_is_be.domain.job.repository.JobRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Slice; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@Component +@RequiredArgsConstructor +public class JobSyncPageProcessor { + + static final int PAGE_SIZE = 200; + + private final JobPostingRepository jobPostingRepository; + private final JobRepository jobRepository; + private final JobPostingConverter converter; + + @Transactional + public boolean processPage(int page) { + Slice chunk = jobPostingRepository.findAll(PageRequest.of(page, PAGE_SIZE)); + for (JobPosting posting : chunk) { + jobRepository.findBySourceAndExternalId(posting.getSource(), posting.getExternalId()) + .ifPresentOrElse( + existing -> converter.updateFrom(existing, posting), + () -> jobRepository.save(converter.createFrom(posting)) + ); + } + return chunk.hasNext(); + } +} diff --git a/src/main/java/com/leets7th/job_is_be/domain/personality/entity/PersonalityTest.java b/src/main/java/com/leets7th/job_is_be/domain/personality/entity/PersonalityTest.java index c3620b2..b3dae28 100644 --- a/src/main/java/com/leets7th/job_is_be/domain/personality/entity/PersonalityTest.java +++ b/src/main/java/com/leets7th/job_is_be/domain/personality/entity/PersonalityTest.java @@ -8,7 +8,7 @@ import lombok.Getter; import lombok.NoArgsConstructor; -import java.time.LocalDateTime; +import java.time.OffsetDateTime; /** * 직무 성향 퀴즈/테스트 결과 @@ -37,20 +37,20 @@ public class PersonalityTest extends BaseEntity { private String resultTags; // 결과 태그(JSON/CSV), 추천 선호 태그 시드 @Column(name = "started_at", nullable = false) - private LocalDateTime startedAt; + private OffsetDateTime startedAt; @Column(name = "completed_at") - private LocalDateTime completedAt; + private OffsetDateTime completedAt; @Builder - public PersonalityTest(User user, String source, LocalDateTime startedAt) { + public PersonalityTest(User user, String source, OffsetDateTime startedAt) { this.user = user; this.source = source; this.startedAt = startedAt; this.completed = false; } - public void complete(String resultTags, LocalDateTime now) { + public void complete(String resultTags, OffsetDateTime now) { this.completed = true; this.resultTags = resultTags; this.completedAt = now; diff --git a/src/main/java/com/leets7th/job_is_be/global/base/BaseEntity.java b/src/main/java/com/leets7th/job_is_be/global/base/BaseEntity.java index 3de6647..92d5d46 100644 --- a/src/main/java/com/leets7th/job_is_be/global/base/BaseEntity.java +++ b/src/main/java/com/leets7th/job_is_be/global/base/BaseEntity.java @@ -8,7 +8,7 @@ import org.springframework.data.annotation.LastModifiedDate; import org.springframework.data.jpa.domain.support.AuditingEntityListener; -import java.time.LocalDateTime; +import java.time.OffsetDateTime; @EntityListeners(AuditingEntityListener.class) @MappedSuperclass @@ -17,10 +17,10 @@ public class BaseEntity { @CreatedDate @Column(name = "created_at", nullable = false) - private LocalDateTime createdAt; + private OffsetDateTime createdAt; @LastModifiedDate @Column(name = "updated_at", nullable = false) - private LocalDateTime updatedAt; + private OffsetDateTime updatedAt; } diff --git a/src/test/java/com/leets7th/job_is_be/domain/deck/service/CardServiceTest.java b/src/test/java/com/leets7th/job_is_be/domain/deck/service/CardServiceTest.java new file mode 100644 index 0000000..bb085f2 --- /dev/null +++ b/src/test/java/com/leets7th/job_is_be/domain/deck/service/CardServiceTest.java @@ -0,0 +1,83 @@ +package com.leets7th.job_is_be.domain.deck.service; + +import com.leets7th.job_is_be.domain.deck.dto.CardResponse; +import com.leets7th.job_is_be.domain.deck.entity.Card; +import com.leets7th.job_is_be.domain.deck.entity.Deck; +import com.leets7th.job_is_be.domain.deck.repository.CardRepository; +import com.leets7th.job_is_be.domain.deck.repository.DeckRepository; +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.repository.JobPostingRepository; +import com.leets7th.job_is_be.global.exception.GeneralException; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class CardServiceTest { + + @Mock + private DeckRepository deckRepository; + @Mock + private CardRepository cardRepository; + @Mock + private JobPostingRepository jobPostingRepository; + + @InjectMocks + private CardService cardService; + + @Test + void 덱이_없으면_예외를_던진다() { + when(deckRepository.existsById(1L)).thenReturn(false); + + assertThatThrownBy(() -> cardService.getDeckCards(1L)) + .isInstanceOf(GeneralException.class); + } + + @Test + void 덱의_카드_목록을_추천_이유와_함께_반환한다() { + when(deckRepository.existsById(1L)).thenReturn(true); + + Job job = Job.builder() + .title("백엔드 엔지니어 채용") + .careerLevel("신입") + .source("wanted") + .externalId(10L) + .build(); + Card card = Card.builder() + .job(job) + .position(1) + .fitScore(new BigDecimal("92.50")) + .reason("최근 관심 직무와 일치합니다") + .summary("백엔드, 3년 이상, 서울") + .build(); + when(cardRepository.findByDeckId(1L)).thenReturn(List.of(card)); + + JobPosting posting = JobPosting.builder() + .source("wanted") + .externalId(10L) + .skillTags(List.of("Java", "Spring")) + .build(); + when(jobPostingRepository.findFirstBySourceAndExternalIdOrderByCollectedAtDesc("wanted", 10L)) + .thenReturn(Optional.of(posting)); + + List response = cardService.getDeckCards(1L); + + assertThat(response).hasSize(1); + assertThat(response.get(0).jobTitle()).isEqualTo("백엔드 엔지니어 채용"); + assertThat(response.get(0).reason()).isEqualTo("최근 관심 직무와 일치합니다"); + assertThat(response.get(0).summary()).isEqualTo("백엔드, 3년 이상, 서울"); + assertThat(response.get(0).techStack()).containsExactly("Java", "Spring"); + assertThat(response.get(0).tags()).containsExactly("신입", "연봉 비공개"); + } +} diff --git a/src/test/java/com/leets7th/job_is_be/domain/deck/service/RecommendationServiceTest.java b/src/test/java/com/leets7th/job_is_be/domain/deck/service/RecommendationServiceTest.java new file mode 100644 index 0000000..e3d6404 --- /dev/null +++ b/src/test/java/com/leets7th/job_is_be/domain/deck/service/RecommendationServiceTest.java @@ -0,0 +1,107 @@ +package com.leets7th.job_is_be.domain.deck.service; + +import com.leets7th.job_is_be.domain.deck.dto.CardResponse; +import com.leets7th.job_is_be.domain.deck.entity.Card; +import com.leets7th.job_is_be.domain.deck.entity.Deck; +import com.leets7th.job_is_be.domain.deck.repository.CardRepository; +import com.leets7th.job_is_be.domain.deck.repository.DeckRepository; +import com.leets7th.job_is_be.domain.job.entity.Job; +import com.leets7th.job_is_be.domain.job.enums.JobStatus; +import com.leets7th.job_is_be.domain.job.repository.JobRepository; +import com.leets7th.job_is_be.domain.user.entity.User; +import com.leets7th.job_is_be.domain.user.enums.SocialType; +import com.leets7th.job_is_be.domain.user.repository.UserRepository; +import com.leets7th.job_is_be.global.exception.GeneralException; +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 java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +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.ArgumentMatchers.anyLong; +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 RecommendationServiceTest { + + @Mock + private UserRepository userRepository; + @Mock + private JobRepository jobRepository; + @Mock + private DeckRepository deckRepository; + @Mock + private CardRepository cardRepository; + @Mock + private CardService cardService; + + @InjectMocks + private RecommendationService recommendationService; + + @Test + void 사용자가_없으면_예외를_던진다() { + when(userRepository.findById(1L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> recommendationService.generateTodayDeck(1L)) + .isInstanceOf(GeneralException.class); + } + + @Test + void 오늘_덱이_없으면_새로_만들고_지원가능한_공고로_카드를_채운다() { + User user = User.builder().socialId("s").socialType(SocialType.KAKAO).email("a@a.com").build(); + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + + Deck savedDeck = Deck.builder().user(user).deckDate(LocalDate.now()).build(); + when(deckRepository.findByUserIdAndDeckDate(anyLong(), any(LocalDate.class))) + .thenReturn(Optional.empty()); + when(deckRepository.save(any(Deck.class))).thenReturn(savedDeck); + when(cardRepository.findByDeckId(any())).thenReturn(List.of()); + + Job job1 = Job.builder().title("백엔드 엔지니어").build(); + Job job2 = Job.builder().title("데이터 엔지니어").build(); + when(jobRepository.findApplicableJobs(eq(JobStatus.ACTIVE), any(), any())) + .thenReturn(List.of(job1, job2)); + + CardResponse response = new CardResponse(1L, 2L, null, "백엔드 엔지니어", "TODO", null, + List.of(), null, null, List.of(), null, null, 1, null); + when(cardService.getDeckCards(any())).thenReturn(List.of(response)); + + List result = recommendationService.generateTodayDeck(1L); + + ArgumentCaptor> cardsCaptor = ArgumentCaptor.forClass(List.class); + verify(cardRepository).saveAll(cardsCaptor.capture()); + assertThat(cardsCaptor.getValue()).hasSize(2); + assertThat(cardsCaptor.getValue().get(0).getPosition()).isEqualTo(1); + assertThat(result).containsExactly(response); + } + + @Test + void 오늘_덱에_이미_카드가_있으면_다시_채우지_않는다() { + User user = User.builder().socialId("s").socialType(SocialType.KAKAO).email("a@a.com").build(); + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + + Deck deck = Deck.builder().user(user).deckDate(LocalDate.now()).build(); + when(deckRepository.findByUserIdAndDeckDate(anyLong(), any(LocalDate.class))) + .thenReturn(Optional.of(deck)); + + Card existingCard = Card.builder().deck(deck).position(1).build(); + when(cardRepository.findByDeckId(any())).thenReturn(List.of(existingCard)); + when(cardService.getDeckCards(any())).thenReturn(List.of()); + + recommendationService.generateTodayDeck(1L); + + verify(jobRepository, never()).findApplicableJobs(any(), any(), any()); + verify(cardRepository, never()).saveAll(any()); + } +} diff --git a/src/test/java/com/leets7th/job_is_be/domain/job/converter/JobPostingConverterTest.java b/src/test/java/com/leets7th/job_is_be/domain/job/converter/JobPostingConverterTest.java new file mode 100644 index 0000000..bc88844 --- /dev/null +++ b/src/test/java/com/leets7th/job_is_be/domain/job/converter/JobPostingConverterTest.java @@ -0,0 +1,99 @@ +package com.leets7th.job_is_be.domain.job.converter; + +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; +import org.junit.jupiter.api.Test; + +import java.time.OffsetDateTime; + +import static org.assertj.core.api.Assertions.assertThat; + +class JobPostingConverterTest { + + private final JobPostingConverter converter = new JobPostingConverter(); + + @Test + void 원문_상태가_active면_ACTIVE로_변환된다() { + JobPosting posting = JobPosting.builder() + .source("wanted").externalId(1L).position("백엔드 엔지니어").status("active") + .build(); + + Job job = converter.createFrom(posting); + + assertThat(job.getStatus()).isEqualTo(JobStatus.ACTIVE); + } + + @Test + void 마감일이_지난_공고는_EXPIRED로_변환된다() { + JobPosting posting = JobPosting.builder() + .source("wanted").externalId(1L).position("백엔드 엔지니어").status("active") + .dueTime(OffsetDateTime.now().minusDays(1)) + .build(); + + Job job = converter.createFrom(posting); + + assertThat(job.getStatus()).isEqualTo(JobStatus.EXPIRED); + } + + @Test + void 원문_상태가_active가_아니면_REMOVED로_변환된다() { + JobPosting posting = JobPosting.builder() + .source("wanted").externalId(1L).position("백엔드 엔지니어").status("closed") + .build(); + + Job job = converter.createFrom(posting); + + assertThat(job.getStatus()).isEqualTo(JobStatus.REMOVED); + } + + @Test + void 신입만_가능하고_경력범위가_없으면_신입으로_표기한다() { + JobPosting posting = JobPosting.builder() + .source("wanted").externalId(1L).position("백엔드 엔지니어").status("active") + .isNewbie(true) + .build(); + + Job job = converter.createFrom(posting); + + assertThat(job.getCareerLevel()).isEqualTo("신입"); + } + + @Test + void 경력_최소최대가_있으면_범위로_표기한다() { + JobPosting posting = JobPosting.builder() + .source("wanted").externalId(1L).position("백엔드 엔지니어").status("active") + .careerMin(2).careerMax(5) + .build(); + + Job job = converter.createFrom(posting); + + assertThat(job.getCareerLevel()).isEqualTo("2~5년"); + } + + @Test + void 경력정보가_전혀_없으면_경력무관으로_표기한다() { + JobPosting posting = JobPosting.builder() + .source("wanted").externalId(1L).position("백엔드 엔지니어").status("active") + .build(); + + Job job = converter.createFrom(posting); + + assertThat(job.getCareerLevel()).isEqualTo("경력무관"); + } + + @Test + void updateFrom은_기존_Job의_필드를_최신_원문으로_갱신한다() { + Job existing = Job.builder().source("wanted").externalId(1L).title("old").build(); + JobPosting posting = JobPosting.builder() + .source("wanted").externalId(1L).position("new title").status("closed") + .isRemote(true) + .build(); + + converter.updateFrom(existing, posting); + + assertThat(existing.getTitle()).isEqualTo("new title"); + assertThat(existing.isRemoteAvailable()).isTrue(); + assertThat(existing.getStatus()).isEqualTo(JobStatus.REMOVED); + } +} diff --git a/src/test/java/com/leets7th/job_is_be/domain/job/service/JobPostingSyncServiceTest.java b/src/test/java/com/leets7th/job_is_be/domain/job/service/JobPostingSyncServiceTest.java new file mode 100644 index 0000000..978ceb3 --- /dev/null +++ b/src/test/java/com/leets7th/job_is_be/domain/job/service/JobPostingSyncServiceTest.java @@ -0,0 +1,46 @@ +package com.leets7th.job_is_be.domain.job.service; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class JobPostingSyncServiceTest { + + @Mock + private JobSyncPageProcessor pageProcessor; + + private JobPostingSyncService syncService; + + @BeforeEach + void setUp() { + syncService = new JobPostingSyncService(pageProcessor); + } + + @Test + void sync는_페이지가_없을_때까지_processPage를_반복_호출한다() { + when(pageProcessor.processPage(0)).thenReturn(true); + when(pageProcessor.processPage(1)).thenReturn(true); + when(pageProcessor.processPage(2)).thenReturn(false); + + syncService.sync(); + + verify(pageProcessor).processPage(0); + verify(pageProcessor).processPage(1); + verify(pageProcessor).processPage(2); + } + + @Test + void syncOnStartup은_sync를_호출한다() { + when(pageProcessor.processPage(0)).thenReturn(false); + + syncService.syncOnStartup(); + + verify(pageProcessor).processPage(0); + } +} diff --git a/src/test/java/com/leets7th/job_is_be/domain/job/service/JobSyncPageProcessorTest.java b/src/test/java/com/leets7th/job_is_be/domain/job/service/JobSyncPageProcessorTest.java new file mode 100644 index 0000000..75c2a8a --- /dev/null +++ b/src/test/java/com/leets7th/job_is_be/domain/job/service/JobSyncPageProcessorTest.java @@ -0,0 +1,98 @@ +package com.leets7th.job_is_be.domain.job.service; + +import com.leets7th.job_is_be.domain.job.converter.JobPostingConverter; +import com.leets7th.job_is_be.domain.job.entity.Company; +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.repository.JobPostingRepository; +import com.leets7th.job_is_be.domain.job.repository.JobRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.SliceImpl; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class JobSyncPageProcessorTest { + + @Mock + private JobPostingRepository jobPostingRepository; + @Mock + private JobRepository jobRepository; + + private JobSyncPageProcessor processor; + + @BeforeEach + void setUp() { + processor = new JobSyncPageProcessor(jobPostingRepository, jobRepository, new JobPostingConverter()); + } + + @Test + void 매칭되는_Job이_없으면_새로_생성해서_저장한다() { + Company company = Company.builder().name("래브라도랩스").build(); + JobPosting posting = JobPosting.builder() + .source("wanted").externalId(123L).sourceUrl("https://wanted.example/123") + .company(company).position("백엔드 엔지니어").employmentType("정규직") + .isRemote(false).status("active") + .build(); + + when(jobPostingRepository.findAll(any())) + .thenReturn(new SliceImpl<>(List.of(posting), PageRequest.of(0, 200), false)); + when(jobRepository.findBySourceAndExternalId("wanted", 123L)).thenReturn(Optional.empty()); + + processor.processPage(0); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Job.class); + verify(jobRepository).save(captor.capture()); + assertThat(captor.getValue().getTitle()).isEqualTo("백엔드 엔지니어"); + assertThat(captor.getValue().getExternalId()).isEqualTo(123L); + assertThat(captor.getValue().getCompany()).isEqualTo(company); + } + + @Test + void 매칭되는_Job이_있으면_저장_없이_기존_엔티티를_갱신한다() { + Job existing = Job.builder().source("wanted").externalId(123L).title("old title").build(); + JobPosting posting = JobPosting.builder() + .source("wanted").externalId(123L).position("new title").employmentType("정규직") + .isRemote(true).status("active") + .build(); + + when(jobPostingRepository.findAll(any())) + .thenReturn(new SliceImpl<>(List.of(posting), PageRequest.of(0, 200), false)); + when(jobRepository.findBySourceAndExternalId("wanted", 123L)).thenReturn(Optional.of(existing)); + + processor.processPage(0); + + verify(jobRepository, never()).save(any()); + assertThat(existing.getTitle()).isEqualTo("new title"); + assertThat(existing.isRemoteAvailable()).isTrue(); + } + + @Test + void 다음_페이지가_있으면_true를_반환한다() { + when(jobPostingRepository.findAll(any())) + .thenReturn(new SliceImpl<>(List.of(), PageRequest.of(0, 200), true)); + + assertThat(processor.processPage(0)).isTrue(); + } + + @Test + void 마지막_페이지면_false를_반환한다() { + when(jobPostingRepository.findAll(any())) + .thenReturn(new SliceImpl<>(List.of(), PageRequest.of(0, 200), false)); + + assertThat(processor.processPage(0)).isFalse(); + } +}