diff --git a/Api/src/main/java/playlist/server/mainpagerankingInfo/vo/MainPageRankingInfoVo.java b/Api/src/main/java/playlist/server/mainpagerankingInfo/vo/MainPageRankingInfoVo.java new file mode 100644 index 0000000..680d574 --- /dev/null +++ b/Api/src/main/java/playlist/server/mainpagerankingInfo/vo/MainPageRankingInfoVo.java @@ -0,0 +1,25 @@ +package playlist.server.mainpagerankingInfo.vo; + +import lombok.Builder; +import lombok.Getter; +import playlist.server.domain.domains.ranking.domain.RankingInfo; +import playlist.server.domain.domains.ranking.domain.RankingType; + +import java.util.List; // 추가 + +@Getter +@Builder +public class MainPageRankingInfoVo { + private final RankingInfo rankingInfo; + private final RankingType rankingType; + private final List rankingInfoList; + + + public static MainPageRankingInfoVo from(RankingInfo rankingInfo, RankingType rankingType, List rankingInfoList) { + return MainPageRankingInfoVo.builder() + .rankingInfo(rankingInfo) + .rankingType(rankingType) + .rankingInfoList(rankingInfoList) + .build(); + } +} diff --git a/Api/src/main/java/playlist/server/ranking/controller/RankingController.java b/Api/src/main/java/playlist/server/ranking/controller/RankingController.java new file mode 100644 index 0000000..50fdda8 --- /dev/null +++ b/Api/src/main/java/playlist/server/ranking/controller/RankingController.java @@ -0,0 +1,57 @@ +package playlist.server.ranking.controller; + + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +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 playlist.server.domain.domains.ranking.domain.RankingInfo; +import playlist.server.domain.domains.ranking.domain.RankingType; +import playlist.server.ranking.service.RankingService; +import playlist.server.ranking.vo.ResponseRankingDto; + +import java.util.List; + +import static playlist.server.domain.domains.ranking.domain.RankingInfo.MONTH; +import static playlist.server.domain.domains.ranking.domain.RankingInfo.WEEK; +import static playlist.server.domain.domains.ranking.domain.RankingType.isStringLikeOrView; + +@RestController +@RequestMapping("/ranking") +@RequiredArgsConstructor +@Tag(name = "1. [랭킹]") +public class RankingController { + + private final RankingService rankingService; + + @Operation(summary = "랭킹을 조회합니다.") + @GetMapping("/daily") + public ResponseEntity> getDailyRanking( + @RequestParam(name = "type", defaultValue = "like") String type) { + return ResponseEntity.ok(getRanking(RankingInfo.DAILY, isStringLikeOrView(type))); + } + + @Operation(summary = "주간 랭킹을 조회합니다.") + @GetMapping("/weekly") + public ResponseEntity> getWeeklyRanking( + @RequestParam(name = "type", defaultValue = "like") String type) { + return ResponseEntity.ok(getRanking(WEEK, isStringLikeOrView(type))); + } + + + @Operation(summary = "월간 랭킹을 조회합니다.") + @GetMapping("/monthly") + public ResponseEntity> getMonthlyRanking( + @RequestParam(name = "type", defaultValue = "like") String type) { + return ResponseEntity.ok(getRanking(MONTH, isStringLikeOrView(type))); + } + + + private List getRanking(RankingInfo period, RankingType searchType) { + return rankingService.getRankingList(period, searchType); + } +} diff --git a/Api/src/main/java/playlist/server/ranking/service/RankingService.java b/Api/src/main/java/playlist/server/ranking/service/RankingService.java new file mode 100644 index 0000000..3090d9a --- /dev/null +++ b/Api/src/main/java/playlist/server/ranking/service/RankingService.java @@ -0,0 +1,14 @@ +package playlist.server.ranking.service; + +import playlist.server.domain.domains.ranking.domain.RankingInfo; +import playlist.server.domain.domains.ranking.domain.RankingType; +import playlist.server.ranking.vo.ResponseRankingDto; + +import java.util.List; + +public interface RankingService { + + List getRankingList(RankingInfo period, RankingType type); + + void incrementCountProcess(RankingType type, String boardId); +} diff --git a/Api/src/main/java/playlist/server/ranking/service/RedisRankingService.java b/Api/src/main/java/playlist/server/ranking/service/RedisRankingService.java new file mode 100644 index 0000000..1832903 --- /dev/null +++ b/Api/src/main/java/playlist/server/ranking/service/RedisRankingService.java @@ -0,0 +1,100 @@ +package playlist.server.ranking.service; + + +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; +import playlist.server.domain.domains.ranking.domain.RankingInfo; +import playlist.server.domain.domains.ranking.domain.RankingType; +import playlist.server.ranking.vo.ResponseRankingDto; + +import java.util.Arrays; +import java.util.List; + +/** + * 참고한 Reference : https://cantcoding.tistory.com/82 + */ +@Service +@RequiredArgsConstructor +public class RedisRankingService implements RankingService { + + private final RedisTemplate redisTemplate; + + /** + * 랭킹 리스트를 0~10까지 가져온다. + * @param period + * @param type + * @return + */ + @Override + public List getRankingList(RankingInfo period, RankingType type) { + return redisTemplate.opsForZSet() + .reverseRangeWithScores(getRankingTypeKey(period, type), 0, 10) + .stream() + .map(ResponseRankingDto::convertToResponseRankingDto).toList(); + } + + /** + * Like, View의 Daily, Week, Month의 카운트를 모두 증가시킨다. + * + * @param type + * @param boardId + */ + @Override + public void incrementCountProcess(RankingType type, String boardId) { + Arrays.stream(RankingInfo.values()).forEach(period -> { + if (isExistInRanking(period, type, boardId)) { + incrementRankingCount(period, type, boardId); + } else { + addRanking(period, type, boardId); + } + }); + } + + + /** + * 현재 ZSet에 값이 존재하는지 확인한다. + * @param period + * @param type + * @param boardId + * @return + */ + private boolean isExistInRanking(RankingInfo period, RankingType type, String boardId) { + return redisTemplate.opsForZSet() + .score(getRankingTypeKey(period, type), boardId) != null; + } + + /** + * 값이 존재하지 않는 경우 Default Value로 1을 제공한다. + * + * @param period + * @param type + * @param boardId + */ + private void addRanking(RankingInfo period, RankingType type, String boardId) { + redisTemplate.opsForZSet().add(getRankingTypeKey(period, type), boardId, 1); + } + + /** + * Zset에 존재하는 경우 해당 값을 증가시킨다 + * + * @param period + * @param type + * @param boardId + */ + private void incrementRankingCount(RankingInfo period, RankingType type, String boardId) { + redisTemplate.opsForZSet() + .incrementScore(getRankingTypeKey(period, type), boardId, 1); + } + + /** + * Type에 따라 Key를 다르게 가져온다. + * + * @param period + * @param type + * @return + */ + private String getRankingTypeKey(RankingInfo period, RankingType type) { + return RankingType.LIKE.equals(type) ? period.getLikeKey() : period.getViewKey(); + } +} diff --git a/Api/src/main/java/playlist/server/ranking/vo/ResponseRankingDto.java b/Api/src/main/java/playlist/server/ranking/vo/ResponseRankingDto.java new file mode 100644 index 0000000..4040a1e --- /dev/null +++ b/Api/src/main/java/playlist/server/ranking/vo/ResponseRankingDto.java @@ -0,0 +1,28 @@ +package playlist.server.ranking.vo; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.springframework.data.redis.core.ZSetOperations; + +@Getter +@Setter +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class ResponseRankingDto { + private String boardId; + private Long score; + + public static ResponseRankingDto convertToResponseRankingDto(ZSetOperations.TypedTuple typedTuple) { + return ResponseRankingDto.builder() + .boardId(typedTuple.getValue().toString()) + .score(typedTuple.getScore().longValue()) + .build(); + } + +} + + diff --git a/Api/src/main/java/playlist/server/search/controller/SearchController.java b/Api/src/main/java/playlist/server/search/controller/SearchController.java new file mode 100644 index 0000000..67b0c19 --- /dev/null +++ b/Api/src/main/java/playlist/server/search/controller/SearchController.java @@ -0,0 +1,34 @@ +package playlist.server.search.controller; + + +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +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 playlist.server.exception.TagNotFoundException; +import playlist.server.search.service.SearchService; +import playlist.server.search.vo.SearchVo; + +@RestController +@RequestMapping("/search") +@RequiredArgsConstructor +public class SearchController { + + private final SearchService searchService; + + // 태그 기반으로 검색 + @GetMapping("/tag") + public ResponseEntity> searchByTag(@RequestParam("tag") String tag) { + List searchResults = searchService.searchByTag(tag); + + if (searchResults.isEmpty()) { + throw TagNotFoundException.EXCEPTION; + } + + return ResponseEntity.ok(searchResults); + } + +} diff --git a/Api/src/main/java/playlist/server/search/service/SearchService.java b/Api/src/main/java/playlist/server/search/service/SearchService.java new file mode 100644 index 0000000..6a8f608 --- /dev/null +++ b/Api/src/main/java/playlist/server/search/service/SearchService.java @@ -0,0 +1,22 @@ +package playlist.server.search.service; + + +import java.util.List; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import playlist.server.domain.domains.search.repository.SearchRepository; +import playlist.server.search.vo.SearchVo; + +@Service +@RequiredArgsConstructor +public class SearchService { + + private final SearchRepository searchRepository; + + public List searchByTag(String tag) { + return searchRepository.findByTag(tag).stream() + .map(search -> new SearchVo(search.getTitle(), search.getDescription())) + .collect(Collectors.toList()); + } +} diff --git a/Api/src/main/java/playlist/server/search/vo/SearchVo.java b/Api/src/main/java/playlist/server/search/vo/SearchVo.java new file mode 100644 index 0000000..3085e20 --- /dev/null +++ b/Api/src/main/java/playlist/server/search/vo/SearchVo.java @@ -0,0 +1,14 @@ +package playlist.server.search.vo; + + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class SearchVo { + private String title; + private String description; +} diff --git a/Core/src/main/java/playlist/server/exception/DataFetchException.java b/Core/src/main/java/playlist/server/exception/DataFetchException.java new file mode 100644 index 0000000..c466867 --- /dev/null +++ b/Core/src/main/java/playlist/server/exception/DataFetchException.java @@ -0,0 +1,10 @@ +package playlist.server.exception; + +public class DataFetchException extends BaseException { + + public static final BaseException EXCEPTION = new DataFetchException(); + + public DataFetchException() { + super(GlobalException.DATA_FETCH_ERROR); + } +} diff --git a/Core/src/main/java/playlist/server/exception/GlobalException.java b/Core/src/main/java/playlist/server/exception/GlobalException.java index 175cce7..e2b9e32 100644 --- a/Core/src/main/java/playlist/server/exception/GlobalException.java +++ b/Core/src/main/java/playlist/server/exception/GlobalException.java @@ -19,7 +19,11 @@ public enum GlobalException implements BaseErrorCode { EXPIRED_REFRESH_TOKEN_ERROR(UNAUTHORIZED.value(), "401-1", "리프레시 토큰이 만료되었습니다."), INVALID_TOKEN_ERROR(UNAUTHORIZED.value(), "401-2", "올바르지 않은 토큰입니다."), DATE_FORMAT_ERROR(BAD_REQUEST.value(), "400-2", "날짜 형식을 확인해주세요."), - ; + LIKE_INCREMENT_ERROR(INTERNAL_SERVER_ERROR.value(), "500-3", "좋아요 증가 실패"), + VIEW_INCREMENT_ERROR(INTERNAL_SERVER_ERROR.value(), "500-3", "조회수 증가 실패"), + TAG_NOT_FOUND(BAD_REQUEST.value(), "400-3", "태그를 찾을 수 없습니다."), + INVALID_PARAMETER_ERROR(BAD_REQUEST.value(), "400-4", "유효하지 않은 파라미터입니다."), + DATA_FETCH_ERROR(INTERNAL_SERVER_ERROR.value(), "500-4", "데이터를 가져오는데 실패하였습니다."); private final Integer statusCode; private final String errorCode; diff --git a/Core/src/main/java/playlist/server/exception/InvalidParameterException.java b/Core/src/main/java/playlist/server/exception/InvalidParameterException.java new file mode 100644 index 0000000..8a70aba --- /dev/null +++ b/Core/src/main/java/playlist/server/exception/InvalidParameterException.java @@ -0,0 +1,10 @@ +package playlist.server.exception; + +public class InvalidParameterException extends BaseException { + + public static final BaseException EXCEPTION = new InvalidParameterException(); + + public InvalidParameterException() { + super(GlobalException.INVALID_PARAMETER_ERROR); + } +} diff --git a/Core/src/main/java/playlist/server/exception/LikeIncrementException.java b/Core/src/main/java/playlist/server/exception/LikeIncrementException.java new file mode 100644 index 0000000..8cbaa3e --- /dev/null +++ b/Core/src/main/java/playlist/server/exception/LikeIncrementException.java @@ -0,0 +1,10 @@ +package playlist.server.exception; + +public class LikeIncrementException extends BaseException { + + public static final BaseException EXCEPTION = new LikeIncrementException(); + + public LikeIncrementException() { + super(GlobalException.LIKE_INCREMENT_ERROR); + } +} diff --git a/Core/src/main/java/playlist/server/exception/TagNotFoundException.java b/Core/src/main/java/playlist/server/exception/TagNotFoundException.java new file mode 100644 index 0000000..70c9729 --- /dev/null +++ b/Core/src/main/java/playlist/server/exception/TagNotFoundException.java @@ -0,0 +1,15 @@ +package playlist.server.exception; + +import playlist.server.exception.BaseErrorCode; +import playlist.server.exception.BaseException; +import static org.springframework.http.HttpStatus.NOT_FOUND; + +public class TagNotFoundException extends BaseException { + + public static final BaseException EXCEPTION = new TagNotFoundException(); + + private TagNotFoundException() { + super(GlobalException.TAG_NOT_FOUND); + } +} + diff --git a/Core/src/main/java/playlist/server/exception/ViewIncrementException.java b/Core/src/main/java/playlist/server/exception/ViewIncrementException.java new file mode 100644 index 0000000..9492715 --- /dev/null +++ b/Core/src/main/java/playlist/server/exception/ViewIncrementException.java @@ -0,0 +1,10 @@ +package playlist.server.exception; + +public class ViewIncrementException extends BaseException { + + public static final BaseException EXCEPTION = new ViewIncrementException(); + + public ViewIncrementException() { + super(GlobalException.VIEW_INCREMENT_ERROR); + } +} diff --git a/Domain/src/main/java/playlist/server/domain/domains/ranking/domain/RankingInfo.java b/Domain/src/main/java/playlist/server/domain/domains/ranking/domain/RankingInfo.java new file mode 100644 index 0000000..8d152be --- /dev/null +++ b/Domain/src/main/java/playlist/server/domain/domains/ranking/domain/RankingInfo.java @@ -0,0 +1,16 @@ +package playlist.server.domain.domains.ranking.domain; + + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@AllArgsConstructor +@Getter +public enum RankingInfo { + DAILY("daily_like", "daily_view"), + WEEK("week_like", "week_view"), + MONTH("month_like", "month_view"); + + private final String likeKey; + private final String viewKey; +} diff --git a/Domain/src/main/java/playlist/server/domain/domains/ranking/domain/RankingType.java b/Domain/src/main/java/playlist/server/domain/domains/ranking/domain/RankingType.java new file mode 100644 index 0000000..c069753 --- /dev/null +++ b/Domain/src/main/java/playlist/server/domain/domains/ranking/domain/RankingType.java @@ -0,0 +1,25 @@ +package playlist.server.domain.domains.ranking.domain; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@AllArgsConstructor +@Getter +public enum RankingType { + LIKE("like"), // 좋아요 랭킹 + VIEW("view"); // 조회수 랭킹 + + private final String description; + + /** + * 문자열을 LIKE인지 확인한다, 아닌 경우에는 VIEW를 Return 한다. + * @param type + * @return + */ + public static RankingType isStringLikeOrView(String type) { + if(type.toLowerCase().equals(LIKE.description) ) { + return LIKE; + } + return VIEW; + } +} diff --git a/Domain/src/main/java/playlist/server/domain/domains/search/domain/Search.java b/Domain/src/main/java/playlist/server/domain/domains/search/domain/Search.java new file mode 100644 index 0000000..d507d01 --- /dev/null +++ b/Domain/src/main/java/playlist/server/domain/domains/search/domain/Search.java @@ -0,0 +1,25 @@ +package playlist.server.domain.domains.search.domain; + + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Entity +@Getter +@Setter +@NoArgsConstructor +public class Search { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String title; + private String description; + private String tag; +} diff --git a/Domain/src/main/java/playlist/server/domain/domains/search/repository/SearchRepository.java b/Domain/src/main/java/playlist/server/domain/domains/search/repository/SearchRepository.java new file mode 100644 index 0000000..73bb754 --- /dev/null +++ b/Domain/src/main/java/playlist/server/domain/domains/search/repository/SearchRepository.java @@ -0,0 +1,10 @@ +package playlist.server.domain.domains.search.repository; + + +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; +import playlist.server.domain.domains.search.domain.Search; + +public interface SearchRepository extends JpaRepository { + List findByTag(String tag); +}