Skip to content
Closed
9 changes: 9 additions & 0 deletions BACK/spring-app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ dependencies {
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.6.0'
implementation 'org.springframework.boot:spring-boot-starter-validation'

implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE'

compileOnly 'org.projectlombok:lombok'

Expand All @@ -38,10 +39,18 @@ dependencies {
annotationProcessor 'org.projectlombok:lombok'

testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'io.findify:s3mock_2.13:0.2.6'

testRuntimeOnly 'org.junit.platform:junit-platform-launcher'

}

tasks.named('test') {
useJUnitPlatform()
}

tasks.withType(Test) {
systemProperty "spring.profiles.active", "test"
}


Original file line number Diff line number Diff line change
Expand Up @@ -21,19 +21,19 @@ public class CategoryController {
private final CategoryService categoryService;
private final HashTagService hashTagService;

@GetMapping("/categorys")
@GetMapping("/categories")
@Operation(summary = "카테고리 목록 전체 조회")
public ResponseEntity<ResponseDto<List<CategoryDto>>> showCategories() {
List<CategoryDto> categorys = categoryService.findAll();
ResponseDto<List<CategoryDto>> listResponseDto = new ResponseDto<>(categorys);
List<CategoryDto> categories = categoryService.findAll();
ResponseDto<List<CategoryDto>> listResponseDto = new ResponseDto<>(categories);
return ResponseEntity.ok(listResponseDto);
}

@GetMapping("/categorys/{categoryId}/hashtags")
@GetMapping("/categories/{categoryId}/hashtags")
@Operation(summary = "특정 카테고리에 포함되는 해쉬태그 목록 조회")
public ResponseEntity<ResponseDto<List<HashTagDto>>> showHashTags(@PathVariable("categoryId") Long categoryId) {
List<HashTagDto> categorys = hashTagService.findManyByCategory(categoryId);
ResponseDto<List<HashTagDto>> listResponseDto = new ResponseDto<>(categorys);
List<HashTagDto> categories = hashTagService.findManyByCategory(categoryId);
ResponseDto<List<HashTagDto>> listResponseDto = new ResponseDto<>(categories);
return ResponseEntity.ok(listResponseDto);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
@Table(name = "Categorys")
@Table(name = "Categories")
public class Category {
@Id
@GeneratedValue
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.starchive.springapp.global;

public class ErrorMessage {
final public static String FAIL_UPLOAD = "이미지 업로드 실패";
final public static String NOT_IMAGE_EXTENSION = "올바르지 않은 이미지 확장자(허용 확장자: \"jpg\", \"png\", \"gif\", \"jpeg\"";
final public static String INVALID_FILE_SIZE = "파일 크기가 너무 큽니다. 최대 이미지 크기: 5MB";
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.starchive.springapp.global.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableScheduling;

@Configuration
@EnableScheduling
public class SchedulerConfig {
}
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
package com.starchive.springapp.global.exception;

import static com.starchive.springapp.global.ErrorMessage.INVALID_FILE_SIZE;

import java.util.HashMap;
import java.util.Map;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.multipart.MaxUploadSizeExceededException;

@RestControllerAdvice
public class GlobalExceptionHandler {
Expand All @@ -21,4 +25,10 @@ public ResponseEntity<Map<String, String>> handleValidationExceptions(MethodArgu

return ResponseEntity.badRequest().body(errors);
}

@ExceptionHandler(MaxUploadSizeExceededException.class)
public ResponseEntity<String> handleMaxSizeException(MaxUploadSizeExceededException ex) {
return ResponseEntity.status(HttpStatus.PAYLOAD_TOO_LARGE)
.body(INVALID_FILE_SIZE);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package com.starchive.springapp.image.controller;

import com.starchive.springapp.global.dto.ResponseDto;
import com.starchive.springapp.image.dto.PostImageDto;
import com.starchive.springapp.image.service.PostImageService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;

@Tag(name = "게시글 이미지")
@RestController
@RequiredArgsConstructor
public class PostImageController {
private final PostImageService postImageService;

@Operation(summary = "게시글에 포함될 이미지 업로드")
@PostMapping(value = "/postImage", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<ResponseDto<PostImageDto>> imageUpload(@RequestParam("image") MultipartFile image) {
PostImageDto postImageDto = postImageService.uploadImage(image);

return ResponseEntity.ok(new ResponseDto<>(postImageDto));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package com.starchive.springapp.image.domain;

import static jakarta.persistence.FetchType.LAZY;

import com.starchive.springapp.post.domain.Post;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.Lob;
import jakarta.persistence.ManyToOne;
import java.time.LocalDateTime;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Entity
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class PostImage {
@Id
@GeneratedValue
private Long id;

@Lob
String imagePath;

LocalDateTime uploadDate;

@ManyToOne(fetch = LAZY)
@JoinColumn(name = "postId")
Post post;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.starchive.springapp.image.dto;

import lombok.AllArgsConstructor;
import lombok.Data;

@Data
@AllArgsConstructor
public class PostImageDto {
Long id;
String imagePath;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.starchive.springapp.image.repository;

import com.starchive.springapp.image.domain.PostImage;
import java.time.LocalDateTime;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

public interface PostImageRepository extends JpaRepository<PostImage, Long> {
@Query("SELECT p FROM PostImage p WHERE p.post IS NULL AND p.uploadDate < :cutoffDate")
List<PostImage> findOldOrphanedPostImages(@Param("cutoffDate") LocalDateTime cutoffDate);

@Modifying
@Query("DELETE FROM PostImage p WHERE p.id IN :ids")
void deleteByIds(@Param("ids") List<Long> ids);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package com.starchive.springapp.image.service;

import com.starchive.springapp.image.domain.PostImage;
import com.starchive.springapp.image.dto.PostImageDto;
import com.starchive.springapp.image.repository.PostImageRepository;
import com.starchive.springapp.s3.S3Service;
import java.net.URI;
import java.time.LocalDateTime;
import java.util.List;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;

@Slf4j
@Service
@Transactional
@RequiredArgsConstructor
public class PostImageService {
private final S3Service s3Service;
private final PostImageRepository postImageRepository;

public PostImageDto uploadImage(MultipartFile image) {
String imagePath = s3Service.saveFile(image);
PostImage postImage = PostImage.builder()
.imagePath(imagePath)
.uploadDate(LocalDateTime.now())
.build();

postImageRepository.save(postImage);

return new PostImageDto(postImage.getId(), postImage.getImagePath());
}

@Scheduled(cron = "0 0 2 * * ?")
public void deleteOldOrphanedPostImages() {
LocalDateTime cutoffDate = LocalDateTime.now().minusDays(1); // 하루 전

List<PostImage> oldOrphanedPostImages = postImageRepository.findOldOrphanedPostImages(cutoffDate);
oldOrphanedPostImages.forEach(postImage -> {
s3Service.deleteObject(extractKeyFromUrl(postImage.getImagePath()));
postImageRepository.delete(postImage);
log.info("Deleted old orphaned PostImages: {}", postImage.getImagePath());
});

}

private String extractKeyFromUrl(String url) {
URI uri = URI.create(url);
return uri.getPath().substring(1); // 첫 번째 '/' 제거
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package com.starchive.springapp.s3;

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
@Primary
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
@@ -0,0 +1,77 @@
package com.starchive.springapp.s3;

import com.amazonaws.SdkClientException;
import com.amazonaws.services.s3.AmazonS3;
import com.amazonaws.services.s3.model.AmazonS3Exception;
import com.amazonaws.services.s3.model.ObjectMetadata;
import com.starchive.springapp.global.ErrorMessage;
import java.io.IOException;
import java.util.Arrays;
import java.util.List;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;

@Slf4j
@Service
@RequiredArgsConstructor
public class S3Service {
private final AmazonS3 amazonS3;

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

// 단일 파일 저장
public String saveFile(MultipartFile file) {
String randomFilename = generateRandomFilename(file);
log.info("File upload started: {}, bucketName: {}", randomFilename, bucket);

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

try {
amazonS3.putObject(bucket, randomFilename, file.getInputStream(), metadata);
} catch (AmazonS3Exception e) {
log.error("Amazon S3 error while uploading file: " + e.getMessage());
throw new RuntimeException(ErrorMessage.FAIL_UPLOAD);
} catch (SdkClientException e) {
log.error("AWS SDK client error while uploading file: " + e.getMessage());
throw new RuntimeException(ErrorMessage.FAIL_UPLOAD);
} catch (IOException e) {
log.error("IO error while uploading file: " + e.getMessage());
throw new RuntimeException(ErrorMessage.FAIL_UPLOAD);
}

log.info("File upload completed: " + randomFilename);

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

public void deleteObject(String key) {
amazonS3.deleteObject(bucket, key);
log.info("Deleted S3 Object: {}", key);
}

// 랜덤파일명 생성 (파일명 중복 방지)
private String generateRandomFilename(MultipartFile multipartFile) {
String originalFilename = multipartFile.getOriginalFilename();
String fileExtension = validateFileExtension(originalFilename);
String randomFilename = UUID.randomUUID() + "." + fileExtension;
return randomFilename;
}

// 파일 확장자 체크
private String validateFileExtension(String originalFilename) {
String fileExtension = originalFilename.substring(originalFilename.lastIndexOf(".") + 1).toLowerCase();
List<String> allowedExtensions = Arrays.asList("jpg", "png", "gif", "jpeg");

if (!allowedExtensions.contains(fileExtension)) {
throw new RuntimeException(ErrorMessage.NOT_IMAGE_EXTENSION);
}
return fileExtension;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ class CategoryControllerTest {
categoryRepository.save(child3);

// when
mockMvc.perform(get("/categorys")
mockMvc.perform(get("/categories")
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data").isArray())
Expand Down Expand Up @@ -98,7 +98,7 @@ class CategoryControllerTest {
entityManager.persist(postHashTag2);

// When & Then
mockMvc.perform(get("/categorys/{categoryId}/hashtags", category.getId())
mockMvc.perform(get("/categories/{categoryId}/hashtags", category.getId())
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data").isArray())
Expand Down
Loading
Loading