Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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,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<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);
SuccessStatus status = response.created()
? SuccessStatus.RESUME_UPLOAD_CONFIRM_SUCCESS
: SuccessStatus.RESUME_UPLOAD_UPDATE_SUCCESS;
return ApiResponse.success(status, 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,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
) {
}
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, boolean created) {
}
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,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)
Expand All @@ -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;
}
}
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,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;
}
}
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