Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,4 @@ application-prod.yml

## Claude ##
.claude/
.DS_Store
6 changes: 6 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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'

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
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<ApiResponse<PresignedUrlResponse>> 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<ApiResponse<ResumeUploadResponse>> confirmUpload(
@AuthenticationPrincipal Jwt jwt,
@Valid @RequestBody ResumeConfirmRequest request
) {
ResumeUploadResponse response = resumeService.confirmUpload(userId(jwt), request);
return ApiResponse.success(SuccessStatus.RESUME_UPLOAD_CONFIRM_SUCCESS, response);
}

@GetMapping
public ResponseEntity<ApiResponse<List<ResumeResponse>>> getFiles(
@AuthenticationPrincipal Jwt jwt
) {
List<ResumeResponse> response = resumeService.getFiles(userId(jwt));
return ApiResponse.success(SuccessStatus.RESUME_LIST_SUCCESS, response);
}

@DeleteMapping("/{fileId}")
public ResponseEntity<ApiResponse<Void>> 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());
}
}
Original file line number Diff line number Diff line change
@@ -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 PresignedUrlRequest(
@NotNull ResumeCategory category,
@NotBlank String fileName,
@NotBlank String contentType
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.leets7th.job_is_be.domain.user.dto;

public record PresignedUrlResponse(
String presignedUrl,
String objectKey,
long expiresIn
) {
}
Original file line number Diff line number Diff line change
@@ -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
) {
}
Original file line number Diff line number Diff line change
@@ -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()
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
package com.leets7th.job_is_be.domain.user.dto;

public record ResumeUploadResponse(Long fileId) {
}
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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 {
Expand All @@ -26,36 +33,39 @@ 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;

@Column(name = "uploaded_at", nullable = false)
private LocalDateTime uploadedAt;

@Column(name = "deleted_at")
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;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.leets7th.job_is_be.domain.user.enums;

/**
* 이력서/자소서 구분 (유형별 1슬롯 제한의 기준이 되는 유형)
*/
public enum ResumeCategory {
RESUME,
COVER_LETTER
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.leets7th.job_is_be.domain.user.enums;

/**
* 업로드 허용 파일 형식 (확장자 기준)
*/
public enum ResumeFileFormat {
PDF,
DOCX,
HWP,
HWPX;

public static ResumeFileFormat fromExtension(String extension) {
return valueOf(extension.toUpperCase());
}
}
Original file line number Diff line number Diff line change
@@ -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<Resume, Long> {

Optional<Resume> findByUserAndCategory(User user, ResumeCategory category);

List<Resume> findAllByUser(User user);

Optional<Resume> findByIdAndUser(Long id, User user);
}
Loading