diff --git a/.github/workflows/slack-notify-pr-open.yml b/.github/workflows/slack-notify-pr-open.yml new file mode 100644 index 00000000..e992b56a --- /dev/null +++ b/.github/workflows/slack-notify-pr-open.yml @@ -0,0 +1,22 @@ +name: slack-notify-pr-open + +on: + pull_request: + types: [opened, reopened] + +jobs: + notify: + runs-on: ubuntu-latest + steps: + - name: Send Slack notification + uses: rtCamp/action-slack-notify@v2 + env: + SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }} + SLACK_USERNAME: Github CI + SLACK_ICON: https://github.com/github.png + MSG_MINIMAL: ref,event + SLACK_COLOR: '#36a64f' + SLACK_TITLE: 'New Pull Request πŸš€' + SLACK_MESSAGE: | + #${{ github.event.pull_request.number }} ${{ github.event.pull_request.title }} + πŸ”— ${{ github.event.pull_request.html_url }} diff --git a/backend/build.gradle b/backend/build.gradle index caf92a70..fda0ab52 100644 --- a/backend/build.gradle +++ b/backend/build.gradle @@ -42,6 +42,7 @@ dependencies { testAnnotationProcessor 'org.projectlombok:lombok' testRuntimeOnly 'com.h2database:h2' testImplementation 'org.springframework.security:spring-security-test' + testImplementation 'com.github.database-rider:rider-spring:1.44.0' /* ETC */ implementation 'org.apache.commons:commons-lang3:3.12.0' diff --git a/backend/src/main/java/io/f1/backend/domain/admin/app/AdminDetailService.java b/backend/src/main/java/io/f1/backend/domain/admin/app/AdminDetailService.java index 18bcedf8..989a6270 100644 --- a/backend/src/main/java/io/f1/backend/domain/admin/app/AdminDetailService.java +++ b/backend/src/main/java/io/f1/backend/domain/admin/app/AdminDetailService.java @@ -3,6 +3,7 @@ import io.f1.backend.domain.admin.dao.AdminRepository; import io.f1.backend.domain.admin.dto.AdminPrincipal; import io.f1.backend.domain.admin.entity.Admin; +import io.f1.backend.global.exception.errorcode.AdminErrorCode; import lombok.RequiredArgsConstructor; @@ -23,7 +24,9 @@ public UserDetails loadUserByUsername(String username) throws UsernameNotFoundEx adminRepository .findByUsername(username) .orElseThrow( - () -> new UsernameNotFoundException("E404007: μ‘΄μž¬ν•˜μ§€ μ•ŠλŠ” κ΄€λ¦¬μžμž…λ‹ˆλ‹€.")); + () -> + new UsernameNotFoundException( + AdminErrorCode.ADMIN_NOT_FOUND.getMessage())); // ν”„λ‘ νŠΈμ—”λ“œλ‘œ λ‚΄λ €κ°€μ§€ μ•ŠλŠ” μ˜ˆμ™Έ return new AdminPrincipal(admin); } diff --git a/backend/src/main/java/io/f1/backend/domain/admin/app/handler/AdminLoginFailureHandler.java b/backend/src/main/java/io/f1/backend/domain/admin/app/handler/AdminLoginFailureHandler.java index fe16eef5..74ee18d0 100644 --- a/backend/src/main/java/io/f1/backend/domain/admin/app/handler/AdminLoginFailureHandler.java +++ b/backend/src/main/java/io/f1/backend/domain/admin/app/handler/AdminLoginFailureHandler.java @@ -3,6 +3,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import io.f1.backend.domain.admin.dto.AdminLoginFailResponse; +import io.f1.backend.global.exception.errorcode.AuthErrorCode; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; @@ -31,7 +32,9 @@ public void onAuthenticationFailure( response.setContentType("application/json;charset=UTF-8"); AdminLoginFailResponse errorResponse = - new AdminLoginFailResponse("E401005", "아이디 λ˜λŠ” λΉ„λ°€λ²ˆν˜Έκ°€ μΌμΉ˜ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€."); + new AdminLoginFailResponse( + AuthErrorCode.LOGIN_FAILED.getCode(), + AuthErrorCode.LOGIN_FAILED.getMessage()); response.getWriter().write(objectMapper.writeValueAsString(errorResponse)); } diff --git a/backend/src/main/java/io/f1/backend/domain/admin/app/handler/AdminLoginSuccessHandler.java b/backend/src/main/java/io/f1/backend/domain/admin/app/handler/AdminLoginSuccessHandler.java index 7150d35a..8b786f8a 100644 --- a/backend/src/main/java/io/f1/backend/domain/admin/app/handler/AdminLoginSuccessHandler.java +++ b/backend/src/main/java/io/f1/backend/domain/admin/app/handler/AdminLoginSuccessHandler.java @@ -6,6 +6,8 @@ import io.f1.backend.domain.admin.dao.AdminRepository; import io.f1.backend.domain.admin.dto.AdminPrincipal; import io.f1.backend.domain.admin.entity.Admin; +import io.f1.backend.global.exception.CustomException; +import io.f1.backend.global.exception.errorcode.AdminErrorCode; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; @@ -36,7 +38,7 @@ public void onAuthenticationSuccess( Admin admin = adminRepository .findByUsername(principal.getUsername()) - .orElseThrow(() -> new RuntimeException("E404007: μ‘΄μž¬ν•˜μ§€ μ•ŠλŠ” κ΄€λ¦¬μžμž…λ‹ˆλ‹€.")); + .orElseThrow(() -> new CustomException(AdminErrorCode.ADMIN_NOT_FOUND)); admin.updateLastLogin(LocalDateTime.now()); adminRepository.save(admin); 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 12cbd7ed..701b6b73 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,6 +1,8 @@ package io.f1.backend.domain.game.app; -import io.f1.backend.domain.game.dto.GameStartData; +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.event.RoomUpdatedEvent; import io.f1.backend.domain.game.model.GameSetting; @@ -8,14 +10,19 @@ 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.question.entity.Question; import io.f1.backend.domain.quiz.app.QuizService; import io.f1.backend.domain.quiz.entity.Quiz; +import io.f1.backend.global.exception.CustomException; +import io.f1.backend.global.exception.errorcode.GameErrorCode; +import io.f1.backend.global.exception.errorcode.RoomErrorCode; import lombok.RequiredArgsConstructor; import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; +import java.util.List; import java.util.Map; @Service @@ -26,15 +33,17 @@ public class GameService { private final RoomRepository roomRepository; private final ApplicationEventPublisher eventPublisher; - public GameStartData gameStart(Long roomId, Long quizId) { + public GameStartResponse gameStart(Long roomId, GameStartRequest gameStartRequest) { + + Long quizId = gameStartRequest.quizId(); Room room = roomRepository .findRoom(roomId) - .orElseThrow(() -> new IllegalArgumentException("404 μ‘΄μž¬ν•˜μ§€ μ•ŠλŠ” λ°©μž…λ‹ˆλ‹€.")); + .orElseThrow(() -> new CustomException(RoomErrorCode.ROOM_NOT_FOUND)); if (!validateReadyStatus(room)) { - throw new IllegalArgumentException("E403004 : λ ˆλ”” μƒνƒœκ°€ μ•„λ‹™λ‹ˆλ‹€."); + throw new CustomException(RoomErrorCode.PLAYER_NOT_READY); } // 방의 gameSetting에 μ„€μ •λœ ν€΄μ¦ˆλž‘ μš”μ²­ ν€΄μ¦ˆλž‘ 같은지 체크 ν›„ GameSettingμ—μ„œ λΌμš΄λ“œ κ°€μ Έμ˜€κΈ° @@ -43,22 +52,25 @@ public GameStartData gameStart(Long roomId, Long quizId) { Quiz quiz = quizService.getQuizWithQuestionsById(quizId); // λΌμš΄λ“œ 수만큼 랜덀 Question μΆ”μΆœ - GameStartResponse questions = quizService.getRandomQuestionsWithoutAnswer(quizId, round); + List questions = quizService.getRandomQuestionsWithoutAnswer(quizId, round); + room.updateQuestions(questions); + + GameStartResponse gameStartResponse = toGameStartResponse(questions); // λ°© 정보 κ²Œμž„ μ€‘μœΌλ‘œ λ³€κ²½ room.updateRoomState(RoomState.PLAYING); eventPublisher.publishEvent(new RoomUpdatedEvent(room, quiz)); - return new GameStartData(getDestination(roomId), questions); + return gameStartResponse; } private Integer checkGameSetting(Room room, Long quizId) { GameSetting gameSetting = room.getGameSetting(); - if (!gameSetting.checkQuizId(quizId)) { - throw new IllegalArgumentException("E409002 : κ²Œμž„ 섀정이 λ‹€λ¦…λ‹ˆλ‹€. (κ²Œμž„μ„ μ‹œμž‘ν•  수 μ—†μŠ΅λ‹ˆλ‹€.)"); + if (!gameSetting.validateQuizId(quizId)) { + throw new CustomException(GameErrorCode.GAME_SETTING_CONFLICT); } return gameSetting.getRound(); @@ -70,8 +82,4 @@ private boolean validateReadyStatus(Room room) { return playerSessionMap.values().stream().allMatch(Player::isReady); } - - private static String getDestination(Long roomId) { - return "/sub/room/" + roomId; - } } 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 d4544e9f..e96bdec9 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 @@ -4,15 +4,19 @@ 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.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.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.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; @@ -22,15 +26,17 @@ 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.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.question.entity.Question; import io.f1.backend.domain.quiz.app.QuizService; +import io.f1.backend.domain.quiz.dto.QuizMinData; import io.f1.backend.domain.quiz.entity.Quiz; +import io.f1.backend.domain.user.dto.UserPrincipal; import io.f1.backend.global.exception.CustomException; import io.f1.backend.global.exception.errorcode.RoomErrorCode; @@ -60,10 +66,10 @@ public class RoomService { public RoomCreateResponse saveRoom(RoomCreateRequest request) { - Long quizMinId = quizService.getQuizMinId(); - Quiz quiz = quizService.getQuizWithQuestionsById(quizMinId); + QuizMinData quizMinData = quizService.getQuizMinData(); + // Quiz quiz = quizService.getQuizWithQuestionsById(quizMinId); - GameSetting gameSetting = toGameSetting(quiz); + GameSetting gameSetting = toGameSetting(quizMinData); Player host = createPlayer(); @@ -77,7 +83,7 @@ public RoomCreateResponse saveRoom(RoomCreateRequest request) { roomRepository.saveRoom(room); - eventPublisher.publishEvent(new RoomCreatedEvent(room, quiz)); + // eventPublisher.publishEvent(new RoomCreatedEvent(room, quiz)); return new RoomCreateResponse(newId); } @@ -110,11 +116,12 @@ public void enterRoom(RoomValidationRequest request) { } } - public RoomInitialData initializeRoomSocket(Long roomId, String sessionId) { + public RoomInitialData initializeRoomSocket( + Long roomId, String sessionId, UserPrincipal principal) { Room room = findRoom(roomId); - Player player = createPlayer(); + Player player = createPlayer(principal); Map playerSessionMap = room.getPlayerSessionMap(); Map userIdSessionMap = room.getUserIdSessionMap(); @@ -140,30 +147,25 @@ public RoomInitialData initializeRoomSocket(Long roomId, String sessionId) { PlayerListResponse playerListResponse = toPlayerListResponse(room); - SystemNoticeResponse systemNoticeResponse = ofPlayerEvent(player, RoomEventType.ENTER); + SystemNoticeResponse systemNoticeResponse = + ofPlayerEvent(player.getNickname(), RoomEventType.ENTER); return new RoomInitialData( - getDestination(roomId), - roomSettingResponse, - gameSettingResponse, - playerListResponse, - systemNoticeResponse); + roomSettingResponse, gameSettingResponse, playerListResponse, systemNoticeResponse); } - public RoomExitData exitRoom(Long roomId, String sessionId) { + public RoomExitData exitRoom(Long roomId, String sessionId, UserPrincipal principal) { Object lock = roomLocks.computeIfAbsent(roomId, k -> new Object()); synchronized (lock) { Room room = findRoom(roomId); - String destination = getDestination(roomId); - - Player removePlayer = getRemovePlayer(room, sessionId); + Player removePlayer = getRemovePlayer(room, sessionId, principal); /* λ°© μ‚­μ œ */ if (isLastPlayer(room, sessionId)) { - return removeRoom(room, destination); + return removeRoom(room); } /* λ°©μž₯ λ³€κ²½ */ @@ -175,14 +177,27 @@ public RoomExitData exitRoom(Long roomId, String sessionId) { removePlayer(room, sessionId, removePlayer); SystemNoticeResponse systemNoticeResponse = - ofPlayerEvent(removePlayer, RoomEventType.EXIT); + ofPlayerEvent(removePlayer.nickname, RoomEventType.EXIT); PlayerListResponse playerListResponse = toPlayerListResponse(room); - return new RoomExitData(destination, playerListResponse, systemNoticeResponse, false); + return new RoomExitData(playerListResponse, systemNoticeResponse, false); } } + public PlayerListResponse handlePlayerReady(Long roomId, String sessionId) { + Player player = + roomRepository + .findPlayerInRoomBySessionId(roomId, sessionId) + .orElseThrow(() -> new CustomException(RoomErrorCode.PLAYER_NOT_FOUND)); + + player.toggleReady(); + + Room room = findRoom(roomId); + + return toPlayerListResponse(room); + } + public RoomListResponse getAllRooms() { List rooms = roomRepository.findAll(); List roomResponses = @@ -198,17 +213,44 @@ public RoomListResponse getAllRooms() { return new RoomListResponse(roomResponses); } - private Player getRemovePlayer(Room room, String sessionId) { + // todo λ™μ‹œμ„±μ μš© + public RoundResult chat(Long roomId, String sessionId, ChatMessage chatMessage) { + Room room = findRoom(roomId); + + if (!room.isPlaying()) { + return buildResultOnlyChat(chatMessage); + } + + Question currentQuestion = room.getCurrentQuestion(); + + String answer = currentQuestion.getAnswer(); + + if (!answer.equals(chatMessage.message())) { + return buildResultOnlyChat(chatMessage); + } + + room.increasePlayerCorrectCount(sessionId); + + return RoundResult.builder() + .questionResult( + toQuestionResultResponse(currentQuestion.getId(), chatMessage, answer)) + .rankUpdate(toRankUpdateResponse(room)) + .systemNotice(ofPlayerEvent(chatMessage.nickname(), RoomEventType.ENTER)) + .chat(chatMessage) + .build(); + } + + private Player getRemovePlayer(Room room, String sessionId, UserPrincipal principal) { Player removePlayer = room.getPlayerSessionMap().get(sessionId); if (removePlayer == null) { - room.removeUserId(getCurrentUserId()); + room.removeUserId(principal.getUserId()); throw new CustomException(RoomErrorCode.SOCKET_SESSION_NOT_FOUND); } return removePlayer; } - private static String getDestination(Long roomId) { - return "/sub/room/" + roomId; + private Player createPlayer(UserPrincipal principal) { + return new Player(principal.getUserId(), principal.getUserNickname()); } private Player createPlayer() { @@ -226,12 +268,12 @@ private boolean isLastPlayer(Room room, String sessionId) { return playerSessionMap.size() == 1 && playerSessionMap.containsKey(sessionId); } - private RoomExitData removeRoom(Room room, String destination) { + private RoomExitData removeRoom(Room room) { Long roomId = room.getId(); roomRepository.removeRoom(roomId); roomLocks.remove(roomId); log.info("{}번 λ°© μ‚­μ œ", roomId); - return RoomExitData.builder().destination(destination).removedRoom(true).build(); + return RoomExitData.builder().removedRoom(true).build(); } private void changeHost(Room room, String hostSessionId) { @@ -255,4 +297,8 @@ 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(); + } } diff --git a/backend/src/main/java/io/f1/backend/domain/game/dto/ChatMessage.java b/backend/src/main/java/io/f1/backend/domain/game/dto/ChatMessage.java new file mode 100644 index 00000000..da1bb4ea --- /dev/null +++ b/backend/src/main/java/io/f1/backend/domain/game/dto/ChatMessage.java @@ -0,0 +1,5 @@ +package io.f1.backend.domain.game.dto; + +import java.time.Instant; + +public record ChatMessage(String nickname, String message, Instant timestamp) {} diff --git a/backend/src/main/java/io/f1/backend/domain/game/dto/GameStartData.java b/backend/src/main/java/io/f1/backend/domain/game/dto/GameStartData.java deleted file mode 100644 index c68d056e..00000000 --- a/backend/src/main/java/io/f1/backend/domain/game/dto/GameStartData.java +++ /dev/null @@ -1,5 +0,0 @@ -package io.f1.backend.domain.game.dto; - -import io.f1.backend.domain.game.dto.response.GameStartResponse; - -public record GameStartData(String destination, GameStartResponse gameStartResponse) {} 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 22dfdee2..7e5384eb 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 @@ -6,4 +6,7 @@ public enum MessageType { PLAYER_LIST, SYSTEM_NOTICE, GAME_START, + CHAT, + QUESTION_RESULT, + RANK_UPDATE, } diff --git a/backend/src/main/java/io/f1/backend/domain/game/dto/Rank.java b/backend/src/main/java/io/f1/backend/domain/game/dto/Rank.java new file mode 100644 index 00000000..04fc4600 --- /dev/null +++ b/backend/src/main/java/io/f1/backend/domain/game/dto/Rank.java @@ -0,0 +1,3 @@ +package io.f1.backend.domain.game.dto; + +public record Rank(String nickname, int correctCount) {} 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 index 1fc51f2e..76efee90 100644 --- 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 @@ -9,18 +9,15 @@ @Getter public class RoomExitData { - private final String destination; private final PlayerListResponse playerListResponses; private final SystemNoticeResponse systemNoticeResponse; private final boolean removedRoom; @Builder public RoomExitData( - String destination, PlayerListResponse playerListResponses, SystemNoticeResponse systemNoticeResponse, boolean removedRoom) { - this.destination = destination; 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 index c925b690..439daca8 100644 --- 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 @@ -6,7 +6,6 @@ import io.f1.backend.domain.game.dto.response.SystemNoticeResponse; public record RoomInitialData( - String destination, RoomSettingResponse roomSettingResponse, GameSettingResponse gameSettingResponse, PlayerListResponse playerListResponse, 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 new file mode 100644 index 00000000..6a537b3d --- /dev/null +++ b/backend/src/main/java/io/f1/backend/domain/game/dto/RoundResult.java @@ -0,0 +1,36 @@ +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/request/DefaultWebSocketRequest.java b/backend/src/main/java/io/f1/backend/domain/game/dto/request/DefaultWebSocketRequest.java new file mode 100644 index 00000000..a95981e4 --- /dev/null +++ b/backend/src/main/java/io/f1/backend/domain/game/dto/request/DefaultWebSocketRequest.java @@ -0,0 +1,14 @@ +package io.f1.backend.domain.game.dto.request; + +import io.f1.backend.domain.game.dto.MessageType; +import io.f1.backend.domain.game.dto.WebSocketDto; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class DefaultWebSocketRequest implements WebSocketDto { + private final MessageType type; + private final T message; +} 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 new file mode 100644 index 00000000..5d8c131e --- /dev/null +++ b/backend/src/main/java/io/f1/backend/domain/game/dto/response/QuestionResultResponse.java @@ -0,0 +1,3 @@ +package io.f1.backend.domain.game.dto.response; + +public record QuestionResultResponse(Long questionId, String correctUser, String answer) {} diff --git a/backend/src/main/java/io/f1/backend/domain/game/dto/response/RankUpdateResponse.java b/backend/src/main/java/io/f1/backend/domain/game/dto/response/RankUpdateResponse.java new file mode 100644 index 00000000..68cf3ce2 --- /dev/null +++ b/backend/src/main/java/io/f1/backend/domain/game/dto/response/RankUpdateResponse.java @@ -0,0 +1,7 @@ +package io.f1.backend.domain.game.dto.response; + +import io.f1.backend.domain.game.dto.Rank; + +import java.util.List; + +public record RankUpdateResponse(List rank) {} 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 cd3d109f..3ec0bbc9 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,11 +1,15 @@ 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; import io.f1.backend.domain.game.dto.response.GameSettingResponse; import io.f1.backend.domain.game.dto.response.PlayerListResponse; import io.f1.backend.domain.game.dto.response.PlayerResponse; +import io.f1.backend.domain.game.dto.response.QuestionResultResponse; 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; import io.f1.backend.domain.game.dto.response.RoomSettingResponse; import io.f1.backend.domain.game.dto.response.SystemNoticeResponse; @@ -13,9 +17,11 @@ 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.quiz.dto.QuizMinData; import io.f1.backend.domain.quiz.entity.Quiz; import java.time.Instant; +import java.util.Comparator; import java.util.List; public class RoomMapper { @@ -27,8 +33,11 @@ public static RoomSetting toRoomSetting(RoomCreateRequest request) { request.roomName(), request.maxUserCount(), request.locked(), request.password()); } - public static GameSetting toGameSetting(Quiz quiz) { - return new GameSetting(quiz.getId(), quiz.getQuestions().size(), DEFAULT_TIME_LIMIT); + public static GameSetting toGameSetting(QuizMinData quizMinData) { + return new GameSetting( + quizMinData.quizMinId(), + quizMinData.questionCount().intValue(), + DEFAULT_TIME_LIMIT); } public static RoomSettingResponse toRoomSettingResponse(Room room) { @@ -76,13 +85,26 @@ public static QuizResponse toQuizResponse(Quiz quiz) { quiz.getQuestions().size()); } - public static SystemNoticeResponse ofPlayerEvent(Player player, RoomEventType roomEventType) { + public static SystemNoticeResponse ofPlayerEvent(String nickname, RoomEventType roomEventType) { String message = ""; if (roomEventType == RoomEventType.ENTER) { message = " λ‹˜μ΄ μž…μž₯ν•˜μ…¨μŠ΅λ‹ˆλ‹€"; } else if (roomEventType == RoomEventType.EXIT) { message = " λ‹˜μ΄ 퇴μž₯ν•˜μ…¨μŠ΅λ‹ˆλ‹€"; } - return new SystemNoticeResponse(player.getNickname() + message, Instant.now()); + return new SystemNoticeResponse(nickname + message, Instant.now()); + } + + public static QuestionResultResponse toQuestionResultResponse( + Long questionId, ChatMessage chatMessage, String answer) { + return new QuestionResultResponse(questionId, chatMessage.nickname(), answer); + } + + public static RankUpdateResponse toRankUpdateResponse(Room room) { + return new RankUpdateResponse( + room.getPlayerSessionMap().values().stream() + .sorted(Comparator.comparing(Player::getCorrectCount).reversed()) + .map(player -> new Rank(player.getNickname(), player.getCorrectCount())) + .toList()); } } 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 7634d114..0b8b17e1 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 @@ -3,6 +3,8 @@ import lombok.AllArgsConstructor; import lombok.Getter; +import java.util.Objects; + @Getter @AllArgsConstructor public class GameSetting { @@ -11,10 +13,7 @@ public class GameSetting { private Integer round; // κ²Œμž„ λ³€κ²½ μ‹œ ν•΄λ‹Ή κ²Œμž„μ˜ 총 문제 수둜 μ„€μ • private int timeLimit = 60; - public boolean checkQuizId(Long quizId) { - if (this.quizId != null && this.quizId.equals(quizId)) { - return false; - } - return true; + 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 a5b1241c..4d054c8c 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 @@ -23,4 +23,8 @@ public Player(Long id, String nickname) { public void toggleReady() { this.isReady = !this.isReady; } + + public void increaseCorrectCount() { + correctCount++; + } } 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 d42abc8d..82b4b18e 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 @@ -31,6 +31,8 @@ public class Room { private final LocalDateTime createdAt = LocalDateTime.now(); + private int currentRound = 0; + public Room(Long id, RoomSetting roomSetting, GameSetting gameSetting, Player host) { this.id = id; this.roomSetting = roomSetting; @@ -42,6 +44,10 @@ public boolean isHost(Long id) { return this.host.getId().equals(id); } + public void updateQuestions(List questions) { + this.questions = questions; + } + public void updateHost(Player nextHost) { this.host = nextHost; } @@ -57,4 +63,20 @@ public void removeUserId(Long id) { public void removeSessionId(String sessionId) { this.playerSessionMap.remove(sessionId); } + + public void increasePlayerCorrectCount(String sessionId) { + this.playerSessionMap.get(sessionId).increaseCorrectCount(); + } + + public Question getCurrentQuestion() { + return questions.get(currentRound - 1); + } + + public Boolean isPlaying() { + return state == RoomState.PLAYING; + } + + public void increaseCorrectCount() { + currentRound++; + } } diff --git a/backend/src/main/java/io/f1/backend/domain/game/store/RoomRepository.java b/backend/src/main/java/io/f1/backend/domain/game/store/RoomRepository.java index 6e87760d..f2852c5d 100644 --- a/backend/src/main/java/io/f1/backend/domain/game/store/RoomRepository.java +++ b/backend/src/main/java/io/f1/backend/domain/game/store/RoomRepository.java @@ -1,11 +1,13 @@ package io.f1.backend.domain.game.store; +import io.f1.backend.domain.game.model.Player; import io.f1.backend.domain.game.model.Room; import java.util.List; import java.util.Optional; public interface RoomRepository { + void saveRoom(Room room); Optional findRoom(Long roomId); @@ -13,4 +15,6 @@ public interface RoomRepository { List findAll(); void removeRoom(Long roomId); + + Optional findPlayerInRoomBySessionId(Long roomId, String sessionId); } diff --git a/backend/src/main/java/io/f1/backend/domain/game/store/RoomRepositoryImpl.java b/backend/src/main/java/io/f1/backend/domain/game/store/RoomRepositoryImpl.java index cffbba47..7efd4d9e 100644 --- a/backend/src/main/java/io/f1/backend/domain/game/store/RoomRepositoryImpl.java +++ b/backend/src/main/java/io/f1/backend/domain/game/store/RoomRepositoryImpl.java @@ -1,5 +1,6 @@ package io.f1.backend.domain.game.store; +import io.f1.backend.domain.game.model.Player; import io.f1.backend.domain.game.model.Room; import org.springframework.stereotype.Repository; @@ -35,6 +36,11 @@ public void removeRoom(Long roomId) { roomMap.remove(roomId); } + @Override + public Optional findPlayerInRoomBySessionId(Long roomId, String sessionId) { + return findRoom(roomId).map(room -> room.getPlayerSessionMap().get(sessionId)); + } + // ν…ŒμŠ€νŠΈ μ „μš© λ©”μ†Œλ“œ public Room getRoomForTest(Long roomId) { return roomMap.get(roomId); 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 index e84e9c36..d51c18f2 100644 --- 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 @@ -2,11 +2,16 @@ import io.f1.backend.domain.game.app.GameService; import io.f1.backend.domain.game.app.RoomService; -import io.f1.backend.domain.game.dto.GameStartData; +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; @@ -14,6 +19,7 @@ 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 @@ -29,9 +35,12 @@ public void initializeRoomSocket(@DestinationVariable Long roomId, Message me String websocketSessionId = getSessionId(message); + UserPrincipal principal = getSessionUser(message); + RoomInitialData roomInitialData = - roomService.initializeRoomSocket(roomId, websocketSessionId); - String destination = roomInitialData.destination(); + roomService.initializeRoomSocket(roomId, websocketSessionId, principal); + + String destination = getDestination(roomId); messageSender.send( destination, MessageType.ROOM_SETTING, roomInitialData.roomSettingResponse()); @@ -47,10 +56,11 @@ public void initializeRoomSocket(@DestinationVariable Long roomId, Message me public void exitRoom(@DestinationVariable Long roomId, Message message) { String websocketSessionId = getSessionId(message); + UserPrincipal principal = getSessionUser(message); - RoomExitData roomExitData = roomService.exitRoom(roomId, websocketSessionId); + RoomExitData roomExitData = roomService.exitRoom(roomId, websocketSessionId, principal); - String destination = roomExitData.getDestination(); + String destination = getDestination(roomId); if (!roomExitData.isRemovedRoom()) { messageSender.send( @@ -61,19 +71,59 @@ public void exitRoom(@DestinationVariable Long roomId, Message message) { } @MessageMapping("/room/start/{roomId}") - public void gameStart(@DestinationVariable Long roomId, Message message) { + 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()); - Long quizId = message.getPayload().quizId(); + 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()); + } + } - GameStartData gameStartData = gameService.gameStart(roomId, quizId); + @MessageMapping("/room/ready/{roomId}") + public void playerReady(@DestinationVariable Long roomId, Message message) { - String destination = gameStartData.destination(); + PlayerListResponse playerListResponse = + roomService.handlePlayerReady(roomId, getSessionId(message)); - messageSender.send(destination, MessageType.GAME_START, gameStartData.gameStartResponse()); + 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/question/app/QuestionService.java b/backend/src/main/java/io/f1/backend/domain/question/app/QuestionService.java index 6fc7c954..277353b1 100644 --- a/backend/src/main/java/io/f1/backend/domain/question/app/QuestionService.java +++ b/backend/src/main/java/io/f1/backend/domain/question/app/QuestionService.java @@ -9,14 +9,14 @@ import io.f1.backend.domain.question.entity.Question; import io.f1.backend.domain.question.entity.TextQuestion; import io.f1.backend.domain.quiz.entity.Quiz; +import io.f1.backend.global.exception.CustomException; +import io.f1.backend.global.exception.errorcode.QuestionErrorCode; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.util.NoSuchElementException; - @Service @RequiredArgsConstructor public class QuestionService { @@ -44,7 +44,8 @@ public void updateQuestionContent(Long questionId, String content) { Question question = questionRepository .findById(questionId) - .orElseThrow(() -> new NoSuchElementException("μ‘΄μž¬ν•˜μ§€ μ•ŠλŠ” λ¬Έμ œμž…λ‹ˆλ‹€.")); + .orElseThrow( + () -> new CustomException(QuestionErrorCode.QUESTION_NOT_FOUND)); TextQuestion textQuestion = question.getTextQuestion(); textQuestion.changeContent(content); @@ -58,7 +59,8 @@ public void updateQuestionAnswer(Long questionId, String answer) { Question question = questionRepository .findById(questionId) - .orElseThrow(() -> new NoSuchElementException("μ‘΄μž¬ν•˜μ§€ μ•ŠλŠ” λ¬Έμ œμž…λ‹ˆλ‹€.")); + .orElseThrow( + () -> new CustomException(QuestionErrorCode.QUESTION_NOT_FOUND)); question.changeAnswer(answer); } @@ -69,20 +71,21 @@ public void deleteQuestion(Long questionId) { Question question = questionRepository .findById(questionId) - .orElseThrow(() -> new NoSuchElementException("μ‘΄μž¬ν•˜μ§€ μ•ŠλŠ” λ¬Έμ œμž…λ‹ˆλ‹€.")); + .orElseThrow( + () -> new CustomException(QuestionErrorCode.QUESTION_NOT_FOUND)); questionRepository.delete(question); } private void validateAnswer(String answer) { if (answer.trim().length() < 5 || answer.trim().length() > 30) { - throw new IllegalArgumentException("정닡은 1자 이상 30자 μ΄ν•˜λ‘œ μž…λ ₯ν•΄μ£Όμ„Έμš”."); + throw new CustomException(QuestionErrorCode.INVALID_ANSWER_LENGTH); } } private void validateContent(String content) { if (content.trim().length() < 5 || content.trim().length() > 30) { - throw new IllegalArgumentException("λ¬Έμ œλŠ” 5자 이상 30자 μ΄ν•˜λ‘œ μž…λ ₯ν•΄μ£Όμ„Έμš”."); + throw new CustomException(QuestionErrorCode.INVALID_CONTENT_LENGTH); } } } diff --git a/backend/src/main/java/io/f1/backend/domain/quiz/api/QuizController.java b/backend/src/main/java/io/f1/backend/domain/quiz/api/QuizController.java index ee8d03a6..4d7a0d60 100644 --- a/backend/src/main/java/io/f1/backend/domain/quiz/api/QuizController.java +++ b/backend/src/main/java/io/f1/backend/domain/quiz/api/QuizController.java @@ -6,6 +6,8 @@ import io.f1.backend.domain.quiz.dto.QuizListPageResponse; import io.f1.backend.domain.quiz.dto.QuizQuestionListResponse; import io.f1.backend.domain.quiz.dto.QuizUpdateRequest; +import io.f1.backend.global.exception.CustomException; +import io.f1.backend.global.exception.errorcode.CommonErrorCode; import jakarta.validation.Valid; @@ -28,8 +30,6 @@ import org.springframework.web.bind.annotation.RestController; import org.springframework.web.multipart.MultipartFile; -import java.io.IOException; - @RestController @RequestMapping("/quizzes") @RequiredArgsConstructor @@ -40,8 +40,7 @@ public class QuizController { @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) public ResponseEntity saveQuiz( @RequestPart(required = false) MultipartFile thumbnailFile, - @Valid @RequestPart QuizCreateRequest request) - throws IOException { + @Valid @RequestPart QuizCreateRequest request) { QuizCreateResponse response = quizService.saveQuiz(thumbnailFile, request); return ResponseEntity.status(HttpStatus.CREATED).body(response); @@ -58,8 +57,7 @@ public ResponseEntity deleteQuiz(@PathVariable Long quizId) { public ResponseEntity updateQuiz( @PathVariable Long quizId, @RequestPart(required = false) MultipartFile thumbnailFile, - @RequestPart QuizUpdateRequest request) - throws IOException { + @RequestPart QuizUpdateRequest request) { if (request.title() != null) { quizService.updateQuizTitle(quizId, request.title()); @@ -83,6 +81,13 @@ public ResponseEntity getQuizzes( @RequestParam(required = false) String title, @RequestParam(required = false) String creator) { + if (page <= 0) { + throw new CustomException(CommonErrorCode.INVALID_PAGINATION); + } + if (size <= 0 || size > 100) { + throw new CustomException(CommonErrorCode.INVALID_PAGINATION); + } + Pageable pageable = PageRequest.of(page - 1, size, Sort.by(Sort.Direction.DESC, "createdAt")); QuizListPageResponse quizzes = quizService.getQuizzes(title, creator, pageable); 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 ce705fb6..3042f0a0 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 @@ -4,7 +4,6 @@ import static java.nio.file.Files.deleteIfExists; -import io.f1.backend.domain.game.dto.response.GameStartResponse; import io.f1.backend.domain.question.app.QuestionService; import io.f1.backend.domain.question.dto.QuestionRequest; import io.f1.backend.domain.question.entity.Question; @@ -13,10 +12,14 @@ import io.f1.backend.domain.quiz.dto.QuizCreateResponse; import io.f1.backend.domain.quiz.dto.QuizListPageResponse; import io.f1.backend.domain.quiz.dto.QuizListResponse; +import io.f1.backend.domain.quiz.dto.QuizMinData; import io.f1.backend.domain.quiz.dto.QuizQuestionListResponse; import io.f1.backend.domain.quiz.entity.Quiz; import io.f1.backend.domain.user.dao.UserRepository; import io.f1.backend.domain.user.entity.User; +import io.f1.backend.global.exception.CustomException; +import io.f1.backend.global.exception.errorcode.AuthErrorCode; +import io.f1.backend.global.exception.errorcode.QuizErrorCode; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -48,6 +51,7 @@ public class QuizService { private String defaultThumbnailPath; private final String DEFAULT = "default"; + private static final long MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB // TODO : μ‹œνλ¦¬ν‹° κ΅¬ν˜„ 이후 μ‚­μ œν•΄λ„ λ˜λŠ” μ˜μ‘΄μ„± μ£Όμž… private final UserRepository userRepository; @@ -55,8 +59,7 @@ public class QuizService { private final QuizRepository quizRepository; @Transactional - public QuizCreateResponse saveQuiz(MultipartFile thumbnailFile, QuizCreateRequest request) - throws IOException { + public QuizCreateResponse saveQuiz(MultipartFile thumbnailFile, QuizCreateRequest request) { String thumbnailPath = defaultThumbnailPath; if (thumbnailFile != null && !thumbnailFile.isEmpty()) { @@ -81,29 +84,38 @@ public QuizCreateResponse saveQuiz(MultipartFile thumbnailFile, QuizCreateReques private void validateImageFile(MultipartFile thumbnailFile) { if (!thumbnailFile.getContentType().startsWith("image")) { - // TODO : 이후 μ»€μŠ€ν…€ μ˜ˆμ™Έλ‘œ λ³€κ²½ - throw new IllegalArgumentException("이미지 νŒŒμΌμ„ μ—…λ‘œλ“œν•΄μ£Όμ„Έμš”."); + throw new CustomException(QuizErrorCode.UNSUPPORTED_MEDIA_TYPE); } List allowedExt = List.of("jpg", "jpeg", "png", "webp"); - if (!allowedExt.contains(getExtension(thumbnailFile.getOriginalFilename()))) { - throw new IllegalArgumentException("μ§€μ›ν•˜μ§€ μ•ŠλŠ” ν™•μž₯μžμž…λ‹ˆλ‹€."); + String ext = getExtension(thumbnailFile.getOriginalFilename()); + if (!allowedExt.contains(ext)) { + throw new CustomException(QuizErrorCode.UNSUPPORTED_IMAGE_FORMAT); + } + + if (thumbnailFile.getSize() > MAX_FILE_SIZE) { + throw new CustomException(QuizErrorCode.FILE_SIZE_TOO_LARGE); } } - private String convertToThumbnailPath(MultipartFile thumbnailFile) throws IOException { + private String convertToThumbnailPath(MultipartFile thumbnailFile) { String originalFilename = thumbnailFile.getOriginalFilename(); String ext = getExtension(originalFilename); String savedFilename = UUID.randomUUID().toString() + "." + ext; - Path savePath = Paths.get(uploadPath, savedFilename).toAbsolutePath(); - thumbnailFile.transferTo(savePath.toFile()); + try { + Path savePath = Paths.get(uploadPath, savedFilename).toAbsolutePath(); + thumbnailFile.transferTo(savePath.toFile()); + } catch (IOException e) { + log.error("썸넀일 μ—…λ‘œλ“œ 쀑 IOException λ°œμƒ", e); + throw new CustomException(QuizErrorCode.THUMBNAIL_SAVE_FAILED); + } return "/images/thumbnail/" + savedFilename; } private String getExtension(String filename) { - return filename.substring(filename.lastIndexOf(".") + 1); + return filename.substring(filename.lastIndexOf(".") + 1).toLowerCase(); } @Transactional @@ -112,11 +124,11 @@ public void deleteQuiz(Long quizId) { Quiz quiz = quizRepository .findById(quizId) - .orElseThrow(() -> new NoSuchElementException("μ‘΄μž¬ν•˜μ§€ μ•ŠλŠ” ν€΄μ¦ˆμž…λ‹ˆλ‹€.")); + .orElseThrow(() -> new CustomException(QuizErrorCode.QUIZ_NOT_FOUND)); // TODO : util λ©”μ„œλ“œμ—μ„œ μ‚¬μš©μž ID κΊΌλ‚΄μ“°λŠ” μ‹μœΌλ‘œ μˆ˜μ •ν•˜κΈ° if (1L != quiz.getCreator().getId()) { - throw new RuntimeException("κΆŒν•œμ΄ μ—†μŠ΅λ‹ˆλ‹€."); + throw new CustomException(AuthErrorCode.FORBIDDEN); } deleteThumbnailFile(quiz.getThumbnailUrl()); @@ -128,7 +140,7 @@ public void updateQuizTitle(Long quizId, String title) { Quiz quiz = quizRepository .findById(quizId) - .orElseThrow(() -> new NoSuchElementException("μ‘΄μž¬ν•˜μ§€ μ•ŠλŠ” ν€΄μ¦ˆμž…λ‹ˆλ‹€.")); + .orElseThrow(() -> new CustomException(QuizErrorCode.QUIZ_NOT_FOUND)); validateTitle(title); quiz.changeTitle(title); @@ -140,19 +152,19 @@ public void updateQuizDesc(Long quizId, String description) { Quiz quiz = quizRepository .findById(quizId) - .orElseThrow(() -> new NoSuchElementException("μ‘΄μž¬ν•˜μ§€ μ•ŠλŠ” ν€΄μ¦ˆμž…λ‹ˆλ‹€.")); + .orElseThrow(() -> new CustomException(QuizErrorCode.QUIZ_NOT_FOUND)); validateDesc(description); quiz.changeDescription(description); } @Transactional - public void updateThumbnail(Long quizId, MultipartFile thumbnailFile) throws IOException { + public void updateThumbnail(Long quizId, MultipartFile thumbnailFile) { Quiz quiz = quizRepository .findById(quizId) - .orElseThrow(() -> new NoSuchElementException("μ‘΄μž¬ν•˜μ§€ μ•ŠλŠ” ν€΄μ¦ˆμž…λ‹ˆλ‹€.")); + .orElseThrow(() -> new CustomException(QuizErrorCode.QUIZ_NOT_FOUND)); validateImageFile(thumbnailFile); String newThumbnailPath = convertToThumbnailPath(thumbnailFile); @@ -163,13 +175,13 @@ public void updateThumbnail(Long quizId, MultipartFile thumbnailFile) throws IOE private void validateDesc(String desc) { if (desc.trim().length() < 10 || desc.trim().length() > 50) { - throw new IllegalArgumentException("μ„€λͺ…은 10자 이상 50자 μ΄ν•˜λ‘œ μž…λ ₯ν•΄μ£Όμ„Έμš”."); + throw new CustomException(QuizErrorCode.INVALID_DESC_LENGTH); } } private void validateTitle(String title) { if (title.trim().length() < 2 || title.trim().length() > 30) { - throw new IllegalArgumentException("제λͺ©μ€ 2자 이상 30자 μ΄ν•˜λ‘œ μž…λ ₯ν•΄μ£Όμ„Έμš”."); + throw new CustomException(QuizErrorCode.INVALID_TITLE_LENGTH); } } @@ -192,7 +204,7 @@ private void deleteThumbnailFile(String oldFilename) { } } catch (IOException e) { log.error("κΈ°μ‘΄ 썸넀일 μ‚­μ œ 쀑 였λ₯˜ : {}", filePath); - throw new RuntimeException(e); + throw new CustomException(QuizErrorCode.THUMBNAIL_DELETE_FAILED); } } @@ -202,9 +214,9 @@ public QuizListPageResponse getQuizzes(String title, String creator, Pageable pa Page quizzes; // 검색어가 μžˆμ„ λ•Œ - if (StringUtils.isBlank(title)) { + if (!StringUtils.isBlank(title)) { quizzes = quizRepository.findQuizzesByTitleContaining(title, pageable); - } else if (StringUtils.isBlank(creator)) { + } else if (!StringUtils.isBlank(creator)) { quizzes = quizRepository.findQuizzesByCreator_NicknameContaining(creator, pageable); } else { // 검색어가 없을 λ•Œ ν˜Ήμ€ 빈 λ¬Έμžμ—΄μΌ λ•Œ quizzes = quizRepository.findAll(pageable); @@ -220,13 +232,13 @@ public Quiz getQuizWithQuestionsById(Long quizId) { Quiz quiz = quizRepository .findQuizWithQuestionsById(quizId) - .orElseThrow(() -> new RuntimeException("E404002: μ‘΄μž¬ν•˜μ§€ μ•ŠλŠ” ν€΄μ¦ˆμž…λ‹ˆλ‹€.")); + .orElseThrow(() -> new CustomException(QuizErrorCode.QUIZ_NOT_FOUND)); return quiz; } @Transactional(readOnly = true) - public Long getQuizMinId() { - return quizRepository.getQuizMinId(); + public QuizMinData getQuizMinData() { + return quizRepository.getQuizMinData(); } @Transactional(readOnly = true) @@ -234,19 +246,19 @@ public QuizQuestionListResponse getQuizWithQuestions(Long quizId) { Quiz quiz = quizRepository .findById(quizId) - .orElseThrow(() -> new NoSuchElementException("μ‘΄μž¬ν•˜μ§€ μ•ŠλŠ” ν€΄μ¦ˆμž…λ‹ˆλ‹€.")); + .orElseThrow(() -> new CustomException(QuizErrorCode.QUIZ_NOT_FOUND)); return quizToQuizQuestionListResponse(quiz); } @Transactional(readOnly = true) - public GameStartResponse getRandomQuestionsWithoutAnswer(Long quizId, Integer round) { + public List getRandomQuestionsWithoutAnswer(Long quizId, Integer round) { quizRepository .findById(quizId) .orElseThrow(() -> new NoSuchElementException("μ‘΄μž¬ν•˜μ§€ μ•ŠλŠ” ν€΄μ¦ˆμž…λ‹ˆλ‹€.")); List randomQuestions = quizRepository.findRandQuestionsByQuizId(quizId, round); - return toGameStartResponse(randomQuestions); + return randomQuestions; } } diff --git a/backend/src/main/java/io/f1/backend/domain/quiz/dao/QuizRepository.java b/backend/src/main/java/io/f1/backend/domain/quiz/dao/QuizRepository.java index b2d1728d..5c205705 100644 --- a/backend/src/main/java/io/f1/backend/domain/quiz/dao/QuizRepository.java +++ b/backend/src/main/java/io/f1/backend/domain/quiz/dao/QuizRepository.java @@ -1,6 +1,7 @@ package io.f1.backend.domain.quiz.dao; import io.f1.backend.domain.question.entity.Question; +import io.f1.backend.domain.quiz.dto.QuizMinData; import io.f1.backend.domain.quiz.entity.Quiz; import org.springframework.data.domain.Page; @@ -20,8 +21,15 @@ public interface QuizRepository extends JpaRepository { @Query("SELECT q FROM Quiz q LEFT JOIN FETCH q.questions WHERE q.id = :quizId") Optional findQuizWithQuestionsById(Long quizId); - @Query("SELECT MIN(q.id) FROM Quiz q") - Long getQuizMinId(); + @Query( +""" + SELECT new io.f1.backend.domain.quiz.dto.QuizMinData (q.id, COUNT(qs.id)) + FROM Quiz q + LEFT JOIN q.questions qs + WHERE q.id = (SELECT MIN(q2.id) FROM Quiz q2) + GROUP BY q.id +""") + QuizMinData getQuizMinData(); @Query( value = "SELECT * FROM question WHERE quiz_id = :quizId ORDER BY RAND() LIMIT :round", diff --git a/backend/src/main/java/io/f1/backend/domain/quiz/dto/QuizMinData.java b/backend/src/main/java/io/f1/backend/domain/quiz/dto/QuizMinData.java new file mode 100644 index 00000000..cb2b2074 --- /dev/null +++ b/backend/src/main/java/io/f1/backend/domain/quiz/dto/QuizMinData.java @@ -0,0 +1,3 @@ +package io.f1.backend.domain.quiz.dto; + +public record QuizMinData(Long quizMinId, Long questionCount) {} diff --git a/backend/src/main/java/io/f1/backend/domain/quiz/entity/Quiz.java b/backend/src/main/java/io/f1/backend/domain/quiz/entity/Quiz.java index fde07c7e..1cba9273 100644 --- a/backend/src/main/java/io/f1/backend/domain/quiz/entity/Quiz.java +++ b/backend/src/main/java/io/f1/backend/domain/quiz/entity/Quiz.java @@ -50,7 +50,7 @@ public class Quiz extends BaseEntity { private String thumbnailUrl; @ManyToOne - @JoinColumn(name = "creator_id") + @JoinColumn(name = "creator_id", nullable = true) private User creator; public Quiz( @@ -81,4 +81,20 @@ public void changeDescription(String description) { public void changeThumbnailUrl(String thumbnailUrl) { this.thumbnailUrl = thumbnailUrl; } + + public Long findCreatorId() { + if (this.creator == null) { + return null; + } + + return this.creator.getId(); + } + + public String findCreatorNickname() { + if (this.creator == null) { + return "νƒˆν‡΄ν•œ μ‚¬μš©μž"; + } + + return this.creator.getNickname(); + } } diff --git a/backend/src/main/java/io/f1/backend/domain/quiz/mapper/QuizMapper.java b/backend/src/main/java/io/f1/backend/domain/quiz/mapper/QuizMapper.java index 0c0c0513..b9dffc0e 100644 --- a/backend/src/main/java/io/f1/backend/domain/quiz/mapper/QuizMapper.java +++ b/backend/src/main/java/io/f1/backend/domain/quiz/mapper/QuizMapper.java @@ -39,7 +39,7 @@ public static QuizCreateResponse quizToQuizCreateResponse(Quiz quiz) { quiz.getQuizType(), quiz.getDescription(), quiz.getThumbnailUrl(), - quiz.getCreator().getId()); + quiz.findCreatorId()); } public static QuizListResponse quizToQuizListResponse(Quiz quiz) { @@ -47,7 +47,7 @@ public static QuizListResponse quizToQuizListResponse(Quiz quiz) { quiz.getId(), quiz.getTitle(), quiz.getDescription(), - quiz.getCreator().getNickname(), + quiz.findCreatorNickname(), quiz.getQuestions().size(), quiz.getThumbnailUrl()); } @@ -79,7 +79,7 @@ public static QuizQuestionListResponse quizToQuizQuestionListResponse(Quiz quiz) return new QuizQuestionListResponse( quiz.getTitle(), quiz.getQuizType(), - quiz.getCreator().getId(), + quiz.findCreatorId(), quiz.getDescription(), quiz.getThumbnailUrl(), quiz.getQuestions().size(), 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 new file mode 100644 index 00000000..38c14de8 --- /dev/null +++ b/backend/src/main/java/io/f1/backend/domain/stat/api/StatController.java @@ -0,0 +1,32 @@ +package io.f1.backend.domain.stat.api; + +import io.f1.backend.domain.stat.app.StatService; +import io.f1.backend.domain.stat.dto.StatPageResponse; +import io.f1.backend.global.validation.LimitPageSize; + +import lombok.RequiredArgsConstructor; + +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort.Direction; +import org.springframework.data.web.PageableDefault; +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.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/stats") +public class StatController { + + private final StatService statService; + + @LimitPageSize + @GetMapping("/rankings") + public ResponseEntity getRankings( + @PageableDefault(sort = "score", direction = Direction.DESC) Pageable pageable) { + StatPageResponse response = statService.getRanks(pageable); + + 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 new file mode 100644 index 00000000..b123c80d --- /dev/null +++ b/backend/src/main/java/io/f1/backend/domain/stat/app/StatService.java @@ -0,0 +1,25 @@ +package io.f1.backend.domain.stat.app; + +import static io.f1.backend.domain.stat.mapper.StatMapper.toStatListPageResponse; + +import io.f1.backend.domain.stat.dao.StatRepository; +import io.f1.backend.domain.stat.dto.StatPageResponse; +import io.f1.backend.domain.stat.dto.StatWithNickname; + +import lombok.RequiredArgsConstructor; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class StatService { + + private final StatRepository statRepository; + + public StatPageResponse getRanks(Pageable pageable) { + Page stats = statRepository.findWithUser(pageable); + return toStatListPageResponse(stats); + } +} 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 new file mode 100644 index 00000000..912e735c --- /dev/null +++ b/backend/src/main/java/io/f1/backend/domain/stat/dao/StatRepository.java @@ -0,0 +1,22 @@ +package io.f1.backend.domain.stat.dao; + +import io.f1.backend.domain.stat.dto.StatWithNickname; +import io.f1.backend.domain.stat.entity.Stat; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; + +public interface StatRepository extends JpaRepository { + + @Query( + """ + SELECT + new io.f1.backend.domain.stat.dto.StatWithNickname + (u.nickname, s.totalGames, s.winningGames, s.score) + FROM + Stat s JOIN s.user u + """) + Page findWithUser(Pageable pageable); +} diff --git a/backend/src/main/java/io/f1/backend/domain/stat/dto/StatPageResponse.java b/backend/src/main/java/io/f1/backend/domain/stat/dto/StatPageResponse.java new file mode 100644 index 00000000..a33e9a4d --- /dev/null +++ b/backend/src/main/java/io/f1/backend/domain/stat/dto/StatPageResponse.java @@ -0,0 +1,6 @@ +package io.f1.backend.domain.stat.dto; + +import java.util.List; + +public record StatPageResponse( + int totalPages, int currentPage, int totalElements, List ranks) {} diff --git a/backend/src/main/java/io/f1/backend/domain/stat/dto/StatResponse.java b/backend/src/main/java/io/f1/backend/domain/stat/dto/StatResponse.java new file mode 100644 index 00000000..5dd3a88e --- /dev/null +++ b/backend/src/main/java/io/f1/backend/domain/stat/dto/StatResponse.java @@ -0,0 +1,4 @@ +package io.f1.backend.domain.stat.dto; + +public record StatResponse( + long rank, String nickname, long totalGames, long winningGames, long score) {} diff --git a/backend/src/main/java/io/f1/backend/domain/stat/dto/StatWithNickname.java b/backend/src/main/java/io/f1/backend/domain/stat/dto/StatWithNickname.java new file mode 100644 index 00000000..e2596a06 --- /dev/null +++ b/backend/src/main/java/io/f1/backend/domain/stat/dto/StatWithNickname.java @@ -0,0 +1,3 @@ +package io.f1.backend.domain.stat.dto; + +public record StatWithNickname(String nickname, long totalGames, long winningGames, long score) {} diff --git a/backend/src/main/java/io/f1/backend/domain/stat/mapper/StatMapper.java b/backend/src/main/java/io/f1/backend/domain/stat/mapper/StatMapper.java new file mode 100644 index 00000000..57956a32 --- /dev/null +++ b/backend/src/main/java/io/f1/backend/domain/stat/mapper/StatMapper.java @@ -0,0 +1,40 @@ +package io.f1.backend.domain.stat.mapper; + +import io.f1.backend.domain.stat.dto.StatPageResponse; +import io.f1.backend.domain.stat.dto.StatResponse; +import io.f1.backend.domain.stat.dto.StatWithNickname; + +import org.springframework.data.domain.Page; + +public class StatMapper { + + public static StatPageResponse toStatListPageResponse(Page statPage) { + int curPage = statPage.getNumber() + 1; + + return new StatPageResponse( + statPage.getTotalPages(), + curPage, + statPage.getNumberOfElements(), + statPage.stream() + .map( + stat -> { + long rank = + getRankFromPage( + curPage, + statPage.getSize(), + statPage.getContent().indexOf(stat)); + return toStatResponse(stat, rank); + }) + .toList()); + } + + private static StatResponse toStatResponse(StatWithNickname stat, long rank) { + return new StatResponse( + rank, stat.nickname(), stat.totalGames(), stat.winningGames(), stat.score()); + } + + private static long getRankFromPage(int curPage, int pageSize, int index) { + int startRank = (curPage - 1) * pageSize + 1; + return startRank + index; + } +} diff --git a/backend/src/main/java/io/f1/backend/domain/user/api/SignupController.java b/backend/src/main/java/io/f1/backend/domain/user/api/SignupController.java index 9fa1d7de..e39abbc2 100644 --- a/backend/src/main/java/io/f1/backend/domain/user/api/SignupController.java +++ b/backend/src/main/java/io/f1/backend/domain/user/api/SignupController.java @@ -1,8 +1,8 @@ package io.f1.backend.domain.user.api; +import io.f1.backend.domain.auth.dto.CurrentUserAndAdminResponse; import io.f1.backend.domain.user.app.UserService; import io.f1.backend.domain.user.dto.SignupRequest; -import io.f1.backend.domain.user.dto.SignupResponse; import jakarta.servlet.http.HttpSession; @@ -10,8 +10,10 @@ import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @RestController @@ -21,9 +23,15 @@ public class SignupController { private final UserService userService; @PostMapping("/signup") - public ResponseEntity completeSignup( + public ResponseEntity completeSignup( @RequestBody SignupRequest signupRequest, HttpSession httpSession) { - SignupResponse response = userService.signup(httpSession, signupRequest); + CurrentUserAndAdminResponse response = userService.signup(httpSession, signupRequest); return ResponseEntity.status(HttpStatus.CREATED).body(response); } + + @GetMapping("/check-nickname") + public ResponseEntity checkNicknameDuplicate(@RequestParam String nickname) { + userService.checkNickname(nickname); + return ResponseEntity.ok().build(); + } } 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 d2f035d9..bdf86e96 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 @@ -2,13 +2,16 @@ import static io.f1.backend.domain.user.constants.SessionKeys.OAUTH_USER; import static io.f1.backend.domain.user.constants.SessionKeys.USER; -import static io.f1.backend.domain.user.mapper.UserMapper.toSignupResponse; +import io.f1.backend.domain.auth.dto.CurrentUserAndAdminResponse; import io.f1.backend.domain.user.dao.UserRepository; import io.f1.backend.domain.user.dto.AuthenticationUser; import io.f1.backend.domain.user.dto.SignupRequest; -import io.f1.backend.domain.user.dto.SignupResponse; +import io.f1.backend.domain.user.dto.UserPrincipal; import io.f1.backend.domain.user.entity.User; +import io.f1.backend.global.exception.CustomException; +import io.f1.backend.global.exception.errorcode.AuthErrorCode; +import io.f1.backend.global.exception.errorcode.UserErrorCode; import io.f1.backend.global.util.SecurityUtils; import jakarta.servlet.http.HttpSession; @@ -25,45 +28,46 @@ public class UserService { private final UserRepository userRepository; @Transactional - public SignupResponse signup(HttpSession session, SignupRequest signupRequest) { + public CurrentUserAndAdminResponse signup(HttpSession session, SignupRequest signupRequest) { AuthenticationUser authenticationUser = extractSessionUser(session); - String nickname = signupRequest.nickname(); - validateNicknameFormat(nickname); - validateNicknameDuplicate(nickname); + + checkNickname(nickname); User user = initNickname(authenticationUser.userId(), nickname); updateSessionAfterSignup(session, user); + SecurityUtils.setAuthentication(user); + UserPrincipal userPrincipal = SecurityUtils.getCurrentUserPrincipal(); - return toSignupResponse(user); + return CurrentUserAndAdminResponse.from(userPrincipal); } private AuthenticationUser extractSessionUser(HttpSession session) { AuthenticationUser authenticationUser = (AuthenticationUser) session.getAttribute(OAUTH_USER); if (authenticationUser == null) { - throw new RuntimeException("E401001: 둜그인이 ν•„μš”ν•©λ‹ˆλ‹€."); + throw new CustomException(AuthErrorCode.UNAUTHORIZED); } return authenticationUser; } private void validateNicknameFormat(String nickname) { if (nickname == null || nickname.trim().isEmpty()) { - throw new RuntimeException("E400002: λ‹‰λ„€μž„μ€ ν•„μˆ˜ μž…λ ₯μž…λ‹ˆλ‹€."); + throw new CustomException(UserErrorCode.NICKNAME_EMPTY); } if (nickname.length() > 6) { - throw new RuntimeException("E400003: λ‹‰λ„€μž„μ€ 6κΈ€μž μ΄ν•˜λ‘œ μž…λ ₯ν•΄μ•Ό ν•©λ‹ˆλ‹€."); + throw new CustomException(UserErrorCode.NICKNAME_TOO_LONG); } if (!nickname.matches("^[κ°€-힣a-zA-Z0-9]+$")) { - throw new RuntimeException("E400004: ν•œκΈ€, 영문, 숫자만 μž…λ ₯ν•΄μ£Όμ„Έμš”."); + throw new CustomException(UserErrorCode.NICKNAME_NOT_ALLOWED); } } @Transactional(readOnly = true) public void validateNicknameDuplicate(String nickname) { if (userRepository.existsUserByNickname(nickname)) { - throw new RuntimeException("E409001: μ€‘λ³΅λœ λ‹‰λ„€μž„μž…λ‹ˆλ‹€."); + throw new CustomException(UserErrorCode.NICKNAME_CONFLICT); } } @@ -72,7 +76,7 @@ public User initNickname(Long userId, String nickname) { User user = userRepository .findById(userId) - .orElseThrow(() -> new RuntimeException("E404001: μ‘΄μž¬ν•˜μ§€ μ•ŠλŠ” νšŒμ›μž…λ‹ˆλ‹€.")); + .orElseThrow(() -> new CustomException(UserErrorCode.USER_NOT_FOUND)); user.updateNickname(nickname); return userRepository.save(user); @@ -88,17 +92,22 @@ public void deleteUser(Long userId) { User user = userRepository .findById(userId) - .orElseThrow(() -> new RuntimeException("E404001: μ‘΄μž¬ν•˜μ§€ μ•ŠλŠ” νšŒμ›μž…λ‹ˆλ‹€.")); + .orElseThrow(() -> new CustomException(UserErrorCode.USER_NOT_FOUND)); userRepository.delete(user); } @Transactional public void updateNickname(Long userId, String newNickname, HttpSession session) { - validateNicknameFormat(newNickname); - validateNicknameDuplicate(newNickname); + checkNickname(newNickname); User user = initNickname(userId, newNickname); session.setAttribute(USER, AuthenticationUser.from(user)); SecurityUtils.setAuthentication(user); } + + @Transactional(readOnly = true) + public void checkNickname(String nickname) { + validateNicknameFormat(nickname); + validateNicknameDuplicate(nickname); + } } diff --git a/backend/src/main/java/io/f1/backend/domain/user/app/handler/CustomAuthenticationEntryPoint.java b/backend/src/main/java/io/f1/backend/domain/user/app/handler/CustomAuthenticationEntryPoint.java index ebc9ffb1..caa60dd2 100644 --- a/backend/src/main/java/io/f1/backend/domain/user/app/handler/CustomAuthenticationEntryPoint.java +++ b/backend/src/main/java/io/f1/backend/domain/user/app/handler/CustomAuthenticationEntryPoint.java @@ -1,17 +1,28 @@ package io.f1.backend.domain.user.app.handler; +import com.fasterxml.jackson.databind.ObjectMapper; + +import io.f1.backend.global.exception.errorcode.AuthErrorCode; + import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; + import org.springframework.security.core.AuthenticationException; import org.springframework.security.web.AuthenticationEntryPoint; import org.springframework.stereotype.Component; import java.io.IOException; +import java.util.HashMap; +import java.util.Map; @Component +@RequiredArgsConstructor public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint { + private final ObjectMapper objectMapper; + @Override public void commence( HttpServletRequest request, @@ -20,6 +31,11 @@ public void commence( throws IOException { response.setContentType("application/json;charset=UTF-8"); response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); // 401 - response.getWriter().write("{\"error\": \"Unauthorized\"}"); + + Map errorResponse = new HashMap<>(); + errorResponse.put("code", AuthErrorCode.UNAUTHORIZED.getCode()); + errorResponse.put("message", AuthErrorCode.UNAUTHORIZED.getMessage()); + + objectMapper.writeValue(response.getWriter(), errorResponse); } } diff --git a/backend/src/main/java/io/f1/backend/domain/user/app/handler/OAuthSuccessHandler.java b/backend/src/main/java/io/f1/backend/domain/user/app/handler/OAuthSuccessHandler.java index 37f410f0..085f7644 100644 --- a/backend/src/main/java/io/f1/backend/domain/user/app/handler/OAuthSuccessHandler.java +++ b/backend/src/main/java/io/f1/backend/domain/user/app/handler/OAuthSuccessHandler.java @@ -25,9 +25,11 @@ public void onAuthenticationSuccess( response.setContentType("application/json;charset=UTF-8"); if (principal.getUserNickname() == null) { - getRedirectStrategy().sendRedirect(request, response, "/signup"); + String SIGNUP_REDIRECT_URL = "http://localhost:3000/signup"; + getRedirectStrategy().sendRedirect(request, response, SIGNUP_REDIRECT_URL); } else { - getRedirectStrategy().sendRedirect(request, response, "/room"); + String MAIN_REDIRECT_URL = "http://localhost:3000/room"; + getRedirectStrategy().sendRedirect(request, response, MAIN_REDIRECT_URL); } } } diff --git a/backend/src/main/java/io/f1/backend/domain/user/dto/SignupResponse.java b/backend/src/main/java/io/f1/backend/domain/user/dto/SignupResponse.java deleted file mode 100644 index afada0fd..00000000 --- a/backend/src/main/java/io/f1/backend/domain/user/dto/SignupResponse.java +++ /dev/null @@ -1,3 +0,0 @@ -package io.f1.backend.domain.user.dto; - -public record SignupResponse(Long id, String nickname) {} diff --git a/backend/src/main/java/io/f1/backend/domain/user/mapper/UserMapper.java b/backend/src/main/java/io/f1/backend/domain/user/mapper/UserMapper.java deleted file mode 100644 index be828638..00000000 --- a/backend/src/main/java/io/f1/backend/domain/user/mapper/UserMapper.java +++ /dev/null @@ -1,13 +0,0 @@ -package io.f1.backend.domain.user.mapper; - -import io.f1.backend.domain.user.dto.SignupResponse; -import io.f1.backend.domain.user.entity.User; - -public class UserMapper { - - private UserMapper() {} - - public static SignupResponse toSignupResponse(User user) { - return new SignupResponse(user.getId(), user.getNickname()); - } -} diff --git a/backend/src/main/java/io/f1/backend/global/config/CorsConfig.java b/backend/src/main/java/io/f1/backend/global/config/CorsConfig.java index 2b3a4573..3cc2e2aa 100644 --- a/backend/src/main/java/io/f1/backend/global/config/CorsConfig.java +++ b/backend/src/main/java/io/f1/backend/global/config/CorsConfig.java @@ -12,7 +12,8 @@ public class CorsConfig { public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration config = new CorsConfiguration(); - config.addAllowedOriginPattern("*"); + config.addAllowedOrigin("https://brainrace.duckdns.org"); + config.addAllowedHeader("*"); config.addAllowedMethod("*"); config.setAllowCredentials(true); diff --git a/backend/src/main/java/io/f1/backend/global/config/CustomPageableHandlerArgumentResolver.java b/backend/src/main/java/io/f1/backend/global/config/CustomPageableHandlerArgumentResolver.java new file mode 100644 index 00000000..af25faa1 --- /dev/null +++ b/backend/src/main/java/io/f1/backend/global/config/CustomPageableHandlerArgumentResolver.java @@ -0,0 +1,45 @@ +package io.f1.backend.global.config; + +import io.f1.backend.global.exception.CustomException; +import io.f1.backend.global.exception.errorcode.CommonErrorCode; +import io.f1.backend.global.validation.LimitPageSize; + +import org.springframework.core.MethodParameter; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableHandlerMethodArgumentResolver; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.ModelAndViewContainer; + +public class CustomPageableHandlerArgumentResolver extends PageableHandlerMethodArgumentResolver { + + @Override + public Pageable resolveArgument( + MethodParameter methodParameter, + ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, + WebDataBinderFactory binderFactory) { + + Pageable pageable = + super.resolveArgument(methodParameter, mavContainer, webRequest, binderFactory); + + if (methodParameter.hasMethodAnnotation(LimitPageSize.class)) { + LimitPageSize limitPageSize = methodParameter.getMethodAnnotation(LimitPageSize.class); + validatePageable(limitPageSize, pageable); + } + + return PageRequest.of( + oneIndexedPageNumber(pageable.getPageNumber()), pageable.getPageSize()); + } + + private int oneIndexedPageNumber(int pageNumber) { + return pageNumber <= 0 ? 0 : pageNumber - 1; + } + + private void validatePageable(LimitPageSize limitPageSize, Pageable pageable) { + if (pageable.getPageSize() > limitPageSize.max()) { + throw new CustomException(CommonErrorCode.INVALID_PAGINATION); + } + } +} diff --git a/backend/src/main/java/io/f1/backend/global/config/SecurityConfig.java b/backend/src/main/java/io/f1/backend/global/config/SecurityConfig.java index 26a726da..c6b4680c 100644 --- a/backend/src/main/java/io/f1/backend/global/config/SecurityConfig.java +++ b/backend/src/main/java/io/f1/backend/global/config/SecurityConfig.java @@ -11,6 +11,7 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; @@ -47,6 +48,8 @@ public SecurityFilterChain userFilterChain(HttpSecurity http) throws Exception { "/js/**", "/admin/login") .permitAll() + .requestMatchers(HttpMethod.OPTIONS, "/**") + .permitAll() .requestMatchers("/ws/**") .authenticated() .requestMatchers("/user/me") @@ -55,6 +58,10 @@ public SecurityFilterChain userFilterChain(HttpSecurity http) throws Exception { .hasRole("ADMIN") .requestMatchers("/auth/me") .hasAnyRole("USER", "ADMIN") + .requestMatchers("/quizzes/**") + .hasAnyRole("USER", "ADMIN") + .requestMatchers("/questions/**") + .hasAnyRole("USER", "ADMIN") .anyRequest() .authenticated()) .formLogin( diff --git a/backend/src/main/java/io/f1/backend/global/config/WebConfig.java b/backend/src/main/java/io/f1/backend/global/config/WebConfig.java index a80ae64c..1fb6747c 100644 --- a/backend/src/main/java/io/f1/backend/global/config/WebConfig.java +++ b/backend/src/main/java/io/f1/backend/global/config/WebConfig.java @@ -3,9 +3,12 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.web.filter.HiddenHttpMethodFilter; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; +import java.util.List; + @Configuration public class WebConfig implements WebMvcConfigurer { @@ -17,6 +20,11 @@ public void addResourceHandlers(ResourceHandlerRegistry registry) { .addResourceLocations("file:images/thumbnail/"); } + @Override + public void addArgumentResolvers(List resolvers) { + resolvers.add(new CustomPageableHandlerArgumentResolver()); + } + @Bean public HiddenHttpMethodFilter hiddenHttpMethodFilter() { return new HiddenHttpMethodFilter(); 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 910e3b83..cb229ed4 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,6 +8,7 @@ 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 @@ -18,7 +19,9 @@ public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { @Override public void registerStompEndpoints(StompEndpointRegistry registry) { - registry.addEndpoint("/ws/game-room").setAllowedOriginPatterns("*"); + registry.addEndpoint("/ws/game-room") + .addInterceptors(new HttpSessionHandshakeInterceptor()) + .setAllowedOriginPatterns("*"); } @Override diff --git a/backend/src/main/java/io/f1/backend/global/exception/errorcode/AdminErrorCode.java b/backend/src/main/java/io/f1/backend/global/exception/errorcode/AdminErrorCode.java new file mode 100644 index 00000000..5d7dc260 --- /dev/null +++ b/backend/src/main/java/io/f1/backend/global/exception/errorcode/AdminErrorCode.java @@ -0,0 +1,18 @@ +package io.f1.backend.global.exception.errorcode; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum AdminErrorCode implements ErrorCode { + ADMIN_NOT_FOUND("E404007", HttpStatus.NOT_FOUND, "μ‘΄μž¬ν•˜μ§€ μ•ŠλŠ” κ΄€λ¦¬μž μž…λ‹ˆλ‹€"); + + private final String code; + + private final HttpStatus httpStatus; + + private final String message; +} 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 new file mode 100644 index 00000000..73a12122 --- /dev/null +++ b/backend/src/main/java/io/f1/backend/global/exception/errorcode/GameErrorCode.java @@ -0,0 +1,18 @@ +package io.f1.backend.global.exception.errorcode; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum GameErrorCode implements ErrorCode { + GAME_SETTING_CONFLICT("E409002", HttpStatus.CONFLICT, "κ²Œμž„ 섀정이 λ§žμ§€ μ•ŠμŠ΅λ‹ˆλ‹€."); + + private final String code; + + private final HttpStatus httpStatus; + + private final String message; +} diff --git a/backend/src/main/java/io/f1/backend/global/exception/errorcode/QuestionErrorCode.java b/backend/src/main/java/io/f1/backend/global/exception/errorcode/QuestionErrorCode.java index 9b7c0c1e..7a179950 100644 --- a/backend/src/main/java/io/f1/backend/global/exception/errorcode/QuestionErrorCode.java +++ b/backend/src/main/java/io/f1/backend/global/exception/errorcode/QuestionErrorCode.java @@ -8,6 +8,8 @@ @Getter @RequiredArgsConstructor public enum QuestionErrorCode implements ErrorCode { + INVALID_CONTENT_LENGTH("E400011", HttpStatus.BAD_REQUEST, "λ¬Έμ œλŠ” 5자 이상 30자 μ΄ν•˜λ‘œ μž…λ ₯ν•΄μ£Όμ„Έμš”."), + INVALID_ANSWER_LENGTH("E400012", HttpStatus.BAD_REQUEST, "정닡은 1자 이상 30자 μ΄ν•˜λ‘œ μž…λ ₯ν•΄μ£Όμ„Έμš”."), QUESTION_NOT_FOUND("E404003", HttpStatus.NOT_FOUND, "μ‘΄μž¬ν•˜μ§€ μ•ŠλŠ” λ¬Έμ œμž…λ‹ˆλ‹€."); private final String code; diff --git a/backend/src/main/java/io/f1/backend/global/exception/errorcode/QuizErrorCode.java b/backend/src/main/java/io/f1/backend/global/exception/errorcode/QuizErrorCode.java index e03983b1..fbedd44c 100644 --- a/backend/src/main/java/io/f1/backend/global/exception/errorcode/QuizErrorCode.java +++ b/backend/src/main/java/io/f1/backend/global/exception/errorcode/QuizErrorCode.java @@ -9,9 +9,15 @@ @RequiredArgsConstructor public enum QuizErrorCode implements ErrorCode { FILE_SIZE_TOO_LARGE("E400005", HttpStatus.BAD_REQUEST, "파일 크기가 λ„ˆλ¬΄ ν½λ‹ˆλ‹€."), + INVALID_TITLE_LENGTH("E400009", HttpStatus.BAD_REQUEST, "제λͺ©μ€ 2자 이상 30자 μ΄ν•˜λ‘œ μž…λ ₯ν•΄μ£Όμ„Έμš”."), + INVALID_DESC_LENGTH("E400010", HttpStatus.BAD_REQUEST, "μ„€λͺ…은 10자 이상 50자 μ΄ν•˜λ‘œ μž…λ ₯ν•΄μ£Όμ„Έμš”."), + UNSUPPORTED_IMAGE_FORMAT( + "E400013", HttpStatus.BAD_REQUEST, "μ§€μ›ν•˜μ§€ μ•ŠλŠ” 이미지 ν˜•μ‹μž…λ‹ˆλ‹€. (jpg, jpeg, png, webp 만 κ°€λŠ₯)"), UNSUPPORTED_MEDIA_TYPE("E415001", HttpStatus.UNSUPPORTED_MEDIA_TYPE, "μ§€μ›ν•˜μ§€ μ•ŠλŠ” 파일 ν˜•μ‹μž…λ‹ˆλ‹€."), INVALID_FILTER("E400007", HttpStatus.BAD_REQUEST, "title λ˜λŠ” creator 쀑 ν•˜λ‚˜λ§Œ μž…λ ₯ κ°€λŠ₯ν•©λ‹ˆλ‹€."), - QUIZ_NOT_FOUND("E404002", HttpStatus.NOT_FOUND, "μ‘΄μž¬ν•˜μ§€ μ•ŠλŠ” ν€΄μ¦ˆμž…λ‹ˆλ‹€."); + QUIZ_NOT_FOUND("E404002", HttpStatus.NOT_FOUND, "μ‘΄μž¬ν•˜μ§€ μ•ŠλŠ” ν€΄μ¦ˆμž…λ‹ˆλ‹€."), + THUMBNAIL_SAVE_FAILED("E500002", HttpStatus.INTERNAL_SERVER_ERROR, "썸넀일 μ €μž₯에 μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€."), + THUMBNAIL_DELETE_FAILED("E500003", HttpStatus.INTERNAL_SERVER_ERROR, "썸넀일 μ‚­μ œμ— μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€."); 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 0626fab0..2925373e 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,8 +10,10 @@ 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, "μ‘΄μž¬ν•˜μ§€ μ•ŠλŠ” μ†ŒμΌ“ μ„Έμ…˜μž…λ‹ˆλ‹€."); 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 2735545a..96e2b316 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 @@ -3,6 +3,8 @@ import io.f1.backend.domain.admin.dto.AdminPrincipal; import io.f1.backend.domain.user.dto.UserPrincipal; import io.f1.backend.domain.user.entity.User; +import io.f1.backend.global.exception.CustomException; +import io.f1.backend.global.exception.errorcode.AuthErrorCode; import jakarta.servlet.http.HttpSession; @@ -25,7 +27,7 @@ public static void setAuthentication(User user) { } public static UserPrincipal getCurrentUserPrincipal() { - Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + Authentication authentication = getAuthentication(); if (authentication != null && authentication.getPrincipal() instanceof UserPrincipal userPrincipal) { return userPrincipal; @@ -53,12 +55,12 @@ private static void clearAuthentication() { } public static AdminPrincipal getCurrentAdminPrincipal() { - Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + Authentication authentication = getAuthentication(); if (authentication != null && authentication.getPrincipal() instanceof AdminPrincipal adminPrincipal) { return adminPrincipal; } - throw new RuntimeException("E401001: 둜그인이 ν•„μš”ν•©λ‹ˆλ‹€."); + throw new CustomException(AuthErrorCode.UNAUTHORIZED); } public static Authentication getAuthentication() { diff --git a/backend/src/main/java/io/f1/backend/global/validation/LimitPageSize.java b/backend/src/main/java/io/f1/backend/global/validation/LimitPageSize.java new file mode 100644 index 00000000..38ad0f68 --- /dev/null +++ b/backend/src/main/java/io/f1/backend/global/validation/LimitPageSize.java @@ -0,0 +1,14 @@ +package io.f1.backend.global.validation; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Documented +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface LimitPageSize { + int max() default 100; +} diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index 4fe7ef60..711788eb 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -1,4 +1,8 @@ spring: + servlet: + multipart: + max-file-size: 5MB + config: import: optional:file:.env[.properties] @@ -12,11 +16,11 @@ spring: username: ${DB_USERNAME} password: ${DB_PASSWORD} -# Redis λ„μž… μ‹œ μ •μ˜ -# data: -# redis: -# host: ${REDIS_HOST} -# port: ${REDIS_PORT} + # Redis λ„μž… μ‹œ μ •μ˜ + # data: + # redis: + # host: ${REDIS_HOST} + # port: ${REDIS_PORT} jpa: defer-datasource-initialization: true # ν˜„μž¬λŠ” data.sql μ—μ„œ 더미 μœ μ € μžλ™ μΆ”κ°€λ₯Ό μœ„ν•΄ λ„£μ–΄λ’€μŒ. @@ -48,5 +52,14 @@ spring: user-name-attribute: id file: - thumbnail-path : images/thumbnail/ # 이후 배포 ν™˜κ²½μ—μ„œλŠ” λ°”κΎΈλ©΄ 될 λ“― + thumbnail-path: images/thumbnail/ # 이후 배포 ν™˜κ²½μ—μ„œλŠ” λ°”κΎΈλ©΄ 될 λ“― default-thumbnail-url: /images/thumbnail/default.png + +server: + servlet: + session: + cookie: + same-site: None + secure: true + http-only: true + timeout: 60 \ No newline at end of file diff --git a/backend/src/main/resources/data.sql b/backend/src/main/resources/data.sql index 015cab81..db9171aa 100644 --- a/backend/src/main/resources/data.sql +++ b/backend/src/main/resources/data.sql @@ -1,2 +1,40 @@ INSERT INTO user (id, nickname, provider, provider_id, last_login) VALUES (1, 'test-user', 'kakao', 'kakao-1234', NOW()); + + +INSERT INTO quiz (title, + description, + quiz_type, + thumbnail_url, + creator_id) +VALUES ('κΈ°λ³Έ 상식 ν€΄μ¦ˆ', + '일반 상식을 ν…ŒμŠ€νŠΈν•˜λŠ” μ‰¬μš΄ ν€΄μ¦ˆμž…λ‹ˆλ‹€.', + 'TEXT', + 'https://picsum.photos/200/300', + 1); + +INSERT INTO question (quiz_id, answer, created_at, updated_at) +VALUES + (1, 'μ •λ‹΅1', NOW(), NOW()), + (1, 'μ •λ‹΅2', NOW(), NOW()), + (1, 'μ •λ‹΅3', NOW(), NOW()), + (1, 'μ •λ‹΅4', NOW(), NOW()), + (1, 'μ •λ‹΅5', NOW(), NOW()), + (1, 'μ •λ‹΅6', NOW(), NOW()), + (1, 'μ •λ‹΅7', NOW(), NOW()), + (1, 'μ •λ‹΅8', NOW(), NOW()), + (1, 'μ •λ‹΅9', NOW(), NOW()), + (1, 'μ •λ‹΅10', NOW(), NOW()); + +INSERT INTO text_question (question_id, content) +VALUES + (1, '1번 문제 λ‚΄μš©μž…λ‹ˆλ‹€.'), + (2, '2번 문제 λ‚΄μš©μž…λ‹ˆλ‹€.'), + (3, '3번 문제 λ‚΄μš©μž…λ‹ˆλ‹€.'), + (4, '4번 문제 λ‚΄μš©μž…λ‹ˆλ‹€.'), + (5, '5번 문제 λ‚΄μš©μž…λ‹ˆλ‹€.'), + (6, '6번 문제 λ‚΄μš©μž…λ‹ˆλ‹€.'), + (7, '7번 문제 λ‚΄μš©μž…λ‹ˆλ‹€.'), + (8, '8번 문제 λ‚΄μš©μž…λ‹ˆλ‹€.'), + (9, '9번 문제 λ‚΄μš©μž…λ‹ˆλ‹€.'), + (10, '10번 문제 λ‚΄μš©μž…λ‹ˆλ‹€.'); \ No newline at end of file diff --git a/backend/src/test/java/io/f1/backend/domain/game/app/RoomServiceTests.java b/backend/src/test/java/io/f1/backend/domain/game/app/RoomServiceTests.java index 2c9df079..dbe01b8f 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 @@ -11,6 +11,7 @@ import io.f1.backend.domain.game.model.RoomSetting; import io.f1.backend.domain.game.store.RoomRepository; import io.f1.backend.domain.quiz.app.QuizService; +import io.f1.backend.domain.user.dto.UserPrincipal; import io.f1.backend.domain.user.entity.User; import io.f1.backend.global.util.SecurityUtils; @@ -29,6 +30,7 @@ import java.time.LocalDateTime; import java.util.ArrayList; +import java.util.Collections; import java.util.List; import java.util.Optional; import java.util.concurrent.CountDownLatch; @@ -142,9 +144,11 @@ void exitRoom_synchronized() throws Exception { executorService.submit( () -> { try { + UserPrincipal principal = + new UserPrincipal(user, Collections.emptyMap()); SecurityUtils.setAuthentication(user); log.info("room.getHost().getId() = {}", room.getHost().getId()); - roomService.exitRoom(roomId, sessionId); + roomService.exitRoom(roomId, sessionId, principal); } catch (Exception e) { e.printStackTrace(); } finally { 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 new file mode 100644 index 00000000..e3af6120 --- /dev/null +++ b/backend/src/test/java/io/f1/backend/domain/stat/StatBrowserTest.java @@ -0,0 +1,89 @@ +package io.f1.backend.domain.stat; + +import static io.f1.backend.global.exception.errorcode.CommonErrorCode.INVALID_PAGINATION; + +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; + +import com.github.database.rider.core.api.dataset.DataSet; + +import io.f1.backend.global.template.BrowserTestTemplate; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.web.servlet.ResultActions; + +@WithMockUser +public class StatBrowserTest extends BrowserTestTemplate { + @Test + @DataSet("datasets/stat/one-user-stat.yml") + @DisplayName("총 μœ μ € μˆ˜κ°€ 1λͺ…이면 첫 νŽ˜μ΄μ§€μ—μ„œ 1개의 κ²°κ³Όλ₯Ό λ°˜ν™˜ν•œλ‹€") + void totalRankingForSingleUser() throws Exception { + // when + ResultActions result = mockMvc.perform(get("/stats/rankings")); + + // then + result.andExpectAll( + status().isOk(), + jsonPath("$.totalPages").value(1), + jsonPath("$.currentPage").value(1), + jsonPath("$.totalElements").value(1), + jsonPath("$.ranks.length()").value(1)); + } + + @Test + @DisplayName("100을 λ„˜λŠ” νŽ˜μ΄μ§€ 크기 μš”μ²­μ΄ 였면 μ˜ˆμ™Έλ₯Ό λ°œμƒμ‹œν‚¨λ‹€") + void totalRankingForSingleUserWithInvalidPageSize() throws Exception { + // when + ResultActions result = mockMvc.perform(get("/stats/rankings").param("size", "101")); + + // then + result.andExpectAll( + status().isBadRequest(), jsonPath("$.code").value(INVALID_PAGINATION.getCode())); + } + + @Test + @DataSet("datasets/stat/three-user-stat.yml") + @DisplayName("총 μœ μ € μˆ˜κ°€ 3λͺ…이면 첫 νŽ˜μ΄μ§€μ—μ„œ 3개의 κ²°κ³Όλ₯Ό λ°˜ν™˜ν•œλ‹€") + void totalRankingForThreeUser() throws Exception { + // when + ResultActions result = mockMvc.perform(get("/stats/rankings")); + + // then + result.andExpectAll( + status().isOk(), + jsonPath("$.totalPages").value(1), + jsonPath("$.currentPage").value(1), + jsonPath("$.totalElements").value(3), + jsonPath("$.ranks.length()").value(3)); + } + + @Test + @DataSet("datasets/stat/three-user-stat.yml") + @DisplayName("총 μœ μ € μˆ˜κ°€ 3λͺ…일 λ•Œ νŽ˜μ΄μ§€ 크기가 2이면, 첫 νŽ˜μ΄μ§€μ—μ„œ 2개, 두 번째 νŽ˜μ΄μ§€μ— 1개의 κ²°κ³Όλ₯Ό λ°˜ν™˜ν•œλ‹€") + void totalRankingForThreeUserWithPageSize2() throws Exception { + // when + ResultActions resultPage1 = + mockMvc.perform(get("/stats/rankings").param("page", "1").param("size", "2")); + + ResultActions resultPage2 = + mockMvc.perform(get("/stats/rankings").param("page", "2").param("size", "2")); + + // then + resultPage1.andExpectAll( + status().isOk(), + jsonPath("$.totalPages").value(2), + jsonPath("$.currentPage").value(1), + jsonPath("$.totalElements").value(2), + jsonPath("$.ranks.length()").value(2)); + + resultPage2.andExpectAll( + status().isOk(), + jsonPath("$.totalPages").value(2), + jsonPath("$.currentPage").value(2), + jsonPath("$.totalElements").value(1), + jsonPath("$.ranks.length()").value(1)); + } +} diff --git a/backend/src/test/java/io/f1/backend/global/config/TestPhysicalNamingStrategy.java b/backend/src/test/java/io/f1/backend/global/config/TestPhysicalNamingStrategy.java new file mode 100644 index 00000000..68effff3 --- /dev/null +++ b/backend/src/test/java/io/f1/backend/global/config/TestPhysicalNamingStrategy.java @@ -0,0 +1,20 @@ +package io.f1.backend.global.config; + +import org.hibernate.boot.model.naming.CamelCaseToUnderscoresNamingStrategy; +import org.hibernate.boot.model.naming.Identifier; +import org.hibernate.engine.jdbc.env.spi.JdbcEnvironment; + +public class TestPhysicalNamingStrategy extends CamelCaseToUnderscoresNamingStrategy { + + @Override + public Identifier toPhysicalTableName(Identifier name, JdbcEnvironment context) { + if (name == null) return null; + + String tableName = name.getText(); + if ("user".equalsIgnoreCase(tableName)) { + return Identifier.toIdentifier("user_test"); + } + + return super.toPhysicalTableName(name, context); + } +} diff --git a/backend/src/test/java/io/f1/backend/global/template/BrowserTestTemplate.java b/backend/src/test/java/io/f1/backend/global/template/BrowserTestTemplate.java new file mode 100644 index 00000000..061ff7ef --- /dev/null +++ b/backend/src/test/java/io/f1/backend/global/template/BrowserTestTemplate.java @@ -0,0 +1,15 @@ +package io.f1.backend.global.template; + +import com.github.database.rider.spring.api.DBRider; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.web.servlet.MockMvc; + +@DBRider +@SpringBootTest +@AutoConfigureMockMvc +public abstract class BrowserTestTemplate { + @Autowired protected MockMvc mockMvc; +} diff --git a/backend/src/test/resources/application.yml b/backend/src/test/resources/application.yml index c7b6b358..215917fd 100644 --- a/backend/src/test/resources/application.yml +++ b/backend/src/test/resources/application.yml @@ -1,9 +1,15 @@ spring: datasource: - url: jdbc:h2:mem:testdb;MODE=MYSQL + url: jdbc:h2:mem:testdb;MODE=MYSQL;DATABASE_TO_LOWER=TRUE username: sa password: + jpa: + hibernate: + ddl-auto: create + naming: + physical-strategy: io.f1.backend.global.config.TestPhysicalNamingStrategy + sql: init: mode: never diff --git a/backend/src/test/resources/datasets/stat.yml b/backend/src/test/resources/datasets/stat.yml new file mode 100644 index 00000000..85e9e7ee --- /dev/null +++ b/backend/src/test/resources/datasets/stat.yml @@ -0,0 +1,18 @@ +stat: + - id: 1 + user_id: 1 + total_games: 100 + winning_games: 100 + score: 1000 + + - id: 2 + user_id: 2 + total_games: 20 + winning_games: 20 + score: 200 + + - id: 3 + user_id: 3 + total_games: 30 + winning_games: 30 + score: 300 \ No newline at end of file diff --git a/backend/src/test/resources/datasets/stat/one-user-stat.yml b/backend/src/test/resources/datasets/stat/one-user-stat.yml new file mode 100644 index 00000000..a9941d61 --- /dev/null +++ b/backend/src/test/resources/datasets/stat/one-user-stat.yml @@ -0,0 +1,13 @@ +user_test: + - id: 1 + nickname: "USER1" + provider: "kakao" + provider_id: "kakao1" + last_login: 2025-07-17 10:00:00 + +stat: + - id: 1 + user_id: 1 + total_games: 10 + winning_games: 10 + score: 100 \ No newline at end of file diff --git a/backend/src/test/resources/datasets/stat/three-user-stat.yml b/backend/src/test/resources/datasets/stat/three-user-stat.yml new file mode 100644 index 00000000..ab5071a7 --- /dev/null +++ b/backend/src/test/resources/datasets/stat/three-user-stat.yml @@ -0,0 +1,33 @@ +user_test: + - id: 1 + nickname: "USER1" + provider: "kakao" + provider_id: "kakao1" + last_login: 2025-07-17 10:00:00 + - id: 2 + nickname: "USER2" + provider: "kakao" + provider_id: "kakao2" + last_login: 2025-07-17 10:00:00 + - id: 3 + nickname: "USER3" + provider: "kakao" + provider_id: "kakao3" + last_login: 2025-07-17 10:00:00 + +stat: + - id: 1 + user_id: 1 + total_games: 10 + winning_games: 10 + score: 100 + - id: 2 + user_id: 2 + total_games: 20 + winning_games: 20 + score: 200 + - id: 3 + user_id: 3 + total_games: 30 + winning_games: 30 + score: 300 \ No newline at end of file diff --git a/backend/src/test/resources/datasets/user.yml b/backend/src/test/resources/datasets/user.yml new file mode 100644 index 00000000..22ed1f25 --- /dev/null +++ b/backend/src/test/resources/datasets/user.yml @@ -0,0 +1,18 @@ +user_test: + - id: 1 + nickname: "USER1" + provider: "kakao" + provider_id: "kakao1" + last_login: 2025-07-17 10:00:00 + + - id: 2 + nickname: "USER2" + provider: "kakao" + provider_id: "kakao2" + last_login: 2025-07-17 10:00:00 + + - id: 3 + nickname: "USER3" + provider: "kakao" + provider_id: "kakao3" + last_login: 2025-07-17 10:00:00 \ No newline at end of file diff --git a/backend/src/test/resources/dbunit.yml b/backend/src/test/resources/dbunit.yml new file mode 100644 index 00000000..a52c7928 --- /dev/null +++ b/backend/src/test/resources/dbunit.yml @@ -0,0 +1,3 @@ +caseInsensitiveStrategy: LOWERCASE +alwaysCleanBefore: true +alwaysCleanAfter: true