Skip to content

Commit

Permalink
MATE-90 : [FEAT] S3 연결 및 파일(이미지) 관리 서비스 구현 (#92)
Browse files Browse the repository at this point in the history
* MATE-90 : [FEAT] S3 파일 관리 설정

* MATE-90 : [FEAT] S3 파일 업로드, 삭제 서비스 구현

* MATE-90 : [FEAT] 굿즈 거래글 파일 업로드, 삭제 로직 추가 및 기존 로직 수정

* MATE-90 : [FEAT] 메이트 구인글 파일 업로드, 삭제 로직 추가 및 기존 로직 수정

* MATE-90 : [FEAT] 회원 프로필 파일 업로드, 삭제 로직 추가 및 기존 로직 수정

* MATE-90 : [TEST] S3 파일 관리 서비스 테스트

* MATE-90 : [TEST] 굿즈 거래글 S3 파일 관리 서비스 테스트 수정

* MATE-90 : [TEST] 메이트 구인글 S3 파일 관리 서비스 테스트 수정

* MATE-90 : [TEST] 회원 S3 파일 관리 서비스 테스트 수정

* MATE-90 : [REFACTOR] IOException을 도메인의 컨트롤러, 서비스가 아닌 FileService에서 처리하도록 수정

* MATE-90 : [REFACTOR] 도메인 서비스에서 @value Bucket 의존성 제거

* MATE-90 : [REFACTOR] application.yml 삭제

* MATE-90 : [CHORE] deploy.yml 설정

* MATE-90 : [CHORE] deploy.yml 수정

* MATE-90 : [CHORE] deploy.yml 수정

* MATE-90 : [CHORE] deploy.yml 수정

* MATE-90 : [CHORE] deploy.yml 수정

* MATE-90 : [CHORE] deploy.yml 수정

* MATE-90 : [CHORE] deploy.yml 수정

* MATE-90 : [CHORE] deploy.yml 수정

* MATE-90 : [FIX] Revert c73e2ea부터 9a092a7까지 되돌림
  • Loading branch information
jooinjoo authored Dec 6, 2024
1 parent ac00bfe commit 99586be
Show file tree
Hide file tree
Showing 21 changed files with 325 additions and 279 deletions.
4 changes: 4 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,11 @@ dependencies {
// WebSocket
implementation 'org.springframework.boot:spring-boot-starter-websocket'

// Spring Validation
implementation 'org.hibernate.validator:hibernate-validator'

// AWS S3
implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE'
}

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

import com.amazonaws.auth.AWSStaticCredentialsProvider;
import com.amazonaws.auth.BasicAWSCredentials;
import com.amazonaws.services.s3.AmazonS3Client;
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;

@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 AmazonS3Client amazonS3Client() {
BasicAWSCredentials credentials = new BasicAWSCredentials(accessKey, secretKey);

return (AmazonS3Client) AmazonS3ClientBuilder
.standard()
.withRegion(region)
.withCredentials(new AWSStaticCredentialsProvider(credentials))
.build();
}
}
46 changes: 0 additions & 46 deletions src/main/java/com/example/mate/common/utils/file/FileUploader.java

This file was deleted.

89 changes: 89 additions & 0 deletions src/main/java/com/example/mate/domain/file/FileService.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
package com.example.mate.domain.file;

import com.amazonaws.services.s3.AmazonS3;
import com.amazonaws.services.s3.model.ObjectMetadata;
import com.example.mate.common.error.CustomException;
import com.example.mate.common.error.ErrorCode;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;

import java.util.UUID;

@Slf4j
@Service
@RequiredArgsConstructor
@Transactional
public class FileService {

// 파일명에서 허용하지 않는 문자들에 대한 정규식
private static final String FILE_NAME_REGEX = "[^a-zA-Z0-9.\\-_]";
private static final String FILE_NAME_REPLACEMENT = "_";

private final AmazonS3 amazonS3;

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

// 파일 업로드
public String uploadFile(MultipartFile file) {
String fileName = getFileName(file);

ObjectMetadata metadata = new ObjectMetadata();
metadata.setContentLength(file.getSize());
metadata.setContentType(file.getContentType());

try {
amazonS3.putObject(bucket, fileName, file.getInputStream(), metadata); // 파일 저장
} catch (Exception e) {
throw new CustomException(ErrorCode.FILE_UPLOAD_ERROR);
}
return amazonS3.getUrl(bucket, fileName).toString(); // 파일 저장된 URL 반환
}

// 파일 삭제
public void deleteFile(String imageUrl) {
String key = extractKeyFromUrl(imageUrl);
try {
amazonS3.deleteObject(bucket, key); // 파일 삭제
} catch (Exception e) {
throw new CustomException(ErrorCode.FILE_DELETE_ERROR);
}
}

/**
* S3 URL에서 객체 키를 추출
*
* @param imageUrl S3 URL
* @return 추출된 객체 키
*/
private String extractKeyFromUrl(String imageUrl) {
return imageUrl.replace("https://" + bucket + ".s3.ap-northeast-2.amazonaws.com/", "");
}

/**
* 파일명에서 허용되지 않는 문자를 제거하고, UUID 를 추가한 새로운 파일명을 생성
*
* @param file 업로드할 파일
* @return UUID 를 포함한 새로운 파일명
*/
private static String getFileName(MultipartFile file) {
String uuid = UUID.randomUUID().toString();
return uuid + FILE_NAME_REPLACEMENT + cleanFileName(file.getOriginalFilename());
}

/**
* 파일 이름에서 허용되지 않는 문자를 대체 문자로 변경합니다.
*
* @param fileName 원본 파일명
* @return 대체된 파일명
*/
private static String cleanFileName(String fileName) {
return fileName.replaceAll(FILE_NAME_REGEX, FILE_NAME_REPLACEMENT);
}
}
Original file line number Diff line number Diff line change
@@ -1,31 +1,27 @@
package com.example.mate.common.utils.file;
package com.example.mate.domain.file;

import com.example.mate.common.error.CustomException;
import com.example.mate.common.error.ErrorCode;
import java.util.List;
import org.springframework.web.multipart.MultipartFile;

import java.util.List;

public class FileValidator {

private static final int MAX_GOODS_POST_IMAGE_COUNT = 10;

// 굿즈 판매글 이미지 파일 유효성 검사
// 여러 파일 유효성 검사 : 굿즈 판매글
public static void validateGoodsPostImages(List<MultipartFile> files) {
files.forEach(FileValidator::validateNotEmpty);
files.forEach(FileValidator::isNotImage);

if (files.size() > MAX_GOODS_POST_IMAGE_COUNT) {
throw new CustomException(ErrorCode.FILE_UPLOAD_LIMIT_EXCEEDED);
}
}

// 회원 프로필 이미지 파일 유효성 검사
public static void validateMyProfileImage(MultipartFile file) {
isNotImage(file);
files.forEach(FileValidator::validateNotEmpty);
files.forEach(FileValidator::isNotImage);
}

// 메이트 게시글 이미지 파일 유효성 검사
public static void validateMatePostImage(MultipartFile file) {
// 단일 파일 유효성 검사 : 회원 프로필, 메이트 구인글
public static void validateSingleImage(MultipartFile file) {
isNotImage(file);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,25 +12,17 @@
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Pageable;
import org.springframework.data.web.PageableDefault;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.validation.annotation.Validated;
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.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;

import java.util.List;

@RestController
@RequestMapping("/api/goods")
@RequiredArgsConstructor
Expand All @@ -45,7 +37,7 @@ public ResponseEntity<ApiResponse<GoodsPostResponse>> registerGoodsPost(
@AuthenticationPrincipal AuthMember member,
@Parameter(description = "판매글 등록 데이터", required = true) @Validated @RequestPart("data") GoodsPostRequest request,
@Parameter(description = "판매글 이미지 리스트", required = true) @RequestPart("files") List<MultipartFile> files
) {
) {
GoodsPostResponse response = goodsService.registerGoodsPost(member.getMemberId(), request, files);
return ResponseEntity.ok(ApiResponse.success(response));
}
Expand All @@ -66,8 +58,7 @@ public ResponseEntity<ApiResponse<GoodsPostResponse>> updateGoodsPost(
@Operation(summary = "굿즈거래 판매글 삭제", description = "굿즈거래 판매글 상세 페이지에서 판매글을 삭제합니다.")
public ResponseEntity<Void> deleteGoodsPost(
@AuthenticationPrincipal AuthMember member,
@Parameter(description = "삭제할 판매글 ID", required = true) @PathVariable Long goodsPostId)
{
@Parameter(description = "삭제할 판매글 ID", required = true) @PathVariable Long goodsPostId) {
goodsService.deleteGoodsPost(member.getMemberId(), goodsPostId);
return ResponseEntity.noContent().build();
}
Expand Down Expand Up @@ -106,7 +97,7 @@ public ResponseEntity<ApiResponse<Void>> completeGoodsPost(
@AuthenticationPrincipal AuthMember member,
@Parameter(description = "판매글 ID", required = true) @PathVariable Long goodsPostId,
@Parameter(description = "구매자 ID", required = true) @RequestParam Long buyerId
) {
) {
goodsService.completeTransaction(member.getMemberId(), goodsPostId, buyerId);
return ResponseEntity.ok(ApiResponse.success(null));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,7 @@
import com.example.mate.domain.goods.entity.Category;
import com.example.mate.domain.goods.entity.GoodsPost;
import com.example.mate.domain.member.entity.Member;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import jakarta.validation.constraints.*;
import lombok.AllArgsConstructor;
import lombok.Getter;

Expand Down Expand Up @@ -40,7 +36,6 @@ public class GoodsPostRequest {
@NotNull(message = "위치 정보는 필수 입력 값입니다.")
private LocationInfo location;


public static GoodsPost toEntity(Member seller, GoodsPostRequest request) {
LocationInfo locationInfo = request.getLocation();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,26 +3,20 @@
import com.example.mate.common.error.CustomException;
import com.example.mate.common.error.ErrorCode;
import com.example.mate.common.response.PageResponse;
import com.example.mate.common.utils.file.FileUploader;
import com.example.mate.common.utils.file.FileValidator;
import com.example.mate.domain.constant.TeamInfo;
import com.example.mate.domain.file.FileService;
import com.example.mate.domain.file.FileValidator;
import com.example.mate.domain.goods.dto.request.GoodsPostRequest;
import com.example.mate.domain.goods.dto.request.GoodsReviewRequest;
import com.example.mate.domain.goods.dto.response.GoodsPostResponse;
import com.example.mate.domain.goods.dto.response.GoodsPostSummaryResponse;
import com.example.mate.domain.goods.dto.response.GoodsReviewResponse;
import com.example.mate.domain.goods.entity.Category;
import com.example.mate.domain.goods.entity.GoodsPost;
import com.example.mate.domain.goods.entity.GoodsPostImage;
import com.example.mate.domain.goods.entity.GoodsReview;
import com.example.mate.domain.goods.entity.Status;
import com.example.mate.domain.goods.entity.*;
import com.example.mate.domain.goods.repository.GoodsPostImageRepository;
import com.example.mate.domain.goods.repository.GoodsPostRepository;
import com.example.mate.domain.goods.repository.GoodsReviewRepository;
import com.example.mate.domain.member.entity.Member;
import com.example.mate.domain.member.repository.MemberRepository;
import java.util.ArrayList;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
Expand All @@ -31,6 +25,9 @@
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;

import java.util.ArrayList;
import java.util.List;

@Service
@Transactional
@RequiredArgsConstructor
Expand All @@ -40,6 +37,7 @@ public class GoodsService {
private final GoodsPostRepository goodsPostRepository;
private final GoodsPostImageRepository imageRepository;
private final GoodsReviewRepository reviewRepository;
private final FileService fileService;

public GoodsPostResponse registerGoodsPost(Long memberId, GoodsPostRequest request, List<MultipartFile> files) {
Member seller = findMemberById(memberId);
Expand All @@ -54,7 +52,8 @@ public GoodsPostResponse registerGoodsPost(Long memberId, GoodsPostRequest reque
return GoodsPostResponse.of(savedPost);
}

public GoodsPostResponse updateGoodsPost(Long memberId, Long goodsPostId, GoodsPostRequest request, List<MultipartFile> files) {
public GoodsPostResponse updateGoodsPost(Long memberId, Long goodsPostId, GoodsPostRequest request,
List<MultipartFile> files) {
Member seller = findMemberById(memberId);
GoodsPost goodsPost = findGoodsPostById(goodsPostId);

Expand Down Expand Up @@ -96,11 +95,13 @@ public List<GoodsPostSummaryResponse> getMainGoodsPosts(Long teamId) {
}

@Transactional(readOnly = true)
public PageResponse<GoodsPostSummaryResponse> getPageGoodsPosts(Long teamId, String categoryVal, Pageable pageable) {
public PageResponse<GoodsPostSummaryResponse> getPageGoodsPosts(Long teamId, String categoryVal,
Pageable pageable) {
validateTeamInfo(teamId);
Category category = Category.from(categoryVal);

Page<GoodsPost> pageGoodsPosts = goodsPostRepository.findPageGoodsPosts(teamId, Status.OPEN, category, pageable);
Page<GoodsPost> pageGoodsPosts = goodsPostRepository.findPageGoodsPosts(teamId, Status.OPEN, category,
pageable);
List<GoodsPostSummaryResponse> responses = pageGoodsPosts.getContent().stream()
.map(this::convertToSummaryResponse).toList();

Expand Down Expand Up @@ -134,7 +135,7 @@ private List<GoodsPostImage> uploadImageFiles(List<MultipartFile> files, GoodsPo
List<GoodsPostImage> images = new ArrayList<>();

for (MultipartFile file : files) {
String uploadUrl = FileUploader.uploadFile(file);
String uploadUrl = fileService.uploadFile(file);
GoodsPostImage image = GoodsPostImage.builder()
.imageUrl(uploadUrl)
.post(savedPost)
Expand All @@ -146,11 +147,7 @@ private List<GoodsPostImage> uploadImageFiles(List<MultipartFile> files, GoodsPo

private void deleteExistingImageFiles(Long goodsPostId) {
List<String> imageUrls = imageRepository.getImageUrlsByPostId(goodsPostId);
imageUrls.forEach(url -> {
if (!FileUploader.deleteFile(url)) {
throw new CustomException(ErrorCode.FILE_DELETE_ERROR);
}
});
imageUrls.forEach(fileService::deleteFile);
imageRepository.deleteAllByPostId(goodsPostId);
}

Expand Down
Loading

0 comments on commit 99586be

Please sign in to comment.