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

[BE] 이미지(만) 업로드하는 api 추가 #980

Merged
merged 9 commits into from
Jan 12, 2025
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package team.teamby.teambyteam.feed.application;

import jakarta.transaction.Transactional;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import team.teamby.teambyteam.feed.application.dto.FeedImageResponse;
import team.teamby.teambyteam.feed.application.dto.UploadImageRequest;
import team.teamby.teambyteam.feed.domain.image.FeedThreadImage;
import team.teamby.teambyteam.feed.domain.image.FeedThreadImageRepository;
import team.teamby.teambyteam.feed.domain.image.Status;
import team.teamby.teambyteam.feed.domain.image.vo.ImageName;
import team.teamby.teambyteam.feed.domain.image.vo.ImageUrl;
import team.teamby.teambyteam.filesystem.FileStorageManager;
import team.teamby.teambyteam.filesystem.ImageValidationService;

import java.util.List;
import java.util.UUID;

@Service
@Transactional
@RequiredArgsConstructor
public class FeedImageService {

private final FeedThreadImageRepository feedThreadImageRepository;

private final FileStorageManager fileStorageManager;
private final ImageValidationService imageValidationService;

@Value("${aws.s3.image-directory}")
private String imageDirectory;

public List<FeedImageResponse> uploadImages(final UploadImageRequest uploadImageRequest) {
final List<MultipartFile> images = uploadImageRequest.images();
validateImages(images);

return images.stream().map(image -> {
final String originalFilename = image.getOriginalFilename();
final String generatedImageUrl = fileStorageManager.upload(image, imageDirectory + "/" + UUID.randomUUID(), originalFilename);
final ImageUrl imageUrl = new ImageUrl(generatedImageUrl);
final ImageName imageName = new ImageName(originalFilename);
final FeedThreadImage feedThreadImage = new FeedThreadImage(imageUrl, imageName, Status.PENDING);
return feedThreadImageRepository.save(feedThreadImage);
})
.map(FeedImageResponse::from)
.toList();
}

private void validateImages(final List<MultipartFile> images) {
images.forEach(imageValidationService::validateImage);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import team.teamby.teambyteam.feed.domain.FeedThread;
import team.teamby.teambyteam.feed.domain.image.FeedThreadImage;
import team.teamby.teambyteam.feed.domain.image.FeedThreadImageRepository;
import team.teamby.teambyteam.feed.domain.image.Status;
import team.teamby.teambyteam.feed.domain.image.vo.ImageName;
import team.teamby.teambyteam.feed.domain.image.vo.ImageUrl;
import team.teamby.teambyteam.feed.domain.vo.Content;
Expand Down Expand Up @@ -103,7 +104,7 @@ private List<FeedImageResponse> saveImages(final List<MultipartFile> images, fin
final String generatedImageUrl = fileStorageManager.upload(image, imageDirectory + "/" + UUID.randomUUID(), originalFilename);
final ImageUrl imageUrl = new ImageUrl(generatedImageUrl);
final ImageName imageName = new ImageName(originalFilename);
final FeedThreadImage feedThreadImage = new FeedThreadImage(imageUrl, imageName);
final FeedThreadImage feedThreadImage = new FeedThreadImage(imageUrl, imageName, Status.ACTIVATED);
feedThreadImage.confirmFeedThread(savedFeedThread);
return feedThreadImageRepository.save(feedThreadImage);
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

import jakarta.persistence.Embedded;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
Expand All @@ -11,10 +13,10 @@
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import team.teamby.teambyteam.common.domain.BaseEntity;
import team.teamby.teambyteam.feed.domain.FeedThread;
import team.teamby.teambyteam.feed.domain.image.vo.ImageName;
import team.teamby.teambyteam.feed.domain.image.vo.ImageUrl;
import team.teamby.teambyteam.common.domain.BaseEntity;

@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
Expand All @@ -26,7 +28,7 @@ public class FeedThreadImage extends BaseEntity {
private Long id;

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(nullable = false, updatable = false)
@JoinColumn
private FeedThread feedThread;

@Embedded
Expand All @@ -35,16 +37,21 @@ public class FeedThreadImage extends BaseEntity {
@Embedded
private ImageName imageName;

private boolean isExpired;
@Enumerated(EnumType.STRING)
private Status status;

public FeedThreadImage(final ImageUrl imageUrl, final ImageName imageName) {
public FeedThreadImage(final ImageUrl imageUrl, final ImageName imageName, final Status status) {
this.imageUrl = imageUrl;
this.imageName = imageName;
this.isExpired = false;
this.status = status;
}

public void confirmFeedThread(final FeedThread feedThread) {
this.feedThread = feedThread;
feedThread.getImages().add(this);
}

public boolean isExpired() {
return Status.EXPIRED.equals(status);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package team.teamby.teambyteam.feed.domain.image;

public enum Status {
PENDING,
ACTIVATED,
EXPIRED
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
Expand All @@ -10,14 +11,19 @@
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import team.teamby.teambyteam.feed.application.FeedImageService;
import team.teamby.teambyteam.feed.application.FeedReadService;
import team.teamby.teambyteam.feed.application.FeedWriteService;
import team.teamby.teambyteam.feed.application.dto.FeedImageResponse;
import team.teamby.teambyteam.feed.application.dto.FeedThreadWritingRequest;
import team.teamby.teambyteam.feed.application.dto.FeedsResponse;
import team.teamby.teambyteam.feed.application.dto.UploadImageRequest;
import team.teamby.teambyteam.feed.presentation.dto.FeedImagesResponse;
import team.teamby.teambyteam.member.configuration.AuthPrincipal;
import team.teamby.teambyteam.member.configuration.dto.MemberEmailDto;

import java.net.URI;
import java.util.List;

@RestController
@RequestMapping("/api/team-place")
Expand All @@ -26,6 +32,7 @@ public class FeedThreadController {

private final FeedReadService feedReadService;
private final FeedWriteService feedWriteService;
private final FeedImageService feedImageService;

@PostMapping("/{teamPlaceId}/feed/threads")
public ResponseEntity<Void> write(
Expand All @@ -39,6 +46,16 @@ public ResponseEntity<Void> write(
return ResponseEntity.created(location).build();
}

@PostMapping("/{teamPlaceId}/feed/threads/images")
public ResponseEntity<FeedImagesResponse> uploadImages(
@ModelAttribute @Valid final UploadImageRequest request
) {
final List<FeedImageResponse> feedImageResponse = feedImageService.uploadImages(request);
final FeedImagesResponse response = new FeedImagesResponse(feedImageResponse);

return ResponseEntity.status(HttpStatus.CREATED).body(response);
}

@GetMapping(value = "/{teamPlaceId}/feed/threads", params = {"size"})
public ResponseEntity<FeedsResponse> read(
@PathVariable final Long teamPlaceId,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package team.teamby.teambyteam.feed.presentation.dto;

import team.teamby.teambyteam.feed.application.dto.FeedImageResponse;

import java.util.List;

public record FeedImagesResponse(
List<FeedImageResponse> images
) {
}
Original file line number Diff line number Diff line change
@@ -1,14 +1,10 @@
package team.teamby.teambyteam.common.fixtures;

import org.springframework.mock.web.MockMultipartFile;
import team.teamby.teambyteam.feed.application.dto.FeedThreadWritingRequest;
import team.teamby.teambyteam.feed.application.dto.UploadImageRequest;
import team.teamby.teambyteam.feed.domain.FeedThread;
import team.teamby.teambyteam.feed.domain.vo.Content;

import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;

import static team.teamby.teambyteam.common.fixtures.FileFixtures.OVER_SIZE_PNG_MOCK_MULTIPART_FILE;
import static team.teamby.teambyteam.common.fixtures.FileFixtures.UNDER_SIZE_PNG_MOCK_MULTIPART_FILE1;
import static team.teamby.teambyteam.common.fixtures.FileFixtures.UNDER_SIZE_PNG_MOCK_MULTIPART_FILE2;
Expand All @@ -17,9 +13,6 @@
import static team.teamby.teambyteam.common.fixtures.FileFixtures.UNDER_SIZE_WRONG_EXTENSION_MOCK_MULTIPART_FILE;

import java.util.List;
import team.teamby.teambyteam.feed.application.dto.FeedThreadWritingRequest;
import team.teamby.teambyteam.feed.domain.FeedThread;
import team.teamby.teambyteam.feed.domain.vo.Content;

public class FeedThreadFixtures {

Expand All @@ -44,11 +37,18 @@ public class FeedThreadFixtures {
List.of(UNDER_SIZE_PNG_MOCK_MULTIPART_FILE1, UNDER_SIZE_PNG_MOCK_MULTIPART_FILE2, UNDER_SIZE_PNG_MOCK_MULTIPART_FILE3, UNDER_SIZE_PNG_MOCK_MULTIPART_FILE4,
UNDER_SIZE_PNG_MOCK_MULTIPART_FILE1));

public static final FeedThreadWritingRequest OVER_IMAGE_SIZE_REQUEST = new FeedThreadWritingRequest(CONTENT_AND_IMAGE, List.of(OVER_SIZE_PNG_MOCK_MULTIPART_FILE));
public static final FeedThreadWritingRequest NOT_ALLOWED_IMAGE_EXTENSION_REQUEST = new FeedThreadWritingRequest(CONTENT_AND_IMAGE, List.of(UNDER_SIZE_WRONG_EXTENSION_MOCK_MULTIPART_FILE));
public static final FeedThreadWritingRequest FEED_WITH_OVER_IMAGE_SIZE_REQUEST = new FeedThreadWritingRequest(CONTENT_AND_IMAGE, List.of(OVER_SIZE_PNG_MOCK_MULTIPART_FILE));
public static final FeedThreadWritingRequest FEED_WITH_NOT_ALLOWED_IMAGE_EXTENSION_REQUEST = new FeedThreadWritingRequest(CONTENT_AND_IMAGE, List.of(UNDER_SIZE_WRONG_EXTENSION_MOCK_MULTIPART_FILE));

public static final FeedThreadWritingRequest EMPTY_REQUEST = new FeedThreadWritingRequest(CONTENT_EMPTY_AND_IMAGE_EMPTY, null);

/**
* IMAGE REQUESTS
*/
public static final UploadImageRequest IMAGE_REQUEST = new UploadImageRequest(List.of(UNDER_SIZE_PNG_MOCK_MULTIPART_FILE1, UNDER_SIZE_PNG_MOCK_MULTIPART_FILE2));
public static final UploadImageRequest NOT_ALLOWED_IMAGE_EXTENSION_REQUEST = new UploadImageRequest(List.of(UNDER_SIZE_WRONG_EXTENSION_MOCK_MULTIPART_FILE));
public static final UploadImageRequest OVER_IMAGE_SIZE_REQUEST = new UploadImageRequest(List.of(OVER_SIZE_PNG_MOCK_MULTIPART_FILE));

/**
* ENTITY
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
package team.teamby.teambyteam.common.fixtures;

import team.teamby.teambyteam.feed.domain.image.FeedThreadImage;
import team.teamby.teambyteam.feed.domain.image.Status;
import team.teamby.teambyteam.feed.domain.image.vo.ImageName;
import team.teamby.teambyteam.feed.domain.image.vo.ImageUrl;

public final class FeedThreadImageFixtures {

public static final FeedThreadImage A_FEED_THREAD_IMAGE = new FeedThreadImage(new ImageUrl("aaa"), new ImageName("a"));
public static final FeedThreadImage B_FEED_THREAD_IMAGE = new FeedThreadImage(new ImageUrl("bbb"), new ImageName("b"));
public static final FeedThreadImage A_FEED_THREAD_IMAGE = new FeedThreadImage(new ImageUrl("aaa"), new ImageName("a"), Status.ACTIVATED);
public static final FeedThreadImage B_FEED_THREAD_IMAGE = new FeedThreadImage(new ImageUrl("bbb"), new ImageName("b"), Status.ACTIVATED);

public static final int IMAGE_EXPIRATION_DATE = 90;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
package team.teamby.teambyteam.feed.application;

import org.assertj.core.api.SoftAssertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.web.multipart.MultipartFile;
import team.teamby.teambyteam.common.ServiceTest;
import team.teamby.teambyteam.feed.application.dto.FeedImageResponse;
import team.teamby.teambyteam.feed.application.dto.UploadImageRequest;
import team.teamby.teambyteam.feed.domain.image.FeedThreadImage;
import team.teamby.teambyteam.feed.domain.image.FeedThreadImageRepository;
import team.teamby.teambyteam.feed.domain.image.Status;
import team.teamby.teambyteam.filesystem.FileStorageManager;
import team.teamby.teambyteam.filesystem.exception.ImageSizeException;
import team.teamby.teambyteam.filesystem.exception.NotAllowedImageExtensionException;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.given;
import static team.teamby.teambyteam.common.fixtures.FeedThreadFixtures.IMAGE_REQUEST;
import static team.teamby.teambyteam.common.fixtures.FeedThreadFixtures.NOT_ALLOWED_IMAGE_EXTENSION_REQUEST;
import static team.teamby.teambyteam.common.fixtures.FeedThreadFixtures.OVER_IMAGE_SIZE_REQUEST;

class FeedImageServiceTest extends ServiceTest {

@Autowired
private FeedImageService feedImageService;

@Autowired
private FeedThreadImageRepository feedThreadImageRepository;

@MockBean
private FileStorageManager fileStorageManager;

@BeforeEach
void setup() {
given(fileStorageManager.upload(any(MultipartFile.class), any(String.class), any(String.class)))
.willReturn("https://s3://seongha-seeik");
}

@Test
@DisplayName("이미지 업로드가 정상적으로 성공한다.")
void uploadImages() {
// given
final UploadImageRequest request = IMAGE_REQUEST;

// when
final var result = feedImageService.uploadImages(request);

// then
final FeedImageResponse imageResponse = result.get(0);
final FeedThreadImage savedImage = feedThreadImageRepository.findById(imageResponse.id()).orElseThrow();

SoftAssertions.assertSoftly(softly -> {
softly.assertThat(result).isNotEmpty();
softly.assertThat(imageResponse.url()).isEqualTo("https://s3://seongha-seeik");
softly.assertThat(imageResponse.isExpired()).isFalse();
softly.assertThat(savedImage.getStatus()).isEqualTo(Status.PENDING);
});
}

@Test
@DisplayName("이미지 크기가 허용된 크기보다 많으면 예외가 발생한다.")
void failWhenOverImageSize() {
// given
final UploadImageRequest request = OVER_IMAGE_SIZE_REQUEST;

// when & then
assertThatThrownBy(() -> feedImageService.uploadImages(request))
.isInstanceOf(ImageSizeException.class)
.hasMessageContaining("허용된 이미지의 크기를 초과했습니다.");
}

@Test
@DisplayName("이미지 확장자가 허용되지 않으면 예외가 발생한다.")
void failWhenNotAllowedImageExtension() {
// given
final UploadImageRequest request = NOT_ALLOWED_IMAGE_EXTENSION_REQUEST;

// when & then
assertThatThrownBy(() -> feedImageService.uploadImages(request))
.isInstanceOf(NotAllowedImageExtensionException.class)
.hasMessageContaining("허용되지 않은 확장자입니다.");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import team.teamby.teambyteam.feed.domain.FeedThread;
import team.teamby.teambyteam.feed.domain.FeedType;
import team.teamby.teambyteam.feed.domain.image.FeedThreadImage;
import team.teamby.teambyteam.feed.domain.image.Status;
import team.teamby.teambyteam.feed.domain.image.vo.ImageName;
import team.teamby.teambyteam.feed.domain.image.vo.ImageUrl;
import team.teamby.teambyteam.feed.domain.vo.Content;
Expand Down Expand Up @@ -108,7 +109,7 @@ void imageExpireAfter90Days() {
final MemberEmailDto memberEmailDto = new MemberEmailDto(member.getEmailValue());
final TeamPlace teamPlace = testFixtureBuilder.buildTeamPlace(ENGLISH_TEAM_PLACE());
testFixtureBuilder.buildMemberTeamPlace(member, teamPlace);
final FeedThreadImage EXPIRED_IMAGE = BDDMockito.spy(new FeedThreadImage(new ImageUrl("expired.com"), new ImageName("expiredImageName")));
final FeedThreadImage EXPIRED_IMAGE = BDDMockito.spy(new FeedThreadImage(new ImageUrl("expired.com"), new ImageName("expiredImageName"), Status.ACTIVATED));
BDDMockito.given(EXPIRED_IMAGE.isExpired())
.willReturn(true);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,8 @@
import static team.teamby.teambyteam.common.fixtures.FeedThreadFixtures.CONTENT_ONLY_REQUEST;
import static team.teamby.teambyteam.common.fixtures.FeedThreadFixtures.EMPTY_REQUEST;
import static team.teamby.teambyteam.common.fixtures.FeedThreadFixtures.IMAGE_ONLY_REQUEST;
import static team.teamby.teambyteam.common.fixtures.FeedThreadFixtures.NOT_ALLOWED_IMAGE_EXTENSION_REQUEST;
import static team.teamby.teambyteam.common.fixtures.FeedThreadFixtures.OVER_IMAGE_SIZE_REQUEST;
import static team.teamby.teambyteam.common.fixtures.FeedThreadFixtures.FEED_WITH_NOT_ALLOWED_IMAGE_EXTENSION_REQUEST;
import static team.teamby.teambyteam.common.fixtures.FeedThreadFixtures.FEED_WITH_OVER_IMAGE_SIZE_REQUEST;
import static team.teamby.teambyteam.common.fixtures.MemberFixtures.PHILIP;
import static team.teamby.teambyteam.common.fixtures.TeamPlaceFixtures.ENGLISH_TEAM_PLACE;

Expand Down Expand Up @@ -84,7 +84,7 @@ void failWhenOverImageSize() {
// given
final TeamPlace teamPlace = testFixtureBuilder.buildTeamPlace(ENGLISH_TEAM_PLACE());
final Member author = testFixtureBuilder.buildMember(PHILIP());
final FeedThreadWritingRequest request = OVER_IMAGE_SIZE_REQUEST;
final FeedThreadWritingRequest request = FEED_WITH_OVER_IMAGE_SIZE_REQUEST;

// when & then
assertThatThrownBy(() -> feedWriteService.write(request, new MemberEmailDto(author.getEmail().getValue()),
Expand All @@ -99,7 +99,7 @@ void failWhenNotAllowedImageExtension() {
// given
final TeamPlace teamPlace = testFixtureBuilder.buildTeamPlace(ENGLISH_TEAM_PLACE());
final Member author = testFixtureBuilder.buildMember(PHILIP());
final FeedThreadWritingRequest request = NOT_ALLOWED_IMAGE_EXTENSION_REQUEST;
final FeedThreadWritingRequest request = FEED_WITH_NOT_ALLOWED_IMAGE_EXTENSION_REQUEST;

// when & then
assertThatThrownBy(() -> feedWriteService.write(request, new MemberEmailDto(author.getEmail().getValue()),
Expand Down
Loading