Skip to content
1 change: 1 addition & 0 deletions database/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ docker run -d --name jobis-pg -e POSTGRES_PASSWORD=jobis -e POSTGRES_USER=jobis
-e POSTGRES_DB=jobis -p 127.0.0.1:5432:5432 postgres:16

export DATABASE_URL="postgresql://jobis:jobis@127.0.0.1:5432/jobis"
# set DATABASE_URL=postgresql://postgres:1234@localhost:5432/jobisbe

# 2) 스키마 생성 + 적재 (한 번에)
python load.py --dir data --schema schema.sql --init
Expand Down
Binary file not shown.
Binary file not shown.
Binary file not shown.
Original file line number Diff line number Diff line change
@@ -1,17 +1,22 @@
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.dto.DismissReasonRequest;
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 jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.web.bind.annotation.DeleteMapping;
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.RestController;

import java.util.List;

@RestController
@RequestMapping("/api/decks")
Expand All @@ -20,12 +25,34 @@ public class CardController {

private final CardService cardService;

@GetMapping("/{deckId}/cards")
public ResponseEntity<ApiResponse<List<CardResponse>>> getDeckCards(
//TODO 인증관련 세팅 후 요청자가 해당 deckId의 소유자인지 검증
@PathVariable Long deckId
@PostMapping("/{deckId}/cards/{cardId}/dismiss")
public ResponseEntity<ApiResponse<CardResponse>> dismissCard(
@PathVariable Long deckId,
@PathVariable Long cardId,
@AuthenticationPrincipal Jwt jwt
) {
List<CardResponse> response = cardService.getDeckCards(deckId);
return ApiResponse.success(SuccessStatus.DECK_CARDS_SUCCESS, response);
CardResponse response = cardService.dismissCard(deckId, cardId, Long.valueOf(jwt.getSubject()));
return ApiResponse.success(SuccessStatus.CARD_DISMISS_SUCCESS, response);
}

@DeleteMapping("/{deckId}/cards/{cardId}/dismiss")
public ResponseEntity<ApiResponse<CardResponse>> cancelDismissCard(
@PathVariable Long deckId,
@PathVariable Long cardId,
@AuthenticationPrincipal Jwt jwt
) {
CardResponse response = cardService.undismissCard(deckId, cardId, Long.valueOf(jwt.getSubject()));
return ApiResponse.success(SuccessStatus.CARD_DISMISS_CANCEL_SUCCESS, response);
}

@PostMapping("/{deckId}/cards/{cardId}/dismiss-reason")
public ResponseEntity<ApiResponse<Void>> submitDismissReason(
@PathVariable Long deckId,
@PathVariable Long cardId,
@AuthenticationPrincipal Jwt jwt,
@Valid @RequestBody DismissReasonRequest request
) {
cardService.submitDismissReason(deckId, cardId, Long.valueOf(jwt.getSubject()), request);
return ApiResponse.success(SuccessStatus.CARD_DISMISS_REASON_SUCCESS);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.leets7th.job_is_be.domain.deck.dto;

import jakarta.validation.constraints.Size;

public record DismissReasonRequest(
@Size(max = 30)
String reason, // 직무불일치, 지역, 경력요건, 회사규모, 이미지원함, 기타 등 (선택)
@Size(max = 200)
String comment // 자유 코멘트, 최대 200자 (선택)
) {
Comment thread
jihoonkim501 marked this conversation as resolved.
}
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,12 @@ public class Card extends BaseEntity {
@Column(nullable = false, length = 20)
private DeckItemStatus status;

@Column(name = "reason_submitted", nullable = false)
private boolean reasonSubmitted; // 관심없음 사유 제출 여부 (DET-03), 관심없음 사이클마다 초기화
Comment thread
coderabbitai[bot] marked this conversation as resolved.

@Version
private Long version; // 동시 관심없음/사유제출 요청 시 상태 검사-전환을 원자적으로 보호하기 위한 낙관적 락

@Builder
public Card(Deck deck, Job job, Integer position, BigDecimal fitScore, String reason, String summary) {
this.deck = deck;
Expand All @@ -64,5 +70,15 @@ public void save() {

public void dismiss() {
this.status = DeckItemStatus.DISMISSED;
this.reasonSubmitted = false;
}

public void undismiss() {
this.status = DeckItemStatus.PENDING;
this.reasonSubmitted = false;
}

public void markReasonSubmitted() {
this.reasonSubmitted = true;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -35,16 +35,20 @@ public class UserAction extends BaseEntity {
@Column(name = "reason_code", length = 30)
private String reasonCode; // 관심없음 사유 (DET-03): 직무불일치 등

@Column(length = 200)
private String comment; // 관심없음 사유 자유 코멘트 (DET-03), 최대 200자

@Column(name = "source_screen", length = 20)
private String sourceScreen; // 이벤트 발생 화면 ID (REC-03, EXP-03 등)

@Builder
public UserAction(User user, Job job, ActionType actionType, String reasonCode,
String sourceScreen) {
String comment, String sourceScreen) {
this.user = user;
this.job = job;
this.actionType = actionType;
this.reasonCode = reasonCode;
this.comment = comment;
this.sourceScreen = sourceScreen;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.leets7th.job_is_be.domain.deck.repository;

import com.leets7th.job_is_be.domain.deck.entity.UserAction;
import org.springframework.data.jpa.repository.JpaRepository;

public interface UserActionRepository extends JpaRepository<UserAction, Long> {
}
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ public List<CardResponse> getTodayBriefingStatus(Long userId) {
Deck deck = deckRepository.findByUserIdAndDeckDate(userId, OffsetDateTime.now().toLocalDate())
.orElseThrow(() -> new GeneralException(ErrorStatus.DECK_NOT_FOUND));

return cardService.getDeckCards(deck.getId());
return cardService.getDeckCards(deck.getId(), userId);
}

// 시간대 별 인사문구 추출
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,21 @@
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.dto.DismissReasonRequest;
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.entity.UserAction;
import com.leets7th.job_is_be.domain.deck.enums.ActionType;
import com.leets7th.job_is_be.domain.deck.enums.DeckItemStatus;
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.deck.repository.UserActionRepository;
import com.leets7th.job_is_be.domain.job.entity.Job;
import com.leets7th.job_is_be.domain.job.repository.JobPostingRepository;
import com.leets7th.job_is_be.global.exception.GeneralException;
import com.leets7th.job_is_be.global.status.ErrorStatus;
import lombok.RequiredArgsConstructor;
import org.springframework.dao.OptimisticLockingFailureException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

Expand All @@ -21,11 +29,14 @@ public class CardService {
private final DeckRepository deckRepository;
private final CardRepository cardRepository;
private final JobPostingRepository jobPostingRepository;
private final UserActionRepository userActionRepository;

// 추천 파이프라인(recall/precision)이 채운 덱의 카드 목록을 그대로 조회
public List<CardResponse> getDeckCards(Long deckId) {
if (!deckRepository.existsById(deckId)) {
throw new GeneralException(ErrorStatus.DECK_NOT_FOUND);
public List<CardResponse> getDeckCards(Long deckId, Long userId) {
Deck deck = deckRepository.findById(deckId)
.orElseThrow(() -> new GeneralException(ErrorStatus.DECK_NOT_FOUND));
if (!deck.getUser().getId().equals(userId)) {
throw new GeneralException(ErrorStatus.FORBIDDEN);
}

return cardRepository.findByDeckId(deckId).stream()
Expand All @@ -43,4 +54,69 @@ private List<String> resolveTechStack(Job job) {
.map(posting -> posting.getSkillTags() != null ? posting.getSkillTags() : List.<String>of())
.orElse(List.of());
}

// 관심없음: 카드 상태를 DISMISSED로 전환하고 이벤트 로그를 남김
@Transactional
public CardResponse dismissCard(Long deckId, Long cardId, Long userId) {
Card card = getCardInDeck(deckId, cardId, userId);
if (card.getStatus() == DeckItemStatus.DISMISSED) {
throw new GeneralException(ErrorStatus.CARD_ALREADY_DISMISSED);
}
card.dismiss();
userActionRepository.save(UserAction.builder()
.user(card.getDeck().getUser())
.job(card.getJob())
.actionType(ActionType.DISMISSED)
.build());
Comment thread
jihoonkim501 marked this conversation as resolved.
return CardResponse.from(card, resolveTechStack(card.getJob()));
}

// 관심없음 취소: 카드 상태를 PENDING으로 되돌림 (1번과 토글 관계, 로그는 남기지 않음)
@Transactional
public CardResponse undismissCard(Long deckId, Long cardId, Long userId) {
Card card = getCardInDeck(deckId, cardId, userId);
if (card.getStatus() != DeckItemStatus.DISMISSED) {
throw new GeneralException(ErrorStatus.CARD_NOT_DISMISSED);
}
card.undismiss();
return CardResponse.from(card, resolveTechStack(card.getJob()));
}

// 관심없음 사유 제출: 카드 상태는 건드리지 않고 사유 이벤트 로그만 별도로 추가
@Transactional
public void submitDismissReason(Long deckId, Long cardId, Long userId, DismissReasonRequest request) {
Card card = getCardInDeck(deckId, cardId, userId);
if (card.getStatus() != DeckItemStatus.DISMISSED) {
throw new GeneralException(ErrorStatus.CARD_NOT_DISMISSED);
}
if (card.isReasonSubmitted()) {
throw new GeneralException(ErrorStatus.CARD_DISMISS_REASON_ALREADY_SUBMITTED);
}
userActionRepository.save(UserAction.builder()
.user(card.getDeck().getUser())
.job(card.getJob())
.actionType(ActionType.DISMISSED)
.reasonCode(request.reason())
.comment(request.comment())
.build());
try {
card.markReasonSubmitted();
cardRepository.saveAndFlush(card);
} catch (OptimisticLockingFailureException e) {
// 동시 요청이 먼저 사유를 제출해 version이 이미 바뀐 경우: 이미 제출된 것으로 간주
throw new GeneralException(ErrorStatus.CARD_DISMISS_REASON_ALREADY_SUBMITTED);
}
}

private Card getCardInDeck(Long deckId, Long cardId, Long userId) {
Card card = cardRepository.findById(cardId)
.orElseThrow(() -> new GeneralException(ErrorStatus.CARD_NOT_FOUND));
if (!card.getDeck().getId().equals(deckId)) {
throw new GeneralException(ErrorStatus.CARD_NOT_FOUND);
}
if (!card.getDeck().getUser().getId().equals(userId)) {
throw new GeneralException(ErrorStatus.FORBIDDEN);
}
return card;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ public List<CardResponse> generateTodayDeck(Long userId) {
fillWithPlaceholderCards(deck);
}

return cardService.getDeckCards(deck.getId());
return cardService.getDeckCards(deck.getId(), userId);
}

private void fillWithPlaceholderCards(Deck deck) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
package com.leets7th.job_is_be.global.config;

import java.time.OffsetDateTime;
import java.util.Optional;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.auditing.DateTimeProvider;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;

import java.time.OffsetDateTime;
import java.util.Optional;

// BaseEntity의 @CreatedDate, @LastModifiedDate 활성화
// createdAt/updatedAt이 OffsetDateTime이라 기본 DateTimeProvider(LocalDateTime 반환)로는 변환이 안 되어 직접 지정
@Configuration
@EnableJpaAuditing(dateTimeProviderRef = "offsetDateTimeProvider")
public class JpaConfig {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import com.leets7th.job_is_be.global.response.ApiResponse;
import com.leets7th.job_is_be.global.status.ErrorStatus;
import lombok.extern.slf4j.Slf4j;
import org.springframework.dao.OptimisticLockingFailureException;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.ResponseEntity;
Expand Down Expand Up @@ -39,6 +40,14 @@ public ResponseEntity<ApiResponse<Void>> handleIllegalArgumentException(
return ApiResponse.error(ErrorStatus.BAD_REQUEST, errorMessage);
}

@ExceptionHandler(OptimisticLockingFailureException.class)
public ResponseEntity<ApiResponse<Void>> handleOptimisticLockingFailureException(
OptimisticLockingFailureException e
) {
log.error("[*] OptimisticLockingFailureException : {}", e.getMessage());
return ApiResponse.error(ErrorStatus.CONFLICT);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

@ExceptionHandler(NullPointerException.class)
public ResponseEntity<ApiResponse<Void>> handleNullPointerException(
NullPointerException e
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ public enum ErrorStatus implements BaseStatus {
FORBIDDEN(HttpStatus.FORBIDDEN, "COMM_403", "접근 권한이 없습니다."),
NOT_FOUND(HttpStatus.NOT_FOUND, "COMM_404", "요청한 자원을 찾을 수 없습니다."),
METHOD_NOT_ALLOWED(HttpStatus.METHOD_NOT_ALLOWED, "COMM_405", "허용되지 않은 메소드입니다."),
CONFLICT(HttpStatus.CONFLICT, "COMM_409", "다른 요청과 충돌했습니다. 다시 시도해 주세요."),
INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "COMM_500", "서버 내부 오류입니다."),

/**
Expand Down Expand Up @@ -60,6 +61,10 @@ public enum ErrorStatus implements BaseStatus {
* Deck
*/
DECK_NOT_FOUND(HttpStatus.NOT_FOUND, "DECK_404_1", "덱을 찾을 수 없습니다."),
CARD_NOT_FOUND(HttpStatus.NOT_FOUND, "DECK_404_2", "카드를 찾을 수 없습니다."),
CARD_NOT_DISMISSED(HttpStatus.BAD_REQUEST, "DECK_400_1", "관심없음 처리되지 않은 카드입니다."),
CARD_ALREADY_DISMISSED(HttpStatus.BAD_REQUEST, "DECK_400_2", "이미 관심없음 처리된 카드입니다."),
CARD_DISMISS_REASON_ALREADY_SUBMITTED(HttpStatus.BAD_REQUEST, "DECK_400_3", "이미 관심없음 사유를 제출한 카드입니다."),

/**
* Resume (이력서/자소서 파일)
Expand All @@ -69,6 +74,7 @@ public enum ErrorStatus implements BaseStatus {
RESUME_UPLOAD_NOT_FOUND(HttpStatus.BAD_REQUEST, "RESUME_400_3", "S3에 업로드된 파일을 찾을 수 없습니다."),
RESUME_FILE_TOO_LARGE(HttpStatus.PAYLOAD_TOO_LARGE, "RESUME_413_1", "파일 용량은 10MB를 초과할 수 없습니다."),
RESUME_NOT_FOUND(HttpStatus.NOT_FOUND, "RESUME_404_1", "이력서/자소서 파일을 찾을 수 없습니다."),

/**
* Job
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,10 @@ public enum SuccessStatus implements BaseStatus {
/**
* Deck
*/
DECK_CARDS_SUCCESS(HttpStatus.OK, "DECK_200", "덱의 카드들을 조회했습니다."),
DECK_GENERATE_SUCCESS(HttpStatus.OK, "DECK_200_2", "오늘의 추천 덱을 생성했습니다."),
CARD_DISMISS_SUCCESS(HttpStatus.OK, "DECK_200_3", "카드를 관심없음으로 처리했습니다."),
CARD_DISMISS_CANCEL_SUCCESS(HttpStatus.OK, "DECK_200_4", "카드의 관심없음 처리를 취소했습니다."),
CARD_DISMISS_REASON_SUCCESS(HttpStatus.OK, "DECK_200_5", "관심없음 사유를 제출했습니다."),

/**
* Briefing
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ class BriefingServiceTest {

CardResponse card = new CardResponse(1L, 2L, null, "주니어 백엔드 엔지니어", "추천 이유", null,
List.of("Java"), "클라우드 데이터 플랫폼", "서울", List.of("신입"), null, "요약", 1, null);
when(cardService.getDeckCards(deck.getId())).thenReturn(List.of(card));
when(cardService.getDeckCards(deck.getId(), 1L)).thenReturn(List.of(card));

List<CardResponse> response = briefingService.getTodayBriefingStatus(1L);

Expand Down
Loading