Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
e18c29f
feat: BaseSoftDeleteEntity에 markDeleted 도메인 메서드 추가
csh1668 May 15, 2026
092f0b7
feat(resume): Resume 엔티티에 create 팩토리와 softDelete 도메인 메서드 추가
csh1668 May 15, 2026
34c861a
feat(resume): ResumeRepository에 페이지 finder 추가
csh1668 May 15, 2026
1de436f
feat(document): 이력서 활성 세션 참조 검사 native exists 쿼리 추가
csh1668 May 15, 2026
8063df7
feat(messaging): 공용 MessageEnvelope record 추가
csh1668 May 15, 2026
657063e
refactor(messaging): 사용하지 않는 MessageContext 클래스 삭제
csh1668 May 15, 2026
521d023
feat(resume): application 레이어 DTO와 ResumeUploadedEvent 정의
csh1668 May 15, 2026
89b5ac5
feat(resume): ResumeService 스켈레톤 + 빈 파일 검증
csh1668 May 15, 2026
4474d7f
feat(resume): 업로드 파일 검증(MIME/확장자/크기/매직바이트) 구현
csh1668 May 15, 2026
8fb73a8
feat(resume): 업로드 happy path - S3 PUT, DB save, 이벤트 발행
csh1668 May 15, 2026
4229946
feat(resume): 이력서 목록/단건 조회 서비스 메서드
csh1668 May 15, 2026
a939989
feat(resume): 이력서 소프트 삭제 및 활성 세션 사용 중 검사
csh1668 May 15, 2026
36c27a8
feat(resume): analyze.resume 발행 publisher와 AFTER_COMMIT 리스너 추가
csh1668 May 15, 2026
de02004
feat(resume): presentation 응답 DTO 추가
csh1668 May 15, 2026
a48cf44
refactor(resume): ResumePageResponse 제거하고 공용 PageResponse 사용
csh1668 May 15, 2026
aaf38ed
feat(resume): POST /api/resumes 업로드 엔드포인트 구현
csh1668 May 15, 2026
9356589
feat(resume): GET 목록·단건, DELETE 엔드포인트 추가
csh1668 May 15, 2026
6bbde84
refactor(resume): 슬라이스 사이클 제거 위해 ResumeUsageChecker 포트 도입
csh1668 May 15, 2026
62f9273
test: StackupApplicationTests에 신규 ResumeRepository/AnalyzedDocumentRe…
csh1668 May 15, 2026
d5814a2
refactor(resume): ResumeUploadedEvent의 미사용 traceId 필드 제거
csh1668 May 15, 2026
a75fbd3
refactor(common): softDelete를 BaseSoftDeleteEntity로 끌어올려 중복 wrapper 제거
csh1668 May 15, 2026
f32b37b
refactor(resume): 미사용 List 반환 finder 제거
csh1668 May 15, 2026
6721690
fix: 멀티파트 업로드 크기 초과 시 500 대신 RESUME_FILE_TOO_LARGE(400) 응답
csh1668 May 15, 2026
da6f523
test(resume): 404/409 컨트롤러 응답과 20MB 경계 업로드 테스트 추가
csh1668 May 15, 2026
fb43879
refactor(resume): 업로드 최대 크기를 app.resume.max-upload-size로 외부화
csh1668 May 15, 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
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.stackup.stackup.common.config.properties;

import jakarta.validation.constraints.NotNull;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.util.unit.DataSize;
import org.springframework.validation.annotation.Validated;

@Validated
@ConfigurationProperties(prefix = "app.resume")
public record ResumeProperties(
@NotNull DataSize maxUploadSize
) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,8 @@ public abstract class BaseSoftDeleteEntity extends BaseTimeAndUpdateEntity {

@Column(name = "is_deleted", nullable = false)
protected boolean deleted = false;

public void softDelete() {
this.deleted = true;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.multipart.MaxUploadSizeExceededException;

@RestControllerAdvice
public class GlobalExceptionHandler {
Expand Down Expand Up @@ -80,6 +81,18 @@ public ResponseEntity<ApiErrorResponse> handleStorageException(StorageException
);
}

@ExceptionHandler(MaxUploadSizeExceededException.class)
public ResponseEntity<ApiErrorResponse> handleMaxUploadSizeExceeded(
MaxUploadSizeExceededException exception
) {
ApiErrorCode errorCode = ApiErrorCode.RESUME_FILE_TOO_LARGE;
log.warn("Upload size exceeded. code={}, traceId={}, maxBytes={}",
errorCode.name(), TraceContext.getTraceId(), exception.getMaxUploadSize());
return buildResponse(errorCode, errorCode.getDefaultMessage(), Map.of(
"maxBytes", exception.getMaxUploadSize()
));
}

@ExceptionHandler(Exception.class)
public ResponseEntity<ApiErrorResponse> handleException(Exception exception) {
ApiErrorCode errorCode = ApiErrorCode.SYS_INTERNAL_ERROR;
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,15 +1,21 @@
package com.stackup.stackup.common.messaging;

import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
import java.time.Instant;
import java.util.Map;

public record MessageEnvelope<T>(
@JsonInclude(JsonInclude.Include.NON_NULL)
public record MessageEnvelope(
String messageId,
String messageType,
String version,
String traceId,
@JsonSerialize(using = ToStringSerializer.class)
Instant publishedAt,
String publisher,
T payload,
MessageContext context
Object payload,
Map<String, Object> context
) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,32 +3,30 @@
import com.stackup.stackup.common.config.properties.RabbitMqProperties;
import com.stackup.stackup.common.trace.TraceContext;
import java.time.Instant;
import java.util.Map;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
import org.springframework.amqp.core.MessagePostProcessor;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.stereotype.Component;

@Component
@RequiredArgsConstructor
public class RabbitMessagePublisher {

private final RabbitMqProperties properties;
private final RabbitTemplate rabbitTemplate;

public RabbitMessagePublisher(RabbitMqProperties properties, RabbitTemplate rabbitTemplate) {
this.properties = properties;
this.rabbitTemplate = rabbitTemplate;
}

public <T> MessageEnvelope<T> publishToAi(String routingKey, T payload, MessageContext context) {
MessageEnvelope<T> envelope = new MessageEnvelope<>(
public MessageEnvelope publishToAi(String routingKey, Object payload, Map<String, Object> context) {
MessageEnvelope envelope = new MessageEnvelope(
UUID.randomUUID().toString(),
routingKey,
properties.version(),
TraceContext.getTraceId(),
Instant.now(),
properties.publisher(),
payload,
context == null ? MessageContext.empty() : context
context == null ? Map.of() : context
);

rabbitTemplate.convertAndSend(
Expand All @@ -40,7 +38,7 @@ public <T> MessageEnvelope<T> publishToAi(String routingKey, T payload, MessageC
return envelope;
}

private MessagePostProcessor withEnvelopeHeaders(MessageEnvelope<?> envelope) {
private MessagePostProcessor withEnvelopeHeaders(MessageEnvelope envelope) {
return message -> {
message.getMessageProperties().setContentType(properties.message().contentType());
message.getMessageProperties().setContentEncoding(properties.message().contentEncoding());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

public interface AnalyzedDocumentRepository extends JpaRepository<AnalyzedDocument, Long> {

Expand All @@ -14,4 +16,18 @@ Optional<AnalyzedDocument> findByIdAndResume_User_IdOrIdAndRepository_User_Id(
Long repositoryDocumentId,
Long repositoryUserId
);

@Query(value = """
SELECT EXISTS (
SELECT 1
FROM analyzed_documents d
JOIN session_contexts sc ON sc.document_id = d.id
JOIN interview_sessions s ON s.id = sc.session_id
WHERE d.resume_id = :resumeId
AND d.is_deleted = FALSE
AND s.is_deleted = FALSE
AND s.status IN ('READY', 'IN_PROGRESS')
)
""", nativeQuery = true)
boolean existsActiveSessionContextForResume(@Param("resumeId") Long resumeId);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.stackup.stackup.document.infrastructure;

import com.stackup.stackup.document.domain.AnalyzedDocumentRepository;
import com.stackup.stackup.resume.domain.ResumeUsageChecker;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;

@Component
@RequiredArgsConstructor
public class AnalyzedDocumentBasedResumeUsageChecker implements ResumeUsageChecker {

private final AnalyzedDocumentRepository documentRepository;

@Override
public boolean isInUse(Long resumeId) {
return documentRepository.existsActiveSessionContextForResume(resumeId);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
package com.stackup.stackup.resume.application;

import com.stackup.stackup.common.config.properties.ResumeProperties;
import com.stackup.stackup.common.exception.ApiErrorCode;
import com.stackup.stackup.common.exception.DomainException;
import com.stackup.stackup.common.storage.ObjectStorageClient;
import com.stackup.stackup.resume.domain.ResumeUsageChecker;
import com.stackup.stackup.resume.application.dto.ResumeResult;
import com.stackup.stackup.resume.application.dto.ResumeUploadCommand;
import com.stackup.stackup.resume.application.event.ResumeUploadedEvent;
import com.stackup.stackup.resume.domain.Resume;
import com.stackup.stackup.resume.domain.ResumeFileType;
import com.stackup.stackup.resume.domain.ResumeRepository;
import com.stackup.stackup.user.domain.User;
import com.stackup.stackup.user.domain.UserRepository;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class ResumeService {

private static final byte[] PDF_MAGIC = {'%', 'P', 'D', 'F', '-'};

private final ResumeRepository resumeRepository;
private final UserRepository userRepository;
private final ResumeUsageChecker resumeUsageChecker;
private final ObjectStorageClient storage;
private final ApplicationEventPublisher events;
private final ResumeProperties resumeProperties;

@Transactional
public ResumeResult upload(Long userId, ResumeUploadCommand command) {
MultipartFile file = command.file();
if (file == null || file.isEmpty()) {
throw new DomainException(ApiErrorCode.RESUME_EMPTY_FILE);
}
if (file.getSize() > resumeProperties.maxUploadSize().toBytes()) {
throw new DomainException(ApiErrorCode.RESUME_FILE_TOO_LARGE);
}
if (!isPdfContentType(file.getContentType()) || !hasPdfExtension(file.getOriginalFilename())) {
throw new DomainException(ApiErrorCode.RESUME_INVALID_FILE_TYPE);
}
if (!hasPdfMagicBytes(file)) {
throw new DomainException(ApiErrorCode.RESUME_INVALID_FILE_TYPE);
}

String key = "resumes/raw/" + userId + "/" + UUID.randomUUID() + ".pdf";
long size = file.getSize();

try (var in = file.getInputStream()) {
storage.put(key, in, size, "application/pdf");
} catch (java.io.IOException e) {
throw new DomainException(ApiErrorCode.SYS_INTERNAL_ERROR, "Failed to read uploaded file", e);
}

Resume resume;
try {
User user = userRepository.getReferenceById(userId);
resume = resumeRepository.save(Resume.create(
user, file.getOriginalFilename(), key, size, ResumeFileType.PDF
));
} catch (RuntimeException dbError) {
safeDelete(key);
throw dbError;
}

events.publishEvent(new ResumeUploadedEvent(resume.getId(), userId, key));
return ResumeResult.from(resume);
}

public org.springframework.data.domain.Page<ResumeResult> list(Long userId, org.springframework.data.domain.Pageable pageable) {
return resumeRepository.findByUser_IdAndDeletedFalse(userId, pageable)
.map(ResumeResult::from);
}

public ResumeResult get(Long userId, Long resumeId) {
return resumeRepository.findByIdAndUser_IdAndDeletedFalse(resumeId, userId)
.map(ResumeResult::from)
.orElseThrow(() -> new DomainException(ApiErrorCode.RESUME_NOT_FOUND));
}

@Transactional
public void delete(Long userId, Long resumeId) {
Resume resume = resumeRepository.findByIdAndUser_IdAndDeletedFalse(resumeId, userId)
.orElseThrow(() -> new DomainException(ApiErrorCode.RESUME_NOT_FOUND));
if (resumeUsageChecker.isInUse(resumeId)) {
throw new DomainException(ApiErrorCode.RESUME_IN_USE);
}
resume.softDelete();
}

private void safeDelete(String key) {
try {
storage.delete(key);
} catch (RuntimeException ignored) {
}
}

private static boolean isPdfContentType(String contentType) {
return contentType != null && contentType.equalsIgnoreCase("application/pdf");
}

private static boolean hasPdfExtension(String filename) {
return filename != null && filename.toLowerCase(java.util.Locale.ROOT).endsWith(".pdf");
}

private static boolean hasPdfMagicBytes(MultipartFile file) {
try (var in = file.getInputStream()) {
byte[] head = in.readNBytes(PDF_MAGIC.length);
return java.util.Arrays.equals(head, PDF_MAGIC);
} catch (java.io.IOException e) {
throw new DomainException(ApiErrorCode.RESUME_INVALID_FILE_TYPE, "Failed to read uploaded file", e);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package com.stackup.stackup.resume.application.dto;

import com.stackup.stackup.resume.domain.Resume;
import com.stackup.stackup.resume.domain.ResumeStatus;
import java.time.Instant;

public record ResumeResult(
Long id,
String originalFilename,
Long fileSize,
ResumeStatus status,
Instant createdAt,
Instant updatedAt
) {
public static ResumeResult from(Resume resume) {
return new ResumeResult(
resume.getId(),
resume.getOriginalFilename(),
resume.getFileSize(),
resume.getStatus(),
resume.getCreatedAt(),
resume.getUpdatedAt()
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.stackup.stackup.resume.application.dto;

import org.springframework.web.multipart.MultipartFile;

public record ResumeUploadCommand(MultipartFile file) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
package com.stackup.stackup.resume.application.event;

public record ResumeUploadedEvent(Long resumeId, Long userId, String s3Key) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -53,4 +53,22 @@ public class Resume extends BaseSoftDeleteEntity {
@Column(nullable = false, length = 20)
@Enumerated(EnumType.STRING)
private ResumeStatus status = ResumeStatus.PENDING;

public static Resume create(
User user,
String originalFilename,
String filePath,
Long fileSize,
ResumeFileType fileType
) {
Resume resume = new Resume();
resume.user = user;
resume.originalFilename = originalFilename;
resume.filePath = filePath;
resume.fileSize = fileSize;
resume.fileType = fileType;
resume.status = ResumeStatus.PENDING;
return resume;
}

}
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
package com.stackup.stackup.resume.domain;

import java.util.List;
import java.util.Optional;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;

public interface ResumeRepository extends JpaRepository<Resume, Long> {

List<Resume> findByUser_IdAndDeletedFalse(Long userId);
Page<Resume> findByUser_IdAndDeletedFalse(Long userId, Pageable pageable);

Optional<Resume> findByIdAndUser_IdAndDeletedFalse(Long id, Long userId);
}
Loading