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 : 이미지 추가 및 삭제 api 구현, 게시물 작성 및 수정 기능 구현 #29

Merged
merged 12 commits into from
Jan 24, 2025
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
3 changes: 2 additions & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,8 @@ dependencies {
annotationProcessor "jakarta.annotation:jakarta.annotation-api"
annotationProcessor "jakarta.persistence:jakarta.persistence-api"


// amazon s3
implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE'
}

clean { delete file('src/main/generated')}
Expand Down
34 changes: 34 additions & 0 deletions src/main/java/com/ripple/BE/global/config/S3Config.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package com.ripple.BE.global.config;

import com.amazonaws.auth.AWSStaticCredentialsProvider;
import com.amazonaws.auth.BasicAWSCredentials;
import com.amazonaws.services.s3.AmazonS3Client;
import com.amazonaws.services.s3.AmazonS3ClientBuilder;
import lombok.NoArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
@NoArgsConstructor
public class S3Config {

@Value("${cloud.aws.credentials.accessKey}")
private String accessKey;

@Value("${cloud.aws.credentials.secretKey}")
private String secretKey;

@Value("${cloud.aws.region.static}")
private String region;

@Bean
public AmazonS3Client amazonS3Client() {
BasicAWSCredentials credentials = new BasicAWSCredentials(accessKey, secretKey);
return (AmazonS3Client)
AmazonS3ClientBuilder.standard()
.withRegion(region)
.withCredentials(new AWSStaticCredentialsProvider(credentials))
.build();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import com.ripple.BE.global.exception.response.ErrorResponse;
import com.ripple.BE.global.exception.response.ErrorResponse.ValidationError;
import com.ripple.BE.global.exception.response.ErrorResponse.ValidationErrors;
import com.ripple.BE.image.exception.ImageException;
import com.ripple.BE.learning.exception.LearningException;
import com.ripple.BE.learning.exception.QuizException;
import com.ripple.BE.post.exception.PostException;
Expand Down Expand Up @@ -86,6 +87,11 @@ public ResponseEntity<Object> handlePostException(final PostException e) {
return handleExceptionInternal(e.getErrorCode());
}

@ExceptionHandler(ImageException.class)
public ResponseEntity<Object> handleImageException(final ImageException e) {
return handleExceptionInternal(e.getErrorCode());
}

/**
* 예외 처리 결과를 생성하는 내부 메서드
*
Expand Down
45 changes: 45 additions & 0 deletions src/main/java/com/ripple/BE/image/controller/ImageController.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package com.ripple.BE.image.controller;

import com.ripple.BE.global.dto.response.ApiResponse;
import com.ripple.BE.image.dto.response.ImageIdResponse;
import com.ripple.BE.image.service.ImageService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;

@RequiredArgsConstructor
@RestController
@RequestMapping("/api/v1/image")
@Tag(name = "Image", description = "이미지 API")
public class ImageController {

private final ImageService imageService;

@Operation(summary = "게시물 단일 사진 추가", description = "게시물을 등록하기 전 단일 사진을 추가합니다.")
@PostMapping(consumes = "multipart/form-data")
public ResponseEntity<ApiResponse<Object>> createPost(final @RequestParam MultipartFile file) {

long imageId = imageService.addImageToPost(file);

return ResponseEntity.status(HttpStatus.OK)
.body(ApiResponse.from(ImageIdResponse.toImageIdResponse(imageId)));
}

@Operation(summary = "게시물 사진 삭제", description = "게시물에 등록된 사진을 삭제합니다.")
@DeleteMapping("/{imageId}")
public ResponseEntity<ApiResponse<Object>> deleteImage(
final @PathVariable("imageId") long imageId) {

imageService.deleteImage(imageId);
return ResponseEntity.status(HttpStatus.OK).body(ApiResponse.from(ApiResponse.EMPTY_RESPONSE));
}
}
9 changes: 8 additions & 1 deletion src/main/java/com/ripple/BE/image/domain/Image.java
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

@Table(name = "images")
@Getter
Expand All @@ -31,13 +32,19 @@ public class Image extends BaseEntity {
@Column(name = "id")
private Long id;

private String imageUrl;
@Column(name = "s3_info")
private S3Info s3Info;

@Setter
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "post_id")
private Post post;

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "news_id")
private News news;

public static Image toImageEntity(final S3Info s3Info) {
return Image.builder().s3Info(s3Info).build();
}
}
19 changes: 19 additions & 0 deletions src/main/java/com/ripple/BE/image/domain/S3Info.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package com.ripple.BE.image.domain;

import jakarta.persistence.Embeddable;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Embeddable
@Builder
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Getter
public class S3Info {
private String folderName;
private String fileName;
private String url;
}
6 changes: 3 additions & 3 deletions src/main/java/com/ripple/BE/image/dto/ImageDTO.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,13 @@

import com.ripple.BE.image.domain.Image;

public record ImageDTO(String url) {
public record ImageDTO(Long id, String url) {

public static ImageDTO toImageDTO(final Image image) {
return new ImageDTO(image.getImageUrl());
return new ImageDTO(image.getId(), image.getS3Info().getUrl());
}

public static ImageDTO toImageDTO(final String imageUrl) {
return new ImageDTO(imageUrl);
return new ImageDTO(null, imageUrl);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.ripple.BE.image.dto.response;

public record ImageIdResponse(Long imageId) {
public static ImageIdResponse toImageIdResponse(long imageId) {
return new ImageIdResponse(imageId);
}
}
14 changes: 14 additions & 0 deletions src/main/java/com/ripple/BE/image/dto/response/ImageResponse.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.ripple.BE.image.dto.response;

import com.ripple.BE.image.dto.ImageDTO;

public record ImageResponse(Long id, String url) {

public static ImageResponse toImageResponse(final Long id, final String url) {
return new ImageResponse(id, url);
}

public static ImageResponse toImageResponse(final ImageDTO imageDTO) {
return new ImageResponse(imageDTO.id(), imageDTO.url());
}
}
12 changes: 12 additions & 0 deletions src/main/java/com/ripple/BE/image/exception/ImageException.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.ripple.BE.image.exception;

import com.ripple.BE.global.exception.errorcode.ErrorCode;
import lombok.Getter;
import lombok.RequiredArgsConstructor;

@Getter
@RequiredArgsConstructor
public class ImageException extends RuntimeException {

private final ErrorCode errorCode;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.ripple.BE.image.exception.errorcode;

import com.ripple.BE.global.exception.errorcode.ErrorCode;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;

@Getter
@RequiredArgsConstructor
public enum ImageErrorCode implements ErrorCode {
IMAGE_PROCESSING_FAIL(HttpStatus.INTERNAL_SERVER_ERROR, "Image processing fail"),
IMAGE_NOT_FOUND(HttpStatus.NOT_FOUND, "Image not found");

private final HttpStatus httpStatus;
private final String message;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.ripple.BE.image.repository;

import com.ripple.BE.image.domain.Image;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

@Repository
public interface ImageRepository extends JpaRepository<Image, Long> {}
93 changes: 93 additions & 0 deletions src/main/java/com/ripple/BE/image/s3/S3Uploader.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package com.ripple.BE.image.s3;

import static com.ripple.BE.image.exception.errorcode.ImageErrorCode.*;

import com.amazonaws.services.s3.AmazonS3Client;
import com.amazonaws.services.s3.model.CannedAccessControlList;
import com.amazonaws.services.s3.model.ObjectMetadata;
import com.amazonaws.services.s3.model.PutObjectRequest;
import com.ripple.BE.image.domain.S3Info;
import com.ripple.BE.image.exception.ImageException;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;

@Slf4j
@RequiredArgsConstructor
@Component
public class S3Uploader {

private static final String CONTENT_TYPE = "multipart/formed-data";
private final AmazonS3Client amazonS3;

@Value("${cloud.aws.s3.bucket}")
private String bucket;

public S3Info uploadFiles(MultipartFile multipartFile, String folderName) throws ImageException {
File localUploadFile = convertToFile(multipartFile);
return uploadFileToS3(localUploadFile, folderName);
}

public S3Info uploadFileToS3(File file, String folderName) {
String fileName = buildFileName(folderName, file.getName());
String uploadUrl = uploadToS3(file, fileName);

file.delete();

return buildS3Info(folderName, file, uploadUrl);
}

public void deleteFile(S3Info s3Info) {
String fileName = buildFileName(s3Info.getFolderName(), s3Info.getFileName());
log.info("{} 사진 삭제", fileName);
amazonS3.deleteObject(bucket, fileName);
}

private String uploadToS3(File uploadFile, String fileName) {
PutObjectRequest putObjectRequest =
new PutObjectRequest(bucket, fileName, uploadFile)
.withCannedAcl(CannedAccessControlList.PublicRead);

ObjectMetadata metadata = new ObjectMetadata();
metadata.setContentType(CONTENT_TYPE);
amazonS3.putObject(putObjectRequest);

return amazonS3.getUrl(bucket, fileName).toString();
}

private static File convertToFile(MultipartFile file) throws ImageException {
String fileExtension = getFileExtension(file);
File convertedFile =
new File(System.getProperty("user.dir") + "/" + UUID.randomUUID() + "." + fileExtension);

try (FileOutputStream fos = new FileOutputStream(convertedFile)) {
fos.write(file.getBytes());
} catch (IOException e) {
throw new ImageException(IMAGE_PROCESSING_FAIL);
}

return convertedFile;
}

private static String getFileExtension(MultipartFile file) throws ImageException {
String originalFilename = file.getOriginalFilename();
if (originalFilename == null || !originalFilename.contains(".")) {
throw new ImageException(IMAGE_NOT_FOUND);
}
return originalFilename.substring(originalFilename.lastIndexOf(".") + 1);
}

private String buildFileName(String folderName, String fileName) {
return folderName + "/" + fileName;
}

private S3Info buildS3Info(String folderName, File file, String uploadUrl) {
return S3Info.builder().folderName(folderName).fileName(file.getName()).url(uploadUrl).build();
}
}
43 changes: 43 additions & 0 deletions src/main/java/com/ripple/BE/image/service/ImageService.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package com.ripple.BE.image.service;

import static com.ripple.BE.image.exception.errorcode.ImageErrorCode.*;

import com.ripple.BE.image.domain.Image;
import com.ripple.BE.image.domain.S3Info;
import com.ripple.BE.image.exception.ImageException;
import com.ripple.BE.image.repository.ImageRepository;
import com.ripple.BE.image.s3.S3Uploader;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;

@RequiredArgsConstructor
@Service
@Slf4j
public class ImageService {

private final ImageRepository imageRepository;
private final S3Uploader s3Uploader;

private static final String FOLDER_NAME = "post";

@Transactional
public long addImageToPost(MultipartFile file) {

S3Info s3Info = s3Uploader.uploadFiles(file, FOLDER_NAME);

Image image = imageRepository.save(Image.toImageEntity(s3Info));
return image.getId();
}

@Transactional
public void deleteImage(long imageId) {
Image image =
imageRepository.findById(imageId).orElseThrow(() -> new ImageException(IMAGE_NOT_FOUND));
s3Uploader.deleteFile(image.getS3Info());

imageRepository.delete(image);
}
}
Loading