diff --git a/src/main/java/dev/woori/woorilog/domain/blog/controller/BlogController.java b/src/main/java/dev/woori/woorilog/domain/blog/controller/BlogController.java index 50225d4..8b783fa 100644 --- a/src/main/java/dev/woori/woorilog/domain/blog/controller/BlogController.java +++ b/src/main/java/dev/woori/woorilog/domain/blog/controller/BlogController.java @@ -5,10 +5,13 @@ import dev.woori.woorilog.domain.blog.dto.response.BlogDetailInfoRes; import dev.woori.woorilog.domain.blog.dto.response.BlogHomeRes; import dev.woori.woorilog.domain.blog.service.BlogService; +import dev.woori.woorilog.domain.blog.service.ViewCountService; import dev.woori.woorilog.global.resolver.UserId; import dev.woori.woorilog.global.response.ApiResponseUtil; import dev.woori.woorilog.global.response.BaseResponse; import dev.woori.woorilog.global.response.SuccessCode; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; @@ -23,6 +26,7 @@ public class BlogController { private final BlogService blogService; + private final ViewCountService viewCountService; // 홈화면 블로그 목록 조회 @GetMapping("/blog/home") @@ -47,10 +51,16 @@ public ResponseEntity> createBlog( @GetMapping("/blog/{postId}") public ResponseEntity> getBlogInfo( @UserId Optional userId, - @PathVariable("postId") Long postId + @PathVariable("blogId") Long blogId, + HttpServletRequest request, + HttpServletResponse response ) { - BlogDetailInfoRes res = blogService.getBlogInfo(userId, postId); - return ApiResponseUtil.success(SuccessCode.OK, res); + // 조회수 증가 + boolean shouldIncreaseViewCount = viewCountService.shouldIncreaseViewCount(blogId, request); + if (shouldIncreaseViewCount) { + viewCountService.increaseViewCount(blogId, request, response); + } + return ApiResponseUtil.success(SuccessCode.OK, blogService.getBlogInfo(userId, blogId, shouldIncreaseViewCount)); } // 블로그 수정 diff --git a/src/main/java/dev/woori/woorilog/domain/blog/dto/BlogBasicInfoDto.java b/src/main/java/dev/woori/woorilog/domain/blog/dto/BlogBasicInfoDto.java index 7cb08c5..48c995e 100644 --- a/src/main/java/dev/woori/woorilog/domain/blog/dto/BlogBasicInfoDto.java +++ b/src/main/java/dev/woori/woorilog/domain/blog/dto/BlogBasicInfoDto.java @@ -21,6 +21,7 @@ public record BlogBasicInfoDto( Category category, List tags, List progresses, + Long viewCount, LocalDateTime createdAt, LocalDateTime updatedAt ) { @@ -36,6 +37,7 @@ public static BlogBasicInfoDto create(Blog blog) { .category(blog.getCategory()) .tags(blog.getTags()) .progresses(toProgressDtoList(blog.getProgresses())) + .viewCount(blog.getViewCount()) .createdAt(blog.getCreatedAt()) .updatedAt(blog.getUpdatedAt()) .build(); diff --git a/src/main/java/dev/woori/woorilog/domain/blog/dto/BlogDto.java b/src/main/java/dev/woori/woorilog/domain/blog/dto/BlogDto.java index 22c7389..183fb94 100644 --- a/src/main/java/dev/woori/woorilog/domain/blog/dto/BlogDto.java +++ b/src/main/java/dev/woori/woorilog/domain/blog/dto/BlogDto.java @@ -16,6 +16,7 @@ public record BlogDto( Category category, String document, List progresses, + Long viewCount, LocalDateTime createdAt, LocalDateTime updatedAt ) { @@ -28,6 +29,7 @@ public static BlogDto create( .category(blog.getCategory()) .document(blog.getDocument()) .progresses(progressToDto(blog.getProgresses())) + .viewCount(blog.getViewCount()) .createdAt(blog.getCreatedAt()) .updatedAt(blog.getUpdatedAt()) .build(); diff --git a/src/main/java/dev/woori/woorilog/domain/blog/entity/Blog.java b/src/main/java/dev/woori/woorilog/domain/blog/entity/Blog.java index 201c5c8..767cde9 100644 --- a/src/main/java/dev/woori/woorilog/domain/blog/entity/Blog.java +++ b/src/main/java/dev/woori/woorilog/domain/blog/entity/Blog.java @@ -46,6 +46,9 @@ public class Blog extends BaseEntity { @CollectionTable(name = "tags", joinColumns = @JoinColumn(name = "blog_id")) private List tags; + @Builder.Default + private Long viewCount = 0L; + @OneToMany( mappedBy = "blog", fetch = FetchType.LAZY, diff --git a/src/main/java/dev/woori/woorilog/domain/blog/repository/BlogRepository.java b/src/main/java/dev/woori/woorilog/domain/blog/repository/BlogRepository.java index b8884cf..d1f1eb0 100644 --- a/src/main/java/dev/woori/woorilog/domain/blog/repository/BlogRepository.java +++ b/src/main/java/dev/woori/woorilog/domain/blog/repository/BlogRepository.java @@ -4,9 +4,9 @@ import dev.woori.woorilog.domain.blog.entity.Blog; import dev.woori.woorilog.domain.project.entity.Project; import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; @@ -29,14 +29,22 @@ public interface BlogRepository extends JpaRepository { @Query("SELECT b FROM Blog b JOIN FETCH b.member JOIN FETCH b.project LEFT JOIN FETCH b.progresses WHERE b.id = :id") Optional findBlogByIdWithDetails(@Param("id") Long id); - @Query("SELECT DISTINCT b FROM Blog b " + + @Query("SELECT b FROM Blog b LEFT JOIN FETCH b.tags WHERE b.id = :id") + void findBlogByIdWithTags(@Param("id") Long id); + + @Query("SELECT b.id FROM Blog b " + + "WHERE b.category != 'CHECKPOINT' " + ) + Page findBlogIds(Pageable pageable); + + @Query("SELECT DISTINCT b " + + "FROM Blog b " + "LEFT JOIN FETCH b.progresses " + "JOIN FETCH b.member " + "JOIN FETCH b.project " + - "WHERE b.category != 'CHECKPOINT' " + - "ORDER BY b.createdAt DESC " - ) - List findTopOrderByCreatedAtDesc(Pageable pageable); + "WHERE b.id IN :ids " + + "ORDER BY b.createdAt DESC") + List findBlogsWithDetailsByIds(@Param("ids") List ids); @Query("SELECT b.project.id as projectId, COUNT(b.id) as blogCount " + "FROM Blog b " + @@ -44,4 +52,8 @@ public interface BlogRepository extends JpaRepository { "GROUP BY b.project.id" ) List findBlogCountsByProjects(@Param("projects") List projects); + + @Modifying(clearAutomatically = true) + @Query("UPDATE Blog b SET b.viewCount = b.viewCount + 1 WHERE b.id = :id") + void increaseViewCount(@Param("id") Long id); } diff --git a/src/main/java/dev/woori/woorilog/domain/blog/service/BlogService.java b/src/main/java/dev/woori/woorilog/domain/blog/service/BlogService.java index 8cbbee3..ab963da 100644 --- a/src/main/java/dev/woori/woorilog/domain/blog/service/BlogService.java +++ b/src/main/java/dev/woori/woorilog/domain/blog/service/BlogService.java @@ -17,6 +17,7 @@ import dev.woori.woorilog.global.cache.CacheNames; import jakarta.persistence.EntityNotFoundException; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.cache.annotation.CacheEvict; import org.springframework.cache.annotation.Cacheable; import org.springframework.data.domain.Page; @@ -31,6 +32,7 @@ import static dev.woori.woorilog.global.response.error.ErrorMessage.*; +@Slf4j @Service @RequiredArgsConstructor @Transactional(readOnly = true) @@ -73,14 +75,24 @@ public Long createBlog(Long projectId, Long userId, BlogCreateOrUpdateReq reques * 글을 열람하기 위한 글과 작성자, 작성 프로젝트 응답을 생성합니다. * 하위 DTO를 생성하고 조립한 응답을 생성해 리턴합니다. * - * @param postId 열람할 글 id + * @param blogId 열람할 글 id * @return BlogDetailInfoRes: 열람할 글, 작성자, 작성 프로젝트의 정보 */ @Transactional - public BlogDetailInfoRes getBlogInfo(Optional memberId, Long postId) { - Blog blog = blogRepository.findBlogByIdWithDetails(postId) + public BlogDetailInfoRes getBlogInfo(Optional memberId, Long blogId, boolean shouldIncreaseViewCount) { + + // 24시간 이내 방문한 적이 없다면 조회수 증가 + if (shouldIncreaseViewCount) { + blogRepository.increaseViewCount(blogId); + } + + // MultipleBagFetchException 방지를 위한 1차 조회 쿼리 (Progresses) + Blog blog = blogRepository.findBlogByIdWithDetails(blogId) .orElseThrow(() -> new EntityNotFoundException(BLOG_NOT_FOUND)); + // MultipleBagFetchException 방지를 위한 2차 조회 쿼리 (Tags) + blogRepository.findBlogByIdWithTags(blogId); + Project project = blog.getProject(); Member author = blog.getMember(); boolean isAuthor = checkAuthor(memberId, author.getId()); @@ -100,15 +112,19 @@ public BlogDetailInfoRes getBlogInfo(Optional memberId, Long postId) { */ @Cacheable(value = CacheNames.HOME_BLOGS) public BlogHomeRes getBlogBasicInfos(int page) { - Page blogPage = blogRepository.findAll( - PageRequest.of(page - 1, BLOG_PAGE_SIZE, Sort.by(SORT_CRITERIA).descending()) + PageRequest pageRequest = PageRequest.of( + page - 1, BLOG_PAGE_SIZE, Sort.by(SORT_CRITERIA).descending() ); - List blogBasicInfoDtoList = blogPage.stream() + Page blogIds = blogRepository.findBlogIds(pageRequest); + List blogsWithDetailsByIds = blogRepository.findBlogsWithDetailsByIds(blogIds.getContent()); + + + List blogBasicInfoDtoList = blogsWithDetailsByIds.stream() .map(BlogBasicInfoDto::create) .toList(); - return BlogHomeRes.of(blogPage.getNumber() + 1, blogPage.getTotalPages(), blogBasicInfoDtoList); + return BlogHomeRes.of(blogIds.getNumber() + 1, blogIds.getTotalPages(), blogBasicInfoDtoList); } /** diff --git a/src/main/java/dev/woori/woorilog/domain/blog/service/ViewCountService.java b/src/main/java/dev/woori/woorilog/domain/blog/service/ViewCountService.java new file mode 100644 index 0000000..3c8a33b --- /dev/null +++ b/src/main/java/dev/woori/woorilog/domain/blog/service/ViewCountService.java @@ -0,0 +1,48 @@ +package dev.woori.woorilog.domain.blog.service; + +import dev.woori.woorilog.global.util.CookieUtils; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.Optional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class ViewCountService { + + private static final String VIEW_COOKIE_NAME = "postView"; + private static final int COOKIE_MAX_AGE = 24 * 60 * 60; + + public boolean shouldIncreaseViewCount(Long blogId, HttpServletRequest request) { + Optional cookie = CookieUtils.getCookie(request, VIEW_COOKIE_NAME); + + if (cookie.isEmpty()) + return true; + + Cookie originalCookie = cookie.get(); + String originalValue = CookieUtils.getDecodedCookieValue(originalCookie); + String additionalValue = CookieUtils.getCookieValue(String.valueOf(blogId)); + return !CookieUtils.isContainedValue(originalValue, additionalValue); + } + + public void increaseViewCount(Long blogId, HttpServletRequest request, HttpServletResponse response) { + Optional optionalCookie = CookieUtils.getCookie(request, VIEW_COOKIE_NAME); + log.debug("[Cookie] Client Address : {}", request.getRemoteAddr()); + if (optionalCookie.isEmpty()) { + // 새로운 쿠키 추가 + Cookie viewCookie = CookieUtils.createViewCookie(VIEW_COOKIE_NAME, String.valueOf(blogId), COOKIE_MAX_AGE); + response.addCookie(viewCookie); + } else { + // 쿠키 업데이트 + Cookie originalCookie = optionalCookie.get(); + String additionalValue = CookieUtils.getCookieValue(String.valueOf(blogId)); + Cookie updateCookie = CookieUtils.updateCookie(originalCookie, additionalValue, COOKIE_MAX_AGE); + response.addCookie(updateCookie); + } + } +} diff --git a/src/main/java/dev/woori/woorilog/global/util/CookieUtils.java b/src/main/java/dev/woori/woorilog/global/util/CookieUtils.java new file mode 100644 index 0000000..f89e94a --- /dev/null +++ b/src/main/java/dev/woori/woorilog/global/util/CookieUtils.java @@ -0,0 +1,64 @@ +package dev.woori.woorilog.global.util; + +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import lombok.AccessLevel; +import lombok.RequiredArgsConstructor; + +import java.net.URLDecoder; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Optional; + +@RequiredArgsConstructor(access = AccessLevel.PRIVATE) +public class CookieUtils { + + private static final int MAX_COOKIE_SIZE = 3000; + private static final String NAME_PREFIX = "["; + private static final String NAME_POSTFIX = "]"; + + public static Cookie createViewCookie(String name, String value, int cookieMaxAge) { + String cookieValue = getCookieValue(value); + Cookie cookie = new Cookie(name, URLEncoder.encode(cookieValue, StandardCharsets.UTF_8)); + cookie.setMaxAge(cookieMaxAge); + cookie.setPath("/api/blog"); + cookie.setHttpOnly(true); + return cookie; + } + + public static String getCookieValue(String value) { + return NAME_PREFIX + value + NAME_POSTFIX; + } + + public static String getDecodedCookieValue(Cookie cookie) { + return URLDecoder.decode(cookie.getValue(), StandardCharsets.UTF_8); + } + + public static Cookie updateCookie(Cookie oldCookie, String newValue, int cookieMaxAge) { + String decodedValue = getDecodedCookieValue(oldCookie); + String tempValue = decodedValue + "_" + newValue; + // 쿠키 오버플로우 방지 + String updateValue = tempValue.length() <= MAX_COOKIE_SIZE ? + URLEncoder.encode(tempValue, StandardCharsets.UTF_8) : URLEncoder.encode(newValue, StandardCharsets.UTF_8); + + oldCookie.setValue(updateValue); + oldCookie.setMaxAge(cookieMaxAge); + return oldCookie; + } + + public static boolean isContainedValue(String originalValue, String newValue) { + return Arrays.asList(originalValue.split("_")).contains(newValue); + } + + // 쿠키 조회 + public static Optional getCookie(HttpServletRequest request, String cookieName) { + Cookie[] cookies = request.getCookies(); + if (cookies == null) { + return Optional.empty(); + } + return Arrays.stream(cookies) + .filter(cookie -> cookieName.equals(cookie.getName())) + .findFirst(); + } +}