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

3차 클론코딩 과제 완료 #16

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
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
2 changes: 2 additions & 0 deletions SOPT_CloneCoding/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,5 @@ out/

### VS Code ###
.vscode/

src/main/resources/application.yml
2 changes: 2 additions & 0 deletions SOPT_CloneCoding/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ dependencies {
runtimeOnly 'org.postgresql:postgresql'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
implementation("software.amazon.awssdk:bom:2.21.0")
implementation("software.amazon.awssdk:s3:2.21.0")
}

tasks.named('test') {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package com.example.sopt_clonecoding.config;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import software.amazon.awssdk.auth.credentials.SystemPropertyCredentialsProvider;
import software.amazon.awssdk.regions.Region;
import software.amazon.awssdk.services.s3.S3Client;

@Configuration
public class AwsConfig {

private static final String AWS_ACCESS_KEY_ID = "aws.accessKeyId";
private static final String AWS_SECRET_ACCESS_KEY = "aws.secretAccessKey";

private final String accessKey;
private final String secretKey;
private final String regionString;

public AwsConfig(@Value("${aws-property.access-key}") final String accessKey,
@Value("${aws-property.secret-key}") final String secretKey,
@Value("${aws-property.aws-region}") final String regionString) {
this.accessKey = accessKey;
this.secretKey = secretKey;
this.regionString = regionString;
}


@Bean
public SystemPropertyCredentialsProvider systemPropertyCredentialsProvider() {
System.setProperty(AWS_ACCESS_KEY_ID, accessKey);
System.setProperty(AWS_SECRET_ACCESS_KEY, secretKey);
return SystemPropertyCredentialsProvider.create();
}

@Bean
public Region getRegion() {
return Region.of(regionString);
}

@Bean
public S3Client getS3Client() {
return S3Client.builder()
.region(getRegion())
.credentialsProvider(systemPropertyCredentialsProvider())
.build();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,5 +27,12 @@ public ApiResponse<ItemListDto> findAllByPlace(
){
return ApiResponse.ok(itemService.findAllByPlace(place));
}
@DeleteMapping("/items/{itemId}")
public ApiResponse<Void> deleteItem(
@PathVariable Long itemId
){
itemService.deleteItem(itemId);
return ApiResponse.ok(null);
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package com.example.sopt_clonecoding.domain;

import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;

import java.time.LocalDateTime;

@Entity
@Getter
@Table(name="image")
@NoArgsConstructor(access= AccessLevel.PROTECTED)
public class Image {
@Id
@GeneratedValue(strategy=GenerationType.IDENTITY)
private Long id;

private String imageUrl;

@ManyToOne(targetEntity=Item.class, fetch= FetchType.LAZY)
private Item item;

private LocalDateTime createdAt;

public static Image create(String imageUrl, Item item){
return new Image(imageUrl, item);
}

public Image(String imageUrl, Item item) {
this.imageUrl = imageUrl;
this.item = item;
this.createdAt = LocalDateTime.now();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

import com.example.sopt_clonecoding.dto.type.Tag;
import jakarta.annotation.Nullable;
import org.springframework.web.multipart.MultipartFile;

import java.util.List;

public record ItemCreateDto(
String title,
Expand All @@ -14,6 +17,8 @@ public record ItemCreateDto(
boolean isSell,
boolean canOffer,
@Nullable
String place
String place,

List<MultipartFile> images
) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,11 @@ public enum ErrorCode {
// Custom Error
NOT_FOUND_MEMBER(40401, HttpStatus.NOT_FOUND, "존재하지 않는 회원입니다."),
NOT_FOUND_ITEM(40411, HttpStatus.NOT_FOUND, "존재하지 않는 물건입니다."),
NOT_FOUND_LIKE(40421, HttpStatus.NOT_FOUND, "좋아요가 존재하지 않습니다.");
NOT_FOUND_LIKE(40421, HttpStatus.NOT_FOUND, "좋아요가 존재하지 않습니다."),
FAILED_UPLOAD_IMAGE(40031, HttpStatus.BAD_REQUEST, "이미지 업로드에 실패했습니다."),
FAILED_DELETE_IMAGE(40032, HttpStatus.BAD_REQUEST, "이미지 삭제에 실패했습니다."),

;
private final Integer code;
private final HttpStatus httpStatus;
private final String message;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.example.sopt_clonecoding.repository;

import com.example.sopt_clonecoding.domain.Image;
import com.example.sopt_clonecoding.domain.Item;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.List;

public interface ImageRepository extends JpaRepository<Image, Long> {
List<Image> findAllByItem(Item item);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package com.example.sopt_clonecoding.service;

import com.example.sopt_clonecoding.domain.Image;
import com.example.sopt_clonecoding.domain.Item;
import com.example.sopt_clonecoding.repository.ImageRepository;
import jakarta.transaction.Transactional;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

import java.util.List;

@Service
@RequiredArgsConstructor
public class ImageService {
private final ImageRepository imageRepository;

@Transactional
public void createImage(
final String imageUrl,
final Item item){
Image image = Image.create(imageUrl, item);
imageRepository.save(image);
}

public List<Image> findAllByItem(Item item){
return imageRepository.findAllByItem(item);
}

@Transactional
public void removeImage(Image image){
imageRepository.delete(image);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,18 +12,46 @@
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.io.IOException;
import java.util.List;

@Service
@RequiredArgsConstructor
public class ItemService {
private final ItemRepository itemRepository;
private final MemberService memberService;
private final ImageService imageService;
private final S3Service s3Service;
@Transactional
public void createItem(Long memberId, ItemCreateDto itemCreateDto){
Member member = memberService.findMemberById(memberId);
Item item = Item.create(member, itemCreateDto);
itemRepository.save(item);
Item item = itemRepository.save(Item.create(member, itemCreateDto));
itemCreateDto.images().forEach(
image -> {
try {
String imageUrl = s3Service.uploadImage("items/" + item.getId(), image);
imageService.createImage(imageUrl, item);
} catch (IOException e) {
throw new CustomException(ErrorCode.FAILED_UPLOAD_IMAGE);
Comment on lines +32 to +35

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

imageService.createImage imageService.deleteImage
두 가지 모두 메서드에서 IOExceptionthrow 하고 있는 것으로 보여요.
크게 문제 될 건 없지만, IOException 자체는 S3Service 내부에서 핸들링하면
다른 서비스에서 해당 메서드를 사용할 때 더욱 편리하게 사용할 수 있어보입니다.

}
}
);
}

@Transactional
public void deleteItem(Long itemId){
Item item = findItemById(itemId);
imageService.findAllByItem(item).forEach(
image -> {
try{
s3Service.deleteImage(image.getImageUrl());
imageService.removeImage(image);
} catch (IOException e) {
throw new CustomException(ErrorCode.FAILED_DELETE_IMAGE);
}
}
);
itemRepository.delete(item);
}

public ItemListDto findAllByPlace(String place){
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package com.example.sopt_clonecoding.service;

import com.example.sopt_clonecoding.config.AwsConfig;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;
import software.amazon.awssdk.core.sync.RequestBody;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.model.DeleteObjectRequest;
import software.amazon.awssdk.services.s3.model.PutObjectRequest;

import java.io.IOException;
import java.util.Arrays;
import java.util.List;
import java.util.UUID;

@Component
public class S3Service {

private final String bucketName;
private final AwsConfig awsConfig;
private static final List<String> IMAGE_EXTENSIONS = Arrays.asList("image/jpeg", "image/png", "image/jpg", "image/webp");


public S3Service(@Value("${aws-property.s3-bucket-name}") final String bucketName, AwsConfig awsConfig) {
this.bucketName = bucketName;
this.awsConfig = awsConfig;
}


public String uploadImage(String directoryPath, MultipartFile image) throws IOException {
final String key = directoryPath + generateImageFileName();
final S3Client s3Client = awsConfig.getS3Client();

validateExtension(image);
validateFileSize(image);

PutObjectRequest request = PutObjectRequest.builder()
.bucket(bucketName)
.key(key)
.contentType(image.getContentType())
.contentDisposition("inline")
.build();

RequestBody requestBody = RequestBody.fromBytes(image.getBytes());
s3Client.putObject(request, requestBody);
return key;
}

public void deleteImage(String key) throws IOException {
final S3Client s3Client = awsConfig.getS3Client();

s3Client.deleteObject((DeleteObjectRequest.Builder builder) ->
builder.bucket(bucketName)
.key(key)
.build()
);
}


private String generateImageFileName() {
return UUID.randomUUID() + ".jpg";
}


private void validateExtension(MultipartFile image) {
String contentType = image.getContentType();
if (!IMAGE_EXTENSIONS.contains(contentType)) {
throw new RuntimeException("이미지 확장자는 jpg, png, webp만 가능합니다.");
}
}

private static final Long MAX_FILE_SIZE = 5 * 1024 * 1024L;

private void validateFileSize(MultipartFile image) {
if (image.getSize() > MAX_FILE_SIZE) {
throw new RuntimeException("이미지 사이즈는 5MB를 넘을 수 없습니다.");
}
}

}