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 24 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.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>" +
"인기게시글 선정 전략별 조회가 가능함<br>" +
"- DEFAULT : 반응 순 기본 전략<br>")
@PreAuthorize("hasRole('GUEST')")
@GetMapping("/hot")
public ApiResponse<List<BoardListResponseDto>> retrieveHotBoards(
@RequestParam(name = "strategyName", defaultValue = "DEFAULT") String strategyName
) {
List<BoardListResponseDto> boards = retrieveHotBoardsUseCase.retrieveHotBoards(strategyName);
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,58 @@
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;
import java.util.Objects;
import java.util.Set;

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

private static final String HOT_BOARDS_PREFIX = "hotBoards";

private final RedisTemplate<String, String> redisTemplate;

@Override
public void save(String boardId, String strategyName) {
String key = getRedisKey(strategyName);
redisTemplate.opsForList().rightPush(key, boardId);
}

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

@Override
public void clearHotBoard() {
String pattern = HOT_BOARDS_PREFIX + ":*";
Set<String> keys = redisTemplate.keys(pattern);
if (keys == null) {
return;
}
keys.stream()
.filter(Objects::nonNull)
.forEach(key -> {
Long size = redisTemplate.opsForList().size(key);
if (size != null && size > 0) {
redisTemplate.delete(key);
}
});
}

private String getRedisKey(String strategyName) {
return String.format("%s:%s", HOT_BOARDS_PREFIX, strategyName);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package page.clab.api.domain.community.board.application.port.in;

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

import java.util.List;

public interface RetrieveHotBoardsUseCase {
List<BoardListResponseDto> retrieveHotBoards(String strategyName);
}
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, String strategyName);
}
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> findByHotBoardStrategy(String strategyName);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package page.clab.api.domain.community.board.application.service;

import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
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.domain.Board;
import page.clab.api.domain.community.board.domain.HotBoardSelectionStrategies;
import page.clab.api.external.community.comment.application.port.ExternalRetrieveCommentUseCase;

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(HotBoardSelectionStrategies.DEFAULT)
@RequiredArgsConstructor
public class DefaultHotBoardSelectionStrategy implements HotBoardSelectionStrategy {

private final RetrieveBoardPort retrieveBoardPort;
private final RetrieveBoardEmojiPort retrieveBoardEmojiPort;
private final ExternalRetrieveCommentUseCase externalRetrieveCommentUseCase;

@Transactional
@Override
public 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 startOfWeek = LocalDate.now().minusWeeks(weeksAgo).with(DayOfWeek.MONDAY).atStartOfDay();
LocalDateTime endOfWeek = LocalDate.now().minusWeeks(weeksAgo).with(DayOfWeek.SUNDAY).atTime(23, 59, 59);

List<Board> boardsForWeek = retrieveBoardPort.findAllWithinDateRange(startOfWeek, endOfWeek);

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;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package page.clab.api.domain.community.board.application.service;

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.port.out.RegisterHotBoardPort;
import page.clab.api.domain.community.board.application.port.out.RemoveHotBoardPort;
import page.clab.api.domain.community.board.domain.Board;
import page.clab.api.domain.community.board.domain.HotBoardSelectionStrategies;

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

@Service
@RequiredArgsConstructor
public class HotBoardRegisterService {

private final Map<String, HotBoardSelectionStrategy> strategyMap;
private final RegisterHotBoardPort registerHotBoardPort;
private final RemoveHotBoardPort removeHotBoardPort;

/**
* 주어진 전략을 기반으로 인기 게시글을 등록합니다.
* <p>
* 지정된 전략을 기반으로 인기 게시글을 선정하고, 이를 Redis에 저장합니다.
*
* @param strategyName 인기 게시글 선정 전략 이름
* @return 인기 게시글 ID 리스트
*/
@Transactional
public List<String> registerHotBoards(String strategyName) {
HotBoardSelectionStrategy strategy = strategyMap.get(strategyName);
List<String> hotBoardIds = getHotBoardIds(strategy);
hotBoardIds.forEach(id -> registerHotBoardPort.save(id, strategyName));
return hotBoardIds;
}

/**
* 기본 전략을 이용하여 인기 게시글을 등록합니다.
* <p>
* 매주 월요일 자정에 실행되도록 스케줄링되어 있습니다. 먼저 기존 인기 게시글을 초기화한 뒤,
* 기본 전략을 이용하여 선정된 인기 게시글을 Redis에 저장합니다.
*/
@Transactional
@Scheduled(cron = "0 0 0 * * MON") // 매주 월요일 00:00 실행
public void registerDefaultHotBoards() {
HotBoardSelectionStrategy strategy = strategyMap.get(HotBoardSelectionStrategies.DEFAULT);

removeHotBoardPort.clearHotBoard(); // 저장된 지난 모든 인기 게시글 초기화

List<String> hotBoardIds = getHotBoardIds(strategy);
hotBoardIds.forEach(id ->
registerHotBoardPort.save(id, HotBoardSelectionStrategies.DEFAULT));
}

private static List<String> getHotBoardIds(HotBoardSelectionStrategy strategy) {
return strategy.getHotBoards().stream()
.map(Board::getId)
.map(String::valueOf)
.toList();
}
}
Loading
Loading