diff --git a/backend/.gitignore b/backend/.gitignore index 7d8f1866..e03048a6 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -42,5 +42,11 @@ out/ ### .idea ### .idea +### websocket test ### +src/main/resources/static/ws-test.html +src/main/resources/static/ws-test.js + + + ### images/thumbnail ### images/thumbnail/** \ No newline at end of file diff --git a/backend/src/main/java/io/f1/backend/domain/game/api/RoomController.java b/backend/src/main/java/io/f1/backend/domain/game/api/RoomController.java index 7005f220..ffc33605 100644 --- a/backend/src/main/java/io/f1/backend/domain/game/api/RoomController.java +++ b/backend/src/main/java/io/f1/backend/domain/game/api/RoomController.java @@ -2,6 +2,7 @@ import io.f1.backend.domain.game.app.RoomService; 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.RoomCreateResponse; import io.f1.backend.domain.game.dto.response.RoomListResponse; @@ -38,6 +39,12 @@ public RoomCreateResponse saveRoom(@RequestBody @Valid RoomCreateRequest request return roomService.saveRoom(request, loginUser); } + @PostMapping("/validation") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void validateRoom(@RequestBody @Valid RoomValidationRequest request) { + roomService.validateRoom(request); + } + @GetMapping public RoomListResponse getAllRooms() { return roomService.getAllRooms(); 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 ef817fc9..a9d6f324 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 @@ -2,14 +2,21 @@ import static io.f1.backend.domain.game.mapper.RoomMapper.*; +import io.f1.backend.domain.game.dto.RoomInitialData; 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; +import io.f1.backend.domain.game.dto.response.PlayerListResponse; +import io.f1.backend.domain.game.dto.response.QuizResponse; import io.f1.backend.domain.game.dto.response.RoomCreateResponse; import io.f1.backend.domain.game.dto.response.RoomListResponse; import io.f1.backend.domain.game.dto.response.RoomResponse; +import io.f1.backend.domain.game.dto.response.RoomSettingResponse; 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.quiz.entity.Quiz; import io.f1.backend.domain.user.entity.User; @@ -44,6 +51,58 @@ public RoomCreateResponse saveRoom(RoomCreateRequest request, Map new IllegalArgumentException("404 존재하지 않는 방입니다.")); + + if (room.getState().equals(RoomState.PLAYING)) { + throw new IllegalArgumentException("403 게임이 진행중입니다."); + } + + int maxUserCnt = room.getRoomSetting().maxUserCount(); + int currentCnt = room.getPlayerSessionMap().size(); + if (maxUserCnt == currentCnt) { + throw new IllegalArgumentException("403 정원이 모두 찼습니다."); + } + + if (room.getRoomSetting().locked() + && !room.getRoomSetting().password().equals(request.password())) { + throw new IllegalArgumentException("401 비밀번호가 일치하지 않습니다."); + } + } + + public RoomInitialData enterRoom(Long roomId, String sessionId) { + + Room room = + roomRepository + .findRoom(roomId) + .orElseThrow(() -> new IllegalArgumentException("404 존재하지 않는 방입니다.")); + + // todo security + Player player = new Player(1L, "빵야빵야"); + + Map playerSessionMap = room.getPlayerSessionMap(); + + playerSessionMap.put(sessionId, player); + + String destination = "/sub/room/" + roomId; + + RoomSettingResponse roomSettingResponse = toRoomSettingResponse(room); + // todo quiz 생성 api 완성 후 수정 + QuizResponse quiz = + new QuizResponse(room.getGameSetting().getQuizId(), "title", "설명", "url", 10); + GameSettingResponse gameSettingResponse = + toGameSettingResponse(room.getGameSetting(), quiz); + + PlayerListResponse playerListResponse = toPlayerListResponse(room); + + return new RoomInitialData( + destination, roomSettingResponse, gameSettingResponse, playerListResponse); + } + // todo quizService에서 퀴즈 조회 메서드로 변경 public RoomListResponse getAllRooms() { List rooms = roomRepository.findAll(); 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 new file mode 100644 index 00000000..bb748aac --- /dev/null +++ b/backend/src/main/java/io/f1/backend/domain/game/dto/MessageType.java @@ -0,0 +1,7 @@ +package io.f1.backend.domain.game.dto; + +public enum MessageType { + ROOM_SETTING, + GAME_SETTING, + PLAYER_LIST, +} 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 new file mode 100644 index 00000000..ecc16eca --- /dev/null +++ b/backend/src/main/java/io/f1/backend/domain/game/dto/RoomInitialData.java @@ -0,0 +1,11 @@ +package io.f1.backend.domain.game.dto; + +import io.f1.backend.domain.game.dto.response.GameSettingResponse; +import io.f1.backend.domain.game.dto.response.PlayerListResponse; +import io.f1.backend.domain.game.dto.response.RoomSettingResponse; + +public record RoomInitialData( + String destination, + RoomSettingResponse roomSettingResponse, + GameSettingResponse gameSettingResponse, + PlayerListResponse playerListResponse) {} diff --git a/backend/src/main/java/io/f1/backend/domain/game/dto/WebSocketDto.java b/backend/src/main/java/io/f1/backend/domain/game/dto/WebSocketDto.java new file mode 100644 index 00000000..6277db54 --- /dev/null +++ b/backend/src/main/java/io/f1/backend/domain/game/dto/WebSocketDto.java @@ -0,0 +1,7 @@ +package io.f1.backend.domain.game.dto; + +public interface WebSocketDto { + MessageType getType(); + + T getMessage(); +} diff --git a/backend/src/main/java/io/f1/backend/domain/game/dto/request/RoomCreateRequest.java b/backend/src/main/java/io/f1/backend/domain/game/dto/request/RoomCreateRequest.java index 30a94481..ec9a347c 100644 --- a/backend/src/main/java/io/f1/backend/domain/game/dto/request/RoomCreateRequest.java +++ b/backend/src/main/java/io/f1/backend/domain/game/dto/request/RoomCreateRequest.java @@ -12,4 +12,4 @@ public record RoomCreateRequest( @Max(value = 8, message = "방 인원 수는 최대 8명 입니다.") Integer maxUserCount, @NotNull String password, - @NotNull boolean locked) {} + @NotNull Boolean locked) {} diff --git a/backend/src/main/java/io/f1/backend/domain/game/dto/request/RoomValidationRequest.java b/backend/src/main/java/io/f1/backend/domain/game/dto/request/RoomValidationRequest.java new file mode 100644 index 00000000..2df42d83 --- /dev/null +++ b/backend/src/main/java/io/f1/backend/domain/game/dto/request/RoomValidationRequest.java @@ -0,0 +1,7 @@ +package io.f1.backend.domain.game.dto.request; + +import jakarta.validation.constraints.NotNull; + +public record RoomValidationRequest( + @NotNull(message = "roomId 값은 필수입니다. ") Long roomId, + @NotNull(message = "비밀번호는 null 값이 아니여야합니다. ") String password) {} diff --git a/backend/src/main/java/io/f1/backend/domain/game/dto/response/DefaultWebSocketResponse.java b/backend/src/main/java/io/f1/backend/domain/game/dto/response/DefaultWebSocketResponse.java new file mode 100644 index 00000000..72775a27 --- /dev/null +++ b/backend/src/main/java/io/f1/backend/domain/game/dto/response/DefaultWebSocketResponse.java @@ -0,0 +1,14 @@ +package io.f1.backend.domain.game.dto.response; + +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 DefaultWebSocketResponse implements WebSocketDto { + private final MessageType type; + private final T message; +} diff --git a/backend/src/main/java/io/f1/backend/domain/game/dto/response/GameSettingResponse.java b/backend/src/main/java/io/f1/backend/domain/game/dto/response/GameSettingResponse.java new file mode 100644 index 00000000..3275bc29 --- /dev/null +++ b/backend/src/main/java/io/f1/backend/domain/game/dto/response/GameSettingResponse.java @@ -0,0 +1,3 @@ +package io.f1.backend.domain.game.dto.response; + +public record GameSettingResponse(int round, int timeLimit, QuizResponse quiz) {} diff --git a/backend/src/main/java/io/f1/backend/domain/game/dto/response/PlayerListResponse.java b/backend/src/main/java/io/f1/backend/domain/game/dto/response/PlayerListResponse.java new file mode 100644 index 00000000..df09741c --- /dev/null +++ b/backend/src/main/java/io/f1/backend/domain/game/dto/response/PlayerListResponse.java @@ -0,0 +1,5 @@ +package io.f1.backend.domain.game.dto.response; + +import java.util.List; + +public record PlayerListResponse(String host, List players) {} diff --git a/backend/src/main/java/io/f1/backend/domain/game/dto/response/PlayerResponse.java b/backend/src/main/java/io/f1/backend/domain/game/dto/response/PlayerResponse.java new file mode 100644 index 00000000..3d86e90f --- /dev/null +++ b/backend/src/main/java/io/f1/backend/domain/game/dto/response/PlayerResponse.java @@ -0,0 +1,3 @@ +package io.f1.backend.domain.game.dto.response; + +public record PlayerResponse(String nickname, boolean ready) {} diff --git a/backend/src/main/java/io/f1/backend/domain/game/dto/response/QuizResponse.java b/backend/src/main/java/io/f1/backend/domain/game/dto/response/QuizResponse.java new file mode 100644 index 00000000..63710221 --- /dev/null +++ b/backend/src/main/java/io/f1/backend/domain/game/dto/response/QuizResponse.java @@ -0,0 +1,4 @@ +package io.f1.backend.domain.game.dto.response; + +public record QuizResponse( + Long quizId, String title, String description, String thumbnailUrl, int numberOfQuestion) {} diff --git a/backend/src/main/java/io/f1/backend/domain/game/dto/response/RoomSettingResponse.java b/backend/src/main/java/io/f1/backend/domain/game/dto/response/RoomSettingResponse.java new file mode 100644 index 00000000..161d53ce --- /dev/null +++ b/backend/src/main/java/io/f1/backend/domain/game/dto/response/RoomSettingResponse.java @@ -0,0 +1,3 @@ +package io.f1.backend.domain.game.dto.response; + +public record RoomSettingResponse(String roomName, int maxUserCount, int currentUserCount) {} 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 f517f642..9044c41d 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,19 @@ package io.f1.backend.domain.game.mapper; 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.QuizResponse; import io.f1.backend.domain.game.dto.response.RoomResponse; +import io.f1.backend.domain.game.dto.response.RoomSettingResponse; +import io.f1.backend.domain.game.model.GameSetting; import io.f1.backend.domain.game.model.Room; import io.f1.backend.domain.game.model.RoomSetting; import io.f1.backend.domain.quiz.entity.Quiz; +import java.util.List; + public class RoomMapper { public static RoomSetting toRoomSetting(RoomCreateRequest request) { @@ -13,6 +21,27 @@ public static RoomSetting toRoomSetting(RoomCreateRequest request) { request.roomName(), request.maxUserCount(), request.locked(), request.password()); } + public static RoomSettingResponse toRoomSettingResponse(Room room) { + return new RoomSettingResponse( + room.getRoomSetting().roomName(), + room.getRoomSetting().maxUserCount(), + room.getPlayerSessionMap().size()); + } + + public static GameSettingResponse toGameSettingResponse( + GameSetting gameSetting, QuizResponse quiz) { + return new GameSettingResponse(gameSetting.getRound(), gameSetting.getTimeLimit(), quiz); + } + + public static PlayerListResponse toPlayerListResponse(Room room) { + List playerResponseList = + room.getPlayerSessionMap().values().stream() + .map(player -> new PlayerResponse(player.getNickname(), false)) + .toList(); + + return new PlayerListResponse(room.getHost().getNickname(), playerResponseList); + } + public static RoomResponse toRoomResponse(Room room, Quiz quiz) { return new RoomResponse( room.getId(), 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 b20a7fcd..83f1b415 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 @@ -3,10 +3,12 @@ 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); + List findAll(); } 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 80a2e442..4daa0a27 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 @@ -7,6 +7,7 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.concurrent.ConcurrentHashMap; @Repository @@ -19,6 +20,11 @@ public void saveRoom(Room room) { roomMap.put(room.getId(), room); } + @Override + public Optional findRoom(Long roomId) { + return Optional.ofNullable(roomMap.get(roomId)); + } + @Override public List findAll() { return new ArrayList<>(roomMap.values()); 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 new file mode 100644 index 00000000..a93f382c --- /dev/null +++ b/backend/src/main/java/io/f1/backend/domain/game/websocket/GameSocketController.java @@ -0,0 +1,38 @@ +package io.f1.backend.domain.game.websocket; + +import io.f1.backend.domain.game.app.RoomService; +import io.f1.backend.domain.game.dto.MessageType; +import io.f1.backend.domain.game.dto.RoomInitialData; + +import lombok.RequiredArgsConstructor; + +import org.springframework.messaging.Message; +import org.springframework.messaging.handler.annotation.DestinationVariable; +import org.springframework.messaging.handler.annotation.MessageMapping; +import org.springframework.messaging.simp.stomp.StompHeaderAccessor; +import org.springframework.stereotype.Controller; + +@Controller +@RequiredArgsConstructor +public class GameSocketController { + + private final MessageSender messageSender; + private final RoomService roomService; + + @MessageMapping("/room/enter/{roomId}") + public void roomEnter(@DestinationVariable Long roomId, Message message) { + + StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message); + String websocketSessionId = accessor.getSessionId(); + + RoomInitialData roomInitialData = roomService.enterRoom(roomId, websocketSessionId); + String destination = roomInitialData.destination(); + + messageSender.send( + destination, MessageType.ROOM_SETTING, roomInitialData.roomSettingResponse()); + messageSender.send( + destination, MessageType.GAME_SETTING, roomInitialData.gameSettingResponse()); + messageSender.send( + destination, MessageType.PLAYER_LIST, roomInitialData.playerListResponse()); + } +} diff --git a/backend/src/main/java/io/f1/backend/domain/game/websocket/MessageSender.java b/backend/src/main/java/io/f1/backend/domain/game/websocket/MessageSender.java new file mode 100644 index 00000000..0a85ddc1 --- /dev/null +++ b/backend/src/main/java/io/f1/backend/domain/game/websocket/MessageSender.java @@ -0,0 +1,21 @@ +package io.f1.backend.domain.game.websocket; + +import io.f1.backend.domain.game.dto.MessageType; +import io.f1.backend.domain.game.dto.response.DefaultWebSocketResponse; + +import lombok.RequiredArgsConstructor; + +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class MessageSender { + + private final SimpMessagingTemplate messagingTemplate; + + public void send(String destination, MessageType type, T message) { + messagingTemplate.convertAndSend( + destination, new DefaultWebSocketResponse<>(type, message)); + } +} diff --git a/backend/src/main/java/io/f1/backend/global/config/StompChannelInterceptor.java b/backend/src/main/java/io/f1/backend/global/config/StompChannelInterceptor.java new file mode 100644 index 00000000..4a89c04c --- /dev/null +++ b/backend/src/main/java/io/f1/backend/global/config/StompChannelInterceptor.java @@ -0,0 +1,48 @@ +package io.f1.backend.global.config; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageChannel; +import org.springframework.messaging.simp.stomp.StompCommand; +import org.springframework.messaging.simp.stomp.StompHeaderAccessor; +import org.springframework.messaging.support.ChannelInterceptor; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class StompChannelInterceptor implements ChannelInterceptor { + + @Override + public Message preSend(Message message, MessageChannel channel) { + + StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message); + StompCommand command = accessor.getCommand(); + String sessionId = accessor.getSessionId(); + String destination = accessor.getDestination(); + + if (command == null) { + throw new IllegalArgumentException("Stomp command required"); + } + + switch (command) { + case CONNECT -> log.info("CONNECT : 세션 연결 - sessionId = {}", sessionId); + + case SUBSCRIBE -> { + if (destination != null && sessionId != null) { + log.info("SUBSCRIBE : 구독 시작 destination = {}", destination); + } + } + + case SEND -> log.info("SEND : 요청 destination = {}", destination); + + case DISCONNECT -> log.info("DISCONNECT : 연결 해제 sessionId = {}", sessionId); + + default -> throw new IllegalStateException("Unexpected command: " + command); + } + + return message; + } +} diff --git a/backend/src/main/java/io/f1/backend/global/config/WebSocketConfig.java b/backend/src/main/java/io/f1/backend/global/config/WebSocketConfig.java index e2651ff7..910e3b83 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 @@ -1,15 +1,21 @@ package io.f1.backend.global.config; +import lombok.RequiredArgsConstructor; + import org.springframework.context.annotation.Configuration; +import org.springframework.messaging.simp.config.ChannelRegistration; import org.springframework.messaging.simp.config.MessageBrokerRegistry; import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; import org.springframework.web.socket.config.annotation.StompEndpointRegistry; import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; @Configuration +@RequiredArgsConstructor @EnableWebSocketMessageBroker public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { + private final StompChannelInterceptor stompChannelInterceptor; + @Override public void registerStompEndpoints(StompEndpointRegistry registry) { registry.addEndpoint("/ws/game-room").setAllowedOriginPatterns("*"); @@ -20,4 +26,9 @@ public void configureMessageBroker(MessageBrokerRegistry registry) { registry.enableSimpleBroker("/sub"); registry.setApplicationDestinationPrefixes("/pub"); } + + @Override + public void configureClientInboundChannel(ChannelRegistration registration) { + registration.interceptors(stompChannelInterceptor); + } }