Skip to content

Commit

Permalink
feat: 게시글 본문 이미지 업로드/다운로드 기능 추가 (#445)
Browse files Browse the repository at this point in the history
* feat: 게시글 본문 이미지 업로드/다운로드 기능 추가

* feat: @transactional 추가 & url 변경

* feat: fileHash -> fileUUID 코드리뷰 반영
  • Loading branch information
gusah009 authored May 13, 2024
1 parent 2c85e64 commit bbce420
Show file tree
Hide file tree
Showing 10 changed files with 237 additions and 11 deletions.
49 changes: 49 additions & 0 deletions src/docs/asciidoc/post/post.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -472,3 +472,52 @@ include::{snippets}/download-post-file/path-parameters.adoc[]

include::{snippets}/download-post-file/http-response.adoc[]

== *게시글 본문용 파일 업로드*

NOTE: 게시글의 본문 파일 업로드 기능입니다.

=== 요청

==== Request

include::{snippets}/upload-file-for-content/http-request.adoc[]

==== Request Cookies

include::{snippets}/upload-file-for-content/request-cookies.adoc[]

=== 응답

==== Response

include::{snippets}/upload-file-for-content/http-response.adoc[]

==== Response Fields

include::{snippets}/upload-file-for-content/response-fields.adoc[]

== *게시글 본문 파일 다운로드*

=== 요청

==== Request

include::{snippets}/get-file-for-content/http-request.adoc[]

==== Request Cookies

include::{snippets}/get-file-for-content/request-cookies.adoc[]

==== Path Parameters

include::{snippets}/get-file-for-content/path-parameters.adoc[]

=== 응답

==== Response

include::{snippets}/get-file-for-content/http-response.adoc[]

==== Response Headers

include::{snippets}/get-file-for-content/response-headers.adoc[]
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,16 @@
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Optional;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.io.InputStreamResource;
import org.springframework.core.io.Resource;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.util.UriUtils;

@Slf4j
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
Expand All @@ -29,6 +32,10 @@ public FileEntity findById(long fileId) {
.orElseThrow(() -> new BusinessException(fileId, "fileId", FILE_NOT_FOUND));
}

public Optional<FileEntity> findByFileUUID(String fileUUID) {
return fileRepository.findByFileUUID(fileUUID);
}

public Resource getFileResource(FileEntity file) throws IOException {
Path path = Paths.get(file.getFilePath());
return new InputStreamResource(Files.newInputStream(path));
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package com.keeper.homepage.domain.file.dao;

import com.keeper.homepage.domain.file.entity.FileEntity;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;

public interface FileRepository extends JpaRepository<FileEntity, Long> {

Optional<FileEntity> findByFileUUID(String fileUUID);
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import jakarta.persistence.Id;
import jakarta.persistence.OneToOne;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import java.time.LocalDateTime;
import lombok.AccessLevel;
import lombok.Builder;
Expand All @@ -23,9 +24,10 @@
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@EqualsAndHashCode(of = "id")
@Table(name = "file")
@Table(name = "file", uniqueConstraints = {@UniqueConstraint(columnNames = {"file_uuid"})})
public class FileEntity {

private static final int MAX_FILE_UUID_LENGTH = 50;
private static final int MAX_FILE_NAME_LENGTH = 256;
private static final int MAX_FILE_PATH_LENGTH = 512;

Expand All @@ -49,17 +51,21 @@ public class FileEntity {
@Column(name = "ip_address", nullable = false)
private String ipAddress;

@Column(name = "file_uuid", length = MAX_FILE_UUID_LENGTH)
private String fileUUID;

@OneToOne(mappedBy = "file", cascade = REMOVE, fetch = LAZY)
private PostHasFile postHasFile;

@Builder
private FileEntity(String fileName, String filePath, Long fileSize, LocalDateTime uploadTime,
String ipAddress) {
String ipAddress, String fileUUID) {
this.fileName = fileName;
this.filePath = filePath;
this.fileSize = fileSize;
this.uploadTime = uploadTime;
this.ipAddress = ipAddress;
this.fileUUID = fileUUID;
}

public boolean isPost(Post post) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import com.keeper.homepage.domain.post.dto.request.PostFileDeleteRequest;
import com.keeper.homepage.domain.post.dto.request.PostUpdateRequest;
import com.keeper.homepage.domain.post.dto.response.CategoryResponse;
import com.keeper.homepage.domain.post.dto.response.FileForContentResponse;
import com.keeper.homepage.domain.post.dto.response.FileResponse;
import com.keeper.homepage.domain.post.dto.response.MainPostResponse;
import com.keeper.homepage.domain.post.dto.response.MemberPostResponse;
Expand All @@ -27,6 +28,7 @@
import java.net.URI;
import java.util.List;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.io.Resource;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
Expand All @@ -50,6 +52,7 @@
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;

@Slf4j
@Validated
@RestController
@RequiredArgsConstructor
Expand Down Expand Up @@ -242,4 +245,33 @@ public ResponseEntity<Resource> downloadFile(
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + fileName + "\"")
.body(resource);
}

/**
* Response body로 주는 prefix를 프론트엔드에서 보고 그대로 요청하기 때문에 response body의 prefix와 getFileForContent의 path는 바뀌어선 안된다.
*/
@PostMapping(value = "/files", consumes = {MediaType.APPLICATION_JSON_VALUE, MediaType.MULTIPART_FORM_DATA_VALUE})
public ResponseEntity<FileForContentResponse> uploadFileForContent(
@LoginMember Member member,
@RequestPart MultipartFile file
) {
FileEntity fileEntity = postService.uploadFileForContent(file);
log.info("member \"{}\" uploaded file. memberId: {}, fileId: {}", member.getRealName(), member.getId(),
fileEntity.getId());
return ResponseEntity.status(HttpStatus.CREATED)
.body(FileForContentResponse.from("posts/files/" + fileEntity.getFileUUID()));
}

@GetMapping("/files/{fileUUID}")
public ResponseEntity<Resource> getFileForContent(
@LoginMember Member member,
@PathVariable String fileUUID
) throws IOException {
FileEntity file = postService.getFileForContent(fileUUID, member);
Resource resource = fileService.getFileResource(file);
String fileName = fileService.getFileName(file);
return ResponseEntity.ok()
.contentType(MediaType.APPLICATION_OCTET_STREAM)
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + fileName + "\"")
.body(resource);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import static com.keeper.homepage.domain.post.entity.category.Category.CategoryType.시험게시판;
import static com.keeper.homepage.domain.post.entity.category.Category.CategoryType.익명게시판;
import static com.keeper.homepage.global.error.ErrorCode.FILE_NOT_FOUND;
import static com.keeper.homepage.global.error.ErrorCode.POST_ACCESS_CONDITION_NEED;
import static com.keeper.homepage.global.error.ErrorCode.POST_COMMENT_NEED;
import static com.keeper.homepage.global.error.ErrorCode.POST_HAS_NOT_THAT_FILE;
Expand Down Expand Up @@ -35,11 +36,11 @@
import com.keeper.homepage.global.error.BusinessException;
import com.keeper.homepage.global.util.file.FileUtil;
import com.keeper.homepage.global.util.redis.RedisUtil;
import com.keeper.homepage.global.util.thumbnail.ThumbnailUtil;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
Expand All @@ -48,6 +49,7 @@
import org.springframework.util.StringUtils;
import org.springframework.web.multipart.MultipartFile;

@Slf4j
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
Expand Down Expand Up @@ -379,4 +381,18 @@ public FileEntity getFile(Member member, long postId, long fileId) {
}
return file;
}

@Transactional
public FileEntity uploadFileForContent(MultipartFile file) {
return fileUtil.saveFile(file).orElseThrow();
}

public FileEntity getFileForContent(String fileUUID, Member member) {
return fileService.findByFileUUID(fileUUID)
.orElseThrow(() -> {
log.error("fileUUID not found!! member \"{}\" request invalid fileUUID. " +
"fileUUID: {}, memberId: {}", member.getRealName(), fileUUID, member.getId());
return new BusinessException(fileUUID, "fileUUID", FILE_NOT_FOUND);
});
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package com.keeper.homepage.domain.post.dto.response;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;

import static lombok.AccessLevel.PRIVATE;

@Getter
@Builder
@AllArgsConstructor(access = PRIVATE)
public class FileForContentResponse {

private String filePath;

public static FileForContentResponse from(String filePath) {
return FileForContentResponse.builder()
.filePath(filePath)
.build();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import java.util.UUID;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
Expand Down Expand Up @@ -76,14 +77,30 @@ private static String generateRandomFilename(@NonNull MultipartFile file) {

private FileEntity saveFileEntity(MultipartFile file, File newFile, LocalDateTime now) {
String ipAddress = WebUtil.getUserIP();
return fileRepository.save(
FileEntity.builder()
.fileName(file.getOriginalFilename())
.filePath(getFileUrl(newFile))
.fileSize(file.getSize())
.uploadTime(now)
.ipAddress(ipAddress)
.build());
int retryCount = 3;
while (retryCount >= 0) {
try {
return fileRepository.save(
FileEntity.builder()
.fileName(file.getOriginalFilename())
.filePath(getFileUrl(newFile))
.fileSize(file.getSize())
.uploadTime(now)
.ipAddress(ipAddress)
.fileUUID(getRandomUUID())
.build());
} catch (DataIntegrityViolationException e) {
if (retryCount == 0) {
throw e;
}
retryCount--;
}
}
throw new RuntimeException("save file entity failed.");
}

protected String getRandomUUID() {
return UUID.randomUUID().toString();
}

private static String getFileUrl(File newFile) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,18 @@ ResultActions callGetPostsApi(String memberToken, MultiValueMap<String, String>
.cookie(new Cookie(ACCESS_TOKEN.getTokenName(), memberToken)));
}

ResultActions callUploadFileForContent(String accessToken, MockMultipartFile file) throws Exception {
return mockMvc.perform(multipart("/posts/files")
.file(file)
.cookie(new Cookie(ACCESS_TOKEN.getTokenName(), accessToken))
.contentType(MediaType.MULTIPART_FORM_DATA));
}

ResultActions callGetFileForContent(String accessToken, String fileUUID) throws Exception {
return mockMvc.perform(get("/posts/files/{fileUUID}", fileUUID)
.cookie(new Cookie(ACCESS_TOKEN.getTokenName(), accessToken)));
}

FieldDescriptor[] getPostsResponse() {
return new FieldDescriptor[]{
fieldWithPath("id").description("게시글 ID"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1034,4 +1034,68 @@ class DownloadFile {
assertThat(content).contains(POST_HAS_NOT_THAT_FILE.getMessage());
}
}

@Nested
@DisplayName("게시글 본문 파일 업로드 테스트")
class UploadFileForContent {

@Test
@DisplayName("유효한 요청일 경우 게시글 본문 파일 업로드는 성공한다.")
public void 유효한_요청일_경우_게시글_본문_파일_업로드는_성공한다() throws Exception {
String securedValue = getSecuredValue(PostController.class, "uploadFileForContent");

file = new MockMultipartFile("file", "testImage_1x1.png", "image/png",
new FileInputStream("src/test/resources/images/testImage_1x1.png"));

callUploadFileForContent(memberToken, file)
.andExpect(status().isCreated())
.andDo(document("upload-file-for-content",
requestCookies(
cookieWithName(ACCESS_TOKEN.getTokenName())
.description("ACCESS TOKEN %s".formatted(securedValue))
),
requestParts(
partWithName("file").description("게시글의 본문에 넣을 파일")
),
responseFields(
fieldWithPath("filePath").description("저장된 파일을 불러올 수 있는 file의 hash값과 url입니다.")
)));
}
}

@Nested
@DisplayName("게시글 본문 파일 다운로드 테스트")
class GetFileForContent {

@Test
@DisplayName("유효한 요청일 경우 게시글 본문 파일 다운로드는 성공한다.")
public void 유효한_요청일_경우_게시글_본문_파일_다운로드는_성공한다() throws Exception {
String securedValue = getSecuredValue(PostController.class, "getFileForContent");

FileEntity fileEntity = postService.uploadFileForContent(file);

callGetFileForContent(memberToken, fileEntity.getFileUUID())
.andExpect(status().isOk())
.andExpect(
header().string(CONTENT_DISPOSITION, "attachment; filename=\"" + fileEntity.getFileName() + "\""))
.andDo(document("get-file-for-content",
requestCookies(
cookieWithName(ACCESS_TOKEN.getTokenName())
.description("ACCESS TOKEN %s".formatted(securedValue))
),
pathParameters(
parameterWithName("fileUUID").description("업로드한 파일의 uuid")
),
responseHeaders(
headerWithName(CONTENT_DISPOSITION).description("파일 이름을 포함한 응답 헤더입니다.")
)));
}

@Test
@DisplayName("존재하지 않는 파일 해시일 경우 게시글 본문 파일 다운로드는 실패한다.")
public void 존재하지_않는_파일_해시일_경우_게시글_본문_파일_다운로드는_실패한다() throws Exception {
callGetFileForContent(memberToken, "invalidFileUUID")
.andExpect(status().isBadRequest());
}
}
}

0 comments on commit bbce420

Please sign in to comment.