Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
2a5064c
feat: S3 설정 및 이미지 업로드,삭제 기능 구현 (#4)
peanut990 Dec 30, 2024
384471b
feat: 게시글 이미지 업로드 및 삭제 기능 (#4)
peanut990 Dec 30, 2024
40103e3
refactor: 카테고리 테이블명, 관련 엔드포인트 수정
peanut990 Dec 31, 2024
0a44953
refactor: 이미지 업로드 후 경로 반환시 DTO를 사용하도록 변경 (#4)
peanut990 Dec 31, 2024
8e273a0
feat: s3 업로드 기능 테스트 코드 추가 (#4)
peanut990 Dec 31, 2024
92053ed
refactor: 파일 위치 이동, 테스트 코드 엔드포인트 수정 (#4)
peanut990 Dec 31, 2024
147bae9
feat: 파일 업로드 컨트롤러 및 서비스 단위 테스트 추가 (#4)
peanut990 Dec 31, 2024
0cb99f9
feat: 글 작성 컨트롤러 구현 및 요청 DTO 정의 (#4)
peanut990 Dec 31, 2024
d9ae235
feat: 게시글 작성 서비스 로직 구현 (#4)
peanut990 Dec 31, 2024
a1e51c1
fix: s3Client 의 빈 등록이 잘못 설정되어 테스트 실행이 실패하는 현상 수정 (#4)
peanut990 Jan 2, 2025
1e61357
Merge branch 'feature/4-uploadImage' into feature/4-write-post
peanut990 Jan 3, 2025
1aa99b4
refactor: 테스트 코드 수정 (#4)
peanut990 Jan 3, 2025
8fde46f
feat: 글 게시 기능 테스트 코드 작성 (#4)
peanut990 Jan 3, 2025
bba322d
feat: 글 게시 기능 테스트 코드 작성, 스웨거 추가 (#4)
peanut990 Jan 3, 2025
c9597f2
refactor: 사용되지 않는 생성자 제거, 빌더 생성자 제거 (#4)
peanut990 Jan 3, 2025
4eff355
refactor: 테스트 코드 수정 (#4)
peanut990 Jan 3, 2025
d7d4b2d
feat: 예외 핸들러에 예외 추가, 에러 메시지 상수화
peanut990 Jan 3, 2025
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
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
@@ -1,8 +1,10 @@
package com.starchive.springapp.category.exception;

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

public class CategoryNotFoundException extends RuntimeException {
public CategoryNotFoundException() {
super("카테고리가 존재하지 않습니다.");
super(CATEGORY_NOT_FOUND);
}

public CategoryNotFoundException(String message) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import com.starchive.springapp.category.domain.Category;
import com.starchive.springapp.category.dto.CategoryDto;
import com.starchive.springapp.category.exception.CategoryNotFoundException;
import com.starchive.springapp.category.repository.CategoryRepository;
import java.util.List;
import lombok.RequiredArgsConstructor;
Expand All @@ -17,4 +18,8 @@ public List<CategoryDto> findAll() {
return rootCateGories.stream().map(CategoryDto::from).toList();
}

public Category findOne(Long id) {
return categoryRepository.findById(id).orElseThrow(CategoryNotFoundException::new);
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
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";
final public static String CATEGORY_NOT_FOUND = "카테고리가 존재하지 않습니다.";
final public static String HASHTAG_NOT_FOUND = "카테고리가 존재하지 않습니다.";
}
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,7 +1,12 @@
package com.starchive.springapp.global.exception;

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

import com.starchive.springapp.category.exception.CategoryNotFoundException;
import com.starchive.springapp.hashtag.exception.HashTagNotFoundException;
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;
Expand All @@ -21,4 +26,16 @@ public ResponseEntity<Map<String, String>> handleValidationExceptions(MethodArgu

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

@ExceptionHandler(CategoryNotFoundException.class)
public ResponseEntity<String> handleMaxSizeException(CategoryNotFoundException ex) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(ex.getMessage());
}

@ExceptionHandler(HashTagNotFoundException.class)
public ResponseEntity<String> handleMaxSizeException(HashTagNotFoundException ex) {
return ResponseEntity.status(HttpStatus.PAYLOAD_TOO_LARGE)
.body(INVALID_FILE_SIZE);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,12 @@
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Entity
@Table(name = "HashTags")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Getter
public class HashTag {
@Id
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package com.starchive.springapp.hashtag.exception;

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

public class HashTagNotFoundException extends RuntimeException {
public HashTagNotFoundException() {
super("해쉬 태그가 존재 하지 않습니다.");
super(HASHTAG_NOT_FOUND);
}

public HashTagNotFoundException(String message) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,15 @@

@Repository
public interface HashTagRepository extends JpaRepository<HashTag, Long> {
public Optional<HashTag> findByName(String name);
Optional<HashTag> findByName(String name);

@Query("select distinct h from HashTag h "
+ "join fetch PostHashTag ph on h.id = ph.hashTag.id "
+ "join fetch Post p on ph.post.id = p.id "
+ "join fetch Category c on p.category.id = c.id "
+ "where c.id = :categoryId")
List<HashTag> findAllByCategoryId(@Param("categoryId") Long categoryId);


List<HashTag> findManyByIdIn(@Param("ids") List<Long> ids);
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ public HashTag save(String name) {
return hashTagRepository.save(hashTag);
}

public List<HashTag> findManyByIds(List<Long> ids) {
return hashTagRepository.findManyByIdIn(ids);
}

public HashTag findOne(String name) {
return hashTagRepository.findByName(name).orElseThrow(HashTagNotFoundException::new);
}
Expand Down
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,41 @@
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.Getter;
import lombok.NoArgsConstructor;

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

@Lob
String imagePath;

LocalDateTime uploadDate;

@ManyToOne(fetch = LAZY)
@JoinColumn(name = "postId")
Post post;

public PostImage(String imagePath) {
this.imagePath = imagePath;
this.uploadDate = LocalDateTime.now();
}

public void setPost(Post post) {
this.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,20 @@
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);

List<PostImage> findManyByIdIn(@Param("ids") List<Long> ids);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
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.post.domain.Post;
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 = new PostImage(imagePath);

postImageRepository.save(postImage);

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

public void setPost(List<Long> imageIds, Post post) {
List<PostImage> postImages = postImageRepository.findManyByIdIn(imageIds);
postImages.forEach(postImage -> {
postImage.setPost(post);
});
}

@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,28 @@
package com.starchive.springapp.post.controller;

import com.starchive.springapp.post.dto.PostCreateRequest;
import com.starchive.springapp.post.service.PostService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

@RequiredArgsConstructor
@RestController
@Tag(name = "게시글")
public class PostController {
private final PostService postService;

@PostMapping("/post")
@Operation(summary = "게시글 작성")
public ResponseEntity<?> post(@Valid @RequestBody PostCreateRequest request) {

postService.createPost(request);

return ResponseEntity.status(201).build();
}
}
Loading
Loading