diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 000000000..1b6ed9260 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: feature +assignees: '' + +--- + +## 어떤 기능인가요? + +> 추가하려는 기능에 대해 간결하게 설명해주세요 + +## 작업 상세 내용 + +- [ ] TODO +- [ ] TODO +- [ ] TODO + +## 참고할만한 자료(선택) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 000000000..f039ae72b --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,21 @@ +## 관련 이슈 + +- resolves: #이슈 번호 + +## 작업 내용 + + + + + +## 특이 사항 + + + +## 리뷰 요구사항 (선택) + + + + + + diff --git a/.gitignore b/.gitignore index ffed9d77d..9f59fa8d9 100644 --- a/.gitignore +++ b/.gitignore @@ -38,4 +38,8 @@ out/ ### YML ### application-secret.yml -application-prod.yml \ No newline at end of file +application-prod.yml + +### docker volumes ### +mysql_data_local +redis_data_local diff --git a/build.gradle b/build.gradle index b3e68dd00..45902c5f4 100644 --- a/build.gradle +++ b/build.gradle @@ -37,6 +37,7 @@ dependencies {//todo: 안쓰는 의존성이나 deprecated된 의존성 제거 implementation 'org.hibernate.validator:hibernate-validator:8.0.1.Final' implementation 'jakarta.annotation:jakarta.annotation-api:2.1.1' implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.2.0' + testImplementation "org.mockito:mockito-core:3.3.3" compileOnly 'org.projectlombok:lombok:1.18.26' annotationProcessor 'org.projectlombok:lombok' @@ -62,4 +63,4 @@ sourceSets { compileJava { options.annotationProcessorGeneratedSourcesDirectory = file('build/generated/sources/annotationProcessor/java/main') -} \ No newline at end of file +} diff --git a/docker-compose.local.yml b/docker-compose.local.yml index 2102f390b..07fd75023 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -1,5 +1,3 @@ -version: '3.8' - services: mysql: image: mysql:8.0 @@ -11,17 +9,13 @@ services: MYSQL_PASSWORD: solid_connection_local_password ports: - "3306:3306" -# volumes: -# - mysql_data_local:/var/lib/mysql + volumes: + - ./mysql_data_local:/var/lib/mysql redis: image: redis:latest container_name: solid-connection-local-redis ports: - "6379:6379" -# volumes: -# - redis_data_local:/data - -#volumes: -# mysql_data_local: -# redis_data_local: \ No newline at end of file + volumes: + - ./redis_data_local:/data diff --git a/local_compose_down.sh b/local_compose_down.sh new file mode 100755 index 000000000..e45cda18f --- /dev/null +++ b/local_compose_down.sh @@ -0,0 +1,12 @@ +#!/bin/bash + +set -e + +echo "Starting all docker containers..." +docker-compose -f docker-compose.local.yml down + +echo "Pruning unused Docker images..." +docker image prune -f + +echo "Containers are up and running." +docker-compose ps -a diff --git a/local_compose_up.sh b/local_compose_up.sh new file mode 100755 index 000000000..67ef9d0ba --- /dev/null +++ b/local_compose_up.sh @@ -0,0 +1,23 @@ +#!/bin/bash + +# 명령이 0이 아닌 종료값을 가질때 즉시 종료 +set -e + +if [ ! -d "mysql_data_local" ]; then + echo "mysql_data_local 디렉토리가 없습니다. 디렉토리를 생성합니다." + mkdir -p mysql_data_local +fi + +if [ ! -d "redis_data_local" ]; then + echo "redis_data_local 디렉토리가 없습니다. 디렉토리를 생성합니다." + mkdir -p redis_data_local +fi + +echo "Starting all docker containers..." +docker-compose -f docker-compose.local.yml up -d + +echo "Pruning unused Docker images..." +docker image prune -f + +echo "Containers are up and running." +docker-compose ps -a diff --git a/src/main/java/com/example/solidconnection/SolidConnectionApplication.java b/src/main/java/com/example/solidconnection/SolidConnectionApplication.java index 81e27f96b..6727bdece 100644 --- a/src/main/java/com/example/solidconnection/SolidConnectionApplication.java +++ b/src/main/java/com/example/solidconnection/SolidConnectionApplication.java @@ -2,9 +2,11 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; import org.springframework.scheduling.annotation.EnableScheduling; @EnableScheduling +@EnableJpaAuditing @SpringBootApplication public class SolidConnectionApplication { diff --git a/src/main/java/com/example/solidconnection/application/service/ApplicationQueryService.java b/src/main/java/com/example/solidconnection/application/service/ApplicationQueryService.java index e4f314225..104114c5c 100644 --- a/src/main/java/com/example/solidconnection/application/service/ApplicationQueryService.java +++ b/src/main/java/com/example/solidconnection/application/service/ApplicationQueryService.java @@ -39,6 +39,7 @@ public class ApplicationQueryService { * 다른 지원자들의 성적을 조회한다. * - 유저가 다른 지원자들을 볼 수 있는지 검증한다. * - 지역과 키워드를 통해 대학을 필터링한다. + * - 지역은 영어 대문자로 받는다 e.g. ASIA * - 1지망, 2지망 지원자들을 조회한다. * */ @Transactional(readOnly = true) diff --git a/src/main/java/com/example/solidconnection/auth/dto/SignUpRequest.java b/src/main/java/com/example/solidconnection/auth/dto/SignUpRequest.java index 60b2c6cc1..e77cbd31b 100644 --- a/src/main/java/com/example/solidconnection/auth/dto/SignUpRequest.java +++ b/src/main/java/com/example/solidconnection/auth/dto/SignUpRequest.java @@ -16,10 +16,10 @@ public record SignUpRequest( @Schema(description = "카카오 인증 토큰", example = "kakaoToken123") String kakaoOauthToken, - @ArraySchema(schema = @Schema(description = "관심 지역 목록", example = "[\"아시아\", \"유럽\"]")) + @ArraySchema(schema = @Schema(description = "관심 지역 목록", example = "아시아권")) List interestedRegions, - @ArraySchema(schema = @Schema(description = "관심 국가 목록", example = "[\"일본\", \"독일\"]")) + @ArraySchema(schema = @Schema(description = "관심 국가 목록", example = "일본")) List interestedCountries, @Schema(description = "지원 준비 단계", example = "CONSIDERING") diff --git a/src/main/java/com/example/solidconnection/board/controller/BoardController.java b/src/main/java/com/example/solidconnection/board/controller/BoardController.java new file mode 100644 index 000000000..29cfc249a --- /dev/null +++ b/src/main/java/com/example/solidconnection/board/controller/BoardController.java @@ -0,0 +1,45 @@ +package com.example.solidconnection.board.controller; + +import com.example.solidconnection.board.service.BoardService; +import com.example.solidconnection.post.dto.BoardFindPostResponse; +import com.example.solidconnection.type.BoardCode; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.security.SecurityRequirements; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.ArrayList; +import java.util.List; + +import static com.example.solidconnection.config.swagger.SwaggerConfig.ACCESS_TOKEN; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/communities") +@SecurityRequirements +@SecurityRequirement(name = ACCESS_TOKEN) +public class BoardController { + + private final BoardService boardService; + + // todo: 회원별로 접근 가능한 게시판 목록 조회 기능 개발 + @GetMapping() + public ResponseEntity findAccessibleCodes() { + List accessibleCodeList = new ArrayList<>(); + for (BoardCode boardCode : BoardCode.values()) { + accessibleCodeList.add(String.valueOf(boardCode)); + } + return ResponseEntity.ok().body(accessibleCodeList); + } + + @GetMapping("/{code}") + public ResponseEntity findPostsByCodeAndCategory( + @PathVariable(value = "code") String code, + @RequestParam(value = "category", defaultValue = "전체") String category) { + + List postsByCodeAndPostCategory = boardService + .findPostsByCodeAndPostCategory(code, category); + return ResponseEntity.ok().body(postsByCodeAndPostCategory); + } +} diff --git a/src/main/java/com/example/solidconnection/board/domain/Board.java b/src/main/java/com/example/solidconnection/board/domain/Board.java new file mode 100644 index 000000000..007553367 --- /dev/null +++ b/src/main/java/com/example/solidconnection/board/domain/Board.java @@ -0,0 +1,29 @@ +package com.example.solidconnection.board.domain; + +import com.example.solidconnection.post.domain.Post; +import jakarta.persistence.*; +import lombok.*; + +import java.util.ArrayList; +import java.util.List; + +@Entity +@Getter +@NoArgsConstructor +public class Board { + + @Id + @Column(length = 20) + private String code; + + @Column(nullable = false, length = 20) + private String koreanName; + + @OneToMany(mappedBy = "board", cascade = CascadeType.ALL, orphanRemoval = true) + private List postList = new ArrayList<>(); + + public Board(String code, String koreanName) { + this.code = code; + this.koreanName = koreanName; + } +} diff --git a/src/main/java/com/example/solidconnection/board/dto/PostFindBoardResponse.java b/src/main/java/com/example/solidconnection/board/dto/PostFindBoardResponse.java new file mode 100644 index 000000000..b06baa305 --- /dev/null +++ b/src/main/java/com/example/solidconnection/board/dto/PostFindBoardResponse.java @@ -0,0 +1,15 @@ +package com.example.solidconnection.board.dto; + +import com.example.solidconnection.board.domain.Board; + +public record PostFindBoardResponse( + String code, + String koreanName +) { + public static PostFindBoardResponse from(Board board) { + return new PostFindBoardResponse( + board.getCode(), + board.getKoreanName() + ); + } +} diff --git a/src/main/java/com/example/solidconnection/board/repository/BoardRepository.java b/src/main/java/com/example/solidconnection/board/repository/BoardRepository.java new file mode 100644 index 000000000..5c4538279 --- /dev/null +++ b/src/main/java/com/example/solidconnection/board/repository/BoardRepository.java @@ -0,0 +1,30 @@ +package com.example.solidconnection.board.repository; + +import com.example.solidconnection.board.domain.Board; +import com.example.solidconnection.custom.exception.CustomException; +import com.example.solidconnection.custom.exception.ErrorCode; +import org.springframework.data.jpa.repository.EntityGraph; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +import static com.example.solidconnection.custom.exception.ErrorCode.INVALID_BOARD_CODE; + +@Repository +public interface BoardRepository extends JpaRepository { + + @EntityGraph(attributePaths = {"postList"}) + Optional findBoardByCode(@Param("code") String code); + + default Board getByCodeUsingEntityGraph(String code) { + return findBoardByCode(code) + .orElseThrow(() -> new CustomException(ErrorCode.INVALID_BOARD_CODE)); + } + + default Board getByCode(String code) { + return findById(code) + .orElseThrow(() -> new CustomException(INVALID_BOARD_CODE)); + } +} diff --git a/src/main/java/com/example/solidconnection/board/service/BoardService.java b/src/main/java/com/example/solidconnection/board/service/BoardService.java new file mode 100644 index 000000000..3a74b919c --- /dev/null +++ b/src/main/java/com/example/solidconnection/board/service/BoardService.java @@ -0,0 +1,59 @@ +package com.example.solidconnection.board.service; + +import com.example.solidconnection.board.domain.Board; +import com.example.solidconnection.board.repository.BoardRepository; +import com.example.solidconnection.custom.exception.CustomException; +import com.example.solidconnection.custom.exception.ErrorCode; +import com.example.solidconnection.post.domain.Post; +import com.example.solidconnection.post.dto.BoardFindPostResponse; +import com.example.solidconnection.type.BoardCode; +import com.example.solidconnection.type.PostCategory; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class BoardService { + private final BoardRepository boardRepository; + + private String validateCode(String code) { + try { + return String.valueOf(BoardCode.valueOf(code)); + } catch (IllegalArgumentException ex) { + throw new CustomException(ErrorCode.INVALID_BOARD_CODE); + } + } + + private PostCategory validatePostCategory(String postCategory) { + try { + return PostCategory.valueOf(postCategory); + } catch (IllegalArgumentException ex) { + throw new CustomException(ErrorCode.INVALID_POST_CATEGORY); + } + } + + @Transactional(readOnly = true) + public List findPostsByCodeAndPostCategory(String code, String category) { + + String boardCode = validateCode(code); + PostCategory postCategory = validatePostCategory(category); + + Board board = boardRepository.getByCodeUsingEntityGraph(boardCode); + List postList = getPostListByPostCategory(board.getPostList(), postCategory); + + return BoardFindPostResponse.from(postList); + } + + private List getPostListByPostCategory(List postList, PostCategory postCategory) { + if (postCategory.equals(PostCategory.전체)) { + return postList; + } + return postList.stream() + .filter(post -> post.getCategory().equals(postCategory)) + .collect(Collectors.toList()); + } +} diff --git a/src/main/java/com/example/solidconnection/comment/dto/PostFindCommentResponse.java b/src/main/java/com/example/solidconnection/comment/dto/PostFindCommentResponse.java new file mode 100644 index 000000000..75414f943 --- /dev/null +++ b/src/main/java/com/example/solidconnection/comment/dto/PostFindCommentResponse.java @@ -0,0 +1,36 @@ +package com.example.solidconnection.comment.dto; + +import com.example.solidconnection.entity.Comment; +import com.example.solidconnection.siteuser.dto.PostFindSiteUserResponse; + +import java.time.LocalDateTime; + +public record PostFindCommentResponse( + Long id, + Long parentId, + String content, + Boolean isOwner, + LocalDateTime createdAt, + LocalDateTime updatedAt, + PostFindSiteUserResponse postFindSiteUserResponse + +) { + public static PostFindCommentResponse from(Boolean isOwner, Comment comment) { + return new PostFindCommentResponse( + comment.getId(), + getParentCommentId(comment), + comment.getContent(), + isOwner, + comment.getCreatedAt(), + comment.getUpdatedAt(), + PostFindSiteUserResponse.from(comment.getSiteUser()) + ); + } + + private static Long getParentCommentId(Comment comment) { + if (comment.getParentComment() != null) { + return comment.getParentComment().getId(); + } + return null; + } +} diff --git a/src/main/java/com/example/solidconnection/comment/repository/CommentRepository.java b/src/main/java/com/example/solidconnection/comment/repository/CommentRepository.java new file mode 100644 index 000000000..0b0d7152c --- /dev/null +++ b/src/main/java/com/example/solidconnection/comment/repository/CommentRepository.java @@ -0,0 +1,33 @@ +package com.example.solidconnection.comment.repository; + +import com.example.solidconnection.entity.Comment; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; + +public interface CommentRepository extends JpaRepository { + + @Query(value = """ + WITH RECURSIVE CommentTree AS ( + SELECT + id, parent_id, post_id, site_user_id, content, + created_at, updated_at, + 0 AS level, CAST(id AS CHAR(255)) AS path + FROM comment + WHERE post_id = :postId AND parent_id IS NULL + UNION ALL + SELECT + c.id, c.parent_id, c.post_id, c.site_user_id, c.content, + c.created_at, c.updated_at, + ct.level + 1, CONCAT(ct.path, '->', c.id) + FROM comment c + INNER JOIN CommentTree ct ON c.parent_id = ct.id + ) + SELECT * FROM CommentTree + ORDER BY path + """, nativeQuery = true) + List findCommentTreeByPostId(@Param("postId") Long postId); + +} diff --git a/src/main/java/com/example/solidconnection/comment/service/CommentService.java b/src/main/java/com/example/solidconnection/comment/service/CommentService.java new file mode 100644 index 000000000..9e32e4d32 --- /dev/null +++ b/src/main/java/com/example/solidconnection/comment/service/CommentService.java @@ -0,0 +1,29 @@ +package com.example.solidconnection.comment.service; + +import com.example.solidconnection.comment.repository.CommentRepository; +import com.example.solidconnection.comment.dto.PostFindCommentResponse; +import com.example.solidconnection.entity.Comment; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class CommentService { + + private final CommentRepository commentRepository; + + private Boolean isOwner(Comment comment, String email) { + return comment.getSiteUser().getEmail().equals(email); + } + + + public List findCommentsByPostId(String email, Long postId) { + return commentRepository.findCommentTreeByPostId(postId) + .stream() + .map(comment -> PostFindCommentResponse.from(isOwner(comment, email), comment)) + .collect(Collectors.toList()); + } +} diff --git a/src/main/java/com/example/solidconnection/config/redis/RedisConfig.java b/src/main/java/com/example/solidconnection/config/redis/RedisConfig.java index 9c416de9a..1aa671dcf 100644 --- a/src/main/java/com/example/solidconnection/config/redis/RedisConfig.java +++ b/src/main/java/com/example/solidconnection/config/redis/RedisConfig.java @@ -3,9 +3,12 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.core.io.ClassPathResource; +import org.springframework.core.io.Resource; import org.springframework.data.redis.connection.RedisConnectionFactory; import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.script.RedisScript; import org.springframework.data.redis.repository.configuration.EnableRedisRepositories; import org.springframework.data.redis.serializer.StringRedisSerializer; @@ -36,4 +39,10 @@ public RedisTemplate redisTemplate() { redisTemplate.setConnectionFactory(redisConnectionFactory()); return redisTemplate; } + + @Bean(name = "incrViewCountScript") + public RedisScript incrViewCountLuaScript() { + Resource scriptSource = new ClassPathResource("scripts/incrViewCount.lua"); + return RedisScript.of(scriptSource, Long.class); + } } diff --git a/src/main/java/com/example/solidconnection/config/scheduler/SchedulerConfig.java b/src/main/java/com/example/solidconnection/config/scheduler/SchedulerConfig.java new file mode 100644 index 000000000..a52bf281a --- /dev/null +++ b/src/main/java/com/example/solidconnection/config/scheduler/SchedulerConfig.java @@ -0,0 +1,24 @@ +package com.example.solidconnection.config.scheduler; + +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.SchedulingConfigurer; +import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler; +import org.springframework.scheduling.config.ScheduledTaskRegistrar; + +@Configuration +public class SchedulerConfig implements SchedulingConfigurer { + private final int POOL_SIZE = 5; + + @Override + public void configureTasks(ScheduledTaskRegistrar taskRegistrar) { + + final ThreadPoolTaskScheduler threadPoolTaskScheduler = new ThreadPoolTaskScheduler(); + threadPoolTaskScheduler.setPoolSize(POOL_SIZE); + threadPoolTaskScheduler.setThreadNamePrefix("Scheduler-"); + threadPoolTaskScheduler.setWaitForTasksToCompleteOnShutdown(true); + threadPoolTaskScheduler.setAwaitTerminationSeconds(10); + threadPoolTaskScheduler.initialize(); + + taskRegistrar.setTaskScheduler(threadPoolTaskScheduler); + } +} diff --git a/src/main/java/com/example/solidconnection/config/security/SecurityConfiguration.java b/src/main/java/com/example/solidconnection/config/security/SecurityConfiguration.java index e3415e9c4..1c63fe94a 100644 --- a/src/main/java/com/example/solidconnection/config/security/SecurityConfiguration.java +++ b/src/main/java/com/example/solidconnection/config/security/SecurityConfiguration.java @@ -50,7 +50,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { "/", "/index.html", "/favicon.ico", "/file/profile/pre", "/auth/kakao", "/auth/sign-up", "/auth/reissue", - "/university/detail/**", "/university/search/**", "/home", + "/university/detail/**", "/university/search/**", "/university/recommends", "/swagger-ui/**", "/v3/api-docs/**" ) .permitAll() diff --git a/src/main/java/com/example/solidconnection/config/sync/AsyncConfig.java b/src/main/java/com/example/solidconnection/config/sync/AsyncConfig.java new file mode 100644 index 000000000..738d26e04 --- /dev/null +++ b/src/main/java/com/example/solidconnection/config/sync/AsyncConfig.java @@ -0,0 +1,23 @@ +package com.example.solidconnection.config.sync; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +@Configuration +public class AsyncConfig { + + @Bean(name = "asyncExecutor") + public ThreadPoolTaskExecutor asyncExecutor() { + + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(5); + executor.setMaxPoolSize(10); + executor.setQueueCapacity(50); + executor.setThreadNamePrefix("Async-"); + executor.setWaitForTasksToCompleteOnShutdown(true); + executor.setAwaitTerminationSeconds(10); + executor.initialize(); + return executor; + } +} diff --git a/src/main/java/com/example/solidconnection/custom/exception/CustomExceptionHandler.java b/src/main/java/com/example/solidconnection/custom/exception/CustomExceptionHandler.java index 7394203cb..c0c610bce 100644 --- a/src/main/java/com/example/solidconnection/custom/exception/CustomExceptionHandler.java +++ b/src/main/java/com/example/solidconnection/custom/exception/CustomExceptionHandler.java @@ -66,11 +66,10 @@ public ResponseEntity handleJwtException(JwtException ex) { .body(errorResponse); } - @ExceptionHandler(Exception.class) public ResponseEntity handleOtherException(Exception ex) { String errorMessage = ex.getMessage(); - log.error("알 수 없는 예외 발생 : {}", errorMessage); + log.error("서버 내부 예외 발생 : {}", errorMessage); ErrorResponse errorResponse = new ErrorResponse(NOT_DEFINED_ERROR, errorMessage); return ResponseEntity .status(HttpStatus.INTERNAL_SERVER_ERROR) diff --git a/src/main/java/com/example/solidconnection/custom/exception/ErrorCode.java b/src/main/java/com/example/solidconnection/custom/exception/ErrorCode.java index ea1507b04..6caf9edc2 100644 --- a/src/main/java/com/example/solidconnection/custom/exception/ErrorCode.java +++ b/src/main/java/com/example/solidconnection/custom/exception/ErrorCode.java @@ -51,6 +51,14 @@ public enum ErrorCode { CANT_APPLY_FOR_SAME_UNIVERSITY(HttpStatus.BAD_REQUEST.value(), "1, 2지망에 동일한 대학교를 입력할 수 없습니다."), CAN_NOT_CHANGE_NICKNAME_YET(HttpStatus.BAD_REQUEST.value(), "마지막 닉네임 변경으로부터 " + MIN_DAYS_BETWEEN_NICKNAME_CHANGES + "일이 지나지 않았습니다."), + // community + INVALID_POST_CATEGORY(HttpStatus.BAD_REQUEST.value(),"잘못된 카테고리명입니다."), + INVALID_BOARD_CODE(HttpStatus.BAD_REQUEST.value(), "잘못된 게시판 코드입니다."), + INVALID_POST_ID(HttpStatus.BAD_REQUEST.value(), "존재하지 않는 게시글입니다."), + INVALID_POST_ACCESS(HttpStatus.BAD_REQUEST.value(), "자신의 게시글만 제어할 수 있습니다."), + CAN_NOT_DELETE_OR_UPDATE_QUESTION(HttpStatus.BAD_REQUEST.value(), "질문글은 수정이나 삭제할 수 없습니다."), + CAN_NOT_UPLOAD_MORE_THAN_FIVE_IMAGES(HttpStatus.BAD_REQUEST.value(), "5개 이상의 파일을 업로드할 수 없습니다."), + // general JSON_PARSING_FAILED(HttpStatus.BAD_REQUEST.value(), "JSON 파싱을 할 수 없습니다."), JWT_EXCEPTION(HttpStatus.BAD_REQUEST.value(), "JWT 토큰을 처리할 수 없습니다."), diff --git a/src/main/java/com/example/solidconnection/dto/PostFindPostImageResponse.java b/src/main/java/com/example/solidconnection/dto/PostFindPostImageResponse.java new file mode 100644 index 000000000..22a6a4af0 --- /dev/null +++ b/src/main/java/com/example/solidconnection/dto/PostFindPostImageResponse.java @@ -0,0 +1,24 @@ +package com.example.solidconnection.dto; + +import com.example.solidconnection.entity.PostImage; + +import java.util.List; +import java.util.stream.Collectors; + +public record PostFindPostImageResponse( + Long id, + String url +) { + public static PostFindPostImageResponse from(PostImage postImage) { + return new PostFindPostImageResponse( + postImage.getId(), + postImage.getUrl() + ); + } + + public static List from(List postImageList) { + return postImageList.stream() + .map(PostFindPostImageResponse::from) + .collect(Collectors.toList()); + } +} diff --git a/src/main/java/com/example/solidconnection/entity/Comment.java b/src/main/java/com/example/solidconnection/entity/Comment.java new file mode 100644 index 000000000..7b4ad87d8 --- /dev/null +++ b/src/main/java/com/example/solidconnection/entity/Comment.java @@ -0,0 +1,46 @@ +package com.example.solidconnection.entity; + +import com.example.solidconnection.entity.common.BaseEntity; +import com.example.solidconnection.post.domain.Post; +import com.example.solidconnection.siteuser.domain.SiteUser; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.ArrayList; +import java.util.List; + +@Entity +@Getter +@NoArgsConstructor +public class Comment extends BaseEntity { + + // for recursive query + @Transient + private int level; + + @Transient + private String path; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(length = 255) + private String content; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "post_id") + private Post post; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "site_user_id") + private SiteUser siteUser; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "parent_id") + private Comment parentComment; + + @OneToMany(mappedBy = "parentComment", cascade = CascadeType.ALL) + private List commentList = new ArrayList<>(); +} diff --git a/src/main/java/com/example/solidconnection/entity/PostImage.java b/src/main/java/com/example/solidconnection/entity/PostImage.java new file mode 100644 index 000000000..9ab87853c --- /dev/null +++ b/src/main/java/com/example/solidconnection/entity/PostImage.java @@ -0,0 +1,34 @@ +package com.example.solidconnection.entity; + +import com.example.solidconnection.post.domain.Post; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor +public class PostImage { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(length = 500) + private String url; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "post_id") + private Post post; + + public PostImage(String url) { + this.url = url; + } + + public void setPost(Post post) { + if (this.post != null) { + this.post.getPostImageList().remove(this); + } + this.post = post; + post.getPostImageList().add(this); + } +} diff --git a/src/main/java/com/example/solidconnection/entity/common/BaseEntity.java b/src/main/java/com/example/solidconnection/entity/common/BaseEntity.java new file mode 100644 index 000000000..5f1283c64 --- /dev/null +++ b/src/main/java/com/example/solidconnection/entity/common/BaseEntity.java @@ -0,0 +1,26 @@ +package com.example.solidconnection.entity.common; + +import jakarta.persistence.EntityListeners; +import jakarta.persistence.MappedSuperclass; +import lombok.Getter; +import org.hibernate.annotations.DynamicInsert; +import org.hibernate.annotations.DynamicUpdate; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +@Getter +@DynamicUpdate +@DynamicInsert +public abstract class BaseEntity { + + @CreatedDate + private LocalDateTime createdAt; + + @LastModifiedDate + private LocalDateTime updatedAt; +} diff --git a/src/main/java/com/example/solidconnection/entity/mapping/PostLike.java b/src/main/java/com/example/solidconnection/entity/mapping/PostLike.java new file mode 100644 index 000000000..074806838 --- /dev/null +++ b/src/main/java/com/example/solidconnection/entity/mapping/PostLike.java @@ -0,0 +1,24 @@ +package com.example.solidconnection.entity.mapping; + +import com.example.solidconnection.post.domain.Post; +import com.example.solidconnection.siteuser.domain.SiteUser; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor +public class PostLike { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "post_id") + private Post post; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "site_user_id") + private SiteUser siteUser; +} diff --git a/src/main/java/com/example/solidconnection/post/controller/PostController.java b/src/main/java/com/example/solidconnection/post/controller/PostController.java new file mode 100644 index 000000000..022ca8b61 --- /dev/null +++ b/src/main/java/com/example/solidconnection/post/controller/PostController.java @@ -0,0 +1,79 @@ +package com.example.solidconnection.post.controller; + +import com.example.solidconnection.post.dto.*; +import com.example.solidconnection.post.service.PostService; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.security.SecurityRequirements; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.security.Principal; +import java.util.Collections; +import java.util.List; + +import static com.example.solidconnection.config.swagger.SwaggerConfig.ACCESS_TOKEN; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/communities") +@SecurityRequirements +@SecurityRequirement(name = ACCESS_TOKEN) +public class PostController { + + private final PostService postService; + + @PostMapping(value = "/{code}/posts") + public ResponseEntity createPost( + Principal principal, + @PathVariable("code") String code, + @RequestPart("postCreateRequest") PostCreateRequest postCreateRequest, + @RequestParam(value = "file", required = false) List imageFile) { + + if (imageFile == null) { + imageFile = Collections.emptyList(); + } + PostCreateResponse post = postService + .createPost(principal.getName(), code, postCreateRequest, imageFile); + return ResponseEntity.ok().body(post); + } + + @PatchMapping(value = "/{code}/posts/{post_id}") + public ResponseEntity updatePost( + Principal principal, + @PathVariable("code") String code, + @PathVariable("post_id") Long postId, + @RequestPart("postUpdateRequest") PostUpdateRequest postUpdateRequest, + @RequestParam(value = "file", required = false) List imageFile) { + + if (imageFile == null) { + imageFile = Collections.emptyList(); + } + PostUpdateResponse postUpdateResponse = postService + .updatePost(principal.getName(), code, postId, postUpdateRequest, imageFile); + return ResponseEntity.ok().body(postUpdateResponse); + } + + + @GetMapping("/{code}/posts/{post_id}") + public ResponseEntity findPostById( + Principal principal, + @PathVariable("code") String code, + @PathVariable("post_id") Long postId) { + + PostFindResponse postFindResponse = postService + .findPostById(principal.getName(), code, postId); + return ResponseEntity.ok().body(postFindResponse); + } + + @DeleteMapping(value = "/{code}/posts/{post_id}") + public ResponseEntity deletePostById( + Principal principal, + @PathVariable("code") String code, + @PathVariable("post_id") Long postId) { + + PostDeleteResponse postDeleteResponse = postService.deletePostById(principal.getName(), code, postId); + return ResponseEntity.ok().body(postDeleteResponse); + } +} diff --git a/src/main/java/com/example/solidconnection/post/domain/Post.java b/src/main/java/com/example/solidconnection/post/domain/Post.java new file mode 100644 index 000000000..646ac3995 --- /dev/null +++ b/src/main/java/com/example/solidconnection/post/domain/Post.java @@ -0,0 +1,106 @@ +package com.example.solidconnection.post.domain; + +import com.example.solidconnection.board.domain.Board; +import com.example.solidconnection.entity.Comment; +import com.example.solidconnection.entity.PostImage; +import com.example.solidconnection.entity.common.BaseEntity; +import com.example.solidconnection.entity.mapping.PostLike; +import com.example.solidconnection.post.dto.PostUpdateRequest; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.type.PostCategory; +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.BatchSize; + +import java.util.ArrayList; +import java.util.List; + +@Entity +@Getter +@NoArgsConstructor +@EqualsAndHashCode(of = "id") +public class Post extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(length = 255) + private String title; + + @Column(length = 1000) + private String content; + + private Boolean isQuestion; + + private Long likeCount; + + private Long viewCount; + + @Enumerated(EnumType.STRING) + private PostCategory category; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "board_code") + private Board board; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "site_user_id") + private SiteUser siteUser; + + @BatchSize(size = 20) + @OneToMany(mappedBy = "post", cascade = CascadeType.ALL, orphanRemoval = true) + private List commentList = new ArrayList<>(); + + @BatchSize(size = 5) + @OneToMany(mappedBy = "post", cascade = CascadeType.ALL, orphanRemoval = true) + private List postImageList = new ArrayList<>(); + + @OneToMany(mappedBy = "post", cascade = CascadeType.ALL, orphanRemoval = true) + private List postLikeList = new ArrayList<>(); + + public Post(String title, String content, Boolean isQuestion, Long likeCount, Long viewCount, PostCategory category) { + this.title = title; + this.content = content; + this.isQuestion = isQuestion; + this.likeCount = likeCount; + this.viewCount = viewCount; + this.category = category; + } + + public void setBoardAndSiteUser(Board board, SiteUser siteUser) { + if (this.board != null) { + this.board.getPostList().remove(this); + } + this.board = board; + board.getPostList().add(this); + + if (this.siteUser != null) { + this.siteUser.getPostList().remove(this); + } + this.siteUser = siteUser; + siteUser.getPostList().add(this); + } + + public void resetBoardAndSiteUser() { + if (this.board != null) { + this.board.getPostList().remove(this); + this.board = null; + } + if (this.siteUser != null) { + this.siteUser.getPostList().remove(this); + this.siteUser = null; + } + } + + public void update(PostUpdateRequest postUpdateRequest) { + this.title = postUpdateRequest.title(); + this.content = postUpdateRequest.content(); + this.category = PostCategory.valueOf(postUpdateRequest.postCategory()); + } + + public void increaseViewCount(Long updateViewCount) { + this.viewCount += updateViewCount; + } + +} diff --git a/src/main/java/com/example/solidconnection/post/dto/BoardFindPostResponse.java b/src/main/java/com/example/solidconnection/post/dto/BoardFindPostResponse.java new file mode 100644 index 000000000..89c931925 --- /dev/null +++ b/src/main/java/com/example/solidconnection/post/dto/BoardFindPostResponse.java @@ -0,0 +1,52 @@ +package com.example.solidconnection.post.dto; + +import com.example.solidconnection.entity.PostImage; +import com.example.solidconnection.post.domain.Post; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.stream.Collectors; + +public record BoardFindPostResponse( + Long id, + String title, + String content, + Long likeCount, + Integer commentCount, + LocalDateTime createdAt, + LocalDateTime updatedAt, + String postCategory, + String url +) { + + public static BoardFindPostResponse from(Post post) { + return new BoardFindPostResponse( + post.getId(), + post.getTitle(), + post.getContent(), + post.getLikeCount(), + getCommentCount(post), + post.getCreatedAt(), + post.getUpdatedAt(), + String.valueOf(post.getCategory()), + getFirstImageUrl(post) + ); + } + + private static int getCommentCount(Post post) { + return post.getCommentList().size(); + } + + private static String getFirstImageUrl(Post post) { + return post.getPostImageList().stream() + .findFirst() + .map(PostImage::getUrl) + .orElse(null); + } + + public static List from(List postList) { + return postList.stream() + .map(BoardFindPostResponse::from) + .collect(Collectors.toList()); + } +} diff --git a/src/main/java/com/example/solidconnection/post/dto/PostCreateRequest.java b/src/main/java/com/example/solidconnection/post/dto/PostCreateRequest.java new file mode 100644 index 000000000..13cd6469b --- /dev/null +++ b/src/main/java/com/example/solidconnection/post/dto/PostCreateRequest.java @@ -0,0 +1,27 @@ +package com.example.solidconnection.post.dto; + +import com.example.solidconnection.board.domain.Board; +import com.example.solidconnection.post.domain.Post; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.type.PostCategory; + +public record PostCreateRequest( + String postCategory, + String title, + String content, + Boolean isQuestion +) { + + public Post toEntity(SiteUser siteUser, Board board) { + Post post = new Post( + this.title, + this.content, + this.isQuestion, + 0L, + 0L, + PostCategory.valueOf(this.postCategory) + ); + post.setBoardAndSiteUser(board, siteUser); + return post; + } +} diff --git a/src/main/java/com/example/solidconnection/post/dto/PostCreateResponse.java b/src/main/java/com/example/solidconnection/post/dto/PostCreateResponse.java new file mode 100644 index 000000000..a514ffca6 --- /dev/null +++ b/src/main/java/com/example/solidconnection/post/dto/PostCreateResponse.java @@ -0,0 +1,14 @@ +package com.example.solidconnection.post.dto; + +import com.example.solidconnection.post.domain.Post; + +public record PostCreateResponse( + Long id +) { + + public static PostCreateResponse from(Post post) { + return new PostCreateResponse( + post.getId() + ); + } +} diff --git a/src/main/java/com/example/solidconnection/post/dto/PostDeleteResponse.java b/src/main/java/com/example/solidconnection/post/dto/PostDeleteResponse.java new file mode 100644 index 000000000..23c67670d --- /dev/null +++ b/src/main/java/com/example/solidconnection/post/dto/PostDeleteResponse.java @@ -0,0 +1,6 @@ +package com.example.solidconnection.post.dto; + +public record PostDeleteResponse( + Long id +) { +} diff --git a/src/main/java/com/example/solidconnection/post/dto/PostFindResponse.java b/src/main/java/com/example/solidconnection/post/dto/PostFindResponse.java new file mode 100644 index 000000000..bbde1ba91 --- /dev/null +++ b/src/main/java/com/example/solidconnection/post/dto/PostFindResponse.java @@ -0,0 +1,53 @@ +package com.example.solidconnection.post.dto; + +import com.example.solidconnection.board.dto.PostFindBoardResponse; +import com.example.solidconnection.comment.dto.PostFindCommentResponse; +import com.example.solidconnection.dto.*; +import com.example.solidconnection.post.domain.Post; +import com.example.solidconnection.siteuser.dto.PostFindSiteUserResponse; + +import java.time.LocalDateTime; +import java.util.List; + +public record PostFindResponse( + Long id, + String title, + String content, + Boolean isQuestion, + Long likeCount, + Long viewCount, + Integer commentCount, + String postCategory, + Boolean isOwner, + LocalDateTime createdAt, + LocalDateTime updatedAt, + PostFindBoardResponse postFindBoardResponse, + PostFindSiteUserResponse postFindSiteUserResponse, + List postFindCommentResponses, + List postFindPostImageResponses +) { + + public static PostFindResponse from(Post post, Boolean isOwner, PostFindBoardResponse postFindBoardResponse, + PostFindSiteUserResponse postFindSiteUserResponse, + List postFindCommentResponses, + List postFindPostImageResponses + ) { + return new PostFindResponse( + post.getId(), + post.getTitle(), + post.getContent(), + post.getIsQuestion(), + post.getLikeCount(), + post.getViewCount(), + postFindCommentResponses.size(), + String.valueOf(post.getCategory()), + isOwner, + post.getCreatedAt(), + post.getUpdatedAt(), + postFindBoardResponse, + postFindSiteUserResponse, + postFindCommentResponses, + postFindPostImageResponses + ); + } +} diff --git a/src/main/java/com/example/solidconnection/post/dto/PostUpdateRequest.java b/src/main/java/com/example/solidconnection/post/dto/PostUpdateRequest.java new file mode 100644 index 000000000..9394932d7 --- /dev/null +++ b/src/main/java/com/example/solidconnection/post/dto/PostUpdateRequest.java @@ -0,0 +1,8 @@ +package com.example.solidconnection.post.dto; + +public record PostUpdateRequest( + String postCategory, + String title, + String content +) { +} diff --git a/src/main/java/com/example/solidconnection/post/dto/PostUpdateResponse.java b/src/main/java/com/example/solidconnection/post/dto/PostUpdateResponse.java new file mode 100644 index 000000000..70d656766 --- /dev/null +++ b/src/main/java/com/example/solidconnection/post/dto/PostUpdateResponse.java @@ -0,0 +1,13 @@ +package com.example.solidconnection.post.dto; + +import com.example.solidconnection.post.domain.Post; + +public record PostUpdateResponse( + Long id +) { + public static PostUpdateResponse from(Post post) { + return new PostUpdateResponse( + post.getId() + ); + } +} diff --git a/src/main/java/com/example/solidconnection/post/repository/PostRepository.java b/src/main/java/com/example/solidconnection/post/repository/PostRepository.java new file mode 100644 index 000000000..fda9cb166 --- /dev/null +++ b/src/main/java/com/example/solidconnection/post/repository/PostRepository.java @@ -0,0 +1,28 @@ +package com.example.solidconnection.post.repository; + +import com.example.solidconnection.custom.exception.CustomException; +import com.example.solidconnection.post.domain.Post; +import org.springframework.data.jpa.repository.EntityGraph; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +import static com.example.solidconnection.custom.exception.ErrorCode.INVALID_POST_ID; + +@Repository +public interface PostRepository extends JpaRepository { + + @EntityGraph(attributePaths = {"postImageList", "board", "siteUser"}) + Optional findPostById(Long id); + + default Post getByIdUsingEntityGraph(Long id) { + return findPostById(id) + .orElseThrow(() -> new CustomException(INVALID_POST_ID)); + } + + default Post getById(Long id) { + return findById(id) + .orElseThrow(() -> new CustomException(INVALID_POST_ID)); + } +} diff --git a/src/main/java/com/example/solidconnection/post/service/PostService.java b/src/main/java/com/example/solidconnection/post/service/PostService.java new file mode 100644 index 000000000..52bd22310 --- /dev/null +++ b/src/main/java/com/example/solidconnection/post/service/PostService.java @@ -0,0 +1,172 @@ +package com.example.solidconnection.post.service; + +import com.example.solidconnection.board.dto.PostFindBoardResponse; +import com.example.solidconnection.board.repository.BoardRepository; +import com.example.solidconnection.comment.dto.PostFindCommentResponse; +import com.example.solidconnection.comment.service.CommentService; +import com.example.solidconnection.custom.exception.CustomException; +import com.example.solidconnection.dto.*; +import com.example.solidconnection.board.domain.Board; +import com.example.solidconnection.entity.PostImage; +import com.example.solidconnection.post.domain.Post; +import com.example.solidconnection.post.dto.*; +import com.example.solidconnection.post.repository.PostRepository; +import com.example.solidconnection.s3.S3Service; +import com.example.solidconnection.s3.UploadedFileUrlResponse; +import com.example.solidconnection.service.RedisService; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.dto.PostFindSiteUserResponse; +import com.example.solidconnection.siteuser.repository.SiteUserRepository; +import com.example.solidconnection.type.BoardCode; +import com.example.solidconnection.type.ImgType; +import com.example.solidconnection.util.RedisUtils; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; + +import static com.example.solidconnection.custom.exception.ErrorCode.*; + +@Service +@RequiredArgsConstructor +public class PostService { + private final PostRepository postRepository; + private final SiteUserRepository siteUserRepository; + private final BoardRepository boardRepository; + private final S3Service s3Service; + private final CommentService commentService; + private final RedisService redisService; + private final RedisUtils redisUtils; + + private String validateCode(String code) { + try { + return String.valueOf(BoardCode.valueOf(code)); + } catch (IllegalArgumentException ex) { + throw new CustomException(INVALID_BOARD_CODE); + } + } + + private void validateOwnership(Post post, String email) { + if (!post.getSiteUser().getEmail().equals(email)) { + throw new CustomException(INVALID_POST_ACCESS); + } + } + + private void validateFileSize(List imageFile) { + if (imageFile.isEmpty()) { + return; + } + if (imageFile.size() > 5) { + throw new CustomException(CAN_NOT_UPLOAD_MORE_THAN_FIVE_IMAGES); + } + } + + private void validateQuestion(Post post) { + if (post.getIsQuestion()) { + throw new CustomException(CAN_NOT_DELETE_OR_UPDATE_QUESTION); + } + } + + private Boolean getIsOwner(Post post, String email) { + return post.getSiteUser().getEmail().equals(email); + } + + @Transactional + public PostCreateResponse createPost(String email, String code, PostCreateRequest postCreateRequest, + List imageFile) { + + // 유효성 검증 + String boardCode = validateCode(code); + validateFileSize(imageFile); + + // 객체 생성 + SiteUser siteUser = siteUserRepository.getByEmail(email); + Board board = boardRepository.getByCode(boardCode); + Post post = postCreateRequest.toEntity(siteUser, board); + // 이미지 처리 + savePostImages(imageFile, post); + Post createdPost = postRepository.save(post); + + return PostCreateResponse.from(createdPost); + } + + @Transactional + public PostUpdateResponse updatePost(String email, String code, Long postId, PostUpdateRequest postUpdateRequest, + List imageFile) { + + // 유효성 검증 + String boardCode = validateCode(code); + Post post = postRepository.getById(postId); + validateOwnership(post, email); + validateQuestion(post); + validateFileSize(imageFile); + + // 기존 사진 모두 삭제 + removePostImages(post); + // 새로운 이미지 등록 + savePostImages(imageFile, post); + // 게시글 내용 수정 + post.update(postUpdateRequest); + + return PostUpdateResponse.from(post); + } + + private void savePostImages(List imageFile, Post post) { + if (imageFile.isEmpty()) { + return; + } + List uploadedFileUrlResponseList = s3Service.uploadFiles(imageFile, ImgType.COMMUNITY); + for (UploadedFileUrlResponse uploadedFileUrlResponse : uploadedFileUrlResponseList) { + PostImage postImage = new PostImage(uploadedFileUrlResponse.fileUrl()); + postImage.setPost(post); + } + } + + private void removePostImages(Post post) { + for (PostImage postImage : post.getPostImageList()) { + s3Service.deletePostImage(postImage.getUrl()); + } + post.getPostImageList().clear(); + } + + @Transactional(readOnly = true) + public PostFindResponse findPostById(String email, String code, Long postId) { + + String boardCode = validateCode(code); + + Post post = postRepository.getByIdUsingEntityGraph(postId); + Boolean isOwner = getIsOwner(post, email); + + PostFindBoardResponse boardPostFindResultDTO = PostFindBoardResponse.from(post.getBoard()); + PostFindSiteUserResponse siteUserPostFindResultDTO = PostFindSiteUserResponse.from(post.getSiteUser()); + List postImageFindResultDTOList = PostFindPostImageResponse.from(post.getPostImageList()); + List commentFindResultDTOList = commentService.findCommentsByPostId(email, postId); + + // caching && 어뷰징 방지 + if (redisService.isPresent(redisUtils.getValidatePostViewCountRedisKey(email,postId))) { + redisService.increaseViewCountSync(redisUtils.getPostViewCountRedisKey(postId)); + } + + return PostFindResponse.from( + post, isOwner, boardPostFindResultDTO, siteUserPostFindResultDTO, commentFindResultDTOList, postImageFindResultDTOList); + } + + @Transactional + public PostDeleteResponse deletePostById(String email, String code, Long postId) { + + String boardCode = validateCode(code); + Post post = postRepository.getById(postId); + validateOwnership(post, email); + validateQuestion(post); + + removePostImages(post); + post.resetBoardAndSiteUser(); + // cache out + redisService.deleteKey(redisUtils.getPostViewCountRedisKey(postId)); + postRepository.deleteById(post.getId()); + + return new PostDeleteResponse(postId); + } +} diff --git a/src/main/java/com/example/solidconnection/repositories/PostImageRepository.java b/src/main/java/com/example/solidconnection/repositories/PostImageRepository.java new file mode 100644 index 000000000..0ae776877 --- /dev/null +++ b/src/main/java/com/example/solidconnection/repositories/PostImageRepository.java @@ -0,0 +1,9 @@ +package com.example.solidconnection.repositories; + +import com.example.solidconnection.entity.PostImage; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface PostImageRepository extends JpaRepository { +} diff --git a/src/main/java/com/example/solidconnection/s3/S3Service.java b/src/main/java/com/example/solidconnection/s3/S3Service.java index 49e515872..4bee94932 100644 --- a/src/main/java/com/example/solidconnection/s3/S3Service.java +++ b/src/main/java/com/example/solidconnection/s3/S3Service.java @@ -19,10 +19,7 @@ import org.springframework.web.multipart.MultipartFile; import java.io.IOException; -import java.util.Arrays; -import java.util.List; -import java.util.Objects; -import java.util.UUID; +import java.util.*; import static com.example.solidconnection.custom.exception.ErrorCode.FILE_NOT_EXIST; import static com.example.solidconnection.custom.exception.ErrorCode.INVALID_FILE_EXTENSIONS; @@ -76,6 +73,40 @@ public UploadedFileUrlResponse uploadFile(MultipartFile multipartFile, ImgType i return new UploadedFileUrlResponse(amazonS3.getUrl(bucket, fileName).toString()); } + public List uploadFiles(List multipartFile, ImgType imageFile) { + + List uploadedFileUrlResponseList = new ArrayList<>(); + + for (MultipartFile file : multipartFile) { + // 파일 검증 + validateImgFile(file); + + // 메타데이터 생성 + String contentType = file.getContentType(); + ObjectMetadata metadata = new ObjectMetadata(); + metadata.setContentType(contentType); + metadata.setContentLength(file.getSize()); + + // 파일 이름 생성 + UUID randomUUID = UUID.randomUUID(); + String fileName = imageFile.getType() + "/" + randomUUID; + + try { + amazonS3.putObject(new PutObjectRequest(bucket, fileName, file.getInputStream(), metadata) + .withCannedAcl(CannedAccessControlList.PublicRead)); + } catch (AmazonServiceException e) { + log.error("이미지 업로드 중 s3 서비스 예외 발생 : {}", e.getMessage()); + throw new CustomException(S3_SERVICE_EXCEPTION); + } catch (SdkClientException | IOException e) { + log.error("이미지 업로드 중 s3 클라이언트 예외 발생 : {}", e.getMessage()); + throw new CustomException(S3_CLIENT_EXCEPTION); + } + uploadedFileUrlResponseList.add(new UploadedFileUrlResponse(amazonS3.getUrl(bucket, fileName).toString())); + } + + return uploadedFileUrlResponseList; + } + private void validateImgFile(MultipartFile file) { if (file == null || file.isEmpty()) { throw new CustomException(FILE_NOT_EXIST); @@ -108,6 +139,11 @@ public void deleteExProfile(String email) { deleteFile(key); } + public void deletePostImage(String url) { + String key = getPostImageUrl(url); + deleteFile(key); + } + private void deleteFile(String fileName) { try { amazonS3.deleteObject(new DeleteObjectRequest(bucket, fileName)); @@ -126,4 +162,9 @@ private String getExProfileImageUrl(String email) { int domainStartIndex = fileName.indexOf(".com"); return fileName.substring(domainStartIndex + 5); } + + private String getPostImageUrl(String url) { + int domainStartIndex = url.indexOf(".com"); + return url.substring(domainStartIndex + 5); + } } diff --git a/src/main/java/com/example/solidconnection/scheduler/UpdateViewCountScheduler.java b/src/main/java/com/example/solidconnection/scheduler/UpdateViewCountScheduler.java new file mode 100644 index 000000000..41ba66398 --- /dev/null +++ b/src/main/java/com/example/solidconnection/scheduler/UpdateViewCountScheduler.java @@ -0,0 +1,40 @@ +package com.example.solidconnection.scheduler; + +import com.example.solidconnection.service.UpdateViewCountService; +import com.example.solidconnection.util.RedisUtils; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; +import org.springframework.stereotype.Component; + +import java.util.List; + +import static com.example.solidconnection.type.RedisConstants.*; + +@RequiredArgsConstructor +@Component +@EnableScheduling +@EnableAsync +@Slf4j +public class UpdateViewCountScheduler { + + private final RedisUtils redisUtils; + private final ThreadPoolTaskExecutor asyncExecutor; + private final UpdateViewCountService updateViewCountService; + + @Async + @Scheduled(fixedDelayString = "${view.count.scheduling.delay}") + public void updateViewCount() { + + log.info("updateViewCount thread: {}", Thread.currentThread().getName()); + List itemViewCountKeys = redisUtils.getKeysOrderByExpiration(VIEW_COUNT_KEY_PATTERN.getValue()); + + itemViewCountKeys.forEach(key -> asyncExecutor.submit(() -> { + updateViewCountService.updateViewCount(key); + })); + } +} diff --git a/src/main/java/com/example/solidconnection/service/RedisService.java b/src/main/java/com/example/solidconnection/service/RedisService.java new file mode 100644 index 000000000..4776f1692 --- /dev/null +++ b/src/main/java/com/example/solidconnection/service/RedisService.java @@ -0,0 +1,43 @@ +package com.example.solidconnection.service; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.script.RedisScript; +import org.springframework.stereotype.Service; + +import java.util.Collections; +import java.util.concurrent.TimeUnit; + +import static com.example.solidconnection.type.RedisConstants.*; + +@Service +public class RedisService { + private final RedisTemplate redisTemplate; + private final RedisScript incrViewCountLuaScript; + + @Autowired + public RedisService(RedisTemplate redisTemplate, + @Qualifier("incrViewCountScript") RedisScript incrViewCountLuaScript) { + this.redisTemplate = redisTemplate; + this.incrViewCountLuaScript = incrViewCountLuaScript; + } + + // incr & set ttl -> lua + public void increaseViewCountSync(String key) { + redisTemplate.execute(incrViewCountLuaScript, Collections.singletonList(key), VIEW_COUNT_TTL.getValue()); + } + + public void deleteKey(String key){ + redisTemplate.opsForValue().getAndDelete(key); + } + + public Long getAndDelete(String key) { + return Long.valueOf(redisTemplate.opsForValue().getAndDelete(key)); + } + + public boolean isPresent(String key) { + return Boolean.TRUE.equals(redisTemplate.opsForValue() + .setIfAbsent(key, "1", Long.parseLong(VALIDATE_VIEW_COUNT_TTL.getValue()), TimeUnit.SECONDS)); + } +} diff --git a/src/main/java/com/example/solidconnection/service/UpdateViewCountService.java b/src/main/java/com/example/solidconnection/service/UpdateViewCountService.java new file mode 100644 index 000000000..46954fff6 --- /dev/null +++ b/src/main/java/com/example/solidconnection/service/UpdateViewCountService.java @@ -0,0 +1,32 @@ +package com.example.solidconnection.service; + +import com.example.solidconnection.post.domain.Post; +import com.example.solidconnection.post.repository.PostRepository; +import com.example.solidconnection.util.RedisUtils; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Service +@EnableAsync +@Slf4j +public class UpdateViewCountService { + + private final PostRepository postRepository; + private final RedisService redisService; + private final RedisUtils redisUtils; + + @Transactional + @Async + public void updateViewCount(String key) { + log.info("updateViewCount Processing key: {} in thread: {}", key, Thread.currentThread().getName()); + Long postId = redisUtils.getPostIdFromPostViewCountRedisKey(key); + Post post = postRepository.getById(postId); + post.increaseViewCount(redisService.getAndDelete(key)); + log.info("updateViewCount Updated post id: {} with view count from key: {}", postId, key); + } +} diff --git a/src/main/java/com/example/solidconnection/siteuser/domain/SiteUser.java b/src/main/java/com/example/solidconnection/siteuser/domain/SiteUser.java index b13a26e9a..2cd7dc4ff 100644 --- a/src/main/java/com/example/solidconnection/siteuser/domain/SiteUser.java +++ b/src/main/java/com/example/solidconnection/siteuser/domain/SiteUser.java @@ -1,26 +1,23 @@ package com.example.solidconnection.siteuser.domain; +import com.example.solidconnection.entity.Comment; +import com.example.solidconnection.post.domain.Post; +import com.example.solidconnection.entity.mapping.PostLike; import com.example.solidconnection.type.Gender; import com.example.solidconnection.type.PreparationStatus; import com.example.solidconnection.type.Role; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.EnumType; -import jakarta.persistence.Enumerated; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import lombok.AccessLevel; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.Setter; +import jakarta.persistence.*; +import lombok.*; import java.time.LocalDate; import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) @Entity +@AllArgsConstructor public class SiteUser { @Id @@ -59,6 +56,15 @@ public class SiteUser { @Setter private LocalDate quitedAt; + @OneToMany(mappedBy = "siteUser", cascade = CascadeType.ALL, orphanRemoval = true) + private List postList = new ArrayList<>(); + + @OneToMany(mappedBy = "siteUser", cascade = CascadeType.ALL, orphanRemoval = true) + private List commentList = new ArrayList<>(); + + @OneToMany(mappedBy = "siteUser", cascade = CascadeType.ALL, orphanRemoval = true) + private List postLikeList = new ArrayList<>(); + public SiteUser( String email, String nickname, diff --git a/src/main/java/com/example/solidconnection/siteuser/dto/PostFindSiteUserResponse.java b/src/main/java/com/example/solidconnection/siteuser/dto/PostFindSiteUserResponse.java new file mode 100644 index 000000000..85d649631 --- /dev/null +++ b/src/main/java/com/example/solidconnection/siteuser/dto/PostFindSiteUserResponse.java @@ -0,0 +1,17 @@ +package com.example.solidconnection.siteuser.dto; + +import com.example.solidconnection.siteuser.domain.SiteUser; + +public record PostFindSiteUserResponse( + Long id, + String nickname, + String profileImageUrl +) { + public static PostFindSiteUserResponse from(SiteUser siteUser) { + return new PostFindSiteUserResponse( + siteUser.getId(), + siteUser.getNickname(), + siteUser.getProfileImageUrl() + ); + } +} diff --git a/src/main/java/com/example/solidconnection/type/BoardCode.java b/src/main/java/com/example/solidconnection/type/BoardCode.java new file mode 100644 index 000000000..0d161e941 --- /dev/null +++ b/src/main/java/com/example/solidconnection/type/BoardCode.java @@ -0,0 +1,5 @@ +package com.example.solidconnection.type; + +public enum BoardCode { + EUROPE, AMERICAS, ASIA, FREE; +} diff --git a/src/main/java/com/example/solidconnection/type/ImgType.java b/src/main/java/com/example/solidconnection/type/ImgType.java index 4cba0787c..45eb516bb 100644 --- a/src/main/java/com/example/solidconnection/type/ImgType.java +++ b/src/main/java/com/example/solidconnection/type/ImgType.java @@ -4,7 +4,7 @@ @Getter public enum ImgType { - PROFILE("profile"), GPA("gpa"), LANGUAGE_TEST("language"); + PROFILE("profile"), GPA("gpa"), LANGUAGE_TEST("language"), COMMUNITY("community"); private final String type; diff --git a/src/main/java/com/example/solidconnection/type/PostCategory.java b/src/main/java/com/example/solidconnection/type/PostCategory.java new file mode 100644 index 000000000..b42b94f95 --- /dev/null +++ b/src/main/java/com/example/solidconnection/type/PostCategory.java @@ -0,0 +1,5 @@ +package com.example.solidconnection.type; + +public enum PostCategory { + 전체, 자유, 질문 +} diff --git a/src/main/java/com/example/solidconnection/type/RedisConstants.java b/src/main/java/com/example/solidconnection/type/RedisConstants.java new file mode 100644 index 000000000..22d7762b1 --- /dev/null +++ b/src/main/java/com/example/solidconnection/type/RedisConstants.java @@ -0,0 +1,18 @@ +package com.example.solidconnection.type; + +import lombok.Getter; + +@Getter +public enum RedisConstants { + VIEW_COUNT_TTL("60"), + VALIDATE_VIEW_COUNT_TTL("1"), + VALIDATE_VIEW_COUNT_KEY_PREFIX("validate:post:view:"), + VIEW_COUNT_KEY_PREFIX("post:view:"), + VIEW_COUNT_KEY_PATTERN("post:view:*"); + + private final String value; + + RedisConstants(String value) { + this.value = value; + } +} diff --git a/src/main/java/com/example/solidconnection/university/domain/UniversityInfoForApply.java b/src/main/java/com/example/solidconnection/university/domain/UniversityInfoForApply.java index bbb4dd015..903bc9320 100644 --- a/src/main/java/com/example/solidconnection/university/domain/UniversityInfoForApply.java +++ b/src/main/java/com/example/solidconnection/university/domain/UniversityInfoForApply.java @@ -14,6 +14,7 @@ import jakarta.persistence.OneToMany; import lombok.AccessLevel; import lombok.AllArgsConstructor; +import lombok.EqualsAndHashCode; import lombok.Getter; import lombok.NoArgsConstructor; @@ -21,6 +22,7 @@ import java.util.Set; @Getter +@EqualsAndHashCode(of = "id") @AllArgsConstructor(access = AccessLevel.PUBLIC) @NoArgsConstructor(access = AccessLevel.PROTECTED) @Entity @@ -71,10 +73,10 @@ public class UniversityInfoForApply { @Column(length = 500) private String details; - @OneToMany(mappedBy = "universityInfoForApply", fetch = FetchType.LAZY) + @OneToMany(mappedBy = "universityInfoForApply", fetch = FetchType.EAGER) private Set languageRequirements = new HashSet<>(); - @ManyToOne(fetch = FetchType.LAZY) + @ManyToOne(fetch = FetchType.EAGER) private University university; public void addLanguageRequirements(LanguageRequirement languageRequirements) { diff --git a/src/main/java/com/example/solidconnection/university/repository/custom/UniversityFilterRepository.java b/src/main/java/com/example/solidconnection/university/repository/custom/UniversityFilterRepository.java index bc2306737..009496be7 100644 --- a/src/main/java/com/example/solidconnection/university/repository/custom/UniversityFilterRepository.java +++ b/src/main/java/com/example/solidconnection/university/repository/custom/UniversityFilterRepository.java @@ -10,6 +10,6 @@ public interface UniversityFilterRepository { List findByRegionCodeAndKeywords(String regionCode, List keywords); - List findByRegionCodeAndKeywordsAndLanguageTestTypeAndTestScore( - String regionCode, List keywords, LanguageTestType testType, String testScore); + List findByRegionCodeAndKeywordsAndLanguageTestTypeAndTestScoreAndTerm( + String regionCode, List keywords, LanguageTestType testType, String testScore, String term); } diff --git a/src/main/java/com/example/solidconnection/university/repository/custom/UniversityFilterRepositoryImpl.java b/src/main/java/com/example/solidconnection/university/repository/custom/UniversityFilterRepositoryImpl.java index 994618a6b..0beac6763 100644 --- a/src/main/java/com/example/solidconnection/university/repository/custom/UniversityFilterRepositoryImpl.java +++ b/src/main/java/com/example/solidconnection/university/repository/custom/UniversityFilterRepositoryImpl.java @@ -3,7 +3,6 @@ import com.example.solidconnection.entity.QCountry; import com.example.solidconnection.entity.QRegion; import com.example.solidconnection.type.LanguageTestType; -import com.example.solidconnection.university.domain.QLanguageRequirement; import com.example.solidconnection.university.domain.QUniversity; import com.example.solidconnection.university.domain.QUniversityInfoForApply; import com.example.solidconnection.university.domain.University; @@ -68,26 +67,25 @@ private BooleanExpression createKeywordCondition(StringPath namePath, List findByRegionCodeAndKeywordsAndLanguageTestTypeAndTestScore( - String regionCode, List keywords, LanguageTestType testType, String testScore) { + public List findByRegionCodeAndKeywordsAndLanguageTestTypeAndTestScoreAndTerm( + String regionCode, List keywords, LanguageTestType testType, String testScore, String term) { QUniversity university = QUniversity.university; QCountry country = QCountry.country; QRegion region = QRegion.region; QUniversityInfoForApply universityInfoForApply = QUniversityInfoForApply.universityInfoForApply; - QLanguageRequirement languageRequirement = QLanguageRequirement.languageRequirement; List filteredUniversityInfoForApply = queryFactory .selectFrom(universityInfoForApply) .join(universityInfoForApply.university, university) .join(university.country, country) - .join(country.region, region) - .join(universityInfoForApply.languageRequirements, languageRequirement) + .join(university.region, region) .where(regionCodeEq(region, regionCode) - .and(countryOrUniversityContainsKeyword(country, university, keywords))) + .and(countryOrUniversityContainsKeyword(country, university, keywords)) + .and(universityInfoForApply.term.eq(term))) .fetch(); - if(testType == null || testScore == null || testScore.isEmpty()) { + if(testScore == null || testScore.isEmpty()) { if(testType != null) { return filteredUniversityInfoForApply.stream() .filter(uifa -> uifa.getLanguageRequirements().stream() diff --git a/src/main/java/com/example/solidconnection/university/service/GeneralRecommendUniversities.java b/src/main/java/com/example/solidconnection/university/service/GeneralRecommendUniversities.java index f9441c61d..6eb70f81b 100644 --- a/src/main/java/com/example/solidconnection/university/service/GeneralRecommendUniversities.java +++ b/src/main/java/com/example/solidconnection/university/service/GeneralRecommendUniversities.java @@ -1,11 +1,13 @@ package com.example.solidconnection.university.service; +import com.example.solidconnection.repositories.CountryRepository; import com.example.solidconnection.university.domain.UniversityInfoForApply; import com.example.solidconnection.university.repository.UniversityInfoForApplyRepository; -import jakarta.annotation.PostConstruct; import lombok.Getter; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.event.EventListener; import org.springframework.stereotype.Component; import java.util.List; @@ -24,17 +26,18 @@ public class GeneralRecommendUniversities { @Getter private final List recommendUniversities; private final UniversityInfoForApplyRepository universityInfoForApplyRepository; + private final CountryRepository countryRepository; private final List candidates = List.of( "오스트라바 대학", "RMIT멜버른공과대학(A형)", "알브슈타트 지그마링엔 대학", "뉴저지시티대학(A형)", "도요대학", "템플대학(A형)", "빈 공과대학교", "리스본대학 공과대학", "바덴뷔르템베르크 산학협력대학", "긴다이대학", "네바다주립대학 라스베이거스(B형)", "릴 가톨릭 대학", - "그라츠공과대학", "그라츠 대학", "코펜하겐 IT대학", "메이지대학", "분쿄가쿠인대학" + "그라츠공과대학", "그라츠 대학", "코펜하겐 IT대학", "메이지대학", "분쿄가쿠인대학", "린츠 카톨릭 대학교" ); @Value("${university.term}") public String term; - @PostConstruct + @EventListener(ApplicationReadyEvent.class) public void init() { int i = 0; while (recommendUniversities.size() < RECOMMEND_UNIVERSITY_NUM && i < candidates.size()) { diff --git a/src/main/java/com/example/solidconnection/university/service/UniversityRecommendService.java b/src/main/java/com/example/solidconnection/university/service/UniversityRecommendService.java index cf58f801a..bb704faea 100644 --- a/src/main/java/com/example/solidconnection/university/service/UniversityRecommendService.java +++ b/src/main/java/com/example/solidconnection/university/service/UniversityRecommendService.java @@ -24,6 +24,7 @@ public class UniversityRecommendService { private final UniversityInfoForApplyRepository universityInfoForApplyRepository; private final GeneralRecommendUniversities generalRecommendUniversities; private final SiteUserRepository siteUserRepository; + @Value("${university.term}") public String term; @@ -39,16 +40,16 @@ public UniversityRecommendsResponse getPersonalRecommends(String email) { // 맞춤 추천 대학교를 불러온다. List personalRecommends = universityInfoForApplyRepository .findUniversityInfoForAppliesBySiteUsersInterestedCountryOrRegionAndTerm(siteUser, term); - List shuffledList + List trimmedRecommendUniversities = personalRecommends.subList(0, Math.min(RECOMMEND_UNIVERSITY_NUM, personalRecommends.size())); - Collections.shuffle(personalRecommends); + Collections.shuffle(trimmedRecommendUniversities); // 맞춤 추천 대학교의 수가 6개보다 적다면, 일반 추천 대학교를 부족한 수 만큼 불러온다. - if (shuffledList.size() < 6) { - shuffledList.addAll(getGeneralRecommendsExcludingSelected(shuffledList)); + if (trimmedRecommendUniversities.size() < RECOMMEND_UNIVERSITY_NUM) { + trimmedRecommendUniversities.addAll(getGeneralRecommendsExcludingSelected(trimmedRecommendUniversities)); } - return new UniversityRecommendsResponse(shuffledList.stream() + return new UniversityRecommendsResponse(trimmedRecommendUniversities.stream() .map(UniversityInfoForApplyPreviewResponse::from) .toList()); } diff --git a/src/main/java/com/example/solidconnection/university/service/UniversityService.java b/src/main/java/com/example/solidconnection/university/service/UniversityService.java index cbc97e4f6..94e8bba83 100644 --- a/src/main/java/com/example/solidconnection/university/service/UniversityService.java +++ b/src/main/java/com/example/solidconnection/university/service/UniversityService.java @@ -52,6 +52,7 @@ public UniversityDetailResponse getUniversityDetail(Long universityInfoForApplyI /* * 대학교 검색 결과를 불러온다. * - 권역, 키워드, 언어 시험 종류, 언어 시험 점수를 조건으로 검색하여 결과를 반환한다. + * - 권역은 영어 대문자로 받는다 e.g. ASIA * - 키워드는 국가명 또는 대학명에 포함되는 것이 조건이다. * - 언어 시험 점수는 합격 최소 점수보다 높은 것이 조건이다. * */ @@ -59,7 +60,7 @@ public UniversityDetailResponse getUniversityDetail(Long universityInfoForApplyI public List searchUniversity( String regionCode, List keywords, LanguageTestType testType, String testScore) { return universityFilterRepository - .findByRegionCodeAndKeywordsAndLanguageTestTypeAndTestScore(regionCode, keywords, testType, testScore) + .findByRegionCodeAndKeywordsAndLanguageTestTypeAndTestScoreAndTerm(regionCode, keywords, testType, testScore, term) .stream() .map(UniversityInfoForApplyPreviewResponse::from) .toList(); diff --git a/src/main/java/com/example/solidconnection/util/RedisUtils.java b/src/main/java/com/example/solidconnection/util/RedisUtils.java new file mode 100644 index 000000000..7df79418e --- /dev/null +++ b/src/main/java/com/example/solidconnection/util/RedisUtils.java @@ -0,0 +1,52 @@ +package com.example.solidconnection.util; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Component; + +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.Set; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +import static com.example.solidconnection.type.RedisConstants.VALIDATE_VIEW_COUNT_KEY_PREFIX; +import static com.example.solidconnection.type.RedisConstants.VIEW_COUNT_KEY_PREFIX; + +@Component +public class RedisUtils { + + private final RedisTemplate redisTemplate; + + @Autowired + public RedisUtils(RedisTemplate redisTemplate) { + this.redisTemplate = redisTemplate; + } + + public List getKeysOrderByExpiration(String pattern) { + Set keys = redisTemplate.keys(pattern); + if (keys == null || keys.isEmpty()) { + return Collections.emptyList(); + } + return keys.stream() + .sorted(Comparator.comparingLong(this::getExpirationTime)) + .collect(Collectors.toList()); + } + + public Long getExpirationTime(String key) { + return redisTemplate.getExpire(key, TimeUnit.MILLISECONDS); + } + + public String getPostViewCountRedisKey(Long postId) { + return VIEW_COUNT_KEY_PREFIX.getValue() + postId; + } + + public String getValidatePostViewCountRedisKey(String email, Long postId) { + return VALIDATE_VIEW_COUNT_KEY_PREFIX.getValue() + postId + ":" + email; + } + + public Long getPostIdFromPostViewCountRedisKey(String key) { + return Long.parseLong(key.substring(VIEW_COUNT_KEY_PREFIX.getValue().length())); + } +} diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index fa7eebacb..f0baec690 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -10,6 +10,9 @@ spring: show-sql: true database: mysql defer-datasource-initialization: true + properties: + hibernate: + format_sql: true sql: init: diff --git a/src/main/resources/application-test.yml b/src/main/resources/application-test.yml index 4dc996907..bf25bb62f 100644 --- a/src/main/resources/application-test.yml +++ b/src/main/resources/application-test.yml @@ -11,6 +11,9 @@ spring: show-sql: true database: mysql defer-datasource-initialization: true + properties: + hibernate: + format_sql: true datasource: url: jdbc:h2:mem:testdb diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 55193a7b3..22cc4368e 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -11,6 +11,10 @@ spring: - test - secret + tomcat: + threads: + min-spare: 20 # default 10 + servlet: multipart: max-file-size: 10MB @@ -38,4 +42,9 @@ jwt: kakao: token_url: "https://kauth.kakao.com/oauth/token" - user_info_url: "https://kapi.kakao.com/v2/user/me" \ No newline at end of file + user_info_url: "https://kapi.kakao.com/v2/user/me" + +view: + count: + scheduling: + delay: 3000 diff --git a/src/main/resources/data.sql b/src/main/resources/data.sql index c3fa34ce4..5d2ce9a04 100644 --- a/src/main/resources/data.sql +++ b/src/main/resources/data.sql @@ -221,4 +221,50 @@ VALUES ('2024-1', 1, 2, 1, 'IRRELEVANT', 'HOME_UNIVERSITY_PAYMENT', '파견대 '여학생만 신청가능', NULL, NULL, '기숙사 없음, 계약된 외부 기숙사 사용-“Maison de Claire Ibaraki” 62,300엔/월, 2식 포함, 계약시 66,000엔 청구 (2023년 6월기준)', NULL), ('2024-1', 20, 2, 3, 'ONE_YEAR', 'HOME_UNIVERSITY_PAYMENT', NULL, NULL, NULL, NULL, - '기숙사 보유, off campus, 식사 미제공, 45,000~50,000엔/월', NULL); \ No newline at end of file + '기숙사 보유, off campus, 식사 미제공, 45,000~50,000엔/월', NULL); + +INSERT INTO language_requirement(language_test_type, min_score, university_info_for_apply_id) +VALUES ('TOEFL_IBT', '80', 1), + ('IELTS', '6.5', 1), + ('TOEFL_IBT', '80', 2), + ('IELTS', '6.5', 2), + ('TOEFL_IBT', '79', 3), + ('IELTS', '6.0', 3), + ('TOEFL_IBT', '88', 4), + ('IELTS', '6.5', 4), + ('TOEFL_IBT', '88', 5), + ('IELTS', '6.5', 5), + ('TOEFL_IBT', '85', 6), + ('IELTS', '6.5', 6), + ('TOEFL_IBT', '85', 7), + ('IELTS', '6.5', 7), + ('TOEFL_IBT', '80', 8), + ('IELTS', '6.0', 8), + ('TOEFL_IBT', '83', 9), + ('IELTS', '6.5', 9), + ('TOEFL_IBT', '87', 10), + ('IELTS', '6.5', 10), + ('TOEFL_IBT', '90', 11), + ('IELTS', '6.5', 11), + ('TOEFL_IBT', '85', 12), + ('IELTS', '6.5', 12), + ('TOEFL_IBT', '82', 13), + ('IELTS', '6.0', 13), + ('TOEFL_IBT', '85', 14), + ('IELTS', '6.5', 14), + ('TOEFL_IBT', '90', 15), + ('IELTS', '7.0', 15), + ('TOEFL_IBT', '85', 16), + ('IELTS', '6.5', 16), + ('DELF', 'B2', 17), + ('DALF', 'C1', 17), + ('JLPT', 'N2', 18), + ('JLPT', 'N1', 19), + ('TOEFL_IBT', '85', 20), + ('IELTS', '6.5', 20); + +INSERT INTO board (code, korean_name) +VALUES ('EUROPE', '유럽권'), + ('AMERICAS', '미주권'), + ('ASIA', '아시아권'), + ('FREE', '자유게시판'); diff --git a/src/main/resources/scripts/incrViewCount.lua b/src/main/resources/scripts/incrViewCount.lua new file mode 100644 index 000000000..9421c0739 --- /dev/null +++ b/src/main/resources/scripts/incrViewCount.lua @@ -0,0 +1,14 @@ +-- Define the key and TTL (Time To Live) from KEYS and ARGV +local key = KEYS[1] +local ttl = tonumber(ARGV[1]) + +-- Attempt to set the key with value 1 if it does not already exist, and set TTL +local result = redis.call('SET', key, 1, 'NX', 'EX', ttl) + +-- If the key was set, it means it did not exist and we set it +if result then + return 1 +else + -- If the key was not set, it means it already existed, so increment the value + return redis.call('INCR', key) +end diff --git a/src/test/java/com/example/solidconnection/concurrency/PostViewCountConcurrencyTest.java b/src/test/java/com/example/solidconnection/concurrency/PostViewCountConcurrencyTest.java new file mode 100644 index 000000000..9eb936301 --- /dev/null +++ b/src/test/java/com/example/solidconnection/concurrency/PostViewCountConcurrencyTest.java @@ -0,0 +1,171 @@ +package com.example.solidconnection.concurrency; + +import com.example.solidconnection.board.domain.Board; +import com.example.solidconnection.board.repository.BoardRepository; +import com.example.solidconnection.post.domain.Post; +import com.example.solidconnection.post.repository.PostRepository; +import com.example.solidconnection.service.RedisService; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.repository.SiteUserRepository; +import com.example.solidconnection.type.Gender; +import com.example.solidconnection.type.PostCategory; +import com.example.solidconnection.type.PreparationStatus; +import com.example.solidconnection.type.Role; +import com.example.solidconnection.util.RedisUtils; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; + +import static com.example.solidconnection.type.RedisConstants.*; +import static org.junit.jupiter.api.Assertions.assertEquals; + +@SpringBootTest +@ActiveProfiles("test") +@DisplayName("게시글 조회수 동시성 테스트") +public class PostViewCountConcurrencyTest { + + @Autowired + private RedisService redisService; + @Autowired + private PostRepository postRepository; + @Autowired + private BoardRepository boardRepository; + @Autowired + private SiteUserRepository siteUserRepository; + @Autowired + private RedisUtils redisUtils; + + @Value("${view.count.scheduling.delay}") + private int SCHEDULING_DELAY_MS; + private int THREAD_NUMS = 1000; + private int THREAD_POOL_SIZE = 200; + private int TIMEOUT_SECONDS = 10; + + private Post post; + private Board board; + private SiteUser siteUser; + + @BeforeEach + public void setUp() { + board = createBoard(); + boardRepository.save(board); + siteUser = createSiteUser(); + siteUserRepository.save(siteUser); + post = createPost(board, siteUser); + postRepository.save(post); + } + + private SiteUser createSiteUser() { + return new SiteUser( + "test@example.com", + "nickname", + "profileImageUrl", + "1999-01-01", + PreparationStatus.CONSIDERING, + Role.MENTEE, + Gender.MALE + ); + } + + private Board createBoard() { + return new Board( + "FREE", "자유게시판"); + } + + private Post createPost(Board board, SiteUser siteUser) { + Post post = new Post( + "title", + "content", + false, + 0L, + 0L, + PostCategory.valueOf("자유") + ); + post.setBoardAndSiteUser(board, siteUser); + + return post; + } + + @Test + public void 게시글을_조회할_때_조회수_동시성_문제를_해결한다() throws InterruptedException { + + ExecutorService executorService = Executors.newFixedThreadPool(THREAD_POOL_SIZE); + CountDownLatch doneSignal = new CountDownLatch(THREAD_NUMS); + + for (int i = 0; i < THREAD_NUMS; i++) { + executorService.submit(() -> { + try { + redisService.increaseViewCountSync(redisUtils.getPostViewCountRedisKey(post.getId())); + } finally { + doneSignal.countDown(); + } + }); + } + + doneSignal.await(TIMEOUT_SECONDS, TimeUnit.SECONDS); + executorService.shutdown(); + boolean terminated = executorService.awaitTermination(TIMEOUT_SECONDS, TimeUnit.SECONDS); + if (!terminated) { + System.err.println("ExecutorService did not terminate in the expected time."); + } + + Thread.sleep(SCHEDULING_DELAY_MS); + + assertEquals(THREAD_NUMS, postRepository.getById(post.getId()).getViewCount()); + } + + @Test + public void 게시글을_조회할_때_조회수_조작_문제를_해결한다() throws InterruptedException { + + redisService.deleteKey(redisUtils.getValidatePostViewCountRedisKey(siteUser.getEmail(), post.getId())); + + ExecutorService executorService = Executors.newFixedThreadPool(THREAD_POOL_SIZE); + CountDownLatch doneSignal = new CountDownLatch(THREAD_NUMS); + + for (int i = 0; i < THREAD_NUMS; i++) { + executorService.submit(() -> { + try { + boolean isFirstTime = redisService.isPresent(redisUtils.getValidatePostViewCountRedisKey(siteUser.getEmail(), post.getId())); + if (isFirstTime) { + redisService.increaseViewCountSync(redisUtils.getPostViewCountRedisKey(post.getId())); + } + } finally { + doneSignal.countDown(); + } + }); + } + Thread.sleep(Long.parseLong(VALIDATE_VIEW_COUNT_TTL.getValue()) * 1000); + for (int i = 0; i < THREAD_NUMS; i++) { + executorService.submit(() -> { + try { + boolean isFirstTime = redisService.isPresent(redisUtils.getValidatePostViewCountRedisKey(siteUser.getEmail(), post.getId())); + if (isFirstTime) { + redisService.increaseViewCountSync(redisUtils.getPostViewCountRedisKey(post.getId())); + } + } finally { + doneSignal.countDown(); + } + }); + } + + doneSignal.await(TIMEOUT_SECONDS, TimeUnit.SECONDS); + executorService.shutdown(); + boolean terminated = executorService.awaitTermination(TIMEOUT_SECONDS, TimeUnit.SECONDS); + if (!terminated) { + System.err.println("ExecutorService did not terminate in the expected time."); + } + + Thread.sleep(SCHEDULING_DELAY_MS); + + assertEquals(2L, postRepository.getById(post.getId()).getViewCount()); + } +} diff --git a/src/test/java/com/example/solidconnection/database/DatabaseConnectionTest.java b/src/test/java/com/example/solidconnection/database/DatabaseConnectionTest.java index 7d9849cbd..a9d80afcc 100644 --- a/src/test/java/com/example/solidconnection/database/DatabaseConnectionTest.java +++ b/src/test/java/com/example/solidconnection/database/DatabaseConnectionTest.java @@ -44,7 +44,12 @@ void connectDatabaseAndCheckTables() { () -> assertThat(isTableExist("LANGUAGE_REQUIREMENT")).isTrue(), () -> assertThat(isTableExist("UNIVERSITY")).isTrue(), () -> assertThat(isTableExist("LIKED_UNIVERSITY")).isTrue(), - () -> assertThat(isTableExist("UNIVERSITY_INFO_FOR_APPLY")).isTrue() + () -> assertThat(isTableExist("UNIVERSITY_INFO_FOR_APPLY")).isTrue(), + () -> assertThat(isTableExist("BOARD")).isTrue(), + () -> assertThat(isTableExist("COMMENT")).isTrue(), + () -> assertThat(isTableExist("POST")).isTrue(), + () -> assertThat(isTableExist("POST_IMAGE")).isTrue(), + () -> assertThat(isTableExist("POST_LIKE")).isTrue() ); } diff --git a/src/test/java/com/example/solidconnection/e2e/UniversityRecommendTest.java b/src/test/java/com/example/solidconnection/e2e/UniversityRecommendTest.java new file mode 100644 index 000000000..ee46733a1 --- /dev/null +++ b/src/test/java/com/example/solidconnection/e2e/UniversityRecommendTest.java @@ -0,0 +1,193 @@ +package com.example.solidconnection.e2e; + +import com.example.solidconnection.config.token.TokenService; +import com.example.solidconnection.config.token.TokenType; +import com.example.solidconnection.entity.InterestedCountry; +import com.example.solidconnection.entity.InterestedRegion; +import com.example.solidconnection.repositories.InterestedCountyRepository; +import com.example.solidconnection.repositories.InterestedRegionRepository; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.repository.SiteUserRepository; +import com.example.solidconnection.university.dto.UniversityInfoForApplyPreviewResponse; +import com.example.solidconnection.university.dto.UniversityRecommendsResponse; +import com.example.solidconnection.university.service.GeneralRecommendUniversities; +import io.restassured.RestAssured; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; + +import java.util.List; + +import static com.example.solidconnection.e2e.DynamicFixture.createSiteUserByEmail; +import static com.example.solidconnection.university.service.UniversityRecommendService.RECOMMEND_UNIVERSITY_NUM; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@DisplayName("추천 대학 목록 조회 테스트") +class UniversityRecommendTest extends UniversityDataSetUpEndToEndTest { + + @Autowired + private SiteUserRepository siteUserRepository; + + @Autowired + private InterestedRegionRepository interestedRegionRepository; + + @Autowired + private InterestedCountyRepository interestedCountyRepository; + + @Autowired + private TokenService tokenService; + + @Autowired + private GeneralRecommendUniversities generalRecommendUniversities; + + private SiteUser siteUser; + private String accessToken; + + @BeforeEach + void setUp() { + // setUp - 회원 정보 저장 + String email = "email@email.com"; + siteUser = siteUserRepository.save(createSiteUserByEmail(email)); + generalRecommendUniversities.init(); + + // setUp - 엑세스 토큰 생성과 리프레시 토큰 생성 및 저장 + accessToken = tokenService.generateToken(email, TokenType.ACCESS); + String refreshToken = tokenService.generateToken(email, TokenType.REFRESH); + tokenService.saveToken(refreshToken, TokenType.REFRESH); + } + + @Test + void 관심_지역을_설정한_사용자의_추천_대학_목록을_조회한다() { + // setUp - 관심 지역 저장 + interestedRegionRepository.save(new InterestedRegion(siteUser, 영미권)); + + // request - 요청 + UniversityRecommendsResponse response = RestAssured.given() + .header("Authorization", "Bearer " + accessToken) + .log().all() + .get("/university/recommends") + .then().log().all() + .statusCode(HttpStatus.OK.value()) + .extract().as(UniversityRecommendsResponse.class); + + assertAll( + String.format("관심 지역에 해당하는 학교를 포함한 %d개의 대학 목록을 반환한다.", RECOMMEND_UNIVERSITY_NUM), + () -> assertThat(response.recommendedUniversities()) + .hasSize(RECOMMEND_UNIVERSITY_NUM), + () -> assertThat(response.recommendedUniversities()) + .containsOnlyOnceElementsOf(List.of( + UniversityInfoForApplyPreviewResponse.from(괌대학_A_지원_정보), + UniversityInfoForApplyPreviewResponse.from(괌대학_B_지원_정보), + UniversityInfoForApplyPreviewResponse.from(메모리얼대학_세인트존스_A_지원_정보), + UniversityInfoForApplyPreviewResponse.from(네바다주립대학_라스베이거스_지원_정보) + )) + ); + } + + @Test + void 관심_국가를_설정한_사용자의_추천_대학_목록을_조회한다() { + // setUp - 관심 국가 저장 + interestedCountyRepository.save(new InterestedCountry(siteUser, 덴마크)); + + // request - 요청 + UniversityRecommendsResponse response = RestAssured.given() + .header("Authorization", "Bearer " + accessToken) + .log().all() + .get("/university/recommends") + .then().log().all() + .statusCode(HttpStatus.OK.value()) + .extract().as(UniversityRecommendsResponse.class); + + assertAll( + String.format("관심 국가에 해당하는 학교를 포함한 %d개의 대학 목록을 반환한다.", RECOMMEND_UNIVERSITY_NUM), + () -> assertThat(response.recommendedUniversities()) + .hasSize(RECOMMEND_UNIVERSITY_NUM), + () -> assertThat(response.recommendedUniversities()) + .containsOnlyOnceElementsOf(List.of( + UniversityInfoForApplyPreviewResponse.from(서던덴마크대학교_지원_정보), + UniversityInfoForApplyPreviewResponse.from(코펜하겐IT대학_지원_정보) + )) + ); + } + + @Test + void 관심_지역과_관심_국가를_설정한_사용자의_추천_대학_목록을_조회한다() { + // setUp - 관심 지역과 국가 저장 + interestedRegionRepository.save(new InterestedRegion(siteUser, 영미권)); + interestedCountyRepository.save(new InterestedCountry(siteUser, 덴마크)); + + // request - 요청 + UniversityRecommendsResponse response = RestAssured.given() + .header("Authorization", "Bearer " + accessToken) + .log().all() + .get("/university/recommends") + .then().log().all() + .statusCode(HttpStatus.OK.value()) + .extract().as(UniversityRecommendsResponse.class); + + assertAll( + String.format("관심 지역 또는 국가에 해당하는 학교를 포함한 %d개의 대학 목록을 반환한다.", RECOMMEND_UNIVERSITY_NUM), + () -> assertThat(response.recommendedUniversities()) + .hasSize(RECOMMEND_UNIVERSITY_NUM), + () -> assertThat(response.recommendedUniversities()) + .containsOnlyOnceElementsOf(List.of( + UniversityInfoForApplyPreviewResponse.from(괌대학_A_지원_정보), + UniversityInfoForApplyPreviewResponse.from(괌대학_B_지원_정보), + UniversityInfoForApplyPreviewResponse.from(메모리얼대학_세인트존스_A_지원_정보), + UniversityInfoForApplyPreviewResponse.from(네바다주립대학_라스베이거스_지원_정보), + UniversityInfoForApplyPreviewResponse.from(서던덴마크대학교_지원_정보), + UniversityInfoForApplyPreviewResponse.from(코펜하겐IT대학_지원_정보) + )) + ); + } + + @Test + void 관심_지역_또는_관심_국가를_설정하지_않은_사용자의_추천_대학_목록을_조회한다() { + // request - 요청 + UniversityRecommendsResponse response = RestAssured.given() + .header("Authorization", "Bearer " + accessToken) + .log().all() + .get("/university/recommends") + .then().log().all() + .statusCode(HttpStatus.OK.value()) + .extract().as(UniversityRecommendsResponse.class); + + List generalRecommendUniversities + = this.generalRecommendUniversities.getRecommendUniversities().stream() + .map(UniversityInfoForApplyPreviewResponse::from) + .toList(); + assertAll( + String.format("일반 추천 대학 목록 %d개를 반환한다.", RECOMMEND_UNIVERSITY_NUM), + () -> assertThat(response.recommendedUniversities()) + .hasSize(RECOMMEND_UNIVERSITY_NUM), + () -> assertThat(generalRecommendUniversities) + .containsOnlyOnceElementsOf(response.recommendedUniversities()) + ); + } + + @Test + void 로그인하지_않은_방문객의_추천_대학_목록을_조회한다() { + // request - 요청 + UniversityRecommendsResponse response = RestAssured.given() + .log().all() + .get("/university/recommends") + .then().log().all() + .statusCode(HttpStatus.OK.value()) + .extract().as(UniversityRecommendsResponse.class); + + List generalRecommendUniversities + = this.generalRecommendUniversities.getRecommendUniversities().stream() + .map(UniversityInfoForApplyPreviewResponse::from) + .toList(); + assertAll( + String.format("일반 추천 대학 목록 %d개를 반환한다.", RECOMMEND_UNIVERSITY_NUM), + () -> assertThat(response.recommendedUniversities()) + .hasSize(RECOMMEND_UNIVERSITY_NUM), + () -> assertThat(generalRecommendUniversities) + .containsOnlyOnceElementsOf(response.recommendedUniversities()) + ); + } +} diff --git a/src/test/java/com/example/solidconnection/unit/repository/BoardRepositoryTest.java b/src/test/java/com/example/solidconnection/unit/repository/BoardRepositoryTest.java new file mode 100644 index 000000000..c540954b1 --- /dev/null +++ b/src/test/java/com/example/solidconnection/unit/repository/BoardRepositoryTest.java @@ -0,0 +1,141 @@ +package com.example.solidconnection.unit.repository; + +import com.example.solidconnection.board.domain.Board; +import com.example.solidconnection.board.repository.BoardRepository; +import com.example.solidconnection.custom.exception.CustomException; +import com.example.solidconnection.post.domain.Post; +import com.example.solidconnection.post.repository.PostRepository; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.repository.SiteUserRepository; +import com.example.solidconnection.type.Gender; +import com.example.solidconnection.type.PostCategory; +import com.example.solidconnection.type.PreparationStatus; +import com.example.solidconnection.type.Role; +import jakarta.persistence.EntityManager; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +import static com.example.solidconnection.custom.exception.ErrorCode.INVALID_BOARD_CODE; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.*; + +@DataJpaTest +@ActiveProfiles("test") +@DisplayName("게시판 레포지토리 테스트") +public class BoardRepositoryTest { + @Autowired + private PostRepository postRepository; + @Autowired + private BoardRepository boardRepository; + @Autowired + private SiteUserRepository siteUserRepository; + @Autowired + private EntityManager entityManager; + + private Board board; + private SiteUser siteUser; + private Post post; + + @BeforeEach + public void setUp() { + board = createBoard(); + boardRepository.save(board); + + siteUser = createSiteUser(); + siteUserRepository.save(siteUser); + + post = createPost(board, siteUser); + post = postRepository.save(post); + + entityManager.flush(); + entityManager.clear(); + } + + private Board createBoard() { + return new Board( + "FREE", "자유게시판"); + } + + private SiteUser createSiteUser() { + return new SiteUser( + "test@example.com", + "nickname", + "profileImageUrl", + "1999-01-01", + PreparationStatus.CONSIDERING, + Role.MENTEE, + Gender.MALE + ); + } + + private Post createPost(Board board, SiteUser siteUser) { + Post post = new Post( + "title", + "content", + false, + 0L, + 0L, + PostCategory.valueOf("자유") + ); + post.setBoardAndSiteUser(board, siteUser); + return post; + } + + @Test + @Transactional + public void 게시판을_조회할_때_게시글은_즉시_로딩한다() { + // when + Board foundBoard = boardRepository.getByCodeUsingEntityGraph(board.getCode()); + foundBoard.getPostList().size(); // 추가쿼리 발생하지 않는다. + + // then + assertThat(foundBoard.getCode()).isEqualTo(board.getCode()); + } + + @Test + @Transactional + public void 게시판을_조회할_때_게시글은_즉시_로딩한다_유효한_게시판이_아니라면_예외_응답을_반환한다() { + // given + String invalidCode = "INVALID_CODE"; + + // when, then + CustomException exception = assertThrows(CustomException.class, () -> { + boardRepository.getByCodeUsingEntityGraph(invalidCode); + }); + assertThat(exception.getMessage()) + .isEqualTo(INVALID_BOARD_CODE.getMessage()); + assertThat(exception.getCode()) + .isEqualTo(INVALID_BOARD_CODE.getCode()); + } + + @Test + @Transactional + public void 게시판을_조회한다() { + // when + Board foundBoard = boardRepository.getByCode(board.getCode()); + + // then + assertEquals(board.getCode(), foundBoard.getCode()); + } + + @Test + @Transactional + public void 게시판을_조회할_때_유효한_게시판이_아니라면_예외_응답을_반환한다() { + // given + String invalidCode = "INVALID_CODE"; + + // when, then + CustomException exception = assertThrows(CustomException.class, () -> { + boardRepository.getByCode(invalidCode); + }); + assertThat(exception.getMessage()) + .isEqualTo(INVALID_BOARD_CODE.getMessage()); + assertThat(exception.getCode()) + .isEqualTo(INVALID_BOARD_CODE.getCode()); + } +} diff --git a/src/test/java/com/example/solidconnection/unit/repository/PostRepositoryTest.java b/src/test/java/com/example/solidconnection/unit/repository/PostRepositoryTest.java new file mode 100644 index 000000000..b81ee952c --- /dev/null +++ b/src/test/java/com/example/solidconnection/unit/repository/PostRepositoryTest.java @@ -0,0 +1,139 @@ +package com.example.solidconnection.unit.repository; + +import com.example.solidconnection.board.domain.Board; +import com.example.solidconnection.board.repository.BoardRepository; +import com.example.solidconnection.custom.exception.CustomException; +import com.example.solidconnection.entity.PostImage; +import com.example.solidconnection.post.domain.Post; +import com.example.solidconnection.post.repository.PostRepository; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.repository.SiteUserRepository; +import com.example.solidconnection.type.Gender; +import com.example.solidconnection.type.PostCategory; +import com.example.solidconnection.type.PreparationStatus; +import com.example.solidconnection.type.Role; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.List; + +import static com.example.solidconnection.custom.exception.ErrorCode.INVALID_POST_ID; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.*; + +@DataJpaTest +@ActiveProfiles("test") +@DisplayName("게시글 레포지토리 테스트") +public class PostRepositoryTest { + @Autowired + private PostRepository postRepository; + @Autowired + private BoardRepository boardRepository; + @Autowired + private SiteUserRepository siteUserRepository; + + private Post post; + private Board board; + private SiteUser siteUser; + + @BeforeEach + public void setUp() { + board = createBoard(); + boardRepository.save(board); + siteUser = createSiteUser(); + siteUserRepository.save(siteUser); + post = createPostWithImages(board, siteUser); + post = postRepository.save(post); + } + + private SiteUser createSiteUser() { + return new SiteUser( + "test@example.com", + "nickname", + "profileImageUrl", + "1999-01-01", + PreparationStatus.CONSIDERING, + Role.MENTEE, + Gender.MALE + ); + } + + private Board createBoard() { + return new Board( + "FREE", "자유게시판"); + } + + private Post createPostWithImages(Board board, SiteUser siteUser) { + Post postWithImages = new Post( + "title", + "content", + false, + 0L, + 0L, + PostCategory.valueOf("자유") + ); + postWithImages.setBoardAndSiteUser(board, siteUser); + + List postImageList = new ArrayList<>(); + postImageList.add(new PostImage("https://s3.example.com/test1.png")); + postImageList.add(new PostImage("https://s3.example.com/test2.png")); + for (PostImage postImage : postImageList) { + postImage.setPost(postWithImages); + } + return postWithImages; + } + + @Test + @Transactional + public void 게시글을_조회할_때_게시글_이미지는_즉시_로딩한다() { + Post foundPost = postRepository.getByIdUsingEntityGraph(post.getId()); + foundPost.getPostImageList().size(); // 추가쿼리 발생하지 않는다. + + assertThat(foundPost).isEqualTo(post); + } + + @Test + @Transactional + public void 게시글을_조회할_때_게시글_이미지는_즉시_로딩한다_유효한_게시글이_아니라면_예외_응답을_반환한다() { + // given + Long invalidId = -1L; + + // when, then + CustomException exception = assertThrows(CustomException.class, () -> { + postRepository.getByIdUsingEntityGraph(invalidId); + }); + assertThat(exception.getMessage()) + .isEqualTo(INVALID_POST_ID.getMessage()); + assertThat(exception.getCode()) + .isEqualTo(INVALID_POST_ID.getCode()); + } + + @Test + @Transactional + public void 게시글을_조회한다() { + Post foundPost = postRepository.getById(post.getId()); + + assertEquals(post, foundPost); + } + + @Test + @Transactional + public void 게시글을_조회할_때_유효한_게시글이_아니라면_예외_응답을_반환한다() { + Long invalidId = -1L; + + CustomException exception = assertThrows(CustomException.class, () -> { + postRepository.getById(invalidId); + }); + assertThat(exception.getMessage()) + .isEqualTo(INVALID_POST_ID.getMessage()); + assertThat(exception.getCode()) + .isEqualTo(INVALID_POST_ID.getCode()); + } +} diff --git a/src/test/java/com/example/solidconnection/unit/service/BoardServiceTest.java b/src/test/java/com/example/solidconnection/unit/service/BoardServiceTest.java new file mode 100644 index 000000000..710546c9b --- /dev/null +++ b/src/test/java/com/example/solidconnection/unit/service/BoardServiceTest.java @@ -0,0 +1,152 @@ +package com.example.solidconnection.unit.service; + +import com.example.solidconnection.board.domain.Board; +import com.example.solidconnection.board.repository.BoardRepository; +import com.example.solidconnection.board.service.BoardService; +import com.example.solidconnection.custom.exception.CustomException; +import com.example.solidconnection.custom.exception.ErrorCode; +import com.example.solidconnection.post.domain.Post; +import com.example.solidconnection.post.dto.BoardFindPostResponse; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.type.*; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + + +@ExtendWith(MockitoExtension.class) +@DisplayName("게시판 서비스 테스트") +public class BoardServiceTest { + @InjectMocks + BoardService boardService; + @Mock + BoardRepository boardRepository; + + private SiteUser siteUser; + private Board board; + private List postList = new ArrayList<>(); + private List freePostList = new ArrayList<>(); + private List questionPostList = new ArrayList<>(); + + @BeforeEach + void setUp() { + siteUser = createSiteUser(); + board = createBoard("FREE", "자유게시판"); + + Post post_question_1 = createPost("질문", board, siteUser); + Post post_free_1 = createPost("자유", board, siteUser); + Post post_free_2 = createPost("자유", board, siteUser); + + postList.add(post_question_1); + postList.add(post_free_1); + postList.add(post_free_2); + questionPostList.add(post_question_1); + freePostList.add(post_free_1); + freePostList.add(post_free_2); + } + + private SiteUser createSiteUser() { + return new SiteUser( + "test@example.com", + "nickname", + "profileImageUrl", + "1999-01-01", + PreparationStatus.CONSIDERING, + Role.MENTEE, + Gender.MALE + ); + } + + private Board createBoard(String code, String koreanName) { + return new Board(code, koreanName); + } + + private Post createPost(String postCategory, Board board, SiteUser siteUser) { + Post post = new Post( + "title", + "content", + false, + 0L, + 0L, + PostCategory.valueOf(postCategory) + ); + post.setBoardAndSiteUser(board, siteUser); + return post; + } + + @Test + void 게시판을_조회할_때_게시판_코드와_게시글_카테고리에_따라서_조회한다() { + // Given + String category = "자유"; + when(boardRepository.getByCodeUsingEntityGraph(board.getCode())).thenReturn(board); + + // When + List responses = boardService.findPostsByCodeAndPostCategory(board.getCode(), category); + + // Then + List expectedResponses = freePostList.stream() + .map(BoardFindPostResponse::from) + .toList(); + assertIterableEquals(expectedResponses, responses); + verify(boardRepository, times(1)).getByCodeUsingEntityGraph(board.getCode()); + } + + @Test + void 게시판을_조회할_때_카테고리가_전체라면_해당_게시판의_모든_게시글을_조회한다() { + // Given + String category = "전체"; + when(boardRepository.getByCodeUsingEntityGraph(board.getCode())).thenReturn(board); + + // When + List responses = boardService.findPostsByCodeAndPostCategory(board.getCode(), category); + + // Then + List expectedResponses = postList.stream() + .map(BoardFindPostResponse::from) + .toList(); + assertIterableEquals(expectedResponses, responses); + verify(boardRepository, times(1)).getByCodeUsingEntityGraph(board.getCode()); + } + + @Test + void 게시판을_조회할_때_유효한_게시판이_아니라면_예외_응답을_반환한다() { + // Given + String invalidCode = "INVALID_CODE"; + String category = "자유"; + + // When & Then + CustomException exception = assertThrows(CustomException.class, () -> { + boardService.findPostsByCodeAndPostCategory(invalidCode, category); + }); + assertThat(exception.getMessage()) + .isEqualTo(ErrorCode.INVALID_BOARD_CODE.getMessage()); + assertThat(exception.getCode()) + .isEqualTo(ErrorCode.INVALID_BOARD_CODE.getCode()); + } + + @Test + void 게시판을_조회할_때_유효한_카테고리가_아니라면_예외_응답을_반환한다() { + // Given + String invalidCategory = "INVALID_CATEGORY"; + + // When & Then + CustomException exception = assertThrows(CustomException.class, () -> { + boardService.findPostsByCodeAndPostCategory(board.getCode(), invalidCategory); + }); + assertThat(exception.getMessage()) + .isEqualTo(ErrorCode.INVALID_POST_CATEGORY.getMessage()); + assertThat(exception.getCode()) + .isEqualTo(ErrorCode.INVALID_POST_CATEGORY.getCode()); + } +} diff --git a/src/test/java/com/example/solidconnection/unit/service/PostServiceTest.java b/src/test/java/com/example/solidconnection/unit/service/PostServiceTest.java new file mode 100644 index 000000000..c04c1485a --- /dev/null +++ b/src/test/java/com/example/solidconnection/unit/service/PostServiceTest.java @@ -0,0 +1,541 @@ +package com.example.solidconnection.unit.service; + +import com.example.solidconnection.board.domain.Board; +import com.example.solidconnection.board.dto.PostFindBoardResponse; +import com.example.solidconnection.board.repository.BoardRepository; +import com.example.solidconnection.comment.dto.PostFindCommentResponse; +import com.example.solidconnection.comment.service.CommentService; +import com.example.solidconnection.custom.exception.CustomException; +import com.example.solidconnection.custom.exception.ErrorCode; +import com.example.solidconnection.dto.PostFindPostImageResponse; +import com.example.solidconnection.entity.PostImage; +import com.example.solidconnection.post.domain.Post; +import com.example.solidconnection.post.dto.*; +import com.example.solidconnection.post.repository.PostRepository; +import com.example.solidconnection.post.service.PostService; +import com.example.solidconnection.s3.S3Service; +import com.example.solidconnection.s3.UploadedFileUrlResponse; +import com.example.solidconnection.service.RedisService; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.dto.PostFindSiteUserResponse; +import com.example.solidconnection.siteuser.repository.SiteUserRepository; +import com.example.solidconnection.type.*; +import com.example.solidconnection.util.RedisUtils; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.web.multipart.MultipartFile; + +import java.util.*; + +import static com.example.solidconnection.custom.exception.ErrorCode.*; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; +import static org.mockito.Mockito.when; + + +@ExtendWith(MockitoExtension.class) +@DisplayName("게시글 서비스 테스트") +public class PostServiceTest { + @InjectMocks + PostService postService; + @Mock + PostRepository postRepository; + @Mock + SiteUserRepository siteUserRepository; + @Mock + BoardRepository boardRepository; + @Mock + S3Service s3Service; + @Mock + CommentService commentService; + @Mock + RedisService redisService; + @Mock + RedisUtils redisUtils; + + private SiteUser siteUser; + private Board board; + private Post post; + private Post postWithImages; + private Post questionPost; + private List imageFiles; + private List imageFilesWithMoreThanFiveFiles; + private List uploadedFileUrlResponseList; + + + @BeforeEach + void setUp() { + siteUser = createSiteUser(); + board = createBoard(); + imageFiles = createMockImageFiles(); + imageFilesWithMoreThanFiveFiles = createMockImageFilesWithMoreThanFiveFiles(); + uploadedFileUrlResponseList = createUploadedFileUrlResponses(); + post = createPost(board, siteUser); + postWithImages = createPostWithImages(board, siteUser); + questionPost = createQuestionPost(board, siteUser); + } + + private SiteUser createSiteUser() { + return new SiteUser( + "test@example.com", + "nickname", + "profileImageUrl", + "1999-01-01", + PreparationStatus.CONSIDERING, + Role.MENTEE, + Gender.MALE + ); + } + + private Board createBoard() { + return new Board( + "FREE", "자유게시판"); + } + + private Post createPost(Board board, SiteUser siteUser) { + Post post = new Post( + "title", + "content", + false, + 0L, + 0L, + PostCategory.valueOf("자유") + ); + post.setBoardAndSiteUser(board, siteUser); + + return post; + } + + private Post createPostWithImages(Board board, SiteUser siteUser) { + Post postWithImages = new Post( + "title", + "content", + false, + 0L, + 0L, + PostCategory.valueOf("자유") + ); + postWithImages.setBoardAndSiteUser(board, siteUser); + + List postImageList = new ArrayList<>(); + postImageList.add(new PostImage("https://s3.example.com/test1.png")); + postImageList.add(new PostImage("https://s3.example.com/test2.png")); + for (PostImage postImage : postImageList) { + postImage.setPost(postWithImages); + } + return postWithImages; + } + + private Post createQuestionPost(Board board, SiteUser siteUser) { + Post post = new Post( + "title", + "content", + true, + 0L, + 0L, + PostCategory.valueOf("자유") + ); + post.setBoardAndSiteUser(board, siteUser); + return post; + } + + private List createMockImageFiles() { + List multipartFileList = new ArrayList<>(); + multipartFileList.add(new MockMultipartFile("file1", "test1.png", + "image/png", "test image content 1".getBytes())); + multipartFileList.add(new MockMultipartFile("file2", "test1.png", + "image/png", "test image content 1".getBytes())); + return multipartFileList; + } + + private List createUploadedFileUrlResponses() { + return Arrays.asList( + new UploadedFileUrlResponse("https://s3.example.com/test1.png"), + new UploadedFileUrlResponse("https://s3.example.com/test2.png") + ); + } + + private List createMockImageFilesWithMoreThanFiveFiles() { + List multipartFileList = new ArrayList<>(); + multipartFileList.add(new MockMultipartFile("file1", "test1.png", + "image/png", "test image content 1".getBytes())); + multipartFileList.add(new MockMultipartFile("file2", "test1.png", + "image/png", "test image content 1".getBytes())); + multipartFileList.add(new MockMultipartFile("file3", "test1.png", + "image/png", "test image content 1".getBytes())); + multipartFileList.add(new MockMultipartFile("file4", "test1.png", + "image/png", "test image content 1".getBytes())); + multipartFileList.add(new MockMultipartFile("file5", "test1.png", + "image/png", "test image content 1".getBytes())); + multipartFileList.add(new MockMultipartFile("file6", "test1.png", + "image/png", "test image content 1".getBytes())); + return multipartFileList; + } + + /** + * 게시글 등록 + */ + @Test + void 게시글을_등록한다_이미지_있음() { + // Given + PostCreateRequest postCreateRequest = new PostCreateRequest( + "자유", "title", "content", false); + when(siteUserRepository.getByEmail(siteUser.getEmail())).thenReturn(siteUser); + when(boardRepository.getByCode(board.getCode())).thenReturn(board); + when(s3Service.uploadFiles(imageFiles, ImgType.COMMUNITY)).thenReturn(uploadedFileUrlResponseList); + when(postRepository.save(any(Post.class))).thenReturn(postWithImages); + + // When + PostCreateResponse postCreateResponse = postService.createPost( + siteUser.getEmail(), board.getCode(), postCreateRequest, imageFiles); + + // Then + assertEquals(postCreateResponse, PostCreateResponse.from(postWithImages)); + verify(siteUserRepository, times(1)).getByEmail(siteUser.getEmail()); + verify(boardRepository, times(1)).getByCode(board.getCode()); + verify(s3Service, times(1)).uploadFiles(imageFiles, ImgType.COMMUNITY); + verify(postRepository, times(1)).save(any(Post.class)); + } + + @Test + void 게시글을_등록한다_이미지_없음() { + // Given + PostCreateRequest postCreateRequest = new PostCreateRequest( + "자유", "title", "content", false); + when(siteUserRepository.getByEmail(siteUser.getEmail())).thenReturn(siteUser); + when(boardRepository.getByCode(board.getCode())).thenReturn(board); + when(postRepository.save(postCreateRequest.toEntity(siteUser, board))).thenReturn(post); + + // When + PostCreateResponse postCreateResponse = postService.createPost( + siteUser.getEmail(), board.getCode(), postCreateRequest, Collections.emptyList()); + + // Then + assertEquals(postCreateResponse, PostCreateResponse.from(post)); + verify(siteUserRepository, times(1)).getByEmail(siteUser.getEmail()); + verify(boardRepository, times(1)).getByCode(board.getCode()); + verify(postRepository, times(1)).save(postCreateRequest.toEntity(siteUser, board)); + } + + @Test + void 게시글을_등록할_때_유효한_게시판이_아니라면_예외_응답을_반환한다() { + // Given + String invalidBoardCode = "INVALID_CODE"; + PostCreateRequest postCreateRequest = new PostCreateRequest( + "자유", "title", "content", false); + + // When & Then + CustomException exception = assertThrows(CustomException.class, () -> postService + .createPost(siteUser.getEmail(), invalidBoardCode, postCreateRequest, Collections.emptyList())); + assertThat(exception.getMessage()) + .isEqualTo(INVALID_BOARD_CODE.getMessage()); + assertThat(exception.getCode()) + .isEqualTo(INVALID_BOARD_CODE.getCode()); + } + + @Test + void 게시글을_등록할_때_파일_수가_5개를_넘는다면_예외_응답을_반환한다() { + // Given + PostCreateRequest postCreateRequest = new PostCreateRequest( + "자유", "title", "content", false); + + // When & Then + CustomException exception = assertThrows(CustomException.class, () -> postService + .createPost(siteUser.getEmail(), board.getCode(), postCreateRequest, imageFilesWithMoreThanFiveFiles)); + assertThat(exception.getMessage()) + .isEqualTo(CAN_NOT_UPLOAD_MORE_THAN_FIVE_IMAGES.getMessage()); + assertThat(exception.getCode()) + .isEqualTo(CAN_NOT_UPLOAD_MORE_THAN_FIVE_IMAGES.getCode()); + } + + /** + * 게시글 수정 + */ + @Test + void 게시글을_수정한다_기존_사진_없음_수정_사진_없음() { + // Given + PostUpdateRequest postUpdateRequest = new PostUpdateRequest("질문", "updateTitle", "updateContent"); + when(postRepository.getById(post.getId())).thenReturn(post); + + // When + PostUpdateResponse response = postService.updatePost( + siteUser.getEmail(), board.getCode(), post.getId(), postUpdateRequest, Collections.emptyList()); + + // Then + assertEquals(response, PostUpdateResponse.from(post)); + verify(postRepository, times(1)).getById(post.getId()); + verify(s3Service, times(0)).deletePostImage(any(String.class)); + verify(s3Service, times(0)).uploadFiles(anyList(), any(ImgType.class)); + } + + @Test + void 게시글을_수정한다_기존_사진_있음_수정_사진_없음() { + // Given + PostUpdateRequest postUpdateRequest = new PostUpdateRequest("자유", "updateTitle", "updateContent"); + when(postRepository.getById(postWithImages.getId())).thenReturn(postWithImages); + + // When + PostUpdateResponse response = postService.updatePost( + siteUser.getEmail(), board.getCode(), postWithImages.getId(), postUpdateRequest, Collections.emptyList()); + + // Then + assertEquals(response, PostUpdateResponse.from(postWithImages)); + verify(postRepository, times(1)).getById(postWithImages.getId()); + verify(s3Service, times(imageFiles.size())).deletePostImage(any(String.class)); + verify(s3Service, times(0)).uploadFiles(anyList(), any(ImgType.class)); + } + + @Test + void 게시글을_수정한다_기존_사진_없음_수정_사진_있음() { + // Given + PostUpdateRequest postUpdateRequest = new PostUpdateRequest("자유", "updateTitle", "updateContent"); + when(postRepository.getById(post.getId())).thenReturn(post); + when(s3Service.uploadFiles(imageFiles, ImgType.COMMUNITY)).thenReturn(uploadedFileUrlResponseList); + + // When + PostUpdateResponse response = postService.updatePost( + siteUser.getEmail(), board.getCode(), post.getId(), postUpdateRequest, imageFiles); + + // Then + assertEquals(response, PostUpdateResponse.from(post)); + verify(postRepository, times(1)).getById(post.getId()); + verify(s3Service, times(0)).deletePostImage(any(String.class)); + verify(s3Service, times(1)).uploadFiles(imageFiles, ImgType.COMMUNITY); + } + + @Test + void 게시글을_수정한다_기존_사진_있음_수정_사진_있음() { + // Given + PostUpdateRequest postUpdateRequest = new PostUpdateRequest("자유", "updateTitle", "updateContent"); + when(postRepository.getById(postWithImages.getId())).thenReturn(postWithImages); + when(s3Service.uploadFiles(imageFiles, ImgType.COMMUNITY)).thenReturn(uploadedFileUrlResponseList); + + // When + PostUpdateResponse response = postService.updatePost( + siteUser.getEmail(), board.getCode(), postWithImages.getId(), postUpdateRequest, imageFiles); + + // Then + assertEquals(response, PostUpdateResponse.from(postWithImages)); + verify(postRepository, times(1)).getById(postWithImages.getId()); + verify(s3Service, times(imageFiles.size())).deletePostImage(any(String.class)); + verify(s3Service, times(1)).uploadFiles(imageFiles, ImgType.COMMUNITY); + } + + @Test + void 게시글을_수정할_때_유효한_게시판이_아니라면_예외_응답을_반환한다() { + // Given + PostUpdateRequest postUpdateRequest = new PostUpdateRequest("자유", "title", "content"); + String invalidBoardCode = "INVALID_CODE"; + + // When & Then + CustomException exception = assertThrows(CustomException.class, () -> + postService.updatePost(siteUser.getEmail(), invalidBoardCode, post.getId(), postUpdateRequest, imageFiles)); + assertThat(exception.getMessage()) + .isEqualTo(ErrorCode.INVALID_BOARD_CODE.getMessage()); + assertThat(exception.getCode()) + .isEqualTo(ErrorCode.INVALID_BOARD_CODE.getCode()); + } + + @Test + void 게시글을_수정할_때_유효한_게시글이_아니라면_예외_응답을_반환한다() { + // Given + Long invalidPostId = -1L; + PostUpdateRequest postUpdateRequest = new PostUpdateRequest("자유", "title", "content"); + when(postRepository.getById(invalidPostId)).thenThrow(new CustomException(INVALID_POST_ID)); + + // When & Then + CustomException exception = assertThrows(CustomException.class, () -> + postService.updatePost(siteUser.getEmail(), board.getCode(), invalidPostId, postUpdateRequest, imageFiles)); + assertThat(exception.getMessage()) + .isEqualTo(INVALID_POST_ID.getMessage()); + assertThat(exception.getCode()) + .isEqualTo(INVALID_POST_ID.getCode()); + } + + @Test + void 게시글을_수정할_때_본인의_게시글이_아니라면_예외_응답을_반환한다() { + // Given + String invalidEmail = "invalidEmail@example.com"; + PostUpdateRequest postUpdateRequest = new PostUpdateRequest("자유", "title", "content"); + when(postRepository.getById(post.getId())).thenReturn(post); + + // When & Then + CustomException exception = assertThrows(CustomException.class, () -> + postService.updatePost(invalidEmail, board.getCode(), post.getId(), postUpdateRequest, imageFiles)); + assertThat(exception.getMessage()) + .isEqualTo(INVALID_POST_ACCESS.getMessage()); + assertThat(exception.getCode()) + .isEqualTo(INVALID_POST_ACCESS.getCode()); + } + + @Test + void 게시글을_수정할_때_질문글_이라면_예외_응답을_반환한다() { + // Given + PostUpdateRequest postUpdateRequest = new PostUpdateRequest("자유", "title", "content"); + when(postRepository.getById(questionPost.getId())).thenReturn(questionPost); + + // When & Then + CustomException exception = assertThrows(CustomException.class, () -> + postService.updatePost(siteUser.getEmail(), board.getCode(), questionPost.getId(), postUpdateRequest, imageFiles)); + assertThat(exception.getMessage()) + .isEqualTo(CAN_NOT_DELETE_OR_UPDATE_QUESTION.getMessage()); + assertThat(exception.getCode()) + .isEqualTo(CAN_NOT_DELETE_OR_UPDATE_QUESTION.getCode()); + } + + + @Test + void 게시글을_수정할_때_파일_수가_5개를_넘는다면_예외_응답을_반환한다() { + // Given + PostUpdateRequest postUpdateRequest = new PostUpdateRequest("자유", "title", "content"); + when(postRepository.getById(post.getId())).thenReturn(post); + + // When & Then + CustomException exception = assertThrows(CustomException.class, () -> + postService.updatePost(siteUser.getEmail(), board.getCode(), post.getId(), postUpdateRequest, imageFilesWithMoreThanFiveFiles)); + assertThat(exception.getMessage()) + .isEqualTo(CAN_NOT_UPLOAD_MORE_THAN_FIVE_IMAGES.getMessage()); + assertThat(exception.getCode()) + .isEqualTo(CAN_NOT_UPLOAD_MORE_THAN_FIVE_IMAGES.getCode()); + } + + /** + * 게시글 조회 + */ + @Test + void 게시글을_찾는다() { + // Given + List commentFindResultDTOList = new ArrayList<>(); + when(postRepository.getByIdUsingEntityGraph(post.getId())).thenReturn(post); + when(commentService.findCommentsByPostId(siteUser.getEmail(), post.getId())).thenReturn(commentFindResultDTOList); + + // When + PostFindResponse response = postService.findPostById(siteUser.getEmail(), board.getCode(), post.getId()); + + // Then + PostFindResponse expectedResponse = PostFindResponse.from( + post, + true, + PostFindBoardResponse.from(post.getBoard()), + PostFindSiteUserResponse.from(post.getSiteUser()), + commentFindResultDTOList, + PostFindPostImageResponse.from(post.getPostImageList()) + ); + assertEquals(expectedResponse, response); + verify(postRepository, times(1)).getByIdUsingEntityGraph(post.getId()); + verify(commentService, times(1)).findCommentsByPostId(siteUser.getEmail(), post.getId()); + } + + @Test + void 게시글을_찾을_때_유효한_게시판이_아니라면_예외_응답을_반환한다() { + // Given + String invalidBoardCode = "INVALID_CODE"; + + // When & Then + CustomException exception = assertThrows(CustomException.class, () -> + postService.findPostById(siteUser.getEmail(), invalidBoardCode, post.getId())); + assertThat(exception.getMessage()) + .isEqualTo(ErrorCode.INVALID_BOARD_CODE.getMessage()); + assertThat(exception.getCode()) + .isEqualTo(ErrorCode.INVALID_BOARD_CODE.getCode()); + } + + @Test + void 게시글을_찾을_때_유효한_게시글이_아니라면_예외_응답을_반환한다() { + // Given + Long invalidPostId = -1L; + when(postRepository.getByIdUsingEntityGraph(invalidPostId)).thenThrow(new CustomException(INVALID_POST_ID)); + + // When & Then + CustomException exception = assertThrows(CustomException.class, () -> + postService.findPostById(siteUser.getEmail(), board.getCode(), invalidPostId)); + assertThat(exception.getMessage()) + .isEqualTo(INVALID_POST_ID.getMessage()); + assertThat(exception.getCode()) + .isEqualTo(INVALID_POST_ID.getCode()); + } + + /** + * 게시글 삭제 + */ + @Test + void 게시글을_삭제한다() { + // Give + when(postRepository.getById(post.getId())).thenReturn(post); + + // When + PostDeleteResponse postDeleteResponse = postService.deletePostById(siteUser.getEmail(), board.getCode(), post.getId()); + + // Then + assertEquals(postDeleteResponse.id(), post.getId()); + verify(postRepository, times(1)).getById(post.getId()); + verify(redisService, times(1)).deleteKey(redisUtils.getPostViewCountRedisKey(post.getId())); + verify(postRepository, times(1)).deleteById(post.getId()); + } + + @Test + void 게시글을_삭제할_때_유효한_게시판이_아니라면_예외_응답을_반환한다() { + // Given + String invalidBoardCode = "INVALID_CODE"; + + // When & Then + CustomException exception = assertThrows(CustomException.class, () -> + postService.deletePostById(siteUser.getEmail(), invalidBoardCode, post.getId())); + assertThat(exception.getMessage()) + .isEqualTo(ErrorCode.INVALID_BOARD_CODE.getMessage()); + assertThat(exception.getCode()) + .isEqualTo(ErrorCode.INVALID_BOARD_CODE.getCode()); + } + + @Test + void 게시글을_삭제할_때_유효한_게시글이_아니라면_예외_응답을_반환한다() { + // Given + Long invalidPostId = -1L; + when(postRepository.getById(invalidPostId)).thenThrow(new CustomException(INVALID_POST_ID)); + + // When & Then + CustomException exception = assertThrows(CustomException.class, () -> + postService.deletePostById(siteUser.getEmail(), board.getCode(), invalidPostId)); + assertThat(exception.getMessage()) + .isEqualTo(ErrorCode.INVALID_POST_ID.getMessage()); + assertThat(exception.getCode()) + .isEqualTo(ErrorCode.INVALID_POST_ID.getCode()); + } + + @Test + void 게시글을_삭제할_때_자신의_게시글이_아니라면_예외_응답을_반환한다() { + // Given + String invalidEmail = "invalidEmail@example.com"; + when(postRepository.getById(post.getId())).thenReturn(post); + + // When & Then + CustomException exception = assertThrows(CustomException.class, () -> + postService.deletePostById(invalidEmail, board.getCode(), post.getId()) + ); + assertThat(exception.getMessage()) + .isEqualTo(INVALID_POST_ACCESS.getMessage()); + assertThat(exception.getCode()) + .isEqualTo(INVALID_POST_ACCESS.getCode()); + } + + @Test + void 게시글을_삭제할_때_질문글_이라면_예외_응답을_반환한다() { + when(postRepository.getById(questionPost.getId())).thenReturn(questionPost); + + // When & Then + CustomException exception = assertThrows(CustomException.class, () -> + postService.deletePostById(siteUser.getEmail(), board.getCode(), questionPost.getId())); + assertThat(exception.getMessage()) + .isEqualTo(ErrorCode.CAN_NOT_DELETE_OR_UPDATE_QUESTION.getMessage()); + assertThat(exception.getCode()) + .isEqualTo(ErrorCode.CAN_NOT_DELETE_OR_UPDATE_QUESTION.getCode()); + } +}