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 new file mode 100644 index 00000000..18bcedf8 --- /dev/null +++ b/backend/src/main/java/io/f1/backend/domain/admin/app/AdminDetailService.java @@ -0,0 +1,30 @@ +package io.f1.backend.domain.admin.app; + +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 lombok.RequiredArgsConstructor; + +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class AdminDetailService implements UserDetailsService { + + private final AdminRepository adminRepository; + + @Override + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + Admin admin = + adminRepository + .findByUsername(username) + .orElseThrow( + () -> new UsernameNotFoundException("E404007: 존재하지 않는 관리자입니다.")); + // 프론트엔드로 내려가지 않는 예외 + 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 new file mode 100644 index 00000000..fe16eef5 --- /dev/null +++ b/backend/src/main/java/io/f1/backend/domain/admin/app/handler/AdminLoginFailureHandler.java @@ -0,0 +1,38 @@ +package io.f1.backend.domain.admin.app.handler; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import io.f1.backend.domain.admin.dto.AdminLoginFailResponse; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import lombok.RequiredArgsConstructor; + +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.authentication.AuthenticationFailureHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Component +@RequiredArgsConstructor +public class AdminLoginFailureHandler implements AuthenticationFailureHandler { + + private final ObjectMapper objectMapper; + + @Override + public void onAuthenticationFailure( + HttpServletRequest request, + HttpServletResponse response, + AuthenticationException exception) + throws IOException { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); // 401 + response.setContentType("application/json;charset=UTF-8"); + + AdminLoginFailResponse errorResponse = + new AdminLoginFailResponse("E401005", "아이디 또는 비밀번호가 일치하지 않습니다."); + + 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 new file mode 100644 index 00000000..7150d35a --- /dev/null +++ b/backend/src/main/java/io/f1/backend/domain/admin/app/handler/AdminLoginSuccessHandler.java @@ -0,0 +1,47 @@ +package io.f1.backend.domain.admin.app.handler; + +import static io.f1.backend.domain.user.constants.SessionKeys.ADMIN; +import static io.f1.backend.global.util.SecurityUtils.getCurrentAdminPrincipal; + +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 jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.servlet.http.HttpSession; + +import lombok.RequiredArgsConstructor; + +import org.springframework.security.core.Authentication; +import org.springframework.security.web.authentication.AuthenticationSuccessHandler; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; + +@Component +@RequiredArgsConstructor +public class AdminLoginSuccessHandler implements AuthenticationSuccessHandler { + + private final AdminRepository adminRepository; + private final HttpSession httpSession; + + @Override + public void onAuthenticationSuccess( + HttpServletRequest request, + HttpServletResponse response, + Authentication authentication) { + + AdminPrincipal principal = getCurrentAdminPrincipal(); + Admin admin = + adminRepository + .findByUsername(principal.getUsername()) + .orElseThrow(() -> new RuntimeException("E404007: 존재하지 않는 관리자입니다.")); + + admin.updateLastLogin(LocalDateTime.now()); + adminRepository.save(admin); + httpSession.setAttribute(ADMIN, principal.getAuthenticationAdmin()); + + response.setStatus(HttpServletResponse.SC_OK); // 200 + } +} diff --git a/backend/src/main/java/io/f1/backend/domain/admin/dao/AdminRepository.java b/backend/src/main/java/io/f1/backend/domain/admin/dao/AdminRepository.java new file mode 100644 index 00000000..21cc9d13 --- /dev/null +++ b/backend/src/main/java/io/f1/backend/domain/admin/dao/AdminRepository.java @@ -0,0 +1,14 @@ +package io.f1.backend.domain.admin.dao; + +import io.f1.backend.domain.admin.entity.Admin; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface AdminRepository extends JpaRepository { + + Optional findByUsername(String username); +} diff --git a/backend/src/main/java/io/f1/backend/domain/admin/dto/AdminLoginFailResponse.java b/backend/src/main/java/io/f1/backend/domain/admin/dto/AdminLoginFailResponse.java new file mode 100644 index 00000000..4daa6810 --- /dev/null +++ b/backend/src/main/java/io/f1/backend/domain/admin/dto/AdminLoginFailResponse.java @@ -0,0 +1,11 @@ +package io.f1.backend.domain.admin.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class AdminLoginFailResponse { + private String code; + private String message; +} diff --git a/backend/src/main/java/io/f1/backend/domain/admin/dto/AdminPrincipal.java b/backend/src/main/java/io/f1/backend/domain/admin/dto/AdminPrincipal.java new file mode 100644 index 00000000..048e7076 --- /dev/null +++ b/backend/src/main/java/io/f1/backend/domain/admin/dto/AdminPrincipal.java @@ -0,0 +1,59 @@ +package io.f1.backend.domain.admin.dto; + +import io.f1.backend.domain.admin.entity.Admin; + +import lombok.Getter; + +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import java.util.Collection; +import java.util.Collections; + +@Getter +public class AdminPrincipal implements UserDetails { + + public static final String ROLE_ADMIN = "ROLE_ADMIN"; + private final AuthenticationAdmin authenticationAdmin; + private final String password; + + public AdminPrincipal(Admin admin) { + this.authenticationAdmin = AuthenticationAdmin.from(admin); + this.password = admin.getPassword(); + } + + @Override + public Collection getAuthorities() { + return Collections.singleton(() -> ROLE_ADMIN); + } + + @Override + public String getPassword() { + return this.password; + } + + @Override + public String getUsername() { + return authenticationAdmin.username(); + } + + @Override + public boolean isAccountNonExpired() { + return true; + } + + @Override + public boolean isAccountNonLocked() { + return true; + } + + @Override + public boolean isCredentialsNonExpired() { + return true; + } + + @Override + public boolean isEnabled() { + return true; + } +} diff --git a/backend/src/main/java/io/f1/backend/domain/admin/dto/AuthenticationAdmin.java b/backend/src/main/java/io/f1/backend/domain/admin/dto/AuthenticationAdmin.java new file mode 100644 index 00000000..99565de0 --- /dev/null +++ b/backend/src/main/java/io/f1/backend/domain/admin/dto/AuthenticationAdmin.java @@ -0,0 +1,10 @@ +package io.f1.backend.domain.admin.dto; + +import io.f1.backend.domain.admin.entity.Admin; + +public record AuthenticationAdmin(Long adminId, String username) { + + public static AuthenticationAdmin from(Admin admin) { + return new AuthenticationAdmin(admin.getId(), admin.getUsername()); + } +} diff --git a/backend/src/main/java/io/f1/backend/domain/admin/entity/Admin.java b/backend/src/main/java/io/f1/backend/domain/admin/entity/Admin.java index 05c8fe3c..f457e6dc 100644 --- a/backend/src/main/java/io/f1/backend/domain/admin/entity/Admin.java +++ b/backend/src/main/java/io/f1/backend/domain/admin/entity/Admin.java @@ -8,9 +8,12 @@ import jakarta.persistence.GenerationType; import jakarta.persistence.Id; +import lombok.Getter; + import java.time.LocalDateTime; @Entity +@Getter public class Admin extends BaseEntity { @Id @@ -25,4 +28,8 @@ public class Admin extends BaseEntity { @Column(nullable = false) private LocalDateTime lastLogin; + + public void updateLastLogin(LocalDateTime lastLogin) { + this.lastLogin = lastLogin; + } } diff --git a/backend/src/main/java/io/f1/backend/domain/auth/api/AuthController.java b/backend/src/main/java/io/f1/backend/domain/auth/api/AuthController.java new file mode 100644 index 00000000..b8296262 --- /dev/null +++ b/backend/src/main/java/io/f1/backend/domain/auth/api/AuthController.java @@ -0,0 +1,32 @@ +package io.f1.backend.domain.auth.api; + +import static io.f1.backend.global.util.SecurityUtils.getAuthentication; + +import io.f1.backend.domain.admin.dto.AdminPrincipal; +import io.f1.backend.domain.auth.dto.CurrentUserAndAdminResponse; +import io.f1.backend.domain.user.dto.UserPrincipal; + +import lombok.RequiredArgsConstructor; + +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/auth") +@RequiredArgsConstructor +public class AuthController { + + @GetMapping("/me") + public ResponseEntity getCurrentUserOrAdmin() { + Authentication authentication = getAuthentication(); + Object principal = authentication.getPrincipal(); + + if (principal instanceof UserPrincipal userPrincipal) { + return ResponseEntity.ok(CurrentUserAndAdminResponse.from(userPrincipal)); + } + return ResponseEntity.ok(CurrentUserAndAdminResponse.from((AdminPrincipal) principal)); + } +} diff --git a/backend/src/main/java/io/f1/backend/domain/auth/dto/CurrentUserAndAdminResponse.java b/backend/src/main/java/io/f1/backend/domain/auth/dto/CurrentUserAndAdminResponse.java new file mode 100644 index 00000000..d8579951 --- /dev/null +++ b/backend/src/main/java/io/f1/backend/domain/auth/dto/CurrentUserAndAdminResponse.java @@ -0,0 +1,21 @@ +package io.f1.backend.domain.auth.dto; + +import io.f1.backend.domain.admin.dto.AdminPrincipal; +import io.f1.backend.domain.user.dto.UserPrincipal; + +public record CurrentUserAndAdminResponse(Long id, String name, String role) { + + public static CurrentUserAndAdminResponse from(UserPrincipal userPrincipal) { + return new CurrentUserAndAdminResponse( + userPrincipal.getUserId(), + userPrincipal.getUserNickname(), + UserPrincipal.ROLE_USER); + } + + public static CurrentUserAndAdminResponse from(AdminPrincipal adminPrincipal) { + return new CurrentUserAndAdminResponse( + adminPrincipal.getAuthenticationAdmin().adminId(), + adminPrincipal.getUsername(), + AdminPrincipal.ROLE_ADMIN); + } +} 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 79a245de..e492d754 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 @@ -31,10 +31,10 @@ public RoomCreateResponse saveRoom(@RequestBody @Valid RoomCreateRequest request return roomService.saveRoom(request); } - @PostMapping("/validation") + @PostMapping("/enterRoom") @ResponseStatus(HttpStatus.NO_CONTENT) - public void validateRoom(@RequestBody @Valid RoomValidationRequest request) { - roomService.validateRoom(request); + public void enterRoom(@RequestBody @Valid RoomValidationRequest request) { + roomService.enterRoom(request); } @GetMapping 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 new file mode 100644 index 00000000..12cbd7ed --- /dev/null +++ b/backend/src/main/java/io/f1/backend/domain/game/app/GameService.java @@ -0,0 +1,77 @@ +package io.f1.backend.domain.game.app; + +import io.f1.backend.domain.game.dto.GameStartData; +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; +import io.f1.backend.domain.game.model.Player; +import io.f1.backend.domain.game.model.Room; +import io.f1.backend.domain.game.model.RoomState; +import io.f1.backend.domain.game.store.RoomRepository; +import io.f1.backend.domain.quiz.app.QuizService; +import io.f1.backend.domain.quiz.entity.Quiz; + +import lombok.RequiredArgsConstructor; + +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; + +import java.util.Map; + +@Service +@RequiredArgsConstructor +public class GameService { + + private final QuizService quizService; + private final RoomRepository roomRepository; + private final ApplicationEventPublisher eventPublisher; + + public GameStartData gameStart(Long roomId, Long quizId) { + + Room room = + roomRepository + .findRoom(roomId) + .orElseThrow(() -> new IllegalArgumentException("404 존재하지 않는 방입니다.")); + + if (!validateReadyStatus(room)) { + throw new IllegalArgumentException("E403004 : 레디 상태가 아닙니다."); + } + + // 방의 gameSetting에 설정된 퀴즈랑 요청 퀴즈랑 같은지 체크 후 GameSetting에서 라운드 가져오기 + Integer round = checkGameSetting(room, quizId); + + Quiz quiz = quizService.getQuizWithQuestionsById(quizId); + + // 라운드 수만큼 랜덤 Question 추출 + GameStartResponse questions = quizService.getRandomQuestionsWithoutAnswer(quizId, round); + + // 방 정보 게임 중으로 변경 + room.updateRoomState(RoomState.PLAYING); + + eventPublisher.publishEvent(new RoomUpdatedEvent(room, quiz)); + + return new GameStartData(getDestination(roomId), questions); + } + + private Integer checkGameSetting(Room room, Long quizId) { + + GameSetting gameSetting = room.getGameSetting(); + + if (!gameSetting.checkQuizId(quizId)) { + throw new IllegalArgumentException("E409002 : 게임 설정이 다릅니다. (게임을 시작할 수 없습니다.)"); + } + + return gameSetting.getRound(); + } + + private boolean validateReadyStatus(Room room) { + + Map playerSessionMap = room.getPlayerSessionMap(); + + 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 723c8355..d4544e9f 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 @@ -31,8 +31,11 @@ import io.f1.backend.domain.game.store.RoomRepository; 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.RoomErrorCode; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; @@ -40,8 +43,10 @@ import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicLong; +@Slf4j @Service @RequiredArgsConstructor public class RoomService { @@ -50,11 +55,13 @@ public class RoomService { private final RoomRepository roomRepository; private final AtomicLong roomIdGenerator = new AtomicLong(0); private final ApplicationEventPublisher eventPublisher; + private final Map roomLocks = new ConcurrentHashMap<>(); + private static final String PENDING_SESSION_ID = "PENDING_SESSION_ID"; public RoomCreateResponse saveRoom(RoomCreateRequest request) { Long quizMinId = quizService.getQuizMinId(); - Quiz quiz = quizService.getQuizById(quizMinId); + Quiz quiz = quizService.getQuizWithQuestionsById(quizMinId); GameSetting gameSetting = toGameSetting(quiz); @@ -66,6 +73,8 @@ public RoomCreateResponse saveRoom(RoomCreateRequest request) { Room room = new Room(newId, roomSetting, gameSetting, host); + room.getUserIdSessionMap().put(host.id, PENDING_SESSION_ID); + roomRepository.saveRoom(room); eventPublisher.publishEvent(new RoomCreatedEvent(room, quiz)); @@ -73,46 +82,58 @@ public RoomCreateResponse saveRoom(RoomCreateRequest request) { return new RoomCreateResponse(newId); } - public void validateRoom(RoomValidationRequest request) { + public void enterRoom(RoomValidationRequest request) { - Room room = - roomRepository - .findRoom(request.roomId()) - .orElseThrow(() -> new IllegalArgumentException("404 존재하지 않는 방입니다.-1")); + Long roomId = request.roomId(); - if (room.getState().equals(RoomState.PLAYING)) { - throw new IllegalArgumentException("403 게임이 진행중입니다."); - } + Object lock = roomLocks.computeIfAbsent(roomId, k -> new Object()); - int maxUserCnt = room.getRoomSetting().maxUserCount(); - int currentCnt = room.getPlayerSessionMap().size(); - if (maxUserCnt == currentCnt) { - throw new IllegalArgumentException("403 정원이 모두 찼습니다."); - } + synchronized (lock) { + Room room = findRoom(request.roomId()); + + if (room.getState().equals(RoomState.PLAYING)) { + throw new CustomException(RoomErrorCode.ROOM_GAME_IN_PROGRESS); + } + + int maxUserCnt = room.getRoomSetting().maxUserCount(); + int currentCnt = room.getUserIdSessionMap().size(); + if (maxUserCnt == currentCnt) { + throw new CustomException(RoomErrorCode.ROOM_USER_LIMIT_REACHED); + } - if (room.getRoomSetting().locked() - && !room.getRoomSetting().password().equals(request.password())) { - throw new IllegalArgumentException("401 비밀번호가 일치하지 않습니다."); + if (room.getRoomSetting().locked() + && !room.getRoomSetting().password().equals(request.password())) { + throw new CustomException(RoomErrorCode.WRONG_PASSWORD); + } + + room.getUserIdSessionMap().put(getCurrentUserId(), PENDING_SESSION_ID); } } - public RoomInitialData enterRoom(Long roomId, String sessionId) { + public RoomInitialData initializeRoomSocket(Long roomId, String sessionId) { - Room room = - roomRepository - .findRoom(roomId) - .orElseThrow(() -> new IllegalArgumentException("404 존재하지 않는 방입니다.")); + Room room = findRoom(roomId); Player player = createPlayer(); Map playerSessionMap = room.getPlayerSessionMap(); + Map userIdSessionMap = room.getUserIdSessionMap(); + + if (room.isHost(player.getId())) { + player.toggleReady(); + } playerSessionMap.put(sessionId, player); + String existingSession = userIdSessionMap.get(player.getId()); + /* 정상 흐름 or 재연결 */ + if (existingSession.equals(PENDING_SESSION_ID) || !existingSession.equals(sessionId)) { + userIdSessionMap.put(player.getId(), sessionId); + } RoomSettingResponse roomSettingResponse = toRoomSettingResponse(room); Long quizId = room.getGameSetting().getQuizId(); - Quiz quiz = quizService.getQuizById(quizId); + Quiz quiz = quizService.getQuizWithQuestionsById(quizId); GameSettingResponse gameSettingResponse = toGameSettingResponse(room.getGameSetting(), quiz); @@ -130,42 +151,36 @@ public RoomInitialData enterRoom(Long roomId, String sessionId) { } public RoomExitData exitRoom(Long roomId, String sessionId) { - Room room = - roomRepository - .findRoom(roomId) - .orElseThrow(() -> new IllegalArgumentException("404 존재하지 않는 방입니다.")); - Map playerSessionMap = room.getPlayerSessionMap(); + Object lock = roomLocks.computeIfAbsent(roomId, k -> new Object()); - String destination = getDestination(roomId); + synchronized (lock) { + Room room = findRoom(roomId); - if (playerSessionMap.size() == 1 && playerSessionMap.get(sessionId) != null) { - roomRepository.removeRoom(roomId); - return RoomExitData.builder().destination(destination).removedRoom(true).build(); - } + String destination = getDestination(roomId); - Player removedPlayer = playerSessionMap.remove(sessionId); - if (removedPlayer == null) { - throw new IllegalArgumentException("퇴장 처리 불가 - 404 해당 세션 플레이어는 존재하지않습니다."); - } + Player removePlayer = getRemovePlayer(room, sessionId); - if (room.getHost().getId().equals(removedPlayer.getId())) { - Optional nextHostSessionId = playerSessionMap.keySet().stream().findFirst(); - Player nextHost = - playerSessionMap.get( - nextHostSessionId.orElseThrow( - () -> - new IllegalArgumentException( - "방장 교체 불가 - 404 해당 세션 플레이어는 존재하지않습니다."))); - room.updateHost(nextHost); - } + /* 방 삭제 */ + if (isLastPlayer(room, sessionId)) { + return removeRoom(room, destination); + } - SystemNoticeResponse systemNoticeResponse = - ofPlayerEvent(removedPlayer, RoomEventType.EXIT); + /* 방장 변경 */ + if (room.isHost(removePlayer.getId())) { + changeHost(room, sessionId); + } - PlayerListResponse playerListResponse = toPlayerListResponse(room); + /* 플레이어 삭제 */ + removePlayer(room, sessionId, removePlayer); - return new RoomExitData(destination, playerListResponse, systemNoticeResponse, false); + SystemNoticeResponse systemNoticeResponse = + ofPlayerEvent(removePlayer, RoomEventType.EXIT); + + PlayerListResponse playerListResponse = toPlayerListResponse(room); + + return new RoomExitData(destination, playerListResponse, systemNoticeResponse, false); + } } public RoomListResponse getAllRooms() { @@ -175,7 +190,7 @@ public RoomListResponse getAllRooms() { .map( room -> { Long quizId = room.getGameSetting().getQuizId(); - Quiz quiz = quizService.getQuizById(quizId); + Quiz quiz = quizService.getQuizWithQuestionsById(quizId); return toRoomResponse(room, quiz); }) @@ -183,11 +198,61 @@ public RoomListResponse getAllRooms() { return new RoomListResponse(roomResponses); } + private Player getRemovePlayer(Room room, String sessionId) { + Player removePlayer = room.getPlayerSessionMap().get(sessionId); + if (removePlayer == null) { + room.removeUserId(getCurrentUserId()); + throw new CustomException(RoomErrorCode.SOCKET_SESSION_NOT_FOUND); + } + return removePlayer; + } + private static String getDestination(Long roomId) { return "/sub/room/" + roomId; } - private static Player createPlayer() { + private Player createPlayer() { return new Player(getCurrentUserId(), getCurrentUserNickname()); } + + private Room findRoom(Long roomId) { + return roomRepository + .findRoom(roomId) + .orElseThrow(() -> new CustomException(RoomErrorCode.ROOM_NOT_FOUND)); + } + + private boolean isLastPlayer(Room room, String sessionId) { + Map playerSessionMap = room.getPlayerSessionMap(); + return playerSessionMap.size() == 1 && playerSessionMap.containsKey(sessionId); + } + + private RoomExitData removeRoom(Room room, String destination) { + Long roomId = room.getId(); + roomRepository.removeRoom(roomId); + roomLocks.remove(roomId); + log.info("{}번 방 삭제", roomId); + return RoomExitData.builder().destination(destination).removedRoom(true).build(); + } + + private void changeHost(Room room, String hostSessionId) { + Map playerSessionMap = room.getPlayerSessionMap(); + + Optional nextHostSessionId = + playerSessionMap.keySet().stream() + .filter(key -> !key.equals(hostSessionId)) + .findFirst(); + + Player nextHost = + playerSessionMap.get( + nextHostSessionId.orElseThrow( + () -> new CustomException(RoomErrorCode.SOCKET_SESSION_NOT_FOUND))); + + room.updateHost(nextHost); + log.info("user_id:{} 방장 변경 완료 ", nextHost.getId()); + } + + private void removePlayer(Room room, String sessionId, Player removePlayer) { + room.removeUserId(removePlayer.getId()); + room.removeSessionId(sessionId); + } } 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 new file mode 100644 index 00000000..c68d056e --- /dev/null +++ b/backend/src/main/java/io/f1/backend/domain/game/dto/GameStartData.java @@ -0,0 +1,5 @@ +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 f6d40420..22dfdee2 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 @@ -5,4 +5,5 @@ public enum MessageType { GAME_SETTING, PLAYER_LIST, SYSTEM_NOTICE, + GAME_START, } diff --git a/backend/src/main/java/io/f1/backend/domain/game/dto/request/GameStartRequest.java b/backend/src/main/java/io/f1/backend/domain/game/dto/request/GameStartRequest.java new file mode 100644 index 00000000..61f792ea --- /dev/null +++ b/backend/src/main/java/io/f1/backend/domain/game/dto/request/GameStartRequest.java @@ -0,0 +1,3 @@ +package io.f1.backend.domain.game.dto.request; + +public record GameStartRequest(Long quizId) {} diff --git a/backend/src/main/java/io/f1/backend/domain/game/dto/response/GameStartResponse.java b/backend/src/main/java/io/f1/backend/domain/game/dto/response/GameStartResponse.java new file mode 100644 index 00000000..9545a8ff --- /dev/null +++ b/backend/src/main/java/io/f1/backend/domain/game/dto/response/GameStartResponse.java @@ -0,0 +1,7 @@ +package io.f1.backend.domain.game.dto.response; + +import io.f1.backend.domain.quiz.dto.GameQuestionResponse; + +import java.util.List; + +public record GameStartResponse(List questions) {} 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 7643d861..7634d114 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 @@ -10,4 +10,11 @@ public class GameSetting { private Long quizId; private Integer round; // 게임 변경 시 해당 게임의 총 문제 수로 설정 private int timeLimit = 60; + + public boolean checkQuizId(Long quizId) { + if (this.quizId != null && this.quizId.equals(quizId)) { + return false; + } + return true; + } } 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 3d3230c1..a5b1241c 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 @@ -19,4 +19,8 @@ public Player(Long id, String nickname) { this.id = id; this.nickname = nickname; } + + public void toggleReady() { + this.isReady = !this.isReady; + } } 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 00b7e71b..d42abc8d 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 @@ -27,6 +27,8 @@ public class Room { private Map playerSessionMap = new ConcurrentHashMap<>(); + private Map userIdSessionMap = new ConcurrentHashMap<>(); + private final LocalDateTime createdAt = LocalDateTime.now(); public Room(Long id, RoomSetting roomSetting, GameSetting gameSetting, Player host) { @@ -36,7 +38,23 @@ public Room(Long id, RoomSetting roomSetting, GameSetting gameSetting, Player ho this.host = host; } + public boolean isHost(Long id) { + return this.host.getId().equals(id); + } + public void updateHost(Player nextHost) { this.host = nextHost; } + + public void updateRoomState(RoomState newState) { + this.state = newState; + } + + public void removeUserId(Long id) { + this.userIdSessionMap.remove(id); + } + + public void removeSessionId(String sessionId) { + this.playerSessionMap.remove(sessionId); + } } diff --git a/backend/src/main/java/io/f1/backend/domain/game/websocket/GameSocketController.java b/backend/src/main/java/io/f1/backend/domain/game/websocket/GameSocketController.java index 9bf2f17f..e84e9c36 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 @@ -1,9 +1,12 @@ package io.f1.backend.domain.game.websocket; +import io.f1.backend.domain.game.app.GameService; import io.f1.backend.domain.game.app.RoomService; +import io.f1.backend.domain.game.dto.GameStartData; 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.request.GameStartRequest; import lombok.RequiredArgsConstructor; @@ -19,13 +22,15 @@ public class GameSocketController { private final MessageSender messageSender; private final RoomService roomService; + private final GameService gameService; - @MessageMapping("/room/enter/{roomId}") - public void roomEnter(@DestinationVariable Long roomId, Message message) { + @MessageMapping("/room/initializeRoomSocket/{roomId}") + public void initializeRoomSocket(@DestinationVariable Long roomId, Message message) { String websocketSessionId = getSessionId(message); - RoomInitialData roomInitialData = roomService.enterRoom(roomId, websocketSessionId); + RoomInitialData roomInitialData = + roomService.initializeRoomSocket(roomId, websocketSessionId); String destination = roomInitialData.destination(); messageSender.send( @@ -55,6 +60,18 @@ public void exitRoom(@DestinationVariable Long roomId, Message message) { } } + @MessageMapping("/room/start/{roomId}") + public void gameStart(@DestinationVariable Long roomId, Message message) { + + Long quizId = message.getPayload().quizId(); + + GameStartData gameStartData = gameService.gameStart(roomId, quizId); + + String destination = gameStartData.destination(); + + messageSender.send(destination, MessageType.GAME_START, gameStartData.gameStartResponse()); + } + private static String getSessionId(Message message) { StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message); return accessor.getSessionId(); diff --git a/backend/src/main/java/io/f1/backend/domain/question/api/QuestionController.java b/backend/src/main/java/io/f1/backend/domain/question/api/QuestionController.java new file mode 100644 index 00000000..328ba7d9 --- /dev/null +++ b/backend/src/main/java/io/f1/backend/domain/question/api/QuestionController.java @@ -0,0 +1,44 @@ +package io.f1.backend.domain.question.api; + +import io.f1.backend.domain.question.app.QuestionService; +import io.f1.backend.domain.question.dto.QuestionUpdateRequest; + +import lombok.RequiredArgsConstructor; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/questions") +@RequiredArgsConstructor +public class QuestionController { + + private final QuestionService questionService; + + @PutMapping("/{questionId}") + public ResponseEntity updateQuestion( + @PathVariable Long questionId, @RequestBody QuestionUpdateRequest request) { + + if (request.content() != null) { + questionService.updateQuestionContent(questionId, request.content()); + } + + if (request.content() != null) { + questionService.updateQuestionAnswer(questionId, request.answer()); + } + + return ResponseEntity.noContent().build(); + } + + @DeleteMapping("/{questionId}") + public ResponseEntity deleteQuestion(@PathVariable Long questionId) { + questionService.deleteQuestion(questionId); + + return ResponseEntity.noContent().build(); + } +} 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 87a0e222..6fc7c954 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 @@ -15,6 +15,8 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.NoSuchElementException; + @Service @RequiredArgsConstructor public class QuestionService { @@ -33,4 +35,54 @@ public void saveQuestion(Quiz quiz, QuestionRequest request) { textQuestionRepository.save(textQuestion); question.addTextQuestion(textQuestion); } + + @Transactional + public void updateQuestionContent(Long questionId, String content) { + + validateContent(content); + + Question question = + questionRepository + .findById(questionId) + .orElseThrow(() -> new NoSuchElementException("존재하지 않는 문제입니다.")); + + TextQuestion textQuestion = question.getTextQuestion(); + textQuestion.changeContent(content); + } + + @Transactional + public void updateQuestionAnswer(Long questionId, String answer) { + + validateAnswer(answer); + + Question question = + questionRepository + .findById(questionId) + .orElseThrow(() -> new NoSuchElementException("존재하지 않는 문제입니다.")); + + question.changeAnswer(answer); + } + + @Transactional + public void deleteQuestion(Long questionId) { + + Question question = + questionRepository + .findById(questionId) + .orElseThrow(() -> new NoSuchElementException("존재하지 않는 문제입니다.")); + + questionRepository.delete(question); + } + + private void validateAnswer(String answer) { + if (answer.trim().length() < 5 || answer.trim().length() > 30) { + throw new IllegalArgumentException("정답은 1자 이상 30자 이하로 입력해주세요."); + } + } + + private void validateContent(String content) { + if (content.trim().length() < 5 || content.trim().length() > 30) { + throw new IllegalArgumentException("문제는 5자 이상 30자 이하로 입력해주세요."); + } + } } diff --git a/backend/src/main/java/io/f1/backend/domain/question/dto/QuestionRequest.java b/backend/src/main/java/io/f1/backend/domain/question/dto/QuestionRequest.java index 9374a9b9..4cdce5ff 100644 --- a/backend/src/main/java/io/f1/backend/domain/question/dto/QuestionRequest.java +++ b/backend/src/main/java/io/f1/backend/domain/question/dto/QuestionRequest.java @@ -1,5 +1,7 @@ package io.f1.backend.domain.question.dto; +import io.f1.backend.global.validation.TrimmedSize; + import jakarta.validation.constraints.NotBlank; import lombok.AccessLevel; @@ -10,9 +12,11 @@ @NoArgsConstructor(access = AccessLevel.PROTECTED) public class QuestionRequest { + @TrimmedSize(min = 5, max = 30) @NotBlank(message = "문제를 입력해주세요.") private String content; + @TrimmedSize(min = 1, max = 30) @NotBlank(message = "정답을 입력해주세요.") private String answer; } diff --git a/backend/src/main/java/io/f1/backend/domain/question/dto/QuestionUpdateRequest.java b/backend/src/main/java/io/f1/backend/domain/question/dto/QuestionUpdateRequest.java new file mode 100644 index 00000000..e77a3dea --- /dev/null +++ b/backend/src/main/java/io/f1/backend/domain/question/dto/QuestionUpdateRequest.java @@ -0,0 +1,3 @@ +package io.f1.backend.domain.question.dto; + +public record QuestionUpdateRequest(String content, String answer) {} diff --git a/backend/src/main/java/io/f1/backend/domain/question/entity/Question.java b/backend/src/main/java/io/f1/backend/domain/question/entity/Question.java index b197ac6e..fe6d0783 100644 --- a/backend/src/main/java/io/f1/backend/domain/question/entity/Question.java +++ b/backend/src/main/java/io/f1/backend/domain/question/entity/Question.java @@ -45,4 +45,8 @@ public Question(Quiz quiz, String answer) { public void addTextQuestion(TextQuestion textQuestion) { this.textQuestion = textQuestion; } + + public void changeAnswer(String answer) { + this.answer = answer; + } } diff --git a/backend/src/main/java/io/f1/backend/domain/question/entity/TextQuestion.java b/backend/src/main/java/io/f1/backend/domain/question/entity/TextQuestion.java index 7adf05c1..b10be109 100644 --- a/backend/src/main/java/io/f1/backend/domain/question/entity/TextQuestion.java +++ b/backend/src/main/java/io/f1/backend/domain/question/entity/TextQuestion.java @@ -32,4 +32,8 @@ public TextQuestion(Question question, String content) { this.question = question; this.content = content; } + + public void changeContent(String content) { + this.content = content; + } } 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 64b4846e..ee8d03a6 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 @@ -61,7 +61,17 @@ public ResponseEntity updateQuiz( @RequestPart QuizUpdateRequest request) throws IOException { - quizService.updateQuiz(quizId, thumbnailFile, request); + if (request.title() != null) { + quizService.updateQuizTitle(quizId, request.title()); + } + + if (request.description() != null) { + quizService.updateQuizDesc(quizId, request.description()); + } + + if (thumbnailFile != null && !thumbnailFile.isEmpty()) { + quizService.updateThumbnail(quizId, thumbnailFile); + } return ResponseEntity.noContent().build(); } 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 37c0585b..ce705fb6 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,15 +4,16 @@ 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; import io.f1.backend.domain.quiz.dao.QuizRepository; import io.f1.backend.domain.quiz.dto.QuizCreateRequest; 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.QuizQuestionListResponse; -import io.f1.backend.domain.quiz.dto.QuizUpdateRequest; import io.f1.backend.domain.quiz.entity.Quiz; import io.f1.backend.domain.user.dao.UserRepository; import io.f1.backend.domain.user.entity.User; @@ -123,28 +124,52 @@ public void deleteQuiz(Long quizId) { } @Transactional - public void updateQuiz(Long quizId, MultipartFile thumbnailFile, QuizUpdateRequest request) - throws IOException { + public void updateQuizTitle(Long quizId, String title) { + Quiz quiz = + quizRepository + .findById(quizId) + .orElseThrow(() -> new NoSuchElementException("존재하지 않는 퀴즈입니다.")); + + validateTitle(title); + quiz.changeTitle(title); + } + + @Transactional + public void updateQuizDesc(Long quizId, String description) { Quiz quiz = quizRepository .findById(quizId) .orElseThrow(() -> new NoSuchElementException("존재하지 않는 퀴즈입니다.")); - if (request.title() != null) { - quiz.changeTitle(request.title()); - } + validateDesc(description); + quiz.changeDescription(description); + } - if (request.description() != null) { - quiz.changeDescription(request.description()); - } + @Transactional + public void updateThumbnail(Long quizId, MultipartFile thumbnailFile) throws IOException { - if (thumbnailFile != null && !thumbnailFile.isEmpty()) { - validateImageFile(thumbnailFile); - String newThumbnailPath = convertToThumbnailPath(thumbnailFile); + Quiz quiz = + quizRepository + .findById(quizId) + .orElseThrow(() -> new NoSuchElementException("존재하지 않는 퀴즈입니다.")); + + validateImageFile(thumbnailFile); + String newThumbnailPath = convertToThumbnailPath(thumbnailFile); + + deleteThumbnailFile(quiz.getThumbnailUrl()); + quiz.changeThumbnailUrl(newThumbnailPath); + } + + private void validateDesc(String desc) { + if (desc.trim().length() < 10 || desc.trim().length() > 50) { + throw new IllegalArgumentException("설명은 10자 이상 50자 이하로 입력해주세요."); + } + } - deleteThumbnailFile(quiz.getThumbnailUrl()); - quiz.changeThumbnailUrl(newThumbnailPath); + private void validateTitle(String title) { + if (title.trim().length() < 2 || title.trim().length() > 30) { + throw new IllegalArgumentException("제목은 2자 이상 30자 이하로 입력해주세요."); } } @@ -191,12 +216,11 @@ public QuizListPageResponse getQuizzes(String title, String creator, Pageable pa } @Transactional(readOnly = true) - public Quiz getQuizById(Long quizId) { + public Quiz getQuizWithQuestionsById(Long quizId) { Quiz quiz = quizRepository - .findById(quizId) + .findQuizWithQuestionsById(quizId) .orElseThrow(() -> new RuntimeException("E404002: 존재하지 않는 퀴즈입니다.")); - quiz.getQuestions().size(); return quiz; } @@ -205,6 +229,7 @@ public Long getQuizMinId() { return quizRepository.getQuizMinId(); } + @Transactional(readOnly = true) public QuizQuestionListResponse getQuizWithQuestions(Long quizId) { Quiz quiz = quizRepository @@ -213,4 +238,15 @@ public QuizQuestionListResponse getQuizWithQuestions(Long quizId) { return quizToQuizQuestionListResponse(quiz); } + + @Transactional(readOnly = true) + public GameStartResponse getRandomQuestionsWithoutAnswer(Long quizId, Integer round) { + quizRepository + .findById(quizId) + .orElseThrow(() -> new NoSuchElementException("존재하지 않는 퀴즈입니다.")); + + List randomQuestions = quizRepository.findRandQuestionsByQuizId(quizId, round); + + return toGameStartResponse(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 d5021088..b2d1728d 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,5 +1,6 @@ package io.f1.backend.domain.quiz.dao; +import io.f1.backend.domain.question.entity.Question; import io.f1.backend.domain.quiz.entity.Quiz; import org.springframework.data.domain.Page; @@ -7,12 +8,23 @@ import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; +import java.util.List; +import java.util.Optional; + public interface QuizRepository extends JpaRepository { Page findQuizzesByTitleContaining(String title, Pageable pageable); Page findQuizzesByCreator_NicknameContaining(String creator, Pageable pageable); + @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( + value = "SELECT * FROM question WHERE quiz_id = :quizId ORDER BY RAND() LIMIT :round", + nativeQuery = true) + List findRandQuestionsByQuizId(Long quizId, Integer round); } diff --git a/backend/src/main/java/io/f1/backend/domain/quiz/dto/GameQuestionResponse.java b/backend/src/main/java/io/f1/backend/domain/quiz/dto/GameQuestionResponse.java new file mode 100644 index 00000000..a47b93d9 --- /dev/null +++ b/backend/src/main/java/io/f1/backend/domain/quiz/dto/GameQuestionResponse.java @@ -0,0 +1,3 @@ +package io.f1.backend.domain.quiz.dto; + +public record GameQuestionResponse(Long id, String question) {} diff --git a/backend/src/main/java/io/f1/backend/domain/quiz/dto/QuizCreateRequest.java b/backend/src/main/java/io/f1/backend/domain/quiz/dto/QuizCreateRequest.java index 418477fb..313a519a 100644 --- a/backend/src/main/java/io/f1/backend/domain/quiz/dto/QuizCreateRequest.java +++ b/backend/src/main/java/io/f1/backend/domain/quiz/dto/QuizCreateRequest.java @@ -2,6 +2,7 @@ import io.f1.backend.domain.question.dto.QuestionRequest; import io.f1.backend.domain.quiz.entity.QuizType; +import io.f1.backend.global.validation.TrimmedSize; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; @@ -17,12 +18,14 @@ @NoArgsConstructor(access = AccessLevel.PROTECTED) public class QuizCreateRequest { + @TrimmedSize(min = 2, max = 30) @NotBlank(message = "퀴즈 제목을 설정해주세요.") private String title; @NotNull(message = "퀴즈 종류를 선택해주세요.") private QuizType quizType; + @TrimmedSize(min = 10, max = 50) @NotBlank(message = "퀴즈 설명을 적어주세요.") private String description; 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 b515f679..0c0c0513 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 @@ -1,7 +1,9 @@ package io.f1.backend.domain.quiz.mapper; +import io.f1.backend.domain.game.dto.response.GameStartResponse; import io.f1.backend.domain.question.dto.QuestionResponse; import io.f1.backend.domain.question.entity.Question; +import io.f1.backend.domain.quiz.dto.GameQuestionResponse; import io.f1.backend.domain.quiz.dto.QuizCreateRequest; import io.f1.backend.domain.quiz.dto.QuizCreateResponse; import io.f1.backend.domain.quiz.dto.QuizListPageResponse; @@ -83,4 +85,16 @@ public static QuizQuestionListResponse quizToQuizQuestionListResponse(Quiz quiz) quiz.getQuestions().size(), questionsToQuestionResponses(quiz.getQuestions())); } + + public static List toGameQuestionResponseList(List questions) { + return questions.stream().map(QuizMapper::toGameQuestionResponse).toList(); + } + + public static GameQuestionResponse toGameQuestionResponse(Question question) { + return new GameQuestionResponse(question.getId(), question.getTextQuestion().getContent()); + } + + public static GameStartResponse toGameStartResponse(List questions) { + return new GameStartResponse(toGameQuestionResponseList(questions)); + } } 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 d0cb45ac..9fa1d7de 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.user.app.UserService; -import io.f1.backend.domain.user.dto.SignupRequestDto; -import io.f1.backend.domain.user.dto.SignupResponseDto; +import io.f1.backend.domain.user.dto.SignupRequest; +import io.f1.backend.domain.user.dto.SignupResponse; import jakarta.servlet.http.HttpSession; @@ -21,9 +21,9 @@ public class SignupController { private final UserService userService; @PostMapping("/signup") - public ResponseEntity completeSignup( - @RequestBody SignupRequestDto signupRequest, HttpSession httpSession) { - SignupResponseDto response = userService.signup(httpSession, signupRequest); + public ResponseEntity completeSignup( + @RequestBody SignupRequest signupRequest, HttpSession httpSession) { + SignupResponse response = userService.signup(httpSession, signupRequest); return ResponseEntity.status(HttpStatus.CREATED).body(response); } } diff --git a/backend/src/main/java/io/f1/backend/domain/user/api/UserController.java b/backend/src/main/java/io/f1/backend/domain/user/api/UserController.java new file mode 100644 index 00000000..4a56d6e7 --- /dev/null +++ b/backend/src/main/java/io/f1/backend/domain/user/api/UserController.java @@ -0,0 +1,45 @@ +package io.f1.backend.domain.user.api; + +import static io.f1.backend.global.util.SecurityUtils.logout; + +import io.f1.backend.domain.user.app.UserService; +import io.f1.backend.domain.user.dto.SignupRequest; +import io.f1.backend.domain.user.dto.UserPrincipal; + +import jakarta.servlet.http.HttpSession; + +import lombok.RequiredArgsConstructor; + +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/user/me") +public class UserController { + + private final UserService userService; + + @DeleteMapping + public ResponseEntity deleteCurrentUser( + @AuthenticationPrincipal UserPrincipal userPrincipal, HttpSession httpSession) { + userService.deleteUser(userPrincipal.getUserId()); + logout(httpSession); + return ResponseEntity.noContent().build(); + } + + @PutMapping + public ResponseEntity updateNickname( + @AuthenticationPrincipal UserPrincipal userPrincipal, + @RequestBody SignupRequest signupRequest, + HttpSession httpSession) { + userService.updateNickname( + userPrincipal.getUserId(), signupRequest.nickname(), httpSession); + return ResponseEntity.noContent().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 c7e48e56..d2f035d9 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 @@ -6,8 +6,8 @@ import io.f1.backend.domain.user.dao.UserRepository; import io.f1.backend.domain.user.dto.AuthenticationUser; -import io.f1.backend.domain.user.dto.SignupRequestDto; -import io.f1.backend.domain.user.dto.SignupResponseDto; +import io.f1.backend.domain.user.dto.SignupRequest; +import io.f1.backend.domain.user.dto.SignupResponse; import io.f1.backend.domain.user.entity.User; import io.f1.backend.global.util.SecurityUtils; @@ -25,14 +25,14 @@ public class UserService { private final UserRepository userRepository; @Transactional - public SignupResponseDto signup(HttpSession session, SignupRequestDto signupRequest) { + public SignupResponse signup(HttpSession session, SignupRequest signupRequest) { AuthenticationUser authenticationUser = extractSessionUser(session); String nickname = signupRequest.nickname(); validateNicknameFormat(nickname); validateNicknameDuplicate(nickname); - User user = updateUserNickname(authenticationUser.userId(), nickname); + User user = initNickname(authenticationUser.userId(), nickname); updateSessionAfterSignup(session, user); SecurityUtils.setAuthentication(user); @@ -68,7 +68,7 @@ public void validateNicknameDuplicate(String nickname) { } @Transactional - public User updateUserNickname(Long userId, String nickname) { + public User initNickname(Long userId, String nickname) { User user = userRepository .findById(userId) @@ -82,4 +82,23 @@ private void updateSessionAfterSignup(HttpSession session, User user) { session.removeAttribute(OAUTH_USER); session.setAttribute(USER, AuthenticationUser.from(user)); } + + @Transactional + public void deleteUser(Long userId) { + User user = + userRepository + .findById(userId) + .orElseThrow(() -> new RuntimeException("E404001: 존재하지 않는 회원입니다.")); + userRepository.delete(user); + } + + @Transactional + public void updateNickname(Long userId, String newNickname, HttpSession session) { + validateNicknameFormat(newNickname); + validateNicknameDuplicate(newNickname); + + User user = initNickname(userId, newNickname); + session.setAttribute(USER, AuthenticationUser.from(user)); + SecurityUtils.setAuthentication(user); + } } 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 a465c885..37f410f0 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 @@ -1,7 +1,5 @@ package io.f1.backend.domain.user.app.handler; -import com.fasterxml.jackson.databind.ObjectMapper; - import io.f1.backend.domain.user.dto.UserPrincipal; import jakarta.servlet.http.HttpServletRequest; @@ -14,14 +12,11 @@ import org.springframework.stereotype.Component; import java.io.IOException; -import java.util.Map; @Component @RequiredArgsConstructor public class OAuthSuccessHandler extends SimpleUrlAuthenticationSuccessHandler { - private final ObjectMapper objectMapper; - @Override public void onAuthenticationSuccess( HttpServletRequest request, HttpServletResponse response, Authentication authentication) @@ -30,13 +25,9 @@ public void onAuthenticationSuccess( response.setContentType("application/json;charset=UTF-8"); if (principal.getUserNickname() == null) { - // 닉네임 설정 필요 → 202 Accepted - response.setStatus(HttpServletResponse.SC_ACCEPTED); - objectMapper.writeValue(response.getWriter(), Map.of("message", "닉네임을 설정하세요.")); + getRedirectStrategy().sendRedirect(request, response, "/signup"); } else { - // 정상 로그인 → 200 OK - response.setStatus(HttpServletResponse.SC_OK); - objectMapper.writeValue(response.getWriter(), Map.of("message", "로그인 성공")); + getRedirectStrategy().sendRedirect(request, response, "/room"); } } } diff --git a/backend/src/main/java/io/f1/backend/domain/user/app/handler/OAuthLogoutSuccessHandler.java b/backend/src/main/java/io/f1/backend/domain/user/app/handler/UserAndAdminLogoutSuccessHandler.java similarity index 88% rename from backend/src/main/java/io/f1/backend/domain/user/app/handler/OAuthLogoutSuccessHandler.java rename to backend/src/main/java/io/f1/backend/domain/user/app/handler/UserAndAdminLogoutSuccessHandler.java index 8f0528b6..9ed53381 100644 --- a/backend/src/main/java/io/f1/backend/domain/user/app/handler/OAuthLogoutSuccessHandler.java +++ b/backend/src/main/java/io/f1/backend/domain/user/app/handler/UserAndAdminLogoutSuccessHandler.java @@ -8,7 +8,7 @@ import org.springframework.stereotype.Component; @Component -public class OAuthLogoutSuccessHandler implements LogoutSuccessHandler { +public class UserAndAdminLogoutSuccessHandler implements LogoutSuccessHandler { @Override public void onLogoutSuccess( diff --git a/backend/src/main/java/io/f1/backend/domain/user/constants/SessionKeys.java b/backend/src/main/java/io/f1/backend/domain/user/constants/SessionKeys.java index dc549453..a728bee8 100644 --- a/backend/src/main/java/io/f1/backend/domain/user/constants/SessionKeys.java +++ b/backend/src/main/java/io/f1/backend/domain/user/constants/SessionKeys.java @@ -6,4 +6,5 @@ private SessionKeys() {} public static final String OAUTH_USER = "OAuthUser"; public static final String USER = "user"; + public static final String ADMIN = "admin"; } diff --git a/backend/src/main/java/io/f1/backend/domain/user/dto/SignupRequest.java b/backend/src/main/java/io/f1/backend/domain/user/dto/SignupRequest.java new file mode 100644 index 00000000..95436b65 --- /dev/null +++ b/backend/src/main/java/io/f1/backend/domain/user/dto/SignupRequest.java @@ -0,0 +1,5 @@ +package io.f1.backend.domain.user.dto; + +import jakarta.validation.constraints.NotBlank; + +public record SignupRequest(@NotBlank(message = "닉네임을 입력하세요") String nickname) {} diff --git a/backend/src/main/java/io/f1/backend/domain/user/dto/SignupRequestDto.java b/backend/src/main/java/io/f1/backend/domain/user/dto/SignupRequestDto.java deleted file mode 100644 index efb34bc4..00000000 --- a/backend/src/main/java/io/f1/backend/domain/user/dto/SignupRequestDto.java +++ /dev/null @@ -1,5 +0,0 @@ -package io.f1.backend.domain.user.dto; - -import jakarta.validation.constraints.NotBlank; - -public record SignupRequestDto(@NotBlank(message = "닉네임을 입력하세요") String nickname) {} 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 new file mode 100644 index 00000000..afada0fd --- /dev/null +++ b/backend/src/main/java/io/f1/backend/domain/user/dto/SignupResponse.java @@ -0,0 +1,3 @@ +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/dto/SignupResponseDto.java b/backend/src/main/java/io/f1/backend/domain/user/dto/SignupResponseDto.java deleted file mode 100644 index 96ebbec2..00000000 --- a/backend/src/main/java/io/f1/backend/domain/user/dto/SignupResponseDto.java +++ /dev/null @@ -1,3 +0,0 @@ -package io.f1.backend.domain.user.dto; - -public record SignupResponseDto(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 index 1fd129a8..be828638 100644 --- 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 @@ -1,13 +1,13 @@ package io.f1.backend.domain.user.mapper; -import io.f1.backend.domain.user.dto.SignupResponseDto; +import io.f1.backend.domain.user.dto.SignupResponse; import io.f1.backend.domain.user.entity.User; public class UserMapper { private UserMapper() {} - public static SignupResponseDto toSignupResponse(User user) { - return new SignupResponseDto(user.getId(), user.getNickname()); + 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 new file mode 100644 index 00000000..2b3a4573 --- /dev/null +++ b/backend/src/main/java/io/f1/backend/global/config/CorsConfig.java @@ -0,0 +1,24 @@ +package io.f1.backend.global.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +@Configuration +public class CorsConfig { + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration config = new CorsConfiguration(); + + config.addAllowedOriginPattern("*"); + config.addAllowedHeader("*"); + config.addAllowedMethod("*"); + config.setAllowCredentials(true); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", config); + return source; + } +} 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 ebcfc42e..26a726da 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 @@ -1,14 +1,17 @@ package io.f1.backend.global.config; +import io.f1.backend.domain.admin.app.handler.AdminLoginFailureHandler; +import io.f1.backend.domain.admin.app.handler.AdminLoginSuccessHandler; import io.f1.backend.domain.user.app.CustomOAuthUserService; import io.f1.backend.domain.user.app.handler.CustomAuthenticationEntryPoint; -import io.f1.backend.domain.user.app.handler.OAuthLogoutSuccessHandler; import io.f1.backend.domain.user.app.handler.OAuthSuccessHandler; +import io.f1.backend.domain.user.app.handler.UserAndAdminLogoutSuccessHandler; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; @@ -22,11 +25,14 @@ public class SecurityConfig { private final CustomAuthenticationEntryPoint customAuthenticationEntryPoint; private final CustomOAuthUserService customOAuthUserService; private final OAuthSuccessHandler oAuthSuccessHandler; - private final OAuthLogoutSuccessHandler oAuthLogoutSuccessHandler; + private final UserAndAdminLogoutSuccessHandler userAndAdminLogoutSuccessHandler; + private final AdminLoginSuccessHandler adminLoginSuccessHandler; + private final AdminLoginFailureHandler adminLoginFailureHandler; @Bean public SecurityFilterChain userFilterChain(HttpSecurity http) throws Exception { http.csrf(AbstractHttpConfigurer::disable) + .cors(Customizer.withDefaults()) .exceptionHandling( exception -> exception.authenticationEntryPoint(customAuthenticationEntryPoint)) @@ -38,13 +44,25 @@ public SecurityFilterChain userFilterChain(HttpSecurity http) throws Exception { "/oauth2/**", "/signup", "/css/**", - "/js/**") + "/js/**", + "/admin/login") .permitAll() .requestMatchers("/ws/**") .authenticated() + .requestMatchers("/user/me") + .hasRole("USER") + .requestMatchers("/admin/**") + .hasRole("ADMIN") + .requestMatchers("/auth/me") + .hasAnyRole("USER", "ADMIN") .anyRequest() .authenticated()) - .formLogin(AbstractHttpConfigurer::disable) + .formLogin( + form -> + form.loginProcessingUrl("/admin/login") // 로그인 form action 경로 + .successHandler(adminLoginSuccessHandler) + .failureHandler(adminLoginFailureHandler) + .permitAll()) .oauth2Login( oauth2 -> oauth2.userInfoEndpoint( @@ -55,7 +73,7 @@ public SecurityFilterChain userFilterChain(HttpSecurity http) throws Exception { .logout( logout -> logout.logoutUrl("/logout") - .logoutSuccessHandler(oAuthLogoutSuccessHandler) + .logoutSuccessHandler(userAndAdminLogoutSuccessHandler) .clearAuthentication(true) .invalidateHttpSession(true) .permitAll()) diff --git a/backend/src/main/java/io/f1/backend/global/config/StompChannelInterceptor.java b/backend/src/main/java/io/f1/backend/global/config/StompChannelInterceptor.java index 4a89c04c..dea375a8 100644 --- a/backend/src/main/java/io/f1/backend/global/config/StompChannelInterceptor.java +++ b/backend/src/main/java/io/f1/backend/global/config/StompChannelInterceptor.java @@ -27,20 +27,16 @@ public Message preSend(Message message, MessageChannel channel) { 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); - } + if (command.equals(StompCommand.CONNECT)) { + log.info("CONNECT : 세션 연결 - sessionId = {}", sessionId); + } else if (command.equals(StompCommand.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); + } else if (command.equals(StompCommand.SEND)) { + log.info("SEND : 요청 destination = {}", destination); + } else if (command.equals(StompCommand.DISCONNECT)) { + log.info("DISCONNECT : 연결 해제 sessionId = {}", sessionId); } return message; 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 8d093eec..a80ae64c 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 @@ -1,6 +1,8 @@ package io.f1.backend.global.config; +import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.web.filter.HiddenHttpMethodFilter; import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; @@ -14,4 +16,9 @@ public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler("/images/thumbnail/**") .addResourceLocations("file:images/thumbnail/"); } + + @Bean + public HiddenHttpMethodFilter hiddenHttpMethodFilter() { + return new HiddenHttpMethodFilter(); + } } diff --git a/backend/src/main/java/io/f1/backend/global/exception/CustomException.java b/backend/src/main/java/io/f1/backend/global/exception/CustomException.java index 2cd6202f..a2a14cdd 100644 --- a/backend/src/main/java/io/f1/backend/global/exception/CustomException.java +++ b/backend/src/main/java/io/f1/backend/global/exception/CustomException.java @@ -1,8 +1,17 @@ package io.f1.backend.global.exception; +import io.f1.backend.global.exception.errorcode.ErrorCode; + public class CustomException extends RuntimeException { - public CustomException(String message) { - super(message); + private final ErrorCode errorCode; + + public CustomException(ErrorCode errorCode) { + super(errorCode.getMessage()); + this.errorCode = errorCode; + } + + public ErrorCode getErrorCode() { + return errorCode; } } diff --git a/backend/src/main/java/io/f1/backend/global/exception/errorcode/AuthErrorCode.java b/backend/src/main/java/io/f1/backend/global/exception/errorcode/AuthErrorCode.java new file mode 100644 index 00000000..54ffb652 --- /dev/null +++ b/backend/src/main/java/io/f1/backend/global/exception/errorcode/AuthErrorCode.java @@ -0,0 +1,24 @@ +package io.f1.backend.global.exception.errorcode; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum AuthErrorCode implements ErrorCode { + UNAUTHORIZED("E401001", HttpStatus.UNAUTHORIZED, "로그인이 필요합니다."), + AUTH_SESSION_NOT_FOUND("E401002", HttpStatus.UNAUTHORIZED, "세션이 존재하지 않습니다. 로그인 후 이용해주세요."), + AUTH_SESSION_EXPIRED("E401003", HttpStatus.UNAUTHORIZED, "세션이 만료되었습니다. 다시 로그인해주세요."), + AUTH_SESSION_LOST("E401004", HttpStatus.UNAUTHORIZED, "세션 정보가 유실되었습니다. 다시 로그인해주세요."), + FORBIDDEN("E403001", HttpStatus.FORBIDDEN, "권한이 없습니다."), + + LOGIN_FAILED("E401005", HttpStatus.UNAUTHORIZED, "아이디 또는 비밀번호가 일치하지 않습니다."); + + 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/CommonErrorCode.java b/backend/src/main/java/io/f1/backend/global/exception/errorcode/CommonErrorCode.java new file mode 100644 index 00000000..a998d9c5 --- /dev/null +++ b/backend/src/main/java/io/f1/backend/global/exception/errorcode/CommonErrorCode.java @@ -0,0 +1,22 @@ +package io.f1.backend.global.exception.errorcode; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum CommonErrorCode implements ErrorCode { + BAD_REQUEST_DATA("E400001", HttpStatus.BAD_REQUEST, "잘못된 요청 데이터입니다."), + INVALID_PAGINATION("E400006", HttpStatus.BAD_REQUEST, "page와 size는 1 이상의 정수여야 합니다."), + INTERNAL_SERVER_ERROR( + "E500001", HttpStatus.INTERNAL_SERVER_ERROR, "서버에러가 발생했습니다. 관리자에게 문의해주세요."), + INVALID_JSON_FORMAT("E400008", HttpStatus.BAD_REQUEST, "요청 형식이 올바르지 않습니다. JSON 문법을 확인해주세요."); + + 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/ErrorCode.java b/backend/src/main/java/io/f1/backend/global/exception/errorcode/ErrorCode.java new file mode 100644 index 00000000..a77eaa2d --- /dev/null +++ b/backend/src/main/java/io/f1/backend/global/exception/errorcode/ErrorCode.java @@ -0,0 +1,12 @@ +package io.f1.backend.global.exception.errorcode; + +import org.springframework.http.HttpStatus; + +public interface ErrorCode { + + String getCode(); + + HttpStatus getHttpStatus(); + + String getMessage(); +} 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 new file mode 100644 index 00000000..9b7c0c1e --- /dev/null +++ b/backend/src/main/java/io/f1/backend/global/exception/errorcode/QuestionErrorCode.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 QuestionErrorCode implements ErrorCode { + QUESTION_NOT_FOUND("E404003", 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/QuizErrorCode.java b/backend/src/main/java/io/f1/backend/global/exception/errorcode/QuizErrorCode.java new file mode 100644 index 00000000..e03983b1 --- /dev/null +++ b/backend/src/main/java/io/f1/backend/global/exception/errorcode/QuizErrorCode.java @@ -0,0 +1,21 @@ +package io.f1.backend.global.exception.errorcode; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum QuizErrorCode implements ErrorCode { + FILE_SIZE_TOO_LARGE("E400005", HttpStatus.BAD_REQUEST, "파일 크기가 너무 큽니다."), + UNSUPPORTED_MEDIA_TYPE("E415001", HttpStatus.UNSUPPORTED_MEDIA_TYPE, "지원하지 않는 파일 형식입니다."), + INVALID_FILTER("E400007", HttpStatus.BAD_REQUEST, "title 또는 creator 중 하나만 입력 가능합니다."), + QUIZ_NOT_FOUND("E404002", 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/RoomErrorCode.java b/backend/src/main/java/io/f1/backend/global/exception/errorcode/RoomErrorCode.java new file mode 100644 index 00000000..0626fab0 --- /dev/null +++ b/backend/src/main/java/io/f1/backend/global/exception/errorcode/RoomErrorCode.java @@ -0,0 +1,22 @@ +package io.f1.backend.global.exception.errorcode; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum RoomErrorCode implements ErrorCode { + ROOM_USER_LIMIT_REACHED("E403002", HttpStatus.FORBIDDEN, "정원이 모두 찼습니다."), + ROOM_GAME_IN_PROGRESS("E403003", HttpStatus.FORBIDDEN, "게임이 진행 중 입니다."), + ROOM_NOT_FOUND("E404005", HttpStatus.NOT_FOUND, "존재하지 않는 방입니다."), + WRONG_PASSWORD("E401006", HttpStatus.UNAUTHORIZED, "비밀번호가 일치하지않습니다."), + SOCKET_SESSION_NOT_FOUND("E404006", 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/UserErrorCode.java b/backend/src/main/java/io/f1/backend/global/exception/errorcode/UserErrorCode.java new file mode 100644 index 00000000..a53f35a4 --- /dev/null +++ b/backend/src/main/java/io/f1/backend/global/exception/errorcode/UserErrorCode.java @@ -0,0 +1,22 @@ +package io.f1.backend.global.exception.errorcode; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum UserErrorCode implements ErrorCode { + NICKNAME_EMPTY("E400002", HttpStatus.BAD_REQUEST, "닉네임은 필수 입력입니다."), + NICKNAME_TOO_LONG("E400003", HttpStatus.BAD_REQUEST, "닉네임은 6글자 이하로 입력해야 합니다."), + NICKNAME_NOT_ALLOWED("E400004", HttpStatus.BAD_REQUEST, "한글, 영문, 숫자만 입력해주세요."), + NICKNAME_CONFLICT("E409001", HttpStatus.CONFLICT, "중복된 닉네임입니다."), + USER_NOT_FOUND("E404001", 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/handler/GlobalExceptionHandler.java b/backend/src/main/java/io/f1/backend/global/exception/handler/GlobalExceptionHandler.java new file mode 100644 index 00000000..c5da75e0 --- /dev/null +++ b/backend/src/main/java/io/f1/backend/global/exception/handler/GlobalExceptionHandler.java @@ -0,0 +1,66 @@ +package io.f1.backend.global.exception.handler; + +import io.f1.backend.global.exception.CustomException; +import io.f1.backend.global.exception.errorcode.CommonErrorCode; +import io.f1.backend.global.exception.errorcode.ErrorCode; +import io.f1.backend.global.exception.response.ErrorResponse; + +import lombok.extern.slf4j.Slf4j; + +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@Slf4j +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(CustomException.class) + public ResponseEntity handleCustomException(CustomException e) { + log.warn(e.getMessage()); + ErrorCode errorCode = e.getErrorCode(); + + ErrorResponse response = new ErrorResponse(errorCode.getCode(), errorCode.getMessage()); + return new ResponseEntity<>(response, errorCode.getHttpStatus()); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity handleException(Exception e) { + log.warn("handleException: {}", e.getMessage()); + CommonErrorCode errorCode = CommonErrorCode.INTERNAL_SERVER_ERROR; + + ErrorResponse response = new ErrorResponse(errorCode.getCode(), errorCode.getMessage()); + return new ResponseEntity<>(response, errorCode.getHttpStatus()); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleMethodArgumentNotValidException( + MethodArgumentNotValidException e) { + log.warn("MethodArgumentNotValidException: {}", e.getMessage()); + CommonErrorCode code = CommonErrorCode.BAD_REQUEST_DATA; + + String message = + e.getBindingResult().getFieldErrors().stream() + .map(FieldError::getDefaultMessage) + .findFirst() + .orElse(code.getMessage()); + + ErrorResponse response = new ErrorResponse(code.getCode(), message); + + return new ResponseEntity<>(response, code.getHttpStatus()); + } + + @ExceptionHandler(HttpMessageNotReadableException.class) + public ResponseEntity handleHttpMessageNotReadableException( + HttpMessageNotReadableException e) { + log.warn("HttpMessageNotReadableException: {}", e.getMessage()); + CommonErrorCode code = CommonErrorCode.INVALID_JSON_FORMAT; + + ErrorResponse response = new ErrorResponse(code.getCode(), code.getMessage()); + + return new ResponseEntity<>(response, code.getHttpStatus()); + } +} diff --git a/backend/src/main/java/io/f1/backend/global/exception/response/ErrorResponse.java b/backend/src/main/java/io/f1/backend/global/exception/response/ErrorResponse.java new file mode 100644 index 00000000..42f423c0 --- /dev/null +++ b/backend/src/main/java/io/f1/backend/global/exception/response/ErrorResponse.java @@ -0,0 +1,12 @@ +package io.f1.backend.global.exception.response; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public class ErrorResponse { + + private final String code; + private final String message; +} 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 5915e5d2..2735545a 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 @@ -1,8 +1,11 @@ package io.f1.backend.global.util; +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 jakarta.servlet.http.HttpSession; + import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; @@ -37,4 +40,28 @@ public static Long getCurrentUserId() { public static String getCurrentUserNickname() { return getCurrentUserPrincipal().getUserNickname(); } + + public static void logout(HttpSession session) { + if (session != null) { + session.invalidate(); + } + clearAuthentication(); + } + + private static void clearAuthentication() { + SecurityContextHolder.clearContext(); + } + + public static AdminPrincipal getCurrentAdminPrincipal() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication != null + && authentication.getPrincipal() instanceof AdminPrincipal adminPrincipal) { + return adminPrincipal; + } + throw new RuntimeException("E401001: 로그인이 필요합니다."); + } + + public static Authentication getAuthentication() { + return SecurityContextHolder.getContext().getAuthentication(); + } } diff --git a/backend/src/main/java/io/f1/backend/global/validation/TrimmedSize.java b/backend/src/main/java/io/f1/backend/global/validation/TrimmedSize.java new file mode 100644 index 00000000..9b0d9fa9 --- /dev/null +++ b/backend/src/main/java/io/f1/backend/global/validation/TrimmedSize.java @@ -0,0 +1,23 @@ +package io.f1.backend.global.validation; + +import jakarta.validation.Constraint; +import jakarta.validation.Payload; + +import java.lang.annotation.*; + +@Documented +@Constraint(validatedBy = TrimmedSizeValidator.class) +@Target({ElementType.FIELD, ElementType.PARAMETER}) +@Retention(RetentionPolicy.RUNTIME) +public @interface TrimmedSize { + + String message() default "공백 제외 길이가 {min}자 이상 {min}자 이하여야 합니다."; + + int min() default 0; + + int max() default 50; + + Class[] groups() default {}; + + Class[] payload() default {}; +} diff --git a/backend/src/main/java/io/f1/backend/global/validation/TrimmedSizeValidator.java b/backend/src/main/java/io/f1/backend/global/validation/TrimmedSizeValidator.java new file mode 100644 index 00000000..f3c32e99 --- /dev/null +++ b/backend/src/main/java/io/f1/backend/global/validation/TrimmedSizeValidator.java @@ -0,0 +1,26 @@ +package io.f1.backend.global.validation; + +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; + +public class TrimmedSizeValidator implements ConstraintValidator { + + private int min; + private int max; + + @Override + public void initialize(TrimmedSize constraintAnnotation) { + this.min = constraintAnnotation.min(); + this.max = constraintAnnotation.max(); + } + + @Override + public boolean isValid(String value, ConstraintValidatorContext context) { + if (value == null) return true; + + String trimmed = value.trim(); + int length = trimmed.length(); + + return length >= min && length <= max; + } +} 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 new file mode 100644 index 00000000..2c9df079 --- /dev/null +++ b/backend/src/test/java/io/f1/backend/domain/game/app/RoomServiceTests.java @@ -0,0 +1,190 @@ +package io.f1.backend.domain.game.app; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.when; + +import io.f1.backend.domain.game.dto.request.RoomValidationRequest; +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.store.RoomRepository; +import io.f1.backend.domain.quiz.app.QuizService; +import io.f1.backend.domain.user.entity.User; +import io.f1.backend.global.util.SecurityUtils; + +import lombok.extern.slf4j.Slf4j; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.security.core.context.SecurityContextHolder; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +@Slf4j +@ExtendWith(MockitoExtension.class) +class RoomServiceTests { + + private RoomService roomService; + + @Mock private RoomRepository roomRepository; + @Mock private QuizService quizService; + @Mock private ApplicationEventPublisher eventPublisher; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); // @Mock 어노테이션이 붙은 필드들을 초기화합니다. + roomService = new RoomService(quizService, roomRepository, eventPublisher); + + SecurityContextHolder.clearContext(); + } + + @AfterEach + void afterEach() { + SecurityContextHolder.clearContext(); + } + + @Test + @DisplayName("enterRoom_동시성_테스트") + void enterRoom_synchronized() throws Exception { + Long roomId = 1L; + Long quizId = 1L; + Long playerId = 1L; + int maxUserCount = 5; + String password = "123"; + boolean locked = true; + + Room room = createRoom(roomId, playerId, quizId, password, maxUserCount, locked); + + when(roomRepository.findRoom(roomId)).thenReturn(Optional.of(room)); + + int threadCount = 10; + ExecutorService executorService = Executors.newFixedThreadPool(threadCount); + CountDownLatch countDownLatch = new CountDownLatch(threadCount); + RoomValidationRequest roomValidationRequest = new RoomValidationRequest(roomId, password); + for (int i = 1; i <= threadCount; i++) { + User user = createUser(i); + + executorService.submit( + () -> { + try { + SecurityUtils.setAuthentication(user); + roomService.enterRoom(roomValidationRequest); + } catch (Exception e) { + e.printStackTrace(); + } finally { + SecurityContextHolder.clearContext(); + countDownLatch.countDown(); + } + }); + } + countDownLatch.await(); + assertThat(room.getUserIdSessionMap()).hasSize(room.getRoomSetting().maxUserCount()); + } + + @Test + @DisplayName("exitRoom_동시성_테스트") + void exitRoom_synchronized() throws Exception { + Long roomId = 1L; + Long quizId = 1L; + Long playerId = 1L; + int maxUserCount = 5; + String password = "123"; + boolean locked = true; + + Room room = createRoom(roomId, playerId, quizId, password, maxUserCount, locked); + + int threadCount = 10; + + List players = new ArrayList<>(); + for (int i = 1; i <= threadCount; i++) { + Long id = i + 1L; + String nickname = "nickname " + i; + + Player player = new Player(id, nickname); + players.add(player); + } + Player host = players.getFirst(); + room.updateHost(host); + + for (int i = 1; i <= threadCount; i++) { + String sessionId = "sessionId" + i; + Player player = players.get(i - 1); + room.getPlayerSessionMap().put(sessionId, player); + room.getUserIdSessionMap().put(player.getId(), sessionId); + } + + log.info("room.getPlayerSessionMap().size() = {}", room.getPlayerSessionMap().size()); + + when(roomRepository.findRoom(roomId)).thenReturn(Optional.of(room)); + doNothing().when(roomRepository).removeRoom(roomId); + + ExecutorService executorService = Executors.newFixedThreadPool(threadCount); + CountDownLatch countDownLatch = new CountDownLatch(threadCount); + + for (int i = 1; i <= threadCount; i++) { + String sessionId = "sessionId" + i; + User user = createUser(i); + executorService.submit( + () -> { + try { + SecurityUtils.setAuthentication(user); + log.info("room.getHost().getId() = {}", room.getHost().getId()); + roomService.exitRoom(roomId, sessionId); + } catch (Exception e) { + e.printStackTrace(); + } finally { + SecurityContextHolder.clearContext(); + countDownLatch.countDown(); + } + }); + } + countDownLatch.await(); + assertThat(room.getUserIdSessionMap()).hasSize(1); + } + + private Room createRoom( + Long roomId, + Long playerId, + Long quizId, + String password, + int maxUserCount, + boolean locked) { + RoomSetting roomSetting = new RoomSetting("방제목", maxUserCount, locked, password); + GameSetting gameSetting = new GameSetting(quizId, 10, 60); + Player host = new Player(playerId, "nickname"); + + return new Room(roomId, roomSetting, gameSetting, host); + } + + private User createUser(int i) { + Long userId = i + 1L; + String provider = "provider +" + i; + String providerId = "providerId" + i; + LocalDateTime lastLogin = LocalDateTime.now(); + + User user = + User.builder() + .provider(provider) + .providerId(providerId) + .lastLogin(lastLogin) + .build(); + user.setId(userId); + + return user; + } +}