-
Notifications
You must be signed in to change notification settings - Fork 0
[Feat] 카드(공고), 덱(공고 모음) 크롤링 적용 기능 구현 #18
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 11 commits
99a4dcc
a54417c
c83d8e8
f503503
0c89aff
801a501
bd3fbf0
cac7c39
a41a7ec
4bb1289
381d5cf
6489036
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -46,4 +46,4 @@ application-prod.yml | |
| !.env.example | ||
|
|
||
| ## Cluade ## | ||
| .claude/ | ||
| .cluade/ | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<ApiResponse<List<CardResponse>>> getDeckCards( | ||
| //TODO 인증관련 세팅 후 요청자가 해당 deckId의 소유자인지 검증 | ||
| @PathVariable Long deckId | ||
| ) { | ||
| List<CardResponse> response = cardService.getDeckCards(deckId); | ||
| return ApiResponse.success(SuccessStatus.DECK_CARDS_SUCCESS, response); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<ApiResponse<List<CardResponse>>> generateTodayDeck( | ||
| //TODO 인증관련 세팅 후 userDetail 또는 Authentication 객체에서 userId 가져오기 | ||
| @PathVariable Long userId | ||
| ) { | ||
| List<CardResponse> response = recommendationService.generateTodayDeck(userId); | ||
| return ApiResponse.success(SuccessStatus.DECK_GENERATE_SUCCESS, response); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<CardResponse> 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())); | ||
|
Comment on lines
+49
to
+50
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
deck_file="$(fd -a -t f '^Deck\.java$' src/main/java | head -n 1)"
test -n "$deck_file"
ast-grep outline "$deck_file" --items all
rg -n -C3 'uniqueConstraints|UniqueConstraint|deckDate|findByUserIdAndDeckDate' src/main/javaRepository: Leets-Official/Job-is-BE Length of output: 10415 🏁 Script executed: #!/bin/bash
sed -n '1,220p' src/main/java/com/leets7th/job_is_be/domain/deck/service/RecommendationService.java
printf '\n---\n'
sed -n '1,140p' src/main/java/com/leets7th/job_is_be/domain/deck/entity/Deck.java
printf '\n---\n'
rg -n -C2 'DataIntegrityViolationException|ConstraintViolationException|Duplicate|retry|`@Transactional`' src/main/java/com/leets7th/job_is_be | sed -n '1,220p'Repository: Leets-Official/Job-is-BE Length of output: 7396 동시 최초 요청의 충돌을 처리하세요. 🤖 Prompt for AI Agents |
||
|
|
||
| if (cardRepository.findByDeckId(deck.getId()).isEmpty()) { | ||
| fillWithPlaceholderCards(deck); | ||
| } | ||
|
|
||
| return cardService.getDeckCards(deck.getId()); | ||
| } | ||
|
|
||
| private void fillWithPlaceholderCards(Deck deck) { | ||
| List<Job> jobs = jobRepository.findApplicableJobs( | ||
| JobStatus.ACTIVE, OffsetDateTime.now(), PageRequest.of(0, DECK_SIZE)); | ||
|
|
||
| List<Card> 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); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 "경력무관"; | ||
| } | ||
| } |
Uh oh!
There was an error while loading. Please reload this page.