diff --git a/src/main/java/com/example/solidconnection/chat/controller/ChatMessageController.java b/src/main/java/com/example/solidconnection/chat/controller/ChatMessageController.java index a7e158224..47bf9939a 100644 --- a/src/main/java/com/example/solidconnection/chat/controller/ChatMessageController.java +++ b/src/main/java/com/example/solidconnection/chat/controller/ChatMessageController.java @@ -1,5 +1,6 @@ package com.example.solidconnection.chat.controller; +import com.example.solidconnection.chat.dto.ChatImageSendRequest; import com.example.solidconnection.chat.dto.ChatMessageSendRequest; import com.example.solidconnection.chat.service.ChatService; import com.example.solidconnection.security.authentication.TokenAuthentication; @@ -29,4 +30,16 @@ public void sendChatMessage( chatService.sendChatMessage(chatMessageSendRequest, siteUserDetails.getSiteUser().getId(), roomId); } + + @MessageMapping("/chat/{roomId}/image") + public void sendChatImage( + @DestinationVariable Long roomId, + @Valid @Payload ChatImageSendRequest chatImageSendRequest, + Principal principal + ) { + TokenAuthentication tokenAuthentication = (TokenAuthentication) principal; + SiteUserDetails siteUserDetails = (SiteUserDetails) tokenAuthentication.getPrincipal(); + + chatService.sendChatImage(chatImageSendRequest, siteUserDetails.getSiteUser().getId(), roomId); + } } diff --git a/src/main/java/com/example/solidconnection/chat/domain/ChatAttachment.java b/src/main/java/com/example/solidconnection/chat/domain/ChatAttachment.java index def9263c8..da9c917ba 100644 --- a/src/main/java/com/example/solidconnection/chat/domain/ChatAttachment.java +++ b/src/main/java/com/example/solidconnection/chat/domain/ChatAttachment.java @@ -7,6 +7,7 @@ import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; import lombok.AccessLevel; import lombok.Getter; @@ -30,7 +31,7 @@ public class ChatAttachment extends BaseEntity { @Column(length = 500) private String thumbnailUrl; - @ManyToOne(fetch = FetchType.LAZY) + @ManyToOne(fetch = FetchType.LAZY, optional = false) private ChatMessage chatMessage; public ChatAttachment(boolean isImage, String url, String thumbnailUrl, ChatMessage chatMessage) { @@ -42,4 +43,17 @@ public ChatAttachment(boolean isImage, String url, String thumbnailUrl, ChatMess chatMessage.getChatAttachments().add(this); } } + + protected void setChatMessage(ChatMessage chatMessage) { + if (this.chatMessage == chatMessage) return; + + if (this.chatMessage != null) { + this.chatMessage.getChatAttachments().remove(this); + } + + this.chatMessage = chatMessage; + if (chatMessage != null && !chatMessage.getChatAttachments().contains(this)) { + chatMessage.getChatAttachments().add(this); + } + } } diff --git a/src/main/java/com/example/solidconnection/chat/domain/ChatMessage.java b/src/main/java/com/example/solidconnection/chat/domain/ChatMessage.java index 07fc99131..170a93f05 100644 --- a/src/main/java/com/example/solidconnection/chat/domain/ChatMessage.java +++ b/src/main/java/com/example/solidconnection/chat/domain/ChatMessage.java @@ -33,7 +33,7 @@ public class ChatMessage extends BaseEntity { @ManyToOne(fetch = FetchType.LAZY) private ChatRoom chatRoom; - @OneToMany(mappedBy = "chatMessage", cascade = CascadeType.ALL) + @OneToMany(mappedBy = "chatMessage", cascade = CascadeType.ALL, orphanRemoval = true) private final List chatAttachments = new ArrayList<>(); public ChatMessage(String content, long senderId, ChatRoom chatRoom) { @@ -44,4 +44,9 @@ public ChatMessage(String content, long senderId, ChatRoom chatRoom) { chatRoom.getChatMessages().add(this); } } + + public void addAttachment(ChatAttachment attachment) { + this.chatAttachments.add(attachment); + attachment.setChatMessage(this); + } } diff --git a/src/main/java/com/example/solidconnection/chat/domain/MessageType.java b/src/main/java/com/example/solidconnection/chat/domain/MessageType.java new file mode 100644 index 000000000..6e2750f38 --- /dev/null +++ b/src/main/java/com/example/solidconnection/chat/domain/MessageType.java @@ -0,0 +1,6 @@ +package com.example.solidconnection.chat.domain; + +public enum MessageType { + TEXT, + IMAGE, +} diff --git a/src/main/java/com/example/solidconnection/chat/dto/ChatImageSendRequest.java b/src/main/java/com/example/solidconnection/chat/dto/ChatImageSendRequest.java new file mode 100644 index 000000000..e32be3633 --- /dev/null +++ b/src/main/java/com/example/solidconnection/chat/dto/ChatImageSendRequest.java @@ -0,0 +1,14 @@ +package com.example.solidconnection.chat.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import java.util.List; + +public record ChatImageSendRequest( + @NotNull(message = "이미지 URL 목록은 필수입니다") + @Size(min = 1, max = 10, message = "이미지는 1~10개까지 가능합니다") + List<@NotBlank(message = "이미지 URL은 필수입니다") String> imageUrls +) { + +} diff --git a/src/main/java/com/example/solidconnection/chat/dto/ChatMessageSendResponse.java b/src/main/java/com/example/solidconnection/chat/dto/ChatMessageSendResponse.java index 065c7ba1c..8e976148a 100644 --- a/src/main/java/com/example/solidconnection/chat/dto/ChatMessageSendResponse.java +++ b/src/main/java/com/example/solidconnection/chat/dto/ChatMessageSendResponse.java @@ -1,19 +1,38 @@ package com.example.solidconnection.chat.dto; import com.example.solidconnection.chat.domain.ChatMessage; +import com.example.solidconnection.chat.domain.MessageType; +import java.util.List; public record ChatMessageSendResponse( long messageId, String content, - long senderId + long senderId, + MessageType messageType, + List attachments ) { public static ChatMessageSendResponse from(ChatMessage chatMessage) { + MessageType messageType = chatMessage.getChatAttachments().isEmpty() + ? MessageType.TEXT + : MessageType.IMAGE; + + List attachments = chatMessage.getChatAttachments().stream() + .map(attachment -> ChatAttachmentResponse.of( + attachment.getId(), + attachment.getIsImage(), + attachment.getUrl(), + attachment.getThumbnailUrl(), + attachment.getCreatedAt() + )) + .toList(); + return new ChatMessageSendResponse( chatMessage.getId(), chatMessage.getContent(), - chatMessage.getSenderId() + chatMessage.getSenderId(), + messageType, + attachments ); } - } diff --git a/src/main/java/com/example/solidconnection/chat/service/ChatService.java b/src/main/java/com/example/solidconnection/chat/service/ChatService.java index 1bd372d81..78530d752 100644 --- a/src/main/java/com/example/solidconnection/chat/service/ChatService.java +++ b/src/main/java/com/example/solidconnection/chat/service/ChatService.java @@ -5,10 +5,12 @@ import static com.example.solidconnection.common.exception.ErrorCode.INVALID_CHAT_ROOM_STATE; import static com.example.solidconnection.common.exception.ErrorCode.USER_NOT_FOUND; +import com.example.solidconnection.chat.domain.ChatAttachment; import com.example.solidconnection.chat.domain.ChatMessage; import com.example.solidconnection.chat.domain.ChatParticipant; import com.example.solidconnection.chat.domain.ChatRoom; import com.example.solidconnection.chat.dto.ChatAttachmentResponse; +import com.example.solidconnection.chat.dto.ChatImageSendRequest; import com.example.solidconnection.chat.dto.ChatMessageResponse; import com.example.solidconnection.chat.dto.ChatMessageSendRequest; import com.example.solidconnection.chat.dto.ChatMessageSendResponse; @@ -195,6 +197,53 @@ public void sendChatMessage(ChatMessageSendRequest chatMessageSendRequest, long simpMessageSendingOperations.convertAndSend("/topic/chat/" + roomId, chatMessageResponse); } + @Transactional + public void sendChatImage(ChatImageSendRequest chatImageSendRequest, long siteUserId, long roomId) { + long senderId = chatParticipantRepository.findByChatRoomIdAndSiteUserId(roomId, siteUserId) + .orElseThrow(() -> new CustomException(CHAT_PARTICIPANT_NOT_FOUND)) + .getId(); + + ChatRoom chatRoom = chatRoomRepository.findById(roomId) + .orElseThrow(() -> new CustomException(INVALID_CHAT_ROOM_STATE)); + + ChatMessage chatMessage = new ChatMessage( + "", + senderId, + chatRoom + ); + + for (String imageUrl : chatImageSendRequest.imageUrls()) { + String thumbnailUrl = generateThumbnailUrl(imageUrl); + + ChatAttachment attachment = new ChatAttachment(true, imageUrl, thumbnailUrl, null); + chatMessage.addAttachment(attachment); + } + + chatMessageRepository.save(chatMessage); + + ChatMessageSendResponse chatMessageResponse = ChatMessageSendResponse.from(chatMessage); + simpMessageSendingOperations.convertAndSend("/topic/chat/" + roomId, chatMessageResponse); + } + + private String generateThumbnailUrl(String originalUrl) { + try { + String fileName = originalUrl.substring(originalUrl.lastIndexOf('/') + 1); + + String nameWithoutExt = fileName.substring(0, fileName.lastIndexOf('.')); + String extension = fileName.substring(fileName.lastIndexOf('.')); + + String thumbnailFileName = nameWithoutExt + "_thumb" + extension; + + String thumbnailUrl = originalUrl.replace("chat/images/", "chat/thumbnails/") + .replace(fileName, thumbnailFileName); + + return thumbnailUrl; + + } catch (Exception e) { + return originalUrl; + } + } + @Transactional public Long createMentoringChatRoom(Long mentoringId, Long mentorId, Long menteeId) { ChatRoom existingChatRoom = chatRoomRepository.findByMentoringId(mentoringId); diff --git a/src/main/java/com/example/solidconnection/s3/controller/S3Controller.java b/src/main/java/com/example/solidconnection/s3/controller/S3Controller.java index 1bd978627..98b0574f9 100644 --- a/src/main/java/com/example/solidconnection/s3/controller/S3Controller.java +++ b/src/main/java/com/example/solidconnection/s3/controller/S3Controller.java @@ -5,6 +5,7 @@ import com.example.solidconnection.s3.dto.UploadedFileUrlResponse; import com.example.solidconnection.s3.dto.urlPrefixResponse; import com.example.solidconnection.s3.service.S3Service; +import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.ResponseEntity; @@ -68,6 +69,14 @@ public ResponseEntity uploadLanguageImage( return ResponseEntity.ok(profileImageUrl); } + @PostMapping("/chat") + public ResponseEntity> uploadChatImage( + @RequestParam("files") List imageFiles + ) { + List chatImageUrls = s3Service.uploadFiles(imageFiles, ImgType.CHAT); + return ResponseEntity.ok(chatImageUrls); + } + @GetMapping("/s3-url-prefix") public ResponseEntity getS3UrlPrefix() { return ResponseEntity.ok(new urlPrefixResponse(s3Default, s3Uploaded, cloudFrontDefault, cloudFrontUploaded)); diff --git a/src/main/java/com/example/solidconnection/s3/domain/ImgType.java b/src/main/java/com/example/solidconnection/s3/domain/ImgType.java index 7efedb1a5..50ac78b30 100644 --- a/src/main/java/com/example/solidconnection/s3/domain/ImgType.java +++ b/src/main/java/com/example/solidconnection/s3/domain/ImgType.java @@ -4,7 +4,7 @@ @Getter public enum ImgType { - PROFILE("profile"), GPA("gpa"), LANGUAGE_TEST("language"), COMMUNITY("community"), NEWS("news"); + PROFILE("profile"), GPA("gpa"), LANGUAGE_TEST("language"), COMMUNITY("community"), NEWS("news"), CHAT("chat"); private final String type; diff --git a/src/main/java/com/example/solidconnection/s3/service/S3Service.java b/src/main/java/com/example/solidconnection/s3/service/S3Service.java index 11b66a499..4c4110693 100644 --- a/src/main/java/com/example/solidconnection/s3/service/S3Service.java +++ b/src/main/java/com/example/solidconnection/s3/service/S3Service.java @@ -35,7 +35,7 @@ public class S3Service { private static final Logger log = LoggerFactory.getLogger(S3Service.class); - private static final long MAX_FILE_SIZE_MB = 1024 * 1024 * 3; + private static final long MAX_FILE_SIZE_MB = 1024 * 1024 * 5; private final AmazonS3Client amazonS3; private final SiteUserRepository siteUserRepository; @@ -52,8 +52,8 @@ public class S3Service { * - 파일에 대한 메타 데이터를 생성한다. * - 임의의 랜덤한 문자열로 파일 이름을 생성한다. * - S3에 파일을 업로드한다. - * - 3mb 이상의 파일은 /origin/ 경로로 업로드하여 lambda 함수로 리사이징 진행한다. - * - 3mb 미만의 파일은 바로 업로드한다. + * - 5mb 이상의 파일은 /origin/ 경로로 업로드하여 lambda 함수로 리사이징 진행한다. + * - 5mb 미만의 파일은 바로 업로드한다. * */ public UploadedFileUrlResponse uploadFile(MultipartFile multipartFile, ImgType imageFile) { // 파일 검증 diff --git a/src/main/resources/secret b/src/main/resources/secret index fd0d80ad2..bb3bf0f41 160000 --- a/src/main/resources/secret +++ b/src/main/resources/secret @@ -1 +1 @@ -Subproject commit fd0d80ad28d28698e3e27160d9d27bf4e5462238 +Subproject commit bb3bf0f4122d10ddacab279a368cf9f06d6f6dbd diff --git a/src/test/java/com/example/solidconnection/chat/service/ChatServiceTest.java b/src/test/java/com/example/solidconnection/chat/service/ChatServiceTest.java index 9f3c1f017..f5ec202bb 100644 --- a/src/test/java/com/example/solidconnection/chat/service/ChatServiceTest.java +++ b/src/test/java/com/example/solidconnection/chat/service/ChatServiceTest.java @@ -11,6 +11,8 @@ import com.example.solidconnection.chat.domain.ChatParticipant; import com.example.solidconnection.chat.domain.ChatReadStatus; import com.example.solidconnection.chat.domain.ChatRoom; +import com.example.solidconnection.chat.domain.MessageType; +import com.example.solidconnection.chat.dto.ChatImageSendRequest; import com.example.solidconnection.chat.dto.ChatMessageResponse; import com.example.solidconnection.chat.dto.ChatMessageSendRequest; import com.example.solidconnection.chat.dto.ChatMessageSendResponse; @@ -28,6 +30,7 @@ import com.example.solidconnection.siteuser.fixture.SiteUserFixture; import com.example.solidconnection.support.TestContainerSpringBootTest; import java.time.ZonedDateTime; +import java.util.List; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; @@ -433,7 +436,7 @@ void setUp() { } @Test - void 채팅_참여자가_아니면_메시지를_전송할_수_없다() { + void 채팅_참여자가_아니면_예외가_발생한다() { // given SiteUser nonParticipant = siteUserFixture.사용자(333, "nonParticipant"); ChatMessageSendRequest request = new ChatMessageSendRequest("안녕하세요"); @@ -444,4 +447,115 @@ void setUp() { .hasMessage(CHAT_PARTICIPANT_NOT_FOUND.getMessage()); } } + + @Nested + class 채팅_이미지를_전송한다 { + + private SiteUser sender; + private ChatParticipant senderParticipant; + private ChatRoom chatRoom; + private static final String TEST_IMAGE_URL = "https://bucket.s3.ap-northeast-2.amazonaws.com/chat/images/example.jpg"; + private static final String TEST_IMAGE_URL2 = "https://bucket.s3.ap-northeast-2.amazonaws.com/chat/images/example2.jpg"; + private static final String EXPECTED_THUMBNAIL_URL = "https://bucket.s3.ap-northeast-2.amazonaws.com/chat/thumbnails/example_thumb.jpg"; + + @BeforeEach + void setUp() { + sender = siteUserFixture.사용자(111, "sender"); + chatRoom = chatRoomFixture.채팅방(false); + senderParticipant = chatParticipantFixture.참여자(sender.getId(), chatRoom); + } + + @Test + void 채팅방_참여자는_이미지_메시지를_전송할_수_있다() { + // given + final List imageUrls = List.of( + TEST_IMAGE_URL, + TEST_IMAGE_URL2 + ); + ChatImageSendRequest request = new ChatImageSendRequest(imageUrls); + + // when + chatService.sendChatImage(request, sender.getId(), chatRoom.getId()); + + // then + ArgumentCaptor destinationCaptor = ArgumentCaptor.forClass(String.class); + ArgumentCaptor payloadCaptor = ArgumentCaptor.forClass(ChatMessageSendResponse.class); + + BDDMockito.verify(simpMessagingTemplate).convertAndSend(destinationCaptor.capture(), payloadCaptor.capture()); + + ChatMessageSendResponse response = payloadCaptor.getValue(); + + assertAll( + () -> assertThat(destinationCaptor.getValue()).isEqualTo("/topic/chat/" + chatRoom.getId()), + () -> assertThat(response.attachments()).hasSize(imageUrls.size()), + () -> assertThat(response.attachments().get(0).url()).isEqualTo(imageUrls.get(0)), + () -> assertThat(response.attachments().get(1).url()).isEqualTo(imageUrls.get(1)), + () -> assertThat(response.messageType()).isEqualTo(MessageType.IMAGE), + () -> assertThat(response.senderId()).isEqualTo(senderParticipant.getId()), + () -> assertThat(response.content()).isEmpty() + ); + } + + @Test + void 단일_이미지_메시지가_정상_전송된다() { + // given + final List imageUrls = List.of( + TEST_IMAGE_URL + ); + ChatImageSendRequest request = new ChatImageSendRequest(imageUrls); + + // when + chatService.sendChatImage(request, sender.getId(), chatRoom.getId()); + + // then + ArgumentCaptor payloadCaptor = ArgumentCaptor.forClass(ChatMessageSendResponse.class); + BDDMockito.verify(simpMessagingTemplate).convertAndSend(BDDMockito.anyString(), payloadCaptor.capture()); + + ChatMessageSendResponse response = payloadCaptor.getValue(); + + assertAll( + () -> assertThat(response.attachments()).hasSize(1), + () -> assertThat(response.attachments().get(0).url()).isEqualTo(imageUrls.get(0)), + () -> assertThat(response.messageType()).isEqualTo(MessageType.IMAGE) + ); + } + + @Test + void 채팅_참여자가_아니면_예외가_발생한다() { + // given + SiteUser nonParticipant = siteUserFixture.사용자(333, "nonParticipant"); + List imageUrls = List.of(TEST_IMAGE_URL); + ChatImageSendRequest request = new ChatImageSendRequest(imageUrls); + + // when & then + assertThatCode(() -> chatService.sendChatImage(request, nonParticipant.getId(), chatRoom.getId())) + .isInstanceOf(CustomException.class) + .hasMessage(CHAT_PARTICIPANT_NOT_FOUND.getMessage()); + } + + @Test + void 썸네일_URL이_정상적으로_생성된다() { + // given + final List imageUrls = List.of( + TEST_IMAGE_URL + ); + ChatImageSendRequest request = new ChatImageSendRequest(imageUrls); + + // when + chatService.sendChatImage(request, sender.getId(), chatRoom.getId()); + + // then + ArgumentCaptor payloadCaptor = ArgumentCaptor.forClass(ChatMessageSendResponse.class); + BDDMockito.verify(simpMessagingTemplate).convertAndSend(BDDMockito.anyString(), payloadCaptor.capture()); + + ChatMessageSendResponse response = payloadCaptor.getValue(); + + assertAll( + () -> assertThat(response.attachments().get(0).url()).isEqualTo(imageUrls.get(0)), + () -> assertThat(response.attachments().get(0).thumbnailUrl()).isEqualTo( + EXPECTED_THUMBNAIL_URL + ) + ); + } + } }