Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.cotato.itda.domain.chat.controller.dto;

import com.cotato.itda.domain.chat.enums.AttachmentType;

import jakarta.validation.constraints.NotNull;

public record ChatAttachmentRequest (
@NotNull AttachmentType attachmentType,
@NotNull String objectKey,
String mimeType,
Long sizeBytes,
Integer durationMs
){
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,26 @@
@RequiredArgsConstructor
public enum ChatErrorCode implements ErrorCode {

INVALID_MESSAGE_CONTENT(
HttpStatus.BAD_REQUEST,
"메시지 내용이 유효하지 않습니다.",
"CHAT_ERROR_400_INVALID_MESSAGE_CONTENT"
),
INVALID_ATTACHMENT_FOR_TEXT(
HttpStatus.BAD_REQUEST,
"텍스트 메시지에는 첨부파일이 포함될 수 없습니다.",
"CHAT_ERROR_400_INVALID_ATTACHMENT_FOR_TEXT"
),
INVALID_ATTACHMENT_REQUIRED(
HttpStatus.BAD_REQUEST,
"첨부파일 메시지에는 첨부파일이 반드시 포함되어야 합니다.",
"CHAT_ERROR_400_INVALID_ATTACHMENT_REQUIRED"
),
INVALID_ATTACHMENT_OBJECT_KEY(
HttpStatus.BAD_REQUEST,
"첨부파일의 objectKey가 유효하지 않습니다.",
"CHAT_ERROR_400_INVALID_ATTACHMENT_OBJECT_KEY"
),
// 현재 상태가 ACTIVE가 아닙니다
CHAT_ROOM_MEMBER_STATUS_INVALID(
HttpStatus.BAD_REQUEST,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,5 @@

import com.cotato.itda.domain.chat.entity.ChatMessageAttachment;

public interface ChatMessasgeAttachmentRepository extends JpaRepository<ChatMessageAttachment,Long> {
public interface ChatMessageAttachmentRepository extends JpaRepository<ChatMessageAttachment,Long> {
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,19 @@
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import com.cotato.itda.domain.chat.controller.dto.ChatAttachmentRequest;
import com.cotato.itda.domain.chat.controller.dto.ChatMessageItemDto;
import com.cotato.itda.domain.chat.controller.dto.ChatMessageSliceResponse;
import com.cotato.itda.domain.chat.entity.ChatMessage;
import com.cotato.itda.domain.chat.entity.ChatMessageAttachment;
import com.cotato.itda.domain.chat.entity.ChatRoom;
import com.cotato.itda.domain.chat.entity.ChatRoomMember;
import com.cotato.itda.domain.chat.enums.AttachmentStatus;
import com.cotato.itda.domain.chat.enums.LastMessageType;
import com.cotato.itda.domain.chat.enums.MemberRoomStatus;
import com.cotato.itda.domain.chat.enums.MessageType;
import com.cotato.itda.domain.chat.exception.code.ChatErrorCode;
import com.cotato.itda.domain.chat.repository.ChatMessageAttachmentRepository;
import com.cotato.itda.domain.chat.repository.ChatRoomMemberRepository;
import com.cotato.itda.domain.chat.repository.ChatRoomQueryRepository;
import com.cotato.itda.domain.chat.repository.ChatRoomRepository;
Expand Down Expand Up @@ -44,7 +48,7 @@ public class ChatMessageService {
private final ChatRoomMemberRepository chatRoomMemberRepository;
private final ChatMessageRepository chatMessageRepository;
private final ChatRoomQueryRepository chatRoomQueryRepository;

private final ChatMessageAttachmentRepository chatMessageAttachmentRepository;
private final MemberRepository memberRepository;

private final SimpMessagingTemplate messagingTemplate;
Expand All @@ -68,6 +72,7 @@ public void sendMessage(Long senderMemberId, ChatSendMessageRequest req) {
throw new BusinessException(ChatErrorCode.INVALID_REQUEST_BOTH_ROOMID_OPPONENTID_NULL);
}

validateSendRequest(req);
// ===== [1] 발신자 조회 =====
log.info("[발신자 조회] senderMemberId={} 조회 시작", senderMemberId);
Member sender = memberRepository.findById(senderMemberId)
Expand Down Expand Up @@ -172,6 +177,23 @@ public void sendMessage(Long senderMemberId, ChatSendMessageRequest req) {
roomId, message.getId(), message.getMessageSeq(), senderMemberId, req.messageType()
);

ChatMessageAttachment savedAttachment = null;
if (req.messageType() == MessageType.ATTACHMENT) {

ChatAttachmentRequest a = req.attachment();

ChatMessageAttachment attachment = ChatMessageAttachment.builder()
.message(message)
.attachmentType(a.attachmentType())
.objectKey(a.objectKey())
.mimeType(a.mimeType())
.sizeBytes(a.sizeBytes())
.durationMs(a.durationMs())
.status(AttachmentStatus.READY) // 초기 상태는 READY
.build();

savedAttachment = chatMessageAttachmentRepository.save(attachment);
}
// ===== [5] 내 메시지 자동 읽음 처리 =====
myMembership.markRead(nextSeq, message.getId());
log.info("[읽음 처리] senderMemberId={} 가 보낸 메시지 자동 읽음 처리. roomId={}, readSeq={}, readMessageId={}",
Expand Down Expand Up @@ -210,6 +232,19 @@ public void sendMessage(Long senderMemberId, ChatSendMessageRequest req) {
log.info("[상대 조회] roomId={}, senderMemberId={}, opponentId={}",
roomId, senderMemberId, opponentId
);
ChatMessageItemDto.AttachmentMeta meta = null;

if (savedAttachment != null) {
meta = ChatMessageItemDto.AttachmentMeta.builder()
.attachmentType(savedAttachment.getAttachmentType())
.objectKey(savedAttachment.getObjectKey())
.mimeType(savedAttachment.getMimeType())
.sizeBytes(savedAttachment.getSizeBytes())
.status(savedAttachment.getStatus())
.durationMs(savedAttachment.getDurationMs())
.build();
}


// ===== [8] 방 토픽 발행 =====
ChatRoomMessageDto roomMessageDto = ChatRoomMessageDto.builder()
Expand All @@ -220,7 +255,7 @@ public void sendMessage(Long senderMemberId, ChatSendMessageRequest req) {
.messageType(req.messageType())
.content(req.content())
.createdAt(now)
.attachment(null)
.attachment(meta)
.build();

String roomTopicDest = "/topic/chat/rooms/" + roomId;
Expand Down Expand Up @@ -379,4 +414,30 @@ public ChatMessageSliceResponse getRoomMessages(Long memberId, Long roomId, Inte
.nextCursorSeq(nextCursor)
.build();
}

private void validateSendRequest(ChatSendMessageRequest req) {
if (req.messageType() == MessageType.TEXT) {
if (req.content() == null || req.content().isBlank()) {
throw new BusinessException(ChatErrorCode.INVALID_MESSAGE_CONTENT);
}
if (req.attachment() != null) {
throw new BusinessException(ChatErrorCode.INVALID_ATTACHMENT_FOR_TEXT);
}
return;
}

// ATTACHMENT
if (req.attachment() == null) {
throw new BusinessException(ChatErrorCode.INVALID_ATTACHMENT_REQUIRED);
}
if (req.attachment().objectKey() == null || req.attachment().objectKey().isBlank()) {
throw new BusinessException(ChatErrorCode.INVALID_ATTACHMENT_OBJECT_KEY);
}

// 보안/검증: objectKey 접두어 제한 추천
// 예: attachments/ 로 시작하는 것만 허용
if (!req.attachment().objectKey().startsWith("attachments/")) {
throw new BusinessException(ChatErrorCode.INVALID_ATTACHMENT_OBJECT_KEY);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import java.time.LocalDateTime;

import com.cotato.itda.domain.chat.controller.dto.ChatMessageItemDto;
import com.cotato.itda.domain.chat.enums.AttachmentStatus;
import com.cotato.itda.domain.chat.enums.AttachmentType;
import com.cotato.itda.domain.chat.enums.MessageType;
Expand All @@ -20,7 +21,7 @@ public record ChatRoomMessageDto(
MessageType messageType,
String content,
LocalDateTime createdAt,
Attachment attachment
ChatMessageItemDto.AttachmentMeta attachment
) {
@Builder
public record Attachment(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,19 +1,27 @@
package com.cotato.itda.domain.websocket.dto;

import com.cotato.itda.domain.chat.controller.dto.ChatAttachmentRequest;
import com.cotato.itda.domain.chat.enums.MessageType;

import jakarta.validation.constraints.NotNull;
import lombok.ToString;

/**
* 임시 화면(roomId 없음)에서도 보내야 하므로 roomId는 nullable
* - clientMessageId: 클라이언트에서 생성한 메시지 ID (중복 방지 및 추적용)
* TEXT
* - content 필수
* - attachment null
* ATTACHMENT (S3 업로드 완료 후)
* - content null
* - attachment 필수
* - attachment.objectKey 필수
*/
public record ChatSendMessageRequest(
@NotNull String clientMessageId,
Long roomId,
Long opponentMemberId, // roomId가 없을 때 필수
@NotNull MessageType messageType,
String content
String content,
ChatAttachmentRequest attachment
){
}