Skip to content

Commit ca26c3a

Browse files
authored
MOSU-21 refactor: 파일 업로드 기능과 FAQ 기능을 분리 (#35)
* MOSU-21 refactor: 파일 업로드와 FAQ 등록 기능을 분리 * MOSU-21 refactor: 파일 업로드 시 folderName과 입력 유성 검증 추가 * MOSU-21 refactor: 파일 고아 객체 tag로 처리하도록 변경 * MOSU-21 refactor: question 답변 길이 500으로 설정 * MOSU-21 test: 파일 등록 메서드 테스트
1 parent bd2fd75 commit ca26c3a

File tree

15 files changed

+217
-54
lines changed

15 files changed

+217
-54
lines changed

src/main/java/life/mosu/mosuserver/application/faq/FaqService.java

Lines changed: 25 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,14 @@
1010
import life.mosu.mosuserver.domain.faq.FaqRepository;
1111
import life.mosu.mosuserver.global.exception.CustomRuntimeException;
1212
import life.mosu.mosuserver.global.exception.ErrorCode;
13+
import life.mosu.mosuserver.infra.storage.application.FileUploadHelper;
1314
import life.mosu.mosuserver.infra.storage.application.S3Service;
15+
import life.mosu.mosuserver.infra.storage.domain.FileMoveFailLog;
16+
import life.mosu.mosuserver.infra.storage.domain.FileMoveFailLogRepository;
1417
import life.mosu.mosuserver.infra.storage.domain.Folder;
1518
import life.mosu.mosuserver.presentation.faq.dto.FaqCreateRequest;
1619
import life.mosu.mosuserver.presentation.faq.dto.FaqResponse;
20+
import life.mosu.mosuserver.presentation.faq.dto.FileRequest;
1721
import lombok.RequiredArgsConstructor;
1822
import org.springframework.beans.factory.annotation.Value;
1923
import org.springframework.data.domain.Page;
@@ -27,10 +31,11 @@
2731
@Service
2832
@RequiredArgsConstructor
2933
public class FaqService {
34+
3035
private final FaqRepository faqRepository;
3136
private final FaqAttachmentRepository faqAttachmentRepository;
3237
private final S3Service s3Service;
33-
private final ExecutorService executorService;
38+
private final FileUploadHelper fileUploadHelper;
3439

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

42-
createAttachmentIfPresent(request, faqEntity);
47+
createAttachmentIfPresent(request.attachments(), faqEntity);
4348
}
4449

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

6267
deleteAttachmentIfPresent(faqEntity);
6368
}
6469

6570

66-
private void createAttachmentIfPresent(FaqCreateRequest request, FaqJpaEntity faqEntity) {
67-
if (request.file() == null || request.file().isEmpty()) {
71+
private void createAttachmentIfPresent(List<FileRequest> fileRequests, FaqJpaEntity faqEntity) {
72+
if (fileRequests == null) {
6873
return;
6974
}
70-
7175
Long faqId = faqEntity.getId();
7276

73-
List<CompletableFuture<Void>> futures = request.file().stream()
74-
.map(file -> CompletableFuture.runAsync(() -> {
75-
String s3Key = s3Service.uploadFile(file, Folder.FAQ);
76-
String fileName = file.getOriginalFilename();
77-
faqAttachmentRepository.save(request.toAttachmentEntity(fileName, s3Key, faqId));
78-
}, executorService))
79-
.toList();
80-
81-
try{
82-
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
83-
}
84-
catch (Exception e) {
85-
throw new CustomRuntimeException(ErrorCode.FILE_UPLOAD_FAILED);
77+
for(FileRequest fileRequest : fileRequests) {
78+
fileUploadHelper.updateTag(fileRequest.s3Key());
79+
faqAttachmentRepository.save(fileRequest.toAttachmentEntity(
80+
fileRequest.fileName(),
81+
fileRequest.s3Key(),
82+
faqId
83+
));
8684
}
87-
8885
}
8986

9087
private FaqResponse toFaqResponse(FaqJpaEntity faq) {
91-
List<FaqAttachmentJpaEntity> attachments = faqAttachmentRepository.findAllByFaqId(faq.getId());
92-
List<FaqResponse.AttachmentResponse> attachmentResponses = toAttachmentResponses(attachments);
88+
List<FaqAttachmentJpaEntity> attachments = faqAttachmentRepository.findAllByFaqId(
89+
faq.getId());
90+
List<FaqResponse.AttachmentResponse> attachmentResponses = toAttachmentResponses(
91+
attachments);
9392
return FaqResponse.of(faq, attachmentResponses);
9493
}
9594

96-
private List<FaqResponse.AttachmentResponse> toAttachmentResponses(List<FaqAttachmentJpaEntity> attachments) {
95+
private List<FaqResponse.AttachmentResponse> toAttachmentResponses(
96+
List<FaqAttachmentJpaEntity> attachments) {
9797
return attachments.stream()
9898
.map(attachment -> new FaqResponse.AttachmentResponse(
9999
attachment.getFileName(),
@@ -107,7 +107,8 @@ private List<FaqResponse.AttachmentResponse> toAttachmentResponses(List<FaqAttac
107107

108108
//TODO: S3Service에서 SoftDelete 적용 된 파일들에 대해 따로 분리하는 로직 필요할 듯
109109
private void deleteAttachmentIfPresent(FaqJpaEntity entity) {
110-
List<FaqAttachmentJpaEntity> attachments = faqAttachmentRepository.findAllByFaqId(entity.getId());
110+
List<FaqAttachmentJpaEntity> attachments = faqAttachmentRepository.findAllByFaqId(
111+
entity.getId());
111112
faqAttachmentRepository.deleteAll(attachments);
112113
}
113114
}

src/main/java/life/mosu/mosuserver/domain/faq/FaqJpaEntity.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ public class FaqJpaEntity extends BaseTimeEntity {
1616
@Column(name = "faq_id", nullable = false)
1717
private Long id;
1818

19-
@Column(name = "question", nullable = false)
19+
@Column(name = "question", nullable = false, length = 500)
2020
private String question;
2121

2222
@Column(name = "answer", nullable = false)

src/main/java/life/mosu/mosuserver/global/exception/ErrorCode.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ public enum ErrorCode {
3333
FILE_UPLOAD_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "파일 업로드에 실패했습니다."),
3434
FILE_DOWNLOAD_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "파일 다운로드에 실패했습니다."),
3535
FILE_DELETE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "파일 삭제에 실패했습니다."),
36+
WRONG_FOLDER_TYPE(HttpStatus.BAD_REQUEST, "잘못된 폴더명 입니다."),
3637

3738
// FAQ 관련 에러
3839
FAQ_NOT_FOUND(HttpStatus.NOT_FOUND, "FAQ를 찾을 수 없습니다."),
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
package life.mosu.mosuserver.infra.storage.application;
2+
3+
import java.util.List;
4+
import java.util.concurrent.CompletableFuture;
5+
import java.util.concurrent.ExecutorService;
6+
import life.mosu.mosuserver.domain.faq.FaqAttachmentRepository;
7+
import life.mosu.mosuserver.global.exception.CustomRuntimeException;
8+
import life.mosu.mosuserver.global.exception.ErrorCode;
9+
import life.mosu.mosuserver.infra.storage.domain.FileMoveFailLog;
10+
import life.mosu.mosuserver.infra.storage.domain.FileMoveFailLogRepository;
11+
import life.mosu.mosuserver.infra.storage.domain.Folder;
12+
import life.mosu.mosuserver.presentation.faq.dto.FileRequest;
13+
import lombok.RequiredArgsConstructor;
14+
import org.springframework.stereotype.Service;
15+
16+
@Service
17+
@RequiredArgsConstructor
18+
public class FileUploadHelper {
19+
private final S3Service s3Service;
20+
private final FileMoveFailLogRepository fileMoveFailLogRepository;
21+
private final FaqAttachmentRepository faqAttachmentRepository;
22+
private final ExecutorService executorService;
23+
24+
public void updateTag(String s3Key) {
25+
s3Service.updateFileTagToActive(s3Key);
26+
}
27+
28+
}

src/main/java/life/mosu/mosuserver/infra/storage/application/S3Service.java

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
package life.mosu.mosuserver.infra.storage.application;
22

3+
import java.util.List;
34
import life.mosu.mosuserver.infra.storage.domain.File;
45
import life.mosu.mosuserver.infra.storage.domain.Folder;
6+
import life.mosu.mosuserver.infra.storage.presentation.dto.FileUploadResponse;
57
import lombok.RequiredArgsConstructor;
68
import org.springframework.beans.factory.annotation.Value;
79
import org.springframework.stereotype.Service;
@@ -17,7 +19,9 @@
1719
import java.nio.charset.StandardCharsets;
1820
import java.time.Duration;
1921
import java.util.UUID;
22+
import lombok.extern.slf4j.Slf4j;
2023

24+
@Slf4j
2125
@Service
2226
@RequiredArgsConstructor
2327
public class S3Service {
@@ -32,7 +36,7 @@ public class S3Service {
3236
private int preSignedUrlExpirationMinutes;
3337

3438

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

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

54-
return s3Key;
59+
return FileUploadResponse.of(file.getOriginalFilename(), s3Key);
5560
}
5661

5762
public void deleteFile(File file) {
@@ -65,6 +70,18 @@ public void deleteFile(File file) {
6570
}
6671
}
6772

73+
public void updateFileTagToActive(String key) {
74+
PutObjectTaggingRequest tagReq = PutObjectTaggingRequest.builder()
75+
.bucket(bucketName)
76+
.key(key)
77+
.tagging(Tagging.builder()
78+
.tagSet(List.of(Tag.builder().key("status").value("active").build()))
79+
.build())
80+
.build();
81+
82+
s3Client.putObjectTagging(tagReq);
83+
}
84+
6885
public String getUrl(File file) {
6986
return file.isPublic()
7087
? getPublicUrl(file.getS3Key())
@@ -97,4 +114,11 @@ private String sanitizeFileName(String originalFilename) {
97114
throw new RuntimeException("파일 이름 인코딩 실패", e);
98115
}
99116
}
117+
118+
private String shortenKey(String key) {
119+
if (key.length() <= 40) {
120+
return key;
121+
}
122+
return key.substring(0, 10) + "..." + key.substring(key.length() - 20);
123+
}
100124
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
package life.mosu.mosuserver.infra.storage.domain;
2+
3+
import jakarta.persistence.Entity;
4+
import jakarta.persistence.Id;
5+
import jakarta.persistence.GeneratedValue;
6+
import jakarta.persistence.GenerationType;
7+
import life.mosu.mosuserver.domain.base.BaseTimeEntity;
8+
import lombok.Builder;
9+
import lombok.NoArgsConstructor;
10+
11+
@Entity
12+
@NoArgsConstructor(access = lombok.AccessLevel.PROTECTED)
13+
public class FileMoveFailLog extends BaseTimeEntity {
14+
15+
@Id
16+
@GeneratedValue(strategy = GenerationType.IDENTITY)
17+
private Long id;
18+
19+
private Long faqId;
20+
21+
private String s3Key;
22+
23+
private Folder destinationFolder;
24+
25+
private FileMoveFailLog(Long faqId, String s3Key, Folder destinationFolder) {
26+
this.s3Key = s3Key;
27+
this.destinationFolder = destinationFolder;
28+
this.faqId = faqId;
29+
}
30+
31+
public static FileMoveFailLog of(Long faqId, String s3Key, Folder destinationFolder) {
32+
return new FileMoveFailLog(faqId, s3Key, destinationFolder);
33+
}
34+
35+
36+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
package life.mosu.mosuserver.infra.storage.domain;
2+
3+
import org.springframework.data.jpa.repository.JpaRepository;
4+
5+
public interface FileMoveFailLogRepository extends JpaRepository<FileMoveFailLog, Long> {
6+
7+
}

src/main/java/life/mosu/mosuserver/infra/storage/domain/Folder.java

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
package life.mosu.mosuserver.infra.storage.domain;
22

3+
import life.mosu.mosuserver.global.exception.CustomRuntimeException;
4+
import life.mosu.mosuserver.global.exception.ErrorCode;
35
import lombok.Getter;
46

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

16+
TEMP("temp", PRIVATE),
1417
INQUIRY("inquiry", PRIVATE),
1518
INQUIRY_ANSWER("inquiryAnswer", PRIVATE),
1619
ADMISSION_TICKET_IMAGE("admissionTicket/images", PRIVATE),
@@ -23,4 +26,12 @@ public enum Folder {
2326
this.path = path;
2427
this.visibility = visibility;
2528
}
29+
30+
public static Folder validate(String folderName) {
31+
for (Folder folder : Folder.values())
32+
if (folder.name().equals(folderName)) {
33+
return folder;
34+
}
35+
throw new CustomRuntimeException(ErrorCode.WRONG_FOLDER_TYPE);
36+
}
2637
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
package life.mosu.mosuserver.infra.storage.presentation;
2+
3+
import jakarta.validation.constraints.NotNull;
4+
import life.mosu.mosuserver.global.exception.CustomRuntimeException;
5+
import life.mosu.mosuserver.global.exception.ErrorCode;
6+
import life.mosu.mosuserver.global.util.ApiResponseWrapper;
7+
import life.mosu.mosuserver.infra.storage.application.S3Service;
8+
import life.mosu.mosuserver.infra.storage.domain.Folder;
9+
import life.mosu.mosuserver.infra.storage.presentation.dto.FileUploadResponse;
10+
import lombok.RequiredArgsConstructor;
11+
import org.springframework.http.HttpStatus;
12+
import org.springframework.web.bind.annotation.PostMapping;
13+
import org.springframework.web.bind.annotation.RequestMapping;
14+
import org.springframework.web.bind.annotation.RequestParam;
15+
import org.springframework.web.bind.annotation.RestController;
16+
import org.springframework.web.multipart.MultipartFile;
17+
18+
@RestController
19+
@RequiredArgsConstructor
20+
@RequestMapping("/s3")
21+
public class S3Controller {
22+
private final S3Service s3Service;
23+
24+
@PostMapping
25+
public ApiResponseWrapper<FileUploadResponse> uploadFile(
26+
@RequestParam("file") MultipartFile file,
27+
@RequestParam(defaultValue = "temp") String folderName
28+
) {
29+
30+
if (file.isEmpty()) {
31+
throw new CustomRuntimeException(ErrorCode.FILE_UPLOAD_FAILED, "업로드할 파일이 비어 있습니다.");
32+
}
33+
34+
Folder folder = Folder.validate(folderName);
35+
36+
FileUploadResponse fileResponse = s3Service.uploadFile(file, folder);
37+
return ApiResponseWrapper.success(HttpStatus.CREATED, "파일 업로드 성공", fileResponse);
38+
}
39+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package life.mosu.mosuserver.infra.storage.presentation.dto;
2+
3+
public record FileUploadResponse(
4+
String fileName,
5+
String s3Key
6+
) {
7+
public static FileUploadResponse of(String fileName, String s3Key) {
8+
return new FileUploadResponse(fileName, s3Key);
9+
}
10+
}

0 commit comments

Comments
 (0)