diff --git a/backend/src/main/java/org/juniortown/backend/comment/repository/CommentRepository.java b/backend/src/main/java/org/juniortown/backend/comment/repository/CommentRepository.java index 0e0d862..3b51caf 100644 --- a/backend/src/main/java/org/juniortown/backend/comment/repository/CommentRepository.java +++ b/backend/src/main/java/org/juniortown/backend/comment/repository/CommentRepository.java @@ -3,8 +3,19 @@ import java.util.List; import org.juniortown.backend.comment.entity.Comment; +import org.juniortown.backend.post.dto.response.IdCount; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; +@Repository public interface CommentRepository extends JpaRepository { List findByPostIdOrderByCreatedAtAsc(Long postId); + + @Query("SELECT c.post.id AS postId, COUNT(c) AS count " + + "FROM Comment c " + + "WHERE c.post.id IN :postIds " + + "GROUP BY c.post.id") + List countByPostIds(@Param("postIds") List postsIds); } diff --git a/backend/src/main/java/org/juniortown/backend/config/RedisConfig.java b/backend/src/main/java/org/juniortown/backend/config/RedisConfig.java index 7d05aa5..028666d 100644 --- a/backend/src/main/java/org/juniortown/backend/config/RedisConfig.java +++ b/backend/src/main/java/org/juniortown/backend/config/RedisConfig.java @@ -35,9 +35,10 @@ public RedisTemplate keyCheckRedisTemplate(RedisConnectionFacto @Bean public RedisTemplate readCountRedisTemplate(RedisConnectionFactory redisConnectionFactory) { RedisTemplate redisTemplate = new RedisTemplate<>(); - redisTemplate.setConnectionFactory(redisConnectionFactory); redisTemplate.setKeySerializer(new StringRedisSerializer()); - redisTemplate.setValueSerializer(new Jackson2JsonRedisSerializer<>(Long.class)); + redisTemplate.setHashKeySerializer(new StringRedisSerializer()); + redisTemplate.setHashValueSerializer(new Jackson2JsonRedisSerializer<>(Long.class)); + redisTemplate.setConnectionFactory(redisConnectionFactory); return redisTemplate; } } diff --git a/backend/src/main/java/org/juniortown/backend/config/SecurityConfig.java b/backend/src/main/java/org/juniortown/backend/config/SecurityConfig.java index acebe7c..9bf7177 100644 --- a/backend/src/main/java/org/juniortown/backend/config/SecurityConfig.java +++ b/backend/src/main/java/org/juniortown/backend/config/SecurityConfig.java @@ -7,6 +7,7 @@ import org.juniortown.backend.user.jwt.LoginFilter; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; @@ -77,9 +78,9 @@ public CorsConfiguration getCorsConfiguration(HttpServletRequest request) { //경로별 인가 작업 http .authorizeHttpRequests((auth) -> auth - .requestMatchers("/api/auth/login", "/", "/api/auth/signup","/swagger-ui/**","/v3/api-docs/**", - "/api/posts/details/**", "/actuator/health","/actuator/prometheus").permitAll() - .requestMatchers(RegexRequestMatcher.regexMatcher("/api/posts/\\d+")).permitAll() + .requestMatchers("/api/auth/login", "/", "/api/auth/signup","/swagger-ui/**","/v3/api-docs/**").permitAll() + .requestMatchers(HttpMethod.GET, "/api/posts/details/**", "/api/posts", "/api/posts/home").permitAll() + .requestMatchers("/actuator/health","/actuator/prometheus").permitAll() .requestMatchers("/admin").hasRole("ADMIN") .anyRequest().authenticated()); diff --git a/backend/src/main/java/org/juniortown/backend/dummyData/DummyDataInit.java b/backend/src/main/java/org/juniortown/backend/dummyData/DummyDataInit.java index de7e2db..1198cca 100644 --- a/backend/src/main/java/org/juniortown/backend/dummyData/DummyDataInit.java +++ b/backend/src/main/java/org/juniortown/backend/dummyData/DummyDataInit.java @@ -1,5 +1,6 @@ package org.juniortown.backend.dummyData; +import org.juniortown.backend.post.entity.Category; import org.juniortown.backend.post.entity.Post; import org.juniortown.backend.post.repository.PostRepository; import org.juniortown.backend.user.entity.User; @@ -35,18 +36,19 @@ public void run(String... args) throws Exception { .orElseThrow(() -> new RuntimeException("User not found")); // 게시글 생성 + Category[] categories = Category.values(); + for (int i = 0; i < 25; i++) { + createPost("title" + i,"content" + i, user, categories[i % categories.length]); + } + } + + private void createPost(String Dummy_Post_Title, String content, User user, Category category) { Post testPost1 = Post.builder() - .title("Dummy Post Title") - .content("This is a dummy post content.") + .title(Dummy_Post_Title) + .content(content) .user(user) + .category(category) .build(); postRepository.save(testPost1); - - Post testPost2 = Post.builder() - .title("Another Dummy Post Title") - .content("This is another dummy post content.") - .user(user) - .build(); - postRepository.save(testPost2); } } diff --git a/backend/src/main/java/org/juniortown/backend/like/repository/LikeRepository.java b/backend/src/main/java/org/juniortown/backend/like/repository/LikeRepository.java index e9afffe..2673bf5 100644 --- a/backend/src/main/java/org/juniortown/backend/like/repository/LikeRepository.java +++ b/backend/src/main/java/org/juniortown/backend/like/repository/LikeRepository.java @@ -1,13 +1,23 @@ package org.juniortown.backend.like.repository; +import java.util.List; import java.util.Optional; import org.juniortown.backend.like.entity.Like; +import org.juniortown.backend.post.dto.response.IdCount; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; @Repository public interface LikeRepository extends JpaRepository { Optional findByUserIdAndPostId(Long userId, Long postId); Long countByPostId(Long postId); + + @Query("SELECT l.post.id AS postId, COUNT(l) AS count " + + "FROM Like l " + + "WHERE l.post.id IN :postIds " + + "GROUP BY l.post.id") + List countByPostIds(@Param("postIds")ListpostIds); } diff --git a/backend/src/main/java/org/juniortown/backend/post/controller/PostController.java b/backend/src/main/java/org/juniortown/backend/post/controller/PostController.java index 9e5619d..e19ed0b 100644 --- a/backend/src/main/java/org/juniortown/backend/post/controller/PostController.java +++ b/backend/src/main/java/org/juniortown/backend/post/controller/PostController.java @@ -1,15 +1,14 @@ package org.juniortown.backend.post.controller; -import java.util.stream.Collectors; - import org.juniortown.backend.post.dto.request.PostCreateRequest; +import org.juniortown.backend.post.dto.request.PostEditRequest; +import org.juniortown.backend.post.dto.response.HomePageResponse; import org.juniortown.backend.post.dto.response.PageResponse; import org.juniortown.backend.post.dto.response.PostDetailResponse; -import org.juniortown.backend.post.dto.response.PostWithLikeCount; import org.juniortown.backend.post.dto.response.PostResponse; -import org.juniortown.backend.post.dto.response.PostWithLikeCountProjection; +import org.juniortown.backend.post.entity.Category; import org.juniortown.backend.post.service.PostService; -import org.juniortown.backend.post.service.ViewCountService; +import org.juniortown.backend.post.service.ReadCountRedisService; import org.juniortown.backend.user.dto.CustomUserDetails; import org.springframework.data.domain.Page; import org.springframework.http.HttpStatus; @@ -23,6 +22,7 @@ import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import jakarta.validation.Valid; @@ -33,7 +33,7 @@ @RequestMapping("/api") public class PostController { private final PostService postService; - private final ViewCountService viewCountService; + private final ReadCountRedisService readCountRedisService; @PostMapping("/posts") public ResponseEntity create(@AuthenticationPrincipal CustomUserDetails customUserDetails, @Valid @RequestBody PostCreateRequest postCreateRequest) { @@ -52,20 +52,22 @@ public ResponseEntity delete(@AuthenticationPrincipal CustomUserDetails custo } @PatchMapping("/posts/{postId}") - public ResponseEntity update(@AuthenticationPrincipal CustomUserDetails customUserDetails, @PathVariable Long postId, @Valid @RequestBody PostCreateRequest postCreateRequest) { + public ResponseEntity update(@AuthenticationPrincipal CustomUserDetails customUserDetails, @PathVariable Long postId, @Valid @RequestBody PostEditRequest postEditRequest) { Long userId = customUserDetails.getUserId(); - PostResponse response = postService.update(postId, userId, postCreateRequest); + PostResponse response = postService.update(postId, userId, postEditRequest); // 202가 뭐임? return ResponseEntity.status(HttpStatus.OK).body(response); } // 게시글 목록 조회, 페이지네이션 적용 - @GetMapping("/posts/{page}") + @GetMapping("/posts") public ResponseEntity> getPosts(@AuthenticationPrincipal CustomUserDetails customUserDetails, - @PathVariable int page + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "10") int size, + @RequestParam(defaultValue = "NOTICE") Category category ) { Long userId = (customUserDetails != null) ? customUserDetails.getUserId() : null; - Page posts = postService.getPosts(userId, page); + Page posts = postService.getPosts(userId, page, size, category); PageResponse response = new PageResponse<>(posts); return ResponseEntity.ok(response); } @@ -80,4 +82,12 @@ public ResponseEntity getPost(@AuthenticationPrincipal Custo return ResponseEntity.ok(postDetailResponse); } + @GetMapping("/posts/home") + public ResponseEntity homePagePosts( + @RequestParam(defaultValue = "5") int perCategory + ){ + HomePageResponse homePageResponse = postService.homePagePosts(perCategory); + return ResponseEntity.ok(homePageResponse); + } + } diff --git a/backend/src/main/java/org/juniortown/backend/post/dto/request/PostCreateRequest.java b/backend/src/main/java/org/juniortown/backend/post/dto/request/PostCreateRequest.java index 14f0449..e7cb3fb 100644 --- a/backend/src/main/java/org/juniortown/backend/post/dto/request/PostCreateRequest.java +++ b/backend/src/main/java/org/juniortown/backend/post/dto/request/PostCreateRequest.java @@ -1,5 +1,6 @@ package org.juniortown.backend.post.dto.request; +import org.juniortown.backend.post.entity.Category; import org.juniortown.backend.post.entity.Post; import org.juniortown.backend.user.entity.User; @@ -17,16 +18,20 @@ public class PostCreateRequest { private String title; @NotBlank(message = "컨텐츠를 입력해주세요.") private String content; + @NotBlank(message = "카테고리를 입력해주세요.") + private String category; @Builder - public PostCreateRequest(String title, String content) { + public PostCreateRequest(String title, String content, String category) { this.title = title; this.content = content; + this.category = category; } public Post toEntity(User user) { return Post.builder() .title(title) .content(content) + .category(Category.from(category)) .user(user) .build(); } diff --git a/backend/src/main/java/org/juniortown/backend/post/dto/PostEdit.java b/backend/src/main/java/org/juniortown/backend/post/dto/request/PostEditRequest.java similarity index 73% rename from backend/src/main/java/org/juniortown/backend/post/dto/PostEdit.java rename to backend/src/main/java/org/juniortown/backend/post/dto/request/PostEditRequest.java index ef46cfb..b9cd001 100644 --- a/backend/src/main/java/org/juniortown/backend/post/dto/PostEdit.java +++ b/backend/src/main/java/org/juniortown/backend/post/dto/request/PostEditRequest.java @@ -1,4 +1,4 @@ -package org.juniortown.backend.post.dto; +package org.juniortown.backend.post.dto.request; import jakarta.validation.constraints.NotBlank; import lombok.Builder; @@ -8,13 +8,13 @@ @Getter @Builder @ToString -public class PostEdit { +public class PostEditRequest { @NotBlank(message = "타이틀을 입력해주세요.") private String title; @NotBlank(message = "컨텐츠를 입력해주세요.") private String content; @Builder - public PostEdit(String title, String content) { + public PostEditRequest(String title, String content) { this.title = title; this.content = content; } diff --git a/backend/src/main/java/org/juniortown/backend/post/dto/response/HomePageResponse.java b/backend/src/main/java/org/juniortown/backend/post/dto/response/HomePageResponse.java new file mode 100644 index 0000000..a232132 --- /dev/null +++ b/backend/src/main/java/org/juniortown/backend/post/dto/response/HomePageResponse.java @@ -0,0 +1,20 @@ +package org.juniortown.backend.post.dto.response; + +import java.time.LocalDateTime; +import java.util.List; + +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class HomePageResponse { + List sections; + private LocalDateTime createdAt; + @Builder + public HomePageResponse(List sections, LocalDateTime createdAt) { + this.sections = sections; + this.createdAt = createdAt; + } +} diff --git a/backend/src/main/java/org/juniortown/backend/post/dto/response/HomePageSection.java b/backend/src/main/java/org/juniortown/backend/post/dto/response/HomePageSection.java new file mode 100644 index 0000000..6b40f9d --- /dev/null +++ b/backend/src/main/java/org/juniortown/backend/post/dto/response/HomePageSection.java @@ -0,0 +1,19 @@ +package org.juniortown.backend.post.dto.response; + +import java.util.List; + +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class HomePageSection { + private String category; + private List items; + @Builder + public HomePageSection(String category, List items) { + this.category = category; + this.items = items; + } +} diff --git a/backend/src/main/java/org/juniortown/backend/post/dto/response/IdCount.java b/backend/src/main/java/org/juniortown/backend/post/dto/response/IdCount.java new file mode 100644 index 0000000..6d749c5 --- /dev/null +++ b/backend/src/main/java/org/juniortown/backend/post/dto/response/IdCount.java @@ -0,0 +1,6 @@ +package org.juniortown.backend.post.dto.response; + +public interface IdCount { + Long getPostId(); + Long getCount(); +} diff --git a/backend/src/main/java/org/juniortown/backend/post/dto/response/PostForHomePage.java b/backend/src/main/java/org/juniortown/backend/post/dto/response/PostForHomePage.java new file mode 100644 index 0000000..78acbd7 --- /dev/null +++ b/backend/src/main/java/org/juniortown/backend/post/dto/response/PostForHomePage.java @@ -0,0 +1,32 @@ +package org.juniortown.backend.post.dto.response; + +import java.time.LocalDateTime; + +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class PostForHomePage { + private Long postId; + private String title; + private Long userId; + private String nickname; + private Long likeCount; + private Long commentCount; + private Long readCount; + private LocalDateTime createdAt; + @Builder + public PostForHomePage(Long postId, String title, Long userId, String nickname, Long likeCount, Long commentCount, + Long readCount, LocalDateTime createdAt) { + this.postId = postId; + this.title = title; + this.userId = userId; + this.nickname = nickname; + this.likeCount = likeCount; + this.commentCount = commentCount; + this.readCount = readCount; + this.createdAt = createdAt; + } +} diff --git a/backend/src/main/java/org/juniortown/backend/post/entity/Category.java b/backend/src/main/java/org/juniortown/backend/post/entity/Category.java new file mode 100644 index 0000000..990ee99 --- /dev/null +++ b/backend/src/main/java/org/juniortown/backend/post/entity/Category.java @@ -0,0 +1,23 @@ +package org.juniortown.backend.post.entity; + +import lombok.Getter; + +@Getter +public enum Category { + QUESTION(), + STUDY(), + NOTICE(), + SECOND_HAND_MARKET(), + MENTOR(); + + public static Category from(String value) { + if (value == null) { + throw new IllegalArgumentException("Category는 null일 수 없다."); + } + try { + return Category.valueOf(value.toUpperCase()); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("유효하지 않은 카테고리입니다: " + value); + } + } +} diff --git a/backend/src/main/java/org/juniortown/backend/post/entity/Post.java b/backend/src/main/java/org/juniortown/backend/post/entity/Post.java index 67110cc..1ee76cf 100644 --- a/backend/src/main/java/org/juniortown/backend/post/entity/Post.java +++ b/backend/src/main/java/org/juniortown/backend/post/entity/Post.java @@ -6,10 +6,13 @@ import org.juniortown.backend.entity.BaseTimeEntity; import org.juniortown.backend.post.dto.request.PostCreateRequest; +import org.juniortown.backend.post.dto.request.PostEditRequest; import org.juniortown.backend.user.entity.User; 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; @@ -43,20 +46,25 @@ public class Post extends BaseTimeEntity { @Column(name = "read_count", nullable = false) private Long readCount = 0L; + @Column(nullable = false) + @Enumerated(EnumType.STRING) + private Category category; + @Builder - public Post(String title, String content, User user) { + public Post(String title, String content, User user, Category category) { this.title = title; this.content = content; this.user = user; + this.category = category; } public void softDelete(Clock clock) { this.deletedAt = LocalDateTime.now(clock); } - public void update(PostCreateRequest postCreateRequest, Clock clock) { - this.title = postCreateRequest.getTitle(); - this.content = postCreateRequest.getContent(); + public void update(String title, String content) { + this.title = title; + this.content = content; } public void addReadCount(Long redisReadCount) { diff --git a/backend/src/main/java/org/juniortown/backend/post/repository/PostRepository.java b/backend/src/main/java/org/juniortown/backend/post/repository/PostRepository.java index 36ce4f0..801815f 100644 --- a/backend/src/main/java/org/juniortown/backend/post/repository/PostRepository.java +++ b/backend/src/main/java/org/juniortown/backend/post/repository/PostRepository.java @@ -1,6 +1,9 @@ package org.juniortown.backend.post.repository; +import java.util.List; + import org.juniortown.backend.post.dto.response.PostWithLikeCountProjection; +import org.juniortown.backend.post.entity.Category; import org.juniortown.backend.post.entity.Post; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -24,10 +27,10 @@ public interface PostRepository extends JpaRepository, PostRepositor "FROM Post p " + "JOIN p.user u " + "LEFT JOIN Like l ON l.post.id = p.id " + - "WHERE p.deletedAt IS NULL " + + "WHERE p.deletedAt IS NULL and p.category = :category " + "GROUP BY p.id, p.title, p.readCount, u.name, u.id, p.createdAt, p.updatedAt, p.deletedAt" ) - Page findAllWithLikeCountForUser(@Param("userId") Long userId, Pageable pageable); + Page findAllWithLikeCountForUser(@Param("userId") Long userId, Pageable pageable, @Param("category") Category category); @Query( "SELECT p.id AS id, p.title AS title, p.readCount AS readCount, u.name AS username, u.id AS userId, COUNT(l.id) AS likeCount, " + @@ -35,8 +38,11 @@ public interface PostRepository extends JpaRepository, PostRepositor "FROM Post p " + "JOIN p.user u " + "LEFT JOIN Like l ON l.post.id = p.id " + - "WHERE p.deletedAt IS NULL " + + "WHERE p.deletedAt IS NULL and p.category = :category " + "GROUP BY p.id, p.title, p.readCount, u.name, u.id, p.createdAt, p.updatedAt, p.deletedAt" ) - Page findAllWithLikeCountForNonUser(Pageable pageable); + Page findAllWithLikeCountForNonUser(Pageable pageable, @Param("category") Category category); + + Page findByCategoryAndDeletedAtIsNull(Category category, Pageable pageable); + } diff --git a/backend/src/main/java/org/juniortown/backend/post/service/PostService.java b/backend/src/main/java/org/juniortown/backend/post/service/PostService.java index 8abbc80..114cd9f 100644 --- a/backend/src/main/java/org/juniortown/backend/post/service/PostService.java +++ b/backend/src/main/java/org/juniortown/backend/post/service/PostService.java @@ -1,12 +1,13 @@ package org.juniortown.backend.post.service; import java.time.Clock; +import java.time.LocalDateTime; +import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Optional; import java.util.stream.Collectors; -import java.util.stream.IntStream; import org.juniortown.backend.comment.dto.response.CommentsInPost; import org.juniortown.backend.comment.entity.Comment; @@ -15,9 +16,15 @@ import org.juniortown.backend.like.entity.Like; import org.juniortown.backend.like.repository.LikeRepository; import org.juniortown.backend.post.dto.request.PostCreateRequest; +import org.juniortown.backend.post.dto.request.PostEditRequest; +import org.juniortown.backend.post.dto.response.HomePageResponse; +import org.juniortown.backend.post.dto.response.HomePageSection; +import org.juniortown.backend.post.dto.response.IdCount; import org.juniortown.backend.post.dto.response.PostDetailResponse; +import org.juniortown.backend.post.dto.response.PostForHomePage; import org.juniortown.backend.post.dto.response.PostResponse; import org.juniortown.backend.post.dto.response.PostWithLikeCountProjection; +import org.juniortown.backend.post.entity.Category; import org.juniortown.backend.post.entity.Post; import org.juniortown.backend.post.exception.PostDeletePermissionDeniedException; import org.juniortown.backend.post.exception.PostNotFoundException; @@ -46,11 +53,10 @@ public class PostService { private final PostRepository postRepository; private final UserRepository userRepository; private final LikeRepository likeRepository; - private final ViewCountService viewCountService; + private final ReadCountRedisService readCountRedisService; private final CommentRepository commentRepository; private final Clock clock; private final RedisTemplate redisTemplate; - private final static int PAGE_SIZE = 10; @Transactional public PostResponse create(Long userId, PostCreateRequest postCreateRequest) { @@ -79,7 +85,7 @@ public void delete(Long postId, Long userId) { } @Transactional - public PostResponse update(Long postId, Long userId, PostCreateRequest postCreateRequest) { + public PostResponse update(Long postId, Long userId, PostEditRequest postEditRequest) { Post post = postRepository.findById(postId) .orElseThrow(() -> new PostNotFoundException()); @@ -87,17 +93,17 @@ public PostResponse update(Long postId, Long userId, PostCreateRequest postCreat throw new PostUpdatePermissionDeniedException(); } - post.update(postCreateRequest, clock); + post.update(postEditRequest.getTitle(), postEditRequest.getContent()); log.info("게시글 업데이트 성공: {}", post); return PostResponse.from(post); } @Transactional(readOnly = true) - public Page getPosts(Long userId, int page) { - Pageable pageable = PageRequest.of(page, PAGE_SIZE, Sort.by(Sort.Order.desc("createdAt"), Sort.Order.desc("id"))); + public Page getPosts(Long userId, int page, int size, Category category) { + Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Order.desc("createdAt"), Sort.Order.desc("id"))); Page postPage = (userId == null) - ? postRepository.findAllWithLikeCountForNonUser(pageable) - : postRepository.findAllWithLikeCountForUser(userId, pageable); + ? postRepository.findAllWithLikeCountForNonUser(pageable, category) + : postRepository.findAllWithLikeCountForUser(userId, pageable, category); Map redisReadCounts = getReadCountFromRedisCache(postPage); @@ -126,7 +132,7 @@ private Map getReadCountFromRedisCache(Page keys = ids.stream() - .map(id -> ViewCountService.VIEW_COUNT_KEY + id) + .map(id -> ReadCountRedisService.VIEW_COUNT_KEY + id) .toList(); try { List readCounts = redisTemplate.opsForValue().multiGet(keys); @@ -157,7 +163,7 @@ public PostDetailResponse getPost(Long postId, String viewerId) { like = likeRepository.findByUserIdAndPostId(Long.valueOf(viewerId), post.getId()); } Long likeCount = likeRepository.countByPostId(postId); - Long redisReadCount = viewCountService.readCountUp(viewerId, postId.toString()); + Long redisReadCount = readCountRedisService.readCountUp(viewerId, postId.toString()); // 댓글 조회 로직 List comments = commentRepository.findByPostIdOrderByCreatedAtAsc(postId); @@ -187,4 +193,67 @@ private boolean isLong(String str) { return false; } } + + @Transactional(readOnly = true) + public HomePageResponse homePagePosts(int perCategory) { + List categories = List.of(Category.values()); + Map> byCategory = new HashMap<>(); + // postId를 담기 위한 리스트 + List collected = new ArrayList<>(); + + PageRequest categoryPageRequest = PageRequest.of(0, perCategory, + Sort.by(Sort.Order.desc("createdAt"), + Sort.Order.desc("id"))); + + // 5개의 카테고리별로 최신 게시글 5개씩 조회 + for (Category category : categories) { + List posts = postRepository.findByCategoryAndDeletedAtIsNull(category, categoryPageRequest) + .getContent(); + byCategory.put(category, posts); + collected.addAll(posts); + } + + // 조회된 게시글들의 좋아요 수, 댓글 수 일괄 조회 + List postIds = collected.stream().map(Post::getId).toList(); + Map readDeltaFromRedisMap = readCountRedisService.getReadDeltaFromRedis(postIds); + // 게시글이 하나도 없는 경우에 대한 방어 코드 + if (postIds.isEmpty()) { + return HomePageResponse.builder() + .sections(List.of()) + .createdAt(LocalDateTime.now()) + .build(); + } + + Map commentMap = commentRepository.countByPostIds(postIds) + .stream().collect(Collectors.toMap(IdCount::getPostId, IdCount::getCount)); + Map likeMap = likeRepository.countByPostIds(postIds) + .stream().collect(Collectors.toMap(IdCount::getPostId, IdCount::getCount)); + + List sections = categories.stream() + .map(cat -> { + List items = byCategory.getOrDefault(cat, List.of()) + .stream() + .map(p -> PostForHomePage.builder() + .postId(p.getId()) + .title(p.getTitle()) + .userId(p.getUser().getId()) + .nickname(p.getUser().getName()) + .likeCount(likeMap.getOrDefault(p.getId(), 0L)) + .commentCount(commentMap.getOrDefault(p.getId(), 0L)) + .readCount(p.getReadCount() + readDeltaFromRedisMap.getOrDefault(p.getId(), 0L)) + .createdAt(p.getCreatedAt()) + .build()) + .toList(); + return HomePageSection.builder() + .category(cat.name().toLowerCase()) + .items(items) + .build(); + }) + .toList(); + + return HomePageResponse.builder() + .sections(sections) + .createdAt(LocalDateTime.now()) + .build(); + } } diff --git a/backend/src/main/java/org/juniortown/backend/post/service/ViewCountService.java b/backend/src/main/java/org/juniortown/backend/post/service/ReadCountRedisService.java similarity index 56% rename from backend/src/main/java/org/juniortown/backend/post/service/ViewCountService.java rename to backend/src/main/java/org/juniortown/backend/post/service/ReadCountRedisService.java index f9d0800..1658b4f 100644 --- a/backend/src/main/java/org/juniortown/backend/post/service/ViewCountService.java +++ b/backend/src/main/java/org/juniortown/backend/post/service/ReadCountRedisService.java @@ -1,8 +1,15 @@ package org.juniortown.backend.post.service; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.data.redis.core.HashOperations; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -14,7 +21,7 @@ @RequiredArgsConstructor @Slf4j @Transactional -public class ViewCountService { +public class ReadCountRedisService { @Qualifier("keyCheckRedisTemplate") private final RedisTemplate keyCheckRedisTemplate; @Qualifier("readCountRedisTemplate") @@ -26,7 +33,6 @@ public class ViewCountService { public Long readCountUp(String userId, String postId) { String dupKey = buildDupPreventKey(postId, userId); - String readCountKey = buildReadCountKey(postId); try { // dupKey 조회 Boolean exist = keyCheckRedisTemplate.opsForValue().get(dupKey); @@ -38,7 +44,8 @@ public Long readCountUp(String userId, String postId) { // 중복키 생성 keyCheckRedisTemplate.opsForValue().set(dupKey, true, 10, TimeUnit.MINUTES); // 조회수 캐시++1 - readCountRedisTemplate.opsForValue().increment(readCountKey); + //readCountRedisTemplate.opsForValue().increment(readCountKey); + readCountRedisTemplate.opsForHash().increment(VIEW_COUNT_KEY, postId,1); return getReadCount(postId); } } catch (Exception e) { @@ -49,16 +56,40 @@ public Long readCountUp(String userId, String postId) { } public Long getReadCount(String postId) { - String readCountKey = buildReadCountKey(postId); - Long readCount = readCountRedisTemplate.opsForValue().get(readCountKey); + //Long readCount = readCountRedisTemplate.opsForValue().get(readCountKey); + Long readCount = (Long)readCountRedisTemplate.opsForHash().get(VIEW_COUNT_KEY, postId); return readCount != null ? readCount : 0L; } - private String buildDupPreventKey(String postId, String userId) { - return DUP_PREVENT_KEY + postId + ":" + userId; + public Map getReadDeltaFromRedis(List postIds) { + if(postIds == null || postIds.isEmpty()) return Map.of(); + + Collection postIdKeys = postIds.stream() + .map(String::valueOf) + .collect(Collectors.toList()); + + // multiGet으로 캐시된 조회수 가져오기 + List deltas = readCountRedisTemplate.opsForHash() + .multiGet(VIEW_COUNT_KEY, postIdKeys); + + HashMap map = new HashMap<>(postIds.size()); + for (int i = 0; i < postIds.size(); i++) { + // 여기서도 바로 (Long)으로 캐스팅 하면 위험한가? + Object readCountCache = deltas.get(i); + Long readCount = 0L; + if(readCountCache != null) { + if(readCountCache instanceof Long) { + readCount = (Long)readCountCache; + } else if(readCountCache instanceof Number) { + readCount = ((Number)readCountCache).longValue(); + } + } + map.put(postIds.get(i), readCount); + } + return map; } - private String buildReadCountKey(String postId) { - return VIEW_COUNT_KEY + postId; + private String buildDupPreventKey(String postId, String userId) { + return DUP_PREVENT_KEY + postId + ":" + userId; } } diff --git a/backend/src/main/java/org/juniortown/backend/post/service/ViewCountSyncService.java b/backend/src/main/java/org/juniortown/backend/post/service/ViewCountSyncService.java index 4c4d245..3417dae 100644 --- a/backend/src/main/java/org/juniortown/backend/post/service/ViewCountSyncService.java +++ b/backend/src/main/java/org/juniortown/backend/post/service/ViewCountSyncService.java @@ -1,13 +1,19 @@ package org.juniortown.backend.post.service; -import java.util.Set; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; import java.util.concurrent.TimeUnit; import org.juniortown.backend.post.entity.Post; import org.juniortown.backend.post.repository.PostRepository; import org.redisson.api.RLock; import org.redisson.api.RedissonClient; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.dao.DataAccessException; +import org.springframework.data.redis.core.Cursor; import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ScanOptions; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -20,10 +26,15 @@ @Transactional @Slf4j public class ViewCountSyncService { + @Qualifier("readCountRedisTemplate") private final RedisTemplate redisTemplate; private final PostRepository postRepository; private final RedissonClient redissonClient; public static final String SYNC_LOCK_KEY = "sync:viewCount:lock"; + // 운영 해시 키 + private static final String SRC_KEY = ReadCountRedisService.VIEW_COUNT_KEY; + // 한 번에 처리할 배치 크기 + private static final int BATCH = 1000; @Scheduled(fixedDelay = 5 * 60 * 1000) // 5분마다 실행 public void syncViewCounts() { @@ -32,30 +43,81 @@ public void syncViewCounts() { boolean available = false; try { available = lock.tryLock(10, 300, TimeUnit.SECONDS); - if(!available){ + if (!available) { log.info("동기화 락을 획득하지 못했습니다. 다른 인스턴스에세 이미 실행 중입니다."); return; } - Set keys = redisTemplate.keys("post:viewCount:*"); - if(keys == null) return; - for (String key : keys) { - //Long postId = Long.valueOf(key.split(":")[2]); - Long postId = getPostIdFromKey(key); - if (postId == null) - continue; - Long count = redisTemplate.opsForValue().get(key); - if(count == null || count == 0) continue; - - postRepository.findById(postId).ifPresent(post -> { - post.addReadCount(count); - }); - redisTemplate.delete(key); + String snapKey = SRC_KEY + ":processing:" + System.currentTimeMillis(); + boolean snapCreated = tryRenameIfExists(SRC_KEY, snapKey); + if (!snapCreated) { + log.info("스냅샷 생성 대상이 없습니다(키 없음). 종료합니다."); + return; } + ScanOptions scanOptions = ScanOptions.scanOptions().count(BATCH).build(); + try (Cursor> cursor = redisTemplate.opsForHash().scan(snapKey, scanOptions)) { + List> chunk = new ArrayList<>(BATCH); + + while (cursor.hasNext()) { + chunk.add(cursor.next()); + if (chunk.size() == BATCH) { + applyChunkToDbOnly(chunk); + chunk.clear(); + } + } + + if (!chunk.isEmpty()) { + applyChunkToDbOnly(chunk); + } + } + redisTemplate.delete(snapKey); + log.info("ViewCountSyncService.syncViewCounts() - 완료 (snapKey: {})", snapKey); + } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new RuntimeException(e); } finally { - if(available) lock.unlock(); + if (available) lock.unlock(); + } + } + + private void applyChunkToDbOnly(List> chunk) { + for (Map.Entry e : chunk) { + Long postId = parsePostId(e.getKey()); + long delta = parseDelta(e.getValue()); + if(postId == null || delta == 0L) continue; + + postRepository.findById(postId).ifPresent((Post p) -> p.addReadCount(delta)); + } + } + private long parseDelta(Object val) { + if (val == null) return 0L; + // Number가 뭡니까? + if (val instanceof Number) return ((Number) val).longValue(); + try { + return Long.parseLong(String.valueOf(val)); + } catch (NumberFormatException ex) { + log.warn("숫자 파싱 실패: {}", val); + return 0L; + } + } + + private Long parsePostId(Object field) { + if(field == null) return null; + try { + return Long.valueOf(String.valueOf(field)); + } catch (NumberFormatException e) { + log.warn("잘못된 필드값(포스트 ID 아님): {}", field); + return null; + } + } + + private boolean tryRenameIfExists(String srcKey, String snapKey) { + try { + redisTemplate.rename(srcKey, snapKey); + return true; + } catch (DataAccessException e) { + log.debug("RENAME 실패 (아마 키 없음): srcKey={}, snapKey={}, cause={}", srcKey, snapKey, e.getMessage()); + return false; } } diff --git a/backend/src/test/java/org/juniortown/backend/comment/controller/CommentControllerTest.java b/backend/src/test/java/org/juniortown/backend/comment/controller/CommentControllerTest.java index 1872be1..6839a9a 100644 --- a/backend/src/test/java/org/juniortown/backend/comment/controller/CommentControllerTest.java +++ b/backend/src/test/java/org/juniortown/backend/comment/controller/CommentControllerTest.java @@ -22,6 +22,7 @@ import org.juniortown.backend.config.RedisTestConfig; import org.juniortown.backend.config.SyncConfig; import org.juniortown.backend.config.TestClockConfig; +import org.juniortown.backend.post.entity.Category; import org.juniortown.backend.post.entity.Post; import org.juniortown.backend.post.exception.PostNotFoundException; import org.juniortown.backend.post.repository.PostRepository; @@ -113,6 +114,7 @@ public void init() throws Exception { .title("테스트 게시글") .content("테스트 내용입니다.") .user(testUser) + .category(Category.NOTICE) .build() ); postRepository.save(post); @@ -222,6 +224,7 @@ void create_comment_fail_with_not_same_post() throws Exception { .title("다른 게시글") .content("다른 게시글 내용입니다.") .user(testUser) + .category(Category.NOTICE) .build(); Post savedDummyPost = postRepository.save(dummyPost); diff --git a/backend/src/test/java/org/juniortown/backend/comment/service/CommentTreeBuilderTest.java b/backend/src/test/java/org/juniortown/backend/comment/service/CommentTreeBuilderTest.java index c295175..52dade2 100644 --- a/backend/src/test/java/org/juniortown/backend/comment/service/CommentTreeBuilderTest.java +++ b/backend/src/test/java/org/juniortown/backend/comment/service/CommentTreeBuilderTest.java @@ -1,6 +1,5 @@ package org.juniortown.backend.comment.service; -import static org.juniortown.backend.util.TestDataUtil.*; import static org.junit.jupiter.api.Assertions.*; import java.util.List; @@ -10,6 +9,7 @@ import org.juniortown.backend.comment.entity.Comment; import org.juniortown.backend.post.entity.Post; import org.juniortown.backend.user.entity.User; +import org.juniortown.backend.util.TestBuilders; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -28,13 +28,13 @@ class CommentTreeBuilderTest { @BeforeEach void setUp() { - userA = createUser(1L, "testA", "testA@gmail.com"); - userB = createUser(2L, "testB", "testB@gmail.com"); - post = createPost(1L, "테스트 게시글", "테스트 게시글 내용", userA); - parentComment1 = createComment(1L, post, userA, "부모 댓글1", null); - parentComment2 = createComment(2L, post, userB, "부모 댓글2", null); - childComment1 = createComment(3L, post, userA, "자식 댓글1", parentComment1); - childComment2 = createComment(4L, post, userB, "자식 댓글2", parentComment2); + userA = TestBuilders.user().id(1L).name("testA").email("testA@email.com").build(); + userB = TestBuilders.user().id(2L).name("testB").email("testB@email.com").build(); + post = TestBuilders.post().id(1L).title("테스트 게시글").content("테스트 게시글 내용").user(userA).build(); + parentComment1 = TestBuilders.comment().id(1L).post(post).user(userA).content("부모 댓글1").parent(null).build(); + parentComment2 = TestBuilders.comment().id(2L).post(post).user(userB).content("부모 댓글2").parent(null).build(); + childComment1 = TestBuilders.comment().id(3L).post(post).user(userA).content("자식 댓글1").parent(parentComment1).build(); + childComment2 = TestBuilders.comment().id(4L).post(post).user(userB).content("자식 댓글2").parent(parentComment2).build(); } @Test @DisplayName("빈 리스트 주입 시 빈 리스트 반환") diff --git a/backend/src/test/java/org/juniortown/backend/config/RedisTestConfig.java b/backend/src/test/java/org/juniortown/backend/config/RedisTestConfig.java index 1500263..b3b52a5 100644 --- a/backend/src/test/java/org/juniortown/backend/config/RedisTestConfig.java +++ b/backend/src/test/java/org/juniortown/backend/config/RedisTestConfig.java @@ -34,7 +34,8 @@ public RedisTemplate keyCheckRedisTemplate(RedisConnectionFacto public RedisTemplate readCountRedisTemplate(RedisConnectionFactory redisConnectionFactory) { RedisTemplate redisTemplate = new RedisTemplate<>(); redisTemplate.setKeySerializer(new StringRedisSerializer()); - redisTemplate.setValueSerializer(new Jackson2JsonRedisSerializer<>(Long.class)); + redisTemplate.setHashKeySerializer(new StringRedisSerializer()); + redisTemplate.setHashValueSerializer(new Jackson2JsonRedisSerializer<>(Long.class)); redisTemplate.setConnectionFactory(redisConnectionFactory); return redisTemplate; } diff --git a/backend/src/test/java/org/juniortown/backend/like/controller/LikeControllerTest.java b/backend/src/test/java/org/juniortown/backend/like/controller/LikeControllerTest.java index 4407969..e4fc5c2 100644 --- a/backend/src/test/java/org/juniortown/backend/like/controller/LikeControllerTest.java +++ b/backend/src/test/java/org/juniortown/backend/like/controller/LikeControllerTest.java @@ -11,6 +11,7 @@ import org.juniortown.backend.like.entity.Like; import org.juniortown.backend.like.exception.LikeFailureException; import org.juniortown.backend.like.repository.LikeRepository; +import org.juniortown.backend.post.entity.Category; import org.juniortown.backend.post.entity.Post; import org.juniortown.backend.post.repository.PostRepository; import org.juniortown.backend.user.dto.LoginDTO; @@ -65,6 +66,7 @@ void clean() { .title("테스트 게시글") .content("테스트 내용입니다.") .user(testUser) + .category(Category.NOTICE) .build()); } @BeforeAll diff --git a/backend/src/test/java/org/juniortown/backend/controller/PostControllerPagingTest.java b/backend/src/test/java/org/juniortown/backend/post/controller/PostControllerPagingTest.java similarity index 86% rename from backend/src/test/java/org/juniortown/backend/controller/PostControllerPagingTest.java rename to backend/src/test/java/org/juniortown/backend/post/controller/PostControllerPagingTest.java index 57553c3..09f975d 100644 --- a/backend/src/test/java/org/juniortown/backend/controller/PostControllerPagingTest.java +++ b/backend/src/test/java/org/juniortown/backend/post/controller/PostControllerPagingTest.java @@ -1,4 +1,4 @@ -package org.juniortown.backend.controller; +package org.juniortown.backend.post.controller; import static org.hamcrest.Matchers.*; import static org.springframework.http.MediaType.*; @@ -14,6 +14,7 @@ import org.juniortown.backend.like.entity.Like; import org.juniortown.backend.like.repository.LikeRepository; import org.juniortown.backend.like.service.LikeService; +import org.juniortown.backend.post.entity.Category; import org.juniortown.backend.post.entity.Post; import org.juniortown.backend.post.repository.PostRepository; import org.juniortown.backend.user.dto.LoginDTO; @@ -39,6 +40,7 @@ import org.testcontainers.containers.GenericContainer; import org.testcontainers.junit.jupiter.Container; import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.shaded.org.checkerframework.checker.units.qual.C; import org.testcontainers.utility.DockerImageName; import com.fasterxml.jackson.databind.ObjectMapper; @@ -129,6 +131,7 @@ void clean_and_init() throws Exception { .user(testUser1) .title("테스트 글 " + (i + 1)) .content("테스트 내용 " + (i + 1)) + .category(Category.NOTICE) .build(); postRepository.save(post); } @@ -138,9 +141,14 @@ void clean_and_init() throws Exception { void get_posts_first_page_success() throws Exception { // given int page = 0; // 첫 페이지 + int pageSize = 10; + String category = "NOTICE"; // expected - mockMvc.perform(MockMvcRequestBuilders.get("/api/posts/{page}", page) + mockMvc.perform(MockMvcRequestBuilders.get("/api/posts") + .param("page", String.valueOf(page)) + .param("size", String.valueOf(pageSize)) + .param("category", category) .header("Authorization", jwt) ) .andExpect(status().isOk()) @@ -166,9 +174,14 @@ void get_posts_first_page_success() throws Exception { void get_posts_second_page_success() throws Exception { // given int page = 1; // 두 번째 페이지 + int pageSize = 10; // 페이지당 게시글 수 + String category = "NOTICE"; // 카테고리 // expected - mockMvc.perform(MockMvcRequestBuilders.get("/api/posts/{page}", page) + mockMvc.perform(MockMvcRequestBuilders.get("/api/posts") + .param("page", String.valueOf(page)) + .param("size", String.valueOf(pageSize)) + .param("category", category) .header("Authorization", jwt) ) .andExpect(status().isOk()) @@ -186,9 +199,14 @@ void get_posts_second_page_success() throws Exception { void get_posts_last_page_success() throws Exception { // given int page = 5; // 마지막 페이지 (53개 게시글, 페이지당 10개 -> 마지막 페이지는 6번째) + int pageSize = 10; // 페이지당 게시글 수 + String category = "NOTICE"; // 카테고리 // expected - mockMvc.perform(MockMvcRequestBuilders.get("/api/posts/{page}", page) + mockMvc.perform(MockMvcRequestBuilders.get("/api/posts") + .param("page", String.valueOf(page)) + .param("size", String.valueOf(pageSize)) + .param("category", category) .header("Authorization", jwt) ) .andExpect(status().isOk()) @@ -208,6 +226,8 @@ void get_posts_last_page_success() throws Exception { void get_posts_page_deleted_posts_not_included() throws Exception { // given int page = 5; // 첫 페이지 + int pageSize = 10; // 페이지당 게시글 수 + String category = "NOTICE"; // 카테고리 Long postId = postRepository.findAll().get(0).getId(); @@ -220,7 +240,10 @@ void get_posts_page_deleted_posts_not_included() throws Exception { // expected - mockMvc.perform(MockMvcRequestBuilders.get("/api/posts/{page}", page) + mockMvc.perform(MockMvcRequestBuilders.get("/api/posts") + .param("page", String.valueOf(page)) + .param("size", String.valueOf(pageSize)) + .param("category", category) .header("Authorization", jwt) ) .andExpect(status().isOk()) @@ -233,10 +256,13 @@ void get_posts_page_deleted_posts_not_included() throws Exception { void like_post_and_get_posts_first_page_success() throws Exception { // given int page = 0; // 첫 페이지 + int pageSize = 10; // 페이지당 게시글 수 + String category = "NOTICE"; // 카테고리 List posts = postRepository.findAll(Sort.by("createdAt").descending()); Post testPost1 = posts.get(0); Post testPost2 = posts.get(1); + Like like1 = Like.builder() .user(testUser1) .post(testPost1) @@ -253,7 +279,10 @@ void like_post_and_get_posts_first_page_success() throws Exception { likeRepository.saveAll(List.of(like1, like2, like3)); // expected - mockMvc.perform(MockMvcRequestBuilders.get("/api/posts/{page}", page) + mockMvc.perform(MockMvcRequestBuilders.get("/api/posts") + .param("page", String.valueOf(page)) + .param("size", String.valueOf(pageSize)) + .param("category", category) .header("Authorization", jwt) ) .andExpect(status().isOk()) @@ -275,6 +304,8 @@ void like_post_and_get_posts_first_page_success() throws Exception { void unlike_post_and_get_posts_first_page_success() throws Exception { // given int page = 0; // 첫 페이지 + int pageSize = 10; // 페이지당 게시글 수 + String category = "NOTICE"; // 카테고리 List posts = postRepository.findAll(Sort.by("createdAt").descending()); Post testPost1 = posts.get(0); Post testPost2 = posts.get(1); @@ -299,7 +330,10 @@ void unlike_post_and_get_posts_first_page_success() throws Exception { likeService.likePost(testUser1.getId(), testPost2.getId()); // expected - mockMvc.perform(MockMvcRequestBuilders.get("/api/posts/{page}", page) + mockMvc.perform(MockMvcRequestBuilders.get("/api/posts") + .param("page", String.valueOf(page)) + .param("size", String.valueOf(pageSize)) + .param("category", category) .header("Authorization", jwt) ) .andExpect(status().isOk()) @@ -321,9 +355,16 @@ void unlike_post_and_get_posts_first_page_success() throws Exception { void get_posts_first_page_success_with_non_user() throws Exception { // given int page = 0; // 첫 페이지 + int pageSize = 10; // 페이지당 게시글 수 + String category = "NOTICE"; // 카테고리 // expected - mockMvc.perform(MockMvcRequestBuilders.get("/api/posts/{page}", page)) + mockMvc.perform(MockMvcRequestBuilders.get("/api/posts") + .param("page", String.valueOf(page)) + .param("size", String.valueOf(pageSize)) + .param("category", category) + .header("Authorization", jwt) + ) .andExpect(status().isOk()) .andExpect(jsonPath("$.content", hasSize(10))) // 첫 페이지는 10개 .andExpect(jsonPath("$.totalElements").value(POST_COUNT)) // 전체 게시글 수 diff --git a/backend/src/test/java/org/juniortown/backend/controller/PostControllerTest.java b/backend/src/test/java/org/juniortown/backend/post/controller/PostControllerTest.java similarity index 91% rename from backend/src/test/java/org/juniortown/backend/controller/PostControllerTest.java rename to backend/src/test/java/org/juniortown/backend/post/controller/PostControllerTest.java index 3502e5a..f51a0bf 100644 --- a/backend/src/test/java/org/juniortown/backend/controller/PostControllerTest.java +++ b/backend/src/test/java/org/juniortown/backend/post/controller/PostControllerTest.java @@ -1,24 +1,20 @@ -package org.juniortown.backend.controller; +package org.juniortown.backend.post.controller; -import static org.hamcrest.Matchers.*; import static org.junit.jupiter.api.Assertions.*; import static org.springframework.http.MediaType.*; import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; -import java.util.List; -import java.util.Optional; import java.util.UUID; -import java.util.stream.Collectors; -import java.util.stream.IntStream; import org.juniortown.backend.config.RedisTestConfig; import org.juniortown.backend.config.SyncConfig; import org.juniortown.backend.config.TestClockConfig; import org.juniortown.backend.post.dto.request.PostCreateRequest; +import org.juniortown.backend.post.entity.Category; import org.juniortown.backend.post.entity.Post; import org.juniortown.backend.post.repository.PostRepository; -import org.juniortown.backend.post.dto.PostEdit; +import org.juniortown.backend.post.dto.request.PostEditRequest; import org.juniortown.backend.user.dto.LoginDTO; import org.juniortown.backend.user.entity.User; import org.juniortown.backend.user.jwt.JWTUtil; @@ -27,10 +23,8 @@ import org.juniortown.backend.user.service.AuthService; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.MethodOrderer; -import org.junit.jupiter.api.Order; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.TestInstance; import org.junit.jupiter.api.TestMethodOrder; @@ -40,16 +34,12 @@ import org.springframework.boot.test.context.SpringBootTest; import org.springframework.context.annotation.Import; -import org.springframework.http.HttpStatus; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; import org.springframework.transaction.annotation.Transactional; -import net.bytebuddy.build.ToStringPlugin; - -import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; @SpringBootTest @@ -113,6 +103,7 @@ void only_signupUser_can_make_post() throws Exception { PostCreateRequest request = PostCreateRequest.builder() .title("제목입니다.") .content("내용입니다.") + .category("NOTICE") .build(); String json = objectMapper.writeValueAsString(request); @@ -161,6 +152,7 @@ void post_should_be_in_DB() throws Exception { PostCreateRequest request = PostCreateRequest.builder() .title("제목 입니다.") .content("내용 입니다.") + .category("NOTICE") .build(); String json = objectMapper.writeValueAsString(request); @@ -192,6 +184,7 @@ void post_create_success() throws Exception { PostCreateRequest request = PostCreateRequest.builder() .title("제목 입니다.") .content("내용 입니다.") + .category("NOTICE") .build(); String json = objectMapper.writeValueAsString(request); @@ -213,6 +206,7 @@ void delete_post_success() throws Exception { .user(testUser) .title("테스트 글") .content("테스트 내용") + .category(Category.NOTICE) .build(); Post savePost = postRepository.save(post); @@ -251,6 +245,7 @@ void delete_only_myPost() throws Exception { .user(testUser) .title("테스트 글") .content("테스트 내용") + .category(Category.NOTICE) .build(); Post savePost = postRepository.save(post); @@ -282,17 +277,18 @@ void update_post_success() throws Exception { .user(testUser) .title("테스트 글") .content("테스트 내용") + .category(Category.NOTICE) .build(); Post savePost = postRepository.save(post); Long postId = savePost.getId(); - PostEdit postEdit = PostEdit.builder() + PostEditRequest postEditRequest = PostEditRequest.builder() .title("수정된 제목") .content("수정된 내용") .build(); - String json = objectMapper.writeValueAsString(postEdit); + String json = objectMapper.writeValueAsString(postEditRequest); // expected mockMvc.perform(MockMvcRequestBuilders.patch("/api/posts/{postId}", postId) @@ -301,8 +297,8 @@ void update_post_success() throws Exception { .header("Authorization", jwt) ) .andExpect(status().isOk()) - .andExpect(jsonPath("$.title").value(postEdit.getTitle())) - .andExpect(jsonPath("$.content").value(postEdit.getContent())) + .andExpect(jsonPath("$.title").value(postEditRequest.getTitle())) + .andExpect(jsonPath("$.content").value(postEditRequest.getContent())) .andDo(print()); } @@ -312,12 +308,12 @@ void update_post_not_found() throws Exception { // given Long postId = 999L; // 존재하지 않는 게시글 ID - PostEdit postEdit = PostEdit.builder() + PostEditRequest postEditRequest = PostEditRequest.builder() .title("수정된 제목") .content("수정된 내용") .build(); - String json = objectMapper.writeValueAsString(postEdit); + String json = objectMapper.writeValueAsString(postEditRequest); // expected mockMvc.perform(MockMvcRequestBuilders.patch("/api/posts/{postId}", postId) @@ -339,6 +335,7 @@ void update_only_myPost() throws Exception { .user(testUser) .title("테스트 글") .content("테스트 내용") + .category(Category.NOTICE) .build(); Post savePost = postRepository.save(post); @@ -352,12 +349,12 @@ void update_only_myPost() throws Exception { String jwt = "Bearer " + jwtUtil.createJwt(saveUser2.getId(), saveUser2.getEmail(),"doncham", saveUser2.getRole(), 10000L); - PostEdit postEdit = PostEdit.builder() + PostEditRequest postEditRequest = PostEditRequest.builder() .title("수정된 제목") .content("수정된 내용") .build(); - String json = objectMapper.writeValueAsString(postEdit); + String json = objectMapper.writeValueAsString(postEditRequest); mockMvc.perform(MockMvcRequestBuilders.patch("/api/posts/{postId}", postId) .contentType(APPLICATION_JSON) @@ -379,16 +376,17 @@ void need_title_when_update_post() throws Exception { .user(testUser) .title("테스트 글") .content("테스트 내용") + .category(Category.NOTICE) .build(); Post savePost = postRepository.save(post); Long postId = savePost.getId(); - PostEdit postEdit = PostEdit.builder() + PostEditRequest postEditRequest = PostEditRequest.builder() .content("수정된 내용") .build(); - String json = objectMapper.writeValueAsString(postEdit); + String json = objectMapper.writeValueAsString(postEditRequest); // expected mockMvc.perform(MockMvcRequestBuilders.patch("/api/posts/{postId}", postId) @@ -412,16 +410,17 @@ void need_content_when_update_post() throws Exception { .user(testUser) .title("테스트 글") .content("테스트 내용") + .category(Category.NOTICE) .build(); Post savePost = postRepository.save(post); Long postId = savePost.getId(); - PostEdit postEdit = PostEdit.builder() + PostEditRequest postEditRequest = PostEditRequest.builder() .title("수정된 제목") .build(); - String json = objectMapper.writeValueAsString(postEdit); + String json = objectMapper.writeValueAsString(postEditRequest); // expected mockMvc.perform(MockMvcRequestBuilders.patch("/api/posts/{postId}", postId) diff --git a/backend/src/test/java/org/juniortown/backend/post/controller/PostMainPageControllerTest.java b/backend/src/test/java/org/juniortown/backend/post/controller/PostMainPageControllerTest.java new file mode 100644 index 0000000..3c1f87b --- /dev/null +++ b/backend/src/test/java/org/juniortown/backend/post/controller/PostMainPageControllerTest.java @@ -0,0 +1,170 @@ +package org.juniortown.backend.post.controller; + +import static org.springframework.http.MediaType.*; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import java.util.List; + +import org.juniortown.backend.comment.entity.Comment; +import org.juniortown.backend.comment.repository.CommentRepository; +import org.juniortown.backend.config.RedisTestConfig; +import org.juniortown.backend.config.SyncConfig; +import org.juniortown.backend.config.TestClockConfig; +import org.juniortown.backend.like.entity.Like; +import org.juniortown.backend.like.repository.LikeRepository; +import org.juniortown.backend.post.entity.Category; +import org.juniortown.backend.post.entity.Post; +import org.juniortown.backend.post.repository.PostRepository; +import org.juniortown.backend.post.service.ReadCountRedisService; +import org.juniortown.backend.user.entity.User; +import org.juniortown.backend.user.repository.UserRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; +import org.springframework.transaction.annotation.Transactional; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; + +import com.redis.testcontainers.RedisContainer; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +@Import({RedisTestConfig.class, TestClockConfig.class}) +@Testcontainers +@Transactional +public class PostMainPageControllerTest { + @Autowired + private MockMvc mockMvc; + @Autowired + private PostRepository postRepository; + @Autowired + private UserRepository userRepository; + @Autowired + CommentRepository commentRepository; + @Autowired + LikeRepository likeRepository; + @Autowired + ReadCountRedisService readCountRedisService; + + private User u1, u2, u3, u4; + private Post q1, q2, s1, s2; + + @Container + static GenericContainer redis = new RedisContainer(DockerImageName.parse("redis:8.0")) + .withCommand("redis-server --port 6382") + .withExposedPorts(6382); + + + @DynamicPropertySource + static void overrideProps(DynamicPropertyRegistry registry) { + registry.add("spring.data.redis.host", redis::getHost); + registry.add("spring.data.redis.port", () -> redis.getFirstMappedPort()); + } + + @BeforeEach + void setUp() { + u1 = userRepository.save(User.builder().name("u1").email("u1@test.com").password("p").build()); + u2 = userRepository.save(User.builder().name("u2").email("u2@test.com").password("p").build()); + u3 = userRepository.save(User.builder().name("u3").email("u3@test.com").password("p").build()); + u4 = userRepository.save(User.builder().name("u4").email("u4@test.com").password("p").build()); + + // QUESTION 카테고리 2개 + q1 = postRepository.save(Post.builder().user(u1).title("Q1").content("Q1C").category(Category.QUESTION).build()); + q2 = postRepository.save(Post.builder().user(u2).title("Q2").content("Q2C").category(Category.QUESTION).build()); + + // STUDY 카테고리 2개 + s1 = postRepository.save(Post.builder().user(u3).title("S1").content("S1C").category(Category.STUDY).build()); + s2 = postRepository.save(Post.builder().user(u4).title("S2").content("S2C").category(Category.STUDY).build()); + + // Q1: like=2, comment=1 + likeRepository.saveAll(List.of( + Like.builder().post(q1).user(u1).build(), + Like.builder().post(q1).user(u2).build() + )); + commentRepository.save(Comment.builder().post(q1).user(u3).username(u3.getName()).content("c").build()); + + // Q2: like=1, comment=0 + likeRepository.save(Like.builder().post(q2).user(u3).build()); + + // S1: like=0, comment=2 + commentRepository.saveAll(List.of( + Comment.builder().post(s1).user(u1).username(u1.getName()).content("c1").build(), + Comment.builder().post(s1).user(u2).username(u2.getName()).content("c2").build() + )); + + // S2: like=1, comment=0 + likeRepository.save(Like.builder().post(s2).user(u1).build()); + } + + @Test + @DisplayName("홈 섹션 조회 성공: 카테고리별 Top-N과 좋아요/댓글 집계가 응답에 반영된다") + void homepage_success() throws Exception { + // when & then + mockMvc.perform(MockMvcRequestBuilders.get("/api/posts/home") + .param("perCategory", "2") + .accept(APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.sections").isArray()) + // 섹션 개수 = Category.values().length + .andExpect(jsonPath("$.sections.length()").value(Category.values().length)) + // QUESTION 섹션이 존재하고 아이템이 최대 2개 + // 최신순 정렬(createdAt DESC, id DESC)에 따라 Q2가 첫 번째로 나와야 함 + .andExpect(jsonPath("$.sections[?(@.category=='question')]").isArray()) + .andExpect(jsonPath("$.sections[?(@.category=='question')].items.length()").value(2)) + // STUDY 섹션이 존재하고 아이템이 최대 2개 + .andExpect(jsonPath("$.sections[?(@.category=='study')]").isArray()) + .andExpect(jsonPath("$.sections[?(@.category=='study')].items.length()").value(2)) + // 특정 포스트의 집계 값 예시 검증 (Q1: like=2, comment=1) + .andExpect(jsonPath("$.sections[?(@.category=='question')].items[0].title").value("Q2")) + // STUDY 섹션 내 임의 하나의 아이템 필드 확인 + .andExpect(jsonPath("$.sections[?(@.category=='study')].items[0].postId").value(s2.getId().intValue())) + .andDo(print()); + } + + @Test + @DisplayName("perCategory 제한: 1로 요청하면 섹션별 최대 1건만 노출") + void perCategory_limit_works() throws Exception { + mockMvc.perform(MockMvcRequestBuilders.get("/api/posts/home") + .param("perCategory", "1") + .accept(APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.sections").isArray()) + // question 섹션 아이템 길이 <= 1 + .andExpect(jsonPath("$.sections[?(@.category=='question')].items.length()").value(1)) + // study 섹션 아이템 길이 <= 1 + .andExpect(jsonPath("$.sections[?(@.category=='study')].items.length()").value(1)) + .andDo(print()); + } + + @Test + @DisplayName("모든 카테고리가 비어있으면 sections는 빈 배열로 응답한다") + void homepage_all_empty() throws Exception { + // given: 모두 삭제 (트랜잭션 내에서 안전) + likeRepository.deleteAll(); + commentRepository.deleteAll(); + postRepository.deleteAll(); + + mockMvc.perform(MockMvcRequestBuilders.get("/api/posts/home") + .param("perCategory", "5") + .accept(APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.sections").isArray()) + .andExpect(jsonPath("$.sections.length()").value(0)) + .andDo(print()); + } + +} diff --git a/backend/src/test/java/org/juniortown/backend/controller/PostRedisReadControllerTest.java b/backend/src/test/java/org/juniortown/backend/post/controller/PostRedisReadControllerTest.java similarity index 97% rename from backend/src/test/java/org/juniortown/backend/controller/PostRedisReadControllerTest.java rename to backend/src/test/java/org/juniortown/backend/post/controller/PostRedisReadControllerTest.java index d0e7955..b4deeb6 100644 --- a/backend/src/test/java/org/juniortown/backend/controller/PostRedisReadControllerTest.java +++ b/backend/src/test/java/org/juniortown/backend/post/controller/PostRedisReadControllerTest.java @@ -1,4 +1,4 @@ -package org.juniortown.backend.controller; +package org.juniortown.backend.post.controller; import static org.junit.jupiter.api.Assertions.*; import static org.springframework.http.MediaType.*; @@ -15,8 +15,10 @@ import org.juniortown.backend.comment.repository.CommentRepository; import org.juniortown.backend.config.RedisTestConfig; import org.juniortown.backend.config.TestClockConfig; +import org.juniortown.backend.post.entity.Category; import org.juniortown.backend.post.entity.Post; import org.juniortown.backend.post.repository.PostRepository; +import org.juniortown.backend.post.service.ReadCountRedisService; import org.juniortown.backend.post.service.ViewCountSyncService; import org.juniortown.backend.user.dto.LoginDTO; import org.juniortown.backend.user.entity.User; @@ -127,6 +129,7 @@ public void init() throws Exception { .user(testUser) .title("테스트 글") .content("테스트 내용") + .category(Category.NOTICE) .build(); testPost = postRepository.save(post); @@ -329,8 +332,8 @@ void view_count_sync_success() { // given Long postId = testPost.getId(); - String key = "post:viewCount:" + postId; - readCountRedisTemplate.opsForValue().set(key, 10L); + String key = ReadCountRedisService.VIEW_COUNT_KEY + postId; + readCountRedisTemplate.opsForHash().put(ReadCountRedisService.VIEW_COUNT_KEY, postId.toString(), 10L); // when viewCountSyncService.syncViewCounts(); diff --git a/backend/src/test/java/org/juniortown/backend/post/entity/PostTest.java b/backend/src/test/java/org/juniortown/backend/post/entity/PostTest.java index da0de4b..02d9102 100644 --- a/backend/src/test/java/org/juniortown/backend/post/entity/PostTest.java +++ b/backend/src/test/java/org/juniortown/backend/post/entity/PostTest.java @@ -8,6 +8,7 @@ import java.time.ZoneOffset; import org.juniortown.backend.post.dto.request.PostCreateRequest; +import org.juniortown.backend.post.dto.request.PostEditRequest; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -33,16 +34,14 @@ void softDelete_setsDeletedAt() { void post_update_changesUpdatedAt() { // given Post post = Post.builder().title("T").content("C").build(); - PostCreateRequest editRequest = PostCreateRequest.builder() + PostEditRequest editRequest = PostEditRequest.builder() .title("Changed Title") .content("Changed Content") .build(); - Clock fixedClock = Clock.fixed( - Instant.parse("2025-06-20T10:15:30Z"), ZoneOffset.UTC); // when - post.update(editRequest, fixedClock); + post.update(editRequest.getTitle(), editRequest.getContent()); // then assertEquals("Changed Title", post.getTitle()); diff --git a/backend/src/test/java/org/juniortown/backend/post/service/HomePageServiceTest.java b/backend/src/test/java/org/juniortown/backend/post/service/HomePageServiceTest.java new file mode 100644 index 0000000..7c14994 --- /dev/null +++ b/backend/src/test/java/org/juniortown/backend/post/service/HomePageServiceTest.java @@ -0,0 +1,195 @@ +package org.juniortown.backend.post.service; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +import org.juniortown.backend.comment.repository.CommentRepository; +import org.juniortown.backend.like.repository.LikeRepository; +import org.juniortown.backend.post.dto.response.HomePageResponse; +import org.juniortown.backend.post.dto.response.HomePageSection; +import org.juniortown.backend.post.dto.response.IdCount; +import org.juniortown.backend.post.dto.response.PostForHomePage; +import org.juniortown.backend.post.entity.Category; +import org.juniortown.backend.post.entity.Post; +import org.juniortown.backend.post.repository.PostRepository; + +import org.juniortown.backend.util.TestBuilders; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.*; +import org.springframework.test.context.ActiveProfiles; + +// { +// "sections": [ +// { +// "category": "question", +// "items": [ +// { +// "postId": 21, +// "title": "title20", +// "userId": 1, +// "nickname": "testUser", +// "likeCount": 0, +// "commentCount": 0, +// "readCount": 0, +// "createdAt": "2025-08-31T07:11:39.535671" +// }, +// +// ] +// }, +// { +// "category": "study", +// "items": [ +// { +// "postId": 22, +// "title": "title21", +// "userId": 1, +// "nickname": "testUser", +// "likeCount": 0, +// "commentCount": 0, +// "readCount": 0, +// "createdAt": "2025-08-31T07:11:39.539753" +// } +// ] +// }, +// { +// "category": "notice", +// "items": [ +// { +// "postId": 23, +// "title": "title22", +// "userId": 1, +// "nickname": "testUser", +// "likeCount": 0, +// "commentCount": 0, +// "readCount": 0, +// "createdAt": "2025-08-31T07:11:39.544494" +// } +// "createdAt": "2025-08-31T16:11:54.634256" +// } +@ExtendWith(MockitoExtension.class) +@ActiveProfiles("test") +public class HomePageServiceTest { + @InjectMocks + private PostService postService; + @Mock + private PostRepository postRepository; + @Mock + private LikeRepository likeRepository; + @Mock + private CommentRepository commentRepository; + @Mock + private ReadCountRedisService readCountRedisService; + + + private IdCount idCount(long postId, long cnt) { + return new IdCount() { + @Override + public Long getPostId() { return postId; } + @Override + public Long getCount() { return cnt; } + }; + } + + @Test + @DisplayName("메인 페이지 조회 성공") + void get_main_page_success() { + int perCategory = 2; + long userId = 1L; + LocalDateTime now = LocalDateTime.now(); + + Category[] cats = Category.values(); + Category C1 = cats[0]; + Category C2 = cats[1]; + + List c1Posts = List.of( + TestBuilders.post().id(101L).user(TestBuilders.user().id(1L).name("user1").build()) + .title("Q1").category(C1).readCount(5).createdAt(now.minusMinutes(1)).build(), + TestBuilders.post().id(102L).user(TestBuilders.user().id(2L).name("user2").build()) + .title("Q2").category(C1).readCount(5).createdAt(now.minusMinutes(2)).build() + ); + List c2Posts = List.of( + TestBuilders.post().id(201L).user(TestBuilders.user().id(3L).name("user3").build()) + .title("S1").category(C2).readCount(10).createdAt(now.minusMinutes(3)).build(), + TestBuilders.post().id(202L).user(TestBuilders.user().id(4L).name("user4").build()) + .title("S2").category(C2).readCount(11).createdAt(now.minusMinutes(4)).build() + ); + // 1) 기본 스텁: 모든 카테고리는 빈 페이지 반환 + when(postRepository.findByCategoryAndDeletedAtIsNull(any(Category.class), any(Pageable.class))) + .thenReturn(new PageImpl<>(List.of())); + + // Page 목킹 (count 쿼리까지는 신경 안 써도 됨), 기본 스텁을 덮어씌움. + when(postRepository.findByCategoryAndDeletedAtIsNull(eq(C1), any(Pageable.class))) + .thenReturn(new PageImpl<>(c1Posts)); + when(postRepository.findByCategoryAndDeletedAtIsNull(eq(C2), any(Pageable.class))) + .thenReturn(new PageImpl<>(c2Posts)); + + List allIds = List.of(101L, 102L, 201L, 202L); + when(readCountRedisService.getReadDeltaFromRedis(allIds)).thenReturn(Map.of( + 101L, 1L, + 102L, 2L + )); + when(commentRepository.countByPostIds(allIds)) + .thenReturn(List.of(idCount(101, 7), idCount(201, 3))); + when(likeRepository.countByPostIds(allIds)) + .thenReturn(List.of(idCount(101, 2), idCount(102, 4), idCount(201, 1), idCount(202, 0))); + + // 실행 + HomePageResponse resp = postService.homePagePosts(perCategory); + + // 검증 + assertNotNull(resp); + assertNotNull(resp.getSections()); + assertTrue(resp.getSections().size() >= 2); + + // 왜 cat[0]에 있는 값이랑 반환값의 첫번째 category가 같은건지 이해하기 + HomePageSection s0 = resp.getSections().get(0); + assertEquals(cats[0].name().toLowerCase(), s0.getCategory()); + assertEquals(2, s0.getItems().size()); + PostForHomePage p0 = s0.getItems().get(0); + assertEquals(101L, p0.getPostId()); + assertEquals(2L, p0.getLikeCount()); + assertEquals(7L, p0.getCommentCount()); // hit + assertEquals(6L, p0.getReadCount()); + + PostForHomePage p1 = s0.getItems().get(1); + assertEquals(102L, p1.getPostId()); + assertEquals(4L, p1.getLikeCount()); + assertEquals(0L, p1.getCommentCount()); // 누락 → 0 폴백 + assertEquals(7L, p1.getReadCount()); + + // Pageable 전달 검증 + ArgumentCaptor pr = ArgumentCaptor.forClass(PageRequest.class); + verify(postRepository).findByCategoryAndDeletedAtIsNull(eq(C1), pr.capture()); + PageRequest used = pr.getValue(); + assertEquals(perCategory, used.getPageSize()); + List orderList = used.getSort().toList(); + assertEquals("createdAt", orderList.get(0).getProperty()); + assertTrue(orderList.get(0).isDescending()); + assertEquals("id", orderList.get(1).getProperty()); + assertTrue(orderList.get(1).isDescending()); + } + + @Test + void when_empty_category_returns_empty_array_and_no_repository_call() { + // 모든 카테고리에서 빈 Page 반환 + when(postRepository.findByCategoryAndDeletedAtIsNull(any(), any(PageRequest.class))) + .thenReturn(new PageImpl(List.of())); + + HomePageResponse resp = postService.homePagePosts(5); + + assertNotNull(resp); + assertEquals(0, resp.getSections().size()); + verify(commentRepository, never()).countByPostIds(anyList()); + verify(likeRepository, never()).countByPostIds(anyList()); + } +} diff --git a/backend/src/test/java/org/juniortown/backend/post/service/PostServiceTest.java b/backend/src/test/java/org/juniortown/backend/post/service/PostServiceTest.java index 36ce536..2fad635 100644 --- a/backend/src/test/java/org/juniortown/backend/post/service/PostServiceTest.java +++ b/backend/src/test/java/org/juniortown/backend/post/service/PostServiceTest.java @@ -1,7 +1,6 @@ package org.juniortown.backend.post.service; import static org.assertj.core.api.Assertions.*; -import static org.juniortown.backend.util.TestDataUtil.*; import static org.mockito.Mockito.*; import java.time.Clock; @@ -12,9 +11,11 @@ import org.juniortown.backend.comment.repository.CommentRepository; import org.juniortown.backend.like.repository.LikeRepository; import org.juniortown.backend.post.dto.request.PostCreateRequest; +import org.juniortown.backend.post.dto.request.PostEditRequest; import org.juniortown.backend.post.dto.response.PostDetailResponse; import org.juniortown.backend.post.dto.response.PostResponse; import org.juniortown.backend.post.dto.response.PostWithLikeCountProjection; +import org.juniortown.backend.post.entity.Category; import org.juniortown.backend.post.entity.Post; import org.juniortown.backend.post.exception.PostNotFoundException; import org.juniortown.backend.post.exception.PostUpdatePermissionDeniedException; @@ -22,6 +23,7 @@ import org.juniortown.backend.user.entity.User; import org.juniortown.backend.user.exception.UserNotFoundException; import org.juniortown.backend.user.repository.UserRepository; +import org.juniortown.backend.util.TestBuilders; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -50,7 +52,7 @@ class PostServiceTest { @Mock private LikeRepository likeRepository; @Mock - private ViewCountService viewCountService; + private ReadCountRedisService readCountRedisService; @Mock private CommentRepository commentRepository; @Mock @@ -72,6 +74,7 @@ void createPost_success() { PostCreateRequest postCreateRequest = PostCreateRequest.builder() .title("테스트 게시물") .content("테스트 내용입니다.") + .category(Category.NOTICE.name()) .build(); long userId = 1L; // 예시로 사용될 userId @@ -144,7 +147,7 @@ void update_post_success() { Long postId = 1L; Long userId = 1L; - PostCreateRequest editRequest = PostCreateRequest.builder() + PostEditRequest editRequest = PostEditRequest.builder() .title("Changed Title") .content("Changed Content") .build(); @@ -156,7 +159,7 @@ void update_post_success() { postService.update(postId, userId, editRequest); // then - verify(post).update(editRequest, clock); + verify(post).update(editRequest.getTitle(), editRequest.getContent()); } @Test @@ -166,7 +169,7 @@ void update_post_not_found_failure() { Long postId = 1L; Long userId = 1L; - PostCreateRequest editRequest = PostCreateRequest.builder() + PostEditRequest editRequest = PostEditRequest.builder() .title("Changed Title") .content("Changed Content") .build(); @@ -186,7 +189,7 @@ void update_post_no_permission_failure() { Long postId = 1L; Long userId = 1L; - PostCreateRequest editRequest = PostCreateRequest.builder() + PostEditRequest editRequest = PostEditRequest.builder() .title("Changed Title") .content("Changed Content") .build(); @@ -207,8 +210,10 @@ void update_post_no_permission_failure() { void getPosts_returnsMappedPage() { // given int page = 0; + int pageSize = 10; Sort sort = Sort.by(Sort.Order.desc("createdAt"), Sort.Order.desc("id")); PageRequest expectedPageable = PageRequest.of(page, PAGE_SIZE, sort); + Category category = Category.NOTICE; PostWithLikeCountProjection post1 = mock(PostWithLikeCountProjection.class); when(post1.getTitle()).thenReturn("TA"); @@ -220,13 +225,13 @@ void getPosts_returnsMappedPage() { List projections = List.of(post1, post2); PageImpl mockPage = new PageImpl<>(projections, expectedPageable, totalElements); when(user.getId()).thenReturn(1L); - when(postRepository.findAllWithLikeCountForUser(user.getId(), expectedPageable)).thenReturn(mockPage); + when(postRepository.findAllWithLikeCountForUser(user.getId(), expectedPageable, category)).thenReturn(mockPage); when(redisTemplate.opsForValue()).thenReturn(readCountValueOperations); // when - Page result = postService.getPosts(user.getId(), page); + Page result = postService.getPosts(user.getId(), page, pageSize, category); // then - verify(postRepository).findAllWithLikeCountForUser(user.getId(), expectedPageable); + verify(postRepository).findAllWithLikeCountForUser(user.getId(), expectedPageable, category); assertThat(result.getTotalElements()).isEqualTo(2); assertThat(result.getSize()).isEqualTo(PAGE_SIZE); @@ -247,8 +252,11 @@ void getPosts_returnsMappedPage() { void getPosts_with_non_user() { // given int page = 0; + int pageSize = 10; Sort sort = Sort.by(Sort.Order.desc("createdAt"), Sort.Order.desc("id")); PageRequest expectedPageable = PageRequest.of(page, PAGE_SIZE, sort); + Category category = Category.NOTICE; + PostWithLikeCountProjection post1 = mock(PostWithLikeCountProjection.class); when(post1.getTitle()).thenReturn("TA"); @@ -259,13 +267,13 @@ void getPosts_with_non_user() { List projections = List.of(post1, post2); PageImpl mockPage = new PageImpl<>(projections, expectedPageable, totalElements); - when(postRepository.findAllWithLikeCountForNonUser(expectedPageable)).thenReturn(mockPage); + when(postRepository.findAllWithLikeCountForNonUser(expectedPageable, category)).thenReturn(mockPage); when(redisTemplate.opsForValue()).thenReturn(readCountValueOperations); // when - Page result = postService.getPosts(null, page); + Page result = postService.getPosts(null, page, pageSize, category); // then - verify(postRepository).findAllWithLikeCountForNonUser(expectedPageable); + verify(postRepository).findAllWithLikeCountForNonUser(expectedPageable, category); assertThat(result.getTotalElements()).isEqualTo(2); assertThat(result.getSize()).isEqualTo(PAGE_SIZE); @@ -286,13 +294,15 @@ void getPosts_with_non_user() { void getPosts_emptyPage() { //given int page = 0; + int pageSize = 10; PageRequest pageable = PageRequest.of(page, PAGE_SIZE, Sort.by(Sort.Order.desc("createdAt"), Sort.Order.desc("id"))); Page emptyPage = Page.empty(pageable); + Category category = Category.NOTICE; when(user.getId()).thenReturn(1L); - when(postRepository.findAllWithLikeCountForUser(user.getId(),pageable)).thenReturn(emptyPage); + when(postRepository.findAllWithLikeCountForUser(user.getId(),pageable,category)).thenReturn(emptyPage); // when - Page result = postService.getPosts(user.getId(), page); + Page result = postService.getPosts(user.getId(), page, pageSize, category); // then assertThat(result.getContent()).isEmpty(); @@ -313,12 +323,18 @@ void getPost_return_pageDetail() { String title = "testTitle"; String content = "testContent"; String name = "testName"; - user = createUser(userId, name, "test@email.com"); - post = createPost(postId, title, content, user); - Comment parentComment1 = createComment(1L, post, user, "부모 댓글1", null); - Comment parentComment2 = createComment(2L, post, user, "부모 댓글2", null); - Comment childComment1 = createComment(3L, post, user, "자식 댓글1", parentComment1); - Comment childComment2 = createComment(4L, post, user, "자식 댓글2", parentComment2); + user = TestBuilders.user().id(userId).name(name).email("test@email.com").build(); + post = TestBuilders.post().id(postId).title(title).content(content).user(user).build(); + Comment parentComment1 = TestBuilders.comment().id(1L).post(post).user(user).content("부모 댓글1").parent(null).build(); + Comment parentComment2 = TestBuilders.comment().id(2L).post(post).user(user).content("부모 댓글2").parent(null).build(); + Comment childComment1 = TestBuilders.comment().id(3L).post(post).user(user).content("자식 댓글1").parent(parentComment1).build(); + Comment childComment2 = TestBuilders.comment() + .id(4L) + .post(post) + .user(user) + .content("자식 댓글2") + .parent(parentComment2) + .build(); // when diff --git a/backend/src/test/java/org/juniortown/backend/post/service/ViewCountServiceTest.java b/backend/src/test/java/org/juniortown/backend/post/service/ReadCountRedisServiceTest.java similarity index 64% rename from backend/src/test/java/org/juniortown/backend/post/service/ViewCountServiceTest.java rename to backend/src/test/java/org/juniortown/backend/post/service/ReadCountRedisServiceTest.java index 295fcd2..ac60ef4 100644 --- a/backend/src/test/java/org/juniortown/backend/post/service/ViewCountServiceTest.java +++ b/backend/src/test/java/org/juniortown/backend/post/service/ReadCountRedisServiceTest.java @@ -11,12 +11,13 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.redis.core.HashOperations; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.core.ValueOperations; @ExtendWith(MockitoExtension.class) -class ViewCountServiceTest { - private ViewCountService viewCountService; +class ReadCountRedisServiceTest { + private ReadCountRedisService readCountRedisService; @Mock private RedisTemplate keyCheckRedisTemplate; @Mock @@ -24,11 +25,11 @@ class ViewCountServiceTest { @Mock private RedisTemplate readCountRedisTemplate; @Mock - private ValueOperations readCountValueOperations; + private HashOperations readCountValueOperations; @BeforeEach public void setUp() { - viewCountService = new ViewCountService(keyCheckRedisTemplate, readCountRedisTemplate); + readCountRedisService = new ReadCountRedisService(keyCheckRedisTemplate, readCountRedisTemplate); } @Test @@ -38,19 +39,19 @@ void incrementViewCount_success() { String userId = "1"; String postId = "1"; String dupKey = "postDup:key:" + postId + ":" + userId; - String readCountKey = "post:viewCount:" + postId; + String readCountKey = ReadCountRedisService.VIEW_COUNT_KEY; when(keyCheckRedisTemplate.opsForValue()).thenReturn(keyCheckValueOperations); - when(readCountRedisTemplate.opsForValue()).thenReturn(readCountValueOperations); + when(readCountRedisTemplate.opsForHash()).thenReturn(readCountValueOperations); when(keyCheckValueOperations.get(dupKey)).thenReturn(null); - when(readCountValueOperations.get(readCountKey)).thenReturn(1L); + when(readCountValueOperations.get(readCountKey, postId)).thenReturn(1L); // when - Long result = viewCountService.readCountUp(userId, postId); + Long result = readCountRedisService.readCountUp(userId, postId); // then Assertions.assertEquals(1L, result); verify(keyCheckValueOperations).set(eq(dupKey), eq(true), eq(10L), eq(TimeUnit.MINUTES)); - verify(readCountRedisTemplate.opsForValue()).increment(readCountKey); + verify(readCountRedisTemplate.opsForHash()).increment(readCountKey, postId, 1); } @Test @@ -60,19 +61,19 @@ void incrementViewCount_fail() { String userId = "1"; String postId = "1"; String dupKey = "postDup:key:" + postId + ":" + userId; - String readCountKey = "post:viewCount:" + postId; + String readCountKey = ReadCountRedisService.VIEW_COUNT_KEY; when(keyCheckRedisTemplate.opsForValue()).thenReturn(keyCheckValueOperations); - when(readCountRedisTemplate.opsForValue()).thenReturn(readCountValueOperations); + when(readCountRedisTemplate.opsForHash()).thenReturn(readCountValueOperations); when(keyCheckValueOperations.get(dupKey)).thenReturn(true); - when(readCountValueOperations.get(readCountKey)).thenReturn(0L); + when(readCountValueOperations.get(readCountKey, postId)).thenReturn(0L); // when - Long result = viewCountService.readCountUp(userId, postId); + Long result = readCountRedisService.readCountUp(userId, postId); // then Assertions.assertEquals(0L, result); verify(keyCheckValueOperations, never()).set(eq(dupKey), eq(true), eq(10L), eq(TimeUnit.MINUTES)); - verify(readCountValueOperations, never()).increment(readCountKey); - verify(readCountRedisTemplate.opsForValue()).get(readCountKey); + verify(readCountValueOperations, never()).increment(readCountKey, postId,1); + verify(readCountRedisTemplate.opsForHash()).get(readCountKey, postId); } } \ No newline at end of file diff --git a/backend/src/test/java/org/juniortown/backend/util/TestBuilders.java b/backend/src/test/java/org/juniortown/backend/util/TestBuilders.java new file mode 100644 index 0000000..bd2a72b --- /dev/null +++ b/backend/src/test/java/org/juniortown/backend/util/TestBuilders.java @@ -0,0 +1,98 @@ +package org.juniortown.backend.util; + +import java.time.LocalDateTime; + +import org.juniortown.backend.comment.entity.Comment; +import org.juniortown.backend.post.entity.Category; +import org.juniortown.backend.post.entity.Post; +import org.juniortown.backend.user.entity.User; +import org.springframework.test.util.ReflectionTestUtils; + +public class TestBuilders { + public static class UserBuilder { + private Long id = 1L; + private String name = "user"; + private String email = "user@example.com"; + private String password = "password"; + + public UserBuilder id(Long id) { this.id = id; return this; } + public UserBuilder name(String name) { this.name = name; return this; } + public UserBuilder email(String email) { this.email = email; return this; } + + public User build() { + User u = User.builder() + .name(name) + .email(email) + .password(password) + .build(); + ReflectionTestUtils.setField(u, "id", id); + return u; + } + } + public static UserBuilder user() { return new UserBuilder(); } + + public static class PostBuilder { + private Long id = 100L; + private String title = "title"; + private String content = "content"; + private User user = TestBuilders.user().build(); + private Category category = Category.QUESTION; + private long readCount = 0L; + private LocalDateTime createdAt = LocalDateTime.now(); + + public PostBuilder id(Long id) { this.id = id; return this; } + public PostBuilder title(String title) { this.title = title; return this; } + public PostBuilder content(String content) { this.content = content; return this; } + public PostBuilder user(User user) { this.user = user; return this; } + public PostBuilder category(Category category) { this.category = category; return this; } + public PostBuilder readCount(long readCount) { this.readCount = readCount; return this; } + public PostBuilder createdAt(LocalDateTime createdAt) { this.createdAt = createdAt; return this; } + + public Post build() { + Post p = Post.builder() + .title(title) + .content(content) + .user(user) + .category(category) + .build(); + ReflectionTestUtils.setField(p, "id", id); + ReflectionTestUtils.setField(p, "readCount", readCount); + ReflectionTestUtils.setField(p, "createdAt", createdAt); + return p; + } + } + public static PostBuilder post() { return new PostBuilder(); } + + + public static class CommentBuilder { + private Long id = 1000L; + private Post post = TestBuilders.post().build(); + private User user = TestBuilders.user().build(); + private String username = "user"; + private String content = "comment"; + private Comment parent = null; + private LocalDateTime createdAt = LocalDateTime.now(); + + public CommentBuilder id(Long id) { this.id = id; return this; } + public CommentBuilder post(Post post) { this.post = post; return this; } + public CommentBuilder user(User user) { this.user = user; this.username = user.getName(); return this; } + public CommentBuilder username(String username) { this.username = username; return this; } + public CommentBuilder content(String content) { this.content = content; return this; } + public CommentBuilder parent(Comment parent) { this.parent = parent; return this; } + public CommentBuilder createdAt(LocalDateTime createdAt) { this.createdAt = createdAt; return this; } + + public Comment build() { + Comment c = Comment.builder() + .post(post) + .user(user) + .username(username) + .content(content) + .parent(parent) + .build(); + ReflectionTestUtils.setField(c, "id", id); + ReflectionTestUtils.setField(c, "createdAt", createdAt); + return c; + } + } + public static CommentBuilder comment() { return new CommentBuilder(); } +} diff --git a/backend/src/test/java/org/juniortown/backend/util/TestDataUtil.java b/backend/src/test/java/org/juniortown/backend/util/TestDataUtil.java deleted file mode 100644 index 08b1c9c..0000000 --- a/backend/src/test/java/org/juniortown/backend/util/TestDataUtil.java +++ /dev/null @@ -1,38 +0,0 @@ -package org.juniortown.backend.util; - -import org.juniortown.backend.comment.entity.Comment; -import org.juniortown.backend.post.entity.Post; -import org.juniortown.backend.user.entity.User; -import org.springframework.test.util.ReflectionTestUtils; - -public class TestDataUtil { - public static Comment createComment(Long id, Post post, User user, String content, Comment parentComment) { - Comment comment = Comment.builder() - .post(post) - .user(user) - .username(user.getName()) - .content(content) - .parent(parentComment) - .build(); - ReflectionTestUtils.setField(comment, "id", id); - return comment; - } - public static Post createPost(Long id,String title,String content, User user) { - Post post = Post.builder() - .title(title) - .content(content) - .user(user) - .build(); - ReflectionTestUtils.setField(post, "id", id); - return post; - } - public static User createUser(Long id, String name,String email) { - User user = User.builder() - .name(name) - .password("password") - .email(email) - .build(); - ReflectionTestUtils.setField(user, "id", id); - return user; - } -} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 37d78a1..a1ff017 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -21,7 +21,7 @@ const App = () => { } /> } /> } /> - } /> + } /> } /> diff --git a/frontend/src/pages/Home.jsx b/frontend/src/pages/Home.jsx index 513a0a1..4b70c83 100644 --- a/frontend/src/pages/Home.jsx +++ b/frontend/src/pages/Home.jsx @@ -1,8 +1,269 @@ -const Home = () => { +// src/pages/Home.jsx +import { useEffect, useState, useMemo } from 'react'; +import axios from 'axios'; +import { Link } from 'react-router-dom'; +import { + Container, Row, Col, Card, ListGroup, Button, Placeholder, Alert, Badge, OverlayTrigger, Tooltip, +} from 'react-bootstrap'; + +// ───────────────────────────────────────────────────────────────────────────── +// 서버 응답 (예시) +// { "sections": [ { "category": "question", "items": [ { "postId": 2, ... } ] }, ... ] } +// ───────────────────────────────────────────────────────────────────────────── + +// 카테고리 메타: 라벨/경로/아이콘 +const CATEGORY_META = { + QUESTION: { label: '질문', path: '/posts/category/QUESTION', icon: '❓' }, + STUDY: { label: '스터디', path: '/posts/category/STUDY', icon: '📚' }, + NOTICE: { label: '공지', path: '/posts/category/NOTICE', icon: '📢' }, + SECOND_HAND_MARKET: { label: '중고장터', path: '/posts/category/SECOND_HAND_MARKET', icon: '💱' }, + MENTOR: { label: '멘토', path: '/posts/category/MENTOR', icon: '🧭' }, +}; + +// 서버 카테고리 문자열 → 내부 Enum 키로 정규화 +const CATEGORY_NORMALIZE = { + question: 'QUESTION', + study: 'STUDY', + notice: 'NOTICE', + second_hand_market: 'SECOND_HAND_MARKET', + mentor: 'MENTOR', +}; + +// 표시 순서 고정 +const CATEGORY_ORDER = ['QUESTION', 'STUDY', 'NOTICE', 'SECOND_HAND_MARKET', 'MENTOR']; + +// 날짜 포맷터 +const fmtDate = (iso) => + iso + ? new Date(iso).toLocaleString('ko-KR', { + year: '2-digit', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }) + : ''; + +// 서버 payload → 화면에서 쓰기 좋은 형태로 1회 변환 +function normalizeSections(serverSections = []) { + const base = CATEGORY_ORDER.reduce((acc, k) => { + acc[k] = []; + return acc; + }, {}); + for (const sec of serverSections) { + const enumKey = CATEGORY_NORMALIZE[sec?.category] ?? null; + if (!enumKey) continue; + const mappedItems = (sec?.items ?? []).map((p) => ({ + id: p.postId, + title: p.title, + authorName: p.nickname, + createdAt: p.createdAt, + readCount: p.readCount ?? 0, + commentCount: p.commentCount ?? 0, + likeCount: p.likeCount ?? 0, + userId: p.userId, + })); + base[enumKey] = mappedItems; + } + return base; +} + +// 공통: 숫자 포맷 +const fmtNumber = (n) => + typeof n === 'number' ? n.toLocaleString?.('ko-KR') ?? `${n}` : '0'; + +// 빈 상태(Empty State) 컴포넌트 +function EmptyState({ title, description, ctaText, to }) { return ( -
-

홈 화면에 오신 걸 환영합니다

+
+
🗒️
+
{title}
+
{description}
+ {to && ( + + )}
); -}; -export default Home; \ No newline at end of file +} + +// 메타 뱃지 묶음 (좋아요/조회/댓글) +function MetaBadges({ likeCount, readCount, commentCount }) { + return ( +
+ 좋아요}> + {`👍 ${fmtNumber(likeCount)}`} + + 조회수}> + {`👁️ ${fmtNumber(readCount)}`} + + 댓글}> + {`💬 ${fmtNumber(commentCount)}`} + +
+ ); +} + +// 개별 섹션 +function CategorySection({ category, items, loading }) { + const meta = CATEGORY_META[category]; + + // 헤더 스타일(부드러운 그라데이션 + 얇은 구분선) + const headerStyle = { + background: + 'linear-gradient(180deg, rgba(250,250,252,1) 0%, rgba(245,246,248,1) 100%)', + }; + + return ( + + +
+ + {meta.label} +
+ +
+ + {loading ? ( + + {Array.from({ length: 5 }).map((_, i) => ( + + +
+ + +
+ ))} +
+ ) : (items?.length ?? 0) === 0 ? ( + // to를 수정해야 함. 글 작성 페이지는 이 URL이 아님. + + ) : ( + + {items.map((post) => ( + (e.currentTarget.style.backgroundColor = 'rgba(33,37,41,0.025)')} + onMouseLeave={(e) => (e.currentTarget.style.backgroundColor = 'transparent')} + onMouseDown={(e) => (e.currentTarget.style.transform = 'scale(0.995)')} + onMouseUp={(e) => (e.currentTarget.style.transform = 'scale(1)')} + > + {/* 제목만 링크가 되도록 title 영역만 Link로 감쌈 */} +
+
+ +
{post.title}
+ +
+ {post.authorName ?? '익명'} · {fmtDate(post.createdAt)} +
+
+
+ +
+
+
+ ))} +
+ )} +
+ ); +} + +export default function Home() { + const [sections, setSections] = useState(null); // { QUESTION: [], STUDY: [], ... } + const [loading, setLoading] = useState(true); + const [err, setErr] = useState(null); + + const ordered = useMemo(() => CATEGORY_ORDER, []); + + useEffect(() => { + const ctrl = new AbortController(); + setLoading(true); + setErr(null); + + axios + .get('/api/posts/home', { params: { limit: 5 }, signal: ctrl.signal }) + .then((res) => { + const serverSections = res.data?.sections ?? []; + const normalized = normalizeSections(serverSections); + setSections(normalized); + }) + .catch((e) => { + if (axios.isCancel(e)) return; + setErr('홈 데이터를 불러오는 중 문제가 발생했습니다.'); + }) + .finally(() => setLoading(false)); + + return () => ctrl.abort(); + }, []); + + return ( + + {err && {err}} + + {/* 상단 히어로/서브헤더 영역(선택) */} + + +
+
JuniorTown
+
최신 글을 한눈에 모아보세요.
+
+
+ + +
+
+
+ + {/* 그리드 */} + + {ordered.map((cat) => ( + + + + ))} + +
+ ); +} diff --git a/frontend/src/pages/posts/PostCreatePage.jsx b/frontend/src/pages/posts/PostCreatePage.jsx index 8f067ac..1f2422a 100644 --- a/frontend/src/pages/posts/PostCreatePage.jsx +++ b/frontend/src/pages/posts/PostCreatePage.jsx @@ -1,72 +1,169 @@ -import { useState } from 'react'; -import { useNavigate } from 'react-router-dom'; -import { Button } from 'react-bootstrap'; -import axios from 'axios'; // axios를 import -import { useAuth } from '../../auth/AuthContext.jsx'; // 인증 컨텍스트 임포트 +// src/pages/PostCreatePage.jsx +import { useEffect, useMemo, useState } from 'react'; +import { useNavigate, useSearchParams, Link } from 'react-router-dom'; +import { Button, Nav, Card, Badge } from 'react-bootstrap'; +import axios from 'axios'; +import { useAuth } from '../../auth/AuthContext.jsx'; + +// 1) 카테고리 메타 & 순서 +const CATEGORY_META = { + QUESTION: { label: '질문', icon: '❓' }, + STUDY: { label: '스터디', icon: '📚' }, + NOTICE: { label: '공지', icon: '📢' }, + SECOND_HAND_MARKET: { label: '중고장터', icon: '💱' }, + MENTOR: { label: '멘토', icon: '🧭' }, +}; +const CATEGORY_ORDER = ['QUESTION', 'STUDY', 'NOTICE', 'SECOND_HAND_MARKET', 'MENTOR']; + +// 2) 쿼리스트링(category) → 내부 Enum 키로 정규화 +function normalizeCategoryFromQuery(raw) { + if (!raw) return null; + const upper = String(raw).toUpperCase(); + if (CATEGORY_META[upper]) return upper; + const lowerMap = { + question: 'QUESTION', + study: 'STUDY', + notice: 'NOTICE', + second_hand_market: 'SECOND_HAND_MARKET', + mentor: 'MENTOR', + }; + return lowerMap[String(raw).toLowerCase()] ?? null; +} const PostCreatePage = () => { const navigate = useNavigate(); - const { token } = useAuth(); // 인증 토큰 가져오기 + const { token } = useAuth(); + const [searchParams] = useSearchParams(); - // ① 제목과 내용을 담을 상태 변수 선언 + // 3) 폼 상태 + const [category, setCategory] = useState('QUESTION'); // 기본값 const [title, setTitle] = useState(''); const [content, setContent] = useState(''); + const [submitting, setSubmitting] = useState(false); - // ② 폼 제출(버튼 클릭) 핸들러 + // 4) URL 쿼리(category)로 초기 선택 반영 (예: /posts/new?category=STUDY) + useEffect(() => { + const fromQuery = normalizeCategoryFromQuery(searchParams.get('category')); + if (fromQuery) setCategory(fromQuery); + }, [searchParams]); + + const canSubmit = useMemo(() => { + return Boolean(title.trim()) && Boolean(content.trim()) && Boolean(category) && !submitting; + }, [title, content, category, submitting]); + + // 5) 제출 핸들러 const handleSubmit = async () => { - const postData = { title, content }; + if (!token) { + alert('로그인이 필요합니다.'); + navigate('/login', { replace: true }); + return; + } + if (!canSubmit) return; - try { - if (!token) { - alert('로그인이 필요합니다.'); - navigate('/login', { replace: true }); - return; - } - const response = await axios.post('/api/posts', postData, { + const postData = { title: title.trim(), content: content.trim(), category }; - }); + try { + setSubmitting(true); - // const result = response.data; + const response = await axios.post('/api/posts', postData); - navigate('/posts', { replace: true }); + // 생성 후: 홈으로 이동 + // 서버가 생성된 게시글 id를 반환한다면 다음처럼 상세로 보내도 됨: + // const createdId = response?.data?.id; + // if (createdId) return navigate(`/posts/${createdId}`, { replace: true }); + navigate(`/`, { replace: true }); } catch (error) { console.error('게시물 작성 중 오류 발생:', error); alert('게시물 등록에 실패했습니다. 다시 시도해 주세요.'); + } finally { + setSubmitting(false); } }; return (
-

게시물을 작성해보자!

- - {/* 제목 입력란 */} -
- setTitle(e.target.value)} // ③ 입력 시 state 업데이트 - /> -
- - {/* 내용 입력란 */} -
-