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
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, stat.userId(), stat.score());

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

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

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) {
long userId = getUserIdFromNickname(nickname);
long rowNum = requireNonNull(zSetOps.reverseRank(STAT_RANK, userId)) + 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, userId, deltaScore);
hashOps.increment(statUserKey, "totalGames", 1);
if (win) {
hashOps.increment(statUserKey, "winningGames", 1);
}
}

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

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

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

private StatResponse convertToStatResponse(TypedTuple<Object> rank, int rankValue) {
long userId = ((Number) requireNonNull(rank.getValue())).longValue();
Map<Object, Object> statUserMap = hashOps.entries(getStatUserKey(userId));

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 long getUserIdFromNickname(String nickname) {
return ((Number) requireNonNull(valueOps.get(getStatNickname(nickname)))).longValue();
}
}
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