Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: 게시글 본문 이미지 업로드/다운로드 기능 추가 #445

Merged
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: 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(),
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

로그 남기는 이유가 뭔가요?? 또, 보통 하나의 메서드에 로그 하나씩은 남기는 편인가요?

제가 로그를 남겨본적이 없어서..ㅎㅎ 여쭤봅니다!

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

아 보통은 로그를 따로 안남기는데 이 API 같은 경우는 게시글 본문에 이미지를 올리기만 하면 업로드가 되어서 서버 용량에 영향을 미치기가 쉬운 API라 혹시나 모를 어뷰징에 대비해 로그를 남겨놨습니다~

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");

Comment on lines +1044 to +1046
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getSecuredValue 아직 쓰이고 있었군요 ㄷㄷ

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());
}
}
}
Loading