Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
2 changes: 2 additions & 0 deletions backend/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ dependencies {
testRuntimeOnly 'com.h2database:h2'
testImplementation 'org.springframework.security:spring-security-test'
testImplementation 'com.github.database-rider:rider-spring:1.44.0'
testImplementation 'org.testcontainers:junit-jupiter'
testImplementation "com.redis:testcontainers-redis:2.2.4"

/* ETC */
implementation 'org.apache.commons:commons-lang3:3.12.0'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@
import org.springframework.data.web.PageableDefault;
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.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RestController
Expand All @@ -25,18 +25,8 @@ public class StatController {
@LimitPageSize
@GetMapping("/rankings")
public ResponseEntity<StatPageResponse> getRankings(
@RequestParam(required = false) String nickname,
@PageableDefault(sort = "score", direction = Direction.DESC) Pageable pageable) {
StatPageResponse response = statService.getRanks(pageable);

return ResponseEntity.ok().body(response);
}

@LimitPageSize
@GetMapping("/rankings/{nickname}")
public ResponseEntity<StatPageResponse> getRankingsByNickname(
@PathVariable String nickname, @PageableDefault Pageable pageable) {
StatPageResponse response =
statService.getRanksByNickname(nickname, pageable.getPageSize());
return ResponseEntity.ok().body(response);
return ResponseEntity.ok().body(statService.getRanks(pageable, nickname));
}
}
Original file line number Diff line number Diff line change
@@ -1,53 +1,55 @@
package io.f1.backend.domain.stat.app;

import static io.f1.backend.domain.stat.mapper.StatMapper.toStatListPageResponse;

import io.f1.backend.domain.stat.dao.StatRepository;
import io.f1.backend.domain.stat.dto.StatPageResponse;
import io.f1.backend.domain.stat.dto.StatWithNickname;
import io.f1.backend.global.exception.CustomException;
import io.f1.backend.global.exception.errorcode.RoomErrorCode;

import lombok.RequiredArgsConstructor;

import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.apache.commons.lang3.StringUtils;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.data.domain.Sort.Direction;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@Transactional
@RequiredArgsConstructor
public class StatService {

private final StatRepository statRepository;

@Transactional(readOnly = true)
public StatPageResponse getRanks(Pageable pageable) {
Page<StatWithNickname> stats = statRepository.findAllStatsWithUser(pageable);
return toStatListPageResponse(stats);
}
public StatPageResponse getRanks(Pageable pageable, String nickname) {
StatPageResponse response;

@Transactional(readOnly = true)
public StatPageResponse getRanksByNickname(String nickname, int pageSize) {
if (StringUtils.isBlank(nickname)) {
response = statRepository.getRanks(pageable);
} else {
response = statRepository.getRanksByNickname(nickname, pageable.getPageSize());
}

Page<StatWithNickname> stats =
statRepository.findAllStatsWithUser(getPageableFromNickname(nickname, pageSize));
if (response.totalElements() == 0) {
throw new CustomException(RoomErrorCode.PLAYER_NOT_FOUND);
}

return toStatListPageResponse(stats);
return response;
}

private Pageable getPageableFromNickname(String nickname, int pageSize) {
long score =
statRepository
.findScoreByNickname(nickname)
.orElseThrow(() -> new CustomException(RoomErrorCode.PLAYER_NOT_FOUND));
// TODO: 게임 종료 후 호출 필요
public void updateRank(long userId, boolean win, int deltaScore) {
statRepository.updateRank(userId, win, deltaScore);
}

long rowNum = statRepository.countByScoreGreaterThan(score);
public void addUser(long userId, String nickname) {
statRepository.addUser(userId, nickname);
}

public void removeUser(long userId) {
statRepository.removeUser(userId);
}

int pageNumber = rowNum > 0 ? (int) (rowNum / pageSize) : 0;
return PageRequest.of(pageNumber, pageSize, Sort.by(Direction.DESC, "score"));
public void updateNickname(long userId, String newNickname) {
statRepository.updateNickname(userId, newNickname);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package io.f1.backend.domain.stat.dao;

import io.f1.backend.domain.stat.dto.StatWithNickname;
import io.f1.backend.domain.stat.dto.StatWithNicknameAndUserId;
import io.f1.backend.domain.stat.entity.Stat;

import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;

import java.util.List;
import java.util.Optional;

public interface StatJpaRepository extends JpaRepository<Stat, Long> {

@Query(
"""
SELECT
new io.f1.backend.domain.stat.dto.StatWithNickname
(u.nickname, s.totalGames, s.winningGames, s.score)
FROM
Stat s JOIN s.user u
""")
Page<StatWithNickname> findAllStatsWithUser(Pageable pageable);

@Query("SELECT s.score FROM Stat s WHERE s.user.nickname = :nickname")
Optional<Long> findScoreByNickname(String nickname);

long countByScoreGreaterThan(Long score);

@Query(
"""
SELECT
new io.f1.backend.domain.stat.dto.StatWithNicknameAndUserId
(u.id, u.nickname, s.totalGames, s.winningGames, s.score)
FROM
Stat s JOIN s.user u
""")
List<StatWithNicknameAndUserId> findAllStatWithNicknameAndUserId();

@Modifying
@Query(
"""
UPDATE
Stat s
SET
s.totalGames = s.totalGames + 1, s.winningGames = s.winningGames + 1, s.score = s.score + :deltaScore
WHERE
s.user.id = :userId
""")
void updateStatByUserIdCaseWin(long deltaScore, long userId);

@Modifying
@Query(
"""
UPDATE
Stat s
SET
s.totalGames = s.totalGames + 1, s.score = s.score + :deltaScore
WHERE
s.user.id = :userId
""")
void updateStatByUserIdCaseLose(long deltaScore, long userId);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
package io.f1.backend.domain.stat.dao;

import static java.util.Objects.requireNonNull;

import io.f1.backend.domain.stat.dto.StatPageResponse;
import io.f1.backend.domain.stat.dto.StatResponse;
import io.f1.backend.domain.stat.dto.StatWithNicknameAndUserId;

import lombok.RequiredArgsConstructor;

import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.data.domain.Sort.Direction;
import org.springframework.data.redis.core.HashOperations;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.data.redis.core.ZSetOperations;
import org.springframework.data.redis.core.ZSetOperations.TypedTuple;
import org.springframework.stereotype.Repository;

import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.atomic.AtomicInteger;

@Repository
@RequiredArgsConstructor
public class StatRedisRepository {
private static final String STAT_RANK = "stat:rank";
private static final String STAT_USER = "stat:user:%d";
private static final String STAT_NICKNAME = "stat:%s";

private final RedisTemplate<String, Object> redisTemplate;
private ZSetOperations<String, Object> zSetOps;
private HashOperations<String, Object, Object> hashOps;
private ValueOperations<String, Object> valueOps;

public void setup() {
zSetOps = redisTemplate.opsForZSet();
hashOps = redisTemplate.opsForHash();
valueOps = redisTemplate.opsForValue();
}

public void initialize(StatWithNicknameAndUserId stat) {
String statUserKey = getStatUserKey(stat.userId());
String statNicknameKey = getStatNickname(stat.nickname());

// stat:user:{id}
hashOps.put(statUserKey, "nickname", stat.nickname());
hashOps.put(statUserKey, "totalGames", stat.totalGames());
hashOps.put(statUserKey, "winningGames", stat.winningGames());

// stat:rank
zSetOps.add(STAT_RANK, statUserKey, stat.score());

// stat:{nickname}
valueOps.set(statNicknameKey, statUserKey);
}

public void removeUser(long userId) {
String statUserKey = getStatUserKey(userId);
String nickname = (String) hashOps.get(statUserKey, "nickname");
redisTemplate.delete(getStatUserKey(userId));
valueOps.getAndDelete(getStatNickname(nickname));
zSetOps.remove(STAT_RANK, statUserKey);
}

public StatPageResponse findAllStatsWithUser(Pageable pageable) {
int page = pageable.getPageNumber();
int size = pageable.getPageSize();
long startIdx = (long) page * size;

long totalCount = getTotalMemberCount();
int totalPages = (int) Math.ceil((double) totalCount / size);

Set<TypedTuple<Object>> rankSet = getRankSet(startIdx, size);

AtomicInteger rankCounter = new AtomicInteger((int) startIdx + 1);
List<StatResponse> ranks =
rankSet.stream()
.map(rank -> convertToStatResponse(rank, rankCounter.getAndIncrement()))
.toList();

return new StatPageResponse(totalPages, page + 1, rankSet.size(), ranks);
}

public Pageable getPageableFromNickname(String nickname, int pageSize) {
String statUserKey = getStatUserKeyFromNickname(nickname);
long rowNum = requireNonNull(zSetOps.reverseRank(STAT_RANK, statUserKey)) + 1;
int pageNumber = rowNum > 0 ? (int) (rowNum / pageSize) : 0;
return PageRequest.of(pageNumber, pageSize, Sort.by(Direction.DESC, "score"));
}

private long getTotalMemberCount() {
long count = requireNonNull(zSetOps.zCard(STAT_RANK));
if (count == 0) {
throw new NullPointerException("No member found in redis");
}
return count;
}

private Set<TypedTuple<Object>> getRankSet(long startIdx, int size) {
Set<TypedTuple<Object>> result =
zSetOps.reverseRangeWithScores(STAT_RANK, startIdx, startIdx + size - 1);
return requireNonNull(result);
}

public void updateRank(long userId, boolean win, int deltaScore) {
String statUserKey = getStatUserKey(userId);

zSetOps.incrementScore(STAT_RANK, getStatUserKey(userId), deltaScore);
hashOps.increment(statUserKey, "totalGame", 1);
Copy link
Collaborator

@LimKangHyun LimKangHyun Jul 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[L4-변경제안]
totalGame과 winnigGame 키 명에 s가 붙어야 할 것 같습니다!

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

헉... update 로직은 아직 사용 위치가 없어 테스트를 안했더니,, 큰일날뻔했네요!
감사합니다 수정하겠습니다!

if (win) {
hashOps.increment(statUserKey, "winningGame", 1);
}
}

public void updateNickname(long userId, String newNickname) {
String statUserKey = getStatUserKey(userId);
valueOps.set(getStatNickname(newNickname), statUserKey);

String oldNickname = (String) hashOps.get(statUserKey, "nickname");
valueOps.getAndDelete(getStatNickname(oldNickname));

hashOps.put(statUserKey, "nickname", newNickname);
}

private StatResponse convertToStatResponse(TypedTuple<Object> rank, int rankValue) {
String statUserKey = (String) requireNonNull(rank.getValue());
Map<Object, Object> statUserMap = hashOps.entries(statUserKey);

return new StatResponse(
rankValue,
(String) statUserMap.get("nickname"),
(long) statUserMap.get("totalGames"),
(long) statUserMap.get("winningGames"),
requireNonNull(rank.getScore()).longValue());
}

private static String getStatUserKey(long userId) {
return String.format(STAT_USER, userId);
}

private static String getStatNickname(String nickname) {
return String.format(STAT_NICKNAME, nickname);
}

private String getStatUserKeyFromNickname(String nickname) {
return (String) requireNonNull(valueOps.get(getStatNickname(nickname)));
}
}
Original file line number Diff line number Diff line change
@@ -1,29 +1,20 @@
package io.f1.backend.domain.stat.dao;

import io.f1.backend.domain.stat.dto.StatWithNickname;
import io.f1.backend.domain.stat.entity.Stat;
import io.f1.backend.domain.stat.dto.StatPageResponse;

import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;

import java.util.Optional;
public interface StatRepository {

public interface StatRepository extends JpaRepository<Stat, Long> {
StatPageResponse getRanks(Pageable pageable);

@Query(
"""
SELECT
new io.f1.backend.domain.stat.dto.StatWithNickname
(u.nickname, s.totalGames, s.winningGames, s.score)
FROM
Stat s JOIN s.user u
""")
Page<StatWithNickname> findAllStatsWithUser(Pageable pageable);
StatPageResponse getRanksByNickname(String nickname, int pageSize);

@Query("SELECT s.score FROM Stat s WHERE s.user.nickname = :nickname")
Optional<Long> findScoreByNickname(String nickname);
void addUser(long userId, String nickname);

long countByScoreGreaterThan(Long score);
void updateRank(long userId, boolean win, int deltaScore);

void updateNickname(long userId, String nickname);

void removeUser(long userId);
}
Loading