diff --git a/backend/src/main/java/io/f1/backend/domain/admin/api/AdminController.java b/backend/src/main/java/io/f1/backend/domain/admin/api/AdminController.java index 096681d7..472f7783 100644 --- a/backend/src/main/java/io/f1/backend/domain/admin/api/AdminController.java +++ b/backend/src/main/java/io/f1/backend/domain/admin/api/AdminController.java @@ -6,10 +6,12 @@ import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.StringUtils; import org.springframework.data.domain.Pageable; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @RestController @@ -21,8 +23,15 @@ public class AdminController { @LimitPageSize @GetMapping("/users") - public ResponseEntity getUsers(Pageable pageable) { - UserPageResponse response = adminService.getAllUsers(pageable); + public ResponseEntity getUsers( + @RequestParam(required = false) String nickname, Pageable pageable) { + UserPageResponse response; + + if (StringUtils.isBlank(nickname)) { + response = adminService.getAllUsers(pageable); + } else { + response = adminService.searchUsersByNickname(nickname, pageable); + } return ResponseEntity.ok().body(response); } } diff --git a/backend/src/main/java/io/f1/backend/domain/admin/app/AdminService.java b/backend/src/main/java/io/f1/backend/domain/admin/app/AdminService.java index 66621538..15dae5e6 100644 --- a/backend/src/main/java/io/f1/backend/domain/admin/app/AdminService.java +++ b/backend/src/main/java/io/f1/backend/domain/admin/app/AdminService.java @@ -24,4 +24,10 @@ public UserPageResponse getAllUsers(Pageable pageable) { Page users = userRepository.findAllUsersWithPaging(pageable); return toUserListPageResponse(users); } + + @Transactional(readOnly = true) + public UserPageResponse searchUsersByNickname(String nickname, Pageable pageable) { + Page users = userRepository.findUsersByNicknameContaining(nickname, pageable); + return toUserListPageResponse(users); + } } 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 d8579951..1df1ccaa 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,19 +3,21 @@ 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) { +public record CurrentUserAndAdminResponse(Long id, String name, String role, String providerId) { public static CurrentUserAndAdminResponse from(UserPrincipal userPrincipal) { return new CurrentUserAndAdminResponse( userPrincipal.getUserId(), userPrincipal.getUserNickname(), - UserPrincipal.ROLE_USER); + UserPrincipal.ROLE_USER, + userPrincipal.getName()); } public static CurrentUserAndAdminResponse from(AdminPrincipal adminPrincipal) { return new CurrentUserAndAdminResponse( adminPrincipal.getAuthenticationAdmin().adminId(), adminPrincipal.getUsername(), - AdminPrincipal.ROLE_ADMIN); + AdminPrincipal.ROLE_ADMIN, + null); } } 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 701b6b73..4c2d2010 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,18 +1,20 @@ package io.f1.backend.domain.game.app; +import static io.f1.backend.domain.game.mapper.RoomMapper.toQuestionStartResponse; +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.request.GameStartRequest; -import io.f1.backend.domain.game.dto.response.GameStartResponse; +import io.f1.backend.domain.game.dto.MessageType; import io.f1.backend.domain.game.event.RoomUpdatedEvent; -import io.f1.backend.domain.game.model.GameSetting; import io.f1.backend.domain.game.model.Player; import io.f1.backend.domain.game.model.Room; 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.entity.Quiz; +import io.f1.backend.domain.user.dto.UserPrincipal; import io.f1.backend.global.exception.CustomException; import io.f1.backend.global.exception.errorcode.GameErrorCode; import io.f1.backend.global.exception.errorcode.RoomErrorCode; @@ -24,56 +26,48 @@ import java.util.List; import java.util.Map; +import java.util.Objects; @Service @RequiredArgsConstructor public class GameService { + public static final int START_DELAY = 5; + + private final MessageSender messageSender; + private final TimerService timerService; private final QuizService quizService; private final RoomRepository roomRepository; private final ApplicationEventPublisher eventPublisher; - public GameStartResponse gameStart(Long roomId, GameStartRequest gameStartRequest) { + public void gameStart(Long roomId, UserPrincipal principal) { - Long quizId = gameStartRequest.quizId(); + String destination = getDestination(roomId); Room room = roomRepository .findRoom(roomId) .orElseThrow(() -> new CustomException(RoomErrorCode.ROOM_NOT_FOUND)); - if (!validateReadyStatus(room)) { - throw new CustomException(RoomErrorCode.PLAYER_NOT_READY); - } - - // 방의 gameSetting에 설정된 퀴즈랑 요청 퀴즈랑 같은지 체크 후 GameSetting에서 라운드 가져오기 - Integer round = checkGameSetting(room, quizId); + validateRoomStart(room, principal); + Long quizId = room.getGameSetting().getQuizId(); Quiz quiz = quizService.getQuizWithQuestionsById(quizId); + List questions = prepareQuestions(room, quiz); - // 라운드 수만큼 랜덤 Question 추출 - List questions = quizService.getRandomQuestionsWithoutAnswer(quizId, round); room.updateQuestions(questions); - - GameStartResponse gameStartResponse = toGameStartResponse(questions); - - // 방 정보 게임 중으로 변경 + room.increaseCurrentRound(); room.updateRoomState(RoomState.PLAYING); eventPublisher.publishEvent(new RoomUpdatedEvent(room, quiz)); - return gameStartResponse; - } - - private Integer checkGameSetting(Room room, Long quizId) { - - GameSetting gameSetting = room.getGameSetting(); - - if (!gameSetting.validateQuizId(quizId)) { - throw new CustomException(GameErrorCode.GAME_SETTING_CONFLICT); - } + timerService.startTimer(room, START_DELAY); - return gameSetting.getRound(); + messageSender.send(destination, MessageType.GAME_START, toGameStartResponse(questions)); + messageSender.send( + destination, + MessageType.QUESTION_START, + toQuestionStartResponse(room, START_DELAY)); } private boolean validateReadyStatus(Room room) { @@ -82,4 +76,25 @@ private boolean validateReadyStatus(Room room) { return playerSessionMap.values().stream().allMatch(Player::isReady); } + + private void validateRoomStart(Room room, UserPrincipal principal) { + if (!Objects.equals(principal.getUserId(), room.getHost().getId())) { + throw new CustomException(RoomErrorCode.NOT_ROOM_OWNER); + } + + if (!validateReadyStatus(room)) { + throw new CustomException(GameErrorCode.PLAYER_NOT_READY); + } + + if (room.getState() == RoomState.PLAYING) { + throw new CustomException(RoomErrorCode.GAME_ALREADY_PLAYING); + } + } + + // 라운드 수만큼 랜덤 Question 추출 + private List prepareQuestions(Room room, Quiz quiz) { + Long quizId = quiz.getId(); + Integer round = room.getGameSetting().getRound(); + return quizService.getRandomQuestionsWithoutAnswer(quizId, round); + } } 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 e96bdec9..eb5ae614 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,22 +1,24 @@ 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; 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 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.RoomExitData; -import io.f1.backend.domain.game.dto.RoomInitialData; -import io.f1.backend.domain.game.dto.RoundResult; 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.GameSettingResponse; @@ -26,12 +28,15 @@ import io.f1.backend.domain.game.dto.response.RoomResponse; import io.f1.backend.domain.game.dto.response.RoomSettingResponse; import io.f1.backend.domain.game.dto.response.SystemNoticeResponse; +import io.f1.backend.domain.game.event.RoomCreatedEvent; +import io.f1.backend.domain.game.model.ConnectionState; import io.f1.backend.domain.game.model.GameSetting; import io.f1.backend.domain.game.model.Player; import io.f1.backend.domain.game.model.Room; import io.f1.backend.domain.game.model.RoomSetting; 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; @@ -57,17 +62,22 @@ @RequiredArgsConstructor public class RoomService { + private final TimerService timerService; private final QuizService quizService; private final RoomRepository roomRepository; private final AtomicLong roomIdGenerator = new AtomicLong(0); private final ApplicationEventPublisher eventPublisher; private final Map roomLocks = new ConcurrentHashMap<>(); - private static final String PENDING_SESSION_ID = "PENDING_SESSION_ID"; + + private final MessageSender messageSender; + + private static final int CONTINUE_DELAY = 3; public RoomCreateResponse saveRoom(RoomCreateRequest request) { QuizMinData quizMinData = quizService.getQuizMinData(); - // Quiz quiz = quizService.getQuizWithQuestionsById(quizMinId); + + Quiz quiz = quizService.findQuizById(quizMinData.quizMinId()); GameSetting gameSetting = toGameSetting(quizMinData); @@ -79,11 +89,11 @@ public RoomCreateResponse saveRoom(RoomCreateRequest request) { Room room = new Room(newId, roomSetting, gameSetting, host); - room.getUserIdSessionMap().put(host.id, PENDING_SESSION_ID); + room.addValidatedUserId(getCurrentUserId()); roomRepository.saveRoom(room); - // eventPublisher.publishEvent(new RoomCreatedEvent(room, quiz)); + eventPublisher.publishEvent(new RoomCreatedEvent(room, quiz)); return new RoomCreateResponse(newId); } @@ -102,7 +112,7 @@ public void enterRoom(RoomValidationRequest request) { } int maxUserCnt = room.getRoomSetting().maxUserCount(); - int currentCnt = room.getUserIdSessionMap().size(); + int currentCnt = room.getCurrentUserCnt(); if (maxUserCnt == currentCnt) { throw new CustomException(RoomErrorCode.ROOM_USER_LIMIT_REACHED); } @@ -112,30 +122,17 @@ public void enterRoom(RoomValidationRequest request) { throw new CustomException(RoomErrorCode.WRONG_PASSWORD); } - room.getUserIdSessionMap().put(getCurrentUserId(), PENDING_SESSION_ID); + room.addValidatedUserId(getCurrentUserId()); } } - public RoomInitialData initializeRoomSocket( - Long roomId, String sessionId, UserPrincipal principal) { + public void initializeRoomSocket(Long roomId, String sessionId, UserPrincipal principal) { Room room = findRoom(roomId); Player player = createPlayer(principal); - Map playerSessionMap = room.getPlayerSessionMap(); - Map userIdSessionMap = room.getUserIdSessionMap(); - - if (room.isHost(player.getId())) { - player.toggleReady(); - } - - playerSessionMap.put(sessionId, player); - String existingSession = userIdSessionMap.get(player.getId()); - /* 정상 흐름 or 재연결 */ - if (existingSession.equals(PENDING_SESSION_ID) || !existingSession.equals(sessionId)) { - userIdSessionMap.put(player.getId(), sessionId); - } + room.addPlayer(sessionId, player); RoomSettingResponse roomSettingResponse = toRoomSettingResponse(room); @@ -150,11 +147,15 @@ public RoomInitialData initializeRoomSocket( SystemNoticeResponse systemNoticeResponse = ofPlayerEvent(player.getNickname(), RoomEventType.ENTER); - return new RoomInitialData( - roomSettingResponse, gameSettingResponse, playerListResponse, systemNoticeResponse); + 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); } - public RoomExitData exitRoom(Long roomId, String sessionId, UserPrincipal principal) { + public void exitRoom(Long roomId, String sessionId, UserPrincipal principal) { Object lock = roomLocks.computeIfAbsent(roomId, k -> new Object()); @@ -163,9 +164,12 @@ public RoomExitData exitRoom(Long roomId, String sessionId, UserPrincipal princi Player removePlayer = getRemovePlayer(room, sessionId, principal); + String destination = getDestination(roomId); + /* 방 삭제 */ - if (isLastPlayer(room, sessionId)) { - return removeRoom(room); + if (room.isLastPlayer(sessionId)) { + removeRoom(room); + return; } /* 방장 변경 */ @@ -181,11 +185,12 @@ public RoomExitData exitRoom(Long roomId, String sessionId, UserPrincipal princi PlayerListResponse playerListResponse = toPlayerListResponse(room); - return new RoomExitData(playerListResponse, systemNoticeResponse, false); + messageSender.send(destination, MessageType.PLAYER_LIST, playerListResponse); + messageSender.send(destination, MessageType.SYSTEM_NOTICE, systemNoticeResponse); } } - public PlayerListResponse handlePlayerReady(Long roomId, String sessionId) { + public void handlePlayerReady(Long roomId, String sessionId) { Player player = roomRepository .findPlayerInRoomBySessionId(roomId, sessionId) @@ -195,7 +200,9 @@ public PlayerListResponse handlePlayerReady(Long roomId, String sessionId) { Room room = findRoom(roomId); - return toPlayerListResponse(room); + String destination = getDestination(roomId); + + messageSender.send(destination, MessageType.PLAYER_LIST, toPlayerListResponse(room)); } public RoomListResponse getAllRooms() { @@ -214,30 +221,107 @@ public RoomListResponse getAllRooms() { } // todo 동시성적용 - public RoundResult chat(Long roomId, String sessionId, ChatMessage chatMessage) { + 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 buildResultOnlyChat(chatMessage); + return; } Question currentQuestion = room.getCurrentQuestion(); String answer = currentQuestion.getAnswer(); - if (!answer.equals(chatMessage.message())) { - return buildResultOnlyChat(chatMessage); + 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); + + messageSender.send( + 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)); + } else { + RoomSettingResponse roomSettingResponse = toRoomSettingResponse(room); + + Long quizId = room.getGameSetting().getQuizId(); + + Quiz quiz = quizService.getQuizWithQuestionsById(quizId); + + GameSettingResponse gameSettingResponse = + toGameSettingResponse(room.getGameSetting(), quiz); + + 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); } + } + + public void changeConnectedStatus(Long roomId, String sessionId, ConnectionState newState) { + Room room = findRoom(roomId); + room.updatePlayerConnectionState(sessionId, newState); + } - room.increasePlayerCorrectCount(sessionId); + public boolean isExit(String sessionId, Long roomId) { + Room room = findRoom(roomId); + return room.isExit(sessionId); + } - return RoundResult.builder() - .questionResult( - toQuestionResultResponse(currentQuestion.getId(), chatMessage, answer)) - .rankUpdate(toRankUpdateResponse(room)) - .systemNotice(ofPlayerEvent(chatMessage.nickname(), RoomEventType.ENTER)) - .chat(chatMessage) - .build(); + public void exitIfNotPlaying(Long roomId, String sessionId, UserPrincipal principal) { + Room room = findRoom(roomId); + if (!room.isPlaying()) { + exitRoom(roomId, sessionId, principal); + } } private Player getRemovePlayer(Room room, String sessionId, UserPrincipal principal) { @@ -263,25 +347,21 @@ private Room findRoom(Long roomId) { .orElseThrow(() -> new CustomException(RoomErrorCode.ROOM_NOT_FOUND)); } - private boolean isLastPlayer(Room room, String sessionId) { - Map playerSessionMap = room.getPlayerSessionMap(); - return playerSessionMap.size() == 1 && playerSessionMap.containsKey(sessionId); - } - - private RoomExitData removeRoom(Room room) { + private void removeRoom(Room room) { Long roomId = room.getId(); roomRepository.removeRoom(roomId); roomLocks.remove(roomId); log.info("{}번 방 삭제", roomId); - return RoomExitData.builder().removedRoom(true).build(); } private void changeHost(Room room, String hostSessionId) { Map playerSessionMap = room.getPlayerSessionMap(); Optional nextHostSessionId = - playerSessionMap.keySet().stream() - .filter(key -> !key.equals(hostSessionId)) + playerSessionMap.entrySet().stream() + .filter(entry -> !entry.getKey().equals(hostSessionId)) + .filter(entry -> entry.getValue().getState() == ConnectionState.CONNECTED) + .map(Map.Entry::getKey) .findFirst(); Player nextHost = @@ -296,9 +376,6 @@ private void changeHost(Room room, String hostSessionId) { private void removePlayer(Room room, String sessionId, Player removePlayer) { room.removeUserId(removePlayer.getId()); room.removeSessionId(sessionId); - } - - private RoundResult buildResultOnlyChat(ChatMessage chatMessage) { - return RoundResult.builder().chat(chatMessage).build(); + room.removeValidatedUserId(removePlayer.getId()); } } 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 new file mode 100644 index 00000000..407892e0 --- /dev/null +++ b/backend/src/main/java/io/f1/backend/domain/game/app/TimerService.java @@ -0,0 +1,90 @@ +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.model.Room; +import io.f1.backend.domain.game.websocket.MessageSender; + +import lombok.RequiredArgsConstructor; + +import org.springframework.stereotype.Service; + +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; + +@Service +@RequiredArgsConstructor +public class TimerService { + + private final MessageSender messageSender; + + private static final String NONE_CORRECT_USER = ""; + private static final int CONTINUE_DELAY = 3; + + public void startTimer(Room room, int delaySec) { + cancelTimer(room); + + ScheduledFuture timer = + room.getScheduler() + .schedule( + () -> { + handleTimeout(room); + }, + delaySec + room.getGameSetting().getTimeLimit(), + TimeUnit.SECONDS); + + 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; + } + cancelTimer(room); + room.getScheduler().shutdown(); + return false; + } + + public boolean cancelTimer(Room room) { + // 정답 맞혔어요 ~ 타이머 캔슬 부탁 + ScheduledFuture timer = room.getTimer(); + if (timer != null && !timer.isDone()) { + return timer.cancel(false); + } + return false; + } +} diff --git a/backend/src/main/java/io/f1/backend/domain/game/dto/GameEventType.java b/backend/src/main/java/io/f1/backend/domain/game/dto/GameEventType.java new file mode 100644 index 00000000..55c5ba40 --- /dev/null +++ b/backend/src/main/java/io/f1/backend/domain/game/dto/GameEventType.java @@ -0,0 +1,6 @@ +package io.f1.backend.domain.game.dto; + +public enum GameEventType { + START, + CONTINUE +} 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 7e5384eb..b2b79f20 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,4 +9,5 @@ public enum MessageType { CHAT, QUESTION_RESULT, RANK_UPDATE, + QUESTION_START } 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 c6346c83..f9cdce92 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 @@ -1,8 +1,26 @@ package io.f1.backend.domain.game.dto; public enum RoomEventType { - ENTER, - EXIT, - START, - END, + ENTER(SystemNoticeMessage.ENTER), + EXIT(SystemNoticeMessage.EXIT), + START(null), + END(null), + CORRECT_ANSWER(SystemNoticeMessage.CORRECT_ANSWER), + TIMEOUT(SystemNoticeMessage.TIMEOUT), + RECONNECT(SystemNoticeMessage.RECONNECT); + + private final SystemNoticeMessage systemMessage; + + RoomEventType(SystemNoticeMessage systemMessage) { + this.systemMessage = systemMessage; + } + + public String getMessage(String nickname) { + + if (this == TIMEOUT) { + return systemMessage.getMessage(); + } + + return nickname + systemMessage.getMessage(); + } } diff --git a/backend/src/main/java/io/f1/backend/domain/game/dto/RoomExitData.java b/backend/src/main/java/io/f1/backend/domain/game/dto/RoomExitData.java deleted file mode 100644 index 76efee90..00000000 --- a/backend/src/main/java/io/f1/backend/domain/game/dto/RoomExitData.java +++ /dev/null @@ -1,25 +0,0 @@ -package io.f1.backend.domain.game.dto; - -import io.f1.backend.domain.game.dto.response.PlayerListResponse; -import io.f1.backend.domain.game.dto.response.SystemNoticeResponse; - -import lombok.Builder; -import lombok.Getter; - -@Getter -public class RoomExitData { - - private final PlayerListResponse playerListResponses; - private final SystemNoticeResponse systemNoticeResponse; - private final boolean removedRoom; - - @Builder - public RoomExitData( - PlayerListResponse playerListResponses, - SystemNoticeResponse systemNoticeResponse, - boolean removedRoom) { - this.playerListResponses = playerListResponses; - this.systemNoticeResponse = systemNoticeResponse; - this.removedRoom = removedRoom; - } -} diff --git a/backend/src/main/java/io/f1/backend/domain/game/dto/RoomInitialData.java b/backend/src/main/java/io/f1/backend/domain/game/dto/RoomInitialData.java deleted file mode 100644 index 439daca8..00000000 --- a/backend/src/main/java/io/f1/backend/domain/game/dto/RoomInitialData.java +++ /dev/null @@ -1,12 +0,0 @@ -package io.f1.backend.domain.game.dto; - -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.RoomSettingResponse; -import io.f1.backend.domain.game.dto.response.SystemNoticeResponse; - -public record RoomInitialData( - RoomSettingResponse roomSettingResponse, - GameSettingResponse gameSettingResponse, - PlayerListResponse playerListResponse, - SystemNoticeResponse systemNoticeResponse) {} diff --git a/backend/src/main/java/io/f1/backend/domain/game/dto/RoundResult.java b/backend/src/main/java/io/f1/backend/domain/game/dto/RoundResult.java deleted file mode 100644 index 6a537b3d..00000000 --- a/backend/src/main/java/io/f1/backend/domain/game/dto/RoundResult.java +++ /dev/null @@ -1,36 +0,0 @@ -package io.f1.backend.domain.game.dto; - -import io.f1.backend.domain.game.dto.response.QuestionResultResponse; -import io.f1.backend.domain.game.dto.response.RankUpdateResponse; -import io.f1.backend.domain.game.dto.response.SystemNoticeResponse; - -import lombok.Builder; -import lombok.Getter; - -@Getter -public class RoundResult { - QuestionResultResponse questionResult; - RankUpdateResponse rankUpdate; - SystemNoticeResponse systemNotice; - ChatMessage chat; - - @Builder - public RoundResult( - QuestionResultResponse questionResult, - RankUpdateResponse rankUpdate, - SystemNoticeResponse systemNotice, - ChatMessage chat) { - this.questionResult = questionResult; - this.rankUpdate = rankUpdate; - this.systemNotice = systemNotice; - this.chat = chat; - } - - public boolean hasChat() { - return chat != null; - } - - public boolean hasOnlyChat() { - return chat != null && questionResult == null && rankUpdate == null && systemNotice == null; - } -} 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 new file mode 100644 index 00000000..636e5bf1 --- /dev/null +++ b/backend/src/main/java/io/f1/backend/domain/game/dto/SystemNoticeMessage.java @@ -0,0 +1,19 @@ +package io.f1.backend.domain.game.dto; + +public enum SystemNoticeMessage { + ENTER(" 님이 입장하셨습니다"), + EXIT(" 님이 퇴장하셨습니다"), + CORRECT_ANSWER(" 님 정답입니다 !"), + TIMEOUT("땡 ~ ⏰ 제한 시간 초과!"), + RECONNECT(" 님이 재연결 되었습니다."); + + private final String message; + + SystemNoticeMessage(String message) { + this.message = message; + } + + public String getMessage() { + return message; + } +} diff --git a/backend/src/main/java/io/f1/backend/domain/game/dto/request/GameStartRequest.java b/backend/src/main/java/io/f1/backend/domain/game/dto/request/GameStartRequest.java deleted file mode 100644 index 61f792ea..00000000 --- a/backend/src/main/java/io/f1/backend/domain/game/dto/request/GameStartRequest.java +++ /dev/null @@ -1,3 +0,0 @@ -package io.f1.backend.domain.game.dto.request; - -public record GameStartRequest(Long quizId) {} diff --git a/backend/src/main/java/io/f1/backend/domain/game/dto/response/QuestionResultResponse.java b/backend/src/main/java/io/f1/backend/domain/game/dto/response/QuestionResultResponse.java index 5d8c131e..57de08cc 100644 --- a/backend/src/main/java/io/f1/backend/domain/game/dto/response/QuestionResultResponse.java +++ b/backend/src/main/java/io/f1/backend/domain/game/dto/response/QuestionResultResponse.java @@ -1,3 +1,3 @@ package io.f1.backend.domain.game.dto.response; -public record QuestionResultResponse(Long questionId, String correctUser, String answer) {} +public record QuestionResultResponse(String correctUser, String answer) {} 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 new file mode 100644 index 00000000..c6788e0f --- /dev/null +++ b/backend/src/main/java/io/f1/backend/domain/game/dto/response/QuestionStartResponse.java @@ -0,0 +1,5 @@ +package io.f1.backend.domain.game.dto.response; + +import java.time.Instant; + +public record QuestionStartResponse(Long questionId, int round, Instant timestamp) {} diff --git a/backend/src/main/java/io/f1/backend/domain/game/dto/response/RoomSettingResponse.java b/backend/src/main/java/io/f1/backend/domain/game/dto/response/RoomSettingResponse.java index 161d53ce..019aeff7 100644 --- a/backend/src/main/java/io/f1/backend/domain/game/dto/response/RoomSettingResponse.java +++ b/backend/src/main/java/io/f1/backend/domain/game/dto/response/RoomSettingResponse.java @@ -1,3 +1,4 @@ package io.f1.backend.domain.game.dto.response; -public record RoomSettingResponse(String roomName, int maxUserCount, int currentUserCount) {} +public record RoomSettingResponse( + String roomName, int maxUserCount, int currentUserCount, boolean locked) {} 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 3ec0bbc9..a05a097f 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 @@ -1,6 +1,5 @@ package io.f1.backend.domain.game.mapper; -import io.f1.backend.domain.game.dto.ChatMessage; 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; @@ -8,6 +7,7 @@ import io.f1.backend.domain.game.dto.response.PlayerListResponse; import io.f1.backend.domain.game.dto.response.PlayerResponse; import io.f1.backend.domain.game.dto.response.QuestionResultResponse; +import io.f1.backend.domain.game.dto.response.QuestionStartResponse; import io.f1.backend.domain.game.dto.response.QuizResponse; import io.f1.backend.domain.game.dto.response.RankUpdateResponse; import io.f1.backend.domain.game.dto.response.RoomResponse; @@ -44,7 +44,8 @@ public static RoomSettingResponse toRoomSettingResponse(Room room) { return new RoomSettingResponse( room.getRoomSetting().roomName(), room.getRoomSetting().maxUserCount(), - room.getPlayerSessionMap().size()); + room.getPlayerSessionMap().size(), + room.getRoomSetting().locked()); } public static GameSettingResponse toGameSettingResponse(GameSetting gameSetting, Quiz quiz) { @@ -86,18 +87,11 @@ public static QuizResponse toQuizResponse(Quiz quiz) { } public static SystemNoticeResponse ofPlayerEvent(String nickname, RoomEventType roomEventType) { - String message = ""; - if (roomEventType == RoomEventType.ENTER) { - message = " 님이 입장하셨습니다"; - } else if (roomEventType == RoomEventType.EXIT) { - message = " 님이 퇴장하셨습니다"; - } - return new SystemNoticeResponse(nickname + message, Instant.now()); + return new SystemNoticeResponse(roomEventType.getMessage(nickname), Instant.now()); } - public static QuestionResultResponse toQuestionResultResponse( - Long questionId, ChatMessage chatMessage, String answer) { - return new QuestionResultResponse(questionId, chatMessage.nickname(), answer); + public static QuestionResultResponse toQuestionResultResponse(String nickname, String answer) { + return new QuestionResultResponse(nickname, answer); } public static RankUpdateResponse toRankUpdateResponse(Room room) { @@ -107,4 +101,11 @@ public static RankUpdateResponse toRankUpdateResponse(Room room) { .map(player -> new Rank(player.getNickname(), player.getCorrectCount())) .toList()); } + + public static QuestionStartResponse toQuestionStartResponse(Room room, int delay) { + return new QuestionStartResponse( + room.getCurrentQuestion().getId(), + room.getCurrentRound(), + Instant.now().plusSeconds(delay)); + } } 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 0b8b17e1..bf17a522 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 @@ -11,7 +11,7 @@ public class GameSetting { private Long quizId; private Integer round; // 게임 변경 시 해당 게임의 총 문제 수로 설정 - private int timeLimit = 60; + private int timeLimit; public boolean validateQuizId(Long quizId) { return Objects.equals(this.quizId, quizId); 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 4d054c8c..9d20b212 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 @@ -27,4 +27,8 @@ public void toggleReady() { public void increaseCorrectCount() { correctCount++; } + + 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 82b4b18e..fd581102 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 @@ -1,14 +1,21 @@ package io.f1.backend.domain.game.model; import io.f1.backend.domain.question.entity.Question; +import io.f1.backend.global.exception.CustomException; +import io.f1.backend.global.exception.errorcode.RoomErrorCode; import lombok.Getter; import java.time.LocalDateTime; import java.util.ArrayList; +import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; @Getter public class Room { @@ -27,12 +34,16 @@ public class Room { private Map playerSessionMap = new ConcurrentHashMap<>(); - private Map userIdSessionMap = new ConcurrentHashMap<>(); + private final Set validatedUserIds = new HashSet<>(); private final LocalDateTime createdAt = LocalDateTime.now(); private int currentRound = 0; + private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(); + + private ScheduledFuture timer; + public Room(Long id, RoomSetting roomSetting, GameSetting gameSetting, Player host) { this.id = id; this.roomSetting = roomSetting; @@ -40,6 +51,26 @@ public Room(Long id, RoomSetting roomSetting, GameSetting gameSetting, Player ho this.host = host; } + public void addValidatedUserId(Long userId) { + validatedUserIds.add(userId); + } + + public int getCurrentUserCnt() { + return validatedUserIds.size(); + } + + public void addPlayer(String sessionId, Player player) { + Long userId = player.getId(); + if (!validatedUserIds.contains(userId)) { + throw new CustomException(RoomErrorCode.ROOM_ENTER_REQUIRED); + } + + if (isHost(userId)) { + player.toggleReady(); + } + playerSessionMap.put(sessionId, player); + } + public boolean isHost(Long id) { return this.host.getId().equals(id); } @@ -56,14 +87,22 @@ public void updateRoomState(RoomState newState) { this.state = newState; } - public void removeUserId(Long id) { - this.userIdSessionMap.remove(id); + public void updateTimer(ScheduledFuture timer) { + this.timer = timer; } public void removeSessionId(String sessionId) { this.playerSessionMap.remove(sessionId); } + public void removeValidatedUserId(Long userId) { + validatedUserIds.remove(userId); + } + + public void removeUserId(Long userId) { + validatedUserIds.remove(userId); + } + public void increasePlayerCorrectCount(String sessionId) { this.playerSessionMap.get(sessionId).increaseCorrectCount(); } @@ -72,11 +111,34 @@ public Question getCurrentQuestion() { return questions.get(currentRound - 1); } - public Boolean isPlaying() { + public boolean isPlaying() { return state == RoomState.PLAYING; } - public void increaseCorrectCount() { + public void increaseCurrentRound() { currentRound++; } + + public void reconnectSession(String oldSessionId, String newSessionId) { + Player player = playerSessionMap.get(oldSessionId); + removeSessionId(oldSessionId); + player.updateState(ConnectionState.CONNECTED); + playerSessionMap.put(newSessionId, player); + } + + public void updatePlayerConnectionState(String sessionId, ConnectionState newState) { + playerSessionMap.get(sessionId).updateState(newState); + } + + public boolean isExit(String sessionId) { + return playerSessionMap.get(sessionId) == null; + } + + public boolean isLastPlayer(String sessionId) { + long connectedCount = + playerSessionMap.values().stream() + .filter(player -> player.getState() == ConnectionState.CONNECTED) + .count(); + return connectedCount == 1 && playerSessionMap.containsKey(sessionId); + } } diff --git a/backend/src/main/java/io/f1/backend/domain/game/websocket/GameSocketController.java b/backend/src/main/java/io/f1/backend/domain/game/websocket/GameSocketController.java deleted file mode 100644 index d51c18f2..00000000 --- a/backend/src/main/java/io/f1/backend/domain/game/websocket/GameSocketController.java +++ /dev/null @@ -1,129 +0,0 @@ -package io.f1.backend.domain.game.websocket; - -import io.f1.backend.domain.game.app.GameService; -import io.f1.backend.domain.game.app.RoomService; -import io.f1.backend.domain.game.dto.ChatMessage; -import io.f1.backend.domain.game.dto.MessageType; -import io.f1.backend.domain.game.dto.RoomExitData; -import io.f1.backend.domain.game.dto.RoomInitialData; -import io.f1.backend.domain.game.dto.RoundResult; -import io.f1.backend.domain.game.dto.request.DefaultWebSocketRequest; -import io.f1.backend.domain.game.dto.request.GameStartRequest; -import io.f1.backend.domain.game.dto.response.GameStartResponse; -import io.f1.backend.domain.game.dto.response.PlayerListResponse; -import io.f1.backend.domain.user.dto.UserPrincipal; - -import lombok.RequiredArgsConstructor; - -import org.springframework.messaging.Message; -import org.springframework.messaging.handler.annotation.DestinationVariable; -import org.springframework.messaging.handler.annotation.MessageMapping; -import org.springframework.messaging.simp.stomp.StompHeaderAccessor; -import org.springframework.security.core.Authentication; -import org.springframework.stereotype.Controller; - -@Controller -@RequiredArgsConstructor -public class GameSocketController { - - private final MessageSender messageSender; - private final RoomService roomService; - private final GameService gameService; - - @MessageMapping("/room/initializeRoomSocket/{roomId}") - public void initializeRoomSocket(@DestinationVariable Long roomId, Message message) { - - String websocketSessionId = getSessionId(message); - - UserPrincipal principal = getSessionUser(message); - - RoomInitialData roomInitialData = - roomService.initializeRoomSocket(roomId, websocketSessionId, principal); - - String destination = getDestination(roomId); - - messageSender.send( - destination, MessageType.ROOM_SETTING, roomInitialData.roomSettingResponse()); - messageSender.send( - destination, MessageType.GAME_SETTING, roomInitialData.gameSettingResponse()); - messageSender.send( - destination, MessageType.PLAYER_LIST, roomInitialData.playerListResponse()); - messageSender.send( - destination, MessageType.SYSTEM_NOTICE, roomInitialData.systemNoticeResponse()); - } - - @MessageMapping("/room/exit/{roomId}") - public void exitRoom(@DestinationVariable Long roomId, Message message) { - - String websocketSessionId = getSessionId(message); - UserPrincipal principal = getSessionUser(message); - - RoomExitData roomExitData = roomService.exitRoom(roomId, websocketSessionId, principal); - - String destination = getDestination(roomId); - - if (!roomExitData.isRemovedRoom()) { - messageSender.send( - destination, MessageType.PLAYER_LIST, roomExitData.getPlayerListResponses()); - messageSender.send( - destination, MessageType.SYSTEM_NOTICE, roomExitData.getSystemNoticeResponse()); - } - } - - @MessageMapping("/room/start/{roomId}") - public void gameStart( - @DestinationVariable Long roomId, - Message> message) { - - GameStartResponse gameStartResponse = - gameService.gameStart(roomId, message.getPayload().getMessage()); - - String destination = getDestination(roomId); - - messageSender.send(destination, MessageType.GAME_START, gameStartResponse); - } - - @MessageMapping("room/chat/{roomId}") - public void chat( - @DestinationVariable Long roomId, - Message> message) { - RoundResult roundResult = - roomService.chat(roomId, getSessionId(message), message.getPayload().getMessage()); - - String destination = getDestination(roomId); - - messageSender.send(destination, MessageType.CHAT, roundResult.getChat()); - - if (!roundResult.hasOnlyChat()) { - messageSender.send( - destination, MessageType.QUESTION_RESULT, roundResult.getQuestionResult()); - messageSender.send(destination, MessageType.RANK_UPDATE, roundResult.getRankUpdate()); - messageSender.send( - destination, MessageType.SYSTEM_NOTICE, roundResult.getSystemNotice()); - } - } - - @MessageMapping("/room/ready/{roomId}") - public void playerReady(@DestinationVariable Long roomId, Message message) { - - PlayerListResponse playerListResponse = - roomService.handlePlayerReady(roomId, getSessionId(message)); - - messageSender.send(getDestination(roomId), MessageType.PLAYER_LIST, playerListResponse); - } - - private static String getSessionId(Message message) { - StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message); - return accessor.getSessionId(); - } - - private static UserPrincipal getSessionUser(Message message) { - StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message); - Authentication auth = (Authentication) accessor.getUser(); - return (UserPrincipal) auth.getPrincipal(); - } - - private String getDestination(Long roomId) { - return "/sub/room/" + roomId; - } -} diff --git a/backend/src/main/java/io/f1/backend/domain/game/websocket/WebSocketUtils.java b/backend/src/main/java/io/f1/backend/domain/game/websocket/WebSocketUtils.java new file mode 100644 index 00000000..b615f6ff --- /dev/null +++ b/backend/src/main/java/io/f1/backend/domain/game/websocket/WebSocketUtils.java @@ -0,0 +1,30 @@ +package io.f1.backend.domain.game.websocket; + +import io.f1.backend.domain.user.dto.UserPrincipal; + +import org.springframework.messaging.Message; +import org.springframework.messaging.simp.stomp.StompHeaderAccessor; +import org.springframework.security.core.Authentication; + +public class WebSocketUtils { + + public static String getSessionId(Message message) { + StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message); + return accessor.getSessionId(); + } + + public static UserPrincipal getSessionUser(Message message) { + StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message); + Authentication auth = (Authentication) accessor.getUser(); + return (UserPrincipal) auth.getPrincipal(); + } + + public static String getDestination(Long roomId) { + return "/sub/room/" + roomId; + } + + public static String getRoomSubscriptionDestination(Message message) { + StompHeaderAccessor headerAccessor = StompHeaderAccessor.wrap(message); + return headerAccessor.getDestination(); + } +} 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 new file mode 100644 index 00000000..90a2bb3a --- /dev/null +++ b/backend/src/main/java/io/f1/backend/domain/game/websocket/controller/GameSocketController.java @@ -0,0 +1,87 @@ +package io.f1.backend.domain.game.websocket.controller; + +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.GameService; +import io.f1.backend.domain.game.app.RoomService; +import io.f1.backend.domain.game.dto.ChatMessage; +import io.f1.backend.domain.game.dto.request.DefaultWebSocketRequest; +import io.f1.backend.domain.game.websocket.service.SessionService; +import io.f1.backend.domain.user.dto.UserPrincipal; + +import lombok.RequiredArgsConstructor; + +import org.springframework.messaging.Message; +import org.springframework.messaging.handler.annotation.DestinationVariable; +import org.springframework.messaging.handler.annotation.MessageMapping; +import org.springframework.stereotype.Controller; + +@Controller +@RequiredArgsConstructor +public class GameSocketController { + + private final RoomService roomService; + private final GameService gameService; + private final SessionService sessionService; + + @MessageMapping("/room/initializeRoomSocket/{roomId}") + public void initializeRoomSocket(@DestinationVariable Long roomId, Message message) { + + String websocketSessionId = getSessionId(message); + + UserPrincipal principal = getSessionUser(message); + + roomService.initializeRoomSocket(roomId, websocketSessionId, principal); + } + + @MessageMapping("/room/reconnect/{roomId}") + public void reconnect(@DestinationVariable Long roomId, Message message) { + String websocketSessionId = getSessionId(message); + + UserPrincipal principal = getSessionUser(message); + Long userId = principal.getUserId(); + + if (!sessionService.hasOldSessionId(userId)) { + return; + } + + String oldSessionId = sessionService.getOldSessionId(userId); + + /* room 재연결 대상인지 아닌지 판별 */ + if (!roomService.isExit(oldSessionId, roomId)) { + roomService.reconnectSession(roomId, oldSessionId, websocketSessionId, principal); + } + } + + @MessageMapping("/room/exit/{roomId}") + public void exitRoom(@DestinationVariable Long roomId, Message message) { + + String websocketSessionId = getSessionId(message); + UserPrincipal principal = getSessionUser(message); + + roomService.exitRoom(roomId, websocketSessionId, principal); + } + + @MessageMapping("/room/start/{roomId}") + public void gameStart(@DestinationVariable Long roomId, Message message) { + + UserPrincipal principal = getSessionUser(message); + + gameService.gameStart(roomId, principal); + } + + @MessageMapping("room/chat/{roomId}") + public void chat( + @DestinationVariable Long roomId, + Message> message) { + + roomService.chat(roomId, getSessionId(message), message.getPayload().getMessage()); + } + + @MessageMapping("/room/ready/{roomId}") + public void playerReady(@DestinationVariable Long roomId, Message message) { + + roomService.handlePlayerReady(roomId, getSessionId(message)); + } +} diff --git a/backend/src/main/java/io/f1/backend/domain/game/websocket/eventlistener/WebsocketEventListener.java b/backend/src/main/java/io/f1/backend/domain/game/websocket/eventlistener/WebsocketEventListener.java new file mode 100644 index 00000000..15139da9 --- /dev/null +++ b/backend/src/main/java/io/f1/backend/domain/game/websocket/eventlistener/WebsocketEventListener.java @@ -0,0 +1,65 @@ +package io.f1.backend.domain.game.websocket.eventlistener; + +import static io.f1.backend.domain.game.websocket.WebSocketUtils.getRoomSubscriptionDestination; +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.websocket.service.SessionService; +import io.f1.backend.domain.user.dto.UserPrincipal; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import org.springframework.context.event.EventListener; +import org.springframework.messaging.Message; +import org.springframework.stereotype.Component; +import org.springframework.web.socket.messaging.SessionConnectEvent; +import org.springframework.web.socket.messaging.SessionDisconnectEvent; +import org.springframework.web.socket.messaging.SessionSubscribeEvent; + +@Slf4j +@Component +@RequiredArgsConstructor +public class WebsocketEventListener { + + private final SessionService sessionService; + + @EventListener + public void handleConnectListener(SessionConnectEvent event) { + Message message = event.getMessage(); + + String sessionId = getSessionId(message); + UserPrincipal user = getSessionUser(message); + + sessionService.addSession(sessionId, user.getUserId()); + } + + @EventListener + public void handleSubscribeListener(SessionSubscribeEvent event) { + + Message message = event.getMessage(); + + String sessionId = getSessionId(message); + + String destination = getRoomSubscriptionDestination(message); + + // todo 인덱스 길이 유효성 추가 + String[] subscribeType = destination.split("/"); + + if (subscribeType[2].equals("room")) { + Long roomId = Long.parseLong(subscribeType[3]); + sessionService.addRoomId(roomId, sessionId); + } + } + + @EventListener + public void handleDisconnectedListener(SessionDisconnectEvent event) { + + Message message = event.getMessage(); + + String sessionId = getSessionId(message); + UserPrincipal principal = getSessionUser(message); + + sessionService.handleUserDisconnect(sessionId, principal); + } +} 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 new file mode 100644 index 00000000..ec40f46f --- /dev/null +++ b/backend/src/main/java/io/f1/backend/domain/game/websocket/service/SessionService.java @@ -0,0 +1,86 @@ +package io.f1.backend.domain.game.websocket.service; + +import io.f1.backend.domain.game.app.RoomService; +import io.f1.backend.domain.game.model.ConnectionState; +import io.f1.backend.domain.user.dto.UserPrincipal; + +import lombok.RequiredArgsConstructor; + +import org.springframework.stereotype.Service; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +@Service +@RequiredArgsConstructor +public class SessionService { + + private final RoomService roomService; + private final Map sessionIdUser = new ConcurrentHashMap<>(); + private final Map sessionIdRoom = new ConcurrentHashMap<>(); + private final Map userIdSession = new ConcurrentHashMap<>(); + private final Map userIdLatestSession = new ConcurrentHashMap<>(); + + // todo 부하테스트 후 스레드 풀 변경 + private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(2); + + public void addSession(String sessionId, Long userId) { + sessionIdUser.put(sessionId, userId); + userIdSession.put(userId, sessionId); + } + + public void addRoomId(Long roomId, String sessionId) { + sessionIdRoom.put(sessionId, roomId); + } + + public boolean hasOldSessionId(Long userId) { + return userIdLatestSession.get(userId) != null; + } + + public String getOldSessionId(Long userId) { + return userIdLatestSession.get(userId); + } + + public void handleUserDisconnect(String sessionId, UserPrincipal principal) { + + Long roomId = sessionIdRoom.get(sessionId); + Long userId = sessionIdUser.get(sessionId); + + /* 정상 동작*/ + if (roomService.isExit(sessionId, roomId)) { + removeSession(sessionId, userId); + return; + } + + userIdLatestSession.put(userId, sessionId); + + roomService.changeConnectedStatus(roomId, sessionId, ConnectionState.DISCONNECTED); + + // 5초 뒤 실행 + scheduler.schedule( + () -> { + /* 재연결 실패 */ + if (sessionId.equals(userIdSession.get(userId))) { + roomService.exitIfNotPlaying(roomId, sessionId, principal); + // 메세지 응답 추가 + } + removeSession(sessionId, userId); + }, + 5, + TimeUnit.SECONDS); + } + + public void removeSession(String sessionId, Long userId) { + + if (sessionId.equals(userIdSession.get(userId))) { + userIdSession.remove(userId); + } + sessionIdUser.remove(sessionId); + sessionIdRoom.remove(sessionId); + + userIdLatestSession.remove(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 a8f7a3fa..c16365d3 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 @@ -277,4 +277,11 @@ public List getRandomQuestionsWithoutAnswer(Long quizId, Integer round return quizRepository.findRandQuestionsByQuizId(quizId, round); } + + @Transactional(readOnly = true) + public Quiz findQuizById(Long quizId) { + return quizRepository + .findById(quizId) + .orElseThrow(() -> new CustomException(QuizErrorCode.QUIZ_NOT_FOUND)); + } } diff --git a/backend/src/main/java/io/f1/backend/domain/stat/api/StatController.java b/backend/src/main/java/io/f1/backend/domain/stat/api/StatController.java index 38c14de8..2918612a 100644 --- a/backend/src/main/java/io/f1/backend/domain/stat/api/StatController.java +++ b/backend/src/main/java/io/f1/backend/domain/stat/api/StatController.java @@ -11,6 +11,7 @@ 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.RestController; @@ -29,4 +30,13 @@ public ResponseEntity getRankings( return ResponseEntity.ok().body(response); } + + @LimitPageSize + @GetMapping("/rankings/{nickname}") + public ResponseEntity getRankingsByNickname( + @PathVariable String nickname, @PageableDefault Pageable pageable) { + StatPageResponse response = + statService.getRanksByNickname(nickname, pageable.getPageSize()); + return ResponseEntity.ok().body(response); + } } diff --git a/backend/src/main/java/io/f1/backend/domain/stat/app/StatService.java b/backend/src/main/java/io/f1/backend/domain/stat/app/StatService.java index b123c80d..53668555 100644 --- a/backend/src/main/java/io/f1/backend/domain/stat/app/StatService.java +++ b/backend/src/main/java/io/f1/backend/domain/stat/app/StatService.java @@ -5,12 +5,18 @@ 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.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 @RequiredArgsConstructor @@ -18,8 +24,30 @@ public class StatService { private final StatRepository statRepository; + @Transactional(readOnly = true) public StatPageResponse getRanks(Pageable pageable) { - Page stats = statRepository.findWithUser(pageable); + Page stats = statRepository.findAllStatsWithUser(pageable); return toStatListPageResponse(stats); } + + @Transactional(readOnly = true) + public StatPageResponse getRanksByNickname(String nickname, int pageSize) { + + Page stats = + statRepository.findAllStatsWithUser(getPageableFromNickname(nickname, pageSize)); + + return toStatListPageResponse(stats); + } + + private Pageable getPageableFromNickname(String nickname, int pageSize) { + long score = + statRepository + .findScoreByNickname(nickname) + .orElseThrow(() -> new CustomException(RoomErrorCode.PLAYER_NOT_FOUND)); + + long rowNum = statRepository.countByScoreGreaterThan(score); + + int pageNumber = rowNum > 0 ? (int) (rowNum / pageSize) : 0; + return PageRequest.of(pageNumber, pageSize, Sort.by(Direction.DESC, "score")); + } } 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 912e735c..445abc37 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 @@ -8,6 +8,8 @@ import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; +import java.util.Optional; + public interface StatRepository extends JpaRepository { @Query( @@ -18,5 +20,10 @@ public interface StatRepository extends JpaRepository { FROM Stat s JOIN s.user u """) - Page findWithUser(Pageable pageable); + Page findAllStatsWithUser(Pageable pageable); + + @Query("SELECT s.score FROM Stat s WHERE s.user.nickname = :nickname") + Optional findScoreByNickname(String nickname); + + long countByScoreGreaterThan(Long score); } 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 bdf86e96..4db37734 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 @@ -66,7 +66,7 @@ private void validateNicknameFormat(String nickname) { @Transactional(readOnly = true) public void validateNicknameDuplicate(String nickname) { - if (userRepository.existsUserByNickname(nickname)) { + if (userRepository.existsUserByNicknameIgnoreCase(nickname)) { throw new CustomException(UserErrorCode.NICKNAME_CONFLICT); } } diff --git a/backend/src/main/java/io/f1/backend/domain/user/dao/UserRepository.java b/backend/src/main/java/io/f1/backend/domain/user/dao/UserRepository.java index 0cb50caf..c82ba2e1 100644 --- a/backend/src/main/java/io/f1/backend/domain/user/dao/UserRepository.java +++ b/backend/src/main/java/io/f1/backend/domain/user/dao/UserRepository.java @@ -16,10 +16,16 @@ public interface UserRepository extends JpaRepository { Optional findByProviderAndProviderId(String provider, String providerId); - Boolean existsUserByNickname(String nickname); + Boolean existsUserByNicknameIgnoreCase(String nickname); @Query( "SELECT new io.f1.backend.domain.admin.dto.UserResponse(u.id, u.nickname, u.lastLogin," + " u.createdAt)FROM User u ORDER BY u.id") Page findAllUsersWithPaging(Pageable pageable); + + @Query( + "SELECT new io.f1.backend.domain.admin.dto.UserResponse(u.id, u.nickname, u.lastLogin," + + " u.createdAt) FROM User u WHERE LOWER(u.nickname) LIKE CONCAT('%'," + + " LOWER(:nickname), '%')") + Page findUsersByNicknameContaining(String nickname, Pageable pageable); } diff --git a/backend/src/main/java/io/f1/backend/global/config/CustomHandshakeInterceptor.java b/backend/src/main/java/io/f1/backend/global/config/CustomHandshakeInterceptor.java new file mode 100644 index 00000000..314b37df --- /dev/null +++ b/backend/src/main/java/io/f1/backend/global/config/CustomHandshakeInterceptor.java @@ -0,0 +1,46 @@ +package io.f1.backend.global.config; + +import lombok.extern.slf4j.Slf4j; + +import org.springframework.http.HttpStatus; +import org.springframework.http.server.ServerHttpRequest; +import org.springframework.http.server.ServerHttpResponse; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.socket.WebSocketHandler; +import org.springframework.web.socket.server.HandshakeInterceptor; + +import java.util.Map; + +@Slf4j +@Component +public class CustomHandshakeInterceptor implements HandshakeInterceptor { + + @Override + public boolean beforeHandshake( + ServerHttpRequest request, + ServerHttpResponse response, + WebSocketHandler wsHandler, + Map attributes) + throws Exception { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication == null || !authentication.isAuthenticated()) { + response.setStatusCode(HttpStatus.UNAUTHORIZED); // 서버 로그에만 적용되는 StatusCode + return false; + } + + attributes.put("auth", authentication); + return true; + } + + @Override + public void afterHandshake( + ServerHttpRequest request, + ServerHttpResponse response, + WebSocketHandler wsHandler, + Exception exception) { + // TODO : 연결 이후, 사용자 웹소켓 세션 로그 및 IP 등 추적 및 메트릭 수집 로직 추가 + + } +} diff --git a/backend/src/main/java/io/f1/backend/global/config/StompChannelInterceptor.java b/backend/src/main/java/io/f1/backend/global/config/StompChannelInterceptor.java index dea375a8..fd3d02a0 100644 --- a/backend/src/main/java/io/f1/backend/global/config/StompChannelInterceptor.java +++ b/backend/src/main/java/io/f1/backend/global/config/StompChannelInterceptor.java @@ -27,16 +27,21 @@ public Message preSend(Message message, MessageChannel channel) { throw new IllegalArgumentException("Stomp command required"); } + String username = "알수없는 사용자"; + if (accessor.getUser() != null) { + username = accessor.getUser().getName(); + } + if (command.equals(StompCommand.CONNECT)) { - log.info("CONNECT : 세션 연결 - sessionId = {}", sessionId); + log.info("user : {} | CONNECT : 세션 연결 - sessionId = {}", username, sessionId); } else if (command.equals(StompCommand.SUBSCRIBE)) { if (destination != null && sessionId != null) { - log.info("SUBSCRIBE : 구독 시작 destination = {}", destination); + log.info("user : {} | SUBSCRIBE : 구독 시작 destination = {}", username, destination); } } else if (command.equals(StompCommand.SEND)) { - log.info("SEND : 요청 destination = {}", destination); + log.info("user : {} | SEND : 요청 destination = {}", username, destination); } else if (command.equals(StompCommand.DISCONNECT)) { - log.info("DISCONNECT : 연결 해제 sessionId = {}", sessionId); + log.info("user : {} | DISCONNECT : 연결 해제 sessionId = {}", username, sessionId); } return message; diff --git a/backend/src/main/java/io/f1/backend/global/config/WebSocketConfig.java b/backend/src/main/java/io/f1/backend/global/config/WebSocketConfig.java index cb229ed4..1a1d475c 100644 --- a/backend/src/main/java/io/f1/backend/global/config/WebSocketConfig.java +++ b/backend/src/main/java/io/f1/backend/global/config/WebSocketConfig.java @@ -8,7 +8,6 @@ import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; import org.springframework.web.socket.config.annotation.StompEndpointRegistry; import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; -import org.springframework.web.socket.server.support.HttpSessionHandshakeInterceptor; @Configuration @RequiredArgsConstructor @@ -16,18 +15,21 @@ public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { private final StompChannelInterceptor stompChannelInterceptor; + private final CustomHandshakeInterceptor customHandshakeInterceptor; @Override public void registerStompEndpoints(StompEndpointRegistry registry) { registry.addEndpoint("/ws/game-room") - .addInterceptors(new HttpSessionHandshakeInterceptor()) + .addInterceptors(customHandshakeInterceptor) .setAllowedOriginPatterns("*"); } @Override public void configureMessageBroker(MessageBrokerRegistry registry) { - registry.enableSimpleBroker("/sub"); + registry.enableSimpleBroker("/sub", "/queue"); registry.setApplicationDestinationPrefixes("/pub"); + + registry.setUserDestinationPrefix("/user"); } @Override diff --git a/backend/src/main/java/io/f1/backend/global/exception/errorcode/GameErrorCode.java b/backend/src/main/java/io/f1/backend/global/exception/errorcode/GameErrorCode.java index 73a12122..923252d3 100644 --- a/backend/src/main/java/io/f1/backend/global/exception/errorcode/GameErrorCode.java +++ b/backend/src/main/java/io/f1/backend/global/exception/errorcode/GameErrorCode.java @@ -8,7 +8,8 @@ @Getter @RequiredArgsConstructor public enum GameErrorCode implements ErrorCode { - GAME_SETTING_CONFLICT("E409002", HttpStatus.CONFLICT, "게임 설정이 맞지 않습니다."); + GAME_SETTING_CONFLICT("E409002", HttpStatus.CONFLICT, "게임 설정이 맞지 않습니다."), + PLAYER_NOT_READY("E403004", HttpStatus.FORBIDDEN, "게임 시작을 위한 준비 상태가 아닙니다."); private final String code; diff --git a/backend/src/main/java/io/f1/backend/global/exception/errorcode/RoomErrorCode.java b/backend/src/main/java/io/f1/backend/global/exception/errorcode/RoomErrorCode.java index 2925373e..0041e450 100644 --- a/backend/src/main/java/io/f1/backend/global/exception/errorcode/RoomErrorCode.java +++ b/backend/src/main/java/io/f1/backend/global/exception/errorcode/RoomErrorCode.java @@ -10,11 +10,13 @@ public enum RoomErrorCode implements ErrorCode { ROOM_USER_LIMIT_REACHED("E403002", HttpStatus.FORBIDDEN, "정원이 모두 찼습니다."), ROOM_GAME_IN_PROGRESS("E403003", HttpStatus.FORBIDDEN, "게임이 진행 중 입니다."), - PLAYER_NOT_READY("E403004", HttpStatus.FORBIDDEN, "게임 시작을 위한 준비 상태가 아닙니다."), ROOM_NOT_FOUND("E404005", HttpStatus.NOT_FOUND, "존재하지 않는 방입니다."), WRONG_PASSWORD("E401006", HttpStatus.UNAUTHORIZED, "비밀번호가 일치하지않습니다."), PLAYER_NOT_FOUND("E404007", HttpStatus.NOT_FOUND, "존재하지 않는 플레이어입니다."), - SOCKET_SESSION_NOT_FOUND("E404006", HttpStatus.NOT_FOUND, "존재하지 않는 소켓 세션입니다."); + SOCKET_SESSION_NOT_FOUND("E404006", HttpStatus.NOT_FOUND, "존재하지 않는 소켓 세션입니다."), + GAME_ALREADY_PLAYING("E400015", HttpStatus.BAD_REQUEST, "이미 게임이 진행 중 입니다."), + NOT_ROOM_OWNER("E403005", HttpStatus.FORBIDDEN, "방장만 게임 시작이 가능합니다."), + ROOM_ENTER_REQUIRED("E400014", HttpStatus.NOT_FOUND, "방 입장 후에 소켓 연결이 시도되어야합니다."); private final String code; diff --git a/backend/src/main/java/io/f1/backend/global/util/SecurityUtils.java b/backend/src/main/java/io/f1/backend/global/util/SecurityUtils.java index 194e310a..38e88730 100644 --- a/backend/src/main/java/io/f1/backend/global/util/SecurityUtils.java +++ b/backend/src/main/java/io/f1/backend/global/util/SecurityUtils.java @@ -33,7 +33,7 @@ public static UserPrincipal getCurrentUserPrincipal() { && authentication.getPrincipal() instanceof UserPrincipal userPrincipal) { return userPrincipal; } - throw new RuntimeException("E401001: 로그인이 필요합니다."); + throw new CustomException(AuthErrorCode.UNAUTHORIZED); } public static Long getCurrentUserId() { diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index 828cd9f7..d561f3f4 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -64,5 +64,5 @@ server: same-site: None secure: true http-only: true - timeout: 60 + timeout: ${SESSION_TIMEOUT} diff --git a/backend/src/main/resources/data.sql b/backend/src/main/resources/data.sql index db9171aa..27c3b9e7 100644 --- a/backend/src/main/resources/data.sql +++ b/backend/src/main/resources/data.sql @@ -37,4 +37,629 @@ VALUES (7, '7번 문제 내용입니다.'), (8, '8번 문제 내용입니다.'), (9, '9번 문제 내용입니다.'), - (10, '10번 문제 내용입니다.'); \ No newline at end of file + (10, '10번 문제 내용입니다.'); + +-- 퀴즈 2 ~ 21 추가 +INSERT INTO quiz (title, description, quiz_type, thumbnail_url, creator_id) VALUES + ('세계 수도 퀴즈', '나라의 수도를 맞혀보는 퀴즈입니다.', 'TEXT', 'https://picsum.photos/seed/2/200/300', 1), + ('동물 상식 퀴즈', '동물에 대한 재미있는 상식을 알아보세요.', 'TEXT', 'https://picsum.photos/seed/3/200/300', 1), + ('과학 기초 퀴즈', '과학 기초 개념을 테스트합니다.', 'TEXT', 'https://picsum.photos/seed/4/200/300', 1), + ('역사 인물 퀴즈', '유명한 역사 인물들을 맞혀보세요.', 'TEXT', 'https://picsum.photos/seed/5/200/300', 1), + ('스포츠 상식 퀴즈', '스포츠에 관한 기본 지식을 묻는 퀴즈입니다.', 'TEXT', 'https://picsum.photos/seed/6/200/300', 1), + ('문학 작품 퀴즈', '유명한 문학 작품과 작가를 묻는 퀴즈입니다.', 'TEXT', 'https://picsum.photos/seed/7/200/300', 1), + ('음악 상식 퀴즈', '음악 이론과 아티스트에 대해 맞혀보세요.', 'TEXT', 'https://picsum.photos/seed/8/200/300', 1), + ('IT 기초 퀴즈', 'IT 기초 개념과 용어를 묻는 퀴즈입니다.', 'TEXT', 'https://picsum.photos/seed/9/200/300', 1), + ('한국 지리 퀴즈', '대한민국의 지리 상식을 테스트합니다.', 'TEXT', 'https://picsum.photos/seed/10/200/300', 1), + ('세계 음식 퀴즈', '다양한 국가의 음식 이름을 맞혀보세요.', 'TEXT', 'https://picsum.photos/seed/11/200/300', 1), + ('속담 퀴즈', '한국 속담의 빈칸을 채워보세요.', 'TEXT', 'https://picsum.photos/seed/12/200/300', 1), + ('관용어 퀴즈', '국어 관용어 표현을 테스트하는 퀴즈입니다.', 'TEXT', 'https://picsum.photos/seed/13/200/300', 1), + ('물리 법칙 퀴즈', '물리학의 기본 법칙에 대해 알아보세요.', 'TEXT', 'https://picsum.photos/seed/14/200/300', 1), + ('화학 원소 퀴즈', '주기율표 원소에 관한 퀴즈입니다.', 'TEXT', 'https://picsum.photos/seed/15/200/300', 1), + ('영화 제목 퀴즈', '유명 영화의 제목을 맞혀보세요.', 'TEXT', 'https://picsum.photos/seed/16/200/300', 1), + ('명언 출처 퀴즈', '유명한 명언의 출처를 맞혀보세요.', 'TEXT', 'https://picsum.photos/seed/17/200/300', 1), + ('한국사 연도 퀴즈', '한국사의 주요 사건 연도를 묻습니다.', 'TEXT', 'https://picsum.photos/seed/18/200/300', 1), + ('생활 속 수학 퀴즈', '생활 속에서 활용되는 수학 개념 퀴즈입니다.', 'TEXT', 'https://picsum.photos/seed/19/200/300', 1), + ('논리 퀴즈', '간단한 논리 문제를 풀어보세요.', 'TEXT', 'https://picsum.photos/seed/20/200/300', 1), + ('영어 단어 퀴즈', '기본적인 영어 단어를 테스트하는 퀴즈입니다.', 'TEXT', 'https://picsum.photos/seed/21/200/300', 1); + +-- 퀴즈별 문제들 추가 (퀴즈 2~21, 각 퀴즈당 문제 개수 10~80개 랜덤) +-- 예시로 퀴즈 2 (퀴즈 ID 2번)부터 3개만 우선 생성해드릴게요. 전체 20개 모두 원하시면 이어서 계속 드릴게요. + +-- 퀴즈 2: 세계 수도 퀴즈 (15문제) +INSERT INTO question (quiz_id, answer, created_at, updated_at) VALUES + (2, '파리', NOW(), NOW()), + (2, '도쿄', NOW(), NOW()), + (2, '베이징', NOW(), NOW()), + (2, '카이로', NOW(), NOW()), + (2, '런던', NOW(), NOW()), + (2, '마드리드', NOW(), NOW()), + (2, '로마', NOW(), NOW()), + (2, '오타와', NOW(), NOW()), + (2, '캔버라', NOW(), NOW()), + (2, '방콕', NOW(), NOW()), + (2, '하노이', NOW(), NOW()), + (2, '자카르타', NOW(), NOW()), + (2, '베를린', NOW(), NOW()), + (2, '리스본', NOW(), NOW()), + (2, '서울', NOW(), NOW()); + +INSERT INTO text_question (question_id, content) VALUES + (11, '프랑스의 수도는 어디인가요?'), + (12, '일본의 수도는 어디인가요?'), + (13, '중국의 수도는 어디인가요?'), + (14, '이집트의 수도는 어디인가요?'), + (15, '영국의 수도는 어디인가요?'), + (16, '스페인의 수도는 어디인가요?'), + (17, '이탈리아의 수도는 어디인가요?'), + (18, '캐나다의 수도는 어디인가요?'), + (19, '호주의 수도는 어디인가요?'), + (20, '태국의 수도는 어디인가요?'), + (21, '베트남의 수도는 어디인가요?'), + (22, '인도네시아의 수도는 어디인가요?'), + (23, '독일의 수도는 어디인가요?'), + (24, '포르투갈의 수도는 어디인가요?'), + (25, '대한민국의 수도는 어디인가요?'); + +-- 퀴즈 3: 동물 상식 퀴즈 (12문제) +INSERT INTO question (quiz_id, answer, created_at, updated_at) VALUES + (3, '코끼리', NOW(), NOW()), + (3, '기린', NOW(), NOW()), + (3, '하마', NOW(), NOW()), + (3, '펭귄', NOW(), NOW()), + (3, '고래', NOW(), NOW()), + (3, '호랑이', NOW(), NOW()), + (3, '늑대', NOW(), NOW()), + (3, '표범', NOW(), NOW()), + (3, '캥거루', NOW(), NOW()), + (3, '타조', NOW(), NOW()), + (3, '침팬지', NOW(), NOW()), + (3, '코알라', NOW(), NOW()); + +INSERT INTO text_question (question_id, content) VALUES + (26, '가장 큰 육상 동물은?'), + (27, '목이 가장 긴 동물은?'), + (28, '물속에서 오래 숨을 참을 수 있는 동물은?'), + (29, '날지 못하지만 헤엄을 잘 치는 남극 동물은?'), + (30, '포유류 중에서 가장 큰 동물은?'), + (31, '한국의 대표적인 맹수는?'), + (32, '개과 동물로 무리를 지어 사냥하는 동물은?'), + (33, '나무 위에서 생활하는 표범 종류는?'), + (34, '아기를 주머니에 넣고 다니는 동물은?'), + (35, '날지 못하는 세계에서 가장 큰 새는?'), + (36, '인간과 DNA가 가장 비슷한 동물은?'), + (37, '호주의 대표 동물 중 나무에 사는 귀여운 동물은?'); + +-- 퀴즈 4: 과학 기초 퀴즈 (10문제) +INSERT INTO question (quiz_id, answer, created_at, updated_at) VALUES + (4, '물', NOW(), NOW()), + (4, '태양', NOW(), NOW()), + (4, '빛의 속도', NOW(), NOW()), + (4, '뉴턴', NOW(), NOW()), + (4, '중력', NOW(), NOW()), + (4, '산소', NOW(), NOW()), + (4, '수소', NOW(), NOW()), + (4, '지구', NOW(), NOW()), + (4, '탄소', NOW(), NOW()), + (4, '전자', NOW(), NOW()); + +INSERT INTO text_question (question_id, content) VALUES + (38, '화학식 H2O는 무엇인가요?'), + (39, '지구에 빛과 열을 제공하는 별은?'), + (40, '우주에서 가장 빠른 것은?'), + (41, '운동 법칙을 정리한 과학자는?'), + (42, '물체를 지구로 끌어당기는 힘은?'), + (43, '인간이 숨쉴 때 필요한 기체는?'), + (44, '가장 가벼운 원소는?'), + (45, '우리가 사는 행성은?'), + (46, '생명체의 주된 구성 원소 중 하나는?'), + (47, '원자핵 주위를 도는 입자는?'); + +-- 퀴즈 5: 역사 인물 퀴즈 (문제 13개) +INSERT INTO question (quiz_id, answer, created_at, updated_at) VALUES + (5, '세종대왕', NOW(), NOW()), + (5, '이순신', NOW(), NOW()), + (5, '율곡 이이', NOW(), NOW()), + (5, '유관순', NOW(), NOW()), + (5, '간디', NOW(), NOW()), + (5, '나폴레옹', NOW(), NOW()), + (5, '히틀러', NOW(), NOW()), + (5, '마틴 루터 킹', NOW(), NOW()), + (5, '링컨', NOW(), NOW()), + (5, '알렉산더 대왕', NOW(), NOW()), + (5, '클레오파트라', NOW(), NOW()), + (5, '레오나르도 다 빈치', NOW(), NOW()), + (5, '아인슈타인', NOW(), NOW()); + +INSERT INTO text_question (question_id, content) VALUES + (48, '한글을 창제한 조선의 왕은?'), + (49, '임진왜란 당시 활약한 조선의 장군은?'), + (50, '10만 양병설로 유명한 조선의 학자는?'), + (51, '3.1운동에 참여한 여성 독립운동가는?'), + (52, '비폭력 저항 운동으로 인도 독립을 이끈 인물은?'), + (53, '프랑스 황제로 유럽 정복을 시도한 인물은?'), + (54, '제2차 세계대전의 독일 총통은?'), + (55, '"I have a dream" 연설로 유명한 인권 운동가는?'), + (56, '미국의 노예 해방을 선언한 대통령은?'), + (57, '마케도니아 출신의 세계 정복자는?'), + (58, '고대 이집트의 여왕으로 유명한 인물은?'), + (59, '모나리자를 그린 예술가는?'), + (60, '상대성 이론을 제안한 과학자는?'); + +-- 퀴즈 6: 스포츠 상식 퀴즈 (문제 11개) +INSERT INTO question (quiz_id, answer, created_at, updated_at) VALUES + (6, '축구', NOW(), NOW()), + (6, '야구', NOW(), NOW()), + (6, '농구', NOW(), NOW()), + (6, '배구', NOW(), NOW()), + (6, '골프', NOW(), NOW()), + (6, '테니스', NOW(), NOW()), + (6, '복싱', NOW(), NOW()), + (6, '씨름', NOW(), NOW()), + (6, '마라톤', NOW(), NOW()), + (6, '탁구', NOW(), NOW()), + (6, '수영', NOW(), NOW()); + +INSERT INTO text_question (question_id, content) VALUES + (61, '11명이 한 팀으로 경기하는 스포츠는?'), + (62, '홈런이 나오는 스포츠는?'), + (63, '슛과 리바운드가 중요한 스포츠는?'), + (64, '서브와 블로킹이 중요한 스포츠는?'), + (65, '홀인원이 가능한 스포츠는?'), + (66, '라켓으로 치고 네트를 넘기는 스포츠는?'), + (67, 'KO 승부가 있는 격투 스포츠는?'), + (68, '한국 전통적인 힘겨루기 스포츠는?'), + (69, '42.195km를 달리는 경기 종목은?'), + (70, '라켓으로 작은 공을 빠르게 치는 실내 스포츠는?'), + (71, '자유형, 배영, 평영 등이 있는 스포츠는?'); + +-- 퀴즈 7: 문학 작품 퀴즈 (문제 14개) +INSERT INTO question (quiz_id, answer, created_at, updated_at) VALUES + (7, '이광수', NOW(), NOW()), + (7, '김소월', NOW(), NOW()), + (7, '이상', NOW(), NOW()), + (7, '한강', NOW(), NOW()), + (7, '도스토예프스키', NOW(), NOW()), + (7, '셰익스피어', NOW(), NOW()), + (7, '카프카', NOW(), NOW()), + (7, '헤밍웨이', NOW(), NOW()), + (7, '조지 오웰', NOW(), NOW()), + (7, '허먼 멜빌', NOW(), NOW()), + (7, '톨스토이', NOW(), NOW()), + (7, '괴테', NOW(), NOW()), + (7, '박완서', NOW(), NOW()), + (7, '루이자 메이 올컷', NOW(), NOW()); + +INSERT INTO text_question (question_id, content) VALUES + (72, '『무정』을 쓴 작가는 누구인가요?'), + (73, '『진달래꽃』을 지은 시인은 누구인가요?'), + (74, '『날개』를 쓴 작가는 누구인가요?'), + (75, '『채식주의자』로 맨부커상을 수상한 한국 작가는?'), + (76, '『죄와 벌』을 쓴 러시아 작가는?'), + (77, '『햄릿』과 『로미오와 줄리엣』의 작가는?'), + (78, '『변신』을 쓴 작가는 누구인가요?'), + (79, '『노인과 바다』의 작가는?'), + (80, '『1984』와 『동물농장』을 쓴 작가는?'), + (81, '『모비딕』의 작가는 누구인가요?'), + (82, '『전쟁과 평화』를 쓴 러시아 문호는?'), + (83, '『젊은 베르테르의 슬픔』을 쓴 독일 작가는?'), + (84, '『엄마의 말뚝』을 쓴 작가는?'), + (85, '『작은 아씨들』을 쓴 작가는?'); + +-- 퀴즈 8: 음악 상식 퀴즈 (문제 10개) +INSERT INTO question (quiz_id, answer, created_at, updated_at) VALUES + (8, '피아노', NOW(), NOW()), + (8, '바이올린', NOW(), NOW()), + (8, '베토벤', NOW(), NOW()), + (8, '모차르트', NOW(), NOW()), + (8, '쇼팽', NOW(), NOW()), + (8, '드럼', NOW(), NOW()), + (8, '기타', NOW(), NOW()), + (8, '악보', NOW(), NOW()), + (8, '음표', NOW(), NOW()), + (8, '템포', NOW(), NOW()); + +INSERT INTO text_question (question_id, content) VALUES + (86, '88개의 건반이 있는 대표적인 건반악기는?'), + (87, '활을 사용하는 대표적인 현악기는?'), + (88, '『운명 교향곡』을 작곡한 사람은?'), + (89, '『마술피리』를 작곡한 천재 음악가는?'), + (90, '『야상곡』으로 유명한 작곡가는?'), + (91, '리듬을 만드는 대표적인 타악기는?'), + (92, '스트링과 프렛이 있는 악기는?'), + (93, '음의 높낮이와 길이를 적는 음악 기호는?'), + (94, '소리의 길이를 나타내는 기호는?'), + (95, '빠르기를 나타내는 음악 용어는?'); + + +-- 퀴즈 9: 한국 지리 퀴즈 (문제 10개) +INSERT INTO question (quiz_id, answer, created_at, updated_at) VALUES + (9, '한라산', NOW(), NOW()), + (9, '백두산', NOW(), NOW()), + (9, '낙동강', NOW(), NOW()), + (9, '한강', NOW(), NOW()), + (9, '서울', NOW(), NOW()), + (9, '제주도', NOW(), NOW()), + (9, '부산', NOW(), NOW()), + (9, '강원도', NOW(), NOW()), + (9, '울릉도', NOW(), NOW()), + (9, '독도', NOW(), NOW()); + +INSERT INTO text_question (question_id, content) VALUES + (96, '대한민국에서 가장 높은 산은?'), + (97, '한반도의 가장 북쪽에 있는 산은?'), + (98, '대한민국에서 가장 긴 강은?'), + (99, '서울을 관통하는 강은?'), + (100, '대한민국의 수도는?'), + (101, '화산섬으로 유명한 섬은?'), + (102, '대한민국 제2의 도시는?'), + (103, '평창이 위치한 도는?'), + (104, '독도와 가장 가까운 섬은?'), + (105, '우리나라 영토로 분쟁이 있는 섬은?'); + +-- 퀴즈 10: 세계 음식 퀴즈 (문제 12개) +INSERT INTO question (quiz_id, answer, created_at, updated_at) VALUES + (10, '김치', NOW(), NOW()), + (10, '스시', NOW(), NOW()), + (10, '파스타', NOW(), NOW()), + (10, '타코', NOW(), NOW()), + (10, '크로아상', NOW(), NOW()), + (10, '카레', NOW(), NOW()), + (10, '딤섬', NOW(), NOW()), + (10, '파에야', NOW(), NOW()), + (10, '브랏부어스트', NOW(), NOW()), + (10, '햄버거', NOW(), NOW()), + (10, '포케', NOW(), NOW()), + (10, '무사카', NOW(), NOW()); + +INSERT INTO text_question (question_id, content) VALUES + (106, '한국을 대표하는 발효 음식은?'), + (107, '일본의 생선 초밥을 무엇이라 하나요?'), + (108, '이탈리아의 대표적인 면 요리는?'), + (109, '멕시코 전통 음식으로, 또띠아에 재료를 싸서 먹는 음식은?'), + (110, '프랑스의 대표적인 빵은?'), + (111, '인도의 대표 향신료 요리는?'), + (112, '중국의 대표적인 한입 크기 음식은?'), + (113, '스페인의 해산물 볶음밥은?'), + (114, '독일의 대표적인 소시지는?'), + (115, '미국의 대표적인 패스트푸드는?'), + (116, '하와이에서 유래한 생선덮밥은?'), + (117, '그리스의 전통 가지요리는?'); + +-- 퀴즈 11: 속담 퀴즈 (문제 13개) +INSERT INTO question (quiz_id, answer, created_at, updated_at) VALUES + (11, '말', NOW(), NOW()), + (11, '소', NOW(), NOW()), + (11, '호랑이', NOW(), NOW()), + (11, '고래', NOW(), NOW()), + (11, '바늘', NOW(), NOW()), + (11, '도둑', NOW(), NOW()), + (11, '떡', NOW(), NOW()), + (11, '금', NOW(), NOW()), + (11, '개구리', NOW(), NOW()), + (11, '도토리', NOW(), NOW()), + (11, '낮말', NOW(), NOW()), + (11, '돌다리', NOW(), NOW()), + (11, '벼', NOW(), NOW()); + +INSERT INTO text_question (question_id, content) VALUES + (118, '가는 말이 고와야 오는 ○○○ 곱다'), + (119, '소 잃고 ○○○ 고친다'), + (120, '호랑이도 ○○○ 앞에서는 없다'), + (121, '고래 싸움에 ○○○ 등 터진다'), + (122, '바늘 도둑이 ○○○ 도둑 된다'), + (123, '도둑이 제 ○○○ 찔린다'), + (124, '보기 좋은 ○○○이 먹기도 좋다'), + (125, '시간은 ○○이다'), + (126, '개구리 올챙이 적 생각 못 한다'), + (127, '키 작은 사람이 ○○ 키 재기'), + (128, '○○은 새가 듣고 밤말은 쥐가 듣는다'), + (129, '○○○도 두들겨 보고 건너라'), + (130, '○○ 이삭이 고개를 숙인다'); + +-- 퀴즈 12: 관용어 퀴즈 (문제 11개) +INSERT INTO question (quiz_id, answer, created_at, updated_at) VALUES + (12, '입', NOW(), NOW()), + (12, '눈', NOW(), NOW()), + (12, '손', NOW(), NOW()), + (12, '귀', NOW(), NOW()), + (12, '발', NOW(), NOW()), + (12, '속', NOW(), NOW()), + (12, '코', NOW(), NOW()), + (12, '입맛', NOW(), NOW()), + (12, '머리', NOW(), NOW()), + (12, '목', NOW(), NOW()), + (12, '배', NOW(), NOW()); + +INSERT INTO text_question (question_id, content) VALUES + (131, '○이 가볍다 = 말이 많다'), + (132, '○에 불이 나다 = 매우 바쁘다'), + (133, '○에 장난 치다 = 방해하다'), + (134, '○이 얇다 = 참견을 잘한다'), + (135, '○이 닳도록 빌다 = 간절히 사과하다'), + (136, '○이 상하다 = 기분이 나쁘다'), + (137, '○를 굴리다 = 생각하다'), + (138, '○을 걸다 = 강하게 주장하다'), + (139, '○가 아프다 = 부러워하다'), + (140, '○이 끊기다 = 매우 화가 나다'), + (141, '○가 부르다 = 만족하다'); + + +-- 퀴즈 13: 물리 법칙 퀴즈 (문제 12개) +INSERT INTO question (quiz_id, answer, created_at, updated_at) VALUES + (13, '뉴턴 제1법칙', NOW(), NOW()), + (13, '뉴턴 제2법칙', NOW(), NOW()), + (13, '뉴턴 제3법칙', NOW(), NOW()), + (13, '중력', NOW(), NOW()), + (13, '속도', NOW(), NOW()), + (13, '가속도', NOW(), NOW()), + (13, '관성', NOW(), NOW()), + (13, '힘', NOW(), NOW()), + (13, '일', NOW(), NOW()), + (13, '에너지 보존 법칙', NOW(), NOW()), + (13, '작용 반작용', NOW(), NOW()), + (13, '운동량 보존 법칙', NOW(), NOW()); + +INSERT INTO text_question (question_id, content) VALUES + (142, '정지한 물체는 계속 정지하고, 운동 중인 물체는 계속 운동하려는 법칙은?'), + (143, '힘 = 질량 x 가속도, 어떤 법칙인가요?'), + (144, '모든 작용에는 크기가 같고 방향이 반대인 반작용이 따른다. 어떤 법칙인가요?'), + (145, '지구가 물체를 끌어당기는 힘은?'), + (146, '단위 시간당 위치 변화는?'), + (147, '속도의 변화율은?'), + (148, '물체가 운동 상태를 유지하려는 성질은?'), + (149, '질량과 가속도의 곱으로 정의되는 물리량은?'), + (150, '힘 x 거리로 정의되는 물리량은?'), + (151, '고립계에서 전체 에너지는 일정하다는 법칙은?'), + (152, '힘을 가하면 반대 방향으로 같은 크기의 힘이 생기는 현상은?'), + (153, '운동량이 외부 힘 없이 일정하게 유지되는 법칙은?'); + +-- 퀴즈 14: 화학 원소 퀴즈 (문제 11개) +INSERT INTO question (quiz_id, answer, created_at, updated_at) VALUES + (14, '수소', NOW(), NOW()), + (14, '헬륨', NOW(), NOW()), + (14, '산소', NOW(), NOW()), + (14, '탄소', NOW(), NOW()), + (14, '질소', NOW(), NOW()), + (14, '철', NOW(), NOW()), + (14, '구리', NOW(), NOW()), + (14, '은', NOW(), NOW()), + (14, '금', NOW(), NOW()), + (14, '납', NOW(), NOW()), + (14, '우라늄', NOW(), NOW()); + +INSERT INTO text_question (question_id, content) VALUES + (154, '가장 가벼운 원소는?'), + (155, '풍선에 쓰이며 비활성 기체인 원소는?'), + (156, '생명 유지에 꼭 필요한 기체 원소는?'), + (157, '생명체의 주된 구성 원소로 유기화합물을 이루는 것은?'), + (158, '대기의 78%를 차지하는 기체는?'), + (159, '자석에 붙고 단단한 금속 원소는?'), + (160, '전선을 만드는 데 자주 사용되는 금속은?'), + (161, '하얀색 광택을 가진 귀금속은?'), + (162, '노란색 광택을 가진 귀금속은?'), + (163, '연하고 무거운 금속으로 배터리에 쓰이기도 하는 원소는?'), + (164, '방사성 원소로 원자력 발전에 사용되는 것은?'); + +-- 퀴즈 15: 영화 제목 퀴즈 (문제 13개) +INSERT INTO question (quiz_id, answer, created_at, updated_at) VALUES + (15, '기생충', NOW(), NOW()), + (15, '인셉션', NOW(), NOW()), + (15, '어벤져스', NOW(), NOW()), + (15, '타이타닉', NOW(), NOW()), + (15, '쇼생크 탈출', NOW(), NOW()), + (15, '인터스텔라', NOW(), NOW()), + (15, '다크 나이트', NOW(), NOW()), + (15, '노인을 위한 나라는 없다', NOW(), NOW()), + (15, '아바타', NOW(), NOW()), + (15, '라라랜드', NOW(), NOW()), + (15, '매트릭스', NOW(), NOW()), + (15, '해리포터', NOW(), NOW()), + (15, '반지의 제왕', NOW(), NOW()); + +INSERT INTO text_question (question_id, content) VALUES + (165, '봉준호 감독의 아카데미 수상작은?'), + (166, '꿈 속에서 또 다른 꿈으로 들어가는 영화는?'), + (167, '히어로들이 모여서 싸우는 마블 영화는?'), + (168, '배 침몰과 로맨스를 다룬 제임스 카메론 감독의 영화는?'), + (169, '감옥 탈출을 다룬 명작 영화는?'), + (170, '블랙홀과 시간 여행을 다룬 크리스토퍼 놀란 영화는?'), + (171, '조커가 등장하는 배트맨 영화는?'), + (172, '잔인한 살인마가 등장하는 코엔 형제의 영화는?'), + (173, '파란 피부의 외계인이 나오는 SF 영화는?'), + (174, '재즈와 사랑을 다룬 뮤지컬 영화는?'), + (175, '가상현실에서 싸우는 SF 영화는?'), + (176, '마법학교가 배경인 판타지 영화는?'), + (177, '반지를 파괴하기 위해 여행을 떠나는 이야기의 영화는?'); + +-- 퀴즈 16: 명언 출처 퀴즈 (문제 10개) +INSERT INTO question (quiz_id, answer, created_at, updated_at) VALUES + (16, '아인슈타인', NOW(), NOW()), + (16, '간디', NOW(), NOW()), + (16, '마더 테레사', NOW(), NOW()), + (16, '스티브 잡스', NOW(), NOW()), + (16, '링컨', NOW(), NOW()), + (16, '처칠', NOW(), NOW()), + (16, '넬슨 만델라', NOW(), NOW()), + (16, '마하트마 간디', NOW(), NOW()), + (16, '이순신', NOW(), NOW()), + (16, '세종대왕', NOW(), NOW()); + +INSERT INTO text_question (question_id, content) VALUES + (178, '"상상력은 지식보다 중요하다." 라는 명언을 남긴 사람은?'), + (179, '"당신이 세상에서 보고 싶은 변화가 되어라." 라고 말한 인물은?'), + (180, '"우리는 큰 일을 할 수는 없지만, 작은 일을 큰 사랑으로 할 수 있습니다." 는 누구의 말인가요?'), + (181, '"Stay hungry, stay foolish" 는 누구의 연설에서 나온 말인가요?'), + (182, '"국민의, 국민에 의한, 국민을 위한 정부" 를 말한 미국 대통령은?'), + (183, '"피할 수 없다면, 즐겨라" 라고 한 영국 수상은?'), + (184, '"교육은 세상을 바꾸는 가장 강력한 무기다" 를 말한 사람은?'), + (185, '"비폭력 저항" 운동을 이끈 인도 독립운동가는 누구인가요?'), + (186, '"신에게는 아직 12척의 배가 있습니다" 를 말한 조선의 장군은?'), + (187, '"백성을 가르치는 것이 나라를 다스리는 근본이다" 라고 말한 조선의 왕은?'); + +-- 퀴즈 17: 나라 수도 맞히기 퀴즈 (문제 15개) +INSERT INTO question (quiz_id, answer, created_at, updated_at) VALUES + (17, '서울', NOW(), NOW()), + (17, '워싱턴 D.C.', NOW(), NOW()), + (17, '도쿄', NOW(), NOW()), + (17, '파리', NOW(), NOW()), + (17, '베를린', NOW(), NOW()), + (17, '런던', NOW(), NOW()), + (17, '베이징', NOW(), NOW()), + (17, '모스크바', NOW(), NOW()), + (17, '마드리드', NOW(), NOW()), + (17, '로마', NOW(), NOW()), + (17, '오타와', NOW(), NOW()), + (17, '카이로', NOW(), NOW()), + (17, '하노이', NOW(), NOW()), + (17, '방콕', NOW(), NOW()), + (17, '자카르타', NOW(), NOW()); + +INSERT INTO text_question (question_id, content) VALUES + (188, '대한민국의 수도는?'), + (189, '미국의 수도는?'), + (190, '일본의 수도는?'), + (191, '프랑스의 수도는?'), + (192, '독일의 수도는?'), + (193, '영국의 수도는?'), + (194, '중국의 수도는?'), + (195, '러시아의 수도는?'), + (196, '스페인의 수도는?'), + (197, '이탈리아의 수도는?'), + (198, '캐나다의 수도는?'), + (199, '이집트의 수도는?'), + (200, '베트남의 수도는?'), + (201, '태국의 수도는?'), + (202, '인도네시아의 수도는?'); + +-- 퀴즈 18: 넌센스 퀴즈 (문제 12개) +INSERT INTO question (quiz_id, answer, created_at, updated_at) VALUES + (18, '하와이', NOW(), NOW()), + (18, '이유는 이 유리해서', NOW(), NOW()), + (18, '코끼리', NOW(), NOW()), + (18, '엄마상', NOW(), NOW()), + (18, '가위바위보', NOW(), NOW()), + (18, '말대꾸', NOW(), NOW()), + (18, '서울대', NOW(), NOW()), + (18, '시소', NOW(), NOW()), + (18, '도레미파솔라시도', NOW(), NOW()), + (18, '바나나', NOW(), NOW()), + (18, '기린', NOW(), NOW()), + (18, '안경', NOW(), NOW()); + +INSERT INTO text_question (question_id, content) VALUES + (203, '“니가 가라 ○○○”에서 ○○○에 들어갈 말은?'), + (204, '유리가 집을 나간 이유는?'), + (205, '하늘에서 떨어지는 코는?'), + (206, '코끼리 아빠는 코끼리, 그럼 엄마는?'), + (207, '손이 세 개인 게임은?'), + (208, '말이 거꾸로 말하면?'), + (209, '서울에서 제일 높은 대학은?'), + (210, '시소 타다가 떨어지면 뭐라고 할까?'), + (211, '음표 중 가장 비싼 것은?'), + (212, '가장 노란 과일은?'), + (213, '목이 제일 긴 동물은?'), + (214, '눈이 나쁜 사람이 쓰는 것은?'); + +-- 퀴즈 19: 세계 명소 퀴즈 (문제 10개) +INSERT INTO question (quiz_id, answer, created_at, updated_at) VALUES + (19, '에펠탑', NOW(), NOW()), + (19, '자유의 여신상', NOW(), NOW()), + (19, '콜로세움', NOW(), NOW()), + (19, '만리장성', NOW(), NOW()), + (19, '피사의 사탑', NOW(), NOW()), + (19, '타지마할', NOW(), NOW()), + (19, '시드니 오페라 하우스', NOW(), NOW()), + (19, '버킹엄 궁전', NOW(), NOW()), + (19, '크레몰린 궁전', NOW(), NOW()), + (19, '앙코르와트', NOW(), NOW()); + +INSERT INTO text_question (question_id, content) VALUES + (215, '프랑스 파리의 상징적인 탑은?'), + (216, '뉴욕에 있는 유명한 동상은?'), + (217, '로마에 있는 고대 원형 경기장은?'), + (218, '중국에 있는 긴 성벽은?'), + (219, '이탈리아에 기울어진 탑은?'), + (220, '인도의 아름다운 묘는?'), + (221, '호주의 유명한 공연장은?'), + (222, '영국 왕실의 궁전은?'), + (223, '러시아 모스크바의 궁전은?'), + (224, '캄보디아의 고대 사원은?'); + +-- 퀴즈 20: 세계 음식 퀴즈 (문제 14개) +INSERT INTO question (quiz_id, answer, created_at, updated_at) VALUES + (20, '김치', NOW(), NOW()), + (20, '스시', NOW(), NOW()), + (20, '피자', NOW(), NOW()), + (20, '타코', NOW(), NOW()), + (20, '파에야', NOW(), NOW()), + (20, '크루아상', NOW(), NOW()), + (20, '딤섬', NOW(), NOW()), + (20, '함버거', NOW(), NOW()), + (20, '카레', NOW(), NOW()), + (20, '똠얌꿍', NOW(), NOW()), + (20, '쌀국수', NOW(), NOW()), + (20, '소시지', NOW(), NOW()), + (20, '치즈', NOW(), NOW()), + (20, '와플', NOW(), NOW()); + +INSERT INTO text_question (question_id, content) VALUES + (225, '한국을 대표하는 발효 음식은?'), + (226, '생선을 얇게 썰어 만든 일본 음식은?'), + (227, '이탈리아에서 유래된 빵 위에 토핑을 얹은 음식은?'), + (228, '멕시코의 전통 음식으로 토르티야를 사용하는 것은?'), + (229, '스페인의 해산물 볶음밥은?'), + (230, '프랑스의 바삭한 빵은?'), + (231, '중국식 찐만두 요리는?'), + (232, '미국의 대표적인 패스트푸드는?'), + (233, '인도의 향신료 강한 국물 요리는?'), + (234, '태국의 매운 해산물 수프는?'), + (235, '베트남의 국수 요리는?'), + (236, '독일의 대표적인 육가공 음식은?'), + (237, '유럽의 대표 유제품은?'), + (238, '벨기에의 디저트 빵은?'); + + +-- 퀴즈 21: 영어 단어 뜻 맞히기 퀴즈 (문제 20개) +INSERT INTO question (quiz_id, answer, created_at, updated_at) VALUES + (21, '사과', NOW(), NOW()), + (21, '바나나', NOW(), NOW()), + (21, '고양이', NOW(), NOW()), + (21, '개', NOW(), NOW()), + (21, '행복한', NOW(), NOW()), + (21, '슬픈', NOW(), NOW()), + (21, '달리다', NOW(), NOW()), + (21, '걷다', NOW(), NOW()), + (21, '생각하다', NOW(), NOW()), + (21, '알다', NOW(), NOW()), + (21, '보다', NOW(), NOW()), + (21, '듣다', NOW(), NOW()), + (21, '말하다', NOW(), NOW()), + (21, '쓰다', NOW(), NOW()), + (21, '읽다', NOW(), NOW()), + (21, '학교', NOW(), NOW()), + (21, '의자', NOW(), NOW()), + (21, '책상', NOW(), NOW()), + (21, '문', NOW(), NOW()), + (21, '창문', NOW(), NOW()); + +INSERT INTO text_question (question_id, content) VALUES + (239, 'apple의 뜻은?'), + (240, 'banana의 뜻은?'), + (241, 'cat의 뜻은?'), + (242, 'dog의 뜻은?'), + (243, 'happy의 뜻은?'), + (244, 'sad의 뜻은?'), + (245, 'run의 뜻은?'), + (246, 'walk의 뜻은?'), + (247, 'think의 뜻은?'), + (248, 'know의 뜻은?'), + (249, 'see의 뜻은?'), + (250, 'hear의 뜻은?'), + (251, 'say의 뜻은?'), + (252, 'write의 뜻은?'), + (253, 'read의 뜻은?'), + (254, 'school의 뜻은?'), + (255, 'chair의 뜻은?'), + (256, 'desk의 뜻은?'), + (257, 'door의 뜻은?'), + (258, 'window의 뜻은?'); \ No newline at end of file diff --git a/backend/src/test/java/io/f1/backend/domain/admin/app/AdminServiceTests.java b/backend/src/test/java/io/f1/backend/domain/admin/app/AdminServiceTests.java index b3db7eea..357ff7b5 100644 --- a/backend/src/test/java/io/f1/backend/domain/admin/app/AdminServiceTests.java +++ b/backend/src/test/java/io/f1/backend/domain/admin/app/AdminServiceTests.java @@ -1,5 +1,6 @@ package io.f1.backend.domain.admin.app; +import static org.hamcrest.Matchers.hasSize; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -35,18 +36,34 @@ void totalUser() throws Exception { @Test @DataSet("datasets/admin/sorted-user.yml") @DisplayName("유저 목록이 id 순으로 정렬되어 반환된다") - void getUsersSortedByLastLogin() throws Exception { + void getUsersSortedByUserId() throws Exception { // when ResultActions result = mockMvc.perform(get("/admin/users")); // then result.andExpectAll( status().isOk(), jsonPath("$.totalElements").value(3), - // 가장 최근 로그인한 USER3이 첫 번째 jsonPath("$.users[0].id").value(1), - // 중간 로그인한 USER2가 두 번째 jsonPath("$.users[1].id").value(2), - // 가장 오래된 로그인한 USER1이 세 번째 + jsonPath("$.users[2].id").value(3)); + } + + @Test + @DataSet("datasets/admin/search-user.yml") + @DisplayName("특정 닉네임이 포함된 유저들의 정보를 조회한다") + void searchUsersByNickname() throws Exception { + // given + String searchNickname = "us"; + // when + ResultActions result = + mockMvc.perform(get("/admin/users").param("nickname", searchNickname)); + // then + result.andExpectAll( + status().isOk(), + jsonPath("$.totalElements").value(3), + jsonPath("$.users", hasSize(3)), + jsonPath("$.users[0].id").value(1), + jsonPath("$.users[1].id").value(2), jsonPath("$.users[2].id").value(3)); } } 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 dbe01b8f..7be3f8e5 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 @@ -1,7 +1,7 @@ package io.f1.backend.domain.game.app; import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import io.f1.backend.domain.game.dto.request.RoomValidationRequest; @@ -10,6 +10,7 @@ import io.f1.backend.domain.game.model.Room; import io.f1.backend.domain.game.model.RoomSetting; import io.f1.backend.domain.game.store.RoomRepository; +import io.f1.backend.domain.game.websocket.MessageSender; import io.f1.backend.domain.quiz.app.QuizService; import io.f1.backend.domain.user.dto.UserPrincipal; import io.f1.backend.domain.user.entity.User; @@ -45,12 +46,16 @@ class RoomServiceTests { @Mock private RoomRepository roomRepository; @Mock private QuizService quizService; + @Mock private TimerService timerService; @Mock private ApplicationEventPublisher eventPublisher; + @Mock private MessageSender messageSender; @BeforeEach void setUp() { MockitoAnnotations.openMocks(this); // @Mock 어노테이션이 붙은 필드들을 초기화합니다. - roomService = new RoomService(quizService, roomRepository, eventPublisher); + roomService = + new RoomService( + timerService, quizService, roomRepository, eventPublisher, messageSender); SecurityContextHolder.clearContext(); } @@ -95,7 +100,7 @@ void enterRoom_synchronized() throws Exception { }); } countDownLatch.await(); - assertThat(room.getUserIdSessionMap()).hasSize(room.getRoomSetting().maxUserCount()); + assertThat(room.getCurrentUserCnt()).isEqualTo(room.getRoomSetting().maxUserCount()); } @Test @@ -127,13 +132,11 @@ void exitRoom_synchronized() throws Exception { String sessionId = "sessionId" + i; Player player = players.get(i - 1); room.getPlayerSessionMap().put(sessionId, player); - room.getUserIdSessionMap().put(player.getId(), sessionId); } log.info("room.getPlayerSessionMap().size() = {}", room.getPlayerSessionMap().size()); when(roomRepository.findRoom(roomId)).thenReturn(Optional.of(room)); - doNothing().when(roomRepository).removeRoom(roomId); ExecutorService executorService = Executors.newFixedThreadPool(threadCount); CountDownLatch countDownLatch = new CountDownLatch(threadCount); @@ -158,7 +161,7 @@ void exitRoom_synchronized() throws Exception { }); } countDownLatch.await(); - assertThat(room.getUserIdSessionMap()).hasSize(1); + verify(roomRepository).removeRoom(roomId); } private Room createRoom( 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 new file mode 100644 index 00000000..df3c5f57 --- /dev/null +++ b/backend/src/test/java/io/f1/backend/domain/game/websocket/SessionServiceTests.java @@ -0,0 +1,234 @@ +package io.f1.backend.domain.game.websocket; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import io.f1.backend.domain.game.app.RoomService; +import io.f1.backend.domain.game.model.ConnectionState; +import io.f1.backend.domain.game.websocket.service.SessionService; +import io.f1.backend.domain.user.dto.UserPrincipal; +import io.f1.backend.domain.user.entity.User; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executors; + +@ExtendWith(MockitoExtension.class) +class SessionServiceTests { + + @Mock private RoomService roomService; + + @InjectMocks private SessionService sessionService; + + // 테스트를 위한 더미 데이터 + 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() { + + ReflectionTestUtils.setField(sessionService, "sessionIdUser", new ConcurrentHashMap<>()); + ReflectionTestUtils.setField(sessionService, "sessionIdRoom", new ConcurrentHashMap<>()); + ReflectionTestUtils.setField(sessionService, "userIdSession", new ConcurrentHashMap<>()); + ReflectionTestUtils.setField( + sessionService, "userIdLatestSession", new ConcurrentHashMap<>()); + + ReflectionTestUtils.setField( + sessionService, "scheduler", Executors.newScheduledThreadPool(2)); + } + + @Test + @DisplayName("addSession: 세션과 사용자 ID가 올바르게 추가되는지 확인") + void addSession_shouldAddSessionAndUser() { + sessionService.addSession(sessionId1, userId1); + + Map sessionIdUser = + (Map) ReflectionTestUtils.getField(sessionService, "sessionIdUser"); + Map userIdSession = + (Map) ReflectionTestUtils.getField(sessionService, "userIdSession"); + + assertEquals(1, sessionIdUser.size()); + assertEquals(1, userIdSession.size()); + + // 값의 정확성 확인 + assertEquals(userId1, sessionIdUser.get(sessionId1)); + assertEquals(sessionId1, userIdSession.get(userId1)); + } + + @Test + @DisplayName("addRoomId: 세션과 룸 ID가 올바르게 추가되는지 확인") + void addRoomId_shouldAddSessionAndRoom() { + sessionService.addRoomId(roomId1, sessionId1); + + Map sessionIdRoom = + (Map) ReflectionTestUtils.getField(sessionService, "sessionIdRoom"); + + assertEquals(1, sessionIdRoom.size()); + assertEquals(roomId1, sessionIdRoom.get(sessionId1)); + } + + @Test + @DisplayName("handleUserDisconnect: 연결 끊김 상태이고 재연결되지 않았으면 exitIfNotPlaying 호출") + void handleUserDisconnect_shouldExitIfNotPlayingIfDisconnected() throws InterruptedException { + // 준비: 맵에 더미 데이터 추가 + sessionService.addRoomId(roomId1, sessionId1); + sessionService.addSession(sessionId1, userId1); + + User user = new User("provider", "providerId", LocalDateTime.now()); + user.setId(userId1); + UserPrincipal principal = new UserPrincipal(user, new HashMap<>()); + + // disconnect 호출 + sessionService.handleUserDisconnect(sessionId1, principal); + + Thread.sleep(5100); // 5초 + 여유 시간 + + // verify: roomService.changeConnectedStatus가 호출되었는지 확인 + verify(roomService, times(1)) + .changeConnectedStatus(roomId1, sessionId1, ConnectionState.DISCONNECTED); + + // verify: roomService.exitIfNotPlaying이 호출되었는지 확인 + verify(roomService, times(1)).exitIfNotPlaying(eq(roomId1), eq(sessionId1), eq(principal)); + + Map userIdLatestSession = + (Map) + ReflectionTestUtils.getField(sessionService, "userIdLatestSession"); + assertEquals(null, userIdLatestSession.get(principal.getUserId())); + } + + @Test + @DisplayName("removeSession: 세션 관련 정보가 올바르게 제거되고 userIdLatestSession에 삭제 확인") + void removeSession_shouldRemoveAndPutLatestSession() { + // 준비: 초기 데이터 추가 + sessionService.addSession(sessionId1, userId1); + sessionService.addRoomId(roomId1, sessionId1); + + // removeSession 호출 + sessionService.removeSession(sessionId1, userId1); + + // 맵의 크기 및 내용 확인 + Map sessionIdUser = + (Map) ReflectionTestUtils.getField(sessionService, "sessionIdUser"); + Map sessionIdRoom = + (Map) ReflectionTestUtils.getField(sessionService, "sessionIdRoom"); + Map userIdSession = + (Map) ReflectionTestUtils.getField(sessionService, "userIdSession"); + Map userIdLatestSession = + (Map) + ReflectionTestUtils.getField(sessionService, "userIdLatestSession"); + + // 제거되었는지 확인 + assertFalse(sessionIdUser.containsKey(sessionId1)); + assertFalse(sessionIdRoom.containsKey(sessionId1)); + assertFalse(userIdSession.containsKey(userId1)); + + // userIdLatestSession에 업데이트되었는지 확인 + assertFalse(userIdLatestSession.containsKey(userId1)); + } + + @Test + @DisplayName("handleUserDisconnect: 연결 끊김 시 userIdLatestSession에 이전 세션 ID가 저장되는지 확인") + void handleUserDisconnect_shouldStoreOldSessionIdInLatestSession() { + // given + sessionService.addSession(sessionId1, userId1); // 유저의 현재 활성 세션 + sessionService.addRoomId(roomId1, sessionId1); + + User user = new User("provider", "providerId", LocalDateTime.now()); + user.setId(userId1); + UserPrincipal principal = new UserPrincipal(user, new HashMap<>()); + + // when + sessionService.handleUserDisconnect(sessionId1, principal); + + // then + Map userIdLatestSession = + (Map) + ReflectionTestUtils.getField(sessionService, "userIdLatestSession"); + + assertTrue(userIdLatestSession.containsKey(userId1)); + assertEquals(sessionId1, userIdLatestSession.get(userId1)); + + // 재연결 상태 변경 검증 + verify(roomService, times(1)) + .changeConnectedStatus(roomId1, sessionId1, ConnectionState.DISCONNECTED); + } + + @Test + @DisplayName( + "handleUserDisconnect 후 5초 내 재연결: userIdLatestSession이 정리되고 exitIfNotPlaying이 호출되지 않음") + void handleUserDisconnect_reconnectWithin5Seconds_shouldCleanLatestSession() + throws InterruptedException { + // given + sessionService.addSession(sessionId1, userId1); // 초기 세션 + sessionService.addRoomId(roomId1, sessionId1); + + User user = new User("provider", "providerId", LocalDateTime.now()); + user.setId(userId1); + UserPrincipal principal = new UserPrincipal(user, new HashMap<>()); + + sessionService.handleUserDisconnect( + sessionId1, principal); // 세션1 끊김, userIdLatestSession에 세션1 저장 + + // 5초 타이머가 실행되기 전에 새로운 세션으로 재연결 시도 (userIdSession 업데이트) + sessionService.addSession(sessionId2, userId1); // userId1의 새 세션은 sessionId2 + sessionService.addRoomId(roomId1, sessionId2); // 새 세션도 룸에 추가 + + // when (5초가 경과했다고 가정) + Thread.sleep(5100); + + // then + Map userIdLatestSession = + (Map) + ReflectionTestUtils.getField(sessionService, "userIdLatestSession"); + Map sessionIdUser = + (Map) ReflectionTestUtils.getField(sessionService, "sessionIdUser"); + Map sessionIdRoom = + (Map) ReflectionTestUtils.getField(sessionService, "sessionIdRoom"); + Map userIdSession = + (Map) ReflectionTestUtils.getField(sessionService, "userIdSession"); + + // userIdLatestSession은 정리되어야 함 + assertFalse(userIdLatestSession.containsKey(userId1)); + assertNull(userIdLatestSession.get(userId1)); + + // roomService.exitIfNotPlaying은 호출되지 않아야 함 (재연결 성공했으므로) + verify(roomService, never()) + .exitIfNotPlaying(anyLong(), anyString(), any(UserPrincipal.class)); + + // 세션 관련 맵들이 올바르게 정리되었는지 확인 + // sessionId1에 대한 정보는 모두 삭제되어야 함 + assertFalse(sessionIdUser.containsKey(sessionId1)); // sessionId1은 sessionIdUser에서 삭제 + assertFalse(sessionIdRoom.containsKey(sessionId1)); // sessionId1은 sessionIdRoom에서 삭제 + + // userIdSession은 sessionId2로 업데이트되어 있어야 함 + assertTrue(userIdSession.containsKey(userId1)); + assertEquals(sessionId2, userIdSession.get(userId1)); + + // sessionId2에 대한 정보는 남아있어야 함 + assertTrue(sessionIdUser.containsKey(sessionId2)); + assertEquals(userId1, sessionIdUser.get(sessionId2)); + assertTrue(sessionIdRoom.containsKey(sessionId2)); + assertEquals(roomId1, sessionIdRoom.get(sessionId2)); + } +} diff --git a/backend/src/test/java/io/f1/backend/domain/stat/StatBrowserTest.java b/backend/src/test/java/io/f1/backend/domain/stat/StatBrowserTest.java index e3af6120..1120f5ed 100644 --- a/backend/src/test/java/io/f1/backend/domain/stat/StatBrowserTest.java +++ b/backend/src/test/java/io/f1/backend/domain/stat/StatBrowserTest.java @@ -1,6 +1,7 @@ package io.f1.backend.domain.stat; import static io.f1.backend.global.exception.errorcode.CommonErrorCode.INVALID_PAGINATION; +import static io.f1.backend.global.exception.errorcode.RoomErrorCode.PLAYER_NOT_FOUND; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; @@ -35,7 +36,7 @@ void totalRankingForSingleUser() throws Exception { @Test @DisplayName("100을 넘는 페이지 크기 요청이 오면 예외를 발생시킨다") - void totalRankingForSingleUserWithInvalidPageSize() throws Exception { + void totalRankingWithInvalidPageSize() throws Exception { // when ResultActions result = mockMvc.perform(get("/stats/rankings").param("size", "101")); @@ -86,4 +87,61 @@ void totalRankingForThreeUserWithPageSize2() throws Exception { jsonPath("$.totalElements").value(1), jsonPath("$.ranks.length()").value(1)); } + + @Test + @DataSet("datasets/stat/three-user-stat.yml") + @DisplayName("랭킹 페이지에서 존재하지 않는 닉네임을 검색하면 예외를 발생시킨다.") + void totalRankingWithUnregisteredNickname() throws Exception { + // given + String nickname = "UNREGISTERED"; + + // when + ResultActions result = mockMvc.perform(get("/stats/rankings/" + nickname)); + + // then + result.andExpectAll( + status().isNotFound(), jsonPath("$.code").value(PLAYER_NOT_FOUND.getCode())); + } + + @Test + @DataSet("datasets/stat/three-user-stat.yml") + @DisplayName("총 유저 수가 3명이고 페이지 크기가 2일 때 1위 유저의 닉네임을 검색하면 첫 번째 페이지에 2개의 결과를 반환한다") + void totalRankingForThreeUserWithFirstRankedNickname() throws Exception { + // given + String nickname = "USER3"; + + // when + ResultActions result = + mockMvc.perform(get("/stats/rankings/" + nickname).param("size", "2")); + + // then + result.andExpectAll( + status().isOk(), + jsonPath("$.totalPages").value(2), + jsonPath("$.currentPage").value(1), + jsonPath("$.totalElements").value(2), + jsonPath("$.ranks.length()").value(2), + jsonPath("$.ranks[0].nickname").value(nickname)); + } + + @Test + @DataSet("datasets/stat/three-user-stat.yml") + @DisplayName("총 유저 수가 3명이고 페이지 크기가 2일 때 3위 유저의 닉네임을 검색하면 두 번째 페이지에 1개의 결과를 반환한다") + void totalRankingForThreeUserWithLastRankedNickname() throws Exception { + // given + String nickname = "USER1"; + + // when + ResultActions result = + mockMvc.perform(get("/stats/rankings/" + nickname).param("size", "2")); + + // then + result.andExpectAll( + status().isOk(), + jsonPath("$.totalPages").value(2), + jsonPath("$.currentPage").value(2), + jsonPath("$.totalElements").value(1), + jsonPath("$.ranks.length()").value(1), + jsonPath("$.ranks[0].nickname").value(nickname)); + } } diff --git a/backend/src/test/resources/application.yml b/backend/src/test/resources/application.yml index 215917fd..bf62a914 100644 --- a/backend/src/test/resources/application.yml +++ b/backend/src/test/resources/application.yml @@ -35,3 +35,4 @@ spring: file: thumbnail-path : images/thumbnail/ # 이후 배포 환경에서는 바꾸면 될 듯 default-thumbnail-url: /images/thumbnail/default.png + diff --git a/backend/src/test/resources/datasets/admin/search-user.yml b/backend/src/test/resources/datasets/admin/search-user.yml new file mode 100644 index 00000000..542deef1 --- /dev/null +++ b/backend/src/test/resources/datasets/admin/search-user.yml @@ -0,0 +1,22 @@ + +user_test: + - id: 1 + nickname: "US" + provider: "kakao" + provider_id: "kakao1" + last_login: 2025-07-16 09:00:00 + created_at: 2025-07-01 10:00:00 + + - id: 2 + nickname: "USE" + provider: "kakao" + provider_id: "kakao2" + last_login: 2025-07-17 12:00:00 + created_at: 2025-07-02 10:00:00 + + - id: 3 + nickname: "USER" + provider: "kakao" + provider_id: "kakao3" + last_login: 2025-07-18 15:00:00 + created_at: 2025-07-03 10:00:00 \ No newline at end of file