diff --git a/.gitignore b/.gitignore index 54fc4e8..ecd8261 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,4 @@ application-prod.yml ## Claude ## .claude/ +.DS_Store diff --git a/build.gradle b/build.gradle index 1afd9eb..14bfdb9 100644 --- a/build.gradle +++ b/build.gradle @@ -21,10 +21,13 @@ repositories { dependencies { + implementation platform('software.amazon.awssdk:bom:2.48.4') + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server' implementation 'org.springframework.boot:spring-boot-starter-webmvc' + implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.2' // Lombok @@ -34,6 +37,9 @@ dependencies { // PostgreSQL implementation 'org.postgresql:postgresql' + // AWS S3 (Presigned URL 업로드) + implementation 'software.amazon.awssdk:s3' + implementation 'org.springframework.boot:spring-boot-starter-cache' implementation 'org.springframework.boot:spring-boot-starter-data-redis' diff --git a/src/main/java/com/leets7th/job_is_be/domain/user/controller/ResumeController.java b/src/main/java/com/leets7th/job_is_be/domain/user/controller/ResumeController.java new file mode 100644 index 0000000..34bde0a --- /dev/null +++ b/src/main/java/com/leets7th/job_is_be/domain/user/controller/ResumeController.java @@ -0,0 +1,76 @@ +package com.leets7th.job_is_be.domain.user.controller; + +import com.leets7th.job_is_be.domain.user.dto.PresignedUrlRequest; +import com.leets7th.job_is_be.domain.user.dto.PresignedUrlResponse; +import com.leets7th.job_is_be.domain.user.dto.ResumeConfirmRequest; +import com.leets7th.job_is_be.domain.user.dto.ResumeResponse; +import com.leets7th.job_is_be.domain.user.dto.ResumeUploadResponse; +import com.leets7th.job_is_be.domain.user.service.ResumeService; +import com.leets7th.job_is_be.global.response.ApiResponse; +import com.leets7th.job_is_be.global.status.SuccessStatus; +import jakarta.validation.Valid; +import org.springframework.http.ResponseEntity; +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.GetMapping; +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/profile/files") +public class ResumeController { + + private final ResumeService resumeService; + + public ResumeController(ResumeService resumeService) { + this.resumeService = resumeService; + } + + @PostMapping("/presigned-url") + public ResponseEntity> issuePresignedUrl( + @AuthenticationPrincipal Jwt jwt, + @Valid @RequestBody PresignedUrlRequest request + ) { + PresignedUrlResponse response = resumeService.issuePresignedUrl(userId(jwt), request); + return ApiResponse.success(SuccessStatus.RESUME_PRESIGNED_URL_SUCCESS, response); + } + + @PostMapping + public ResponseEntity> confirmUpload( + @AuthenticationPrincipal Jwt jwt, + @Valid @RequestBody ResumeConfirmRequest request + ) { + ResumeUploadResponse response = resumeService.confirmUpload(userId(jwt), request); + SuccessStatus status = response.created() + ? SuccessStatus.RESUME_UPLOAD_CONFIRM_SUCCESS + : SuccessStatus.RESUME_UPLOAD_UPDATE_SUCCESS; + return ApiResponse.success(status, response); + } + + @GetMapping + public ResponseEntity>> getFiles( + @AuthenticationPrincipal Jwt jwt + ) { + List response = resumeService.getFiles(userId(jwt)); + return ApiResponse.success(SuccessStatus.RESUME_LIST_SUCCESS, response); + } + + @DeleteMapping("/{fileId}") + public ResponseEntity> deleteFile( + @AuthenticationPrincipal Jwt jwt, + @PathVariable Long fileId + ) { + resumeService.deleteFile(userId(jwt), fileId); + return ApiResponse.success(SuccessStatus.RESUME_DELETE_SUCCESS); + } + + private Long userId(Jwt jwt) { + return Long.valueOf(jwt.getSubject()); + } +} diff --git a/src/main/java/com/leets7th/job_is_be/domain/user/dto/PresignedUrlRequest.java b/src/main/java/com/leets7th/job_is_be/domain/user/dto/PresignedUrlRequest.java new file mode 100644 index 0000000..2801ce8 --- /dev/null +++ b/src/main/java/com/leets7th/job_is_be/domain/user/dto/PresignedUrlRequest.java @@ -0,0 +1,11 @@ +package com.leets7th.job_is_be.domain.user.dto; + +import com.leets7th.job_is_be.domain.user.enums.ResumeCategory; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +public record PresignedUrlRequest( + @NotNull ResumeCategory category, + @NotBlank String fileName +) { +} diff --git a/src/main/java/com/leets7th/job_is_be/domain/user/dto/PresignedUrlResponse.java b/src/main/java/com/leets7th/job_is_be/domain/user/dto/PresignedUrlResponse.java new file mode 100644 index 0000000..398c42e --- /dev/null +++ b/src/main/java/com/leets7th/job_is_be/domain/user/dto/PresignedUrlResponse.java @@ -0,0 +1,8 @@ +package com.leets7th.job_is_be.domain.user.dto; + +public record PresignedUrlResponse( + String presignedUrl, + String objectKey, + long expiresIn +) { +} diff --git a/src/main/java/com/leets7th/job_is_be/domain/user/dto/ResumeConfirmRequest.java b/src/main/java/com/leets7th/job_is_be/domain/user/dto/ResumeConfirmRequest.java new file mode 100644 index 0000000..ee1b86a --- /dev/null +++ b/src/main/java/com/leets7th/job_is_be/domain/user/dto/ResumeConfirmRequest.java @@ -0,0 +1,12 @@ +package com.leets7th.job_is_be.domain.user.dto; + +import com.leets7th.job_is_be.domain.user.enums.ResumeCategory; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +public record ResumeConfirmRequest( + @NotBlank String objectKey, + @NotBlank String fileName, + @NotNull ResumeCategory category +) { +} diff --git a/src/main/java/com/leets7th/job_is_be/domain/user/dto/ResumeResponse.java b/src/main/java/com/leets7th/job_is_be/domain/user/dto/ResumeResponse.java new file mode 100644 index 0000000..a79a401 --- /dev/null +++ b/src/main/java/com/leets7th/job_is_be/domain/user/dto/ResumeResponse.java @@ -0,0 +1,25 @@ +package com.leets7th.job_is_be.domain.user.dto; + +import com.leets7th.job_is_be.domain.user.entity.Resume; +import com.leets7th.job_is_be.domain.user.enums.ResumeCategory; +import com.leets7th.job_is_be.domain.user.enums.ResumeFileFormat; + +import java.time.LocalDateTime; + +public record ResumeResponse( + Long fileId, + ResumeCategory category, + String fileName, + ResumeFileFormat fileFormat, + LocalDateTime uploadedAt +) { + public static ResumeResponse from(Resume resume) { + return new ResumeResponse( + resume.getId(), + resume.getCategory(), + resume.getFileName(), + resume.getFileFormat(), + resume.getUploadedAt() + ); + } +} diff --git a/src/main/java/com/leets7th/job_is_be/domain/user/dto/ResumeUploadResponse.java b/src/main/java/com/leets7th/job_is_be/domain/user/dto/ResumeUploadResponse.java new file mode 100644 index 0000000..366cd54 --- /dev/null +++ b/src/main/java/com/leets7th/job_is_be/domain/user/dto/ResumeUploadResponse.java @@ -0,0 +1,4 @@ +package com.leets7th.job_is_be.domain.user.dto; + +public record ResumeUploadResponse(Long fileId, boolean created) { +} diff --git a/src/main/java/com/leets7th/job_is_be/domain/user/entity/Resume.java b/src/main/java/com/leets7th/job_is_be/domain/user/entity/Resume.java index 52327a1..5ab3c80 100644 --- a/src/main/java/com/leets7th/job_is_be/domain/user/entity/Resume.java +++ b/src/main/java/com/leets7th/job_is_be/domain/user/entity/Resume.java @@ -1,5 +1,7 @@ package com.leets7th.job_is_be.domain.user.entity; +import com.leets7th.job_is_be.domain.user.enums.ResumeCategory; +import com.leets7th.job_is_be.domain.user.enums.ResumeFileFormat; import com.leets7th.job_is_be.global.base.BaseEntity; import jakarta.persistence.*; import lombok.AccessLevel; @@ -10,10 +12,15 @@ import java.time.LocalDateTime; /** - * 이력서/자소서 파일 + * 이력서/자소서 파일 (S3 Presigned URL 업로드, 유형별 1슬롯) */ @Entity -@Table(name = "resumes") +@Table( + name = "resumes", + uniqueConstraints = { + @UniqueConstraint(name = "uk_resumes_user_category", columnNames = {"user_id", "category"}) + } +) @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) public class Resume extends BaseEntity { @@ -26,17 +33,19 @@ public class Resume extends BaseEntity { @JoinColumn(name = "user_id", nullable = false) private User user; + @Enumerated(EnumType.STRING) + @Column(name = "category", nullable = false, length = 20) + private ResumeCategory category; + @Column(name = "file_name", nullable = false, length = 255) private String fileName; + @Enumerated(EnumType.STRING) @Column(name = "file_type", nullable = false, length = 10) - private String fileType; // PDF, DOCX, HWP, HWPX - - @Column(name = "file_url", nullable = false, length = 500) - private String fileUrl; + private ResumeFileFormat fileFormat; - @Column(name = "is_active", nullable = false) - private boolean active; + @Column(name = "s3_key", nullable = false, length = 500) + private String s3Key; // TODO: LocalDateTime → OffsetDateTime으로 통일 필요 (BaseEntity와 타입 불일치) @Column(name = "uploaded_at", nullable = false) @@ -47,17 +56,21 @@ public class Resume extends BaseEntity { private LocalDateTime deletedAt; @Builder - public Resume(User user, String fileName, String fileType, String fileUrl, LocalDateTime uploadedAt) { + public Resume(User user, ResumeCategory category, String fileName, ResumeFileFormat fileFormat, String s3Key, LocalDateTime uploadedAt) { this.user = user; + this.category = category; this.fileName = fileName; - this.fileType = fileType; - this.fileUrl = fileUrl; + this.fileFormat = fileFormat; + this.s3Key = s3Key; this.uploadedAt = uploadedAt; - this.active = true; } - public void delete(LocalDateTime now) { - this.active = false; - this.deletedAt = now; + /** + * 같은 유형(user+category) 재업로드 시 기존 row를 새 파일 정보로 갱신 (S3 오브젝트 키는 고정이라 그대로 유지) + */ + public void replace(String fileName, ResumeFileFormat fileFormat, LocalDateTime uploadedAt) { + this.fileName = fileName; + this.fileFormat = fileFormat; + this.uploadedAt = uploadedAt; } } diff --git a/src/main/java/com/leets7th/job_is_be/domain/user/enums/ResumeCategory.java b/src/main/java/com/leets7th/job_is_be/domain/user/enums/ResumeCategory.java new file mode 100644 index 0000000..3752b6c --- /dev/null +++ b/src/main/java/com/leets7th/job_is_be/domain/user/enums/ResumeCategory.java @@ -0,0 +1,9 @@ +package com.leets7th.job_is_be.domain.user.enums; + +/** + * 이력서/자소서 구분 (유형별 1슬롯 제한의 기준이 되는 유형) + */ +public enum ResumeCategory { + RESUME, + COVER_LETTER +} diff --git a/src/main/java/com/leets7th/job_is_be/domain/user/enums/ResumeFileFormat.java b/src/main/java/com/leets7th/job_is_be/domain/user/enums/ResumeFileFormat.java new file mode 100644 index 0000000..e6443bd --- /dev/null +++ b/src/main/java/com/leets7th/job_is_be/domain/user/enums/ResumeFileFormat.java @@ -0,0 +1,29 @@ +package com.leets7th.job_is_be.domain.user.enums; + +/** + * 업로드 허용 파일 형식 (확장자 기준) + */ +public enum ResumeFileFormat { + PDF("application/pdf"), + DOCX("application/vnd.openxmlformats-officedocument.wordprocessingml.document"), + HWP("application/x-hwp"), + HWPX("application/haansofthwpx"); + + private final String mimeType; + + ResumeFileFormat(String mimeType) { + this.mimeType = mimeType; + } + + public static ResumeFileFormat fromExtension(String extension) { + return valueOf(extension.toUpperCase()); + } + + /** + * 확장자에 대응하는 서버 허용 MIME 타입. + * 클라이언트가 보낸 contentType은 신뢰하지 않고 이 값을 S3 PutObject에 사용한다. + */ + public String mimeType() { + return mimeType; + } +} diff --git a/src/main/java/com/leets7th/job_is_be/domain/user/repository/ResumeRepository.java b/src/main/java/com/leets7th/job_is_be/domain/user/repository/ResumeRepository.java new file mode 100644 index 0000000..b5ad73e --- /dev/null +++ b/src/main/java/com/leets7th/job_is_be/domain/user/repository/ResumeRepository.java @@ -0,0 +1,18 @@ +package com.leets7th.job_is_be.domain.user.repository; + +import com.leets7th.job_is_be.domain.user.entity.Resume; +import com.leets7th.job_is_be.domain.user.entity.User; +import com.leets7th.job_is_be.domain.user.enums.ResumeCategory; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface ResumeRepository extends JpaRepository { + + Optional findByUserAndCategory(User user, ResumeCategory category); + + List findAllByUser(User user); + + Optional findByIdAndUser(Long id, User user); +} diff --git a/src/main/java/com/leets7th/job_is_be/domain/user/service/ResumeService.java b/src/main/java/com/leets7th/job_is_be/domain/user/service/ResumeService.java new file mode 100644 index 0000000..a6f11d1 --- /dev/null +++ b/src/main/java/com/leets7th/job_is_be/domain/user/service/ResumeService.java @@ -0,0 +1,198 @@ +package com.leets7th.job_is_be.domain.user.service; + +import com.leets7th.job_is_be.domain.user.dto.PresignedUrlRequest; +import com.leets7th.job_is_be.domain.user.dto.PresignedUrlResponse; +import com.leets7th.job_is_be.domain.user.dto.ResumeConfirmRequest; +import com.leets7th.job_is_be.domain.user.dto.ResumeResponse; +import com.leets7th.job_is_be.domain.user.dto.ResumeUploadResponse; +import com.leets7th.job_is_be.domain.user.entity.Resume; +import com.leets7th.job_is_be.domain.user.entity.User; +import com.leets7th.job_is_be.domain.user.enums.ResumeCategory; +import com.leets7th.job_is_be.domain.user.enums.ResumeFileFormat; +import com.leets7th.job_is_be.domain.user.repository.ResumeRepository; +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.properties.AwsS3Properties; +import com.leets7th.job_is_be.global.status.ErrorStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.DeleteObjectRequest; +import software.amazon.awssdk.services.s3.model.HeadObjectRequest; +import software.amazon.awssdk.services.s3.model.HeadObjectResponse; +import software.amazon.awssdk.services.s3.model.NoSuchKeyException; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.model.S3Exception; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; +import software.amazon.awssdk.services.s3.presigner.model.PresignedPutObjectRequest; +import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +@Service +public class ResumeService { + + private static final long MAX_FILE_SIZE_BYTES = 10L * 1024 * 1024; // 10MB + + private final ResumeRepository resumeRepository; + private final UserRepository userRepository; + private final S3Client s3Client; + private final S3Presigner s3Presigner; + private final AwsS3Properties awsS3Properties; + + public ResumeService( + ResumeRepository resumeRepository, + UserRepository userRepository, + S3Client s3Client, + S3Presigner s3Presigner, + AwsS3Properties awsS3Properties + ) { + this.resumeRepository = resumeRepository; + this.userRepository = userRepository; + this.s3Client = s3Client; + this.s3Presigner = s3Presigner; + this.awsS3Properties = awsS3Properties; + } + + public PresignedUrlResponse issuePresignedUrl(Long userId, PresignedUrlRequest request) { + ResumeFileFormat fileFormat = parseFileFormat(request.fileName()); // 확장자 검증 (PDF/DOCX/HWP/HWPX 아니면 예외) + String objectKey = buildObjectKey(userId, request.category()); + + PutObjectRequest objectRequest = PutObjectRequest.builder() + .bucket(awsS3Properties.bucket()) + .key(objectKey) + .contentType(fileFormat.mimeType()) // 클라이언트 값 대신 확장자 기준 서버 매핑 MIME 사용 + .build(); + + PutObjectPresignRequest presignRequest = PutObjectPresignRequest.builder() + .signatureDuration(awsS3Properties.presignedUrlExpiration()) + .putObjectRequest(objectRequest) + .build(); + + PresignedPutObjectRequest presignedRequest = s3Presigner.presignPutObject(presignRequest); + + return new PresignedUrlResponse( + presignedRequest.url().toString(), + objectKey, + awsS3Properties.presignedUrlExpiration().toSeconds() + ); + } + + @Transactional + public ResumeUploadResponse confirmUpload(Long userId, ResumeConfirmRequest request) { + User user = getUser(userId); + ResumeFileFormat fileFormat = parseFileFormat(request.fileName()); + + String expectedObjectKey = buildObjectKey(userId, request.category()); + if (!expectedObjectKey.equals(request.objectKey())) { + throw new GeneralException(ErrorStatus.RESUME_OBJECT_KEY_MISMATCH); + } + + HeadObjectResponse headObjectResponse = headObjectOrThrow(request.objectKey()); + if (headObjectResponse.contentLength() > MAX_FILE_SIZE_BYTES) { + deleteObject(request.objectKey()); + throw new GeneralException(ErrorStatus.RESUME_FILE_TOO_LARGE); + } + + LocalDateTime uploadedAt = LocalDateTime.now(); + Optional existingResume = resumeRepository.findByUserAndCategory(user, request.category()); + boolean created = existingResume.isEmpty(); + + Resume resume = existingResume + .map(existing -> { + existing.replace(request.fileName(), fileFormat, uploadedAt); + return existing; + }) + .orElseGet(() -> resumeRepository.save(Resume.builder() + .user(user) + .category(request.category()) + .fileName(request.fileName()) + .fileFormat(fileFormat) + .s3Key(request.objectKey()) + .uploadedAt(uploadedAt) + .build())); + + return new ResumeUploadResponse(resume.getId(), created); + } + + @Transactional(readOnly = true) + public List getFiles(Long userId) { + User user = getUser(userId); + return resumeRepository.findAllByUser(user).stream() + .map(ResumeResponse::from) + .toList(); + } + + @Transactional + public void deleteFile(Long userId, Long fileId) { + User user = getUser(userId); + Resume resume = resumeRepository.findByIdAndUser(fileId, user) + .orElseThrow(() -> new GeneralException(ErrorStatus.RESUME_NOT_FOUND)); + + String s3Key = resume.getS3Key(); + resumeRepository.delete(resume); + + // DB 커밋이 실패하면 S3 삭제도 실행되지 않도록, 커밋 성공 이후에만 S3 객체를 삭제한다. + // (S3 orphan은 무해하지만, S3만 먼저 지우고 DB 커밋이 실패하면 ghost row가 남는다) + if (TransactionSynchronizationManager.isSynchronizationActive()) { + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + deleteObject(s3Key); + } + }); + } else { + deleteObject(s3Key); + } + } + + private User getUser(Long userId) { + return userRepository.findById(userId) + .orElseThrow(() -> new GeneralException(ErrorStatus.USER_NOT_FOUND)); + } + + private ResumeFileFormat parseFileFormat(String fileName) { + int lastDot = fileName.lastIndexOf('.'); + if (lastDot < 0 || lastDot == fileName.length() - 1) { + throw new GeneralException(ErrorStatus.RESUME_INVALID_FILE_FORMAT); + } + + try { + return ResumeFileFormat.fromExtension(fileName.substring(lastDot + 1)); + } catch (IllegalArgumentException e) { + throw new GeneralException(ErrorStatus.RESUME_INVALID_FILE_FORMAT); + } + } + + private String buildObjectKey(Long userId, ResumeCategory category) { + return "profile/" + userId + "/" + category.name(); + } + + private HeadObjectResponse headObjectOrThrow(String objectKey) { + try { + return s3Client.headObject(HeadObjectRequest.builder() + .bucket(awsS3Properties.bucket()) + .key(objectKey) + .build()); + } catch (NoSuchKeyException e) { + throw new GeneralException(ErrorStatus.RESUME_UPLOAD_NOT_FOUND); + } catch (S3Exception e) { + // HeadObject 404는 body가 없어 SDK 버전에 따라 NoSuchKeyException 대신 S3Exception(404)로 올 수 있음 + if (e.statusCode() == 404) { + throw new GeneralException(ErrorStatus.RESUME_UPLOAD_NOT_FOUND); + } + throw e; + } + } + + private void deleteObject(String objectKey) { + s3Client.deleteObject(DeleteObjectRequest.builder() + .bucket(awsS3Properties.bucket()) + .key(objectKey) + .build()); + } +} diff --git a/src/main/java/com/leets7th/job_is_be/global/config/S3Config.java b/src/main/java/com/leets7th/job_is_be/global/config/S3Config.java new file mode 100644 index 0000000..c70f9e2 --- /dev/null +++ b/src/main/java/com/leets7th/job_is_be/global/config/S3Config.java @@ -0,0 +1,28 @@ +package com.leets7th.job_is_be.global.config; + +import com.leets7th.job_is_be.global.properties.AwsS3Properties; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; + +@Configuration +@EnableConfigurationProperties(AwsS3Properties.class) +public class S3Config { + + @Bean + public S3Client s3Client(AwsS3Properties properties) { + return S3Client.builder() + .region(Region.of(properties.region())) + .build(); + } + + @Bean + public S3Presigner s3Presigner(AwsS3Properties properties) { + return S3Presigner.builder() + .region(Region.of(properties.region())) + .build(); + } +} diff --git a/src/main/java/com/leets7th/job_is_be/global/properties/AwsS3Properties.java b/src/main/java/com/leets7th/job_is_be/global/properties/AwsS3Properties.java new file mode 100644 index 0000000..af8d13c --- /dev/null +++ b/src/main/java/com/leets7th/job_is_be/global/properties/AwsS3Properties.java @@ -0,0 +1,17 @@ +package com.leets7th.job_is_be.global.properties; + +import jakarta.validation.constraints.NotBlank; +import org.hibernate.validator.constraints.time.DurationMin; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.validation.annotation.Validated; + +import java.time.Duration; + +@Validated +@ConfigurationProperties(prefix = "app.aws.s3") +public record AwsS3Properties( + @NotBlank String bucket, + @NotBlank String region, + @DurationMin(seconds = 1) Duration presignedUrlExpiration +) { +} diff --git a/src/main/java/com/leets7th/job_is_be/global/status/ErrorStatus.java b/src/main/java/com/leets7th/job_is_be/global/status/ErrorStatus.java index 52a5781..0e0b3fd 100644 --- a/src/main/java/com/leets7th/job_is_be/global/status/ErrorStatus.java +++ b/src/main/java/com/leets7th/job_is_be/global/status/ErrorStatus.java @@ -47,6 +47,14 @@ public enum ErrorStatus implements BaseStatus { */ DECK_NOT_FOUND(HttpStatus.NOT_FOUND, "DECK_404_1", "덱을 찾을 수 없습니다."), + /** + * Resume (이력서/자소서 파일) + */ + RESUME_INVALID_FILE_FORMAT(HttpStatus.BAD_REQUEST, "RESUME_400_1", "지원하지 않는 파일 형식입니다. (PDF, DOCX, HWP, HWPX만 업로드 가능합니다)"), + RESUME_OBJECT_KEY_MISMATCH(HttpStatus.BAD_REQUEST, "RESUME_400_2", "발급받은 업로드 경로와 일치하지 않습니다."), + 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 */ diff --git a/src/main/java/com/leets7th/job_is_be/global/status/SuccessStatus.java b/src/main/java/com/leets7th/job_is_be/global/status/SuccessStatus.java index 63fd8b0..8222d7d 100644 --- a/src/main/java/com/leets7th/job_is_be/global/status/SuccessStatus.java +++ b/src/main/java/com/leets7th/job_is_be/global/status/SuccessStatus.java @@ -36,7 +36,16 @@ public enum SuccessStatus implements BaseStatus { BRIEFING_STATUS_SUCCESS(HttpStatus.OK, "BRIEFING_200_2", "오늘의 브리핑 덱 카드 목록을 조회했습니다."), /** - * Job + * Resume (이력서/자소서 파일) + */ + RESUME_PRESIGNED_URL_SUCCESS(HttpStatus.OK, "RESUME_200_1", "업로드용 Presigned URL을 발급했습니다."), + RESUME_UPLOAD_CONFIRM_SUCCESS(HttpStatus.CREATED, "RESUME_201_1", "이력서/자소서 파일을 저장했습니다."), + RESUME_LIST_SUCCESS(HttpStatus.OK, "RESUME_200_2", "이력서/자소서 파일 목록을 조회했습니다."), + RESUME_DELETE_SUCCESS(HttpStatus.OK, "RESUME_200_3", "이력서/자소서 파일을 삭제했습니다."), + RESUME_UPLOAD_UPDATE_SUCCESS(HttpStatus.OK, "RESUME_200_4", "이력서/자소서 파일을 갱신했습니다."), + + /** + * Job */ JOB_SAVE_SUCCESS(HttpStatus.CREATED, "JOB_201_1", "공고를 저장했습니다."), JOB_UNSAVE_SUCCESS(HttpStatus.OK, "JOB_200_1", "공고 저장을 취소했습니다."); diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index d2e1e3a..7bde90f 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -31,6 +31,11 @@ server: shutdown: graceful app: + aws: + s3: + bucket: ${AWS_S3_BUCKET} + region: ${AWS_REGION:us-east-1} + presigned-url-expiration: ${AWS_S3_PRESIGNED_URL_EXPIRATION:5m} cors: allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:3000,http://localhost:5173} oauth: diff --git a/src/test/java/com/leets7th/job_is_be/domain/user/service/ResumeServiceTest.java b/src/test/java/com/leets7th/job_is_be/domain/user/service/ResumeServiceTest.java new file mode 100644 index 0000000..cfe4c2e --- /dev/null +++ b/src/test/java/com/leets7th/job_is_be/domain/user/service/ResumeServiceTest.java @@ -0,0 +1,280 @@ +package com.leets7th.job_is_be.domain.user.service; + +import com.leets7th.job_is_be.domain.user.dto.PresignedUrlRequest; +import com.leets7th.job_is_be.domain.user.dto.PresignedUrlResponse; +import com.leets7th.job_is_be.domain.user.dto.ResumeConfirmRequest; +import com.leets7th.job_is_be.domain.user.dto.ResumeResponse; +import com.leets7th.job_is_be.domain.user.dto.ResumeUploadResponse; +import com.leets7th.job_is_be.domain.user.entity.Resume; +import com.leets7th.job_is_be.domain.user.entity.User; +import com.leets7th.job_is_be.domain.user.enums.ResumeCategory; +import com.leets7th.job_is_be.domain.user.enums.ResumeFileFormat; +import com.leets7th.job_is_be.domain.user.enums.SocialType; +import com.leets7th.job_is_be.domain.user.repository.ResumeRepository; +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.properties.AwsS3Properties; +import com.leets7th.job_is_be.global.status.ErrorStatus; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.DeleteObjectRequest; +import software.amazon.awssdk.services.s3.model.HeadObjectRequest; +import software.amazon.awssdk.services.s3.model.HeadObjectResponse; +import software.amazon.awssdk.services.s3.model.NoSuchKeyException; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; +import software.amazon.awssdk.services.s3.presigner.model.PresignedPutObjectRequest; +import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest; + +import java.net.URI; +import java.net.URL; +import java.time.Duration; +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.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ResumeServiceTest { + + @Mock + private ResumeRepository resumeRepository; + @Mock + private UserRepository userRepository; + @Mock + private S3Client s3Client; + @Mock + private S3Presigner s3Presigner; + + private final AwsS3Properties awsS3Properties = + new AwsS3Properties("test-bucket", "ap-northeast-2", Duration.ofMinutes(5)); + + private ResumeService resumeService; + + private ResumeService newService() { + return new ResumeService(resumeRepository, userRepository, s3Client, s3Presigner, awsS3Properties); + } + + private User user(Long id) { + User user = User.builder() + .socialId("social-id") + .socialType(SocialType.GOOGLE) + .email("user@example.com") + .build(); + ReflectionTestUtils.setField(user, "id", id); + return user; + } + + @Test + void 파일명_확장자가_유효하면_Presigned_URL을_발급한다() throws Exception { + resumeService = newService(); + + PresignedPutObjectRequest presignedRequest = mock(PresignedPutObjectRequest.class); + when(presignedRequest.url()).thenReturn(URI.create("https://s3.example.com/upload").toURL()); + when(s3Presigner.presignPutObject(any(PutObjectPresignRequest.class))).thenReturn(presignedRequest); + + PresignedUrlRequest request = new PresignedUrlRequest(ResumeCategory.RESUME, "resume.pdf"); + + PresignedUrlResponse response = resumeService.issuePresignedUrl(1L, request); + + assertThat(response.presignedUrl()).isEqualTo("https://s3.example.com/upload"); + assertThat(response.objectKey()).isEqualTo("profile/1/RESUME"); + assertThat(response.expiresIn()).isEqualTo(300L); + } + + @Test + void 지원하지_않는_확장자면_Presigned_URL_발급시_예외를_던진다() { + resumeService = newService(); + + PresignedUrlRequest request = new PresignedUrlRequest(ResumeCategory.RESUME, "resume.exe"); + + assertThatThrownBy(() -> resumeService.issuePresignedUrl(1L, request)) + .isInstanceOf(GeneralException.class) + .satisfies(e -> assertThat(((GeneralException) e).getErrorStatus()) + .isEqualTo(ErrorStatus.RESUME_INVALID_FILE_FORMAT)); + } + + @Test + void objectKey가_불일치하면_업로드_확인시_예외를_던진다() { + resumeService = newService(); + when(userRepository.findById(1L)).thenReturn(Optional.of(user(1L))); + + ResumeConfirmRequest request = new ResumeConfirmRequest("profile/1/COVER_LETTER", "resume.pdf", ResumeCategory.RESUME); + + assertThatThrownBy(() -> resumeService.confirmUpload(1L, request)) + .isInstanceOf(GeneralException.class) + .satisfies(e -> assertThat(((GeneralException) e).getErrorStatus()) + .isEqualTo(ErrorStatus.RESUME_OBJECT_KEY_MISMATCH)); + } + + @Test + void S3에_업로드된_파일이_없으면_업로드_확인시_예외를_던진다() { + resumeService = newService(); + when(userRepository.findById(1L)).thenReturn(Optional.of(user(1L))); + when(s3Client.headObject(any(HeadObjectRequest.class))) + .thenThrow(NoSuchKeyException.builder().message("no such key").build()); + + ResumeConfirmRequest request = new ResumeConfirmRequest("profile/1/RESUME", "resume.pdf", ResumeCategory.RESUME); + + assertThatThrownBy(() -> resumeService.confirmUpload(1L, request)) + .isInstanceOf(GeneralException.class) + .satisfies(e -> assertThat(((GeneralException) e).getErrorStatus()) + .isEqualTo(ErrorStatus.RESUME_UPLOAD_NOT_FOUND)); + } + + @Test + void 파일_용량이_초과되면_S3_객체를_삭제하고_예외를_던진다() { + resumeService = newService(); + when(userRepository.findById(1L)).thenReturn(Optional.of(user(1L))); + + HeadObjectResponse headObjectResponse = HeadObjectResponse.builder() + .contentLength(11L * 1024 * 1024) + .build(); + when(s3Client.headObject(any(HeadObjectRequest.class))).thenReturn(headObjectResponse); + + ResumeConfirmRequest request = new ResumeConfirmRequest("profile/1/RESUME", "resume.pdf", ResumeCategory.RESUME); + + assertThatThrownBy(() -> resumeService.confirmUpload(1L, request)) + .isInstanceOf(GeneralException.class) + .satisfies(e -> assertThat(((GeneralException) e).getErrorStatus()) + .isEqualTo(ErrorStatus.RESUME_FILE_TOO_LARGE)); + + verify(s3Client).deleteObject(any(DeleteObjectRequest.class)); + } + + @Test + void 같은_카테고리에_기존_파일이_없으면_새로_저장한다() { + resumeService = newService(); + User user = user(1L); + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + when(resumeRepository.findByUserAndCategory(user, ResumeCategory.RESUME)).thenReturn(Optional.empty()); + + HeadObjectResponse headObjectResponse = HeadObjectResponse.builder() + .contentLength(1024L) + .build(); + when(s3Client.headObject(any(HeadObjectRequest.class))).thenReturn(headObjectResponse); + + Resume savedResume = Resume.builder() + .user(user) + .category(ResumeCategory.RESUME) + .fileName("resume.pdf") + .fileFormat(ResumeFileFormat.PDF) + .s3Key("profile/1/RESUME") + .uploadedAt(java.time.LocalDateTime.now()) + .build(); + ReflectionTestUtils.setField(savedResume, "id", 10L); + when(resumeRepository.save(any(Resume.class))).thenReturn(savedResume); + + ResumeConfirmRequest request = new ResumeConfirmRequest("profile/1/RESUME", "resume.pdf", ResumeCategory.RESUME); + + ResumeUploadResponse response = resumeService.confirmUpload(1L, request); + + assertThat(response.fileId()).isEqualTo(10L); + assertThat(response.created()).isTrue(); + verify(resumeRepository).save(any(Resume.class)); + } + + @Test + void 같은_카테고리에_기존_파일이_있으면_갱신한다() { + resumeService = newService(); + User user = user(1L); + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + + Resume existing = Resume.builder() + .user(user) + .category(ResumeCategory.RESUME) + .fileName("old.pdf") + .fileFormat(ResumeFileFormat.PDF) + .s3Key("profile/1/RESUME") + .uploadedAt(java.time.LocalDateTime.now().minusDays(1)) + .build(); + ReflectionTestUtils.setField(existing, "id", 5L); + when(resumeRepository.findByUserAndCategory(user, ResumeCategory.RESUME)).thenReturn(Optional.of(existing)); + + HeadObjectResponse headObjectResponse = HeadObjectResponse.builder() + .contentLength(2048L) + .build(); + when(s3Client.headObject(any(HeadObjectRequest.class))).thenReturn(headObjectResponse); + + ResumeConfirmRequest request = new ResumeConfirmRequest("profile/1/RESUME", "new.docx", ResumeCategory.RESUME); + + ResumeUploadResponse response = resumeService.confirmUpload(1L, request); + + assertThat(response.fileId()).isEqualTo(5L); + assertThat(response.created()).isFalse(); + assertThat(existing.getFileName()).isEqualTo("new.docx"); + assertThat(existing.getFileFormat()).isEqualTo(ResumeFileFormat.DOCX); + verify(resumeRepository, never()).save(any(Resume.class)); + } + + @Test + void 사용자의_파일_목록을_반환한다() { + resumeService = newService(); + User user = user(1L); + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + + Resume resume = Resume.builder() + .user(user) + .category(ResumeCategory.COVER_LETTER) + .fileName("cover.hwp") + .fileFormat(ResumeFileFormat.HWP) + .s3Key("profile/1/COVER_LETTER") + .uploadedAt(java.time.LocalDateTime.now()) + .build(); + when(resumeRepository.findAllByUser(user)).thenReturn(List.of(resume)); + + List responses = resumeService.getFiles(1L); + + assertThat(responses).hasSize(1); + assertThat(responses.get(0).fileName()).isEqualTo("cover.hwp"); + assertThat(responses.get(0).category()).isEqualTo(ResumeCategory.COVER_LETTER); + assertThat(responses.get(0).fileFormat()).isEqualTo(ResumeFileFormat.HWP); + } + + @Test + void 존재하지_않는_파일을_삭제하면_예외를_던진다() { + resumeService = newService(); + User user = user(1L); + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + when(resumeRepository.findByIdAndUser(99L, user)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> resumeService.deleteFile(1L, 99L)) + .isInstanceOf(GeneralException.class) + .satisfies(e -> assertThat(((GeneralException) e).getErrorStatus()) + .isEqualTo(ErrorStatus.RESUME_NOT_FOUND)); + + verify(s3Client, never()).deleteObject(any(DeleteObjectRequest.class)); + } + + @Test + void 파일을_삭제하면_S3_객체와_레코드를_함께_삭제한다() { + resumeService = newService(); + User user = user(1L); + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + + Resume resume = Resume.builder() + .user(user) + .category(ResumeCategory.RESUME) + .fileName("resume.pdf") + .fileFormat(ResumeFileFormat.PDF) + .s3Key("profile/1/RESUME") + .uploadedAt(java.time.LocalDateTime.now()) + .build(); + ReflectionTestUtils.setField(resume, "id", 7L); + when(resumeRepository.findByIdAndUser(7L, user)).thenReturn(Optional.of(resume)); + + resumeService.deleteFile(1L, 7L); + + verify(s3Client).deleteObject(any(DeleteObjectRequest.class)); + verify(resumeRepository).delete(resume); + } +}