Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
Expand Up @@ -15,6 +15,7 @@
import com.threestar.trainus.domain.profile.service.ProfileFacadeService;
import com.threestar.trainus.global.unit.BaseResponse;

import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpSession;
import jakarta.validation.Valid;
Expand All @@ -29,6 +30,7 @@ public class ProfileController {
private final ProfileFacadeService facadeService;

@GetMapping("{userId}")
@Operation(summary = "유저 프로필 상세 조회 api")
public ResponseEntity<BaseResponse<ProfileDetailResponseDto>> getProfileDetail(
@PathVariable Long userId
) {
Expand All @@ -37,6 +39,7 @@ public ResponseEntity<BaseResponse<ProfileDetailResponseDto>> getProfileDetail(
}

@PatchMapping
@Operation(summary = "유저 프로필 수정 api")
public ResponseEntity<BaseResponse<ProfileResponseDto>> updateProfile(
@Valid @RequestBody ProfileUpdateRequestDto requestDto,
HttpSession session
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,15 @@
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import com.threestar.trainus.domain.ranking.dto.RankingResponseDto;
import com.threestar.trainus.domain.ranking.service.RankingService;
import com.threestar.trainus.global.unit.BaseResponse;

import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;

Expand All @@ -24,8 +26,19 @@ public class RankingController {
private final RankingService rankingService;

@GetMapping
@Operation(summary = "전체 랭킹 조회 api", description = "카테고리와 관계없이 전체 랭킹 Top10을 조회")
public ResponseEntity<BaseResponse<List<RankingResponseDto>>> getRankings() {
List<RankingResponseDto> rankings = rankingService.getTopRankings();
List<RankingResponseDto> rankings = rankingService.getTopRankings("ALL");
return BaseResponse.ok("전체 랭킹 조회 성공", rankings, HttpStatus.OK);
}

@GetMapping("/{category}")
@Operation(summary = "특정 카테고리 랭킹 조회 api", description = "지정된 카테고리의 랭킹을 조회")
public ResponseEntity<BaseResponse<List<RankingResponseDto>>> getRankingsByCategory(
@PathVariable String category
) {
List<RankingResponseDto> rankings = rankingService.getTopRankings(category);
return BaseResponse.ok(category + "카테고리별 랭킹 조회 성공", rankings, HttpStatus.OK);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -4,28 +4,51 @@

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

import com.threestar.trainus.domain.lesson.admin.entity.Category;
import com.threestar.trainus.domain.metadata.entity.ProfileMetadata;
import com.threestar.trainus.domain.ranking.dto.RankingData;

public interface RankingRepository extends JpaRepository<ProfileMetadata, Long> {

@Query("""
SELECT pm.user.id as userId,
pm.user.nickname as userNickname,
pm.reviewCount as reviewCount,
pm.rating as rating,
p.profileImage as profileImage
FROM ProfileMetadata pm
JOIN pm.user u
LEFT JOIN Profile p ON p.user = u
WHERE pm.reviewCount >= 20
ORDER BY (
(pm.rating / 5.0) * 0.5 +
(LEAST(pm.reviewCount, 100) / 100.0) * 0.5
) DESC
LIMIT 10
""")
SELECT pm.user.id as userId,
pm.user.nickname as userNickname,
pm.reviewCount as reviewCount,
pm.rating as rating,
p.profileImage as profileImage
FROM ProfileMetadata pm
JOIN pm.user u
LEFT JOIN Profile p ON p.user = u
WHERE pm.reviewCount >= 20
ORDER BY (
(pm.rating / 5.0) * 0.5 +
(LEAST(pm.reviewCount, 100) / 100.0) * 0.5
) DESC
LIMIT 10
""")
List<RankingData> findTopRankings();

}
@Query("""
SELECT r.reviewee.id as userId,
r.reviewee.nickname as userNickname,
CAST(COUNT(r.reviewId) AS INTEGER) as reviewCount,
AST(AVG(r.rating) AS FLOAT) as rating,
p.profileImage as profileImage
FROM Review r
JOIN r.lesson l
JOIN r.reviewee u
LEFT JOIN Profile p ON p.user = u
WHERE l.category = :category
AND r.deletedAt IS NULL
GROUP BY r.reviewee.id, r.reviewee.nickname, p.profileImage
HAVING COUNT(r.reviewId) >= 20
ORDER BY (
(AVG(r.rating) / 5.0) * 0.5 +
(LEAST(COUNT(r.reviewId), 100) / 100.0) * 0.5
) DESC
LIMIT 10
""")
List<RankingData> findTopRankingsByCategory(@Param("category") Category category);
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,22 @@

import java.time.Duration;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;

import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.threestar.trainus.domain.lesson.admin.entity.Category;
import com.threestar.trainus.domain.ranking.dto.RankingData;
import com.threestar.trainus.domain.ranking.dto.RankingResponseDto;
import com.threestar.trainus.domain.ranking.repository.RankingRepository;
import com.threestar.trainus.global.exception.domain.ErrorCode;
import com.threestar.trainus.global.exception.handler.BusinessException;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
Expand All @@ -27,10 +32,25 @@ public class RankingService {
private final ObjectMapper objectMapper;

private static final String RANKING_KEY = "ranking:all:top10";
private static final String CATEGORY_RANKING_KEY_PREFIX = "ranking:";
private static final String CATEGORY_RANKING_KEY_SUFFIX = ":top10";

public List<RankingResponseDto> getTopRankings(String categoryStr) {

Category category = null;
String cacheKey = RANKING_KEY;

if (!"ALL".equalsIgnoreCase(categoryStr)) {
try {
category = Category.valueOf(categoryStr.toUpperCase());
cacheKey = CATEGORY_RANKING_KEY_PREFIX + categoryStr.toLowerCase() + CATEGORY_RANKING_KEY_SUFFIX;
} catch (IllegalArgumentException e) {
throw new BusinessException(ErrorCode.INVALID_CATEGORY);
}
}

public List<RankingResponseDto> getTopRankings() {
try {
String cachedData = redisTemplate.opsForValue().get(RANKING_KEY);
String cachedData = redisTemplate.opsForValue().get(cacheKey);
if (cachedData != null) {
return objectMapper.readValue(cachedData, new TypeReference<List<RankingResponseDto>>() {
});
Expand All @@ -39,14 +59,15 @@ public List<RankingResponseDto> getTopRankings() {
log.warn("레디스 조회 실패: {}", e.getMessage());
}

List<RankingResponseDto> rankings = calculateRankings();
saveToRedis(rankings);
List<RankingResponseDto> rankings = calculateRankings(category);
saveToRedis(rankings, cacheKey);

return rankings;
}

private List<RankingResponseDto> calculateRankings() {
List<RankingData> data = rankingRepository.findTopRankings();
private List<RankingResponseDto> calculateRankings(Category category) {
List<RankingData> data = (category == null) ? rankingRepository.findTopRankings() :
rankingRepository.findTopRankingsByCategory(category);

List<RankingResponseDto> rankings = new ArrayList<>();

Expand All @@ -55,7 +76,7 @@ private List<RankingResponseDto> calculateRankings() {
rankings.add(RankingResponseDto.builder()
.userId(item.getUserId())
.userNickname(item.getUserNickname())
.category(null) //카테고리별 분류는 추후 도입
.category(category) //null이면 전체 조회
.rating(item.getRating())
.reviewCount(item.getReviewCount())
.rank(i + 1)
Expand All @@ -66,11 +87,10 @@ private List<RankingResponseDto> calculateRankings() {
return rankings;
}

private void saveToRedis(List<RankingResponseDto> rankings) {
private void saveToRedis(List<RankingResponseDto> rankings, String cacheKey) {
try {
String json = objectMapper.writeValueAsString(rankings);
redisTemplate.opsForValue().set(RANKING_KEY, json, Duration.ofHours(24));
log.info("Redis에 랭킹 데이터 저장 완료");
redisTemplate.opsForValue().set(cacheKey, json, Duration.ofHours(24));
} catch (Exception e) {
log.warn("Redis 저장 실패: {}", e.getMessage());
}
Expand All @@ -80,10 +100,22 @@ private void saveToRedis(List<RankingResponseDto> rankings) {
@Scheduled(cron = "0 0 0 * * *")
public void updateRankings() {
log.info("랭킹 업데이트 시작");

Map<String, Integer> categoryCounts = new HashMap<>();

try {
List<RankingResponseDto> rankings = calculateRankings();
saveToRedis(rankings);
log.info("랭킹 업데이트 완료");
//전체 랭킹 업데이트
List<RankingResponseDto> allRankings = calculateRankings(null);
saveToRedis(allRankings, RANKING_KEY);
categoryCounts.put("ALL", allRankings.size());

//카테고리별 랭킹 업데이트
for (Category category : Category.values()) {
List<RankingResponseDto> categoryRankings = calculateRankings(category);
String cacheKey = CATEGORY_RANKING_KEY_PREFIX + category.name().toLowerCase() + CATEGORY_RANKING_KEY_SUFFIX;
saveToRedis(categoryRankings, cacheKey);
categoryCounts.put(category.name(), categoryRankings.size());
}
} catch (Exception e) {
log.error("랭킹 업데이트 실패: {}", e.getMessage(), e);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import com.threestar.trainus.domain.user.service.UserService;
import com.threestar.trainus.global.unit.BaseResponse;

import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpSession;
import jakarta.validation.Valid;
Expand All @@ -34,6 +35,7 @@ public class UserController {
private final EmailVerificationService emailVerificationService;

@PostMapping("/signup")
@Operation(summary = "회원가입 api")
public ResponseEntity<BaseResponse<SignupResponseDto>> signup(
@Valid @RequestBody SignupRequestDto request
) {
Expand All @@ -43,6 +45,7 @@ public ResponseEntity<BaseResponse<SignupResponseDto>> signup(
}

@PostMapping("/login")
@Operation(summary = "로그인 api")
public ResponseEntity<BaseResponse<LoginResponseDto>> login(
@Valid @RequestBody LoginRequestDto request,
HttpSession session
Expand All @@ -52,12 +55,14 @@ public ResponseEntity<BaseResponse<LoginResponseDto>> login(
}

@PostMapping("/logout")
@Operation(summary = "로그아웃 api")
public ResponseEntity<BaseResponse<Void>> logout(HttpSession session) {
userService.logout(session);
return BaseResponse.ok("로그아웃이 완료되었습니다.", null, HttpStatus.OK);
}

@PostMapping("/verify/check-nickname")
@Operation(summary = "닉네임 중복 체크 api")
public ResponseEntity<BaseResponse<Void>> checkNickname(
@Valid @RequestBody NicknameCheckRequestDto request
) {
Expand All @@ -66,6 +71,7 @@ public ResponseEntity<BaseResponse<Void>> checkNickname(
}

@PostMapping("/verify/email-send")
@Operation(summary = "이메일 인증코드 발송 api", description = "회원가입 중 이메일 인증 코드를 발송")
public ResponseEntity<BaseResponse<EmailSendResponseDto>> sendVerificationCode(
@Valid @RequestBody EmailSendRequestDto request
) {
Expand All @@ -74,6 +80,7 @@ public ResponseEntity<BaseResponse<EmailSendResponseDto>> sendVerificationCode(
}

@PostMapping("/verify/email-check")
@Operation(summary = "이메일 인증코드 인증 api", description = "이메일 인증코드(6자리) 입력 시 인증 가능하고 나머지 회원가입 진행")
public ResponseEntity<BaseResponse<Void>> confirmVerificationCode(
@Valid @RequestBody EmailVerificationDto request
) {
Expand Down