Skip to content

Commit

Permalink
[feat] 이미지 업로드 기능을 구현한다. (#91)
Browse files Browse the repository at this point in the history
* chore: s3 의존성 추가

* feat: image 도메인 추가

* feat: AmazonS3 빈으로 등

* feat: ImageExtension Exception 정의

* feat: MultipartFile을 구현한 UploadFile을 추가

* feat: Image 업로드, 삭제를 위한 기반 코드 구현

* feat: 의존성 역전을 위한 IdeaValidator와 ImageRepository 추가

* feat: image upload 기능 구현

* feat: image delete 기능 구현

* feat: image 업로드 api 구현

* feat: image 삭제 api 구현

* feat: 아이디어 조회시 이미지도 조회 가능하도록 수정

* feat: consumes 타입 및 RequestPart 추가

* refactor: image->images 로 Restful 하게 변경

* refactor: image 들을 저장할 수 있도록 변경

* refactor: 이미지 추가 API 가 아무것도 반환하지 않도록 수정

* refactor: Swagger UI 적용

* chore: s3 bucket 및 cloud front url을 application.yml 에 추가

* refactor: image 이름 규칙 변경

* chore: build시 jar 이름 설정

* feat: ImageChecker 구현

* refactor: deleteImages->updateImages로 API 변경

* refactor: MultipartFormData를 받을 수 있도록 수정

* refactor: 응답은 ok로 반환할 수 있도록 수정

* refactor: 테스트 완료 후 원래대로 코드 수정

* refactor: 이미지 추가 시 예외 처리 강화

* refactor: ImageChecker에 AdditionImagesSize를 검증하는 책임을 위임

* test: ideaValidator Test 작성

* test: ImageChecker Test 작성

* refactor: ideaValidator가 바로 예외를 발생시킬 수 있도록 수정

* refactor: s3Client 분리

* refactor: 사용하지 않는 코드 삭제

* refactor: 요청 한번에 처리할 수 있도록 수정

* refactor: test 수정

* refactor: ImageResponse 생성 방법 수정
  • Loading branch information
kpeel5839 authored Jun 15, 2024
1 parent 7cf4279 commit dda5de5
Show file tree
Hide file tree
Showing 23 changed files with 740 additions and 59 deletions.
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'

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

}
31 changes: 25 additions & 6 deletions src/main/java/kr/co/conceptbe/idea/application/IdeaService.java
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.application.ImageService;
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 @@ -37,6 +39,7 @@
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;

@Service
@Transactional
Expand All @@ -52,10 +55,15 @@ public class IdeaService {
private final HitRepository hitRepository;
private final SkillCategoryRepository skillCategoryRepository;
private final CommentRepository commentRepository;
private final ImageRepository imageRepository;
private final ImageService imageService;

public Long save(AuthCredentials authCredentials, IdeaRequest request) {
public Long save(
AuthCredentials authCredentials,
IdeaRequest request,
List<MultipartFile> files
) {
validateMember(authCredentials);

Idea idea = Idea.of(
request.title(),
request.introduce(),
Expand All @@ -66,7 +74,7 @@ public Long save(AuthCredentials authCredentials, IdeaRequest request) {
purposeRepository.findByIdIn(request.purposeIds()),
skillCategoryRepository.findByIdIn(request.skillCategoryIds())
);

imageService.save(idea.getId(), files);
return ideaRepository.save(idea).getId();
}

Expand Down Expand Up @@ -122,11 +130,16 @@ private Set<Idea> getIdeasBookmarkedByMember(Member member) {

public IdeaDetailResponse getDetailIdeaResponse(Long tokenMemberId, Long ideaId) {
Idea idea = ideaRepository.getById(ideaId);
IdeaDetailResponse ideaDetailResponse = IdeaDetailResponse.of(tokenMemberId, idea);
IdeaDetailResponse ideaDetailResponse = IdeaDetailResponse.of(
tokenMemberId,
idea,
imageService.getImageResponses(ideaId)
);

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 Expand Up @@ -186,7 +199,12 @@ public List<IdeaHitResponse> getIdeaHitsResponse(Long ideaId) {
.toList();
}

public void updateIdea(AuthCredentials auth, Long id, IdeaUpdateRequest request) {
public void updateIdea(
AuthCredentials auth,
Long id,
IdeaUpdateRequest request,
List<MultipartFile> files
) {
Idea idea = ideaRepository.getById(id);
validateWriter(auth, idea);
idea.update(
Expand All @@ -198,6 +216,7 @@ public void updateIdea(AuthCredentials auth, Long id, IdeaUpdateRequest request)
purposeRepository.findByIdIn(request.purposeIds()),
skillCategoryRepository.findByIdIn(request.skillCategoryIds())
);
imageService.update(idea.getId(), request.imageIds(), files);
}

private void validateWriter(AuthCredentials auth, Idea idea) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ public record IdeaUpdateRequest(
@ArraySchema(arraySchema = @Schema(description = "목적 ID 목록", example = "[\"1\", \"2\"]"))
List<Long> purposeIds,
@ArraySchema(arraySchema = @Schema(description = "팀원 ID 목록", example = "[\"1\", \"2\", \"3\", \"4\"]"))
List<Long> skillCategoryIds
List<Long> skillCategoryIds,
@ArraySchema(arraySchema = @Schema(description = "현재 게시글에 포함된 이미지 ID 목록", example = "[\"1\", \"2\", \"3\", \"4\"]"))
List<Long> imageIds
) {

}
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<ImageResponse> imageResponses) {
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),
imageResponses
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import java.net.URI;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
import kr.co.conceptbe.auth.presentation.dto.AuthCredentials;
import kr.co.conceptbe.comment.dto.CommentParentResponse;
import kr.co.conceptbe.common.auth.Auth;
Expand All @@ -24,16 +26,18 @@
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.web.PageableDefault;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;

@RestController
@RequiredArgsConstructor
Expand All @@ -43,24 +47,35 @@ public class IdeaController implements IdeaApi {

private final IdeaService ideaService;

@PostMapping
@PostMapping(consumes = {MediaType.APPLICATION_JSON_VALUE, MediaType.MULTIPART_FORM_DATA_VALUE})
public ResponseEntity<Void> addIdea(
@Parameter(hidden = true) @Auth AuthCredentials auth,
@RequestBody IdeaRequest request
@RequestPart IdeaRequest request,
@RequestPart List<MultipartFile> files
) {
Long savedId = ideaService.save(auth, request);
Long savedId = ideaService.save(auth, request, files);

return ResponseEntity.created(URI.create("/ideas/" + savedId))
.build();
}

@PutMapping("/{id}")
@PutMapping(value = "/{id}", consumes = {
MediaType.APPLICATION_JSON_VALUE,
MediaType.MULTIPART_FORM_DATA_VALUE
})
public ResponseEntity<Void> modifyIdea(
@Parameter(hidden = true) @Auth AuthCredentials auth,
@RequestBody IdeaUpdateRequest request,
@PathVariable Long id
@RequestPart IdeaUpdateRequest request,
@PathVariable Long id,
@RequestPart(required = false) List<MultipartFile> files
) {
ideaService.updateIdea(auth, id, request);
ideaService.updateIdea(
auth,
id,
request,
Optional.ofNullable(files)
.orElseGet(Collections::emptyList)
);

return ResponseEntity.noContent().build();
}
Expand Down
10 changes: 7 additions & 3 deletions src/main/java/kr/co/conceptbe/idea/presentation/doc/IdeaApi.java
Original file line number Diff line number Diff line change
Expand Up @@ -21,21 +21,25 @@
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.multipart.MultipartFile;

@Tag(name = "Idea", description = "게시글 API")
public interface IdeaApi {

@Operation(summary = "게시글 작성")
ResponseEntity<Void> addIdea(
@Parameter(hidden = true) @Auth AuthCredentials auth,
@RequestBody IdeaRequest request
@RequestPart IdeaRequest request,
@RequestPart(required = false) List<MultipartFile> files
);

@Operation(summary = "게시글 수정")
ResponseEntity<Void> modifyIdea(
@Parameter(hidden = true) @Auth AuthCredentials auth,
@RequestBody IdeaUpdateRequest request,
@PathVariable Long id
@RequestPart IdeaUpdateRequest request,
@PathVariable Long id,
@RequestPart List<MultipartFile> files
);

@Operation(summary = "게시글 삭제")
Expand Down
Loading

0 comments on commit dda5de5

Please sign in to comment.