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] 이미지 업로드 기능을 구현한다. #91

Merged
merged 35 commits into from
Jun 15, 2024
Merged
Show file tree
Hide file tree
Changes from 30 commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
ebdffb6
chore: s3 의존성 추가
kpeel5839 Jun 12, 2024
7e7b427
feat: image 도메인 추가
kpeel5839 Jun 12, 2024
1b8ef9e
feat: AmazonS3 빈으로 등
kpeel5839 Jun 12, 2024
8708ff9
feat: ImageExtension Exception 정의
kpeel5839 Jun 12, 2024
0b1e150
feat: MultipartFile을 구현한 UploadFile을 추가
kpeel5839 Jun 12, 2024
bdcde5e
feat: Image 업로드, 삭제를 위한 기반 코드 구현
kpeel5839 Jun 12, 2024
9000875
feat: 의존성 역전을 위한 IdeaValidator와 ImageRepository 추가
kpeel5839 Jun 12, 2024
9e8670b
feat: image upload 기능 구현
kpeel5839 Jun 12, 2024
5c856cc
feat: image delete 기능 구현
kpeel5839 Jun 12, 2024
ffa88f0
feat: image 업로드 api 구현
kpeel5839 Jun 12, 2024
b8294f4
feat: image 삭제 api 구현
kpeel5839 Jun 12, 2024
ef54292
feat: 아이디어 조회시 이미지도 조회 가능하도록 수정
kpeel5839 Jun 12, 2024
a30e24f
feat: consumes 타입 및 RequestPart 추가
kpeel5839 Jun 12, 2024
4dbc676
refactor: image->images 로 Restful 하게 변경
kpeel5839 Jun 12, 2024
7c00b25
refactor: image 들을 저장할 수 있도록 변경
kpeel5839 Jun 12, 2024
63462d2
refactor: 이미지 추가 API 가 아무것도 반환하지 않도록 수정
kpeel5839 Jun 12, 2024
6e32b5e
refactor: Swagger UI 적용
kpeel5839 Jun 12, 2024
779d50f
chore: s3 bucket 및 cloud front url을 application.yml 에 추가
kpeel5839 Jun 12, 2024
1b4c001
refactor: image 이름 규칙 변경
kpeel5839 Jun 12, 2024
ee1f309
chore: build시 jar 이름 설정
kpeel5839 Jun 12, 2024
8d906ba
feat: ImageChecker 구현
kpeel5839 Jun 14, 2024
d7ec1c4
refactor: deleteImages->updateImages로 API 변경
kpeel5839 Jun 14, 2024
e62eab7
refactor: MultipartFormData를 받을 수 있도록 수정
kpeel5839 Jun 14, 2024
d6b59a7
refactor: 응답은 ok로 반환할 수 있도록 수정
kpeel5839 Jun 14, 2024
a511c70
refactor: 테스트 완료 후 원래대로 코드 수정
kpeel5839 Jun 14, 2024
67e220b
refactor: 이미지 추가 시 예외 처리 강화
kpeel5839 Jun 14, 2024
dc6954c
refactor: ImageChecker에 AdditionImagesSize를 검증하는 책임을 위임
kpeel5839 Jun 14, 2024
3138399
test: ideaValidator Test 작성
kpeel5839 Jun 14, 2024
4ec7201
test: ImageChecker Test 작성
kpeel5839 Jun 14, 2024
6b8b16b
refactor: ideaValidator가 바로 예외를 발생시킬 수 있도록 수정
kpeel5839 Jun 14, 2024
0150da5
refactor: s3Client 분리
kpeel5839 Jun 14, 2024
648259b
refactor: 사용하지 않는 코드 삭제
kpeel5839 Jun 14, 2024
9f5140b
refactor: 요청 한번에 처리할 수 있도록 수정
kpeel5839 Jun 14, 2024
a61e0cf
refactor: test 수정
kpeel5839 Jun 14, 2024
610d7f0
refactor: ImageResponse 생성 방법 수정
kpeel5839 Jun 15, 2024
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
66 changes: 37 additions & 29 deletions build.gradle
Original file line number Diff line number Diff line change
@@ -1,59 +1,67 @@
plugins {
id 'java'
id 'org.springframework.boot' version '3.2.0'
id 'io.spring.dependency-management' version '1.1.4'
id 'java'
id 'org.springframework.boot' version '3.2.0'
id 'io.spring.dependency-management' version '1.1.4'
}

group = 'kr.co'
version = '0.0.1-SNAPSHOT'

java {
sourceCompatibility = '17'
sourceCompatibility = '17'
}

configurations {
compileOnly {
extendsFrom annotationProcessor
}
compileOnly {
extendsFrom annotationProcessor
}
}

repositories {
mavenCentral()
mavenCentral()
}

dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-webflux'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-webflux'
implementation 'org.springframework.boot:spring-boot-starter-validation'

implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'

compileOnly 'org.projectlombok:lombok'
compileOnly 'org.projectlombok:lombok'

runtimeOnly 'com.mysql:mysql-connector-j'
runtimeOnly 'com.h2database:h2'
runtimeOnly 'com.mysql:mysql-connector-j'
runtimeOnly 'com.h2database:h2'

annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.junit.jupiter:junit-jupiter-engine:5.9.0'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.junit.jupiter:junit-jupiter-engine:5.9.0'

implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta'
annotationProcessor "com.querydsl:querydsl-apt:5.0.0:jakarta"
annotationProcessor "jakarta.annotation:jakarta.annotation-api"
annotationProcessor "jakarta.persistence:jakarta.persistence-api"
annotationProcessor "jakarta.annotation:jakarta.annotation-api"
annotationProcessor "jakarta.persistence:jakarta.persistence-api" // Query DSL
implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta'
annotationProcessor "com.querydsl:querydsl-apt:5.0.0:jakarta"
annotationProcessor "jakarta.annotation:jakarta.annotation-api"
annotationProcessor "jakarta.persistence:jakarta.persistence-api"
annotationProcessor "jakarta.annotation:jakarta.annotation-api"
annotationProcessor "jakarta.persistence:jakarta.persistence-api" // Query DSL

implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.3.0'
// S3
implementation platform('com.amazonaws:aws-java-sdk-bom:1.11.1000')
implementation 'com.amazonaws:aws-java-sdk-s3'
Comment on lines +50 to +52
Copy link
Contributor Author

Choose a reason for hiding this comment

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

요것만 추가했어요! 의존성에서는용


implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.3.0'
}

def generatedDir = "src/main/generated"

clean {
delete file(generatedDir)
delete file(generatedDir)
}

bootJar {
archiveFileName = 'conceptbe.jar'
}

//tasks.named('test') {
Expand Down
26 changes: 26 additions & 0 deletions src/main/java/kr/co/conceptbe/common/config/S3Config.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package kr.co.conceptbe.common.config;

import com.amazonaws.auth.InstanceProfileCredentialsProvider;
import com.amazonaws.regions.Regions;
import com.amazonaws.services.s3.AmazonS3;
import com.amazonaws.services.s3.AmazonS3ClientBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class S3Config {

@Bean
public InstanceProfileCredentialsProvider instanceProfileCredentialsProvider() {
return InstanceProfileCredentialsProvider.getInstance();
}

@Bean
public AmazonS3 amazonS3() {
return AmazonS3ClientBuilder.standard()
.withRegion(Regions.AP_NORTHEAST_2)
.withCredentials(instanceProfileCredentialsProvider())
.build();
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@
import kr.co.conceptbe.idea.dto.IdeaHitResponse;
import kr.co.conceptbe.idea.exception.AlreadyIdeaLikeException;
import kr.co.conceptbe.idea.exception.NotFoundIdeaLikeException;
import kr.co.conceptbe.image.domain.Image;
import kr.co.conceptbe.image.domain.ImageRepository;
import kr.co.conceptbe.member.domain.Member;
import kr.co.conceptbe.member.exception.UnAuthorizedMemberException;
import kr.co.conceptbe.member.persistence.MemberRepository;
Expand All @@ -52,6 +54,7 @@ public class IdeaService {
private final HitRepository hitRepository;
private final SkillCategoryRepository skillCategoryRepository;
private final CommentRepository commentRepository;
private final ImageRepository imageRepository;

public Long save(AuthCredentials authCredentials, IdeaRequest request) {
validateMember(authCredentials);
Expand Down Expand Up @@ -122,11 +125,13 @@ private Set<Idea> getIdeasBookmarkedByMember(Member member) {

public IdeaDetailResponse getDetailIdeaResponse(Long tokenMemberId, Long ideaId) {
Idea idea = ideaRepository.getById(ideaId);
IdeaDetailResponse ideaDetailResponse = IdeaDetailResponse.of(tokenMemberId, idea);
List<Image> images = imageRepository.findAllByIdeaId(ideaId);
IdeaDetailResponse ideaDetailResponse = IdeaDetailResponse.of(tokenMemberId, idea, images);
Copy link
Contributor Author

Choose a reason for hiding this comment

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

요것만 바뀐거에요! 아래는 option+command+L눌러서 바뀌었어용 호호호


Member member = memberRepository.getById(tokenMemberId);

Optional<Hit> hitOptional = hitRepository.findFirstByMemberAndIdeaOrderByCreatedAtDesc(member, idea);
Optional<Hit> hitOptional = hitRepository.findFirstByMemberAndIdeaOrderByCreatedAtDesc(
member, idea);
if (hitOptional.isEmpty() || hitOptional.get().isBeforeLocalDate()) {
Hit.ofIdeaAndMember(idea, member);
}
Expand Down
26 changes: 26 additions & 0 deletions src/main/java/kr/co/conceptbe/idea/domain/IdeaValidatorImpl.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package kr.co.conceptbe.idea.domain;

import kr.co.conceptbe.idea.domain.persistence.IdeaRepository;
import kr.co.conceptbe.image.domain.IdeaValidator;
import kr.co.conceptbe.member.exception.NotOwnerException;
import org.springframework.stereotype.Component;

@Component
public class IdeaValidatorImpl implements IdeaValidator {

private final IdeaRepository ideaRepository;

public IdeaValidatorImpl(IdeaRepository ideaRepository) {
this.ideaRepository = ideaRepository;
}

@Override
public void validateIdea(Long ideaId, Long memberId) {
Idea savedIdea = ideaRepository.getById(ideaId);
if (savedIdea.isOwner(memberId)) {
return;
}
throw new NotOwnerException(memberId);
}

}
10 changes: 7 additions & 3 deletions src/main/java/kr/co/conceptbe/idea/dto/IdeaDetailResponse.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
import java.util.List;
import kr.co.conceptbe.comment.Comment;
import kr.co.conceptbe.idea.domain.Idea;
import kr.co.conceptbe.image.application.response.ImageResponse;
import kr.co.conceptbe.image.domain.Image;

public record IdeaDetailResponse(
Long memberId,
Expand All @@ -24,10 +26,11 @@ public record IdeaDetailResponse(
Integer hits,
Boolean owner,
Boolean ownerLike,
Boolean ownerScrap
Boolean ownerScrap,
List<ImageResponse> imageResponses
) {

public static IdeaDetailResponse of(Long tokenMemberId, Idea idea) {
public static IdeaDetailResponse of(Long tokenMemberId, Idea idea, List<Image> images) {
return new IdeaDetailResponse(
idea.getCreator().getId(),
idea.getCreator().getProfileImageUrl(),
Expand All @@ -48,7 +51,8 @@ public static IdeaDetailResponse of(Long tokenMemberId, Idea idea) {
idea.getHitsCount(),
idea.isOwner(tokenMemberId),
idea.isOwnerLike(tokenMemberId),
idea.isOwnerScrap(tokenMemberId)
idea.isOwnerScrap(tokenMemberId),
images.stream().map(ImageResponse::from).toList()
);
}
}
124 changes: 124 additions & 0 deletions src/main/java/kr/co/conceptbe/image/application/ImageService.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
package kr.co.conceptbe.image.application;

import com.amazonaws.services.s3.AmazonS3;
import com.amazonaws.services.s3.model.DeleteObjectRequest;
import com.amazonaws.services.s3.model.PutObjectRequest;
import java.io.File;
import java.io.IOException;
import java.util.List;
import java.util.Objects;
import kr.co.conceptbe.auth.presentation.dto.AuthCredentials;
import kr.co.conceptbe.image.domain.IdeaValidator;
import kr.co.conceptbe.image.domain.Image;
import kr.co.conceptbe.image.domain.ImageChecker;
import kr.co.conceptbe.image.domain.ImageRepository;
import kr.co.conceptbe.image.domain.UploadFile;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;

@Service
@Transactional
@RequiredArgsConstructor
public class ImageService {

@Value("${s3.bucket}")
private String bucket;
private final AmazonS3 amazonS3;
private final ImageRepository imageRepository;
private final IdeaValidator ideaValidator;
private final ImageChecker imageChecker;

public void save(Long ideaId, AuthCredentials authCredentials, List<MultipartFile> files) {
ideaValidator.validateIdea(ideaId, authCredentials.id());
imageChecker.validateAdditionImagesSize(files.size());
uploadImages(ideaId, files);
}

private void uploadImages(Long ideaId, List<MultipartFile> files) {
files.stream()
.map(this::upload)
.map(imageUrl -> new Image(ideaId, imageUrl))
.forEach(imageRepository::save);
}

private String upload(MultipartFile multipartFile) {
try {
UploadFile uploadFile = UploadFile.from(multipartFile);
fileUpload(uploadFile);
return uploadFile.getOriginalFilename();
} catch (IOException exception) {
throw new RuntimeException(exception);
}
}

private void fileUpload(MultipartFile multipartFile) throws IOException {
File tempFile = null;

try {
tempFile = File.createTempFile("upload_", ".tmp");
multipartFile.transferTo(tempFile);
amazonS3.putObject(new PutObjectRequest(
bucket,
multipartFile.getOriginalFilename(),
tempFile
));
} catch (IOException exception) {
throw new IOException(exception);
} finally {
removeTempFileIfExists(tempFile);
}
}

private void removeTempFileIfExists(File tempFile) {
if (Objects.nonNull(tempFile) && tempFile.exists()) {
tempFile.delete();
}
}
Copy link
Member

Choose a reason for hiding this comment

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

얘네는 서비스에말고 저장소(s3,.. )에 저장하는 책임을 담당하는 객체 만들어주면 더 좋겠네영

Copy link
Contributor Author

Choose a reason for hiding this comment

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

사실 처음에 그렇게 하려고 했었는데, Service가 다른 Service를 참조하는 모양이 조금 그랬었어요.
그냥 그렇게 하는게 나으려나요?

Copy link
Member

Choose a reason for hiding this comment

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

분리하면 걔는 service라기 보다는 하나의 역할을 하는 객체인데 주입할 수 있는 컴포넌트에 가깝다고 생각함당(한단계 낮은)


public void update(
Long ideaId,
AuthCredentials authCredentials,
List<Long> imageIds,
List<MultipartFile> additionFiles
) {
ideaValidator.validateIdea(ideaId, authCredentials.id());
List<Image> imagesToDeleted = getImagesToDeleted(ideaId, imageIds, additionFiles.size());
imagesToDeleted.forEach(this::deleteImage);
additionFiles.forEach(this::upload);
}

private List<Image> getImagesToDeleted(
Long ideaId,
List<Long> imageIds,
int additionFilesSize
) {
List<Image> savedImages = imageRepository.findAllByIdeaId(ideaId);
List<Long> imageIdsToDeleted = imageChecker.getImageIdsToDeleted(
extractIds(savedImages),
imageIds
);
imageChecker.validateTotalImageSize(
savedImages.size(),
imageIdsToDeleted.size(),
additionFilesSize
);
return savedImages.stream()
.filter(image -> imageIdsToDeleted.contains(image.getId()))
.toList();
}

private List<Long> extractIds(List<Image> savedImages) {
return savedImages.stream()
.map(Image::getId)
.toList();
}

private void deleteImage(Image image) {
amazonS3.deleteObject(new DeleteObjectRequest(bucket, image.getImageUrl()));
imageRepository.delete(image);
}
Copy link
Member

Choose a reason for hiding this comment

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

얘도


}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package kr.co.conceptbe.image.application.response;

import kr.co.conceptbe.image.domain.Image;
import org.springframework.beans.factory.annotation.Value;

public record ImageResponse(
Long id,
Long ideaId,
String imageUrl
) {

@Value("${cloud-front-url}")
private static String cloudFrontUrl;
Copy link
Member

Choose a reason for hiding this comment

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

얘는 밖에서 만들어주는게 좋지 않나용

이렇게하면 이거 필요한 레코드마다 다 넣어줘야 될거같아서영

Copy link
Contributor Author

Choose a reason for hiding this comment

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

레코드에서 말고 Service 딴에서 말씀하시는건가용?

Copy link
Member

Choose a reason for hiding this comment

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


public static ImageResponse from(Image image) {
return new ImageResponse(
image.getId(),
image.getIdeaId(),
String.join("/", cloudFrontUrl, image.getImageUrl())
);
}
}
10 changes: 10 additions & 0 deletions src/main/java/kr/co/conceptbe/image/domain/IdeaValidator.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package kr.co.conceptbe.image.domain;

import org.springframework.stereotype.Component;

@Component
public interface IdeaValidator {

void validateIdea(Long ideaId, Long memberId);

}
Loading
Loading