Skip to content

Commit 4bf4178

Browse files
authored
Merge pull request #33 from Leets-Official/feat/#30
[Feat] #30 이력서/자소서 Presigned URL 업로드 기능 구현
2 parents 305c0cf + 13030a6 commit 4bf4178

19 files changed

Lines changed: 773 additions & 16 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,3 +47,4 @@ application-prod.yml
4747

4848
## Claude ##
4949
.claude/
50+
.DS_Store

build.gradle

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,13 @@ repositories {
2121

2222
dependencies {
2323

24+
implementation platform('software.amazon.awssdk:bom:2.48.4')
25+
2426
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
2527
implementation 'org.springframework.boot:spring-boot-starter-security'
2628
implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server'
2729
implementation 'org.springframework.boot:spring-boot-starter-webmvc'
30+
implementation 'org.springframework.boot:spring-boot-starter-validation'
2831
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.2'
2932

3033
// Lombok
@@ -34,6 +37,9 @@ dependencies {
3437
// PostgreSQL
3538
implementation 'org.postgresql:postgresql'
3639

40+
// AWS S3 (Presigned URL 업로드)
41+
implementation 'software.amazon.awssdk:s3'
42+
3743
implementation 'org.springframework.boot:spring-boot-starter-cache'
3844
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
3945

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
package com.leets7th.job_is_be.domain.user.controller;
2+
3+
import com.leets7th.job_is_be.domain.user.dto.PresignedUrlRequest;
4+
import com.leets7th.job_is_be.domain.user.dto.PresignedUrlResponse;
5+
import com.leets7th.job_is_be.domain.user.dto.ResumeConfirmRequest;
6+
import com.leets7th.job_is_be.domain.user.dto.ResumeResponse;
7+
import com.leets7th.job_is_be.domain.user.dto.ResumeUploadResponse;
8+
import com.leets7th.job_is_be.domain.user.service.ResumeService;
9+
import com.leets7th.job_is_be.global.response.ApiResponse;
10+
import com.leets7th.job_is_be.global.status.SuccessStatus;
11+
import jakarta.validation.Valid;
12+
import org.springframework.http.ResponseEntity;
13+
import org.springframework.security.core.annotation.AuthenticationPrincipal;
14+
import org.springframework.security.oauth2.jwt.Jwt;
15+
import org.springframework.web.bind.annotation.DeleteMapping;
16+
import org.springframework.web.bind.annotation.GetMapping;
17+
import org.springframework.web.bind.annotation.PathVariable;
18+
import org.springframework.web.bind.annotation.PostMapping;
19+
import org.springframework.web.bind.annotation.RequestBody;
20+
import org.springframework.web.bind.annotation.RequestMapping;
21+
import org.springframework.web.bind.annotation.RestController;
22+
23+
import java.util.List;
24+
25+
@RestController
26+
@RequestMapping("/api/profile/files")
27+
public class ResumeController {
28+
29+
private final ResumeService resumeService;
30+
31+
public ResumeController(ResumeService resumeService) {
32+
this.resumeService = resumeService;
33+
}
34+
35+
@PostMapping("/presigned-url")
36+
public ResponseEntity<ApiResponse<PresignedUrlResponse>> issuePresignedUrl(
37+
@AuthenticationPrincipal Jwt jwt,
38+
@Valid @RequestBody PresignedUrlRequest request
39+
) {
40+
PresignedUrlResponse response = resumeService.issuePresignedUrl(userId(jwt), request);
41+
return ApiResponse.success(SuccessStatus.RESUME_PRESIGNED_URL_SUCCESS, response);
42+
}
43+
44+
@PostMapping
45+
public ResponseEntity<ApiResponse<ResumeUploadResponse>> confirmUpload(
46+
@AuthenticationPrincipal Jwt jwt,
47+
@Valid @RequestBody ResumeConfirmRequest request
48+
) {
49+
ResumeUploadResponse response = resumeService.confirmUpload(userId(jwt), request);
50+
SuccessStatus status = response.created()
51+
? SuccessStatus.RESUME_UPLOAD_CONFIRM_SUCCESS
52+
: SuccessStatus.RESUME_UPLOAD_UPDATE_SUCCESS;
53+
return ApiResponse.success(status, response);
54+
}
55+
56+
@GetMapping
57+
public ResponseEntity<ApiResponse<List<ResumeResponse>>> getFiles(
58+
@AuthenticationPrincipal Jwt jwt
59+
) {
60+
List<ResumeResponse> response = resumeService.getFiles(userId(jwt));
61+
return ApiResponse.success(SuccessStatus.RESUME_LIST_SUCCESS, response);
62+
}
63+
64+
@DeleteMapping("/{fileId}")
65+
public ResponseEntity<ApiResponse<Void>> deleteFile(
66+
@AuthenticationPrincipal Jwt jwt,
67+
@PathVariable Long fileId
68+
) {
69+
resumeService.deleteFile(userId(jwt), fileId);
70+
return ApiResponse.success(SuccessStatus.RESUME_DELETE_SUCCESS);
71+
}
72+
73+
private Long userId(Jwt jwt) {
74+
return Long.valueOf(jwt.getSubject());
75+
}
76+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package com.leets7th.job_is_be.domain.user.dto;
2+
3+
import com.leets7th.job_is_be.domain.user.enums.ResumeCategory;
4+
import jakarta.validation.constraints.NotBlank;
5+
import jakarta.validation.constraints.NotNull;
6+
7+
public record PresignedUrlRequest(
8+
@NotNull ResumeCategory category,
9+
@NotBlank String fileName
10+
) {
11+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
package com.leets7th.job_is_be.domain.user.dto;
2+
3+
public record PresignedUrlResponse(
4+
String presignedUrl,
5+
String objectKey,
6+
long expiresIn
7+
) {
8+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package com.leets7th.job_is_be.domain.user.dto;
2+
3+
import com.leets7th.job_is_be.domain.user.enums.ResumeCategory;
4+
import jakarta.validation.constraints.NotBlank;
5+
import jakarta.validation.constraints.NotNull;
6+
7+
public record ResumeConfirmRequest(
8+
@NotBlank String objectKey,
9+
@NotBlank String fileName,
10+
@NotNull ResumeCategory category
11+
) {
12+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package com.leets7th.job_is_be.domain.user.dto;
2+
3+
import com.leets7th.job_is_be.domain.user.entity.Resume;
4+
import com.leets7th.job_is_be.domain.user.enums.ResumeCategory;
5+
import com.leets7th.job_is_be.domain.user.enums.ResumeFileFormat;
6+
7+
import java.time.LocalDateTime;
8+
9+
public record ResumeResponse(
10+
Long fileId,
11+
ResumeCategory category,
12+
String fileName,
13+
ResumeFileFormat fileFormat,
14+
LocalDateTime uploadedAt
15+
) {
16+
public static ResumeResponse from(Resume resume) {
17+
return new ResumeResponse(
18+
resume.getId(),
19+
resume.getCategory(),
20+
resume.getFileName(),
21+
resume.getFileFormat(),
22+
resume.getUploadedAt()
23+
);
24+
}
25+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
package com.leets7th.job_is_be.domain.user.dto;
2+
3+
public record ResumeUploadResponse(Long fileId, boolean created) {
4+
}
Lines changed: 28 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
package com.leets7th.job_is_be.domain.user.entity;
22

3+
import com.leets7th.job_is_be.domain.user.enums.ResumeCategory;
4+
import com.leets7th.job_is_be.domain.user.enums.ResumeFileFormat;
35
import com.leets7th.job_is_be.global.base.BaseEntity;
46
import jakarta.persistence.*;
57
import lombok.AccessLevel;
@@ -10,10 +12,15 @@
1012
import java.time.LocalDateTime;
1113

1214
/**
13-
* 이력서/자소서 파일
15+
* 이력서/자소서 파일 (S3 Presigned URL 업로드, 유형별 1슬롯)
1416
*/
1517
@Entity
16-
@Table(name = "resumes")
18+
@Table(
19+
name = "resumes",
20+
uniqueConstraints = {
21+
@UniqueConstraint(name = "uk_resumes_user_category", columnNames = {"user_id", "category"})
22+
}
23+
)
1724
@Getter
1825
@NoArgsConstructor(access = AccessLevel.PROTECTED)
1926
public class Resume extends BaseEntity {
@@ -26,17 +33,19 @@ public class Resume extends BaseEntity {
2633
@JoinColumn(name = "user_id", nullable = false)
2734
private User user;
2835

36+
@Enumerated(EnumType.STRING)
37+
@Column(name = "category", nullable = false, length = 20)
38+
private ResumeCategory category;
39+
2940
@Column(name = "file_name", nullable = false, length = 255)
3041
private String fileName;
3142

43+
@Enumerated(EnumType.STRING)
3244
@Column(name = "file_type", nullable = false, length = 10)
33-
private String fileType; // PDF, DOCX, HWP, HWPX
34-
35-
@Column(name = "file_url", nullable = false, length = 500)
36-
private String fileUrl;
45+
private ResumeFileFormat fileFormat;
3746

38-
@Column(name = "is_active", nullable = false)
39-
private boolean active;
47+
@Column(name = "s3_key", nullable = false, length = 500)
48+
private String s3Key;
4049

4150
// TODO: LocalDateTime → OffsetDateTime으로 통일 필요 (BaseEntity와 타입 불일치)
4251
@Column(name = "uploaded_at", nullable = false)
@@ -47,17 +56,21 @@ public class Resume extends BaseEntity {
4756
private LocalDateTime deletedAt;
4857

4958
@Builder
50-
public Resume(User user, String fileName, String fileType, String fileUrl, LocalDateTime uploadedAt) {
59+
public Resume(User user, ResumeCategory category, String fileName, ResumeFileFormat fileFormat, String s3Key, LocalDateTime uploadedAt) {
5160
this.user = user;
61+
this.category = category;
5262
this.fileName = fileName;
53-
this.fileType = fileType;
54-
this.fileUrl = fileUrl;
63+
this.fileFormat = fileFormat;
64+
this.s3Key = s3Key;
5565
this.uploadedAt = uploadedAt;
56-
this.active = true;
5766
}
5867

59-
public void delete(LocalDateTime now) {
60-
this.active = false;
61-
this.deletedAt = now;
68+
/**
69+
* 같은 유형(user+category) 재업로드 시 기존 row를 새 파일 정보로 갱신 (S3 오브젝트 키는 고정이라 그대로 유지)
70+
*/
71+
public void replace(String fileName, ResumeFileFormat fileFormat, LocalDateTime uploadedAt) {
72+
this.fileName = fileName;
73+
this.fileFormat = fileFormat;
74+
this.uploadedAt = uploadedAt;
6275
}
6376
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package com.leets7th.job_is_be.domain.user.enums;
2+
3+
/**
4+
* 이력서/자소서 구분 (유형별 1슬롯 제한의 기준이 되는 유형)
5+
*/
6+
public enum ResumeCategory {
7+
RESUME,
8+
COVER_LETTER
9+
}

0 commit comments

Comments
 (0)