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

70 feat ranking 기능 구현 #81

Merged
merged 11 commits into from
Feb 27, 2025
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 @@ -38,11 +38,10 @@ public ResponseEntity<RankingResponseDto> fetchRanking(@PathVariable Long rankin
}

/**
* 특정 Ranking에 User 추가
* User 티어에 맞는 랭킹 방 추가 혹인 배치고사
*/
@PostMapping("/{rankingId}/users/{userId}")
@PostMapping("/users/{userId}")
public ResponseEntity<Void> addUserToRanking(
@PathVariable Long rankingId,
@PathVariable Long userId
) {
rankingService.handleRankingParticipation(userId);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,10 @@
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;

import java.util.List;

@RequestMapping("/ranking")
public interface RankingControllerDocs {

@Operation(
Expand All @@ -27,11 +28,11 @@ public interface RankingControllerDocs {
ResponseEntity<RankingResponseDto> fetchRanking(@PathVariable Long rankingId);

@Operation(
summary = "특정 Ranking에 User 추가",
description = "주어진 User ID를 특정 Ranking 방에 추가합니다."
summary = "User 티어에 맞는 랭킹 방 추가 혹인 배치고사",
description = "주어진 User ID의 티어에 맞는 Ranking 방에 추가합니다. 티어가 없다면 배치고사 진행"
)
@PostMapping("/{rankingId}/users/{userId}")
ResponseEntity<Void> addUserToRanking(@PathVariable Long rankingId, @PathVariable Long userId);
@PostMapping("/users/{userId}")
ResponseEntity<Void> addUserToRanking(@PathVariable Long userId);

@Operation(
summary = "특정 Ranking의 User 기록 조회",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package com.alom.dorundorunbe.domain.ranking.dto;

import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;

import java.util.List;

@Data
@Schema(description = "웹소켓을 통해 전달되는 랭킹 정보 DTO")
public class RankingSocketDto {
@Schema(description = "Ranking 방 ID", example = "1")
private Long rankingId;

@Schema(description = "랭킹 방의 Tier (예: 아마추어, 프로 등)", example = "스타터")
private String tier;

@Schema(description = "랭킹 방에 참가한 사용자 목록(등수, 평균점수)")
private List<RankingSocketUserDto> participants;

public RankingSocketDto(Long rankingId, String tier, List<RankingSocketUserDto> users) {
this.rankingId = rankingId;
this.tier = tier;
this.participants = users;
}
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package com.alom.dorundorunbe.domain.ranking.dto;

import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;

@Data
@Schema(description = "웹소켓을 통해 전달되는 랭킹 참가자 정보 DTO")
public class RankingSocketUserDto{
@Schema(description = "사용자 ID", example = "101")
private Long userId;

@Schema(description = "사용자의 평균 점수", example = "85.0")
private Double averagePoint;

@Schema(description = "사용자의 랭킹 등수 (예: 1, 2, 3 등)", example = "1")
private Long grade;

public RankingSocketUserDto(Long userId, Double averagePoint, Long grade) {
this.userId = userId;
this.averagePoint = averagePoint;
this.grade = grade;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package com.alom.dorundorunbe.domain.ranking.repository;

import com.alom.dorundorunbe.global.enums.Tier;
import org.springframework.data.redis.core.ZSetOperations;

import java.util.Set;

public interface RankingCacheRepository {

// 사용자 점수를 Redis Sorted Set에 저장
void saveUserRanking(Tier tier, Long userId, Double avgPoint);

// 특정 티어의 랭킹 정보를 조회 (내림차순 정렬)
Set<ZSetOperations.TypedTuple<Object>> getTierRanking(Tier tier);



// 특정 티어의 모든 랭킹 데이터 삭제
void deleteTierRanking(Tier tier);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package com.alom.dorundorunbe.domain.ranking.repository;

import com.alom.dorundorunbe.domain.ranking.util.RankingCacheKeyUtil;
import com.alom.dorundorunbe.global.enums.Tier;
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ZSetOperations;
import org.springframework.stereotype.Repository;

import java.util.Set;

@Repository
@RequiredArgsConstructor
public class RankingCacheRepositoryImpl implements RankingCacheRepository {

private final RedisTemplate<String, Object> redisTemplate;

@Override
public void saveUserRanking(Tier tier, Long userId, Double avgPoint) {
String tierKey = RankingCacheKeyUtil.getTierRankingKey(tier);
redisTemplate.opsForZSet().add(tierKey, userId, avgPoint);
}

@Override
public Set<ZSetOperations.TypedTuple<Object>> getTierRanking(Tier tier) {
String tierKey = RankingCacheKeyUtil.getTierRankingKey(tier);
return redisTemplate.opsForZSet().reverseRangeWithScores(tierKey, 0, -1);
}



@Override
public void deleteTierRanking(Tier tier) {
String tierKey = RankingCacheKeyUtil.getTierRankingKey(tier);
redisTemplate.delete(tierKey);
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.alom.dorundorunbe.domain.ranking.service;

import com.alom.dorundorunbe.domain.ranking.domain.Ranking;
import com.alom.dorundorunbe.domain.ranking.repository.RankingCacheRepository;
import com.alom.dorundorunbe.domain.ranking.repository.RankingRepository;
import com.alom.dorundorunbe.domain.ranking.repository.UserRankingRepository;
import com.alom.dorundorunbe.global.exception.BusinessException;
Expand All @@ -18,13 +19,15 @@ public class RankingRewardService {//랭킹 보상 지급 로직
private final RankingRepository rankingRepository;
private final PointService pointService;
private final UserRankingRepository userRankingRepository;
private final RankingCacheRepository rankingCacheRepository;

@Transactional
public void processWeeklyRewards() {
List<Ranking> rankings = rankingRepository.findAll();
for (Ranking ranking : rankings) {
pointService.giveRankingRewardToUsersByRanking(ranking.getId());
deleteRankingRecords(ranking.getId());
rankingCacheRepository.deleteTierRanking(ranking.getTier());
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package com.alom.dorundorunbe.domain.ranking.service;

import com.alom.dorundorunbe.global.enums.Tier;
import lombok.RequiredArgsConstructor;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;

import java.util.Arrays;

@Service
@RequiredArgsConstructor
public class RankingScheduleService {//트랜잭션 처리 스케줄러내에서 프록시로 인해 동시 처리 안된다 하여 다른 외부 서비스에 구현(reward, sync service)
private final RankingRewardService rankingRewardService;
private final RankingSyncService rankingSyncService;
@Scheduled(cron = "0 0 0 * * MON",zone = "Asia/Seoul") // 매주 월요일 00:00 실행
public void distributeWeeklyRewardsAndClearRankings() {

Arrays.stream(Tier.values()).forEach(rankingSyncService::syncRankingForTier);
rankingRewardService.processWeeklyRewards();


}
//동기화 작업(write down방식 선택) cache에 일단 업데이트 쳐서 실시간 순위 및 평균 포인트 제공하고 30분단위로 db에 업데이트 치는 방식
@Scheduled(fixedDelay = 1800000L) // 30분마다 실행
public void syncRankingFromCacheToDb() {
Arrays.stream(Tier.values()).forEach(rankingSyncService::syncRankingForTier);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package com.alom.dorundorunbe.domain.ranking.service;

import com.alom.dorundorunbe.domain.ranking.repository.RankingCacheRepository;
import com.alom.dorundorunbe.domain.ranking.repository.UserRankingRepository;
import com.alom.dorundorunbe.global.enums.Tier;
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.ZSetOperations;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.Set;

@Service
@RequiredArgsConstructor
public class RankingSyncService {//캐쉬 db 동기화


private final RankingCacheRepository rankingCacheRepository;
private final UserRankingRepository userRankingRepository;


@Transactional
public void syncRankingForTier(Tier tier) {
Set<ZSetOperations.TypedTuple<Object>> rankingSet = rankingCacheRepository.getTierRanking(tier);
if (rankingSet == null || rankingSet.isEmpty()) { // 동기화 할 캐쉬가 없다면 리턴
return;
}

long counter = 0;
Long rank = null;
Double previousScore = null;


for (ZSetOperations.TypedTuple<Object> tuple : rankingSet) {
counter++;
Long userId = (Long) tuple.getValue();
Double avgScore = tuple.getScore();


avgScore = (avgScore != null) ? avgScore : -1.0;


if (avgScore == -1.0) {
rank = null;
} else {

if (previousScore == null || !avgScore.equals(previousScore)) {
rank = counter;
}
}


Long finalRank = (avgScore == -1.0) ? null : rank;
userRankingRepository.findByUserId(userId).ifPresent(userRanking -> {
if (userRanking.getGrade() == null || !userRanking.getGrade().equals(finalRank)) {
userRanking.updateGrade(finalRank);
}
});
previousScore = avgScore;
}
}


}
Loading
Loading