Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: 인기 게시글 기능 구현 완료 #610

Merged
merged 25 commits into from
Dec 15, 2024
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
87a42b7
feat(HotBoard): 핫 게시글 조회 로직 구현
SongJaeHoonn Nov 8, 2024
422d3bd
refactor(HotBoardsRetrievalService): size보다 작은 게시글 전체 반환 로직 위치 수정
SongJaeHoonn Nov 13, 2024
0f158eb
refactor(HotBoards): 파라미터명 통일
SongJaeHoonn Nov 13, 2024
15ba5a1
refactor(HotBoards): size가 음수인지 검증
SongJaeHoonn Nov 14, 2024
5cd56f0
refactor(HotBoards): 부족한 인기 게시글 수량 추가 전략 수정
SongJaeHoonn Nov 14, 2024
d943ffe
refactor(HotBoards): import DayOfWeek
SongJaeHoonn Nov 14, 2024
bab6227
refactor(HotBoards): sortAndLimit 메서드명 변경
SongJaeHoonn Nov 14, 2024
499a16e
refactor(HotBoards): 핫 게시글을 인기 게시글로 변경
SongJaeHoonn Nov 14, 2024
2560a09
refactor(HotBoards): 인기 게시글 선정에 Strategy 패턴 적용
SongJaeHoonn Nov 15, 2024
901ee99
refactor(HotBoards): 인기 게시글 저장에 Redis를 사용하도록 변경
SongJaeHoonn Nov 27, 2024
b8aee5d
Merge branch 'develop' into feat/#607
limehee Nov 27, 2024
bfc564b
refactor(messages): 게시글 크기 관련 메시지 제거
SongJaeHoonn Nov 28, 2024
b7fcbe7
refactor(DefaultHotBoardService): 변수명 변경
SongJaeHoonn Nov 28, 2024
1b9a11d
refactor(HotBoardSelectionStrategyType): enum 이름 변경
SongJaeHoonn Nov 28, 2024
365a2ea
refactor(HotBoardSelectionStrategy): 전략 패턴에서 저장과 조회 로직을 분리
SongJaeHoonn Nov 28, 2024
5492a00
refactor(HotBoardRegisterService): 스케줄링 변경
SongJaeHoonn Nov 28, 2024
97c4ef5
refactor(HotBoardSelectionStrategies): 전략 상수화
SongJaeHoonn Dec 6, 2024
e2a8644
refactor(HotBoardRetrievalController): 인기게시글 조회에 전략을 사용하도록 변경
SongJaeHoonn Dec 13, 2024
dd12766
refactor(RedisHotBoardPersistenceAdapter): 인기게시글 저장 및 조회시 전략 이름을 사용하도…
SongJaeHoonn Dec 13, 2024
405bde6
refactor(RetrieveHotBoardsUseCase): 인기게시글 조회시 전략 이름을 사용하도록 변경
SongJaeHoonn Dec 13, 2024
fe8ed1c
refactor(RetrieveHotBoardPort): 인기게시글 조회시 전략 이름을 사용하도록 변경
SongJaeHoonn Dec 13, 2024
fda1d29
refactor(RegisterHotBoardPort): 인기게시글 저장시 전략 이름을 사용하도록 변경
SongJaeHoonn Dec 13, 2024
e80719f
refactor(HotBoardRegisterService): 전략 패턴별 저장 로직 변경
SongJaeHoonn Dec 13, 2024
ffea8ab
refactor(HotBoardRegisterService): 인기 게시글 조회시 전략 이름을 사용하도록 변경
SongJaeHoonn Dec 13, 2024
b19ebdf
refactor(HotBoardSelectionStrategies): 패키지 이동
SongJaeHoonn Dec 14, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package page.clab.api.domain.community.board.adapter.in.web;

import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import page.clab.api.domain.community.board.application.dto.response.BoardListResponseDto;
import page.clab.api.domain.community.board.application.port.in.RetrieveHotBoardsUseCase;
import page.clab.api.domain.community.board.domain.HotBoardStrategyType;
import page.clab.api.global.common.dto.ApiResponse;

import java.util.List;

@RestController
@RequestMapping("/api/v1/boards")
@RequiredArgsConstructor
@Tag(name = "Community - Board", description = "커뮤니티 게시판")
public class HotBoardsRetrievalController {

private final RetrieveHotBoardsUseCase retrieveHotBoardsUseCase;

@Operation(summary = "[G] 커뮤니티 인기 게시글 목록 조회", description = "ROLE_GUEST 이상의 권한이 필요함<br>" +
"인기 게시글 선정 타입 설정 가능")
@PreAuthorize("hasRole('GUEST')")
@GetMapping("/hot")
public ApiResponse<List<BoardListResponseDto>> retrieveHotBoards(
@RequestParam(name = "type") HotBoardStrategyType type
) {
List<BoardListResponseDto> boards = retrieveHotBoardsUseCase.retrieveHotBoards(type);
return ApiResponse.success(boards);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@
import page.clab.api.domain.community.board.domain.BoardCategory;
import page.clab.api.global.exception.NotFoundException;

import java.time.LocalDateTime;
import java.util.List;
import java.util.stream.Collectors;

@Component
@RequiredArgsConstructor
public class BoardPersistenceAdapter implements
Expand Down Expand Up @@ -40,6 +44,20 @@ public Board findByIdRegardlessOfDeletion(Long boardId) {
.orElseThrow(() -> new NotFoundException("[Board] id: " + boardId + "에 해당하는 게시글이 존재하지 않습니다."));
}

@Override
public List<Board> findAllWithinDateRange(LocalDateTime startDate, LocalDateTime endDate) {
return boardRepository.findAllWithinDateRange(startDate, endDate).stream()
.map(boardMapper::toDomain)
.collect(Collectors.toList());
}

@Override
public List<Board> findAll() {
return boardRepository.findAll().stream()
.map(boardMapper::toDomain)
.collect(Collectors.toList());
}

@Override
public Page<Board> findAllByCategory(BoardCategory category, Pageable pageable) {
return boardRepository.findAllByCategory(category, pageable)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package page.clab.api.domain.community.board.adapter.out.persistence;

import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
import org.springframework.data.domain.Page;
Expand All @@ -16,6 +17,9 @@ public interface BoardRepository extends JpaRepository<BoardJpaEntity, Long> {
@Query("SELECT b FROM BoardJpaEntity b WHERE b.memberId = ?1 AND b.isDeleted = false")
Page<BoardJpaEntity> findAllByMemberIdAndIsDeletedFalse(String memberId, Pageable pageable);

@Query("SELECT b FROM BoardJpaEntity b WHERE b.createdAt BETWEEN :start AND :end AND b.isDeleted = false")
List<BoardJpaEntity> findAllWithinDateRange(LocalDateTime start, LocalDateTime end);

Page<BoardJpaEntity> findAllByCategory(BoardCategory category, Pageable pageable);

@Query(value = "SELECT b.* FROM board b WHERE b.is_deleted = true", nativeQuery = true)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package page.clab.api.domain.community.board.adapter.out.persistence;

import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import page.clab.api.domain.community.board.application.port.out.RegisterHotBoardPort;
import page.clab.api.domain.community.board.application.port.out.RemoveHotBoardPort;
import page.clab.api.domain.community.board.application.port.out.RetrieveHotBoardPort;

import java.util.List;

@Component
@RequiredArgsConstructor
public class RedisHotBoardPersistenceAdapter implements
RegisterHotBoardPort,
RetrieveHotBoardPort,
RemoveHotBoardPort {

private static final String HOT_BOARDS_KEY = "hotBoards";

private final RedisTemplate<String, String> redisTemplate;

@Override
public void save(String boardId) {
redisTemplate.opsForList().rightPush(HOT_BOARDS_KEY, boardId);
}

@Override
public List<String> findAll() {
List<String> hotBoards = redisTemplate.opsForList().range(HOT_BOARDS_KEY, 0, -1);
return (hotBoards != null) ? hotBoards : List.of();
}

@Override
public void clearHotBoard() {
redisTemplate.delete(HOT_BOARDS_KEY);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package page.clab.api.domain.community.board.application.port.in;

import page.clab.api.domain.community.board.application.dto.response.BoardListResponseDto;
import page.clab.api.domain.community.board.domain.HotBoardStrategyType;

import java.util.List;

public interface RetrieveHotBoardsUseCase {
List<BoardListResponseDto> retrieveHotBoards(HotBoardStrategyType type);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package page.clab.api.domain.community.board.application.port.out;

public interface RegisterHotBoardPort {
void save(String boardId);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package page.clab.api.domain.community.board.application.port.out;

public interface RemoveHotBoardPort {
void clearHotBoard();
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,19 @@
import page.clab.api.domain.community.board.domain.Board;
import page.clab.api.domain.community.board.domain.BoardCategory;

import java.time.LocalDateTime;
import java.util.List;

public interface RetrieveBoardPort {

Board getById(Long boardId);

Board findByIdRegardlessOfDeletion(Long boardId);

List<Board> findAllWithinDateRange(LocalDateTime startDate, LocalDateTime endDate);

List<Board> findAll();

Page<Board> findAll(Pageable pageable);

Page<Board> findAllByCategory(BoardCategory category, Pageable pageable);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package page.clab.api.domain.community.board.application.port.out;

import java.util.List;

public interface RetrieveHotBoardPort {
List<String> findAll();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
package page.clab.api.domain.community.board.application.service;

import com.drew.lang.annotations.NotNull;
import lombok.RequiredArgsConstructor;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import page.clab.api.domain.community.board.application.dto.mapper.BoardDtoMapper;
import page.clab.api.domain.community.board.application.dto.response.BoardListResponseDto;
import page.clab.api.domain.community.board.application.port.out.RegisterHotBoardPort;
import page.clab.api.domain.community.board.application.port.out.RemoveHotBoardPort;
import page.clab.api.domain.community.board.application.port.out.RetrieveBoardEmojiPort;
import page.clab.api.domain.community.board.application.port.out.RetrieveBoardPort;
import page.clab.api.domain.community.board.application.port.out.RetrieveHotBoardPort;
import page.clab.api.domain.community.board.domain.Board;
import page.clab.api.domain.memberManagement.member.application.dto.shared.MemberDetailedInfoDto;
import page.clab.api.external.community.comment.application.port.ExternalRetrieveCommentUseCase;
import page.clab.api.external.memberManagement.member.application.port.ExternalRetrieveMemberUseCase;

import java.time.DayOfWeek;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.Comparator;
import java.util.List;
import java.util.stream.Collectors;

@Service("default")
@RequiredArgsConstructor
public class DefaultHotBoardService implements HotBoardsStrategy {

private final RetrieveBoardPort retrieveBoardPort;
private final RetrieveHotBoardPort retrieveHotBoardPort;
private final RegisterHotBoardPort registerHotBoardPort;
private final RemoveHotBoardPort removeHotBoardPort;
private final RetrieveBoardEmojiPort retrieveBoardEmojiPort;
private final ExternalRetrieveCommentUseCase externalRetrieveCommentUseCase;
private final ExternalRetrieveMemberUseCase externalRetrieveMemberUseCase;
private final BoardDtoMapper mapper;

@Transactional
@Override
public List<BoardListResponseDto> retrieveHotBoards() {
List<String> hotBoardIds = retrieveHotBoardPort.findAll();

return hotBoardIds.stream()
.map(hotBoardId -> retrieveBoardPort.getById(Long.parseLong(hotBoardId)))
.map(board -> mapToBoardListResponseDto(board, getMemberDetailedInfoByBoard(board)))
.toList();
}

@Transactional
@Scheduled(cron = "0 0 0 * * MON") // 매주 월요일 00:00 실행
public void saveHotBoards() {
clearHotBoards(); // 저장된 지난 인기 게시글 초기화

List<String> hotBoardIds = getHotBoards().stream()
.map(Board::getId)
.map(String::valueOf)
.toList();

hotBoardIds.forEach(registerHotBoardPort::save);
}

private List<Board> getHotBoards() {
// 만약 게시글의 총 개수가 5개보다 적다면 모든 게시글 반환
List<Board> allBoards = retrieveBoardPort.findAll();
if (allBoards.size() < 5) {
return sortBoardsByReactionAndDateWithLimit(allBoards.size(), allBoards);
}

List<Board> hotBoards = getHotBoardsForWeek(1, 5);

int weeksAgo = 2;
// 필요한 수량을 확보할 때까지 반복해서 이전 주로 이동하여 인기 게시글 보충
while (hotBoards.size() < 5) {
List<Board> additionalBoards = getLatestHotBoardForWeek(weeksAgo++, 5 - hotBoards.size());
if (additionalBoards != null && !additionalBoards.isEmpty()) {
hotBoards.addAll(additionalBoards);
}
}

return hotBoards;
}

private List<Board> getHotBoardsForWeek(int weeksAgo, int size) {
LocalDateTime startDate = LocalDate.now().minusWeeks(weeksAgo).with(DayOfWeek.MONDAY).atStartOfDay();
LocalDateTime endDate = LocalDate.now().minusWeeks(weeksAgo).with(DayOfWeek.SUNDAY).atTime(23, 59, 59);
limehee marked this conversation as resolved.
Show resolved Hide resolved

List<Board> boardsForWeek = retrieveBoardPort.findAllWithinDateRange(startDate, endDate);

return sortBoardsByReactionAndDateWithLimit(size, boardsForWeek);
}

private List<Board> sortBoardsByReactionAndDateWithLimit(int size, List<Board> boardsForWeek) {
if (boardsForWeek == null) {
return null;
}

return boardsForWeek.stream()
.sorted(Comparator
.comparingInt(this::getTotalReactionCount).reversed()
.thenComparing(Board::getCreatedAt, Comparator.reverseOrder()))
.limit(size)
.collect(Collectors.toList());
}

private List<Board> getLatestHotBoardForWeek(int weeksAgo, int size) {

List<Board> topHotBoardsForWeek = getHotBoardsForWeek(weeksAgo, size);

return topHotBoardsForWeek.stream()
.sorted(Comparator.comparing(Board::getCreatedAt).reversed())
.toList();
}

private int getTotalReactionCount(Board board) {
Long commentCount = externalRetrieveCommentUseCase.countByBoardId(board.getId());
int emojiCount = retrieveBoardEmojiPort.findEmojiClickCountsByBoardId(board.getId(), null).size();

return commentCount.intValue() + emojiCount;
}

private MemberDetailedInfoDto getMemberDetailedInfoByBoard(Board board) {
return externalRetrieveMemberUseCase.getMemberDetailedInfoById(board.getMemberId());
}

@NotNull
private BoardListResponseDto mapToBoardListResponseDto(Board board, MemberDetailedInfoDto memberInfo) {
Long commentCount = externalRetrieveCommentUseCase.countByBoardId(board.getId());

return mapper.toListDto(board, memberInfo, commentCount);
}

private void clearHotBoards() {
removeHotBoardPort.clearHotBoard();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package page.clab.api.domain.community.board.application.service;

import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import page.clab.api.domain.community.board.application.dto.response.BoardListResponseDto;
import page.clab.api.domain.community.board.application.port.in.RetrieveHotBoardsUseCase;
import page.clab.api.domain.community.board.domain.HotBoardStrategyType;

import java.util.List;
import java.util.Map;

@Service
@RequiredArgsConstructor
public class HotBoardsRetrievalService implements RetrieveHotBoardsUseCase {

private final Map<String, HotBoardsStrategy> strategies;

@Override
public List<BoardListResponseDto> retrieveHotBoards(HotBoardStrategyType type) {
HotBoardsStrategy hotBoardsStrategy = strategies.get(type.getKey());
return hotBoardsStrategy.retrieveHotBoards();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package page.clab.api.domain.community.board.application.service;

import page.clab.api.domain.community.board.application.dto.response.BoardListResponseDto;

import java.util.List;

public interface HotBoardsStrategy {
List<BoardListResponseDto> retrieveHotBoards();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package page.clab.api.domain.community.board.domain;

import lombok.AllArgsConstructor;
import lombok.Getter;

@Getter
@AllArgsConstructor
public enum HotBoardStrategyType {

DEFAULT("default", "댓글, 반응순 정렬");

private final String key;
private final String description;
}
1 change: 1 addition & 0 deletions src/main/resources/messages.properties
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ email.member.email=올바른 이메일 형식으로 입력하세요.
min.application.grade=학년은 {value}학년 이상이어야 합니다.
min.donation.amount=금액은 {value}원 이상이어야 합니다.
min.member.grade=최소값은 {value} 이상이어야 합니다.
min.board.size=최소값은 {value} 이상이어야 합니다.
limehee marked this conversation as resolved.
Show resolved Hide resolved
max.application.grade=학년은 {value}학년 이하여야 합니다.
max.member.grade=최대값은 {value} 이하여야 합니다.
url.application.githubUrl=올바른 URL 형식으로 입력하세요.
Expand Down