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
49 changes: 25 additions & 24 deletions src/main/java/life/mosu/mosuserver/application/faq/FaqService.java
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,14 @@
import life.mosu.mosuserver.domain.faq.FaqRepository;
import life.mosu.mosuserver.global.exception.CustomRuntimeException;
import life.mosu.mosuserver.global.exception.ErrorCode;
import life.mosu.mosuserver.infra.storage.application.FileUploadHelper;
import life.mosu.mosuserver.infra.storage.application.S3Service;
import life.mosu.mosuserver.infra.storage.domain.FileMoveFailLog;
import life.mosu.mosuserver.infra.storage.domain.FileMoveFailLogRepository;
import life.mosu.mosuserver.infra.storage.domain.Folder;
import life.mosu.mosuserver.presentation.faq.dto.FaqCreateRequest;
import life.mosu.mosuserver.presentation.faq.dto.FaqResponse;
import life.mosu.mosuserver.presentation.faq.dto.FileRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.domain.Page;
Expand All @@ -27,10 +31,11 @@
@Service
@RequiredArgsConstructor
public class FaqService {

private final FaqRepository faqRepository;
private final FaqAttachmentRepository faqAttachmentRepository;
private final S3Service s3Service;
private final ExecutorService executorService;
private final FileUploadHelper fileUploadHelper;

@Value("${aws.s3.presigned-url-expiration-minutes}")
private int durationTime;
Expand All @@ -39,7 +44,7 @@ public class FaqService {
public void createFaq(FaqCreateRequest request) {
FaqJpaEntity faqEntity = faqRepository.save(request.toEntity());

createAttachmentIfPresent(request, faqEntity);
createAttachmentIfPresent(request.attachments(), faqEntity);
}

@Transactional(readOnly = true, propagation = Propagation.SUPPORTS)
Expand All @@ -56,44 +61,39 @@ public List<FaqResponse> getFaqWithAttachments(int page, int size) {
@Transactional
public void deleteFaq(Long faqId) {
FaqJpaEntity faqEntity = faqRepository.findById(faqId)
.orElseThrow(() -> new CustomRuntimeException(ErrorCode.FILE_NOT_FOUND));
.orElseThrow(() -> new CustomRuntimeException(ErrorCode.FILE_NOT_FOUND));
faqRepository.delete(faqEntity);

deleteAttachmentIfPresent(faqEntity);
}


private void createAttachmentIfPresent(FaqCreateRequest request, FaqJpaEntity faqEntity) {
if (request.file() == null || request.file().isEmpty()) {
private void createAttachmentIfPresent(List<FileRequest> fileRequests, FaqJpaEntity faqEntity) {
if (fileRequests == null) {
return;
}

Long faqId = faqEntity.getId();

List<CompletableFuture<Void>> futures = request.file().stream()
.map(file -> CompletableFuture.runAsync(() -> {
String s3Key = s3Service.uploadFile(file, Folder.FAQ);
String fileName = file.getOriginalFilename();
faqAttachmentRepository.save(request.toAttachmentEntity(fileName, s3Key, faqId));
}, executorService))
.toList();

try{
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
}
catch (Exception e) {
throw new CustomRuntimeException(ErrorCode.FILE_UPLOAD_FAILED);
for(FileRequest fileRequest : fileRequests) {
fileUploadHelper.updateTag(fileRequest.s3Key());
faqAttachmentRepository.save(fileRequest.toAttachmentEntity(
fileRequest.fileName(),
fileRequest.s3Key(),
faqId
));
}

}

private FaqResponse toFaqResponse(FaqJpaEntity faq) {
List<FaqAttachmentJpaEntity> attachments = faqAttachmentRepository.findAllByFaqId(faq.getId());
List<FaqResponse.AttachmentResponse> attachmentResponses = toAttachmentResponses(attachments);
List<FaqAttachmentJpaEntity> attachments = faqAttachmentRepository.findAllByFaqId(
faq.getId());
List<FaqResponse.AttachmentResponse> attachmentResponses = toAttachmentResponses(
attachments);
return FaqResponse.of(faq, attachmentResponses);
}

private List<FaqResponse.AttachmentResponse> toAttachmentResponses(List<FaqAttachmentJpaEntity> attachments) {
private List<FaqResponse.AttachmentResponse> toAttachmentResponses(
List<FaqAttachmentJpaEntity> attachments) {
return attachments.stream()
.map(attachment -> new FaqResponse.AttachmentResponse(
attachment.getFileName(),
Expand All @@ -107,7 +107,8 @@ private List<FaqResponse.AttachmentResponse> toAttachmentResponses(List<FaqAttac

//TODO: S3Service에서 SoftDelete 적용 된 파일들에 대해 따로 분리하는 로직 필요할 듯
private void deleteAttachmentIfPresent(FaqJpaEntity entity) {
List<FaqAttachmentJpaEntity> attachments = faqAttachmentRepository.findAllByFaqId(entity.getId());
List<FaqAttachmentJpaEntity> attachments = faqAttachmentRepository.findAllByFaqId(
entity.getId());
faqAttachmentRepository.deleteAll(attachments);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ public class FaqJpaEntity extends BaseTimeEntity {
@Column(name = "faq_id", nullable = false)
private Long id;

@Column(name = "question", nullable = false)
@Column(name = "question", nullable = false, length = 500)
private String question;

@Column(name = "answer", nullable = false)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ public enum ErrorCode {
FILE_UPLOAD_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "파일 업로드에 실패했습니다."),
FILE_DOWNLOAD_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "파일 다운로드에 실패했습니다."),
FILE_DELETE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "파일 삭제에 실패했습니다."),
WRONG_FOLDER_TYPE(HttpStatus.BAD_REQUEST, "잘못된 폴더명 입니다."),

// FAQ 관련 에러
FAQ_NOT_FOUND(HttpStatus.NOT_FOUND, "FAQ를 찾을 수 없습니다."),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package life.mosu.mosuserver.infra.storage.application;

import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import life.mosu.mosuserver.domain.faq.FaqAttachmentRepository;
import life.mosu.mosuserver.global.exception.CustomRuntimeException;
import life.mosu.mosuserver.global.exception.ErrorCode;
import life.mosu.mosuserver.infra.storage.domain.FileMoveFailLog;
import life.mosu.mosuserver.infra.storage.domain.FileMoveFailLogRepository;
import life.mosu.mosuserver.infra.storage.domain.Folder;
import life.mosu.mosuserver.presentation.faq.dto.FileRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class FileUploadHelper {
private final S3Service s3Service;
private final FileMoveFailLogRepository fileMoveFailLogRepository;
private final FaqAttachmentRepository faqAttachmentRepository;
private final ExecutorService executorService;

public void updateTag(String s3Key) {
s3Service.updateFileTagToActive(s3Key);
}

}
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package life.mosu.mosuserver.infra.storage.application;

import java.util.List;
import life.mosu.mosuserver.infra.storage.domain.File;
import life.mosu.mosuserver.infra.storage.domain.Folder;
import life.mosu.mosuserver.infra.storage.presentation.dto.FileUploadResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
Expand All @@ -17,7 +19,9 @@
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.UUID;
import lombok.extern.slf4j.Slf4j;

@Slf4j
@Service
@RequiredArgsConstructor
public class S3Service {
Expand All @@ -32,7 +36,7 @@ public class S3Service {
private int preSignedUrlExpirationMinutes;


public String uploadFile(MultipartFile file, Folder folder) {
public FileUploadResponse uploadFile(MultipartFile file, Folder folder) {
String sanitizedName = sanitizeFileName(file.getOriginalFilename());
String s3Key = folder.getPath() + "/" + UUID.randomUUID() + "_" + sanitizedName;

Expand All @@ -41,6 +45,7 @@ public String uploadFile(MultipartFile file, Folder folder) {
PutObjectRequest.builder()
.bucket(bucketName)
.key(s3Key)
.tagging("status=temp")
.contentType(file.getContentType())
.build(),
RequestBody.fromInputStream(file.getInputStream(), file.getSize())
Expand All @@ -51,7 +56,7 @@ public String uploadFile(MultipartFile file, Folder folder) {
throw new RuntimeException("S3 업로드 실패", e);
}

return s3Key;
return FileUploadResponse.of(file.getOriginalFilename(), s3Key);
}

public void deleteFile(File file) {
Expand All @@ -65,6 +70,18 @@ public void deleteFile(File file) {
}
}

public void updateFileTagToActive(String key) {
PutObjectTaggingRequest tagReq = PutObjectTaggingRequest.builder()
.bucket(bucketName)
.key(key)
.tagging(Tagging.builder()
.tagSet(List.of(Tag.builder().key("status").value("active").build()))
.build())
.build();

s3Client.putObjectTagging(tagReq);
}

public String getUrl(File file) {
return file.isPublic()
? getPublicUrl(file.getS3Key())
Expand Down Expand Up @@ -97,4 +114,11 @@ private String sanitizeFileName(String originalFilename) {
throw new RuntimeException("파일 이름 인코딩 실패", e);
}
}

private String shortenKey(String key) {
if (key.length() <= 40) {
return key;
}
return key.substring(0, 10) + "..." + key.substring(key.length() - 20);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package life.mosu.mosuserver.infra.storage.domain;

import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import life.mosu.mosuserver.domain.base.BaseTimeEntity;
import lombok.Builder;
import lombok.NoArgsConstructor;

@Entity
@NoArgsConstructor(access = lombok.AccessLevel.PROTECTED)
public class FileMoveFailLog extends BaseTimeEntity {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

private Long faqId;

private String s3Key;

private Folder destinationFolder;

private FileMoveFailLog(Long faqId, String s3Key, Folder destinationFolder) {
this.s3Key = s3Key;
this.destinationFolder = destinationFolder;
this.faqId = faqId;
}

public static FileMoveFailLog of(Long faqId, String s3Key, Folder destinationFolder) {
return new FileMoveFailLog(faqId, s3Key, destinationFolder);
}


}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package life.mosu.mosuserver.infra.storage.domain;

import org.springframework.data.jpa.repository.JpaRepository;

public interface FileMoveFailLogRepository extends JpaRepository<FileMoveFailLog, Long> {

}
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package life.mosu.mosuserver.infra.storage.domain;

import life.mosu.mosuserver.global.exception.CustomRuntimeException;
import life.mosu.mosuserver.global.exception.ErrorCode;
import lombok.Getter;

import static life.mosu.mosuserver.infra.storage.domain.Visibility.PRIVATE;
Expand All @@ -11,6 +13,7 @@ public enum Folder {
FAQ("faq", PUBLIC),
NOTICE("notice", PUBLIC),

TEMP("temp", PRIVATE),
INQUIRY("inquiry", PRIVATE),
INQUIRY_ANSWER("inquiryAnswer", PRIVATE),
ADMISSION_TICKET_IMAGE("admissionTicket/images", PRIVATE),
Expand All @@ -23,4 +26,12 @@ public enum Folder {
this.path = path;
this.visibility = visibility;
}

public static Folder validate(String folderName) {
for (Folder folder : Folder.values())
if (folder.name().equals(folderName)) {
return folder;
}
throw new CustomRuntimeException(ErrorCode.WRONG_FOLDER_TYPE);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package life.mosu.mosuserver.infra.storage.presentation;

import jakarta.validation.constraints.NotNull;
import life.mosu.mosuserver.global.exception.CustomRuntimeException;
import life.mosu.mosuserver.global.exception.ErrorCode;
import life.mosu.mosuserver.global.util.ApiResponseWrapper;
import life.mosu.mosuserver.infra.storage.application.S3Service;
import life.mosu.mosuserver.infra.storage.domain.Folder;
import life.mosu.mosuserver.infra.storage.presentation.dto.FileUploadResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;

@RestController
@RequiredArgsConstructor
@RequestMapping("/s3")
public class S3Controller {
private final S3Service s3Service;

@PostMapping
public ApiResponseWrapper<FileUploadResponse> uploadFile(
@RequestParam("file") MultipartFile file,
@RequestParam(defaultValue = "temp") String folderName
) {

if (file.isEmpty()) {
throw new CustomRuntimeException(ErrorCode.FILE_UPLOAD_FAILED, "업로드할 파일이 비어 있습니다.");
}

Folder folder = Folder.validate(folderName);

FileUploadResponse fileResponse = s3Service.uploadFile(file, folder);
return ApiResponseWrapper.success(HttpStatus.CREATED, "파일 업로드 성공", fileResponse);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package life.mosu.mosuserver.infra.storage.presentation.dto;

public record FileUploadResponse(
String fileName,
String s3Key
) {
public static FileUploadResponse of(String fileName, String s3Key) {
return new FileUploadResponse(fileName, s3Key);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import org.springframework.web.bind.annotation.ModelAttribute;
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.RequestParam;
import org.springframework.web.bind.annotation.RestController;
Expand All @@ -24,7 +25,7 @@ public class FaqController {

//TODO: 관리자 권한 체크 추가
@PostMapping
public ApiResponseWrapper<Void> create(@ModelAttribute FaqCreateRequest request) {
public ApiResponseWrapper<Void> create(@RequestBody FaqCreateRequest request) {
faqService.createFaq(request);
return ApiResponseWrapper.success(HttpStatus.CREATED, "게시글 등록 성공");
}
Expand Down
Loading