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

}
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
Loading