diff --git a/src/main/java/com/back/domain/studyroom/controller/RoomController.java b/src/main/java/com/back/domain/studyroom/controller/RoomController.java index 6d43d576..50a5b01e 100644 --- a/src/main/java/com/back/domain/studyroom/controller/RoomController.java +++ b/src/main/java/com/back/domain/studyroom/controller/RoomController.java @@ -65,7 +65,7 @@ public ResponseEntity> createRoom( request.getMaxParticipants() != null ? request.getMaxParticipants() : 10, currentUserId, request.getUseWebRTC() != null ? request.getUseWebRTC() : true, // 디폴트: true - request.getThumbnailUrl() // 썸네일 URL + request.getThumbnailAttachmentId() // 썸네일 Attachment ID ); RoomResponse response = roomService.toRoomResponse(room); @@ -336,7 +336,7 @@ public ResponseEntity>> getMyRooms() { @PutMapping("/{roomId}") @Operation( summary = "방 설정 수정", - description = "방의 제목, 설명, 정원, 썸네일을 수정합니다. 방장만 수정 가능합니다. WebRTC 설정은 현재 수정 불가합니다." + description = "방의 제목, 설명, 정원, 썸네일을 수정합니다.thumbnailAttachmentId가 null이면 썸네일 변경 없이 기존 유지됩니다. 방장만 수정 가능합니다. WebRTC 설정은 현재 수정 불가합니다." ) @ApiResponses({ @ApiResponse(responseCode = "200", description = "수정 성공"), @@ -356,7 +356,7 @@ public ResponseEntity> updateRoom( request.getTitle(), request.getDescription(), request.getMaxParticipants(), - request.getThumbnailUrl(), + request.getThumbnailAttachmentId(), // 썸네일 Attachment ID currentUserId ); diff --git a/src/main/java/com/back/domain/studyroom/dto/CreateRoomRequest.java b/src/main/java/com/back/domain/studyroom/dto/CreateRoomRequest.java index 8c1adaeb..201767ef 100644 --- a/src/main/java/com/back/domain/studyroom/dto/CreateRoomRequest.java +++ b/src/main/java/com/back/domain/studyroom/dto/CreateRoomRequest.java @@ -16,9 +16,12 @@ public class CreateRoomRequest { @Size(max = 500, message = "방 설명은 500자를 초과할 수 없습니다") private String description; - // 방 썸네일 이미지 URL (선택) - @Size(max = 500, message = "썸네일 URL은 500자를 초과할 수 없습니다") - private String thumbnailUrl; + // 방 썸네일 FileAttachment ID (선택) + // 사용 방법: + // 1. POST /api/files/upload로 파일 업로드 + // 2. 받은 fileId를 여기에 설정 + // 3. 방 생성 시 AttachmentMapping 자동 생성 + private Long thumbnailAttachmentId; private Boolean isPrivate = false; diff --git a/src/main/java/com/back/domain/studyroom/dto/UpdateRoomSettingsRequest.java b/src/main/java/com/back/domain/studyroom/dto/UpdateRoomSettingsRequest.java index 974ce652..7d1e6f09 100644 --- a/src/main/java/com/back/domain/studyroom/dto/UpdateRoomSettingsRequest.java +++ b/src/main/java/com/back/domain/studyroom/dto/UpdateRoomSettingsRequest.java @@ -20,9 +20,13 @@ public class UpdateRoomSettingsRequest { @Max(value = 100, message = "최대 100명까지 가능합니다") private Integer maxParticipants; - // 방 썸네일 이미지 URL (선택) - @Size(max = 500, message = "썸네일 URL은 500자를 초과할 수 없습니다") - private String thumbnailUrl; + // 방 썸네일 FileAttachment ID (선택) + // 사용 방법: + // 1. POST /api/files/upload로 새 파일 업로드 + // 2. 받은 fileId를 여기에 설정 + // 3. 방 수정 시 기존 매핑 삭제 + 새 매핑 생성 + // null인 경우: 썸네일 변경 없음 (기존 유지) + private Long thumbnailAttachmentId; // ===== WebRTC 설정 (추후 팀원 구현 시 주석 해제) ===== // WebRTC 기능은 방 생성 이후 별도 API로 관리 예정 diff --git a/src/main/java/com/back/domain/studyroom/service/RoomService.java b/src/main/java/com/back/domain/studyroom/service/RoomService.java index 460b64e7..1b44228c 100644 --- a/src/main/java/com/back/domain/studyroom/service/RoomService.java +++ b/src/main/java/com/back/domain/studyroom/service/RoomService.java @@ -52,6 +52,7 @@ public class RoomService { private final SimpMessagingTemplate messagingTemplate; private final ApplicationEventPublisher eventPublisher; private final AvatarService avatarService; + private final RoomThumbnailService roomThumbnailService; /** * 방 생성 메서드 @@ -68,21 +69,32 @@ public class RoomService { */ @Transactional public Room createRoom(String title, String description, boolean isPrivate, - String password, int maxParticipants, Long creatorId, boolean useWebRTC, String thumbnailUrl) { + String password, int maxParticipants, Long creatorId, boolean useWebRTC, Long thumbnailAttachmentId) { User creator = userRepository.findById(creatorId) .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); - Room room = Room.create(title, description, isPrivate, password, maxParticipants, creator, null, useWebRTC, thumbnailUrl); + // 방 생성 (썸네일 URL은 null로 시작) + Room room = Room.create(title, description, isPrivate, password, + maxParticipants, creator, null, useWebRTC, null); Room savedRoom = roomRepository.save(room); + // 썸네일 매핑 생성 및 URL 업데이트 + if (thumbnailAttachmentId != null) { + String thumbnailUrl = roomThumbnailService.createThumbnailMapping( + savedRoom.getId(), thumbnailAttachmentId); + savedRoom.updateSettings(title, description, maxParticipants, thumbnailUrl); + } + + // 방장 등록 RoomMember hostMember = RoomMember.createHost(savedRoom, creator); roomMemberRepository.save(hostMember); // savedRoom.incrementParticipant(); // Redis로 이관 - DB 업데이트 제거 - log.info("방 생성 완료 - RoomId: {}, Title: {}, CreatorId: {}, WebRTC: {}, Thumbnail: {}", - savedRoom.getId(), title, creatorId, useWebRTC, thumbnailUrl != null ? "설정됨" : "없음"); + log.info("방 생성 완료 - RoomId: {}, Title: {}, CreatorId: {}, WebRTC: {}, ThumbnailId: {}", + savedRoom.getId(), title, creatorId, useWebRTC, + thumbnailAttachmentId != null ? thumbnailAttachmentId : "없음"); return savedRoom; } @@ -257,9 +269,6 @@ public Room getRoomDetail(Long roomId, Long userId) { Room room = roomRepository.findById(roomId) .orElseThrow(() -> new CustomException(ErrorCode.ROOM_NOT_FOUND)); - // ⭐ 비공개 방 접근 제한 제거 - 모든 사용자가 조회 가능 - // (프론트엔드에서 입장 시 로그인 체크) - return room; } @@ -269,7 +278,7 @@ public List getUserRooms(Long userId) { @Transactional public void updateRoomSettings(Long roomId, String title, String description, - int maxParticipants, String thumbnailUrl, Long userId) { + int maxParticipants, Long thumbnailAttachmentId, Long userId) { Room room = roomRepository.findById(roomId) .orElseThrow(() -> new CustomException(ErrorCode.ROOM_NOT_FOUND)); @@ -285,10 +294,18 @@ public void updateRoomSettings(Long roomId, String title, String description, throw new CustomException(ErrorCode.BAD_REQUEST); } + // 썸네일 변경 처리 + String thumbnailUrl = room.getRawThumbnailUrl(); // 기존 URL 유지 + if (thumbnailAttachmentId != null) { + // 기존 매핑 삭제 + 새 매핑 생성 + thumbnailUrl = roomThumbnailService.updateThumbnailMapping( + roomId, thumbnailAttachmentId); + } + room.updateSettings(title, description, maxParticipants, thumbnailUrl); - log.info("방 설정 변경 완료 - RoomId: {}, UserId: {}, Thumbnail: {}", - roomId, userId, thumbnailUrl != null ? "변경됨" : "없음"); + log.info("방 설정 변경 완료 - RoomId: {}, UserId: {}, ThumbnailId: {}", + roomId, userId, thumbnailAttachmentId != null ? thumbnailAttachmentId : "변경 없음"); } /** @@ -353,6 +370,9 @@ public void terminateRoom(Long roomId, Long userId) { throw new CustomException(ErrorCode.NOT_ROOM_MANAGER); } + // 썸네일 매핑 삭제 + roomThumbnailService.deleteThumbnailMapping(roomId); + room.terminate(); // Redis에서 모든 온라인 사용자 제거 @@ -530,8 +550,6 @@ public List getRoomMembers(Long roomId, Long userId) { Room room = roomRepository.findById(roomId) .orElseThrow(() -> new CustomException(ErrorCode.ROOM_NOT_FOUND)); - // ⭐ 비공개 방 접근 제한 제거 - 모든 사용자가 조회 가능 - // 1. Redis에서 온라인 사용자 ID 조회 Set onlineUserIds = roomParticipantService.getParticipants(roomId); diff --git a/src/main/java/com/back/domain/studyroom/service/RoomThumbnailService.java b/src/main/java/com/back/domain/studyroom/service/RoomThumbnailService.java new file mode 100644 index 00000000..0319670f --- /dev/null +++ b/src/main/java/com/back/domain/studyroom/service/RoomThumbnailService.java @@ -0,0 +1,138 @@ +package com.back.domain.studyroom.service; + +import com.back.domain.file.entity.AttachmentMapping; +import com.back.domain.file.entity.EntityType; +import com.back.domain.file.entity.FileAttachment; +import com.back.domain.file.repository.AttachmentMappingRepository; +import com.back.domain.file.repository.FileAttachmentRepository; +import com.back.global.exception.CustomException; +import com.back.global.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +/** + * 스터디룸 썸네일 전용 서비스 + * 매핑 전략에 따라 AttachmentMapping을 통해 관리 + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class RoomThumbnailService { + + private final FileAttachmentRepository fileAttachmentRepository; + private final AttachmentMappingRepository attachmentMappingRepository; + + /** + * 방 생성 시 썸네일 매핑 생성 + * + * @param roomId 방 ID + * @param thumbnailAttachmentId 썸네일 파일 ID + * @return 썸네일 URL + */ + @Transactional + public String createThumbnailMapping(Long roomId, Long thumbnailAttachmentId) { + if (thumbnailAttachmentId == null) { + return null; + } + + // FileAttachment 조회 + FileAttachment fileAttachment = fileAttachmentRepository.findById(thumbnailAttachmentId) + .orElseThrow(() -> new CustomException(ErrorCode.FILE_NOT_FOUND)); + + // AttachmentMapping 생성 + AttachmentMapping mapping = new AttachmentMapping( + fileAttachment, + EntityType.STUDY_ROOM, + roomId + ); + + attachmentMappingRepository.save(mapping); + + log.info("썸네일 매핑 생성 - RoomId: {}, AttachmentId: {}, URL: {}", + roomId, thumbnailAttachmentId, fileAttachment.getPublicURL()); + + return fileAttachment.getPublicURL(); + } + + /** + * 방 수정 시 썸네일 변경 + * 1. 기존 매핑 삭제 + * 2. 새 매핑 생성 + * + * @param roomId 방 ID + * @param newThumbnailAttachmentId 새 썸네일 파일 ID (null이면 변경 없음) + * @return 새 썸네일 URL (null이면 변경 없음) + */ + @Transactional + public String updateThumbnailMapping(Long roomId, Long newThumbnailAttachmentId) { + if (newThumbnailAttachmentId == null) { + // null이면 썸네일 변경 없음 + return null; + } + + // 기존 매핑 모두 삭제 + attachmentMappingRepository.deleteAllByEntityTypeAndEntityId( + EntityType.STUDY_ROOM, roomId); + + log.info("기존 썸네일 매핑 삭제 - RoomId: {}", roomId); + + // 새 매핑 생성 + return createThumbnailMapping(roomId, newThumbnailAttachmentId); + } + + /** + * 방 삭제 시 썸네일 매핑 삭제 + * + * @param roomId 방 ID + */ + @Transactional + public void deleteThumbnailMapping(Long roomId) { + // 연결된 파일 매핑 모두 삭제 + attachmentMappingRepository.deleteAllByEntityTypeAndEntityId( + EntityType.STUDY_ROOM, roomId); + + log.info("방 삭제 - 썸네일 매핑 삭제 완료 - RoomId: {}", roomId); + } + + /** + * 방의 썸네일 URL 조회 + * + * @param roomId 방 ID + * @return 썸네일 URL (없으면 null) + */ + @Transactional(readOnly = true) + public String getThumbnailUrl(Long roomId) { + List mappings = attachmentMappingRepository + .findAllByEntityTypeAndEntityId(EntityType.STUDY_ROOM, roomId); + + if (mappings.isEmpty()) { + return null; + } + + // 첫 번째 매핑의 URL 반환 (썸네일은 단일 파일) + return mappings.get(0).getFileAttachment().getPublicURL(); + } + + /** + * 방의 썸네일 FileAttachment 조회 + * + * @param roomId 방 ID + * @return FileAttachment (없으면 null) + */ + @Transactional(readOnly = true) + public FileAttachment getThumbnailAttachment(Long roomId) { + List mappings = attachmentMappingRepository + .findAllByEntityTypeAndEntityId(EntityType.STUDY_ROOM, roomId); + + if (mappings.isEmpty()) { + return null; + } + + // 첫 번째 매핑의 FileAttachment 반환 (썸네일은 단일 파일) + return mappings.get(0).getFileAttachment(); + } +} diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 9d428429..fece4404 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -73,7 +73,6 @@ spring: token-uri: https://github.com/login/oauth/access_token user-info-uri: https://api.github.com/user user-name-attribute: id - servlet: multipart: max-file-size: 10MB # 업로드할 수 있는 개별 파일의 최대 크기 diff --git a/src/test/java/com/back/domain/studyroom/controller/RoomControllerTest.java b/src/test/java/com/back/domain/studyroom/controller/RoomControllerTest.java index edb98059..ae52d9c0 100644 --- a/src/test/java/com/back/domain/studyroom/controller/RoomControllerTest.java +++ b/src/test/java/com/back/domain/studyroom/controller/RoomControllerTest.java @@ -94,7 +94,7 @@ void createRoom() { CreateRoomRequest request = new CreateRoomRequest( "테스트 방", "테스트 설명", - null, // thumbnailUrl + null, // thumbnailAttachmentId false, null, 10, @@ -109,7 +109,7 @@ void createRoom() { anyInt(), eq(1L), anyBoolean(), // useWebRTC - any() // thumbnailUrl + any() // thumbnailAttachmentId )).willReturn(testRoom); RoomResponse roomResponse = RoomResponse.from(testRoom, 1); @@ -133,7 +133,7 @@ void createRoom() { anyInt(), eq(1L), anyBoolean(), // useWebRTC - any() // thumbnailUrl + any() // thumbnailAttachmentId ); verify(roomService, times(1)).toRoomResponse(any(Room.class)); } @@ -282,7 +282,7 @@ void updateRoom() { "변경된 제목", "변경된 설명", 15, - "https://example.com/new-thumbnail.jpg" // thumbnailUrl + 456L // thumbnailAttachmentId ); // when @@ -299,7 +299,7 @@ void updateRoom() { anyString(), anyString(), anyInt(), - anyString(), // thumbnailUrl + anyLong(), // thumbnailAttachmentId eq(1L) ); } @@ -386,7 +386,7 @@ void createRoom_WithWebRTC() { CreateRoomRequest request = new CreateRoomRequest( "WebRTC 방", "화상 채팅 가능", - "https://example.com/webrtc.jpg", // thumbnailUrl + 123L, // thumbnailAttachmentId false, null, 10, @@ -402,7 +402,7 @@ void createRoom_WithWebRTC() { testUser, null, true, // useWebRTC - "https://example.com/webrtc.jpg" // thumbnailUrl + "https://example.com/webrtc.jpg" // thumbnailUrl (서비스에서 변환됨) ); given(roomService.createRoom( @@ -413,7 +413,7 @@ void createRoom_WithWebRTC() { anyInt(), eq(1L), eq(true), // WebRTC true 검증 - anyString() // thumbnailUrl + anyLong() // thumbnailAttachmentId )).willReturn(webRTCRoom); RoomResponse roomResponse = RoomResponse.from(webRTCRoom, 1); @@ -437,7 +437,7 @@ void createRoom_WithWebRTC() { anyInt(), eq(1L), eq(true), // WebRTC - anyString() // thumbnailUrl + anyLong() // thumbnailAttachmentId ); } @@ -450,7 +450,7 @@ void createRoom_WithoutWebRTC() { CreateRoomRequest request = new CreateRoomRequest( "채팅 전용 방", "텍스트만 가능", - null, // thumbnailUrl 없음 + null, // thumbnailAttachmentId 없음 false, null, 50, @@ -477,7 +477,7 @@ void createRoom_WithoutWebRTC() { anyInt(), eq(1L), eq(false), // WebRTC false 검증 - any() // thumbnailUrl + any() // thumbnailAttachmentId )).willReturn(chatOnlyRoom); RoomResponse roomResponse = RoomResponse.from(chatOnlyRoom, 1); @@ -501,7 +501,7 @@ void createRoom_WithoutWebRTC() { anyInt(), eq(1L), eq(false), // WebRTC - any() // thumbnailUrl + any() // thumbnailAttachmentId ); } } diff --git a/src/test/java/com/back/domain/studyroom/service/RoomServiceTest.java b/src/test/java/com/back/domain/studyroom/service/RoomServiceTest.java index d9cf5b83..b787db7e 100644 --- a/src/test/java/com/back/domain/studyroom/service/RoomServiceTest.java +++ b/src/test/java/com/back/domain/studyroom/service/RoomServiceTest.java @@ -61,6 +61,9 @@ class RoomServiceTest { @Mock private AvatarService avatarService; + + @Mock + private RoomThumbnailService roomThumbnailService; @InjectMocks private RoomService roomService; @@ -120,7 +123,7 @@ void createRoom_Success() { 10, 1L, true, // useWebRTC - null // thumbnailUrl + null // thumbnailAttachmentId ); // then @@ -129,6 +132,7 @@ void createRoom_Success() { assertThat(createdRoom.getDescription()).isEqualTo("테스트 설명"); verify(roomRepository, times(1)).save(any(Room.class)); verify(roomMemberRepository, times(1)).save(any(RoomMember.class)); + verify(roomThumbnailService, never()).createThumbnailMapping(any(), any()); } @Test @@ -146,7 +150,7 @@ void createRoom_UserNotFound() { 10, 999L, true, // useWebRTC - null // thumbnailUrl + null // thumbnailAttachmentId )) .isInstanceOf(CustomException.class) .hasFieldOrPropertyWithValue("errorCode", ErrorCode.USER_NOT_FOUND); @@ -298,7 +302,7 @@ void updateRoomSettings_Success() { "변경된 제목", "변경된 설명", 15, - "https://example.com/new-thumbnail.jpg", // thumbnailUrl + null, // thumbnailAttachmentId (null이면 변경 없음) 1L ); @@ -306,7 +310,7 @@ void updateRoomSettings_Success() { assertThat(testRoom.getTitle()).isEqualTo("변경된 제목"); assertThat(testRoom.getDescription()).isEqualTo("변경된 설명"); assertThat(testRoom.getMaxParticipants()).isEqualTo(15); - assertThat(testRoom.getThumbnailUrl()).isEqualTo("https://example.com/new-thumbnail.jpg"); + verify(roomThumbnailService, never()).updateThumbnailMapping(any(), any()); } @Test @@ -341,6 +345,7 @@ void terminateRoom_Success() { // then assertThat(testRoom.getStatus()).isEqualTo(RoomStatus.TERMINATED); assertThat(testRoom.isActive()).isFalse(); + verify(roomThumbnailService, times(1)).deleteThumbnailMapping(1L); } @Test @@ -424,6 +429,8 @@ void createRoom_WithWebRTC() { given(userRepository.findById(1L)).willReturn(Optional.of(testUser)); given(roomRepository.save(any(Room.class))).willAnswer(invocation -> invocation.getArgument(0)); given(roomMemberRepository.save(any(RoomMember.class))).willReturn(testMember); + given(roomThumbnailService.createThumbnailMapping(any(), eq(123L))) + .willReturn("https://s3.amazonaws.com/bucket/thumbnail.jpg"); // when Room createdRoom = roomService.createRoom( @@ -434,7 +441,7 @@ void createRoom_WithWebRTC() { 10, 1L, true, // WebRTC 사용 - "https://example.com/webrtc-room.jpg" // thumbnailUrl + 123L // thumbnailAttachmentId ); // then @@ -442,7 +449,8 @@ void createRoom_WithWebRTC() { assertThat(createdRoom.isAllowCamera()).isTrue(); assertThat(createdRoom.isAllowAudio()).isTrue(); assertThat(createdRoom.isAllowScreenShare()).isTrue(); - assertThat(createdRoom.getThumbnailUrl()).isEqualTo("https://example.com/webrtc-room.jpg"); + assertThat(createdRoom.getThumbnailUrl()).isEqualTo("https://s3.amazonaws.com/bucket/thumbnail.jpg"); + verify(roomThumbnailService, times(1)).createThumbnailMapping(any(), eq(123L)); } @Test @@ -462,7 +470,7 @@ void createRoom_WithoutWebRTC() { 50, // WebRTC 없으면 더 많은 인원 가능 1L, false, // WebRTC 미사용 - null // thumbnailUrl 없음 + null // thumbnailAttachmentId 없음 ); // then @@ -474,5 +482,59 @@ void createRoom_WithoutWebRTC() { assertThat(createdRoom.getThumbnailUrl()).isEqualTo("/images/default-room-thumbnail.png"); // 원본 값(DB 저장값)은 null이어야 함 assertThat(createdRoom.getRawThumbnailUrl()).isNull(); + verify(roomThumbnailService, never()).createThumbnailMapping(any(), any()); + } + + @Test + @DisplayName("방 생성 - 썸네일 포함") + void createRoom_WithThumbnail() { + // given + given(userRepository.findById(1L)).willReturn(Optional.of(testUser)); + given(roomRepository.save(any(Room.class))).willAnswer(invocation -> invocation.getArgument(0)); + given(roomMemberRepository.save(any(RoomMember.class))).willReturn(testMember); + given(roomThumbnailService.createThumbnailMapping(any(), eq(456L))) + .willReturn("https://s3.amazonaws.com/bucket/custom-thumbnail.jpg"); + + // when + Room createdRoom = roomService.createRoom( + "커스텀 썸네일 방", + "예쁜 썸네일", + false, + null, + 10, + 1L, + true, + 456L // thumbnailAttachmentId + ); + + // then + assertThat(createdRoom).isNotNull(); + assertThat(createdRoom.getThumbnailUrl()).isEqualTo("https://s3.amazonaws.com/bucket/custom-thumbnail.jpg"); + verify(roomThumbnailService, times(1)).createThumbnailMapping(any(), eq(456L)); + } + + @Test + @DisplayName("방 설정 변경 - 썸네일 변경") + void updateRoomSettings_WithThumbnailChange() { + // given + given(roomRepository.findById(1L)).willReturn(Optional.of(testRoom)); + given(roomParticipantService.getParticipantCount(1L)).willReturn(0L); + given(roomThumbnailService.updateThumbnailMapping(eq(1L), eq(789L))) + .willReturn("https://s3.amazonaws.com/bucket/new-thumbnail.jpg"); + + // when + roomService.updateRoomSettings( + 1L, + "변경된 제목", + "변경된 설명", + 15, + 789L, // 새 thumbnailAttachmentId + 1L + ); + + // then + assertThat(testRoom.getTitle()).isEqualTo("변경된 제목"); + assertThat(testRoom.getThumbnailUrl()).isEqualTo("https://s3.amazonaws.com/bucket/new-thumbnail.jpg"); + verify(roomThumbnailService, times(1)).updateThumbnailMapping(eq(1L), eq(789L)); } }