-
Notifications
You must be signed in to change notification settings - Fork 0
[FEAT] 채팅 관련 REST API 구현 #61
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 28 commits
de77a9d
d804a1c
47d8b25
17c8dc3
b0d2590
aab874c
765edab
fa9c15f
bbbbbff
4b1a6dc
3276efc
8199551
9c9ca48
3cf1826
4f989f4
b1077d6
b83585e
b8d0338
79081a1
0a92563
3c8ae4a
965f82b
2abc494
c155765
f580a07
687c97b
c4690d1
9c2ed7b
4e7cf65
539bd6e
6ce9df5
c3b74c9
48f106b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,4 +1,71 @@ | ||
| package konkuk.chacall.domain.chat.application; | ||
|
|
||
| import konkuk.chacall.domain.chat.application.message.ChatMessageService; | ||
| import konkuk.chacall.domain.chat.application.room.ChatRoomService; | ||
| import konkuk.chacall.domain.chat.presentation.dto.request.GetChatRoomRequest; | ||
| import konkuk.chacall.domain.chat.presentation.dto.request.SendChatMessageRequest; | ||
| import konkuk.chacall.domain.chat.presentation.dto.response.ChatMessageResponse; | ||
| import konkuk.chacall.domain.chat.presentation.dto.response.ChatRoomMetaDataResponse; | ||
| import konkuk.chacall.domain.chat.presentation.dto.response.ChatRoomIdResponse; | ||
| import konkuk.chacall.domain.chat.presentation.dto.response.ChatRoomResponse; | ||
| import konkuk.chacall.domain.member.application.validator.MemberValidator; | ||
| import konkuk.chacall.domain.user.domain.model.User; | ||
| import konkuk.chacall.global.common.dto.CursorPagingRequest; | ||
| import konkuk.chacall.global.common.dto.CursorPagingResponse; | ||
| import konkuk.chacall.global.common.dto.SortType; | ||
| import lombok.RequiredArgsConstructor; | ||
| import org.springframework.stereotype.Service; | ||
| import org.springframework.transaction.annotation.Transactional; | ||
|
|
||
| import java.util.List; | ||
|
|
||
| @Service | ||
| @RequiredArgsConstructor | ||
| @Transactional(readOnly = true) | ||
| public class ChatService { | ||
|
|
||
| private final ChatRoomService chatRoomService; | ||
| private final ChatMessageService chatMessageService; | ||
|
|
||
| private final MemberValidator memberValidator; | ||
|
|
||
| @Transactional | ||
| public ChatRoomIdResponse createChatRoom(Long memberId, Long foodTruckId) { | ||
| User member = memberValidator.validateAndGetMember(memberId); | ||
|
|
||
| return chatRoomService.createChatRoom(member, foodTruckId); | ||
| } | ||
|
|
||
| public ChatRoomMetaDataResponse getChatRoomMetaData(Long memberId, Long roomId, boolean isOwner) { | ||
| User user = memberValidator.validateAndGetMember(memberId); | ||
|
|
||
| return chatRoomService.getChatRoomMetaData(user, roomId, isOwner); | ||
| } | ||
|
|
||
| @Transactional | ||
| public ChatMessageResponse sendMessage(Long roomId, Long userId, SendChatMessageRequest request) { | ||
| User senderUser = memberValidator.validateAndGetMember(userId); | ||
|
|
||
| return chatMessageService.sendMessage(roomId, senderUser, request); | ||
| } | ||
|
|
||
| public List<ChatMessageResponse> getChatMessages(Long memberId, Long roomId, int page, int size) { | ||
| User user = memberValidator.validateAndGetMember(memberId); | ||
|
|
||
| return chatMessageService.getChatMessages(roomId, user, page, size); | ||
| } | ||
|
|
||
| public CursorPagingResponse<ChatRoomResponse> getChatRooms(Long memberId, GetChatRoomRequest request) { | ||
| User member = memberValidator.validateAndGetMember(memberId); | ||
|
|
||
| CursorPagingRequest cursorPagingRequest = request.pagingOrDefault(SortType.NEWEST); | ||
| return chatRoomService.getChatRooms(member, request.isOwner(), cursorPagingRequest.cursor(), cursorPagingRequest.size()); | ||
| } | ||
|
|
||
| @Transactional | ||
| public void markMessagesAsRead(Long userId, Long roomId) { | ||
| User user = memberValidator.validateAndGetMember(userId); | ||
|
|
||
| chatMessageService.markMessagesAsRead(user, roomId); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,70 @@ | ||
| package konkuk.chacall.domain.chat.application.message; | ||
|
|
||
| import konkuk.chacall.domain.chat.domain.ChatMessage; | ||
| import konkuk.chacall.domain.chat.domain.ChatRoomMetaData; | ||
| import konkuk.chacall.domain.chat.domain.repository.ChatMessageRepository; | ||
| import konkuk.chacall.domain.chat.domain.repository.ChatRoomMetaDataRepository; | ||
| import konkuk.chacall.domain.chat.presentation.dto.request.SendChatMessageRequest; | ||
| import konkuk.chacall.domain.chat.presentation.dto.response.ChatMessageResponse; | ||
| import konkuk.chacall.domain.user.domain.model.User; | ||
| import konkuk.chacall.global.common.exception.EntityNotFoundException; | ||
| import lombok.RequiredArgsConstructor; | ||
| import org.springframework.data.domain.PageRequest; | ||
| import org.springframework.data.domain.Sort; | ||
| import org.springframework.stereotype.Service; | ||
|
|
||
| import java.util.List; | ||
|
|
||
| import static konkuk.chacall.global.common.exception.code.ErrorCode.*; | ||
|
|
||
| @Service | ||
| @RequiredArgsConstructor | ||
| public class ChatMessageService { | ||
|
|
||
| private final ChatMessageRepository chatMessageRepository; | ||
| private final ChatRoomMetaDataRepository chatRoomMetaDataRepository; | ||
|
|
||
| public ChatMessageResponse sendMessage(Long roomId, User senderUser, SendChatMessageRequest request) { | ||
|
|
||
| ChatRoomMetaData chatRoomMetaData = chatRoomMetaDataRepository.findByRoomId(roomId) | ||
| .orElseThrow(() -> new EntityNotFoundException(CHAT_ROOM_NOT_FOUND)); | ||
|
|
||
| chatRoomMetaData.validateParticipant(senderUser); | ||
|
|
||
| ChatMessage chatMessage = ChatMessage.createChatMessage( | ||
| chatRoomMetaData.getRoomId(), | ||
| senderUser, | ||
| request.content(), | ||
| request.contentType() | ||
| ); | ||
|
|
||
| ChatMessage savedMessage = chatMessageRepository.save(chatMessage); | ||
| chatRoomMetaData.updateLastMessage(savedMessage.getContent(), savedMessage.getSendTime()); | ||
| chatRoomMetaDataRepository.save(chatRoomMetaData); | ||
|
|
||
| return ChatMessageResponse.from(savedMessage); | ||
| } | ||
|
|
||
| public List<ChatMessageResponse> getChatMessages(Long roomId, User user, int page, int size) { | ||
| ChatRoomMetaData chatRoomMetaData = chatRoomMetaDataRepository.findByRoomId(roomId) | ||
| .orElseThrow(() -> new EntityNotFoundException(CHAT_ROOM_NOT_FOUND)); | ||
|
|
||
| chatRoomMetaData.validateParticipant(user); | ||
|
|
||
| var pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "sendTime")); | ||
|
|
||
| return chatMessageRepository.findByRoomId(chatRoomMetaData.getRoomId(), pageable) | ||
| .stream() | ||
| .map(ChatMessageResponse::from) | ||
| .toList(); | ||
| } | ||
|
|
||
| public void markMessagesAsRead(User user, Long roomId) { | ||
| ChatRoomMetaData chatRoomMetaData = chatRoomMetaDataRepository.findByRoomId(roomId) | ||
| .orElseThrow(() -> new EntityNotFoundException(CHAT_ROOM_NOT_FOUND)); | ||
|
|
||
| chatRoomMetaData.validateParticipant(user); | ||
|
|
||
| chatMessageRepository.markMessagesAsReadByUserInRoom(user.getUserId(), roomId); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,116 @@ | ||
| package konkuk.chacall.domain.chat.application.room; | ||
|
|
||
| import konkuk.chacall.domain.chat.domain.ChatRoom; | ||
| import konkuk.chacall.domain.chat.domain.ChatRoomMetaData; | ||
| import konkuk.chacall.domain.chat.domain.repository.ChatRoomMetaDataRepository; | ||
| import konkuk.chacall.domain.chat.domain.repository.ChatRoomRepository; | ||
| import konkuk.chacall.domain.chat.domain.repository.dto.ChatRoomMetaDataProjection; | ||
| import konkuk.chacall.domain.chat.presentation.dto.response.ChatRoomMetaDataResponse; | ||
| import konkuk.chacall.domain.chat.presentation.dto.response.ChatRoomIdResponse; | ||
| import konkuk.chacall.domain.chat.presentation.dto.response.ChatRoomResponse; | ||
| import konkuk.chacall.domain.foodtruck.domain.model.FoodTruck; | ||
| import konkuk.chacall.domain.foodtruck.domain.repository.FoodTruckRepository; | ||
| import konkuk.chacall.domain.reservation.domain.model.Reservation; | ||
| import konkuk.chacall.domain.reservation.domain.repository.ReservationRepository; | ||
| import konkuk.chacall.domain.user.domain.model.Role; | ||
| import konkuk.chacall.domain.user.domain.model.User; | ||
| import konkuk.chacall.global.common.dto.CursorPagingResponse; | ||
| import konkuk.chacall.global.common.exception.BusinessException; | ||
| import konkuk.chacall.global.common.exception.EntityNotFoundException; | ||
| import lombok.RequiredArgsConstructor; | ||
| import org.springframework.stereotype.Service; | ||
|
|
||
| import java.util.List; | ||
| import java.util.Map; | ||
| import java.util.function.Function; | ||
| import java.util.stream.Collectors; | ||
|
|
||
| import static konkuk.chacall.global.common.exception.code.ErrorCode.*; | ||
|
|
||
| @Service | ||
| @RequiredArgsConstructor | ||
| public class ChatRoomService { | ||
|
|
||
| private final ChatRoomRepository chatRoomRepository; | ||
| private final FoodTruckRepository foodTruckRepository; | ||
| private final ChatRoomMetaDataRepository chatRoomMetaDataRepository; | ||
| private final ReservationRepository reservationRepository; | ||
|
|
||
| public ChatRoomIdResponse createChatRoom(User member, Long foodTruckId) { | ||
| FoodTruck foodTruck = foodTruckRepository.findById(foodTruckId) | ||
| .orElseThrow(() -> new EntityNotFoundException(FOOD_TRUCK_NOT_FOUND)); | ||
|
|
||
| // 채팅방이 이미 존재하는지 확인 | ||
| ChatRoom chatRoom = chatRoomRepository.findByMemberAndFoodTruck(member, foodTruck) | ||
| .orElseGet(() -> chatRoomRepository.save(ChatRoom.createChatRoom(member, foodTruck))); | ||
|
|
||
| // MongoDB 메타데이터 존재 여부 확인 | ||
| chatRoomMetaDataRepository.findByRoomId(chatRoom.getChatRoomId()) | ||
| .orElseGet(() -> { | ||
| // 없을 경우 새로 생성 | ||
| ChatRoomMetaData metaData = ChatRoomMetaData.from(chatRoom); | ||
| return chatRoomMetaDataRepository.save(metaData); | ||
| }); | ||
|
|
||
| return ChatRoomIdResponse.of(chatRoom); | ||
| } | ||
|
|
||
| public ChatRoomMetaDataResponse getChatRoomMetaData(User user, Long roomId, boolean isOwner) { | ||
| ChatRoom chatRoom = chatRoomRepository.findById(roomId) | ||
| .orElseThrow(() -> new EntityNotFoundException(CHAT_ROOM_NOT_FOUND)); | ||
|
|
||
| Long reservationId = reservationRepository.findByChatRoom(chatRoom) | ||
| .map(Reservation::getReservationId) | ||
| .orElse(null); // 없으면 null | ||
|
|
||
| if(isOwner && user.getRole() != Role.OWNER) { | ||
| throw new BusinessException(USER_FORBIDDEN); | ||
| } | ||
|
|
||
| // 푸드트럭 사장일 경우 예약자 이름 반환 | ||
| if(isOwner) return ChatRoomMetaDataResponse.of(chatRoom.getMember().getName(), null, reservationId); | ||
|
|
||
| // 예약자일 경우 푸드트럭 사장 이름 및 푸드트럭 이름 반환 | ||
| FoodTruck foodTruck = chatRoom.getFoodTruck(); | ||
| return ChatRoomMetaDataResponse.of(foodTruck.getOwner().getName(), foodTruck.getFoodTruckInfo().getName(), reservationId); | ||
| } | ||
|
|
||
| public CursorPagingResponse<ChatRoomResponse> getChatRooms(User member, Boolean isOwner, Long cursor, Integer size) { | ||
| int pageSize = (size == null || size < 1) ? 20 : size; | ||
|
|
||
| Long cursorSortKey = (cursor != null) ? cursor : Long.MAX_VALUE; | ||
| int limit = pageSize + 1; | ||
|
|
||
| // 1. MongoDB에서 메타데이터 + unreadCount 조회 | ||
| List<ChatRoomMetaDataProjection> metaList = | ||
| chatRoomMetaDataRepository.findChatRoomsForUser(member.getUserId(), isOwner, cursorSortKey, limit); | ||
|
|
||
| boolean hasNext = metaList.size() > pageSize; | ||
| if (hasNext) { | ||
| metaList = metaList.subList(0, pageSize); | ||
| } | ||
|
|
||
| // 2. roomId 리스트로 RDB에서 ChatRoom 배치 조회 | ||
| List<Long> roomIds = metaList.stream() | ||
| .map(ChatRoomMetaDataProjection::getRoomId) | ||
| .toList(); | ||
|
|
||
| List<ChatRoom> chatRooms = chatRoomRepository.findByChatRoomIdIn(roomIds); | ||
| Map<Long, ChatRoom> chatRoomMap = chatRooms.stream() | ||
| .collect(Collectors.toMap(ChatRoom::getChatRoomId, Function.identity())); | ||
|
|
||
| // 3. 메타데이터 + RDB 정보 조합해서 ChatRoomResponse 생성 | ||
| List<ChatRoomResponse> responses = metaList.stream() | ||
| .map(meta -> ChatRoomResponse.from(chatRoomMap.get(meta.getRoomId()), meta, isOwner)) | ||
| .toList(); | ||
|
Comment on lines
100
to
122
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. RDB 조회 결과 누락 시 NPE 가능성
List<ChatRoomResponse> responses = metaList.stream()
+ .filter(meta -> chatRoomMap.containsKey(meta.getRoomId()))
.map(meta -> ChatRoomResponse.from(chatRoomMap.get(meta.getRoomId()), meta, isOwner))
.toList();🤖 Prompt for AI Agents |
||
|
|
||
| // 4. CursorPagingResponse 생성 | ||
| Long lastCursor = responses.isEmpty() ? null : metaList.get(metaList.size() - 1).getSortKey(); | ||
| return new CursorPagingResponse<>( | ||
| responses, | ||
| lastCursor, | ||
| hasNext, | ||
| null | ||
| ); | ||
| } | ||
|
Comment on lines
80
to
132
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. isOwner null 처리 필요
- List<ChatRoomMetaDataProjection> metaList =
- chatRoomMetaDataRepository.findChatRoomsForUser(member.getUserId(), isOwner, cursorSortKey, limit);
+ boolean ownerView = Boolean.TRUE.equals(isOwner);
+
+ List<ChatRoomMetaDataProjection> metaList =
+ chatRoomMetaDataRepository.findChatRoomsForUser(member.getUserId(), ownerView, cursorSortKey, limit);
...
- .map(meta -> ChatRoomResponse.from(chatRoomMap.get(meta.getRoomId()), meta, isOwner))
+ .map(meta -> ChatRoomResponse.from(chatRoomMap.get(meta.getRoomId()), meta, ownerView)) |
||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,43 +1,42 @@ | ||
| package konkuk.chacall.domain.chat.domain; | ||
|
|
||
| import jakarta.persistence.*; | ||
| import konkuk.chacall.domain.chat.domain.value.MessageContentType; | ||
| import konkuk.chacall.domain.user.domain.model.User; | ||
| import konkuk.chacall.global.common.domain.BaseEntity; | ||
| import lombok.AccessLevel; | ||
| import lombok.NoArgsConstructor; | ||
| import lombok.*; | ||
| import org.springframework.data.annotation.Id; | ||
| import org.springframework.data.mongodb.core.index.CompoundIndex; | ||
| import org.springframework.data.mongodb.core.mapping.Document; | ||
|
|
||
| import java.time.LocalDateTime; | ||
|
|
||
| @Entity | ||
| @Table(name = "chat_messages") | ||
| @Getter | ||
| @Document(collection = "chat_messages") | ||
| @CompoundIndex(name = "room_time_idx", def = "{'roomId': 1, 'sendTime': 1}") | ||
|
||
| @Builder | ||
| @AllArgsConstructor | ||
| @NoArgsConstructor(access = AccessLevel.PROTECTED) | ||
| public class ChatMessage extends BaseEntity { | ||
| public class ChatMessage { | ||
|
|
||
| @Id | ||
| @GeneratedValue(strategy = GenerationType.IDENTITY) | ||
| @Column(nullable = false) | ||
| private Long chatMessageId; | ||
| private String id; | ||
|
|
||
| @Column(name = "content", nullable = false, length = 1000) | ||
| private Long roomId; | ||
| private Long senderId; | ||
| private String content; | ||
|
|
||
| @Column(nullable = false) | ||
| private LocalDateTime sendTime; | ||
|
|
||
| @Column(name = "is_read", nullable = false) | ||
| private boolean isRead; | ||
|
|
||
| @ManyToOne(fetch = FetchType.LAZY, optional = false) | ||
| @JoinColumn(name = "chat_room_id", nullable = false) | ||
| private ChatRoom chatRoom; | ||
|
|
||
| @ManyToOne(fetch = FetchType.LAZY, optional = false) | ||
| @JoinColumn(name = "user_id", nullable = false) | ||
| private User senderUser; | ||
|
|
||
| @Enumerated(EnumType.STRING) | ||
| @Column(nullable = false, length = 20) | ||
| private MessageContentType contentType; | ||
|
|
||
| private String contentType; | ||
|
|
||
| @Builder.Default | ||
| private LocalDateTime sendTime = LocalDateTime.now(); | ||
|
|
||
| @Builder.Default | ||
| private boolean read = false; | ||
|
|
||
| public static ChatMessage createChatMessage(Long roomId, User sender, String content, MessageContentType contentType) { | ||
| return ChatMessage.builder() | ||
| .roomId(roomId) | ||
| .senderId(sender.getUserId()) | ||
| .content(content) | ||
| .contentType(contentType.name()) | ||
| .build(); | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
여기서 isOwner 는 '차콜 서비스 자체에서 사장님으로 등록되어있는지' 를 의미하는 게 아니라, 채팅방 내의 예약자 <-> 사장님 관계에서 예약중인 푸드트럭의 사장님인지 여부를 의미하는 거라고 생각하면 되죠?
다른 푸드트럭의 사장님도 예약자 신분이 될 수 있다보니까 이런 식으로 구분해야 뷰에 차이를 둘 수 있긴 하겠네요 좋슴다
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
네넵 초기에 isOwner라는 플래그를 프론트로부터 받지 않고 구현을 하니 사장님과 사장님간의 채팅시에 논리적인 모순이 생기더라구요. 따라서, isOwner는 말씀하신대로 현재 예약자와 사장님의 관계를 구별해주기 위한 용도라고 생각하시면 될 것 같습니다. 프론트 쪽은 사장님과 예약자 뷰가 명확하게 나뉘어져 있으니 이를 보내주는 것이 쉬울거라고 판단했습니다!