diff --git a/backend/build.gradle b/backend/build.gradle index 619de390..692fafd2 100644 --- a/backend/build.gradle +++ b/backend/build.gradle @@ -24,6 +24,7 @@ repositories { } dependencies { + /* SPRING BOOT STARTER */ implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' @@ -31,10 +32,14 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-websocket' implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' + implementation 'org.springframework.boot:spring-boot-starter-actuator' /* DATABASE */ runtimeOnly 'com.mysql:mysql-connector-j' + /* MONITORING */ + implementation 'io.micrometer:micrometer-registry-prometheus' + /* TEST */ testImplementation 'org.springframework.boot:spring-boot-starter-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' diff --git a/backend/src/main/java/io/f1/backend/domain/auth/dto/CurrentUserAndAdminResponse.java b/backend/src/main/java/io/f1/backend/domain/auth/dto/CurrentUserAndAdminResponse.java index 1df1ccaa..d8579951 100644 --- a/backend/src/main/java/io/f1/backend/domain/auth/dto/CurrentUserAndAdminResponse.java +++ b/backend/src/main/java/io/f1/backend/domain/auth/dto/CurrentUserAndAdminResponse.java @@ -3,21 +3,19 @@ import io.f1.backend.domain.admin.dto.AdminPrincipal; import io.f1.backend.domain.user.dto.UserPrincipal; -public record CurrentUserAndAdminResponse(Long id, String name, String role, String providerId) { +public record CurrentUserAndAdminResponse(Long id, String name, String role) { public static CurrentUserAndAdminResponse from(UserPrincipal userPrincipal) { return new CurrentUserAndAdminResponse( userPrincipal.getUserId(), userPrincipal.getUserNickname(), - UserPrincipal.ROLE_USER, - userPrincipal.getName()); + UserPrincipal.ROLE_USER); } public static CurrentUserAndAdminResponse from(AdminPrincipal adminPrincipal) { return new CurrentUserAndAdminResponse( adminPrincipal.getAuthenticationAdmin().adminId(), adminPrincipal.getUsername(), - AdminPrincipal.ROLE_ADMIN, - null); + AdminPrincipal.ROLE_ADMIN); } } diff --git a/backend/src/main/java/io/f1/backend/domain/game/app/ChatService.java b/backend/src/main/java/io/f1/backend/domain/game/app/ChatService.java new file mode 100644 index 00000000..ae6df69f --- /dev/null +++ b/backend/src/main/java/io/f1/backend/domain/game/app/ChatService.java @@ -0,0 +1,48 @@ +package io.f1.backend.domain.game.app; + +import static io.f1.backend.domain.game.websocket.WebSocketUtils.getDestination; + +import io.f1.backend.domain.game.dto.ChatMessage; +import io.f1.backend.domain.game.dto.MessageType; +import io.f1.backend.domain.game.event.GameCorrectAnswerEvent; +import io.f1.backend.domain.game.model.Room; +import io.f1.backend.domain.game.websocket.MessageSender; +import io.f1.backend.domain.question.entity.Question; + +import lombok.RequiredArgsConstructor; + +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class ChatService { + + private final RoomService roomService; + private final TimerService timerService; + private final MessageSender messageSender; + private final ApplicationEventPublisher eventPublisher; + + // todo 동시성적용 + public void chat(Long roomId, String sessionId, ChatMessage chatMessage) { + + Room room = roomService.findRoom(roomId); + + String destination = getDestination(roomId); + + messageSender.sendBroadcast(destination, MessageType.CHAT, chatMessage); + + if (!room.isPlaying()) { + return; + } + + Question currentQuestion = room.getCurrentQuestion(); + + String answer = currentQuestion.getAnswer(); + + if (answer.equals(chatMessage.message())) { + eventPublisher.publishEvent( + new GameCorrectAnswerEvent(room, sessionId, chatMessage, answer)); + } + } +} diff --git a/backend/src/main/java/io/f1/backend/domain/game/app/GameService.java b/backend/src/main/java/io/f1/backend/domain/game/app/GameService.java index 2ecae340..72fd186e 100644 --- a/backend/src/main/java/io/f1/backend/domain/game/app/GameService.java +++ b/backend/src/main/java/io/f1/backend/domain/game/app/GameService.java @@ -1,13 +1,23 @@ package io.f1.backend.domain.game.app; +import static io.f1.backend.domain.game.mapper.RoomMapper.ofPlayerEvent; +import static io.f1.backend.domain.game.mapper.RoomMapper.toGameResultListResponse; import static io.f1.backend.domain.game.mapper.RoomMapper.toGameSettingResponse; import static io.f1.backend.domain.game.mapper.RoomMapper.toPlayerListResponse; +import static io.f1.backend.domain.game.mapper.RoomMapper.toQuestionResultResponse; import static io.f1.backend.domain.game.mapper.RoomMapper.toQuestionStartResponse; +import static io.f1.backend.domain.game.mapper.RoomMapper.toRankUpdateResponse; +import static io.f1.backend.domain.game.mapper.RoomMapper.toRoomSettingResponse; +import static io.f1.backend.domain.game.websocket.WebSocketUtils.getDestination; import static io.f1.backend.domain.quiz.mapper.QuizMapper.toGameStartResponse; +import io.f1.backend.domain.game.dto.ChatMessage; import io.f1.backend.domain.game.dto.MessageType; +import io.f1.backend.domain.game.dto.RoomEventType; import io.f1.backend.domain.game.dto.request.GameSettingChanger; import io.f1.backend.domain.game.dto.response.PlayerListResponse; +import io.f1.backend.domain.game.event.GameCorrectAnswerEvent; +import io.f1.backend.domain.game.event.GameTimeoutEvent; import io.f1.backend.domain.game.event.RoomUpdatedEvent; import io.f1.backend.domain.game.model.Player; import io.f1.backend.domain.game.model.Room; @@ -26,9 +36,11 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.event.EventListener; import org.springframework.stereotype.Service; import java.util.List; +import java.util.Map; import java.util.Objects; @Slf4j @@ -36,11 +48,14 @@ @RequiredArgsConstructor public class GameService { - public static final int START_DELAY = 5; + private static final int START_DELAY = 5; + private static final int CONTINUE_DELAY = 3; + private static final String NONE_CORRECT_USER = ""; - private final MessageSender messageSender; - private final TimerService timerService; private final QuizService quizService; + private final RoomService roomService; + private final TimerService timerService; + private final MessageSender messageSender; private final RoomRepository roomRepository; private final ApplicationEventPublisher eventPublisher; @@ -67,13 +82,114 @@ public void gameStart(Long roomId, UserPrincipal principal) { timerService.startTimer(room, START_DELAY); - messageSender.send(destination, MessageType.GAME_START, toGameStartResponse(questions)); - messageSender.send( + messageSender.sendBroadcast( + destination, MessageType.GAME_START, toGameStartResponse(questions)); + messageSender.sendBroadcast( + destination, MessageType.RANK_UPDATE, toRankUpdateResponse(room)); + messageSender.sendBroadcast( destination, MessageType.QUESTION_START, toQuestionStartResponse(room, START_DELAY)); } + @EventListener + public void onCorrectAnswer(GameCorrectAnswerEvent event) { + + Room room = event.room(); + String sessionId = event.sessionId(); + ChatMessage chatMessage = event.chatMessage(); + String answer = event.answer(); + + String destination = getDestination(room.getId()); + + room.increasePlayerCorrectCount(sessionId); + + messageSender.sendBroadcast( + destination, + MessageType.QUESTION_RESULT, + toQuestionResultResponse(chatMessage.nickname(), answer)); + messageSender.sendBroadcast( + destination, MessageType.RANK_UPDATE, toRankUpdateResponse(room)); + messageSender.sendBroadcast( + destination, + MessageType.SYSTEM_NOTICE, + ofPlayerEvent(chatMessage.nickname(), RoomEventType.CORRECT_ANSWER)); + + timerService.cancelTimer(room); + + if (!timerService.validateCurrentRound(room)) { + gameEnd(room); + return; + } + + room.increaseCurrentRound(); + + // 타이머 추가하기 + timerService.startTimer(room, CONTINUE_DELAY); + messageSender.sendBroadcast( + destination, + MessageType.QUESTION_START, + toQuestionStartResponse(room, CONTINUE_DELAY)); + } + + @EventListener + public void onTimeout(GameTimeoutEvent event) { + Room room = event.room(); + String destination = getDestination(room.getId()); + + messageSender.sendBroadcast( + destination, + MessageType.QUESTION_RESULT, + toQuestionResultResponse(NONE_CORRECT_USER, room.getCurrentQuestion().getAnswer())); + messageSender.sendBroadcast( + destination, + MessageType.SYSTEM_NOTICE, + ofPlayerEvent(NONE_CORRECT_USER, RoomEventType.TIMEOUT)); + + if (!timerService.validateCurrentRound(room)) { + gameEnd(room); + return; + } + + room.increaseCurrentRound(); + + timerService.startTimer(room, CONTINUE_DELAY); + messageSender.sendBroadcast( + destination, + MessageType.QUESTION_START, + toQuestionStartResponse(room, CONTINUE_DELAY)); + } + + public void gameEnd(Room room) { + Long roomId = room.getId(); + String destination = getDestination(roomId); + + Map playerSessionMap = room.getPlayerSessionMap(); + + // TODO : 랭킹 정보 업데이트 + messageSender.sendBroadcast( + destination, + MessageType.GAME_RESULT, + toGameResultListResponse(playerSessionMap, room.getGameSetting().getRound())); + + room.initializeRound(); + room.initializePlayers(); + + List disconnectedPlayers = room.getDisconnectedPlayers(); + roomService.handleDisconnectedPlayers(room, disconnectedPlayers); + + room.updateRoomState(RoomState.WAITING); + + messageSender.sendBroadcast( + destination, + MessageType.GAME_SETTING, + toGameSettingResponse( + room.getGameSetting(), + quizService.getQuizWithQuestionsById(room.getGameSetting().getQuizId()))); + messageSender.sendBroadcast( + destination, MessageType.ROOM_SETTING, toRoomSettingResponse(room)); + } + public void handlePlayerReady(Long roomId, String sessionId) { Room room = findRoom(roomId); @@ -86,7 +202,7 @@ public void handlePlayerReady(Long roomId, String sessionId) { PlayerListResponse playerListResponse = toPlayerListResponse(room); log.info(playerListResponse.toString()); - messageSender.send(destination, MessageType.PLAYER_LIST, playerListResponse); + messageSender.sendBroadcast(destination, MessageType.PLAYER_LIST, playerListResponse); } public void changeGameSetting( @@ -136,10 +252,6 @@ private Room findRoom(Long roomId) { .orElseThrow(() -> new CustomException(RoomErrorCode.ROOM_NOT_FOUND)); } - private String getDestination(Long roomId) { - return "/sub/room/" + roomId; - } - private void validateHostAndState(Room room, UserPrincipal principal) { if (!room.isHost(principal.getUserId())) { throw new CustomException(RoomErrorCode.NOT_ROOM_OWNER); @@ -161,7 +273,7 @@ private void toggleReadyIfPossible(Room room, Player player) { private void broadcastGameSetting(Room room) { String destination = getDestination(room.getId()); Quiz quiz = quizService.getQuizWithQuestionsById(room.getGameSetting().getQuizId()); - messageSender.send( + messageSender.sendBroadcast( destination, MessageType.GAME_SETTING, toGameSettingResponse(room.getGameSetting(), quiz)); diff --git a/backend/src/main/java/io/f1/backend/domain/game/app/RoomService.java b/backend/src/main/java/io/f1/backend/domain/game/app/RoomService.java index 2ca33fa8..db2a8f96 100644 --- a/backend/src/main/java/io/f1/backend/domain/game/app/RoomService.java +++ b/backend/src/main/java/io/f1/backend/domain/game/app/RoomService.java @@ -1,12 +1,9 @@ package io.f1.backend.domain.game.app; -import static io.f1.backend.domain.game.app.GameService.START_DELAY; import static io.f1.backend.domain.game.mapper.RoomMapper.ofPlayerEvent; import static io.f1.backend.domain.game.mapper.RoomMapper.toGameSetting; import static io.f1.backend.domain.game.mapper.RoomMapper.toGameSettingResponse; import static io.f1.backend.domain.game.mapper.RoomMapper.toPlayerListResponse; -import static io.f1.backend.domain.game.mapper.RoomMapper.toQuestionResultResponse; -import static io.f1.backend.domain.game.mapper.RoomMapper.toQuestionStartResponse; import static io.f1.backend.domain.game.mapper.RoomMapper.toRankUpdateResponse; import static io.f1.backend.domain.game.mapper.RoomMapper.toRoomResponse; import static io.f1.backend.domain.game.mapper.RoomMapper.toRoomSetting; @@ -16,11 +13,11 @@ import static io.f1.backend.global.util.SecurityUtils.getCurrentUserId; import static io.f1.backend.global.util.SecurityUtils.getCurrentUserNickname; -import io.f1.backend.domain.game.dto.ChatMessage; import io.f1.backend.domain.game.dto.MessageType; import io.f1.backend.domain.game.dto.RoomEventType; import io.f1.backend.domain.game.dto.request.RoomCreateRequest; import io.f1.backend.domain.game.dto.request.RoomValidationRequest; +import io.f1.backend.domain.game.dto.response.ExitSuccessResponse; import io.f1.backend.domain.game.dto.response.GameSettingResponse; import io.f1.backend.domain.game.dto.response.PlayerListResponse; import io.f1.backend.domain.game.dto.response.RoomCreateResponse; @@ -37,7 +34,6 @@ import io.f1.backend.domain.game.model.RoomState; import io.f1.backend.domain.game.store.RoomRepository; import io.f1.backend.domain.game.websocket.MessageSender; -import io.f1.backend.domain.question.entity.Question; import io.f1.backend.domain.quiz.app.QuizService; import io.f1.backend.domain.quiz.dto.QuizMinData; import io.f1.backend.domain.quiz.entity.Quiz; @@ -62,7 +58,6 @@ @RequiredArgsConstructor public class RoomService { - private final TimerService timerService; private final QuizService quizService; private final RoomRepository roomRepository; private final AtomicLong roomIdGenerator = new AtomicLong(0); @@ -149,10 +144,12 @@ public void initializeRoomSocket(Long roomId, String sessionId, UserPrincipal pr String destination = getDestination(roomId); - messageSender.send(destination, MessageType.ROOM_SETTING, roomSettingResponse); - messageSender.send(destination, MessageType.GAME_SETTING, gameSettingResponse); - messageSender.send(destination, MessageType.PLAYER_LIST, playerListResponse); - messageSender.send(destination, MessageType.SYSTEM_NOTICE, systemNoticeResponse); + messageSender.sendPersonal( + getUserDestination(), MessageType.GAME_SETTING, gameSettingResponse, principal); + + messageSender.sendBroadcast(destination, MessageType.ROOM_SETTING, roomSettingResponse); + messageSender.sendBroadcast(destination, MessageType.PLAYER_LIST, playerListResponse); + messageSender.sendBroadcast(destination, MessageType.SYSTEM_NOTICE, systemNoticeResponse); } public void exitRoom(Long roomId, String sessionId, UserPrincipal principal) { @@ -166,27 +163,22 @@ public void exitRoom(Long roomId, String sessionId, UserPrincipal principal) { String destination = getDestination(roomId); - /* 방 삭제 */ - if (room.isLastPlayer(sessionId)) { - removeRoom(room); - return; - } - - /* 방장 변경 */ - if (room.isHost(removePlayer.getId())) { - changeHost(room, sessionId); - } + messageSender.sendPersonal( + getUserDestination(), + MessageType.EXIT_SUCCESS, + new ExitSuccessResponse(true), + principal); - /* 플레이어 삭제 */ - removePlayer(room, sessionId, removePlayer); + cleanRoom(room, sessionId, removePlayer); SystemNoticeResponse systemNoticeResponse = ofPlayerEvent(removePlayer.nickname, RoomEventType.EXIT); PlayerListResponse playerListResponse = toPlayerListResponse(room); - messageSender.send(destination, MessageType.PLAYER_LIST, playerListResponse); - messageSender.send(destination, MessageType.SYSTEM_NOTICE, systemNoticeResponse); + messageSender.sendBroadcast(destination, MessageType.PLAYER_LIST, playerListResponse); + messageSender.sendBroadcast( + destination, MessageType.SYSTEM_NOTICE, systemNoticeResponse); } } @@ -205,75 +197,36 @@ public RoomListResponse getAllRooms() { return new RoomListResponse(roomResponses); } - // todo 동시성적용 - public void chat(Long roomId, String sessionId, ChatMessage chatMessage) { - - Room room = findRoom(roomId); - - String destination = getDestination(roomId); - - messageSender.send(destination, MessageType.CHAT, chatMessage); - - if (!room.isPlaying()) { - return; - } - - Question currentQuestion = room.getCurrentQuestion(); - - String answer = currentQuestion.getAnswer(); - - if (answer.equals(chatMessage.message())) { - room.increasePlayerCorrectCount(sessionId); - - messageSender.send( - destination, - MessageType.QUESTION_RESULT, - toQuestionResultResponse(chatMessage.nickname(), answer)); - messageSender.send(destination, MessageType.RANK_UPDATE, toRankUpdateResponse(room)); - messageSender.send( - destination, - MessageType.SYSTEM_NOTICE, - ofPlayerEvent(chatMessage.nickname(), RoomEventType.CORRECT_ANSWER)); - - timerService.cancelTimer(room); - - // TODO : 게임 종료 로직 추가 - if (!timerService.validateCurrentRound(room)) { - // 게임 종료 로직 - return; - } - - room.increaseCurrentRound(); - - // 타이머 추가하기 - timerService.startTimer(room, CONTINUE_DELAY); - messageSender.send( - destination, - MessageType.QUESTION_START, - toQuestionStartResponse(room, CONTINUE_DELAY)); - } - } - public void reconnectSession( Long roomId, String oldSessionId, String newSessionId, UserPrincipal principal) { Room room = findRoom(roomId); room.reconnectSession(oldSessionId, newSessionId); String destination = getDestination(roomId); + String userDestination = getUserDestination(); - messageSender.send( + messageSender.sendBroadcast( destination, MessageType.SYSTEM_NOTICE, ofPlayerEvent(principal.getUserNickname(), RoomEventType.RECONNECT)); if (room.isPlaying()) { - // todo 랭킹 리스트 추가 - messageSender.send( - destination, MessageType.GAME_START, toGameStartResponse(room.getQuestions())); - messageSender.send( - destination, - MessageType.QUESTION_START, - toQuestionStartResponse(room, START_DELAY)); + messageSender.sendPersonal( + userDestination, + MessageType.SYSTEM_NOTICE, + ofPlayerEvent( + principal.getUserNickname(), RoomEventType.RECONNECT_PRIVATE_NOTICE), + principal); + messageSender.sendPersonal( + userDestination, + MessageType.RANK_UPDATE, + toRankUpdateResponse(room), + principal); + messageSender.sendPersonal( + userDestination, + MessageType.GAME_START, + toGameStartResponse(room.getQuestions()), + principal); } else { RoomSettingResponse roomSettingResponse = toRoomSettingResponse(room); @@ -286,9 +239,12 @@ public void reconnectSession( PlayerListResponse playerListResponse = toPlayerListResponse(room); - messageSender.send(destination, MessageType.ROOM_SETTING, roomSettingResponse); - messageSender.send(destination, MessageType.GAME_SETTING, gameSettingResponse); - messageSender.send(destination, MessageType.PLAYER_LIST, playerListResponse); + messageSender.sendPersonal( + userDestination, MessageType.ROOM_SETTING, roomSettingResponse, principal); + messageSender.sendPersonal( + userDestination, MessageType.PLAYER_LIST, playerListResponse, principal); + messageSender.sendPersonal( + userDestination, MessageType.GAME_SETTING, gameSettingResponse, principal); } } @@ -326,7 +282,7 @@ private Player createPlayer() { return new Player(getCurrentUserId(), getCurrentUserNickname()); } - private Room findRoom(Long roomId) { + public Room findRoom(Long roomId) { return roomRepository .findRoom(roomId) .orElseThrow(() -> new CustomException(RoomErrorCode.ROOM_NOT_FOUND)); @@ -362,4 +318,53 @@ private void removePlayer(Room room, String sessionId, Player removePlayer) { room.removeSessionId(sessionId); room.removeValidatedUserId(removePlayer.getId()); } + + private String getUserDestination() { + return "/queue"; + } + + public void exitRoomForDisconnectedPlayer(Long roomId, Player player, String sessionId) { + + Object lock = roomLocks.computeIfAbsent(roomId, k -> new Object()); + + synchronized (lock) { + // 연결 끊긴 플레이어 exit 로직 타게 해주기 + Room room = findRoom(roomId); + + cleanRoom(room, sessionId, player); + + String destination = getDestination(roomId); + + SystemNoticeResponse systemNoticeResponse = + ofPlayerEvent(player.nickname, RoomEventType.EXIT); + + messageSender.sendBroadcast( + destination, MessageType.PLAYER_LIST, toPlayerListResponse(room)); + messageSender.sendBroadcast( + destination, MessageType.SYSTEM_NOTICE, systemNoticeResponse); + } + } + + private void cleanRoom(Room room, String sessionId, Player player) { + /* 방 삭제 */ + if (room.isLastPlayer(sessionId)) { + removeRoom(room); + return; + } + + /* 방장 변경 */ + if (room.isHost(player.getId())) { + changeHost(room, sessionId); + } + + /* 플레이어 삭제 */ + removePlayer(room, sessionId, player); + } + + public void handleDisconnectedPlayers(Room room, List disconnectedPlayers) { + for (Player player : disconnectedPlayers) { + String sessionId = room.getSessionIdByUserId(player.getId()); + exitRoomForDisconnectedPlayer(room.getId(), player, sessionId); + } + } } diff --git a/backend/src/main/java/io/f1/backend/domain/game/app/TimerService.java b/backend/src/main/java/io/f1/backend/domain/game/app/TimerService.java index 407892e0..93a06df8 100644 --- a/backend/src/main/java/io/f1/backend/domain/game/app/TimerService.java +++ b/backend/src/main/java/io/f1/backend/domain/game/app/TimerService.java @@ -1,17 +1,11 @@ package io.f1.backend.domain.game.app; -import static io.f1.backend.domain.game.mapper.RoomMapper.ofPlayerEvent; -import static io.f1.backend.domain.game.mapper.RoomMapper.toQuestionResultResponse; -import static io.f1.backend.domain.game.mapper.RoomMapper.toQuestionStartResponse; -import static io.f1.backend.domain.game.websocket.WebSocketUtils.getDestination; - -import io.f1.backend.domain.game.dto.MessageType; -import io.f1.backend.domain.game.dto.RoomEventType; +import io.f1.backend.domain.game.event.GameTimeoutEvent; import io.f1.backend.domain.game.model.Room; -import io.f1.backend.domain.game.websocket.MessageSender; import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import java.util.concurrent.ScheduledFuture; @@ -21,10 +15,7 @@ @RequiredArgsConstructor public class TimerService { - private final MessageSender messageSender; - - private static final String NONE_CORRECT_USER = ""; - private static final int CONTINUE_DELAY = 3; + private final ApplicationEventPublisher eventPublisher; public void startTimer(Room room, int delaySec) { cancelTimer(room); @@ -33,7 +24,7 @@ public void startTimer(Room room, int delaySec) { room.getScheduler() .schedule( () -> { - handleTimeout(room); + eventPublisher.publishEvent(new GameTimeoutEvent(room)); }, delaySec + room.getGameSetting().getTimeLimit(), TimeUnit.SECONDS); @@ -41,35 +32,6 @@ public void startTimer(Room room, int delaySec) { room.updateTimer(timer); } - private void handleTimeout(Room room) { - String destination = getDestination(room.getId()); - - messageSender.send( - destination, - MessageType.QUESTION_RESULT, - toQuestionResultResponse(NONE_CORRECT_USER, room.getCurrentQuestion().getAnswer())); - messageSender.send( - destination, - MessageType.SYSTEM_NOTICE, - ofPlayerEvent(NONE_CORRECT_USER, RoomEventType.TIMEOUT)); - - // TODO : 게임 종료 로직 - if (!validateCurrentRound(room)) { - // 게임 종료 로직 - // GAME_SETTING, PLAYER_LIST, GAME_RESULT, ROOM_SETTING - return; - } - - // 다음 문제 출제 - room.increaseCurrentRound(); - - startTimer(room, CONTINUE_DELAY); - messageSender.send( - destination, - MessageType.QUESTION_START, - toQuestionStartResponse(room, CONTINUE_DELAY)); - } - public boolean validateCurrentRound(Room room) { if (room.getGameSetting().getRound() != room.getCurrentRound()) { return true; diff --git a/backend/src/main/java/io/f1/backend/domain/game/dto/MessageType.java b/backend/src/main/java/io/f1/backend/domain/game/dto/MessageType.java index b2b79f20..0e0f39a6 100644 --- a/backend/src/main/java/io/f1/backend/domain/game/dto/MessageType.java +++ b/backend/src/main/java/io/f1/backend/domain/game/dto/MessageType.java @@ -9,5 +9,7 @@ public enum MessageType { CHAT, QUESTION_RESULT, RANK_UPDATE, - QUESTION_START + QUESTION_START, + GAME_RESULT, + EXIT_SUCCESS } diff --git a/backend/src/main/java/io/f1/backend/domain/game/dto/RoomEventType.java b/backend/src/main/java/io/f1/backend/domain/game/dto/RoomEventType.java index f9cdce92..924af183 100644 --- a/backend/src/main/java/io/f1/backend/domain/game/dto/RoomEventType.java +++ b/backend/src/main/java/io/f1/backend/domain/game/dto/RoomEventType.java @@ -7,7 +7,8 @@ public enum RoomEventType { END(null), CORRECT_ANSWER(SystemNoticeMessage.CORRECT_ANSWER), TIMEOUT(SystemNoticeMessage.TIMEOUT), - RECONNECT(SystemNoticeMessage.RECONNECT); + RECONNECT(SystemNoticeMessage.RECONNECT), + RECONNECT_PRIVATE_NOTICE(SystemNoticeMessage.RECONNECT_PRIVATE_NOTICE); private final SystemNoticeMessage systemMessage; @@ -17,7 +18,7 @@ public enum RoomEventType { public String getMessage(String nickname) { - if (this == TIMEOUT) { + if (this == TIMEOUT || this == RECONNECT_PRIVATE_NOTICE) { return systemMessage.getMessage(); } diff --git a/backend/src/main/java/io/f1/backend/domain/game/dto/SystemNoticeMessage.java b/backend/src/main/java/io/f1/backend/domain/game/dto/SystemNoticeMessage.java index 636e5bf1..31f2e345 100644 --- a/backend/src/main/java/io/f1/backend/domain/game/dto/SystemNoticeMessage.java +++ b/backend/src/main/java/io/f1/backend/domain/game/dto/SystemNoticeMessage.java @@ -5,7 +5,8 @@ public enum SystemNoticeMessage { EXIT(" 님이 퇴장하셨습니다"), CORRECT_ANSWER(" 님 정답입니다 !"), TIMEOUT("땡 ~ ⏰ 제한 시간 초과!"), - RECONNECT(" 님이 재연결 되었습니다."); + RECONNECT(" 님이 재연결 되었습니다. "), + RECONNECT_PRIVATE_NOTICE("⚠️다음 라운드부터 참여하실 수 있습니다"); private final String message; diff --git a/backend/src/main/java/io/f1/backend/domain/game/dto/request/QuizChangeRequest.java b/backend/src/main/java/io/f1/backend/domain/game/dto/request/QuizChangeRequest.java index 3ec2f497..1237a44c 100644 --- a/backend/src/main/java/io/f1/backend/domain/game/dto/request/QuizChangeRequest.java +++ b/backend/src/main/java/io/f1/backend/domain/game/dto/request/QuizChangeRequest.java @@ -8,7 +8,6 @@ import io.f1.backend.domain.game.model.Room; import io.f1.backend.domain.game.websocket.MessageSender; import io.f1.backend.domain.quiz.app.QuizService; -import io.f1.backend.domain.quiz.entity.Quiz; import lombok.extern.slf4j.Slf4j; @@ -20,11 +19,8 @@ public boolean change(Room room, QuizService quizService) { if (room.getQuizId() == quizId) { return false; // 동일하면 무시 } - Quiz quiz = quizService.getQuizWithQuestionsById(quizId); - int questionSize = quiz.getQuestions().size(); - room.changeQuiz(quiz); - // 퀴즈의 문제 갯수로 변경 - room.changeRound(questionSize, questionSize); + Long questionsCount = quizService.getQuestionsCount(quizId); + room.changeQuiz(quizId, questionsCount.intValue()); return true; } @@ -36,6 +32,6 @@ public void afterChange(Room room, MessageSender messageSender) { PlayerListResponse response = toPlayerListResponse(room); log.info(response.toString()); - messageSender.send(destination, MessageType.PLAYER_LIST, response); + messageSender.sendBroadcast(destination, MessageType.PLAYER_LIST, response); } } diff --git a/backend/src/main/java/io/f1/backend/domain/game/dto/request/RoundChangeRequest.java b/backend/src/main/java/io/f1/backend/domain/game/dto/request/RoundChangeRequest.java index a3ad914e..b44cb72a 100644 --- a/backend/src/main/java/io/f1/backend/domain/game/dto/request/RoundChangeRequest.java +++ b/backend/src/main/java/io/f1/backend/domain/game/dto/request/RoundChangeRequest.java @@ -3,7 +3,6 @@ import io.f1.backend.domain.game.model.Room; import io.f1.backend.domain.game.websocket.MessageSender; import io.f1.backend.domain.quiz.app.QuizService; -import io.f1.backend.domain.quiz.entity.Quiz; public record RoundChangeRequest(int round) implements GameSettingChanger { @@ -13,10 +12,9 @@ public boolean change(Room room, QuizService quizService) { return false; // 동일하면 무시 } - Quiz quiz = quizService.findQuizById(room.getQuizId()); - int questionSize = quiz.getQuestions().size(); + Long questionsCount = quizService.getQuestionsCount(room.getQuizId()); - room.changeRound(round, questionSize); + room.changeRound(round, questionsCount.intValue()); return true; } diff --git a/backend/src/main/java/io/f1/backend/domain/game/dto/response/ExitSuccessResponse.java b/backend/src/main/java/io/f1/backend/domain/game/dto/response/ExitSuccessResponse.java new file mode 100644 index 00000000..aa9d9ed9 --- /dev/null +++ b/backend/src/main/java/io/f1/backend/domain/game/dto/response/ExitSuccessResponse.java @@ -0,0 +1,3 @@ +package io.f1.backend.domain.game.dto.response; + +public record ExitSuccessResponse(boolean success) {} diff --git a/backend/src/main/java/io/f1/backend/domain/game/dto/response/GameResultListResponse.java b/backend/src/main/java/io/f1/backend/domain/game/dto/response/GameResultListResponse.java new file mode 100644 index 00000000..c2c38d46 --- /dev/null +++ b/backend/src/main/java/io/f1/backend/domain/game/dto/response/GameResultListResponse.java @@ -0,0 +1,5 @@ +package io.f1.backend.domain.game.dto.response; + +import java.util.List; + +public record GameResultListResponse(List result) {} diff --git a/backend/src/main/java/io/f1/backend/domain/game/dto/response/GameResultResponse.java b/backend/src/main/java/io/f1/backend/domain/game/dto/response/GameResultResponse.java new file mode 100644 index 00000000..8be85b6a --- /dev/null +++ b/backend/src/main/java/io/f1/backend/domain/game/dto/response/GameResultResponse.java @@ -0,0 +1,3 @@ +package io.f1.backend.domain.game.dto.response; + +public record GameResultResponse(String nickname, int score, int totalCorrectCount, int rank) {} diff --git a/backend/src/main/java/io/f1/backend/domain/game/dto/response/QuestionStartResponse.java b/backend/src/main/java/io/f1/backend/domain/game/dto/response/QuestionStartResponse.java index c6788e0f..9c502931 100644 --- a/backend/src/main/java/io/f1/backend/domain/game/dto/response/QuestionStartResponse.java +++ b/backend/src/main/java/io/f1/backend/domain/game/dto/response/QuestionStartResponse.java @@ -2,4 +2,10 @@ import java.time.Instant; -public record QuestionStartResponse(Long questionId, int round, Instant timestamp) {} +public record QuestionStartResponse( + Long questionId, + int round, + Instant timestamp, + int timeLimit, + Instant serverTime, + int totalRound) {} diff --git a/backend/src/main/java/io/f1/backend/domain/game/event/GameCorrectAnswerEvent.java b/backend/src/main/java/io/f1/backend/domain/game/event/GameCorrectAnswerEvent.java new file mode 100644 index 00000000..76f5bcd1 --- /dev/null +++ b/backend/src/main/java/io/f1/backend/domain/game/event/GameCorrectAnswerEvent.java @@ -0,0 +1,7 @@ +package io.f1.backend.domain.game.event; + +import io.f1.backend.domain.game.dto.ChatMessage; +import io.f1.backend.domain.game.model.Room; + +public record GameCorrectAnswerEvent( + Room room, String sessionId, ChatMessage chatMessage, String answer) {} diff --git a/backend/src/main/java/io/f1/backend/domain/game/event/GameTimeoutEvent.java b/backend/src/main/java/io/f1/backend/domain/game/event/GameTimeoutEvent.java new file mode 100644 index 00000000..1670cc90 --- /dev/null +++ b/backend/src/main/java/io/f1/backend/domain/game/event/GameTimeoutEvent.java @@ -0,0 +1,5 @@ +package io.f1.backend.domain.game.event; + +import io.f1.backend.domain.game.model.Room; + +public record GameTimeoutEvent(Room room) {} diff --git a/backend/src/main/java/io/f1/backend/domain/game/mapper/RoomMapper.java b/backend/src/main/java/io/f1/backend/domain/game/mapper/RoomMapper.java index 206282d5..1731385b 100644 --- a/backend/src/main/java/io/f1/backend/domain/game/mapper/RoomMapper.java +++ b/backend/src/main/java/io/f1/backend/domain/game/mapper/RoomMapper.java @@ -3,6 +3,8 @@ import io.f1.backend.domain.game.dto.Rank; import io.f1.backend.domain.game.dto.RoomEventType; import io.f1.backend.domain.game.dto.request.RoomCreateRequest; +import io.f1.backend.domain.game.dto.response.GameResultListResponse; +import io.f1.backend.domain.game.dto.response.GameResultResponse; import io.f1.backend.domain.game.dto.response.GameSettingResponse; import io.f1.backend.domain.game.dto.response.PlayerListResponse; import io.f1.backend.domain.game.dto.response.PlayerResponse; @@ -21,8 +23,10 @@ import io.f1.backend.domain.quiz.entity.Quiz; import java.time.Instant; +import java.util.ArrayList; import java.util.Comparator; import java.util.List; +import java.util.Map; public class RoomMapper { @@ -106,6 +110,52 @@ public static QuestionStartResponse toQuestionStartResponse(Room room, int delay return new QuestionStartResponse( room.getCurrentQuestion().getId(), room.getCurrentRound(), - Instant.now().plusSeconds(delay)); + Instant.now().plusSeconds(delay), + room.getGameSetting().getTimeLimit(), + Instant.now(), + room.getGameSetting().getRound()); + } + + public static GameResultResponse toGameResultResponse( + Player player, int round, int rank, int totalPlayers) { + double correctRate = (double) player.getCorrectCount() / round; + int score = (int) (correctRate * 100) + (totalPlayers - rank) * 5; + + return new GameResultResponse(player.nickname, score, player.getCorrectCount(), rank); + } + + public static GameResultListResponse toGameResultListResponse( + Map playerSessionMap, int round) { + + List rankedPlayers = + playerSessionMap.values().stream() + .sorted(Comparator.comparingInt(Player::getCorrectCount).reversed()) + .toList(); + + List gameResults = buildRankedGameResults(rankedPlayers, round); + + return new GameResultListResponse(gameResults); + } + + private static List buildRankedGameResults( + List rankedPlayers, int round) { + int totalPlayers = rankedPlayers.size(); + int prevCorrectCnt = -1; + int rank = 0; + + List results = new ArrayList<>(); + for (int i = 0; i < totalPlayers; i++) { + Player player = rankedPlayers.get(i); + + int correctCnt = player.getCorrectCount(); + + if (prevCorrectCnt != correctCnt) { + rank = i + 1; + } + + results.add(toGameResultResponse(player, round, rank, totalPlayers)); + prevCorrectCnt = correctCnt; + } + return results; } } diff --git a/backend/src/main/java/io/f1/backend/domain/game/model/GameSetting.java b/backend/src/main/java/io/f1/backend/domain/game/model/GameSetting.java index 9c38bd68..3c644453 100644 --- a/backend/src/main/java/io/f1/backend/domain/game/model/GameSetting.java +++ b/backend/src/main/java/io/f1/backend/domain/game/model/GameSetting.java @@ -1,7 +1,6 @@ package io.f1.backend.domain.game.model; import io.f1.backend.domain.game.dto.request.TimeLimit; -import io.f1.backend.domain.quiz.entity.Quiz; import io.f1.backend.global.exception.CustomException; import io.f1.backend.global.exception.errorcode.GameErrorCode; @@ -16,9 +15,9 @@ public class GameSetting { private Integer round; private int timeLimit; - public void changeQuiz(Quiz quiz) { - quizId = quiz.getId(); - round = quiz.getQuestions().size(); // 라운드를 바꾼 퀴즈의 문제 수로 동기화 + public void changeQuiz(Long quizId, int questionsCount) { + this.quizId = quizId; + round = questionsCount; // 라운드를 바꾼 퀴즈의 문제 수로 동기화 } public void changeTimeLimit(TimeLimit timeLimit) { diff --git a/backend/src/main/java/io/f1/backend/domain/game/model/Player.java b/backend/src/main/java/io/f1/backend/domain/game/model/Player.java index fe92c1a3..9392539d 100644 --- a/backend/src/main/java/io/f1/backend/domain/game/model/Player.java +++ b/backend/src/main/java/io/f1/backend/domain/game/model/Player.java @@ -32,6 +32,10 @@ public void increaseCorrectCount() { correctCount++; } + public void initializeCorrectCount() { + correctCount = 0; + } + public void updateState(ConnectionState newState) { state = newState; } diff --git a/backend/src/main/java/io/f1/backend/domain/game/model/Room.java b/backend/src/main/java/io/f1/backend/domain/game/model/Room.java index 7580594c..cd3ef88f 100644 --- a/backend/src/main/java/io/f1/backend/domain/game/model/Room.java +++ b/backend/src/main/java/io/f1/backend/domain/game/model/Room.java @@ -2,7 +2,6 @@ import io.f1.backend.domain.game.dto.request.TimeLimit; import io.f1.backend.domain.question.entity.Question; -import io.f1.backend.domain.quiz.entity.Quiz; import io.f1.backend.global.exception.CustomException; import io.f1.backend.global.exception.errorcode.RoomErrorCode; @@ -94,8 +93,8 @@ public void updateTimer(ScheduledFuture timer) { this.timer = timer; } - public void removeSessionId(String sessionId) { - this.playerSessionMap.remove(sessionId); + public boolean removeSessionId(String sessionId) { + return this.playerSessionMap.remove(sessionId) != null; } public void removeValidatedUserId(Long userId) { @@ -118,6 +117,40 @@ public void increaseCurrentRound() { currentRound++; } + public void initializeRound() { + currentRound = 0; + } + + public List getDisconnectedPlayers() { + List disconnectedPlayers = new ArrayList<>(); + + for (Player player : this.playerSessionMap.values()) { + if (player.getState().equals(ConnectionState.DISCONNECTED)) { + disconnectedPlayers.add(player); + } + } + return disconnectedPlayers; + } + + public void initializePlayers() { + this.playerSessionMap + .values() + .forEach( + player -> { + player.initializeCorrectCount(); + player.toggleReady(); + }); + } + + public String getSessionIdByUserId(Long userId) { + for (Map.Entry entry : playerSessionMap.entrySet()) { + if (entry.getValue().getId().equals(userId)) { + return entry.getKey(); + } + } + throw new CustomException(RoomErrorCode.PLAYER_NOT_FOUND); + } + public void reconnectSession(String oldSessionId, String newSessionId) { Player player = playerSessionMap.get(oldSessionId); removeSessionId(oldSessionId); @@ -134,10 +167,7 @@ public boolean isExit(String sessionId) { } public boolean isLastPlayer(String sessionId) { - long connectedCount = - playerSessionMap.values().stream() - .filter(player -> player.getState() == ConnectionState.CONNECTED) - .count(); + long connectedCount = playerSessionMap.size(); return connectedCount == 1 && playerSessionMap.containsKey(sessionId); } @@ -161,8 +191,8 @@ public void resetAllPlayerReadyStates() { } } - public void changeQuiz(Quiz quiz) { - gameSetting.changeQuiz(quiz); + public void changeQuiz(Long quizId, int questionsCount) { + gameSetting.changeQuiz(quizId, questionsCount); } public void changeTimeLimit(TimeLimit timeLimit) { diff --git a/backend/src/main/java/io/f1/backend/domain/game/model/RoomState.java b/backend/src/main/java/io/f1/backend/domain/game/model/RoomState.java index 50d30dca..8d35f963 100644 --- a/backend/src/main/java/io/f1/backend/domain/game/model/RoomState.java +++ b/backend/src/main/java/io/f1/backend/domain/game/model/RoomState.java @@ -2,6 +2,5 @@ public enum RoomState { WAITING, - PLAYING, - FINISHED + PLAYING } diff --git a/backend/src/main/java/io/f1/backend/domain/game/store/RoomRepositoryImpl.java b/backend/src/main/java/io/f1/backend/domain/game/store/RoomRepositoryImpl.java index cffbba47..4b978f60 100644 --- a/backend/src/main/java/io/f1/backend/domain/game/store/RoomRepositoryImpl.java +++ b/backend/src/main/java/io/f1/backend/domain/game/store/RoomRepositoryImpl.java @@ -34,9 +34,4 @@ public List findAll() { public void removeRoom(Long roomId) { roomMap.remove(roomId); } - - // 테스트 전용 메소드 - public Room getRoomForTest(Long roomId) { - return roomMap.get(roomId); - } } diff --git a/backend/src/main/java/io/f1/backend/domain/game/websocket/MessageSender.java b/backend/src/main/java/io/f1/backend/domain/game/websocket/MessageSender.java index 0a85ddc1..46f53b7e 100644 --- a/backend/src/main/java/io/f1/backend/domain/game/websocket/MessageSender.java +++ b/backend/src/main/java/io/f1/backend/domain/game/websocket/MessageSender.java @@ -2,6 +2,7 @@ import io.f1.backend.domain.game.dto.MessageType; import io.f1.backend.domain.game.dto.response.DefaultWebSocketResponse; +import io.f1.backend.domain.user.dto.UserPrincipal; import lombok.RequiredArgsConstructor; @@ -14,8 +15,14 @@ public class MessageSender { private final SimpMessagingTemplate messagingTemplate; - public void send(String destination, MessageType type, T message) { + public void sendBroadcast(String destination, MessageType type, T message) { messagingTemplate.convertAndSend( destination, new DefaultWebSocketResponse<>(type, message)); } + + public void sendPersonal( + String destination, MessageType type, T message, UserPrincipal principal) { + messagingTemplate.convertAndSendToUser( + principal.getName(), destination, new DefaultWebSocketResponse<>(type, message)); + } } diff --git a/backend/src/main/java/io/f1/backend/domain/game/websocket/controller/GameSocketController.java b/backend/src/main/java/io/f1/backend/domain/game/websocket/controller/GameSocketController.java index 06ee39d0..1090af38 100644 --- a/backend/src/main/java/io/f1/backend/domain/game/websocket/controller/GameSocketController.java +++ b/backend/src/main/java/io/f1/backend/domain/game/websocket/controller/GameSocketController.java @@ -3,6 +3,7 @@ import static io.f1.backend.domain.game.websocket.WebSocketUtils.getSessionId; import static io.f1.backend.domain.game.websocket.WebSocketUtils.getSessionUser; +import io.f1.backend.domain.game.app.ChatService; import io.f1.backend.domain.game.app.GameService; import io.f1.backend.domain.game.app.RoomService; import io.f1.backend.domain.game.dto.ChatMessage; @@ -26,6 +27,8 @@ public class GameSocketController { private final RoomService roomService; private final GameService gameService; + private final ChatService chatService; + private final SessionService sessionService; @MessageMapping("/room/initializeRoomSocket/{roomId}") @@ -79,7 +82,7 @@ public void chat( @DestinationVariable Long roomId, Message> message) { - roomService.chat(roomId, getSessionId(message), message.getPayload().getMessage()); + chatService.chat(roomId, getSessionId(message), message.getPayload().getMessage()); } @MessageMapping("/room/ready/{roomId}") diff --git a/backend/src/main/java/io/f1/backend/domain/game/websocket/service/SessionService.java b/backend/src/main/java/io/f1/backend/domain/game/websocket/service/SessionService.java index ec40f46f..d568b0de 100644 --- a/backend/src/main/java/io/f1/backend/domain/game/websocket/service/SessionService.java +++ b/backend/src/main/java/io/f1/backend/domain/game/websocket/service/SessionService.java @@ -65,7 +65,6 @@ public void handleUserDisconnect(String sessionId, UserPrincipal principal) { /* 재연결 실패 */ if (sessionId.equals(userIdSession.get(userId))) { roomService.exitIfNotPlaying(roomId, sessionId, principal); - // 메세지 응답 추가 } removeSession(sessionId, userId); }, diff --git a/backend/src/main/java/io/f1/backend/domain/quiz/app/QuizService.java b/backend/src/main/java/io/f1/backend/domain/quiz/app/QuizService.java index c16365d3..bc85f2af 100644 --- a/backend/src/main/java/io/f1/backend/domain/quiz/app/QuizService.java +++ b/backend/src/main/java/io/f1/backend/domain/quiz/app/QuizService.java @@ -284,4 +284,9 @@ public Quiz findQuizById(Long quizId) { .findById(quizId) .orElseThrow(() -> new CustomException(QuizErrorCode.QUIZ_NOT_FOUND)); } + + @Transactional(readOnly = true) + public Long getQuestionsCount(Long quizId) { + return quizRepository.countQuestionsByQuizId(quizId); + } } diff --git a/backend/src/main/java/io/f1/backend/domain/quiz/dao/QuizRepository.java b/backend/src/main/java/io/f1/backend/domain/quiz/dao/QuizRepository.java index 5c205705..636dac7f 100644 --- a/backend/src/main/java/io/f1/backend/domain/quiz/dao/QuizRepository.java +++ b/backend/src/main/java/io/f1/backend/domain/quiz/dao/QuizRepository.java @@ -21,6 +21,9 @@ public interface QuizRepository extends JpaRepository { @Query("SELECT q FROM Quiz q LEFT JOIN FETCH q.questions WHERE q.id = :quizId") Optional findQuizWithQuestionsById(Long quizId); + @Query("SELECT COUNT(qst) FROM Quiz q JOIN q.questions qst WHERE q.id = :quizId") + Long countQuestionsByQuizId(Long quizId); + @Query( """ SELECT new io.f1.backend.domain.quiz.dto.QuizMinData (q.id, COUNT(qs.id)) diff --git a/backend/src/main/java/io/f1/backend/domain/stat/dao/StatJpaRepository.java b/backend/src/main/java/io/f1/backend/domain/stat/dao/StatJpaRepository.java index 5034bac5..2d65fee5 100644 --- a/backend/src/main/java/io/f1/backend/domain/stat/dao/StatJpaRepository.java +++ b/backend/src/main/java/io/f1/backend/domain/stat/dao/StatJpaRepository.java @@ -1,7 +1,7 @@ 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.dto.StatWithUserSummary; import io.f1.backend.domain.stat.entity.Stat; import org.springframework.data.domain.Page; @@ -33,12 +33,12 @@ public interface StatJpaRepository extends JpaRepository { @Query( """ SELECT - new io.f1.backend.domain.stat.dto.StatWithNicknameAndUserId + new io.f1.backend.domain.stat.dto.StatWithUserSummary (u.id, u.nickname, s.totalGames, s.winningGames, s.score) FROM Stat s JOIN s.user u """) - List findAllStatWithNicknameAndUserId(); + List findAllStatWithUserSummary(); @Modifying @Query( @@ -63,4 +63,14 @@ public interface StatJpaRepository extends JpaRepository { s.user.id = :userId """) void updateStatByUserIdCaseLose(long deltaScore, long userId); + + @Query( + """ + SELECT new io.f1.backend.domain.stat.dto.StatWithUserSummary( + u.id, u.nickname, s.totalGames, s.winningGames, s.score + ) + FROM Stat s JOIN s.user u + WHERE u.id = :userId + """) + Optional findStatWithUserSummary(long userId); } diff --git a/backend/src/main/java/io/f1/backend/domain/stat/dao/StatRedisRepository.java b/backend/src/main/java/io/f1/backend/domain/stat/dao/StatRedisRepository.java index 54c41ecb..827bdb94 100644 --- a/backend/src/main/java/io/f1/backend/domain/stat/dao/StatRedisRepository.java +++ b/backend/src/main/java/io/f1/backend/domain/stat/dao/StatRedisRepository.java @@ -4,7 +4,8 @@ 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 io.f1.backend.domain.stat.dto.StatWithUserSummary; +import io.f1.backend.domain.user.dto.MyPageInfo; import lombok.RequiredArgsConstructor; @@ -27,6 +28,7 @@ @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"; @@ -42,7 +44,7 @@ public void setup() { valueOps = redisTemplate.opsForValue(); } - public void initialize(StatWithNicknameAndUserId stat) { + public void initialize(StatWithUserSummary stat) { String statUserKey = getStatUserKey(stat.userId()); String statNicknameKey = getStatNickname(stat.nickname()); @@ -149,4 +151,23 @@ private static String getStatNickname(String nickname) { private long getUserIdFromNickname(String nickname) { return ((Number) requireNonNull(valueOps.get(getStatNickname(nickname)))).longValue(); } + + public MyPageInfo getStatByUserId(long userId) { + String statUserKey = getStatUserKey(userId); + + Long rank = zSetOps.reverseRank(STAT_RANK, userId); + Double score = zSetOps.score(STAT_RANK, userId); + Map statMap = hashOps.entries(statUserKey); + + if (rank == null || score == null || statMap.isEmpty()) { + throw new IllegalStateException("User not found in Redis: " + userId); + } + + return new MyPageInfo( + (String) statMap.get("nickname"), + rank + 1, + (long) statMap.get("totalGames"), + (long) statMap.get("winningGames"), + score.longValue()); + } } diff --git a/backend/src/main/java/io/f1/backend/domain/stat/dao/StatRepository.java b/backend/src/main/java/io/f1/backend/domain/stat/dao/StatRepository.java index 1326786e..d614ba07 100644 --- a/backend/src/main/java/io/f1/backend/domain/stat/dao/StatRepository.java +++ b/backend/src/main/java/io/f1/backend/domain/stat/dao/StatRepository.java @@ -1,6 +1,7 @@ package io.f1.backend.domain.stat.dao; import io.f1.backend.domain.stat.dto.StatPageResponse; +import io.f1.backend.domain.user.dto.MyPageInfo; import org.springframework.data.domain.Pageable; @@ -17,4 +18,6 @@ public interface StatRepository { void updateNickname(long userId, String nickname); void removeUser(long userId); + + MyPageInfo getMyPageByUserId(long userId); } diff --git a/backend/src/main/java/io/f1/backend/domain/stat/dao/StatRepositoryAdapter.java b/backend/src/main/java/io/f1/backend/domain/stat/dao/StatRepositoryAdapter.java index 06885eb7..d95aace5 100644 --- a/backend/src/main/java/io/f1/backend/domain/stat/dao/StatRepositoryAdapter.java +++ b/backend/src/main/java/io/f1/backend/domain/stat/dao/StatRepositoryAdapter.java @@ -4,9 +4,11 @@ import io.f1.backend.domain.stat.dto.StatPageResponse; import io.f1.backend.domain.stat.dto.StatWithNickname; -import io.f1.backend.domain.stat.dto.StatWithNicknameAndUserId; +import io.f1.backend.domain.stat.dto.StatWithUserSummary; +import io.f1.backend.domain.user.dto.MyPageInfo; import io.f1.backend.global.exception.CustomException; import io.f1.backend.global.exception.errorcode.RoomErrorCode; +import io.f1.backend.global.exception.errorcode.UserErrorCode; import jakarta.annotation.PostConstruct; @@ -54,7 +56,7 @@ public StatPageResponse getRanksByNickname(String nickname, int pageSize) { @Override public void addUser(long userId, String nickname) { - redisRepository.initialize(new StatWithNicknameAndUserId(userId, nickname, 0, 0, 0)); + redisRepository.initialize(new StatWithUserSummary(userId, nickname, 0, 0, 0)); } @Override @@ -78,7 +80,7 @@ public void removeUser(long userId) { } private void warmingRedis() { - jpaRepository.findAllStatWithNicknameAndUserId().forEach(redisRepository::initialize); + jpaRepository.findAllStatWithUserSummary().forEach(redisRepository::initialize); } private Pageable getPageableFromNickname(String nickname, int pageSize) { @@ -97,4 +99,25 @@ private Pageable getPageableFromNickname(String nickname, int pageSize) { int pageNumber = rowNum > 0 ? (int) (rowNum / pageSize) : 0; return PageRequest.of(pageNumber, pageSize, Sort.by(Direction.DESC, "score")); } + + @Override + public MyPageInfo getMyPageByUserId(long userId) { + try { + return redisRepository.getStatByUserId(userId); + } catch (Exception e) { + log.error("Redis miss, fallback to MySQL for userId={}", userId, e); + } + + StatWithUserSummary stat = findStatByUserId(userId); + long rank = jpaRepository.countByScoreGreaterThan(stat.score()) + 1; + + return new MyPageInfo( + stat.nickname(), rank, stat.totalGames(), stat.winningGames(), stat.score()); + } + + private StatWithUserSummary findStatByUserId(long userId) { + return jpaRepository + .findStatWithUserSummary(userId) + .orElseThrow(() -> new CustomException(UserErrorCode.USER_NOT_FOUND)); + } } diff --git a/backend/src/main/java/io/f1/backend/domain/stat/dto/StatWithNicknameAndUserId.java b/backend/src/main/java/io/f1/backend/domain/stat/dto/StatWithUserSummary.java similarity index 75% rename from backend/src/main/java/io/f1/backend/domain/stat/dto/StatWithNicknameAndUserId.java rename to backend/src/main/java/io/f1/backend/domain/stat/dto/StatWithUserSummary.java index 609313c1..52454e91 100644 --- a/backend/src/main/java/io/f1/backend/domain/stat/dto/StatWithNicknameAndUserId.java +++ b/backend/src/main/java/io/f1/backend/domain/stat/dto/StatWithUserSummary.java @@ -1,4 +1,4 @@ package io.f1.backend.domain.stat.dto; -public record StatWithNicknameAndUserId( +public record StatWithUserSummary( long userId, String nickname, long totalGames, long winningGames, long score) {} diff --git a/backend/src/main/java/io/f1/backend/domain/user/api/UserController.java b/backend/src/main/java/io/f1/backend/domain/user/api/UserController.java index 4a56d6e7..747137bf 100644 --- a/backend/src/main/java/io/f1/backend/domain/user/api/UserController.java +++ b/backend/src/main/java/io/f1/backend/domain/user/api/UserController.java @@ -3,6 +3,7 @@ import static io.f1.backend.global.util.SecurityUtils.logout; import io.f1.backend.domain.user.app.UserService; +import io.f1.backend.domain.user.dto.MyPageInfo; import io.f1.backend.domain.user.dto.SignupRequest; import io.f1.backend.domain.user.dto.UserPrincipal; @@ -13,6 +14,7 @@ import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; @@ -42,4 +44,11 @@ public ResponseEntity updateNickname( userPrincipal.getUserId(), signupRequest.nickname(), httpSession); return ResponseEntity.noContent().build(); } + + @GetMapping + public ResponseEntity getMyPage( + @AuthenticationPrincipal UserPrincipal userPrincipal) { + MyPageInfo response = userService.getMyPage(userPrincipal); + return ResponseEntity.ok(response); + } } diff --git a/backend/src/main/java/io/f1/backend/domain/user/app/UserService.java b/backend/src/main/java/io/f1/backend/domain/user/app/UserService.java index 3003d6a0..9ab5e21a 100644 --- a/backend/src/main/java/io/f1/backend/domain/user/app/UserService.java +++ b/backend/src/main/java/io/f1/backend/domain/user/app/UserService.java @@ -7,8 +7,10 @@ import static io.f1.backend.global.util.RedisPublisher.USER_UPDATE; import io.f1.backend.domain.auth.dto.CurrentUserAndAdminResponse; +import io.f1.backend.domain.stat.dao.StatRepository; import io.f1.backend.domain.user.dao.UserRepository; import io.f1.backend.domain.user.dto.AuthenticationUser; +import io.f1.backend.domain.user.dto.MyPageInfo; import io.f1.backend.domain.user.dto.SignupRequest; import io.f1.backend.domain.user.dto.UserPrincipal; import io.f1.backend.domain.user.dto.UserSummary; @@ -32,6 +34,7 @@ public class UserService { private final UserRepository userRepository; private final RedisPublisher redisPublisher; + private final StatRepository statRepository; @Transactional public CurrentUserAndAdminResponse signup(HttpSession session, SignupRequest signupRequest) { @@ -122,4 +125,10 @@ public void checkNickname(String nickname) { validateNicknameFormat(nickname); validateNicknameDuplicate(nickname); } + + @Transactional(readOnly = true) + public MyPageInfo getMyPage(UserPrincipal userPrincipal) { + Long userId = userPrincipal.getUserId(); + return statRepository.getMyPageByUserId(userId); + } } diff --git a/backend/src/main/java/io/f1/backend/domain/user/dto/MyPageInfo.java b/backend/src/main/java/io/f1/backend/domain/user/dto/MyPageInfo.java new file mode 100644 index 00000000..a66bbd1a --- /dev/null +++ b/backend/src/main/java/io/f1/backend/domain/user/dto/MyPageInfo.java @@ -0,0 +1,4 @@ +package io.f1.backend.domain.user.dto; + +public record MyPageInfo( + String nickname, long rank, long score, long totalGames, long winningGames) {} diff --git a/backend/src/main/java/io/f1/backend/global/config/SecurityConfig.java b/backend/src/main/java/io/f1/backend/global/config/SecurityConfig.java index 844acb30..652e201a 100644 --- a/backend/src/main/java/io/f1/backend/global/config/SecurityConfig.java +++ b/backend/src/main/java/io/f1/backend/global/config/SecurityConfig.java @@ -10,6 +10,7 @@ import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpMethod; @@ -32,6 +33,9 @@ public class SecurityConfig { private final AdminLoginSuccessHandler adminLoginSuccessHandler; private final AdminLoginFailureHandler adminLoginFailureHandler; + @Value("${management.endpoints.web.base-path:/actuator}") + private String actuatorBasePath; + @Bean public SecurityFilterChain userFilterChain(HttpSecurity http) throws Exception { http.csrf(AbstractHttpConfigurer::disable) @@ -66,6 +70,8 @@ public SecurityFilterChain userFilterChain(HttpSecurity http) throws Exception { .hasAnyRole("USER", "ADMIN") .requestMatchers("/questions/**") .hasAnyRole("USER", "ADMIN") + .requestMatchers(actuatorBasePath + "/**") + .hasRole("PROMETHEUS") .anyRequest() .authenticated()) .formLogin( diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index 94bd698f..10cbe36e 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -64,4 +64,26 @@ server: secure: true http-only: true timeout: ${SESSION_TIMEOUT} +--- +spring: + config: + activate: + on-profile: prod + + security: + user: + name: ${PROM_NAME} + password: ${PROM_PASSWORD} + roles: PROMETHEUS +management: + server: + port: ${ACTUATOR_PORT} + endpoints: + web: + exposure: + include: "prometheus" + base-path: ${ACTUATOR_BASE_PATH} + endpoint: + prometheus: + access: read_only \ No newline at end of file diff --git a/backend/src/test/java/io/f1/backend/domain/game/app/RoomServiceTests.java b/backend/src/test/java/io/f1/backend/domain/game/app/RoomServiceTests.java index 7be3f8e5..67c26298 100644 --- a/backend/src/test/java/io/f1/backend/domain/game/app/RoomServiceTests.java +++ b/backend/src/test/java/io/f1/backend/domain/game/app/RoomServiceTests.java @@ -29,6 +29,7 @@ import org.springframework.context.ApplicationEventPublisher; import org.springframework.security.core.context.SecurityContextHolder; +import java.lang.reflect.Field; import java.time.LocalDateTime; import java.util.ArrayList; import java.util.Collections; @@ -46,6 +47,7 @@ class RoomServiceTests { @Mock private RoomRepository roomRepository; @Mock private QuizService quizService; + @Mock private GameService gameService; @Mock private TimerService timerService; @Mock private ApplicationEventPublisher eventPublisher; @Mock private MessageSender messageSender; @@ -53,6 +55,7 @@ class RoomServiceTests { @BeforeEach void setUp() { MockitoAnnotations.openMocks(this); // @Mock 어노테이션이 붙은 필드들을 초기화합니다. + roomService = new RoomService( timerService, quizService, roomRepository, eventPublisher, messageSender); @@ -83,6 +86,7 @@ void enterRoom_synchronized() throws Exception { ExecutorService executorService = Executors.newFixedThreadPool(threadCount); CountDownLatch countDownLatch = new CountDownLatch(threadCount); RoomValidationRequest roomValidationRequest = new RoomValidationRequest(roomId, password); + for (int i = 1; i <= threadCount; i++) { User user = createUser(i); @@ -113,6 +117,7 @@ void exitRoom_synchronized() throws Exception { String password = "123"; boolean locked = true; + /* 방 생성 */ Room room = createRoom(roomId, playerId, quizId, password, maxUserCount, locked); int threadCount = 10; @@ -128,6 +133,7 @@ void exitRoom_synchronized() throws Exception { Player host = players.getFirst(); room.updateHost(host); + /* 방 입장 */ for (int i = 1; i <= threadCount; i++) { String sessionId = "sessionId" + i; Player player = players.get(i - 1); @@ -141,6 +147,7 @@ void exitRoom_synchronized() throws Exception { ExecutorService executorService = Executors.newFixedThreadPool(threadCount); CountDownLatch countDownLatch = new CountDownLatch(threadCount); + /* 방 퇴장 테스트 */ for (int i = 1; i <= threadCount; i++) { String sessionId = "sessionId" + i; User user = createUser(i); @@ -190,7 +197,14 @@ private User createUser(int i) { .providerId(providerId) .lastLogin(lastLogin) .build(); - user.setId(userId); + + try { + Field idField = User.class.getDeclaredField("id"); + idField.setAccessible(true); + idField.set(user, userId); + } catch (Exception e) { + throw new RuntimeException("ID 설정 실패", e); + } return user; } diff --git a/backend/src/test/java/io/f1/backend/domain/game/store/RoomRepositoryTests.java b/backend/src/test/java/io/f1/backend/domain/game/store/RoomRepositoryTests.java index e3c5f3d7..872dfdeb 100644 --- a/backend/src/test/java/io/f1/backend/domain/game/store/RoomRepositoryTests.java +++ b/backend/src/test/java/io/f1/backend/domain/game/store/RoomRepositoryTests.java @@ -52,7 +52,7 @@ void saveRoom_test() { roomRepository.saveRoom(newRoom); - Room savedRoom = roomRepository.getRoomForTest(newId); + Room savedRoom = roomRepository.findRoom(newId).orElseThrow(); assertThat(savedRoom.getHost().getId()).isEqualTo(loginUser.get("id")); assertThat(savedRoom.getHost().getNickname()).isEqualTo(loginUser.get("nickname")); diff --git a/backend/src/test/java/io/f1/backend/domain/game/websocket/SessionServiceTests.java b/backend/src/test/java/io/f1/backend/domain/game/websocket/SessionServiceTests.java index df3c5f57..b8b160ba 100644 --- a/backend/src/test/java/io/f1/backend/domain/game/websocket/SessionServiceTests.java +++ b/backend/src/test/java/io/f1/backend/domain/game/websocket/SessionServiceTests.java @@ -1,6 +1,9 @@ package io.f1.backend.domain.game.websocket; -import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyString; @@ -24,6 +27,7 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.test.util.ReflectionTestUtils; +import java.lang.reflect.Field; import java.time.LocalDateTime; import java.util.HashMap; import java.util.Map; @@ -41,9 +45,7 @@ class SessionServiceTests { private String sessionId1 = "session1"; private String sessionId2 = "session2"; private Long userId1 = 100L; - private Long userId2 = 200L; private Long roomId1 = 1L; - private Long roomId2 = 2L; @BeforeEach void setUp() { @@ -95,8 +97,7 @@ void handleUserDisconnect_shouldExitIfNotPlayingIfDisconnected() throws Interrup sessionService.addRoomId(roomId1, sessionId1); sessionService.addSession(sessionId1, userId1); - User user = new User("provider", "providerId", LocalDateTime.now()); - user.setId(userId1); + User user = createUser(1); UserPrincipal principal = new UserPrincipal(user, new HashMap<>()); // disconnect 호출 @@ -154,8 +155,7 @@ void handleUserDisconnect_shouldStoreOldSessionIdInLatestSession() { sessionService.addSession(sessionId1, userId1); // 유저의 현재 활성 세션 sessionService.addRoomId(roomId1, sessionId1); - User user = new User("provider", "providerId", LocalDateTime.now()); - user.setId(userId1); + User user = createUser(1); UserPrincipal principal = new UserPrincipal(user, new HashMap<>()); // when @@ -183,8 +183,7 @@ void handleUserDisconnect_reconnectWithin5Seconds_shouldCleanLatestSession() sessionService.addSession(sessionId1, userId1); // 초기 세션 sessionService.addRoomId(roomId1, sessionId1); - User user = new User("provider", "providerId", LocalDateTime.now()); - user.setId(userId1); + User user = createUser(1); UserPrincipal principal = new UserPrincipal(user, new HashMap<>()); sessionService.handleUserDisconnect( @@ -231,4 +230,28 @@ void handleUserDisconnect_reconnectWithin5Seconds_shouldCleanLatestSession() assertTrue(sessionIdRoom.containsKey(sessionId2)); assertEquals(roomId1, sessionIdRoom.get(sessionId2)); } + + private User createUser(int i) { + Long userId = i + 1L; + String provider = "provider +" + i; + String providerId = "providerId" + i; + LocalDateTime lastLogin = LocalDateTime.now(); + + User user = + User.builder() + .provider(provider) + .providerId(providerId) + .lastLogin(lastLogin) + .build(); + + try { + Field idField = User.class.getDeclaredField("id"); + idField.setAccessible(true); + idField.set(user, userId); + } catch (Exception e) { + throw new RuntimeException("ID 설정 실패", e); + } + + return user; + } } diff --git a/backend/src/test/java/io/f1/backend/domain/stat/RedisStatBrowserTest.java b/backend/src/test/java/io/f1/backend/domain/stat/RedisStatBrowserTest.java index 8ced8afd..4cb06b2a 100644 --- a/backend/src/test/java/io/f1/backend/domain/stat/RedisStatBrowserTest.java +++ b/backend/src/test/java/io/f1/backend/domain/stat/RedisStatBrowserTest.java @@ -26,7 +26,7 @@ import io.f1.backend.domain.stat.dao.StatJpaRepository; import io.f1.backend.domain.stat.dao.StatRepositoryAdapter; import io.f1.backend.domain.stat.dto.StatWithNickname; -import io.f1.backend.domain.stat.dto.StatWithNicknameAndUserId; +import io.f1.backend.domain.stat.dto.StatWithUserSummary; import io.f1.backend.domain.user.dao.UserRepository; import io.f1.backend.domain.user.dto.AuthenticationUser; import io.f1.backend.domain.user.dto.SignupRequest; @@ -186,9 +186,9 @@ private MockHttpSession getMockSession(User user, boolean signup) { } private void warmingRedisOneUser(User user) { - StatWithNicknameAndUserId mockStat = - new StatWithNicknameAndUserId(user.getId(), user.getNickname(), 10, 10, 100); - given(statJpaRepository.findAllStatWithNicknameAndUserId()).willReturn(List.of(mockStat)); + StatWithUserSummary mockStat = + new StatWithUserSummary(user.getId(), user.getNickname(), 10, 10, 100); + given(statJpaRepository.findAllStatWithUserSummary()).willReturn(List.of(mockStat)); statRepositoryAdapter.setup(); } }