Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 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
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -46,4 +46,4 @@ application-prod.yml
!.env.example

## Cluade ##
.claude/
.cluade/
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
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);
Comment thread
jihoonkim501 marked this conversation as resolved.
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -16,15 +16,15 @@
public record CardResponse(
Long cardId,
Long jobId,
LocalDateTime postedAt, // 공고 올라온 날짜
OffsetDateTime postedAt, // 공고 올라온 날짜
String jobTitle, // 공고명
String reason, // 추천 이유
BigDecimal fitScore, // 적합도
List<String> techStack, // 기술 스택
String jobScope, // 공고 범위 (ex. 클라우드 데이터 플랫폼)
String companyLocation, // 회사 위치
List<String> tags, // 태그 (ex. 신입, 서울 강남, 연봉 비공개)
LocalDateTime deadlineAt, // 마감 일자 (NULL이면 상시)
OffsetDateTime deadlineAt, // 마감 일자 (NULL이면 상시)
String summary, // 한눈에 요약
Integer position,
DeckItemStatus status
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
import lombok.NoArgsConstructor;

import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.OffsetDateTime;

/**
* 사용자별 일일 추천 덱 헤더
Expand Down Expand Up @@ -41,24 +41,24 @@ 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) {
this.user = user;
this.deckDate = deckDate;
}
Comment thread
jihoonkim501 marked this conversation as resolved.

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;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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> deck = deckRepository.findByUserIdAndDeckDate(userId, today);
Expand All @@ -78,7 +81,7 @@ public List<CardResponse> 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());
Expand Down
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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/java

Repository: 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


동시 최초 요청의 충돌을 처리하세요. orElseGet(save(...))는 원자적이지 않아서, 두 요청이 동시에 들어오면 한쪽이 uk_decks_user_date 제약 위반으로 실패할 수 있습니다. 충돌 시 기존 Deck을 다시 조회하도록 처리하세요.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/main/java/com/leets7th/job_is_be/domain/deck/service/RecommendationService.java`
around lines 49 - 50, Update the deck lookup/creation flow in
RecommendationService to handle concurrent first requests: if saving the new
Deck fails due to the uk_decks_user_date unique constraint, catch that conflict
and re-query deckRepository.findByUserIdAndDeckDate(userId, today) to use the
existing Deck. Preserve the current lookup and creation behavior when no
concurrent conflict occurs.


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 "경력무관";
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;

import java.time.LocalDateTime;
import java.time.OffsetDateTime;

// 기업 정보. 크롤러(원티드+잡코리아 메타)가 이 테이블을 직접 적재/갱신하므로 앱은 읽기 전용으로만 사용
@Entity
Expand Down Expand Up @@ -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")
Expand All @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
import lombok.Getter;
import lombok.NoArgsConstructor;

import java.time.LocalDateTime;
import java.time.OffsetDateTime;

/**
* 채용 공고
Expand Down Expand Up @@ -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)
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
Loading