Skip to content

Commit

Permalink
Merge pull request #22 from SQUAD-D/feature#17/image-s3
Browse files Browse the repository at this point in the history
S3 image upload
  • Loading branch information
songhaechan authored Nov 20, 2023
2 parents 0e4aac8 + a1afeda commit 885642e
Show file tree
Hide file tree
Showing 30 changed files with 487 additions and 63 deletions.
5 changes: 5 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@ jobs:
spring.datasource.url: ${{ secrets.DB_URL }}
spring.datasource.username: ${{ secrets.DB_USER_NAME }}
spring.datasource.password: ${{ secrets.DB_PASSWORD }}
cloud.aws.s3.bucket: ${{ secrets.BUCKET_NAME }}
cloud.aws.region.static: ${{ secrets.AWS_REGION }}
cloud.aws.credentials.access-key: ${{ secrets.S3_ACCESS }}
cloud.aws.credentials.secret-key: ${{ secrets.S3_SECRET }}


- name: Grant execute permission for gradlew
run: chmod +x gradlew
Expand Down
3 changes: 3 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-aop'
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation 'org.springframework.session:spring-session-data-redis'
implementation 'software.amazon.awssdk:s3:2.16.58'
implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE'
implementation 'org.springframework.boot:spring-boot-starter-quartz:2.7.5'
}

tasks.named('test') {
Expand Down
Binary file modified mdimg/pja.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 3 additions & 1 deletion src/main/java/squad/board/BoardApplication.java
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
package squad.board;

import org.quartz.*;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.session.data.redis.config.annotation.web.http.EnableRedisHttpSession;
import org.springframework.session.web.http.CookieSerializer;
import org.springframework.session.web.http.DefaultCookieSerializer;

@SpringBootApplication
@EnableRedisHttpSession
@EnableConfigurationProperties
@EnableScheduling
public class BoardApplication {

public static void main(String[] args) {
Expand All @@ -24,5 +27,4 @@ public CookieSerializer cookieSerializer() {
serializer.setCookiePath("/");
return serializer;
}

}
18 changes: 12 additions & 6 deletions src/main/java/squad/board/apicontroller/BoardApiController.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import squad.board.aop.BoardWriterAuth;
import squad.board.argumentresolver.SessionAttribute;
import squad.board.commonresponse.CommonIdResponse;
Expand All @@ -18,32 +19,37 @@ public class BoardApiController {
private final BoardService boardService;

// 게시글 생성
@PostMapping("/boards")
@PostMapping(value = "/boards")
public CommonIdResponse saveBoard(
@SessionAttribute Long memberId,
@Valid @RequestBody CreateBoardRequest createBoard) {
return boardService.createBoard(memberId, createBoard);
}

// 이미지 S3 전송
@PostMapping(value = "/boards/img")
public ImageInfoResponse saveImg(
@RequestPart(value = "image") MultipartFile image) {
return boardService.saveImage(image);
}

// 게시글 삭제
@DeleteMapping("/boards/{boardId}")
@BoardWriterAuth
public CommonIdResponse deleteBoard(
@PathVariable Long boardId,
@SessionAttribute Long memberId
@PathVariable Long boardId
) {
return boardService.deleteBoard(boardId, memberId);
return boardService.deleteBoard(boardId);
}

// 게시글 수정
@PatchMapping("/boards/{boardId}")
@BoardWriterAuth
public CommonIdResponse updateBoard(
@PathVariable Long boardId,
@SessionAttribute Long memberId,
@Valid @RequestBody BoardUpdateRequest boardUpdateRequest
) {
return boardService.updateBoard(boardId, memberId, boardUpdateRequest);
return boardService.updateBoard(boardId, boardUpdateRequest);
}

// 상세 게시글 조회
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ public boolean supportsParameter(MethodParameter parameter) {
}

@Override
public Long resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
public Long resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) {
return (Long) webRequest.getAttribute("memberId", WebRequest.SCOPE_SESSION);
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package squad.board;
package squad.board.config;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
Expand Down
33 changes: 33 additions & 0 deletions src/main/java/squad/board/config/S3Config.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package squad.board.config;

import com.amazonaws.auth.AWSCredentials;
import com.amazonaws.auth.AWSStaticCredentialsProvider;
import com.amazonaws.auth.BasicAWSCredentials;
import com.amazonaws.services.s3.AmazonS3;
import com.amazonaws.services.s3.AmazonS3ClientBuilder;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;

@Configuration
public class S3Config {
@Value("${cloud.aws.credentials.access-key}")
private String accessKey;

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

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

@Bean
public AmazonS3 amazonS3Client() {
AWSCredentials credentials = new BasicAWSCredentials(accessKey, secretKey);
return AmazonS3ClientBuilder
.standard()
.withCredentials(new AWSStaticCredentialsProvider(credentials))
.withRegion(region)
.build();
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package squad.board;
package squad.board.config;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
Expand Down Expand Up @@ -27,7 +27,7 @@ public void addInterceptors(InterceptorRegistry registry) {
.excludePathPatterns("/css/**", "/js/**", "/webjars/axios/1.4.0/**", "/", "/api/members/login", "/api/members/logout", "/api/members/id-validation", "/api/members/nick-validation", "/api/members");
}

// Session Attribute Argument Resolver 등록
//Argument Resolver 등록
@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
resolvers.add(sessionAttributeArgumentResolver);
Expand Down
9 changes: 8 additions & 1 deletion src/main/java/squad/board/dto/board/CreateBoardRequest.java
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,22 @@
import squad.board.domain.board.Board;

import java.time.LocalDateTime;
import java.util.List;

@Getter
@Setter
@AllArgsConstructor
public class CreateBoardRequest {
@NotEmpty(message = "제목을 입력해주세요.")
private String title;
@NotEmpty(message = "내용을 입력해주세요.")
private String content;
private List<ImageInfoRequest> imageInfo;

public CreateBoardRequest(String title, String content, List<ImageInfoRequest> imageInfo) {
this.title = title;
this.content = content.replace(".com/tmp/", ".com/original/");
this.imageInfo = imageInfo;
}

public Board toEntity(Long memberId) {
return Board.builder()
Expand Down
14 changes: 14 additions & 0 deletions src/main/java/squad/board/dto/board/ImageInfoRequest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package squad.board.dto.board;

import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.Setter;

@AllArgsConstructor
@Getter
@Setter
public class ImageInfoRequest {
private String imageUUID;
private long imageSize;
private String imageOriginalName;
}
13 changes: 13 additions & 0 deletions src/main/java/squad/board/dto/board/ImageInfoResponse.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package squad.board.dto.board;

import lombok.AllArgsConstructor;
import lombok.Getter;

@AllArgsConstructor
@Getter
public class ImageInfoResponse {
private String imageUUID;
private long imageSize;
private String imageOriginalName;
private String imageSrc;
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
package squad.board.exception.comment;

import org.springframework.http.HttpStatus;
import squad.board.exception.CommonException;

public class CommentException extends CommonException {

public CommentException(CommentStatus status) {
super(status);
}
Expand Down
10 changes: 10 additions & 0 deletions src/main/java/squad/board/exception/image/ImageException.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package squad.board.exception.image;

import squad.board.commonresponse.CommonStatus;
import squad.board.exception.CommonException;

public class ImageException extends CommonException {
public ImageException(CommonStatus status) {
super(status);
}
}
21 changes: 21 additions & 0 deletions src/main/java/squad/board/exception/image/ImageStatus.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package squad.board.exception.image;

import lombok.Getter;
import org.springframework.http.HttpStatus;
import squad.board.commonresponse.CommonStatus;

@Getter
public enum ImageStatus implements CommonStatus {
IMAGE_SIZE_EXCEEDED(HttpStatus.valueOf(400), 500, "[이미지 크기 초과]이미지 파일 허용 크기는 5MB입니다."),
IMAGE_NAME_SIZE_EXCEEDED(HttpStatus.valueOf(400), 501, "이미지명은 100자를 초과할 수 없습니다."),
INVALID_IMAGE_EXTENSION(HttpStatus.valueOf(400), 502, "업로드가 불가능한 이미지 확장자입니다.");
private final HttpStatus httpStatusCode;
private final int code;
private final String message;

ImageStatus(HttpStatus httpStatusCode, int code, String message) {
this.httpStatusCode = httpStatusCode;
this.code = code;
this.message = message;
}
}
4 changes: 2 additions & 2 deletions src/main/java/squad/board/repository/BoardMapper.java
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
@Mapper
public interface BoardMapper {
// 게시글 저장
Long save(Board board);
void save(Board board);

// 상세게시글 조회 [member join for nickname]
BoardDetailResponse findByIdWithNickName(Long boardId);
Expand All @@ -28,7 +28,7 @@ public interface BoardMapper {
// 게시글 삭제
void deleteById(Long boardId);

Long countByKeyWord(String keyWord);
Long countByKeyWord(String keyWord, String searchType);

// 게시글 단건 조회
Board findById(Long boardId);
Expand Down
15 changes: 15 additions & 0 deletions src/main/java/squad/board/repository/ImageMapper.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package squad.board.repository;

import org.apache.ibatis.annotations.Mapper;
import squad.board.dto.board.ImageInfoRequest;

import java.util.List;

@Mapper
public interface ImageMapper {
void save(ImageInfoRequest request, Long boardId);

// 이미지UUID로 파일이름 조회
List<String> findImageUUID(Long boardId);
}

56 changes: 47 additions & 9 deletions src/main/java/squad/board/service/BoardService.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,19 @@
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import squad.board.commonresponse.CommonIdResponse;
import squad.board.domain.board.Board;
import squad.board.dto.ContentListResponse;
import squad.board.dto.Pagination;
import squad.board.dto.board.BoardDetailResponse;
import squad.board.dto.board.BoardResponse;
import squad.board.dto.board.BoardUpdateRequest;
import squad.board.dto.board.CreateBoardRequest;
import squad.board.dto.board.*;
import squad.board.exception.board.BoardException;
import squad.board.exception.board.BoardStatus;
import squad.board.exception.image.ImageException;
import squad.board.exception.image.ImageStatus;
import squad.board.repository.BoardMapper;
import squad.board.repository.CommentMapper;
import squad.board.repository.ImageMapper;

import java.time.LocalDateTime;
import java.util.List;
Expand All @@ -28,11 +29,44 @@ public class BoardService {

private final BoardMapper boardMapper;
private final CommentMapper commentMapper;
private final ImageMapper imageMapper;
private final S3Service s3Service;
private static final long MAX_IMAGE_SIZE = 5000000L;
private static final int MAX_IMAGE_NAME_SIZE = 100;


public CommonIdResponse createBoard(Long memberId, CreateBoardRequest createBoard) {
// 게시글 저장
Board board = createBoard.toEntity(memberId);
Long boardId = boardMapper.save(board);
return new CommonIdResponse(boardId);
boardMapper.save(board);
// 이미지 정보 저장
List<ImageInfoRequest> imageInfoRequests = createBoard.getImageInfo();
for (ImageInfoRequest request : imageInfoRequests) {
// DB에 이미지 정보 저장
imageMapper.save(request, board.getBoardId());
// tmp 폴더의 이미지를 original 폴더로 이동
s3Service.moveImageToOriginal(request.getImageUUID(), "tmp", "original");
}
return new CommonIdResponse(board.getBoardId());
}

public ImageInfoResponse saveImage(MultipartFile image) {
String imgOriginalName = image.getOriginalFilename();
if (!imgOriginalName.substring(imgOriginalName.lastIndexOf(".")).matches("(.png|.jpg|.jpeg)$")) {
throw new ImageException(ImageStatus.INVALID_IMAGE_EXTENSION);
}
// 이미지 파일명 길이 제한
if (imgOriginalName.length() > MAX_IMAGE_NAME_SIZE) {
throw new ImageException(ImageStatus.IMAGE_NAME_SIZE_EXCEEDED);
}
long imgSize = image.getSize();
// 이미지 파일 크기 제한
if (imgSize > MAX_IMAGE_SIZE) {
throw new ImageException(ImageStatus.IMAGE_SIZE_EXCEEDED);
}
String uuid = s3Service.saveFile(image, "tmp");
String imgSrc = s3Service.loadImage(uuid, "tmp");
return new ImageInfoResponse(uuid, imgSize, imgOriginalName, imgSrc);
}

@Transactional(readOnly = true)
Expand All @@ -58,13 +92,17 @@ public BoardDetailResponse findOneBoard(Long boardId) {
return boardMapper.findByIdWithNickName(boardId);
}

public CommonIdResponse deleteBoard(Long boardId, Long memberId) {
public CommonIdResponse deleteBoard(Long boardId) {
List<String> imageUUID = imageMapper.findImageUUID(boardId);
for (String uuid : imageUUID) {
s3Service.deleteImage(uuid, "original");
}
boardMapper.deleteById(boardId);
commentMapper.deleteByBoardId(boardId);
return new CommonIdResponse(boardId);
}

public CommonIdResponse updateBoard(Long boardId, Long memberId, BoardUpdateRequest dto) {
public CommonIdResponse updateBoard(Long boardId, BoardUpdateRequest dto) {
LocalDateTime modifiedDate = LocalDateTime.now();
boardMapper.updateById(boardId, dto, modifiedDate);
return new CommonIdResponse(boardId);
Expand All @@ -73,7 +111,7 @@ public CommonIdResponse updateBoard(Long boardId, Long memberId, BoardUpdateRequ
@Transactional(readOnly = true)
public ContentListResponse<BoardResponse> searchBoard(String keyWord, Long size, Long requestPage, String searchType) {
Long offset = calcOffset(requestPage, size);
Pagination boardPaging = new Pagination(requestPage, boardMapper.countByKeyWord(keyWord), size);
Pagination boardPaging = new Pagination(requestPage, boardMapper.countByKeyWord(keyWord, searchType), size);
List<BoardResponse> byKeyWord = boardMapper.findByKeyWord(keyWord, size, offset, searchType);
return new ContentListResponse<>(boardMapper.findByKeyWord(keyWord, size, offset, searchType), boardPaging);
}
Expand Down
Loading

0 comments on commit 885642e

Please sign in to comment.