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
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package es.princip.ringus.domain.exception;

import es.princip.ringus.global.exception.ErrorCode;
import org.springframework.http.HttpStatus;

public enum FileErrorCode implements ErrorCode {
FILE_NOT_FOUND(HttpStatus.NOT_FOUND,"파일을 찾을 수 없음"),
FILE_UPLOAD_FAILED(HttpStatus.BAD_REQUEST,"파일 업로드 실패"),
FILE_DELETE_FAILED(HttpStatus.BAD_REQUEST,"파일 삭제 실패"),
FILE_DOWNLOAD_FAILED(HttpStatus.BAD_REQUEST,"파일 다운로드 실패"),
FILE_NOT_ALLOWED(HttpStatus.BAD_REQUEST,"허용되지 않는 파일 형식"),
FILE_SIZE_EXCEEDED(HttpStatus.BAD_REQUEST,"파일 크기 초과");

FileErrorCode( HttpStatus status,String message) {
this.status = status;
this.message = message;
}

private final String message;
private final HttpStatus status;

@Override
public HttpStatus status() {
return this.status;
}

@Override
public String message() {
return this.message;
}

@Override
public String code() {
return this.name();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,16 @@
import es.princip.ringus.global.util.ApiResponseWrapper;
import es.princip.ringus.infra.storage.application.StorageCertificateService;
import es.princip.ringus.infra.storage.dto.CertificateUploadRequest;
import es.princip.ringus.infra.storage.dto.FilePreviewResponse;
import jakarta.servlet.http.HttpSession;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;

import java.util.List;

@RestController
@RequestMapping("/storage/certificate")
@RequiredArgsConstructor
Expand Down Expand Up @@ -52,4 +56,6 @@ public ResponseEntity<ApiResponseWrapper<Void>> uploadMentorCertificate(
return ResponseEntity.ok(ApiResponseWrapper.success(HttpStatus.OK, filePath));
}



}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package es.princip.ringus.infra.storage.api;

import es.princip.ringus.global.util.ApiResponseWrapper;
import es.princip.ringus.infra.storage.application.StorageFileService;
import es.princip.ringus.infra.storage.dto.FilePreviewResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.List;

@RestController
@RequestMapping("/storage/files")
@RequiredArgsConstructor
public class FileController implements FileControllerDocs{

private final StorageFileService storageFileService;
/**
* 관리자 전용 - 증명서 presigned URL 발급
*/
@PreAuthorize("hasRole('ADMIN')")
@GetMapping("/{fileMemberId}")
public ResponseEntity<ApiResponseWrapper<FilePreviewResponse>> getFile(
@PathVariable Long fileMemberId
) {
FilePreviewResponse filePreviewResponse = storageFileService.getFile(fileMemberId);
return ResponseEntity.ok(ApiResponseWrapper.success(HttpStatus.OK, "success",filePreviewResponse));
}

@PreAuthorize("hasRole('ADMIN')")
@GetMapping
public ResponseEntity<ApiResponseWrapper<List<FilePreviewResponse>>> getFiles(
) {
List<FilePreviewResponse> filePreviewResponses = storageFileService.getFiles();
return ResponseEntity.ok(ApiResponseWrapper.success(HttpStatus.OK, "success",filePreviewResponses));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package es.princip.ringus.infra.storage.api;

import es.princip.ringus.global.util.ApiResponseWrapper;
import es.princip.ringus.infra.storage.dto.FilePreviewResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;

import java.util.List;

@Tag(name = "파일 관리 API", description = "파일 관리 API")
@RequestMapping("/storage/files")
public interface FileControllerDocs {

/**
* 관리자 전용 - 단일 파일 조회
*/
@Operation(summary = "단일 파일 조회",
description = "관리자가 특정 파일의 **Presigned URL**을 조회합니다. 해당 파일은 **S3에서 Presigned URL을 통해 다운로드** 가능합니다.",
parameters = {
@Parameter(name = "fileMemberId", description = "조회할 파일의 고유 ID", required = true)
})
@ApiResponse(responseCode = "200", description = "파일 조회 성공")
@ApiResponse(responseCode = "401", description = "권한 없음 (관리자만 접근 가능)")
@ApiResponse(responseCode = "404", description = "파일을 찾을 수 없음")
@GetMapping("/{fileMemberId}")
ResponseEntity<ApiResponseWrapper<FilePreviewResponse>> getFile(
@Parameter(description = "파일 멤버 ID", required = true)
@PathVariable Long fileMemberId
);

/**
* 관리자 전용 - 파일 목록 조회
*/
@Operation(summary = "파일 목록 조회",
description = "관리자가 **파일 목록**을 조회할 수 있습니다. 각 파일의 **Presigned URL**이 포함된 목록을 반환합니다.")
@ApiResponse(responseCode = "200", description = "파일 목록 조회 성공")
@ApiResponse(responseCode = "401", description = "권한 없음 (관리자만 접근 가능)")
@ApiResponse(responseCode = "404", description = "파일이 존재하지 않음")
@GetMapping
ResponseEntity<ApiResponseWrapper<List<FilePreviewResponse>>> getFiles();
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package es.princip.ringus.infra.storage.api;

import es.princip.ringus.domain.exception.MemberErrorCode;
import es.princip.ringus.global.annotation.SessionCheck;
import es.princip.ringus.global.annotation.SessionMemberId;
import es.princip.ringus.global.exception.CustomRuntimeException;
import es.princip.ringus.global.util.ApiResponseWrapper;
Expand All @@ -23,10 +24,13 @@ public class ProfileImageController implements ProfileImageControllerDocs {
* 프로필 이미지 업로드
*/
@PostMapping("/image")
@SessionCheck
public ResponseEntity<ApiResponseWrapper<Void>> uploadProfileImage(
@ModelAttribute ProfileUploadRequest request
@ModelAttribute ProfileUploadRequest request,
@SessionMemberId Long memberId
) {
String filePath = storageProfileService.uploadProfileImage(request);

String filePath = storageProfileService.uploadProfileImage(request, memberId);
return ResponseEntity.ok(ApiResponseWrapper.success(HttpStatus.OK, filePath));
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package es.princip.ringus.infra.storage.api;

import es.princip.ringus.global.annotation.SessionMemberId;
import es.princip.ringus.global.util.ApiResponseWrapper;
import es.princip.ringus.infra.storage.dto.ProfileUploadRequest;
import io.swagger.v3.oas.annotations.Operation;
Expand Down Expand Up @@ -32,6 +33,9 @@ public interface ProfileImageControllerDocs {
@PostMapping(value = "/image", consumes = "multipart/form-data")
ResponseEntity<ApiResponseWrapper<Void>> uploadProfileImage(
@Parameter(description = "프로필 이미지 업로드 요청 데이터 (폼데이터 형식)", required = true)
@ModelAttribute ProfileUploadRequest request
@ModelAttribute ProfileUploadRequest request,
@Parameter(description = "세션에서 조회한 사용자 ID", hidden = true)
@SessionMemberId
Long memberId
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,21 @@
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider;
import software.amazon.awssdk.core.sync.RequestBody;
import software.amazon.awssdk.regions.Region;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.model.GetObjectRequest;
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.GetObjectPresignRequest;
import software.amazon.awssdk.services.s3.presigner.model.PresignedGetObjectRequest;

import java.io.IOException;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.UUID;

@Service
Expand All @@ -22,21 +29,23 @@ public class S3Service {
@Value("${aws.s3.bucket-name}")
private String bucketName;

@Value("${aws.s3.region}")
private String region;
/**
* S3에 파일 업로드
* @param file 업로드할 파일
* @param folderPath S3에 저장할 폴더 경로 (예: "images/profile/mentor", "certificates/mentee/ENROLLMENT" 등)
* @return 업로드된 파일의 S3 URL
*/
public String uploadFile(MultipartFile file, String folderPath) {
public String uploadFile(MultipartFile file, String folderPath, boolean isPublic) {
String fileName = UUID.randomUUID() + "_" + sanitizeFileName(file.getOriginalFilename());
String s3Key = folderPath + "/" + fileName;

try {
s3Client.putObject(
PutObjectRequest.builder()
.bucket(bucketName)
.key(s3Key)
.acl(isPublic ? "public-read" : "private")
.contentType(file.getContentType())
.build(),
RequestBody.fromInputStream(file.getInputStream(), file.getSize())
Expand Down Expand Up @@ -68,4 +77,25 @@ private String sanitizeFileName(String originalFilename) {
throw new RuntimeException("파일 이름 인코딩에 실패했습니다.", e);
}
}

public String generatePresignedUrl(String s3Key, Duration duration) {
try (S3Presigner presigner = S3Presigner.builder()
.region(Region.of(region))
.credentialsProvider(DefaultCredentialsProvider.create())
.build()) {

GetObjectRequest getObjectRequest = GetObjectRequest.builder()
.bucket(bucketName)
.key(s3Key)
.build();

GetObjectPresignRequest presignRequest = GetObjectPresignRequest.builder()
.getObjectRequest(getObjectRequest)
.signatureDuration(duration)
.build();

PresignedGetObjectRequest presignedRequest = presigner.presignGetObject(presignRequest);
return presignedRequest.url().toString();
}
}
}
Original file line number Diff line number Diff line change
@@ -1,26 +1,38 @@
package es.princip.ringus.infra.storage.application;

import es.princip.ringus.domain.exception.FileErrorCode;
import es.princip.ringus.domain.exception.MemberErrorCode;
import es.princip.ringus.domain.member.Member;
import es.princip.ringus.domain.member.MemberRepository;
import es.princip.ringus.global.exception.CustomRuntimeException;
import es.princip.ringus.global.util.StoragePathUtil;
import es.princip.ringus.infra.storage.domain.FileMember;
import es.princip.ringus.infra.storage.domain.FileMemberRepository;
import es.princip.ringus.infra.storage.dto.CertificateUploadRequest;
import es.princip.ringus.infra.storage.dto.FilePreviewResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.time.Duration;
import java.util.List;
import java.util.stream.Collectors;

@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class StorageCertificateService {
private final S3Service s3Service;
private final FileMemberRepository fileMemberRepository;
private final MemberRepository memberRepository;

/**
* 멘티 증명서 업로드
*/
@Transactional
public String uploadMenteeCertificate(CertificateUploadRequest request, Long memberId) {
String folderPath = StoragePathUtil.buildCertificateFolderPath(request.certificateType(), false);
String filePath = s3Service.uploadFile(request.file(), folderPath);
String filePath = s3Service.uploadFile(request.file(), folderPath, false);

fileMemberRepository.save(request.toFileMemberEntity(filePath, memberId));

Expand All @@ -33,11 +45,12 @@ public String uploadMenteeCertificate(CertificateUploadRequest request, Long mem
@Transactional
public String uploadMentorCertificate(CertificateUploadRequest request, Long memberId) {
String folderPath = StoragePathUtil.buildCertificateFolderPath(request.certificateType(), true);
String filePath = s3Service.uploadFile(request.file(), folderPath);
String filePath = s3Service.uploadFile(request.file(), folderPath, false);

fileMemberRepository.save(request.toFileMemberEntity(filePath, memberId));

return filePath;
}


}
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package es.princip.ringus.infra.storage.application;

import es.princip.ringus.domain.exception.FileErrorCode;
import es.princip.ringus.domain.exception.MemberErrorCode;
import es.princip.ringus.domain.member.Member;
import es.princip.ringus.domain.member.MemberRepository;
import es.princip.ringus.global.exception.CustomRuntimeException;
import es.princip.ringus.infra.storage.domain.FileMember;
import es.princip.ringus.infra.storage.domain.FileMemberRepository;
import es.princip.ringus.infra.storage.dto.FilePreviewResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.time.Duration;
import java.util.List;
import java.util.stream.Collectors;

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class StorageFileService {
private final S3Service s3Service;
private final FileMemberRepository fileMemberRepository;
private final MemberRepository memberRepository;

@Transactional
public FilePreviewResponse getFile(Long fileMemberId) {
FileMember fileMember = fileMemberRepository.findById(fileMemberId)
.orElseThrow(() -> new CustomRuntimeException(FileErrorCode.FILE_NOT_FOUND));

Member member = getMember(fileMember.getMemberId());

String presignedUrl = getPresignedUrl(fileMember.getFilePath());

return FilePreviewResponse.of(presignedUrl, fileMember, member);
}

public List<FilePreviewResponse> getFiles() {
List<FileMember> fileMembers = fileMemberRepository.findAll();
return fileMembers.stream()
.map(fileMember -> {
Member member = getMember(fileMember.getMemberId());
String presignedUrl = getPresignedUrl(fileMember.getFilePath());
return FilePreviewResponse.of(presignedUrl, fileMember, member);
})
.collect(Collectors.toList());
}

private String getPresignedUrl(String filePath) {
return s3Service.generatePresignedUrl(filePath, Duration.ofMinutes(10));
}

private Member getMember(Long memberId) {
return memberRepository.findById(memberId)
.orElseThrow(() -> new CustomRuntimeException(MemberErrorCode.MEMBER_NOT_FOUND));
}
}
Loading